diff --git a/src/app/(search-app)/open-shifts/shift/[serviceId]/[openShiftId]/page.tsx b/src/app/(search-app)/open-shifts/shift/[serviceId]/[openShiftId]/page.tsx index 402559aa61..d87b16a453 100644 --- a/src/app/(search-app)/open-shifts/shift/[serviceId]/[openShiftId]/page.tsx +++ b/src/app/(search-app)/open-shifts/shift/[serviceId]/[openShiftId]/page.tsx @@ -10,9 +10,10 @@ export const metadata: Metadata = { type OpenShiftsAdvertRouteProps = { params: Promise<{ serviceId: string; openShiftId: string }>; + searchParams: Promise<{ request?: string | string[] }>; }; -export default async function OpenShiftsAdvertRoute({ params }: OpenShiftsAdvertRouteProps) { - const { serviceId, openShiftId } = await params; - return ; +export default async function OpenShiftsAdvertRoute({ params, searchParams }: OpenShiftsAdvertRouteProps) { + const [{ serviceId, openShiftId }, { request }] = await Promise.all([params, searchParams]); + return ; } diff --git a/src/components/clinical-dashboard/master-search-header.tsx b/src/components/clinical-dashboard/master-search-header.tsx index 141439e44b..84f6fb1a14 100644 --- a/src/components/clinical-dashboard/master-search-header.tsx +++ b/src/components/clinical-dashboard/master-search-header.tsx @@ -677,7 +677,7 @@ export function MasterSearchHeader(props: MasterSearchHeaderProps) { // One task-oriented placeholder per mode (PT-14): the follow-up composer must // not swap to brand copy that hides what the input actually does. const queryPlaceholder = composerPlaceholder ?? selectedSearch.placeholder; - const SelectedAppModeIcon = appModeIcons[selectedAppMode.id]; + const SelectedAppModeIcon = appModeIcons[routeWorkArea?.identity ?? selectedAppMode.id]; // Open shifts wears Roster's badge const actionMenuModeOptions: ModeActionModeOption[] = visibleAppModeOptions.map((mode) => ({ id: mode.id, label: mode.label, diff --git a/src/components/open-shifts/open-shifts-advert-actions.tsx b/src/components/open-shifts/open-shifts-advert-actions.tsx new file mode 100644 index 0000000000..46b84a8618 --- /dev/null +++ b/src/components/open-shifts/open-shifts-advert-actions.tsx @@ -0,0 +1,87 @@ +"use client"; + +import { CalendarPlus, Check, Share2 } from "lucide-react"; +import { useState } from "react"; + +import { useRosterNow } from "@/components/roster/roster-format"; +import { downloadTextFile } from "@/lib/admin/download-file"; +import { icsFileName, toIcs } from "@/lib/calendar/ics"; +import type { OpenShiftListing } from "@/lib/open-shifts/model"; +import { openShiftCalendarEvent, openShiftShareText } from "@/lib/open-shifts/share"; + +import { advertHref } from "./open-shifts-ui"; + +const action = + "inline-flex min-h-12 flex-1 items-center justify-center gap-2 rounded-md border border-[color:var(--border-strong)] px-3 text-sm font-semibold text-[color:var(--text-heading)] focus-visible:outline-2 focus-visible:outline-[color:var(--mode-identity)]"; + +/** + * Share a shift with a colleague, and put a booked one in your phone's calendar + * (12 Oct 2026). Sharing sends only what the advert shows; the calendar file is + * made on this phone and nothing is sent anywhere. + */ +export function OpenShiftsAdvertActions({ + listing, + booked, + sample, +}: { + readonly listing: OpenShiftListing; + /** Example data: a made-up shift is never shared or put in a real calendar. */ + readonly sample: boolean; + /** Approved for you: the calendar button shows. */ + readonly booked: boolean; +}) { + const [copied, setCopied] = useState(false); + const now = useRosterNow(); + // A shift that has started can't be asked for, so it isn't passed on. + const shareable = listing.status === "open" && !listing.claimedByMe && Date.parse(listing.startsAt) > now.getTime(); + if (sample || (!shareable && !booked)) return null; + + async function share() { + const link = `${window.location.origin}${advertHref(listing)}`; + const text = openShiftShareText(listing, link); + if (typeof navigator.share === "function") { + try { + await navigator.share({ title: "Open shift", text }); + return; + } catch (error) { + // A dismissed share sheet is not an error. Any other failure falls back to copying. + if (error instanceof DOMException && error.name === "AbortError") return; + } + } + try { + await navigator.clipboard.writeText(text); + setCopied(true); + } catch { + setCopied(false); + } + } + + function addToCalendar() { + const event = openShiftCalendarEvent(listing); + downloadTextFile(toIcs([event]), icsFileName(event.title), "text/calendar;charset=utf-8"); + } + + return ( +
+ {booked ? ( + + ) : null} + {shareable ? ( + + ) : null} +
+ ); +} diff --git a/src/components/open-shifts/open-shifts-advert-page.tsx b/src/components/open-shifts/open-shifts-advert-page.tsx index 1e07a875e8..8680d9a9c5 100644 --- a/src/components/open-shifts/open-shifts-advert-page.tsx +++ b/src/components/open-shifts/open-shifts-advert-page.tsx @@ -36,6 +36,7 @@ import { shiftTitle, startsIn, } from "./open-shifts-ui"; +import { OpenShiftsAdvertActions } from "./open-shifts-advert-actions"; import { useOpenShifts } from "./use-open-shifts"; const AGREEMENT = `clause 15 of the ${FATIGUE_RULE_SET.source.title.includes("2024") ? "2024 WA AMA agreement" : "WA AMA agreement"}`; @@ -248,7 +249,16 @@ export function RequestSheet({ ); } -export function OpenShiftsAdvertPage({ serviceId, openShiftId }: { serviceId: string; openShiftId: string }) { +export function OpenShiftsAdvertPage({ + serviceId, + openShiftId, + requestOnOpen = false, +}: { + serviceId: string; + openShiftId: string; + /** "Request" on a Fits your roster row: open the confirm sheet straight away. Nothing is sent until confirmed. */ + requestOnOpen?: boolean; +}) { const state = useOpenShifts(); const signedOut = useSignedOut(); const now = useRosterNow(); @@ -256,6 +266,7 @@ export function OpenShiftsAdvertPage({ serviceId, openShiftId }: { serviceId: st // 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 [autoOpened, setAutoOpened] = useState(false); const listing = state.listings.find((row) => row.serviceId === serviceId && row.id === openShiftId) ?? null; if (state.status === "loading") { @@ -320,6 +331,20 @@ export function OpenShiftsAdvertPage({ serviceId, openShiftId }: { serviceId: st : listing.status === "cancelled" ? "withdrawn" : "taken"; + const requestable = + !sample && + !closed && + !alreadyMine && + !signedOut && + !state.offline && + check.state !== "overlap" && + check.state !== "loading" && + check.state !== "unread"; + if (requestOnOpen && requestable && !autoOpened) { + setAutoOpened(true); + setSheetLoaded(true); + setSheetOpen(true); + } return (
@@ -346,6 +371,11 @@ export function OpenShiftsAdvertPage({ serviceId, openShiftId }: { serviceId: st + {/* Example shifts are checked against the example roster, as Browse flags them; a release-held sample has none. */}
diff --git a/src/components/open-shifts/open-shifts-browse-page.tsx b/src/components/open-shifts/open-shifts-browse-page.tsx index 92eea6203d..38219a61c8 100644 --- a/src/components/open-shifts/open-shifts-browse-page.tsx +++ b/src/components/open-shifts/open-shifts-browse-page.tsx @@ -15,16 +15,26 @@ import { summariseBrowse, type BrowseFilters, } from "@/lib/open-shifts/browse"; -import { TIME_OF_DAY_LABEL, gradeLabel, windowOf } from "@/lib/open-shifts/model"; +import { TIME_OF_DAY_LABEL, WINDOW_DAYS, gradeLabel, windowOf } from "@/lib/open-shifts/model"; import { rosterCoveredUntil } from "@/lib/open-shifts/roster-check"; import { perthDateOf, perthTimeOf } from "@/lib/roster/shifts/perth-time"; import { OpenShiftsCalendar } from "./open-shifts-calendar"; +import { OpenShiftsFitsYou, OpenShiftsRequestsStrip, fitsYou } from "./open-shifts-for-you"; import { clearSavedFilters, readSavedFilters, saveFilters } from "./open-shifts-saved-filters"; import { useSignedOut } from "@/components/mode-kit/use-signed-out-sample"; import { SignInAction } from "./open-shifts-sign-in"; import { WorkSignInNotice } from "@/components/mode-kit/work-sign-in-notice"; -import { FlatList, ListSkeleton, Note, ShiftRow, advertHref, formatDayLong, formatDayShort } from "./open-shifts-ui"; +import { + FlatList, + ListSkeleton, + Note, + SectionHeading, + ShiftRow, + advertHref, + formatDayLong, + formatDayShort, +} from "./open-shifts-ui"; import { LoadFailed, NoTeam, openShiftsStatus } from "./open-shifts-states"; import { useOpenShifts } from "./use-open-shifts"; import { zonedDateOf } from "@/lib/work-time/format"; @@ -74,6 +84,8 @@ export function OpenShiftsBrowsePage() { }; const [chosenDay, setChosenDay] = useState(null); const [showHidden, setShowHidden] = useState(false); + // A list on a phone, the calendar one tap away (12 Oct 2026). From 1024px both show side by side. + const [view, setView] = useState<"list" | "calendar">("list"); // The made-up examples are filtered like real ones, against the made-up example roster in the sample data. const summary = useMemo( @@ -124,254 +136,303 @@ export function OpenShiftsBrowsePage() { ) : ( <> -
-
- - - {summary.sites.length > 1 ? ( - + Your roster couldn't be read, so clashes aren't checked or + hidden.{" "} + + ) : null} - - -
-
- - - {`${partial ? "At least " : ""}${summary.matching.length} of ${summary.total}`} - {" "} - open shifts match · next 14 days - - {filterCount ? ( - + {state.rosterStatus === "ready" && (!state.roster || state.roster.length === 0) && !sample ? ( + + ) : coveredUntil && coveredUntil < windowEnd && !sample ? ( + + ) : null} + {state.offline && state.readAt ? ( + ) : null} -
-
- { - setChosenDay(date); - setShowHidden(false); - }} - perDay={summary.perDay} - urgentDays={summary.urgentDays} - rosteredDays={rosteredDays} - /> -
    -
  • - - Shifts that match -
  • -
  • -
  • - {state.roster && state.roster.length > 0 ? ( -
  • -
  • - ) : null} -
- - {state.rosterStatus === "error" && !sample ? ( -
+
+ 0} /> +
+
+
+ { + setChosenDay(date); + setShowHidden(false); + // From 1024px the list sits beside the calendar, so a tapped day scrolls the list to it. + if (window.matchMedia("(min-width: 1024px)").matches) { + document + .getElementById(`open-shifts-day-${date}`) + ?.scrollIntoView({ behavior: "smooth", block: "start" }); + } + }} + perDay={summary.perDay} + urgentDays={summary.urgentDays} + rosteredDays={rosteredDays} + /> +
    - Try again - - - ) : null} - {state.rosterStatus === "ready" && (!state.roster || state.roster.length === 0) && !sample ? ( - - ) : coveredUntil && coveredUntil < windowEnd && !sample ? ( - - ) : null} - {state.offline && state.readAt ? ( - - ) : null} +
  • + + Shifts that match +
  • +
  • + + Includes an urgent shift +
  • + {state.roster && state.roster.length > 0 ? ( +
  • +
  • + ) : null} +
-
-

{formatDayLong(selected)}

- - {`${partial ? "at least " : ""}${day.shown.length} match`} - -
+ {/* The chosen day. From 1024px the list beside the calendar already shows every day. */} +
+
+

+ {formatDayLong(selected)} +

+ + {`${partial ? "at least " : ""}${day.shown.length} match`} + +
- {day.shown.length > 0 ? ( - - {day.shown.map((row) => ( - - ))} - - ) : ( -
-
- )} + {day.shown.length > 0 ? ( + + {day.shown.map((row) => ( + + ))} + + ) : ( +
+
+ )} - {day.hidden.length > 0 ? ( - <> -
- - {`${day.hidden.length} hidden: ${[ - day.hiddenClash - ? `${day.hiddenClash} ${day.hiddenClash === 1 ? "overlaps" : "overlap"} your roster` - : null, - day.hiddenLevel - ? `${day.hiddenLevel} ${day.hiddenLevel === 1 ? "is" : "are"} for a lower level` - : null, - day.hidden.length - day.hiddenClash - day.hiddenLevel > 0 - ? `${day.hidden.length - day.hiddenClash - day.hiddenLevel} outside your site or time choices` - : null, - ] - .filter(Boolean) - .join(", ")}`} - - + {day.hidden.length > 0 ? ( + <> +
+ + {`${day.hidden.length} hidden: ${[ + day.hiddenClash + ? `${day.hiddenClash} ${day.hiddenClash === 1 ? "overlaps" : "overlap"} your roster` + : null, + day.hiddenLevel + ? `${day.hiddenLevel} ${day.hiddenLevel === 1 ? "is" : "are"} for a lower level` + : null, + day.hidden.length - day.hiddenClash - day.hiddenLevel > 0 + ? `${day.hidden.length - day.hiddenClash - day.hiddenLevel} outside your site or time choices` + : null, + ] + .filter(Boolean) + .join(", ")}`} + + +
+ {showHidden ? ( + + {day.hidden.map((row) => ( + + ))} + + ) : null} + + ) : null}
- {showHidden ? ( - - {day.hidden.map((row) => ( - - ))} - - ) : null} - - ) : null} +
+
{signedOut ? : null} @@ -390,3 +451,104 @@ export function OpenShiftsBrowsePage() { ); } + +/** A day's shifts that the filters or the clash and level settings hide, one tap away. */ +function HiddenOnDay({ date, rows }: { readonly date: string; readonly rows: ReturnType["hidden"] }) { + const [open, setOpen] = useState(false); + return ( + <> +
+ {`${rows.length} hidden by your settings`} + +
+ {open ? ( + + {rows.map((row) => ( + + ))} + + ) : null} + + ); +} + +/** Every open shift, day by day: the phone's first view and the desktop's left column. */ +/** Days shown before "Show later days", so a phone list stays short. */ +const FIRST_DAYS = 5; + +function OpenShiftsDayList({ + summary, + partial, + filtered, +}: { + readonly summary: ReturnType; + /** Some teams couldn't be read, or the list is an offline copy. */ + readonly partial: boolean; + readonly filtered: boolean; +}) { + const [all, setAll] = useState(false); + // Days with only hidden shifts still get a section, so a computer's calendar can scroll to them and the + // hidden ones can be shown. + const allDays = [...new Set(summary.all.map((row) => perthDateOf(row.listing.startsAt)))].sort(); + // Later days stay in the page on a computer, where the calendar scrolls to them; a phone shows them on request. + const later = all ? 0 : Math.max(0, allDays.length - FIRST_DAYS); + // When nothing matches, the message still sits above each day's hidden shifts, so they can be shown in place. + const noneShown = + summary.perDay.size === 0 ? ( +

+ {partial + ? "None found in the teams read. Some of your teams couldn't be read, so there may be more." + : summary.total === 0 + ? `Nothing is posted for the next ${WINDOW_DAYS} days. New shifts appear here when a roster manager posts them.` + : `None match. ${summary.total} ${summary.total === 1 ? "is" : "are"} hidden by ${filtered ? "your filters" : "the clash and level settings"}; open Filters, or show ${summary.total === 1 ? "it" : "them"} below.`} +

+ ) : null; + return ( + <> + {noneShown} + {allDays.map((date, index) => { + const { shown, hidden } = dayRows(summary, date); + return ( +
= FIRST_DAYS ? "max-[1023px]:hidden" : ""}`} + > + {formatDayLong(date)} + {shown.length > 0 ? ( + + {shown.map((row) => ( + + ))} + + ) : null} + {hidden.length > 0 ? : null} +
+ ); + })} + {later > 0 ? ( + + ) : null} + + ); +} diff --git a/src/components/open-shifts/open-shifts-confirm.tsx b/src/components/open-shifts/open-shifts-confirm.tsx index ae69b20d60..273a0fdfce 100644 --- a/src/components/open-shifts/open-shifts-confirm.tsx +++ b/src/components/open-shifts/open-shifts-confirm.tsx @@ -46,7 +46,7 @@ export function ConfirmSheet({ return ( {} : onClose} title={title} testId={testId} footer={ @@ -60,7 +60,7 @@ export function ConfirmSheet({ > {confirmLabel} - diff --git a/src/components/open-shifts/open-shifts-fit-line.tsx b/src/components/open-shifts/open-shifts-fit-line.tsx new file mode 100644 index 0000000000..d7cfd10355 --- /dev/null +++ b/src/components/open-shifts/open-shifts-fit-line.tsx @@ -0,0 +1,50 @@ +"use client"; + +import { Hand } from "lucide-react"; +import { useMemo } from "react"; + +import { WorkCard, WorkIconRow } from "@/components/mode-kit/work"; +import { useRosterNow } from "@/components/roster/roster-format"; +import { DEFAULT_FILTERS, summariseBrowse } from "@/lib/open-shifts/browse"; +import { WINDOW_DAYS } from "@/lib/open-shifts/model"; + +import { fitsYou } from "./open-shifts-for-you"; +import { OPEN_SHIFTS_HREF } from "./open-shifts-ui"; +import { useOpenShifts } from "./use-open-shifts"; + +/** + * One row on Roster's Month page (12 Oct 2026): how many open shifts fit your + * roster, so you see them without opening the tab. Nothing shows when none do, + * or while the list or your roster is still being read. + */ +export function OpenShiftsFitLine() { + const state = useOpenShifts(); + const now = useRosterNow(); + const count = useMemo( + () => + state.status === "ready" + ? fitsYou( + summariseBrowse(state.listings, state.roster, DEFAULT_FILTERS, now, state.rosterStatus).matching, + Infinity, + ).length + : 0, + [state.status, state.listings, state.roster, state.rosterStatus, now], + ); + if (count === 0) return null; + // An old list (offline, or a refresh that failed) may be out of date either way. + const stale = state.offline || state.refreshFailed; + // A current list missing a team that couldn't be read may hold more. + const partial = !stale && state.failedTeams.length > 0; + return ( + + + + ); +} diff --git a/src/components/open-shifts/open-shifts-for-you.tsx b/src/components/open-shifts/open-shifts-for-you.tsx new file mode 100644 index 0000000000..8eb7e9fd56 --- /dev/null +++ b/src/components/open-shifts/open-shifts-for-you.tsx @@ -0,0 +1,152 @@ +"use client"; + +import { ChevronRight, Clock, Send } from "lucide-react"; +import Link from "next/link"; +import { useMemo } from "react"; + +import { modeInsetHairline, modePressable } from "@/components/mode-kit/recipes"; +import { WorkCard, WorkIconRow } from "@/components/mode-kit/work"; +import type { BrowseRow } from "@/lib/open-shifts/browse"; +import { groupMine, hoursMeter } from "@/lib/open-shifts/mine"; +import { formatHours, hoursBetween, type OpenShiftListing } from "@/lib/open-shifts/model"; +import type { FatigueShift } from "@/lib/roster/fatigue-rules"; +import { perthDateOf, perthTimeOf } from "@/lib/roster/shifts/perth-time"; + +import { + FlatList, + SectionHeading, + advertHref, + formatDayShort, + formatWeekday, + shiftPlace, + shiftTitle, +} from "./open-shifts-ui"; + +/** + * The top of Open shifts once it became a Roster tab (12 Oct 2026): your + * requests and hours in one strip, then the next shifts that fit your roster. + * The full list of requests stays one tap away at /open-shifts/mine. + */ + +const MINE_HREF = "/open-shifts/mine"; + +function plural(count: number, one: string, many: string): string { + return `${count} ${count === 1 ? one : many}`; +} + +export function OpenShiftsRequestsStrip({ + listings, + roster, + now, +}: { + readonly listings: readonly OpenShiftListing[]; + readonly roster: readonly FatigueShift[] | null; + readonly now: Date; +}) { + const groups = useMemo(() => groupMine(listings, now), [listings, now]); + const meter = useMemo(() => (roster ? hoursMeter(roster, listings, now) : null), [roster, listings, now]); + const waiting = groups.requested.length; + const booked = groups.booked.length; + const changed = groups.cancelled.length; + const requestsLine = + waiting + booked + changed === 0 + ? "Nothing asked for yet" + : [ + waiting ? `${waiting} waiting` : null, + booked ? `${booked} booked` : null, + changed ? plural(changed, "changed", "changed") : null, + ] + .filter(Boolean) + .join(" · "); + return ( +
+ + + {meter ? ( + + ) : null} + +
+ ); +} + +/** The next shifts with no clash and nothing flagged on your roster, soonest first. */ +export function fitsYou(rows: readonly BrowseRow[], limit = 3): readonly BrowseRow[] { + return rows + .filter((row) => row.check.state === "ok") + .slice() + .sort((a, b) => Date.parse(a.listing.startsAt) - Date.parse(b.listing.startsAt)) + .slice(0, limit); +} + +export function OpenShiftsFitsYou({ + rows, + requestable, +}: { + readonly rows: readonly BrowseRow[]; + readonly requestable: boolean; +}) { + if (rows.length === 0) return null; + return ( +
+ Fits your roster + + {rows.map((row) => ( + + ))} + +
+ ); +} + +function FitRow({ listing, requestable }: { readonly listing: OpenShiftListing; readonly requestable: boolean }) { + const href = advertHref(listing); + const date = perthDateOf(listing.startsAt); + const weekday = formatWeekday(date); + const day = Number(date.slice(8, 10)); + return ( +
  • + + + + {weekday} + + {day} + + + + {shiftTitle(listing)} + + + {`${perthTimeOf(listing.startsAt)} to ${perthTimeOf(listing.endsAt)} · ${formatHours(hoursBetween(listing.startsAt, listing.endsAt))}`} + + {shiftPlace(listing)} + + {requestable ? null : ( +
  • + ); +} diff --git a/src/components/open-shifts/open-shifts-post-page.tsx b/src/components/open-shifts/open-shifts-post-page.tsx index 65dd7ca0ed..9352d4394c 100644 --- a/src/components/open-shifts/open-shifts-post-page.tsx +++ b/src/components/open-shifts/open-shifts-post-page.tsx @@ -1,12 +1,26 @@ "use client"; +import { Plus, X } from "lucide-react"; import { useRouter } from "next/navigation"; -import { useId, useState, type ReactNode } from "react"; +import { useEffect, useId, useRef, useState, type ReactNode } from "react"; import { useRosterNow } from "@/components/roster/roster-format"; import { postRosterAction } from "@/components/roster/use-roster-team"; import { Button } from "@/components/ui/button"; import { formatHours, gapTimes, gradeLabel, hoursBetween, kindLabel } from "@/lib/open-shifts/model"; +import { + MAX_POST_DATES, + POST_LEVELS, + datesProblem, + nextExtraDate, + parsePostAgain, + planDates, + splitAttempts, + type PostAgainPrefill, + type PostAttempt, + type PostKind, + type PostLevel, +} from "@/lib/open-shifts/post-plan"; import { addDaysToDate, perthDateOf } from "@/lib/roster/shifts/perth-time"; import { ROSTER_OPEN_SHIFT_KINDS, type RosterAction } from "@/lib/roster/team/model"; @@ -20,14 +34,15 @@ import { SubHeader, Switch, formatDayLong, + formatDayShort, formatShiftTimes, postedShiftHref, } from "./open-shifts-ui"; import { usePostedShifts } from "./use-posted-shifts"; -type Kind = (typeof ROSTER_OPEN_SHIFT_KINDS)[number]; -const LEVELS = ["intern", "resident", "registrar", "fellow", "consultant"] as const; -type Level = (typeof LEVELS)[number]; +type Kind = PostKind; +const LEVELS = POST_LEVELS; +type Level = PostLevel; const DEFAULTS: Record = { day: { start: "08:00", end: "16:30", code: "D" }, @@ -62,6 +77,11 @@ function Field({ ); } +/** When a post was tried, read in the click handler (never during render). */ +function attemptTime(): number { + return Date.now(); +} + export function OpenShiftsPostPage() { const state = usePostedShifts(); const router = useRouter(); @@ -77,8 +97,63 @@ export function OpenShiftsPostPage() { const [level, setLevel] = useState("registrar"); const [urgent, setUrgent] = useState(false); const [confirming, setConfirming] = useState(false); + // Extra dates for the same shift. Each keeps its own id so removing one never moves another's input. + const [extras, setExtras] = useState<{ id: number; date: string }[]>([]); + const nextExtraId = useRef(0); + // Posts that went live during this visit, by everything that was sent, so "Yes" after a partial + // failure sends only the dates still missing and never posts the same shift twice. + const [done, setDone] = useState>({}); + const [progress, setProgress] = useState<{ current: number; total: number } | null>(null); + // Dates whose post got no answer (the connection dropped), with the team's shift ids known before that + // attempt. Only these are checked against the team's list on a retry: one that went live is not sent again. + const [unanswered, setUnanswered] = useState< + Record + >({}); + const [copied, setCopied] = useState(false); + // The last partial-failure report, kept on the page so closing the sheet never hides what went live. + const [report, setReport] = useState(null); + + function applyPrefill(prefill: PostAgainPrefill) { + if (prefill.serviceId) setServiceId(prefill.serviceId); + if (prefill.kind) { + setKind(prefill.kind); + setStart(DEFAULTS[prefill.kind].start); + setEnd(DEFAULTS[prefill.kind].end); + setCode(DEFAULTS[prefill.kind].code); + } + if (prefill.start && prefill.end) { + setStart(prefill.start); + setEnd(prefill.end); + } + if (prefill.code) setCode(prefill.code); + if (prefill.level !== undefined) setLevel(prefill.level); + if (prefill.siteId) setSiteId(prefill.siteId); + if (prefill.urgent !== undefined) setUrgent(prefill.urgent); + setCopied(true); + } + + useEffect(() => { + // "Post again" carries the shift in the address. Read once, on arrival, in the browser, so the server + // and first paint agree; everything is checked before it is used, and the date is left for the manager. + let prefill: PostAgainPrefill = {}; + try { + prefill = parsePostAgain(window.location.search); + } catch { + prefill = {}; + } + // eslint-disable-next-line react-hooks/set-state-in-effect + if (Object.keys(prefill).length > 0) applyPrefill(prefill); + }, []); const team = state.teams.find((row) => row.serviceId === serviceId) ?? state.teams[0] ?? null; + // "Post again" from a team this manager no longer posts for: say which team it will go to instead. + const copiedTeamMissing = copied && serviceId !== "" && team !== null && team.serviceId !== serviceId; + // A site from the address (or another team) only counts if it is one of this team's sites. + const site = team?.sites.some((row) => row.id === siteId) ? siteId : ""; + const dates = planDates( + date, + extras.map((extra) => extra.date), + ); const times = gapTimes(date, start, end); const hours = times ? hoursBetween(times.startsAt, times.endsAt) : 0; const problem = !times @@ -87,8 +162,46 @@ export function OpenShiftsPostPage() { ? "The date has passed." : !code.trim() ? "Give the shift a code." - : null; - const siteName = team?.sites.find((site) => site.id === siteId)?.name ?? null; + : datesProblem(dates, today); + const siteName = team?.sites.find((row) => row.id === site)?.name ?? null; + const signature = (on: string) => + JSON.stringify([team?.serviceId, on, start, end, code.trim(), kind, site, level, urgent]); + /** + * For a date whose earlier post got no answer: the open shift that post made, if it did go live. It must be + * new since that attempt and match everything that was sent. A first post is never checked, so a manager can + * still deliberately post a second opening for the same slot. + */ + const lostAnswerMatch = (on: string) => { + const before = unanswered[signature(on)]?.ids; + const onTimes = gapTimes(on, start, end); + if (!before || !onTimes || !team) return null; + return ( + state.shifts.find( + (row) => + !before.includes(row.id) && + // Only a shift this person posted, so another manager's identical opening is never taken for theirs. + state.actorId !== null && + row.postedBy === state.actorId && + row.serviceId === team.serviceId && + row.status === "open" && + row.shiftCode === code.trim() && + row.kind === kind && + (row.siteId ?? "") === site && + (row.minGrade ?? "") === level && + row.urgent === urgent && + Date.parse(row.startsAt) === Date.parse(onTimes.startsAt) && + Date.parse(row.endsAt) === Date.parse(onTimes.endsAt), + ) ?? null + ); + }; + const alreadyPosted = dates.filter((on) => signature(on) in done); + const toPost = dates.filter((on) => !(signature(on) in done)); + + function addExtra() { + if (dates.length >= MAX_POST_DATES) return; + nextExtraId.current += 1; + setExtras([...extras, { id: nextExtraId.current, date: nextExtraDate(dates, today) }]); + } function chooseKind(next: Kind) { setKind(next); @@ -97,25 +210,106 @@ export function OpenShiftsPostPage() { setCode(DEFAULTS[next].code); } + /** + * One `open.post` per date, one after another, each the same shift. A date that fails doesn't stop the + * rest; the answer names every date that went live and every one that didn't. + */ async function post(): Promise { if (!team || !times) return "Choose a team, date and times."; - const result = await postRosterAction(team.serviceId, { - action: "open.post", - startsAt: times.startsAt, - endsAt: times.endsAt, - shiftCode: code.trim(), - kind, - siteId: siteId || null, - minGrade: level || null, - urgent, - } as RosterAction); - if (!result.ok) return `Not posted. ${result.message}`; - router.push( - result.result.openShiftId - ? postedShiftHref(team.serviceId, result.result.openShiftId) - : `${OPEN_SHIFTS_HREF}/post`, + // A post that got no answer for a team that couldn't be read just now: wait, rather than let the form + // fall back to another team and post there instead. A team the manager picked themselves still posts. + const fellBack = team.serviceId !== serviceId; + if ( + fellBack && + Object.entries(unanswered).some( + ([key, entry]) => !(key in done) && !state.teams.some((row) => row.serviceId === entry.serviceId), + ) + ) { + state.reload(); + return "Still checking whether an earlier post went live. Try again in a moment."; + } + const attempts: PostAttempt[] = []; + const nowDone = { ...done }; + const nowUnanswered = { ...unanswered }; + for (const [index, on] of toPost.entries()) { + setProgress({ current: index + 1, total: toPost.length }); + const onTimes = gapTimes(on, start, end); + // A date whose earlier post got no answer waits for a fresh read of the team's shifts, so it is never sent + // twice just because the list hadn't caught up yet. + const pending = unanswered[signature(on)]; + if (pending && (!state.readAt || state.readAt.getTime() <= pending.at || state.refreshFailed)) { + attempts.push({ + date: on, + ok: false, + message: "Still checking whether this one went live. Try again in a moment.", + }); + continue; + } + const live = lostAnswerMatch(on); + if (live) { + attempts.push({ date: on, ok: true, openShiftId: live.id }); + nowDone[signature(on)] = live.id; + continue; + } + if (!onTimes) { + attempts.push({ date: on, ok: false, message: "The times couldn't be worked out for this date." }); + continue; + } + const result = await postRosterAction(team.serviceId, { + action: "open.post", + startsAt: onTimes.startsAt, + endsAt: onTimes.endsAt, + shiftCode: code.trim(), + kind, + siteId: site || null, + minGrade: level || null, + urgent, + } as RosterAction); + if (result.ok) { + attempts.push({ date: on, ok: true, openShiftId: result.result.openShiftId ?? null }); + nowDone[signature(on)] = result.result.openShiftId ?? ""; + } else { + attempts.push({ date: on, ok: false, message: result.message }); + // No answer at all: it may still have gone live, so a retry checks the team's list first. A repeat + // with no answer replaces the earlier record, so the next retry waits for a read newer than this one. + // The current list holds no copy from an earlier attempt, or that copy would have been matched above. + if (result.code === "roster_unavailable") { + nowUnanswered[signature(on)] = { + ids: state.shifts.map((row) => row.id), + at: attemptTime(), + serviceId: team.serviceId, + }; + } + } + } + setProgress(null); + setDone(nowDone); + setUnanswered(nowUnanswered); + // Pin the team a post with no answer went to, so a re-read that misses it can't quietly switch the form. + if (Object.keys(nowUnanswered).length > Object.keys(unanswered).length) setServiceId(team.serviceId); + const { failed } = splitAttempts(attempts); + // Read the team's shifts again, so a retry sees any date whose answer was lost but did go live. + if (failed.length > 0) state.reload(); + if (failed.length === 0) { + const only = dates.length === 1 ? nowDone[signature(dates[0]!)] : ""; + router.push(only ? postedShiftHref(team.serviceId, only) : `${OPEN_SHIFTS_HREF}/post`); + return null; + } + if (dates.length === 1) { + setReport(null); + return `Not posted. ${failed[0]!.message}`; + } + const posted = dates.filter((on) => signature(on) in nowDone); + const missing = failed.map((row) => formatDayShort(row.date)).join(", "); + const reason = failed[0]!.message; + if (posted.length === 0) { + setReport(null); + return `None of the ${dates.length} dates were posted. ${reason}`; + } + setReport( + `Posted ${posted.length} of ${dates.length}: ${posted.map(formatDayShort).join(", ")}. Not posted: ${missing}.`, ); - return null; + return `Posted ${posted.length} of ${dates.length}: ${posted.map(formatDayShort).join(", ")}. Not posted: ${missing}. ${reason} "Yes" now sends only the dates not posted.`; } return ( @@ -141,6 +335,22 @@ export function OpenShiftsPostPage() { if (!problem) setConfirming(true); }} > + {copied ? ( +

    + {copiedTeamMissing + ? `Copied from a posted shift in a team you no longer post for, so this will post for ${team?.name}. Choose the date, then check the rest.` + : "Copied from a posted shift. Choose the date, then check the rest."} +

    + ) : null} + {report && !confirming ? ( +

    + {`${report} Posting again sends only the dates not posted.`} +

    + ) : null} + {state.teams.length > 1 ? ( + setExtras(extras.map((row) => (row.id === extra.id ? { ...row, date: event.target.value } : row))) + } + /> + + + ))} + {dates.length < MAX_POST_DATES ? ( + + ) : ( +

    {`Up to ${MAX_POST_DATES} dates at once.`}

    + )}