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
121 changes: 121 additions & 0 deletions src/lib/business-date.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
106 changes: 106 additions & 0 deletions src/lib/business-date.ts
Original file line number Diff line number Diff line change
@@ -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;
}
}
2 changes: 2 additions & 0 deletions src/lib/server/db.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -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'],
Expand Down
24 changes: 9 additions & 15 deletions src/routes/calculate/+page.server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 }) => {
Expand All @@ -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 = {
Expand Down
4 changes: 3 additions & 1 deletion src/routes/calculate/+page.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -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<StaffRow[]>(data.staff);

Expand Down Expand Up @@ -78,7 +80,7 @@
<div>
<label class="label">Shift</label>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:0.5rem;">
{#each ['Lunch', 'Dinner'] as s}
{#each SHIFTS as s}
<button type="button"
onclick={() => shift = s}
style="
Expand Down
6 changes: 6 additions & 0 deletions src/routes/settings/+page.server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import type { Actions, PageServerLoad } from './$types';
import { fail, redirect } from '@sveltejs/kit';
import db from '$lib/server/db';
import { getSettings } from '$lib/server/auth';
import { isValidTimeZone, DEFAULT_TIMEZONE } from '$lib/business-date';

export const load: PageServerLoad = ({ locals }) => {
if (!locals.user) redirect(303, '/');
Expand Down Expand Up @@ -33,12 +34,17 @@ export const actions: Actions = {
if (kitchenPct + barLiquorPct > 100)
return fail(400, { error: 'Kitchen % and bar liquor % cannot exceed 100% combined' });

const timezone = String(fd.get('timezone') ?? DEFAULT_TIMEZONE);
if (!isValidTimeZone(timezone))
return fail(400, { error: 'Invalid timezone' });

const updates: [string, string][] = [
['cc_fee_rate', String(ccFeeRate)],
['kitchen_pct', String(kitchenPct)],
['bar_liquor_pct', String(barLiquorPct)],
['busser_rate', String(busserRate)],
['lunch_cutoff', String(fd.get('lunch_cutoff') ?? '15:00')],
['timezone', timezone],
['restaurant_name', String(fd.get('restaurant_name') ?? '')],
['google_sheets_spreadsheet_id', String(fd.get('google_sheets_spreadsheet_id') ?? '')],
['google_sheets_sheet_name', String(fd.get('google_sheets_sheet_name') ?? '')],
Expand Down
22 changes: 21 additions & 1 deletion src/routes/settings/+page.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,17 @@

let { data, form }: { data: PageData; form: ActionData } = $props();
let saving = $state(false);

// Common US restaurant timezones. Business day rolls over at 3 AM local time.
const TIMEZONES = [
{ id: 'America/Los_Angeles', label: 'Pacific (Los Angeles)' },
{ id: 'America/Denver', label: 'Mountain (Denver)' },
{ id: 'America/Phoenix', label: 'Arizona (no DST)' },
{ id: 'America/Chicago', label: 'Central (Chicago)' },
{ id: 'America/New_York', label: 'Eastern (New York)' },
{ id: 'America/Anchorage', label: 'Alaska (Anchorage)' },
{ id: 'Pacific/Honolulu', label: 'Hawaii (Honolulu)' },
];
</script>

<div class="page" style="padding-top:0;">
Expand Down Expand Up @@ -47,7 +58,16 @@
</label>

<label class="field">
<span>Lunch Cutoff (Pacific, 24h)</span>
<span>Timezone</span>
<select class="input" name="timezone">
{#each TIMEZONES as tz}
<option value={tz.id} selected={tz.id === (data.settings.timezone ?? 'America/Los_Angeles')}>{tz.label}</option>
{/each}
</select>
</label>

<label class="field">
<span>Lunch Cutoff (local, 24h)</span>
<input class="input" type="time" name="lunch_cutoff"
value={data.settings.lunch_cutoff ?? '15:00'} />
</label>
Expand Down