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
+2 -1
View File
@@ -1,4 +1,5 @@
"""PDF output: the data book, the job traveler, the ITP, the printed copies, their stamps and metadata.
"""PDF output: the data book, the job traveler, the ITP, the printed copies, the PM production status report
(pmreport), their stamps and metadata.
Pure builders: they read through the connection they are given and the stored asset files, and return bytes. They
never write to the database and never log a print; the endpoints and the filing pipeline do that.
+12 -6
View File
@@ -45,10 +45,12 @@ from .style import (
R,
WhoRef,
clean,
fmt_clock,
fmt_clock_hhmm,
fmt_clock_hms,
fmt_d,
fmt_dt,
fmt_num,
round_half_up,
site_tz,
utc_now,
)
@@ -164,7 +166,7 @@ def co_summary(co: Mapping[str, Any]) -> str:
added = sum(1 for i in items if i.get("added"))
if added:
lines.append(f"{added} sheet{'s' if added > 1 else ''} added")
return ", ".join(lines) or str(co.get("reason") or "") or "documents changed"
return ", ".join(lines) or "documents changed" # the old coSummary(): the reason has its own column
class Book:
@@ -268,7 +270,7 @@ class Book:
w, h = float(page.mediabox.width), float(page.mediabox.height)
if int(page.get("/Rotate", 0) or 0) % 180 == 90:
w, h = h, w
return f'{round(w / 72)}" x {round(h / 72)}"'
return f'{round_half_up(w / 72)}" x {round_half_up(h / 72)}"'
except Exception as e: # an unreadable sheet is reported in section 1; try the next one
log.debug("sheet size not read: %s", type(e).__name__)
continue
@@ -428,6 +430,10 @@ class Book:
recs = list(closed)
if p.get("stage"):
recs.append(dict(p["stage"], current=True))
elif recs:
# Q.C. accept closes the stage here; the old build kept it as the packet's live record, which shows
# the work order target beside the time. The last closed stage plays that part.
recs[-1]["last"] = True
if not recs:
recs.append(
{
@@ -483,8 +489,8 @@ class Book:
stamp = welder.get("stamp") if isinstance(welder, Mapping) else None
wname = parts.name_of(self.users, welder) if welder else ""
clock = rec.get("clock") or {}
time = fmt_clock(clock.get("elapsed_ms"))
if rec.get("current"):
time = fmt_clock_hms(clock.get("elapsed_ms"))
if rec.get("current") or rec.get("last"):
time += f" (target {fmt_num(p.get('target_hrs') or 0)} h)"
rows: list[tuple[str, Any]] = [
("Department", name),
@@ -869,7 +875,7 @@ class Book:
(str(len(welds)), "Welds mapped"),
(f"{traced}/{len(bom)}", "BOM heats traced"),
(str(q.get("result") or "open").upper(), "Q.C. result"),
(fmt_clock(elapsed), "Weld time (h:mm)"),
(fmt_clock_hhmm(elapsed), "Weld time (h:mm)"),
]
kw = (CW - 3 * 16) / 4
for i, (n, label) in enumerate(figures):
+8 -4
View File
@@ -31,6 +31,11 @@ SPACE_LIMIT = 740.0
LINE_FACTOR = 1.15
PAD_V = 4.2
PAD_H = 4.0
# The old tables wrapped cells with jsPDF's splitTextToSize. autotable gives it the cell width less the padding plus
# 1 pt, and jsPDF's Helvetica widths run about 0.8 % narrower than the AFM widths reportlab measures with. Both are
# kept here so a cell breaks where the old book broke it ("10/18/2026 11:32 AM" stays on one line in 86 pt).
WRAP_SLACK = 1.0
JSPDF_WIDTH = 0.992
def rgb(c: Sequence[int]) -> Color:
@@ -348,7 +353,8 @@ class Doc:
) -> tuple[list[str], str, float]:
font = self._font(cell, spec)
sz = float(cell.get("size", spec.get("size", size)))
return split_lines(cell.get("content"), font, sz, width - 2 * PAD_H), font, sz
room = (width - 2 * PAD_H + WRAP_SLACK) / JSPDF_WIDTH
return split_lines(cell.get("content"), font, sz, room), font, sz
def _row_height(
self, row: Sequence[Any], widths: Sequence[float], cols: Mapping[int, Mapping[str, Any]], size: float
@@ -372,9 +378,7 @@ class Doc:
) -> None:
c = self.c
for col, span, cell in self._cells(row):
spec = {} if head else cols.get(col, {})
if head and col in cols and cols[col].get("align"):
spec = {"align": cols[col]["align"]}
spec = {} if head else cols.get(col, {}) # autotable column styles style the body only: heads sit left
x = L + sum(widths[:col])
w = sum(widths[col : col + span])
if cell.get("fill") is not None:
+444
View File
@@ -0,0 +1,444 @@
"""The PESCO production status report: the old pmPdf, report format C (dark).
Letter, portrait: a cover with the headline numbers, PART 01 SUMMARY (the plain-words lines and the pipeline bars),
PART 02 PROJECTS (every unit), PART 03 FLOW (the aging tables), PART 04 LABOR (the hours tables) and PART 05 ISSUES
(the quality tables). Every page carries the PESCO foot, CONFIDENTIAL & PROPRIETARY and its number, and the
controlled-copy stamp. Everything comes from domain/pm.py; nothing here reads VISUAL or writes the database.
Drawn on a reportlab Canvas directly. Every y here is measured from the top of the page, as jsPDF did. Text always
goes through `clean`, and cells are wrapped with simpleSplit on plain strings, so no markup is interpreted.
"""
from __future__ import annotations
import contextlib
import io
from collections.abc import Mapping, Sequence
from dataclasses import dataclass
from datetime import UTC, datetime
from functools import lru_cache
from pathlib import Path
from typing import Any
from reportlab.lib.colors import Color
from reportlab.lib.utils import ImageReader, simpleSplit
from reportlab.pdfbase.pdfmetrics import stringWidth
from reportlab.pdfgen.canvas import Canvas
from ..domain import pm
from . import stamps
from .style import WhoRef, clean, who_text
W, H, M = 612.0, 792.0, 40.0
CW = W - 2 * M
LOGO_PATH = Path(__file__).parent / "assets" / "logo_color.png"
# The old pmPdf colour map.
C: dict[str, tuple[int, int, int]] = {
"bg": (13, 22, 27),
"panel": (26, 37, 43),
"alt": (22, 41, 49),
"cyan": (73, 192, 222),
"pink": (229, 88, 159),
"amber": (231, 179, 60),
"white": (255, 255, 255),
"near": (239, 245, 248),
"body": (169, 186, 195),
"muted": (119, 135, 143),
"sub": (191, 228, 240),
"line": (38, 54, 62),
"zebra": (17, 29, 35),
}
TABLE_TOP = 70.0
TABLE_BOTTOM = 40.0
BODY_SIZE = 7.2
HEAD_SIZE = 6.8
PAD = 3.2
LINE_FACTOR = 1.15
@dataclass(frozen=True)
class Built:
pdf: bytes
pages: int
stamp: str
stamp_sig: str | None
def rgb(c: Sequence[int]) -> Color:
return Color(c[0] / 255, c[1] / 255, c[2] / 255)
def font_of(bold: bool = False) -> str:
return "Helvetica-Bold" if bold else "Helvetica"
def split_lines(text: Any, font: str, size: float, width: float) -> list[str]:
"""The text wrapped to `width`, keeping its own line breaks. An empty text is one empty line."""
out: list[str] = []
for part in clean(text).split("\n"):
out += simpleSplit(part, font, size, max(width, 1.0)) or [""]
return out or [""]
@lru_cache(maxsize=1)
def _logo() -> ImageReader | None:
try:
return ImageReader(io.BytesIO(LOGO_PATH.read_bytes()))
except OSError:
return None
class Pages:
"""The report canvas. Pages are numbered as they start; each gets the dark background and the foot."""
def __init__(self) -> None:
self.buf = io.BytesIO()
self.c = Canvas(self.buf, pagesize=(W, H), pageCompression=1)
self.page = 1
self.bg()
# ------------------------------------------------------------ primitives
def Y(self, y: float) -> float:
return H - y
def fill(self, color: Sequence[int]) -> None:
self.c.setFillColor(rgb(color))
def rect(self, x: float, y: float, w: float, h: float, color: Sequence[int], *, radius: float = 0) -> None:
self.fill(color)
if radius:
self.c.roundRect(x, self.Y(y + h), w, h, radius, stroke=0, fill=1)
else:
self.c.rect(x, self.Y(y + h), w, h, stroke=0, fill=1)
def text(
self,
s: Any,
x: float,
y: float,
*,
size: float = 9.5,
bold: bool = False,
color: Sequence[int] = C["body"],
align: str = "left",
max_width: float | None = None,
cs: float = 0.0,
) -> int:
"""jsPDF's text(): `y` is the first baseline; with max_width the text wraps. Returns the line count."""
font = font_of(bold)
lines = split_lines(s, font, size, max_width) if max_width else clean(s).split("\n")
self.fill(color)
for i, ln in enumerate(lines):
yy = self.Y(y + i * size * LINE_FACTOR)
width = stringWidth(ln, font, size) + cs * len(ln)
xx = x - width if align == "right" else x - width / 2 if align == "center" else x
t = self.c.beginText(xx, yy)
t.setFont(font, size)
t.setCharSpace(cs)
t.textOut(ln)
self.c.drawText(t)
return len(lines)
# ------------------------------------------------------------ pages
def bg(self) -> None:
self.rect(0, 0, W, H, C["bg"])
def foot(self) -> None:
self.text("PESCO · PROCESS EQUIPMENT & SERVICE COMPANY, INC.", M, H - 22, size=7, color=C["muted"])
self.text("CONFIDENTIAL & PROPRIETARY", W / 2, H - 22, size=7, color=C["amber"], align="center", bold=True)
self.text(str(self.page), W - M, H - 22, size=7, color=C["muted"], align="right")
def new_page(self) -> None:
self.c.showPage()
self.page += 1
self.bg()
self.foot()
def body(self, eyebrow: str, title: str, stand: str) -> float:
"""A body page: the eyebrow, "Production status report", a rule, the title and the stand-first."""
self.new_page()
self.text(eyebrow, M, 44, size=7.6, color=C["cyan"], bold=True, cs=1.2)
self.text("Production status report", W - M, 44, size=7.6, color=C["muted"], align="right")
self.c.setStrokeColor(rgb(C["line"]))
self.c.setLineWidth(0.6)
self.c.line(M, self.Y(52), W - M, self.Y(52))
self.text(title, M, 82, size=21, bold=True, color=C["near"])
if stand:
self.text(stand, M, 100, size=9.5, color=C["body"], max_width=CW)
return 118.0 if stand else 100.0
def finish(self) -> bytes:
self.c.showPage()
self.c.save()
return self.buf.getvalue()
# ------------------------------------------------------------ tables
def _widths(self, head: Sequence[str], body: Sequence[Sequence[str]]) -> list[float]:
n = len(head)
natural = [2 * PAD + 10.0] * n
minw = [2 * PAD + 10.0] * n
rows: list[tuple[Sequence[str], str, float]] = [(head, font_of(True), HEAD_SIZE)]
rows += [(r, font_of(), BODY_SIZE) for r in body]
for cells, font, size in rows:
for i in range(min(n, len(cells))):
for part in clean(cells[i]).split("\n"):
natural[i] = max(natural[i], stringWidth(part, font, size) + 2 * PAD)
for word in part.split():
minw[i] = max(minw[i], min(stringWidth(word, font, size) + 2 * PAD, CW / 3))
total = sum(natural)
if total <= CW:
return [w * CW / total for w in natural]
base = sum(minw)
if base >= CW:
return [w * CW / base for w in minw]
extra = [natural[i] - minw[i] for i in range(n)]
spare = sum(extra) or 1.0
return [minw[i] + (CW - base) * extra[i] / spare for i in range(n)]
def _row_height(self, cells: Sequence[str], widths: Sequence[float], font: str, size: float) -> float:
lines = max(
len(split_lines(cells[i] if i < len(cells) else "", font, size, widths[i] - 2 * PAD))
for i in range(len(widths))
)
return lines * size * LINE_FACTOR + 2 * PAD
def _draw_row(
self,
cells: Sequence[str],
widths: Sequence[float],
y: float,
h: float,
*,
font: str,
size: float,
color: Sequence[int],
fill: Sequence[int],
) -> None:
x = M
self.c.setStrokeColor(rgb(C["line"]))
self.c.setLineWidth(0.4)
for i, w in enumerate(widths):
self.fill(fill)
self.c.rect(x, self.Y(y + h), w, h, stroke=1, fill=1)
lines = split_lines(cells[i] if i < len(cells) else "", font, size, w - 2 * PAD)
self.fill(color)
self.c.setFont(font, size)
for j, ln in enumerate(lines):
self.c.drawString(x + PAD, self.Y(y + PAD + j * size * LINE_FACTOR + size * 0.85), ln)
x += w
def table(self, t: Mapping[str, Any], start_y: float) -> float:
"""The old autoTable look: the head in cyan on the alt fill, zebra rows, flagged rows in pink, project rows
bold. The head repeats on every page. Returns the y under the table."""
head = [str(h) for h in t["head"]]
rows = list(t["rows"]) or [{"cells": ["Nothing in scope.", *[""] * (len(head) - 1)]}]
body = [[str(c) for c in r["cells"]] for r in rows]
widths = self._widths(head, body)
limit = H - TABLE_BOTTOM
is_projects = t.get("key") == "projects"
head_h = self._row_height(head, widths, font_of(True), HEAD_SIZE)
def draw_head(yy: float) -> float:
self._draw_row(head, widths, yy, head_h, font=font_of(True), size=HEAD_SIZE, color=C["cyan"], fill=C["alt"])
return yy + head_h
y = start_y
first = self._row_height(body[0], widths, font_of(), BODY_SIZE)
if y + head_h + first > limit:
self.new_page()
y = TABLE_TOP
y = draw_head(y)
for i, (r, cells) in enumerate(zip(rows, body, strict=True)):
bold = is_projects and r.get("lvl") == 0 and r.get("project_id") is not None
font = font_of(bold)
h = self._row_height(cells, widths, font, BODY_SIZE)
if y + h > limit:
self.new_page()
y = draw_head(TABLE_TOP)
color = C["pink"] if r.get("flag") else C["near"] if bold else C["body"]
fill = C["zebra"] if i % 2 == 1 else C["bg"]
self._draw_row(cells, widths, y, h, font=font, size=BODY_SIZE, color=color, fill=fill)
y += h
return y
def section(self, eyebrow: str, title: str, stand: str, tables: Sequence[Mapping[str, Any]]) -> None:
y = self.body(eyebrow, title, stand)
for t in tables:
if y > H - 140:
y = self.body(eyebrow, title + " (continued)", "")
self.text(t["title"], M, y + 6, size=11, bold=True, color=C["near"])
if t.get("note"):
self.text(t["note"], M, y + 20, size=7.8, color=C["muted"], max_width=CW)
y = self.table(t, y + (30 if t.get("note") else 16)) + 22
# ---------------------------------------------------------------- the pages
def _cover(r: Pages, rep: pm.Report, kpis: list[dict[str, Any]], who: WhoRef | None) -> None:
for i in range(60):
t = i / 60
r.rect(0, i * 4.4, W, 4.6, (round(15 + 10 * (1 - t)), round(48 + 20 * (1 - t)), round(58 + 22 * (1 - t))))
logo = _logo()
if logo is not None:
# A logo that does not draw is left out, as the old build did.
with contextlib.suppress(Exception):
r.c.drawImage(logo, M, r.Y(46 + 34), width=150, height=34, mask="auto", preserveAspectRatio=True)
r.text("PRODUCTION STATUS REPORT", M, 150, size=30, bold=True, color=C["white"])
r.text(f"Packet Station · {rep.scope_label()}", M, 176, size=12, color=C["sub"], max_width=CW)
for i in range(100):
t = i / 100
col = (round(73 + (229 - 73) * t), round(192 + (88 - 192) * t), round(222 + (159 - 222) * t))
r.rect(M + i * CW / 100, 196, CW / 100 + 0.5, 3, col)
cw = (CW - 3 * 10) / 4
for i, k in enumerate(kpis[:8]):
cx = M + (i % 4) * (cw + 10)
cy = 300 + (i // 4) * 96
hot = bool(k["alert"]) and str(k["n"]) != "0"
r.rect(cx, cy, cw, 84, C["panel"], radius=6)
r.rect(cx, cy, cw, 3, C["pink"] if hot else C["cyan"])
r.text(str(k["n"]), cx + 12, cy + 44, size=30, bold=True, color=C["white"])
r.text(
str(k["label"]).upper(),
cx + 12,
cy + 68,
size=7.2,
bold=True,
color=C["pink"] if hot else C["cyan"],
max_width=cw - 20,
)
meta = [
("Prepared by", str(who["display_name"]) if who and who.get("display_name") else who_text(who)),
("Source", "Packet Station live data"),
("History period", rep.period_label),
("Issued", pm.fmt_dt(rep.now, rep.tz)),
]
for i, (a, b) in enumerate(meta):
x = M + i * (CW / 4)
r.text(a.upper(), x, 560, size=7, color=C["muted"], bold=True, cs=0.6)
r.text(b, x, 575, size=9.5, color=C["near"], max_width=CW / 4 - 8)
r.text(
"Live snapshot of the shop's job packets at the time of issue. Figures follow the scope shown above.",
M,
610,
size=8.5,
color=C["muted"],
max_width=CW,
)
r.foot()
def _summary(r: Pages, lines: list[str], pipe: list[dict[str, Any]]) -> None:
y = r.body("PART 01 · SUMMARY", "Where things stand", "The short version first; the detail follows.")
for ln in lines:
r.fill(C["cyan"])
r.c.circle(M + 4, r.Y(y - 3), 2, stroke=0, fill=1)
n = r.text(ln, M + 14, y, size=10, color=C["near"], max_width=CW - 14)
y += n * 13 + 6
y += 10
top = max(1, *(s["count"] for s in pipe))
slow = next((s for s in pipe if s["slowest"]), None)
r.rect(M, y, CW, 34 + len(pipe) * 22, C["alt"], radius=6)
r.text("Open packets by step", M + 14, y + 20, size=10.5, bold=True, color=C["white"])
r.text(
"bar = packets in the step · label = average time there",
W - M - 14,
y + 20,
size=7.5,
color=C["muted"],
align="right",
)
y += 36
track = CW - 190 - 110
for s in pipe:
is_slow = s is slow
r.text(s["label"], M + 14, y + 9, size=8.2, color=C["body"])
bx = M + 190
r.rect(bx, y + 1, track, 10, C["line"])
if s["count"]:
r.rect(bx, y + 1, max(2.0, track * s["count"] / top), 10, C["pink"] if is_slow else C["cyan"])
label = f"{s['count']}" + (f" · avg {pm.fmt_days(s['avg_s'])}" if s["count"] else "")
label += " · slowest" if is_slow else ""
r.text(label, W - M - 104, y + 9, size=8, color=C["pink"] if is_slow else C["near"], bold=is_slow)
y += 22
y += 12
r.rect(M, y, CW, 40, C["panel"])
r.rect(M, y, 3, 40, C["cyan"])
r.text("What this says:", M + 12, y + 16, size=9.5, bold=True, color=C["white"])
says = (
f'"{slow["label"]}" is where packets wait longest right now'
f" (average {pm.fmt_days(slow['avg_s'])}). Start there."
if slow
else "Nothing is waiting in any step."
)
r.text(says, M + 12, y + 30, size=9, color=C["body"], max_width=CW - 24)
def build(
conn: Any,
settings: Any,
*,
scope_params: Mapping[str, Any],
who: WhoRef | None,
now: datetime | None = None,
report: pm.Report | None = None,
) -> Built:
"""The report as PDF bytes, its page count and the stamp. `scope_params` are the find bar values plus
project_id, unit_id and period; a `report` already read for the same scope is used as it is."""
now = now or datetime.now(UTC)
rep = report
if rep is None:
rep = pm.load(
conn,
None,
scope_params,
now=now,
tz=str(getattr(settings, "site_tz", None) or "America/Denver"),
period=scope_params.get("period"),
)
pipe = pm.pipeline(rep)
kpis = pm.kpis(rep)
lines = pm.summary(rep, kpis, pipe)
r = Pages()
_cover(r, rep, kpis, who)
_summary(r, lines, pipe)
r.section(
"PART 02 · PROJECTS",
"Projects and units",
"Every unit's packets by step, how much is done, and whether it's on track.",
[pm.projects_table(rep, expand_all=True)],
)
r.section(
"PART 03 · FLOW",
"Bottlenecks and aging",
"How long packets sit in each step, oldest first.",
pm.aging_tables(rep, pipe),
)
r.section(
"PART 04 · LABOR",
"Hours vs target",
"Station clock hours and VISUAL ticket hours against work-order target hours.",
pm.hours_tables(rep),
)
r.section(
"PART 05 · ISSUES",
"Quality and issues",
"Q.C. rejects, shorts, change orders, holds and send-backs.",
pm.quality_tables(rep),
)
raw = r.finish()
pages = r.page
text = stamps.controlled_stamp(who, now, mode="report")
line, sig = stamps.signed_stamp(settings, text, packet_id=None, kind="report", seal_hash_hex=None, who=who, now=now)
out = stamps.apply_stamp(raw, line)
out = stamps.set_metadata(
out, "Production status report", rep.scope_label(), "Packet Station report", "Packet Station", now=now
)
return Built(pdf=out, pages=pages, stamp=line, stamp_sig=sig or None)
+4 -1
View File
@@ -35,7 +35,10 @@ Draw = Callable[[Canvas, int, float, float], None]
def controlled_stamp(who: WhoRef | None, now: datetime | None, *, mode: str, seal_hash_hex: str | None = None) -> str:
"""The controlled-copy line. mode "filed" names the seal (first 12 hex), "preview" says not yet filed, "print"
says printed by (and the seal or not yet filed), "traveler" carries no seal part."""
says printed by (and the seal or not yet filed), "traveler" carries no seal part, "report" (the PM production
status report) names Packet Station and carries no seal part."""
if mode == "report":
return ascii_only(f"Controlled copy - Packet Station - printed by {who_text(who)} {utc_stamp(now)}")
verb = "printed" if mode == "print" else "exported"
text = f"Controlled copy - {verb} by {who_text(who)} {utc_stamp(now)}"
if mode != "traveler":
+26
View File
@@ -5,6 +5,7 @@ The colours, the page frame and the address lines are the old databook.js values
from __future__ import annotations
import math
import re
from dataclasses import dataclass, field
from datetime import UTC, date, datetime
@@ -148,6 +149,31 @@ def fmt_clock(ms: Any) -> str:
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