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>