Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
31 commits
Select commit Hold shift + click to select a range
69a846a
Open shifts: fold into Roster tabs, add requests strip, Fits your ros…
claude Oct 12, 2026
9cdaddd
Open shifts: share, add to calendar, Month row, manager tools and upd…
claude Oct 12, 2026
67c6abb
Open shifts: Roster badge on the pill, slimmer Request button
claude Oct 12, 2026
0c2e9cc
Keep the header within its line budget
claude Oct 12, 2026
0aad6cd
fix(open-shifts): meet the design-system contract ratchets
claude Oct 12, 2026
c484957
Merge remote-tracking branch 'origin/main' into claude/open-shifts-ex…
claude Oct 12, 2026
e335f40
fix(open-shifts): help link label no longer reads "Open Open shifts"
claude Oct 12, 2026
28ca1dd
fix: Prevent dismissing an active posting confirmation
qodo-code-review[bot] Oct 12, 2026
cf5e866
fix: Reject unavailable copied teams explicitly
qodo-code-review[bot] Oct 12, 2026
8c6c1ec
fix: 3 findings — Show roster warnings in both browse views; Improve emp
qodo-code-review[bot] Oct 12, 2026
d5a9004
fix: 2 findings — Fallback to clipboard after failed sharing; Disable ex
qodo-code-review[bot] Oct 12, 2026
e171228
fix: 2 findings — Exclude shift posters from candidates; Require claimab
qodo-code-review[bot] Oct 12, 2026
858732d
fix(open-shifts): act on review findings before going live
claude Oct 12, 2026
d988ee7
Merge remote-tracking branch 'origin/claude/open-shifts-expansion-hj3…
claude Oct 12, 2026
6a2d3a7
fix(open-shifts): second round of review findings
claude Oct 12, 2026
518e380
fix(open-shifts): narrow the repost check and show hidden shifts on c…
claude Oct 12, 2026
20463ea
fix(open-shifts): midnight-ending shifts, old-list wording and auto-a…
claude Oct 12, 2026
79419b0
fix(open-shifts): only count your own new shift as a lost post
claude Oct 12, 2026
f72cdb5
fix(open-shifts): hold a retry until the team's shifts are read again
claude Oct 12, 2026
0b5cec2
fix(open-shifts): never redirect an unanswered post to another team
claude Oct 12, 2026
1bd776c
Merge branch 'main' into claude/open-shifts-expansion-hj3sp1
BigSimmo Oct 12, 2026
346c161
Merge remote-tracking branch 'origin/claude/open-shifts-expansion-hj3…
claude Oct 12, 2026
b752fb4
fix(open-shifts): only hold a post when the form fell back from the c…
claude Oct 12, 2026
18e779b
Merge remote-tracking branch 'origin/main' into claude/open-shifts-ex…
claude Oct 12, 2026
9d26452
Merge origin/main into claude/open-shifts-expansion-hj3sp1
claude Oct 12, 2026
94b2c73
fix(open-shifts): keep each day's hidden shifts reachable when nothin…
claude Oct 12, 2026
c1b0156
Merge remote-tracking branch 'origin/main' into claude/open-shifts-ex…
claude Oct 12, 2026
a3ab1cc
Merge remote-tracking branch 'origin/main' into claude/open-shifts-ex…
claude Oct 12, 2026
2bb9c46
Merge remote-tracking branch 'origin/main' into claude/open-shifts-ex…
claude Oct 12, 2026
49a075c
Open shifts: a repeat post with no answer waits for a newer read
claude Oct 12, 2026
65773d5
Merge branch 'main' into claude/open-shifts-expansion-hj3sp1
BigSimmo Oct 12, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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 <OpenShiftsAdvertPage serviceId={serviceId} openShiftId={openShiftId} />;
export default async function OpenShiftsAdvertRoute({ params, searchParams }: OpenShiftsAdvertRouteProps) {
const [{ serviceId, openShiftId }, { request }] = await Promise.all([params, searchParams]);
return <OpenShiftsAdvertPage serviceId={serviceId} openShiftId={openShiftId} requestOnOpen={request === "1"} />;
}
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
87 changes: 87 additions & 0 deletions src/components/open-shifts/open-shifts-advert-actions.tsx
Original file line number Diff line number Diff line change
@@ -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);
Comment thread
gitar-bot[bot] marked this conversation as resolved.
Resolved
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;
}
}
Comment thread
qodo-code-review[bot] marked this conversation as resolved.
Resolved
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 (
<div className="flex gap-2 px-3 pt-3" data-testid="open-shifts-advert-actions">
{booked ? (
<button type="button" className={action} onClick={addToCalendar}>
<CalendarPlus aria-hidden="true" strokeWidth={1.6} className="size-icon-sm" />
Add to calendar
</button>
) : null}
{shareable ? (
<button type="button" className={action} onClick={() => void share()}>
{copied ? (
<Check aria-hidden="true" strokeWidth={1.6} className="size-icon-sm" />
) : (
<Share2 aria-hidden="true" strokeWidth={1.6} className="size-icon-sm" />
)}
<span>{copied ? "Copied to share" : "Share with a colleague"}</span>
<span aria-live="polite" className="sr-only">
{copied ? "Copied to share" : ""}
</span>
</button>
) : null}
</div>
);
}
32 changes: 31 additions & 1 deletion src/components/open-shifts/open-shifts-advert-page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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"}`;
Expand Down Expand Up @@ -248,14 +249,24 @@ 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();
const [sheetOpen, setSheetOpen] = useState(false);
// Kept mounted after the first open so closing hands focus back to "Request this shift".
const [sheetLoaded, setSheetLoaded] = useState(false);
const [sent, setSent] = useState<string | null>(null);
const [autoOpened, setAutoOpened] = useState(false);
const listing = state.listings.find((row) => row.serviceId === serviceId && row.id === openShiftId) ?? null;

if (state.status === "loading") {
Expand Down Expand Up @@ -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 (
<div className="mx-auto w-full max-w-reading pb-6" data-mode-identity="open-shifts">
Expand All @@ -346,6 +371,11 @@ export function OpenShiftsAdvertPage({ serviceId, openShiftId }: { serviceId: st
<Fact label="Length" value={formatHours(hoursBetween(listing.startsAt, listing.endsAt))} />
<Fact label="Shift code" value={listing.shiftCode} />
</dl>
<OpenShiftsAdvertActions
listing={listing}
sample={sample}
booked={(listing.claimedByMe && listing.status === "approved") || sent === "approved"}
/>
Comment thread
BigSimmo marked this conversation as resolved.
Resolved

{/* Example shifts are checked against the example roster, as Browse flags them; a release-held sample has none. */}
<div className="mt-4">
Expand Down
Loading
Loading