From 281f6b811711c839721e2ec1e8cbef090d5cd26e Mon Sep 17 00:00:00 2001 From: mrramam Date: Fri, 23 Oct 2026 11:45:49 -0700 Subject: [PATCH] fix(chores): a display in another time zone shows the household's chore day shouldShowChoreToday read the crontab in the household zone but compared it against midnight in the browser's zone. When the two zones are on different dates, a chore set for chosen weekdays showed on the wrong day: with the household on UTC and a browser in Los Angeles, a Monday-only chore showed all Sunday evening. The chore sounds and the daily bonus gate (hasOutstandingBonusChore) followed the same wrong day, so the widget could disagree with the server's bonus. - shouldShowChoreToday matches the crontab against the household's date (getTodayDateString) with cronFiresOn from choreDays.js, the calendar match the chore history row already uses and the server's cronFiresOnDate mirrors. hasOutstandingBonusChore passes its today. - isVacationModeActiveToday defaults today to the household's date, not the browser's, so vacation starts and ends on the household's day. - Until /api/timezone answers, the client assumes the browser's own zone instead of America/New_York. Every-day chores were never affected. Tests set the browser and the household zones on either side of midnight, both ways. --- client/src/utils/choreHelpers.js | 32 ++++--------- client/src/utils/choreHelpers.test.js | 55 +++++++++++++++++++--- client/src/utils/interfaceSettings.js | 13 +++-- client/src/utils/interfaceSettings.test.js | 30 +++++++++++- client/src/utils/timezone.js | 4 +- client/src/utils/timezone.test.js | 10 ++-- 6 files changed, 102 insertions(+), 42 deletions(-) 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', () => {