- {sent === "approved" || listing.status === "approved" ? (
+ {approved ? (
<>
ApprovedIt's on your
roster now.
@@ -479,6 +569,18 @@ export function OpenShiftsAdvertPage({ serviceId, openShiftId }: { serviceId: st
>
Go to My shifts
+ {canWithdraw ? (
+
+ ) : null}
) : state.offline ? (
@@ -531,12 +633,26 @@ export function OpenShiftsAdvertPage({ serviceId, openShiftId }: { serviceId: st
open={sheetOpen}
onClose={() => setSheetOpen(false)}
onSent={(status) => {
+ setWithdrawn(false);
setSent(status);
setSheetOpen(false);
state.reload();
}}
/>
) : null}
+ {withdrawLoaded ? (
+ setWithdrawOpen(false)}
+ onWithdrawn={() => {
+ setSent(null);
+ setWithdrawn(true);
+ setWithdrawOpen(false);
+ state.reload();
+ }}
+ />
+ ) : null}
);
}
diff --git a/src/lib/roster/team/demo-team-core.ts b/src/lib/roster/team/demo-team-core.ts
index 8ca73f0720..b63afdf72b 100644
--- a/src/lib/roster/team/demo-team-core.ts
+++ b/src/lib/roster/team/demo-team-core.ts
@@ -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" };
diff --git a/src/lib/roster/team/model.ts b/src/lib/roster/team/model.ts
index 9f3f7dedb7..4557614e5a 100644
--- a/src/lib/roster/team/model.ts
+++ b/src/lib/roster/team/model.ts
@@ -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).
diff --git a/src/lib/roster/team/repository.ts b/src/lib/roster/team/repository.ts
index 313240d010..6b024f5b43 100644
--- a/src/lib/roster/team/repository.ts
+++ b/src/lib/roster/team/repository.ts
@@ -79,20 +79,27 @@ export async function rosterCommand(
requireActor(actorId);
const { action: name, ...payload } = action;
// The safe number goes through the stale check, which refuses it once anyone has saved since the editor's read.
+ // Taking back an open-shift request has its own function, which only the doctor who asked can call.
const { data, error } =
- action.action === "needs.set"
- ? await client.rpc("roster_needs_replace", {
+ action.action === "open.withdraw"
+ ? await client.rpc("roster_open_withdraw", {
p_actor_id: actorId,
p_service_id: serviceId,
- p_expected_ids: action.expectedIds as never,
- p_needs: action.needs as never,
+ p_open_shift_id: action.openShiftId,
})
- : await client.rpc("roster_command", {
- p_actor_id: actorId,
- p_service_id: serviceId,
- p_action: name,
- p_payload: payload as never,
- });
+ : action.action === "needs.set"
+ ? await client.rpc("roster_needs_replace", {
+ p_actor_id: actorId,
+ p_service_id: serviceId,
+ p_expected_ids: action.expectedIds as never,
+ p_needs: action.needs as never,
+ })
+ : await client.rpc("roster_command", {
+ p_actor_id: actorId,
+ p_service_id: serviceId,
+ p_action: name,
+ p_payload: payload as never,
+ });
if (error) throw rosterApiError(error);
const parsed = rosterCommandResultSchema.safeParse(data);
if (!parsed.success) throw rosterUnavailable();
diff --git a/src/lib/supabase/database.types.ts b/src/lib/supabase/database.types.ts
index a490c10fa2..d6171f7116 100644
--- a/src/lib/supabase/database.types.ts
+++ b/src/lib/supabase/database.types.ts
@@ -4941,6 +4941,10 @@ export type Database = {
Args: { p_actor_id: string; p_service_id: string; p_expected_ids: Json; p_needs: Json };
Returns: Json;
};
+ roster_open_withdraw: {
+ Args: { p_actor_id: string; p_service_id: string; p_open_shift_id: string };
+ Returns: Json;
+ };
on_call_service_set_verified: {
Args: { p_service_id: string; p_actor_id: string; p_verified: boolean; p_is_demo: boolean };
Returns: Json;
diff --git a/supabase/applied-migration-hashes.json b/supabase/applied-migration-hashes.json
index 495792169b..381c284a50 100644
--- a/supabase/applied-migration-hashes.json
+++ b/supabase/applied-migration-hashes.json
@@ -1,7 +1,7 @@
{
"note": "sha256 of every migration this repository has shipped, with newlines normalised. An entry whose hash changes means an applied migration was edited, which cannot reach the live database. See scripts/check-migration-immutability.mjs.",
"generator": "scripts/check-migration-immutability.mjs",
- "sealed_at": "2026-10-11T06:04:22.664Z",
+ "sealed_at": "2026-10-12T08:58:13.775Z",
"versions": {
"20260527000000_bulk_ingestion.sql": "813e8e314064a6dcbaaa55e2765a8ce901d5783b304b4a7af7bb7417a07daf7c",
"20260527001000_documents_policy_cleanup.sql": "559c26f3172bf75e545a41adef75538b2bf648ea7d53c8eb3d3ea7c244c65a48",
@@ -302,6 +302,7 @@
"20261009225900_work_bookings_retention.sql": "67800d7b4136b2955b8f34360e9b79564d2bd9c89aa66397548b1e1b3e4fc5cb",
"20261010011100_work_bookings_organiser_role.sql": "e8c284f391bccc4d6327968e076ef15dd8f17d9a4ff40254a01f92ea687cad76",
"20261010081500_roster_needs_stale_check.sql": "b9db1f643397ab1919d087d8cede955272c22474353b060acdf19a617db1b7d8",
- "20261010120000_evaluate_site_content_release_digest_once_per_health_read.sql": "4d9cf9487e74d3773c0263928fa5546b2d4ed60877c8cfdd179cbf1e16b866c2"
+ "20261010120000_evaluate_site_content_release_digest_once_per_health_read.sql": "4d9cf9487e74d3773c0263928fa5546b2d4ed60877c8cfdd179cbf1e16b866c2",
+ "20261012090000_roster_open_withdraw.sql": "09baf7b30abf8b39bcf00169f553187a61f0891169df6f9f3bda807f7ce00ba8"
}
}
diff --git a/supabase/drift-manifest.json b/supabase/drift-manifest.json
index 54f3cf20cd..5a3caf8993 100644
--- a/supabase/drift-manifest.json
+++ b/supabase/drift-manifest.json
@@ -2,7 +2,7 @@
"generated_at": "2026-10-11T06:07:15.735Z",
"generator": "scripts/generate-drift-manifest.ts",
"postgres_image": "supabase/postgres:17.6.1.127@sha256:be60aee15997daca475b710b734bc6bfe52cd544dcd7e9fd2ff58210b6747d83",
- "schema_sha256": "81cf0bcf6adba44b01ade3e0961a0335766e57c9596160cf37d7ce9c6d04dde4",
+ "schema_sha256": "dab7d9beae6f335c5517c176237b5e8b6cd01ac55035c4a6e33d755e3e069967",
"replay_seconds": 13,
"snapshot": {
"views": [
@@ -19096,6 +19096,14 @@
"def_hash": "2c12e7f103e1b697134442322ee25fd3",
"signature": "public.roster_open_shifts_mark_reported()"
},
+ {
+ "acl": [
+ "postgres=X/postgres",
+ "service_role=X/postgres"
+ ],
+ "def_hash": "8420bb35d2ef293c223c5a40bc234de8",
+ "signature": "public.roster_open_withdraw(uuid,uuid,uuid)"
+ },
{
"acl": [
"postgres=X/postgres",
diff --git a/supabase/migrations/20261012090000_roster_open_withdraw.sql b/supabase/migrations/20261012090000_roster_open_withdraw.sql
new file mode 100644
index 0000000000..c18fdc7706
--- /dev/null
+++ b/supabase/migrations/20261012090000_roster_open_withdraw.sql
@@ -0,0 +1,42 @@
+-- Never edit an applied migration. Merging this applies it to the live database.
+-- A doctor can take back their own open-shift request while it is still waiting for approval
+-- (owner decision 12 Oct 2026, Open shifts expansion item 10, "Go ahead with your recommendations").
+-- Until now only a roster manager could clear a waiting request (open.decline), so a doctor who
+-- asked by mistake, or whose plans changed, had to message the manager. Taking it back puts the
+-- shift straight back on the list for everyone else. An approved shift is already on the roster,
+-- so it is not taken back here: the doctor gives it away through Roster as for any other shift.
+--
+-- One new function. roster_command is unchanged. No table or data changes.
+set local lock_timeout = '5s';
+set local statement_timeout = '30s';
+
+create function public.roster_open_withdraw(p_actor_id uuid, p_service_id uuid, p_open_shift_id uuid)
+returns jsonb language plpgsql security invoker set search_path = public, pg_catalog, pg_temp as $$
+declare
+ v_service public.on_call_services;
+ v_open public.roster_open_shifts;
+begin
+ if p_actor_id is null then raise exception 'roster_auth_required'; end if;
+ if p_service_id is null or p_open_shift_id is null then raise exception 'roster_invalid_request'; end if;
+ -- The same lock order as every team write: the service row FOR SHARE, then the per-service
+ -- advisory lock, then the row itself.
+ select * into v_service from public.on_call_services where id = p_service_id for share;
+ if not found or not public.service_member_active(p_service_id, p_actor_id) then
+ raise exception 'roster_access_denied';
+ end if;
+ if v_service.verified_at is null and not v_service.is_demo then raise exception 'roster_team_not_verified'; end if;
+ perform pg_advisory_xact_lock(hashtextextended(p_service_id::text, 74817));
+
+ select * into v_open from public.roster_open_shifts
+ where id = p_open_shift_id and service_id = p_service_id for update;
+ -- Someone else's request reads as missing, so nobody learns who asked for what.
+ if not found or v_open.claimed_by is distinct from p_actor_id then raise exception 'roster_not_found'; end if;
+ -- Approved, cancelled or expired since the doctor last looked: nothing is changed.
+ if v_open.status <> 'claimed' then raise exception 'roster_conflict'; end if;
+
+ update public.roster_open_shifts set status = 'open', claimed_by = null, claimed_at = null where id = v_open.id;
+ return jsonb_build_object('openShiftId', v_open.id, 'status', 'open');
+end $$;
+
+revoke all on function public.roster_open_withdraw(uuid, uuid, uuid) from public, anon, authenticated;
+grant execute on function public.roster_open_withdraw(uuid, uuid, uuid) to service_role;
diff --git a/supabase/schema.sql b/supabase/schema.sql
index 018f1a9704..052f683905 100644
--- a/supabase/schema.sql
+++ b/supabase/schema.sql
@@ -27284,3 +27284,36 @@ end $$;
revoke all on function public.roster_needs_replace(uuid, uuid, jsonb, jsonb) from public, anon, authenticated;
grant execute on function public.roster_needs_replace(uuid, uuid, jsonb, jsonb) to service_role;
+
+-- A doctor takes back their own open-shift request while it still waits for approval
+-- (20261012090000_roster_open_withdraw.sql). roster_command is unchanged.
+create function public.roster_open_withdraw(p_actor_id uuid, p_service_id uuid, p_open_shift_id uuid)
+returns jsonb language plpgsql security invoker set search_path = public, pg_catalog, pg_temp as $$
+declare
+ v_service public.on_call_services;
+ v_open public.roster_open_shifts;
+begin
+ if p_actor_id is null then raise exception 'roster_auth_required'; end if;
+ if p_service_id is null or p_open_shift_id is null then raise exception 'roster_invalid_request'; end if;
+ -- The same lock order as every team write: the service row FOR SHARE, then the per-service
+ -- advisory lock, then the row itself.
+ select * into v_service from public.on_call_services where id = p_service_id for share;
+ if not found or not public.service_member_active(p_service_id, p_actor_id) then
+ raise exception 'roster_access_denied';
+ end if;
+ if v_service.verified_at is null and not v_service.is_demo then raise exception 'roster_team_not_verified'; end if;
+ perform pg_advisory_xact_lock(hashtextextended(p_service_id::text, 74817));
+
+ select * into v_open from public.roster_open_shifts
+ where id = p_open_shift_id and service_id = p_service_id for update;
+ -- Someone else's request reads as missing, so nobody learns who asked for what.
+ if not found or v_open.claimed_by is distinct from p_actor_id then raise exception 'roster_not_found'; end if;
+ -- Approved, cancelled or expired since the doctor last looked: nothing is changed.
+ if v_open.status <> 'claimed' then raise exception 'roster_conflict'; end if;
+
+ update public.roster_open_shifts set status = 'open', claimed_by = null, claimed_at = null where id = v_open.id;
+ return jsonb_build_object('openShiftId', v_open.id, 'status', 'open');
+end $$;
+
+revoke all on function public.roster_open_withdraw(uuid, uuid, uuid) from public, anon, authenticated;
+grant execute on function public.roster_open_withdraw(uuid, uuid, uuid) to service_role;
diff --git a/tests/open-shifts-withdraw.dom.test.tsx b/tests/open-shifts-withdraw.dom.test.tsx
new file mode 100644
index 0000000000..6479a28a5f
--- /dev/null
+++ b/tests/open-shifts-withdraw.dom.test.tsx
@@ -0,0 +1,103 @@
+/**
+ * @vitest-environment jsdom
+ */
+import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+
+import type { OpenShiftsState } from "@/components/open-shifts/use-open-shifts";
+import type { OpenShiftListing } from "@/lib/open-shifts/model";
+import { sampleListings, sampleRoster } from "@/lib/open-shifts/sample";
+
+// Mon 5 Oct 2026, 08:00 Perth.
+const NOW = new Date("2026-10-05T00:00:00.000Z");
+
+const mocks = vi.hoisted(() => ({
+ state: null as unknown as OpenShiftsState,
+ post: vi.fn(),
+}));
+vi.mock("@/components/open-shifts/use-open-shifts", () => ({ useOpenShifts: () => mocks.state }));
+vi.mock("@/components/mode-kit/use-signed-out-sample", () => ({ useSignedOut: () => false }));
+vi.mock("@/components/roster/roster-format", async (importOriginal) => ({
+ ...(await importOriginal()),
+ useRosterNow: () => NOW,
+}));
+vi.mock("@/components/roster/use-roster-team", async (importOriginal) => ({
+ ...(await importOriginal()),
+ postRosterAction: mocks.post,
+}));
+
+import { OpenShiftsAdvertPage } from "@/components/open-shifts/open-shifts-advert-page";
+
+function waiting(overrides: Partial = {}): OpenShiftListing {
+ const future = sampleListings(NOW).find((row) => Date.parse(row.startsAt) > NOW.getTime() + 86_400_000)!;
+ return { ...future, status: "claimed", claimedByMe: true, mine: false, ...overrides };
+}
+
+function stateWith(listing: OpenShiftListing, overrides: Partial = {}): OpenShiftsState {
+ return {
+ status: "ready",
+ listings: [listing],
+ teams: [],
+ roster: sampleRoster(NOW),
+ rosterStatus: "ready",
+ readAt: NOW,
+ sample: null,
+ offline: false,
+ failedTeams: [],
+ refreshFailed: false,
+ actorId: null,
+ message: null,
+ reload: vi.fn(),
+ ...overrides,
+ };
+}
+
+function renderFor(listing: OpenShiftListing) {
+ return render();
+}
+
+beforeEach(() => mocks.post.mockReset());
+afterEach(cleanup);
+
+describe("Open shifts, taking back a waiting request", () => {
+ it("takes back a waiting request after the doctor confirms, and says the shift is open again", async () => {
+ const listing = waiting();
+ mocks.state = stateWith(listing);
+ mocks.post.mockResolvedValue({ ok: true, result: { ok: true, openShiftId: listing.id, status: "open" } });
+ renderFor(listing);
+ expect(screen.getByText("Waiting for approval")).toBeTruthy();
+ fireEvent.click(screen.getByRole("button", { name: "Take back my request" }));
+ // Nothing is sent until the doctor confirms.
+ expect(mocks.post).not.toHaveBeenCalled();
+ fireEvent.click(screen.getByRole("button", { name: "Yes, take it back" }));
+ await waitFor(() => expect(screen.getByText("Request taken back. The shift is open again.")).toBeTruthy());
+ expect(mocks.post).toHaveBeenCalledWith(listing.serviceId, { action: "open.withdraw", openShiftId: listing.id });
+ expect(mocks.state.reload).toHaveBeenCalledOnce();
+ expect(screen.queryByText("Waiting for approval")).toBeNull();
+ expect(screen.queryByText(/Someone else has it now/)).toBeNull();
+ });
+
+ it("explains a request decided before it could be taken back, and keeps it", async () => {
+ const listing = waiting();
+ mocks.state = stateWith(listing);
+ mocks.post.mockResolvedValue({ ok: false, code: "roster_conflict", message: "Already decided." });
+ renderFor(listing);
+ fireEvent.click(screen.getByRole("button", { name: "Take back my request" }));
+ fireEvent.click(screen.getByRole("button", { name: "Yes, take it back" }));
+ await waitFor(() => expect(screen.getByRole("alert").textContent).toMatch(/decided before it could be taken back/));
+ expect(screen.getByText("Waiting for approval")).toBeTruthy();
+ });
+
+ it("never offers to take back an approved shift, or while offline", () => {
+ const approved = waiting({ status: "approved" });
+ mocks.state = stateWith(approved);
+ renderFor(approved);
+ expect(screen.queryByRole("button", { name: "Take back my request" })).toBeNull();
+ cleanup();
+
+ const listing = waiting();
+ mocks.state = stateWith(listing, { offline: true });
+ renderFor(listing);
+ expect(screen.queryByRole("button", { name: "Take back my request" })).toBeNull();
+ });
+});
diff --git a/tests/roster-team-api.test.ts b/tests/roster-team-api.test.ts
index f96c7a398c..6a8c0eba8f 100644
--- a/tests/roster-team-api.test.ts
+++ b/tests/roster-team-api.test.ts
@@ -247,6 +247,22 @@ describe("writing to a team", () => {
});
});
+ it("sends a doctor's take-back of their open-shift request (open.withdraw) to its own function", async () => {
+ mocks.rpc.mockResolvedValue({ data: { openShiftId: SWAP, status: "open" }, error: null });
+ const response = await POST(jsonRequest({ action: "open.withdraw", openShiftId: SWAP }), ctx());
+ expect(response.status).toBe(200);
+ expect(mocks.rpc).toHaveBeenCalledTimes(1);
+ expect(mocks.rpc).toHaveBeenCalledWith("roster_open_withdraw", {
+ p_actor_id: ALEX,
+ p_service_id: SERVICE,
+ p_open_shift_id: SWAP,
+ });
+ expect((await POST(jsonRequest({ action: "open.withdraw", openShiftId: "not-an-id" }), ctx())).status).toBe(400);
+ expect((await POST(jsonRequest({ action: "open.withdraw", openShiftId: SWAP, userId: ALEX }), ctx())).status).toBe(
+ 400,
+ );
+ });
+
it("answers a safe number saved over a stale read with 409 roster_conflict", async () => {
mocks.rpc.mockResolvedValue({ data: null, error: { message: "roster_conflict" } });
const response = await POST(jsonRequest({ action: "needs.set", expectedIds: [], needs: [] }), ctx());
diff --git a/tests/sql/roster-behaviour.sql b/tests/sql/roster-behaviour.sql
index 98797db7f4..fad2777c23 100644
--- a/tests/sql/roster-behaviour.sql
+++ b/tests/sql/roster-behaviour.sql
@@ -163,11 +163,26 @@ begin
if r ->> 'status' is distinct from 'claimed' then raise exception 'open: expected different-grade claim pending approval, got %', r; end if;
perform pg_temp.expect_error(format($f$select pg_temp.cmd('mei', 'open.claim', %L)$f$, jsonb_build_object('openShiftId', oid)),
'roster_open_shift_taken');
+ -- Taking back a waiting request: only the doctor who asked can, and the shift is open again.
+ perform pg_temp.expect_error(format('select public.roster_open_withdraw(%L,%L,%L)',
+ pg_temp.id('mei'), pg_temp.id('svc'), oid), 'roster_not_found');
+ perform pg_temp.expect_error(format('select public.roster_open_withdraw(%L,%L,%L)',
+ pg_temp.id('mgr'), pg_temp.id('svc'), oid), 'roster_not_found');
+ r := public.roster_open_withdraw(pg_temp.id('lee'), pg_temp.id('svc'), oid);
+ if r ->> 'status' is distinct from 'open'
+ or exists (select 1 from public.roster_open_shifts where id = oid and (status <> 'open' or claimed_by is not null or claimed_at is not null)) then
+ raise exception 'open: withdraw did not reopen the shift, got %', r;
+ end if;
+ r := pg_temp.cmd('lee', 'open.claim', jsonb_build_object('openShiftId', oid));
+ if r ->> 'status' is distinct from 'claimed' then raise exception 'open: claim after withdraw failed %', r; end if;
r := pg_temp.cmd('mgr', 'open.approve', jsonb_build_object('openShiftId', oid));
if r ->> 'status' is distinct from 'approved' then raise exception 'open: manager approval failed %', r; end if;
if (select user_id from public.roster_assignments where id = day) is distinct from pg_temp.id('lee') then
raise exception 'open: approved assignment did not move to claimant';
end if;
+ -- An approved shift is on the roster, so it is never taken back here.
+ perform pg_temp.expect_error(format('select public.roster_open_withdraw(%L,%L,%L)',
+ pg_temp.id('lee'), pg_temp.id('svc'), oid), 'roster_conflict');
end
$open$;
From 0eb011092e45c50fe4651a09c525971d9ec61637 Mon Sep 17 00:00:00 2001
From: Claude
Date: Mon, 12 Oct 2026 09:13:37 +0000
Subject: [PATCH 02/12] Open shifts: re-read a request decided before it could
be taken back
Co-Authored-By: Claude Opus 5.5 (1M context)
Claude-Session: https://claude.ai/code/session_01CZwGiwt7eXkD4fwyFM83gY
---
src/components/open-shifts/open-shifts-advert-page.tsx | 5 +++++
tests/open-shifts-withdraw.dom.test.tsx | 3 ++-
2 files changed, 7 insertions(+), 1 deletion(-)
diff --git a/src/components/open-shifts/open-shifts-advert-page.tsx b/src/components/open-shifts/open-shifts-advert-page.tsx
index 34226b42e8..2e578972a2 100644
--- a/src/components/open-shifts/open-shifts-advert-page.tsx
+++ b/src/components/open-shifts/open-shifts-advert-page.tsx
@@ -257,11 +257,14 @@ function WithdrawSheet({
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);
@@ -279,6 +282,7 @@ function WithdrawSheet({
} as RosterAction);
setBusy(false);
if (!result.ok) {
+ if (result.code === "roster_conflict" || result.code === "roster_not_found") onStale();
setError(
result.code === "roster_conflict"
? "Your roster manager decided before it could be taken back. Check My shifts."
@@ -645,6 +649,7 @@ export function OpenShiftsAdvertPage({ serviceId, openShiftId }: { serviceId: st
listing={listing}
open={withdrawOpen}
onClose={() => setWithdrawOpen(false)}
+ onStale={state.reload}
onWithdrawn={() => {
setSent(null);
setWithdrawn(true);
diff --git a/tests/open-shifts-withdraw.dom.test.tsx b/tests/open-shifts-withdraw.dom.test.tsx
index 6479a28a5f..45485042c6 100644
--- a/tests/open-shifts-withdraw.dom.test.tsx
+++ b/tests/open-shifts-withdraw.dom.test.tsx
@@ -85,7 +85,8 @@ describe("Open shifts, taking back a waiting request", () => {
fireEvent.click(screen.getByRole("button", { name: "Take back my request" }));
fireEvent.click(screen.getByRole("button", { name: "Yes, take it back" }));
await waitFor(() => expect(screen.getByRole("alert").textContent).toMatch(/decided before it could be taken back/));
- expect(screen.getByText("Waiting for approval")).toBeTruthy();
+ // The page reads the request again, so it shows what the manager decided.
+ expect(mocks.state.reload).toHaveBeenCalledOnce();
});
it("never offers to take back an approved shift, or while offline", () => {
From ea9da23238f19f5c850cd2a741b11f11d50b87e3 Mon Sep 17 00:00:00 2001
From: Claude
Date: Mon, 12 Oct 2026 12:33:29 +0000
Subject: [PATCH 03/12] Open shifts: forget this visit's sent request when a
take-back finds it decided
Co-Authored-By: Claude Opus 5.5 (1M context)
Claude-Session: https://claude.ai/code/session_01CZwGiwt7eXkD4fwyFM83gY
---
.../open-shifts/open-shifts-advert-page.tsx | 6 +++++-
tests/open-shifts-withdraw.dom.test.tsx | 20 +++++++++++++++++++
2 files changed, 25 insertions(+), 1 deletion(-)
diff --git a/src/components/open-shifts/open-shifts-advert-page.tsx b/src/components/open-shifts/open-shifts-advert-page.tsx
index 2e578972a2..dfc08c5f75 100644
--- a/src/components/open-shifts/open-shifts-advert-page.tsx
+++ b/src/components/open-shifts/open-shifts-advert-page.tsx
@@ -649,7 +649,11 @@ export function OpenShiftsAdvertPage({ serviceId, openShiftId }: { serviceId: st
listing={listing}
open={withdrawOpen}
onClose={() => setWithdrawOpen(false)}
- onStale={state.reload}
+ onStale={() => {
+ // The request was decided elsewhere, so this visit's own record of sending it no longer holds.
+ setSent(null);
+ state.reload();
+ }}
onWithdrawn={() => {
setSent(null);
setWithdrawn(true);
diff --git a/tests/open-shifts-withdraw.dom.test.tsx b/tests/open-shifts-withdraw.dom.test.tsx
index 45485042c6..32f01d5345 100644
--- a/tests/open-shifts-withdraw.dom.test.tsx
+++ b/tests/open-shifts-withdraw.dom.test.tsx
@@ -89,6 +89,26 @@ describe("Open shifts, taking back a waiting request", () => {
expect(mocks.state.reload).toHaveBeenCalledOnce();
});
+ it("drops a request sent on this visit once a take-back finds it already declined", async () => {
+ const open = waiting({ status: "open", claimedByMe: false });
+ mocks.state = stateWith(open);
+ const view = renderFor(open);
+ mocks.post.mockResolvedValueOnce({ ok: true, result: { ok: true, openShiftId: open.id, status: "claimed" } });
+ fireEvent.click(screen.getAllByRole("button", { name: "Request this shift" })[0]!);
+ fireEvent.click(screen.getByRole("checkbox"));
+ fireEvent.click(screen.getByRole("button", { name: "Yes, send request" }));
+ await waitFor(() => expect(screen.getByText("Waiting for approval")).toBeTruthy());
+
+ // The manager declines before the take-back lands, which clears the request.
+ mocks.post.mockResolvedValueOnce({ ok: false, code: "roster_not_found", message: "Not found." });
+ fireEvent.click(screen.getByRole("button", { name: "Take back my request" }));
+ fireEvent.click(screen.getByRole("button", { name: "Yes, take it back" }));
+ await waitFor(() => expect(mocks.state.reload).toHaveBeenCalledTimes(2));
+ mocks.state = stateWith(open);
+ view.rerender();
+ expect(screen.queryByText("Waiting for approval")).toBeNull();
+ });
+
it("never offers to take back an approved shift, or while offline", () => {
const approved = waiting({ status: "approved" });
mocks.state = stateWith(approved);
From 5e482fe767f3ab3c6120c956671eeee07f42efd1 Mon Sep 17 00:00:00 2001
From: Claude
Date: Mon, 12 Oct 2026 12:39:41 +0000
Subject: [PATCH 04/12] Open shifts: refuse a take-back once the shift has
started
A started shift can no longer be claimed, so reopening a waiting request
after the start time would strand the shift as open but unclaimable. The
function now answers roster_conflict for a started shift, the advert hides
the button, and the SQL behaviour test covers the refusal.
The migration hash is resealed on purpose: this migration is not on main
and has never been applied to production, only to this PR's preview
database. The drift manifest def_hash was recomputed with the
schema_drift_snapshot expression on a local Postgres 16, which reproduces
the previous committed hash for the old body exactly.
Co-Authored-By: Claude Opus 5.5 (1M context)
Claude-Session: https://claude.ai/code/session_01CZwGiwt7eXkD4fwyFM83gY
---
.../open-shifts/open-shifts-advert-page.tsx | 4 +++-
supabase/applied-migration-hashes.json | 4 ++--
supabase/drift-manifest.json | 4 ++--
.../20261012090000_roster_open_withdraw.sql | 6 ++++--
supabase/schema.sql | 6 ++++--
tests/open-shifts-withdraw.dom.test.tsx | 10 ++++++++++
tests/sql/roster-behaviour.sql | 11 ++++++++++-
7 files changed, 35 insertions(+), 10 deletions(-)
diff --git a/src/components/open-shifts/open-shifts-advert-page.tsx b/src/components/open-shifts/open-shifts-advert-page.tsx
index dfc08c5f75..b967197e39 100644
--- a/src/components/open-shifts/open-shifts-advert-page.tsx
+++ b/src/components/open-shifts/open-shifts-advert-page.tsx
@@ -393,7 +393,9 @@ export function OpenShiftsAdvertPage({ serviceId, openShiftId }: { serviceId: st
: formatShiftTimes(listing.startsAt, listing.endsAt);
const alreadyMine = (listing.claimedByMe && !withdrawn) || sent !== null;
const approved = sent === "approved" || listing.status === "approved";
- const canWithdraw = alreadyMine && !approved && state.sample === null && !state.offline;
+ // 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
diff --git a/supabase/applied-migration-hashes.json b/supabase/applied-migration-hashes.json
index 381c284a50..ee33347702 100644
--- a/supabase/applied-migration-hashes.json
+++ b/supabase/applied-migration-hashes.json
@@ -1,7 +1,7 @@
{
"note": "sha256 of every migration this repository has shipped, with newlines normalised. An entry whose hash changes means an applied migration was edited, which cannot reach the live database. See scripts/check-migration-immutability.mjs.",
"generator": "scripts/check-migration-immutability.mjs",
- "sealed_at": "2026-10-12T08:58:13.775Z",
+ "sealed_at": "2026-10-12T12:38:37.206Z",
"versions": {
"20260527000000_bulk_ingestion.sql": "813e8e314064a6dcbaaa55e2765a8ce901d5783b304b4a7af7bb7417a07daf7c",
"20260527001000_documents_policy_cleanup.sql": "559c26f3172bf75e545a41adef75538b2bf648ea7d53c8eb3d3ea7c244c65a48",
@@ -303,6 +303,6 @@
"20261010011100_work_bookings_organiser_role.sql": "e8c284f391bccc4d6327968e076ef15dd8f17d9a4ff40254a01f92ea687cad76",
"20261010081500_roster_needs_stale_check.sql": "b9db1f643397ab1919d087d8cede955272c22474353b060acdf19a617db1b7d8",
"20261010120000_evaluate_site_content_release_digest_once_per_health_read.sql": "4d9cf9487e74d3773c0263928fa5546b2d4ed60877c8cfdd179cbf1e16b866c2",
- "20261012090000_roster_open_withdraw.sql": "09baf7b30abf8b39bcf00169f553187a61f0891169df6f9f3bda807f7ce00ba8"
+ "20261012090000_roster_open_withdraw.sql": "5b79b4ca60e26f4c8a3353dcdfc27c5d677e0789df33f4e27414450d2ebe60c1"
}
}
diff --git a/supabase/drift-manifest.json b/supabase/drift-manifest.json
index 5a3caf8993..8728e02ce5 100644
--- a/supabase/drift-manifest.json
+++ b/supabase/drift-manifest.json
@@ -2,7 +2,7 @@
"generated_at": "2026-10-11T06:07:15.735Z",
"generator": "scripts/generate-drift-manifest.ts",
"postgres_image": "supabase/postgres:17.6.1.127@sha256:be60aee15997daca475b710b734bc6bfe52cd544dcd7e9fd2ff58210b6747d83",
- "schema_sha256": "dab7d9beae6f335c5517c176237b5e8b6cd01ac55035c4a6e33d755e3e069967",
+ "schema_sha256": "28e77e1120c520b3f8400a85252929a4c728db70f673b60053cb572b88bc680e",
"replay_seconds": 13,
"snapshot": {
"views": [
@@ -19101,7 +19101,7 @@
"postgres=X/postgres",
"service_role=X/postgres"
],
- "def_hash": "8420bb35d2ef293c223c5a40bc234de8",
+ "def_hash": "2845e24c86ad9dec2d6b158e994be600",
"signature": "public.roster_open_withdraw(uuid,uuid,uuid)"
},
{
diff --git a/supabase/migrations/20261012090000_roster_open_withdraw.sql b/supabase/migrations/20261012090000_roster_open_withdraw.sql
index c18fdc7706..0b08419fc9 100644
--- a/supabase/migrations/20261012090000_roster_open_withdraw.sql
+++ b/supabase/migrations/20261012090000_roster_open_withdraw.sql
@@ -31,8 +31,10 @@ begin
where id = p_open_shift_id and service_id = p_service_id for update;
-- Someone else's request reads as missing, so nobody learns who asked for what.
if not found or v_open.claimed_by is distinct from p_actor_id then raise exception 'roster_not_found'; end if;
- -- Approved, cancelled or expired since the doctor last looked: nothing is changed.
- if v_open.status <> 'claimed' then raise exception 'roster_conflict'; end if;
+ -- Approved, cancelled or expired since the doctor last looked, or the shift has already
+ -- started (a started shift can no longer be claimed, so reopening it would strand it): nothing
+ -- is changed.
+ if v_open.status <> 'claimed' or v_open.starts_at <= now() then raise exception 'roster_conflict'; end if;
update public.roster_open_shifts set status = 'open', claimed_by = null, claimed_at = null where id = v_open.id;
return jsonb_build_object('openShiftId', v_open.id, 'status', 'open');
diff --git a/supabase/schema.sql b/supabase/schema.sql
index 052f683905..ba5b23d018 100644
--- a/supabase/schema.sql
+++ b/supabase/schema.sql
@@ -27308,8 +27308,10 @@ begin
where id = p_open_shift_id and service_id = p_service_id for update;
-- Someone else's request reads as missing, so nobody learns who asked for what.
if not found or v_open.claimed_by is distinct from p_actor_id then raise exception 'roster_not_found'; end if;
- -- Approved, cancelled or expired since the doctor last looked: nothing is changed.
- if v_open.status <> 'claimed' then raise exception 'roster_conflict'; end if;
+ -- Approved, cancelled or expired since the doctor last looked, or the shift has already
+ -- started (a started shift can no longer be claimed, so reopening it would strand it): nothing
+ -- is changed.
+ if v_open.status <> 'claimed' or v_open.starts_at <= now() then raise exception 'roster_conflict'; end if;
update public.roster_open_shifts set status = 'open', claimed_by = null, claimed_at = null where id = v_open.id;
return jsonb_build_object('openShiftId', v_open.id, 'status', 'open');
diff --git a/tests/open-shifts-withdraw.dom.test.tsx b/tests/open-shifts-withdraw.dom.test.tsx
index 32f01d5345..7b549d6972 100644
--- a/tests/open-shifts-withdraw.dom.test.tsx
+++ b/tests/open-shifts-withdraw.dom.test.tsx
@@ -121,4 +121,14 @@ describe("Open shifts, taking back a waiting request", () => {
renderFor(listing);
expect(screen.queryByRole("button", { name: "Take back my request" })).toBeNull();
});
+
+ it("stops offering a take-back once the shift has started", () => {
+ const started = waiting({
+ startsAt: new Date(NOW.getTime() - 3_600_000).toISOString(),
+ endsAt: new Date(NOW.getTime() + 7 * 3_600_000).toISOString(),
+ });
+ mocks.state = stateWith(started);
+ renderFor(started);
+ expect(screen.queryByRole("button", { name: "Take back my request" })).toBeNull();
+ });
});
diff --git a/tests/sql/roster-behaviour.sql b/tests/sql/roster-behaviour.sql
index fad2777c23..4e8e018f7e 100644
--- a/tests/sql/roster-behaviour.sql
+++ b/tests/sql/roster-behaviour.sql
@@ -154,7 +154,7 @@ $clash$;
-- 8. Open shifts: the first eligible claim reserves it; a different grade needs manager approval.
do $open$
-declare day uuid; r jsonb; oid uuid;
+declare day uuid; r jsonb; oid uuid; was_start timestamptz; was_end timestamptz;
begin
select id into day from public.roster_assignments where user_id = pg_temp.id('ivy') and shift_code = 'D';
r := pg_temp.cmd('ivy', 'open.post', jsonb_build_object('assignmentId', day));
@@ -175,6 +175,15 @@ begin
end if;
r := pg_temp.cmd('lee', 'open.claim', jsonb_build_object('openShiftId', oid));
if r ->> 'status' is distinct from 'claimed' then raise exception 'open: claim after withdraw failed %', r; end if;
+ -- Once the shift has started it can no longer be claimed, so a waiting request is not taken back.
+ select starts_at, ends_at into was_start, was_end from public.roster_open_shifts where id = oid;
+ update public.roster_open_shifts set starts_at = now() - interval '1 hour', ends_at = now() + interval '7 hours' where id = oid;
+ perform pg_temp.expect_error(format('select public.roster_open_withdraw(%L,%L,%L)',
+ pg_temp.id('lee'), pg_temp.id('svc'), oid), 'roster_conflict');
+ if (select status from public.roster_open_shifts where id = oid) is distinct from 'claimed' then
+ raise exception 'open: refused withdraw of a started shift changed the row';
+ end if;
+ update public.roster_open_shifts set starts_at = was_start, ends_at = was_end where id = oid;
r := pg_temp.cmd('mgr', 'open.approve', jsonb_build_object('openShiftId', oid));
if r ->> 'status' is distinct from 'approved' then raise exception 'open: manager approval failed %', r; end if;
if (select user_id from public.roster_assignments where id = day) is distinct from pg_temp.id('lee') then
From 851b479b445f973c2f40d843b2888a84cc9ad0e0 Mon Sep 17 00:00:00 2001
From: Claude
Date: Mon, 12 Oct 2026 12:42:16 +0000
Subject: [PATCH 05/12] Open shifts: tighten the take-back page after review
- A take-back that finds the request already decided can't be retried
from the same sheet against the old listing.
- The taken-back marker is tied to the shift it came from, so another
shift shown by the same page keeps its own waiting state.
- After a take-back, keyboard focus moves to the result, because the
button that opened the sheet is gone.
- The result only says the shift is open again while it is.
Co-Authored-By: Claude Opus 5.5 (1M context)
Claude-Session: https://claude.ai/code/session_01CZwGiwt7eXkD4fwyFM83gY
---
.../open-shifts/open-shifts-advert-page.tsx | 42 +++++++++++++++----
tests/open-shifts-withdraw.dom.test.tsx | 19 +++++++++
2 files changed, 52 insertions(+), 9 deletions(-)
diff --git a/src/components/open-shifts/open-shifts-advert-page.tsx b/src/components/open-shifts/open-shifts-advert-page.tsx
index b967197e39..6898f64ddd 100644
--- a/src/components/open-shifts/open-shifts-advert-page.tsx
+++ b/src/components/open-shifts/open-shifts-advert-page.tsx
@@ -268,10 +268,15 @@ function WithdrawSheet({
}) {
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);
+ if (open) {
+ setError(null);
+ setStale(false);
+ }
}
async function withdraw() {
setBusy(true);
@@ -282,7 +287,10 @@ function WithdrawSheet({
} as RosterAction);
setBusy(false);
if (!result.ok) {
- if (result.code === "roster_conflict" || result.code === "roster_not_found") onStale();
+ if (result.code === "roster_conflict" || result.code === "roster_not_found") {
+ setStale(true);
+ onStale();
+ }
setError(
result.code === "roster_conflict"
? "Your roster manager decided before it could be taken back. Check My shifts."
@@ -302,7 +310,14 @@ function WithdrawSheet({
testId="open-shifts-withdraw"
footer={
-
{withdrawn && !alreadyMine ? (
-
- Request taken back. The shift is open again.
+
node?.focus()}
+ 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."}
) : null}
{signedOut ? (
@@ -639,7 +663,7 @@ export function OpenShiftsAdvertPage({ serviceId, openShiftId }: { serviceId: st
open={sheetOpen}
onClose={() => setSheetOpen(false)}
onSent={(status) => {
- setWithdrawn(false);
+ setWithdrawnKey(null);
setSent(status);
setSheetOpen(false);
state.reload();
@@ -658,7 +682,7 @@ export function OpenShiftsAdvertPage({ serviceId, openShiftId }: { serviceId: st
}}
onWithdrawn={() => {
setSent(null);
- setWithdrawn(true);
+ setWithdrawnKey(`${listing.serviceId}:${listing.id}`);
setWithdrawOpen(false);
state.reload();
}}
diff --git a/tests/open-shifts-withdraw.dom.test.tsx b/tests/open-shifts-withdraw.dom.test.tsx
index 7b549d6972..5cf46cd0de 100644
--- a/tests/open-shifts-withdraw.dom.test.tsx
+++ b/tests/open-shifts-withdraw.dom.test.tsx
@@ -75,6 +75,23 @@ describe("Open shifts, taking back a waiting request", () => {
expect(mocks.state.reload).toHaveBeenCalledOnce();
expect(screen.queryByText("Waiting for approval")).toBeNull();
expect(screen.queryByText(/Someone else has it now/)).toBeNull();
+ // The button that opened the take-back is gone, so focus moves to the result.
+ expect(document.activeElement?.textContent).toBe("Request taken back. The shift is open again.");
+ });
+
+ it("does not call the shift open when someone else asked for it before the page re-read it", async () => {
+ const listing = waiting();
+ mocks.state = stateWith(listing);
+ mocks.post.mockResolvedValue({ ok: true, result: { ok: true, openShiftId: listing.id, status: "open" } });
+ const view = renderFor(listing);
+ fireEvent.click(screen.getByRole("button", { name: "Take back my request" }));
+ fireEvent.click(screen.getByRole("button", { name: "Yes, take it back" }));
+ await waitFor(() => expect(mocks.state.reload).toHaveBeenCalledOnce());
+ const taken = { ...listing, status: "claimed" as const, claimedByMe: false };
+ mocks.state = stateWith(taken);
+ view.rerender();
+ expect(screen.getByText("Request taken back.")).toBeTruthy();
+ expect(screen.queryByText(/The shift is open again/)).toBeNull();
});
it("explains a request decided before it could be taken back, and keeps it", async () => {
@@ -87,6 +104,8 @@ describe("Open shifts, taking back a waiting request", () => {
await waitFor(() => expect(screen.getByRole("alert").textContent).toMatch(/decided before it could be taken back/));
// The page reads the request again, so it shows what the manager decided.
expect(mocks.state.reload).toHaveBeenCalledOnce();
+ // The same take-back can't be sent again against what the page last read.
+ expect((screen.getByRole("button", { name: "Yes, take it back" }) as HTMLButtonElement).disabled).toBe(true);
});
it("drops a request sent on this visit once a take-back finds it already declined", async () => {
From 90f86c530fdceac61639ff7e38798645d838a313 Mon Sep 17 00:00:00 2001
From: Claude
Date: Mon, 12 Oct 2026 12:45:01 +0000
Subject: [PATCH 06/12] Open shifts: move focus to the take-back result once,
not on every render
Co-Authored-By: Claude Opus 5.5 (1M context)
Claude-Session: https://claude.ai/code/session_01CZwGiwt7eXkD4fwyFM83gY
---
.../open-shifts/open-shifts-advert-page.tsx | 11 ++++++++---
1 file changed, 8 insertions(+), 3 deletions(-)
diff --git a/src/components/open-shifts/open-shifts-advert-page.tsx b/src/components/open-shifts/open-shifts-advert-page.tsx
index 6898f64ddd..49f226cdbf 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";
@@ -358,6 +358,12 @@ export function OpenShiftsAdvertPage({ serviceId, openShiftId }: { serviceId: st
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);
+ 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 listing = state.listings.find((row) => row.serviceId === serviceId && row.id === openShiftId) ?? null;
if (state.status === "loading") {
@@ -503,8 +509,7 @@ export function OpenShiftsAdvertPage({ serviceId, openShiftId }: { serviceId: st
{withdrawn && !alreadyMine ? (
node?.focus()}
+ ref={withdrawnNoticeRef}
tabIndex={-1}
className="mx-3 mt-4 text-sm font-medium text-[color:var(--text-heading)]"
>
From 9103f116bb6f1e35fae1fa41c70f51a12ad508ab Mon Sep 17 00:00:00 2001
From: Claude
Date: Mon, 12 Oct 2026 12:48:28 +0000
Subject: [PATCH 07/12] Open shifts: let this visit's request lapse once a
later read shows it decided
A request sent on this visit kept the page on Waiting for approval even
after a re-read showed the manager had declined or cancelled it. It now
holds only until a newer read no longer shows the request as this doctor's.
Co-Authored-By: Claude Opus 5.5 (1M context)
Claude-Session: https://claude.ai/code/session_01CZwGiwt7eXkD4fwyFM83gY
---
.../open-shifts/open-shifts-advert-page.tsx | 7 ++++++-
tests/open-shifts-withdraw.dom.test.tsx | 20 ++++++++++++++++++-
2 files changed, 25 insertions(+), 2 deletions(-)
diff --git a/src/components/open-shifts/open-shifts-advert-page.tsx b/src/components/open-shifts/open-shifts-advert-page.tsx
index 49f226cdbf..8cf70bf5a5 100644
--- a/src/components/open-shifts/open-shifts-advert-page.tsx
+++ b/src/components/open-shifts/open-shifts-advert-page.tsx
@@ -353,7 +353,10 @@ export function OpenShiftsAdvertPage({ serviceId, openShiftId }: { serviceId: st
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.
@@ -413,6 +416,7 @@ export function OpenShiftsAdvertPage({ serviceId, openShiftId }: { serviceId: st
? `${perthTimeOf(listing.startsAt)}–${perthTimeOf(listing.endsAt)} next day`
: formatShiftTimes(listing.startsAt, listing.endsAt);
const withdrawn = withdrawnKey === `${listing.serviceId}:${listing.id}`;
+ 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.
@@ -670,6 +674,7 @@ export function OpenShiftsAdvertPage({ serviceId, openShiftId }: { serviceId: st
onSent={(status) => {
setWithdrawnKey(null);
setSent(status);
+ setSentReadAt(state.readAt?.getTime() ?? null);
setSheetOpen(false);
state.reload();
}}
diff --git a/tests/open-shifts-withdraw.dom.test.tsx b/tests/open-shifts-withdraw.dom.test.tsx
index 5cf46cd0de..96f7f707a2 100644
--- a/tests/open-shifts-withdraw.dom.test.tsx
+++ b/tests/open-shifts-withdraw.dom.test.tsx
@@ -76,7 +76,9 @@ describe("Open shifts, taking back a waiting request", () => {
expect(screen.queryByText("Waiting for approval")).toBeNull();
expect(screen.queryByText(/Someone else has it now/)).toBeNull();
// The button that opened the take-back is gone, so focus moves to the result.
- expect(document.activeElement?.textContent).toBe("Request taken back. The shift is open again.");
+ await waitFor(() =>
+ expect(document.activeElement?.textContent).toBe("Request taken back. The shift is open again."),
+ );
});
it("does not call the shift open when someone else asked for it before the page re-read it", async () => {
@@ -128,6 +130,22 @@ describe("Open shifts, taking back a waiting request", () => {
expect(screen.queryByText("Waiting for approval")).toBeNull();
});
+ it("lets a request sent on this visit lapse once a later read shows it declined", async () => {
+ const open = waiting({ status: "open", claimedByMe: false });
+ mocks.state = stateWith(open);
+ const view = renderFor(open);
+ mocks.post.mockResolvedValueOnce({ ok: true, result: { ok: true, openShiftId: open.id, status: "claimed" } });
+ fireEvent.click(screen.getAllByRole("button", { name: "Request this shift" })[0]!);
+ fireEvent.click(screen.getByRole("checkbox"));
+ fireEvent.click(screen.getByRole("button", { name: "Yes, send request" }));
+ await waitFor(() => expect(screen.getByText("Waiting for approval")).toBeTruthy());
+
+ // The manager declines, and the next read shows the shift open again and not this doctor's.
+ mocks.state = stateWith(open, { readAt: new Date(NOW.getTime() + 60_000) });
+ view.rerender();
+ expect(screen.queryByText("Waiting for approval")).toBeNull();
+ });
+
it("never offers to take back an approved shift, or while offline", () => {
const approved = waiting({ status: "approved" });
mocks.state = stateWith(approved);
From 65ded159aee7a76c6f323b7981649b44c119ce64 Mon Sep 17 00:00:00 2001
From: Claude
Date: Mon, 12 Oct 2026 12:50:09 +0000
Subject: [PATCH 08/12] Open shifts: read the clock after the locks, and word
the take-back refusal neutrally
The started-shift check now uses clock_timestamp(), so a take-back that
waited on a lock past the start time is still refused. The refusal no
longer says the roster manager decided, because a started shift gives the
same answer.
The unmerged migration is resealed and its drift-manifest def_hash
recomputed, as in 5e482fe76; it has only been applied to this PR's
preview database.
Co-Authored-By: Claude Opus 5.5 (1M context)
Claude-Session: https://claude.ai/code/session_01CZwGiwt7eXkD4fwyFM83gY
---
src/components/open-shifts/open-shifts-advert-page.tsx | 2 +-
supabase/applied-migration-hashes.json | 4 ++--
supabase/drift-manifest.json | 4 ++--
supabase/migrations/20261012090000_roster_open_withdraw.sql | 4 ++--
supabase/schema.sql | 4 ++--
tests/open-shifts-withdraw.dom.test.tsx | 6 +++++-
6 files changed, 14 insertions(+), 10 deletions(-)
diff --git a/src/components/open-shifts/open-shifts-advert-page.tsx b/src/components/open-shifts/open-shifts-advert-page.tsx
index 8cf70bf5a5..c76080b0f7 100644
--- a/src/components/open-shifts/open-shifts-advert-page.tsx
+++ b/src/components/open-shifts/open-shifts-advert-page.tsx
@@ -293,7 +293,7 @@ function WithdrawSheet({
}
setError(
result.code === "roster_conflict"
- ? "Your roster manager decided before it could be taken back. Check My shifts."
+ ? "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}`,
diff --git a/supabase/applied-migration-hashes.json b/supabase/applied-migration-hashes.json
index ee33347702..909ce59a61 100644
--- a/supabase/applied-migration-hashes.json
+++ b/supabase/applied-migration-hashes.json
@@ -1,7 +1,7 @@
{
"note": "sha256 of every migration this repository has shipped, with newlines normalised. An entry whose hash changes means an applied migration was edited, which cannot reach the live database. See scripts/check-migration-immutability.mjs.",
"generator": "scripts/check-migration-immutability.mjs",
- "sealed_at": "2026-10-12T12:38:37.206Z",
+ "sealed_at": "2026-10-12T12:49:50.382Z",
"versions": {
"20260527000000_bulk_ingestion.sql": "813e8e314064a6dcbaaa55e2765a8ce901d5783b304b4a7af7bb7417a07daf7c",
"20260527001000_documents_policy_cleanup.sql": "559c26f3172bf75e545a41adef75538b2bf648ea7d53c8eb3d3ea7c244c65a48",
@@ -303,6 +303,6 @@
"20261010011100_work_bookings_organiser_role.sql": "e8c284f391bccc4d6327968e076ef15dd8f17d9a4ff40254a01f92ea687cad76",
"20261010081500_roster_needs_stale_check.sql": "b9db1f643397ab1919d087d8cede955272c22474353b060acdf19a617db1b7d8",
"20261010120000_evaluate_site_content_release_digest_once_per_health_read.sql": "4d9cf9487e74d3773c0263928fa5546b2d4ed60877c8cfdd179cbf1e16b866c2",
- "20261012090000_roster_open_withdraw.sql": "5b79b4ca60e26f4c8a3353dcdfc27c5d677e0789df33f4e27414450d2ebe60c1"
+ "20261012090000_roster_open_withdraw.sql": "35a6591442186ba238d96695d7a407d08aa6a019fa5eb01318c039039fedc8a5"
}
}
diff --git a/supabase/drift-manifest.json b/supabase/drift-manifest.json
index 8728e02ce5..9d561b13ab 100644
--- a/supabase/drift-manifest.json
+++ b/supabase/drift-manifest.json
@@ -2,7 +2,7 @@
"generated_at": "2026-10-11T06:07:15.735Z",
"generator": "scripts/generate-drift-manifest.ts",
"postgres_image": "supabase/postgres:17.6.1.127@sha256:be60aee15997daca475b710b734bc6bfe52cd544dcd7e9fd2ff58210b6747d83",
- "schema_sha256": "28e77e1120c520b3f8400a85252929a4c728db70f673b60053cb572b88bc680e",
+ "schema_sha256": "58ae519e96479e7fd131dc50a7781bc4c394c30d7dc0cfeffd68b1e41a5ba6de",
"replay_seconds": 13,
"snapshot": {
"views": [
@@ -19101,7 +19101,7 @@
"postgres=X/postgres",
"service_role=X/postgres"
],
- "def_hash": "2845e24c86ad9dec2d6b158e994be600",
+ "def_hash": "abed0339949b065bf0ce884e689f4205",
"signature": "public.roster_open_withdraw(uuid,uuid,uuid)"
},
{
diff --git a/supabase/migrations/20261012090000_roster_open_withdraw.sql b/supabase/migrations/20261012090000_roster_open_withdraw.sql
index 0b08419fc9..e04c3173e2 100644
--- a/supabase/migrations/20261012090000_roster_open_withdraw.sql
+++ b/supabase/migrations/20261012090000_roster_open_withdraw.sql
@@ -33,8 +33,8 @@ begin
if not found or v_open.claimed_by is distinct from p_actor_id then raise exception 'roster_not_found'; end if;
-- Approved, cancelled or expired since the doctor last looked, or the shift has already
-- started (a started shift can no longer be claimed, so reopening it would strand it): nothing
- -- is changed.
- if v_open.status <> 'claimed' or v_open.starts_at <= now() then raise exception 'roster_conflict'; end if;
+ -- is changed. The clock is read after the locks, so a call that waited on them can't slip past the start.
+ if v_open.status <> 'claimed' or v_open.starts_at <= clock_timestamp() then raise exception 'roster_conflict'; end if;
update public.roster_open_shifts set status = 'open', claimed_by = null, claimed_at = null where id = v_open.id;
return jsonb_build_object('openShiftId', v_open.id, 'status', 'open');
diff --git a/supabase/schema.sql b/supabase/schema.sql
index ba5b23d018..2fe838aa3f 100644
--- a/supabase/schema.sql
+++ b/supabase/schema.sql
@@ -27310,8 +27310,8 @@ begin
if not found or v_open.claimed_by is distinct from p_actor_id then raise exception 'roster_not_found'; end if;
-- Approved, cancelled or expired since the doctor last looked, or the shift has already
-- started (a started shift can no longer be claimed, so reopening it would strand it): nothing
- -- is changed.
- if v_open.status <> 'claimed' or v_open.starts_at <= now() then raise exception 'roster_conflict'; end if;
+ -- is changed. The clock is read after the locks, so a call that waited on them can't slip past the start.
+ if v_open.status <> 'claimed' or v_open.starts_at <= clock_timestamp() then raise exception 'roster_conflict'; end if;
update public.roster_open_shifts set status = 'open', claimed_by = null, claimed_at = null where id = v_open.id;
return jsonb_build_object('openShiftId', v_open.id, 'status', 'open');
diff --git a/tests/open-shifts-withdraw.dom.test.tsx b/tests/open-shifts-withdraw.dom.test.tsx
index 96f7f707a2..c1893bffe6 100644
--- a/tests/open-shifts-withdraw.dom.test.tsx
+++ b/tests/open-shifts-withdraw.dom.test.tsx
@@ -103,7 +103,11 @@ describe("Open shifts, taking back a waiting request", () => {
renderFor(listing);
fireEvent.click(screen.getByRole("button", { name: "Take back my request" }));
fireEvent.click(screen.getByRole("button", { name: "Yes, take it back" }));
- await waitFor(() => expect(screen.getByRole("alert").textContent).toMatch(/decided before it could be taken back/));
+ await waitFor(() =>
+ expect(screen.getByRole("alert").textContent).toMatch(
+ /can't be taken back now\. Your roster manager has decided, or the shift has started/,
+ ),
+ );
// The page reads the request again, so it shows what the manager decided.
expect(mocks.state.reload).toHaveBeenCalledOnce();
// The same take-back can't be sent again against what the page last read.
From 00923f44fd40effc814d04980dae4487e677e15f Mon Sep 17 00:00:00 2001
From: "qodo-code-review[bot]"
<151058649+qodo-code-review[bot]@users.noreply.github.com>
Date: Mon, 12 Oct 2026 16:41:07 +0000
Subject: [PATCH 09/12] fix: Add browser coverage for stale take-back race
---
playwright.config.ts | 4 +-
tests/ui-open-shifts-withdraw.spec.ts | 86 +++++++++++++++++++++++++++
2 files changed, 88 insertions(+), 2 deletions(-)
create mode 100644 tests/ui-open-shifts-withdraw.spec.ts
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/tests/ui-open-shifts-withdraw.spec.ts b/tests/ui-open-shifts-withdraw.spec.ts
new file mode 100644
index 0000000000..ee1c3b4148
--- /dev/null
+++ b/tests/ui-open-shifts-withdraw.spec.ts
@@ -0,0 +1,86 @@
+import { expect, test, type Page } from "playwright/test";
+import { clickWhenHydrated } from "./playwright-settlement";
+
+const serviceId = "5e000000-0000-4000-8000-000000000001";
+const openShiftId = "5e000000-0000-4000-8000-000000000002";
+const actorId = "5e000000-0000-4000-8000-000000000003";
+
+async function stubOpenShift(page: Page) {
+ await page.clock.setFixedTime(new Date("2026-10-05T00:00:00.000Z"));
+ const startsAt = "2026-10-12T09:00:00.000Z";
+ const endsAt = "2026-10-12T17:00:00.000Z";
+ const openShift = {
+ id: openShiftId,
+ status: "open",
+ urgent: false,
+ startsAt,
+ endsAt,
+ shiftCode: "D",
+ kind: "day",
+ minGrade: "registrar",
+ siteId: null,
+ mine: false,
+ // The request is deliberately absent on the read after the stale withdrawal.
+ claimedByMe: false,
+ };
+
+ await page.route("**/api/roster/team", async (route) =>
+ route.fulfill({
+ json: {
+ actorId,
+ teams: [{ serviceId, name: "Example team", enabled: true, role: "member", grade: "registrar" }],
+ },
+ }),
+ );
+ await page.route(`**/api/roster/team/${serviceId}**`, async (route) => {
+ const request = route.request();
+ const url = new URL(request.url());
+ if (request.method() === "POST") {
+ const action = request.postDataJSON() as { action?: string };
+ if (action.action === "open.claim") {
+ return route.fulfill({ json: { result: { ok: true, openShiftId, status: "claimed" } } });
+ }
+ if (action.action === "open.withdraw") {
+ return route.fulfill({
+ status: 404,
+ json: { code: "roster_not_found", message: "Not found." },
+ });
+ }
+ }
+ if (url.searchParams.get("what") === "overview") {
+ return route.fulfill({
+ json: {
+ service: { id: serviceId, name: "Example team" },
+ me: { role: "member", grade: "registrar", rotationEndsOn: null },
+ latestPublication: null,
+ seenLatest: true,
+ settings: { swapApproval: "manager", rules: {}, rulesSource: null, payFortnightAnchor: null },
+ sites: [],
+ managers: [],
+ },
+ });
+ }
+ if (url.searchParams.get("what") === "requests") {
+ return route.fulfill({ json: { swaps: [], openShifts: [openShift] } });
+ }
+ return route.fulfill({ json: { assignments: [] } });
+ });
+ await page.route("**/api/roster/shifts**", (route) => route.fulfill({ json: { shifts: [], sample: false } }));
+}
+
+test("a stale take-back reports the decision and refreshes the real advert", async ({ page }) => {
+ await stubOpenShift(page);
+ await page.goto(`/open-shifts/shift/${serviceId}/${openShiftId}`);
+
+ await clickWhenHydrated(page.getByRole("button", { name: "Request this shift", exact: true }));
+ await clickWhenHydrated(page.getByRole("checkbox"));
+ await clickWhenHydrated(page.getByRole("button", { name: "Yes, send request", exact: true }));
+ await expect(page.getByText("Waiting for approval")).toBeVisible();
+
+ await clickWhenHydrated(page.getByRole("button", { name: "Take back my request", exact: true }));
+ await clickWhenHydrated(page.getByRole("button", { name: "Yes, take it back", exact: true }));
+
+ await expect(page.getByRole("alert")).toHaveText("This request can't be found any more. Check My shifts.");
+ await expect(page.getByText("Waiting for approval")).toHaveCount(0);
+ await expect(page.getByRole("button", { name: "Take back my request", exact: true })).toHaveCount(0);
+});
From 9127b34f5c782657c6b9408c96c917cfadc113e2 Mon Sep 17 00:00:00 2001
From: Claude
Date: Mon, 12 Oct 2026 16:52:42 +0000
Subject: [PATCH 10/12] test(open-shifts): match the take-back notice by its
text
Next.js renders an empty route announcer with role="alert", so a bare
getByRole("alert") matched two elements and failed in strict mode.
Co-Authored-By: Claude Opus 5.5 (1M context)
Claude-Session: https://claude.ai/code/session_01CZwGiwt7eXkD4fwyFM83gY
---
tests/ui-open-shifts-withdraw.spec.ts | 5 ++++-
1 file changed, 4 insertions(+), 1 deletion(-)
diff --git a/tests/ui-open-shifts-withdraw.spec.ts b/tests/ui-open-shifts-withdraw.spec.ts
index ee1c3b4148..c7567f60ac 100644
--- a/tests/ui-open-shifts-withdraw.spec.ts
+++ b/tests/ui-open-shifts-withdraw.spec.ts
@@ -80,7 +80,10 @@ test("a stale take-back reports the decision and refreshes the real advert", asy
await clickWhenHydrated(page.getByRole("button", { name: "Take back my request", exact: true }));
await clickWhenHydrated(page.getByRole("button", { name: "Yes, take it back", exact: true }));
- await expect(page.getByRole("alert")).toHaveText("This request can't be found any more. Check My shifts.");
+ // Next.js renders its own empty route announcer with role="alert", so match the notice by its text.
+ await expect(page.getByRole("alert").filter({ hasText: "This request can't be found any more." })).toHaveText(
+ "This request can't be found any more. Check My shifts.",
+ );
await expect(page.getByText("Waiting for approval")).toHaveCount(0);
await expect(page.getByRole("button", { name: "Take back my request", exact: true })).toHaveCount(0);
});
From 08c567898e761a0c561b2988a93004392c3e61d4 Mon Sep 17 00:00:00 2001
From: Claude
Date: Mon, 12 Oct 2026 17:01:41 +0000
Subject: [PATCH 11/12] Open shifts: a newer read ends the take-back override;
shard the take-back spec
The take-back override now applies only to the read it was made against, so a
request made again from another tab or device shows as waiting rather than
offering Request this shift. The new take-back browser spec joins the PR and
release shard tables.
Co-Authored-By: Claude Opus 5.5 (1M context)
Claude-Session: https://claude.ai/code/session_01CZwGiwt7eXkD4fwyFM83gY
---
scripts/playwright-pr-shards.mjs | 9 ++++++++-
scripts/playwright-release-shards.mjs | 10 ++++++++++
.../open-shifts/open-shifts-advert-page.tsx | 9 +++++++--
tests/open-shifts-withdraw.dom.test.tsx | 16 ++++++++++++++++
4 files changed, 41 insertions(+), 3 deletions(-)
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 437beefe12..71f4408f3d 100644
--- a/src/components/open-shifts/open-shifts-advert-page.tsx
+++ b/src/components/open-shifts/open-shifts-advert-page.tsx
@@ -371,6 +371,9 @@ export function OpenShiftsAdvertPage({
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.
@@ -426,7 +429,8 @@ export function OpenShiftsAdvertPage({
const timeText = endsNextDay(listing.startsAt, listing.endsAt)
? `${perthTimeOf(listing.startsAt)}–${perthTimeOf(listing.endsAt)} next day`
: formatShiftTimes(listing.startsAt, listing.endsAt);
- const withdrawn = withdrawnKey === `${listing.serviceId}:${listing.id}`;
+ 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";
@@ -540,7 +544,7 @@ export function OpenShiftsAdvertPage({