Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
baab0cf
Open shifts: a doctor can take back a request still waiting for approval
claude Oct 12, 2026
0eb0110
Open shifts: re-read a request decided before it could be taken back
claude Oct 12, 2026
8c3ec2c
Merge branch 'main' into claude/open-shifts-withdraw-hj3sp1
BigSimmo Oct 12, 2026
ea9da23
Open shifts: forget this visit's sent request when a take-back finds …
claude Oct 12, 2026
5e482fe
Open shifts: refuse a take-back once the shift has started
claude Oct 12, 2026
851b479
Open shifts: tighten the take-back page after review
claude Oct 12, 2026
90f86c5
Open shifts: move focus to the take-back result once, not on every re…
claude Oct 12, 2026
9103f11
Open shifts: let this visit's request lapse once a later read shows i…
claude Oct 12, 2026
65ded15
Open shifts: read the clock after the locks, and word the take-back r…
claude Oct 12, 2026
7857214
Merge branch 'main' into claude/open-shifts-withdraw-hj3sp1
BigSimmo Oct 12, 2026
25573bf
Merge remote-tracking branch 'origin/main' into claude/open-shifts-wi…
claude Oct 12, 2026
353b515
Merge remote-tracking branch 'origin/main' into claude/open-shifts-wi…
claude Oct 12, 2026
9d178eb
Merge remote-tracking branch 'origin/main' into claude/open-shifts-wi…
claude Oct 12, 2026
00923f4
fix: Add browser coverage for stale take-back race
qodo-code-review[bot] Oct 12, 2026
9127b34
test(open-shifts): match the take-back notice by its text
claude Oct 12, 2026
08c5678
Open shifts: a newer read ends the take-back override; shard the take…
claude Oct 12, 2026
a5308ce
test(open-shifts): the take-back spec serves the server's changing vi…
claude Oct 12, 2026
9fb65dc
Merge remote-tracking branch 'origin/main' into claude/open-shifts-wi…
claude Oct 12, 2026
179461b
Merge remote-tracking branch 'origin/main' into claude/open-shifts-wi…
claude Oct 12, 2026
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
4 changes: 2 additions & 2 deletions playwright.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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/;
Comment thread
BigSimmo marked this conversation as resolved.
Resolved
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/;
Expand Down Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion scripts/lib/cross-tenant-roster-probe.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
})),
Expand Down
9 changes: 8 additions & 1 deletion scripts/playwright-pr-shards.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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",
Expand Down
10 changes: 10 additions & 0 deletions scripts/playwright-release-shards.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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).
Expand Down
178 changes: 172 additions & 6 deletions src/components/open-shifts/open-shifts-advert-page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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<string | null>(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;
}
Comment thread
BigSimmo marked this conversation as resolved.
Resolved
onWithdrawn();
}
return (
<Sheet
open={open}
onClose={onClose}
title="Take back your request?"
testId="open-shifts-withdraw"
footer={
<div className="flex flex-col gap-2">
<Button
variant="primary"
block
busy={busy}
busyLabel="Taking back"
disabled={stale}
onClick={() => void withdraw()}
>
Yes, take it back
</Button>
<Button variant="ghost" block onClick={onClose}>
Keep my request
</Button>
</div>
}
>
<div className="flex flex-col gap-4">
<div className="rounded-md bg-[color:var(--surface-subtle)] px-4 py-3 text-sm">
<p className="font-semibold text-[color:var(--text-heading)]">{shiftTitle(listing)}</p>
<p className="nums text-[color:var(--text)]">{`${formatDayLong(perthDateOf(listing.startsAt))}, ${formatShiftTimes(listing.startsAt, listing.endsAt)}`}</p>
<p className="text-[color:var(--text-muted)]">{listing.siteName ?? listing.teamName}</p>
</div>
<p className="text-sm text-[color:var(--text-muted)]">
The shift goes back on the list for everyone, and your roster manager no longer needs to decide. You can ask
again while it&apos;s still open.
</p>
{error ? (
<p role="alert" className="text-sm font-medium text-[color:var(--danger-text)]">
{error}
</p>
) : null}
</div>
</Sheet>
);
}

export function OpenShiftsAdvertPage({
serviceId,
openShiftId,
Expand All @@ -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<string | null>(null);
const [sentState, setSent] = useState<string | null>(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<number | null>(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<string | null>(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<number | null>(null);
const withdrawnNoticeRef = useRef<HTMLParagraphElement>(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;

Expand Down Expand Up @@ -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;
Comment thread
qodo-code-review[bot] marked this conversation as resolved.
Resolved
const sent = listing.claimedByMe || (state.readAt?.getTime() ?? null) === sentReadAt ? sentState : null;
const alreadyMine = (listing.claimedByMe && !withdrawn) || sent !== null;
Comment thread
BigSimmo marked this conversation as resolved.
Resolved
Comment thread
BigSimmo marked this conversation as resolved.
Resolved
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())
Comment thread
BigSimmo marked this conversation as resolved.
Resolved
? null
: listing.mine
? "own"
Expand Down Expand Up @@ -423,6 +544,18 @@ export function OpenShiftsAdvertPage({
</p>
</div>

{withdrawnHere && !alreadyMine ? (
<p
role="status"
ref={withdrawnNoticeRef}
tabIndex={-1}
className="mx-3 mt-4 text-sm font-medium text-[color:var(--text-heading)]"
>
{listing.claimedByMe || listing.status === "open"
? "Request taken back. The shift is open again."
: "Request taken back."}
</p>
Comment thread
BigSimmo marked this conversation as resolved.
Resolved
) : null}
{signedOut ? (
<SignInAction label="Sign in to request shifts" />
) : closed ? (
Expand Down Expand Up @@ -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 ? (
<CircleCheck
aria-hidden="true"
strokeWidth={1.6}
Expand All @@ -490,7 +623,7 @@ export function OpenShiftsAdvertPage({
/>
)}
<p className="text-sm text-[color:var(--text)]">
{sent === "approved" || listing.status === "approved" ? (
{approved ? (
<>
<b className="block font-semibold text-[color:var(--text-heading)]">Approved</b>It&apos;s on your
roster now.
Expand All @@ -509,6 +642,18 @@ export function OpenShiftsAdvertPage({
>
Go to My shifts
</Link>
{canWithdraw ? (
<Button
variant="ghost"
block
onClick={() => {
setWithdrawLoaded(true);
setWithdrawOpen(true);
}}
>
Take back my request
</Button>
) : null}
</FootAction>
) : state.offline ? (
<FootAction note="Nothing has been sent. Try again when you're back online.">
Expand Down Expand Up @@ -561,12 +706,33 @@ export function OpenShiftsAdvertPage({
open={sheetOpen}
onClose={() => setSheetOpen(false)}
onSent={(status) => {
setWithdrawnKey(null);
setSent(status);
setSentReadAt(state.readAt?.getTime() ?? null);
setSheetOpen(false);
state.reload();
}}
/>
) : null}
{withdrawLoaded ? (
<WithdrawSheet
listing={listing}
open={withdrawOpen}
onClose={() => setWithdrawOpen(false)}
onStale={() => {
// The request was decided elsewhere, so this visit's own record of sending it no longer holds.
setSent(null);
state.reload();
}}
Comment thread
BigSimmo marked this conversation as resolved.
Resolved
onWithdrawn={() => {
setSent(null);
setWithdrawnKey(`${listing.serviceId}:${listing.id}`);
setWithdrawnReadAt(state.readAt?.getTime() ?? null);
setWithdrawOpen(false);
Comment thread
BigSimmo marked this conversation as resolved.
Resolved
state.reload();
}}
/>
) : null}
</div>
);
}
1 change: 1 addition & 0 deletions src/lib/roster/team/demo-team-core.ts
Original file line number Diff line number Diff line change
Expand Up @@ -427,6 +427,7 @@ export function demoRosterCommand(action: RosterAction): RosterCommandResult {
return { ok: true, openShiftId: action.openShiftId, status: "filled" };
case "open.decline":
case "open.release":
case "open.withdraw":
return { ok: true, openShiftId: action.openShiftId, status: "open" };
case "open.cancel":
return { ok: true, openShiftId: action.openShiftId, status: "cancelled" };
Expand Down
2 changes: 2 additions & 0 deletions src/lib/roster/team/model.ts
Original file line number Diff line number Diff line change
Expand Up @@ -487,6 +487,8 @@ export const rosterActionSchema = z.union([
openIdAction("open.approve"),
openIdAction("open.decline"),
openIdAction("open.cancel"),
// A doctor takes back their own request while it still waits for approval (roster_open_withdraw).
openIdAction("open.withdraw"),
z.object({ action: z.literal("open.release"), openShiftId: uuid, urgent: z.boolean().optional() }).strict(),
z.object({ action: z.literal("seen.mark"), publicationId: uuid }).strict(),
// The team's safe number: replaces its whole list of cover needs. Manager only (the SQL checks).
Expand Down
Loading
Loading