Spaces:
Runtime error
Runtime error
| """Streamlit testing console for the Reno Scheduling Engine (Phase 1). | |
| Run the API first: python -m uvicorn app.api.app:app --port 8000 | |
| Then: streamlit run streamlit_app.py | |
| It exercises the live HTTP API (create project, add BOQ items, add manual | |
| dependencies, compute a baseline) and visualises the result as a Gantt chart and | |
| a dependency DAG, with explanatory details for every concept. | |
| """ | |
| from __future__ import annotations | |
| import datetime as dt | |
| import httpx | |
| import pandas as pd | |
| import plotly.express as px | |
| import streamlit as st | |
| st.set_page_config(page_title="Reno Scheduling Engine β Tester", layout="wide") | |
| KIND_COLORS = { | |
| "PROCUREMENT": "#2563eb", | |
| "INSTALLATION": "#16a34a", | |
| "COMMISSIONING": "#9333ea", | |
| "INSPECTION": "#d97706", | |
| "CUSTOM": "#64748b", | |
| } | |
| # --------------------------------------------------------------------------- API helpers | |
| def api_base() -> str: | |
| return st.session_state.get("api_base", "http://127.0.0.1:8000") | |
| def _client() -> httpx.Client: | |
| return httpx.Client(base_url=api_base(), timeout=15) | |
| def api_get(path: str): | |
| with _client() as c: | |
| r = c.get(path) | |
| return r | |
| def api_post(path: str, body: dict): | |
| with _client() as c: | |
| r = c.post(path, json=body) | |
| return r | |
| def ok(r) -> bool: | |
| return 200 <= r.status_code < 300 | |
| def parse_iso(s: str) -> dt.datetime: | |
| return dt.datetime.fromisoformat(s.replace("Z", "+00:00")) | |
| def _demo_seed() -> None: | |
| """Create a demo project with a mix of BOQ items and a baseline.""" | |
| cats = api_get("/v1/categories").json() | |
| by_code = {c["code"]: c["id"] for c in cats} | |
| flooring = by_code.get("FLOORING", cats[0]["id"]) | |
| joinery = by_code.get("JOINERY", flooring) | |
| pid = api_post("/v1/projects", {"name": "Demo Villa", "planned_start": "2026-07-01"}).json()["id"] | |
| plan = [ | |
| (flooring, "Living room flooring", "SUPPLY_AND_APPLY", {"override_lead_time_days": 10}), | |
| (flooring, "Bathroom tiles", "SUPPLY", {}), | |
| (joinery, "Kitchen cabinetry", "SUPPLY_AND_APPLY", {}), | |
| (joinery, "Wardrobe install (client-supplied)", "APPLY", {}), | |
| (flooring, "Skirting", "APPLY", {}), | |
| ] | |
| for cat, name, ptype, meta in plan: | |
| api_post(f"/v1/projects/{pid}/boq-items", { | |
| "category_id": cat, "name": name, "quantity": 20, "uom": "sqm", | |
| "procurement_type": ptype, "procurement_responsibility": "CONTRACTOR", | |
| "is_material": ptype != "APPLY", "metadata": meta, | |
| }) | |
| api_post(f"/v1/projects/{pid}/schedule/baseline", {"start_date": "2026-07-01"}) | |
| # Stash under a NON-widget key; the selectbox key cannot be set after the | |
| # widget is instantiated. It is applied on the next run before the widget. | |
| st.session_state["_demo_pid"] = pid | |
| st.toast(f"Demo project #{pid} created with a baseline schedule") | |
| # --------------------------------------------------------------------------- sidebar | |
| with st.sidebar: | |
| st.title("βοΈ Scheduler Tester") | |
| st.session_state["api_base"] = st.text_input("API base URL", api_base()) | |
| health = None | |
| try: | |
| health = api_get("/v1/projects") | |
| except Exception as exc: # noqa: BLE001 | |
| st.error(f"API unreachable:\n{exc}") | |
| if health is not None and ok(health): | |
| st.success(f"API online Β· {len(health.json())} project(s)") | |
| elif health is not None: | |
| st.warning(f"API responded {health.status_code}") | |
| st.divider() | |
| st.caption( | |
| "Start the API with:\n\n`python -m uvicorn app.api.app:app --port 8000`\n\n" | |
| "Seed reference data:\n\n`python -m scripts.seed`" | |
| ) | |
| st.divider() | |
| st.subheader("Projects") | |
| projects = [] | |
| try: | |
| pr = api_get("/v1/projects") | |
| if ok(pr): | |
| projects = pr.json() | |
| except Exception: # noqa: BLE001 | |
| pass | |
| labels = {p["id"]: f"#{p['id']} Β· {p['name']} ({p['status']})" for p in projects} | |
| # Apply a pending selection (e.g. from "Seed a demo project") BEFORE the | |
| # widget is created β setting a widget key after instantiation is illegal. | |
| if "_demo_pid" in st.session_state: | |
| _pid = st.session_state.pop("_demo_pid") | |
| if _pid in [p["id"] for p in projects]: | |
| st.session_state["active_project"] = _pid | |
| if projects: | |
| selected = st.selectbox( | |
| "Active project", | |
| options=[p["id"] for p in projects], | |
| format_func=lambda i: labels.get(i, str(i)), | |
| key="active_project", | |
| ) | |
| else: | |
| st.info("No projects yet β create one below.") | |
| selected = None | |
| with st.form("new_project", clear_on_submit=True): | |
| st.markdown("**New project**") | |
| np_name = st.text_input("Name", "Sample Villa Renovation") | |
| np_start = st.date_input("Planned start", dt.date(2026, 7, 1)) | |
| if st.form_submit_button("Create project"): | |
| r = api_post("/v1/projects", {"name": np_name, "planned_start": np_start.isoformat()}) | |
| if ok(r): | |
| st.success(f"Created project #{r.json()['id']}") | |
| st.rerun() | |
| else: | |
| st.error(r.text) | |
| if st.button("π Seed a demo project (5 BOQ items + baseline)"): | |
| _demo_seed() | |
| st.rerun() | |
| # --------------------------------------------------------------------------- header | |
| st.title("ποΈ Reno Scheduling Engine β Phase 1 Tester") | |
| st.caption( | |
| "Minimum-viable DAG scheduler: template-driven activity generation, cycle-safe " | |
| "dependencies, and a CPM forward pass against a 24/7 calendar stub." | |
| ) | |
| with st.expander("π How this works (read me)", expanded=False): | |
| st.markdown( | |
| """ | |
| **Pipeline:** `BOQ item β Rules Engine β activities + dependencies β DAG (cycle-checked) β CPM forward pass β schedule version` | |
| | `procurement_type` | activities generated | dependency | | |
| |---|---|---| | |
| | `SUPPLY` | 1 Γ **PROCUREMENT** | β | | |
| | `APPLY` | 1 Γ **INSTALLATION** | β | | |
| | `SUPPLY_AND_APPLY` | **PROCUREMENT** + **INSTALLATION** | FS, PROC β INSTALL, lag 0 | | |
| - **Clock** β `WALL` activities (procurement lead time) count calendar days (24/7); `WORKING` activities (installation effort) count working time against a **real calendar** (weekends, holidays, reduced-permit windows, per-contractor calendars). A project with no calendar attached falls back to 24/7. | |
| - **Dependencies** β `FS` finishβstart, `SS` startβstart, `FF` finishβfinish, `SF` startβfinish, each with an optional `lag_days`. Manual edges are rejected (HTTP 409) if they would create a **cycle** (Kahn's algorithm, inside the same DB transaction). | |
| - **Day 0 / Baseline** β an immutable snapshot (`schedule_versions` + `scheduled_activities`) with a deterministic `inputs_hash`. One BASELINE per project; calendar/pin/project changes write append-only FORECAST re-solves. See the **Day-0 Explainability** tab for a full derivation of every date. | |
| - **Pins** β hard PM constraints (`START_NO_EARLIER` / `START_NO_LATER` / `MUST_START_ON` / `MUST_FINISH_ON`); conflicts are flagged, not silently dropped. | |
| - **Not yet** β incremental Day-X reforecast & progress (Stage 4), backward pass / float (Stage 5). | |
| """ | |
| ) | |
| if not selected: | |
| st.stop() | |
| project_id = selected | |
| tab_boq, tab_deps, tab_sched, tab_explain, tab_raw = st.tabs( | |
| ["β BOQ items & activities", "β‘ Dependencies", "β’ Schedule & Gantt", | |
| "β£ Day-0 Explainability", "β€ Raw API"] | |
| ) | |
| # --------------------------------------------------------------------------- helpers | |
| def load_activities() -> list[dict]: | |
| r = api_get(f"/v1/projects/{project_id}/activities") | |
| return r.json() if ok(r) else [] | |
| def load_dependencies() -> list[dict]: | |
| r = api_get(f"/v1/projects/{project_id}/dependencies") | |
| return r.json() if ok(r) else [] | |
| def activity_label(a: dict) -> str: | |
| return f"#{a['id']} Β· {a['name']} [{a['kind']}]" | |
| # --------------------------------------------------------------------------- β BOQ tab | |
| with tab_boq: | |
| st.subheader("Add a BOQ item") | |
| st.caption("On creation the Rules Engine auto-generates 1β2 activities + intra-item edges (no hardcoded if/else).") | |
| cats = [] | |
| cr = api_get("/v1/categories") | |
| if ok(cr): | |
| cats = cr.json() | |
| l1 = [c for c in cats if c["level"] == 1] | |
| with st.form("add_boq", clear_on_submit=False): | |
| c1, c2, c3 = st.columns(3) | |
| with c1: | |
| cat_id = st.selectbox( | |
| "Category", | |
| options=[c["id"] for c in (l1 or cats)], | |
| format_func=lambda i: next((f"{c['code']} β {c['name']}" for c in cats if c["id"] == i), str(i)), | |
| ) | |
| name = st.text_input("Name", "Living room flooring") | |
| with c2: | |
| ptype = st.selectbox("procurement_type", ["SUPPLY", "APPLY", "SUPPLY_AND_APPLY"], index=2) | |
| resp = st.selectbox("procurement_responsibility", ["CONTRACTOR", "CUSTOMER", "ORG"]) | |
| with c3: | |
| qty = st.number_input("Quantity", min_value=0.0, value=35.0) | |
| uom = st.text_input("UOM", "sqm") | |
| c4, c5, c6 = st.columns(3) | |
| with c4: | |
| is_material = st.checkbox("is_material", value=True) | |
| with c5: | |
| ov_lead = st.number_input("override_lead_time_days (0 = use template)", min_value=0.0, value=0.0) | |
| with c6: | |
| ov_effort = st.number_input("override_effort_days (0 = use template)", min_value=0.0, value=0.0) | |
| if st.form_submit_button("β Add BOQ item"): | |
| meta: dict = {} | |
| if ov_lead > 0: | |
| meta["override_lead_time_days"] = ov_lead | |
| if ov_effort > 0: | |
| meta["override_effort_days"] = ov_effort | |
| r = api_post(f"/v1/projects/{project_id}/boq-items", { | |
| "category_id": cat_id, "name": name, "quantity": qty, "uom": uom, | |
| "procurement_type": ptype, "procurement_responsibility": resp, | |
| "is_material": is_material, "metadata": meta, | |
| }) | |
| if r.status_code == 201: | |
| body = r.json() | |
| st.success( | |
| f"Created BOQ #{body['boq_item']['id']} β " | |
| f"{len(body['activities'])} activit{'y' if len(body['activities'])==1 else 'ies'}, " | |
| f"{len(body['dependencies'])} edge(s)" | |
| ) | |
| st.json(body, expanded=False) | |
| elif r.status_code == 422: | |
| st.error(f"NO_TEMPLATE β {r.json().get('message')}") | |
| else: | |
| st.error(r.text) | |
| st.divider() | |
| acts = load_activities() | |
| st.subheader(f"Activities in project #{project_id} ({len(acts)})") | |
| if acts: | |
| df = pd.DataFrame(acts)[ | |
| ["id", "boq_item_id", "kind", "name", "clock", "lead_time_days", "effort_days", "responsibility", "status"] | |
| ] | |
| st.dataframe(df, use_container_width=True, hide_index=True) | |
| else: | |
| st.info("No activities yet. Add a BOQ item above.") | |
| # --------------------------------------------------------------------------- β‘ Dependencies tab | |
| with tab_deps: | |
| st.subheader("Add a manual cross-item dependency") | |
| st.caption("Rejected with HTTP 409 if it would introduce a cycle (the insert is rolled back).") | |
| acts = load_activities() | |
| if len(acts) < 2: | |
| st.info("Add at least two activities first (tab β ).") | |
| else: | |
| opts = {activity_label(a): a["id"] for a in acts} | |
| with st.form("add_dep"): | |
| d1, d2, d3, d4 = st.columns([3, 3, 1, 1]) | |
| with d1: | |
| pred = st.selectbox("Predecessor", list(opts.keys())) | |
| with d2: | |
| succ = st.selectbox("Successor", list(opts.keys()), index=min(1, len(opts) - 1)) | |
| with d3: | |
| dep_type = st.selectbox("Type", ["FS", "SS", "FF", "SF"]) | |
| with d4: | |
| lag = st.number_input("Lag (d)", value=0.0) | |
| if st.form_submit_button("π Add dependency"): | |
| r = api_post(f"/v1/projects/{project_id}/dependencies", { | |
| "predecessor_id": opts[pred], "successor_id": opts[succ], | |
| "dep_type": dep_type, "lag_days": lag, | |
| }) | |
| if r.status_code == 201: | |
| st.success(f"Edge #{r.json()['id']} created (origin MANUAL).") | |
| elif r.status_code == 409: | |
| st.error(f"CYCLE_DETECTED β path: {' β '.join(map(str, r.json()['cycle']))}") | |
| else: | |
| st.error(r.text) | |
| st.divider() | |
| deps = load_dependencies() | |
| st.subheader(f"Dependencies ({len(deps)})") | |
| if deps: | |
| name_by_id = {a["id"]: a["name"] for a in load_activities()} | |
| rows = [ | |
| { | |
| "id": d["id"], | |
| "predecessor": f"#{d['predecessor_id']} {name_by_id.get(d['predecessor_id'], '')}", | |
| "successor": f"#{d['successor_id']} {name_by_id.get(d['successor_id'], '')}", | |
| "type": d["dep_type"], | |
| "lag_days": d["lag_days"], | |
| "origin": d["origin"], | |
| } | |
| for d in deps | |
| ] | |
| st.dataframe(pd.DataFrame(rows), use_container_width=True, hide_index=True) | |
| else: | |
| st.info("No dependencies yet (template edges appear automatically for SUPPLY_AND_APPLY items).") | |
| # --------------------------------------------------------------------------- β’ Schedule tab | |
| with tab_sched: | |
| top = st.columns([1, 1, 2]) | |
| with top[0]: | |
| base_start = st.date_input("Baseline start_date", dt.date(2026, 7, 1), key="base_start") | |
| with top[1]: | |
| st.write("") | |
| st.write("") | |
| if st.button("π Compute baseline"): | |
| r = api_post(f"/v1/projects/{project_id}/schedule/baseline", {"start_date": base_start.isoformat()}) | |
| if r.status_code == 201: | |
| st.success(f"Baseline v{r.json()['schedule_version_id']} computed.") | |
| elif r.status_code == 409: | |
| st.warning(f"BASELINE_EXISTS β existing version #{r.json()['existing_version_id']}") | |
| else: | |
| st.error(r.text) | |
| versions = [] | |
| vr = api_get(f"/v1/projects/{project_id}/schedule-versions") | |
| if ok(vr): | |
| versions = vr.json() | |
| if not versions: | |
| st.info("No schedule version yet β compute a baseline above.") | |
| else: | |
| with top[2]: | |
| vid = st.selectbox( | |
| "Schedule version", | |
| options=[v["id"] for v in versions], | |
| format_func=lambda i: next( | |
| (f"v{v['id']} Β· {v['kind']} Β· {v['computed_at']}" for v in versions if v["id"] == i), str(i) | |
| ), | |
| ) | |
| detail = api_get(f"/v1/schedule-versions/{vid}").json() | |
| sched = detail["activities"] | |
| # Summary metrics | |
| finishes = [parse_iso(a["earliest_finish"]) for a in sched] | |
| starts = [parse_iso(a["earliest_start"]) for a in sched] | |
| m1, m2, m3, m4 = st.columns(4) | |
| m1.metric("Activities", len(sched)) | |
| m2.metric("Project start", min(starts).strftime("%Y-%m-%d") if starts else "β") | |
| m3.metric("Project finish", max(finishes).strftime("%Y-%m-%d %H:%M") if finishes else "β") | |
| if starts and finishes: | |
| m4.metric("Span (days)", f"{(max(finishes) - min(starts)).total_seconds()/86400:.1f}") | |
| st.caption(f"inputs_hash `{detail['inputs_hash'][:16]}β¦` Β· stats {detail.get('stats')}") | |
| # Gantt | |
| st.subheader("π Gantt") | |
| gdf = pd.DataFrame([ | |
| { | |
| "Activity": f"#{a['activity_id']} {a['name']}", | |
| "Start": parse_iso(a["earliest_start"]), | |
| "Finish": parse_iso(a["earliest_finish"]), | |
| "Kind": a["kind"], | |
| } | |
| for a in sched | |
| ]).sort_values("Start") | |
| if not gdf.empty: | |
| fig = px.timeline( | |
| gdf, x_start="Start", x_end="Finish", y="Activity", color="Kind", | |
| color_discrete_map=KIND_COLORS, | |
| ) | |
| fig.update_yaxes(autorange="reversed") | |
| fig.update_layout(height=80 + 36 * len(gdf), legend_title="Kind", margin=dict(l=10, r=10, t=10, b=10)) | |
| st.plotly_chart(fig, use_container_width=True) | |
| # DAG | |
| st.subheader("πΈοΈ Dependency DAG") | |
| st.caption("Node colour = activity kind. Edge label = dep_type / lag.") | |
| deps = load_dependencies() | |
| dot = ["digraph G {", 'rankdir=LR; node [style=filled, fontcolor=white, shape=box, fontsize=10];'] | |
| for a in sched: | |
| color = KIND_COLORS.get(a["kind"], "#64748b") | |
| label = f"#{a['activity_id']}\\n{a['name']}\\n{a['kind']}" | |
| dot.append(f' {a["activity_id"]} [label="{label}", fillcolor="{color}"];') | |
| for d in deps: | |
| lag = f" +{d['lag_days']:g}d" if d["lag_days"] else "" | |
| style = "dashed" if d["origin"] == "MANUAL" else "solid" | |
| dot.append(f' {d["predecessor_id"]} -> {d["successor_id"]} ' | |
| f'[label="{d["dep_type"]}{lag}", style={style}, fontsize=9];') | |
| dot.append("}") | |
| st.graphviz_chart("\n".join(dot)) | |
| # Scheduled table | |
| st.subheader("ποΈ Scheduled activities") | |
| tdf = pd.DataFrame([ | |
| { | |
| "id": a["activity_id"], "name": a["name"], "kind": a["kind"], | |
| "earliest_start": a["earliest_start"], "earliest_finish": a["earliest_finish"], | |
| "predecessors": ", ".join(map(str, a["predecessors"])) or "β", | |
| "successors": ", ".join(map(str, a["successors"])) or "β", | |
| } | |
| for a in sched | |
| ]) | |
| st.dataframe(tdf, use_container_width=True, hide_index=True) | |
| # --------------------------------------------------------------------------- β£ Raw tab | |
| with tab_raw: | |
| st.caption("Inspect the live API responses for the active project.") | |
| st.markdown("**GET** `/v1/projects/{id}/activities`") | |
| st.json(load_activities(), expanded=False) | |
| st.markdown("**GET** `/v1/projects/{id}/dependencies`") | |
| st.json(load_dependencies(), expanded=False) | |
| st.markdown("**GET** `/v1/projects/{id}/boq-items`") | |
| br = api_get(f"/v1/projects/{project_id}/boq-items") | |
| st.json(br.json() if ok(br) else {}, expanded=False) | |
| st.markdown("**GET** `/v1/projects/{id}/schedule-versions`") | |
| sr = api_get(f"/v1/projects/{project_id}/schedule-versions") | |
| st.json(sr.json() if ok(sr) else {}, expanded=False) | |
| # --------------------------------------------------------------------------- β£ Day-0 Explainability | |
| with tab_explain: | |
| st.subheader("Day-0 Explainability β how each date is computed") | |
| st.caption( | |
| "A dry-run of the CPM forward pass (no baseline is written). It shows, " | |
| "for every activity, exactly how its earliest start/finish were derived β " | |
| "which predecessor drove the start, the clock & calendar used, how the " | |
| "duration was computed β plus the critical path (longest chain to finish)." | |
| ) | |
| exp_start = st.date_input("Day-0 start date", dt.date(2026, 7, 1), key="exp_start") | |
| r = api_get(f"/v1/projects/{project_id}/schedule/explain?start_date={exp_start.isoformat()}") | |
| if r is None or not ok(r): | |
| st.info("Add BOQ items first (tab β ), then return here.") | |
| else: | |
| data = r.json() | |
| acts = data["activities"] | |
| if not acts: | |
| st.info("No activities yet β add BOQ items in tab β .") | |
| else: | |
| by_id = {a["activity_id"]: a for a in acts} | |
| crit = set(data["critical_path"]) | |
| m1, m2, m3 = st.columns(3) | |
| m1.metric("Activities", len(acts)) | |
| m2.metric("Project finish", parse_iso(data["project_finish"]).strftime("%Y-%m-%d %H:%M")) | |
| m3.metric("Critical path", " β ".join(f"#{i}" for i in data["critical_path"]) or "β") | |
| # β Rules Engine | |
| st.markdown("#### β Rules Engine β BOQ β activities") | |
| st.caption("Each activity was generated from its BOQ item's category + procurement_type via a template (no hardcoded if/else).") | |
| st.dataframe(pd.DataFrame([{ | |
| "activity": f"#{a['activity_id']} {a['name']}", "kind": a["kind"], "clock": a["clock"], | |
| "effort_days": a["duration"].get("effort_days"), | |
| "lead_time_days": a["duration"].get("lead_time_days"), | |
| "calendar": a["calendar_id"] or "24/7", | |
| } for a in acts]), hide_index=True, use_container_width=True) | |
| # β‘ DAG + topo order | |
| st.markdown("#### β‘ DAG & topological order") | |
| st.caption("Activities are scheduled in this dependency-respecting order (ties broken by id). Critical-path nodes/edges are red.") | |
| st.code(" β ".join(f"#{i}" for i in data["topo_order"]) or "(none)", language=None) | |
| deps = load_dependencies() | |
| crit_edges = set(zip(data["critical_path"], data["critical_path"][1:])) | |
| dot = ["digraph G {", 'rankdir=LR; node [style=filled, fontcolor=white, shape=box, fontsize=10];'] | |
| for a in acts: | |
| color = "#dc2626" if a["activity_id"] in crit else KIND_COLORS.get(a["kind"], "#64748b") | |
| dot.append(f' {a["activity_id"]} [label="#{a["activity_id"]}\\n{a["name"]}\\n{a["kind"]}", fillcolor="{color}"];') | |
| for d in deps: | |
| p, s = d["predecessor_id"], d["successor_id"] | |
| is_c = (p, s) in crit_edges | |
| lag = f" +{d['lag_days']:g}d" if d["lag_days"] else "" | |
| dot.append(f' {p} -> {s} [label="{d["dep_type"]}{lag}", ' | |
| f'color="{"#dc2626" if is_c else "#94a3b8"}", penwidth={"3" if is_c else "1"}];') | |
| dot.append("}") | |
| st.graphviz_chart("\n".join(dot)) | |
| # β’ CPM forward-pass derivation | |
| st.markdown("#### β’ CPM forward pass β per-activity derivation") | |
| st.caption("ES = max over predecessors of (constraint date + lag); EF = ES + duration.") | |
| for aid in data["topo_order"]: | |
| a = by_id[aid] | |
| star = " β" if aid in crit else "" | |
| es_lbl = parse_iso(a["earliest_start"]).strftime("%b %d %H:%M") | |
| ef_lbl = parse_iso(a["earliest_finish"]).strftime("%b %d %H:%M") | |
| with st.expander(f"#{aid} Β· {a['name']} [{a['kind']}]{star} {es_lbl} β {ef_lbl}"): | |
| if a["es_driver"] == "project_start": | |
| st.markdown(f"- **Earliest start** = project start `{a['raw_es']}` (no predecessors).") | |
| else: | |
| st.markdown(f"- **Earliest start** driven by predecessor **#{a['es_driver']}** (the max constraint):") | |
| st.dataframe(pd.DataFrame([{ | |
| "predecessor": f"#{p['predecessor_id']}", "dep_type": p["dep_type"], | |
| "lag_days": p["lag_days"], "pred_finish": p["predecessor_finish"], | |
| "constraint_date": p["constraint_date"], | |
| } for p in a["predecessors"]]), hide_index=True, use_container_width=True) | |
| if a["clock"] == "WORKING": | |
| dur = a["duration"] | |
| st.markdown( | |
| f"- **Clock = WORKING** (calendar `{a['calendar_id'] or '24/7'}`). " | |
| f"Duration = {dur['effort_days']} effort-days Γ {dur['hours_per_day']} h/day " | |
| f"= **{dur['working_minutes'] / 60:.1f} working hours**, placed inside open windows." | |
| ) | |
| if a["snapped_to_working"]: | |
| st.markdown(f" - Start snapped forward into the next working window β `{a['earliest_start']}`.") | |
| else: | |
| st.markdown( | |
| f"- **Clock = WALL**. Duration = **{a['duration']['lead_time_days']} calendar days** " | |
| "(weekends/holidays ignored β vendor lead time runs 24/7)." | |
| ) | |
| st.markdown(f"- **Earliest finish = `{a['earliest_finish']}`**.") | |
| if a["pins"]: | |
| warn = " β β οΈ violates a dependency" if a["pin_violates_dependency"] else "" | |
| st.markdown(f"- **Pins applied:** `{a['pins']}`{warn}") | |
| # β£ Gantt with critical path | |
| st.markdown("#### β£ Gantt (critical path in red)") | |
| gdf = pd.DataFrame([{ | |
| "Activity": f"#{a['activity_id']} {a['name']}", | |
| "Start": parse_iso(a["earliest_start"]), | |
| "Finish": parse_iso(a["earliest_finish"]), | |
| "Lane": "Critical" if a["activity_id"] in crit else a["kind"], | |
| } for a in acts]).sort_values("Start") | |
| cmap = dict(KIND_COLORS) | |
| cmap["Critical"] = "#dc2626" | |
| fig = px.timeline(gdf, x_start="Start", x_end="Finish", y="Activity", color="Lane", | |
| color_discrete_map=cmap) | |
| fig.update_yaxes(autorange="reversed") | |
| fig.update_layout(height=80 + 34 * len(gdf), margin=dict(l=10, r=10, t=10, b=10)) | |
| st.plotly_chart(fig, use_container_width=True) | |