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
resources/components/DateRangePicker.stxadded+256-0
Changes to resources/components/DateRangePicker.stx
@@ -0,0 +1,256 @@
1{{-- Reporting-period picker: presets beside two month grids, in the style of
2 Fathom's. Replaces two native date inputs that had no presets, no calendar,
3 and sat off-screen on a phone (mobile audit, dashboard.stx:2242).
4
5 The URL is the only state. On load the client reads ?range / ?from / ?to and
6 on a choice writes them back with navigate(), keeping every other key
7 (site, filters, compare). Spans the server already knows (7d, 30d, 90d,
8 1y, all) go out as ?range= so the chart keeps its bucket logic; everything
9 else goes out as ?from=&to=. The server reads those as UTC day bounds; the
10 presets here are computed in the browser's local day, which is what the
11 person clicking means by "today". A range applies on the second click.
12
13 `summary` and `active` are the only props and appear only in markup, so
14 nothing here crosses the server->client data bridge. The pure date
15 functions sit between the #region markers so tests/unit/date-range-picker
16 .test.ts can evaluate exactly the code that ships. --}}
17<script client>
18// #region pure
19/** 'YYYY-MM-DD' for a local calendar day; m is 0-based like Date. */
20function pickYmd(y, m, d) {
21 return `${y}-${String(m + 1).padStart(2, '0')}-${String(d).padStart(2, '0')}`
22}
23function pickParse(s) {
24 const [y, m, d] = s.split('-').map(Number)
25 return { y, m: m - 1, d }
26}
27/** Day arithmetic through the Date constructor, so DST never shifts a date. */
28function pickShift(s, days) {
29 const { y, m, d } = pickParse(s)
30 const t = new Date(y, m, d + days)
31 return pickYmd(t.getFullYear(), t.getMonth(), t.getDate())
32}
33function pickMonthOf(s) {
34 return s.slice(0, 7)
35}
36function pickShiftMonth(ym, n) {
37 const [y, m] = ym.split('-').map(Number)
38 const t = new Date(y, m - 1 + n, 1)
39 return `${t.getFullYear()}-${String(t.getMonth() + 1).padStart(2, '0')}`
40}
41/** Six weeks of cells starting on the Sunday on or before the 1st. */
42function pickGrid(ym, todayYmd) {
43 const [y, m] = ym.split('-').map(Number)
44 const first = new Date(y, m - 1, 1)
45 const cells = []
46 for (let i = 0; i < 42; i++) {
47 const t = new Date(y, m - 1, 1 - first.getDay() + i)
48 const s = pickYmd(t.getFullYear(), t.getMonth(), t.getDate())
49 cells.push({ ymd: s, day: t.getDate(), inMonth: t.getMonth() === m - 1, future: s > todayYmd })
50 }
51 return cells
52}
53function pickOrder(a, b) {
54 return a <= b ? { from: a, to: b } : { from: b, to: a }
55}
56/** What a preset means on a given local day. Server-native spans go out as ?range=. */
57function pickPreset(key, todayYmd) {
58 const { y, m } = pickParse(todayYmd)
59 switch (key) {
60 case 'today': return { from: todayYmd, to: todayYmd }
61 case 'yesterday': { const d = pickShift(todayYmd, -1); return { from: d, to: d } }
62 case '7d': case '30d': case '90d': case '1y': case 'all': return { range: key }
63 case 'this-month': return { from: pickYmd(y, m, 1), to: todayYmd }
64 case 'last-month': {
65 const end = new Date(y, m, 0)
66 return { from: pickYmd(end.getFullYear(), end.getMonth(), 1), to: pickYmd(end.getFullYear(), end.getMonth(), end.getDate()) }
67 }
68 case 'this-year': return { from: pickYmd(y, 0, 1), to: todayYmd }
69 case 'last-year': return { from: pickYmd(y - 1, 0, 1), to: pickYmd(y - 1, 11, 31) }
70 }
71 return null
72}
73/** The next URL: every other key stays (site, filters, compare); the period is replaced. */
74function pickQuery(search, sel) {
75 const q = new URLSearchParams(search)
76 q.delete('from')
77 q.delete('to')
78 q.delete('range')
79 if (sel.range) {
80 q.set('range', sel.range)
81 }
82 else {
83 q.set('from', sel.from)
84 q.set('to', sel.to)
85 }
86 return `?${q.toString()}`
87}
88// #endregion pure
89
90const pickToday = (() => {
91 const t = new Date()
92 return pickYmd(t.getFullYear(), t.getMonth(), t.getDate())
93})()
94// useRoute().query is the router's parsed param object (runtime.ts); reading it
95// here keeps the component off window.location, which stx's DOM guard flags.
96const pickUrl = new URLSearchParams(useRoute().query)
97const pickUrlFrom = pickUrl.get('from') || ''
98const pickUrlTo = pickUrl.get('to') || ''
99const pickUrlRange = pickUrl.get('range') || ''
100
101// Open on the month the period starts in; when that is this month, step back one
102// so the current month sits on the right and the end of the period is in view.
103const calYm = state((() => {
104 const base = pickUrlFrom ? pickMonthOf(pickUrlFrom) : pickMonthOf(pickToday)
105 return base === pickMonthOf(pickToday) ? pickShiftMonth(base, -1) : base
106})())
107const selStart = state('')
108const selHover = state('')
109
110const pickYears = (() => {
111 const thisYear = Number(pickToday.slice(0, 4))
112 const out = []
113 for (let y = 2015; y <= thisYear; y++) out.push(y)
114 return out
115})()
116
117const calLeftCells = derived(() => pickGrid(calYm(), pickToday))
118const calRightCells = derived(() => pickGrid(pickShiftMonth(calYm(), 1), pickToday))
119
120function pickIsActive(key) {
121 const p = pickPreset(key, pickToday)
122 if (!p) return false
123 if (p.range) return !pickUrlFrom && (pickUrlRange || '1y') === p.range
124 return pickUrlFrom === p.from && pickUrlTo === p.to
125}
126function pickPresetClass(key) {
127 return pickIsActive(key) ? 'drp-preset is-active' : 'drp-preset'
128}
129function pickBounds() {
130 const a = selStart()
131 if (a) return pickOrder(a, selHover() || a)
132 if (pickUrlFrom && pickUrlTo) return { from: pickUrlFrom, to: pickUrlTo }
133 return null
134}
135function pickCellClass(c) {
136 const b = pickBounds()
137 const inRange = !!b && c.ymd >= b.from && c.ymd <= b.to
138 const edge = !!b && (c.ymd === b.from || c.ymd === b.to)
139 return ['drp-day', c.inMonth ? '' : 'is-out', c.future ? 'is-disabled' : '', c.ymd === pickToday ? 'is-today' : '', inRange ? 'is-range' : '', edge ? 'is-edge' : '']
140 .filter(Boolean)
141 .join(' ')
142}
143function pickCellLabel(c) {
144 const { y, m, d } = pickParse(c.ymd)
145 return new Date(y, m, d).toLocaleDateString('en-US', { weekday: 'long', month: 'long', day: 'numeric', year: 'numeric' })
146}
147function pickDay(c) {
148 if (c.future) return
149 const a = selStart()
150 if (!a) {
151 selStart.set(c.ymd)
152 selHover.set(c.ymd)
153 return
154 }
155 navigate(pickQuery(useRoute().query, pickOrder(a, c.ymd)))
156}
157function pickHover(c) {
158 if (selStart()) selHover.set(c.ymd)
159}
160function pickApply(key) {
161 const p = pickPreset(key, pickToday)
162 if (p) navigate(pickQuery(useRoute().query, p))
163}
164function pickMove(n) {
165 calYm.set(pickShiftMonth(calYm(), n))
166}
167function pickRightYm() {
168 return pickShiftMonth(calYm(), 1)
169}
170function pickMonthIdx(ym) {
171 return Number(ym.slice(5, 7)) - 1
172}
173function pickYearOf(ym) {
174 return Number(ym.slice(0, 4))
175}
176function pickSetYm(y, monthIdx, fromRight) {
177 const ym = `${y}-${String(monthIdx + 1).padStart(2, '0')}`
178 calYm.set(fromRight ? pickShiftMonth(ym, -1) : ym)
179}
180function pickLeftMonth(e) { pickSetYm(pickYearOf(calYm()), Number(e.target.value), false) }
181function pickLeftYear(e) { pickSetYm(Number(e.target.value), pickMonthIdx(calYm()), false) }
182function pickRightMonth(e) { pickSetYm(pickYearOf(pickRightYm()), Number(e.target.value), true) }
183function pickRightYear(e) { pickSetYm(Number(e.target.value), pickMonthIdx(pickRightYm()), true) }
184function pickEscape(e) {
185 if (e.key !== 'Escape') return
186 selStart.set('')
187 selHover.set('')
188 e.currentTarget.removeAttribute('open')
189}
190</script>
191
192<details class="date-menu" @keydown="pickEscape">
193 <summary class="range-tool " aria-haspopup="dialog">
194 <i class="i-hugeicons-calendar-03" aria-hidden="true"></i>
195 <span>{{ summary }}</span>
196 </summary>
197 <div class="date-popover drp" role="dialog" aria-label="Reporting period">
198 <div class="drp-presets" role="list">
199 <button type="button" role="listitem" :class="pickPresetClass('today')" @click="pickApply('today')">Today</button>
200 <button type="button" role="listitem" :class="pickPresetClass('yesterday')" @click="pickApply('yesterday')">Yesterday</button>
201 <button type="button" role="listitem" :class="pickPresetClass('7d')" @click="pickApply('7d')">Last 7 days</button>
202 <button type="button" role="listitem" :class="pickPresetClass('30d')" @click="pickApply('30d')">Last 30 days</button>
203 <button type="button" role="listitem" :class="pickPresetClass('90d')" @click="pickApply('90d')">Last 90 days</button>
204 <button type="button" role="listitem" :class="pickPresetClass('1y')" @click="pickApply('1y')">Last 365 days</button>
205 <button type="button" role="listitem" :class="pickPresetClass('this-month')" @click="pickApply('this-month')">This month</button>
206 <button type="button" role="listitem" :class="pickPresetClass('last-month')" @click="pickApply('last-month')">Last month</button>
207 <button type="button" role="listitem" :class="pickPresetClass('this-year')" @click="pickApply('this-year')">This year</button>
208 <button type="button" role="listitem" :class="pickPresetClass('last-year')" @click="pickApply('last-year')">Last year</button>
209 <button type="button" role="listitem" :class="pickPresetClass('all')" @click="pickApply('all')">All time</button>
210 </div>
211
212 <div class="drp-cals">
213 <div class="drp-cal is-left">
214 <div class="drp-head">
215 <button type="button" class="drp-nav" @click="pickMove(-1)" aria-label="Previous month">
216 <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true"><path stroke-linecap="round" stroke-linejoin="round" d="M15 19l-7-7 7-7"/></svg>
217 </button>
218 <select class="drp-sel" aria-label="Month" :value="pickMonthIdx(calYm())" @change="pickLeftMonth">
219 <option value="0">Jan</option><option value="1">Feb</option><option value="2">Mar</option><option value="3">Apr</option><option value="4">May</option><option value="5">Jun</option><option value="6">Jul</option><option value="7">Aug</option><option value="8">Sep</option><option value="9">Oct</option><option value="10">Nov</option><option value="11">Dec</option>
220 </select>
221 <select class="drp-sel" aria-label="Year" :value="pickYearOf(calYm())" @change="pickLeftYear">
222 <option x-for="yr in pickYears" :value="yr" x-text="yr"></option>
223 </select>
224 {{-- Hidden beside the second calendar; the phone layout, which shows one
225 calendar, makes it visible again. --}}
226 <button type="button" class="drp-nav is-hidden" @click="pickMove(1)" aria-label="Next month">
227 <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true"><path stroke-linecap="round" stroke-linejoin="round" d="M9 5l7 7-7 7"/></svg>
228 </button>
229 </div>
230 <div class="drp-dow" aria-hidden="true"><span>Su</span><span>Mo</span><span>Tu</span><span>We</span><span>Th</span><span>Fr</span><span>Sa</span></div>
231 <div class="drp-grid">
232 <button x-for="c in calLeftCells()" type="button" :class="pickCellClass(c)" :disabled="c.future" :aria-label="pickCellLabel(c)" @click="pickDay(c)" @mouseenter="pickHover(c)" x-text="c.day"></button>
233 </div>
234 </div>
235
236 <div class="drp-cal is-right">
237 <div class="drp-head">
238 <span class="drp-nav is-hidden" aria-hidden="true"></span>
239 <select class="drp-sel" aria-label="Month" :value="pickMonthIdx(pickRightYm())" @change="pickRightMonth">
240 <option value="0">Jan</option><option value="1">Feb</option><option value="2">Mar</option><option value="3">Apr</option><option value="4">May</option><option value="5">Jun</option><option value="6">Jul</option><option value="7">Aug</option><option value="8">Sep</option><option value="9">Oct</option><option value="10">Nov</option><option value="11">Dec</option>
241 </select>
242 <select class="drp-sel" aria-label="Year" :value="pickYearOf(pickRightYm())" @change="pickRightYear">
243 <option x-for="yr in pickYears" :value="yr" x-text="yr"></option>
244 </select>
245 <button type="button" class="drp-nav" @click="pickMove(1)" aria-label="Next month">
246 <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true"><path stroke-linecap="round" stroke-linejoin="round" d="M9 5l7 7-7 7"/></svg>
247 </button>
248 </div>
249 <div class="drp-dow" aria-hidden="true"><span>Su</span><span>Mo</span><span>Tu</span><span>We</span><span>Th</span><span>Fr</span><span>Sa</span></div>
250 <div class="drp-grid">
251 <button x-for="c in calRightCells()" type="button" :class="pickCellClass(c)" :disabled="c.future" :aria-label="pickCellLabel(c)" @click="pickDay(c)" @mouseenter="pickHover(c)" x-text="c.day"></button>
252 </div>
253 </div>
254 </div>
255 </div>
256</details>
resources/views/dashboard.stxmodified+77-34
Changes to resources/views/dashboard.stx
@@ -54,6 +54,15 @@ let from = isAll
5454 : isCustom ? `${customFrom}T00:00:00.000Z` : new Date(Date.now() - days * 864e5).toISOString()
5555if (isCustom)
5656 days = Math.max(1, Math.round((new Date(to).getTime() - new Date(from).getTime()) / 864e5))
57// What the period control reads. A preset keeps its name and the days it resolves
58// to; 'all' has no start until loadData finds the earliest pageview, so it is
59// named alone. Dates are formatted in UTC because from/to are UTC day bounds.
60const RANGE_LABELS = { '24h': 'Last 24 hours', '7d': 'Last 7 days', '30d': 'Last 30 days', '90d': 'Last 90 days', '1y': 'Last 365 days', 'all': 'All time' }
61const fmtDay = s => new Date(`${s.slice(0, 10)}T00:00:00Z`).toLocaleDateString('en-US', { month: 'short', day: 'numeric', timeZone: 'UTC' })
62const fmtSpan = (a, b) => a.slice(0, 4) === b.slice(0, 4)
63 ? `${fmtDay(a)} – ${fmtDay(b)}, ${b.slice(0, 4)}`
64 : `${fmtDay(a)}, ${a.slice(0, 4)} – ${fmtDay(b)}, ${b.slice(0, 4)}`
65const rangeLabel = isCustom ? fmtSpan(customFrom, customTo) : isAll ? 'All time' : `${RANGE_LABELS[range] ?? 'Custom'} · ${fmtSpan(from, to)}`
5766// Chart bucket granularity by span: hour (<= 2d) for a real 24h curve, day (<= ~400d),
5867// or month (wider, e.g. all-time) so a multi-year chart isn't thousands of points.
5968// bucketLen = the LEFT(timestamp, N) prefix — 'YYYY-MM-DDTHH'(13) / 'YYYY-MM-DD'(10) /
@@ -2169,30 +2178,79 @@ summary:focus-visible {
21692178 right: 0;
21702179 top: calc(100% + 0.5rem);
21712180 z-index: 40;
2172 width: min(24rem, calc(100vw - 2.5rem));
2173 padding: 1rem;
2181 width: min(44rem, calc(100vw - 2.5rem));
2182 padding: 0.75rem;
21742183 border: 1px solid var(--border);
21752184 border-radius: 12px;
21762185 background: var(--panel);
21772186 box-shadow: 0 18px 50px color-mix(in srgb, var(--text) 10%, transparent);
21782187}
2179.date-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 0.75rem; }
2180.date-field label {
2188/* Period picker: presets down the left, two months on the right. The range is
2189 drawn flat with rounded ends, the way Fathom's is, on the --bar wash the KPI
2190 bars already use so it reads as the same accent at the same strength. */
2191.drp { display: grid; grid-template-columns: 10.5rem minmax(0, 1fr); gap: 0.75rem; }
2192.drp-presets { display: flex; flex-direction: column; gap: 0.15rem; padding-right: 0.75rem; border-right: 1px solid var(--border); }
2193.drp-preset {
21812194 display: block;
2182 margin-bottom: 0.35rem;
2195 width: 100%;
2196 padding: 0.5rem 0.7rem;
2197 border: 0;
2198 border-radius: 8px;
2199 background: transparent;
2200 color: var(--text-2);
2201 font: 500 0.8125rem/1.2 var(--sans, ui-sans-serif);
2202 text-align: left;
2203 cursor: pointer;
2204}
2205.drp-preset:hover { color: var(--text); background: color-mix(in srgb, var(--text) 6%, transparent); }
2206.drp-preset.is-active { color: var(--accent-ink); background: var(--accent); }
2207.drp-cals { display: grid; grid-template-columns: 1fr 1fr; gap: 1rem; }
2208.drp-head { display: grid; grid-template-columns: 2rem minmax(0, 1fr) minmax(0, 1fr) 2rem; align-items: center; gap: 0.35rem; margin-bottom: 0.5rem; }
2209.drp-nav {
2210 display: inline-grid;
2211 place-items: center;
2212 width: 2rem;
2213 height: 2rem;
2214 padding: 0;
2215 border: 1px solid transparent;
2216 border-radius: 8px;
2217 background: transparent;
21832218 color: var(--text-2);
2184 font: 600 0.75rem/1.2 var(--sans, ui-sans-serif);
2219 cursor: pointer;
21852220}
2186.date-field input {
2187 width: 100%;
2188 padding: 0.55rem 0.65rem;
2221.drp-nav:hover { color: var(--text); border-color: var(--border); }
2222.drp-nav svg { width: 1rem; height: 1rem; }
2223.drp-nav.is-hidden { visibility: hidden; }
2224.drp-sel {
2225 min-width: 0;
2226 height: 2rem;
2227 padding: 0 0.5rem;
21892228 border: 1px solid var(--border);
21902229 border-radius: 8px;
21912230 background: var(--bg);
21922231 color: var(--text);
2232 font: 600 0.8125rem/1 var(--sans, ui-sans-serif);
2233}
2234.drp-dow, .drp-grid { display: grid; grid-template-columns: repeat(7, minmax(0, 1fr)); }
2235.drp-dow span { padding: 0.25rem 0; color: var(--text-3); font: 600 0.7rem/1.2 var(--sans, ui-sans-serif); text-align: center; }
2236.drp-day {
2237 height: 2.25rem;
2238 padding: 0;
2239 border: 0;
2240 border-radius: 0;
2241 background: transparent;
2242 color: var(--text);
21932243 font: 500 0.8125rem/1 var(--mono, ui-monospace);
2244 cursor: pointer;
21942245}
2195.date-submit { width: 100%; margin-top: 0.75rem; }
2246.drp-day:hover { background: color-mix(in srgb, var(--accent) 22%, transparent); }
2247.drp-day.is-out { color: var(--text-3); }
2248.drp-day.is-today { font-weight: 700; text-decoration: underline; text-underline-offset: 3px; }
2249.drp-day.is-range { background: var(--bar); }
2250.drp-day.is-edge { background: var(--accent); color: var(--accent-ink); border-radius: 8px; }
2251.drp-day.is-disabled { color: var(--text-3); opacity: 0.45; cursor: default; }
2252.drp-day.is-disabled:hover { background: transparent; }
2253.drp-day:focus-visible { outline: 2px solid var(--accent); outline-offset: -2px; border-radius: 8px; }
21962254.live-status {
21972255 display: inline-flex;
21982256 align-items: center;
@@ -2242,7 +2300,14 @@ summary:focus-visible {
22422300 .range-link { flex: 1 0 auto; }
22432301 .range-actions { justify-content: flex-start; }
22442302 .date-menu { position: static; }
2245 .date-popover { left: 0; right: auto; }
2303 .date-popover { left: 0; right: auto; width: calc(100vw - 2rem); max-height: calc(100dvh - 6rem); overflow-y: auto; overscroll-behavior: contain; }
2304 .drp { grid-template-columns: minmax(0, 1fr); }
2305 .drp-presets { flex-direction: row; flex-wrap: wrap; gap: 0.35rem; padding: 0 0 0.6rem; border-right: 0; border-bottom: 1px solid var(--border); }
2306 .drp-preset { width: auto; padding: 0.45rem 0.65rem; border: 1px solid var(--border); border-radius: 999px; }
2307 .drp-cals { grid-template-columns: minmax(0, 1fr); }
2308 .drp-cal.is-right { display: none; }
2309 .drp-cal.is-left .drp-nav.is-hidden { visibility: visible; }
2310 .drp-day { height: 2.5rem; }
22462311}
22472312
22482313/* Site switcher. Surface, border and radius come from .panel. The native menu remains
@@ -2434,29 +2499,7 @@ select.panel:hover { border-color: var(--accent); }
24342499 </nav>
24352500
24362501 <div class="range-actions">
2437 <details class="date-menu">
2438 <summary class="range-tool ">
2439 <i class="i-hugeicons-calendar-03" aria-hidden="true"></i>
2440 {{ isCustom ? (customFrom + ' to ' + customTo) : 'Custom' }}
2441 </summary>
2442 <form method="get" class="date-popover" aria-label="Custom date range">
2443 <input type="hidden" name="site" value="">
2444 @if (compare)
2445 <input type="hidden" name="compare" value="1">
2446 @endif
2447 <div class="date-grid">
2448 <div class="date-field">
2449 <label for="range-from">From</label>
2450 <input id="range-from" type="date" name="from" value="">
2451 </div>
2452 <div class="date-field">
2453 <label for="range-to">To</label>
2454 <input id="range-to" type="date" name="to" value="">
2455 </div>
2456 </div>
2457 <button type="submit" class="date-submit is-active range-tool">Apply range</button>
2458 </form>
2459 </details>
2502 <DateRangePicker :summary="rangeLabel" :active="isCustom" />
24602503 <StxLink
24612504 to=""
24622505 title="Overlay the previous period on the chart"
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})