An app for logging board climbs
0
fork

Configure Feed

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

feat: use civility 0.0.8

+387 -895
+3 -2
deno.json
··· 21 21 "fetch:benchmarks": "deno run --allow-net --allow-read --allow-write --allow-env scripts/moon.ts" 22 22 }, 23 23 "imports": { 24 - "@bpev/civility": "jsr:@bpev/civility@^0.0.5", 24 + "@bpev/civility": "jsr:@bpev/civility@^0.0.8", 25 25 "@bpev/sync-link": "jsr:@bpev/sync-link@^0.0.17", 26 26 "@inro/simple-tools": "jsr:@inro/simple-tools@^0.5.2", 27 - "@std/assert": "jsr:@std/assert@^1.0.18", 27 + "@std/assert": "jsr:@std/assert@^1.0.19", 28 28 "@std/dotenv": "jsr:@std/dotenv@^0.225.6", 29 29 "@std/path": "jsr:@std/path@^1.1.4", 30 30 "@std/testing": "jsr:@std/testing@^1.0.17", 31 31 "hammerjs": "npm:hammerjs@^2.0.8", 32 + "lit": "npm:lit@^3.3.2", 32 33 "zod": "npm:zod@^4.3.6" 33 34 } 34 35 }
+34 -27
deno.lock
··· 1 1 { 2 2 "version": "5", 3 3 "specifiers": { 4 - "jsr:@bpev/civility@^0.0.5": "0.0.5", 4 + "jsr:@bpev/civility@^0.0.8": "0.0.8", 5 5 "jsr:@bpev/sync-link@^0.0.17": "0.0.17", 6 6 "jsr:@inro/simple-tools@0.5.2": "0.5.2", 7 7 "jsr:@inro/simple-tools@~0.5.2": "0.5.2", 8 8 "jsr:@paulmillr/qr@~0.5.2": "0.5.4", 9 9 "jsr:@std/dotenv@~0.225.6": "0.225.6", 10 - "npm:@hono/zod-openapi@^1.1.0": "1.2.1_hono@4.11.8_zod@4.3.6", 10 + "npm:@hono/zod-openapi@^1.1.0": "1.2.2_hono@4.12.3_zod@4.3.6", 11 11 "npm:hammerjs@^2.0.8": "2.0.8", 12 - "npm:lit@^3.3.1": "3.3.1", 12 + "npm:lit@^3.3.2": "3.3.2", 13 13 "npm:native-file-system-adapter@^3.0.1": "3.0.1", 14 14 "npm:zod@^4.3.6": "4.3.6" 15 15 }, 16 16 "jsr": { 17 - "@bpev/civility@0.0.5": { 18 - "integrity": "efda2154a6863608221ee9f0efec5a5cf790bbc4d07b754e245580431e05374b", 17 + "@bpev/civility@0.0.8": { 18 + "integrity": "a60ac3392e3850d7d1bb47cee5940a754052030cbb1988989bb207999676dd5e", 19 19 "dependencies": [ 20 20 "npm:lit" 21 21 ] ··· 32 32 "@inro/simple-tools@0.5.2": { 33 33 "integrity": "cc34cd0914b9e0576d9bed9a66a91994123b73f3fd87a4e8db76880181731ee5" 34 34 }, 35 - "@paulmillr/qr@0.5.2": { 36 - "integrity": "dcaabde6e5125cabecd82f7f0044062dfc0439493c2945bd14c9368e5a3982f2" 37 - }, 38 35 "@paulmillr/qr@0.5.4": { 39 36 "integrity": "b9c40e31104c63700df2c37c5d2674770112c75713d7dd1b922a23926500b26b" 40 37 }, ··· 43 40 } 44 41 }, 45 42 "npm": { 46 - "@asteasolutions/zod-to-openapi@8.4.0_zod@4.3.6": { 47 - "integrity": "sha512-Ckp971tmTw4pnv+o7iK85ldBHBKk6gxMaoNyLn3c2Th/fKoTG8G3jdYuOanpdGqwlDB0z01FOjry2d32lfTqrA==", 43 + "@asteasolutions/zod-to-openapi@8.4.1_zod@4.3.6": { 44 + "integrity": "sha512-WmJUsFINbnWxGvHSd16aOjgKf+5GsfdxruO2YDLcgplsidakCauik1lhlk83YDH06265Yd1XtUyF24o09uygpw==", 48 45 "dependencies": [ 49 46 "openapi3-ts", 50 47 "zod" 51 48 ] 52 49 }, 53 - "@hono/zod-openapi@1.2.1_hono@4.11.8_zod@4.3.6": { 54 - "integrity": "sha512-aZza4V8wkqpdHBWFNPiCeWd0cGOXbYuQW9AyezHs/jwQm5p67GkUyXwfthAooAwnG7thTpvOJkThZpCoY6us8w==", 50 + "@hono/zod-openapi@1.2.2_hono@4.12.3_zod@4.3.6": { 51 + "integrity": "sha512-va6vsL23wCJ1d0Vd+vGL1XOt+wPwItxirYafuhlW9iC2MstYr2FvsI7mctb45eBTjZfkqB/3LYDJEppPjOEiHw==", 55 52 "dependencies": [ 56 53 "@asteasolutions/zod-to-openapi", 57 54 "@hono/zod-validator", ··· 60 57 "zod" 61 58 ] 62 59 }, 63 - "@hono/zod-validator@0.7.6_hono@4.11.8_zod@4.3.6": { 60 + "@hono/zod-validator@0.7.6_hono@4.12.3_zod@4.3.6": { 64 61 "integrity": "sha512-Io1B6d011Gj1KknV4rXYz4le5+5EubcWEU/speUjuw9XMMIaP3n78yXLhjd2A3PXaXaUwEAluOiAyLqhBEJgsw==", 65 62 "dependencies": [ 66 63 "hono", 67 64 "zod" 68 65 ] 69 66 }, 70 - "@lit-labs/ssr-dom-shim@1.4.0": { 71 - "integrity": "sha512-ficsEARKnmmW5njugNYKipTm4SFnbik7CXtoencDZzmzo/dQ+2Q0bgkzJuoJP20Aj0F+izzJjOqsnkd6F/o1bw==" 67 + "@lit-labs/ssr-dom-shim@1.5.1": { 68 + "integrity": "sha512-Aou5UdlSpr5whQe8AA/bZG0jMj96CoJIWbGfZ91qieWu5AWUMKw8VR/pAkQkJYvBNhmCcWnZlyyk5oze8JIqYA==" 72 69 }, 73 - "@lit/reactive-element@2.1.1": { 74 - "integrity": "sha512-N+dm5PAYdQ8e6UlywyyrgI2t++wFGXfHx+dSJ1oBrg6FAxUj40jId++EaRm80MKX5JnlH1sBsyZ5h0bcZKemCg==", 70 + "@lit/reactive-element@2.1.2": { 71 + "integrity": "sha512-pbCDiVMnne1lYUIaYNN5wrwQXDtHaYtg7YEFPeW+hws6U47WeFvISGUWekPGKWOP1ygrs0ef0o1VJMk1exos5A==", 75 72 "dependencies": [ 76 73 "@lit-labs/ssr-dom-shim" 77 74 ] ··· 89 86 "hammerjs@2.0.8": { 90 87 "integrity": "sha512-tSQXBXS/MWQOn/RKckawJ61vvsDpCom87JgxiYdGwHdOa0ht0vzUWDlfioofFCRU0L+6NGDt6XzbgoJvZkMeRQ==" 91 88 }, 92 - "hono@4.11.8": { 93 - "integrity": "sha512-eVkB/CYCCei7K2WElZW9yYQFWssG0DhaDhVvr7wy5jJ22K+ck8fWW0EsLpB0sITUTvPnc97+rrbQqIr5iqiy9Q==" 89 + "hono@4.12.3": { 90 + "integrity": "sha512-SFsVSjp8sj5UumXOOFlkZOG6XS9SJDKw0TbwFeV+AJ8xlST8kxK5Z/5EYa111UY8732lK2S/xB653ceuaoGwpg==" 94 91 }, 95 - "lit-element@4.2.1": { 96 - "integrity": "sha512-WGAWRGzirAgyphK2urmYOV72tlvnxw7YfyLDgQ+OZnM9vQQBQnumQ7jUJe6unEzwGU3ahFOjuz1iz1jjrpCPuw==", 92 + "lit-element@4.2.2": { 93 + "integrity": "sha512-aFKhNToWxoyhkNDmWZwEva2SlQia+jfG0fjIWV//YeTaWrVnOxD89dPKfigCUspXFmjzOEUQpOkejH5Ly6sG0w==", 97 94 "dependencies": [ 98 95 "@lit-labs/ssr-dom-shim", 99 96 "@lit/reactive-element", 100 97 "lit-html" 101 98 ] 102 99 }, 103 - "lit-html@3.3.1": { 104 - "integrity": "sha512-S9hbyDu/vs1qNrithiNyeyv64c9yqiW9l+DBgI18fL+MTvOtWoFR0FWiyq1TxaYef5wNlpEmzlXoBlZEO+WjoA==", 100 + "lit-html@3.3.2": { 101 + "integrity": "sha512-Qy9hU88zcmaxBXcc10ZpdK7cOLXvXpRoBxERdtqV9QOrfpMZZ6pSYP91LhpPtap3sFMUiL7Tw2RImbe0Al2/kw==", 105 102 "dependencies": [ 106 103 "@types/trusted-types" 107 104 ] 108 105 }, 109 - "lit@3.3.1": { 110 - "integrity": "sha512-Ksr/8L3PTapbdXJCk+EJVB78jDodUMaP54gD24W186zGRARvwrsPfS60wae/SSCTCNZVPd1chXqio1qHQmu4NA==", 106 + "lit@3.3.2": { 107 + "integrity": "sha512-NF9zbsP79l4ao2SNrH3NkfmFgN/hBYSQo90saIVI1o5GpjAdCPVstVzO1MrLOakHoEhYkrtRjPK6Ob521aoYWQ==", 111 108 "dependencies": [ 112 109 "@lit/reactive-element", 113 110 "lit-element", ··· 141 138 "integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==" 142 139 } 143 140 }, 141 + "redirects": { 142 + "https://esm.sh/jsr/@bpev/civility@^0.0.7/workers": "https://esm.sh/jsr/@bpev/civility@0.0.7/workers", 143 + "https://esm.sh/jsr/@bpev/civility@^0.0.7/workers?worker": "https://esm.sh/jsr/@bpev/civility@0.0.7/workers?worker" 144 + }, 145 + "remote": { 146 + "https://esm.sh/@jsr/bpev__civility@0.0.7/denonext/workers.mjs": "862bb790ddff27a5acf2035e0ebc7b6ff9dfa2886753bc832234bb0873f518e3", 147 + "https://esm.sh/jsr/@bpev/civility@0.0.7/workers": "4cae407e5a3af8397e6600fb7678d107d01cf487bfd84ba8ddf88d455bbfb40f", 148 + "https://esm.sh/jsr/@bpev/civility@0.0.7/workers?worker": "0f1288607f51350185f70af66699c735c15bca9a31a43c90e8efe703ab095caf" 149 + }, 144 150 "workspace": { 145 151 "dependencies": [ 146 - "jsr:@bpev/civility@^0.0.5", 152 + "jsr:@bpev/civility@^0.0.8", 147 153 "jsr:@bpev/sync-link@^0.0.17", 148 154 "jsr:@inro/simple-tools@~0.5.2", 149 - "jsr:@std/assert@^1.0.18", 155 + "jsr:@std/assert@^1.0.19", 150 156 "jsr:@std/dotenv@~0.225.6", 151 157 "jsr:@std/path@^1.1.4", 152 158 "jsr:@std/testing@^1.0.17", 153 159 "npm:hammerjs@^2.0.8", 160 + "npm:lit@^3.3.2", 154 161 "npm:zod@^4.3.6" 155 162 ] 156 163 }
-60
www/components/climb-header.ts
··· 1 - import { 2 - type Benchmark, 3 - escapeHtml, 4 - GRADE_FULL, 5 - sandbagLabel, 6 - } from '../utils/benchmarks.ts' 7 - import app from '../models/app.ts' 8 - 9 - export let activeClimbHeader: ClimbHeader | null = null 10 - 11 - export class ClimbHeader extends HTMLElement { 12 - connectedCallback() { 13 - activeClimbHeader = this 14 - } 15 - 16 - disconnectedCallback() { 17 - if (activeClimbHeader === this) activeClimbHeader = null 18 - } 19 - 20 - update(climb: Benchmark): void { 21 - const nav = app.getNav()! 22 - this.innerHTML = ` 23 - <button class="back" aria-label="Back"> 24 - <img src="/static/icons/chevron-right.svg" alt="" aria-hidden="true"> 25 - </button> 26 - <div class="title-info"> 27 - <strong class="name">${escapeHtml(climb.name)}</strong> 28 - <small class="subtitle">${GRADE_FULL[climb.grade] ?? ''} ${ 29 - sandbagLabel(climb.sandbag_score) 30 - } · ${escapeHtml(climb.setter)}</small> 31 - </div> 32 - <div class="meta"> 33 - ${this.metaHtml(climb)} 34 - </div> 35 - ` 36 - this.querySelector('.back')?.addEventListener('click', () => { 37 - globalThis.location.hash = nav.backRoute 38 - }) 39 - } 40 - 41 - metaHtml(climb: Benchmark): string { 42 - const entry = app.getClimbLog(climb.id) 43 - if (entry) { 44 - const badge = entry.sent 45 - ? '<ui-badge variant="success">Sent</ui-badge>' 46 - : '<ui-badge>Project</ui-badge>' 47 - const stars = entry.rating ? '★'.repeat(entry.rating) : '' 48 - return ` 49 - <span>${badge} ${stars}</span> 50 - <span>${entry.totalAttempts} attempts</span> 51 - ` 52 - } 53 - return ` 54 - <span>★ ${climb.avg_user_stars.toFixed(1)}</span> 55 - <span>${climb.repeats.toLocaleString()} sends</span> 56 - ` 57 - } 58 - } 59 - 60 - customElements.define('climb-header', ClimbHeader)
-177
www/components/home-filters.ts
··· 1 - import { GRADE_FRENCH, GRADE_V } from '../utils/benchmarks.ts' 2 - import app from '../models/app.ts' 3 - 4 - export const PAGE_SIZE = 50 5 - 6 - type LogFilter = 'all' | 'sent' | 'unsent' 7 - 8 - export const state = { 9 - search: '', 10 - gradeMin: 0, 11 - gradeMax: 16, 12 - logFilter: 'all' as LogFilter, 13 - shown: PAGE_SIZE, 14 - } 15 - 16 - export const emitter = new EventTarget() 17 - 18 - export class HomeFilters extends HTMLElement { 19 - private gradeScale: 'french' | 'v' = 'french' 20 - 21 - connectedCallback() { 22 - state.search = '' 23 - state.gradeMin = app.settings.state.homeGradeMin 24 - state.gradeMax = app.settings.state.homeGradeMax 25 - state.logFilter = app.settings.state.homeFilter 26 - state.shown = PAGE_SIZE 27 - this.gradeScale = app.settings.state.gradeScale 28 - this.render() 29 - this.addEventListener('input', this.#onInput) 30 - this.addEventListener('change', this.#onChange) 31 - this.addEventListener('click', this.#onClick) 32 - app.settings.addEventListener(this.#onSettingsUpdate) 33 - } 34 - 35 - disconnectedCallback() { 36 - this.removeEventListener('input', this.#onInput) 37 - this.removeEventListener('change', this.#onChange) 38 - this.removeEventListener('click', this.#onClick) 39 - app.settings.removeEventListener(this.#onSettingsUpdate) 40 - } 41 - 42 - #onSettingsUpdate = () => { 43 - const settings = app.settings.state 44 - if ( 45 - settings.homeGradeMax === state.gradeMax && 46 - settings.homeGradeMin === state.gradeMin && 47 - settings.homeFilter === state.logFilter && 48 - settings.gradeScale === this.gradeScale 49 - ) return 50 - state.logFilter = settings.homeFilter 51 - state.gradeMax = settings.homeGradeMax 52 - state.gradeMin = settings.homeGradeMin 53 - this.gradeScale = settings.gradeScale 54 - this.render() 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')) 106 - } 107 - 108 - private render() { 109 - this.innerHTML = ` 110 - <input 111 - type="search" 112 - id="bm-search" 113 - placeholder="Search by name or setter..." 114 - autocomplete="off" 115 - > 116 - <ui-button-group id="bm-log-filter"> 117 - ${this.logFilterHtml()} 118 - </ui-button-group> 119 - <div class="grade-filter"> 120 - <label for="bm-grade-min">Grade</label> 121 - <select id="bm-grade-min"> 122 - ${this.gradeOptions('min')} 123 - </select> 124 - <span aria-hidden="true">–</span> 125 - <select id="bm-grade-max"> 126 - ${this.gradeOptions('max')} 127 - </select> 128 - </div> 129 - ` 130 - } 131 - 132 - private logFilterHtml(): string { 133 - const opts: { value: LogFilter; label: string }[] = [ 134 - { value: 'all', label: 'All' }, 135 - { value: 'sent', label: 'Completed' }, 136 - { value: 'unsent', label: 'Not Completed' }, 137 - ] 138 - return opts 139 - .map( 140 - (o) => 141 - `<button aria-pressed="${ 142 - state.logFilter === o.value 143 - }" data-log-filter="${o.value}">${o.label}</button>`, 144 - ) 145 - .join('') 146 - } 147 - 148 - private gradeGroups(): { label: string; first: number; last: number }[] { 149 - const table = this.gradeScale === 'v' ? GRADE_V : GRADE_FRENCH 150 - const groups: { label: string; first: number; last: number }[] = [] 151 - for (let i = 0; i <= 16; i++) { 152 - const label = table[i] ?? '?' 153 - const prev = groups[groups.length - 1] 154 - if (prev && prev.label === label) { 155 - prev.last = i 156 - } else { 157 - groups.push({ label, first: i, last: i }) 158 - } 159 - } 160 - return groups 161 - } 162 - 163 - private gradeOptions(role: 'min' | 'max'): string { 164 - const current = role === 'min' ? state.gradeMin : state.gradeMax 165 - return this.gradeGroups() 166 - .map((g) => { 167 - const value = role === 'min' ? g.first : g.last 168 - const selected = current >= g.first && current <= g.last 169 - return `<option value="${value}" ${ 170 - selected ? 'selected' : '' 171 - }>${g.label}</option>` 172 - }) 173 - .join('') 174 - } 175 - } 176 - 177 - customElements.define('home-filters', HomeFilters)
-64
www/components/library-filters.ts
··· 1 - import app from '../models/app.ts' 2 - 3 - type Filter = 'all' | 'sent' | 'unsent' 4 - 5 - export const libState = { filter: 'all' as Filter } 6 - export const libEmitter = new EventTarget() 7 - 8 - export class LibraryFilters extends HTMLElement { 9 - connectedCallback() { 10 - libState.filter = app.settings.state.libraryFilter 11 - this.render() 12 - this.addEventListener('click', this.#handleClick) 13 - app.settings.addEventListener(this.#onSettingsUpdate) 14 - } 15 - 16 - disconnectedCallback() { 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')) 27 - } 28 - 29 - private render() { 30 - this.innerHTML = ` 31 - <ui-button-group> 32 - ${this.filterBarHtml()} 33 - </ui-button-group> 34 - ` 35 - } 36 - 37 - private filterBarHtml(): string { 38 - const opts: { value: Filter; label: string }[] = [ 39 - { value: 'all', label: 'All' }, 40 - { value: 'sent', label: 'Completed' }, 41 - { value: 'unsent', label: 'Not Completed' }, 42 - ] 43 - return opts 44 - .map( 45 - (o) => 46 - `<button aria-pressed="${ 47 - libState.filter === o.value 48 - }" data-filter="${o.value}">${o.label}</button>`, 49 - ) 50 - .join('') 51 - } 52 - 53 - #handleClick = (e: Event) => { 54 - const btn = (e.target as HTMLElement).closest<HTMLElement>('[data-filter]') 55 - if (!btn) return 56 - libState.filter = btn.dataset.filter as Filter 57 - app.updateSettings({ libraryFilter: libState.filter }) 58 - const group = this.querySelector('ui-button-group') 59 - if (group) group.innerHTML = this.filterBarHtml() 60 - libEmitter.dispatchEvent(new Event('filter')) 61 - } 62 - } 63 - 64 - customElements.define('library-filters', LibraryFilters)
-90
www/components/ui-counter.ts
··· 1 - class UICounter extends HTMLElement { 2 - static observedAttributes = ['value'] 3 - 4 - connectedCallback() { 5 - this.#render() 6 - this.addEventListener('click', this.#handleClick) 7 - this.addEventListener('change', this.#handleInputChange) 8 - } 9 - 10 - disconnectedCallback() { 11 - this.removeEventListener('click', this.#handleClick) 12 - this.removeEventListener('change', this.#handleInputChange) 13 - } 14 - 15 - get value(): number { 16 - return parseInt(this.getAttribute('value') ?? '0') 17 - } 18 - 19 - set value(v: number) { 20 - this.#updateValue(v) 21 - } 22 - 23 - get #min(): number { 24 - const v = this.getAttribute('min') 25 - return v !== null ? parseInt(v) : -Infinity 26 - } 27 - 28 - get #max(): number { 29 - const v = this.getAttribute('max') 30 - return v !== null ? parseInt(v) : Infinity 31 - } 32 - 33 - get #step(): number { 34 - return parseInt(this.getAttribute('step') ?? '1') 35 - } 36 - 37 - #handleClick = (e: Event) => { 38 - const target = e.target as HTMLElement 39 - if (target.closest('[data-action="decrement"]')) { 40 - this.#updateValue(this.value - this.#step) 41 - } else if (target.closest('[data-action="increment"]')) { 42 - this.#updateValue(this.value + this.#step) 43 - } 44 - } 45 - 46 - #handleInputChange = (e: Event) => { 47 - const target = e.target as HTMLInputElement 48 - if (target.tagName !== 'INPUT') return 49 - e.stopPropagation() 50 - const parsed = parseInt(target.value) 51 - this.#updateValue(isNaN(parsed) ? this.value : parsed) 52 - } 53 - 54 - #updateValue(raw: number) { 55 - const clamped = Math.max( 56 - isFinite(this.#min) ? this.#min : -Infinity, 57 - Math.min(isFinite(this.#max) ? this.#max : Infinity, raw), 58 - ) 59 - this.setAttribute('value', String(clamped)) 60 - const input = this.querySelector<HTMLInputElement>('input') 61 - if (input && input.value !== String(clamped)) input.value = String(clamped) 62 - this.dispatchEvent(new Event('change', { bubbles: true })) 63 - } 64 - 65 - attributeChangedCallback(_: string, _old: string, next: string) { 66 - if (!this.isConnected) return 67 - const input = this.querySelector<HTMLInputElement>('input') 68 - if (input && input.value !== next) input.value = next 69 - } 70 - 71 - #render() { 72 - const min = this.getAttribute('min') 73 - const max = this.getAttribute('max') 74 - const step = this.getAttribute('step') ?? '1' 75 - const value = this.getAttribute('value') ?? '0' 76 - this.innerHTML = ` 77 - <button type="button" data-action="decrement" aria-label="Decrease">−</button> 78 - <input 79 - type="number" 80 - value="${value}" 81 - ${min !== null ? `min="${min}"` : ''} 82 - ${max !== null ? `max="${max}"` : ''} 83 - step="${step}" 84 - > 85 - <button type="button" data-action="increment" aria-label="Increase">+</button> 86 - ` 87 - } 88 - } 89 - 90 - customElements.define('ui-counter', UICounter)
-65
www/components/ui-version.ts
··· 1 - class UIVersion extends HTMLElement { 2 - #currentVersion: string 3 - #pendingVersion: string | null = null 4 - #onSwMessage: ((event: MessageEvent) => void) | null = null 5 - 6 - constructor() { 7 - super() 8 - this.#currentVersion = this.getAttribute('version') ?? 9 - (globalThis as { __APP_VERSION__?: string }).__APP_VERSION__ ?? 10 - 'unknown' 11 - } 12 - 13 - connectedCallback() { 14 - this.#render() 15 - this.addEventListener('click', this.#handleClick) 16 - 17 - if ('serviceWorker' in navigator) { 18 - this.#onSwMessage = ({ data }: MessageEvent) => { 19 - if (data?.type === 'UPDATE_AVAILABLE') { 20 - this.#pendingVersion = data.newVersion 21 - this.#render() 22 - } 23 - } 24 - navigator.serviceWorker.addEventListener('message', this.#onSwMessage) 25 - } 26 - } 27 - 28 - disconnectedCallback() { 29 - this.removeEventListener('click', this.#handleClick) 30 - if (this.#onSwMessage) { 31 - navigator.serviceWorker.removeEventListener('message', this.#onSwMessage) 32 - } 33 - } 34 - 35 - #handleClick = (e: Event) => { 36 - if ((e.target as HTMLElement).closest('[data-action="update"]')) { 37 - void this.#applyUpdate() 38 - } 39 - } 40 - 41 - async #applyUpdate() { 42 - if ('caches' in globalThis) { 43 - const names = await caches.keys() 44 - await Promise.all(names.map((name) => caches.delete(name))) 45 - } 46 - location.reload() 47 - } 48 - 49 - #render() { 50 - if (this.#pendingVersion) { 51 - this.innerHTML = ` 52 - <p>Current: <code>${this.#currentVersion}</code></p> 53 - <p>New version available: <code>${this.#pendingVersion}</code></p> 54 - <button data-action="update">Update now</button> 55 - ` 56 - } else { 57 - this.innerHTML = ` 58 - <p>Current: <code>${this.#currentVersion}</code></p> 59 - <p>Up to date</p> 60 - ` 61 - } 62 - } 63 - } 64 - 65 - customElements.define('ui-version', UIVersion)
+3 -1
www/index.ts
··· 1 1 import { Router } from '@bpev/civility' 2 - import './utils/updates.ts' 2 + import { client } from '@bpev/civility/workers' 3 3 import './routes/home.ts' 4 4 import './routes/library.ts' 5 5 import './routes/climb.ts' 6 6 import { formatStopwatchShort, globalStopwatch } from './routes/stopwatch.ts' 7 7 import './routes/settings.ts' 8 + 9 + client.init() 8 10 9 11 export class App { 10 12 private router: ReturnType<typeof Router.new>
+55 -1
www/routes/climb.ts
··· 3 3 type Benchmark, 4 4 BOARD_CONFIGS, 5 5 CANVAS_WIDTH, 6 + GRADE_FULL, 6 7 canvasHeight, 7 8 drawClimb, 8 9 escapeHtml, 9 10 loadBenchmarks, 11 + sandbagLabel, 10 12 youtubeUrl, 11 13 } from '../utils/benchmarks.ts' 12 14 import app from '../models/app.ts' 13 15 import { activeClimbHeader } from '../components/climb-header.ts' 14 - import '../components/ui-counter.ts' 16 + 17 + export 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 = app.getNav()! 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 = app.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}</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 + } 67 + 15 68 16 69 export class ClimbPage extends HTMLElement { 17 70 private hammer: HammerManager | null = null ··· 276 329 } 277 330 278 331 customElements.define('climb-page', ClimbPage) 332 + customElements.define('climb-header', ClimbHeader)
+183 -1
www/routes/home.ts
··· 4 4 escapeHtml, 5 5 gradeLabel, 6 6 loadBenchmarks, 7 + GRADE_FRENCH, GRADE_V 7 8 } from '../utils/benchmarks.ts' 8 9 import app from '../models/app.ts' 9 - import { emitter, PAGE_SIZE, state } from '../components/home-filters.ts' 10 + 11 + export const PAGE_SIZE = 50 12 + 13 + type LogFilter = 'all' | 'sent' | 'unsent' 14 + 15 + export const state = { 16 + search: '', 17 + gradeMin: 0, 18 + gradeMax: 16, 19 + logFilter: 'all' as LogFilter, 20 + shown: PAGE_SIZE, 21 + } 22 + 23 + export const emitter = new EventTarget() 24 + 25 + export class HomeFilters extends HTMLElement { 26 + private gradeScale: 'french' | 'v' = 'french' 27 + 28 + connectedCallback() { 29 + state.search = '' 30 + state.gradeMin = app.settings.state.homeGradeMin 31 + state.gradeMax = app.settings.state.homeGradeMax 32 + state.logFilter = app.settings.state.homeFilter 33 + state.shown = PAGE_SIZE 34 + this.gradeScale = app.settings.state.gradeScale 35 + this.render() 36 + this.addEventListener('input', this.#onInput) 37 + this.addEventListener('change', this.#onChange) 38 + this.addEventListener('click', this.#onClick) 39 + app.settings.addEventListener(this.#onSettingsUpdate) 40 + } 41 + 42 + disconnectedCallback() { 43 + this.removeEventListener('input', this.#onInput) 44 + this.removeEventListener('change', this.#onChange) 45 + this.removeEventListener('click', this.#onClick) 46 + app.settings.removeEventListener(this.#onSettingsUpdate) 47 + } 48 + 49 + #onSettingsUpdate = () => { 50 + const settings = app.settings.state 51 + if ( 52 + settings.homeGradeMax === state.gradeMax && 53 + settings.homeGradeMin === state.gradeMin && 54 + settings.homeFilter === state.logFilter && 55 + settings.gradeScale === this.gradeScale 56 + ) return 57 + state.logFilter = settings.homeFilter 58 + state.gradeMax = settings.homeGradeMax 59 + state.gradeMin = settings.homeGradeMin 60 + this.gradeScale = settings.gradeScale 61 + this.render() 62 + emitter.dispatchEvent(new Event('filter')) 63 + } 64 + 65 + #onInput = (e: Event) => { 66 + if ((e.target as HTMLElement).id !== 'bm-search') return 67 + state.search = (e.target as HTMLInputElement).value 68 + state.shown = PAGE_SIZE 69 + emitter.dispatchEvent(new Event('filter')) 70 + } 71 + 72 + #onChange = (e: Event) => { 73 + const target = e.target as HTMLSelectElement 74 + const val = parseInt(target.value, 10) 75 + if (target.id === 'bm-grade-min') { 76 + state.gradeMin = val 77 + if (state.gradeMax < val) { 78 + const group = this.gradeGroups().find( 79 + (g) => g.first <= val && val <= g.last, 80 + ) 81 + state.gradeMax = group?.last ?? val 82 + const maxEl = this.querySelector<HTMLSelectElement>('#bm-grade-max') 83 + if (maxEl) maxEl.value = state.gradeMax.toString() 84 + } 85 + app.updateSettings({ 86 + homeGradeMin: state.gradeMin, 87 + homeGradeMax: state.gradeMax, 88 + }) 89 + state.shown = PAGE_SIZE 90 + emitter.dispatchEvent(new Event('filter')) 91 + } else if (target.id === 'bm-grade-max') { 92 + state.gradeMax = val 93 + if (state.gradeMin > val) { 94 + const group = this.gradeGroups().find( 95 + (g) => g.first <= val && val <= g.last, 96 + ) 97 + state.gradeMin = group?.first ?? val 98 + const minEl = this.querySelector<HTMLSelectElement>('#bm-grade-min') 99 + if (minEl) minEl.value = state.gradeMin.toString() 100 + } 101 + app.updateSettings({ 102 + homeGradeMin: state.gradeMin, 103 + homeGradeMax: state.gradeMax, 104 + }) 105 + state.shown = PAGE_SIZE 106 + emitter.dispatchEvent(new Event('filter')) 107 + } 108 + } 109 + 110 + #onClick = (e: Event) => { 111 + const btn = (e.target as HTMLElement).closest<HTMLElement>( 112 + '[data-log-filter]', 113 + ) 114 + if (!btn) return 115 + state.logFilter = btn.dataset.logFilter as LogFilter 116 + app.updateSettings({ homeFilter: state.logFilter }) 117 + const bar = this.querySelector('#bm-log-filter') 118 + if (bar) bar.innerHTML = this.logFilterHtml() 119 + state.shown = PAGE_SIZE 120 + emitter.dispatchEvent(new Event('filter')) 121 + } 122 + 123 + private render() { 124 + this.innerHTML = ` 125 + <input 126 + type="search" 127 + id="bm-search" 128 + placeholder="Search by name or setter..." 129 + autocomplete="off" 130 + > 131 + <ui-button-group id="bm-log-filter"> 132 + ${this.logFilterHtml()} 133 + </ui-button-group> 134 + <div class="grade-filter"> 135 + <label for="bm-grade-min">Grade</label> 136 + <select id="bm-grade-min"> 137 + ${this.gradeOptions('min')} 138 + </select> 139 + <span aria-hidden="true">–</span> 140 + <select id="bm-grade-max"> 141 + ${this.gradeOptions('max')} 142 + </select> 143 + </div> 144 + ` 145 + } 146 + 147 + private logFilterHtml(): string { 148 + const opts: { value: LogFilter; label: string }[] = [ 149 + { value: 'all', label: 'All' }, 150 + { value: 'sent', label: 'Completed' }, 151 + { value: 'unsent', label: 'Not Completed' }, 152 + ] 153 + return opts 154 + .map( 155 + (o) => 156 + `<button aria-pressed="${ 157 + state.logFilter === o.value 158 + }" data-log-filter="${o.value}">${o.label}</button>`, 159 + ) 160 + .join('') 161 + } 162 + 163 + private gradeGroups(): { label: string; first: number; last: number }[] { 164 + const table = this.gradeScale === 'v' ? GRADE_V : GRADE_FRENCH 165 + const groups: { label: string; first: number; last: number }[] = [] 166 + for (let i = 0; i <= 16; i++) { 167 + const label = table[i] ?? '?' 168 + const prev = groups[groups.length - 1] 169 + if (prev && prev.label === label) { 170 + prev.last = i 171 + } else { 172 + groups.push({ label, first: i, last: i }) 173 + } 174 + } 175 + return groups 176 + } 177 + 178 + private gradeOptions(role: 'min' | 'max'): string { 179 + const current = role === 'min' ? state.gradeMin : state.gradeMax 180 + return this.gradeGroups() 181 + .map((g) => { 182 + const value = role === 'min' ? g.first : g.last 183 + const selected = current >= g.first && current <= g.last 184 + return `<option value="${value}" ${ 185 + selected ? 'selected' : '' 186 + }>${g.label}</option>` 187 + }) 188 + .join('') 189 + } 190 + } 10 191 11 192 export class HomePage extends HTMLElement { 12 193 private benchmarks: Benchmark[] = [] ··· 193 374 } 194 375 195 376 customElements.define('home-page', HomePage) 377 + customElements.define('home-filters', HomeFilters)
+63
www/routes/library.ts
··· 4 4 import { formatDate, starsHtml } from '../utils/format.ts' 5 5 import { libEmitter, libState } from '../components/library-filters.ts' 6 6 7 + 8 + type Filter = 'all' | 'sent' | 'unsent' 9 + 10 + export const libState = { filter: 'all' as Filter } 11 + export const libEmitter = new EventTarget() 12 + 13 + export class LibraryFilters extends HTMLElement { 14 + connectedCallback() { 15 + libState.filter = app.settings.state.libraryFilter 16 + this.render() 17 + this.addEventListener('click', this.#handleClick) 18 + app.settings.addEventListener(this.#onSettingsUpdate) 19 + } 20 + 21 + disconnectedCallback() { 22 + this.removeEventListener('click', this.#handleClick) 23 + app.settings.removeEventListener(this.#onSettingsUpdate) 24 + } 25 + 26 + #onSettingsUpdate = () => { 27 + const filter = app.settings.state.libraryFilter 28 + if (filter === libState.filter) return 29 + libState.filter = filter 30 + this.render() 31 + libEmitter.dispatchEvent(new Event('filter')) 32 + } 33 + 34 + private render() { 35 + this.innerHTML = ` 36 + <ui-button-group> 37 + ${this.filterBarHtml()} 38 + </ui-button-group> 39 + ` 40 + } 41 + 42 + private filterBarHtml(): string { 43 + const opts: { value: Filter; label: string }[] = [ 44 + { value: 'all', label: 'All' }, 45 + { value: 'sent', label: 'Completed' }, 46 + { value: 'unsent', label: 'Not Completed' }, 47 + ] 48 + return opts 49 + .map( 50 + (o) => 51 + `<button aria-pressed="${ 52 + libState.filter === o.value 53 + }" data-filter="${o.value}">${o.label}</button>`, 54 + ) 55 + .join('') 56 + } 57 + 58 + #handleClick = (e: Event) => { 59 + const btn = (e.target as HTMLElement).closest<HTMLElement>('[data-filter]') 60 + if (!btn) return 61 + libState.filter = btn.dataset.filter as Filter 62 + app.updateSettings({ libraryFilter: libState.filter }) 63 + const group = this.querySelector('ui-button-group') 64 + if (group) group.innerHTML = this.filterBarHtml() 65 + libEmitter.dispatchEvent(new Event('filter')) 66 + } 67 + } 68 + 7 69 export class LibraryPage extends HTMLElement { 8 70 private gradeScale: 'french' | 'v' = 'french' 9 71 ··· 109 171 } 110 172 } 111 173 174 + customElements.define('library-filters', LibraryFilters) 112 175 customElements.define('library-page', LibraryPage)
-1
www/routes/settings.ts
··· 1 1 import app from '../models/app.ts' 2 - import '../components/ui-version.ts' 3 2 4 3 const BOARD_OPTIONS = [ 5 4 { mb_type: 0, label: '2016 40°' },
+13 -4
www/routes/stopwatch.ts
··· 54 54 return this.#adjust(this.#inner.state) 55 55 } 56 56 57 - start(): void { this.#inner.start() } 58 - pause(): void { this.#inner.pause() } 57 + start(): void { 58 + this.#inner.start() 59 + } 60 + pause(): void { 61 + this.#inner.pause() 62 + } 59 63 60 64 reset(): void { 61 65 this.#offset = 0 62 66 this.#inner.reset() 63 67 } 64 68 65 - lap(): void { this.#inner.lap() } 69 + lap(): void { 70 + this.#inner.lap() 71 + } 66 72 } 67 73 68 74 // Singleton — persists across route navigation 69 75 export const globalStopwatch = new StopwatchProxy( 70 - new Stopwatch({ resolutionMS: 1000, formatDisplayTime: formatStopwatchShort }), 76 + new Stopwatch({ 77 + resolutionMS: 1000, 78 + formatDisplayTime: formatStopwatchShort, 79 + }), 71 80 ) 72 81 73 82 // Restore a running timer on app load, before the user navigates to the route
-22
www/utils/updates.ts
··· 1 - /** 2 - * Service Worker registration and update handling 3 - */ 4 - 5 - export async function applyUpdate(): Promise<void> { 6 - if ('caches' in globalThis) { 7 - const names = await caches.keys() 8 - await Promise.all(names.map((name) => caches.delete(name))) 9 - } 10 - globalThis.location.reload() 11 - } 12 - 13 - if ('serviceWorker' in navigator) { 14 - navigator.serviceWorker 15 - .register('/dist/worker.js', { scope: '/' }) 16 - .then((registration) => 17 - console.log('Service Worker registered:', registration.scope) 18 - ) 19 - .catch((error) => { 20 - console.error('Service Worker registration failed:', error) 21 - }) 22 - }
-10
www/utils/workers/broadcast.ts
··· 1 - /** 2 - * Sends a message to all active clients of this service worker. 3 - * 4 - * @example 5 - * await broadcast({ type: 'SW_ACTIVATED', version: '1.2.0' }) 6 - */ 7 - export async function broadcast(message: unknown): Promise<void> { 8 - const clients = await self.clients.matchAll() 9 - clients.forEach((client) => client.postMessage(message)) 10 - }
-36
www/utils/workers/cache-cleanup.ts
··· 1 - /** 2 - * Registers an `activate` handler that deletes all caches whose names start 3 - * with `prefix` except the current one, then calls `clients.claim()` so the 4 - * worker takes control of existing pages immediately. 5 - * 6 - * @param prefix - Cache name prefix used to identify caches owned by this app 7 - * (e.g. `'app-v'`). Only caches that start with this prefix are touched. 8 - * @param currentName - The active cache name to preserve (e.g. `'app-v1.2.0'`). 9 - * @param afterClaim - Optional async callback invoked after `clients.claim()` 10 - * completes. Useful for broadcasting an activation message to clients. 11 - * 12 - * @example 13 - * cleanOldCaches('app-v', CACHE_NAME, () => 14 - * broadcast({ type: 'SW_ACTIVATED', version: APP_VERSION }) 15 - * ) 16 - */ 17 - export function cleanOldCaches( 18 - prefix: string, 19 - currentName: string, 20 - afterClaim?: () => void | Promise<void>, 21 - ): void { 22 - self.addEventListener('activate', (event) => { 23 - event.waitUntil( 24 - caches.keys() 25 - .then((names) => 26 - Promise.all( 27 - names 28 - .filter((name) => name.startsWith(prefix) && name !== currentName) 29 - .map((name) => caches.delete(name)), 30 - ) 31 - ) 32 - .then(() => self.clients.claim()) 33 - .then(() => afterClaim?.()), 34 - ) 35 - }) 36 - }
-11
www/utils/workers/mod.ts
··· 1 - export { broadcast } from './broadcast.ts' 2 - export { cleanOldCaches } from './cache-cleanup.ts' 3 - export { precache } from './precache.ts' 4 - export { cacheFirst, networkFirst, staleWhileRevalidate } from './strategies.ts' 5 - export type { 6 - FetchHandler, 7 - NetworkFirstOptions, 8 - StrategyOptions, 9 - } from './strategies.ts' 10 - export { listenForUpdates } from './sw-updates.ts' 11 - export type { SwUpdateOptions } from './sw-updates.ts'
-19
www/utils/workers/precache.ts
··· 1 - /** 2 - * Registers an `install` handler that caches the given URLs and calls 3 - * `skipWaiting()` so the new worker activates immediately. 4 - * 5 - * @param cacheName - Name of the cache to populate (typically versioned). 6 - * @param urls - List of URLs to cache on install. 7 - * 8 - * @example 9 - * precache(`app-v${APP_VERSION}`, ['/', '/index.html', '/static/app.css']) 10 - */ 11 - export function precache(cacheName: string, urls: string[]): void { 12 - self.addEventListener('install', (event) => { 13 - event.waitUntil( 14 - caches.open(cacheName) 15 - .then((cache) => cache.addAll(urls)) 16 - .then(() => self.skipWaiting()), 17 - ) 18 - }) 19 - }
-156
www/utils/workers/strategies.ts
··· 1 - export interface StrategyOptions { 2 - /** Name of the cache to read from and write to. */ 3 - cacheName: string 4 - /** 5 - * URL to serve when a navigation request fails both cache and network 6 - * (e.g. `'/index.html'` for SPAs). Only applies to requests where 7 - * `event.request.mode === 'navigate'`. 8 - */ 9 - navigationFallback?: string 10 - } 11 - 12 - export interface NetworkFirstOptions extends StrategyOptions { 13 - /** 14 - * Milliseconds to wait for a network response before falling back to cache. 15 - * The in-flight request is aborted when the timeout fires. 16 - */ 17 - networkTimeoutMs?: number 18 - } 19 - 20 - /** A function suitable for calling directly inside a `fetch` event listener. */ 21 - export type FetchHandler = (event: FetchEvent) => void 22 - 23 - /** 24 - * Cache-first strategy: serve from cache when available, otherwise fetch from 25 - * the network and cache the response for next time. 26 - * 27 - * Best for: versioned static assets, fonts, images — content that rarely 28 - * changes between deployments. 29 - * 30 - * @example 31 - * const strategy = cacheFirst({ cacheName: CACHE_NAME, navigationFallback: '/index.html' }) 32 - * self.addEventListener('fetch', (event) => { 33 - * if (event.request.method !== 'GET') return 34 - * strategy(event) 35 - * }) 36 - */ 37 - export function cacheFirst(options: StrategyOptions): FetchHandler { 38 - return (event: FetchEvent) => { 39 - event.respondWith( 40 - caches.match(event.request).then((cached) => { 41 - if (cached) return cached 42 - 43 - return fetch(event.request) 44 - .then((response) => { 45 - if (response.ok) { 46 - caches.open(options.cacheName).then((cache) => 47 - cache.put(event.request, response.clone()) 48 - ) 49 - } 50 - return response 51 - }) 52 - .catch(() => { 53 - if ( 54 - options.navigationFallback && 55 - event.request.mode === 'navigate' 56 - ) { 57 - return caches.match(options.navigationFallback) as Promise< 58 - Response 59 - > 60 - } 61 - throw new Error('Network error and no cache available') 62 - }) 63 - }), 64 - ) 65 - } 66 - } 67 - 68 - /** 69 - * Network-first strategy: fetch from the network and cache the response, 70 - * falling back to cache on failure. Optionally aborts the request after a 71 - * timeout and falls back to cache immediately. 72 - * 73 - * Best for: HTML pages, API responses, or any content that should be fresh 74 - * when the network is available. 75 - * 76 - * @example 77 - * const strategy = networkFirst({ cacheName: CACHE_NAME, networkTimeoutMs: 3000 }) 78 - * self.addEventListener('fetch', (event) => { 79 - * if (event.request.method !== 'GET') return 80 - * strategy(event) 81 - * }) 82 - */ 83 - export function networkFirst(options: NetworkFirstOptions): FetchHandler { 84 - return (event: FetchEvent) => { 85 - const fetchFromNetwork = (): Promise<Response> => { 86 - if (!options.networkTimeoutMs) return fetch(event.request) 87 - 88 - const controller = new AbortController() 89 - const timeoutId = setTimeout( 90 - () => controller.abort(), 91 - options.networkTimeoutMs, 92 - ) 93 - return fetch(event.request, { signal: controller.signal }).finally(() => 94 - clearTimeout(timeoutId) 95 - ) 96 - } 97 - 98 - event.respondWith( 99 - fetchFromNetwork() 100 - .then((response) => { 101 - if (response.ok) { 102 - caches.open(options.cacheName).then((cache) => 103 - cache.put(event.request, response.clone()) 104 - ) 105 - } 106 - return response 107 - }) 108 - .catch(() => 109 - caches.match(event.request).then((cached) => { 110 - if (cached) return cached 111 - if ( 112 - options.navigationFallback && 113 - event.request.mode === 'navigate' 114 - ) { 115 - return caches.match(options.navigationFallback) as Promise< 116 - Response 117 - > 118 - } 119 - throw new Error('Network error and no cache available') 120 - }) 121 - ), 122 - ) 123 - } 124 - } 125 - 126 - /** 127 - * Stale-while-revalidate strategy: respond immediately from cache (if 128 - * available) while simultaneously fetching a fresh response to update the 129 - * cache in the background. Falls back to waiting for the network when no 130 - * cached version exists. 131 - * 132 - * Best for: content that benefits from fast loads but should eventually 133 - * reflect updates — icons, non-critical assets, infrequently changing data. 134 - * 135 - * @example 136 - * const strategy = staleWhileRevalidate({ cacheName: CACHE_NAME }) 137 - * self.addEventListener('fetch', (event) => { 138 - * if (event.request.method !== 'GET') return 139 - * strategy(event) 140 - * }) 141 - */ 142 - export function staleWhileRevalidate(options: StrategyOptions): FetchHandler { 143 - return (event: FetchEvent) => { 144 - event.respondWith( 145 - caches.open(options.cacheName).then((cache) => 146 - cache.match(event.request).then((cached) => { 147 - const networkFetch = fetch(event.request).then((response) => { 148 - if (response.ok) cache.put(event.request, response.clone()) 149 - return response 150 - }) 151 - return cached ?? networkFetch 152 - }) 153 - ), 154 - ) 155 - } 156 - }
-89
www/utils/workers/sw-updates.ts
··· 1 - export interface SwUpdateOptions { 2 - /** 3 - * URL of a JS file to fetch and inspect for a version string. 4 - * Defaults to `'/dist/index.js'`. 5 - */ 6 - checkUrl?: string 7 - /** 8 - * How often (in milliseconds) to poll for updates after the initial check. 9 - * Defaults to 5 minutes. 10 - */ 11 - intervalMs?: number 12 - /** 13 - * Delay (in milliseconds) before the first update check runs after activate. 14 - * Defaults to 10 seconds. 15 - */ 16 - initialDelayMs?: number 17 - } 18 - 19 - /** 20 - * Starts a version polling loop and wires up message handling for 21 - * `SKIP_WAITING` and `CHECK_UPDATE`. Call this from an `activate` handler. 22 - * 23 - * The worker fetches `checkUrl` on each tick, extracts the `__APP_VERSION__` 24 - * string from its source, and broadcasts an `UPDATE_AVAILABLE` message to all 25 - * clients when a newer version is detected. 26 - * 27 - * **Client messages handled:** 28 - * - `SKIP_WAITING` — forces the waiting worker to activate immediately. 29 - * - `CHECK_UPDATE` — triggers an out-of-band update check. 30 - * 31 - * @param appVersion - The version string baked into the current worker bundle. 32 - * 33 - * @example 34 - * const APP_VERSION = globalThis.__APP_VERSION__ || '0.0.0' 35 - * self.addEventListener('activate', () => listenForUpdates(APP_VERSION)) 36 - */ 37 - export function listenForUpdates( 38 - appVersion: string, 39 - options: SwUpdateOptions = {}, 40 - ): void { 41 - const { 42 - checkUrl = '/dist/index.js', 43 - intervalMs = 5 * 60 * 1000, 44 - initialDelayMs = 10000, 45 - } = options 46 - 47 - async function checkForUpdates(): Promise<void> { 48 - try { 49 - const response = await fetch(checkUrl, { 50 - cache: 'no-cache', 51 - headers: { 'Cache-Control': 'no-cache' }, 52 - }) 53 - 54 - if (!response.ok) return 55 - 56 - const text = await response.text() 57 - const match = text.match(/__APP_VERSION__\s*=\s*["']([^"']+)["']/) 58 - const serverVersion = match?.[1] 59 - 60 - if (serverVersion && serverVersion !== appVersion) { 61 - const clients = await self.clients.matchAll() 62 - clients.forEach((client) => 63 - client.postMessage({ 64 - type: 'UPDATE_AVAILABLE', 65 - currentVersion: appVersion, 66 - newVersion: serverVersion, 67 - }) 68 - ) 69 - } 70 - } catch (error) { 71 - console.log('[SW] Update check failed:', (error as Error).message) 72 - } 73 - } 74 - 75 - setInterval(checkForUpdates, intervalMs) 76 - setTimeout(checkForUpdates, initialDelayMs) 77 - 78 - self.addEventListener('message', (event) => { 79 - const { data } = event as MessageEvent 80 - switch (data?.type) { 81 - case 'SKIP_WAITING': 82 - self.skipWaiting() 83 - break 84 - case 'CHECK_UPDATE': 85 - void checkForUpdates() 86 - break 87 - } 88 - }) 89 - }
+33
www/worker.js
··· 1 + import { 2 + init, 3 + withCleanup, 4 + withFetchStrategy, 5 + withPrecache, 6 + withUpdatePolling, 7 + } from 'https://esm.sh/jsr/@bpev/civility@^0.0.7/workers' 8 + 9 + init([ 10 + withPrecache([ 11 + '/', 12 + '/index.html', 13 + '/static/civility.css', 14 + '/static/utilities.css', 15 + '/static/theme.css', 16 + '/static/data/benchmarks.json', 17 + '/static/icons/home.svg', 18 + '/static/icons/library.svg', 19 + '/static/icons/clock.svg', 20 + '/static/icons/tool.svg', 21 + '/dist/index.js', 22 + '/manifest.json', 23 + '/static/images/logo.png', 24 + '/static/images/mbsetup-2016.png', 25 + '/static/images/mbsetup-2017.png', 26 + '/static/images/mbsetup-2019.png', 27 + '/static/images/mbsetup-2020.png', 28 + '/static/images/mbsetup-2024.png', 29 + ]), 30 + withCleanup(), 31 + withUpdatePolling(), 32 + withFetchStrategy(), 33 + ])
-59
www/worker.ts
··· 1 - import { 2 - broadcast, 3 - cacheFirst, 4 - cleanOldCaches, 5 - listenForUpdates, 6 - precache, 7 - } from './utils/workers/mod.ts' 8 - 9 - const APP_VERSION = globalThis.__APP_VERSION__ || '1.0.0' 10 - const CACHE_NAME = `civility-v${APP_VERSION}` 11 - 12 - precache(CACHE_NAME, [ 13 - '/', 14 - '/index.html', 15 - '/static/civility.css', 16 - '/static/utilities.css', 17 - '/static/theme.css', 18 - '/static/data/benchmarks.json', 19 - '/static/icons/home.svg', 20 - '/static/icons/library.svg', 21 - '/static/icons/clock.svg', 22 - '/static/icons/tool.svg', 23 - '/dist/index.js', 24 - '/manifest.json', 25 - '/static/images/logo.png', 26 - '/static/images/black-hold.png', 27 - '/static/images/hold-red.png', 28 - '/static/images/hold-white.png', 29 - '/static/images/hold-yellow.png', 30 - '/static/images/hold-woodenA.png', 31 - '/static/images/hold-woodenB.png', 32 - '/static/images/hold-woodenC.png', 33 - '/static/images/mbsetup-2016.png', 34 - '/static/images/mbsetup-2017.png', 35 - '/static/images/mbsetup-2019.png', 36 - '/static/images/mbsetup-2020.png', 37 - '/static/images/mbsetup-2024.png', 38 - ]) 39 - 40 - cleanOldCaches( 41 - 'civility-v', 42 - CACHE_NAME, 43 - () => broadcast({ type: 'SW_ACTIVATED', version: APP_VERSION }), 44 - ) 45 - 46 - self.addEventListener('activate', () => { 47 - listenForUpdates(APP_VERSION) 48 - }) 49 - 50 - const strategy = cacheFirst({ 51 - cacheName: CACHE_NAME, 52 - navigationFallback: '/index.html', 53 - }) 54 - 55 - self.addEventListener('fetch', (event) => { 56 - if (event.request.method !== 'GET') return 57 - if (!event.request.url.startsWith(self.location.origin)) return 58 - strategy(event) 59 - })