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
+199
View File
@@ -0,0 +1,199 @@
import { act, renderHook } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { memoryStorage } from "../screens/supervisor/memoryStorage";
import { makeCard } from "../test/packets";
import {
countActive,
countAdvanced,
FLT_DEFAULT,
FLT_FLAGKIND,
FLT_FLAGS,
FLT_KEY,
FLT_SORT,
FLT_STATUS,
kpiActive,
kpiFilter,
loadFilters,
loadPick,
PROJ_KEY,
saveFilters,
savePick,
sorter,
toQuery,
uniqueCards,
useFilters,
type Filters,
} from "./filters";
const PROJECT = { id: 7, number: "L999", name: "Spools" };
const UNIT = { id: 12, number: "L999002", lot: "002", name: null, due: null, due_effective: null, status: "open" as const, version: 1 };
const f = (over: Partial<Filters> = {}): Filters => ({ ...FLT_DEFAULT, ...over });
beforeEach(() => {
vi.stubGlobal("localStorage", memoryStorage());
});
describe("the vocabulary", () => {
it("keeps the old labels", () => {
expect(FLT_STATUS.map(([v]) => v)).toEqual(["draft", "warehouse", "ready", "shop", "station", "review", "qc", "complete", "filed", "open"]);
expect(FLT_FLAGS.map(([, l]) => l)).toEqual(["Change order open", "Shorts open", "Project overdue", "Due within 7 days", "Mine"]);
expect(FLT_FLAGKIND.map(([v]) => v)).toEqual(["any", "rev", "unconf", "hand", "none", "vault", "read", "wait", "no"]);
expect(FLT_SORT).toEqual([
["updated", "Last updated"],
["project", "Project / lot"],
["due", "Due date"],
["status", "Status"],
["dept", "Department"],
]);
});
});
describe("load and save", () => {
it("saves everything but the search text, and loads it back with q empty", () => {
saveFilters(f({ q: "heat 4471", dept: "03", flags: ["short"], views: { clerical: "list" }, sort: "due", more: true }));
const raw = JSON.parse(window.localStorage.getItem(FLT_KEY) ?? "{}") as Record<string, unknown>;
expect(raw).not.toHaveProperty("q");
expect(raw).toMatchObject({ dept: "03", flags: ["short"], views: { clerical: "list" }, sort: "due", more: true });
expect(loadFilters()).toEqual(f({ dept: "03", flags: ["short"], views: { clerical: "list" }, sort: "due", more: true }));
});
it("falls back to the default on bad or missing values", () => {
window.localStorage.setItem(FLT_KEY, JSON.stringify({ q: "x", sort: "nope", flags: ["short", "bogus", 3], views: { qc: "grid", stall: "list" }, dept: 5 }));
expect(loadFilters()).toEqual(f({ flags: ["short"], views: { stall: "list" } }));
window.localStorage.setItem(FLT_KEY, "{not json");
expect(loadFilters()).toEqual(FLT_DEFAULT);
});
it("keeps the project pick in ps.proj", () => {
expect(loadPick()).toEqual({ project: null, unit: null });
savePick({ project: PROJECT, unit: UNIT });
expect(JSON.parse(window.localStorage.getItem(PROJ_KEY) ?? "null")).toMatchObject({ project_id: 7, unit_id: 12 });
expect(loadPick()).toEqual({ project: PROJECT, unit: UNIT });
savePick({ project: null, unit: null });
expect(window.localStorage.getItem(PROJ_KEY)).toBeNull();
});
});
describe("counts", () => {
it("counts the advanced selects and everything active", () => {
const x = f({ q: " weld ", dept: "03", status: "ready", flag: "rev", flags: ["co", "mine"] });
expect(countAdvanced(x)).toBe(3);
expect(countActive(x, true)).toBe(1 + 3 + 2 + 1);
expect(countActive(FLT_DEFAULT, false)).toBe(0);
});
it("leaves out the parts a screen hides", () => {
const x = f({ dept: "03", stall: "4", who: "u-1", flag: "rev", status: "qc", flags: ["mine", "short"] });
expect(countAdvanced(x, "stall")).toBe(1);
expect(countActive(x, false, "stall")).toBe(2);
expect(countAdvanced(x, "supervisor")).toBe(4);
});
});
describe("toQuery", () => {
it("sends only what is set, the pick last", () => {
expect(toQuery(FLT_DEFAULT, { project: null, unit: null }, { screen: "clerical" })).toEqual({});
const q = toQuery(
f({ q: " lot 52 ", dept: "03", status: "station", stall: "4", who: "u-1", flag: "rev", flags: ["mine", "co"], sort: "project" }),
{ project: PROJECT, unit: UNIT },
{ screen: "clerical" },
);
expect(q).toEqual({
q: "lot 52",
dept: "03",
status: "station",
station_id: 4,
welder_id: "u-1",
flag: "rev",
flags: "co,mine",
mine_screen: "clerical",
sort: "project",
unit_id: 12,
});
expect(toQuery(f(), { project: PROJECT, unit: null }, { screen: "qc" })).toEqual({ project_id: 7 });
});
it("the stall sends no department, station, worker, flag kind or project, and its Mine names the station", () => {
const q = toQuery(f({ dept: "03", stall: "4", who: "u-1", flag: "rev", flags: ["mine", "short"], status: "station" }), { project: PROJECT, unit: null }, {
screen: "stall",
stationId: 9,
});
expect(q).toEqual({ status: "station", flags: "short", project_id: 7 });
expect(toQuery(f({ flags: ["mine"] }), { project: null, unit: null }, { screen: "warehouse" })).toEqual({ flags: "mine", mine_screen: "warehouse" });
expect(toQuery(f({ flag: "rev" }), { project: null, unit: null }, { screen: "supervisor" })).toEqual({});
});
});
describe("KPI taps", () => {
it("toggles a status and opens the advanced row", () => {
const on = { ...FLT_DEFAULT, ...kpiFilter(FLT_DEFAULT, "status", "ready") };
expect(on).toMatchObject({ status: "ready", more: true });
expect(kpiActive(on, "status", "ready")).toBe(true);
const off = { ...on, ...kpiFilter(on, "status", "ready") };
expect(off.status).toBe("all");
expect(kpiActive(off, "status", "ready")).toBe(false);
});
it("a quick flag toggles the chip; a flag kind toggles the Flag select", () => {
const short = { ...FLT_DEFAULT, ...kpiFilter(FLT_DEFAULT, "flag", "short") };
expect(short.flags).toEqual(["short"]);
expect(kpiActive(short, "flag", "short")).toBe(true);
expect({ ...short, ...kpiFilter(short, "flag", "short") }.flags).toEqual([]);
const any = { ...FLT_DEFAULT, ...kpiFilter(FLT_DEFAULT, "flag", "any") };
expect(any).toMatchObject({ flag: "any", more: true, flags: [] });
expect(kpiActive(any, "flag", "any")).toBe(true);
expect({ ...any, ...kpiFilter(any, "flag", "any") }.flag).toBe("all");
});
});
describe("useFilters", () => {
it("debounces q into the query, saves without q, and keeps the view per screen", () => {
vi.useFakeTimers();
try {
const { result } = renderHook(() => useFilters("supervisor"));
expect(result.current.query).toEqual({});
act(() => result.current.set({ q: "elbow" }));
expect(result.current.query).toEqual({});
act(() => {
vi.advanceTimersByTime(260);
});
expect(result.current.query).toEqual({ q: "elbow" });
act(() => result.current.setView("list"));
expect(result.current.view).toBe("list");
const saved = JSON.parse(window.localStorage.getItem(FLT_KEY) ?? "{}") as Record<string, unknown>;
expect(saved).toMatchObject({ views: { supervisor: "list" } });
expect(JSON.stringify(saved)).not.toContain("elbow");
act(() => result.current.set({ q: "" }));
expect(result.current.query).toEqual({});
} finally {
vi.useRealTimers();
}
});
it("keeps KPI numbers on the pick only, and Clear keeps the view and the sort", () => {
const { result } = renderHook(() => useFilters("clerical"));
act(() => result.current.setPick({ project: PROJECT, unit: null }));
act(() => result.current.tapKpi("status", "warehouse"));
expect(result.current.query).toEqual({ status: "warehouse", project_id: 7 });
expect(result.current.scope).toEqual({ project_id: 7 });
expect(result.current.isKpiOn("status", "warehouse")).toBe(true);
expect(result.current.active).toBe(2);
act(() => result.current.set({ sort: "due", views: { clerical: "list" } }));
act(() => result.current.clear());
expect(result.current.f).toEqual(f({ sort: "due", views: { clerical: "list" } }));
expect(result.current.pick).toEqual({ project: null, unit: null });
expect(result.current.query).toEqual({ sort: "due" });
});
});
describe("list helpers", () => {
it("merges columns once each and sorts like the server", () => {
const a = makeCard({ id: 1, updated_at: "2026-10-10T00:00:00Z", project: { id: 1, number: "L10", name: null }, unit: { id: 1, number: "L10002" } });
const b = makeCard({ id: 2, updated_at: "2026-10-12T00:00:00Z", project: { id: 2, number: "L9", name: null }, unit: { id: 2, number: "L9001" }, status: "qc" });
const merged = uniqueCards([[a, b], [b]]);
expect(merged.map((p) => p.id)).toEqual([1, 2]);
expect([...merged].sort(sorter("updated")).map((p) => p.id)).toEqual([2, 1]);
expect([...merged].sort(sorter("project")).map((p) => p.id)).toEqual([2, 1]);
expect([...merged].sort(sorter("status")).map((p) => p.id)).toEqual([1, 2]);
});
});
+430
View File
@@ -0,0 +1,430 @@
// The find bar's filters on every home screen (the old filters.js), applied by the server.
// The filters are saved per browser under ps.flt without the search text. The project pick lives in ps.proj.
import { useCallback, useEffect, useMemo, useState } from "react";
import type { PacketCard, PacketListQuery, ProjectRef, Unit } from "../api/types";
import { STATUSES } from "../api/types";
import type { Screen } from "../auth/roles";
import type { UnitPick } from "../components/PickUnit";
import { nextFrame } from "./motion";
export type SortKey = "updated" | "project" | "due" | "status" | "dept";
export type ViewKind = "board" | "list";
/** The value of a select that filters nothing. */
export const ALL = "all";
/** Status words the server expands (domain/search.py FLT_STATUS). */
export const FLT_STATUS: ReadonlyArray<readonly [string, string]> = [
["draft", "Ready to release"],
["warehouse", "With warehouse (any stage)"],
["ready", "Ready to assign"],
["shop", "In the shop (ready to Q.C.)"],
["station", "At a station (assigned / working)"],
["review", "Supervisor review"],
["qc", "At Q.C."],
["complete", "Accepted / routed"],
["filed", "Filed"],
["open", "Everything not filed"],
];
/** Quick flag chips. They go to the server as flags=co,short,... */
export const FLT_FLAGS: ReadonlyArray<readonly [string, string]> = [
["co", "Change order open"],
["short", "Shorts open"],
["overdue", "Project overdue"],
["week", "Due within 7 days"],
["mine", "Mine"],
];
/** Why a draft is flagged. Goes to the server as flag=. */
export const FLT_FLAGKIND: ReadonlyArray<readonly [string, string]> = [
["any", "Flagged (any reason)"],
["rev", "Rev mismatch with M-Files"],
["unconf", "Rev not confirmed yet"],
["hand", "Made or uploaded by hand"],
["none", "Still in drafting"],
["vault", "Drawing not found in M-Files"],
["read", "Drawing couldn't be read"],
["wait", "Drawing being looked up"],
["no", "Not flagged"],
];
export const FLT_SORT: ReadonlyArray<readonly [SortKey, string]> = [
["updated", "Last updated"],
["project", "Project / lot"],
["due", "Due date"],
["status", "Status"],
["dept", "Department"],
];
export interface Filters {
/** The search text. Never saved. */
q: string;
/** A department code, or "all". */
dept: string;
/** A FLT_STATUS value, or "all". */
status: string;
/** A station id as text, or "all". */
stall: string;
/** A welder's user id, or "all". */
who: string;
/** A FLT_FLAGKIND value, or "all". */
flag: string;
/** Quick flags that are on. */
flags: string[];
/** Board or list, per screen: {clerical: "list"}. */
views: Record<string, ViewKind>;
sort: SortKey;
/** The advanced row is open. */
more: boolean;
}
export const FLT_DEFAULT: Filters = {
q: "",
dept: ALL,
status: ALL,
stall: ALL,
who: ALL,
flag: ALL,
flags: [],
views: {},
sort: "updated",
more: false,
};
export const FLT_KEY = "ps.flt";
export const PROJ_KEY = "ps.proj";
function lsGet(key: string): string | null {
try {
return window.localStorage.getItem(key);
} catch {
return null;
}
}
function lsSet(key: string, value: string | null): void {
try {
if (value === null) window.localStorage.removeItem(key);
else window.localStorage.setItem(key, value);
} catch {
// Storage blocked: the filters last for this page only.
}
}
const SORT_KEYS = new Set<string>(FLT_SORT.map(([k]) => k));
const FLAG_KEYS = new Set<string>(FLT_FLAGS.map(([k]) => k));
const str = (v: unknown, d: string) => (typeof v === "string" && v ? v : d);
/** ps.flt merged over the default. q is always empty. Bad values fall back to the default. */
export function loadFilters(): Filters {
let raw: Record<string, unknown> = {};
try {
const parsed = JSON.parse(lsGet(FLT_KEY) ?? "{}") as unknown;
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) raw = parsed as Record<string, unknown>;
} catch {
raw = {};
}
const views: Record<string, ViewKind> = {};
if (raw.views && typeof raw.views === "object") {
for (const [k, v] of Object.entries(raw.views as Record<string, unknown>)) if (v === "board" || v === "list") views[k] = v;
}
return {
q: "",
dept: str(raw.dept, ALL),
status: str(raw.status, ALL),
stall: str(raw.stall, ALL),
who: str(raw.who, ALL),
flag: str(raw.flag, ALL),
flags: Array.isArray(raw.flags) ? raw.flags.filter((x): x is string => typeof x === "string" && FLAG_KEYS.has(x)) : [],
views,
sort: typeof raw.sort === "string" && SORT_KEYS.has(raw.sort) ? (raw.sort as SortKey) : "updated",
more: raw.more === true,
};
}
/** The saved form: everything but q. */
export function savedForm(f: Filters): Omit<Filters, "q"> {
const { q: _q, ...rest } = f;
void _q;
return rest;
}
/** Saves to ps.flt without q. */
export function saveFilters(f: Filters): void {
lsSet(FLT_KEY, JSON.stringify(savedForm(f)));
}
/* ---------- the project pick (ps.proj) ---------- */
interface SavedPick {
project_id: number;
unit_id: number | null;
project: ProjectRef;
unit: Unit | null;
}
export const NO_PICK: UnitPick = { project: null, unit: null };
export function loadPick(): UnitPick {
try {
const raw = JSON.parse(lsGet(PROJ_KEY) ?? "null") as SavedPick | null;
if (!raw || typeof raw !== "object" || typeof raw.project_id !== "number" || !raw.project) return NO_PICK;
const unit = raw.unit && typeof raw.unit.id === "number" && raw.unit.id === raw.unit_id ? raw.unit : null;
return { project: { ...raw.project, id: raw.project_id }, unit };
} catch {
return NO_PICK;
}
}
export function savePick(p: UnitPick): void {
if (!p.project) {
lsSet(PROJ_KEY, null);
return;
}
const saved: SavedPick = { project_id: p.project.id, unit_id: p.unit?.id ?? null, project: p.project, unit: p.unit };
lsSet(PROJ_KEY, JSON.stringify(saved));
}
/* ---------- what each screen shows ---------- */
/** Which find bar parts a screen shows. Hidden parts never filter. */
export interface ScreenParts {
project: boolean;
dept: boolean;
stall: boolean;
who: boolean;
flag: boolean;
mine: boolean;
view: boolean;
}
export function partsFor(screen: Screen): ScreenParts {
const stall = screen === "stall";
return {
project: !stall,
dept: !stall,
stall: !stall,
who: !stall,
flag: screen === "clerical" || screen === "pm",
mine: !stall && screen !== "pm",
view: screen !== "pm",
};
}
/** The filters with the parts the screen hides set back to their default. */
export function effective(f: Filters, screen?: Screen): Filters {
if (!screen) return f;
const s = partsFor(screen);
return {
...f,
dept: s.dept ? f.dept : ALL,
stall: s.stall ? f.stall : ALL,
who: s.who ? f.who : ALL,
flag: s.flag ? f.flag : ALL,
flags: s.mine ? f.flags : f.flags.filter((x) => x !== "mine"),
};
}
/** The old fltAdvCount: the advanced selects that are set. */
export function countAdvanced(f: Filters, screen?: Screen): number {
const e = effective(f, screen);
return [e.dept, e.status, e.stall, e.who, e.flag].filter((v) => v !== ALL).length;
}
/** The old fltCountActive: everything that narrows the boards, the project pick included. */
export function countActive(f: Filters, projectPicked: boolean, screen?: Screen): number {
const e = effective(f, screen);
return (e.q.trim() ? 1 : 0) + countAdvanced(e) + e.flags.length + (projectPicked ? 1 : 0);
}
/** The project and unit part of a query. KPI numbers follow only this. */
export function pickQuery(pick: UnitPick): PacketListQuery {
if (pick.unit) return { unit_id: pick.unit.id };
if (pick.project) return { project_id: pick.project.id };
return {};
}
/** The server query for the filters and the pick. Defaults are left out. */
export function toQuery(f: Filters, pick: UnitPick, opts: { screen: Screen; stationId?: number | null }): PacketListQuery {
const e = effective(f, opts.screen);
const out: PacketListQuery = {};
const q = e.q.trim();
if (q) out.q = q;
if (e.dept !== ALL) out.dept = e.dept;
if (e.status !== ALL) out.status = e.status;
if (e.stall !== ALL && Number.isFinite(Number(e.stall))) out.station_id = Number(e.stall);
if (e.who !== ALL) out.welder_id = e.who;
if (e.flag !== ALL) out.flag = e.flag;
if (e.flags.length) {
out.flags = [...e.flags].sort((a, b) => FLT_FLAGS.findIndex(([k]) => k === a) - FLT_FLAGS.findIndex(([k]) => k === b)).join(",");
if (e.flags.includes("mine")) {
out.mine_screen = opts.screen;
if (opts.stationId) out.mine_station_id = opts.stationId;
}
}
if (e.sort !== "updated") out.sort = e.sort;
return { ...out, ...pickQuery(pick) };
}
/* ---------- KPI taps ---------- */
export type KpiKey = "status" | "flag";
/**
* A KPI tap toggles its filter. flag with a quick flag value (co, short, ...) toggles the chip; flag with a flag
* kind (any, rev, ...) toggles the Flag select. Turning a select on opens the advanced row.
*/
export function kpiFilter(f: Filters, key: KpiKey, value: string): Partial<Filters> {
if (key === "flag" && FLAG_KEYS.has(value)) {
return { flags: f.flags.includes(value) ? f.flags.filter((x) => x !== value) : [...f.flags, value] };
}
const field = key === "flag" ? "flag" : "status";
const on = f[field] !== value;
return on ? { [field]: value, more: true } : { [field]: ALL };
}
export function kpiActive(f: Filters, key: KpiKey, value: string): boolean {
if (key === "flag" && FLAG_KEYS.has(value)) return f.flags.includes(value);
return (key === "flag" ? f.flag : f.status) === value;
}
/** Scrolls the find bar into view, after the render that follows a KPI tap. */
export function scrollToFindBar(): void {
nextFrame(() => {
const el = document.querySelector<HTMLElement>(".fltbar");
if (el && typeof el.scrollIntoView === "function") {
el.scrollIntoView({ behavior: document.body.classList.contains("motion") ? "smooth" : "auto", block: "start" });
}
});
}
/* ---------- the hook ---------- */
export interface FiltersState {
f: Filters;
set: (patch: Partial<Filters>) => void;
clear: () => void;
pick: UnitPick;
setPick: (p: UnitPick) => void;
view: ViewKind;
setView: (v: ViewKind) => void;
/** The whole query, with q debounced 250 ms. */
query: PacketListQuery;
queryKey: string;
/** The project and unit only, for the KPI numbers. */
scope: PacketListQuery;
scopeKey: string;
/** A KPI tap: toggles the filter and scrolls the find bar into view when it turns on. */
tapKpi: (key: KpiKey, value: string) => void;
isKpiOn: (key: KpiKey, value: string) => boolean;
/** The filters as they apply on this screen (hidden parts left out). */
active: number;
}
/** The find bar state for one screen. Saves on every change except q; keeps the pick in ps.proj. */
export function useFilters(screen: Screen, opts: { stationId?: number | null } = {}): FiltersState {
const [f, setF] = useState<Filters>(loadFilters);
const [pick, setPickState] = useState<UnitPick>(loadPick);
const [dq, setDq] = useState("");
useEffect(() => {
const next = f.q.trim();
if (next === dq) return;
// Clearing the box applies at once; typing waits for a pause.
if (!next) {
setDq("");
return;
}
const t = window.setTimeout(() => setDq(next), 250);
return () => window.clearTimeout(t);
}, [f.q, dq]);
const saved = useMemo(() => JSON.stringify(savedForm(f)), [f]);
useEffect(() => {
lsSet(FLT_KEY, saved);
}, [saved]);
const set = useCallback((patch: Partial<Filters>) => setF((prev) => ({ ...prev, ...patch })), []);
const setPick = useCallback((p: UnitPick) => {
setPickState(p);
savePick(p);
}, []);
const clear = useCallback(() => {
setF((prev) => ({ ...FLT_DEFAULT, views: prev.views, sort: prev.sort }));
setDq("");
setPick(NO_PICK);
}, [setPick]);
const view: ViewKind = partsFor(screen).view && f.views[screen] === "list" ? "list" : "board";
const setView = useCallback((v: ViewKind) => setF((prev) => ({ ...prev, views: { ...prev.views, [screen]: v } })), [screen]);
const stationId = opts.stationId ?? null;
const query = useMemo(() => toQuery({ ...f, q: dq }, pick, { screen, stationId }), [f, dq, pick, screen, stationId]);
const queryKey = JSON.stringify(query);
const scope = useMemo(() => pickQuery(pick), [pick]);
const scopeKey = JSON.stringify(scope);
const tapKpi = useCallback(
(key: KpiKey, value: string) => {
const on = !kpiActive(f, key, value);
set(kpiFilter(f, key, value));
if (on) scrollToFindBar();
},
[f, set],
);
const isKpiOn = useCallback((key: KpiKey, value: string) => kpiActive(effective(f, screen), key, value), [f, screen]);
return {
f,
set,
clear,
pick,
setPick,
view,
setView,
query,
queryKey,
scope,
scopeKey,
tapKpi,
isKpiOn,
active: countActive(f, !!pick.project, screen),
};
}
/* ---------- local sort (the board columns merged into one list) ---------- */
const natural = (a: string, b: string) => a.localeCompare(b, undefined, { numeric: true });
const byUpdatedDesc = (a: PacketCard, b: PacketCard) => (b.updated_at ?? "").localeCompare(a.updated_at ?? "");
/** The same order the server's sort= gives, for lists merged from several board columns. */
export function sorter(k: SortKey): (a: PacketCard, b: PacketCard) => number {
switch (k) {
case "project":
return (a, b) =>
natural(a.project.number, b.project.number) || natural(a.unit.number, b.unit.number) || (a.op ?? "").localeCompare(b.op ?? "");
case "due":
return (a, b) => (a.due_effective ?? "9999").localeCompare(b.due_effective ?? "9999") || byUpdatedDesc(a, b);
case "status":
return (a, b) => STATUSES.indexOf(a.status) - STATUSES.indexOf(b.status) || byUpdatedDesc(a, b);
case "dept":
return (a, b) => natural(a.dept.code, b.dept.code) || byUpdatedDesc(a, b);
default:
return byUpdatedDesc;
}
}
/** Cards from several columns, once each, in column order. */
export function uniqueCards<T extends { id: number }>(lists: ReadonlyArray<readonly T[]>): T[] {
const seen = new Set<number>();
const out: T[] = [];
for (const list of lists) {
for (const c of list) {
if (seen.has(c.id)) continue;
seen.add(c.id);
out.push(c);
}
}
return out;
}
+53
View File
@@ -0,0 +1,53 @@
import { act, renderHook } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { memoryStorage } from "../screens/supervisor/memoryStorage";
import { applyMotionClass, countStep, MOTION_KEY, motionOn, setMotion, useMotion } from "./motion";
function media(reduce: boolean) {
vi.stubGlobal("matchMedia", (q: string) => ({ matches: reduce && q.includes("reduce"), addEventListener: () => undefined, removeEventListener: () => undefined }));
}
beforeEach(() => {
vi.stubGlobal("localStorage", memoryStorage());
document.body.classList.remove("motion");
});
describe("motion", () => {
it("is on by default and off when ps.motion says off", () => {
media(false);
expect(motionOn()).toBe(true);
window.localStorage.setItem(MOTION_KEY, "off");
expect(motionOn()).toBe(false);
window.localStorage.setItem(MOTION_KEY, "anything");
expect(motionOn()).toBe(true);
});
it("reduced motion always wins", () => {
media(true);
window.localStorage.setItem(MOTION_KEY, "on");
expect(motionOn()).toBe(false);
applyMotionClass();
expect(document.body).not.toHaveClass("motion");
});
it("setMotion writes the key, sets the body class and updates useMotion", () => {
media(false);
const { result } = renderHook(() => useMotion());
expect(result.current).toBe(true);
act(() => setMotion(false));
expect(window.localStorage.getItem(MOTION_KEY)).toBe("off");
expect(document.body).not.toHaveClass("motion");
expect(result.current).toBe(false);
act(() => setMotion(true));
expect(window.localStorage.getItem(MOTION_KEY)).toBe("on");
expect(document.body).toHaveClass("motion");
expect(result.current).toBe(true);
});
it("counts with an ease-out cubic", () => {
expect(countStep(10, 20, 0)).toBe(10);
expect(countStep(10, 20, 0.5)).toBe(19);
expect(countStep(10, 20, 1)).toBe(20);
expect(countStep(20, 10, 2)).toBe(10);
});
});
+110
View File
@@ -0,0 +1,110 @@
// Motion (the old motion.js): short animation that shows what changed. One switch per browser in the top bar.
// prefers-reduced-motion: reduce always wins. body.motion is set while motion is on; the CSS keys off it.
import { useSyncExternalStore } from "react";
export const MOTION_KEY = "ps.motion";
const REDUCE = "(prefers-reduced-motion: reduce)";
function reduced(): boolean {
try {
return typeof window.matchMedia === "function" && window.matchMedia(REDUCE).matches;
} catch {
return false;
}
}
function stored(): string | null {
try {
return window.localStorage.getItem(MOTION_KEY);
} catch {
return null;
}
}
/** On unless the browser asks for reduced motion or the switch says off. */
export function motionOn(): boolean {
if (reduced()) return false;
return stored() !== "off";
}
/** body.motion follows motionOn(). */
export function applyMotionClass(): void {
if (typeof document === "undefined" || !document.body) return;
document.body.classList.toggle("motion", motionOn());
}
const listeners = new Set<() => void>();
function emit(): void {
applyMotionClass();
for (const fn of listeners) fn();
}
/** Turns motion on or off for this browser. Reduced motion still wins. */
export function setMotion(on: boolean): void {
try {
window.localStorage.setItem(MOTION_KEY, on ? "on" : "off");
} catch {
// Storage blocked: the switch lasts for this page only.
}
emit();
}
function subscribe(fn: () => void): () => void {
listeners.add(fn);
let mq: MediaQueryList | null = null;
try {
mq = typeof window.matchMedia === "function" ? window.matchMedia(REDUCE) : null;
} catch {
mq = null;
}
const onMq = () => emit();
mq?.addEventListener?.("change", onMq);
const onStorage = (e: StorageEvent) => {
if (e.key === MOTION_KEY) emit();
};
window.addEventListener("storage", onStorage);
return () => {
listeners.delete(fn);
mq?.removeEventListener?.("change", onMq);
window.removeEventListener("storage", onStorage);
};
}
/** Whether motion is on, kept current with the switch, other tabs and the reduced-motion setting. */
export function useMotion(): boolean {
return useSyncExternalStore(subscribe, motionOn, () => false);
}
/** Restarts a CSS animation class on an element (remove, reflow, add), and takes it off after `ms`. */
export function replayClass(el: HTMLElement | null, cls: string, ms: number): () => void {
if (!el) return () => undefined;
el.classList.remove(cls);
void el.offsetWidth;
el.classList.add(cls);
const t = window.setTimeout(() => el.classList.remove(cls), ms);
return () => {
window.clearTimeout(t);
el.classList.remove(cls);
};
}
/** The KPI count-up: 420 ms, then the tile keeps .m-bump for 500 ms. */
export const COUNT_MS = 420;
export const BUMP_MS = 500;
/** Ease-out cubic from a to z at fraction t, rounded to a whole number. */
export function countStep(a: number, z: number, t: number): number {
const f = Math.min(1, Math.max(0, t));
return Math.round(a + (z - a) * (1 - Math.pow(1 - f, 3)));
}
/** requestAnimationFrame, or a 16 ms timer where there is none. */
export function nextFrame(cb: (t: number) => void): number {
if (typeof window.requestAnimationFrame === "function") return window.requestAnimationFrame(cb);
return window.setTimeout(() => cb(performance.now()), 16);
}
export function cancelFrame(id: number): void {
if (typeof window.cancelAnimationFrame === "function") window.cancelAnimationFrame(id);
else window.clearTimeout(id);
}
+80
View File
@@ -0,0 +1,80 @@
import { act, render, screen } from "@testing-library/react";
import { createMemoryRouter, Outlet, RouterProvider, useNavigate } from "react-router";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { rememberScroll, resetScroll, savedScroll, SCROLL_KEEP, useScrollRestore } from "./scrollRestore";
let y = 0;
const scrollTo = vi.fn((_x: number, to: number) => {
y = to;
});
function Frame() {
useScrollRestore();
const navigate = useNavigate();
return (
<>
<button type="button" onClick={() => navigate("/packets/1")}>
Open
</button>
<button type="button" onClick={() => navigate(-1)}>
Back
</button>
<Outlet />
</>
);
}
beforeEach(() => {
resetScroll();
y = 0;
scrollTo.mockClear();
vi.useFakeTimers();
Object.defineProperty(window, "scrollY", { configurable: true, get: () => y });
vi.stubGlobal("scrollTo", scrollTo);
vi.stubGlobal("requestAnimationFrame", (cb: FrameRequestCallback) => window.setTimeout(() => cb(0), 16));
vi.stubGlobal("cancelAnimationFrame", (id: number) => window.clearTimeout(id));
});
afterEach(() => {
vi.useRealTimers();
});
describe("scroll restore", () => {
it("keeps at most 40 positions, oldest out first", () => {
for (let i = 0; i < SCROLL_KEEP + 5; i++) rememberScroll(`k${i}`, i * 10);
expect(savedScroll("k0")).toBeNull();
expect(savedScroll("k4")).toBeNull();
expect(savedScroll("k5")).toBe(50);
expect(savedScroll(`k${SCROLL_KEEP + 4}`)).toBe((SCROLL_KEEP + 4) * 10);
});
it("a push starts at the top, and Back lands on the same spot", () => {
const router = createMemoryRouter(
[
{
element: <Frame />,
children: [
{ path: "/supervisor", element: <h1>Home</h1> },
{ path: "/packets/:id", element: <h1>Packet</h1> },
],
},
],
{ initialEntries: ["/supervisor"] },
);
render(<RouterProvider router={router} />);
// The person scrolls down the board.
y = 900;
act(() => {
window.dispatchEvent(new Event("scroll"));
});
act(() => screen.getByRole("button", { name: "Open" }).click());
expect(screen.getByRole("heading", { name: "Packet" })).toBeInTheDocument();
expect(y).toBe(0);
act(() => screen.getByRole("button", { name: "Back" }).click());
expect(screen.getByRole("heading", { name: "Home" })).toBeInTheDocument();
act(() => {
vi.advanceTimersByTime(200);
});
expect(y).toBe(900);
});
});
+119
View File
@@ -0,0 +1,119 @@
// Back lands on the same spot (the old navPush and navBack). Layout records window.scrollY per history entry
// before the next page draws, and puts it back on a POP after the page has drawn.
import { useLayoutEffect, useRef } from "react";
import { useLocation, useNavigationType } from "react-router";
import { cancelFrame, nextFrame } from "./motion";
/** At most this many positions are kept. */
export const SCROLL_KEEP = 40;
const positions = new Map<string, number>();
/** Remembers the scroll position of one history entry. The oldest go first past SCROLL_KEEP. */
export function rememberScroll(key: string, y: number): void {
positions.delete(key);
positions.set(key, Math.max(0, Math.round(y)));
while (positions.size > SCROLL_KEEP) {
const oldest = positions.keys().next().value;
if (oldest === undefined) break;
positions.delete(oldest);
}
}
export function savedScroll(key: string): number | null {
return positions.get(key) ?? null;
}
/** For tests. */
export function resetScroll(): void {
positions.clear();
}
function scrollTo(y: number): void {
if (Math.abs(window.scrollY - y) < 1) return;
try {
window.scrollTo(0, y);
} catch {
// jsdom has no scrolling.
}
}
/** Extra tries after the 150 ms one, for a board whose data lands later. Each stops once the page is there. */
export const RESTORE_RETRIES_MS = [400, 1000];
/**
* Restores `y` after the page has drawn: on the next frame, and again at 150 ms once the screen has eased in
* (the old way), then at 400 and 1000 ms while the page is still short of it. The user's own wheel, touch or
* key stops it. Returns a cancel function.
*/
export function restoreScroll(y: number): () => void {
const timers: number[] = [];
let stopped = false;
const stop = () => {
stopped = true;
for (const t of timers) window.clearTimeout(t);
cancelFrame(raf);
for (const ev of USER_EVENTS) window.removeEventListener(ev, stop);
};
const go = (last: boolean) => {
if (stopped) return;
scrollTo(y);
if (last) stop();
};
for (const ev of USER_EVENTS) window.addEventListener(ev, stop, { passive: true });
const raf = nextFrame(() => go(false));
timers.push(window.setTimeout(() => go(false), 150));
RESTORE_RETRIES_MS.forEach((ms, i) => timers.push(window.setTimeout(() => go(i === RESTORE_RETRIES_MS.length - 1), ms)));
return stop;
}
const USER_EVENTS = ["wheel", "touchstart", "keydown"] as const;
/**
* Keeps the scroll position per location.key. A POP (Back, Forward) restores the entry's position; a PUSH or
* REPLACE to a new path starts at the top.
*/
export function useScrollRestore(): void {
const loc = useLocation();
const nav = useNavigationType();
const prev = useRef<{ key: string; path: string } | null>(null);
const yNow = useRef(0);
const pending = useRef<(() => void) | null>(null);
// The last known position of the page being left. A scroll listener keeps it, because by the time the
// new location renders the old page may already be gone and the window clamped.
useLayoutEffect(() => {
const on = () => {
yNow.current = window.scrollY;
};
on();
window.addEventListener("scroll", on, { passive: true });
return () => window.removeEventListener("scroll", on);
}, []);
useLayoutEffect(() => {
const before = prev.current;
if (!before || before.key === loc.key) {
prev.current = { key: loc.key, path: loc.pathname };
return;
}
rememberScroll(before.key, yNow.current);
prev.current = { key: loc.key, path: loc.pathname };
pending.current?.();
pending.current = null;
if (nav === "POP") {
const y = savedScroll(loc.key);
if (y !== null) pending.current = restoreScroll(y);
} else if (before.path !== loc.pathname) {
scrollTo(0);
}
}, [loc.key, loc.pathname, nav]);
// A restore still running when the app unmounts stops.
useLayoutEffect(
() => () => {
pending.current?.();
},
[],
);
}
+129
View File
@@ -0,0 +1,129 @@
// Cards glide to their new place (FLIP), new cards fade in, and cards changed elsewhere glow (the old motionAfter).
import { useLayoutEffect, useRef, type RefObject } from "react";
import { motionOn } from "./motion";
export const FLIP_EASE = "cubic-bezier(.2,.7,.2,1)";
/** More moves or new cards than this: the board just appears (a filter, a big sync). */
export const FLIP_MAX = 40;
const GLOW: Keyframe[] = [
{ boxShadow: "0 0 0 0 rgba(73,192,222,0)" },
{ boxShadow: "0 0 0 3px rgba(73,192,222,.75), 0 0 22px rgba(73,192,222,.45)", offset: 0.15 },
{ boxShadow: "0 0 0 0 rgba(73,192,222,0)" },
];
interface Place {
box: string;
x: number;
y: number;
left: number;
top: number;
}
/** The column or section a card sits in. A panel opening above pushes every card down; that is not a move. */
function boxOf(el: Element): Element | null {
return el.closest(".col") ?? el.closest("section") ?? el.parentElement;
}
function boxKey(c: Element | null): string {
if (!c) return "";
const h = c.querySelector(".col-h, .sec-h h2, h2, h3");
return `${c.className}|${(h?.textContent ?? "").replace(/\d+/g, "").trim()}`;
}
/** Where a card sits: inside its column, and inside the board (so a page scroll is not a move). */
function placeOf(el: Element, rootRect: { left: number; top: number }): Place {
const r = el.getBoundingClientRect();
const c = boxOf(el);
const cr = c ? c.getBoundingClientRect() : { left: 0, top: 0 };
return { box: boxKey(c), x: r.left - cr.left, y: r.top - cr.top, left: r.left - rootRect.left, top: r.top - rootRect.top };
}
/** Moves to animate: [element, dx, dy] from the old place to the new. */
export function flipMoves(before: ReadonlyMap<string, Place>, after: ReadonlyArray<[string, Element, Place]>): Array<[Element, number, number]> {
const out: Array<[Element, number, number]> = [];
for (const [k, el, n] of after) {
const o = before.get(k);
if (!o) continue;
const same = o.box === n.box;
const dx = same ? o.x - n.x : o.left - n.left;
const dy = same ? o.y - n.y : o.top - n.top;
if (Math.abs(dx) > 3 || Math.abs(dy) > 3) out.push([el, dx, dy]);
}
return out;
}
/**
* FLIP for the cards on the page that holds `ref` (its `main`, else `ref` itself). `sigs` maps a card id to its signature (status, wh stage, station, open CO
* count); a card whose signature changed since the last render glows. Cards carry data-card-id.
* Before the dependent render the rects are taken from the last layout; after it, moved cards glide.
*/
export function useFlip(ref: RefObject<HTMLElement | null>, selector = "[data-card-id]", deps: readonly unknown[] = [], sigs?: ReadonlyMap<string, string>): void {
const last = useRef<Map<string, Place>>(new Map());
const lastSig = useRef<Map<string, string>>(new Map());
const first = useRef(true);
useLayoutEffect(() => {
// Cards are tracked across the whole page (the old M_SEL under #main), so a card that leaves a board for the
// station grid, or comes back, still glides. One board per screen drives it.
const root = ref.current?.closest("main") ?? ref.current;
if (!root) return;
const els = Array.from(root.querySelectorAll(selector));
const rootRect = root.getBoundingClientRect();
const now: Array<[string, Element, Place]> = [];
const seen = new Set<string>();
for (const el of els) {
const k = (el as HTMLElement).dataset.cardId ?? "";
if (!k || seen.has(k)) continue;
seen.add(k);
now.push([k, el, placeOf(el, rootRect)]);
}
const before = last.current;
last.current = new Map(now.map(([k, , p]) => [k, p]));
const changed = new Set<string>();
if (sigs) {
for (const [k, s] of sigs) {
const o = lastSig.current.get(k);
if (o !== undefined && o !== s) changed.add(k);
}
lastSig.current = new Map(sigs);
}
const wasFirst = first.current;
first.current = false;
if (wasFirst || !motionOn() || typeof (Element.prototype as Partial<Element>).animate !== "function") return;
const vh = window.innerHeight || 900;
const visible = now.filter(([, , p]) => p.top + rootRect.top > -200 && p.top + rootRect.top < vh + 200);
const moves = flipMoves(before, visible);
const fresh = before.size ? visible.filter(([k]) => !before.has(k)).map(([, el]) => el) : [];
if (moves.length <= FLIP_MAX) {
for (const [el, dx, dy] of moves) {
el.animate([{ transform: `translate(${dx}px,${dy}px)`, zIndex: 5 }, { transform: "none", zIndex: 5 }], {
duration: Math.min(420, 220 + Math.hypot(dx, dy) / 6),
easing: FLIP_EASE,
});
}
}
if (fresh.length <= FLIP_MAX) {
for (const el of fresh) el.animate([{ opacity: 0, transform: "scale(.97)" }, { opacity: 1, transform: "none" }], { duration: 240, easing: FLIP_EASE });
}
for (const [k, el] of now) if (changed.has(k)) el.animate(GLOW, { duration: 1700, easing: "ease-out" });
// The caller lists what the render depends on.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, deps);
}
/** A card's signature: what changing elsewhere makes it glow. */
export function cardSig(p: { status: string; wh_stage?: string | null; station?: { id: number } | null; open_cos?: unknown }): string {
const cos = Array.isArray(p.open_cos) ? p.open_cos.length : Number(p.open_cos) || 0;
return [p.status, p.wh_stage ?? "", p.station?.id ?? "", cos].join("|");
}
/** Signatures for a list of cards. */
export function sigMap(cards: Iterable<{ id: number } & Parameters<typeof cardSig>[0]>): Map<string, string> {
const m = new Map<string, string>();
for (const c of cards) m.set(String(c.id), cardSig(c));
return m;
}