An app for logging board climbs
0
fork

Configure Feed

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

feat: add sort

+148 -41
+4
www/models/app.ts
··· 148 148 ? existing.sessionIds 149 149 : [...(existing?.sessionIds ?? []), session.id] 150 150 const firstAttempted = existing?.firstAttempted ?? now 151 + const attemptsToSend = input.sent 152 + ? existing?.attemptsToSend || totalAttempts 153 + : null 151 154 152 155 const next: Progress = { 153 156 id: climb.id, ··· 157 160 grade: climb.grade, 158 161 setter: climb.setter, 159 162 sent, 163 + attemptsToSend, 160 164 totalAttempts, 161 165 rating, 162 166 sessionIds,
+1
www/models/schema.ts
··· 6 6 type ClimbAttempt, 7 7 defaultPreferences, 8 8 GradeScale, 9 + LibrarySortBy, 9 10 LogFilter, 10 11 type Preferences, 11 12 Progress,
+5
www/models/schema/v1.ts
··· 57 57 export const GradeScale = z.enum(['french', 'v']) 58 58 export type GradeScale = z.infer<typeof GradeScale> 59 59 60 + export const LibrarySortBy = z.enum(['easiest', 'hardest', 'stars', 'sends']) 61 + export type LibrarySortBy = z.infer<typeof LibrarySortBy> 62 + 60 63 export type Preferences = { 61 64 syncLinkUrl: string 62 65 boardId: string ··· 65 68 libraryGradeMin: number 66 69 libraryGradeMax: number 67 70 libraryFilter: LogFilter 71 + librarySortBy: LibrarySortBy 68 72 activeSessionId: string | null 69 73 } 70 74 ··· 76 80 libraryGradeMin: 0, 77 81 libraryGradeMax: 16, 78 82 libraryFilter: LogFilter.enum.all, 83 + librarySortBy: LibrarySortBy.enum.easiest, 79 84 activeSessionId: null, 80 85 } 81 86
+107 -40
www/routes/library.ts
··· 7 7 loadClimbs, 8 8 } from '../utils/climbs.ts' 9 9 import { boardAngleLabel, BOARDS } from '../utils/boards.ts' 10 - import { type Climb, GradeScale, LogFilter } from '../models/schema.ts' 10 + import { 11 + type Climb, 12 + GradeScale, 13 + LibrarySortBy, 14 + LogFilter, 15 + } from '../models/schema.ts' 11 16 import { formatDate } from '../utils/format.ts' 12 17 import app from '../models/app.ts' 13 18 14 19 const { all, sent, project, unattempted } = LogFilter.enum 20 + const { easiest, hardest, stars, sends } = LibrarySortBy.enum 15 21 16 22 export const PAGE_SIZE = 50 17 23 18 - export const state = { 24 + export const state: { 25 + search: string 26 + gradeMin: number 27 + gradeMax: number 28 + logFilter: LogFilter 29 + sortBy: LibrarySortBy 30 + shown: number 31 + } = { 19 32 search: '', 20 33 gradeMin: 0, 21 34 gradeMax: 16, 22 35 logFilter: all as LogFilter, 36 + sortBy: LibrarySortBy.enum.easiest, 23 37 shown: PAGE_SIZE, 24 38 } 25 39 ··· 38 52 state.gradeMin = app.preferences.libraryGradeMin 39 53 state.gradeMax = app.preferences.libraryGradeMax 40 54 state.logFilter = app.preferences.libraryFilter 55 + state.sortBy = app.preferences.librarySortBy 41 56 state.shown = PAGE_SIZE 42 57 this.gradeScale = app.preferences.gradeScale 43 58 this.addEventListener('input', this.#onInput) ··· 55 70 } 56 71 57 72 #onSettingsUpdate = () => { 58 - const settings = app.preferences 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 73 + state.logFilter = app.preferences.libraryFilter 74 + state.gradeMax = app.preferences.libraryGradeMax 75 + state.gradeMin = app.preferences.libraryGradeMin 76 + state.sortBy = app.preferences.librarySortBy 77 + this.gradeScale = app.preferences.gradeScale 69 78 this.requestUpdate() 70 79 emitter.dispatchEvent(new Event('filter')) 71 80 } 72 81 82 + override updated() { 83 + const sortEl = this.querySelector<HTMLSelectElement>('#bm-sort-by') 84 + if (sortEl && sortEl.value !== state.sortBy) { 85 + sortEl.value = state.sortBy 86 + } 87 + } 88 + 73 89 #onInput = (e: Event) => { 74 90 if ((e.target as HTMLElement).id !== 'bm-search') return 75 91 state.search = (e.target as HTMLInputElement).value ··· 110 126 libraryGradeMin: state.gradeMin, 111 127 libraryGradeMax: state.gradeMax, 112 128 }) 129 + state.shown = PAGE_SIZE 130 + emitter.dispatchEvent(new Event('filter')) 131 + } else if (target.id === 'bm-sort-by') { 132 + state.sortBy = target.value as LibrarySortBy 133 + app.updatePreferences({ librarySortBy: state.sortBy }) 113 134 state.shown = PAGE_SIZE 114 135 emitter.dispatchEvent(new Event('filter')) 115 136 } ··· 137 158 <ui-button-group id="bm-log-filter"> 138 159 ${unsafeHTML(this.logFilterHtml())} 139 160 </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> 161 + <div class="filters-row"> 162 + <div class="grade-filter"> 163 + <label for="bm-grade-min">Grade</label> 164 + <select id="bm-grade-min"> 165 + ${unsafeHTML(this.gradeOptions('min'))} 166 + </select> 167 + <span aria-hidden="true">–</span> 168 + <select id="bm-grade-max"> 169 + ${unsafeHTML(this.gradeOptions('max'))} 170 + </select> 171 + </div> 172 + <div class="sort-filter"> 173 + <label for="bm-sort-by">Sort</label> 174 + <select id="bm-sort-by"> 175 + ${unsafeHTML(this.sortByOptions())} 176 + </select> 177 + </div> 149 178 </div> 150 179 ` 151 180 } ··· 182 211 return groups 183 212 } 184 213 214 + private sortByOptions(): string { 215 + const opts: { value: LibrarySortBy; label: string }[] = [ 216 + { value: easiest, label: 'Easiest' }, 217 + { value: hardest, label: 'Hardest' }, 218 + { value: stars, label: '★ Rating' }, 219 + { value: sends, label: 'Sends' }, 220 + ] 221 + return opts 222 + .map( 223 + (o) => 224 + `<option value="${o.value}" ${ 225 + state.sortBy === o.value ? 'selected' : '' 226 + }">${o.label}</option>`, 227 + ) 228 + .join('') 229 + } 230 + 185 231 private gradeOptions(role: 'min' | 'max'): string { 186 232 const current = role === 'min' ? state.gradeMin : state.gradeMax 187 233 return this.gradeGroups() ··· 224 270 const all = await loadClimbs() 225 271 this.climbs = all 226 272 .filter((c) => c.boardId === this.boardId && c.angle === this.angle) 227 - .sort((a, b) => a.grade - b.grade || b.repeats - a.repeats) 273 + .sort(this.#sortClimbs) 228 274 this.loading = false 229 275 this.applyFilters() 230 276 this.requestUpdate() ··· 261 307 const all = await loadClimbs() 262 308 this.climbs = all 263 309 .filter((c) => c.boardId === this.boardId && c.angle === this.angle) 264 - .sort((a, b) => a.grade - b.grade || b.repeats - a.repeats) 310 + .sort(this.#sortClimbs) 265 311 } 266 312 } 267 313 this.applyFilters() ··· 291 337 private applyFilters(): void { 292 338 const q = state.search.toLowerCase() 293 339 const progress = app.progress 294 - this.filtered = this.climbs.filter((c) => { 295 - if (c.grade < state.gradeMin || c.grade > state.gradeMax) return false 296 - if ( 297 - q && 298 - !c.name.toLowerCase().includes(q) && 299 - !c.setter.toLowerCase().includes(q) 300 - ) { 301 - return false 340 + this.filtered = this.climbs 341 + .filter((c) => { 342 + if (c.grade < state.gradeMin || c.grade > state.gradeMax) return false 343 + if ( 344 + q && 345 + !c.name.toLowerCase().includes(q) && 346 + !c.setter.toLowerCase().includes(q) 347 + ) { 348 + return false 349 + } 350 + const p = progress[c.id] 351 + if (state.logFilter === sent) return !!p?.sent 352 + if (state.logFilter === project) return !!p && !p.sent 353 + if (state.logFilter === unattempted) return !p 354 + return true 355 + }) 356 + .sort(this.#sortClimbs) 357 + } 358 + 359 + #sortClimbs = (a: Climb, b: Climb): number => { 360 + const progress = app.progress 361 + const pa = progress[a.id] 362 + const pb = progress[b.id] 363 + switch (state.sortBy) { 364 + case easiest: 365 + return a.grade - b.grade || a.repeats - b.repeats 366 + case hardest: 367 + return b.grade - a.grade || a.repeats - b.repeats 368 + case stars: { 369 + const ra = pa?.rating ?? -1 370 + const rb = pb?.rating ?? -1 371 + return rb - ra || a.grade - b.grade || a.repeats - b.repeats 302 372 } 303 - const p = progress[c.id] 304 - if (state.logFilter === sent) return !!p?.sent 305 - if (state.logFilter === project) return !!p && !p.sent 306 - if (state.logFilter === unattempted) return !p 307 - return true 308 - }) 373 + case sends: 374 + return b.repeats - a.repeats || a.grade - b.grade 375 + } 309 376 } 310 377 311 378 override render(): TemplateResult { ··· 357 424 const badge = p?.sent 358 425 ? '<ui-badge variant="success">Sent</ui-badge>' 359 426 : p 360 - ? '<ui-badge>Project</ui-badge>' 361 - : '' 427 + ? '<ui-badge>Project</ui-badge>' 428 + : '' 362 429 const meta = p?.sent 363 430 ? `Sent ${formatDate(p.sent)}` 364 431 : `${c.repeats.toLocaleString()} sends`
+31 -1
www/static/theme.css
··· 236 236 border-radius: var(--br-full); 237 237 font-size: var(--f6); 238 238 padding: var(--s2); 239 + margin: var(--s2) 0 var(--s2) 0; 239 240 } 240 241 241 242 ui-button-group button[aria-pressed='true'] { ··· 417 418 } 418 419 419 420 /* ── Grade range filter row ────────────────────────────────────────────── */ 421 + .filters-row { 422 + display: flex; 423 + gap: var(--s4); 424 + flex-wrap: wrap; 425 + justify-content: space-between; 426 + } 427 + 428 + .sort-filter { 429 + display: flex; 430 + align-items: center; 431 + gap: var(--s1); 432 + font-size: var(--f6); 433 + flex-grow: 0.5; 434 + margin-left: var(--s3); 435 + } 436 + 437 + .sort-filter label { 438 + font-weight: var(--fw-medium); 439 + margin: 0; 440 + } 441 + 442 + .sort-filter select { 443 + padding: var(--s1) var(--s2); 444 + font-size: var(--f6); 445 + margin: 0; 446 + min-width: 6.5em; 447 + width: 100%; 448 + } 420 449 421 450 .grade-filter { 422 451 display: flex; 423 452 align-items: center; 424 453 gap: var(--s2); 425 454 font-size: var(--f6); 455 + flex-grow: 0.45; 426 456 } 427 457 428 458 .grade-filter label { ··· 695 725 margin-bottom: var(--s5); 696 726 } 697 727 698 - settings-page section>p { 728 + settings-page section > p { 699 729 opacity: 0.6; 700 730 margin-bottom: var(--s3); 701 731 }