| @@ -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> |