diff --git a/samples/crm-web/src/features/sales/__tests__/consoleHorizon.test.ts b/samples/crm-web/src/features/sales/__tests__/consoleHorizon.test.ts new file mode 100644 index 00000000..3a1aff39 --- /dev/null +++ b/samples/crm-web/src/features/sales/__tests__/consoleHorizon.test.ts @@ -0,0 +1,52 @@ +/** + * The console's horizon boundaries, pinned against the timezone that broke them. + * + * These run under `TZ=Asia/Bangkok` (UTC+07) rather than the machine's own zone, because the bug + * they cover is invisible at UTC and at every negative offset: `new Date(2026, 9, 1)` is local + * midnight, `toISOString()` moves it back seven hours, and the quarter ends on 30 September + * instead of 1 October. A test that ran in the developer's zone would pass in London and fail in + * Hanoi, which is exactly the failure being fixed. + */ +import { afterAll, beforeAll, describe, expect, it } from 'vitest' +import { horizonBounds } from '../useConsole' + +const REAL_TZ = process.env.TZ + +describe('horizonBounds, east of UTC', () => { + beforeAll(() => { + process.env.TZ = 'Asia/Bangkok' + }) + + afterAll(() => { + process.env.TZ = REAL_TZ + }) + + it('ends the quarter on the first day of the next one, not the day before', () => { + // 8 August is in Q3, which runs to 30 September inclusive — so the exclusive bound is 1 Oct. + expect(horizonBounds(new Date(2026, 7, 8, 12)).quarterEnd).toBe('2026-10-01') + }) + + it('keeps a deal closing on the last day of the quarter inside it', () => { + const { quarterEnd } = horizonBounds(new Date(2026, 7, 8, 12)) + + // This is the comparison the filter makes. Before the fix it was true, and the first screen + // of the application showed an empty pipeline to every reader in this timezone. + expect('2026-09-30' >= quarterEnd).toBe(false) + }) + + it('ends the month on the first of the next month', () => { + expect(horizonBounds(new Date(2026, 7, 8, 12)).monthEnd).toBe('2026-09-01') + }) + + it('reads today as the local calendar day, just after local midnight', () => { + // 00:30 local on 9 August is 17:30Z on 8 August. `toISOString()` calls that yesterday. + expect(horizonBounds(new Date(2026, 7, 9, 0, 30)).today).toBe('2026-08-09') + }) + + it('ends December in the next year rather than wrapping to January of this one', () => { + const { quarterEnd, monthEnd } = horizonBounds(new Date(2026, 11, 15, 12)) + + expect(quarterEnd).toBe('2027-01-01') + expect(monthEnd).toBe('2027-01-01') + }) +}) diff --git a/samples/crm-web/src/features/sales/useConsole.ts b/samples/crm-web/src/features/sales/useConsole.ts index b6d62e66..78198f3b 100644 --- a/samples/crm-web/src/features/sales/useConsole.ts +++ b/samples/crm-web/src/features/sales/useConsole.ts @@ -22,6 +22,51 @@ import type { RecordView } from '@/api/contracts' * carry means a stage an administrator added appears the moment a deal enters it. */ +/** + * A `Date`'s calendar day where the person is, as `YYYY-MM-DD`. + * + * `toISOString().slice(0, 10)` is the obvious way to write this and it is wrong everywhere east + * of Greenwich: `new Date(2026, 9, 1)` is local midnight on 1 October, which in UTC+07 is + * 2026-09-30T17:00Z, so the string comes back a day early. The boundaries here are compared + * against `close_date`, which the server sends as a plain calendar date with no zone at all, so + * the comparison has to be made in the same terms. + * + * The visible symptom was the whole point of this function: with the quarter ending a day early, + * an opportunity closing on the last day of the quarter fell outside "this quarter" — the default + * filter on the first screen of the application — and every reader in Asia, Australia or eastern + * Europe was shown an empty pipeline where a reader in London was shown a full one. + */ +function localDay(date: Date): string { + const month = String(date.getMonth() + 1).padStart(2, '0') + const day = String(date.getDate()).padStart(2, '0') + + return `${date.getFullYear()}-${month}-${day}` +} + +export interface HorizonBounds { + /** The local calendar day, as the server writes `close_date`. */ + today: string + /** Exclusive: the first day of next month. */ + monthEnd: string + /** Exclusive: the first day of the next quarter. */ + quarterEnd: string +} + +/** + * The two boundaries the horizon filter compares against, plus today. + * + * Exported and taking `now` as an argument so the boundaries can be tested at a timezone and a + * date, which is the only way to pin behaviour that was correct in one hemisphere of offsets and + * wrong in the other. `useConsole` passes the real clock. + */ +export function horizonBounds(now: Date): HorizonBounds { + return { + today: localDay(now), + monthEnd: localDay(new Date(now.getFullYear(), now.getMonth() + 1, 1)), + quarterEnd: localDay(new Date(now.getFullYear(), (Math.floor(now.getMonth() / 3) + 1) * 3, 1)), + } +} + export type OwnerFilter = 'mine' | 'all' export type HorizonFilter = 'quarter' | 'month' | 'open' export type OutcomeFilter = 'all' | 'open' | 'Won' | 'Lost' @@ -95,13 +140,7 @@ export function useConsole(): ConsoleModel { // Boundaries from today rather than from a date this file was written on. The fixtures had a // hard-coded "today" so their dates read as this quarter; live rows are dated whenever the // tenant made them, and a fixed reference would call every one of them historic. - const now = new Date() - const monthEnd = new Date(now.getFullYear(), now.getMonth() + 1, 1).toISOString().slice(0, 10) - const quarterEnd = new Date( - now.getFullYear(), - (Math.floor(now.getMonth() / 3) + 1) * 3, - 1, - ).toISOString().slice(0, 10) + const { today, monthEnd, quarterEnd } = horizonBounds(new Date()) const inScope = all.filter((deal) => { // "Mine" is answerable and "my team's" is not: an opportunity carries an owner uuid and the @@ -142,8 +181,7 @@ export function useConsole(): ConsoleModel { won, totals: [...byStage.values()], closingThisMonth: open.filter( - (deal) => (deal.closeDate ?? '') >= now.toISOString().slice(0, 10) - && (deal.closeDate ?? '') < monthEnd, + (deal) => (deal.closeDate ?? '') >= today && (deal.closeDate ?? '') < monthEnd, ), } }, [all, filters, ownerId])