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> | |
| @@ -54,6 +54,15 @@ let from = isAll | ||
| 54 | 54 | : isCustom ? `${customFrom}T00:00:00.000Z` : new Date(Date.now() - days * 864e5).toISOString() |
| 55 | 55 | if (isCustom) |
| 56 | 56 | 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. | |
| 60 | const 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' } | |
| 61 | const fmtDay = s => new Date(`${s.slice(0, 10)}T00:00:00Z`).toLocaleDateString('en-US', { month: 'short', day: 'numeric', timeZone: 'UTC' }) | |
| 62 | const 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)}` | |
| 65 | const rangeLabel = isCustom ? fmtSpan(customFrom, customTo) : isAll ? 'All time' : `${RANGE_LABELS[range] ?? 'Custom'} · ${fmtSpan(from, to)}` | |
| 57 | 66 | // Chart bucket granularity by span: hour (<= 2d) for a real 24h curve, day (<= ~400d), |
| 58 | 67 | // or month (wider, e.g. all-time) so a multi-year chart isn't thousands of points. |
| 59 | 68 | // bucketLen = the LEFT(timestamp, N) prefix — 'YYYY-MM-DDTHH'(13) / 'YYYY-MM-DD'(10) / |
| @@ -2169,30 +2178,79 @@ summary:focus-visible { | ||
| 2169 | 2178 | right: 0; |
| 2170 | 2179 | top: calc(100% + 0.5rem); |
| 2171 | 2180 | 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; | |
| 2174 | 2183 | border: 1px solid var(--border); |
| 2175 | 2184 | border-radius: 12px; |
| 2176 | 2185 | background: var(--panel); |
| 2177 | 2186 | box-shadow: 0 18px 50px color-mix(in srgb, var(--text) 10%, transparent); |
| 2178 | 2187 | } |
| 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 { | |
| 2181 | 2194 | 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; | |
| 2183 | 2218 | color: var(--text-2); |
| 2184 | font: 600 0.75rem/1.2 var(--sans, ui-sans-serif); | |
| 2219 | cursor: pointer; | |
| 2185 | 2220 | } |
| 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; | |
| 2189 | 2228 | border: 1px solid var(--border); |
| 2190 | 2229 | border-radius: 8px; |
| 2191 | 2230 | background: var(--bg); |
| 2192 | 2231 | 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); | |
| 2193 | 2243 | font: 500 0.8125rem/1 var(--mono, ui-monospace); |
| 2244 | cursor: pointer; | |
| 2194 | 2245 | } |
| 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; } | |
| 2196 | 2254 | .live-status { |
| 2197 | 2255 | display: inline-flex; |
| 2198 | 2256 | align-items: center; |
| @@ -2242,7 +2300,14 @@ summary:focus-visible { | ||
| 2242 | 2300 | .range-link { flex: 1 0 auto; } |
| 2243 | 2301 | .range-actions { justify-content: flex-start; } |
| 2244 | 2302 | .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; } | |
| 2246 | 2311 | } |
| 2247 | 2312 | |
| 2248 | 2313 | /* Site switcher. Surface, border and radius come from .panel. The native menu remains |
| @@ -2434,29 +2499,7 @@ select.panel:hover { border-color: var(--accent); } | ||
| 2434 | 2499 | </nav> |
| 2435 | 2500 | |
| 2436 | 2501 | <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" /> | |
| 2460 | 2503 | <StxLink |
| 2461 | 2504 | to="" |
| 2462 | 2505 | title="Overlay the previous period on the chart" |
| @@ -0,0 +1,124 @@ | ||
| 1 | import { describe, expect, test } from 'bun:test' | |
| 2 | import { readFileSync } from 'node:fs' | |
| 3 | import { join } from 'node:path' | |
| 4 | ||
| 5 | const ROOT = join(import.meta.dir, '../..') | |
| 6 | const component = readFileSync(join(ROOT, 'resources/components/DateRangePicker.stx'), 'utf8') | |
| 7 | const 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. | |
| 13 | const pureSlice = component.match(/\/\/ #region pure\n([\s\S]*?)\n\/\/ #endregion pure/)?.[1] ?? '' | |
| 14 | const pure = new Function(`${pureSlice}\nreturn { pickYmd, pickParse, pickShift, pickShiftMonth, pickMonthOf, pickGrid, pickOrder, pickPreset, pickQuery }`)() as Record<string, (...args: any[]) => any> | |
| 15 | ||
| 16 | describe('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 | ||
| 43 | describe('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 | ||
| 63 | describe('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 | ||
| 90 | describe('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 | ||
| 110 | describe('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 | }) | |