An app for logging board climbs
0
fork

Configure Feed

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

feat: add ui-calendar

+306 -214
+144
www/components/ui-calendar.ts
··· 1 + import { html, LitElement, type TemplateResult } from 'lit' 2 + 3 + export interface CalendarCell { 4 + date: Date 5 + key: string 6 + count: number 7 + level: 0 | 1 | 2 | 3 | 4 8 + future: boolean 9 + } 10 + 11 + export class UiCalendar extends LitElement { 12 + weeks = 53 13 + selected: string | null = null 14 + 15 + private _activity: Map<string, number> = new Map() 16 + 17 + set activity(value: Map<string, number>) { 18 + this._activity = value ?? new Map() 19 + this.requestUpdate() 20 + } 21 + 22 + get activity(): Map<string, number> { 23 + return this._activity 24 + } 25 + 26 + protected override createRenderRoot() { 27 + return this 28 + } 29 + 30 + #dayKey(d: Date): string { 31 + return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${ 32 + String(d.getDate()).padStart(2, '0') 33 + }` 34 + } 35 + 36 + #monthName(m: number): string { 37 + return new Date(2000, m, 1).toLocaleDateString('en-US', { month: 'short' }) 38 + } 39 + 40 + #cellTitle(cell: CalendarCell): string { 41 + const date = cell.date.toLocaleDateString('en-US', { 42 + month: 'short', 43 + day: 'numeric', 44 + year: 'numeric', 45 + }) 46 + if (cell.future) return date 47 + const noun = cell.count === 1 ? 'attempt' : 'attempts' 48 + return `${date} · ${cell.count} ${noun}` 49 + } 50 + 51 + #buildCalendar(): { 52 + weeks: CalendarCell[][] 53 + monthLabels: (string | null)[] 54 + } { 55 + const today = new Date() 56 + today.setHours(0, 0, 0, 0) 57 + const startOfCurrentWeek = new Date(today) 58 + startOfCurrentWeek.setDate(today.getDate() - today.getDay()) 59 + const start = new Date(startOfCurrentWeek) 60 + start.setDate(start.getDate() - (this.weeks - 1) * 7) 61 + 62 + let max = 0 63 + for (const v of this._activity.values()) if (v > max) max = v 64 + const step = Math.max(1, Math.ceil(max / 4)) 65 + const levelFor = (count: number): 0 | 1 | 2 | 3 | 4 => { 66 + if (count <= 0) return 0 67 + return Math.min(4, Math.ceil(count / step)) as 1 | 2 | 3 | 4 68 + } 69 + 70 + const weekCols: CalendarCell[][] = [] 71 + const monthLabels: (string | null)[] = [] 72 + let prevMonth = -1 73 + for (let w = 0; w < this.weeks; w++) { 74 + const col: CalendarCell[] = [] 75 + for (let d = 0; d < 7; d++) { 76 + const date = new Date(start) 77 + date.setDate(start.getDate() + w * 7 + d) 78 + const key = this.#dayKey(date) 79 + const count = this._activity.get(key) ?? 0 80 + col.push({ 81 + date, 82 + key, 83 + count, 84 + level: levelFor(count), 85 + future: date.getTime() > today.getTime(), 86 + }) 87 + } 88 + weekCols.push(col) 89 + const firstOfWeek = col[0].date 90 + const month = firstOfWeek.getMonth() 91 + monthLabels.push(month !== prevMonth ? this.#monthName(month) : null) 92 + prevMonth = month 93 + } 94 + return { weeks: weekCols, monthLabels } 95 + } 96 + 97 + #handleClick(cell: CalendarCell): void { 98 + if (cell.future || cell.count === 0) return 99 + this.selected = this.selected === cell.key ? null : cell.key 100 + this.dispatchEvent( 101 + new CustomEvent('select', { 102 + detail: { selected: this.selected }, 103 + bubbles: true, 104 + }), 105 + ) 106 + } 107 + 108 + override render(): TemplateResult { 109 + const { weeks, monthLabels } = this.#buildCalendar() 110 + return html` 111 + <ui-calendar-months 112 + style="grid-template-columns: repeat(${weeks.length}, 1fr);" 113 + > 114 + ${monthLabels.map((l) => 115 + html` 116 + <span>${l ?? ''}</span> 117 + ` 118 + )} 119 + </ui-calendar-months> 120 + <ui-calendar-grid 121 + style="grid-template-columns: repeat(${weeks.length}, 1fr);" 122 + > 123 + ${weeks.map((col) => 124 + col.map((cell) => { 125 + const isSelected = this.selected === cell.key 126 + return html` 127 + <div 128 + class="cal-cell" 129 + data-level="${cell.level}" 130 + ?data-future="${cell.future}" 131 + ?data-selected="${isSelected}" 132 + title="${this.#cellTitle(cell)}" 133 + @click="${() => this.#handleClick(cell)}" 134 + > 135 + </div> 136 + ` 137 + }) 138 + )} 139 + </ui-calendar-grid> 140 + ` 141 + } 142 + } 143 + 144 + customElements.define('ui-calendar', UiCalendar)
+58 -6
www/routes/sessions.ts
··· 1 1 import { html, LitElement, type TemplateResult } from 'lit' 2 2 import Stopwatch, { type StopwatchState } from '@inro/simple-tools/stopwatch' 3 3 import { ulid } from '@std/ulid' 4 + import '../components/ui-calendar.ts' 4 5 import { 5 6 formatDate, 6 7 formatDuration, ··· 10 11 import { findClimb, gradeLabel } from '../utils/climbs.ts' 11 12 import { type ClimbAttempt, type Session } from '../models/schema.ts' 12 13 import app from '../models/app.ts' 14 + 15 + function dayKey(d: Date): string { 16 + return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${ 17 + String(d.getDate()).padStart(2, '0') 18 + }` 19 + } 20 + 21 + function attemptsByDay(): Map<string, number> { 22 + const m = new Map<string, number>() 23 + for (const session of Object.values(app.sessions)) { 24 + for (const a of session.attempts) { 25 + const key = dayKey(new Date(a.timestamp)) 26 + m.set(key, (m.get(key) ?? 0) + a.attempts) 27 + } 28 + } 29 + return m 30 + } 13 31 14 32 function renderAttemptRow( 15 33 a: ClimbAttempt, ··· 205 223 private completeButton: HTMLButtonElement | null = null 206 224 private removeListener: (() => void) | null = null 207 225 private prevActiveId: string | null = null 226 + private selectedDate: string | null = null 208 227 209 228 protected override createRenderRoot() { 210 229 return this ··· 316 335 317 336 private pastSessions(): Session[] { 318 337 const activeId = app.preferences.activeSessionId 319 - return Object.values(app.sessions) 320 - .filter((s) => s.id !== activeId && s.attempts.length > 0) 321 - .sort( 322 - (a, b) => new Date(b.start).getTime() - new Date(a.start).getTime(), 323 - ) 338 + let sessions = Object.values(app.sessions).filter( 339 + (s) => s.id !== activeId && s.attempts.length > 0, 340 + ) 341 + if (this.selectedDate) { 342 + const [year, month, day] = this.selectedDate.split('-').map(Number) 343 + sessions = sessions.filter((s) => { 344 + const d = new Date(s.start) 345 + return d.getFullYear() === year && 346 + d.getMonth() === month - 1 && 347 + d.getDate() === day 348 + }) 349 + } 350 + return sessions.sort( 351 + (a, b) => new Date(b.start).getTime() - new Date(a.start).getTime(), 352 + ) 324 353 } 325 354 326 355 override render(): TemplateResult { ··· 356 385 : ''} 357 386 </section> 358 387 388 + <section class="sessions-section"> 389 + <h2 class="sessions-section-title">Activity (past year)</h2> 390 + ${this.renderCalendar()} 391 + </section> 392 + 359 393 <section class="sessions-history"> 360 - <h2 class="sessions-history-title">Past sessions</h2> 394 + <h2 class="sessions-history-title">Past sessions${this.selectedDate 395 + ? ` (${this.selectedDate})` 396 + : ''}</h2> 361 397 ${past.length === 0 362 398 ? html` 363 399 <p class="empty-message"> ··· 377 413 `} 378 414 </section> 379 415 ` 416 + } 417 + 418 + private renderCalendar(): TemplateResult { 419 + return html` 420 + <ui-calendar 421 + .activity="${attemptsByDay()}" 422 + .selected="${this.selectedDate}" 423 + @select="${this.onCalendarSelect}" 424 + ></ui-calendar> 425 + ` 426 + } 427 + 428 + private onCalendarSelect = (e: Event) => { 429 + const custom = e as CustomEvent<{ selected: string | null }> 430 + this.selectedDate = custom.detail.selected 431 + this.requestUpdate() 380 432 } 381 433 382 434 private renderSessionRow(s: Session): TemplateResult {
+76 -76
www/routes/settings.ts
··· 89 89 > 90 90 </a> 91 91 </nav> 92 - </section> 93 - <section> 94 - <h2>Board Setup</h2> 95 - <p>Select which Board setup you are using.</p> 96 - <div role="radiogroup" aria-label="Board setup"> 97 - ${BOARD_OPTIONS.map((opt) => { 98 - const key = optionKey(opt.boardId, opt.angle) 99 - return html` 100 - <label> 101 - <input 102 - type="radio" 103 - name="board-angle" 104 - value="${key}" 105 - ?checked="${this.selectedKey === key}" 106 - @change="${() => 107 - app.updatePreferences({ 108 - boardId: opt.boardId, 109 - angle: opt.angle, 110 - })}" 111 - > 112 - <span>${opt.label}</span> 113 - </label> 114 - ` 115 - })} 116 - </div> 117 - </section> 92 + </section> 93 + <section> 94 + <h2>Board Setup</h2> 95 + <p>Select which Board setup you are using.</p> 96 + <div role="radiogroup" aria-label="Board setup"> 97 + ${BOARD_OPTIONS.map((opt) => { 98 + const key = optionKey(opt.boardId, opt.angle) 99 + return html` 100 + <label> 101 + <input 102 + type="radio" 103 + name="board-angle" 104 + value="${key}" 105 + ?checked="${this.selectedKey === key}" 106 + @change="${() => 107 + app.updatePreferences({ 108 + boardId: opt.boardId, 109 + angle: opt.angle, 110 + })}" 111 + > 112 + <span>${opt.label}</span> 113 + </label> 114 + ` 115 + })} 116 + </div> 117 + </section> 118 118 119 - <section> 120 - <h2>Grade Scale</h2> 121 - <p>Choose how grades are displayed throughout the app.</p> 122 - <div role="radiogroup" aria-label="Grade scale"> 123 - ${GRADE_SCALE_OPTIONS.map((opt) => 124 - html` 125 - <label> 126 - <input 127 - type="radio" 128 - name="grade_scale" 129 - value="${opt.value}" 130 - ?checked="${this.gradeScale === opt.value}" 131 - @change="${() => 132 - app.updatePreferences({ 133 - gradeScale: opt.value as GradeScale, 134 - })}" 135 - > 136 - <div> 137 - <span>${opt.label}</span> 138 - <small>${opt.example}</small> 139 - </div> 140 - </label> 141 - ` 142 - )} 143 - </div> 144 - </section> 119 + <section> 120 + <h2>Grade Scale</h2> 121 + <p>Choose how grades are displayed throughout the app.</p> 122 + <div role="radiogroup" aria-label="Grade scale"> 123 + ${GRADE_SCALE_OPTIONS.map((opt) => 124 + html` 125 + <label> 126 + <input 127 + type="radio" 128 + name="grade_scale" 129 + value="${opt.value}" 130 + ?checked="${this.gradeScale === opt.value}" 131 + @change="${() => 132 + app.updatePreferences({ 133 + gradeScale: opt.value as GradeScale, 134 + })}" 135 + > 136 + <div> 137 + <span>${opt.label}</span> 138 + <small>${opt.example}</small> 139 + </div> 140 + </label> 141 + ` 142 + )} 143 + </div> 144 + </section> 145 145 146 - <section> 147 - <h2>Sync</h2> 148 - <p> 149 - Sync your progress across devices. Connect to a Civility server and 150 - authenticate with an API token from the server dashboard. 151 - </p> 152 - <ui-sync 153 - storage-key="moonboard-sync" 154 - .synced="${app.synced}" 155 - ></ui-sync> 156 - </section> 146 + <section> 147 + <h2>Sync</h2> 148 + <p> 149 + Sync your progress across devices. Connect to a Civility server and 150 + authenticate with an API token from the server dashboard. 151 + </p> 152 + <ui-sync 153 + storage-key="moonboard-sync" 154 + .synced="${app.synced}" 155 + ></ui-sync> 156 + </section> 157 157 158 - <section> 159 - <h2>Data</h2> 160 - <ui-data-actions 161 - .methods="${{ 162 - exportData: () => app.exportStore(), 163 - importData: () => app.importStore(), 164 - deleteAllData: () => app.deleteAllData(), 165 - } as UiDataActionMethods}" 166 - ></ui-data-actions> 167 - </section> 168 - ` 169 - } 158 + <section> 159 + <h2>Data</h2> 160 + <ui-data-actions 161 + .methods="${{ 162 + exportData: () => app.exportStore(), 163 + importData: () => app.importStore(), 164 + deleteAllData: () => app.deleteAllData(), 165 + } as UiDataActionMethods}" 166 + ></ui-data-actions> 167 + </section> 168 + ` 170 169 } 170 + } 171 171 172 - customElements.define('settings-page', SettingsPage) 172 + customElements.define('settings-page', SettingsPage)
-128
www/routes/stats.ts
··· 141 141 return `hsl(${hue}, 65%, 50%)` 142 142 } 143 143 144 - function dayKey(d: Date): string { 145 - return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${ 146 - String(d.getDate()).padStart(2, '0') 147 - }` 148 - } 149 - 150 - function attemptsByDay(): Map<string, number> { 151 - const m = new Map<string, number>() 152 - for (const session of Object.values(app.sessions)) { 153 - for (const a of session.attempts) { 154 - const key = dayKey(new Date(a.timestamp)) 155 - m.set(key, (m.get(key) ?? 0) + a.attempts) 156 - } 157 - } 158 - return m 159 - } 160 - 161 - interface CalendarCell { 162 - date: Date 163 - key: string 164 - count: number 165 - level: 0 | 1 | 2 | 3 | 4 166 - future: boolean 167 - } 168 - 169 - function buildCalendar( 170 - weeks: number, 171 - ): { weeks: CalendarCell[][]; monthLabels: (string | null)[] } { 172 - const today = new Date() 173 - today.setHours(0, 0, 0, 0) 174 - const startOfCurrentWeek = new Date(today) 175 - startOfCurrentWeek.setDate(today.getDate() - today.getDay()) 176 - const start = new Date(startOfCurrentWeek) 177 - start.setDate(start.getDate() - (weeks - 1) * 7) 178 - 179 - const activity = attemptsByDay() 180 - let max = 0 181 - for (const v of activity.values()) if (v > max) max = v 182 - const step = Math.max(1, Math.ceil(max / 4)) 183 - const levelFor = (count: number): 0 | 1 | 2 | 3 | 4 => { 184 - if (count <= 0) return 0 185 - return Math.min(4, Math.ceil(count / step)) as 1 | 2 | 3 | 4 186 - } 187 - 188 - const weekCols: CalendarCell[][] = [] 189 - const monthLabels: (string | null)[] = [] 190 - let prevMonth = -1 191 - for (let w = 0; w < weeks; w++) { 192 - const col: CalendarCell[] = [] 193 - for (let d = 0; d < 7; d++) { 194 - const date = new Date(start) 195 - date.setDate(start.getDate() + w * 7 + d) 196 - const key = dayKey(date) 197 - const count = activity.get(key) ?? 0 198 - col.push({ 199 - date, 200 - key, 201 - count, 202 - level: levelFor(count), 203 - future: date.getTime() > today.getTime(), 204 - }) 205 - } 206 - weekCols.push(col) 207 - const firstOfWeek = col[0].date 208 - const month = firstOfWeek.getMonth() 209 - monthLabels.push(month !== prevMonth ? monthName(month) : null) 210 - prevMonth = month 211 - } 212 - return { weeks: weekCols, monthLabels } 213 - } 214 - 215 - function monthName(m: number): string { 216 - return new Date(2000, m, 1).toLocaleDateString('en-US', { month: 'short' }) 217 - } 218 - 219 - function cellTitle(cell: CalendarCell): string { 220 - const date = cell.date.toLocaleDateString('en-US', { 221 - month: 'short', 222 - day: 'numeric', 223 - year: 'numeric', 224 - }) 225 - if (cell.future) return date 226 - const noun = cell.count === 1 ? 'attempt' : 'attempts' 227 - return `${date} · ${cell.count} ${noun}` 228 - } 229 - 230 144 export class StatsPage extends LitElement { 231 145 private range: Range = 'month' 232 146 private gradeScale: GradeScale = GradeScale.enum.french ··· 369 283 ) 370 284 371 285 return html` 372 - <section class="stats-section"> 373 - <h2 class="stats-section-title">Activity (past year)</h2> 374 - ${this.renderCalendar()} 375 - </section> 376 - 377 286 <ui-button-group id="stats-range"> 378 287 ${unsafeHTML(this.rangeHtml())} 379 288 </ui-button-group> ··· 415 324 `} 416 325 </div> 417 326 </section> 418 - ` 419 - } 420 - 421 - private renderCalendar(): TemplateResult { 422 - const { weeks, monthLabels } = buildCalendar(53) 423 - return html` 424 - <div class="cal"> 425 - <div 426 - class="cal-months" 427 - style="grid-template-columns: repeat(${weeks 428 - .length}, 1fr);" 429 - > 430 - ${monthLabels.map((l) => 431 - html` 432 - <span>${l ?? ''}</span> 433 - ` 434 - )} 435 - </div> 436 - <div 437 - class="cal-grid" 438 - style="grid-template-columns: repeat(${weeks.length}, 1fr);" 439 - > 440 - ${weeks.map((col) => 441 - col.map((cell) => 442 - html` 443 - <div 444 - class="cal-cell" 445 - data-level="${cell.level}" 446 - ?data-future="${cell.future}" 447 - title="${cellTitle(cell)}" 448 - > 449 - </div> 450 - ` 451 - ) 452 - )} 453 - </div> 454 - </div> 455 327 ` 456 328 } 457 329
+28 -4
www/static/theme.css
··· 315 315 margin-bottom: var(--s5); 316 316 } 317 317 318 - settings-about-page section>p { 318 + settings-about-page section > p { 319 319 opacity: 0.6; 320 320 margin-bottom: var(--s3); 321 321 } ··· 415 415 margin: 0; 416 416 } 417 417 418 + ui-calendar, 418 419 .cal { 419 420 display: flex; 420 421 flex-direction: column; ··· 422 423 overflow-x: auto; 423 424 } 424 425 426 + ui-calendar-months, 425 427 .cal-months, 428 + ui-calendar-grid, 426 429 .cal-grid { 427 430 display: grid; 428 - gap: 2px; 431 + gap: 1px; 429 432 min-width: 560px; 433 + padding: 5px; 430 434 } 431 435 432 436 .cal-months { ··· 444 448 } 445 449 446 450 .cal-cell { 451 + width: 100%; 452 + aspect-ratio: 1; 447 453 border-radius: 2px; 448 454 background: hsla(var(--bodyH), var(--bodyS), var(--bodyL), 0.08); 455 + cursor: pointer; 456 + user-select: none; 457 + } 458 + 459 + .cal-cell[data-level='0'] { 460 + cursor: default; 449 461 } 450 462 451 463 .cal-cell[data-level='1'] { ··· 468 480 visibility: hidden; 469 481 } 470 482 483 + .cal-cell[data-selected] { 484 + outline: 2px solid var(--primary); 485 + outline-offset: 1px; 486 + } 487 + 471 488 .active-session-card { 472 489 display: flex; 473 490 flex-direction: column; ··· 484 501 gap: var(--s3); 485 502 } 486 503 487 - .sessions-history-title { 504 + .sessions-history-title, 505 + .sessions-section-title { 488 506 font-size: var(--f5); 489 507 font-weight: var(--fw-semibold); 490 508 margin: 0; 509 + } 510 + 511 + .sessions-section { 512 + display: flex; 513 + flex-direction: column; 514 + gap: var(--s2); 491 515 } 492 516 493 517 .sessions-list { ··· 897 921 margin-bottom: var(--s5); 898 922 } 899 923 900 - settings-page section>p { 924 + settings-page section > p { 901 925 opacity: 0.6; 902 926 margin-bottom: var(--s3); 903 927 }