T4.1 - B4: the counts come from the server, and disagreement is said out loud
The defect B4 names is not that the numbers were wrong. On one browser with one
cache they were right. It is that they were derived from the caller's own
localStorage, so two people on the same project saw different numbers and neither
was told.
SERVER. /api/wps/metrics now returns everything the creator's dashboard shows -
total, mine, release-ready, on hold, overdue, est/actual hours, by_status,
by_discipline, progress (overall and per discipline) and the gating list. It
already existed for a subset; the rest was being summed in the browser.
Two things moved to the server rather than being duplicated there:
- PROGRESS_WEIGHT, the status-to-percentage table. It was PROGRESS_W in
wp-creation-app.js; the JS copy is deleted rather than left in place, because
two copies of a weighting table is how the two drift apart.
- "release-ready" now counts `waitingOn` predecessors as blocking, which the
browser did and the old endpoint did not. Without that the phrase would have
changed meaning the moment the dashboard stopped computing it locally.
New GET /api/projects/{id}/summary gives the launcher the SOP state it was
reading out of localStorage.
by_location is shaped for CR-018 in wave 6, per the task's instruction not to
build a shape that cannot group by building/floor/sector. It reports its own
dimensions alongside the groups:
{"dimensions": ["location"], "groups": [{"key": {...}, "total": n,
"release_ready": n, "on_hold": n, "overdue": n, "by_status": {...}}]}
Today a package carries one free-text `location`, so that is the one dimension.
_location_key() already prefers structured building/floor/sector when present, so
CR-004 changes the dimensions and the keys and leaves the response shape alone.
CLIENT. The launcher's SOP card and the dashboard's tiles, chips, progress bars
and gating panel all read the server. There is deliberately no cache fallback: a
silently-stale number that looks authoritative is the thing being removed, so a
failed request renders an explicit error and a retry.
Writes flush through the outbox before the counts are re-read (dashRefreshAfterWrite).
Without that the refresh races the push and shows pre-write totals - the same
stale number arriving by a different route.
THE ONE COUNT STILL COMPUTED LOCALLY, stated rather than skipped: the board table
is a LIST of the packages this browser holds, which is what keeps the field view
working offline, and its header counts rows. Rather than pretend otherwise, it is
reconciled against the server's total and shows "this browser has N of M" when
they differ - usually a save that has not finished syncing. Nobody now sees a
number that disagrees with the project without being told, which is what B4 asks
for.
VERIFICATION. tests/aggregates_check.py, 16 checks, all passing. It tests what was
broken rather than whether the totals are right - the latter passed before this
change:
- root and pat get byte-identical aggregates for the same project
- the dashboard shows the server's total after localStorage is POISONED with a
different package list; it cannot do that if it is summing the cache
- a simulated outage renders "Counts unavailable", names the failure, offers a
retry, renders no tiles beside it, and announces via role=alert
- the launcher reports the SOP complete against a cache that says otherwise,
and says "Could not check" when the request fails
- by_location's groups are keyed by its declared dimensions, carry their own
rollups, and sum to the project total
browser_check 71/71, f_items 5 FIXED / F6 REPRODUCES.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
163
server/app.py
163
server/app.py
@@ -1717,11 +1717,45 @@ def list_wps(
|
||||
return [(w.to_dict() if full else w.summary()) for w in rows]
|
||||
|
||||
|
||||
# Weighted completion by status, 0..1, so progress reads as a slope rather than
|
||||
# done/not-done. Mirrors PROGRESS_W in wp-creation-app.js — the dashboard used to
|
||||
# compute this in the browser from localStorage, which is exactly what B4 removes.
|
||||
PROGRESS_WEIGHT = {
|
||||
"Draft": 0.0, "Scheduled": 0.25, "Issue": 0.4, "Issued": 0.5,
|
||||
"In Progress": 0.75, "QC": 0.9, "Closed": 1.0,
|
||||
}
|
||||
|
||||
# The dimensions a location rollup is grouped by. Today a work package carries one
|
||||
# free-text `location` ("building / level / sector / room"), so that is the single
|
||||
# dimension. CR-004 replaces it with structured building / floor / sector in wave 6;
|
||||
# when it does, only this tuple and the keys inside each group change — the response
|
||||
# shape does not, which is what T4.1 means by "can be grouped by location without a
|
||||
# schema change". CR-018's rollup consumes `by_location.groups` either way.
|
||||
LOCATION_DIMENSIONS = ("location",)
|
||||
|
||||
|
||||
def _location_key(data: dict) -> dict:
|
||||
"""The location dimensions of one package, as a dict keyed by dimension name.
|
||||
|
||||
Structured fields win when they exist, so this keeps working unchanged the day
|
||||
CR-004 lands; until then it falls back to the free-text field."""
|
||||
structured = {d: (data.get(d) or "").strip() for d in ("building", "floor", "sector")}
|
||||
if any(structured.values()):
|
||||
return {k: v or "(unset)" for k, v in structured.items()}
|
||||
return {"location": (data.get("location") or "").strip() or "(unset)"}
|
||||
|
||||
|
||||
@app.get("/api/wps/metrics")
|
||||
def wp_metrics(project_id: Optional[str] = Query(None), sop_id: Optional[str] = Query(None), user: models.User = Depends(auth.get_current_user), db: Session = Depends(get_db)):
|
||||
"""Aggregates for the dashboard. Masters (data.split == true) are excluded
|
||||
from counts so a split package's hours aren't double-counted with its
|
||||
instances."""
|
||||
"""Every count and rollup the creator's dashboard shows, computed from the
|
||||
database rather than from the caller's own browser (B4).
|
||||
|
||||
Masters (data.split == true) are excluded so a split package's hours are not
|
||||
double-counted with its instances. Archived packages are excluded throughout.
|
||||
|
||||
Two people on the same project get the same numbers from this endpoint. They
|
||||
did not when each browser derived them from its own localStorage, and neither
|
||||
was told — which is the failure B4 exists to remove."""
|
||||
stmt = select(models.WorkPackage).where(models.WorkPackage.archived_at.is_(None))
|
||||
if project_id:
|
||||
stmt = stmt.where(models.WorkPackage.project_id == project_id)
|
||||
@@ -1730,9 +1764,17 @@ def wp_metrics(project_id: Optional[str] = Query(None), sop_id: Optional[str] =
|
||||
stmt = scope_to_access(stmt, models.WorkPackage.project_id, db, user)
|
||||
rows = db.scalars(stmt).all()
|
||||
|
||||
today = models.utcnow().date().isoformat()
|
||||
|
||||
by_status: dict[str, int] = {}
|
||||
by_discipline: dict[str, int] = {}
|
||||
total = ready = on_hold = est_hours = actual_hours = 0
|
||||
loc_groups: dict[tuple, dict] = {}
|
||||
prog_by_disc: dict[str, dict] = {}
|
||||
gating: list[dict] = []
|
||||
total = ready = on_hold = overdue = mine = 0
|
||||
est_hours = actual_hours = 0.0
|
||||
progress_sum = 0.0
|
||||
|
||||
for w in rows:
|
||||
data = w.data or {}
|
||||
if data.get("split"):
|
||||
@@ -1741,22 +1783,127 @@ def wp_metrics(project_id: Optional[str] = Query(None), sop_id: Optional[str] =
|
||||
by_status[w.status] = by_status.get(w.status, 0) + 1
|
||||
if w.status == "Issue":
|
||||
on_hold += 1
|
||||
if w.assignee_id and w.assignee_id == user.id:
|
||||
mine += 1
|
||||
|
||||
due = (data.get("due") or "").strip()
|
||||
is_overdue = bool(due and w.status != "Closed" and due < today)
|
||||
if is_overdue:
|
||||
overdue += 1
|
||||
|
||||
constraints = data.get("constraints") or []
|
||||
open_count = sum(1 for c in constraints if c.get("status") == "open")
|
||||
if open_count == 0 and w.status not in ("Closed", "Issue"):
|
||||
open_constraints = [c for c in constraints if c.get("status") == "open"]
|
||||
# `waitingOn` are predecessor packages not yet closed; the browser counted
|
||||
# them as blocking too, so the server has to, or "release-ready" changes
|
||||
# meaning the moment the dashboard stops computing it locally.
|
||||
waiting_on = [x for x in (data.get("waitingOn") or []) if x]
|
||||
blocked = bool(open_constraints or waiting_on)
|
||||
if not blocked and w.status not in ("Closed", "Issue"):
|
||||
ready += 1
|
||||
if open_constraints:
|
||||
gating.append({
|
||||
"id": w.id, "number": w.number, "subject": w.subject,
|
||||
"blocked_by": [
|
||||
{"name": c.get("name") or "", "comment": c.get("comment") or ""}
|
||||
for c in open_constraints
|
||||
],
|
||||
})
|
||||
|
||||
try:
|
||||
est_hours += float(data.get("hours") or 0)
|
||||
except (TypeError, ValueError):
|
||||
pass
|
||||
try:
|
||||
actual_hours += float(data.get("actualHrs") or 0)
|
||||
except (TypeError, ValueError):
|
||||
pass
|
||||
for d in (data.get("disciplines") or ["(none)"]):
|
||||
|
||||
weight = PROGRESS_WEIGHT.get(w.status, 0.0)
|
||||
progress_sum += weight
|
||||
|
||||
disciplines = data.get("disciplines") or ["(none)"]
|
||||
for d in disciplines:
|
||||
by_discipline[d] = by_discipline.get(d, 0) + 1
|
||||
slot = prog_by_disc.setdefault(d, {"total": 0, "done": 0, "weight": 0.0})
|
||||
slot["total"] += 1
|
||||
slot["weight"] += weight
|
||||
if w.status == "Closed":
|
||||
slot["done"] += 1
|
||||
|
||||
key = _location_key(data)
|
||||
kt = tuple(key.get(d, "(unset)") for d in LOCATION_DIMENSIONS) if len(key) == len(LOCATION_DIMENSIONS) else tuple(sorted(key.items()))
|
||||
slot = loc_groups.setdefault(kt, {"key": key, "total": 0, "release_ready": 0,
|
||||
"on_hold": 0, "overdue": 0, "by_status": {}})
|
||||
slot["total"] += 1
|
||||
slot["by_status"][w.status] = slot["by_status"].get(w.status, 0) + 1
|
||||
if not blocked and w.status not in ("Closed", "Issue"):
|
||||
slot["release_ready"] += 1
|
||||
if w.status == "Issue":
|
||||
slot["on_hold"] += 1
|
||||
if is_overdue:
|
||||
slot["overdue"] += 1
|
||||
|
||||
dimensions = list(LOCATION_DIMENSIONS)
|
||||
if loc_groups:
|
||||
first = next(iter(loc_groups.values()))["key"]
|
||||
dimensions = list(first.keys())
|
||||
|
||||
return {
|
||||
"total": total, "release_ready": ready, "on_hold": on_hold,
|
||||
"total": total, "mine": mine, "release_ready": ready, "on_hold": on_hold,
|
||||
"overdue": overdue,
|
||||
"est_hours": round(est_hours), "actual_hours": round(actual_hours),
|
||||
"by_status": by_status, "by_discipline": by_discipline,
|
||||
"progress": {
|
||||
"overall_pct": round(progress_sum / total * 100) if total else 0,
|
||||
"by_discipline": [
|
||||
{"name": d, "pct": round(v["weight"] / v["total"] * 100) if v["total"] else 0,
|
||||
"done": v["done"], "total": v["total"]}
|
||||
for d, v in sorted(prog_by_disc.items())
|
||||
],
|
||||
},
|
||||
"gating": sorted(gating, key=lambda g: (g["number"] or "", g["id"])),
|
||||
"by_location": {"dimensions": dimensions, "groups": sorted(
|
||||
loc_groups.values(), key=lambda g: tuple(str(v) for v in g["key"].values()))},
|
||||
"generated_at": models.utcnow().isoformat(),
|
||||
}
|
||||
|
||||
|
||||
@app.get("/api/projects/{project_id}/summary")
|
||||
def project_summary(project_id: str, user: models.User = Depends(auth.get_current_user), db: Session = Depends(get_db)):
|
||||
"""What the launcher needs to describe a project without asking the browser
|
||||
what it remembers (B4).
|
||||
|
||||
The launcher used to read `wp_suite_sop_complete` out of localStorage, which is
|
||||
a per-browser mirror: a colleague completing the SOP on their machine left your
|
||||
card saying "Complete SOP first" with nothing to indicate the answer was stale."""
|
||||
proj = db.get(models.Project, project_id)
|
||||
if not proj:
|
||||
raise HTTPException(status_code=404, detail="Project not found")
|
||||
require_project_access(db, user, project_id)
|
||||
|
||||
sop = db.scalars(
|
||||
select(models.Sop)
|
||||
.where(models.Sop.project_id == project_id, models.Sop.complete.is_(True))
|
||||
.order_by(models.Sop.updated_at.desc())
|
||||
.limit(1)
|
||||
).first()
|
||||
|
||||
wp_total = db.scalar(
|
||||
select(func.count()).select_from(models.WorkPackage).where(
|
||||
models.WorkPackage.project_id == project_id,
|
||||
models.WorkPackage.archived_at.is_(None),
|
||||
)
|
||||
) or 0
|
||||
|
||||
return {
|
||||
"project_id": project_id,
|
||||
"project_name": proj.name,
|
||||
"sop_complete": sop is not None,
|
||||
"sop_id": sop.id if sop else None,
|
||||
"sop_name": (sop.name if sop else "") or "",
|
||||
"sop_updated_at": models._iso(sop.updated_at) if sop else None,
|
||||
"wp_total": wp_total,
|
||||
"generated_at": models.utcnow().isoformat(),
|
||||
}
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user