An app for logging board climbs
0
fork

Configure Feed

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

feat: add session route

+699 -287
+2 -2
www/index.html
··· 54 54 <img src="/static/icons/library.svg" alt="" aria-hidden="true"> 55 55 <span>Library</span> 56 56 </a> 57 - <a href="/stopwatch" data-route> 57 + <a href="/sessions" data-route> 58 58 <img src="/static/icons/clock.svg" alt="" aria-hidden="true"> 59 - <span>Stopwatch</span> 59 + <span>Sessions</span> 60 60 </a> 61 61 <a href="/settings" data-route> 62 62 <img src="/static/icons/tool.svg" alt="" aria-hidden="true">
+15 -6
www/index.ts
··· 4 4 import './routes/library.ts' 5 5 import './routes/climb.ts' 6 6 import './routes/settings.ts' 7 - import { getRestTime, globalStopwatch } from './routes/stopwatch.ts' 7 + import { getRestTime, globalStopwatch } from './routes/sessions.ts' 8 8 import { formatStopwatchShort } from './utils/format.ts' 9 9 10 10 client.init() ··· 60 60 landmarks: { subHeader: 'library-filters', main: 'library-page' }, 61 61 meta: { title: 'Library', navActive: '/library' }, 62 62 }, 63 - '/stopwatch': { 64 - landmarks: { main: 'stopwatch-page' }, 65 - meta: { title: 'Stopwatch', navActive: '/stopwatch' }, 63 + '/sessions': { 64 + landmarks: { main: 'sessions-page' }, 65 + meta: { title: 'Sessions', navActive: '/sessions' }, 66 + }, 67 + '/session/{id}': { 68 + landmarks: { 69 + main: (ctx) => ({ 70 + tag: 'session-page', 71 + attrs: { 'session-id': ctx.params.id }, 72 + }), 73 + }, 74 + meta: { title: 'Session', navActive: '/sessions' }, 66 75 }, 67 76 '/settings': { 68 77 landmarks: { main: 'settings-page' }, ··· 76 85 // Stopwatch nav indicator — app-specific, stays outside the declarative config 77 86 function initStopwatchIndicator() { 78 87 const label = document.querySelector( 79 - 'ui-bottom-bar a[href="/stopwatch"] span', 88 + 'ui-bottom-bar a[href="/sessions"] span', 80 89 ) 81 90 if (!label) return 82 91 globalStopwatch.addEventListener((state) => { ··· 85 94 label.textContent = formatStopwatchShort(getRestTime(state)) 86 95 label.classList.add('sw-running') 87 96 } else { 88 - label.textContent = 'Stopwatch' 97 + label.textContent = 'Sessions' 89 98 label.classList.remove('sw-running') 90 99 } 91 100 })
+9 -4
www/models/app.ts
··· 150 150 if (prevId && prevId !== id) { 151 151 const prev = await this.store.getSession(prevId) 152 152 if (prev && prev.end == null) { 153 - await this.store.saveSession({ 154 - ...prev, 155 - end: new Date().toISOString(), 156 - }) 153 + if (prev.attempts.length === 0) { 154 + await this.store.deleteSession(prevId) 155 + } else { 156 + const lastAttempt = prev.attempts[prev.attempts.length - 1] 157 + await this.store.saveSession({ 158 + ...prev, 159 + end: lastAttempt?.timestamp ?? new Date().toISOString(), 160 + }) 161 + } 157 162 } 158 163 } 159 164 this.settings.update({ activeSessionId: id })
+4
www/models/store.ts
··· 105 105 await this.#sessions.set(s.id, s) 106 106 } 107 107 108 + async deleteSession(id: string): Promise<void> { 109 + await this.#sessions.delete(id) 110 + } 111 + 108 112 async clearAllData(): Promise<void> { 109 113 await this.#store.deleteAll() 110 114 this.#progressCache = {}
+1 -1
www/routes/climb.ts
··· 5 5 import { BOARDS } from '../utils/boards.ts' 6 6 import type { Climb } from '../models/schema.ts' 7 7 import app from '../models/app.ts' 8 - import { markAttempt } from './stopwatch.ts' 8 + import { markAttempt } from './sessions.ts' 9 9 10 10 export let activeClimbHeader: ClimbHeader | null = null 11 11
+4 -4
www/routes/home.ts
··· 321 321 } 322 322 if (this.error) { 323 323 return html` 324 - <p class="empty-message">Failed to load benchmarks.</p> 324 + <p class="empty-message">Failed to load climbs.</p> 325 325 ` 326 326 } 327 327 if (!BOARDS[this.boardId]) { ··· 331 331 } 332 332 if (this.climbs.length === 0) { 333 333 return html` 334 - <p class="empty-message">No benchmarks available for this board.</p> 334 + <p class="empty-message">No climbs available for this board.</p> 335 335 ` 336 336 } 337 337 if (this.filtered.length === 0) { 338 338 return html` 339 - <p class="empty-message">No benchmarks match your filters.</p> 339 + <p class="empty-message">No climbs match your filters.</p> 340 340 ` 341 341 } 342 342 ··· 347 347 return html` 348 348 <div class="bm-count"> 349 349 ${this.filtered.length 350 - .toLocaleString()} benchmark${this.filtered.length === 1 ? '' : 's'} 350 + .toLocaleString()} climb${this.filtered.length === 1 ? '' : 's'} 351 351 </div> 352 352 ${visible.map((c) => { 353 353 const p = progress[c.id]
+514
www/routes/sessions.ts
··· 1 + import { html, LitElement, type TemplateResult } from 'lit' 2 + import Stopwatch, { type StopwatchState } from '@inro/simple-tools/stopwatch' 3 + import { ulid } from '@std/ulid' 4 + import { 5 + formatDate, 6 + formatDuration, 7 + formatStopwatchShort, 8 + formatTimeOfDay, 9 + } from '../utils/format.ts' 10 + import { findClimb, gradeLabel } from '../utils/climbs.ts' 11 + import { type ClimbAttempt, type Session } from '../models/schema.ts' 12 + import app from '../models/app.ts' 13 + 14 + function renderAttemptRow( 15 + a: ClimbAttempt, 16 + backRoute: string, 17 + ): TemplateResult { 18 + const climb = findClimb(a.climbId) 19 + const name = climb?.name ?? a.climbId 20 + const grade = climb ? gradeLabel(climb.grade) : '' 21 + return html` 22 + <button 23 + class="session-attempt" 24 + role="listitem" 25 + data-attempt-climb-id="${a.climbId}" 26 + data-attempt-back="${backRoute}" 27 + > 28 + <div class="session-attempt-row"> 29 + <span class="session-attempt-name">${name}</span> 30 + ${grade 31 + ? html` 32 + <ui-badge class="grade">${grade}</ui-badge> 33 + ` 34 + : ''} ${a.sent 35 + ? html` 36 + <ui-badge variant="success">Sent</ui-badge> 37 + ` 38 + : ''} 39 + </div> 40 + <div class="session-attempt-meta"> 41 + <span>${formatTimeOfDay(a.timestamp)}</span> 42 + <span>·</span> 43 + <span>${a.attempts} attempt${a.attempts === 1 ? '' : 's'}</span> 44 + </div> 45 + </button> 46 + ` 47 + } 48 + 49 + function navigateToClimb(climbId: string, backRoute: string): void { 50 + app.setNav({ id: climbId, backRoute }) 51 + globalThis.location.hash = `/climb/${climbId}` 52 + } 53 + 54 + const AUTO_RESET_MS = 6 * 60 * 60 * 1000 // 20 hours 55 + 56 + // Wraps the inner Stopwatch so that index.ts subscriptions survive restoration, 57 + // and so elapsed offsets (from a persisted running timer) are applied uniformly 58 + // to all consumers without them needing to know about the offset. 59 + class StopwatchProxy { 60 + #inner: Stopwatch 61 + #offset = 0 62 + #listeners: ((state: StopwatchState) => void)[] = [] 63 + 64 + constructor(inner: Stopwatch) { 65 + this.#inner = inner 66 + this.#inner.addEventListener((state) => { 67 + const adjusted = this.#adjust(state) 68 + for (const cb of this.#listeners) cb(adjusted) 69 + }) 70 + } 71 + 72 + #adjust(state: StopwatchState): StopwatchState { 73 + if (this.#offset === 0) return state 74 + const elapsed = state.elapsed + this.#offset 75 + return { 76 + ...state, 77 + elapsed, 78 + display: formatStopwatchShort(elapsed), 79 + laps: state.laps.map((lap) => ({ 80 + ...lap, 81 + total: lap.total + this.#offset, 82 + totalDisplay: formatStopwatchShort(lap.total + this.#offset), 83 + })), 84 + } 85 + } 86 + 87 + // Resets the inner stopwatch and sets an elapsed offset so the timer resumes 88 + // from the saved time rather than from zero. 89 + restore(offset: number): void { 90 + this.#offset = Math.max(0, offset) 91 + this.#inner.reset() 92 + } 93 + 94 + addEventListener(cb: (state: StopwatchState) => void): () => void { 95 + this.#listeners.push(cb) 96 + return () => { 97 + const i = this.#listeners.indexOf(cb) 98 + if (i !== -1) this.#listeners.splice(i, 1) 99 + } 100 + } 101 + 102 + get state(): StopwatchState { 103 + return this.#adjust(this.#inner.state) 104 + } 105 + 106 + start(): void { 107 + this.#inner.start() 108 + } 109 + pause(): void { 110 + this.#inner.pause() 111 + } 112 + 113 + reset(): void { 114 + this.#offset = 0 115 + this.#inner.reset() 116 + } 117 + 118 + lap(): void { 119 + this.#inner.lap() 120 + } 121 + } 122 + 123 + // Singleton — persists across route navigation 124 + export const globalStopwatch = new StopwatchProxy( 125 + new Stopwatch({ 126 + resolutionMS: 1000, 127 + formatDisplayTime: formatStopwatchShort, 128 + }), 129 + ) 130 + 131 + // Restore a running timer on app load by reading the active Session's start. 132 + // Runs once immediately (so a plain page refresh restores from localStorage) 133 + // and again on any later settings update (e.g. sync pulling new state). 134 + async function restoreFromSettings(): Promise<void> { 135 + if (globalStopwatch.state.isStarted) return 136 + const activeId = app.settings.state.activeSessionId 137 + if (!activeId) return 138 + const session = await app.store.getSession(activeId) 139 + if (!session || session.end != null) return 140 + const elapsed = Date.now() - new Date(session.start).getTime() 141 + if (elapsed >= AUTO_RESET_MS) { 142 + globalStopwatch.reset() 143 + await app.setActiveSession(null) 144 + return 145 + } 146 + globalStopwatch.restore(elapsed) 147 + globalStopwatch.start() 148 + } 149 + 150 + void app.store.waitUntilReady().then(() => restoreFromSettings()) 151 + app.settings.addEventListener(() => { 152 + void restoreFromSettings() 153 + }) 154 + 155 + // Returns ms since the last lap, or total elapsed if no laps have been logged. 156 + export function getRestTime(state: StopwatchState): number { 157 + const last = state.laps[state.laps.length - 1] 158 + return last ? state.elapsed - last.total : state.elapsed 159 + } 160 + 161 + // Starts the stopwatch and creates/resumes a Session reflecting its start time. 162 + export async function startStopwatch(): Promise<void> { 163 + globalStopwatch.start() 164 + const effectiveStart = new Date( 165 + Date.now() - globalStopwatch.state.elapsed, 166 + ).toISOString() 167 + const activeId = app.settings.state.activeSessionId 168 + const existing = activeId ? await app.store.getSession(activeId) : null 169 + if (existing && existing.end == null) { 170 + if (existing.start !== effectiveStart) { 171 + await app.store.saveSession({ ...existing, start: effectiveStart }) 172 + } 173 + return 174 + } 175 + const session = { 176 + id: ulid(), 177 + start: effectiveStart, 178 + end: null, 179 + attempts: [], 180 + notes: '', 181 + } 182 + await app.store.saveSession(session) 183 + app.updateSettings({ activeSessionId: session.id }) 184 + } 185 + 186 + // Called when logging an attempt: start from reset, lap if running, no-op if paused. 187 + export function markAttempt(): void { 188 + const { isStarted, isPaused } = globalStopwatch.state 189 + if (!isStarted) { 190 + void startStopwatch() 191 + } else if (!isPaused) { 192 + globalStopwatch.lap() 193 + } 194 + } 195 + 196 + // ============== Sessions list page ============== 197 + 198 + const PAGE_SIZE = 50 199 + const listState = { shown: PAGE_SIZE } 200 + 201 + export class SessionsPage extends LitElement { 202 + private timeElement: HTMLElement | null = null 203 + private restElement: HTMLElement | null = null 204 + private startButton: HTMLButtonElement | null = null 205 + private completeButton: HTMLButtonElement | null = null 206 + private removeListener: (() => void) | null = null 207 + private prevActiveId: string | null = null 208 + 209 + protected override createRenderRoot() { 210 + return this 211 + } 212 + 213 + override connectedCallback() { 214 + super.connectedCallback() 215 + listState.shown = PAGE_SIZE 216 + this.prevActiveId = app.settings.state.activeSessionId 217 + this.removeListener = globalStopwatch.addEventListener((state) => 218 + this.updateActiveCard(state) 219 + ) 220 + this.addEventListener('click', this.#onClick) 221 + app.store.addEventListener(this.#onStoreUpdate) 222 + app.settings.addEventListener(this.#onSettingsUpdate) 223 + } 224 + 225 + override disconnectedCallback() { 226 + super.disconnectedCallback() 227 + this.removeListener?.() 228 + this.removeListener = null 229 + this.removeEventListener('click', this.#onClick) 230 + app.store.removeEventListener(this.#onStoreUpdate) 231 + app.settings.removeEventListener(this.#onSettingsUpdate) 232 + } 233 + 234 + #onStoreUpdate = () => { 235 + this.requestUpdate() 236 + } 237 + 238 + #onSettingsUpdate = () => { 239 + const activeId = app.settings.state.activeSessionId 240 + if (activeId !== this.prevActiveId) { 241 + this.prevActiveId = activeId 242 + this.requestUpdate() 243 + } 244 + } 245 + 246 + #onClick = (e: Event) => { 247 + const target = e.target as HTMLElement 248 + 249 + if (target.closest('#start-button')) { 250 + const { isStarted, isPaused } = globalStopwatch.state 251 + if (isStarted && !isPaused) { 252 + globalStopwatch.pause() 253 + } else { 254 + void startStopwatch() 255 + } 256 + return 257 + } 258 + 259 + if (target.closest('#complete-button')) { 260 + globalStopwatch.reset() 261 + void app.setActiveSession(null) 262 + return 263 + } 264 + 265 + if (target.closest('#sessions-load-more')) { 266 + listState.shown += PAGE_SIZE 267 + this.requestUpdate() 268 + return 269 + } 270 + 271 + const climbBtn = target.closest<HTMLElement>('[data-attempt-climb-id]') 272 + if (climbBtn) { 273 + const climbId = climbBtn.dataset.attemptClimbId ?? '' 274 + const back = climbBtn.dataset.attemptBack ?? '/sessions' 275 + if (climbId) navigateToClimb(climbId, back) 276 + return 277 + } 278 + 279 + const sessionEl = target.closest<HTMLElement>('[data-session-id]') 280 + if (sessionEl) { 281 + const id = sessionEl.dataset.sessionId ?? '' 282 + if (id) globalThis.location.hash = `/session/${id}` 283 + } 284 + } 285 + 286 + protected override updated() { 287 + this.timeElement = this.querySelector('#stopwatch-time') 288 + this.restElement = this.querySelector('#stopwatch-rest') 289 + this.startButton = this.querySelector('#start-button') 290 + this.completeButton = this.querySelector('#complete-button') 291 + this.updateActiveCard(globalStopwatch.state) 292 + } 293 + 294 + private updateActiveCard(state: StopwatchState): void { 295 + const isRunning = state.isStarted && !state.isPaused 296 + 297 + if (this.timeElement) { 298 + this.timeElement.textContent = state.display 299 + } 300 + 301 + if (this.restElement) { 302 + const showRest = state.isStarted 303 + this.restElement.hidden = !showRest 304 + if (showRest) { 305 + const valueEl = this.restElement.querySelector('.stopwatch-rest-value') 306 + if (valueEl) { 307 + valueEl.textContent = formatStopwatchShort(getRestTime(state)) 308 + } 309 + } 310 + } 311 + 312 + if (this.startButton) { 313 + this.startButton.textContent = isRunning ? 'Pause' : 'Start' 314 + } 315 + } 316 + 317 + private activeSession(): Session | null { 318 + const id = app.settings.state.activeSessionId 319 + if (!id) return null 320 + return app.sessions[id] ?? null 321 + } 322 + 323 + private pastSessions(): Session[] { 324 + const activeId = app.settings.state.activeSessionId 325 + return Object.values(app.sessions) 326 + .filter((s) => s.id !== activeId && s.attempts.length > 0) 327 + .sort( 328 + (a, b) => new Date(b.start).getTime() - new Date(a.start).getTime(), 329 + ) 330 + } 331 + 332 + override render(): TemplateResult { 333 + const past = this.pastSessions() 334 + const visible = past.slice(0, listState.shown) 335 + const remaining = past.length - visible.length 336 + const active = this.activeSession() 337 + const activeAttempts = active ? [...active.attempts].reverse() : [] 338 + 339 + return html` 340 + <section class="active-session-card"> 341 + <div id="stopwatch-display"> 342 + <div id="stopwatch-time">0:00</div> 343 + <div id="stopwatch-rest" class="stopwatch-rest" hidden> 344 + <span class="stopwatch-rest-label">Rest</span> 345 + <span class="stopwatch-rest-value">0:00</span> 346 + </div> 347 + </div> 348 + <div id="stopwatch-controls"> 349 + <button id="start-button" class="primary">Start</button> 350 + ${active 351 + ? html` 352 + <button id="complete-button">Complete Session</button> 353 + ` 354 + : ''} 355 + </div> 356 + ${activeAttempts.length > 0 357 + ? html` 358 + <div class="session-attempts-list" role="list"> 359 + ${activeAttempts.map((a) => renderAttemptRow(a, '/sessions'))} 360 + </div> 361 + ` 362 + : ''} 363 + </section> 364 + 365 + <section class="sessions-history"> 366 + <h2 class="sessions-history-title">Past sessions</h2> 367 + ${past.length === 0 368 + ? html` 369 + <p class="empty-message"> 370 + No past sessions yet.<br>Log attempts to start one. 371 + </p> 372 + ` 373 + : html` 374 + <div class="sessions-list"> 375 + ${visible.map((s) => this.renderSessionRow(s))} 376 + </div> 377 + ${remaining > 0 378 + ? html` 379 + <button id="sessions-load-more">Load more (${remaining 380 + .toLocaleString()} remaining)</button> 381 + ` 382 + : ''} 383 + `} 384 + </section> 385 + ` 386 + } 387 + 388 + private renderSessionRow(s: Session): TemplateResult { 389 + const ended = s.end ?? new Date().toISOString() 390 + const duration = new Date(ended).getTime() - new Date(s.start).getTime() 391 + const totalAttempts = s.attempts.reduce((sum, a) => sum + a.attempts, 0) 392 + return html` 393 + <button class="session-row" data-session-id="${s.id}"> 394 + <div class="session-row-main"> 395 + <span class="session-row-date">${formatDate(s.start)}</span> 396 + <span class="session-row-duration">${formatDuration(duration)}</span> 397 + </div> 398 + <div class="session-row-meta"> 399 + <span>${s.attempts.length} climb${s.attempts.length === 1 400 + ? '' 401 + : 's'}</span> 402 + <span>${totalAttempts} attempt${totalAttempts === 1 ? '' : 's'}</span> 403 + </div> 404 + </button> 405 + ` 406 + } 407 + } 408 + 409 + // ============== Session detail page ============== 410 + 411 + export class SessionPage extends LitElement { 412 + private session: Session | null = null 413 + private notFound = false 414 + 415 + protected override createRenderRoot() { 416 + return this 417 + } 418 + 419 + override async connectedCallback() { 420 + super.connectedCallback() 421 + this.addEventListener('click', this.#onClick) 422 + const id = this.getAttribute('session-id') ?? '' 423 + if (!id) { 424 + this.notFound = true 425 + this.requestUpdate() 426 + return 427 + } 428 + await app.store.waitUntilReady() 429 + this.session = await app.store.getSession(id) 430 + if (!this.session) this.notFound = true 431 + this.requestUpdate() 432 + } 433 + 434 + override disconnectedCallback() { 435 + super.disconnectedCallback() 436 + this.removeEventListener('click', this.#onClick) 437 + } 438 + 439 + #onClick = (e: Event) => { 440 + const target = e.target as HTMLElement 441 + const climbBtn = target.closest<HTMLElement>('[data-attempt-climb-id]') 442 + if (climbBtn) { 443 + const climbId = climbBtn.dataset.attemptClimbId ?? '' 444 + const back = climbBtn.dataset.attemptBack ?? '/sessions' 445 + if (climbId) navigateToClimb(climbId, back) 446 + } 447 + } 448 + 449 + override render(): TemplateResult { 450 + if (this.notFound) { 451 + return html` 452 + <p class="empty-message">Session not found.</p> 453 + ` 454 + } 455 + if (!this.session) { 456 + return html` 457 + <div class="empty-message"><ui-spinner size="lg"></ui-spinner></div> 458 + ` 459 + } 460 + 461 + const s = this.session 462 + const isActive = s.end == null 463 + const ended = s.end ?? new Date().toISOString() 464 + const duration = new Date(ended).getTime() - new Date(s.start).getTime() 465 + const totalAttempts = s.attempts.reduce((sum, a) => sum + a.attempts, 0) 466 + 467 + return html` 468 + <section class="session-detail-header"> 469 + <div class="session-detail-row"> 470 + <strong>${formatDate(s.start)}</strong> 471 + ${isActive 472 + ? html` 473 + <ui-badge variant="success">Active</ui-badge> 474 + ` 475 + : ''} 476 + </div> 477 + <div class="session-detail-meta"> 478 + <span>${formatTimeOfDay(s.start)}${s.end 479 + ? ` – ${formatTimeOfDay(s.end)}` 480 + : ''}</span> 481 + <span>·</span> 482 + <span>${formatDuration(duration)}</span> 483 + <span>·</span> 484 + <span>${s.attempts.length} climb${s.attempts.length === 1 485 + ? '' 486 + : 's'}, ${totalAttempts} attempt${totalAttempts === 1 487 + ? '' 488 + : 's'}</span> 489 + </div> 490 + </section> 491 + 492 + <section class="session-attempts"> 493 + ${s.attempts.length === 0 494 + ? html` 495 + <p class="empty-message">No attempts logged.</p> 496 + ` 497 + : html` 498 + <div class="session-attempts-list" role="list"> 499 + ${[...s.attempts].reverse().map((a) => 500 + renderAttemptRow(a, `/session/${s.id}`) 501 + )} 502 + </div> 503 + `} ${s.notes 504 + ? html` 505 + <div class="session-notes">${s.notes}</div> 506 + ` 507 + : ''} 508 + </section> 509 + ` 510 + } 511 + } 512 + 513 + customElements.define('sessions-page', SessionsPage) 514 + customElements.define('session-page', SessionPage)
-268
www/routes/stopwatch.ts
··· 1 - import { html, LitElement, type TemplateResult } from 'lit' 2 - import Stopwatch, { type StopwatchState } from '@inro/simple-tools/stopwatch' 3 - import { ulid } from '@std/ulid' 4 - import { formatStopwatchShort } from '../utils/format.ts' 5 - import app from '../models/app.ts' 6 - 7 - const AUTO_RESET_MS = 20 * 60 * 60 * 1000 // 20 hours 8 - 9 - // Wraps the inner Stopwatch so that index.ts subscriptions survive restoration, 10 - // and so elapsed offsets (from a persisted running timer) are applied uniformly 11 - // to all consumers without them needing to know about the offset. 12 - class StopwatchProxy { 13 - #inner: Stopwatch 14 - #offset = 0 15 - #listeners: ((state: StopwatchState) => void)[] = [] 16 - 17 - constructor(inner: Stopwatch) { 18 - this.#inner = inner 19 - this.#inner.addEventListener((state) => { 20 - const adjusted = this.#adjust(state) 21 - for (const cb of this.#listeners) cb(adjusted) 22 - }) 23 - } 24 - 25 - #adjust(state: StopwatchState): StopwatchState { 26 - if (this.#offset === 0) return state 27 - const elapsed = state.elapsed + this.#offset 28 - return { 29 - ...state, 30 - elapsed, 31 - display: formatStopwatchShort(elapsed), 32 - laps: state.laps.map((lap) => ({ 33 - ...lap, 34 - total: lap.total + this.#offset, 35 - totalDisplay: formatStopwatchShort(lap.total + this.#offset), 36 - })), 37 - } 38 - } 39 - 40 - // Resets the inner stopwatch and sets an elapsed offset so the timer resumes 41 - // from the saved time rather than from zero. 42 - restore(offset: number): void { 43 - this.#offset = Math.max(0, offset) 44 - this.#inner.reset() 45 - } 46 - 47 - addEventListener(cb: (state: StopwatchState) => void): () => void { 48 - this.#listeners.push(cb) 49 - return () => { 50 - const i = this.#listeners.indexOf(cb) 51 - if (i !== -1) this.#listeners.splice(i, 1) 52 - } 53 - } 54 - 55 - get state(): StopwatchState { 56 - return this.#adjust(this.#inner.state) 57 - } 58 - 59 - start(): void { 60 - this.#inner.start() 61 - } 62 - pause(): void { 63 - this.#inner.pause() 64 - } 65 - 66 - reset(): void { 67 - this.#offset = 0 68 - this.#inner.reset() 69 - } 70 - 71 - lap(): void { 72 - this.#inner.lap() 73 - } 74 - } 75 - 76 - // Singleton — persists across route navigation 77 - export const globalStopwatch = new StopwatchProxy( 78 - new Stopwatch({ 79 - resolutionMS: 1000, 80 - formatDisplayTime: formatStopwatchShort, 81 - }), 82 - ) 83 - 84 - // Restore a running timer on app load by reading the active Session's start. 85 - // Runs once immediately (so a plain page refresh restores from localStorage) 86 - // and again on any later settings update (e.g. sync pulling new state). 87 - async function restoreFromSettings(): Promise<void> { 88 - if (globalStopwatch.state.isStarted) return 89 - const activeId = app.settings.state.activeSessionId 90 - if (!activeId) return 91 - const session = await app.store.getSession(activeId) 92 - if (!session || session.end != null) return 93 - const elapsed = Date.now() - new Date(session.start).getTime() 94 - if (elapsed >= AUTO_RESET_MS) { 95 - globalStopwatch.reset() 96 - await app.setActiveSession(null) 97 - return 98 - } 99 - globalStopwatch.restore(elapsed) 100 - globalStopwatch.start() 101 - } 102 - 103 - void app.store.waitUntilReady().then(() => restoreFromSettings()) 104 - app.settings.addEventListener(() => { 105 - void restoreFromSettings() 106 - }) 107 - 108 - // Returns ms since the last lap, or total elapsed if no laps have been logged. 109 - export function getRestTime(state: StopwatchState): number { 110 - const last = state.laps[state.laps.length - 1] 111 - return last ? state.elapsed - last.total : state.elapsed 112 - } 113 - 114 - // Starts the stopwatch and creates/resumes a Session reflecting its start time. 115 - export async function startStopwatch(): Promise<void> { 116 - globalStopwatch.start() 117 - const effectiveStart = new Date( 118 - Date.now() - globalStopwatch.state.elapsed, 119 - ).toISOString() 120 - const activeId = app.settings.state.activeSessionId 121 - const existing = activeId ? await app.store.getSession(activeId) : null 122 - if (existing && existing.end == null) { 123 - if (existing.start !== effectiveStart) { 124 - await app.store.saveSession({ ...existing, start: effectiveStart }) 125 - } 126 - return 127 - } 128 - const session = { 129 - id: ulid(), 130 - start: effectiveStart, 131 - end: null, 132 - attempts: [], 133 - notes: '', 134 - } 135 - await app.store.saveSession(session) 136 - app.updateSettings({ activeSessionId: session.id }) 137 - } 138 - 139 - // Called when logging an attempt: start from reset, lap if running, no-op if paused. 140 - export function markAttempt(): void { 141 - const { isStarted, isPaused } = globalStopwatch.state 142 - if (!isStarted) { 143 - void startStopwatch() 144 - } else if (!isPaused) { 145 - globalStopwatch.lap() 146 - } 147 - } 148 - 149 - export class StopwatchPage extends LitElement { 150 - private timeElement: HTMLElement | null = null 151 - private restElement: HTMLElement | null = null 152 - private startButton: HTMLButtonElement | null = null 153 - private resetButton: HTMLButtonElement | null = null 154 - private lapsContainer: HTMLElement | null = null 155 - private removeListener: (() => void) | null = null 156 - 157 - protected override createRenderRoot() { 158 - return this 159 - } 160 - 161 - override connectedCallback() { 162 - super.connectedCallback() 163 - this.removeListener = globalStopwatch.addEventListener((state) => 164 - this.updateUI(state) 165 - ) 166 - } 167 - 168 - override disconnectedCallback() { 169 - super.disconnectedCallback() 170 - this.removeListener?.() 171 - this.removeListener = null 172 - } 173 - 174 - protected override firstUpdated() { 175 - this.timeElement = this.querySelector('#stopwatch-time') 176 - this.restElement = this.querySelector('#stopwatch-rest') 177 - this.startButton = this.querySelector('#start-button') 178 - this.resetButton = this.querySelector('#reset-button') 179 - this.lapsContainer = this.querySelector('#laps-container') 180 - this.bindEvents() 181 - this.updateUI(globalStopwatch.state) 182 - } 183 - 184 - override render(): TemplateResult { 185 - return html` 186 - <div id="stopwatch-display"> 187 - <div id="stopwatch-time">0:00</div> 188 - <div id="stopwatch-rest" class="stopwatch-rest" hidden> 189 - <span class="stopwatch-rest-label">Rest</span> 190 - <span class="stopwatch-rest-value">0:00</span> 191 - </div> 192 - </div> 193 - <div id="stopwatch-controls"> 194 - <button id="start-button" class="primary">Start</button> 195 - <button id="reset-button">Reset</button> 196 - </div> 197 - <div id="laps-container"></div> 198 - ` 199 - } 200 - 201 - private bindEvents(): void { 202 - this.startButton?.addEventListener('click', () => { 203 - const { isStarted, isPaused } = globalStopwatch.state 204 - if (isStarted && !isPaused) { 205 - globalStopwatch.pause() 206 - void app.setActiveSession(null) 207 - } else { 208 - void startStopwatch() 209 - } 210 - }) 211 - 212 - this.resetButton?.addEventListener('click', () => { 213 - globalStopwatch.reset() 214 - void app.setActiveSession(null) 215 - }) 216 - } 217 - 218 - private updateUI(state: StopwatchState): void { 219 - const isRunning = state.isStarted && !state.isPaused 220 - 221 - if (this.timeElement) { 222 - this.timeElement.textContent = state.display 223 - } 224 - 225 - if (this.restElement) { 226 - const showRest = state.isStarted 227 - this.restElement.hidden = !showRest 228 - if (showRest) { 229 - const valueEl = this.restElement.querySelector('.stopwatch-rest-value') 230 - if (valueEl) { 231 - valueEl.textContent = formatStopwatchShort(getRestTime(state)) 232 - } 233 - } 234 - } 235 - 236 - if (this.startButton) { 237 - this.startButton.textContent = isRunning ? 'Pause' : 'Start' 238 - } 239 - 240 - this.renderLaps(state.laps) 241 - } 242 - 243 - private renderLaps(laps: StopwatchState['laps']): void { 244 - if (!this.lapsContainer) return 245 - 246 - if (laps.length === 0) { 247 - this.lapsContainer.innerHTML = '' 248 - return 249 - } 250 - 251 - const lapsHTML = laps 252 - .map((lap, index) => { 253 - const lapNumber = laps.length - index 254 - return ` 255 - <div class="lap-item"> 256 - <span class="lap-number">Attempt ${lapNumber}</span> 257 - <span class="lap-time">${lap.splitDisplay}</span> 258 - </div> 259 - ` 260 - }) 261 - .reverse() 262 - .join('') 263 - 264 - this.lapsContainer.innerHTML = lapsHTML 265 - } 266 - } 267 - 268 - customElements.define('stopwatch-page', StopwatchPage)
+124 -2
www/static/theme.css
··· 286 286 margin: 0 auto; 287 287 } 288 288 289 - stopwatch-page { 289 + sessions-page, 290 + session-page { 290 291 display: flex; 291 292 flex-direction: column; 292 - align-items: center; 293 293 padding: var(--s4) var(--s3); 294 294 gap: var(--s4); 295 295 max-width: 600px; 296 296 margin: 0 auto; 297 + } 298 + 299 + .active-session-card { 300 + display: flex; 301 + flex-direction: column; 302 + align-items: center; 303 + gap: var(--s4); 304 + padding: var(--s4) var(--s3); 305 + border: 1px solid var(--border); 306 + border-radius: var(--br-lg); 307 + } 308 + 309 + .sessions-history { 310 + display: flex; 311 + flex-direction: column; 312 + gap: var(--s3); 313 + } 314 + 315 + .sessions-history-title { 316 + font-size: var(--f5); 317 + font-weight: var(--fw-semibold); 318 + margin: 0; 319 + } 320 + 321 + .sessions-list { 322 + display: flex; 323 + flex-direction: column; 324 + gap: var(--s2); 325 + } 326 + 327 + .session-row { 328 + display: flex; 329 + flex-direction: column; 330 + gap: var(--s1); 331 + padding: var(--s3); 332 + text-align: left; 333 + background: transparent; 334 + border: 1px solid var(--border); 335 + border-radius: var(--br); 336 + cursor: pointer; 337 + } 338 + 339 + .session-row-main { 340 + display: flex; 341 + justify-content: space-between; 342 + align-items: center; 343 + font-weight: var(--fw-semibold); 344 + } 345 + 346 + .session-row-meta { 347 + display: flex; 348 + gap: var(--s3); 349 + font-size: var(--f6); 350 + color: var(--muted); 351 + } 352 + 353 + #sessions-load-more { 354 + margin-top: var(--s2); 355 + } 356 + 357 + .session-detail-header { 358 + display: flex; 359 + flex-direction: column; 360 + gap: var(--s2); 361 + } 362 + 363 + .session-detail-row { 364 + display: flex; 365 + align-items: center; 366 + gap: var(--s2); 367 + } 368 + 369 + .session-detail-meta { 370 + display: flex; 371 + flex-wrap: wrap; 372 + gap: var(--s2); 373 + font-size: var(--f6); 374 + color: var(--muted); 375 + } 376 + 377 + .session-attempts-list { 378 + list-style: none; 379 + margin: 0; 380 + padding: 0; 381 + display: flex; 382 + flex-direction: column; 383 + gap: var(--s2); 384 + } 385 + 386 + .session-attempt { 387 + display: flex; 388 + flex-direction: column; 389 + gap: var(--s1); 390 + padding: var(--s3); 391 + border: 1px solid var(--border); 392 + border-radius: var(--br); 393 + } 394 + 395 + .session-attempt-row { 396 + display: flex; 397 + align-items: center; 398 + gap: var(--s2); 399 + } 400 + 401 + .session-attempt-name { 402 + font-weight: var(--fw-semibold); 403 + flex: 1; 404 + } 405 + 406 + .session-attempt-meta { 407 + display: flex; 408 + gap: var(--s2); 409 + font-size: var(--f6); 410 + color: var(--muted); 411 + } 412 + 413 + .session-notes { 414 + margin-top: var(--s3); 415 + padding: var(--s3); 416 + background: var(--surface); 417 + border-radius: var(--br); 418 + white-space: pre-wrap; 297 419 } 298 420 299 421 /* ── Grade range filter row ────────────────────────────────────────────── */
+26
www/utils/format.ts
··· 25 25 }` 26 26 } 27 27 28 + export function formatDuration(ms: number): string { 29 + if (ms <= 0) return '0m' 30 + const totalMinutes = Math.floor(ms / 60000) 31 + const hours = Math.floor(totalMinutes / 60) 32 + const minutes = totalMinutes % 60 33 + if (hours === 0) return `${minutes}m` 34 + return `${hours}h ${minutes}m` 35 + } 36 + 37 + export function formatDateTime(iso: string): string { 38 + return new Date(iso).toLocaleString('en-US', { 39 + month: 'short', 40 + day: 'numeric', 41 + year: 'numeric', 42 + hour: 'numeric', 43 + minute: '2-digit', 44 + }) 45 + } 46 + 47 + export function formatTimeOfDay(iso: string): string { 48 + return new Date(iso).toLocaleTimeString('en-US', { 49 + hour: 'numeric', 50 + minute: '2-digit', 51 + }) 52 + } 53 + 28 54 export function formatStopwatchShort(ms: number): string { 29 55 const totalSeconds = Math.floor(ms / 1000) 30 56 const hours = Math.floor(totalSeconds / 3600)