An app for logging board climbs
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat: add stats page

+418 -1
+1
deno.json
··· 30 30 "@std/testing": "jsr:@std/testing@^1.0.17", 31 31 "@std/ulid": "jsr:@std/ulid@^1.0.0", 32 32 "@zod/zod": "jsr:@zod/zod@^4.3.6", 33 + "chart.js": "npm:chart.js@^4.4.6", 33 34 "hammerjs": "npm:hammerjs@^2.0.8", 34 35 "lit": "npm:lit@^3.3.2" 35 36 }
+11
deno.lock
··· 24 24 "jsr:@std/ulid@1": "1.0.0", 25 25 "jsr:@zod/zod@^4.3.6": "4.3.6", 26 26 "npm:@tauri-apps/plugin-store@^2.2.0": "2.4.2", 27 + "npm:chart.js@^4.4.6": "4.5.1", 27 28 "npm:fast-json-patch@^3.1.1": "3.1.1", 28 29 "npm:hammerjs@^2.0.8": "2.0.8", 29 30 "npm:lit@^3.3.2": "3.3.2", ··· 127 128 } 128 129 }, 129 130 "npm": { 131 + "@kurkle/color@0.3.4": { 132 + "integrity": "sha512-M5UknZPHRu3DEDWoipU6sE8PdkZ6Z/S+v4dD+Ke8IaNlpdSQah50lz1KtcFBa2vsdOnwbbnxJwVM4wty6udA5w==" 133 + }, 130 134 "@lit-labs/ssr-dom-shim@1.5.1": { 131 135 "integrity": "sha512-Aou5UdlSpr5whQe8AA/bZG0jMj96CoJIWbGfZ91qieWu5AWUMKw8VR/pAkQkJYvBNhmCcWnZlyyk5oze8JIqYA==" 132 136 }, ··· 147 151 }, 148 152 "@types/trusted-types@2.0.7": { 149 153 "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==" 154 + }, 155 + "chart.js@4.5.1": { 156 + "integrity": "sha512-GIjfiT9dbmHRiYi6Nl2yFCq7kkwdkp1W/lp2J99rX0yo9tgJGn3lKQATztIjb5tVtevcBtIdICNWqlq5+E8/Pw==", 157 + "dependencies": [ 158 + "@kurkle/color" 159 + ] 150 160 }, 151 161 "fast-json-patch@3.1.1": { 152 162 "integrity": "sha512-vf6IHUX2SBcA+5/+4883dsIjpBTqmfBjmYiWK1savxQmFk4JfBMLa7ynTYOs1Rolp/T1betJxHiGD3g1Mn8lUQ==" ··· 195 205 "jsr:@std/testing@^1.0.17", 196 206 "jsr:@std/ulid@1", 197 207 "jsr:@zod/zod@^4.3.6", 208 + "npm:chart.js@^4.4.6", 198 209 "npm:hammerjs@^2.0.8", 199 210 "npm:lit@^3.3.2" 200 211 ]
+4
www/index.html
··· 54 54 <img src="/static/icons/clock.svg" alt="" aria-hidden="true"> 55 55 <span>Sessions</span> 56 56 </a> 57 + <a href="/stats" data-route> 58 + <img src="/static/icons/bar-chart-2.svg" alt="" aria-hidden="true"> 59 + <span>Stats</span> 60 + </a> 57 61 <a href="/settings" data-route> 58 62 <img src="/static/icons/tool.svg" alt="" aria-hidden="true"> 59 63 <span>Settings</span>
+5
www/index.ts
··· 3 3 import './routes/library.ts' 4 4 import './routes/climb.ts' 5 5 import './routes/settings.ts' 6 + import './routes/stats.ts' 6 7 import { getRestTime, globalStopwatch } from './routes/sessions.ts' 7 8 import { formatStopwatchShort } from './utils/format.ts' 8 9 ··· 67 68 }), 68 69 }, 69 70 meta: { title: 'Session', navActive: '/sessions' }, 71 + }, 72 + '/stats': { 73 + landmarks: { main: 'stats-page' }, 74 + meta: { title: 'Stats', navActive: '/stats' }, 70 75 }, 71 76 '/settings': { 72 77 landmarks: { main: 'settings-page' },
+342
www/routes/stats.ts
··· 1 + import { html, LitElement, type TemplateResult } from 'lit' 2 + import { unsafeHTML } from 'lit/directives/unsafe-html.js' 3 + import Chart from 'chart.js/auto' 4 + import { findClimb, gradeLabel } from '../utils/climbs.ts' 5 + import { GradeScale } from '../models/schema.ts' 6 + import app from '../models/app.ts' 7 + 8 + type Range = 'week' | 'month' | 'year' | 'all' 9 + 10 + const RANGE_OPTIONS: { value: Range; label: string }[] = [ 11 + { value: 'week', label: 'Week' }, 12 + { value: 'month', label: 'Month' }, 13 + { value: 'year', label: 'Year' }, 14 + { value: 'all', label: 'All' }, 15 + ] 16 + 17 + function rangeStart(range: Range): number { 18 + if (range === 'all') return 0 19 + const d = new Date() 20 + if (range === 'week') d.setDate(d.getDate() - 7) 21 + else if (range === 'month') d.setMonth(d.getMonth() - 1) 22 + else if (range === 'year') d.setFullYear(d.getFullYear() - 1) 23 + return d.getTime() 24 + } 25 + 26 + interface GradeBucket { 27 + attempts: number 28 + sends: number 29 + } 30 + 31 + function aggregate( 32 + since: number, 33 + ): { grades: number[]; buckets: Map<number, GradeBucket> } { 34 + const buckets = new Map<number, GradeBucket>() 35 + for (const session of Object.values(app.sessions)) { 36 + for (const a of session.attempts) { 37 + const t = new Date(a.timestamp).getTime() 38 + if (t < since) continue 39 + const climb = findClimb(a.climbId) 40 + if (!climb) continue 41 + const b = buckets.get(climb.grade) ?? { attempts: 0, sends: 0 } 42 + b.attempts += a.attempts 43 + if (a.sent) b.sends += 1 44 + buckets.set(climb.grade, b) 45 + } 46 + } 47 + const grades = [...buckets.keys()].sort((a, b) => a - b) 48 + return { grades, buckets } 49 + } 50 + 51 + type Granularity = 'day' | 'month' 52 + 53 + function granularity(range: Range): Granularity { 54 + return range === 'week' || range === 'month' ? 'day' : 'month' 55 + } 56 + 57 + function bucketKey(d: Date, g: Granularity): string { 58 + const y = d.getFullYear() 59 + const m = String(d.getMonth() + 1).padStart(2, '0') 60 + if (g === 'month') return `${y}-${m}` 61 + return `${y}-${m}-${String(d.getDate()).padStart(2, '0')}` 62 + } 63 + 64 + function bucketLabel(key: string, g: Granularity): string { 65 + const [y, m, d] = key.split('-').map(Number) 66 + const date = g === 'month' ? new Date(y, m - 1, 1) : new Date(y, m - 1, d) 67 + return g === 'month' 68 + ? date.toLocaleDateString('en-US', { month: 'short', year: '2-digit' }) 69 + : date.toLocaleDateString('en-US', { month: 'short', day: 'numeric' }) 70 + } 71 + 72 + function timelineKeys(start: Date, end: Date, g: Granularity): string[] { 73 + const keys: string[] = [] 74 + const cursor = g === 'month' 75 + ? new Date(start.getFullYear(), start.getMonth(), 1) 76 + : new Date(start.getFullYear(), start.getMonth(), start.getDate()) 77 + const endBucket = g === 'month' 78 + ? new Date(end.getFullYear(), end.getMonth(), 1) 79 + : new Date(end.getFullYear(), end.getMonth(), end.getDate()) 80 + while (cursor.getTime() <= endBucket.getTime()) { 81 + keys.push(bucketKey(cursor, g)) 82 + if (g === 'month') cursor.setMonth(cursor.getMonth() + 1) 83 + else cursor.setDate(cursor.getDate() + 1) 84 + } 85 + return keys 86 + } 87 + 88 + function aggregateOverTime( 89 + range: Range, 90 + ): { 91 + labels: string[] 92 + grades: number[] 93 + byGrade: Map<number, number[]> 94 + } { 95 + const g = granularity(range) 96 + const byGradeKey = new Map<number, Map<string, number>>() 97 + let earliest = Number.POSITIVE_INFINITY 98 + const since = rangeStart(range) 99 + for (const session of Object.values(app.sessions)) { 100 + for (const a of session.attempts) { 101 + if (!a.sent) continue 102 + const t = new Date(a.timestamp).getTime() 103 + if (t < since) continue 104 + const climb = findClimb(a.climbId) 105 + if (!climb) continue 106 + if (t < earliest) earliest = t 107 + const key = bucketKey(new Date(a.timestamp), g) 108 + const inner = byGradeKey.get(climb.grade) ?? new Map<string, number>() 109 + inner.set(key, (inner.get(key) ?? 0) + 1) 110 + byGradeKey.set(climb.grade, inner) 111 + } 112 + } 113 + if (byGradeKey.size === 0) { 114 + return { labels: [], grades: [], byGrade: new Map() } 115 + } 116 + const start = new Date(range === 'all' ? earliest : since) 117 + const keys = timelineKeys(start, new Date(), g) 118 + const grades = [...byGradeKey.keys()].sort((a, b) => a - b) 119 + const byGrade = new Map<number, number[]>() 120 + for (const grade of grades) { 121 + const inner = byGradeKey.get(grade)! 122 + byGrade.set(grade, keys.map((k) => inner.get(k) ?? 0)) 123 + } 124 + return { 125 + labels: keys.map((k) => bucketLabel(k, g)), 126 + grades, 127 + byGrade, 128 + } 129 + } 130 + 131 + // Map a grade to a hue along a green→red ramp (easy→hard) using the range of 132 + // grades actually present, so the palette always uses the full spectrum. 133 + function gradeColor( 134 + grade: number, 135 + min: number, 136 + max: number, 137 + ): string { 138 + if (max === min) return 'hsl(237, 60%, 60%)' 139 + const t = (grade - min) / (max - min) 140 + const hue = 142 - t * 142 141 + return `hsl(${hue}, 65%, 50%)` 142 + } 143 + 144 + export class StatsPage extends LitElement { 145 + private range: Range = 'month' 146 + private gradeScale: GradeScale = GradeScale.enum.french 147 + private gradeChart: Chart | null = null 148 + private timeChart: Chart | null = null 149 + 150 + protected override createRenderRoot() { 151 + return this 152 + } 153 + 154 + override connectedCallback() { 155 + super.connectedCallback() 156 + this.gradeScale = app.preferences.gradeScale 157 + this.addEventListener('click', this.#onClick) 158 + app.addEventListener(this.#onAppUpdate) 159 + } 160 + 161 + override disconnectedCallback() { 162 + super.disconnectedCallback() 163 + this.removeEventListener('click', this.#onClick) 164 + app.removeEventListener(this.#onAppUpdate) 165 + this.gradeChart?.destroy() 166 + this.timeChart?.destroy() 167 + this.gradeChart = null 168 + this.timeChart = null 169 + } 170 + 171 + #onAppUpdate = () => { 172 + this.gradeScale = app.preferences.gradeScale 173 + this.requestUpdate() 174 + } 175 + 176 + #onClick = (e: Event) => { 177 + const btn = (e.target as HTMLElement) 178 + .closest<HTMLElement>('[data-range]') 179 + if (!btn) return 180 + const value = btn.dataset.range as Range 181 + if (value === this.range) return 182 + this.range = value 183 + this.requestUpdate() 184 + } 185 + 186 + protected override updated() { 187 + const style = getComputedStyle(this) 188 + const hsl = (prefix: string, fallback: string) => { 189 + const h = style.getPropertyValue(`--${prefix}H`).trim() 190 + const s = style.getPropertyValue(`--${prefix}S`).trim() 191 + const l = style.getPropertyValue(`--${prefix}L`).trim() 192 + return h && s && l ? `hsl(${h}, ${s}, ${l})` : fallback 193 + } 194 + const primary = hsl('primary', 'hsl(237, 50%, 65%)') 195 + const success = hsl('success', 'hsl(142, 76%, 36%)') 196 + this.renderGradeChart(primary, success) 197 + this.renderTimeChart() 198 + } 199 + 200 + private renderGradeChart(primary: string, success: string): void { 201 + const canvas = this.querySelector<HTMLCanvasElement>('#stats-grade-chart') 202 + this.gradeChart?.destroy() 203 + this.gradeChart = null 204 + if (!canvas) return 205 + 206 + const { grades, buckets } = aggregate(rangeStart(this.range)) 207 + if (grades.length === 0) return 208 + 209 + const labels = grades.map((g) => gradeLabel(g, this.gradeScale)) 210 + const sends = grades.map((g) => buckets.get(g)?.sends ?? 0) 211 + const unsent = grades.map((g) => { 212 + const b = buckets.get(g) 213 + return Math.max(0, (b?.attempts ?? 0) - (b?.sends ?? 0)) 214 + }) 215 + 216 + this.gradeChart = new Chart(canvas, { 217 + type: 'bar', 218 + data: { 219 + labels, 220 + datasets: [ 221 + { label: 'Sends', data: sends, backgroundColor: success }, 222 + { label: 'Attempts', data: unsent, backgroundColor: primary }, 223 + ], 224 + }, 225 + options: { 226 + responsive: true, 227 + maintainAspectRatio: false, 228 + scales: { 229 + x: { stacked: true }, 230 + y: { stacked: true, beginAtZero: true, ticks: { precision: 0 } }, 231 + }, 232 + plugins: { legend: { position: 'bottom' } }, 233 + }, 234 + }) 235 + } 236 + 237 + private renderTimeChart(): void { 238 + const canvas = this.querySelector<HTMLCanvasElement>('#stats-time-chart') 239 + this.timeChart?.destroy() 240 + this.timeChart = null 241 + if (!canvas) return 242 + 243 + const { labels, grades, byGrade } = aggregateOverTime(this.range) 244 + if (labels.length === 0 || grades.length === 0) return 245 + 246 + const min = grades[0] 247 + const max = grades[grades.length - 1] 248 + const datasets = grades.map((grade) => { 249 + const color = gradeColor(grade, min, max) 250 + return { 251 + label: gradeLabel(grade, this.gradeScale), 252 + data: byGrade.get(grade) ?? [], 253 + borderColor: color, 254 + backgroundColor: color, 255 + tension: 0.3, 256 + fill: true, 257 + } 258 + }) 259 + 260 + this.timeChart = new Chart(canvas, { 261 + type: 'line', 262 + data: { labels, datasets }, 263 + options: { 264 + responsive: true, 265 + maintainAspectRatio: false, 266 + scales: { 267 + y: { stacked: true, beginAtZero: true, ticks: { precision: 0 } }, 268 + }, 269 + plugins: { legend: { position: 'bottom' } }, 270 + }, 271 + }) 272 + } 273 + 274 + override render(): TemplateResult { 275 + const totals = aggregate(rangeStart(this.range)) 276 + const totalAttempts = [...totals.buckets.values()].reduce( 277 + (s, b) => s + b.attempts, 278 + 0, 279 + ) 280 + const totalSends = [...totals.buckets.values()].reduce( 281 + (s, b) => s + b.sends, 282 + 0, 283 + ) 284 + 285 + return html` 286 + <ui-button-group id="stats-range"> 287 + ${unsafeHTML(this.rangeHtml())} 288 + </ui-button-group> 289 + 290 + <div class="stats-summary"> 291 + <div class="stats-stat"> 292 + <span class="stats-stat-value">${totalAttempts 293 + .toLocaleString()}</span> 294 + <span class="stats-stat-label">Attempts</span> 295 + </div> 296 + <div class="stats-stat"> 297 + <span class="stats-stat-value">${totalSends.toLocaleString()}</span> 298 + <span class="stats-stat-label">Sends</span> 299 + </div> 300 + </div> 301 + 302 + <section class="stats-section"> 303 + <h2 class="stats-section-title">By grade</h2> 304 + <div class="stats-chart-wrap"> 305 + ${totals.grades.length === 0 306 + ? html` 307 + <p class="empty-message">No climbs logged in this range.</p> 308 + ` 309 + : html` 310 + <canvas id="stats-grade-chart"></canvas> 311 + `} 312 + </div> 313 + </section> 314 + 315 + <section class="stats-section"> 316 + <h2 class="stats-section-title">Sends over time</h2> 317 + <div class="stats-chart-wrap"> 318 + ${totalSends === 0 319 + ? html` 320 + <p class="empty-message">No sends in this range.</p> 321 + ` 322 + : html` 323 + <canvas id="stats-time-chart"></canvas> 324 + `} 325 + </div> 326 + </section> 327 + ` 328 + } 329 + 330 + private rangeHtml(): string { 331 + return RANGE_OPTIONS 332 + .map( 333 + (o) => 334 + `<button aria-pressed="${ 335 + this.range === o.value 336 + }" data-range="${o.value}">${o.label}</button>`, 337 + ) 338 + .join('') 339 + } 340 + } 341 + 342 + customElements.define('stats-page', StatsPage)
+55 -1
www/static/theme.css
··· 286 286 } 287 287 288 288 sessions-page, 289 - session-page { 289 + session-page, 290 + stats-page { 290 291 display: flex; 291 292 flex-direction: column; 292 293 padding: var(--s4) var(--s3); 293 294 gap: var(--s4); 294 295 max-width: 600px; 295 296 margin: 0 auto; 297 + } 298 + 299 + #stats-range button { 300 + flex: 1; 301 + border-radius: var(--br-full); 302 + font-size: var(--f6); 303 + padding: var(--s2); 304 + } 305 + 306 + .stats-summary { 307 + display: flex; 308 + gap: var(--s3); 309 + } 310 + 311 + .stats-stat { 312 + flex: 1; 313 + display: flex; 314 + flex-direction: column; 315 + align-items: center; 316 + gap: var(--s1); 317 + padding: var(--s3); 318 + border: 1px solid var(--border); 319 + border-radius: var(--br-base); 320 + } 321 + 322 + .stats-stat-value { 323 + font-size: var(--f1); 324 + font-weight: var(--fw-bold); 325 + font-variant-numeric: tabular-nums; 326 + } 327 + 328 + .stats-stat-label { 329 + font-size: var(--f6); 330 + opacity: 0.6; 331 + text-transform: uppercase; 332 + letter-spacing: 0.05em; 333 + } 334 + 335 + .stats-chart-wrap { 336 + position: relative; 337 + min-height: 280px; 338 + } 339 + 340 + .stats-section { 341 + display: flex; 342 + flex-direction: column; 343 + gap: var(--s2); 344 + } 345 + 346 + .stats-section-title { 347 + font-size: var(--f5); 348 + font-weight: var(--fw-semibold); 349 + margin: 0; 296 350 } 297 351 298 352 .active-session-card {