Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 14 additions & 3 deletions .github/workflows/ward-flow.yml
Original file line number Diff line number Diff line change
Expand Up @@ -377,15 +377,26 @@ jobs:
- name: Seed release system packages
if: ${{ !cancelled() && steps.install.outcome == 'success' && steps.backend-install.outcome == 'success' }}
run: node scripts/ward-ci-public/apt-archive-cache.mjs seed "$HOME/.cache/ward-release-apt"
# WebKit's own packages too, so the offline install covers both engines when the mirror is down.
# Same path as the save step, because the path is part of the cache version.
- name: Restore release WebKit system packages
if: ${{ !cancelled() && steps.install.outcome == 'success' && steps.backend-install.outcome == 'success' }}
uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9
with:
path: ~/.cache/ward-release-apt
key: ward-release-apt-${{ runner.os }}-webkit-${{ steps.shared-version.outputs.version }}-${{ steps.shared-version.outputs.image }}
restore-keys: |
ward-release-apt-${{ runner.os }}-webkit-${{ steps.shared-version.outputs.version }}-
- name: Seed release WebKit system packages
if: ${{ !cancelled() && steps.install.outcome == 'success' && steps.backend-install.outcome == 'success' }}
run: node scripts/ward-ci-public/apt-archive-cache.mjs seed "$HOME/.cache/ward-release-apt"
- name: Install Chromium and WebKit for the shared-mode journeys
id: shared-chromium
timeout-minutes: 10
if: ${{ !cancelled() && steps.install.outcome == 'success' && steps.backend-install.outcome == 'success' }}
env:
PLAYWRIGHT_CACHE_HIT: ${{ steps.shared-cache.outputs.cache-hit }}
run: |
node scripts/ward-ci-public/install-chromium.mjs
node node_modules/playwright/cli.js install --with-deps webkit
run: node scripts/ward-ci-public/install-chromium.mjs chromium webkit
- name: Run the shared-mode browser journey
timeout-minutes: 20
if: ${{ !cancelled() && steps.shared-chromium.outcome == 'success' }}
Expand Down
2 changes: 1 addition & 1 deletion knip.json
Original file line number Diff line number Diff line change
Expand Up @@ -21,5 +21,5 @@
}
},
"ignoreDependencies": ["@axe-core/playwright", "playwright-core", "rimraf", "tsx"],
"ignoreBinaries": ["pgrep", "openssl", "jq"]
"ignoreBinaries": ["pgrep", "openssl", "jq", "dpkg", "dpkg-query"]
}
97 changes: 95 additions & 2 deletions scripts/ward-ci-public/install-chromium.mjs
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import { spawnSync } from "node:child_process";
import { existsSync, readdirSync } from "node:fs";
import { fileURLToPath, pathToFileURL } from "node:url";
import { resolve } from "node:path";
import { join, resolve } from "node:path";
import { APT_ARCHIVES } from "./apt-archive-cache.mjs";

const root = fileURLToPath(new URL("../../", import.meta.url));
const cli = resolve(root, "node_modules/playwright/cli.js");
Expand All @@ -21,7 +23,10 @@ export function waitForPackageManager(deadline, now = Date.now) {

/**
* Six-minute shared budget, two attempts per operation; the workflow adds an eight-minute outer bound.
* @param {{ browsers?: string[], cacheHit?: boolean, execute?: (command: string, args: string[], timeoutMs: number) => { status: number | null, error?: { code?: string }, signal?: string | null }, now?: () => number, log?: (message: string) => void, waitForIdle?: (deadline: number) => void }} options
* When the seed step has left cached system packages in apt's archive folder, they are installed
* first with no network at all, then each browser must launch; only if that fails does the setup
* fall back to Playwright's own apt-get, which needs the runner's Ubuntu mirror.
* @param {{ browsers?: string[], cacheHit?: boolean, cachedDebs?: string[], execute?: (command: string, args: string[], timeoutMs: number) => { status: number | null, error?: { code?: string }, signal?: string | null }, now?: () => number, log?: (message: string) => void, waitForIdle?: (deadline: number) => void }} options
*/
export function installBrowsers({
browsers = ["chromium"],
Expand All @@ -30,6 +35,7 @@ export function installBrowsers({
now = Date.now,
log = console.log,
waitForIdle = execute === runBounded ? (deadline) => waitForPackageManager(deadline, now) : () => {},
cachedDebs = execute === runBounded ? withoutDowngrades(archiveDebs()) : [],
} = {}) {
if (
!browsers.length ||
Expand All @@ -39,6 +45,7 @@ export function installBrowsers({
throw new Error("Invalid browser selection; name unique supported engines");
const label = browsers.length === 1 && browsers[0] === "chromium" ? "Chromium" : "Playwright browser";
const deadline = now() + 360_000;
if (cachedDebs.length && installOffline({ browsers, cachedDebs, execute, now, deadline, label, log }) === 0) return 0;
const operations = cacheHit
? [
["install-deps", ...browsers],
Expand Down Expand Up @@ -67,6 +74,92 @@ export function installBrowsers({
return 0;
}

/** The cached .deb files the seed step copied in; none when the cache missed. */
export function archiveDebs(dir = APT_ARCHIVES) {
return existsSync(dir)
? readdirSync(dir)
.filter((name) => name.endsWith(".deb"))
.sort()
.map((name) => join(dir, name))
: [];
}

/** Each installed package's version, keyed by "name arch", read once from dpkg. */
function installedVersions() {
const result = spawnSync("dpkg-query", ["-W", "-f", "${Package} ${Architecture} ${Version}\\n"], {
encoding: "utf8",
});
const versions = new Map();
for (const line of result.status === 0 ? result.stdout.split("\n") : []) {
const [name, arch, version] = line.split(" ");
if (version) versions.set(`${name} ${arch}`, version);
}
return versions;
}

/** True when dpkg orders version `a` before version `b`. */
function olderThan(a, b) {
return spawnSync("dpkg", ["--compare-versions", a, "lt", b]).status === 0;
}

/**
* A cache saved on an older runner image can hold a package older than the one installed. Naming
* that file asks apt for a downgrade, which `-y` refuses, and the whole offline install fails. Leave
* such files out; archive names are `name_version_arch.deb`, with an epoch's colon written `%3a`.
*/
export function withoutDowngrades(debs, installed = installedVersions(), isOlder = olderThan) {
return debs.filter((path) => {
const parts = path
.split("/")
.pop()
.replace(/\.deb$/, "")
.split("_");
if (parts.length !== 3) return true;
const [name, rawVersion, arch] = parts;
const current = installed.get(`${name} ${arch}`);
const version = decodeURIComponent(rawVersion);
return !current || current === version || !isOlder(version, current);
});
}

/** A fixed script; the Playwright path and the engine names arrive as arguments, never as code. */
const LAUNCH_PROBE = `const [playwright, ...names] = process.argv.slice(1);
const pw = require(playwright);
(async () => {
for (const name of names) {
const browser = await pw[name].launch();
await browser.close();
}
})().catch((error) => {
console.error(error);
process.exit(1);
});`;

/**
* The runner's Ubuntu mirror can be unreachable for hours (11 Nov 2026: "Temporary failure
* resolving 'azure.archive.ubuntu.com'"), and Playwright's install-deps always runs apt-get update
* first. Install the cached packages as local files with downloads switched off, fetch the browsers
* alone, and pass only if every browser launches. Any failure hands back to the online path.
*/
function installOffline({ browsers, cachedDebs, execute, now, deadline, label, log }) {
log(`${label} setup: installing ${cachedDebs.length} cached system packages without the network.`);
const steps = [
["sudo", ["apt-get", "install", "-y", "--no-download", "--no-install-recommends", ...cachedDebs], 120_000],
[process.execPath, [cli, "install", ...browsers], 120_000],
[process.execPath, ["-e", LAUNCH_PROBE, resolve(root, "node_modules/playwright"), ...browsers], 60_000],
];
for (const [command, args, limit] of steps) {
const remaining = deadline - now();
const result = remaining > 0 ? execute(command, args, Math.min(limit, remaining)) : undefined;
if (!result || result.error || result.status !== 0) {
log(`${label} setup: the offline install did not finish; trying the runner's apt mirror.`);
return 1;
}
}
log(`${label} setup: installed from cached system packages; every browser launches.`);
return 0;
}

// Existing consumers retain their default Chromium-only command and retry budget.
/** Compatibility entry point for callers requesting Chromium only. @param {Parameters<typeof installBrowsers>[0]} [options] */
export function installChromium(options) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,7 @@ import cards from "./home-cards.module.css";
import styles from "./home.module.css";
import { HomeEdPressure } from "./home-ed-pressure";
import { PlannedList } from "./home-activity-lazy";
import { useHomeCardsSettled } from "./home-cards-settled";
import { useHomeLayout } from "./home-layout";
import { BedsGapCard, HomePlacementDrawer, HomeReleaseSheet, MyListCard } from "./home-list-lazy";
import list from "./home-list.module.css";
Expand Down Expand Up @@ -592,6 +593,8 @@ export function CoordinatorScreen() {
// Plan item 2i: the hero's Act now is main's service-scoped Act now figure.
const heroActNow = actNowCount;
const chosenAlert = selectedAlertId ? alertEntries.find((entry) => entry.item.id === selectedAlertId) : undefined;
// Layout shift on Home (11 Nov 2026): the columns appear once the cards above My list and Later today have arrived.
const cardsSettled = useHomeCardsSettled(!isPhone && !chosenAlert);
const releaseAlert = releaseAlertId ? alertEntries.find((entry) => entry.item.id === releaseAlertId) : undefined;
const waitEntries = alertEntries.filter((entry) => entry.group === "wait");
// Home flow mock-up (Q340): a waiting alert is a mark on its patient's Flow row.
Expand Down Expand Up @@ -974,6 +977,7 @@ export function CoordinatorScreen() {
data-testid="ward-coordinator-region-grid"
data-layout={layout}
data-shortlist-open={hasPanelSubject}
data-cards-pending={cardsSettled ? undefined : "true"}
ref={queueFocusRef}
>
<div className={`${styles.side} ${list.workCol}`}>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
"use client";

import { useCallback, useEffect, useState, useSyncExternalStore } from "react";

import { bedsGapCard } from "./home-list-lazy";
import { sinceBanner } from "./home-since-banner-lazy";

/** Home never stays hidden longer than this for a card that is slow or fails to load. */
const HOME_CARDS_WAIT_MS = 1500;

/**
* Layout shift on Home (11 Nov 2026): Home draws on the client, and the two cards it shows at rest
* from split code (the "since you were last here" line, and Beds that fit off a phone) landed a
* moment after My list and Later today and pushed them down (0.106 on a desktop, 0.064 on a phone).
* Home's columns now stay hidden, still taking their space, until those cards have arrived, then
* appear whole. Both are fetched as soon as Home's code runs, so the wait is normally nothing.
* After `HOME_CARDS_WAIT_MS` Home shows anyway and the late card drops in, as before.
*/
export function useHomeCardsSettled(needBeds: boolean): boolean {
const subscribe = useCallback(
(listener: () => void) => {
const stopBanner = sinceBanner.subscribe(listener);
const stopBeds = needBeds ? bedsGapCard.subscribe(listener) : () => undefined;
return () => {
stopBanner();
stopBeds();
};
},
[needBeds],
);
const loaded = useSyncExternalStore(
subscribe,
() => sinceBanner.ready() && (!needBeds || bedsGapCard.ready()),
() => false,
);
const [waitedOut, setWaitedOut] = useState(false);
useEffect(() => {
if (loaded || waitedOut) return;
const timer = window.setTimeout(() => setWaitedOut(true), HOME_CARDS_WAIT_MS);
return () => window.clearTimeout(timer);
}, [loaded, waitedOut]);
return loaded || waitedOut;
}
20 changes: 17 additions & 3 deletions src/components/ward-management/coordinator/home-list-lazy.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,9 @@

import dynamic from "next/dynamic";

import { WF_PHONE_QUERY } from "@/components/wf";

import { loadEarly } from "../load-early";
import { preloadWhenIdle } from "../preload-when-idle";
// A1K-01 / DR-03 (26 Oct 2026): the server draws My list's rows before their code arrives, so their
// styles load with Home. Without them the rows paint as plain text, then grow into cards and push
Expand All @@ -15,7 +18,6 @@ import "@/components/ward-management/alerts/alerts.module.css";
* idle, as the shortlist panels are (see shortlist-panel-lazy.tsx).
*/
const loadPlacementDrawer = () => import("./home-placement-drawer");
const loadBedsGapCard = () => import("./beds-gap-card");

/*
* A1K-01 (26 Oct 2026): My list is the first thing on Home, so it now loads with the page. As a lazy
Expand All @@ -27,10 +29,22 @@ export { MyListCard } from "./my-list-card";
export const HomePlacementDrawer = dynamic(() => loadPlacementDrawer().then((m) => m.HomePlacementDrawer), {
loading: () => null,
});
export const BedsGapCard = dynamic(() => loadBedsGapCard().then((m) => m.BedsGapCard), { loading: () => null });

/*
* Beds that fit sits above Later today at rest on a wider screen. Arriving late, it pushed Later
* today down about 390 px (layout shift on Home, 11 Nov 2026), so off a phone it is fetched as soon
* as Home's code runs and Home holds its columns until it arrives (`useHomeCardsSettled`). A phone
* never shows it, so a phone still fetches it only when idle.
*/
export const bedsGapCard = loadEarly(() => import("./beds-gap-card").then((m) => m.BedsGapCard));
export const BedsGapCard = bedsGapCard.Card;

preloadWhenIdle(loadPlacementDrawer);
preloadWhenIdle(loadBedsGapCard);
const wide =
typeof window !== "undefined" &&
(typeof window.matchMedia !== "function" || !window.matchMedia(WF_PHONE_QUERY).matches);
if (wide) bedsGapCard.start();
else preloadWhenIdle(async () => bedsGapCard.start());

// H-0977: Home's in-place "Release bed" sheet, fetched on first use so it stays off the first load.
export const HomeReleaseSheet = dynamic(() => import("./home-release-sheet").then((m) => m.HomeReleaseSheet), {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,18 +1,15 @@
"use client";

import dynamic from "next/dynamic";

import { preloadWhenIdle } from "../preload-when-idle";
import { loadEarly } from "../load-early";

/**
* Phone budget: the "since you were last here" line brings the last-look comparison and its sheet
* with it. It renders nothing until something is new, so it stays off Home's first load and is
* fetched when the browser is idle, as Later today is (see home-activity-lazy.tsx).
* with it, so it stays off Home's first load. It is on screen at rest (before any review it counts
* from the start of the shift), so it is fetched as soon as Home's code runs and Home holds its
* columns until it arrives (`useHomeCardsSettled`): arriving late, it pushed My list down (layout
* shift on Home, 11 Nov 2026).
*/
const loadSinceBanner = () => import("./home-since-banner");

export const HomeSinceBanner = dynamic(() => loadSinceBanner().then((m) => m.HomeSinceBanner), {
loading: () => null,
});
export const sinceBanner = loadEarly(() => import("./home-since-banner").then((m) => m.HomeSinceBanner));
export const HomeSinceBanner = sinceBanner.Card;

preloadWhenIdle(loadSinceBanner);
sinceBanner.start();
6 changes: 6 additions & 0 deletions src/components/ward-management/coordinator/home.module.css
Original file line number Diff line number Diff line change
Expand Up @@ -316,6 +316,12 @@
min-width: 0;
}

/* Layout shift on Home (11 Nov 2026): held hidden, still taking its space, until the cards at the
top of each column have arrived (useHomeCardsSettled), so nothing lands on rows already shown. */
.grid[data-cards-pending] {
visibility: hidden;
}

@media (min-width: 62.5625rem) {
/* Three equal columns at one height, a little under a window tall (the shell's rail height less
room for the hero), each scrolling inside itself, so the page itself barely scrolls. Tablet
Expand Down
48 changes: 48 additions & 0 deletions src/components/ward-management/load-early.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
"use client";

import { type ComponentType, useSyncExternalStore } from "react";

/**
* A code-split card that Home shows at rest (the "since you were last here" line, Beds that fit).
* It stays out of the first load like any lazy card, but `start` fetches it as soon as Home's code
* runs instead of when the browser is idle, and Home holds its columns until it has arrived
* (`useHomeCardsSettled`), so the card never lands on top of rows already on screen (layout shift
* on Home, 11 Nov 2026). Once loaded it draws in the same render, unlike `next/dynamic`, which
* always waits one pass even when the code is already there. A failed fetch is tried again the next
* time the card mounts. Does nothing on the server.
*/
export function loadEarly<P extends object>(loader: () => Promise<ComponentType<P>>) {
let loaded: ComponentType<P> | null = null;
let pending: Promise<void> | null = null;
const listeners = new Set<() => void>();

const start = () => {
if (loaded || pending || typeof window === "undefined") return;
pending = loader().then(
(component) => {
loaded = component;
for (const listener of listeners) listener();
},
() => {
pending = null;
},
);
};
const subscribe = (listener: () => void) => {
listeners.add(listener);
start();
return () => {
listeners.delete(listener);
};
};
const ready = () => loaded !== null;
const current = () => loaded;
const none = () => null;

function EarlyCard(props: P) {
const Card = useSyncExternalStore(subscribe, current, none);
return Card ? <Card {...props} /> : null;
}

return { Card: EarlyCard, start, subscribe, ready };
}
4 changes: 4 additions & 0 deletions tests/release-gate-journeys.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -236,6 +236,10 @@ test("Morning bed meeting: a ward confirms its numbers and the coordinator's cap
test("Transfer with transport: the ED books transport, the officer delivers, and the ward confirms arrival", async ({
page,
}, testInfo) => {
// Three desks, ten accessibility checks and three desk switches: the longest journey here. Phone
// WebKit on a hosted runner took 1.3 min on 11 Nov at 10:31Z and 2.1 min by 18:00Z with no step
// hanging (each click 1.5 to 3 s, each check about 2 s), so it alone gets three minutes.
test.setTimeout(180_000);
// WF-004 is seeded pulled into its accepted ward with a linked stay waiting for the patient, and
// no transport booked yet. A hand-authored transport job has no stay, so arrival could only move
// the ward's count; this one proves the patient's own stay becomes an occupied bed.
Expand Down
Loading
Loading