An app for logging board climbs
0
fork

Configure Feed

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

chore: cleanup styles

+210 -269
+60 -21
scripts/moon.ts
··· 48 48 const loginPageResponse = await fetch(`${WEB_HOST}/account/login`) 49 49 const loginPageHtml = await loginPageResponse.text() 50 50 51 - const verificationToken = extractInputValue(loginPageHtml, '__RequestVerificationToken') 51 + const verificationToken = extractInputValue( 52 + loginPageHtml, 53 + '__RequestVerificationToken', 54 + ) 52 55 const formKey = extractInputValue(loginPageHtml, 'form_key') 53 56 54 57 if (!verificationToken || !formKey) { ··· 80 83 // Check for authentication errors 81 84 if (loginResponse.status !== 302 && loginResponse.status !== 200) { 82 85 const errorHtml = await loginResponse.text() 83 - if (errorHtml.includes('validation-summary-errors') || errorHtml.includes('field-validation-error')) { 86 + if ( 87 + errorHtml.includes('validation-summary-errors') || 88 + errorHtml.includes('field-validation-error') 89 + ) { 84 90 throw new Error('Login failed: Invalid username or password') 85 91 } 86 92 } ··· 103 109 angle: string, 104 110 token: string, 105 111 pos = 0, 106 - problems: RawProblem[] = [] 112 + problems: RawProblem[] = [], 107 113 ): Promise<RawProblem[]> { 108 - const url = `${HOST}/v1/_moonapi/problems/v3/${holdset}/${angle}/${pos}?v=8.3.4` 114 + const url = 115 + `${HOST}/v1/_moonapi/problems/v3/${holdset}/${angle}/${pos}?v=8.3.4` 109 116 const headers = { 110 117 'accept-encoding': 'gzip', 111 118 'authorization': `BEARER ${token}`, ··· 120 127 } 121 128 122 129 const jsonResponse = await response.json() 123 - const newProblems = problems.length === 0 ? jsonResponse.data : [...problems, ...jsonResponse.data] 130 + const newProblems = problems.length === 0 131 + ? jsonResponse.data 132 + : [...problems, ...jsonResponse.data] 124 133 125 134 // Recursively continue if we got the max batch size 126 135 if (jsonResponse.data.length === 5000) { ··· 136 145 */ 137 146 async function augmentBenchmark(climb: RawProblem): Promise<AugmentedProblem> { 138 147 try { 139 - const url = `${WEB_HOST}/Problems/Details/${climb.apiId}/${climb.setbyId}?ui=x` 148 + const url = 149 + `${WEB_HOST}/Problems/Details/${climb.apiId}/${climb.setbyId}?ui=x` 140 150 const headers = { 141 151 'host': 'www.moonboard.com', 142 - 'accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8', 152 + 'accept': 153 + 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8', 143 154 'accept-language': 'en-CA,en-US;q=0.9,en;q=0.8', 144 155 'connection': 'keep-alive', 145 156 'accept-encoding': 'gzip, deflate, br', ··· 155 166 156 167 // Parse user grades 157 168 const gradeMap: Record<string, number> = {} 158 - const gradeRegex = /<td class="grade">([^<]+)<\/td>[\s\S]*?<span class="total">([0-9,]+)<\/span>/g 169 + const gradeRegex = 170 + /<td class="grade">([^<]+)<\/td>[\s\S]*?<span class="total">([0-9,]+)<\/span>/g 159 171 let match 160 172 while ((match = gradeRegex.exec(html)) !== null) { 161 173 const grade = match[1].trim() ··· 167 179 168 180 // Parse user stars 169 181 const starMap: Record<string, number> = {} 170 - const starSections = html.match(/<div class="stars">[\s\S]*?<span class="total">([0-9,]+)<\/span>/g) || [] 182 + const starSections = html.match( 183 + /<div class="stars">[\s\S]*?<span class="total">([0-9,]+)<\/span>/g, 184 + ) || [] 171 185 for (const section of starSections) { 172 - const starCount = (section.match(/<img[^>]*src="\/Content\/images\/star\.png"/g) || []).length 186 + const starCount = 187 + (section.match(/<img[^>]*src="\/Content\/images\/star\.png"/g) || []) 188 + .length 173 189 const totalMatch = section.match(/<span class="total">([0-9,]+)<\/span>/) 174 190 if (starCount > 0 && totalMatch) { 175 191 const total = parseInt(totalMatch[1].replace(/,/g, '')) ··· 181 197 182 198 // Parse user attempts 183 199 const attemptMap: Record<string, number> = {} 184 - const chartDataMatch = html.match(/kendo\.syncReady\(function\(\)\{jQuery\("#chart"\)\.kendoChart\(([\s\S]*?)\)\;\}\);/) 200 + const chartDataMatch = html.match( 201 + /kendo\.syncReady\(function\(\)\{jQuery\("#chart"\)\.kendoChart\(([\s\S]*?)\)\;\}\);/, 202 + ) 185 203 if (chartDataMatch) { 186 204 try { 187 205 const chartData = JSON.parse(chartDataMatch[1]) ··· 215 233 /** 216 234 * Process augmented benchmark into final format 217 235 */ 218 - function processBenchmark(climb: AugmentedProblem, mbType: string): ProcessedBenchmark { 236 + function processBenchmark( 237 + climb: AugmentedProblem, 238 + mbType: string, 239 + ): ProcessedBenchmark { 219 240 // Calculate average user grade 220 241 let avgUserGrade: number 221 242 const userGradeBreakdown = Array(18).fill(0) ··· 231 252 userGradeBreakdown[gradeValue] = count 232 253 } 233 254 } 234 - const totalGrades = Object.values(climb.userGrades).reduce((a, b) => a + b, 0) 235 - avgUserGrade = totalGrades > 0 ? userGradeSum / totalGrades : ENUMERATE_GRADES[climb.grade] 255 + const totalGrades = Object.values(climb.userGrades).reduce( 256 + (a, b) => a + b, 257 + 0, 258 + ) 259 + avgUserGrade = totalGrades > 0 260 + ? userGradeSum / totalGrades 261 + : ENUMERATE_GRADES[climb.grade] 236 262 } 237 263 238 264 // Calculate sandbag score (difference from benchmark grade) ··· 268 294 userAttemptsBreakdown[attemptValue] = count 269 295 } 270 296 } 271 - const totalAttempts = Object.values(climb.userAttempts).reduce((a, b) => a + b, 0) 297 + const totalAttempts = Object.values(climb.userAttempts).reduce( 298 + (a, b) => a + b, 299 + 0, 300 + ) 272 301 avgUserAttempts = totalAttempts > 0 ? userAttemptsSum / totalAttempts : 0 273 302 } 274 303 ··· 288 317 } 289 318 290 319 // Process holdsets 291 - const holdsets = climb.holdsets.map(hs => ENUMERATE_HOLDSETS[hs.description]).filter(h => h !== undefined) 320 + const holdsets = climb.holdsets.map((hs) => 321 + ENUMERATE_HOLDSETS[hs.description] 322 + ).filter((h) => h !== undefined) 292 323 293 324 // Format date 294 325 const date = climb.dateInserted.substring(0, 19).replace('T', ' ') ··· 336 367 337 368 // Fetch all problems 338 369 const problems = await getProblems(holdset, angle, token) 339 - const benchmarks = problems.filter(p => p.isBenchmark) 370 + const benchmarks = problems.filter((p) => p.isBenchmark) 340 371 console.log(` Found ${benchmarks.length} benchmarks`) 341 372 342 373 // Augment each benchmark ··· 352 383 } 353 384 354 385 // Add delay to avoid overwhelming the server 355 - await new Promise(resolve => setTimeout(resolve, 100)) 386 + await new Promise((resolve) => setTimeout(resolve, 100)) 356 387 } 357 388 358 389 // Process benchmarks ··· 369 400 allBenchmarks.sort((a, b) => a.id - b.id) 370 401 371 402 // Write to file 372 - const outputPath = join(Deno.cwd(), 'www', 'static', 'data', 'benchmarks.json') 403 + const outputPath = join( 404 + Deno.cwd(), 405 + 'www', 406 + 'static', 407 + 'data', 408 + 'benchmarks.json', 409 + ) 373 410 await Deno.writeTextFile(outputPath, JSON.stringify(allBenchmarks, null, 2)) 374 411 375 - console.log(`\n✓ Successfully wrote ${allBenchmarks.length} benchmarks to ${outputPath}`) 412 + console.log( 413 + `\n✓ Successfully wrote ${allBenchmarks.length} benchmarks to ${outputPath}`, 414 + ) 376 415 } 377 416 378 417 if (import.meta.main) { 379 - main().catch(error => { 418 + main().catch((error) => { 380 419 console.error('Error:', error) 381 420 Deno.exit(1) 382 421 })
-3
www/index.html
··· 23 23 <a href="#main" class="skip-to-main">Skip to main content</a> 24 24 25 25 <header> 26 - <button id="header-back" class="header-back-btn" aria-label="Back" hidden> 27 - <img src="/static/icons/chevron-right.svg" alt="" aria-hidden="true" class="header-back-icon"> 28 - </button> 29 26 <strong id="page-title">Moonboard</strong> 30 27 </header> 31 28
+1 -4
www/index.ts
··· 1 1 import { Router } from '@bpev/civility' 2 2 import './utils/updates.ts' 3 3 import './routes/home.ts' 4 - import { 5 - formatStopwatchShort, 6 - globalStopwatch, 7 - } from './routes/stopwatch.ts' 4 + import { formatStopwatchShort, globalStopwatch } from './routes/stopwatch.ts' 8 5 import './routes/settings.ts' 9 6 10 7 export class App {
+64 -115
www/routes/home.ts
··· 84 84 number, 85 85 { label: string; image: string | null; rows: number } 86 86 > = { 87 - 0: { label: '2016 40°', image: '/static/images/mbsetup-2016.jpg', rows: 18 }, 88 - 1: { label: '2017 25°', image: '/static/images/mbsetup-2017.jpg', rows: 18 }, 89 - 2: { label: '2017 40°', image: '/static/images/mbsetup-2017.jpg', rows: 18 }, 90 - 3: { label: '2019 25°', image: '/static/images/mbsetup-2019.jpg', rows: 18 }, 91 - 4: { label: '2019 40°', image: '/static/images/mbsetup-2019.jpg', rows: 18 }, 87 + 0: { label: '2016 40°', image: '/static/images/mbsetup-2016.png', rows: 18 }, 88 + 1: { label: '2017 25°', image: '/static/images/mbsetup-2017.png', rows: 18 }, 89 + 2: { label: '2017 40°', image: '/static/images/mbsetup-2017.png', rows: 18 }, 90 + 3: { label: '2019 25°', image: '/static/images/mbsetup-2019.png', rows: 18 }, 91 + 4: { label: '2019 40°', image: '/static/images/mbsetup-2019.png', rows: 18 }, 92 92 5: { label: '2020 40°', image: null, rows: 12 }, 93 - 6: { label: '2024 40°', image: '/static/images/mbsetup-2024.jpg', rows: 18 }, 93 + 6: { label: '2024 40°', image: '/static/images/mbsetup-2024.png', rows: 18 }, 94 94 } 95 95 96 96 const COL_LABELS = ['A', 'B', 'C', 'D', 'E', 'F', 'G', 'H', 'I', 'J', 'K'] ··· 171 171 172 172 async connectedCallback() { 173 173 this.mbType = parseInt(localStorage.getItem('mb_type') ?? '0', 10) 174 - this.gradeScale = 175 - (localStorage.getItem('grade_scale') as 'french' | 'v') ?? 'french' 174 + this.gradeScale = (localStorage.getItem('grade_scale') as 'french' | 'v') ?? 175 + 'french' 176 176 177 177 const titleEl = document.querySelector('#page-title') 178 178 if (titleEl) { ··· 192 192 } catch { 193 193 const listEl = this.querySelector('#bm-list') 194 194 if (listEl) { 195 - listEl.innerHTML = `<p class="bm-message">Failed to load benchmarks.</p>` 195 + listEl.innerHTML = 196 + `<p class="bm-message">Failed to load benchmarks.</p>` 196 197 } 197 198 } 198 199 } 199 200 200 201 disconnectedCallback() { 201 - this.hideBackButton() 202 202 this.hammer?.destroy() 203 203 this.hammer = null 204 204 } ··· 237 237 aria-modal="true" 238 238 role="dialog" 239 239 > 240 + <div id="bm-detail-header" class="bm-dh-header"></div> 240 241 <div id="bm-detail-content" class="bm-detail-body"></div> 241 - <div class="bm-detail-footer"> 242 - <button id="bm-prev" class="bm-nav-btn" aria-label="Previous climb"> 243 - <img src="/static/icons/chevron-right.svg" alt="" aria-hidden="true" class="bm-chevron-flip"> 244 - Prev 245 - </button> 246 - <span id="bm-pos" class="bm-pos"></span> 247 - <button id="bm-next" class="bm-nav-btn" aria-label="Next climb"> 248 - Next 249 - <img src="/static/icons/chevron-right.svg" alt="" aria-hidden="true"> 250 - </button> 251 - </div> 252 242 </div> 253 243 ` 254 244 } 255 245 256 246 private gradeOptions(selected: number): string { 257 - return Array.from({ length: 17 }, (_, i) => 258 - `<option value="${i}" ${i === selected ? 'selected' : ''}>${this.gradeLabel(i)}</option>` 247 + return Array.from( 248 + { length: 17 }, 249 + (_, i) => 250 + `<option value="${i}" ${i === selected ? 'selected' : ''}>${ 251 + this.gradeLabel(i) 252 + }</option>`, 259 253 ).join('') 260 254 } 261 255 ··· 311 305 this.renderList() 312 306 } 313 307 }) 314 - 315 - // Detail footer nav 316 - this.querySelector('#bm-prev')?.addEventListener('click', () => 317 - this.navigateDetail(-1) 318 - ) 319 - this.querySelector('#bm-next')?.addEventListener('click', () => 320 - this.navigateDetail(1) 321 - ) 322 308 } 323 309 324 310 private applyFilters(): void { ··· 341 327 if (!listEl) return 342 328 343 329 if (this.benchmarks.length === 0) { 344 - listEl.innerHTML = `<p class="bm-message">No benchmarks available for this board.</p>` 330 + listEl.innerHTML = 331 + `<p class="bm-message">No benchmarks available for this board.</p>` 345 332 return 346 333 } 347 334 348 335 if (this.filtered.length === 0) { 349 - listEl.innerHTML = `<p class="bm-message">No benchmarks match your filters.</p>` 336 + listEl.innerHTML = 337 + `<p class="bm-message">No benchmarks match your filters.</p>` 350 338 return 351 339 } 352 340 ··· 355 343 356 344 listEl.innerHTML = ` 357 345 <div class="bm-count"> 358 - ${this.filtered.length.toLocaleString()} benchmark${this.filtered.length === 1 ? '' : 's'} 346 + ${this.filtered.length.toLocaleString()} benchmark${ 347 + this.filtered.length === 1 ? '' : 's' 348 + } 359 349 </div> 360 - ${visible.map((b) => ` 350 + ${ 351 + visible.map((b) => ` 361 352 <button class="bm-item" data-bm-id="${b.id}" role="listitem"> 362 353 <div class="bm-item-main"> 363 354 <span class="bm-name">${escapeHtml(b.name)}</span> ··· 365 356 </div> 366 357 <div class="bm-item-meta"> 367 358 <span class="bm-setter">${escapeHtml(b.setter)}</span> 368 - <span class="bm-stats">★ ${b.avg_user_stars.toFixed(1)} · ${b.repeats.toLocaleString()}</span> 359 + <span class="bm-stats">★ ${ 360 + b.avg_user_stars.toFixed(1) 361 + } · ${b.repeats.toLocaleString()}</span> 369 362 </div> 370 363 </button> 371 - `).join('')} 372 - ${remaining > 0 ? ` 364 + `).join('') 365 + } 366 + ${ 367 + remaining > 0 368 + ? ` 373 369 <button class="bm-load-more" id="bm-load-more"> 374 370 Load more (${remaining.toLocaleString()} remaining) 375 371 </button> 376 - ` : ''} 372 + ` 373 + : '' 374 + } 377 375 ` 378 376 } 379 377 ··· 383 381 this.detailIndex = index 384 382 const climb = this.filtered[index] 385 383 386 - // Update global header 387 - const titleEl = document.querySelector('#page-title') 388 - if (titleEl) titleEl.textContent = climb.name 389 - this.showBackButton() 390 - 391 384 // Init HammerJS once 392 385 if (!this.hammer) { 393 386 const detailEl = this.querySelector<HTMLElement>('#bm-detail') ··· 410 403 this.querySelector('#bm-detail')?.classList.remove('open') 411 404 this.querySelector('#bm-detail')?.setAttribute('aria-hidden', 'true') 412 405 this.detailIndex = -1 413 - 414 - // Restore header 415 - const config = BOARD_CONFIGS[this.mbType] 416 - const titleEl = document.querySelector('#page-title') 417 - if (titleEl) titleEl.textContent = config?.label ?? 'Moonboard' 418 - this.hideBackButton() 419 406 } 420 407 421 408 private navigateDetail(direction: number): void { 422 409 const next = this.detailIndex + direction 423 410 if (next < 0 || next >= this.filtered.length) return 424 411 this.detailIndex = next 425 - const climb = this.filtered[next] 426 - const titleEl = document.querySelector('#page-title') 427 - if (titleEl) titleEl.textContent = climb.name 428 - this.renderDetailContent(climb) 412 + this.renderDetailContent(this.filtered[next]) 429 413 } 430 414 431 415 private renderDetailContent(climb: Benchmark): void { 432 - const contentEl = this.querySelector('#bm-detail-content') 433 - if (!contentEl) return 416 + const headerEl = this.querySelector<HTMLElement>('#bm-detail-header') 417 + const contentEl = this.querySelector<HTMLElement>('#bm-detail-content') 418 + if (!headerEl || !contentEl) return 434 419 435 420 const config = BOARD_CONFIGS[this.mbType] ?? BOARD_CONFIGS[0] 436 - const date = new Date(climb.date_created).toLocaleDateString('en-US', { 437 - year: 'numeric', 438 - month: 'short', 439 - }) 440 421 const height = canvasHeight(config.rows) 441 422 423 + headerEl.innerHTML = ` 424 + <button class="bm-dh-back" aria-label="Back"> 425 + <img src="/static/icons/chevron-right.svg" alt="" aria-hidden="true"> 426 + </button> 427 + <div class="bm-dh-info"> 428 + <div class="bm-dh-name">${escapeHtml(climb.name)}</div> 429 + <div class="bm-dh-sub">${GRADE_FULL[climb.grade] ?? ''} ${ 430 + sandbagLabel(climb.sandbag_score) 431 + } · ${escapeHtml(climb.setter)}</div> 432 + </div> 433 + <div class="bm-dh-meta"> 434 + <span>★ ${climb.avg_user_stars.toFixed(1)}</span> 435 + <span>${climb.repeats.toLocaleString()} sends</span> 436 + </div> 437 + ` 438 + headerEl.querySelector('.bm-dh-back')?.addEventListener( 439 + 'click', 440 + () => this.closeDetail(), 441 + ) 442 + 442 443 const boardHtml = config.image 443 444 ? ` 444 445 <div class="bm-board-wrap"> 445 - <img src="${config.image}" class="bm-board-img" alt="${escapeHtml(config.label)} board layout" loading="lazy"> 446 + <img src="${config.image}" class="bm-board-img" alt="${ 447 + escapeHtml(config.label) 448 + } board layout" loading="lazy"> 446 449 <canvas 447 450 class="bm-board-canvas" 448 451 id="bm-canvas" ··· 465 468 ` 466 469 467 470 contentEl.innerHTML = ` 468 - <div class="bm-dh-name">${escapeHtml(climb.name)}</div> 469 - <div class="bm-dh-sub">${GRADE_FULL[climb.grade] ?? ''} · by ${escapeHtml(climb.setter)} · ${date}</div> 470 - 471 - <div class="bm-detail-stats"> 472 - <div class="bm-stat"> 473 - <span class="bm-stat-label">Stars</span> 474 - <span class="bm-stat-value">★ ${climb.avg_user_stars.toFixed(1)}</span> 475 - </div> 476 - <div class="bm-stat"> 477 - <span class="bm-stat-label">Sends</span> 478 - <span class="bm-stat-value">${climb.repeats.toLocaleString()}</span> 479 - </div> 480 - <div class="bm-stat"> 481 - <span class="bm-stat-label">Avg Attempts</span> 482 - <span class="bm-stat-value">${climb.avg_user_attempts.toFixed(1)}</span> 483 - </div> 484 - <div class="bm-stat"> 485 - <span class="bm-stat-label">Sandbag</span> 486 - <span class="bm-stat-value">${sandbagLabel(climb.sandbag_score)}</span> 487 - </div> 488 - </div> 489 - 490 471 ${boardHtml} 491 472 492 473 <a ··· 500 481 </a> 501 482 ` 502 483 503 - // Scroll content area to top on each new climb 504 484 contentEl.scrollTop = 0 505 485 506 - // Update position indicator and nav button states 507 - this.updateDetailNav() 508 - 509 486 requestAnimationFrame(() => { 510 487 const canvas = this.querySelector<HTMLCanvasElement>('#bm-canvas') 511 488 if (canvas) drawClimb(canvas, climb, config.rows) 512 489 }) 513 - } 514 - 515 - private updateDetailNav(): void { 516 - const posEl = this.querySelector('#bm-pos') 517 - if (posEl) { 518 - posEl.textContent = `${this.detailIndex + 1} / ${this.filtered.length}` 519 - } 520 - const prevBtn = this.querySelector<HTMLButtonElement>('#bm-prev') 521 - const nextBtn = this.querySelector<HTMLButtonElement>('#bm-next') 522 - if (prevBtn) prevBtn.disabled = this.detailIndex <= 0 523 - if (nextBtn) nextBtn.disabled = 524 - this.detailIndex >= this.filtered.length - 1 525 - } 526 - 527 - // ── Global header helpers ─────────────────────────────────────────────── 528 - 529 - private showBackButton(): void { 530 - const btn = document.querySelector<HTMLButtonElement>('#header-back') 531 - if (!btn) return 532 - btn.hidden = false 533 - btn.onclick = () => this.closeDetail() 534 - } 535 - 536 - private hideBackButton(): void { 537 - const btn = document.querySelector<HTMLButtonElement>('#header-back') 538 - if (!btn) return 539 - btn.hidden = true 540 - btn.onclick = null 541 490 } 542 491 } 543 492
+30 -21
www/routes/settings.ts
··· 39 39 <h2>Board Setup</h2> 40 40 <p class="st-desc">Select which Moonboard setup you are using.</p> 41 41 <div class="st-options" role="radiogroup" aria-label="Board setup"> 42 - ${BOARD_OPTIONS.map((opt) => ` 43 - <label class="st-option ${this.selectedType === opt.mb_type ? 'st-option--selected' : ''}"> 42 + ${ 43 + BOARD_OPTIONS.map((opt) => ` 44 + <label class="st-option ${ 45 + this.selectedType === opt.mb_type ? 'st-option--selected' : '' 46 + }"> 44 47 <input 45 48 type="radio" 46 49 name="mb_type" ··· 49 52 > 50 53 <span>${opt.label}</span> 51 54 </label> 52 - `).join('')} 55 + `).join('') 56 + } 53 57 </div> 54 58 </section> 55 59 ··· 57 61 <h2>Grade Scale</h2> 58 62 <p class="st-desc">Choose how grades are displayed throughout the app.</p> 59 63 <div class="st-options" role="radiogroup" aria-label="Grade scale"> 60 - ${GRADE_SCALE_OPTIONS.map((opt) => ` 61 - <label class="st-option ${this.gradeScale === opt.value ? 'st-option--selected' : ''}"> 64 + ${ 65 + GRADE_SCALE_OPTIONS.map((opt) => ` 66 + <label class="st-option ${ 67 + this.gradeScale === opt.value ? 'st-option--selected' : '' 68 + }"> 62 69 <input 63 70 type="radio" 64 71 name="grade_scale" ··· 70 77 <span class="st-option-example">${opt.example}</span> 71 78 </div> 72 79 </label> 73 - `).join('')} 80 + `).join('') 81 + } 74 82 </div> 75 83 </section> 76 84 </div> ··· 94 102 }, 95 103 ) 96 104 97 - this.querySelectorAll<HTMLInputElement>('input[name="grade_scale"]').forEach( 98 - (input) => { 99 - input.addEventListener('change', (e) => { 100 - const value = (e.target as HTMLInputElement).value 101 - localStorage.setItem('grade_scale', value) 102 - this.gradeScale = value 103 - this.querySelectorAll('[name="grade_scale"]').forEach((el) => 104 - el.closest('.st-option')?.classList.remove('st-option--selected') 105 - ) 106 - ;(e.target as HTMLInputElement) 107 - .closest('.st-option') 108 - ?.classList.add('st-option--selected') 109 - }) 110 - }, 111 - ) 105 + this.querySelectorAll<HTMLInputElement>('input[name="grade_scale"]') 106 + .forEach( 107 + (input) => { 108 + input.addEventListener('change', (e) => { 109 + const value = (e.target as HTMLInputElement).value 110 + localStorage.setItem('grade_scale', value) 111 + this.gradeScale = value 112 + this.querySelectorAll('[name="grade_scale"]').forEach((el) => 113 + el.closest('.st-option')?.classList.remove('st-option--selected') 114 + ) 115 + ;(e.target as HTMLInputElement) 116 + .closest('.st-option') 117 + ?.classList.add('st-option--selected') 118 + }) 119 + }, 120 + ) 112 121 } 113 122 } 114 123
+7 -5
www/routes/stopwatch.ts
··· 1 - import Stopwatch, { 2 - type StopwatchState, 3 - } from '@inro/simple-tools/stopwatch' 1 + import Stopwatch, { type StopwatchState } from '@inro/simple-tools/stopwatch' 4 2 5 3 // Singleton — persists across route navigation 6 4 export const globalStopwatch = new Stopwatch() ··· 216 214 const lapsHTML = laps 217 215 .map((lap, index) => { 218 216 const lapNumber = laps.length - index 219 - const ms = typeof lap === 'number' ? lap : (lap as { elapsed: number }).elapsed 217 + const ms = typeof lap === 'number' 218 + ? lap 219 + : (lap as { elapsed: number }).elapsed 220 220 return ` 221 221 <div class="lap-item"> 222 222 <span class="lap-number">Lap ${lapNumber}</span> ··· 253 253 const minutes = Math.floor((totalSeconds % 3600) / 60) 254 254 const seconds = totalSeconds % 60 255 255 if (hours > 0) { 256 - return `${hours}:${String(minutes).padStart(2, '0')}:${String(seconds).padStart(2, '0')}` 256 + return `${hours}:${String(minutes).padStart(2, '0')}:${ 257 + String(seconds).padStart(2, '0') 258 + }` 257 259 } 258 260 return `${minutes}:${String(seconds).padStart(2, '0')}` 259 261 }
www/static/images/mbsetup-2016.jpg

This is a binary file and will not be displayed.

www/static/images/mbsetup-2016.png

This is a binary file and will not be displayed.

www/static/images/mbsetup-2017.jpg

This is a binary file and will not be displayed.

www/static/images/mbsetup-2017.png

This is a binary file and will not be displayed.

www/static/images/mbsetup-2019.jpg

This is a binary file and will not be displayed.

www/static/images/mbsetup-2019.png

This is a binary file and will not be displayed.

www/static/images/mbsetup-2020.png

This is a binary file and will not be displayed.

www/static/images/mbsetup-2024.jpg

This is a binary file and will not be displayed.

www/static/images/mbsetup-2024.png

This is a binary file and will not be displayed.

+48 -100
www/static/theme.css
··· 207 207 white-space: nowrap; 208 208 } 209 209 210 - /* Back button in header */ 211 - .header-back-btn { 212 - display: flex; 213 - align-items: center; 214 - justify-content: center; 215 - background: transparent; 216 - border: none; 217 - padding: var(--s1); 218 - margin: 0; 219 - cursor: pointer; 220 - border-radius: var(--br-base); 221 - flex-shrink: 0; 222 - opacity: 0.8; 223 - transition: opacity var(--transition-fast); 224 - } 225 - 226 - .header-back-btn:hover { 227 - opacity: 1; 228 - transform: none; 229 - } 230 - 231 - .header-back-icon { 232 - width: 22px; 233 - height: 22px; 234 - transform: scaleX(-1); 235 - display: block; 236 - } 237 - 238 210 /* ── Fixed bottom tab bar ──────────────────────────────────────────── */ 239 211 footer[fixed] { 240 212 padding: 0; ··· 444 416 padding: var(--s3); 445 417 } 446 418 447 - /* Climb headline inside detail body */ 448 - .bm-dh-name { 449 - font-size: var(--f2); 450 - font-weight: var(--fw-bold); 451 - margin: 0 0 var(--s1) 0; 452 - line-height: var(--lh-tight); 453 - } 454 - 455 - .bm-dh-sub { 456 - font-size: var(--f6); 457 - opacity: 0.6; 458 - margin: 0 0 var(--s3) 0; 459 - } 460 - 461 - /* Detail footer nav */ 462 - .bm-detail-footer { 463 - flex-shrink: 0; 419 + /* Climb detail sticky header bar */ 420 + .bm-dh-header { 464 421 display: flex; 465 422 align-items: center; 466 - justify-content: space-between; 423 + gap: var(--s2); 467 424 padding: var(--s2) var(--s3); 468 - border-top: 1px solid currentColor; 469 - gap: var(--s2); 425 + border-bottom: 1px solid currentColor; 426 + flex-shrink: 0; 470 427 } 471 428 472 - .bm-nav-btn { 429 + .bm-dh-back { 473 430 display: flex; 474 431 align-items: center; 475 - gap: var(--s1); 476 - padding: var(--s2) var(--s3); 477 - border: 1px solid currentColor; 478 - border-radius: var(--br-base); 432 + justify-content: center; 479 433 background: transparent; 480 - color: inherit; 434 + border: none; 435 + padding: var(--s1); 481 436 cursor: pointer; 482 - font-size: var(--f6); 483 - font-weight: var(--fw-medium); 437 + border-radius: var(--br-base); 438 + flex-shrink: 0; 439 + opacity: 0.8; 484 440 transition: opacity var(--transition-fast); 485 - white-space: nowrap; 486 441 } 487 442 488 - .bm-nav-btn:disabled { 489 - opacity: 0.25; 490 - cursor: not-allowed; 491 - transform: none; 443 + .bm-dh-back:hover { 444 + opacity: 1; 492 445 } 493 446 494 - .bm-nav-btn img { 495 - width: 16px; 496 - height: 16px; 497 - flex-shrink: 0; 498 - } 499 - 500 - .bm-chevron-flip { 447 + .bm-dh-back img { 448 + width: 20px; 449 + height: 20px; 501 450 transform: scaleX(-1); 502 - } 503 - 504 - .bm-pos { 505 - font-size: var(--f6); 506 - opacity: 0.6; 507 - font-variant-numeric: tabular-nums; 508 - white-space: nowrap; 451 + display: block; 509 452 } 510 453 511 - .bm-detail-setter { 512 - font-size: var(--f6); 513 - opacity: 0.6; 514 - margin: 0 0 var(--s3) 0; 515 - } 516 - 517 - .bm-detail-stats { 518 - display: grid; 519 - grid-template-columns: repeat(2, 1fr); 520 - gap: var(--s2); 521 - margin-bottom: var(--s3); 454 + .bm-dh-info { 455 + flex: 1; 456 + min-width: 0; 522 457 } 523 458 524 - .bm-stat { 525 - border: 1px solid currentColor; 526 - border-radius: var(--br-base); 527 - padding: var(--s2) var(--s3); 459 + .bm-dh-name { 460 + font-size: var(--f4); 461 + font-weight: var(--fw-bold); 462 + margin: 0; 463 + line-height: var(--lh-tight); 464 + overflow: hidden; 465 + text-overflow: ellipsis; 466 + white-space: nowrap; 528 467 } 529 468 530 - .bm-stat-label { 469 + .bm-dh-sub { 531 470 font-size: var(--f7); 532 471 opacity: 0.6; 533 - display: block; 534 - margin-bottom: 2px; 535 - text-transform: uppercase; 536 - letter-spacing: 0.05em; 472 + margin: 0; 473 + overflow: hidden; 474 + text-overflow: ellipsis; 475 + white-space: nowrap; 537 476 } 538 477 539 - .bm-stat-value { 540 - font-size: var(--f4); 541 - font-weight: var(--fw-semibold); 478 + .bm-dh-meta { 479 + flex-shrink: 0; 480 + display: flex; 481 + flex-direction: column; 482 + align-items: flex-end; 483 + gap: 2px; 484 + font-size: var(--f6); 485 + font-weight: var(--fw-medium); 486 + white-space: nowrap; 487 + opacity: 0.8; 542 488 } 543 489 544 490 /* Board canvas container */ 545 491 .bm-board-wrap { 546 492 position: relative; 547 - margin-bottom: var(--s3); 493 + max-width: 480px; 494 + margin: 0 auto var(--s3); 548 495 border-radius: var(--br-base); 549 496 overflow: hidden; 550 497 } ··· 566 513 background: #1a1a1a; 567 514 aspect-ratio: 450 / 485; 568 515 border-radius: var(--br-base); 569 - margin-bottom: var(--s3); 516 + max-width: 480px; 517 + margin: 0 auto var(--s3); 570 518 position: relative; 571 519 overflow: hidden; 572 520 }