An app for logging board climbs
0
fork

Configure Feed

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

fix: issue where filters don't update

+142 -65
+64
CLAUDE.md
··· 1 + # CLAUDE.md 2 + 3 + This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. 4 + 5 + ## Commands 6 + 7 + ```sh 8 + deno task dev # Development server with hot reload 9 + deno task build # Production build 10 + deno task fetch:benchmarks # Refresh benchmark data from Moonboard API (requires USERNAME/PASSWORD env vars) 11 + ``` 12 + 13 + Build output goes to `www/dist/`. The build tool is [Civility](https://github.com/bpev/civility) (`civility.json` is its config). 14 + 15 + ## Architecture 16 + 17 + **Moonbound** is an offline-first PWA for browsing Moonboard benchmark climbing problems. It uses Deno + TypeScript with no framework—components are native Web Components. 18 + 19 + ### Routing & Components 20 + 21 + `www/index.ts` initializes a Civility `Router` that maps URL paths to custom elements: 22 + 23 + - `/` → `<home-page>` — benchmark listing with search/grade/status filters 24 + - `/climb` → `<climb-page>` — climb detail with canvas route diagram and logbook entry 25 + - `/library` → `<library-page>` — user's personal climb log 26 + - `/stopwatch` → `<stopwatch-page>` — timer with lap tracking 27 + - `/settings` → `<settings-page>` — board type and grade scale preferences 28 + 29 + Each route is defined in `www/routes/`. Components extend `HTMLElement`, manage their own state, and re-render as needed. 30 + 31 + ### Data Flow 32 + 33 + - **Benchmark data**: Static `www/static/data/benchmarks.json` (fetched from Moonboard API via `scripts/moon.ts`, then committed). Loaded once and cached in memory by `www/utils/benchmarks.ts`. 34 + - **User data**: Everything persisted to `localStorage`—logbook (`mb_logbook`), board type (`mb_type`), grade scale (`grade_scale`), and filter states. 35 + - **Navigation state**: `www/utils/climb-nav.ts` holds a singleton with the current filtered climb list and index, enabling swipe navigation in `<climb-page>` via Hammer.js. 36 + 37 + ### Key Utilities 38 + 39 + - `www/utils/benchmarks.ts` — grade mappings, board configs (7 models, 2016–2024), canvas drawing logic for hold positions 40 + - `www/utils/logbook.ts` — logbook CRUD, session tracking (date, attempts, sent, rating) 41 + - `www/utils/updates.ts` — service worker registration and 5-minute update polling 42 + 43 + ### CSS Architecture 44 + 45 + All styles live in `www/static/theme.css` inside a single `@layer theme` block, which sits above Civility's `base` and `components` layers. The layering order (declared in `civility.css`) is: `normalize → base → base-theme → components → theme → utilities`. 46 + 47 + **Civility elements used for structure:** 48 + 49 + - `ui-sub-header` — sticky filter bars in `<home-page>` and `<library-page>`; styled to be a flex column with padding/border 50 + - `ui-main-header` — climb detail header bar inside `<climb-page>` (flex row: back button, title, meta) 51 + - `ui-badge` — grade pills (`.grade` class) and status badges (`variant="success"` for Sent, bare for Project) 52 + - `ui-button-group` — filter button groups; active state driven by `aria-pressed="true"` rather than a class 53 + 54 + **Host element styling:** page-level layout (flex column, height, padding) is set directly on the custom element tags (`home-page`, `library-page`, `climb-page`, `settings-page`, `stopwatch-page`) rather than inner wrapper divs. 55 + 56 + **Settings page:** uses CSS `:has(input:checked)` on bare `label` elements—no JS needed to toggle selected state. 57 + 58 + ### PWA / Service Worker 59 + 60 + `www/worker.ts` implements a cache-first strategy with network fallback. `www/manifest.json` configures the PWA. The service worker is registered by `www/utils/updates.ts`, which also prompts users when a new version is available. 61 + 62 + ### Code Style 63 + 64 + Deno fmt config (from `deno.json`): single quotes, no semicolons, preserve prose wrap.
+58 -65
www/components/home-filters.ts
··· 26 26 state.shown = PAGE_SIZE 27 27 this.gradeScale = app.settings.state.gradeScale 28 28 this.render() 29 - this.bindEvents() 29 + this.addEventListener('input', this.#onInput) 30 + this.addEventListener('change', this.#onChange) 31 + this.addEventListener('click', this.#onClick) 30 32 app.settings.addEventListener(this.#onSettingsUpdate) 31 33 } 32 34 33 35 disconnectedCallback() { 36 + this.removeEventListener('input', this.#onInput) 37 + this.removeEventListener('change', this.#onChange) 38 + this.removeEventListener('click', this.#onClick) 34 39 app.settings.removeEventListener(this.#onSettingsUpdate) 35 40 } 36 41 37 - #onSettingsUpdate = async () => { 42 + #onSettingsUpdate = () => { 38 43 const settings = app.settings.state 39 44 if ( 40 45 settings.homeGradeMax === state.gradeMax && ··· 47 52 state.gradeMin = settings.homeGradeMin 48 53 this.gradeScale = settings.gradeScale 49 54 this.render() 50 - this.bindEvents() 55 + emitter.dispatchEvent(new Event('filter')) 56 + } 57 + 58 + #onInput = (e: Event) => { 59 + if ((e.target as HTMLElement).id !== 'bm-search') return 60 + state.search = (e.target as HTMLInputElement).value 61 + state.shown = PAGE_SIZE 62 + emitter.dispatchEvent(new Event('filter')) 63 + } 64 + 65 + #onChange = (e: Event) => { 66 + const target = e.target as HTMLSelectElement 67 + const val = parseInt(target.value, 10) 68 + if (target.id === 'bm-grade-min') { 69 + state.gradeMin = val 70 + if (state.gradeMax < val) { 71 + const group = this.gradeGroups().find( 72 + (g) => g.first <= val && val <= g.last, 73 + ) 74 + state.gradeMax = group?.last ?? val 75 + const maxEl = this.querySelector<HTMLSelectElement>('#bm-grade-max') 76 + if (maxEl) maxEl.value = state.gradeMax.toString() 77 + } 78 + app.updateSettings({ homeGradeMin: state.gradeMin, homeGradeMax: state.gradeMax }) 79 + state.shown = PAGE_SIZE 80 + emitter.dispatchEvent(new Event('filter')) 81 + } else if (target.id === 'bm-grade-max') { 82 + state.gradeMax = val 83 + if (state.gradeMin > val) { 84 + const group = this.gradeGroups().find( 85 + (g) => g.first <= val && val <= g.last, 86 + ) 87 + state.gradeMin = group?.first ?? val 88 + const minEl = this.querySelector<HTMLSelectElement>('#bm-grade-min') 89 + if (minEl) minEl.value = state.gradeMin.toString() 90 + } 91 + app.updateSettings({ homeGradeMin: state.gradeMin, homeGradeMax: state.gradeMax }) 92 + state.shown = PAGE_SIZE 93 + emitter.dispatchEvent(new Event('filter')) 94 + } 95 + } 96 + 97 + #onClick = (e: Event) => { 98 + const btn = (e.target as HTMLElement).closest<HTMLElement>('[data-log-filter]') 99 + if (!btn) return 100 + state.logFilter = btn.dataset.logFilter as LogFilter 101 + app.updateSettings({ homeFilter: state.logFilter }) 102 + const bar = this.querySelector('#bm-log-filter') 103 + if (bar) bar.innerHTML = this.logFilterHtml() 104 + state.shown = PAGE_SIZE 105 + emitter.dispatchEvent(new Event('filter')) 51 106 } 52 107 53 108 private render() { ··· 116 171 }>${g.label}</option>` 117 172 }) 118 173 .join('') 119 - } 120 - 121 - private bindEvents() { 122 - const searchEl = this.querySelector<HTMLInputElement>('#bm-search') 123 - searchEl?.addEventListener('input', (e) => { 124 - state.search = (e.target as HTMLInputElement).value 125 - state.shown = PAGE_SIZE 126 - emitter.dispatchEvent(new Event('filter')) 127 - }) 128 - 129 - const gradeMinEl = this.querySelector<HTMLSelectElement>('#bm-grade-min') 130 - gradeMinEl?.addEventListener('change', (e) => { 131 - const val = parseInt((e.target as HTMLSelectElement).value, 10) 132 - state.gradeMin = val 133 - if (state.gradeMax < val) { 134 - const group = this.gradeGroups().find( 135 - (g) => g.first <= val && val <= g.last, 136 - ) 137 - state.gradeMax = group?.last ?? val 138 - const maxEl = this.querySelector<HTMLSelectElement>('#bm-grade-max') 139 - if (maxEl) maxEl.value = state.gradeMax.toString() 140 - } 141 - app.updateSettings({ 142 - homeGradeMin: state.gradeMin, 143 - homeGradeMax: state.gradeMax, 144 - }) 145 - state.shown = PAGE_SIZE 146 - emitter.dispatchEvent(new Event('filter')) 147 - }) 148 - 149 - const gradeMaxEl = this.querySelector<HTMLSelectElement>('#bm-grade-max') 150 - gradeMaxEl?.addEventListener('change', (e) => { 151 - const val = parseInt((e.target as HTMLSelectElement).value, 10) 152 - state.gradeMax = val 153 - if (state.gradeMin > val) { 154 - const group = this.gradeGroups().find( 155 - (g) => g.first <= val && val <= g.last, 156 - ) 157 - state.gradeMin = group?.first ?? val 158 - const minEl = this.querySelector<HTMLSelectElement>('#bm-grade-min') 159 - if (minEl) minEl.value = state.gradeMin.toString() 160 - } 161 - app.updateSettings({ 162 - homeGradeMin: state.gradeMin, 163 - homeGradeMax: state.gradeMax, 164 - }) 165 - state.shown = PAGE_SIZE 166 - emitter.dispatchEvent(new Event('filter')) 167 - }) 168 - 169 - this.querySelector('#bm-log-filter')?.addEventListener('click', (e) => { 170 - const btn = (e.target as HTMLElement).closest<HTMLElement>( 171 - '[data-log-filter]', 172 - ) 173 - if (!btn) return 174 - state.logFilter = btn.dataset.logFilter as LogFilter 175 - app.updateSettings({ homeFilter: state.logFilter }) 176 - const bar = this.querySelector('#bm-log-filter') 177 - if (bar) bar.innerHTML = this.logFilterHtml() 178 - state.shown = PAGE_SIZE 179 - emitter.dispatchEvent(new Event('filter')) 180 - }) 181 174 } 182 175 } 183 176
+10
www/components/library-filters.ts
··· 10 10 libState.filter = app.settings.state.libraryFilter 11 11 this.render() 12 12 this.addEventListener('click', this.#handleClick) 13 + app.settings.addEventListener(this.#onSettingsUpdate) 13 14 } 14 15 15 16 disconnectedCallback() { 16 17 this.removeEventListener('click', this.#handleClick) 18 + app.settings.removeEventListener(this.#onSettingsUpdate) 19 + } 20 + 21 + #onSettingsUpdate = () => { 22 + const filter = app.settings.state.libraryFilter 23 + if (filter === libState.filter) return 24 + libState.filter = filter 25 + this.render() 26 + libEmitter.dispatchEvent(new Event('filter')) 17 27 } 18 28 19 29 private render() {
+10
www/routes/climb.ts
··· 223 223 return 224 224 } 225 225 226 + const sentCheckbox = box.querySelector<HTMLInputElement>('#lb-sent') 227 + if (target.closest('#lb-sent') && !sentCheckbox?.checked) { 228 + this.dialogRating = null 229 + box.querySelectorAll<HTMLButtonElement>('.lb-star-btn').forEach((b) => { 230 + b.classList.remove('lb-star-btn--active') 231 + }) 232 + return 233 + } 234 + 226 235 const star = target.closest<HTMLElement>('.lb-star-btn') 227 236 if (star) { 237 + if (!sentCheckbox?.checked) return 228 238 const val = parseInt(star.dataset.star ?? '0') 229 239 this.dialogRating = this.dialogRating === val ? null : val 230 240 box.querySelectorAll<HTMLButtonElement>('.lb-star-btn').forEach((b) => {