diff --git a/src/lib/business-date.test.ts b/src/lib/business-date.test.ts new file mode 100644 index 0000000..0153505 --- /dev/null +++ b/src/lib/business-date.test.ts @@ -0,0 +1,121 @@ +import { describe, it, expect } from 'vitest'; +import { businessDate, defaultShift, isValidTimeZone, DEFAULT_TIMEZONE } from './business-date'; + +const PACIFIC = 'America/Los_Angeles'; + +// Helper: build a Date from a UTC instant so tests are independent of the +// machine's local timezone (CI runs in UTC). +const at = (iso: string) => new Date(iso); + +describe('businessDate', () => { + it('defaults to America/Los_Angeles', () => { + expect(DEFAULT_TIMEZONE).toBe(PACIFIC); + }); + + it('reproduces the reported bug: 8 PM Pacific stays the same calendar day', () => { + // 8:00 PM PDT on 2026-07-13 is 2026-07-14T03:00:00Z. The old UTC-based + // code showed 2026-07-14; the business date must still be 2026-07-13. + expect(businessDate(at('2026-07-14T03:00:00Z'), PACIFIC)).toBe('2026-07-13'); + }); + + it('does not advance to the next day at closing time (1 AM Pacific)', () => { + // 1:00 AM PDT on 2026-07-14 is 2026-07-14T08:00:00Z — still the prior + // business day because the restaurant day rolls over at 3 AM. + expect(businessDate(at('2026-07-14T08:00:00Z'), PACIFIC)).toBe('2026-07-13'); + }); + + it('stays on the prior day just before the 3 AM rollover (2:59 AM Pacific)', () => { + expect(businessDate(at('2026-07-14T09:59:00Z'), PACIFIC)).toBe('2026-07-13'); + }); + + it('advances to the new day exactly at 3 AM Pacific', () => { + expect(businessDate(at('2026-07-14T10:00:00Z'), PACIFIC)).toBe('2026-07-14'); + }); + + it('stays on the new day after 3 AM Pacific', () => { + expect(businessDate(at('2026-07-14T10:30:00Z'), PACIFIC)).toBe('2026-07-14'); + expect(businessDate(at('2026-07-14T19:00:00Z'), PACIFIC)).toBe('2026-07-14'); // noon + }); + + it('late evening Pacific stays on the same calendar day', () => { + // 11:30 PM PDT on 2026-07-13 → 2026-07-14T06:30:00Z + expect(businessDate(at('2026-07-14T06:30:00Z'), PACIFIC)).toBe('2026-07-13'); + }); + + it('rolls back across a month boundary', () => { + // 12:30 AM PDT on 2026-08-01 → 2026-08-01T07:30:00Z → prior day 2026-07-31 + expect(businessDate(at('2026-08-01T07:30:00Z'), PACIFIC)).toBe('2026-07-31'); + }); + + it('rolls back across a year boundary', () => { + // 1:00 AM PST on 2026-01-01 → 2026-01-01T09:00:00Z → 2025-12-31 + expect(businessDate(at('2026-01-01T09:00:00Z'), PACIFIC)).toBe('2025-12-31'); + }); + + it('handles the spring-forward DST day correctly', () => { + // 2026-03-08: clocks jump 2 AM PST → 3 AM PDT. 1:00 AM PST = 09:00Z (prior day), + // 3:00 AM PDT = 10:00Z (new day, exactly at rollover). + expect(businessDate(at('2026-03-08T09:00:00Z'), PACIFIC)).toBe('2026-03-07'); + expect(businessDate(at('2026-03-08T10:00:00Z'), PACIFIC)).toBe('2026-03-08'); + }); + + it('works for other timezones (America/New_York)', () => { + const NY = 'America/New_York'; + // 1:00 AM EDT on 2026-07-14 → 2026-07-14T05:00:00Z → prior day + expect(businessDate(at('2026-07-14T05:00:00Z'), NY)).toBe('2026-07-13'); + // 3:00 AM EDT on 2026-07-14 → 2026-07-14T07:00:00Z → new day + expect(businessDate(at('2026-07-14T07:00:00Z'), NY)).toBe('2026-07-14'); + }); + + it('respects a custom rollover hour', () => { + // With a 6 AM rollover, 5 AM Pacific still belongs to the prior day. + // 5:00 AM PDT on 2026-07-14 → 2026-07-14T12:00:00Z + expect(businessDate(at('2026-07-14T12:00:00Z'), PACIFIC, 6)).toBe('2026-07-13'); + expect(businessDate(at('2026-07-14T13:00:00Z'), PACIFIC, 6)).toBe('2026-07-14'); // 6 AM + }); +}); + +describe('defaultShift', () => { + const LUNCH_CUTOFF = '15:00'; + + it('is Dinner during late-night closing (before the 3 AM rollover)', () => { + // 1:00 AM PDT — you are closing out dinner, not lunch. + expect(defaultShift(at('2026-07-14T08:00:00Z'), PACIFIC, LUNCH_CUTOFF)).toBe('Dinner'); + }); + + it('is Lunch in the morning after the rollover', () => { + // 10:00 AM PDT → 2026-07-14T17:00:00Z + expect(defaultShift(at('2026-07-14T17:00:00Z'), PACIFIC, LUNCH_CUTOFF)).toBe('Lunch'); + }); + + it('is Dinner in the evening', () => { + // 8:00 PM PDT → 2026-07-14T03:00:00Z (next UTC day, but 8 PM local) + expect(defaultShift(at('2026-07-14T03:00:00Z'), PACIFIC, LUNCH_CUTOFF)).toBe('Dinner'); + }); + + it('switches to Dinner exactly at the lunch cutoff', () => { + // 3:00 PM PDT → 2026-07-14T22:00:00Z + expect(defaultShift(at('2026-07-14T22:00:00Z'), PACIFIC, LUNCH_CUTOFF)).toBe('Dinner'); + // 2:59 PM PDT → 2026-07-14T21:59:00Z is still Lunch + expect(defaultShift(at('2026-07-14T21:59:00Z'), PACIFIC, LUNCH_CUTOFF)).toBe('Lunch'); + }); + + it('is Lunch just after the rollover (3:30 AM Pacific)', () => { + // 3:30 AM PDT → 2026-07-14T10:30:00Z + expect(defaultShift(at('2026-07-14T10:30:00Z'), PACIFIC, LUNCH_CUTOFF)).toBe('Lunch'); + }); +}); + +describe('isValidTimeZone', () => { + it('accepts valid IANA zones', () => { + expect(isValidTimeZone('America/Los_Angeles')).toBe(true); + expect(isValidTimeZone('America/New_York')).toBe(true); + expect(isValidTimeZone('UTC')).toBe(true); + }); + + it('rejects invalid zones', () => { + expect(isValidTimeZone('Not/AZone')).toBe(false); + expect(isValidTimeZone('')).toBe(false); + expect(isValidTimeZone('Pacific Time')).toBe(false); + }); +}); diff --git a/src/lib/business-date.ts b/src/lib/business-date.ts new file mode 100644 index 0000000..ee6f3a8 --- /dev/null +++ b/src/lib/business-date.ts @@ -0,0 +1,106 @@ +// Business-day date handling. +// +// A restaurant's "business day" is not the same as the calendar day: service +// that runs past midnight still belongs to the previous day's books. We roll +// the business date over at 3 AM local time, so late-night close-outs record +// against the day the shift started rather than jumping to tomorrow. +// +// All wall-clock reasoning is done in a configurable IANA timezone (default +// America/Los_Angeles) so the app is correct regardless of the server's own +// clock — the previous code used UTC and showed tomorrow's date after ~5 PM +// Pacific. + +export const DEFAULT_TIMEZONE = 'America/Los_Angeles'; + +/** Hour (local time) at which the business day rolls over to the next date. */ +export const DEFAULT_ROLLOVER_HOUR = 3; + +export interface LocalParts { + year: number; // full year, e.g. 2026 + month: number; // 1-12 + day: number; // 1-31 + hour: number; // 0-23 + minute: number; // 0-59 +} + +/** Wall-clock parts of `now` as observed in `timeZone`. */ +export function localParts(now: Date, timeZone: string): LocalParts { + const parts = new Intl.DateTimeFormat('en-US', { + timeZone, + year: 'numeric', + month: '2-digit', + day: '2-digit', + hour: '2-digit', + minute: '2-digit', + hourCycle: 'h23', // 00-23; avoids the "24" that hour12:false can emit at midnight + }).formatToParts(now); + const get = (type: string) => parts.find((p) => p.type === type)!.value; + return { + year: parseInt(get('year'), 10), + month: parseInt(get('month'), 10), + day: parseInt(get('day'), 10), + hour: parseInt(get('hour'), 10), + minute: parseInt(get('minute'), 10), + }; +} + +function pad2(n: number): string { + return String(n).padStart(2, '0'); +} + +/** + * The current business date (YYYY-MM-DD) in `timeZone`. Between midnight and + * `rolloverHour` local time the date is still the previous calendar day. + * + * Day subtraction is done as pure calendar arithmetic (via UTC on the plain + * Y/M/D components), so month/year boundaries and DST transitions are handled + * without offset math. + */ +export function businessDate( + now: Date, + timeZone: string, + rolloverHour: number = DEFAULT_ROLLOVER_HOUR, +): string { + const { year, month, day, hour } = localParts(now, timeZone); + let y = year; + let m = month; + let d = day; + if (hour < rolloverHour) { + const prev = new Date(Date.UTC(year, month - 1, day)); + prev.setUTCDate(prev.getUTCDate() - 1); + y = prev.getUTCFullYear(); + m = prev.getUTCMonth() + 1; + d = prev.getUTCDate(); + } + return `${y}-${pad2(m)}-${pad2(d)}`; +} + +/** + * Which shift to default the tip-entry form to, based on the wall clock in + * `timeZone`. Between the lunch cutoff and the 3 AM rollover (i.e. dinner + * service and the late-night close-out) it's Dinner; the rest of the day is + * Lunch. + */ +export function defaultShift( + now: Date, + timeZone: string, + lunchCutoff: string, + rolloverHour: number = DEFAULT_ROLLOVER_HOUR, +): 'Lunch' | 'Dinner' { + const { hour, minute } = localParts(now, timeZone); + const [cutoffH, cutoffM] = (lunchCutoff || '15:00').split(':').map(Number); + const afterLunchCutoff = hour > cutoffH || (hour === cutoffH && minute >= cutoffM); + const beforeRollover = hour < rolloverHour; + return afterLunchCutoff || beforeRollover ? 'Dinner' : 'Lunch'; +} + +/** True if `tz` is an IANA timezone the runtime understands. */ +export function isValidTimeZone(tz: string): boolean { + if (!tz) return false; + try { + new Intl.DateTimeFormat('en-US', { timeZone: tz }); + return true; + } catch { + return false; + } +} diff --git a/src/lib/server/db.ts b/src/lib/server/db.ts index 141de94..239317a 100644 --- a/src/lib/server/db.ts +++ b/src/lib/server/db.ts @@ -2,6 +2,7 @@ import Database from 'better-sqlite3'; import { mkdirSync } from 'fs'; import { dirname } from 'path'; import bcrypt from 'bcryptjs'; +import { DEFAULT_TIMEZONE } from '../business-date'; const dbPath = process.env.DATABASE_PATH ?? './data/tipsplit.db'; @@ -144,6 +145,7 @@ for (const [key, value] of [ ['bar_liquor_pct', '10'], ['busser_rate', '20'], ['lunch_cutoff', '15:00'], + ['timezone', DEFAULT_TIMEZONE], ['restaurant_name', 'My Restaurant'], ['google_sheets_spreadsheet_id', ''], ['google_sheets_sheet_name', 'Tip History'], diff --git a/src/routes/calculate/+page.server.ts b/src/routes/calculate/+page.server.ts index c6b48fe..e02d28b 100644 --- a/src/routes/calculate/+page.server.ts +++ b/src/routes/calculate/+page.server.ts @@ -3,6 +3,7 @@ import { fail, redirect } from '@sveltejs/kit'; import db from '$lib/server/db'; import { getSettings } from '$lib/server/auth'; import { calculate, dollarsToCents } from '$lib/calculator'; +import { businessDate, defaultShift, DEFAULT_TIMEZONE } from '$lib/business-date'; import type { StaffRow } from '$lib/server/db'; export const load: PageServerLoad = ({ locals }) => { @@ -13,21 +14,14 @@ export const load: PageServerLoad = ({ locals }) => { ).all() as StaffRow[]; const settings = getSettings(); - const today = new Date().toISOString().split('T')[0]; - const [cutoffH, cutoffM] = (settings.lunch_cutoff ?? '15:00').split(':').map(Number); - const parts = new Intl.DateTimeFormat('en-US', { - timeZone: 'America/Los_Angeles', - hour: '2-digit', - minute: '2-digit', - hour12: false, - }).formatToParts(new Date()); - const localHour = parseInt(parts.find(p => p.type === 'hour')!.value, 10); - const localMinute = parseInt(parts.find(p => p.type === 'minute')!.value, 10); - const defaultShift = localHour > cutoffH || (localHour === cutoffH && localMinute >= cutoffM) - ? 'Dinner' - : 'Lunch'; - - return { staff, settings, today, defaultShift, user: locals.user }; + const timeZone = settings.timezone ?? DEFAULT_TIMEZONE; + const now = new Date(); + // Business day, not calendar day: late-night close-outs stay on the prior + // date until 3 AM local time (see $lib/business-date). + const today = businessDate(now, timeZone); + const shift = defaultShift(now, timeZone, settings.lunch_cutoff ?? '15:00'); + + return { staff, settings, today, defaultShift: shift, user: locals.user }; }; export const actions: Actions = { diff --git a/src/routes/calculate/+page.svelte b/src/routes/calculate/+page.svelte index 0c50f31..f91dec2 100644 --- a/src/routes/calculate/+page.svelte +++ b/src/routes/calculate/+page.svelte @@ -9,6 +9,8 @@ let shift = $state(data.defaultShift); let date = $state(data.today); + const SHIFTS = ['Lunch', 'Dinner'] as const; + // Live staff list — starts from server data, updated when new person is added let staff = $state(data.staff); @@ -78,7 +80,7 @@
- {#each ['Lunch', 'Dinner'] as s} + {#each SHIFTS as s}