ReviewOS

also looking at this

loghqorg/loghq

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

#6
Merged glennmichael123 wants to merge feat/date-range-picker into main
3 files +500 -5
resources/components/DateRangePicker.stxadded+266-0
Changes to resources/components/DateRangePicker.stx
@@ -0,0 +1,266 @@
1{{-- Reporting-period picker: presets beside two month grids, in the style of
2 Fathom's. loghq's analytics view had preset tabs (7d/30d/90d/180d) but no
3 custom range and no calendar; this adds both while keeping the tabs.
4
5 The URL is the only state. On load the client reads ?range / ?from / ?to
6 through useRoute().query and on a choice writes them back with navigate(),
7 keeping every other key (project). The four spans the server buckets go out
8 as ?range=; every other preset and any hand-picked span goes out as
9 ?from=&to=, which analytics.stx now reads as UTC day bounds. Presets are
10 computed in the browser's local day, so "today" means the viewer's today.
11
12 Self-contained: markup, client script and the themed <style> travel together,
13 so the component drops into any loghq view. `summary` and `active` are the
14 only props and appear only in markup, so nothing crosses the server->client
15 bridge. The pure date functions sit between the #region markers so
16 tests/unit/date-range-picker.test.ts evaluates 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 '180d': 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 || '30d') === 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) }
184// A native <details> only closes from its own summary, so an open panel would
185// otherwise sit there after the pointer moves on. The transparent .drp-backdrop
186// below covers the rest of the page while the panel is open; a click on it lands
187// here and closes the panel, the same way clicking away from a menu should.
188function pickClose(e) {
189 const d = e.currentTarget.closest('details')
190 if (d) d.removeAttribute('open')
191 selStart.set('')
192 selHover.set('')
193}
194function pickEscape(e) {
195 if (e.key !== 'Escape') return
196 selStart.set('')
197 selHover.set('')
198 e.currentTarget.removeAttribute('open')
199}
200</script>
201
202<details class="date-menu" @keydown="pickEscape">
203 <summary class="range-tool " aria-haspopup="dialog">
204 <i class="i-hugeicons-calendar-03" aria-hidden="true"></i>
205 <span>{{ summary }}</span>
206 </summary>
207 <div class="drp-backdrop" @click="pickClose" aria-hidden="true"></div>
208 <div class="date-popover drp" role="dialog" aria-label="Reporting period">
209 <div class="drp-presets" role="list">
210 <button type="button" role="listitem" :class="pickPresetClass('today')" @click="pickApply('today')">Today</button>
211 <button type="button" role="listitem" :class="pickPresetClass('yesterday')" @click="pickApply('yesterday')">Yesterday</button>
212 <button type="button" role="listitem" :class="pickPresetClass('7d')" @click="pickApply('7d')">Last 7 days</button>
213 <button type="button" role="listitem" :class="pickPresetClass('30d')" @click="pickApply('30d')">Last 30 days</button>
214 <button type="button" role="listitem" :class="pickPresetClass('90d')" @click="pickApply('90d')">Last 90 days</button>
215 <button type="button" role="listitem" :class="pickPresetClass('180d')" @click="pickApply('180d')">Last 180 days</button>
216 <button type="button" role="listitem" :class="pickPresetClass('this-month')" @click="pickApply('this-month')">This month</button>
217 <button type="button" role="listitem" :class="pickPresetClass('last-month')" @click="pickApply('last-month')">Last month</button>
218 <button type="button" role="listitem" :class="pickPresetClass('this-year')" @click="pickApply('this-year')">This year</button>
219 <button type="button" role="listitem" :class="pickPresetClass('last-year')" @click="pickApply('last-year')">Last year</button>
220 </div>
221
222 <div class="drp-cals">
223 <div class="drp-cal is-left">
224 <div class="drp-head">
225 <button type="button" class="drp-nav" @click="pickMove(-1)" aria-label="Previous month">
226 <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>
227 </button>
228 <select class="drp-sel" aria-label="Month" :value="pickMonthIdx(calYm())" @change="pickLeftMonth">
229 <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>
230 </select>
231 <select class="drp-sel" aria-label="Year" :value="pickYearOf(calYm())" @change="pickLeftYear">
232 <option x-for="yr in pickYears" :value="yr" x-text="yr"></option>
233 </select>
234 {{-- Hidden beside the second calendar; the phone layout, which shows one
235 calendar, makes it visible again. --}}
236 <button type="button" class="drp-nav is-hidden" @click="pickMove(1)" aria-label="Next month">
237 <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>
238 </button>
239 </div>
240 <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>
241 <div class="drp-grid">
242 <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>
243 </div>
244 </div>
245
246 <div class="drp-cal is-right">
247 <div class="drp-head">
248 <span class="drp-nav is-hidden" aria-hidden="true"></span>
249 <select class="drp-sel" aria-label="Month" :value="pickMonthIdx(pickRightYm())" @change="pickRightMonth">
250 <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>
251 </select>
252 <select class="drp-sel" aria-label="Year" :value="pickYearOf(pickRightYm())" @change="pickRightYear">
253 <option x-for="yr in pickYears" :value="yr" x-text="yr"></option>
254 </select>
255 <button type="button" class="drp-nav" @click="pickMove(1)" aria-label="Next month">
256 <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>
257 </button>
258 </div>
259 <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>
260 <div class="drp-grid">
261 <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>
262 </div>
263 </div>
264 </div>
265 </div>
266</details>
resources/views/analytics.stxmodified+159-5
Changes to resources/views/analytics.stx
@@ -33,6 +33,21 @@ const RANGES: AnalyticsRange[] = [
3333
3434const activeRange = RANGES.find(r => r.key === query.get('range')) ?? DEFAULT_RANGE
3535
36// Custom range: ?from=YYYY-MM-DD&to=YYYY-MM-DD, both required and valid, and it
37// wins over the preset. volumeSeries/levelsByRelease already take day bounds, so
38// a custom span needs no query changes; it is still bucketed by day like 180d.
39const qFrom = query.get('from')
40const qTo = query.get('to')
41const isDay = (v: string | null): v is string => typeof v === 'string' && /^\d{4}-\d{2}-\d{2}$/.test(v)
42const isCustom = isDay(qFrom) && isDay(qTo)
43const customFrom = isCustom ? String(qFrom).slice(0, 10) : ''
44const customTo = isCustom ? String(qTo).slice(0, 10) : ''
45// The one-line label the picker button shows.
46const fmtDay = (d: string): string => new Date(`${d}T00:00:00Z`).toLocaleDateString('en-US', { month: 'short', day: 'numeric', timeZone: 'UTC' })
47const rangeLabel = isCustom
48 ? (customFrom.slice(0, 4) === customTo.slice(0, 4) ? `${fmtDay(customFrom)} \u2013 ${fmtDay(customTo)}, ${customTo.slice(0, 4)}` : `${fmtDay(customFrom)}, ${customFrom.slice(0, 4)} \u2013 ${fmtDay(customTo)}, ${customTo.slice(0, 4)}`)
49 : activeRange.label
50
3651const requestedProject = query.get('project')
3752const cookieProject = typeof cookies !== 'undefined' ? (cookies.loghq_project ?? '') : ''
3853
@@ -83,7 +98,7 @@ function pageUrl(overrides: Partial<{ range: string }>): string {
8398 return s ? `/analytics?${s}` : '/analytics'
8499}
85100
86const rangeTabs = RANGES.map(r => ({ ...r, href: pageUrl({ range: r.key }), active: r.key === activeRange.key }))
101const rangeTabs = RANGES.map(r => ({ ...r, href: pageUrl({ range: r.key }), active: !isCustom && r.key === activeRange.key }))
87102const switchUrl = pageUrl({})
88103
89104const cfg = archiveConfig()
@@ -114,10 +129,18 @@ async function loadData(): Promise<void> {
114129 return
115130
116131 const now = new Date()
117 const from = new Date(now.getTime())
118 from.setUTCDate(from.getUTCDate() - activeRange.days)
119 const fromDay = dayOfUtc(from)
120 const toDay = dayOfUtc(now)
132 let fromDay: string
133 let toDay: string
134 if (isCustom) {
135 fromDay = customFrom
136 toDay = customTo
137 }
138 else {
139 const from = new Date(now.getTime())
140 from.setUTCDate(from.getUTCDate() - activeRange.days)
141 fromDay = dayOfUtc(from)
142 toDay = dayOfUtc(now)
143 }
121144
122145 plan = await projectPlan(projectId)
123146 series = await volumeSeries(projectId, { fromDay, toDay, unit: 'day' })
@@ -216,6 +239,136 @@ onMounted(() => {
216239
217240@section('content')
218241
242
243{{-- Date-range picker styling. A loose <style> under a layout: stx sweeps it
244 into the page (loghq's default layout has no @stack('styles')). Self-contained
245 to the picker, themed on the app's tokens. --}}
246<style>
247.range-tool {
248 display: inline-flex;
249 align-items: center;
250 justify-content: center;
251 gap: 0.4rem;
252 min-height: 2.25rem;
253 padding: 0 0.72rem;
254 border: 1px solid var(--border);
255 border-radius: 9px;
256 background: var(--panel);
257 color: var(--text-2);
258 font: 600 0.8125rem/1 var(--sans, ui-sans-serif);
259 text-decoration: none;
260 cursor: pointer;
261 transition: color 0.15s ease, border-color 0.15s ease, background-color 0.15s ease, transform 0.15s ease;
262}
263.range-tool:hover { color: var(--text); border-color: var(--accent); }
264.range-tool:active { transform: scale(0.98); }
265.range-tool:focus-visible,
266.range-link:focus-visible,
267.chrome-btn:focus-visible,
268.icon-btn:focus-visible,
269select.panel:focus-visible,
270input:focus-visible,
271textarea:focus-visible,
272summary:focus-visible {
273 outline: 2px solid var(--accent);
274 outline-offset: 2px;
275}
276.range-tool.is-active { color: var(--accent-ink); background: var(--accent); border-color: var(--accent); }
277.range-tool i { width: 1rem; height: 1rem; }
278.date-menu { position: relative; }
279.date-menu > summary { list-style: none; }
280.date-menu > summary::-webkit-details-marker { display: none; }
281.date-popover {
282 position: absolute;
283 right: 0;
284 top: calc(100% + 0.5rem);
285 z-index: 40;
286 width: min(44rem, calc(100vw - 2.5rem));
287 padding: 0.75rem;
288 border: 1px solid var(--border);
289 border-radius: 12px;
290 background: var(--panel);
291 box-shadow: 0 18px 50px color-mix(in srgb, var(--text) 10%, transparent);
292}
293/* Period picker: presets down the left, two months on the right. The range is
294 drawn flat with rounded ends, the way Fathom's is, on the --bar wash the KPI
295 bars already use so it reads as the same accent at the same strength. */
296.drp-backdrop { position: fixed; inset: 0; z-index: 39; background: transparent; cursor: default; }
297.drp { display: grid; grid-template-columns: 10.5rem minmax(0, 1fr); gap: 0.75rem; }
298.drp-presets { display: flex; flex-direction: column; gap: 0.15rem; padding-right: 0.75rem; border-right: 1px solid var(--border); }
299.drp-preset {
300 display: block;
301 width: 100%;
302 padding: 0.5rem 0.7rem;
303 border: 0;
304 border-radius: 8px;
305 background: transparent;
306 color: var(--text-2);
307 font: 500 0.8125rem/1.2 var(--sans, ui-sans-serif);
308 text-align: left;
309 cursor: pointer;
310}
311.drp-preset:hover { color: var(--text); background: color-mix(in srgb, var(--text) 6%, transparent); }
312.drp-preset.is-active { color: var(--accent-ink); background: var(--accent); }
313.drp-cals { display: grid; grid-template-columns: 1fr 1fr; gap: 1rem; }
314.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; }
315.drp-nav {
316 display: inline-grid;
317 place-items: center;
318 width: 2rem;
319 height: 2rem;
320 padding: 0;
321 border: 1px solid transparent;
322 border-radius: 8px;
323 background: transparent;
324 color: var(--text-2);
325 cursor: pointer;
326}
327.drp-nav:hover { color: var(--text); border-color: var(--border); }
328.drp-nav svg { width: 1rem; height: 1rem; }
329.drp-nav.is-hidden { visibility: hidden; }
330.drp-sel {
331 min-width: 0;
332 height: 2rem;
333 padding: 0 0.5rem;
334 border: 1px solid var(--border);
335 border-radius: 8px;
336 background: var(--bg);
337 color: var(--text);
338 font: 600 0.8125rem/1 var(--sans, ui-sans-serif);
339}
340.drp-dow, .drp-grid { display: grid; grid-template-columns: repeat(7, minmax(0, 1fr)); }
341.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; }
342.drp-day {
343 height: 2.25rem;
344 padding: 0;
345 border: 0;
346 border-radius: 0;
347 background: transparent;
348 color: var(--text);
349 font: 500 0.8125rem/1 var(--mono, ui-monospace);
350 cursor: pointer;
351}
352.drp-day:hover { background: color-mix(in srgb, var(--accent) 22%, transparent); }
353.drp-day.is-out { color: var(--text-3); }
354.drp-day.is-today { font-weight: 700; text-decoration: underline; text-underline-offset: 3px; }
355.drp-day.is-range { background: var(--accent-soft); }
356.drp-day.is-edge { background: var(--accent); color: var(--accent-ink); border-radius: 8px; }
357.drp-day.is-disabled { color: var(--text-3); opacity: 0.45; cursor: default; }
358.drp-day.is-disabled:hover { background: transparent; }
359.drp-day:focus-visible { outline: 2px solid var(--accent); outline-offset: -2px; border-radius: 8px; }
360@media (max-width: 1023px) {
361 .date-menu { position: static; }
362 .date-popover { left: 0; right: auto; width: calc(100vw - 2rem); max-height: calc(100dvh - 6rem); overflow-y: auto; overscroll-behavior: contain; }
363 .drp { grid-template-columns: minmax(0, 1fr); }
364 .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); }
365 .drp-preset { width: auto; padding: 0.45rem 0.65rem; border: 1px solid var(--border); border-radius: 999px; }
366 .drp-cals { grid-template-columns: minmax(0, 1fr); }
367 .drp-cal.is-right { display: none; }
368 .drp-cal.is-left .drp-nav.is-hidden { visibility: visible; }
369 .drp-day { height: 2.5rem; }
370}
371</style>
219372<div class="grain" aria-hidden="true"></div>
220373
221374<div class="px-5 py-10 md:px-10 min-h-[100dvh] page-dashboard">
@@ -268,6 +421,7 @@ onMounted(() => {
268421 </span>
269422 </div>
270423 <nav class="flex flex-wrap gap-1.5 items-center">
424 <DateRangePicker :summary="rangeLabel" :active="isCustom" />
271425 @foreach (rangeTabs as r)
272426 <StxLink to="" class="px-3 py-1.5 font-medium text-sm border pill ">{{ r.label }}</StxLink>
273427 @endforeach
tests/unit/date-range-picker.test.tsadded+75-0
Changes to tests/unit/date-range-picker.test.ts
@@ -0,0 +1,75 @@
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 analytics = readFileSync(join(ROOT, 'resources/views/analytics.stx'), 'utf8')
8
9// The date math ships inside the component's client script; the #region markers
10// fence off the part that touches no runtime, and this evaluates exactly that
11// text, so the tests run against the code the page runs.
12const pureSlice = component.match(/\/\/ #region pure\n([\s\S]*?)\n\/\/ #endregion pure/)?.[1] ?? ''
13const pure = new Function(`${pureSlice}\nreturn { pickYmd, pickShift, pickShiftMonth, pickGrid, pickOrder, pickPreset, pickQuery }`)() as Record<string, (...a: any[]) => any>
14
15describe('loghq date range picker: grid + day math', () => {
16 test('six weeks starting on the Sunday on or before the 1st', () => {
17 const cells = pure.pickGrid('2026-06', '2026-09-16')
18 expect(cells).toHaveLength(42)
19 expect(cells[0].ymd).toBe('2026-05-31')
20 expect(cells.filter((c: any) => c.inMonth)).toHaveLength(30)
21 })
22 test('leap February and future-day flag', () => {
23 expect(pure.pickGrid('2028-02', '2028-09-16').filter((c: any) => c.inMonth)).toHaveLength(29)
24 const cells = pure.pickGrid('2026-09', '2026-09-16')
25 expect(cells.find((c: any) => c.ymd === '2026-09-17').future).toBe(true)
26 expect(cells.find((c: any) => c.ymd === '2026-09-16').future).toBe(false)
27 })
28 test('day shifts survive DST and year ends', () => {
29 expect(pure.pickShift('2026-03-08', 1)).toBe('2026-03-09')
30 expect(pure.pickShift('2026-11-01', 1)).toBe('2026-11-02')
31 expect(pure.pickShift('2026-01-01', -1)).toBe('2025-12-31')
32 expect(pure.pickShiftMonth('2026-12', 1)).toBe('2027-01')
33 })
34})
35
36describe('loghq date range picker: presets match loghq vocab', () => {
37 const today = '2026-09-16'
38 test('loghq native spans go out as ?range=', () => {
39 for (const key of ['7d', '30d', '90d', '180d'])
40 expect(pure.pickPreset(key, today)).toEqual({ range: key })
41 })
42 test('loghq does not offer 24h, 1y or all', () => {
43 for (const key of ['24h', '1y', 'all'])
44 expect(pure.pickPreset(key, today)).toBeNull()
45 expect(component).not.toContain("pickApply('all')")
46 expect(component).not.toContain("pickApply('1y')")
47 expect(component).toContain("pickApply('180d')")
48 })
49 test('calendar presets resolve to local day bounds', () => {
50 expect(pure.pickPreset('today', today)).toEqual({ from: '2026-09-16', to: '2026-09-16' })
51 expect(pure.pickPreset('last-month', today)).toEqual({ from: '2026-08-01', to: '2026-08-31' })
52 expect(pure.pickPreset('this-year', today)).toEqual({ from: '2026-01-01', to: '2026-09-16' })
53 })
54})
55
56describe('loghq date range picker: URL + wiring', () => {
57 test('a custom span keeps project and drops the preset', () => {
58 const next = new URLSearchParams(pure.pickQuery('?project=p1&range=90d', { from: '2026-09-01', to: '2026-09-16' }))
59 expect(next.get('project')).toBe('p1')
60 expect(next.get('from')).toBe('2026-09-01')
61 expect(next.get('to')).toBe('2026-09-16')
62 expect(next.has('range')).toBe(false)
63 })
64 test('a preset drops any custom span', () => {
65 const next = new URLSearchParams(pure.pickQuery('?from=2026-09-01&to=2026-09-16', { range: '30d' }))
66 expect(next.get('range')).toBe('30d')
67 expect(next.has('from')).toBe(false)
68 })
69 test('analytics.stx mounts the picker, reads custom bounds, and neutralizes the pills under a custom range', () => {
70 expect(analytics).toContain('<DateRangePicker :summary="rangeLabel" :active="isCustom" />')
71 expect(analytics).toContain('const isCustom = isDay(qFrom) && isDay(qTo)')
72 expect(analytics).toContain('active: !isCustom && r.key === activeRange.key')
73 expect(analytics).toMatch(/if \(isCustom\) \{\s*fromDay = customFrom\s*toDay = customTo/)
74 })
75})