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
32 changes: 10 additions & 22 deletions client/src/utils/choreHelpers.js
Original file line number Diff line number Diff line change
@@ -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;
}
Expand All @@ -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);
}

/**
Expand Down Expand Up @@ -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));
Expand Down
55 changes: 48 additions & 7 deletions client/src/utils/choreHelpers.test.js
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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);
});
Expand Down Expand Up @@ -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);
});
});
13 changes: 6 additions & 7 deletions client/src/utils/interfaceSettings.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { normalizeDockHideMinutes } from './dockIdleHide.js';
import { getTodayDateString } from './choreHelpers.js';
import {
DEFAULT_EXPAND_IDLE_MINUTES,
DEFAULT_EXPAND_METHOD,
Expand Down Expand Up @@ -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;
};

Expand Down
30 changes: 29 additions & 1 deletion client/src/utils/interfaceSettings.test.js
Original file line number Diff line number Diff line change
@@ -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();
Expand Down Expand Up @@ -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', () => {
Expand Down
4 changes: 3 additions & 1 deletion client/src/utils/timezone.js
Original file line number Diff line number Diff line change
@@ -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;

Expand Down
10 changes: 6 additions & 4 deletions client/src/utils/timezone.test.js
Original file line number Diff line number Diff line change
@@ -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;

Expand Down Expand Up @@ -28,21 +30,21 @@ 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');
});

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 () => {
Expand Down Expand Up @@ -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', () => {
Expand Down
Loading