From 9061342b2999667285f743560e7ce2846b8eb2e6 Mon Sep 17 00:00:00 2001 From: nguyenngothuong Date: Sat, 8 Aug 2026 18:55:49 +0700 Subject: [PATCH] The quarter ends where the reader is, not seven hours earlier MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `useConsole` built its horizon boundaries with `new Date(y, m, 1).toISOString().slice(0, 10)`. That constructs local midnight and then prints it in UTC, so at any positive offset the string comes back a day early: at UTC+07 the third quarter of 2026 ended on 2026-09-30 rather than 2026-10-01. The boundaries are compared against `close_date`, which the server sends as a plain calendar date with no zone on it, so an opportunity closing on the last day of the quarter satisfied `close >= quarterEnd` and was filtered out. `This quarter` is the default horizon on the sales console, which is the application's landing page — so every reader in Asia, Australia or eastern Europe opened the CRM to an empty pipeline while a reader in London opened the same tenant and saw it full. `today` had the same fault: for the first seven hours of a local day it named the previous one. Measured against the running sample, same tenant, same rows, browser timezone the only variable: origin/dev TZ=Asia/Bangkok open pipeline = $0 origin/dev TZ=UTC open pipeline = $184k this commit TZ=Asia/Bangkok open pipeline = $184k The boundaries move into an exported `horizonBounds(now)` so they can be pinned at a date *and* a timezone; `localDay` formats a date's local calendar day and is the only thing either of them now goes through. `src/features/sales/__tests__/consoleHorizon.test.ts` runs under `TZ=Asia/Bangkok` — at UTC every one of its assertions passes against the unfixed code, which is why the suite never caught this. Four of its five fail against the previous implementation. This is the same root cause as the `liveRecords` day-formatting assertion that fails on this commit's parent east of UTC; that one is left alone, because it is a bug in the expectation and this is a bug in the application. Co-Authored-By: Claude Opus 5 (1M context) Signed-off-by: nguyenngothuong --- .../sales/__tests__/consoleHorizon.test.ts | 52 +++++++++++++++++ .../crm-web/src/features/sales/useConsole.ts | 56 ++++++++++++++++--- 2 files changed, 99 insertions(+), 9 deletions(-) create mode 100644 samples/crm-web/src/features/sales/__tests__/consoleHorizon.test.ts 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])