An app for logging board climbs
0
fork

Configure Feed

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

feat: move to be more board-agnostic

+321 -366
+4 -4
deno.json
··· 17 17 }, 18 18 "imports": { 19 19 "@civility/blobs": "jsr:@civility/blobs@^1.0.0-beta.4", 20 - "@civility/store": "jsr:@civility/store@^1.0.0-beta.6", 21 - "@civility/store/idb": "jsr:@civility/store@^1.0.0-beta.6/idb", 22 - "@civility/sync": "jsr:@civility/sync@^1.0.0-beta.7", 23 - "@civility/ui": "jsr:@civility/ui@^1.0.0-beta.1", 20 + "@civility/store": "jsr:@civility/store@^1.0.0-beta.8", 21 + "@civility/store/idb": "jsr:@civility/store@^1.0.0-beta.8/idb", 22 + "@civility/sync": "jsr:@civility/sync@^1.0.0-beta.10", 23 + "@civility/ui": "jsr:@civility/ui@^1.0.0-beta.3", 24 24 "@civility/workers": "jsr:@civility/workers@^0.2.5", 25 25 "@inro/simple-tools": "jsr:@inro/simple-tools@^0.5.2", 26 26 "@std/assert": "jsr:@std/assert@^1.0.19",
+12 -17
deno.lock
··· 2 2 "version": "5", 3 3 "specifiers": { 4 4 "jsr:@civility/blobs@^1.0.0-beta.4": "1.0.0-beta.4", 5 - "jsr:@civility/store@^1.0.0-beta.6": "1.0.0-beta.6", 6 - "jsr:@civility/sync@^1.0.0-beta.7": "1.0.0-beta.7", 7 - "jsr:@civility/ui@^1.0.0-beta.1": "1.0.0-beta.2", 5 + "jsr:@civility/store@^1.0.0-beta.8": "1.0.0-beta.8", 6 + "jsr:@civility/sync@^1.0.0-beta.10": "1.0.0-beta.10", 7 + "jsr:@civility/ui@^1.0.0-beta.3": "1.0.0-beta.3", 8 8 "jsr:@civility/workers@~0.2.5": "0.2.5", 9 9 "jsr:@inro/simple-tools@~0.5.2": "0.5.2", 10 10 "jsr:@paulmillr/qr@~0.5.5": "0.5.5", 11 11 "jsr:@std/assert@^1.0.17": "1.0.19", 12 12 "jsr:@std/assert@^1.0.19": "1.0.19", 13 - "jsr:@std/async@^1.1.0": "1.2.0", 14 13 "jsr:@std/collections@^1.1.0": "1.1.6", 15 14 "jsr:@std/data-structures@^1.0.10": "1.0.10", 16 15 "jsr:@std/dotenv@~0.225.6": "0.225.6", ··· 34 33 "@civility/blobs@1.0.0-beta.4": { 35 34 "integrity": "6806eb2a5b02e9e611385107b539abe0b2fe8e17066cfc42eaf467e301a6afa0" 36 35 }, 37 - "@civility/store@1.0.0-beta.6": { 38 - "integrity": "92226d6e669fd90da7dac1da9f60c63587fb194df1d59a34791f3b827c000383", 36 + "@civility/store@1.0.0-beta.8": { 37 + "integrity": "97d24ab2100fd2dbc5665e45abc1d0de68c81f5b717ddc95313c6a1502eb0546", 39 38 "dependencies": [ 40 39 "jsr:@std/fs@^1.0.23", 41 40 "jsr:@std/path", ··· 44 43 "npm:fast-json-patch" 45 44 ] 46 45 }, 47 - "@civility/sync@1.0.0-beta.7": { 48 - "integrity": "2997902549d7fbe6810c208efa8cd79c852192ca1c36cad90dc6fc1f27a6cf47", 46 + "@civility/sync@1.0.0-beta.10": { 47 + "integrity": "15345da12c2b3d7f83e31626350dd066561a5cac86cb317f094c8ede73872183", 49 48 "dependencies": [ 50 49 "jsr:@civility/blobs", 51 50 "jsr:@civility/store", 52 51 "jsr:@paulmillr/qr" 53 52 ] 54 53 }, 55 - "@civility/ui@1.0.0-beta.2": { 56 - "integrity": "4cdef14beafa95ca418cdd14f5f9d54551bd5c8f9f84517438fd848133be340e", 54 + "@civility/ui@1.0.0-beta.3": { 55 + "integrity": "4c35d660ff511ef45d824ac1941928b1fc099930a9764f81ce7cb9c416df9588", 57 56 "dependencies": [ 58 57 "jsr:@std/html", 59 58 "npm:lit" ··· 79 78 "dependencies": [ 80 79 "jsr:@std/internal" 81 80 ] 82 - }, 83 - "@std/async@1.2.0": { 84 - "integrity": "c059c6f6d95ca7cc012ae8e8d7164d1697113d54b0b679e4372b354b11c2dee5" 85 81 }, 86 82 "@std/collections@1.1.6": { 87 83 "integrity": "b458160ce65ea5ad35da05d0a5cbee4b583677c8b443a10d7beb0c4ac63f2baa" ··· 117 113 "integrity": "87bdc2700fa98249d48a17cd72413352d3d3680dcfbdb64947fd0982d6bbf681", 118 114 "dependencies": [ 119 115 "jsr:@std/assert@^1.0.17", 120 - "jsr:@std/async", 121 116 "jsr:@std/data-structures", 122 117 "jsr:@std/fs@^1.0.22", 123 118 "jsr:@std/internal", ··· 188 183 "workspace": { 189 184 "dependencies": [ 190 185 "jsr:@civility/blobs@^1.0.0-beta.4", 191 - "jsr:@civility/store@^1.0.0-beta.6", 192 - "jsr:@civility/sync@^1.0.0-beta.7", 193 - "jsr:@civility/ui@^1.0.0-beta.1", 186 + "jsr:@civility/store@^1.0.0-beta.8", 187 + "jsr:@civility/sync@^1.0.0-beta.10", 188 + "jsr:@civility/ui@^1.0.0-beta.3", 194 189 "jsr:@civility/workers@~0.2.5", 195 190 "jsr:@inro/simple-tools@~0.5.2", 196 191 "jsr:@std/assert@^1.0.19",
+7 -10
www/models/app.ts
··· 5 5 type ClimbAttempt, 6 6 defaultAppSettings, 7 7 type Progress, 8 - progressKey, 9 8 Session, 10 9 } from './schema.ts' 11 10 import { isV0Settings, migrateSettingsV0toV1 } from './migrations/v1.ts' ··· 14 13 import { ulid } from '@std/ulid' 15 14 16 15 export interface NavState { 17 - climbId: string 18 - boardId: string 16 + id: string 19 17 climb?: Climb 20 18 filteredIds?: string[] 21 19 currentIndex?: number ··· 119 117 return this.store.progress 120 118 } 121 119 122 - getProgress(boardId: string, climbId: string): Progress | null { 123 - return this.store.progress[progressKey(boardId, climbId)] ?? null 120 + getProgress(id: string): Progress | null { 121 + return this.store.progress[id] ?? null 124 122 } 125 123 126 124 // ====== SESSIONS ====== ··· 172 170 173 171 const attempt: ClimbAttempt = { 174 172 climbId: climb.id, 175 - boardId: climb.boardId, 176 173 attempts: input.attempts, 177 174 sent: input.sent, 178 175 timestamp: now, ··· 182 179 attempts: [...session.attempts, attempt], 183 180 }) 184 181 185 - const key = progressKey(climb.boardId, climb.id) 186 - const existing = await this.store.getProgressById(key) 182 + const existing = await this.store.getProgressById(climb.id) 187 183 188 184 const totalAttempts = (existing?.totalAttempts ?? 0) + input.attempts 189 185 const sent = existing?.sent ?? ··· 195 191 const firstAttempted = existing?.firstAttempted ?? now 196 192 197 193 const next: Progress = { 198 - climbId: climb.id, 194 + id: climb.id, 199 195 boardId: climb.boardId, 196 + angle: climb.angle, 200 197 name: climb.name, 201 198 grade: climb.grade, 202 199 setter: climb.setter, ··· 207 204 firstAttempted, 208 205 lastAttempted: now, 209 206 } 210 - await this.store.saveProgress(key, next) 207 + await this.store.saveProgress(climb.id, next) 211 208 this.notify() 212 209 return next 213 210 }
+34 -16
www/models/migrations/v1.ts
··· 4 4 } from '../schema/v0.ts' 5 5 import type { AppSettings as V1Settings, Progress } from '../schema/v1.ts' 6 6 7 - export const MB_TYPE_TO_BOARD_TYPE: Record<number, string> = { 8 - 0: 'mb2016-40', 9 - 1: 'mb2017-25', 10 - 2: 'mb2017-40', 11 - 3: 'mb2019-25', 12 - 4: 'mb2019-40', 13 - 5: 'mb2020-40', 14 - 6: 'mb2024-25', 15 - 7: 'mb2024-40', 16 - 8: 'mb2025-40', 7 + export const MB_TYPE_TO_BOARD_ANGLE: Record< 8 + number, 9 + { boardId: string; angle: number } 10 + > = { 11 + 0: { boardId: 'mb2016', angle: 40 }, 12 + 1: { boardId: 'mb2017', angle: 25 }, 13 + 2: { boardId: 'mb2017', angle: 40 }, 14 + 3: { boardId: 'mb2019', angle: 25 }, 15 + 4: { boardId: 'mb2019', angle: 40 }, 16 + 5: { boardId: 'mb2020', angle: 40 }, 17 + 6: { boardId: 'mb2024', angle: 25 }, 18 + 7: { boardId: 'mb2024', angle: 40 }, 19 + 8: { boardId: 'mb2025', angle: 40 }, 17 20 } 18 21 19 - export const DEFAULT_BOARD_TYPE = 'mb2019-40' 22 + export const DEFAULT_BOARD_ANGLE = { boardId: 'mb2019', angle: 40 } 20 23 21 - export function boardIdFor(mbType: number): string { 22 - return MB_TYPE_TO_BOARD_TYPE[mbType] ?? DEFAULT_BOARD_TYPE 24 + export function boardAngleFor( 25 + mbType: number, 26 + ): { boardId: string; angle: number } { 27 + return MB_TYPE_TO_BOARD_ANGLE[mbType] ?? DEFAULT_BOARD_ANGLE 28 + } 29 + 30 + export function climbIdFor( 31 + boardId: string, 32 + angle: number, 33 + climbId: string | number, 34 + ): string { 35 + return `${boardId}:${angle}:${climbId}` 23 36 } 24 37 25 38 export function migrateRating(r: number | null): number | null { ··· 28 41 } 29 42 30 43 export function migrateLogbookEntryToProgress(entry: V0Entry): Progress { 44 + const { boardId, angle } = boardAngleFor(entry.mbType) 45 + const id = climbIdFor(boardId, angle, entry.climbId) 31 46 const sessions = entry.sessions ?? [] 32 47 33 48 const sentDates = sessions ··· 41 56 const lastAttempted = dates[dates.length - 1] ?? entry.lastAttempted 42 57 43 58 return { 44 - climbId: String(entry.climbId), 45 - boardId: boardIdFor(entry.mbType), 59 + id, 60 + boardId, 61 + angle, 46 62 name: entry.name, 47 63 grade: entry.grade, 48 64 setter: entry.setter, ··· 56 72 } 57 73 58 74 export function migrateSettingsV0toV1(old: V0Settings): V1Settings { 75 + const { boardId, angle } = boardAngleFor(old.mbType) 59 76 return { 60 77 syncLinkUrl: old.syncLinkUrl, 61 - boardId: boardIdFor(old.mbType), 78 + boardId, 79 + angle, 62 80 gradeScale: old.gradeScale, 63 81 homeGradeMin: old.homeGradeMin, 64 82 homeGradeMax: old.homeGradeMax,
-2
www/models/schema.ts
··· 12 12 defaultAppSettings, 13 13 GradeScale, 14 14 LogFilter, 15 - parseProgressKey, 16 15 Progress, 17 - progressKey, 18 16 Session, 19 17 } from './schema/v1.ts' 20 18
+5 -15
www/models/schema/v1.ts
··· 4 4 export const Climb = z.object({ 5 5 id: z.string(), 6 6 boardId: z.string(), 7 + angle: z.number().int(), 7 8 name: z.string(), 8 9 setter: z.string(), 9 10 grade: z.number().int(), ··· 20 21 21 22 export const ClimbAttempt = z.object({ 22 23 climbId: z.string(), 23 - boardId: z.string(), 24 24 attempts: z.number().int().positive().default(1), 25 25 sent: z.boolean().default(false), 26 26 timestamp: z.string(), ··· 37 37 export type Session = z.infer<typeof Session> 38 38 39 39 export const Progress = z.object({ 40 - climbId: z.string(), 40 + id: z.string(), 41 41 boardId: z.string(), 42 + angle: z.number().int(), 42 43 name: z.string(), 43 44 grade: z.number().int(), 44 45 setter: z.string(), ··· 63 64 64 65 export const AppSettings = z.object({ 65 66 syncLinkUrl: z.string().default(''), 66 - boardId: z.string().default('mb2019-40'), 67 + boardId: z.string().default('mb2019'), 68 + angle: z.number().int().default(40), 67 69 gradeScale: GradeScale.default(GradeScale.enum.french), 68 70 homeGradeMin: z.number().int().min(0).max(16).default(0), 69 71 homeGradeMax: z.number().int().min(0).max(16).default(16), ··· 78 80 export interface AppState { 79 81 error: string | null 80 82 } 81 - 82 - export function progressKey(boardId: string, climbId: string): string { 83 - return `${boardId}:${climbId}` 84 - } 85 - 86 - export function parseProgressKey( 87 - key: string, 88 - ): { boardId: string; climbId: string } { 89 - const i = key.indexOf(':') 90 - if (i < 0) throw new Error(`invalid progress key: ${key}`) 91 - return { boardId: key.slice(0, i), climbId: key.slice(i + 1) } 92 - }
+14 -19
www/models/store.ts
··· 1 1 import { type Collection, Store, type StoreExport } from '@civility/store' 2 2 import { IDBStorage } from '@civility/store/idb' 3 - import { 4 - type Progress, 5 - progressKey, 6 - type Session, 7 - storeConfig, 8 - } from './schema.ts' 3 + import { type Progress, type Session, storeConfig } from './schema.ts' 9 4 10 5 export class AppStore { 11 6 #store: Store ··· 53 48 for (const fn of this.#listeners) fn() 54 49 } 55 50 56 - // Legacy entries from v0 were keyed by numeric climbId only. 57 - // After v0→v1 migration, Progress.boardId is populated, so we can 58 - // re-key in place to the composite `${boardId}:${climbId}` form. 51 + // Progress docs may have been stored under legacy keys from earlier 52 + // migration passes (raw numeric climbId, or boardType:climbId slugs). 53 + // At v1 the canonical key is the composite climb id itself (which is also 54 + // stored on the doc body as `id`). Rekey any mismatched entries in place. 59 55 async #rekeyLegacyProgress( 60 56 entries: Map<string, Progress>, 61 57 ): Promise<void> { 62 58 for (const [key, p] of entries) { 63 - if (key.includes(':')) continue 64 - if (!p.boardId || !p.climbId) continue 65 - const newKey = progressKey(p.boardId, p.climbId) 66 - if (newKey === key) continue 67 - if (!entries.has(newKey)) { 68 - await this.#progress.set(newKey, p) 59 + const canonical = p.id 60 + if (!canonical) continue 61 + if (key === canonical) continue 62 + if (!entries.has(canonical)) { 63 + await this.#progress.set(canonical, p) 69 64 } 70 65 await this.#progress.delete(key) 71 66 } ··· 93 88 } 94 89 95 90 // Progress CRUD 96 - async getProgressById(key: string): Promise<Progress | null> { 97 - return await this.#progress.get(key) ?? null 91 + async getProgressById(id: string): Promise<Progress | null> { 92 + return await this.#progress.get(id) ?? null 98 93 } 99 94 100 - async saveProgress(key: string, p: Progress): Promise<void> { 101 - await this.#progress.set(key, p) 95 + async saveProgress(id: string, p: Progress): Promise<void> { 96 + await this.#progress.set(id, p) 102 97 } 103 98 104 99 // Sessions CRUD
+15 -36
www/routes/climb.ts
··· 1 1 import { html, LitElement, type TemplateResult } from 'lit' 2 2 import Hammer, { type HammerManager } from 'hammerjs' 3 - import { 4 - BOARD_CONFIGS, 5 - GRADE_FULL, 6 - loadClimbs, 7 - youtubeUrl, 8 - } from '../utils/climbs.ts' 3 + import { findClimb, GRADE_FULL, youtubeUrl } from '../utils/climbs.ts' 9 4 import { CANVAS_WIDTH, canvasHeight, drawClimb } from '../utils/draw.ts' 10 - import { BOARD_HOLDS } from '../utils/boards.ts' 5 + import { BOARDS } from '../utils/boards.ts' 11 6 import type { Climb } from '../models/schema.ts' 12 7 import app from '../models/app.ts' 13 8 import { markAttempt } from './stopwatch.ts' ··· 67 62 } 68 63 69 64 private renderMeta(climb: Climb): TemplateResult { 70 - const entry = app.getProgress(climb.boardId, climb.id) 65 + const entry = app.getProgress(climb.id) 71 66 if (entry) { 72 67 const badge = entry.sent 73 68 ? html` ··· 101 96 return this 102 97 } 103 98 104 - override async connectedCallback() { 99 + override connectedCallback() { 105 100 super.connectedCallback() 106 101 const climbId = this.getAttribute('climb-id') ?? '' 107 102 if (!climbId) { ··· 110 105 } 111 106 112 107 const nav = app.getNav() 113 - let climb = (nav?.climbId === climbId ? nav.climb : null) ?? null 114 - 115 - if (!climb) { 116 - try { 117 - const all = await loadClimbs() 118 - const boardId = nav?.boardId ?? app.settings.state.boardId 119 - climb = all.find((c) => c.id === climbId && c.boardId === boardId) ?? 120 - all.find((c) => c.id === climbId) ?? 121 - null 122 - } catch { 123 - globalThis.location.hash = nav?.backRoute ?? '/' 124 - return 125 - } 126 - } 108 + const climb = (nav?.id === climbId ? nav.climb : null) ?? findClimb(climbId) 127 109 128 110 if (!climb) { 129 111 globalThis.location.hash = nav?.backRoute ?? '/' ··· 152 134 protected override updated() { 153 135 if (this.climb && this.climb.id !== this.prevClimbId) { 154 136 this.prevClimbId = this.climb.id 155 - const config = BOARD_CONFIGS[this.climb.boardId] 156 - if (!config) return 137 + const board = BOARDS[this.climb.boardId] 138 + if (!board) return 157 139 const mainEl = document.querySelector<HTMLElement>('main') 158 140 if (mainEl) mainEl.scrollTop = 0 159 141 requestAnimationFrame(() => { 160 142 if (!this.climb) return 161 143 const canvas = this.querySelector<HTMLCanvasElement>('#bm-canvas') 162 - if (canvas) { 163 - const boardData = BOARD_HOLDS[this.climb.boardId] 164 - drawClimb(canvas, this.climb, config.rows, boardData) 165 - } 144 + if (canvas) drawClimb(canvas, this.climb, board) 166 145 }) 167 146 } 168 147 ··· 181 160 const nextId = nav.filteredIds[next] 182 161 app.setNav({ 183 162 ...nav, 184 - climbId: nextId, 163 + id: nextId, 185 164 currentIndex: next, 186 165 climb: undefined, 187 166 }) ··· 201 180 } 202 181 203 182 const climb = this.climb 204 - const config = BOARD_CONFIGS[climb.boardId] 205 - if (!config) { 183 + const board = BOARDS[climb.boardId] 184 + if (!board) { 206 185 return html` 207 186 208 187 ` 209 188 } 210 - const height = canvasHeight(config.rows) 189 + const height = canvasHeight(board.rows) 211 190 212 191 return html` 213 192 <div id="cp-body"> 214 - ${config.image 193 + ${board.image 215 194 ? html` 216 195 <div class="board-wrap" style="aspect-ratio: ${CANVAS_WIDTH} / ${height}"> 217 196 <img 218 - src="${config.image}" 219 - alt="${config.label} board layout" 197 + src="${board.image}" 198 + alt="${board.name} board layout" 220 199 loading="lazy" 221 200 > 222 201 <canvas
+31 -26
www/routes/home.ts
··· 1 1 import { html, LitElement, type TemplateResult } from 'lit' 2 2 import { unsafeHTML } from 'lit/directives/unsafe-html.js' 3 3 import { 4 - BOARD_CONFIGS, 5 4 GRADE_FRENCH, 6 5 GRADE_V, 7 6 gradeLabel, 8 7 loadClimbs, 9 8 } from '../utils/climbs.ts' 10 - import { 11 - type Climb, 12 - GradeScale, 13 - LogFilter, 14 - progressKey, 15 - } from '../models/schema.ts' 9 + import { boardAngleLabel, BOARDS } from '../utils/boards.ts' 10 + import { type Climb, GradeScale, LogFilter } from '../models/schema.ts' 16 11 import app from '../models/app.ts' 17 12 18 13 const { all, sent, unsent } = LogFilter.enum ··· 202 197 export class HomePage extends LitElement { 203 198 private climbs: Climb[] = [] 204 199 private filtered: Climb[] = [] 205 - private boardId: string = 'mb2019-40' 200 + private boardId: string = 'mb2019' 201 + private angle: number = 40 206 202 private gradeScale: GradeScale = GradeScale.enum.french 207 203 private loading = true 208 204 private error = false ··· 214 210 override async connectedCallback() { 215 211 super.connectedCallback() 216 212 this.boardId = app.settings.state.boardId 213 + this.angle = app.settings.state.angle 217 214 this.gradeScale = app.settings.state.gradeScale 218 215 219 - const titleEl = document.querySelector('#page-title') 220 - if (titleEl) { 221 - titleEl.textContent = BOARD_CONFIGS[this.boardId]?.label ?? 'ClimbApp' 222 - } 223 - 216 + this.#updateTitle() 224 217 this.addEventListener('click', this.#handleClick) 225 218 emitter.addEventListener('filter', this.#onFilter) 226 219 app.settings.addEventListener(this.#onSettingsUpdate) ··· 228 221 try { 229 222 const all = await loadClimbs() 230 223 this.climbs = all 231 - .filter((c) => c.boardId === this.boardId) 224 + .filter((c) => c.boardId === this.boardId && c.angle === this.angle) 232 225 .sort((a, b) => a.grade - b.grade || b.repeats - a.repeats) 233 226 this.loading = false 234 227 this.applyFilters() ··· 247 240 app.settings.removeEventListener(this.#onSettingsUpdate) 248 241 } 249 242 243 + #updateTitle(): void { 244 + const titleEl = document.querySelector('#page-title') 245 + if (titleEl) { 246 + titleEl.textContent = boardAngleLabel(this.boardId, this.angle) 247 + } 248 + } 249 + 250 250 #onSettingsUpdate = async () => { 251 - const boardId = app.settings.state.boardId 252 - const gradeScale = app.settings.state.gradeScale 253 - if (boardId === this.boardId && gradeScale === this.gradeScale) return 251 + const { boardId, angle, gradeScale } = app.settings.state 252 + if ( 253 + boardId === this.boardId && 254 + angle === this.angle && 255 + gradeScale === this.gradeScale 256 + ) return 254 257 this.boardId = boardId 258 + this.angle = angle 255 259 this.gradeScale = gradeScale 256 - const titleEl = document.querySelector('#page-title') 257 - if (titleEl) { 258 - titleEl.textContent = BOARD_CONFIGS[this.boardId]?.label ?? 'ClimbApp' 259 - } 260 + this.#updateTitle() 260 261 const all = await loadClimbs() 261 262 this.climbs = all 262 - .filter((c) => c.boardId === this.boardId) 263 + .filter((c) => c.boardId === this.boardId && c.angle === this.angle) 263 264 .sort((a, b) => a.grade - b.grade || b.repeats - a.repeats) 264 265 this.applyFilters() 265 266 this.requestUpdate() ··· 297 298 ) { 298 299 return false 299 300 } 300 - const p = progress[progressKey(c.boardId, c.id)] 301 + const p = progress[c.id] 301 302 if (state.logFilter === sent) return !!p?.sent 302 303 if (state.logFilter === unsent) return !p?.sent 303 304 return true ··· 323 324 <p class="empty-message">Failed to load benchmarks.</p> 324 325 ` 325 326 } 327 + if (!BOARDS[this.boardId]) { 328 + return html` 329 + <p class="empty-message">Unknown board.</p> 330 + ` 331 + } 326 332 if (this.climbs.length === 0) { 327 333 return html` 328 334 <p class="empty-message">No benchmarks available for this board.</p> ··· 344 350 .toLocaleString()} benchmark${this.filtered.length === 1 ? '' : 's'} 345 351 </div> 346 352 ${visible.map((c) => { 347 - const p = progress[progressKey(c.boardId, c.id)] 353 + const p = progress[c.id] 348 354 const sentTag = p?.sent 349 355 ? '<ui-badge variant="success">Sent</ui-badge>' 350 356 : '' ··· 375 381 private openClimb(index: number): void { 376 382 const climb = this.filtered[index] 377 383 app.setNav({ 378 - climbId: climb.id, 379 - boardId: this.boardId, 384 + id: climb.id, 380 385 climb, 381 386 filteredIds: this.filtered.map((c) => c.id), 382 387 currentIndex: index,
+6 -9
www/routes/library.ts
··· 107 107 #handleClick = (e: Event) => { 108 108 const target = e.target as HTMLElement 109 109 const el = target.closest<HTMLElement>('[data-climb-id]') 110 - if (el) { 111 - const climbId = el.dataset.climbId ?? '' 112 - const boardId = el.dataset.boardId ?? '' 113 - if (!climbId || !boardId) return 114 - app.setNav({ climbId, boardId, backRoute: '/library' }) 115 - globalThis.location.hash = `/climb/${climbId}` 116 - } 110 + if (!el) return 111 + const id = el.dataset.climbId ?? '' 112 + if (!id) return 113 + app.setNav({ id, backRoute: '/library' }) 114 + globalThis.location.hash = `/climb/${id}` 117 115 } 118 116 119 117 private sortedEntries(): Progress[] { ··· 171 169 return html` 172 170 <button 173 171 class="lb-entry" 174 - data-climb-id="${p.climbId}" 175 - data-board-type="${p.boardId}" 172 + data-climb-id="${p.id}" 176 173 > 177 174 <div class="lb-entry-row"> 178 175 <span class="lb-entry-name">${p.name}</span>
+38 -16
www/routes/settings.ts
··· 1 1 import { html, LitElement, type TemplateResult } from 'lit' 2 2 import app from '../models/app.ts' 3 3 import { GradeScale } from '../models/schema.ts' 4 - import { BOARD_CONFIGS } from '../utils/climbs.ts' 5 - import { BOARD_TYPES } from '../utils/boards.ts' 4 + import { BOARD_IDS, BOARDS } from '../utils/boards.ts' 5 + 6 + interface BoardOption { 7 + boardId: string 8 + angle: number 9 + label: string 10 + } 11 + 12 + const BOARD_OPTIONS: BoardOption[] = BOARD_IDS.flatMap((id) => 13 + BOARDS[id].angles.map((angle) => ({ 14 + boardId: id, 15 + angle, 16 + label: `${BOARDS[id].name} ${angle}°`, 17 + })) 18 + ) 6 19 7 - const BOARD_OPTIONS = BOARD_TYPES.map((slug) => ({ 8 - boardId: slug, 9 - label: BOARD_CONFIGS[slug]?.label ?? slug, 10 - })) 20 + function optionKey(boardId: string, angle: number): string { 21 + return `${boardId}:${angle}` 22 + } 11 23 12 24 const GRADE_SCALE_OPTIONS = [ 13 25 { ··· 23 35 ] 24 36 25 37 export class SettingsPage extends LitElement { 26 - private selectedBoard: string = 'mb2019-40' 38 + private selectedKey: string = optionKey('mb2019', 40) 27 39 private gradeScale: GradeScale = GradeScale.enum.french 28 40 29 41 protected override createRenderRoot() { ··· 32 44 33 45 override connectedCallback() { 34 46 super.connectedCallback() 35 - this.selectedBoard = app.settings.state.boardId 47 + this.selectedKey = optionKey( 48 + app.settings.state.boardId, 49 + app.settings.state.angle, 50 + ) 36 51 this.gradeScale = app.settings.state.gradeScale 37 52 app.settings.addEventListener(this.#onSettingsUpdate) 38 53 } ··· 43 58 } 44 59 45 60 #onSettingsUpdate = () => { 46 - this.selectedBoard = app.settings.state.boardId 61 + this.selectedKey = optionKey( 62 + app.settings.state.boardId, 63 + app.settings.state.angle, 64 + ) 47 65 this.gradeScale = app.settings.state.gradeScale 48 66 this.requestUpdate() 49 67 } ··· 59 77 <h2>Board Setup</h2> 60 78 <p>Select which Board setup you are using.</p> 61 79 <div role="radiogroup" aria-label="Board setup"> 62 - ${BOARD_OPTIONS.map((opt) => 63 - html` 80 + ${BOARD_OPTIONS.map((opt) => { 81 + const key = optionKey(opt.boardId, opt.angle) 82 + return html` 64 83 <label> 65 84 <input 66 85 type="radio" 67 - name="boardId" 68 - value="${opt.boardId}" 69 - ?checked="${this.selectedBoard === opt.boardId}" 86 + name="board-angle" 87 + value="${key}" 88 + ?checked="${this.selectedKey === key}" 70 89 @change="${() => 71 - app.updateSettings({ boardId: opt.boardId })}" 90 + app.updateSettings({ 91 + boardId: opt.boardId, 92 + angle: opt.angle, 93 + })}" 72 94 > 73 95 <span>${opt.label}</span> 74 96 </label> 75 97 ` 76 - )} 98 + })} 77 99 </div> 78 100 </section> 79 101
+3 -2
www/static/boards/mb2016.json
··· 2 2 "id": "mb2016", 3 3 "name": "Moonboard 2016", 4 4 "type": "Moonboard", 5 - "image": "/static/images/moonboard.png", 5 + "image": "/static/images/boards/moonboard.png", 6 6 "rows": 18, 7 - "holdImagePath": "/static/images/holds/mb/h{}", 7 + "angles": [40], 8 + "holdImagePath": "/static/images/holds/mb/h{number}.png", 8 9 "holdSize": 60, 9 10 "holdsets": [ 10 11 {
+3 -2
www/static/boards/mb2017.json
··· 2 2 "id": "mb2017", 3 3 "name": "Moonboard 2017", 4 4 "type": "Moonboard", 5 - "image": "/static/images/moonboard.png", 5 + "image": "/static/images/boards/moonboard.png", 6 6 "rows": 18, 7 - "holdImagePath": "/static/images/holds/mb/h{}", 7 + "angles": [25, 40], 8 + "holdImagePath": "/static/images/holds/mb/h{number}.png", 8 9 "holdSize": 60, 9 10 "holdsets": [ 10 11 {
+3 -2
www/static/boards/mb2019.json
··· 2 2 "id": "mb2019", 3 3 "name": "Moonboard 2019", 4 4 "type": "Moonboard", 5 - "image": "/static/images/moonboard.png", 5 + "image": "/static/images/boards/moonboard.png", 6 6 "rows": 18, 7 - "holdImagePath": "/static/images/holds/mb/h{}", 7 + "angles": [25, 40], 8 + "holdImagePath": "/static/images/holds/mb/h{number}.png", 8 9 "holdSize": 60, 9 10 "holdsets": [ 10 11 {
+3 -2
www/static/boards/mb2020.json
··· 2 2 "id": "mb2020", 3 3 "name": "Moonboard 2020", 4 4 "type": "Moonboard", 5 - "image": "/static/images/minimoonboard.png", 5 + "image": "/static/images/boards/minimoonboard.png", 6 6 "rows": 12, 7 - "holdImagePath": "/static/images/holds/mb/h{}", 7 + "angles": [40], 8 + "holdImagePath": "/static/images/holds/mb/h{number}.png", 8 9 "holdSize": 60, 9 10 "holdsets": [ 10 11 {
+2 -1
www/static/boards/mb2024.json
··· 2 2 "id": "mb2024", 3 3 "name": "Moonboard 2024", 4 4 "type": "Moonboard", 5 - "image": "/static/images/moonboard.png", 5 + "image": "/static/images/boards/moonboard.png", 6 6 "rows": 18, 7 + "angles": [25, 40], 7 8 "holdImagePath": "/static/images/holds/mb/h{number}.png", 8 9 "holdSize": 60, 9 10 "holdsets": [
+3 -2
www/static/boards/mb2025.json
··· 2 2 "id": "mb2025", 3 3 "name": "Moonboard 2025", 4 4 "type": "Moonboard", 5 - "image": "/static/images/minimoonboard.png", 5 + "image": "/static/images/boards/minimoonboard.png", 6 6 "rows": 12, 7 - "holdImagePath": "/static/images/holds/mb/h{}", 7 + "angles": [40], 8 + "holdImagePath": "/static/images/holds/mb/h{number}.png", 8 9 "holdSize": 60, 9 10 "holdsets": [ 10 11 {
+69 -43
www/utils/boards.ts
··· 5 5 import set2024 from '../static/boards/mb2024.json' with { type: 'json' } 6 6 import set2025 from '../static/boards/mb2025.json' with { type: 'json' } 7 7 8 - export const BOARD_HOLDS: Record<string, typeof set2016.holdsets> = { 9 - 'mb2016-40': set2016.holdsets, 10 - 'mb2017-25': set2017.holdsets, 11 - 'mb2017-40': set2017.holdsets, 12 - 'mb2019-25': set2019.holdsets, 13 - 'mb2019-40': set2019.holdsets, 14 - 'mb2020-40': set2020.holdsets, 15 - 'mb2024-25': set2024.holdsets, 16 - 'mb2024-40': set2024.holdsets, 17 - 'mb2025-40': set2025.holdsets, 8 + export interface BoardHoldLocation { 9 + description: string 10 + x: number 11 + y: number 12 + rotation: number 13 + direction?: number 14 + directionString?: string 18 15 } 19 16 20 - /** 21 - * Moonboard API query parameters: [holdsetId, angleId]. 22 - * Only boards queryable through the Moonboard API are listed. 23 - */ 24 - export const MOONBOARD_API_CONFIGS: Record<string, [string, string]> = { 25 - 'mb2016-40': ['1', '0'], 26 - 'mb2017-40': ['15', '1'], 27 - 'mb2017-25': ['15', '2'], 28 - 'mb2019-40': ['17', '1'], 29 - 'mb2019-25': ['17', '2'], 30 - 'mb2020-40': ['19', '1'], 31 - 'mb2024-25': ['21', '2'], 32 - 'mb2024-40': ['21', '3'], 17 + export interface BoardHold { 18 + id: number 19 + number: string 20 + location: BoardHoldLocation 33 21 } 34 22 35 - export const BOARD_TYPES = [ 36 - 'mb2016-40', 37 - 'mb2017-25', 38 - 'mb2017-40', 39 - 'mb2019-25', 40 - 'mb2019-40', 41 - 'mb2020-40', 42 - 'mb2024-25', 43 - 'mb2024-40', 44 - 'mb2025-40', 45 - ] as const 23 + export interface BoardHoldset { 24 + id: number 25 + description: string 26 + color: string 27 + holds: BoardHold[] 28 + } 46 29 47 - export type boardId = typeof BOARD_TYPES[number] 30 + export interface Board { 31 + id: string 32 + name: string 33 + type: string 34 + image: string 35 + rows: number 36 + holdImagePath: string 37 + holdSize: number 38 + holdsets: BoardHoldset[] 39 + angles: number[] 40 + } 48 41 49 - export const ENUMERATE_HOLDSETS: Record<string, number> = { 50 - 'Original School Holds': 0, 51 - 'Hold Set A': 1, 52 - 'Hold Set B': 2, 53 - 'Hold Set C': 3, 54 - 'Wooden Holds': 4, 55 - 'Wooden Holds B': 5, 56 - 'Wooden Holds C': 6, 42 + type RawBoard = { 43 + id: string 44 + name: string 45 + type: string 46 + image: string 47 + rows: number 48 + holdImagePath: string 49 + holdSize: number 50 + angles: number[] 51 + holdsets: unknown[] 52 + } 53 + 54 + function toBoard(raw: RawBoard): Board { 55 + return { 56 + id: raw.id, 57 + name: raw.name, 58 + type: raw.type, 59 + image: raw.image, 60 + rows: raw.rows, 61 + holdImagePath: raw.holdImagePath, 62 + holdSize: raw.holdSize, 63 + holdsets: raw.holdsets as BoardHoldset[], 64 + angles: raw.angles, 65 + } 66 + } 67 + 68 + export const BOARDS: Record<string, Board> = { 69 + [set2016.id]: toBoard(set2016 as RawBoard), 70 + [set2017.id]: toBoard(set2017 as RawBoard), 71 + [set2019.id]: toBoard(set2019 as RawBoard), 72 + [set2020.id]: toBoard(set2020 as RawBoard), 73 + [set2024.id]: toBoard(set2024 as RawBoard), 74 + [set2025.id]: toBoard(set2025 as RawBoard), 75 + } 76 + 77 + export const BOARD_IDS: string[] = Object.keys(BOARDS) 78 + 79 + export function boardAngleLabel(boardId: string, angle: number): string { 80 + const board = BOARDS[boardId] 81 + if (!board) return `${boardId} ${angle}°` 82 + return `${board.name} ${angle}°` 57 83 }
+30 -64
www/utils/climbs.ts
··· 1 - import type { Climb } from '../models/schema/v1.ts' 2 - import { BOARD_TYPES } from './boards.ts' 1 + import { type Climb } from '../models/schema/v1.ts' 2 + import set2016 from '../static/boards/mb2016.json' with { type: 'json' } 3 + import set2017 from '../static/boards/mb2017.json' with { type: 'json' } 4 + import set2019 from '../static/boards/mb2019.json' with { type: 'json' } 5 + import set2020 from '../static/boards/mb2020.json' with { type: 'json' } 6 + import set2024 from '../static/boards/mb2024.json' with { type: 'json' } 7 + import set2025 from '../static/boards/mb2025.json' with { type: 'json' } 3 8 4 9 export const GRADE_FRENCH: Record<number, string> = { 5 10 0: '5+', ··· 61 66 16: '8B+ (V14)', 62 67 } 63 68 64 - export const BOARD_CONFIGS: Record< 65 - string, 66 - { label: string; image: string | null; rows: number } 67 - > = { 68 - 'mb2016-40': { 69 - label: '2016 40°', 70 - image: '/static/images/boards/moonboard.png', 71 - rows: 18, 72 - }, 73 - 'mb2017-25': { 74 - label: '2017 25°', 75 - image: '/static/images/boards/moonboard.png', 76 - rows: 18, 77 - }, 78 - 'mb2017-40': { 79 - label: '2017 40°', 80 - image: '/static/images/boards/moonboard.png', 81 - rows: 18, 82 - }, 83 - 'mb2019-25': { 84 - label: '2019 25°', 85 - image: '/static/images/boards/moonboard.png', 86 - rows: 18, 87 - }, 88 - 'mb2019-40': { 89 - label: '2019 40°', 90 - image: '/static/images/boards/moonboard.png', 91 - rows: 18, 92 - }, 93 - 'mb2020-40': { 94 - label: '2020 40°', 95 - image: '/static/images/boards/minimoonboard.png', 96 - rows: 12, 97 - }, 98 - 'mb2024-25': { 99 - label: '2024 25°', 100 - image: '/static/images/boards/moonboard.png', 101 - rows: 18, 102 - }, 103 - 'mb2024-40': { 104 - label: '2024 40°', 105 - image: '/static/images/boards/moonboard.png', 106 - rows: 18, 107 - }, 108 - 'mb2025-40': { 109 - label: '2025 40°', 110 - image: '/static/images/boards/minimoonboard.png', 111 - rows: 12, 112 - }, 69 + const RAW_CLIMBS: unknown[] = [ 70 + ...set2016.climbs, 71 + ...set2017.climbs, 72 + ...set2019.climbs, 73 + ...set2020.climbs, 74 + ...set2024.climbs, 75 + ...set2025.climbs, 76 + ] 77 + 78 + // Filter out legacy/unmigrated entries that still carry the raw API shape. 79 + const ALL_CLIMBS: Climb[] = RAW_CLIMBS.filter((c): c is Climb => 80 + !!c && typeof c === 'object' && !('ProblemId' in c) && 81 + typeof (c as Climb).id === 'string' && 82 + Array.isArray((c as Climb).startHolds) && 83 + (c as Climb).startHolds.length > 0 && 84 + Array.isArray((c as Climb).endHolds) && 85 + (c as Climb).endHolds.length > 0 86 + ) 87 + 88 + export function loadClimbs(): Promise<Climb[]> { 89 + return Promise.resolve(ALL_CLIMBS) 113 90 } 114 91 115 - let dataCache: Climb[] | null = null 116 - 117 - export async function loadClimbs(): Promise<Climb[]> { 118 - if (dataCache) return dataCache 119 - const arrays = await Promise.all( 120 - BOARD_TYPES.map((t) => 121 - fetch(`/static/data/climbs/${t}.json`).then((r) => r.json()) 122 - ), 123 - ) 124 - dataCache = (arrays.flat() as Climb[]).filter( 125 - (c) => c.startHolds?.length > 0 && c.endHolds?.length > 0, 126 - ) 127 - return dataCache 92 + export function findClimb(id: string): Climb | null { 93 + return ALL_CLIMBS.find((c) => c.id === id) ?? null 128 94 } 129 95 130 96 export function youtubeUrl(climb: Climb): string {
+39 -78
www/utils/draw.ts
··· 1 1 import type { Climb } from '../models/schema/v1.ts' 2 + import type { Board, BoardHoldset } from './boards.ts' 2 3 3 4 export const COL_LABELS = 'ABCDEFGHIJK'.split('') 4 5 export const CANVAS_WIDTH = 450 ··· 7 8 // JSON coord → canvas coord: both axes use the same scale 8 9 const SCALE = 34.6 / 50 9 10 10 - interface BoardHoldLocation { 11 - Description: string 12 - X: number 13 - Y: number 14 - Rotation: number 15 - } 16 - 17 - interface BoardHold { 18 - Number: string 19 - Location: BoardHoldLocation 20 - } 21 - 22 - export interface BoardHoldset { 23 - Description: string 24 - Holds: BoardHold[] 25 - } 26 - 27 11 type HoldPos = { x: number; y: number; rotation: number; imageUrl: string } 28 12 29 - function buildHoldLookup(boardData: BoardHoldset[]): Map<string, HoldPos> { 13 + function buildHoldLookup( 14 + holdsets: BoardHoldset[], 15 + holdImagePath: string, 16 + ): Map<string, HoldPos> { 30 17 const map = new Map<string, HoldPos>() 31 - for (const holdset of boardData) { 32 - for (const hold of holdset.Holds) { 33 - const loc = hold.Location 34 - map.set(loc.Description, { 35 - x: loc.X * SCALE, 36 - y: loc.Y * SCALE, 37 - rotation: loc.Rotation, 38 - imageUrl: `/static/images/holds/h${hold.Number}.png`, 18 + for (const holdset of holdsets) { 19 + for (const hold of holdset.holds) { 20 + const loc = hold.location 21 + map.set(loc.description, { 22 + x: loc.x * SCALE, 23 + y: loc.y * SCALE, 24 + rotation: loc.rotation, 25 + imageUrl: holdImagePath.replace('{number}', hold.number), 39 26 }) 40 27 } 41 28 } ··· 61 48 export async function drawClimb( 62 49 canvas: HTMLCanvasElement, 63 50 climb: Climb, 64 - rows: number, 65 - boardData?: BoardHoldset[], 51 + board: Board, 66 52 ): Promise<void> { 67 53 const ctx = canvas.getContext('2d') 68 54 if (!ctx) return 69 55 ctx.clearRect(0, 0, canvas.width, canvas.height) 70 56 71 - const holdLookup = boardData ? buildHoldLookup(boardData) : null 57 + const holdLookup = buildHoldLookup(board.holdsets, board.holdImagePath) 58 + const holdSize = board.holdSize 72 59 73 60 const holdGroups = [ 74 61 { holds: climb.startHolds, color: 'limegreen' }, ··· 76 63 { holds: climb.endHolds, color: 'tomato' }, 77 64 ] 78 65 79 - if (holdLookup) { 80 - const HOLD_SIZE = 60 81 - // Load all hold images in parallel 82 - const allPositions = [...holdLookup.values()] 83 - const allImages = await Promise.all( 84 - allPositions.map(({ imageUrl }) => loadImage(imageUrl).catch(() => null)), 85 - ) 66 + const allPositions = [...holdLookup.values()] 67 + const allImages = await Promise.all( 68 + allPositions.map(({ imageUrl }) => loadImage(imageUrl).catch(() => null)), 69 + ) 86 70 87 - // Draw every hold on the board 88 - for (let i = 0; i < allPositions.length; i++) { 89 - const img = allImages[i] 90 - if (!img) continue 91 - const { x, y, rotation } = allPositions[i] 92 - ctx.save() 93 - ctx.translate(x, y) 94 - ctx.rotate((rotation * Math.PI) / 180) 95 - ctx.drawImage(img, -HOLD_SIZE / 2, -HOLD_SIZE / 2, HOLD_SIZE, HOLD_SIZE) 96 - ctx.restore() 97 - } 71 + for (let i = 0; i < allPositions.length; i++) { 72 + const img = allImages[i] 73 + if (!img) continue 74 + const { x, y, rotation } = allPositions[i] 75 + ctx.save() 76 + ctx.translate(x, y) 77 + ctx.rotate((rotation * Math.PI) / 180) 78 + ctx.drawImage(img, -holdSize / 2, -holdSize / 2, holdSize, holdSize) 79 + ctx.restore() 80 + } 98 81 99 - // Draw colored rings over climb holds 100 - for (const { holds, color } of holdGroups) { 101 - for (const code of holds) { 102 - const pos = holdLookup.get(code) 103 - if (!pos) continue 104 - ctx.beginPath() 105 - ctx.lineWidth = 3 106 - ctx.strokeStyle = color 107 - ctx.arc(pos.x, pos.y, 18, 0, 2 * Math.PI) 108 - ctx.stroke() 109 - } 110 - } 111 - } else { 112 - // Fallback: grid-based circles 113 - for (let x = 0; x < 11; x++) { 114 - for (let y = 0; y < rows; y++) { 115 - const holdCode = COL_LABELS[x] + (rows - y) 116 - const isStart = climb.startHolds.includes(holdCode) 117 - const isMid = climb.midHolds.includes(holdCode) 118 - const isEnd = climb.endHolds.includes(holdCode) 119 - if (!isStart && !isMid && !isEnd) continue 120 - ctx.beginPath() 121 - ctx.lineWidth = 4 122 - ctx.strokeStyle = isStart 123 - ? 'limegreen' 124 - : isMid 125 - ? 'cornflowerblue' 126 - : 'tomato' 127 - ctx.arc(65 + x * 34.6, 60 + y * 34.6, 20, 0, 2 * Math.PI) 128 - ctx.stroke() 129 - } 82 + for (const { holds, color } of holdGroups) { 83 + for (const code of holds) { 84 + const pos = holdLookup.get(code) 85 + if (!pos) continue 86 + ctx.beginPath() 87 + ctx.lineWidth = 3 88 + ctx.strokeStyle = color 89 + ctx.arc(pos.x, pos.y, 18, 0, 2 * Math.PI) 90 + ctx.stroke() 130 91 } 131 92 } 132 93 }