Phase 8: Project Management, find bar, presence and motion
ci / frontend (push) Successful in 43s
ci / server (push) Successful in 11m6s
ci / image (push) Successful in 38s
ci / e2e (push) Successful in 3m55s

The Project Management screen (/pm, view only) with Overview, Projects & units, Bottlenecks & aging, Hours vs target,
Quality & issues and Find jobs, the period picker, health per unit and per project, drill down, Excel (CSV) per table
and for all, and the PESCO production status report PDF with the controlled-copy stamp; every export logged as
"report exported". The find bar on every home screen: the full search set, the dropdowns, quick flags with Mine, the
flag kinds, Board and List with sorts, filters saved per browser; every filter applied by the server on the list and
the boards, with facets. Presence in packets with role, lock and revoke drop; the motion switch with reduced motion
honoured, card glides, glow and count-up; Back restores the scroll spot; sheet placement and the iPad layout pass. The
data book compared page by page with the old build and six layout differences fixed. Tests for the PM aggregates,
search, filters, presence and exports; Playwright scenarios for the PM screen, the find bar and presence at desktop and
iPad viewports. Docs updated; PLAN.md questions 38 to 42.

Co-Authored-By: Claude Fable 5.1 <[email protected]>
This commit is contained in:
2026-10-18 15:08:34 -06:00
co-authored by Claude Fable 5.1
parent 904437153d
commit 6b6db0230f
90 changed files with 10450 additions and 563 deletions
+76 -1
View File
@@ -8,9 +8,13 @@ stamp per mode. Nothing here reads VISUAL or M-Files."""
from __future__ import annotations
import io
import json
import os
import re
from collections.abc import Iterator
from datetime import UTC, datetime
from pathlib import Path
from typing import Any
import pytest
@@ -31,7 +35,8 @@ from qchelpers import g
from shophelpers import Shop, open_shop, run
from packetstation.pdf import databook, stamps
from packetstation.pdf.style import clean
from packetstation.pdf.doc import PAD_H, Doc
from packetstation.pdf.style import L, clean, fmt_clock_hhmm, fmt_clock_hms, round_half_up
from packetstation.settings import Settings
pytestmark = needs_pg
@@ -40,6 +45,7 @@ NOW = datetime(2026, 10, 17, 21, 4, tzinfo=UTC)
SEAL = {"hash": "ab12cd34ef56" + "0" * 52, "key_id": "k1234567", "sealed_at": NOW}
SECTIONS = [1, 2, 3, 4, 5, 6, 7]
SIG_RE = re.compile(r"stamp ([0-9a-f]{16})")
DUMP_DIR = os.environ.get("DATABOOK_DUMP_DIR") # off unless set
@pytest.fixture
@@ -61,6 +67,17 @@ def build(shop: Shop, pid: int, mode: str = "preview", **kw: Any) -> Any:
)
def dump(name: str, built: Any, pkt: dict[str, Any]) -> None:
"""With DATABOOK_DUMP_DIR set to a folder, keep the book and the packet read there (for the side by side
comparison with the old build's data book). Off by default."""
out = DUMP_DIR
if not out:
return
Path(out).mkdir(parents=True, exist_ok=True)
(Path(out) / f"{name}.pdf").write_bytes(built.bytes)
(Path(out) / f"{name}.json").write_text(json.dumps({"packet": pkt, "index": built.index}, default=str, indent=1))
def index(built: Any) -> list[dict[str, Any]]:
keys = ("section", "doc", "ref", "rev", "page", "warn", "count")
return [{k: g(r, k) for k in keys} for r in built.index]
@@ -74,6 +91,7 @@ def test_data_book_preview(shop: Shop) -> None:
pkt = rich_complete(shop)
pid = pkt["id"]
built = build(shop, pid)
dump("preview", built, pkt)
data: bytes = built.bytes
assert data[:5] == b"%PDF-"
reader = pdf_reader(data)
@@ -141,6 +159,17 @@ def test_data_book_preview(shop: Shop) -> None:
assert heat in trace, heat
assert "open items" in trace.lower() and "item 3" in trace
# phase 8 comparison with the old build's book (each line one fix)
assert '(9" x 11")' in index_text # a letter sheet is 8.5 in wide: Math.round gives 9
assert re.search(r"\b\d\d:\d\d WELD TIME", flat(texts[0])), flat(texts[0]) # HH:MM as the old cover
weld_page = next(r for r in rows if str(r["doc"]).startswith("Weld log"))["page"]
assert re.search(r"Time recorded \d\d:\d\d:\d\d \(target 4\.5 h\)", flat(texts[int(weld_page) - 1]))
qc_page = next(r for r in rows if r["section"] == 4)["page"]
qc_lines = texts[int(qc_page) - 1].splitlines()
assert "AM" not in qc_lines and "PM" not in qc_lines, qc_lines # the date and time stay on one line
hist_page = next(r for r in rows if r["section"] == 7)["page"]
assert "CO-1 " in flat(texts[int(hist_page) - 1]) and "documents changed" in flat(texts[int(hist_page) - 1])
# metadata
meta = reader.metadata
assert meta.title == f"{pkt['unit']['project']['number']} Lot 001 Manufacturing Data Book - Spool 7 outlet"
@@ -199,3 +228,49 @@ def test_data_book_with_an_itp_document_has_no_itp_warning(shop: Shop) -> None:
s5 = [r for r in rows if r["section"] == 5]
assert s5[0]["doc"].startswith("ITP"), s5
assert not any(r["doc"] == "No signed ITP filed for this unit" for r in s5)
# ---------------------------------------------------------------- phase 8: against the old build's book
def test_co_summary_of_a_manual_change_without_sheets_reads_documents_changed() -> None:
co: dict[str, Any] = {"origin": "manual", "review": "approved", "reason": "Nozzle moved", "items": []}
assert databook.co_summary(co) == "documents changed" # the reason has its own column, as in coSummary()
co["items"] = [{"old_dwg": "D-5001", "old_rev": "A", "new_rev": "B"}]
assert databook.co_summary(co) == "D-5001 Rev A -> Rev B"
def test_clock_and_sheet_size_forms_of_the_old_build() -> None:
assert fmt_clock_hms(48_112) == "00:00:48" and fmt_clock_hms(3_725_000) == "01:02:05"
assert fmt_clock_hhmm(48_112) == "00:00" and fmt_clock_hhmm(37_800_000) == "10:30"
assert fmt_clock_hhmm(None) == "00:00"
assert round_half_up(612 / 72) == 9 and round_half_up(792 / 72) == 11 and round_half_up(2448 / 72) == 34
def _rows_height(rows: list[list[Any]], cols: dict[int, dict[str, Any]]) -> float:
d = Doc(("T", "S"))
return d.table(None, rows, 200.0, cols) - 200.0
def test_table_cells_wrap_where_autotable_did() -> None:
one_line = 8.3 * 1.15 + 2 * 4.2
# 79.4 pt of text in an 86 pt column: one line in the old book (autotable's 1 pt slack, jsPDF's widths)
assert abs(_rows_height([["10/18/2026 11:32 AM"]], {0: {"w": 86}}) - one_line) < 0.01
# clearly too long still wraps
assert _rows_height([["10/18/2026 11:32 AM and more"]], {0: {"w": 86}}) > one_line * 1.5
def test_table_heads_sit_left_over_centred_columns() -> None:
from pypdf import PdfReader
d = Doc(("T", "S"))
d.table(["ITEM", "QTY", "TEXT"], [["1", "2", "x"]], 200.0, {0: {"w": 30, "align": "center"}, 1: {"w": 30}})
xs: dict[str, float] = {}
def visit(text: str, cm: Any, tm: Any, font: Any, size: Any) -> None:
if text.strip() in ("ITEM", "QTY"):
xs[text.strip()] = float(tm[4])
PdfReader(io.BytesIO(d.finish())).pages[0].extract_text(visitor_text=visit)
assert xs["ITEM"] == pytest.approx(L + PAD_H, abs=0.5), xs # autotable column styles style the body only
assert xs["QTY"] == pytest.approx(L + 30 + PAD_H, abs=0.5), xs