An app for logging board climbs
0
fork

Configure Feed

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

chore: cleanup app state

+97 -79
+1 -2
www/components/climb-header.ts
··· 4 4 GRADE_FULL, 5 5 sandbagLabel, 6 6 } from '../utils/benchmarks.ts' 7 - import { getClimbNav } from '../utils/climb-nav.ts' 8 7 import app from '../models/app.ts' 9 8 10 9 export let activeClimbHeader: ClimbHeader | null = null ··· 19 18 } 20 19 21 20 update(climb: Benchmark): void { 22 - const nav = getClimbNav()! 21 + const nav = app.getNav()! 23 22 this.innerHTML = ` 24 23 <button class="back" aria-label="Back"> 25 24 <img src="/static/icons/chevron-right.svg" alt="" aria-hidden="true">
+14 -14
www/components/home-filters.ts
··· 1 1 import { GRADE_FRENCH, GRADE_V } from '../utils/benchmarks.ts' 2 + import app from '../models/app.ts' 2 3 3 4 export const PAGE_SIZE = 50 4 5 ··· 19 20 20 21 connectedCallback() { 21 22 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' 23 + state.gradeMin = app.settings.state.homeGradeMin 24 + state.gradeMax = app.settings.state.homeGradeMax 25 + state.logFilter = app.settings.state.homeFilter 29 26 state.shown = PAGE_SIZE 30 - this.gradeScale = (localStorage.getItem('grade_scale') as 'french' | 'v') ?? 31 - 'french' 27 + this.gradeScale = app.settings.state.gradeScale 32 28 this.render() 33 29 this.bindEvents() 34 30 } ··· 121 117 const maxEl = this.querySelector<HTMLSelectElement>('#bm-grade-max') 122 118 if (maxEl) maxEl.value = state.gradeMax.toString() 123 119 } 124 - localStorage.setItem('home_grade_min', state.gradeMin.toString()) 125 - localStorage.setItem('home_grade_max', state.gradeMax.toString()) 120 + app.updateSettings({ 121 + homeGradeMin: state.gradeMin, 122 + homeGradeMax: state.gradeMax, 123 + }) 126 124 state.shown = PAGE_SIZE 127 125 emitter.dispatchEvent(new Event('filter')) 128 126 }) ··· 139 137 const minEl = this.querySelector<HTMLSelectElement>('#bm-grade-min') 140 138 if (minEl) minEl.value = state.gradeMin.toString() 141 139 } 142 - localStorage.setItem('home_grade_min', state.gradeMin.toString()) 143 - localStorage.setItem('home_grade_max', state.gradeMax.toString()) 140 + app.updateSettings({ 141 + homeGradeMin: state.gradeMin, 142 + homeGradeMax: state.gradeMax, 143 + }) 144 144 state.shown = PAGE_SIZE 145 145 emitter.dispatchEvent(new Event('filter')) 146 146 }) ··· 151 151 ) 152 152 if (!btn) return 153 153 state.logFilter = btn.dataset.logFilter as LogFilter 154 - localStorage.setItem('home_filter', state.logFilter) 154 + app.updateSettings({ homeFilter: state.logFilter }) 155 155 const bar = this.querySelector('#bm-log-filter') 156 156 if (bar) bar.innerHTML = this.logFilterHtml() 157 157 state.shown = PAGE_SIZE
+4 -3
www/components/library-filters.ts
··· 1 + import app from '../models/app.ts' 2 + 1 3 type Filter = 'all' | 'sent' | 'unsent' 2 4 3 5 export const libState = { filter: 'all' as Filter } ··· 5 7 6 8 export class LibraryFilters extends HTMLElement { 7 9 connectedCallback() { 8 - libState.filter = (localStorage.getItem('library_filter') as Filter) ?? 9 - 'all' 10 + libState.filter = app.settings.state.libraryFilter 10 11 this.render() 11 12 this.addEventListener('click', this.#handleClick) 12 13 } ··· 43 44 const btn = (e.target as HTMLElement).closest<HTMLElement>('[data-filter]') 44 45 if (!btn) return 45 46 libState.filter = btn.dataset.filter as Filter 46 - localStorage.setItem('library_filter', libState.filter) 47 + app.updateSettings({ libraryFilter: libState.filter }) 47 48 const group = this.querySelector('ui-button-group') 48 49 if (group) group.innerHTML = this.filterBarHtml() 49 50 libEmitter.dispatchEvent(new Event('filter'))
+24 -14
www/models/app.ts
··· 6 6 ClimbLogEntry, 7 7 ClimbSession, 8 8 settingsMigrationConfig, 9 - StoreState, 10 9 } from './schema.ts' 11 10 import { Store } from './store.ts' 11 + import type { Benchmark } from './benchmarks.ts' 12 + 13 + export interface NavState { 14 + climbId: number 15 + mbType: number 16 + climb?: Benchmark 17 + filteredIds?: number[] 18 + currentIndex?: number 19 + backRoute: string 20 + } 12 21 13 22 const storage = useJSON<AppSettings>('mb-settings', AppSettings.parse({}), { 14 23 migrations: settingsMigrationConfig, ··· 17 26 export class App extends State<AppState> { 18 27 store: Store 19 28 settings: State<AppSettings> 29 + navState: NavState | null = null 20 30 21 31 constructor() { 22 32 super(AppState.parse({})) 23 - this.#migrateLegacy() 24 33 this.store = new Store('mb-data') 25 34 this.settings = new State(AppSettings.parse({}), { storage }) 26 35 this.settings.waitUntilReady().then(() => this.connectStore()) 27 36 this.settings.addEventListener(() => this.notify()) 28 37 } 29 38 30 - #migrateLegacy(): void { 31 - if (localStorage.getItem('mb-data')) return 32 - const raw = localStorage.getItem('mb_logbook') 33 - if (!raw) return 34 - try { 35 - const logbook = JSON.parse(raw) 36 - localStorage.setItem( 37 - 'mb-data', 38 - JSON.stringify(StoreState.parse({ logbook })), 39 - ) 40 - localStorage.removeItem('mb_logbook') 41 - } catch { /* leave old data in place if migration fails */ } 39 + setNav(s: NavState): void { 40 + this.navState = s 41 + } 42 + 43 + getNav(): NavState | null { 44 + return this.navState 42 45 } 43 46 44 47 // ====== SYNCLINK CONNECTION ====== ··· 108 111 await this.store.saveEntry(entry) 109 112 this.notify() 110 113 return entry 114 + } 115 + 116 + // ====== SETTINGS ====== 117 + 118 + updateSettings(partial: Partial<AppSettings>): void { 119 + Object.assign(this.settings.state, partial) 120 + this.settings.notify() 111 121 } 112 122 113 123 // ====== UTILITY ======
-2
www/models/schema/v0.ts
··· 28 28 }) 29 29 export type StoreState = z.infer<typeof StoreState> 30 30 31 - // Settings are still read from localStorage by routes directly; 32 - // this schema documents intent and will be wired up in a future step. 33 31 export const AppSettings = z.object({ 34 32 syncLinkUrl: z.string().default(''), 35 33 mbType: z.number().int().min(0).max(6).default(0),
+4 -5
www/routes/climb.ts
··· 9 9 loadBenchmarks, 10 10 youtubeUrl, 11 11 } from '../utils/benchmarks.ts' 12 - import { getClimbNav, setClimbNav } from '../utils/climb-nav.ts' 13 12 import app from '../models/app.ts' 14 13 import { activeClimbHeader } from '../components/climb-header.ts' 15 14 import '../components/ui-counter.ts' ··· 20 19 private dialogRating: number | null = null 21 20 22 21 async connectedCallback() { 23 - const nav = getClimbNav() 22 + const nav = app.getNav() 24 23 if (!nav) { 25 24 globalThis.location.hash = '/' 26 25 return ··· 79 78 } 80 79 81 80 private navigate(direction: number): void { 82 - const nav = getClimbNav() 81 + const nav = app.getNav() 83 82 if (!nav?.filteredIds || nav.currentIndex === undefined) return 84 83 const next = nav.currentIndex + direction 85 84 if (next < 0 || next >= nav.filteredIds.length) return ··· 88 87 if (!this.isConnected) return 89 88 const nextClimb = all.find((b) => b.id === nextId) 90 89 if (!nextClimb) return 91 - setClimbNav({ 90 + app.setNav({ 92 91 ...nav, 93 92 climbId: nextId, 94 93 climb: nextClimb, ··· 99 98 } 100 99 101 100 private renderContent(climb: Benchmark): void { 102 - const nav = getClimbNav()! 101 + const nav = app.getNav()! 103 102 const config = BOARD_CONFIGS[nav.mbType] ?? BOARD_CONFIGS[0] 104 103 const height = canvasHeight(config.rows) 105 104
+23 -5
www/routes/home.ts
··· 5 5 gradeLabel, 6 6 loadBenchmarks, 7 7 } from '../utils/benchmarks.ts' 8 - import { setClimbNav } from '../utils/climb-nav.ts' 9 8 import app from '../models/app.ts' 10 9 import { emitter, PAGE_SIZE, state } from '../components/home-filters.ts' 11 10 ··· 16 15 private gradeScale: 'french' | 'v' = 'french' 17 16 18 17 async connectedCallback() { 19 - this.mbType = parseInt(localStorage.getItem('mb_type') ?? '0', 10) 20 - this.gradeScale = (localStorage.getItem('grade_scale') as 'french' | 'v') ?? 21 - 'french' 18 + this.mbType = app.settings.state.mbType 19 + this.gradeScale = app.settings.state.gradeScale 22 20 23 21 const titleEl = document.querySelector('#page-title') 24 22 if (titleEl) { ··· 33 31 34 32 this.addEventListener('click', this.#handleClick) 35 33 emitter.addEventListener('filter', this.#onFilter) 34 + app.settings.addEventListener(this.#onSettingsUpdate) 36 35 37 36 try { 38 37 const all = await loadBenchmarks() ··· 53 52 disconnectedCallback() { 54 53 this.removeEventListener('click', this.#handleClick) 55 54 emitter.removeEventListener('filter', this.#onFilter) 55 + app.settings.removeEventListener(this.#onSettingsUpdate) 56 + } 57 + 58 + #onSettingsUpdate = async () => { 59 + const mbType = app.settings.state.mbType 60 + const gradeScale = app.settings.state.gradeScale 61 + if (mbType === this.mbType && gradeScale === this.gradeScale) return 62 + this.mbType = mbType 63 + this.gradeScale = gradeScale 64 + const titleEl = document.querySelector('#page-title') 65 + if (titleEl) { 66 + titleEl.textContent = BOARD_CONFIGS[this.mbType]?.label ?? 'Moonboard' 67 + } 68 + const all = await loadBenchmarks() 69 + this.benchmarks = all 70 + .filter((b) => b.mb_type === this.mbType) 71 + .sort((a, b) => b.repeats - a.repeats) 72 + this.applyFilters() 73 + this.renderList() 56 74 } 57 75 58 76 #onFilter = () => { ··· 154 172 155 173 private openClimb(index: number): void { 156 174 const climb = this.filtered[index] 157 - setClimbNav({ 175 + app.setNav({ 158 176 climbId: climb.id, 159 177 mbType: this.mbType, 160 178 climb,
+9 -4
www/routes/library.ts
··· 1 1 import app from '../models/app.ts' 2 2 import type { ClimbLogEntry } from '../models/schema.ts' 3 - import { setClimbNav } from '../utils/climb-nav.ts' 4 3 import { escapeHtml, gradeLabel } from '../utils/benchmarks.ts' 5 4 import { formatDate, starsHtml } from '../utils/format.ts' 6 5 import { libEmitter, libState } from '../components/library-filters.ts' ··· 9 8 private gradeScale: 'french' | 'v' = 'french' 10 9 11 10 connectedCallback() { 12 - this.gradeScale = (localStorage.getItem('grade_scale') as 'french' | 'v') ?? 13 - 'french' 11 + this.gradeScale = app.settings.state.gradeScale 14 12 this.innerHTML = `<div id="lb-list"></div>` 15 13 this.renderList() 16 14 libEmitter.addEventListener('filter', this.#onFilter) 17 15 this.addEventListener('click', this.#handleClick) 18 16 app.store.addEventListener(this.#onStoreUpdate) 17 + app.settings.addEventListener(this.#onSettingsUpdate) 19 18 } 20 19 21 20 disconnectedCallback() { 22 21 libEmitter.removeEventListener('filter', this.#onFilter) 23 22 this.removeEventListener('click', this.#handleClick) 24 23 app.store.removeEventListener(this.#onStoreUpdate) 24 + app.settings.removeEventListener(this.#onSettingsUpdate) 25 25 } 26 26 27 27 #onFilter = () => { ··· 32 32 this.renderList() 33 33 } 34 34 35 + #onSettingsUpdate = () => { 36 + this.gradeScale = app.settings.state.gradeScale 37 + this.renderList() 38 + } 39 + 35 40 #handleClick = (e: Event) => { 36 41 const target = e.target as HTMLElement 37 42 const entry = target.closest<HTMLElement>('[data-climb-id]') 38 43 if (entry) { 39 44 const climbId = parseInt(entry.dataset.climbId ?? '0') 40 45 const mbType = parseInt(entry.dataset.mbType ?? '0') 41 - setClimbNav({ climbId, mbType, backRoute: '/library' }) 46 + app.setNav({ climbId, mbType, backRoute: '/library' }) 42 47 globalThis.location.hash = '/climb' 43 48 } 44 49 }
+18 -10
www/routes/settings.ts
··· 29 29 private gradeScale = 'french' 30 30 31 31 connectedCallback() { 32 - this.selectedType = parseInt(localStorage.getItem('mb_type') ?? '0', 10) 33 - this.gradeScale = localStorage.getItem('grade_scale') ?? 'french' 32 + this.selectedType = app.settings.state.mbType 33 + this.gradeScale = app.settings.state.gradeScale 34 34 this.render() 35 35 this.bindEvents() 36 + app.settings.addEventListener(this.#onSettingsUpdate) 36 37 } 37 38 38 39 disconnectedCallback() { 39 40 this.removeEventListener('click', this.#handleClick) 41 + app.settings.removeEventListener(this.#onSettingsUpdate) 42 + } 43 + 44 + #onSettingsUpdate = () => { 45 + this.selectedType = app.settings.state.mbType 46 + this.gradeScale = app.settings.state.gradeScale 47 + this.render() 48 + this.bindEvents() 40 49 } 41 50 42 51 private render(): void { 43 52 this.innerHTML = ` 44 53 <section> 54 + <h2>Version</h2> 55 + <ui-version></ui-version> 56 + </section> 57 + <section> 45 58 <h2>Board Setup</h2> 46 59 <p>Select which Moonboard setup you are using.</p> 47 60 <div role="radiogroup" aria-label="Board setup"> ··· 93 106 </div> 94 107 <p id="settings-data-status" class="settings-data-status" hidden></p> 95 108 </section> 96 - 97 - <section> 98 - <h2>Version</h2> 99 - <ui-version></ui-version> 100 - </section> 101 109 ` 102 110 } 103 111 ··· 150 158 (input) => { 151 159 input.addEventListener('change', (e) => { 152 160 const value = parseInt((e.target as HTMLInputElement).value, 10) 153 - localStorage.setItem('mb_type', value.toString()) 161 + app.updateSettings({ mbType: value }) 154 162 this.selectedType = value 155 163 }) 156 164 }, ··· 160 168 .forEach( 161 169 (input) => { 162 170 input.addEventListener('change', (e) => { 163 - const value = (e.target as HTMLInputElement).value 164 - localStorage.setItem('grade_scale', value) 171 + const value = (e.target as HTMLInputElement).value as 'french' | 'v' 172 + app.updateSettings({ gradeScale: value }) 165 173 this.gradeScale = value 166 174 }) 167 175 },
-20
www/utils/climb-nav.ts
··· 1 - import type { Benchmark } from './benchmarks.ts' 2 - 3 - export interface ClimbNavState { 4 - climbId: number 5 - mbType: number 6 - climb?: Benchmark 7 - filteredIds?: number[] 8 - currentIndex?: number 9 - backRoute: string 10 - } 11 - 12 - let state: ClimbNavState | null = null 13 - 14 - export function setClimbNav(s: ClimbNavState): void { 15 - state = s 16 - } 17 - 18 - export function getClimbNav(): ClimbNavState | null { 19 - return state 20 - }