ReviewOS

also looking at this

stacks/analyticshq

feat(dashboard): presets-and-calendar reporting-period picker

#55
Merged glennmichael123 wants to merge feat/date-range-picker into main
3 files +457 -34
tests/unit/date-range-picker.test.tsadded+124-0
Changes to tests/unit/date-range-picker.test.ts
@@ -0,0 +1,124 @@
1import { describe, expect, test } from 'bun:test'
2import { readFileSync } from 'node:fs'
3import { join } from 'node:path'
4
5const ROOT = join(import.meta.dir, '../..')
6const component = readFileSync(join(ROOT, 'resources/components/DateRangePicker.stx'), 'utf8')
7const dashboard = readFileSync(join(ROOT, 'resources/views/dashboard.stx'), 'utf8')
8
9// The date math ships inside the component's client script, where a unit test
10// cannot import it. The #region markers fence off the part that touches no
11// runtime, and this evaluates exactly that text, so the tests run against the
12// code the page runs rather than a copy of it.
13const pureSlice = component.match(/\/\/ #region pure\n([\s\S]*?)\n\/\/ #endregion pure/)?.[1] ?? ''
14const pure = new Function(`${pureSlice}\nreturn { pickYmd, pickParse, pickShift, pickShiftMonth, pickMonthOf, pickGrid, pickOrder, pickPreset, pickQuery }`)() as Record<string, (...args: any[]) => any>
15
16describe('date range picker: calendar grid', () => {
17 test('six weeks starting on the Sunday on or before the 1st', () => {
18 const cells = pure.pickGrid('2026-06', '2026-09-16')
19 expect(cells).toHaveLength(42)
20 expect(cells[0].ymd).toBe('2026-05-31')
21 expect(cells[0].inMonth).toBe(false)
22 expect(cells.filter((c: any) => c.inMonth)).toHaveLength(30)
23 expect(cells.at(-1)!.ymd).toBe('2026-07-11')
24 })
25
26 test('a month that starts on Sunday still leads with its own 1st', () => {
27 expect(pure.pickGrid('2026-03', '2026-09-16')[0].ymd).toBe('2026-03-01')
28 })
29
30 test('leap February and the December to January rollover', () => {
31 expect(pure.pickGrid('2028-02', '2028-09-16').filter((c: any) => c.inMonth)).toHaveLength(29)
32 const dec = pure.pickGrid('2026-12', '2027-01-16')
33 expect(dec.at(-1)!.ymd.startsWith('2027-01')).toBe(true)
34 })
35
36 test('days after today are marked future', () => {
37 const cells = pure.pickGrid('2026-09', '2026-09-16')
38 expect(cells.find((c: any) => c.ymd === '2026-09-16').future).toBe(false)
39 expect(cells.find((c: any) => c.ymd === '2026-09-17').future).toBe(true)
40 })
41})
42
43describe('date range picker: day arithmetic', () => {
44 test('shifts across the US DST changes without losing or gaining a day', () => {
45 expect(pure.pickShift('2026-03-08', 1)).toBe('2026-03-09')
46 expect(pure.pickShift('2026-03-07', 1)).toBe('2026-03-08')
47 expect(pure.pickShift('2026-11-01', 1)).toBe('2026-11-02')
48 expect(pure.pickShift('2026-11-01', -1)).toBe('2026-10-31')
49 })
50
51 test('shifts across month and year boundaries', () => {
52 expect(pure.pickShift('2026-01-01', -1)).toBe('2025-12-31')
53 expect(pure.pickShiftMonth('2026-01', -1)).toBe('2025-12')
54 expect(pure.pickShiftMonth('2026-12', 1)).toBe('2027-01')
55 })
56
57 test('orders a backwards pick', () => {
58 expect(pure.pickOrder('2026-09-16', '2026-09-01')).toEqual({ from: '2026-09-01', to: '2026-09-16' })
59 expect(pure.pickOrder('2026-09-01', '2026-09-01')).toEqual({ from: '2026-09-01', to: '2026-09-01' })
60 })
61})
62
63describe('date range picker: presets', () => {
64 const today = '2026-09-16'
65
66 test('the spans the server already knows go out as ?range=', () => {
67 for (const key of ['7d', '30d', '90d', '1y', 'all'])
68 expect(pure.pickPreset(key, today)).toEqual({ range: key })
69 })
70
71 test('calendar presets resolve to local day bounds', () => {
72 expect(pure.pickPreset('today', today)).toEqual({ from: '2026-09-16', to: '2026-09-16' })
73 expect(pure.pickPreset('yesterday', today)).toEqual({ from: '2026-09-15', to: '2026-09-15' })
74 expect(pure.pickPreset('this-month', today)).toEqual({ from: '2026-09-01', to: '2026-09-16' })
75 expect(pure.pickPreset('last-month', today)).toEqual({ from: '2026-08-01', to: '2026-08-31' })
76 expect(pure.pickPreset('this-year', today)).toEqual({ from: '2026-01-01', to: '2026-09-16' })
77 expect(pure.pickPreset('last-year', today)).toEqual({ from: '2025-01-01', to: '2025-12-31' })
78 })
79
80 test('last month on the 1st of January is December of the previous year', () => {
81 expect(pure.pickPreset('last-month', '2027-01-01')).toEqual({ from: '2026-12-01', to: '2026-12-31' })
82 expect(pure.pickPreset('yesterday', '2027-01-01')).toEqual({ from: '2026-12-31', to: '2026-12-31' })
83 })
84
85 test('an unknown preset is refused rather than guessed', () => {
86 expect(pure.pickPreset('fortnight', today)).toBeNull()
87 })
88})
89
90describe('date range picker: the URL it writes', () => {
91 test('a custom range replaces the preset and keeps site, filters and compare', () => {
92 const next = new URLSearchParams(pure.pickQuery('?site=demo&range=1y&compare=1&country=US', { from: '2026-09-01', to: '2026-09-16' }))
93 expect(next.get('site')).toBe('demo')
94 expect(next.get('compare')).toBe('1')
95 expect(next.get('country')).toBe('US')
96 expect(next.get('from')).toBe('2026-09-01')
97 expect(next.get('to')).toBe('2026-09-16')
98 // The server ignores ?from/?to while ?range=all is present, so it must go.
99 expect(next.has('range')).toBe(false)
100 })
101
102 test('a preset replaces a custom range', () => {
103 const next = new URLSearchParams(pure.pickQuery('?site=demo&from=2026-09-01&to=2026-09-16', { range: '30d' }))
104 expect(next.get('range')).toBe('30d')
105 expect(next.has('from')).toBe(false)
106 expect(next.has('to')).toBe(false)
107 })
108})
109
110describe('date range picker: wiring', () => {
111 test('the dashboard mounts the picker and no longer ships native date inputs', () => {
112 expect(dashboard).toContain('<DateRangePicker :summary="rangeLabel" :active="isCustom" />')
113 expect(dashboard).not.toContain('type="date"')
114 })
115
116 test('at phone width the panel shows one calendar inside the visible viewport', () => {
117 // Anchored on the rule that already switches the menu to phone layout, so the
118 // test does not depend on the block's breakpoint value.
119 const phone = dashboard.slice(dashboard.indexOf('.date-menu { position: static; }'))
120 expect(phone).toContain('.drp-cal.is-right { display: none; }')
121 expect(phone).toContain('max-height: calc(100dvh - 6rem); overflow-y: auto; overscroll-behavior: contain;')
122 expect(phone).toContain('.drp { grid-template-columns: minmax(0, 1fr); }')
123 })
124})