also looking at this
feat(dashboard): presets-and-calendar reporting-period picker
#55
3 files
+457
-34
| @@ -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. */ | |
| 20 | function pickYmd(y, m, d) { | |
| 21 | return `${y}-${String(m + 1).padStart(2, '0')}-${String(d).padStart(2, '0')}` | |
| 22 | } | |
| 23 | function 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. */ | |
| 28 | function 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 | } | |
| 33 | function pickMonthOf(s) { | |
| 34 | return s.slice(0, 7) | |
| 35 | } | |
| 36 | function 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. */ | |
| 42 | function 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 | } | |
| 53 | function 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=. */ | |
| 57 | function 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. */ | |
| 74 | function 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 | ||
| 90 | const 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. | |
| 96 | const pickUrl = new URLSearchParams(useRoute().query) | |
| 97 | const pickUrlFrom = pickUrl.get('from') || '' | |
| 98 | const pickUrlTo = pickUrl.get('to') || '' | |
| 99 | const 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. | |
| 103 | const calYm = state((() => { | |
| 104 | const base = pickUrlFrom ? pickMonthOf(pickUrlFrom) : pickMonthOf(pickToday) | |
| 105 | return base === pickMonthOf(pickToday) ? pickShiftMonth(base, -1) : base | |
| 106 | })()) | |
| 107 | const selStart = state('') | |
| 108 | const selHover = state('') | |
| 109 | ||
| 110 | const 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 | ||
| 117 | const calLeftCells = derived(() => pickGrid(calYm(), pickToday)) | |
| 118 | const calRightCells = derived(() => pickGrid(pickShiftMonth(calYm(), 1), pickToday)) | |
| 119 | ||
| 120 | function 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 | } | |
| 126 | function pickPresetClass(key) { | |
| 127 | return pickIsActive(key) ? 'drp-preset is-active' : 'drp-preset' | |
| 128 | } | |
| 129 | function 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 | } | |
| 135 | function 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 | } | |
| 143 | function 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 | } | |
| 147 | function 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 | } | |
| 157 | function pickHover(c) { | |
| 158 | if (selStart()) selHover.set(c.ymd) | |
| 159 | } | |
| 160 | function pickApply(key) { | |
| 161 | const p = pickPreset(key, pickToday) | |
| 162 | if (p) navigate(pickQuery(useRoute().query, p)) | |
| 163 | } | |
| 164 | function pickMove(n) { | |
| 165 | calYm.set(pickShiftMonth(calYm(), n)) | |
| 166 | } | |
| 167 | function pickRightYm() { | |
| 168 | return pickShiftMonth(calYm(), 1) | |
| 169 | } | |
| 170 | function pickMonthIdx(ym) { | |
| 171 | return Number(ym.slice(5, 7)) - 1 | |
| 172 | } | |
| 173 | function pickYearOf(ym) { | |
| 174 | return Number(ym.slice(0, 4)) | |
| 175 | } | |
| 176 | function pickSetYm(y, monthIdx, fromRight) { | |
| 177 | const ym = `${y}-${String(monthIdx + 1).padStart(2, '0')}` | |
| 178 | calYm.set(fromRight ? pickShiftMonth(ym, -1) : ym) | |
| 179 | } | |
| 180 | function pickLeftMonth(e) { pickSetYm(pickYearOf(calYm()), Number(e.target.value), false) } | |
| 181 | function pickLeftYear(e) { pickSetYm(Number(e.target.value), pickMonthIdx(calYm()), false) } | |
| 182 | function pickRightMonth(e) { pickSetYm(pickYearOf(pickRightYm()), Number(e.target.value), true) } | |
| 183 | function pickRightYear(e) { pickSetYm(Number(e.target.value), pickMonthIdx(pickRightYm()), true) } | |
| 184 | function 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> | |