also looking at this
Deploy: uniform auth session model (main → production)
#7
282 files
+3028
-64856
Review threads live on the whole diff, not on one commit, so none are shown here - a thread's line means something in the branch's final form, and painting it into an intermediate step would put it on code it is not about.
| @@ -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. */ | |
| 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 '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. */ | |
| 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 || '30d') === 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 | // 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. | |
| 188 | function pickClose(e) { | |
| 189 | const d = e.currentTarget.closest('details') | |
| 190 | if (d) d.removeAttribute('open') | |
| 191 | selStart.set('') | |
| 192 | selHover.set('') | |
| 193 | } | |
| 194 | function 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> | |
| @@ -33,6 +33,21 @@ const RANGES: AnalyticsRange[] = [ | ||
| 33 | 33 | |
| 34 | 34 | const activeRange = RANGES.find(r => r.key === query.get('range')) ?? DEFAULT_RANGE |
| 35 | 35 | |
| 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. | |
| 39 | const qFrom = query.get('from') | |
| 40 | const qTo = query.get('to') | |
| 41 | const isDay = (v: string | null): v is string => typeof v === 'string' && /^\d{4}-\d{2}-\d{2}$/.test(v) | |
| 42 | const isCustom = isDay(qFrom) && isDay(qTo) | |
| 43 | const customFrom = isCustom ? String(qFrom).slice(0, 10) : '' | |
| 44 | const customTo = isCustom ? String(qTo).slice(0, 10) : '' | |
| 45 | // The one-line label the picker button shows. | |
| 46 | const fmtDay = (d: string): string => new Date(`${d}T00:00:00Z`).toLocaleDateString('en-US', { month: 'short', day: 'numeric', timeZone: 'UTC' }) | |
| 47 | const 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 | ||
| 36 | 51 | const requestedProject = query.get('project') |
| 37 | 52 | const cookieProject = typeof cookies !== 'undefined' ? (cookies.loghq_project ?? '') : '' |
| 38 | 53 | |
| @@ -83,7 +98,7 @@ function pageUrl(overrides: Partial<{ range: string }>): string { | ||
| 83 | 98 | return s ? `/analytics?${s}` : '/analytics' |
| 84 | 99 | } |
| 85 | 100 | |
| 86 | const rangeTabs = RANGES.map(r => ({ ...r, href: pageUrl({ range: r.key }), active: r.key === activeRange.key })) | |
| 101 | const rangeTabs = RANGES.map(r => ({ ...r, href: pageUrl({ range: r.key }), active: !isCustom && r.key === activeRange.key })) | |
| 87 | 102 | const switchUrl = pageUrl({}) |
| 88 | 103 | |
| 89 | 104 | const cfg = archiveConfig() |
| @@ -114,10 +129,18 @@ async function loadData(): Promise<void> { | ||
| 114 | 129 | return |
| 115 | 130 | |
| 116 | 131 | 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 | } | |
| 121 | 144 | |
| 122 | 145 | plan = await projectPlan(projectId) |
| 123 | 146 | series = await volumeSeries(projectId, { fromDay, toDay, unit: 'day' }) |
| @@ -216,6 +239,136 @@ onMounted(() => { | ||
| 216 | 239 | |
| 217 | 240 | @section('content') |
| 218 | 241 | |
| 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, | |
| 269 | select.panel:focus-visible, | |
| 270 | input:focus-visible, | |
| 271 | textarea:focus-visible, | |
| 272 | summary: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> | |
| 219 | 372 | <div class="grain" aria-hidden="true"></div> |
| 220 | 373 | |
| 221 | 374 | <div class="px-5 py-10 md:px-10 min-h-[100dvh] page-dashboard"> |
| @@ -268,6 +421,7 @@ onMounted(() => { | ||
| 268 | 421 | </span> |
| 269 | 422 | </div> |
| 270 | 423 | <nav class="flex flex-wrap gap-1.5 items-center"> |
| 424 | <DateRangePicker :summary="rangeLabel" :active="isCustom" /> | |
| 271 | 425 | @foreach (rangeTabs as r) |
| 272 | 426 | <StxLink to="" class="px-3 py-1.5 font-medium text-sm border pill ">{{ r.label }}</StxLink> |
| 273 | 427 | @endforeach |
| @@ -0,0 +1,75 @@ | ||
| 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 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. | |
| 12 | const pureSlice = component.match(/\/\/ #region pure\n([\s\S]*?)\n\/\/ #endregion pure/)?.[1] ?? '' | |
| 13 | const pure = new Function(`${pureSlice}\nreturn { pickYmd, pickShift, pickShiftMonth, pickGrid, pickOrder, pickPreset, pickQuery }`)() as Record<string, (...a: any[]) => any> | |
| 14 | ||
| 15 | describe('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 | ||
| 36 | describe('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 | ||
| 56 | describe('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 | }) | |