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]>
254 lines
7.4 KiB
Python
254 lines
7.4 KiB
Python
"""Shared look and helpers for the PDF builders: colours, page geometry, text cleaning, dates and the result types.
|
||
|
||
The colours, the page frame and the address lines are the old databook.js values (DBC, L, R, CW, PESCO_ADDR).
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import math
|
||
import re
|
||
from dataclasses import dataclass, field
|
||
from datetime import UTC, date, datetime
|
||
from decimal import Decimal
|
||
from typing import Any, TypedDict
|
||
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
|
||
|
||
# ---------------------------------------------------------------- colours (RGB 0..255)
|
||
|
||
NAVY = (31, 43, 69)
|
||
TEAL = (13, 127, 146)
|
||
GREY = (107, 114, 128)
|
||
LINE = (213, 217, 223)
|
||
PANEL = (244, 246, 249)
|
||
AMBER = (138, 97, 0)
|
||
AMBER_BG = (253, 246, 230)
|
||
RED = (154, 43, 43)
|
||
GREEN = (30, 110, 70)
|
||
WHITE = (255, 255, 255)
|
||
GREEN_BG = (232, 245, 238)
|
||
RED_BG = (250, 234, 234)
|
||
REMARK = (55, 65, 81)
|
||
|
||
# ---------------------------------------------------------------- page geometry (points, letter portrait)
|
||
|
||
PAGE_W = 612.0
|
||
PAGE_H = 792.0
|
||
L = 48.0
|
||
R = 564.0
|
||
CW = R - L
|
||
|
||
PESCO_ADDR = ("5680 Highway 64", "Farmington, NM 87401", "505.327.2222 • pescoinc.biz")
|
||
PESCO_NAME = "PESCO - Process Equipment & Service Company, Inc."
|
||
|
||
# ---------------------------------------------------------------- results
|
||
|
||
|
||
class PdfError(Exception):
|
||
"""A PDF could not be assembled. Per-part problems never raise this; they become warnings."""
|
||
|
||
|
||
class NothingToPrint(PdfError):
|
||
"""The print kind has nothing to print for this packet, or the sheet or document is not one of its own."""
|
||
|
||
|
||
class WhoRef(TypedDict):
|
||
"""Who builds the PDF: the user id, the display name and the employee number (text, may be None)."""
|
||
|
||
id: str | None
|
||
display_name: str | None
|
||
emp_no: str | None
|
||
|
||
|
||
class IndexRow(TypedDict):
|
||
"""One row of the data book index. section is 1..7; page is the first page number or None."""
|
||
|
||
section: int
|
||
doc: str
|
||
ref: str | None
|
||
rev: str | None
|
||
page: int | None
|
||
warn: bool
|
||
count: int
|
||
|
||
|
||
@dataclass
|
||
class Built:
|
||
"""A built PDF. `stamp` is the stamp line as printed, `stamp_sig` the 16 hex signed into it, `what` the label."""
|
||
|
||
bytes: bytes
|
||
pages: int
|
||
index: list[IndexRow] = field(default_factory=list)
|
||
warnings: list[str] = field(default_factory=list)
|
||
stamp: str = ""
|
||
stamp_sig: str = ""
|
||
what: str = ""
|
||
|
||
|
||
# ---------------------------------------------------------------- text
|
||
|
||
_MAP = str.maketrans(
|
||
{
|
||
"‘": "'",
|
||
"’": "'",
|
||
"“": '"',
|
||
"”": '"',
|
||
"–": "-",
|
||
"—": "-",
|
||
"→": "->",
|
||
"\t": " ",
|
||
}
|
||
)
|
||
_BULLET = "•"
|
||
_DROP = re.compile(r"[\x00-\x09\x0b-\x1f\x7f-\x9f]")
|
||
|
||
|
||
def clean(text: Any) -> str:
|
||
"""The old clean(): curly quotes to straight, dashes to "-", arrows to "->". Anything outside Latin-1 is
|
||
dropped except the bullet. Control characters are dropped too; a newline stays (it breaks a table cell)."""
|
||
s = "" if text is None else str(text)
|
||
s = s.replace("\r\n", "\n").replace("\r", "\n").translate(_MAP)
|
||
s = "".join(ch for ch in s if ord(ch) <= 0xFF or ch == _BULLET)
|
||
return _DROP.sub("", s)
|
||
|
||
|
||
def ascii_only(text: Any, repl: str = "") -> str:
|
||
"""Printable ASCII only: everything else becomes `repl`."""
|
||
return re.sub(r"[^\x20-\x7e]", repl, clean(text).replace("\n", " "))
|
||
|
||
|
||
def who_text(who: WhoRef | None) -> str:
|
||
"""'Name (emp)'. A missing employee number reads 'Name (?)'."""
|
||
if not who:
|
||
return "unknown"
|
||
name = str(who.get("display_name") or "").strip() or "unknown"
|
||
emp = str(who.get("emp_no") or "").strip() or "?"
|
||
return f"{name} ({emp})"
|
||
|
||
|
||
def fmt_num(value: Any) -> str:
|
||
"""A quantity or hours without trailing zeros: 2, 2.5. Text stays as it is."""
|
||
if value is None or value == "":
|
||
return ""
|
||
if isinstance(value, bool):
|
||
return str(value)
|
||
if isinstance(value, int):
|
||
return str(value)
|
||
if isinstance(value, Decimal | float):
|
||
d = Decimal(str(value)).normalize()
|
||
out = format(d, "f")
|
||
return out.rstrip("0").rstrip(".") if "." in out else out
|
||
return str(value)
|
||
|
||
|
||
def fmt_clock(ms: Any) -> str:
|
||
"""Elapsed milliseconds as h:mm."""
|
||
try:
|
||
t = max(0, int(ms or 0) // 1000)
|
||
except (TypeError, ValueError):
|
||
t = 0
|
||
return f"{t // 3600}:{t % 3600 // 60:02d}"
|
||
|
||
|
||
def _hms(ms: Any) -> tuple[int, int, int]:
|
||
try:
|
||
t = max(0, int(ms or 0) // 1000)
|
||
except (TypeError, ValueError):
|
||
t = 0
|
||
return t // 3600, t % 3600 // 60, t % 60
|
||
|
||
|
||
def fmt_clock_hms(ms: Any) -> str:
|
||
"""Elapsed milliseconds as the old fmtClock(): HH:MM:SS, zero padded (the weld record's time recorded)."""
|
||
h, m, s = _hms(ms)
|
||
return f"{h:02d}:{m:02d}:{s:02d}"
|
||
|
||
|
||
def fmt_clock_hhmm(ms: Any) -> str:
|
||
"""Elapsed milliseconds as HH:MM, zero padded (the cover's weld time, the old fmtClock().slice(0,5))."""
|
||
h, m, _s = _hms(ms)
|
||
return f"{h:02d}:{m:02d}"
|
||
|
||
|
||
def round_half_up(x: float) -> int:
|
||
"""JavaScript's Math.round for positive numbers: 8.5 gives 9 (Python's round gives 8)."""
|
||
return math.floor(x + 0.5)
|
||
|
||
|
||
# ---------------------------------------------------------------- dates
|
||
|
||
|
||
def zone(tz: str | None) -> ZoneInfo:
|
||
try:
|
||
return ZoneInfo(tz or "America/Denver")
|
||
except (ZoneInfoNotFoundError, ValueError):
|
||
return ZoneInfo("America/Denver")
|
||
|
||
|
||
def as_dt(value: Any) -> datetime | date | None:
|
||
"""A datetime or date from a column value or an ISO string (jsonb snapshots store ISO text)."""
|
||
if value is None or value == "":
|
||
return None
|
||
if isinstance(value, datetime | date):
|
||
return value
|
||
if isinstance(value, int | float):
|
||
try:
|
||
return datetime.fromtimestamp(float(value) / 1000, UTC)
|
||
except (OverflowError, OSError, ValueError):
|
||
return None
|
||
try:
|
||
return datetime.fromisoformat(str(value).strip().replace("Z", "+00:00"))
|
||
except ValueError:
|
||
return None
|
||
|
||
|
||
def _local(value: Any, tz: str | None) -> datetime | date | None:
|
||
v = as_dt(value)
|
||
if isinstance(v, datetime) and v.tzinfo is not None:
|
||
return v.astimezone(zone(tz))
|
||
return v
|
||
|
||
|
||
def fmt_d(value: Any, tz: str | None = None, empty: str = "—") -> str:
|
||
"""M/D/YYYY in the site time zone."""
|
||
v = _local(value, tz)
|
||
if v is None:
|
||
return empty
|
||
return f"{v.month}/{v.day}/{v.year}"
|
||
|
||
|
||
def fmt_dt(value: Any, tz: str | None = None, empty: str = "—") -> str:
|
||
"""M/D/YYYY h:mm AM in the site time zone. A plain date gives the date only."""
|
||
v = _local(value, tz)
|
||
if v is None:
|
||
return empty
|
||
if not isinstance(v, datetime):
|
||
return f"{v.month}/{v.day}/{v.year}"
|
||
h = v.hour % 12 or 12
|
||
return f"{v.month}/{v.day}/{v.year} {h}:{v.minute:02d} {'AM' if v.hour < 12 else 'PM'}"
|
||
|
||
|
||
def utc_now(now: datetime | None = None) -> datetime:
|
||
n = now or datetime.now(UTC)
|
||
if n.tzinfo is None:
|
||
n = n.replace(tzinfo=UTC)
|
||
return n.astimezone(UTC)
|
||
|
||
|
||
def utc_stamp(now: datetime | None = None) -> str:
|
||
"""YYYY-MM-DD HH:MM UTC."""
|
||
return utc_now(now).strftime("%Y-%m-%d %H:%M UTC")
|
||
|
||
|
||
def stamp_at(now: datetime | None = None) -> str:
|
||
"""The minute a stamp names, as signed into the stamp signature: YYYY-MM-DDTHH:MMZ."""
|
||
return utc_now(now).strftime("%Y-%m-%dT%H:%MZ")
|
||
|
||
|
||
def pdf_date(now: datetime | None = None) -> str:
|
||
"""A PDF date string: D:YYYYMMDDHHmmSSZ."""
|
||
return utc_now(now).strftime("D:%Y%m%d%H%M%SZ")
|
||
|
||
|
||
def site_tz(settings: Any) -> str:
|
||
return str(getattr(settings, "site_tz", None) or "America/Denver")
|