diff --git a/client/src/utils/choreHelpers.js b/client/src/utils/choreHelpers.js index 5856d1c5..0b9f3f93 100644 --- a/client/src/utils/choreHelpers.js +++ b/client/src/utils/choreHelpers.js @@ -1,7 +1,12 @@ -import { CronExpressionParser } from 'cron-parser'; import { getServerTimezoneSync } from './timezone.js'; - -export function shouldShowChoreToday(schedule) { +import { cronFiresOn } from './choreDays.js'; + +// Whether a chore is on today's list. "Today" is the household's date (the +// server's time zone), not the browser's: a display in another zone must +// show the same chores as the server counts for the daily bonus. The +// crontab is matched on that date's calendar fields, as the server's +// cronFiresOnDate and the history row's cronFiresOn do. +export function shouldShowChoreToday(schedule, today = getTodayDateString()) { if (!schedule.visible) { return false; } @@ -16,24 +21,7 @@ export function shouldShowChoreToday(schedule) { return true; } - try { - const tz = getServerTimezoneSync(); - const today = new Date(); - today.setHours(0, 0, 0, 0); - - const interval = CronExpressionParser.parse(schedule.crontab, { - currentDate: new Date(today.getTime() + 24 * 60 * 60 * 1000), - tz - }); - - const prevOccurrence = interval.prev().toDate(); - prevOccurrence.setHours(0, 0, 0, 0); - - return prevOccurrence.getTime() === today.getTime(); - } catch (error) { - console.error('Error parsing crontab:', schedule.crontab, error); - return false; - } + return cronFiresOn(schedule.crontab, today); } /** @@ -61,7 +49,7 @@ export function hasOutstandingBonusChore(schedules, history, userId, today) { return schedules .filter((schedule) => schedule.user_id === userId && (schedule.clam_value || 0) > 0 - && shouldShowChoreToday(schedule)) + && shouldShowChoreToday(schedule, today)) .some((schedule) => !history.some((entry) => entry.chore_schedule_id === schedule.id && entry.user_id === userId && entry.date === today)); diff --git a/client/src/utils/choreHelpers.test.js b/client/src/utils/choreHelpers.test.js index a424181d..7350366d 100644 --- a/client/src/utils/choreHelpers.test.js +++ b/client/src/utils/choreHelpers.test.js @@ -1,7 +1,8 @@ -import { describe, it, expect, vi, beforeAll, afterAll } from 'vitest'; +import { describe, it, expect, vi, beforeAll, afterAll, afterEach } from 'vitest'; +const zones = vi.hoisted(() => ({ server: 'UTC' })); vi.mock('./timezone.js', () => ({ - getServerTimezoneSync: () => 'UTC', + getServerTimezoneSync: () => zones.server, })); import { shouldShowChoreToday, convertDaysToCrontab, getDueDateStatus, formatDueDate, hasOutstandingBonusChore, compareByKey, countsTowardDailyBonus } from './choreHelpers.js'; @@ -151,11 +152,6 @@ describe('hasOutstandingBonusChore', () => { it('does not block on a bonus chore that is not due today', () => { // Thursday-only chore, uncompleted, on a Sunday: nothing is outstanding. - // Avoid the day immediately after TODAY here: shouldShowChoreToday - // compares a cron occurrence resolved in the SERVER zone against local - // midnight, so with the mocked server zone (UTC) ahead of the runner's, - // tomorrow-00:00 lands on today's local date. That skew is issue #21, - // not this helper. const schedules = [bonus(24, '0 0 * * 4')]; expect(hasOutstandingBonusChore(schedules, [], 4, TODAY)).toBe(false); }); @@ -305,3 +301,48 @@ describe('compareByKey', () => { ], byNext)).toEqual(['three', 'seven']); }); }); + +// A display whose browser is in another time zone than the household still +// shows the household's day: the server counts chores, and the daily bonus, +// on that date (fork issue #68). +describe('shouldShowChoreToday across time zones', () => { + const browserTz = process.env.TZ; + const MONDAY_ONLY = { visible: true, crontab: '0 0 * * 1' }; + + afterEach(() => { + vi.useRealTimers(); + zones.server = 'UTC'; + if (browserTz === undefined) delete process.env.TZ; else process.env.TZ = browserTz; + }); + + const at = (iso, browser, server) => { + process.env.TZ = browser; + zones.server = server; + vi.useFakeTimers(); + vi.setSystemTime(new Date(iso)); + }; + + it('a browser west of the household follows the household into Monday', () => { + // Monday 03:00 in UTC is still Sunday evening in Los Angeles. + at('2026-10-19T03:00:00Z', 'America/Los_Angeles', 'UTC'); + expect(shouldShowChoreToday(MONDAY_ONLY)).toBe(true); + }); + + it('a browser west of the household does not show Monday chores on Sunday', () => { + // Sunday 20:00 in UTC; Los Angeles is Sunday too, the household is Sunday. + at('2026-10-18T20:00:00Z', 'America/Los_Angeles', 'UTC'); + expect(shouldShowChoreToday(MONDAY_ONLY)).toBe(false); + }); + + it('a browser behind a household in Tokyo follows Tokyo', () => { + // Sunday 16:00 UTC is Monday 01:00 in Tokyo. + at('2026-10-18T16:00:00Z', 'UTC', 'Asia/Tokyo'); + expect(shouldShowChoreToday(MONDAY_ONLY)).toBe(true); + }); + + it('a browser east of the household waits for the household day', () => { + // Monday 01:00 in Tokyo is Sunday 16:00 in UTC, the household's zone. + at('2026-10-18T16:00:00Z', 'Asia/Tokyo', 'UTC'); + expect(shouldShowChoreToday(MONDAY_ONLY)).toBe(false); + }); +}); diff --git a/client/src/utils/interfaceSettings.js b/client/src/utils/interfaceSettings.js index af32bee6..8fb100dc 100644 --- a/client/src/utils/interfaceSettings.js +++ b/client/src/utils/interfaceSettings.js @@ -1,4 +1,5 @@ import { normalizeDockHideMinutes } from './dockIdleHide.js'; +import { getTodayDateString } from './choreHelpers.js'; import { DEFAULT_EXPAND_IDLE_MINUTES, DEFAULT_EXPAND_METHOD, @@ -201,14 +202,12 @@ export const normalizeVacationModeSettings = (raw) => ({ // mirrors the server's isVacationActiveOn, and gives bounded vacations // client-side auto-expiry — chimes and the vacation screensaver come back on // their own the day after endDate. -export const isVacationModeActiveToday = (settings, today = null) => { +// Vacation dates are the household's, so "today" is the household's date +// (the server's time zone), not the browser's. +export const isVacationModeActiveToday = (settings, today = getTodayDateString()) => { if (!settings || settings.enabled !== true) return false; - const d = today || (() => { - const now = new Date(); - return `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}-${String(now.getDate()).padStart(2, '0')}`; - })(); - if (settings.startDate && d < settings.startDate) return false; - if (settings.endDate && d > settings.endDate) return false; + if (settings.startDate && today < settings.startDate) return false; + if (settings.endDate && today > settings.endDate) return false; return true; }; diff --git a/client/src/utils/interfaceSettings.test.js b/client/src/utils/interfaceSettings.test.js index cca8266e..b9f3b220 100644 --- a/client/src/utils/interfaceSettings.test.js +++ b/client/src/utils/interfaceSettings.test.js @@ -1,4 +1,10 @@ -import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; + +const zones = vi.hoisted(() => ({ server: 'UTC' })); +vi.mock('./timezone.js', async (importOriginal) => ({ + ...(await importOriginal()), + getServerTimezoneSync: () => zones.server, +})); // The vitest environment is node — provide a minimal localStorage. const storageBacking = new Map(); @@ -107,6 +113,28 @@ describe('isVacationModeActiveToday', () => { }); }); +// With no date passed (as app.jsx calls it), "today" is the household's date, +// not the browser's (fork issue #68). +describe('isVacationModeActiveToday without a date', () => { + const browserTz = process.env.TZ; + afterEach(() => { + vi.useRealTimers(); + zones.server = 'UTC'; + if (browserTz === undefined) delete process.env.TZ; else process.env.TZ = browserTz; + }); + + it('starts on the household date when the browser is still on the day before', () => { + // 03:00 UTC on the 20th is the evening of the 19th in Los Angeles. + process.env.TZ = 'America/Los_Angeles'; + vi.useFakeTimers(); + vi.setSystemTime(new Date('2026-07-20T03:00:00Z')); + const range = { enabled: true, startDate: '2026-07-20', endDate: '2026-07-25' }; + expect(isVacationModeActiveToday(range)).toBe(true); + zones.server = 'America/Los_Angeles'; + expect(isVacationModeActiveToday(range)).toBe(false); + }); +}); + import { hexToRgbTriplet, applyInterfaceColors } from './interfaceSettings.js'; describe('hexToRgbTriplet', () => { diff --git a/client/src/utils/timezone.js b/client/src/utils/timezone.js index ad591104..efa5ed87 100644 --- a/client/src/utils/timezone.js +++ b/client/src/utils/timezone.js @@ -1,6 +1,8 @@ import { API_BASE_URL } from './apiConfig.js'; -const FALLBACK_TIMEZONE = 'America/New_York'; +// Until /api/timezone answers (or if it fails), the browser's own zone is the +// best guess; watchServerTimezone corrects it on its next check. +const FALLBACK_TIMEZONE = Intl.DateTimeFormat().resolvedOptions().timeZone || 'UTC'; let cachedTimezone = null; diff --git a/client/src/utils/timezone.test.js b/client/src/utils/timezone.test.js index 04632a6f..6ffe5892 100644 --- a/client/src/utils/timezone.test.js +++ b/client/src/utils/timezone.test.js @@ -1,5 +1,7 @@ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +const BROWSER_TZ = Intl.DateTimeFormat().resolvedOptions().timeZone; + describe('timezone utilities', () => { const originalFetch = globalThis.fetch; @@ -28,7 +30,7 @@ describe('timezone utilities', () => { expect(fetchMock).toHaveBeenCalledTimes(1); }); - it('falls back to America/New_York when fetch fails', async () => { + it('falls back to the browser\'s own zone when fetch fails', async () => { globalThis.fetch = vi.fn(async () => { throw new Error('network down'); }); @@ -36,13 +38,13 @@ describe('timezone utilities', () => { const timezone = await import('./timezone.js'); const value = await timezone.getServerTimezone(); - expect(value).toBe('America/New_York'); + expect(value).toBe(BROWSER_TZ); }); it('getServerTimezoneSync defaults before initialization', async () => { const timezone = await import('./timezone.js'); - expect(timezone.getServerTimezoneSync()).toBe('America/New_York'); + expect(timezone.getServerTimezoneSync()).toBe(BROWSER_TZ); }); it('initTimezone primes the cache', async () => { @@ -81,7 +83,7 @@ describe('timezone utilities', () => { const timezone = await import('./timezone.js'); await expect(timezone.saveServerTimezone('Nope')).rejects.toThrow('Unknown time zone: Nope'); - expect(timezone.getServerTimezoneSync()).toBe('America/New_York'); + expect(timezone.getServerTimezoneSync()).toBe(BROWSER_TZ); }); describe('watchServerTimezone', () => {