diff --git a/src/app/(staff)/staff/page.tsx b/src/app/(staff)/staff/page.tsx
index e008f09..8b31775 100644
--- a/src/app/(staff)/staff/page.tsx
+++ b/src/app/(staff)/staff/page.tsx
@@ -18,7 +18,7 @@ import { getAvailableRewardFlags } from "@/services/loyalty/loyalty.service";
import { listAppointments, selectCurrentAppointment } from "@/services/scheduling/appointment.service";
import { DateStrip } from "@/features/staff/components/date-strip";
import { DayCalendar } from "@/features/staff/components/day-calendar";
-import type { CalendarAppointment, CalendarBlock } from "@/features/staff/components/day-calendar";
+import type { CalendarAppointment, CalendarBlock, CalendarColumn } from "@/features/staff/components/day-calendar";
import { BlockTimeButton } from "@/features/staff/components/block-time-button";
import { cn } from "@/lib/utils/cn";
import type { AppointmentStatus } from "@/types/domain";
@@ -124,6 +124,19 @@ export default async function StaffCalendarPage({
const currentTimeMinutes =
date === today ? minutesFromMidnight(new Date(), staff.business.timezone) : null;
+ const columns: CalendarColumn[] = [
+ {
+ id: staff.id,
+ name: staff.user.name ?? "You",
+ avatarUrl: staff.user.image,
+ hoursLabel: workingWindow ? formatRange(workingWindow.startMinutes, workingWindow.endMinutes) : null,
+ wholeDayOff: false,
+ appointments: calendarAppointments,
+ blocks,
+ workingWindow,
+ },
+ ];
+
// Desktop-only: a "Right Now" panel beside the calendar so a wide screen
// does more than center the mobile layout (see docs/phase-5-summary.md,
// "Responsive Design"). Only meaningful when viewing today.
@@ -181,12 +194,7 @@ export default async function StaffCalendarPage({
description={wholeDayOff.reason ?? "Approved time off."}
/>
) : view === "day" ? (
-
+
) : calendarAppointments.length === 0 ? (
-
+
{d.toLocaleDateString("en-GB", { weekday: "short" })}
-
+
{d.getDate()}
diff --git a/src/features/staff/components/day-calendar.tsx b/src/features/staff/components/day-calendar.tsx
index 017d985..bd2c5fa 100644
--- a/src/features/staff/components/day-calendar.tsx
+++ b/src/features/staff/components/day-calendar.tsx
@@ -1,23 +1,33 @@
+import Image from "next/image";
import Link from "next/link";
-import { Gift, NotebookText } from "lucide-react";
-import { StatusPill } from "@/components/ui/status-pill";
+import { Ban, CheckCheck, CircleDot, Clock, Gift, LogIn, NotebookText, UserX } from "lucide-react";
+import type { LucideIcon } from "lucide-react";
import { cn } from "@/lib/utils/cn";
import type { AppointmentStatus } from "@/types/domain";
const START_HOUR = 7;
const END_HOUR = 21;
-const PX_PER_HOUR = 64;
-const RANGE_START_MINUTES = START_HOUR * 60;
-const RANGE_END_MINUTES = END_HOUR * 60;
+const PX_PER_HOUR = 112;
+const MINUTES_PER_HOUR = 60;
+const RANGE_START_MINUTES = START_HOUR * MINUTES_PER_HOUR;
+const RANGE_END_MINUTES = END_HOUR * MINUTES_PER_HOUR;
+const HEADER_HEIGHT = 56;
function topFor(minutes: number): number {
- return ((clampToRange(minutes) - RANGE_START_MINUTES) / 60) * PX_PER_HOUR;
+ return ((clampToRange(minutes) - RANGE_START_MINUTES) / MINUTES_PER_HOUR) * PX_PER_HOUR;
}
function clampToRange(minutes: number): number {
return Math.min(RANGE_END_MINUTES, Math.max(RANGE_START_MINUTES, minutes));
}
function heightFor(startMinutes: number, endMinutes: number): number {
- return Math.max(20, topFor(endMinutes) - topFor(startMinutes));
+ return Math.max(24, topFor(endMinutes) - topFor(startMinutes));
+}
+/** Compact "9:30" / "1:15" clock text — no AM/PM, matching the tight in-block time-range label. */
+function formatCompactTime(minutes: number): string {
+ const h = Math.floor(minutes / MINUTES_PER_HOUR);
+ const m = minutes % MINUTES_PER_HOUR;
+ const displayHour = h % 12 === 0 ? 12 : h % 12;
+ return `${displayHour}:${String(m).padStart(2, "0")}`;
}
export interface CalendarAppointment {
@@ -39,25 +49,50 @@ export interface CalendarBlock {
kind: "break" | "timeOff";
}
+export interface CalendarColumn {
+ id: string;
+ name: string;
+ avatarUrl?: string | null;
+ /** Formatted working-hours range for the day (e.g. "10:00 AM – 7:00 PM"), or null if off entirely. */
+ hoursLabel: string | null;
+ wholeDayOff: boolean;
+ appointments: CalendarAppointment[];
+ blocks: CalendarBlock[];
+ /** The column's working window that day, for shading outside-hours space — null if off entirely. */
+ workingWindow: { startMinutes: number; endMinutes: number } | null;
+}
+
+const STATUS_BADGE_ICON: Record = {
+ pending_approval: Clock,
+ confirmed: CheckCheck,
+ arrived: LogIn,
+ in_progress: CircleDot,
+ completed: CheckCheck,
+ cancelled: Ban,
+ no_show: UserX,
+};
+
+/** Column width on small screens — under 100% so the next column peeks in at the edge, inviting a swipe (per the reference design). Desktop ignores this and flexes to fill instead. */
+const COLUMN_WIDTH_CLASS = "w-[86%] shrink-0 snap-start sm:w-[300px] lg:w-auto lg:flex-1";
+
/**
- * A single day's grid for one staff member. Deliberately no drag-and-drop —
- * every interactive element is a plain Link (open details / start a
- * reschedule from there), per the brief's preference for a reliable
- * tap-to-act flow over an unreliable drag interaction. Pure presentation:
- * every minute value here is already computed by the page from real
- * WorkingHours/Break/TimeOff/Appointment rows, nothing is derived here.
+ * A shared-axis, multi-column, swipeable day grid — one time axis pinned to
+ * the left edge (via `sticky`, so it never scrolls out of view), one column
+ * per staff member snapping into place as the row is swiped horizontally
+ * (CSS scroll-snap — no touch/gesture JS needed, matching this app's
+ * "reliable tap/scroll over fragile custom gestures" preference). A single
+ * staff member's own view (staff/page.tsx) is just the one-column case of
+ * the same component. Deliberately no drag-and-drop — every interactive
+ * element is a plain Link (open details / start a reschedule from there).
+ * Pure presentation: every minute value here is already computed by the
+ * page from real WorkingHours/Break/TimeOff/Appointment rows.
*/
export function DayCalendar({
- appointments,
- blocks,
- workingWindow,
+ columns,
currentTimeMinutes,
appointmentHrefBase = "/staff/appointments",
}: {
- appointments: CalendarAppointment[];
- blocks: CalendarBlock[];
- /** The staff member's working window that day, for shading outside-hours space — null if they're off entirely. */
- workingWindow: { startMinutes: number; endMinutes: number } | null;
+ columns: CalendarColumn[];
/** Minutes-from-midnight right now, only passed when the viewed day is today. */
currentTimeMinutes: number | null;
/** Owner's all-staff calendar reuses this component unchanged but links into /owner/appointments/[id] instead — see docs/phase-6-summary.md, "All-staff calendar approach". */
@@ -65,124 +100,242 @@ export function DayCalendar({
}) {
const hourMarks = Array.from({ length: END_HOUR - START_HOUR + 1 }, (_, i) => START_HOUR + i);
const gridHeight = (END_HOUR - START_HOUR) * PX_PER_HOUR;
+ const showCurrentTime =
+ currentTimeMinutes !== null && currentTimeMinutes >= RANGE_START_MINUTES && currentTimeMinutes <= RANGE_END_MINUTES;
return (
-
+ {/* Time axis — a plain sibling of the scrolling columns strip below, not part of its horizontal scroll at all, so it's immune to any scroll-snap/sticky edge cases: it simply never moves. */}
+
+
+ {/* Staff columns — a separately-scrolling strip, so swiping through staff never drags the axis along with it. Each column is a full swipeable "page": its own header, then its own grid body. */}
+