An app for logging board climbs
0
fork

Configure Feed

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

feat: unify library and home routes

+331 -590
-4
www/index.html
··· 47 47 <footer fixed> 48 48 <ui-bottom-bar role="navigation" aria-label="Bottom navigation"> 49 49 <a href="/" data-route aria-current="page"> 50 - <img src="/static/icons/home.svg" alt="" aria-hidden="true"> 51 - <span>Home</span> 52 - </a> 53 - <a href="/library" data-route> 54 50 <img src="/static/icons/library.svg" alt="" aria-hidden="true"> 55 51 <span>Library</span> 56 52 </a>
+2 -7
www/index.ts
··· 1 1 import { client } from '@civility/workers' 2 2 import { createLayoutRouter } from '@civility/ui' 3 - import './routes/home.ts' 4 3 import './routes/library.ts' 5 4 import './routes/climb.ts' 6 5 import './routes/settings.ts' ··· 42 41 }, 43 42 routes: { 44 43 '/': { 45 - landmarks: { subHeader: 'home-filters', main: 'home-page' }, 46 - // meta.title omitted: home-page sets its own via connectedCallback 44 + landmarks: { subHeader: 'library-filters', main: 'library-page' }, 45 + // meta.title omitted: library-page sets its own via connectedCallback 47 46 meta: { navActive: '/' }, 48 47 }, 49 48 '/climb/{id}': { ··· 55 54 }), 56 55 }, 57 56 meta: {}, 58 - }, 59 - '/library': { 60 - landmarks: { subHeader: 'library-filters', main: 'library-page' }, 61 - meta: { title: 'Library', navActive: '/library' }, 62 57 }, 63 58 '/sessions': { 64 59 landmarks: { main: 'sessions-page' },
+3 -4
www/models/migrations/v1.ts
··· 78 78 boardId, 79 79 angle, 80 80 gradeScale: old.gradeScale, 81 - homeGradeMin: old.homeGradeMin, 82 - homeGradeMax: old.homeGradeMax, 83 - homeFilter: old.homeFilter, 84 - libraryFilter: old.libraryFilter, 81 + libraryGradeMin: old.homeGradeMin, 82 + libraryGradeMax: old.homeGradeMax, 83 + libraryFilter: old.homeFilter === 'unsent' ? 'unattempted' : old.homeFilter, 85 84 activeSessionId: null, 86 85 } 87 86 }
+3 -4
www/models/schema/v1.ts
··· 56 56 export const SessionJsonSchema = z.toJSONSchema(Session) as JSONSchema 57 57 export const ProgressJsonSchema = z.toJSONSchema(Progress) as JSONSchema 58 58 59 - export const LogFilter = z.enum(['all', 'sent', 'unsent']) 59 + export const LogFilter = z.enum(['all', 'sent', 'project', 'unattempted']) 60 60 export type LogFilter = z.infer<typeof LogFilter> 61 61 62 62 export const GradeScale = z.enum(['french', 'v']) ··· 67 67 boardId: z.string().default('mb2019'), 68 68 angle: z.number().int().default(40), 69 69 gradeScale: GradeScale.default(GradeScale.enum.french), 70 - homeGradeMin: z.number().int().min(0).max(16).default(0), 71 - homeGradeMax: z.number().int().min(0).max(16).default(16), 72 - homeFilter: LogFilter.default(LogFilter.enum.all), 70 + libraryGradeMin: z.number().int().min(0).max(16).default(0), 71 + libraryGradeMax: z.number().int().min(0).max(16).default(16), 73 72 libraryFilter: LogFilter.default(LogFilter.enum.all), 74 73 activeSessionId: z.string().nullable().default(null), 75 74 })
-395
www/routes/home.ts
··· 1 - import { html, LitElement, type TemplateResult } from 'lit' 2 - import { unsafeHTML } from 'lit/directives/unsafe-html.js' 3 - import { 4 - GRADE_FRENCH, 5 - GRADE_V, 6 - gradeLabel, 7 - loadClimbs, 8 - } from '../utils/climbs.ts' 9 - import { boardAngleLabel, BOARDS } from '../utils/boards.ts' 10 - import { type Climb, GradeScale, LogFilter } from '../models/schema.ts' 11 - import app from '../models/app.ts' 12 - 13 - const { all, sent, unsent } = LogFilter.enum 14 - 15 - export const PAGE_SIZE = 50 16 - 17 - export const state = { 18 - search: '', 19 - gradeMin: 0, 20 - gradeMax: 16, 21 - logFilter: all as LogFilter, 22 - shown: PAGE_SIZE, 23 - } 24 - 25 - export const emitter = new EventTarget() 26 - 27 - export class HomeFilters extends LitElement { 28 - private gradeScale: GradeScale = GradeScale.enum.french 29 - 30 - protected override createRenderRoot() { 31 - return this 32 - } 33 - 34 - override connectedCallback() { 35 - super.connectedCallback() 36 - state.search = '' 37 - state.gradeMin = app.settings.state.homeGradeMin 38 - state.gradeMax = app.settings.state.homeGradeMax 39 - state.logFilter = app.settings.state.homeFilter 40 - state.shown = PAGE_SIZE 41 - this.gradeScale = app.settings.state.gradeScale 42 - this.addEventListener('input', this.#onInput) 43 - this.addEventListener('change', this.#onChange) 44 - this.addEventListener('click', this.#onClick) 45 - app.settings.addEventListener(this.#onSettingsUpdate) 46 - } 47 - 48 - override disconnectedCallback() { 49 - super.disconnectedCallback() 50 - this.removeEventListener('input', this.#onInput) 51 - this.removeEventListener('change', this.#onChange) 52 - this.removeEventListener('click', this.#onClick) 53 - app.settings.removeEventListener(this.#onSettingsUpdate) 54 - } 55 - 56 - #onSettingsUpdate = () => { 57 - const settings = app.settings.state 58 - if ( 59 - settings.homeGradeMax === state.gradeMax && 60 - settings.homeGradeMin === state.gradeMin && 61 - settings.homeFilter === state.logFilter && 62 - settings.gradeScale === this.gradeScale 63 - ) return 64 - state.logFilter = settings.homeFilter 65 - state.gradeMax = settings.homeGradeMax 66 - state.gradeMin = settings.homeGradeMin 67 - this.gradeScale = settings.gradeScale 68 - this.requestUpdate() 69 - emitter.dispatchEvent(new Event('filter')) 70 - } 71 - 72 - #onInput = (e: Event) => { 73 - if ((e.target as HTMLElement).id !== 'bm-search') return 74 - state.search = (e.target as HTMLInputElement).value 75 - state.shown = PAGE_SIZE 76 - emitter.dispatchEvent(new Event('filter')) 77 - } 78 - 79 - #onChange = (e: Event) => { 80 - const target = e.target as HTMLSelectElement 81 - const val = parseInt(target.value, 10) 82 - if (target.id === 'bm-grade-min') { 83 - state.gradeMin = val 84 - if (state.gradeMax < val) { 85 - const group = this.gradeGroups().find( 86 - (g) => g.first <= val && val <= g.last, 87 - ) 88 - state.gradeMax = group?.last ?? val 89 - const maxEl = this.querySelector<HTMLSelectElement>('#bm-grade-max') 90 - if (maxEl) maxEl.value = state.gradeMax.toString() 91 - } 92 - app.updateSettings({ 93 - homeGradeMin: state.gradeMin, 94 - homeGradeMax: state.gradeMax, 95 - }) 96 - state.shown = PAGE_SIZE 97 - emitter.dispatchEvent(new Event('filter')) 98 - } else if (target.id === 'bm-grade-max') { 99 - state.gradeMax = val 100 - if (state.gradeMin > val) { 101 - const group = this.gradeGroups().find( 102 - (g) => g.first <= val && val <= g.last, 103 - ) 104 - state.gradeMin = group?.first ?? val 105 - const minEl = this.querySelector<HTMLSelectElement>('#bm-grade-min') 106 - if (minEl) minEl.value = state.gradeMin.toString() 107 - } 108 - app.updateSettings({ 109 - homeGradeMin: state.gradeMin, 110 - homeGradeMax: state.gradeMax, 111 - }) 112 - state.shown = PAGE_SIZE 113 - emitter.dispatchEvent(new Event('filter')) 114 - } 115 - } 116 - 117 - #onClick = (e: Event) => { 118 - const btn = (e.target as HTMLElement) 119 - .closest<HTMLElement>('[data-log-filter]') 120 - if (!btn) return 121 - state.logFilter = btn.dataset.logFilter as LogFilter 122 - app.updateSettings({ homeFilter: state.logFilter }) 123 - state.shown = PAGE_SIZE 124 - this.requestUpdate() 125 - emitter.dispatchEvent(new Event('filter')) 126 - } 127 - 128 - override render(): TemplateResult { 129 - return html` 130 - <input 131 - type="search" 132 - id="bm-search" 133 - placeholder="Search by name or setter..." 134 - autocomplete="off" 135 - > 136 - <ui-button-group id="bm-log-filter"> 137 - ${unsafeHTML(this.logFilterHtml())} 138 - </ui-button-group> 139 - <div class="grade-filter"> 140 - <label for="bm-grade-min">Grade</label> 141 - <select id="bm-grade-min"> 142 - ${unsafeHTML(this.gradeOptions('min'))} 143 - </select> 144 - <span aria-hidden="true">–</span> 145 - <select id="bm-grade-max"> 146 - ${unsafeHTML(this.gradeOptions('max'))} 147 - </select> 148 - </div> 149 - ` 150 - } 151 - 152 - private logFilterHtml(): string { 153 - const opts: { value: LogFilter; label: string }[] = [ 154 - { value: all, label: 'All' }, 155 - { value: sent, label: 'Completed' }, 156 - { value: unsent, label: 'Not Completed' }, 157 - ] 158 - return opts 159 - .map( 160 - (o) => 161 - `<button aria-pressed="${ 162 - state.logFilter === o.value 163 - }" data-log-filter="${o.value}">${o.label}</button>`, 164 - ) 165 - .join('') 166 - } 167 - 168 - private gradeGroups(): { label: string; first: number; last: number }[] { 169 - const table = this.gradeScale === GradeScale.enum.v ? GRADE_V : GRADE_FRENCH 170 - const groups: { label: string; first: number; last: number }[] = [] 171 - for (let i = 0; i <= 16; i++) { 172 - const label = table[i] ?? '?' 173 - const prev = groups[groups.length - 1] 174 - if (prev && prev.label === label) { 175 - prev.last = i 176 - } else { 177 - groups.push({ label, first: i, last: i }) 178 - } 179 - } 180 - return groups 181 - } 182 - 183 - private gradeOptions(role: 'min' | 'max'): string { 184 - const current = role === 'min' ? state.gradeMin : state.gradeMax 185 - return this.gradeGroups() 186 - .map((g) => { 187 - const value = role === 'min' ? g.first : g.last 188 - const selected = (current >= g.first && current <= g.last) 189 - ? 'selected' 190 - : '' 191 - return `<option value="${value}" ${selected}>${g.label}</option>` 192 - }) 193 - .join('') 194 - } 195 - } 196 - 197 - export class HomePage extends LitElement { 198 - private climbs: Climb[] = [] 199 - private filtered: Climb[] = [] 200 - private boardId: string = 'mb2019' 201 - private angle: number = 40 202 - private gradeScale: GradeScale = GradeScale.enum.french 203 - private loading = true 204 - private error = false 205 - 206 - protected override createRenderRoot() { 207 - return this 208 - } 209 - 210 - override async connectedCallback() { 211 - super.connectedCallback() 212 - this.boardId = app.settings.state.boardId 213 - this.angle = app.settings.state.angle 214 - this.gradeScale = app.settings.state.gradeScale 215 - 216 - this.#updateTitle() 217 - this.addEventListener('click', this.#handleClick) 218 - emitter.addEventListener('filter', this.#onFilter) 219 - app.settings.addEventListener(this.#onSettingsUpdate) 220 - 221 - try { 222 - const all = await loadClimbs() 223 - this.climbs = all 224 - .filter((c) => c.boardId === this.boardId && c.angle === this.angle) 225 - .sort((a, b) => a.grade - b.grade || b.repeats - a.repeats) 226 - this.loading = false 227 - this.applyFilters() 228 - this.requestUpdate() 229 - } catch { 230 - this.loading = false 231 - this.error = true 232 - this.requestUpdate() 233 - } 234 - } 235 - 236 - override disconnectedCallback() { 237 - super.disconnectedCallback() 238 - this.removeEventListener('click', this.#handleClick) 239 - emitter.removeEventListener('filter', this.#onFilter) 240 - app.settings.removeEventListener(this.#onSettingsUpdate) 241 - } 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 - #onSettingsUpdate = async () => { 251 - const { boardId, angle, gradeScale } = app.settings.state 252 - if ( 253 - boardId === this.boardId && 254 - angle === this.angle && 255 - gradeScale === this.gradeScale 256 - ) return 257 - this.boardId = boardId 258 - this.angle = angle 259 - this.gradeScale = gradeScale 260 - this.#updateTitle() 261 - const all = await loadClimbs() 262 - this.climbs = all 263 - .filter((c) => c.boardId === this.boardId && c.angle === this.angle) 264 - .sort((a, b) => a.grade - b.grade || b.repeats - a.repeats) 265 - this.applyFilters() 266 - this.requestUpdate() 267 - } 268 - 269 - #onFilter = () => { 270 - this.applyFilters() 271 - this.requestUpdate() 272 - } 273 - 274 - #handleClick = (e: Event) => { 275 - const target = e.target as HTMLElement 276 - const item = target.closest<HTMLElement>('[data-bm-id]') 277 - if (item) { 278 - const id = item.dataset.bmId ?? '' 279 - const idx = this.filtered.findIndex((c) => c.id === id) 280 - if (idx !== -1) this.openClimb(idx) 281 - return 282 - } 283 - if (target.closest('#bm-load-more')) { 284 - state.shown += PAGE_SIZE 285 - this.requestUpdate() 286 - } 287 - } 288 - 289 - private applyFilters(): void { 290 - const q = state.search.toLowerCase() 291 - const progress = state.logFilter !== all ? app.progress : {} 292 - this.filtered = this.climbs.filter((c) => { 293 - if (c.grade < state.gradeMin || c.grade > state.gradeMax) return false 294 - if ( 295 - q && 296 - !c.name.toLowerCase().includes(q) && 297 - !c.setter.toLowerCase().includes(q) 298 - ) { 299 - return false 300 - } 301 - const p = progress[c.id] 302 - if (state.logFilter === sent) return !!p?.sent 303 - if (state.logFilter === unsent) return !p?.sent 304 - return true 305 - }) 306 - } 307 - 308 - override render(): TemplateResult { 309 - return html` 310 - <div id="bm-list" role="list"> 311 - ${this.listContent()} 312 - </div> 313 - ` 314 - } 315 - 316 - private listContent(): TemplateResult { 317 - if (this.loading) { 318 - return html` 319 - <div class="empty-message"><ui-spinner size="lg"></ui-spinner></div> 320 - ` 321 - } 322 - if (this.error) { 323 - return html` 324 - <p class="empty-message">Failed to load climbs.</p> 325 - ` 326 - } 327 - if (!BOARDS[this.boardId]) { 328 - return html` 329 - <p class="empty-message">Unknown board.</p> 330 - ` 331 - } 332 - if (this.climbs.length === 0) { 333 - return html` 334 - <p class="empty-message">No climbs available for this board.</p> 335 - ` 336 - } 337 - if (this.filtered.length === 0) { 338 - return html` 339 - <p class="empty-message">No climbs match your filters.</p> 340 - ` 341 - } 342 - 343 - const visible = this.filtered.slice(0, state.shown) 344 - const remaining = this.filtered.length - state.shown 345 - const progress = app.progress 346 - 347 - return html` 348 - <div class="bm-count"> 349 - ${this.filtered.length 350 - .toLocaleString()} climb${this.filtered.length === 1 ? '' : 's'} 351 - </div> 352 - ${visible.map((c) => { 353 - const p = progress[c.id] 354 - const sentTag = p?.sent 355 - ? '<ui-badge variant="success">Sent</ui-badge>' 356 - : '' 357 - return html` 358 - <button data-bm-id="${c.id}" role="listitem"> 359 - <div class="bm-item-main"> 360 - <span class="bm-name">${c.name}</span> 361 - ${unsafeHTML(sentTag)} 362 - <ui-badge class="grade"> 363 - ${gradeLabel(c.grade, this.gradeScale)} 364 - </ui-badge> 365 - </div> 366 - <div class="bm-item-meta"> 367 - <span class="bm-setter">${c.setter}</span> 368 - <span class="bm-stats">${c.repeats.toLocaleString()} sends</span> 369 - </div> 370 - </button> 371 - ` 372 - })} ${remaining > 0 373 - ? html` 374 - <button id="bm-load-more">Load more (${remaining 375 - .toLocaleString()} remaining)</button> 376 - ` 377 - : ''} 378 - ` 379 - } 380 - 381 - private openClimb(index: number): void { 382 - const climb = this.filtered[index] 383 - app.setNav({ 384 - id: climb.id, 385 - climb, 386 - filteredIds: this.filtered.map((c) => c.id), 387 - currentIndex: index, 388 - backRoute: '/', 389 - }) 390 - globalThis.location.hash = `/climb/${climb.id}` 391 - } 392 - } 393 - 394 - customElements.define('home-page', HomePage) 395 - customElements.define('home-filters', HomeFilters)
+323 -110
www/routes/library.ts
··· 1 1 import { html, LitElement, type TemplateResult } from 'lit' 2 + import { unsafeHTML } from 'lit/directives/unsafe-html.js' 3 + import { 4 + GRADE_FRENCH, 5 + GRADE_V, 6 + gradeLabel, 7 + loadClimbs, 8 + } from '../utils/climbs.ts' 9 + import { boardAngleLabel, BOARDS } from '../utils/boards.ts' 10 + import { type Climb, GradeScale, LogFilter } from '../models/schema.ts' 11 + import { formatDate } from '../utils/format.ts' 2 12 import app from '../models/app.ts' 3 - import { GradeScale, LogFilter, type Progress } from '../models/schema.ts' 4 - import { gradeLabel } from '../utils/climbs.ts' 5 - import { formatDate, starsHtml } from '../utils/format.ts' 6 13 7 - const { all, sent, unsent } = LogFilter.enum 14 + const { all, sent, project, unattempted } = LogFilter.enum 8 15 9 - export const libState = { filter: all as LogFilter } 10 - export const libEmitter = new EventTarget() 16 + export const PAGE_SIZE = 50 17 + 18 + export const state = { 19 + search: '', 20 + gradeMin: 0, 21 + gradeMax: 16, 22 + logFilter: all as LogFilter, 23 + shown: PAGE_SIZE, 24 + } 25 + 26 + export const emitter = new EventTarget() 11 27 12 28 export class LibraryFilters extends LitElement { 29 + private gradeScale: GradeScale = GradeScale.enum.french 30 + 13 31 protected override createRenderRoot() { 14 32 return this 15 33 } 16 34 17 35 override connectedCallback() { 18 36 super.connectedCallback() 19 - libState.filter = app.settings.state.libraryFilter 20 - this.addEventListener('click', this.#handleClick) 37 + state.search = '' 38 + state.gradeMin = app.settings.state.libraryGradeMin 39 + state.gradeMax = app.settings.state.libraryGradeMax 40 + state.logFilter = app.settings.state.libraryFilter 41 + state.shown = PAGE_SIZE 42 + this.gradeScale = app.settings.state.gradeScale 43 + this.addEventListener('input', this.#onInput) 44 + this.addEventListener('change', this.#onChange) 45 + this.addEventListener('click', this.#onClick) 21 46 app.settings.addEventListener(this.#onSettingsUpdate) 22 47 } 23 48 24 49 override disconnectedCallback() { 25 50 super.disconnectedCallback() 26 - this.removeEventListener('click', this.#handleClick) 51 + this.removeEventListener('input', this.#onInput) 52 + this.removeEventListener('change', this.#onChange) 53 + this.removeEventListener('click', this.#onClick) 27 54 app.settings.removeEventListener(this.#onSettingsUpdate) 28 55 } 29 56 30 57 #onSettingsUpdate = () => { 31 - const filter = app.settings.state.libraryFilter 32 - if (filter === libState.filter) return 33 - libState.filter = filter 58 + const settings = app.settings.state 59 + if ( 60 + settings.libraryGradeMax === state.gradeMax && 61 + settings.libraryGradeMin === state.gradeMin && 62 + settings.libraryFilter === state.logFilter && 63 + settings.gradeScale === this.gradeScale 64 + ) return 65 + state.logFilter = settings.libraryFilter 66 + state.gradeMax = settings.libraryGradeMax 67 + state.gradeMin = settings.libraryGradeMin 68 + this.gradeScale = settings.gradeScale 34 69 this.requestUpdate() 35 - libEmitter.dispatchEvent(new Event('filter')) 70 + emitter.dispatchEvent(new Event('filter')) 36 71 } 37 72 38 - #handleClick = (e: Event) => { 39 - const btn = (e.target as HTMLElement).closest<HTMLElement>('[data-filter]') 73 + #onInput = (e: Event) => { 74 + if ((e.target as HTMLElement).id !== 'bm-search') return 75 + state.search = (e.target as HTMLInputElement).value 76 + state.shown = PAGE_SIZE 77 + emitter.dispatchEvent(new Event('filter')) 78 + } 79 + 80 + #onChange = (e: Event) => { 81 + const target = e.target as HTMLSelectElement 82 + const val = parseInt(target.value, 10) 83 + if (target.id === 'bm-grade-min') { 84 + state.gradeMin = val 85 + if (state.gradeMax < val) { 86 + const group = this.gradeGroups().find( 87 + (g) => g.first <= val && val <= g.last, 88 + ) 89 + state.gradeMax = group?.last ?? val 90 + const maxEl = this.querySelector<HTMLSelectElement>('#bm-grade-max') 91 + if (maxEl) maxEl.value = state.gradeMax.toString() 92 + } 93 + app.updateSettings({ 94 + libraryGradeMin: state.gradeMin, 95 + libraryGradeMax: state.gradeMax, 96 + }) 97 + state.shown = PAGE_SIZE 98 + emitter.dispatchEvent(new Event('filter')) 99 + } else if (target.id === 'bm-grade-max') { 100 + state.gradeMax = val 101 + if (state.gradeMin > val) { 102 + const group = this.gradeGroups().find( 103 + (g) => g.first <= val && val <= g.last, 104 + ) 105 + state.gradeMin = group?.first ?? val 106 + const minEl = this.querySelector<HTMLSelectElement>('#bm-grade-min') 107 + if (minEl) minEl.value = state.gradeMin.toString() 108 + } 109 + app.updateSettings({ 110 + libraryGradeMin: state.gradeMin, 111 + libraryGradeMax: state.gradeMax, 112 + }) 113 + state.shown = PAGE_SIZE 114 + emitter.dispatchEvent(new Event('filter')) 115 + } 116 + } 117 + 118 + #onClick = (e: Event) => { 119 + const btn = (e.target as HTMLElement) 120 + .closest<HTMLElement>('[data-log-filter]') 40 121 if (!btn) return 41 - libState.filter = btn.dataset.filter as LogFilter 42 - app.updateSettings({ libraryFilter: libState.filter }) 122 + state.logFilter = btn.dataset.logFilter as LogFilter 123 + app.updateSettings({ libraryFilter: state.logFilter }) 124 + state.shown = PAGE_SIZE 43 125 this.requestUpdate() 44 - libEmitter.dispatchEvent(new Event('filter')) 126 + emitter.dispatchEvent(new Event('filter')) 45 127 } 46 128 47 129 override render(): TemplateResult { 48 - const opts: { value: LogFilter; label: string }[] = [ 49 - { value: all, label: 'All' }, 50 - { value: sent, label: 'Completed' }, 51 - { value: unsent, label: 'Not Completed' }, 52 - ] 53 130 return html` 54 - <ui-button-group> 55 - ${opts.map((o) => 56 - html` 57 - <button 58 - aria-pressed="${libState.filter === o.value}" 59 - data-filter="${o.value}" 60 - > 61 - ${o.label} 62 - </button> 63 - ` 64 - )} 131 + <input 132 + type="search" 133 + id="bm-search" 134 + placeholder="Search by name or setter..." 135 + autocomplete="off" 136 + > 137 + <ui-button-group id="bm-log-filter"> 138 + ${unsafeHTML(this.logFilterHtml())} 65 139 </ui-button-group> 140 + <div class="grade-filter"> 141 + <label for="bm-grade-min">Grade</label> 142 + <select id="bm-grade-min"> 143 + ${unsafeHTML(this.gradeOptions('min'))} 144 + </select> 145 + <span aria-hidden="true">–</span> 146 + <select id="bm-grade-max"> 147 + ${unsafeHTML(this.gradeOptions('max'))} 148 + </select> 149 + </div> 66 150 ` 67 151 } 152 + 153 + private logFilterHtml(): string { 154 + const opts: { value: LogFilter; label: string }[] = [ 155 + { value: all, label: 'All' }, 156 + { value: sent, label: 'Sent' }, 157 + { value: project, label: 'Projects' }, 158 + { value: unattempted, label: 'Unattempted' }, 159 + ] 160 + return opts 161 + .map( 162 + (o) => 163 + `<button aria-pressed="${ 164 + state.logFilter === o.value 165 + }" data-log-filter="${o.value}">${o.label}</button>`, 166 + ) 167 + .join('') 168 + } 169 + 170 + private gradeGroups(): { label: string; first: number; last: number }[] { 171 + const table = this.gradeScale === GradeScale.enum.v ? GRADE_V : GRADE_FRENCH 172 + const groups: { label: string; first: number; last: number }[] = [] 173 + for (let i = 0; i <= 16; i++) { 174 + const label = table[i] ?? '?' 175 + const prev = groups[groups.length - 1] 176 + if (prev && prev.label === label) { 177 + prev.last = i 178 + } else { 179 + groups.push({ label, first: i, last: i }) 180 + } 181 + } 182 + return groups 183 + } 184 + 185 + private gradeOptions(role: 'min' | 'max'): string { 186 + const current = role === 'min' ? state.gradeMin : state.gradeMax 187 + return this.gradeGroups() 188 + .map((g) => { 189 + const value = role === 'min' ? g.first : g.last 190 + const selected = (current >= g.first && current <= g.last) 191 + ? 'selected' 192 + : '' 193 + return `<option value="${value}" ${selected}>${g.label}</option>` 194 + }) 195 + .join('') 196 + } 68 197 } 69 198 70 199 export class LibraryPage extends LitElement { 200 + private climbs: Climb[] = [] 201 + private filtered: Climb[] = [] 202 + private boardId: string = 'mb2019' 203 + private angle: number = 40 71 204 private gradeScale: GradeScale = GradeScale.enum.french 205 + private loading = true 206 + private error = false 72 207 73 208 protected override createRenderRoot() { 74 209 return this 75 210 } 76 211 77 - override connectedCallback() { 212 + override async connectedCallback() { 78 213 super.connectedCallback() 214 + this.boardId = app.settings.state.boardId 215 + this.angle = app.settings.state.angle 79 216 this.gradeScale = app.settings.state.gradeScale 80 - libEmitter.addEventListener('filter', this.#onFilter) 217 + 218 + this.#updateTitle() 81 219 this.addEventListener('click', this.#handleClick) 220 + emitter.addEventListener('filter', this.#onFilter) 221 + app.settings.addEventListener(this.#onSettingsUpdate) 82 222 app.store.addEventListener(this.#onStoreUpdate) 83 - app.settings.addEventListener(this.#onSettingsUpdate) 223 + 224 + try { 225 + const all = await loadClimbs() 226 + this.climbs = all 227 + .filter((c) => c.boardId === this.boardId && c.angle === this.angle) 228 + .sort((a, b) => a.grade - b.grade || b.repeats - a.repeats) 229 + this.loading = false 230 + this.applyFilters() 231 + this.requestUpdate() 232 + } catch { 233 + this.loading = false 234 + this.error = true 235 + this.requestUpdate() 236 + } 84 237 } 85 238 86 239 override disconnectedCallback() { 87 240 super.disconnectedCallback() 88 - libEmitter.removeEventListener('filter', this.#onFilter) 89 241 this.removeEventListener('click', this.#handleClick) 242 + emitter.removeEventListener('filter', this.#onFilter) 243 + app.settings.removeEventListener(this.#onSettingsUpdate) 90 244 app.store.removeEventListener(this.#onStoreUpdate) 91 - app.settings.removeEventListener(this.#onSettingsUpdate) 92 245 } 93 246 94 - #onFilter = () => { 95 - this.requestUpdate() 247 + #updateTitle(): void { 248 + const titleEl = document.querySelector('#page-title') 249 + if (titleEl) { 250 + titleEl.textContent = boardAngleLabel(this.boardId, this.angle) 251 + } 96 252 } 97 253 98 254 #onStoreUpdate = () => { 255 + this.applyFilters() 99 256 this.requestUpdate() 100 257 } 101 258 102 - #onSettingsUpdate = () => { 103 - this.gradeScale = app.settings.state.gradeScale 259 + #onSettingsUpdate = async () => { 260 + const { boardId, angle, gradeScale } = app.settings.state 261 + if ( 262 + boardId === this.boardId && 263 + angle === this.angle && 264 + gradeScale === this.gradeScale 265 + ) return 266 + this.boardId = boardId 267 + this.angle = angle 268 + this.gradeScale = gradeScale 269 + this.#updateTitle() 270 + const all = await loadClimbs() 271 + this.climbs = all 272 + .filter((c) => c.boardId === this.boardId && c.angle === this.angle) 273 + .sort((a, b) => a.grade - b.grade || b.repeats - a.repeats) 274 + this.applyFilters() 275 + this.requestUpdate() 276 + } 277 + 278 + #onFilter = () => { 279 + this.applyFilters() 104 280 this.requestUpdate() 105 281 } 106 282 107 283 #handleClick = (e: Event) => { 108 284 const target = e.target as HTMLElement 109 - const el = target.closest<HTMLElement>('[data-climb-id]') 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}` 285 + const item = target.closest<HTMLElement>('[data-bm-id]') 286 + if (item) { 287 + const id = item.dataset.bmId ?? '' 288 + const idx = this.filtered.findIndex((c) => c.id === id) 289 + if (idx !== -1) this.openClimb(idx) 290 + return 291 + } 292 + if (target.closest('#bm-load-more')) { 293 + state.shown += PAGE_SIZE 294 + this.requestUpdate() 295 + } 115 296 } 116 297 117 - private sortedEntries(): Progress[] { 118 - return Object.values(app.progress).sort( 119 - (a, b) => 120 - new Date(b.lastAttempted).getTime() - 121 - new Date(a.lastAttempted).getTime(), 122 - ) 298 + private applyFilters(): void { 299 + const q = state.search.toLowerCase() 300 + const progress = app.progress 301 + this.filtered = this.climbs.filter((c) => { 302 + if (c.grade < state.gradeMin || c.grade > state.gradeMax) return false 303 + if ( 304 + q && 305 + !c.name.toLowerCase().includes(q) && 306 + !c.setter.toLowerCase().includes(q) 307 + ) { 308 + return false 309 + } 310 + const p = progress[c.id] 311 + if (state.logFilter === sent) return !!p?.sent 312 + if (state.logFilter === project) return !!p && !p.sent 313 + if (state.logFilter === unattempted) return !p 314 + return true 315 + }) 123 316 } 124 317 125 318 override render(): TemplateResult { 126 - const all = this.sortedEntries() 127 - const filtered = all.filter((p) => { 128 - if (libState.filter === sent) return !!p.sent 129 - if (libState.filter === unsent) return !p.sent 130 - return true 131 - }) 319 + return html` 320 + <div id="bm-list" role="list"> 321 + ${this.listContent()} 322 + </div> 323 + ` 324 + } 132 325 133 - if (all.length === 0) { 326 + private listContent(): TemplateResult { 327 + if (this.loading) { 134 328 return html` 135 - <div id="lb-list"> 136 - <p class="empty-message"> 137 - No climbs logged yet.<br>Log attempts from a climb's detail view. 138 - </p> 139 - </div> 329 + <div class="empty-message"><ui-spinner size="lg"></ui-spinner></div> 140 330 ` 141 331 } 142 - 143 - if (filtered.length === 0) { 332 + if (this.error) { 333 + return html` 334 + <p class="empty-message">Failed to load climbs.</p> 335 + ` 336 + } 337 + if (!BOARDS[this.boardId]) { 338 + return html` 339 + <p class="empty-message">Unknown board.</p> 340 + ` 341 + } 342 + if (this.climbs.length === 0) { 343 + return html` 344 + <p class="empty-message">No climbs available for this board.</p> 345 + ` 346 + } 347 + if (this.filtered.length === 0) { 144 348 return html` 145 - <div id="lb-list"> 146 - <p class="empty-message">No climbs match this filter.</p> 147 - </div> 349 + <p class="empty-message">No climbs match your filters.</p> 148 350 ` 149 351 } 150 352 353 + const visible = this.filtered.slice(0, state.shown) 354 + const remaining = this.filtered.length - state.shown 355 + const progress = app.progress 356 + 151 357 return html` 152 - <div id="lb-list"> 153 - ${filtered.map((e) => this.renderEntry(e))} 358 + <div class="bm-count"> 359 + ${this.filtered.length 360 + .toLocaleString()} climb${this.filtered.length === 1 ? '' : 's'} 154 361 </div> 362 + ${visible.map((c) => { 363 + const p = progress[c.id] 364 + const badge = p?.sent 365 + ? '<ui-badge variant="success">Sent</ui-badge>' 366 + : p 367 + ? '<ui-badge>Project</ui-badge>' 368 + : '' 369 + const meta = p?.sent 370 + ? `Sent ${formatDate(p.sent)}` 371 + : `${c.repeats.toLocaleString()} sends` 372 + return html` 373 + <button data-bm-id="${c.id}" role="listitem"> 374 + <div class="bm-item-main"> 375 + <span class="bm-name">${c.name}</span> 376 + ${unsafeHTML(badge)} 377 + <ui-badge class="grade"> 378 + ${gradeLabel(c.grade, this.gradeScale)} 379 + </ui-badge> 380 + </div> 381 + <div class="bm-item-meta"> 382 + <span class="bm-setter">${c.setter}</span> 383 + <span class="bm-stats">${meta}</span> 384 + </div> 385 + </button> 386 + ` 387 + })} ${remaining > 0 388 + ? html` 389 + <button id="bm-load-more">Load more (${remaining 390 + .toLocaleString()} remaining)</button> 391 + ` 392 + : ''} 155 393 ` 156 394 } 157 395 158 - private renderEntry(p: Progress): TemplateResult { 159 - const grade = gradeLabel(p.grade, this.gradeScale) 160 - const stars = starsHtml(p.rating) 161 - const badge = p.sent 162 - ? html` 163 - <ui-badge variant="success">Sent</ui-badge> 164 - ` 165 - : html` 166 - <ui-badge>Project</ui-badge> 167 - ` 168 - 169 - return html` 170 - <button 171 - class="lb-entry" 172 - data-climb-id="${p.id}" 173 - > 174 - <div class="lb-entry-row"> 175 - <span class="lb-entry-name">${p.name}</span> 176 - <ui-badge class="grade">${grade}</ui-badge> 177 - ${badge} 178 - </div> 179 - <div class="lb-entry-meta"> 180 - <span>${p.totalAttempts} attempt${p.totalAttempts === 1 181 - ? '' 182 - : 's'}</span> 183 - ${stars 184 - ? html` 185 - <span class="lb-entry-stars">${stars}</span> 186 - ` 187 - : ''} 188 - <span>${p.setter}</span> 189 - <span class="lb-entry-date">${formatDate(p.lastAttempted)}</span> 190 - </div> 191 - </button> 192 - ` 396 + private openClimb(index: number): void { 397 + const climb = this.filtered[index] 398 + app.setNav({ 399 + id: climb.id, 400 + climb, 401 + filteredIds: this.filtered.map((c) => c.id), 402 + currentIndex: index, 403 + backRoute: '/', 404 + }) 405 + globalThis.location.hash = `/climb/${climb.id}` 193 406 } 194 407 } 195 408 196 - customElements.define('library-filters', LibraryFilters) 197 409 customElements.define('library-page', LibraryPage) 410 + customElements.define('library-filters', LibraryFilters)
-66
www/static/theme.css
··· 205 205 } 206 206 207 207 /* Filter components are transparent to layout — children flow into ui-sub-header's flex */ 208 - home-filters, 209 208 library-filters { 210 209 display: contents; 211 210 } ··· 268 267 269 268 /* ── Page host elements ────────────────────────────────────────────────── */ 270 269 271 - home-page, 272 270 library-page { 273 271 display: flex; 274 272 flex-direction: column; ··· 689 687 gap: var(--s2); 690 688 font-size: var(--f5); 691 689 flex-wrap: wrap; 692 - } 693 - 694 - /* ── Library list ──────────────────────────────────────────────────────── */ 695 - 696 - #lb-list { 697 - flex: 1; 698 - } 699 - 700 - .lb-entry { 701 - display: flex; 702 - flex-direction: column; 703 - width: 100%; 704 - text-align: left; 705 - gap: var(--s1); 706 - padding: var(--s3); 707 - border: none; 708 - border-bottom: 1px solid currentColor; 709 - border-radius: 0; 710 - background: transparent; 711 - color: inherit; 712 - cursor: pointer; 713 - transition: background var(--transition-fast); 714 - } 715 - 716 - .lb-entry:hover { 717 - background: var(--primary-dull); 718 - opacity: 1; 719 - transform: none; 720 - } 721 - 722 - .lb-entry:active { 723 - transform: none; 724 - } 725 - 726 - .lb-entry-row { 727 - display: flex; 728 - align-items: center; 729 - gap: var(--s2); 730 - } 731 - 732 - .lb-entry-name { 733 - flex: 1; 734 - font-weight: var(--fw-semibold); 735 - font-size: var(--f5); 736 - overflow: hidden; 737 - text-overflow: ellipsis; 738 - white-space: nowrap; 739 - } 740 - 741 - .lb-entry-meta { 742 - display: flex; 743 - align-items: center; 744 - gap: var(--s2); 745 - font-size: var(--f6); 746 - opacity: 0.6; 747 - flex-wrap: wrap; 748 - } 749 - 750 - .lb-entry-stars { 751 - letter-spacing: 0.05em; 752 - } 753 - 754 - .lb-entry-date { 755 - margin-left: auto; 756 690 } 757 691 758 692 /* ── Settings page ─────────────────────────────────────────────────────── */