also looking at this
feat(analytics): presets-and-calendar reporting-period picker
#6
3 files
+500
-5
| @@ -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 |