diff --git a/playwright.config.ts b/playwright.config.ts index e2978a0f54..7e213c214e 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -23,7 +23,7 @@ const chromiumExecutablePath = process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH; // `tests/playwright-project-isolation.test.ts` asserts every such file on disk is // matched here. const productionSpecPattern = - /.*(?:adaptive-answer-ui|api-csrf-proxy|answer-progress-ui-smoke|dsm-ui-smoke|ui-(admin|smoke|stress|accessibility|clinical-ask|cme-phone|dictionary|document-canvas|tools|tools-show-all|overlap|universal-search|specifiers|sources|formulation(?:-result-cards)?|forms-section-nav|chrome-scroll|therapy-nav-scroll|therapy-pathways|mode-nav-density|my-day-calendar|on-call-(?:boards|call|now|service)|teaching|patient-number-field|phone-motion|phone-scroll(?:-[a-z0-9-]+)?|pwa|roster-team|route-coverage|style-contract|token-layer-resolution|visual-artifacts|hydration|user-journeys))\.spec\.ts/; + /.*(?:adaptive-answer-ui|api-csrf-proxy|answer-progress-ui-smoke|dsm-ui-smoke|ui-(admin|smoke|stress|accessibility|clinical-ask|cme-phone|dictionary|document-canvas|tools|tools-show-all|overlap|universal-search|specifiers|sources|formulation(?:-result-cards)?|forms-section-nav|chrome-scroll|therapy-nav-scroll|therapy-pathways|mode-nav-density|my-day-calendar|on-call-(?:boards|call|now|service)|teaching|patient-number-field|phone-motion|phone-scroll(?:-[a-z0-9-]+)?|pwa|roster-team|open-shifts-withdraw|route-coverage|style-contract|token-layer-resolution|visual-artifacts|hydration|user-journeys))\.spec\.ts/; const mockupSpecPattern = /.*ui-(accessible-table-mockup|answer-chat-perfected-mockup|care-plan-mockup|document-image-status-mockup|document-top-navigation-mockup|sidebar-live-mockup|therapy-navigation-mockup|tools|tools-collapse|tools-search-mode-mockup|tools-task-directory)\.spec\.ts/; const mockupTag = /@mockup/; @@ -51,7 +51,7 @@ const iosInstallHintDismissed = { export default defineConfig({ testDir: "./tests", testMatch: - /.*(?:adaptive-answer-ui|api-csrf-proxy|answer-progress-ui-smoke|dsm-ui-smoke|ui-(admin|accessible-table-mockup|smoke|stress|accessibility|answer-chat-perfected-mockup|care-plan-mockup|clinical-ask|cme-phone|dictionary|document-canvas|document-image-status-mockup|document-top-navigation-mockup|sidebar-live-mockup|therapy-navigation-mockup|tools|tools-collapse|tools-show-all|tools-search-mode-mockup|tools-task-directory|overlap|universal-search|specifiers|sources|formulation(?:-result-cards)?|forms-section-nav|chrome-scroll|therapy-nav-scroll|therapy-pathways|mode-nav-density|my-day-calendar|on-call-(?:boards|call|now|service)|teaching|patient-number-field|phone-motion|phone-scroll(?:-[a-z0-9-]+)?|pwa|roster-team|route-coverage|style-contract|token-layer-resolution|visual-artifacts|hydration|user-journeys))\.spec\.ts/, + /.*(?:adaptive-answer-ui|api-csrf-proxy|answer-progress-ui-smoke|dsm-ui-smoke|ui-(admin|accessible-table-mockup|smoke|stress|accessibility|answer-chat-perfected-mockup|care-plan-mockup|clinical-ask|cme-phone|dictionary|document-canvas|document-image-status-mockup|document-top-navigation-mockup|sidebar-live-mockup|therapy-navigation-mockup|tools|tools-collapse|tools-show-all|tools-search-mode-mockup|tools-task-directory|overlap|universal-search|specifiers|sources|formulation(?:-result-cards)?|forms-section-nav|chrome-scroll|therapy-nav-scroll|therapy-pathways|mode-nav-density|my-day-calendar|on-call-(?:boards|call|now|service)|teaching|patient-number-field|phone-motion|phone-scroll(?:-[a-z0-9-]+)?|pwa|roster-team|open-shifts-withdraw|route-coverage|style-contract|token-layer-resolution|visual-artifacts|hydration|user-journeys))\.spec\.ts/, timeout: 60_000, retries: 0, // Fail the run if a stray `test.only` is committed: otherwise it silently diff --git a/scripts/lib/cross-tenant-roster-probe.ts b/scripts/lib/cross-tenant-roster-probe.ts index fceb85d098..4abea3f067 100644 --- a/scripts/lib/cross-tenant-roster-probe.ts +++ b/scripts/lib/cross-tenant-roster-probe.ts @@ -35,7 +35,7 @@ function actionSamples(id: string, userId: string, date: string): RosterAction[] })), { action: "open.post", assignmentId: id }, { action: "open.report", assignmentId: id }, - ...(["open.claim", "open.approve", "open.decline", "open.cancel"] as const).map((action) => ({ + ...(["open.claim", "open.approve", "open.decline", "open.cancel", "open.withdraw"] as const).map((action) => ({ action, openShiftId: id, })), diff --git a/scripts/playwright-pr-shards.mjs b/scripts/playwright-pr-shards.mjs index fe1d87c764..40b2bfce89 100644 --- a/scripts/playwright-pr-shards.mjs +++ b/scripts/playwright-pr-shards.mjs @@ -20,7 +20,7 @@ import { isDirectEntrypoint } from "./lib/is-entrypoint.mjs"; /** Same matcher as playwright.config.ts `productionSpecPattern` (keep in sync). */ export const productionSpecFilePattern = - /^(?:api-csrf-proxy|adaptive-answer-ui|answer-progress-ui-smoke|dsm-ui-smoke|ui-(?:admin|smoke|stress|accessibility|clinical-ask|cme-phone|dictionary|document-canvas|tools|tools-show-all|overlap|universal-search|specifiers|sources|formulation(?:-result-cards)?|forms-section-nav|chrome-scroll|therapy-nav-scroll|therapy-pathways|mode-nav-density|my-day-calendar|on-call-(?:boards|call|now|service)|teaching|patient-number-field|phone-motion|phone-scroll(?:-[a-z0-9-]+)?|pwa|roster-team|route-coverage|style-contract|token-layer-resolution|visual-artifacts|hydration|user-journeys))\.spec\.ts$/; + /^(?:api-csrf-proxy|adaptive-answer-ui|answer-progress-ui-smoke|dsm-ui-smoke|ui-(?:admin|smoke|stress|accessibility|clinical-ask|cme-phone|dictionary|document-canvas|tools|tools-show-all|overlap|universal-search|specifiers|sources|formulation(?:-result-cards)?|forms-section-nav|chrome-scroll|therapy-nav-scroll|therapy-pathways|mode-nav-density|my-day-calendar|on-call-(?:boards|call|now|service)|teaching|patient-number-field|phone-motion|phone-scroll(?:-[a-z0-9-]+)?|pwa|roster-team|open-shifts-withdraw|route-coverage|style-contract|token-layer-resolution|visual-artifacts|hydration|user-journeys))\.spec\.ts$/; /** * Timings: mean of the successful post-critical production Chromium reports from @@ -290,6 +290,13 @@ export const prUiSpecProfiles = Object.freeze([ fullSeconds: 13.3, criticalSeconds: 0, }, + { + // Estimated (one stale take-back test, new 12 Oct 2026); re-measure after the first PR timing report. + file: "tests/ui-open-shifts-withdraw.spec.ts", + shard: 2, + fullSeconds: 5, + criticalSeconds: 0, + }, { // Estimated (new assessment scrolling coverage); re-measure after the first PR timing report. file: "tests/ui-phone-scroll-assessments.spec.ts", diff --git a/scripts/playwright-release-shards.mjs b/scripts/playwright-release-shards.mjs index 4e4c691394..b747501722 100644 --- a/scripts/playwright-release-shards.mjs +++ b/scripts/playwright-release-shards.mjs @@ -73,6 +73,8 @@ export const releaseSpecSeconds = Object.freeze({ "tests/ui-phone-scroll.spec.ts": 215.0, "tests/ui-pwa.spec.ts": 1.8, "tests/ui-roster-team.spec.ts": 13.5, + // Estimated (one stale take-back test, new 12 Oct 2026; re-measure after the first release timing). + "tests/ui-open-shifts-withdraw.spec.ts": 5.0, "tests/ui-route-coverage.spec.ts": 26.7, "tests/ui-smoke.spec.ts": 266.8, "tests/ui-sources.spec.ts": 16.5, @@ -124,6 +126,8 @@ export const releaseSpecSeconds = Object.freeze({ "tests/ui-phone-scroll.spec.ts": 241.3, "tests/ui-pwa.spec.ts": 5.5, "tests/ui-roster-team.spec.ts": 22.9, + // Estimated (one stale take-back test, new 12 Oct 2026; re-measure after the first release timing). + "tests/ui-open-shifts-withdraw.spec.ts": 5.0, "tests/ui-route-coverage.spec.ts": 47.3, "tests/ui-smoke.spec.ts": 407.7, "tests/ui-sources.spec.ts": 24.1, @@ -175,6 +179,8 @@ export const releaseSpecSeconds = Object.freeze({ "tests/ui-phone-scroll.spec.ts": 358.3, "tests/ui-pwa.spec.ts": 6.3, "tests/ui-roster-team.spec.ts": 25.2, + // Estimated (one stale take-back test, new 12 Oct 2026; re-measure after the first release timing). + "tests/ui-open-shifts-withdraw.spec.ts": 5.0, "tests/ui-route-coverage.spec.ts": 61.4, "tests/ui-smoke.spec.ts": 674.0, "tests/ui-sources.spec.ts": 32.9, @@ -226,6 +232,8 @@ export const releaseSpecSeconds = Object.freeze({ "tests/ui-phone-scroll.spec.ts": 315.1, "tests/ui-pwa.spec.ts": 5.6, "tests/ui-roster-team.spec.ts": 21.1, + // Estimated (one stale take-back test, new 12 Oct 2026; re-measure after the first release timing). + "tests/ui-open-shifts-withdraw.spec.ts": 5.0, "tests/ui-route-coverage.spec.ts": 50.4, "tests/ui-smoke.spec.ts": 550.2, "tests/ui-sources.spec.ts": 37.2, @@ -284,6 +292,8 @@ export const releaseSpecSeconds = Object.freeze({ "tests/ui-pwa.spec.ts": 6.3, // Estimated from mobile-webkit (not reached before the timeout). "tests/ui-roster-team.spec.ts": 25.2, + // Estimated (one stale take-back test, new 12 Oct 2026; re-measure after the first release timing). + "tests/ui-open-shifts-withdraw.spec.ts": 5.0, // Estimated from mobile-webkit (not reached before the timeout). "tests/ui-route-coverage.spec.ts": 61.4, // Estimated from mobile-webkit (not reached before the timeout). diff --git a/src/components/open-shifts/open-shifts-advert-page.tsx b/src/components/open-shifts/open-shifts-advert-page.tsx index 8680d9a9c5..71f4408f3d 100644 --- a/src/components/open-shifts/open-shifts-advert-page.tsx +++ b/src/components/open-shifts/open-shifts-advert-page.tsx @@ -2,7 +2,7 @@ import { Building2, CircleCheck, CircleHelp, Clock, Lock, Scale, ShieldCheck, Users } from "lucide-react"; import Link from "next/link"; -import { useState, type ReactNode } from "react"; +import { useEffect, useRef, useState, type ReactNode } from "react"; import { useRosterNow } from "@/components/roster/roster-format"; import { postRosterAction } from "@/components/roster/use-roster-team"; @@ -249,6 +249,104 @@ export function RequestSheet({ ); } +/** + * Taking back a request that still waits for approval (owner decision 12 Oct 2026). The shift goes + * straight back on the list; an approved shift is on the roster, so it is given away through Roster. + */ +function WithdrawSheet({ + listing, + open, + onClose, + onWithdrawn, + onStale, +}: { + listing: OpenShiftListing; + open: boolean; + onClose: () => void; + onWithdrawn: () => void; + /** The request was decided before it could be taken back, so the page reads it again. */ + onStale: () => void; +}) { + const [busy, setBusy] = useState(false); + const [error, setError] = useState(null); + // Set once the request turns out decided, so it can't be retried against the old listing. + const [stale, setStale] = useState(false); + const [wasOpen, setWasOpen] = useState(open); + if (wasOpen !== open) { + setWasOpen(open); + if (open) { + setError(null); + setStale(false); + } + } + async function withdraw() { + setBusy(true); + setError(null); + const result = await postRosterAction(listing.serviceId, { + action: "open.withdraw", + openShiftId: listing.id, + } as RosterAction); + setBusy(false); + if (!result.ok) { + if (result.code === "roster_conflict" || result.code === "roster_not_found") { + setStale(true); + onStale(); + } + setError( + result.code === "roster_conflict" + ? "It can't be taken back now. Your roster manager has decided, or the shift has started. Check My shifts." + : result.code === "roster_not_found" + ? "This request can't be found any more. Check My shifts." + : `Not taken back. ${result.message}`, + ); + return; + } + onWithdrawn(); + } + return ( + + + + + } + > +
+
+

{shiftTitle(listing)}

+

{`${formatDayLong(perthDateOf(listing.startsAt))}, ${formatShiftTimes(listing.startsAt, listing.endsAt)}`}

+

{listing.siteName ?? listing.teamName}

+
+

+ The shift goes back on the list for everyone, and your roster manager no longer needs to decide. You can ask + again while it's still open. +

+ {error ? ( +

+ {error} +

+ ) : null} +
+
+ ); +} + export function OpenShiftsAdvertPage({ serviceId, openShiftId, @@ -265,7 +363,23 @@ export function OpenShiftsAdvertPage({ const [sheetOpen, setSheetOpen] = useState(false); // Kept mounted after the first open so closing hands focus back to "Request this shift". const [sheetLoaded, setSheetLoaded] = useState(false); - const [sent, setSent] = useState(null); + const [sentState, setSent] = useState(null); + // When the request was sent, so a later read that no longer shows it as this doctor's + // (declined or cancelled since) lets this visit's record of sending it lapse. + const [sentReadAt, setSentReadAt] = useState(null); + const [withdrawOpen, setWithdrawOpen] = useState(false); + const [withdrawLoaded, setWithdrawLoaded] = useState(false); + // The shift whose waiting request was taken back, so the page drops it before the reload lands. + const [withdrawnKey, setWithdrawnKey] = useState(null); + // The read the take-back was made against. Only that read is overridden: a newer read is the truth, + // so a request made again from another tab or device shows as waiting. + const [withdrawnReadAt, setWithdrawnReadAt] = useState(null); + const withdrawnNoticeRef = useRef(null); + // The button that opened the take-back is gone, so keyboard focus lands on the result instead, + // once per take-back rather than on every re-render. + useEffect(() => { + if (withdrawnKey) withdrawnNoticeRef.current?.focus(); + }, [withdrawnKey]); const [autoOpened, setAutoOpened] = useState(false); const listing = state.listings.find((row) => row.serviceId === serviceId && row.id === openShiftId) ?? null; @@ -315,14 +429,21 @@ export function OpenShiftsAdvertPage({ const timeText = endsNextDay(listing.startsAt, listing.endsAt) ? `${perthTimeOf(listing.startsAt)}–${perthTimeOf(listing.endsAt)} next day` : formatShiftTimes(listing.startsAt, listing.endsAt); - const alreadyMine = listing.claimedByMe || sent !== null; + const withdrawnHere = withdrawnKey === `${listing.serviceId}:${listing.id}`; + const withdrawn = withdrawnHere && (state.readAt?.getTime() ?? null) === withdrawnReadAt; + const sent = listing.claimedByMe || (state.readAt?.getTime() ?? null) === sentReadAt ? sentState : null; + const alreadyMine = (listing.claimedByMe && !withdrawn) || sent !== null; + const approved = sent === "approved" || listing.status === "approved"; + // A started shift can no longer be claimed, so the server refuses to reopen it. + const started = Date.parse(listing.startsAt) <= now.getTime(); + const canWithdraw = alreadyMine && !approved && !started && state.sample === null && !state.offline; const sample = state.sample !== null; // A deep link or a list read earlier can still name a shift nobody can ask for now. const closed: string | null = alreadyMine ? listing.status === "cancelled" && sent === null ? "cancelled" : null - : isBrowsable(listing, now) + : isBrowsable(listing, now) || (withdrawn && listing.claimedByMe && Date.parse(listing.startsAt) > now.getTime()) ? null : listing.mine ? "own" @@ -423,6 +544,18 @@ export function OpenShiftsAdvertPage({

+ {withdrawnHere && !alreadyMine ? ( +

+ {listing.claimedByMe || listing.status === "open" + ? "Request taken back. The shift is open again." + : "Request taken back."} +

+ ) : null} {signedOut ? ( ) : closed ? ( @@ -476,7 +609,7 @@ export function OpenShiftsAdvertPage({ role="status" className="flex items-start gap-3 rounded-lg border border-[color:var(--border)] px-4 py-3" > - {sent === "approved" || listing.status === "approved" ? ( + {approved ? (