Skip to content
Open
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
Original file line number Diff line number Diff line change
@@ -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')
})
})
56 changes: 47 additions & 9 deletions samples/crm-web/src/features/sales/useConsole.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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])
Expand Down