Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
68 changes: 65 additions & 3 deletions apps/web/src/features/viewer/resolveViewerDisplay.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down Expand Up @@ -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 正(分前)で早く次シフトへ切り替わる", () => {
Expand Down
39 changes: 31 additions & 8 deletions apps/web/src/features/viewer/resolveViewerDisplay.ts
Original file line number Diff line number Diff line change
@@ -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 };

Expand Down Expand Up @@ -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,
};
}
7 changes: 6 additions & 1 deletion apps/web/src/lib/datetime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
23 changes: 21 additions & 2 deletions apps/web/src/routes/_app.s.$siteId.viewer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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")({
Expand All @@ -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])),
Expand Down Expand Up @@ -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")
Expand Down Expand Up @@ -226,17 +239,23 @@ 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
? assignmentKeys.byDateShift(date, shiftId)
: ["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 =
Expand Down
Loading