diff --git a/apps/web/src/features/viewer/resolveViewerDisplay.test.ts b/apps/web/src/features/viewer/resolveViewerDisplay.test.ts index 123f066..0b97c58 100644 --- a/apps/web/src/features/viewer/resolveViewerDisplay.test.ts +++ b/apps/web/src/features/viewer/resolveViewerDisplay.test.ts @@ -33,6 +33,36 @@ const two: WorkPattern = { ], }; +// Night shift runs past midnight (see docs/domain/assignment.md) +const overnight: WorkPattern = { + mode: "multi", + shifts: [ + { id: "day", name: "日勤", startTime: "09:00", endTime: "19:00", order: 0 }, + { + id: "night", + name: "夜勤", + startTime: "19:00", + endTime: "09:00", + order: 1, + }, + ], +}; + +// A shift starting just after midnight, so a lead time pushes its switch into the previous day +const earlyStart: WorkPattern = { + mode: "multi", + shifts: [ + { + id: "early", + name: "早番", + startTime: "00:30", + endTime: "12:00", + order: 0, + }, + { id: "day", name: "日勤", startTime: "12:00", endTime: "00:30", order: 1 }, + ], +}; + describe("resolveViewerDisplay", () => { it("manual は固定の日付・シフトを返す", () => { const c = config({ @@ -67,9 +97,41 @@ describe("resolveViewerDisplay", () => { ); }); - it("auto は始業前は日跨ぎで直前のシフト(最後)を採用", () => { - // 08:00 is before the day-shift switch (09:00) -> wraps to the last Night shift - expect(resolveViewerDisplay(config({}), two, day(8)).shiftId).toBe("night"); + it("auto は始業前は日跨ぎで直前のシフト(最後)を採用し、日付も前日に戻す", () => { + // 08:00 is before the day-shift switch (09:00) -> wraps to the last Night shift, + // which started the previous evening, so the date must roll back with it + expect(resolveViewerDisplay(config({}), two, day(8))).toEqual({ + date: "2026-07-04", + shiftId: "night", + }); + }); + + it("日跨ぎ夜勤の最中は、シフトが始まった前日の配置を表示する", () => { + // 19:00-09:00 night shift seen at 03:00 -> the placement is keyed to the previous day + expect(resolveViewerDisplay(config({}), overnight, day(3))).toEqual({ + date: "2026-07-04", + shiftId: "night", + }); + // Same shift before midnight resolves to the same calendar day it started on + expect(resolveViewerDisplay(config({}), overnight, day(20))).toEqual({ + date: "2026-07-05", + shiftId: "night", + }); + }); + + it("leadMinutes で日付を跨いで前倒しした場合は翌日の配置を表示する", () => { + // Early shift starts 00:30; a 60-min lead moves its switch back to 23:30 the day before, + // so at 23:40 we are already showing the shift that starts tomorrow + const c = config({ leadMinutes: 60 }); + expect(resolveViewerDisplay(c, earlyStart, day(23, 40))).toEqual({ + date: "2026-07-06", + shiftId: "early", + }); + // Just after midnight it is the same shift, now starting on the current day + expect(resolveViewerDisplay(c, earlyStart, day(0, 10))).toEqual({ + date: "2026-07-05", + shiftId: "early", + }); }); it("leadMinutes 正(分前)で早く次シフトへ切り替わる", () => { diff --git a/apps/web/src/features/viewer/resolveViewerDisplay.ts b/apps/web/src/features/viewer/resolveViewerDisplay.ts index 4e5686b..982a8fb 100644 --- a/apps/web/src/features/viewer/resolveViewerDisplay.ts +++ b/apps/web/src/features/viewer/resolveViewerDisplay.ts @@ -1,5 +1,10 @@ import type { ViewerConfig, WorkPattern } from "@haizu/shared"; -import { hmToMinutes, minutesOfDay, toDateStr } from "#/lib/datetime"; +import { + addDaysStr, + hmToMinutes, + minutesOfDay, + toDateStr, +} from "#/lib/datetime"; export type ViewerDisplay = { date: string; shiftId: string | null }; @@ -29,18 +34,36 @@ export function resolveViewerDisplay( if (shifts.length === 0) return { date, shiftId: null }; const nowMin = minutesOfDay(now); - // Each shift's switch time = start time - leadMinutes (positive = earlier). Normalized over 24 hours. const switches = shifts - .map((s) => ({ - id: s.id, - at: mod(hmToMinutes(s.startTime) - config.leadMinutes, DAY), - })) + .map((s) => { + // Switch time = start - leadMinutes (positive = earlier), which can fall outside today. + // startDayOffset records which day the shift itself starts on, relative to the day its + // switch lands on: a lead that reaches back past midnight makes the shift start the next + // day (+1), a negative lead reaching past midnight makes it the previous day (-1). + const raw = hmToMinutes(s.startTime) - config.leadMinutes; + return { + id: s.id, + at: mod(raw, DAY), + startDayOffset: -Math.floor(raw / DAY), + }; + }) .sort((a, b) => a.at - b.at); // Take the largest switch time <= now. If none, wrap to the last shift (largest at) across the day boundary. let active = switches[switches.length - 1]; + let switchDayOffset = -1; for (const s of switches) { - if (s.at <= nowMin) active = s; + if (s.at <= nowMin) { + active = s; + switchDayOffset = 0; + } } - return { date, shiftId: active.id }; + + // Placements are keyed by the date the shift starts, so an overnight shift still running past + // midnight (e.g. 19:00-09:00 seen at 03:00) must resolve to yesterday's date, not today's. + const dayOffset = switchDayOffset + active.startDayOffset; + return { + date: dayOffset === 0 ? date : addDaysStr(now, dayOffset), + shiftId: active.id, + }; } diff --git a/apps/web/src/lib/datetime.ts b/apps/web/src/lib/datetime.ts index e80bc37..83cfea9 100644 --- a/apps/web/src/lib/datetime.ts +++ b/apps/web/src/lib/datetime.ts @@ -27,9 +27,14 @@ export function todayStr(): string { return toDateStr(new Date()); } +// The given instant offset by whole days, as "YYYY-MM-DD" (device-TZ based) +export function addDaysStr(d: Date, days: number): string { + return toDateStr(new Date(d.getTime() + days * 24 * 60 * 60 * 1000)); +} + // Previous day as "YYYY-MM-DD" (device-TZ based) export function yesterdayStr(): string { - return toDateStr(new Date(Date.now() - 24 * 60 * 60 * 1000)); + return addDaysStr(new Date(), -1); } // "YYYY-MM-DD" -> locale-specific date label (with weekday) diff --git a/apps/web/src/routes/_app.s.$siteId.viewer.tsx b/apps/web/src/routes/_app.s.$siteId.viewer.tsx index 98e4d5a..fe08211 100644 --- a/apps/web/src/routes/_app.s.$siteId.viewer.tsx +++ b/apps/web/src/routes/_app.s.$siteId.viewer.tsx @@ -15,7 +15,7 @@ import { fetchVersionSpots, } from "#/lib/api/areas"; import { assignmentKeys, fetchAssignments } from "#/lib/api/assignments"; -import { fetchEmployees } from "#/lib/api/employees"; +import { employeeKeys, fetchEmployees } from "#/lib/api/employees"; import { fetchViewerConfigs, viewerConfigKeys } from "#/lib/api/viewer"; import { fetchWorkPattern, workPatternKeys } from "#/lib/api/workPatterns"; import { formatClock, formatDateLabel, toDateStr } from "#/lib/datetime"; @@ -27,6 +27,13 @@ const BASE_WIDTH = 760; // Margin around the board (PlacementViewCanvas's inner p-4 + a clipping-prevention margin) const BOARD_PADDING = 56; +// The viewer runs on always-on monitors, so it has to pick up placements confirmed elsewhere +// without a reload. Background refetch is required because TanStack Query pauses +// refetchInterval while the window is unfocused, which a kiosk display usually is. +const ASSIGNMENT_POLL_MS = 60_000; +// Layout and master data change far less often than assignments +const MASTER_POLL_MS = 300_000; + type ViewerSearch = { area?: string }; export const Route = createFileRoute("/_app/s/$siteId/viewer")({ @@ -53,10 +60,14 @@ function useCommonData() { const { data: configs = [] } = useQuery({ queryKey: viewerConfigKeys.all, queryFn: fetchViewerConfigs, + refetchInterval: MASTER_POLL_MS, + refetchIntervalInBackground: true, }); const { data: workPattern } = useQuery({ queryKey: workPatternKeys.detail, queryFn: fetchWorkPattern, + refetchInterval: MASTER_POLL_MS, + refetchIntervalInBackground: true, }); const configByArea = useMemo( () => new Map(configs.map((c) => [c.areaId, c])), @@ -127,6 +138,8 @@ function ViewerAreaCard({ shiftId: (display as { shiftId: string | null }).shiftId, }), enabled: !!display, + refetchInterval: ASSIGNMENT_POLL_MS, + refetchIntervalInBackground: true, }); const assigned = assignments.find((a) => a.areaId === area.id && a.status === "confirmed") @@ -226,6 +239,8 @@ function ViewerDetail({ areaId }: { areaId: string }) { const { data: area } = useQuery({ queryKey: areaKeys.detail(areaId), queryFn: () => fetchArea(areaId), + refetchInterval: MASTER_POLL_MS, + refetchIntervalInBackground: true, }); const { data: assignments = [] } = useQuery({ queryKey: date @@ -233,10 +248,14 @@ function ViewerDetail({ areaId }: { areaId: string }) { : ["viewer-detail-idle", areaId], queryFn: () => fetchAssignments({ date: date as string, shiftId }), enabled: !!date, + refetchInterval: ASSIGNMENT_POLL_MS, + refetchIntervalInBackground: true, }); const { data: employees = [] } = useQuery({ - queryKey: ["employees"], + queryKey: employeeKeys.all, queryFn: fetchEmployees, + refetchInterval: MASTER_POLL_MS, + refetchIntervalInBackground: true, }); const serverAssignment =