import gradio as gr
import json
import os
import shutil
from datetime import datetime, timezone, timedelta
# ── Paths & config ────────────────────────────────────────────────────────────
APP_DIR = os.path.dirname(os.path.abspath(__file__))
DATA_FILE = os.path.join(APP_DIR, "data.json")
# Persistent-storage dir on HF (survives restarts if Persistent Storage enabled).
# Falls back to a HF Dataset repo if HF_TOKEN + DATASET_REPO are set as secrets.
PERSIST_DIR = os.environ.get("PERSIST_DIR", "/data" if os.path.isdir("/data") else APP_DIR)
LIVE_DATA = os.path.join(PERSIST_DIR, "data_live.json")
PHOTO_DIR = os.path.join(PERSIST_DIR, "photos")
os.makedirs(PHOTO_DIR, exist_ok=True)
HF_TOKEN = os.environ.get("HF_TOKEN")
DATASET_REPO = os.environ.get("DATASET_REPO") # e.g. "vikun1989/house-tracker-data"
_api = None
if HF_TOKEN and DATASET_REPO:
try:
from huggingface_hub import HfApi
_api = HfApi(token=HF_TOKEN)
except Exception as e:
print("HF Dataset persistence unavailable:", e)
IST = timezone(timedelta(hours=5, minutes=30))
def now_str():
return datetime.now(IST).strftime("%d-%b-%Y %H:%M")
# ── Credentials ───────────────────────────────────────────────────────────────
USERS = {
"vinay": "vkn@2026",
"engineer": "rcc@2026",
"contractor": "rcc@2026",
}
STATUS_OPTIONS = ["Pending", "In Progress", "Completed", "Issue / Query", "Not Applicable"]
STATUS_EMOJI = {"Pending": "⏳", "In Progress": "🔄", "Completed": "✅",
"Issue / Query": "⚠️", "Not Applicable": "➖"}
STATUS_COLOR = {"Pending": "#fff8e1", "In Progress": "#e3f2fd", "Completed": "#e8f5e9",
"Issue / Query": "#ffebee", "Not Applicable": "#f5f5f5"}
STAGE_STATUS = ["Pending", "In Progress", "Completed"]
# ── Persistence layer ─────────────────────────────────────────────────────────
def _push_dataset(local_path, path_in_repo):
if not _api:
return
try:
_api.upload_file(path_or_fileobj=local_path, path_in_repo=path_in_repo,
repo_id=DATASET_REPO, repo_type="dataset")
except Exception as e:
print("Dataset push failed:", e)
def _pull_dataset():
if not _api:
return False
try:
from huggingface_hub import hf_hub_download
p = hf_hub_download(repo_id=DATASET_REPO, filename="data_live.json",
repo_type="dataset", token=HF_TOKEN)
shutil.copy(p, LIVE_DATA)
return True
except Exception as e:
print("Dataset pull (first run is normal):", e)
return False
def load_data():
# priority: dataset -> live persistent file -> bundled default
if not os.path.exists(LIVE_DATA):
_pull_dataset()
src = LIVE_DATA if os.path.exists(LIVE_DATA) else DATA_FILE
with open(src, "r", encoding="utf-8") as f:
data = json.load(f)
for cat in data.get("categories", []):
for item in cat["items"]:
item.setdefault("photos", [])
item.setdefault("updated", "")
item.setdefault("updated_by", "")
data.setdefault("stages", [])
data.setdefault("activity", [])
return data
def save_data(data):
with open(LIVE_DATA, "w", encoding="utf-8") as f:
json.dump(data, f, indent=2, ensure_ascii=False)
_push_dataset(LIVE_DATA, "data_live.json")
def log_activity(data, user, action):
data.setdefault("activity", []).insert(0, {"time": now_str(), "user": user, "action": action})
data["activity"] = data["activity"][:200]
def persistence_banner():
if _api:
return '
💾 Persistent: updates & photos saved permanently to your HF Dataset.
'
if PERSIST_DIR.startswith("/data"):
return '💾 Persistent: updates saved to Space persistent storage.
'
return '⚠️ Temporary mode: edits reset when the Space restarts. Set up persistent storage (see Project Info tab) to keep changes.
'
# ── Helpers ───────────────────────────────────────────────────────────────────
def category_names(data):
return [f"{c['icon']} {c['name']}" for c in data["categories"]]
def find_category(data, display_name):
for cat in data["categories"]:
if f"{cat['icon']} {cat['name']}" == display_name:
return cat
return None
def item_choices(data, cat_display):
cat = find_category(data, cat_display)
if not cat:
return []
return [f"{it['id']} — {it['item']}" for it in cat["items"]]
def parse_item_id(choice):
return choice.split(" — ")[0].strip() if choice else ""
def find_item(data, item_id):
for cat in data["categories"]:
for it in cat["items"]:
if it["id"] == item_id:
return cat, it
return None, None
def progress_stats(data):
total = done = ip = issues = na = 0
for cat in data["categories"]:
for item in cat["items"]:
total += 1
s = item["status"]
if s == "Completed": done += 1
elif s == "In Progress": ip += 1
elif s == "Issue / Query": issues += 1
elif s == "Not Applicable": na += 1
pending = total - done - ip - issues - na
pct = round(done / total * 100, 1) if total else 0
return total, done, ip, pending, issues, na, pct
# ── HTML builders ─────────────────────────────────────────────────────────────
def dashboard_html(data):
p = data["project"]
total, done, ip, pending, issues, na, pct = progress_stats(data)
# payment summary
stages = data.get("stages", [])
total_amt = sum(s["amount"] for s in stages)
paid_amt = sum(s.get("paid", 0) for s in stages)
pay_pct = round(paid_amt / total_amt * 100, 1) if total_amt else 0
cat_rows = ""
for cat in data["categories"]:
ct = len(cat["items"])
cd = sum(1 for i in cat["items"] if i["status"] == "Completed")
ci = sum(1 for i in cat["items"] if i["status"] == "Issue / Query")
cp = round(cd / ct * 100) if ct else 0
badge = (f'⚠️ {ci}' if ci
else '✅ Done' if cd == ct
else f'{cd}/{ct}')
cat_rows += f"""
{cat['icon']} {cat['name']}
"""
return f"""{persistence_banner()}
🏠 House Construction Tracker
{p['owner']} | {p['site']} | {p['location']}
{p['package']} @ {p['rate']} · {p['contractor']}
Total Cost: {p['total_cost']}
Work Completion{pct}% ({done}/{total})
💰 Payment Progress₹{paid_amt:,} / ₹{total_amt:,} ({pay_pct}%)
Category Progress{cat_rows}
"""
def photos_html(item):
ph = item.get("photos", [])
if not ph:
return 'No photos uploaded for this item yet.
'
thumbs = "".join(
f'
'
for p in ph)
return f'{thumbs}
'
def items_html(data, cat_display, filter_status):
cat = find_category(data, cat_display)
if not cat:
return "Select a category above.
"
rows = ""
for item in cat["items"]:
if filter_status != "All" and item["status"] != filter_status:
continue
bg = STATUS_COLOR.get(item["status"], "white")
em = STATUS_EMOJI.get(item["status"], "")
ph = f'📷 {len(item.get("photos", []))}' if item.get("photos") else ""
meta = f'{item["updated_by"]} · {item["updated"]}
' if item.get("updated") else ""
rows += f"""
| {item['id']} |
{item['item']}{meta} |
{item['spec']} |
{em} {item['status']} |
{item['notes']} {ph} |
"""
if not rows:
rows = '| No items match this filter. |
'
return f"""
| ID | Item |
Specification | Status |
Notes |
{rows}
"""
def stages_html(data):
stages = data.get("stages", [])
total_amt = sum(s["amount"] for s in stages)
paid_amt = sum(s.get("paid", 0) for s in stages)
rows = ""
for s in stages:
em = STATUS_EMOJI.get(s["status"], "")
paid = s.get("paid", 0)
pay_badge = ("✅ Fully Paid" if paid >= s["amount"] else
f"🟡 ₹{paid:,} paid" if paid > 0 else "⚪ Unpaid")
bg = "#e8f5e9" if s["status"] == "Completed" else "#e3f2fd" if s["status"] == "In Progress" else "white"
rows += f"""
| {s['id']} |
{s['stage']} |
{s['percent']}% |
₹{s['amount']:,} |
{em} {s['status']} |
{pay_badge} |
{s.get('notes','')} |
"""
return f"""
| ID | Construction Stage |
% of Cost | Amount |
Work Status | Payment |
Notes |
{rows}
| TOTAL |
₹{total_amt:,} |
Paid: ₹{paid_amt:,} |
Balance: ₹{total_amt-paid_amt:,} |
ℹ️ Stage percentages are a standard template — adjust amounts/notes to match your signed payment schedule with RCC.
"""
def issues_html(data):
flagged = [(c["name"], c["icon"], it) for c in data["categories"] for it in c["items"] if it["status"] == "Issue / Query"]
if not flagged:
return '✅ No issues or queries right now!
'
blocks = "".join(f"""
{icon} {cn} | ID: {it['id']} {('· '+it['updated_by']+' '+it['updated']) if it.get('updated') else ''}
{it['item']}
{it['spec']}
{f'
Note: {it["notes"]}
' if it["notes"] else ""}
""" for cn, icon, it in flagged)
return f"⚠️ {len(flagged)} Issue(s)
" + blocks
def full_checklist_html(data):
html = ''
for cat in data["categories"]:
html += f'
{cat["icon"]} {cat["name"]}
'
html += '| Item | Specification | Status | Notes |
'
for it in cat["items"]:
bg = STATUS_COLOR.get(it["status"], "white"); em = STATUS_EMOJI.get(it["status"], "")
html += f'| {it["item"]} | {it["spec"]} | {em} {it["status"]} | {it["notes"]} |
'
html += '
'
return html + '
'
def activity_html(data):
acts = data.get("activity", [])
if not acts:
return 'No activity logged yet.
'
rows = "".join(f'| {a["time"]} | {a["user"]} | {a["action"]} |
' for a in acts[:80])
return f'| Time (IST) | User | Action |
{rows}
'
def do_export(data):
lines = ['"Category","Item","Specification","Status","Notes","Updated By","Updated"']
for cat in data["categories"]:
for it in cat["items"]:
lines.append(",".join(f'"{str(v).replace(chr(34), chr(39))}"'
for v in [cat["name"], it["item"], it["spec"], it["status"], it["notes"], it.get("updated_by",""), it.get("updated","")]))
path = os.path.join(PERSIST_DIR, "house_tracker_export.csv")
with open(path, "w", encoding="utf-8") as f:
f.write("\n".join(lines))
return path
def do_search(data, query):
q = query.strip().lower()
if not q:
return "Type a keyword above to search all items.
"
hits = []
for cat in data["categories"]:
for it in cat["items"]:
if q in it["item"].lower() or q in it["spec"].lower() or q in it["notes"].lower() or q in it["id"].lower():
hits.append((cat, it))
if not hits:
return f"No items match '{query}'.
"
rows = ""
for cat, it in hits:
bg = STATUS_COLOR.get(it["status"], "white"); em = STATUS_EMOJI.get(it["status"], "")
rows += f'| {cat["icon"]} {it["id"]} | {it["item"]} | {it["spec"]} | {em} {it["status"]} |
'
return f'{len(hits)} result(s) for "{query}"
'
# ── UI ────────────────────────────────────────────────────────────────────────
data_init = load_data()
cat_list = category_names(data_init)
with gr.Blocks(title="🏠 House Tracker — VKN Blore") as demo:
who = gr.State("") # logged-in username
# ── LOGIN ─────────────────────────────────────────────────────────────────
with gr.Column(visible=True) as login_screen:
gr.HTML("""
🏠
VKN Blore House Tracker
Mr. Vinay Kumar | 20×30 Site | VIP Package
""")
user_in = gr.Textbox(label="Username", placeholder="Enter username")
pass_in = gr.Textbox(label="Password", type="password", placeholder="Enter password")
login_btn = gr.Button("🔐 Login", variant="primary", size="lg")
login_err = gr.Markdown("")
# ── MAIN ──────────────────────────────────────────────────────────────────
with gr.Column(visible=False) as main_screen:
hello = gr.Markdown("")
with gr.Tabs():
with gr.Tab("📊 Dashboard"):
dash = gr.HTML(value=dashboard_html(data_init))
gr.Button("🔄 Refresh", size="sm").click(lambda: dashboard_html(load_data()), outputs=dash)
with gr.Tab("📋 Track Items"):
with gr.Row():
cat_dd = gr.Dropdown(choices=cat_list, value=cat_list[0], label="Category", scale=3)
flt_dd = gr.Dropdown(choices=["All"] + STATUS_OPTIONS, value="All", label="Filter", scale=1)
tbl = gr.HTML(value=items_html(data_init, cat_list[0], "All"))
gr.Markdown("---\n### ✏️ Update an Item")
item_dd = gr.Dropdown(choices=item_choices(data_init, cat_list[0]), label="Select Item", interactive=True)
with gr.Row():
st_in = gr.Dropdown(choices=STATUS_OPTIONS, value="In Progress", label="New Status", scale=1)
nt_in = gr.Textbox(label="Notes / Engineer Comments", lines=1, scale=2)
photo_in = gr.File(label="📷 Attach site photo(s) (optional)", file_count="multiple", file_types=["image"])
with gr.Row():
save_btn = gr.Button("💾 Save Update", variant="primary")
msg_out = gr.Textbox(label="Result", interactive=False, scale=2)
item_photos = gr.HTML()
def refresh_items(cat, flt):
d = load_data()
return items_html(d, cat, flt), gr.update(choices=item_choices(d, cat))
cat_dd.change(refresh_items, [cat_dd, flt_dd], [tbl, item_dd])
flt_dd.change(lambda c, f: items_html(load_data(), c, f), [cat_dd, flt_dd], tbl)
def show_photos(item_choice):
d = load_data(); _, it = find_item(d, parse_item_id(item_choice))
return photos_html(it) if it else ""
item_dd.change(show_photos, item_dd, item_photos)
def do_update(cat_display, item_choice, status, notes, files, user):
d = load_data()
iid = parse_item_id(item_choice)
cat, it = find_item(d, iid)
if not it:
return "❌ Select an item first.", items_html(d, cat_display, "All"), dashboard_html(d), "", gr.update()
it["status"] = status
it["notes"] = notes
it["updated"] = now_str()
it["updated_by"] = user
saved = 0
if files:
for fp in files:
try:
fn = f"{iid}_{now_str().replace(' ','_').replace(':','')}_{os.path.basename(fp)}"
dest = os.path.join(PHOTO_DIR, fn)
shutil.copy(fp, dest)
it.setdefault("photos", []).append(dest)
_push_dataset(dest, f"photos/{fn}")
saved += 1
except Exception as e:
print("photo save err", e)
log_activity(d, user, f"{status}: {it['item']}" + (f" (+{saved} photo)" if saved else ""))
save_data(d)
extra = f" +{saved} photo(s)" if saved else ""
return (f"✅ '{it['item']}' → {status}{extra}",
items_html(d, cat_display, "All"), dashboard_html(d),
photos_html(it), gr.update(value=None))
save_btn.click(do_update, [cat_dd, item_dd, st_in, nt_in, photo_in, who],
[msg_out, tbl, dash, item_photos, photo_in])
with gr.Tab("💰 Stages & Payment"):
stg = gr.HTML(value=stages_html(data_init))
gr.Markdown("### ✏️ Update a Stage")
with gr.Row():
stg_dd = gr.Dropdown(choices=[f"{s['id']} — {s['stage']}" for s in data_init["stages"]], label="Stage", scale=2)
stg_st = gr.Dropdown(choices=STAGE_STATUS, value="In Progress", label="Work Status", scale=1)
with gr.Row():
stg_paid = gr.Number(label="Amount Paid (₹, cumulative)", value=0, scale=1)
stg_note = gr.Textbox(label="Notes", lines=1, scale=2)
with gr.Row():
stg_btn = gr.Button("💾 Save Stage", variant="primary")
stg_msg = gr.Textbox(label="Result", interactive=False, scale=2)
def update_stage(choice, status, paid, note, user):
d = load_data(); sid = parse_item_id(choice)
for s in d["stages"]:
if s["id"] == sid:
s["status"] = status
s["paid"] = int(paid or 0)
s["notes"] = note
log_activity(d, user, f"Stage {status}: {s['stage']} (paid ₹{int(paid or 0):,})")
save_data(d)
return f"✅ Updated '{s['stage']}'", stages_html(d), dashboard_html(d)
return "❌ Select a stage.", stages_html(d), dashboard_html(d)
stg_btn.click(update_stage, [stg_dd, stg_st, stg_paid, stg_note, who], [stg_msg, stg, dash])
with gr.Tab("🔎 Search"):
sq = gr.Textbox(label="Search all items", placeholder="e.g. teak, quartz, geyser, waterproof...")
sres = gr.HTML()
sq.change(lambda q: do_search(load_data(), q), sq, sres)
with gr.Tab("⚠️ Issues"):
iss = gr.HTML(value=issues_html(data_init))
gr.Button("🔄 Refresh", size="sm").click(lambda: issues_html(load_data()), outputs=iss)
with gr.Tab("📝 Full Checklist"):
full = gr.HTML(value=full_checklist_html(data_init))
with gr.Row():
gr.Button("🔄 Refresh", size="sm").click(lambda: full_checklist_html(load_data()), outputs=full)
exp_btn = gr.Button("⬇️ Export CSV", size="sm")
exp_file = gr.File(label="Download", visible=False)
exp_btn.click(lambda: (do_export(load_data()), gr.update(visible=True)), outputs=[exp_file, exp_file])
with gr.Tab("🕓 Activity Log"):
act = gr.HTML(value=activity_html(data_init))
gr.Button("🔄 Refresh", size="sm").click(lambda: activity_html(load_data()), outputs=act)
with gr.Tab("ℹ️ Project Info"):
gr.Markdown("""
## Project Details
| Field | Details |
|-------|---------|
| **Owner** | Mr. Vinay Kumar |
| **Site** | 20 × 30 ft (600 sqft plot), Bangalore |
| **Package** | VIP with Interiors @ ₹2,999/sqft |
| **Contractor** | Reliable Consultants & Constructions |
| **Total Cost** | ₹63,71,300 (after Akshaya Tritiya offer) |
| **Contact** | 7676740502 / 7022956667 |
## 💾 Enable Permanent Storage (important!)
By default, edits and photos are **lost when the Space restarts**. To keep them forever, pick one:
**Option A — Persistent Storage (simplest):**
1. Space → **Settings** → **Persistent Storage** → enable the free/smallest tier.
2. Restart. The app auto-detects `/data` and saves there.
**Option B — HF Dataset (free, no paid storage):**
1. Create a **private Dataset** repo, e.g. `vikun1989/house-tracker-data`.
2. Create a token: huggingface.co → Settings → **Access Tokens** → write token.
3. In this Space → Settings → **Variables & secrets**, add two **secrets**:
- `HF_TOKEN` = your write token
- `DATASET_REPO` = `vikun1989/house-tracker-data`
4. Restart. Edits & photos now sync to the dataset.
## How to Update
1. **Track Items** → select category → select item → set status, add notes / photos → **Save Update**.
2. **Stages & Payment** → track construction milestones and payments.
3. **Activity Log** shows who changed what.
### Warranty: Structural 10 yr · Waterproofing 10 yr · Construction 1 yr · Fixtures brand warranty
""")
logout_btn = gr.Button("🚪 Logout", variant="stop", size="sm")
# ── Auth ──────────────────────────────────────────────────────────────────
def do_login(username, password):
u = username.strip()
if USERS.get(u) == password.strip():
d = load_data(); log_activity(d, u, "Logged in"); save_data(d)
return (gr.update(visible=False), gr.update(visible=True), "", u,
f"### 👋 Welcome, **{u}** · House Construction Tracker")
return (gr.update(visible=True), gr.update(visible=False),
"❌ Wrong username or password.", "", "")
def do_logout():
return gr.update(visible=True), gr.update(visible=False), ""
login_btn.click(do_login, [user_in, pass_in], [login_screen, main_screen, login_err, who, hello])
pass_in.submit(do_login, [user_in, pass_in], [login_screen, main_screen, login_err, who, hello])
logout_btn.click(do_logout, outputs=[login_screen, main_screen, who])
demo.launch(ssr_mode=False, allowed_paths=[PHOTO_DIR])