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>