Skip to content
5 changes: 3 additions & 2 deletions src/components/open-shifts/open-shifts-browse-page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import { rosterCoveredUntil } from "@/lib/open-shifts/roster-check";
import { perthDateOf, perthTimeOf } from "@/lib/roster/shifts/perth-time";

import { OpenShiftsCalendar } from "./open-shifts-calendar";
import { UrgentDot } from "./urgent-dot";
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";
Expand Down Expand Up @@ -315,9 +316,9 @@ export function OpenShiftsBrowsePage() {
<li className="inline-flex items-center gap-1.5">
<span
aria-hidden="true"
className="inline-flex h-[0.9375rem] min-w-[1.375rem] items-center justify-center rounded-full border border-[color:var(--warning-border)] text-[color:var(--warning-text)]"
className="inline-flex h-[0.9375rem] min-w-[1.375rem] items-center justify-center rounded-full bg-[color:var(--mode-identity-soft)]"
>
<TriangleAlert aria-hidden="true" strokeWidth={2.2} className="size-2.5" />
<UrgentDot />
</span>
Includes an urgent shift
</li>
Expand Down
17 changes: 7 additions & 10 deletions src/components/open-shifts/open-shifts-calendar.tsx
Original file line number Diff line number Diff line change
@@ -1,21 +1,22 @@
"use client";

import { ChevronLeft, ChevronRight, TriangleAlert } from "lucide-react";
import { ChevronLeft, ChevronRight } from "lucide-react";
import { useState } from "react";

import { UrgentDot } from "./urgent-dot";
import { cn } from "@/components/ui-primitives";
import { addDaysToDate } from "@/lib/roster/shifts/perth-time";

/**
* The month on Browse. Each date in the 14-day window carries the number of
* shifts that match the filters (the same rows as the list below it); a date
* with an urgent one carries a small warning mark, and a ring on the date
* with an urgent one carries a small amber dot, and a ring on the date
* means the reader is rostered that day. Past dates and dates outside the
* window carry no number.
*
* Drawn like the Roster month (mockup `.rost-mc rost-oc`, work-mode redesign
* 6 Oct 2026): a filled tile for the chosen day, a count pill per day (amber,
* with the warning mark, when it includes an urgent shift) and a ring on the
* 6 Oct 2026): a filled tile for the chosen day, a count pill per day (with an
* amber dot, the shared "needs attention" shape, when it includes an urgent shift) and a ring on the
* date when you're rostered. The meanings never rest on colour alone: the
* mark, the number and each day's spoken label carry them too.
*/
Expand Down Expand Up @@ -173,14 +174,10 @@ export function OpenShiftsCalendar({
<span
aria-hidden="true"
className={cn(
"nums inline-flex h-[0.9375rem] min-w-[1.375rem] items-center justify-center gap-0.5 rounded-full px-1 text-3xs font-extrabold leading-none",
urgent
? "border border-[color:var(--warning-border)] text-[color:var(--warning-text)]"
: "bg-[color:var(--mode-identity-soft)] text-[color:var(--mode-identity)]",
"nums inline-flex h-[0.9375rem] min-w-[1.375rem] items-center justify-center gap-0.5 rounded-full bg-[color:var(--mode-identity-soft)] px-1 text-3xs font-extrabold leading-none text-[color:var(--mode-identity)]",
)}
>
{urgent ? <TriangleAlert aria-hidden="true" strokeWidth={2.2} className="size-2.5" /> : null}
{count ? count : null}
{urgent ? <UrgentDot>{count || null}</UrgentDot> : count || null}
</span>
) : (
<i aria-hidden="true" className="block h-[0.9375rem]" />
Expand Down
18 changes: 11 additions & 7 deletions src/components/open-shifts/open-shifts-ui.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
"use client";

import { ChevronRight, CircleHelp, CircleX, Info, TriangleAlert } from "lucide-react";

import Link from "next/link";
import type { ReactNode } from "react";

Expand All @@ -17,6 +18,8 @@ import {
import type { RosterCheck } from "@/lib/open-shifts/roster-check";
import { MONTHS, WEEKDAYS, perthDateOf, perthTimeOf } from "@/lib/roster/shifts/perth-time";
import { zonedDateOf } from "@/lib/work-time/format";

import { UrgentDot } from "./urgent-dot";
import { currentWorkTimeZone } from "@/lib/work-time/current-zone";
import { WorkPageBack } from "@/components/work-frame/work-page-back";

Expand Down Expand Up @@ -181,12 +184,7 @@ export function ShiftRow({
status?: ReactNode;
}) {
const hours = hoursBetween(listing.startsAt, listing.endsAt);
const meta = [
endsNextDay(listing.startsAt, listing.endsAt) ? "Ends next day" : null,
listing.urgent ? "Urgent" : null,
]
.filter(Boolean)
.join(" · ");
const meta = endsNextDay(listing.startsAt, listing.endsAt) ? "Ends next day" : null;
return (
<li className={modeInsetHairline}>
<Link
Expand All @@ -205,7 +203,13 @@ export function ShiftRow({
{shiftTitle(listing)}
</span>
<span className="text-2xs text-[color:var(--text-muted)]">{shiftPlace(listing)}</span>
{meta ? <span className="text-2xs text-[color:var(--text-muted)]">{meta}</span> : null}
{meta || listing.urgent ? (
<span className="inline-flex items-center gap-1 text-2xs text-[color:var(--text-muted)]">
{listing.urgent ? <UrgentDot>Urgent</UrgentDot> : null}
Comment thread
qodo-code-review[bot] marked this conversation as resolved.
Resolved
{meta && listing.urgent ? " · " : null}
{meta}
</span>
) : null}
{status ?? (check ? <span className="mt-1.5">{<CheckLine check={check} />}</span> : null)}
</span>
<ChevronRight
Expand Down
20 changes: 20 additions & 0 deletions src/components/open-shifts/urgent-dot.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import type { ReactNode } from "react";

/**
* The urgent marker on Open shifts: the shared amber dot, the Work mode shape for "needs attention
* soon" (Ward Flow review, 12 Oct 2026). A red triangle means a date has passed, which an urgent shift
* has not. The dot leads the text it sits beside (the word "Urgent", or the day's count), and every
* place without visible text carries a spoken label, so the meaning never rests on the colour.
*/
export function UrgentDot({ children }: { children?: ReactNode }) {
return (
<>
<i
aria-hidden="true"
data-mark="dot"
className="inline-block size-1.5 shrink-0 rounded-full bg-[color:var(--warning)] forced-colors:bg-[CanvasText]"
/>
{children}
</>
);
}
13 changes: 13 additions & 0 deletions tests/open-shifts-browse-sample.dom.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,19 @@ describe("Open shifts, signed-out preview", () => {
).toBeTruthy();
});

it("marks urgent rows and the calendar key with the shared dot", () => {
render(<OpenShiftsBrowsePage />);

const legend = screen.getByRole("list", { name: "Calendar key" });
expect(legend.querySelector('[data-mark="dot"]')).not.toBeNull();
expect(legend.querySelector("svg.lucide-triangle-alert")).toBeNull();

const urgentRow = screen.getAllByRole("listitem").find((row) => row.textContent?.includes("Urgent"));
expect(urgentRow).toBeDefined();
expect(urgentRow?.querySelector('[data-mark="dot"]')).not.toBeNull();
expect(urgentRow?.querySelector("svg.lucide-triangle-alert")).toBeNull();
});

it("marks the days the example roster has the doctor working", () => {
render(<OpenShiftsBrowsePage />);
// Mon 5 Oct: on call tonight, off Tuesday, day shifts Wednesday to Friday.
Expand Down
4 changes: 4 additions & 0 deletions tests/open-shifts-calendar.dom.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,10 @@ describe("Open shifts Browse calendar", () => {
name: "Friday 9 October, 3 shifts match, includes an urgent shift",
}),
).toBeTruthy();
// The urgent day carries the shared amber dot, never the overdue triangle.
const urgentDay = screen.getByRole("button", { name: /^Friday 9 October/ });
expect(urgentDay.querySelector('[data-mark="dot"]')).not.toBeNull();
expect(urgentDay.querySelector("svg.lucide-triangle-alert")).toBeNull();
expect(screen.getByRole("button", { name: "Monday 12 October, you're rostered" })).toBeTruthy();
// A past rostered day is not called rostered.
expect(screen.getByRole("button", { name: "Thursday 1 October" })).toBeTruthy();
Expand Down
Loading