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']}
{badge}
""" return f"""{persistence_banner()}

🏠 House Construction Tracker

{p['owner']}  |  {p['site']}  |  {p['location']}
{p['package']} @ {p['rate']}  ·  {p['contractor']}
Total Cost: {p['total_cost']}
{done}
✅ Completed
{ip}
🔄 In Progress
{pending}
⏳ Pending
{issues}
⚠️ Issues
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"""
{rows}
IDItem SpecificationStatus Notes
""" 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"""
{rows}
IDConstruction Stage % of CostAmount Work StatusPayment Notes
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 += '' for it in cat["items"]: bg = STATUS_COLOR.get(it["status"], "white"); em = STATUS_EMOJI.get(it["status"], "") html += f'' html += '
ItemSpecificationStatusNotes
{it["item"]}{it["spec"]}{em} {it["status"]}{it["notes"]}
' 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'{rows}
Time (IST)UserAction
' 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}"

{rows}
' # ── 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])