An app for logging board climbs
0
fork

Configure Feed

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

chore: continue refactor

+366 -335
+61
www/components/climb-header.ts
··· 1 + import { 2 + type Benchmark, 3 + escapeHtml, 4 + GRADE_FULL, 5 + sandbagLabel, 6 + } from '../utils/benchmarks.ts' 7 + import { getClimbNav } from '../utils/climb-nav.ts' 8 + import { getClimbLog } from '../utils/logbook.ts' 9 + 10 + export let activeClimbHeader: ClimbHeader | null = null 11 + 12 + export class ClimbHeader extends HTMLElement { 13 + connectedCallback() { 14 + activeClimbHeader = this 15 + } 16 + 17 + disconnectedCallback() { 18 + if (activeClimbHeader === this) activeClimbHeader = null 19 + } 20 + 21 + update(climb: Benchmark): void { 22 + const nav = getClimbNav()! 23 + this.innerHTML = ` 24 + <button class="back" aria-label="Back"> 25 + <img src="/static/icons/chevron-right.svg" alt="" aria-hidden="true"> 26 + </button> 27 + <div class="title-info"> 28 + <strong class="name">${escapeHtml(climb.name)}</strong> 29 + <small class="subtitle">${GRADE_FULL[climb.grade] ?? ''} ${ 30 + sandbagLabel(climb.sandbag_score) 31 + } · ${escapeHtml(climb.setter)}</small> 32 + </div> 33 + <div class="meta"> 34 + ${this.metaHtml(climb)} 35 + </div> 36 + ` 37 + this.querySelector('.back')?.addEventListener('click', () => { 38 + globalThis.location.hash = nav.backRoute 39 + }) 40 + } 41 + 42 + metaHtml(climb: Benchmark): string { 43 + const entry = getClimbLog(climb.id) 44 + if (entry) { 45 + const badge = entry.sent 46 + ? '<ui-badge variant="success">Sent</ui-badge>' 47 + : '<ui-badge>Project</ui-badge>' 48 + const stars = entry.rating ? '★'.repeat(entry.rating) : '' 49 + return ` 50 + <span>${badge} ${stars}</span> 51 + <span>${entry.totalAttempts} attempts</span> 52 + ` 53 + } 54 + return ` 55 + <span>★ ${climb.avg_user_stars.toFixed(1)}</span> 56 + <span>${climb.repeats.toLocaleString()} sends</span> 57 + ` 58 + } 59 + } 60 + 61 + customElements.define('climb-header', ClimbHeader)
+163
www/components/home-filters.ts
··· 1 + import { GRADE_FRENCH, GRADE_V } from '../utils/benchmarks.ts' 2 + 3 + export const PAGE_SIZE = 50 4 + 5 + type LogFilter = 'all' | 'sent' | 'unsent' 6 + 7 + export const state = { 8 + search: '', 9 + gradeMin: 0, 10 + gradeMax: 16, 11 + logFilter: 'all' as LogFilter, 12 + shown: PAGE_SIZE, 13 + } 14 + 15 + export const emitter = new EventTarget() 16 + 17 + export class HomeFilters extends HTMLElement { 18 + private gradeScale: 'french' | 'v' = 'french' 19 + 20 + connectedCallback() { 21 + state.search = '' 22 + state.gradeMin = parseInt(localStorage.getItem('home_grade_min') ?? '0', 10) 23 + state.gradeMax = parseInt( 24 + localStorage.getItem('home_grade_max') ?? '16', 25 + 10, 26 + ) 27 + state.logFilter = (localStorage.getItem('home_filter') as LogFilter) ?? 28 + 'all' 29 + state.shown = PAGE_SIZE 30 + this.gradeScale = (localStorage.getItem('grade_scale') as 'french' | 'v') ?? 31 + 'french' 32 + this.render() 33 + this.bindEvents() 34 + } 35 + 36 + private render() { 37 + this.innerHTML = ` 38 + <input 39 + type="search" 40 + id="bm-search" 41 + placeholder="Search by name or setter..." 42 + autocomplete="off" 43 + > 44 + <ui-button-group id="bm-log-filter"> 45 + ${this.logFilterHtml()} 46 + </ui-button-group> 47 + <div class="grade-filter"> 48 + <label for="bm-grade-min">Grade</label> 49 + <select id="bm-grade-min"> 50 + ${this.gradeOptions('min')} 51 + </select> 52 + <span aria-hidden="true">–</span> 53 + <select id="bm-grade-max"> 54 + ${this.gradeOptions('max')} 55 + </select> 56 + </div> 57 + ` 58 + } 59 + 60 + private logFilterHtml(): string { 61 + const opts: { value: LogFilter; label: string }[] = [ 62 + { value: 'all', label: 'All' }, 63 + { value: 'sent', label: 'Completed' }, 64 + { value: 'unsent', label: 'Not Completed' }, 65 + ] 66 + return opts 67 + .map( 68 + (o) => 69 + `<button aria-pressed="${ 70 + state.logFilter === o.value 71 + }" data-log-filter="${o.value}">${o.label}</button>`, 72 + ) 73 + .join('') 74 + } 75 + 76 + private gradeGroups(): { label: string; first: number; last: number }[] { 77 + const table = this.gradeScale === 'v' ? GRADE_V : GRADE_FRENCH 78 + const groups: { label: string; first: number; last: number }[] = [] 79 + for (let i = 0; i <= 16; i++) { 80 + const label = table[i] ?? '?' 81 + const prev = groups[groups.length - 1] 82 + if (prev && prev.label === label) { 83 + prev.last = i 84 + } else { 85 + groups.push({ label, first: i, last: i }) 86 + } 87 + } 88 + return groups 89 + } 90 + 91 + private gradeOptions(role: 'min' | 'max'): string { 92 + const current = role === 'min' ? state.gradeMin : state.gradeMax 93 + return this.gradeGroups() 94 + .map((g) => { 95 + const value = role === 'min' ? g.first : g.last 96 + const selected = current >= g.first && current <= g.last 97 + return `<option value="${value}" ${ 98 + selected ? 'selected' : '' 99 + }>${g.label}</option>` 100 + }) 101 + .join('') 102 + } 103 + 104 + private bindEvents() { 105 + const searchEl = this.querySelector<HTMLInputElement>('#bm-search') 106 + searchEl?.addEventListener('input', (e) => { 107 + state.search = (e.target as HTMLInputElement).value 108 + state.shown = PAGE_SIZE 109 + emitter.dispatchEvent(new Event('filter')) 110 + }) 111 + 112 + const gradeMinEl = this.querySelector<HTMLSelectElement>('#bm-grade-min') 113 + gradeMinEl?.addEventListener('change', (e) => { 114 + const val = parseInt((e.target as HTMLSelectElement).value, 10) 115 + state.gradeMin = val 116 + if (state.gradeMax < val) { 117 + const group = this.gradeGroups().find( 118 + (g) => g.first <= val && val <= g.last, 119 + ) 120 + state.gradeMax = group?.last ?? val 121 + const maxEl = this.querySelector<HTMLSelectElement>('#bm-grade-max') 122 + if (maxEl) maxEl.value = state.gradeMax.toString() 123 + } 124 + localStorage.setItem('home_grade_min', state.gradeMin.toString()) 125 + localStorage.setItem('home_grade_max', state.gradeMax.toString()) 126 + state.shown = PAGE_SIZE 127 + emitter.dispatchEvent(new Event('filter')) 128 + }) 129 + 130 + const gradeMaxEl = this.querySelector<HTMLSelectElement>('#bm-grade-max') 131 + gradeMaxEl?.addEventListener('change', (e) => { 132 + const val = parseInt((e.target as HTMLSelectElement).value, 10) 133 + state.gradeMax = val 134 + if (state.gradeMin > val) { 135 + const group = this.gradeGroups().find( 136 + (g) => g.first <= val && val <= g.last, 137 + ) 138 + state.gradeMin = group?.first ?? val 139 + const minEl = this.querySelector<HTMLSelectElement>('#bm-grade-min') 140 + if (minEl) minEl.value = state.gradeMin.toString() 141 + } 142 + localStorage.setItem('home_grade_min', state.gradeMin.toString()) 143 + localStorage.setItem('home_grade_max', state.gradeMax.toString()) 144 + state.shown = PAGE_SIZE 145 + emitter.dispatchEvent(new Event('filter')) 146 + }) 147 + 148 + this.querySelector('#bm-log-filter')?.addEventListener('click', (e) => { 149 + const btn = (e.target as HTMLElement).closest<HTMLElement>( 150 + '[data-log-filter]', 151 + ) 152 + if (!btn) return 153 + state.logFilter = btn.dataset.logFilter as LogFilter 154 + localStorage.setItem('home_filter', state.logFilter) 155 + const bar = this.querySelector('#bm-log-filter') 156 + if (bar) bar.innerHTML = this.logFilterHtml() 157 + state.shown = PAGE_SIZE 158 + emitter.dispatchEvent(new Event('filter')) 159 + }) 160 + } 161 + } 162 + 163 + customElements.define('home-filters', HomeFilters)
+53
www/components/library-filters.ts
··· 1 + type Filter = 'all' | 'sent' | 'unsent' 2 + 3 + export const libState = { filter: 'all' as Filter } 4 + export const libEmitter = new EventTarget() 5 + 6 + export class LibraryFilters extends HTMLElement { 7 + connectedCallback() { 8 + libState.filter = (localStorage.getItem('library_filter') as Filter) ?? 9 + 'all' 10 + this.render() 11 + this.addEventListener('click', this.#handleClick) 12 + } 13 + 14 + disconnectedCallback() { 15 + this.removeEventListener('click', this.#handleClick) 16 + } 17 + 18 + private render() { 19 + this.innerHTML = ` 20 + <ui-button-group> 21 + ${this.filterBarHtml()} 22 + </ui-button-group> 23 + ` 24 + } 25 + 26 + private filterBarHtml(): string { 27 + const opts: { value: Filter; label: string }[] = [ 28 + { value: 'all', label: 'All' }, 29 + { value: 'sent', label: 'Completed' }, 30 + { value: 'unsent', label: 'Not Completed' }, 31 + ] 32 + return opts 33 + .map( 34 + (o) => 35 + `<button aria-pressed="${ 36 + libState.filter === o.value 37 + }" data-filter="${o.value}">${o.label}</button>`, 38 + ) 39 + .join('') 40 + } 41 + 42 + #handleClick = (e: Event) => { 43 + const btn = (e.target as HTMLElement).closest<HTMLElement>('[data-filter]') 44 + if (!btn) return 45 + libState.filter = btn.dataset.filter as Filter 46 + localStorage.setItem('library_filter', libState.filter) 47 + const group = this.querySelector('ui-button-group') 48 + if (group) group.innerHTML = this.filterBarHtml() 49 + libEmitter.dispatchEvent(new Event('filter')) 50 + } 51 + } 52 + 53 + customElements.define('library-filters', LibraryFilters)
+5 -2
www/index.html
··· 2 2 <html lang="en"> 3 3 <head> 4 4 <meta charset="utf-8" /> 5 - <meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover"> 5 + <meta 6 + name="viewport" 7 + content="width=device-width, initial-scale=1, viewport-fit=cover" 8 + > 6 9 <meta name="mobile-web-app-capable" content="yes"> 7 10 <meta name="apple-mobile-web-app-capable" content="yes"> 8 11 ··· 26 29 <strong id="page-title">Moonboard</strong> 27 30 </header> 28 31 29 - <ui-sub-header id="sub-header"></ui-sub-header> 32 + <ui-sub-header id="sub-header" hidden></ui-sub-header> 30 33 31 34 <main id="main"> 32 35 <ui-spinner></ui-spinner>
+6 -1
www/index.ts
··· 96 96 const subHeader = document.querySelector<HTMLElement>('#sub-header') 97 97 if (subHeader) { 98 98 subHeader.innerHTML = '' 99 - if (subHeaderTag) subHeader.appendChild(document.createElement(subHeaderTag)) 99 + if (subHeaderTag) { 100 + subHeader.hidden = false 101 + subHeader.appendChild(document.createElement(subHeaderTag)) 102 + } else { 103 + subHeader.hidden = true 104 + } 100 105 } 101 106 this.mainElement = document.querySelector('main') 102 107 if (!this.mainElement) return
+1 -54
www/routes/climb.ts
··· 6 6 canvasHeight, 7 7 drawClimb, 8 8 escapeHtml, 9 - GRADE_FULL, 10 9 loadBenchmarks, 11 - sandbagLabel, 12 10 youtubeUrl, 13 11 } from '../utils/benchmarks.ts' 14 12 import { getClimbNav, setClimbNav } from '../utils/climb-nav.ts' 15 13 import { getClimbLog, logSession } from '../utils/logbook.ts' 16 - 17 - let activeClimbHeader: ClimbHeader | null = null 18 - 19 - export class ClimbHeader extends HTMLElement { 20 - connectedCallback() { 21 - activeClimbHeader = this 22 - } 23 - 24 - disconnectedCallback() { 25 - if (activeClimbHeader === this) activeClimbHeader = null 26 - } 27 - 28 - update(climb: Benchmark): void { 29 - const nav = getClimbNav()! 30 - this.innerHTML = ` 31 - <button class="back" aria-label="Back"> 32 - <img src="/static/icons/chevron-right.svg" alt="" aria-hidden="true"> 33 - </button> 34 - <div class="title-info"> 35 - <strong class="name">${escapeHtml(climb.name)}</strong> 36 - <small class="subtitle">${GRADE_FULL[climb.grade] ?? ''} ${ 37 - sandbagLabel(climb.sandbag_score) 38 - } · ${escapeHtml(climb.setter)}</small> 39 - </div> 40 - <div class="meta"> 41 - ${this.metaHtml(climb)} 42 - </div> 43 - ` 44 - this.querySelector('.back')?.addEventListener('click', () => { 45 - globalThis.location.hash = nav.backRoute 46 - }) 47 - } 48 - 49 - metaHtml(climb: Benchmark): string { 50 - const entry = getClimbLog(climb.id) 51 - if (entry) { 52 - const badge = entry.sent 53 - ? '<ui-badge variant="success">Sent</ui-badge>' 54 - : '<ui-badge>Project</ui-badge>' 55 - const stars = entry.rating ? '★'.repeat(entry.rating) : '' 56 - return ` 57 - <span> ${badge} ${stars ? `${stars}` : ''}</span> 58 - <span> ${entry.totalAttempts} attempts</span> 59 - ` 60 - } 61 - return ` 62 - <span>★ ${climb.avg_user_stars.toFixed(1)}</span> 63 - <span>${climb.repeats.toLocaleString()} sends</span> 64 - ` 65 - } 66 - } 14 + import { activeClimbHeader } from '../components/climb-header.ts' 67 15 68 16 export class ClimbPage extends HTMLElement { 69 17 private hammer: HammerManager | null = null ··· 325 273 } 326 274 } 327 275 328 - customElements.define('climb-header', ClimbHeader) 329 276 customElements.define('climb-page', ClimbPage)
+7 -163
www/routes/home.ts
··· 2 2 type Benchmark, 3 3 BOARD_CONFIGS, 4 4 escapeHtml, 5 - GRADE_FRENCH, 6 - GRADE_V, 5 + gradeLabel, 7 6 loadBenchmarks, 8 7 } from '../utils/benchmarks.ts' 9 8 import { setClimbNav } from '../utils/climb-nav.ts' 10 9 import { loadLogbook } from '../utils/logbook.ts' 11 - 12 - const PAGE_SIZE = 50 13 - 14 - type LogFilter = 'all' | 'sent' | 'unsent' 15 - 16 - const state = { 17 - search: '', 18 - gradeMin: 0, 19 - gradeMax: 16, 20 - logFilter: 'all' as LogFilter, 21 - shown: PAGE_SIZE, 22 - } 23 - 24 - const emitter = new EventTarget() 25 - 26 - export class HomeFilters extends HTMLElement { 27 - private gradeScale: 'french' | 'v' = 'french' 28 - 29 - connectedCallback() { 30 - state.search = '' 31 - state.gradeMin = parseInt(localStorage.getItem('home_grade_min') ?? '0', 10) 32 - state.gradeMax = parseInt(localStorage.getItem('home_grade_max') ?? '16', 10) 33 - state.logFilter = (localStorage.getItem('home_filter') as LogFilter) ?? 'all' 34 - state.shown = PAGE_SIZE 35 - this.gradeScale = 36 - (localStorage.getItem('grade_scale') as 'french' | 'v') ?? 'french' 37 - this.render() 38 - this.bindEvents() 39 - } 40 - 41 - private render() { 42 - this.innerHTML = ` 43 - <input 44 - type="search" 45 - id="bm-search" 46 - placeholder="Search by name or setter..." 47 - autocomplete="off" 48 - > 49 - <ui-button-group id="bm-log-filter"> 50 - ${this.logFilterHtml()} 51 - </ui-button-group> 52 - <div class="grade-filter"> 53 - <label for="bm-grade-min">Grade</label> 54 - <select id="bm-grade-min"> 55 - ${this.gradeOptions('min')} 56 - </select> 57 - <span aria-hidden="true">–</span> 58 - <select id="bm-grade-max"> 59 - ${this.gradeOptions('max')} 60 - </select> 61 - </div> 62 - ` 63 - } 64 - 65 - private logFilterHtml(): string { 66 - const opts: { value: LogFilter; label: string }[] = [ 67 - { value: 'all', label: 'All' }, 68 - { value: 'sent', label: 'Completed' }, 69 - { value: 'unsent', label: 'Not Completed' }, 70 - ] 71 - return opts 72 - .map( 73 - (o) => 74 - `<button aria-pressed="${state.logFilter === o.value}" data-log-filter="${o.value}">${o.label}</button>`, 75 - ) 76 - .join('') 77 - } 78 - 79 - private gradeGroups(): { label: string; first: number; last: number }[] { 80 - const table = this.gradeScale === 'v' ? GRADE_V : GRADE_FRENCH 81 - const groups: { label: string; first: number; last: number }[] = [] 82 - for (let i = 0; i <= 16; i++) { 83 - const label = table[i] ?? '?' 84 - const prev = groups[groups.length - 1] 85 - if (prev && prev.label === label) { 86 - prev.last = i 87 - } else { 88 - groups.push({ label, first: i, last: i }) 89 - } 90 - } 91 - return groups 92 - } 93 - 94 - private gradeOptions(role: 'min' | 'max'): string { 95 - const current = role === 'min' ? state.gradeMin : state.gradeMax 96 - return this.gradeGroups() 97 - .map((g) => { 98 - const value = role === 'min' ? g.first : g.last 99 - const selected = current >= g.first && current <= g.last 100 - return `<option value="${value}" ${selected ? 'selected' : ''}>${g.label}</option>` 101 - }) 102 - .join('') 103 - } 104 - 105 - private bindEvents() { 106 - const searchEl = this.querySelector<HTMLInputElement>('#bm-search') 107 - searchEl?.addEventListener('input', (e) => { 108 - state.search = (e.target as HTMLInputElement).value 109 - state.shown = PAGE_SIZE 110 - emitter.dispatchEvent(new Event('filter')) 111 - }) 112 - 113 - const gradeMinEl = this.querySelector<HTMLSelectElement>('#bm-grade-min') 114 - gradeMinEl?.addEventListener('change', (e) => { 115 - const val = parseInt((e.target as HTMLSelectElement).value, 10) 116 - state.gradeMin = val 117 - if (state.gradeMax < val) { 118 - const group = this.gradeGroups().find( 119 - (g) => g.first <= val && val <= g.last, 120 - ) 121 - state.gradeMax = group?.last ?? val 122 - const maxEl = this.querySelector<HTMLSelectElement>('#bm-grade-max') 123 - if (maxEl) maxEl.value = state.gradeMax.toString() 124 - } 125 - localStorage.setItem('home_grade_min', state.gradeMin.toString()) 126 - localStorage.setItem('home_grade_max', state.gradeMax.toString()) 127 - state.shown = PAGE_SIZE 128 - emitter.dispatchEvent(new Event('filter')) 129 - }) 130 - 131 - const gradeMaxEl = this.querySelector<HTMLSelectElement>('#bm-grade-max') 132 - gradeMaxEl?.addEventListener('change', (e) => { 133 - const val = parseInt((e.target as HTMLSelectElement).value, 10) 134 - state.gradeMax = val 135 - if (state.gradeMin > val) { 136 - const group = this.gradeGroups().find( 137 - (g) => g.first <= val && val <= g.last, 138 - ) 139 - state.gradeMin = group?.first ?? val 140 - const minEl = this.querySelector<HTMLSelectElement>('#bm-grade-min') 141 - if (minEl) minEl.value = state.gradeMin.toString() 142 - } 143 - localStorage.setItem('home_grade_min', state.gradeMin.toString()) 144 - localStorage.setItem('home_grade_max', state.gradeMax.toString()) 145 - state.shown = PAGE_SIZE 146 - emitter.dispatchEvent(new Event('filter')) 147 - }) 148 - 149 - this.querySelector('#bm-log-filter')?.addEventListener('click', (e) => { 150 - const btn = (e.target as HTMLElement).closest<HTMLElement>( 151 - '[data-log-filter]', 152 - ) 153 - if (!btn) return 154 - state.logFilter = btn.dataset.logFilter as LogFilter 155 - localStorage.setItem('home_filter', state.logFilter) 156 - const bar = this.querySelector('#bm-log-filter') 157 - if (bar) bar.innerHTML = this.logFilterHtml() 158 - state.shown = PAGE_SIZE 159 - emitter.dispatchEvent(new Event('filter')) 160 - }) 161 - } 162 - } 10 + import { emitter, PAGE_SIZE, state } from '../components/home-filters.ts' 163 11 164 12 export class HomePage extends HTMLElement { 165 13 private benchmarks: Benchmark[] = [] ··· 167 15 private mbType = 0 168 16 private gradeScale: 'french' | 'v' = 'french' 169 17 170 - private gradeLabel(grade: number): string { 171 - const table = this.gradeScale === 'v' ? GRADE_V : GRADE_FRENCH 172 - return table[grade] ?? '?' 173 - } 174 - 175 18 async connectedCallback() { 176 19 this.mbType = parseInt(localStorage.getItem('mb_type') ?? '0', 10) 177 - this.gradeScale = 178 - (localStorage.getItem('grade_scale') as 'french' | 'v') ?? 'french' 20 + this.gradeScale = (localStorage.getItem('grade_scale') as 'french' | 'v') ?? 21 + 'french' 179 22 180 23 const titleEl = document.querySelector('#page-title') 181 24 if (titleEl) { ··· 287 130 <div class="bm-item-main"> 288 131 <span class="bm-name">${escapeHtml(b.name)}</span> 289 132 ${sentTag} 290 - <ui-badge class="grade">${this.gradeLabel(b.grade)}</ui-badge> 133 + <ui-badge class="grade">${ 134 + gradeLabel(b.grade, this.gradeScale) 135 + }</ui-badge> 291 136 </div> 292 137 <div class="bm-item-meta"> 293 138 <span class="bm-setter">${escapeHtml(b.setter)}</span> ··· 321 166 } 322 167 } 323 168 324 - customElements.define('home-filters', HomeFilters) 325 169 customElements.define('home-page', HomePage)
+7 -72
www/routes/library.ts
··· 1 1 import { type ClimbLogEntry, loadLogbook } from '../utils/logbook.ts' 2 2 import { setClimbNav } from '../utils/climb-nav.ts' 3 - import { GRADE_FRENCH, GRADE_V, escapeHtml } from '../utils/benchmarks.ts' 4 - 5 - function gradeLabel(grade: number, scale: 'french' | 'v'): string { 6 - return (scale === 'v' ? GRADE_V : GRADE_FRENCH)[grade] ?? '?' 7 - } 8 - 9 - function formatDate(iso: string): string { 10 - return new Date(iso).toLocaleDateString('en-US', { 11 - month: 'short', 12 - day: 'numeric', 13 - year: 'numeric', 14 - }) 15 - } 16 - 17 - function starsHtml(rating: number | null): string { 18 - if (rating === null) return '' 19 - return '★'.repeat(rating) + '☆'.repeat(5 - rating) 20 - } 21 - 22 - type Filter = 'all' | 'sent' | 'unsent' 23 - 24 - const libState = { filter: 'all' as Filter } 25 - const libEmitter = new EventTarget() 26 - 27 - export class LibraryFilters extends HTMLElement { 28 - connectedCallback() { 29 - libState.filter = 30 - (localStorage.getItem('library_filter') as Filter) ?? 'all' 31 - this.render() 32 - this.addEventListener('click', this.#handleClick) 33 - } 34 - 35 - disconnectedCallback() { 36 - this.removeEventListener('click', this.#handleClick) 37 - } 38 - 39 - private render() { 40 - this.innerHTML = ` 41 - <ui-button-group> 42 - ${this.filterBarHtml()} 43 - </ui-button-group> 44 - ` 45 - } 46 - 47 - private filterBarHtml(): string { 48 - const opts: { value: Filter; label: string }[] = [ 49 - { value: 'all', label: 'All' }, 50 - { value: 'sent', label: 'Completed' }, 51 - { value: 'unsent', label: 'Not Completed' }, 52 - ] 53 - return opts 54 - .map( 55 - (o) => 56 - `<button aria-pressed="${libState.filter === o.value}" data-filter="${o.value}">${o.label}</button>`, 57 - ) 58 - .join('') 59 - } 60 - 61 - #handleClick = (e: Event) => { 62 - const btn = (e.target as HTMLElement).closest<HTMLElement>('[data-filter]') 63 - if (!btn) return 64 - libState.filter = btn.dataset.filter as Filter 65 - localStorage.setItem('library_filter', libState.filter) 66 - const group = this.querySelector('ui-button-group') 67 - if (group) group.innerHTML = this.filterBarHtml() 68 - libEmitter.dispatchEvent(new Event('filter')) 69 - } 70 - } 3 + import { escapeHtml, gradeLabel } from '../utils/benchmarks.ts' 4 + import { formatDate, starsHtml } from '../utils/format.ts' 5 + import { libEmitter, libState } from '../components/library-filters.ts' 71 6 72 7 export class LibraryPage extends HTMLElement { 73 8 private gradeScale: 'french' | 'v' = 'french' 74 9 75 10 connectedCallback() { 76 - this.gradeScale = 77 - (localStorage.getItem('grade_scale') as 'french' | 'v') ?? 'french' 11 + this.gradeScale = (localStorage.getItem('grade_scale') as 'french' | 'v') ?? 12 + 'french' 78 13 this.innerHTML = `<div id="lb-list"></div>` 79 14 this.renderList() 80 15 libEmitter.addEventListener('filter', this.#onFilter) ··· 127 62 } 128 63 129 64 if (filtered.length === 0) { 130 - listEl.innerHTML = `<p class="empty-message">No climbs match this filter.</p>` 65 + listEl.innerHTML = 66 + `<p class="empty-message">No climbs match this filter.</p>` 131 67 return 132 68 } 133 69 ··· 161 97 } 162 98 } 163 99 164 - customElements.define('library-filters', LibraryFilters) 165 100 customElements.define('library-page', LibraryPage)
+10 -9
www/routes/settings.ts
··· 90 90 }, 91 91 ) 92 92 93 - this.querySelectorAll<HTMLInputElement>('input[name="grade_scale"]').forEach( 94 - (input) => { 95 - input.addEventListener('change', (e) => { 96 - const value = (e.target as HTMLInputElement).value 97 - localStorage.setItem('grade_scale', value) 98 - this.gradeScale = value 99 - }) 100 - }, 101 - ) 93 + this.querySelectorAll<HTMLInputElement>('input[name="grade_scale"]') 94 + .forEach( 95 + (input) => { 96 + input.addEventListener('change', (e) => { 97 + const value = (e.target as HTMLInputElement).value 98 + localStorage.setItem('grade_scale', value) 99 + this.gradeScale = value 100 + }) 101 + }, 102 + ) 102 103 } 103 104 } 104 105
+3 -29
www/routes/stopwatch.ts
··· 1 1 import Stopwatch, { type StopwatchState } from '@inro/simple-tools/stopwatch' 2 + import { formatStopwatch } from '../utils/format.ts' 3 + 4 + export { formatStopwatchShort } from '../utils/format.ts' 2 5 3 6 // Singleton — persists across route navigation 4 7 export const globalStopwatch = new Stopwatch() 5 - 6 - // ── StopwatchPage component ───────────────────────────────────────────── 7 8 8 9 export class StopwatchPage extends HTMLElement { 9 10 private timeElement: HTMLElement | null = null ··· 113 114 } 114 115 115 116 customElements.define('stopwatch-page', StopwatchPage) 116 - 117 - export function formatStopwatch(ms: number): string { 118 - const totalSeconds = Math.floor(ms / 1000) 119 - const hours = Math.floor(totalSeconds / 3600) 120 - const minutes = Math.floor((totalSeconds % 3600) / 60) 121 - const seconds = totalSeconds % 60 122 - const centiseconds = Math.floor((ms % 1000) / 10) 123 - 124 - return `${String(hours).padStart(2, '0')}:${ 125 - String(minutes).padStart(2, '0') 126 - }:${String(seconds).padStart(2, '0')}.${ 127 - String(centiseconds).padStart(2, '0') 128 - }` 129 - } 130 - 131 - export function formatStopwatchShort(ms: number): string { 132 - const totalSeconds = Math.floor(ms / 1000) 133 - const hours = Math.floor(totalSeconds / 3600) 134 - const minutes = Math.floor((totalSeconds % 3600) / 60) 135 - const seconds = totalSeconds % 60 136 - if (hours > 0) { 137 - return `${hours}:${String(minutes).padStart(2, '0')}:${ 138 - String(seconds).padStart(2, '0') 139 - }` 140 - } 141 - return `${minutes}:${String(seconds).padStart(2, '0')}` 142 - }
+4 -5
www/static/theme.css
··· 55 55 --shadow-md: 56 56 0 8px 16px -2px rgba(0, 0, 0, 0.15), 0 4px 8px -2px rgba(0, 0, 0, 0.1); 57 57 --shadow-lg: 58 - 0 16px 32px -4px rgba(0, 0, 0, 0.18), 59 - 0 8px 16px -4px rgba(0, 0, 0, 0.12); 58 + 0 16px 32px -4px rgba(0, 0, 0, 0.18), 0 8px 16px -4px rgba(0, 0, 0, 0.12); 60 59 61 60 /* Transitions */ 62 61 --transition-fast: 0.15s cubic-bezier(0.4, 0, 0.2, 1); ··· 191 190 192 191 /* ── Civility component overrides ─────────────────────────────────────── */ 193 192 194 - /* Sub-header hides itself when empty (settings, stopwatch pages) */ 195 - ui-sub-header:not(:has(*)) { 193 + /* Filter bars: sticky below the header, flex column for search/filters */ 194 + /* Pages without a sub-header set hidden attribute via JS */ 195 + #sub-header[hidden] { 196 196 display: none; 197 197 } 198 198 199 - /* Filter bars: sticky below the header, flex column for search/filters */ 200 199 ui-sub-header { 201 200 display: flex; 202 201 flex-direction: column;
+7
www/utils/benchmarks.ts
··· 158 158 export function sandbagLabel(score: number): string { 159 159 return (score >= 0 ? '+' : '') + score.toFixed(1) 160 160 } 161 + 162 + export function gradeLabel( 163 + grade: number, 164 + scale: 'french' | 'v' = 'french', 165 + ): string { 166 + return (scale === 'v' ? GRADE_V : GRADE_FRENCH)[grade] ?? '?' 167 + }
+39
www/utils/format.ts
··· 1 + export function formatDate(iso: string): string { 2 + return new Date(iso).toLocaleDateString('en-US', { 3 + month: 'short', 4 + day: 'numeric', 5 + year: 'numeric', 6 + }) 7 + } 8 + 9 + export function starsHtml(rating: number | null): string { 10 + if (rating === null) return '' 11 + return '★'.repeat(rating) + '☆'.repeat(5 - rating) 12 + } 13 + 14 + export function formatStopwatch(ms: number): string { 15 + const totalSeconds = Math.floor(ms / 1000) 16 + const hours = Math.floor(totalSeconds / 3600) 17 + const minutes = Math.floor((totalSeconds % 3600) / 60) 18 + const seconds = totalSeconds % 60 19 + const centiseconds = Math.floor((ms % 1000) / 10) 20 + 21 + return `${String(hours).padStart(2, '0')}:${ 22 + String(minutes).padStart(2, '0') 23 + }:${String(seconds).padStart(2, '0')}.${ 24 + String(centiseconds).padStart(2, '0') 25 + }` 26 + } 27 + 28 + export function formatStopwatchShort(ms: number): string { 29 + const totalSeconds = Math.floor(ms / 1000) 30 + const hours = Math.floor(totalSeconds / 3600) 31 + const minutes = Math.floor((totalSeconds % 3600) / 60) 32 + const seconds = totalSeconds % 60 33 + if (hours > 0) { 34 + return `${hours}:${String(minutes).padStart(2, '0')}:${ 35 + String(seconds).padStart(2, '0') 36 + }` 37 + } 38 + return `${minutes}:${String(seconds).padStart(2, '0')}` 39 + }