An app for logging board climbs
0
fork

Configure Feed

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

feat: use sync-link

+437 -582
+2
deno.json
··· 22 22 }, 23 23 "imports": { 24 24 "@bpev/civility": "jsr:@bpev/civility@^0.0.5", 25 + "@bpev/sync-link": "jsr:@bpev/sync-link@^0.0.17", 25 26 "@inro/simple-tools": "jsr:@inro/simple-tools@^0.5.2", 26 27 "@std/assert": "jsr:@std/assert@^1.0.18", 27 28 "@std/dotenv": "jsr:@std/dotenv@^0.225.6", 28 29 "@std/path": "jsr:@std/path@^1.1.4", 30 + "@std/testing": "jsr:@std/testing@^1.0.17", 29 31 "hammerjs": "npm:hammerjs@^2.0.8", 30 32 "zod": "npm:zod@^4.3.6" 31 33 }
+57 -465
deno.lock
··· 2 2 "version": "5", 3 3 "specifiers": { 4 4 "jsr:@bpev/civility@^0.0.5": "0.0.5", 5 - "jsr:@cliffy/ansi@1.0.0-rc.7": "1.0.0-rc.7", 6 - "jsr:@cliffy/command@1.0.0-rc.7": "1.0.0-rc.7", 7 - "jsr:@cliffy/flags@1.0.0-rc.7": "1.0.0-rc.7", 8 - "jsr:@cliffy/internal@1.0.0-rc.7": "1.0.0-rc.7", 9 - "jsr:@cliffy/table@1.0.0-rc.7": "1.0.0-rc.7", 5 + "jsr:@bpev/sync-link@^0.0.17": "0.0.17", 6 + "jsr:@inro/simple-tools@0.5.2": "0.5.2", 10 7 "jsr:@inro/simple-tools@~0.5.2": "0.5.2", 11 - "jsr:@luca/esbuild-deno-loader@~0.11.1": "0.11.1", 12 - "jsr:@rodney/parsedown@^1.4.3": "1.4.3", 13 - "jsr:@std/assert@^1.0.18": "1.0.19", 14 - "jsr:@std/bytes@^1.0.2": "1.0.6", 15 - "jsr:@std/cli@^1.0.28": "1.0.28", 16 - "jsr:@std/collections@^1.1.0": "1.1.6", 17 - "jsr:@std/collections@^1.1.3": "1.1.6", 8 + "jsr:@paulmillr/qr@~0.5.2": "0.5.4", 18 9 "jsr:@std/dotenv@~0.225.6": "0.225.6", 19 - "jsr:@std/encoding@^1.0.10": "1.0.10", 20 - "jsr:@std/encoding@^1.0.5": "1.0.10", 21 - "jsr:@std/fmt@^1.0.9": "1.0.9", 22 - "jsr:@std/fmt@~1.0.2": "1.0.9", 23 - "jsr:@std/front-matter@^1.0.9": "1.0.9", 24 - "jsr:@std/fs@^1.0.17": "1.0.23", 25 - "jsr:@std/fs@^1.0.20": "1.0.23", 26 - "jsr:@std/fs@^1.0.23": "1.0.23", 27 - "jsr:@std/html@^1.0.5": "1.0.5", 28 - "jsr:@std/http@^1.0.21": "1.0.25", 29 - "jsr:@std/internal@^1.0.12": "1.0.12", 30 - "jsr:@std/media-types@^1.1.0": "1.1.0", 31 - "jsr:@std/net@^1.0.6": "1.0.6", 32 - "jsr:@std/path@^1.0.6": "1.1.4", 33 - "jsr:@std/path@^1.1.2": "1.1.4", 34 - "jsr:@std/path@^1.1.4": "1.1.4", 35 - "jsr:@std/streams@^1.0.17": "1.0.17", 36 - "jsr:@std/text@~1.0.7": "1.0.17", 37 - "jsr:@std/toml@^1.0.3": "1.0.11", 38 - "jsr:@std/yaml@^1.0.5": "1.0.12", 39 - "npm:@tauri-apps/plugin-store@^2.2.0": "2.4.1", 40 - "npm:cheerio@^1.1.2": "1.1.2", 41 - "npm:esbuild@~0.25.10": "0.25.12", 10 + "npm:@hono/zod-openapi@^1.1.0": "1.2.1_hono@4.11.8_zod@4.3.6", 42 11 "npm:hammerjs@^2.0.8": "2.0.8", 43 12 "npm:lit@^3.3.1": "3.3.1", 44 - "npm:ts-fsrs@5": "5.2.3", 13 + "npm:native-file-system-adapter@^3.0.1": "3.0.1", 45 14 "npm:zod@^4.3.6": "4.3.6" 46 15 }, 47 16 "jsr": { 48 17 "@bpev/civility@0.0.5": { 49 18 "integrity": "efda2154a6863608221ee9f0efec5a5cf790bbc4d07b754e245580431e05374b", 50 19 "dependencies": [ 51 - "jsr:@cliffy/ansi", 52 - "jsr:@cliffy/command", 53 - "jsr:@luca/esbuild-deno-loader", 54 - "jsr:@rodney/parsedown", 55 - "jsr:@std/front-matter", 56 - "jsr:@std/fs@^1.0.20", 57 - "jsr:@std/http", 58 - "jsr:@std/path@^1.1.2", 59 - "npm:cheerio", 60 - "npm:esbuild", 61 20 "npm:lit" 62 21 ] 63 22 }, 64 - "@cliffy/ansi@1.0.0-rc.7": { 65 - "integrity": "f71c921cce224c13d322e5cedba4f38e8f7354c7d855c9cb22729362a53f25aa", 23 + "@bpev/sync-link@0.0.17": { 24 + "integrity": "a21a2994482b64a90da7061b113703e07f5af90498474103e3ce073118871ee6", 66 25 "dependencies": [ 67 - "jsr:@std/fmt@~1.0.2" 68 - ] 69 - }, 70 - "@cliffy/command@1.0.0-rc.7": { 71 - "integrity": "1288808d7a3cd18b86c24c2f920e47a6d954b7e23cadc35c8cbd78f8be41f0cd", 72 - "dependencies": [ 73 - "jsr:@cliffy/flags", 74 - "jsr:@cliffy/internal", 75 - "jsr:@cliffy/table", 76 - "jsr:@std/fmt@~1.0.2", 77 - "jsr:@std/text" 78 - ] 79 - }, 80 - "@cliffy/flags@1.0.0-rc.7": { 81 - "integrity": "318d9be98f6a6417b108e03dec427dea96cdd41a15beb21d2554ae6da450a781", 82 - "dependencies": [ 83 - "jsr:@std/text" 84 - ] 85 - }, 86 - "@cliffy/internal@1.0.0-rc.7": { 87 - "integrity": "10412636ab3e67517d448be9eaab1b70c88eba9be22617b5d146257a11cc9b17" 88 - }, 89 - "@cliffy/table@1.0.0-rc.7": { 90 - "integrity": "9fdd9776eda28a0b397981c400eeb1aa36da2371b43eefe12e6ff555290e3180", 91 - "dependencies": [ 92 - "jsr:@std/fmt@~1.0.2" 26 + "jsr:@inro/simple-tools@0.5.2", 27 + "jsr:@paulmillr/qr", 28 + "npm:@hono/zod-openapi", 29 + "npm:native-file-system-adapter" 93 30 ] 94 31 }, 95 32 "@inro/simple-tools@0.5.2": { 96 - "integrity": "cc34cd0914b9e0576d9bed9a66a91994123b73f3fd87a4e8db76880181731ee5", 97 - "dependencies": [ 98 - "jsr:@std/collections@^1.1.0", 99 - "jsr:@std/fs@^1.0.17", 100 - "npm:@tauri-apps/plugin-store", 101 - "npm:ts-fsrs" 102 - ] 33 + "integrity": "cc34cd0914b9e0576d9bed9a66a91994123b73f3fd87a4e8db76880181731ee5" 103 34 }, 104 - "@luca/esbuild-deno-loader@0.11.1": { 105 - "integrity": "dc020d16d75b591f679f6b9288b10f38bdb4f24345edb2f5732affa1d9885267", 106 - "dependencies": [ 107 - "jsr:@std/bytes", 108 - "jsr:@std/encoding@^1.0.5", 109 - "jsr:@std/path@^1.0.6" 110 - ] 35 + "@paulmillr/qr@0.5.2": { 36 + "integrity": "dcaabde6e5125cabecd82f7f0044062dfc0439493c2945bd14c9368e5a3982f2" 111 37 }, 112 - "@rodney/parsedown@1.4.3": { 113 - "integrity": "fd5cbee4554286fc835a0157f7cb28d2c4de6ac82ed62b6b2f91291eaa9fbb2f" 114 - }, 115 - "@std/assert@1.0.19": { 116 - "integrity": "eaada96ee120cb980bc47e040f82814d786fe8162ecc53c91d8df60b8755991e", 117 - "dependencies": [ 118 - "jsr:@std/internal" 119 - ] 120 - }, 121 - "@std/bytes@1.0.6": { 122 - "integrity": "f6ac6adbd8ccd99314045f5703e23af0a68d7f7e58364b47d2c7f408aeb5820a" 123 - }, 124 - "@std/cli@1.0.28": { 125 - "integrity": "74ef9b976db59ca6b23a5283469c9072be6276853807a83ec6c7ce412135c70a" 126 - }, 127 - "@std/collections@1.1.6": { 128 - "integrity": "b458160ce65ea5ad35da05d0a5cbee4b583677c8b443a10d7beb0c4ac63f2baa" 38 + "@paulmillr/qr@0.5.4": { 39 + "integrity": "b9c40e31104c63700df2c37c5d2674770112c75713d7dd1b922a23926500b26b" 129 40 }, 130 41 "@std/dotenv@0.225.6": { 131 42 "integrity": "1d6f9db72f565bd26790fa034c26e45ecb260b5245417be76c2279e5734c421b" 132 - }, 133 - "@std/encoding@1.0.10": { 134 - "integrity": "8783c6384a2d13abd5e9e87a7ae0520a30e9f56aeeaa3bdf910a3eaaf5c811a1" 135 - }, 136 - "@std/fmt@1.0.9": { 137 - "integrity": "2487343e8899fb2be5d0e3d35013e54477ada198854e52dd05ed0422eddcabe0" 138 - }, 139 - "@std/front-matter@1.0.9": { 140 - "integrity": "ee6201d06674cbef137dda2252f62477450b48249e7d8d9ab57a30f85ff6f051", 43 + } 44 + }, 45 + "npm": { 46 + "@asteasolutions/zod-to-openapi@8.4.0_zod@4.3.6": { 47 + "integrity": "sha512-Ckp971tmTw4pnv+o7iK85ldBHBKk6gxMaoNyLn3c2Th/fKoTG8G3jdYuOanpdGqwlDB0z01FOjry2d32lfTqrA==", 141 48 "dependencies": [ 142 - "jsr:@std/toml", 143 - "jsr:@std/yaml" 49 + "openapi3-ts", 50 + "zod" 144 51 ] 145 52 }, 146 - "@std/fs@1.0.23": { 147 - "integrity": "3ecbae4ce4fee03b180fa710caff36bb5adb66631c46a6460aaad49515565a37", 53 + "@hono/zod-openapi@1.2.1_hono@4.11.8_zod@4.3.6": { 54 + "integrity": "sha512-aZza4V8wkqpdHBWFNPiCeWd0cGOXbYuQW9AyezHs/jwQm5p67GkUyXwfthAooAwnG7thTpvOJkThZpCoY6us8w==", 148 55 "dependencies": [ 149 - "jsr:@std/internal", 150 - "jsr:@std/path@^1.1.4" 56 + "@asteasolutions/zod-to-openapi", 57 + "@hono/zod-validator", 58 + "hono", 59 + "openapi3-ts", 60 + "zod" 151 61 ] 152 62 }, 153 - "@std/html@1.0.5": { 154 - "integrity": "4e2d693f474cae8c16a920fa5e15a3b72267b94b84667f11a50c6dd1cb18d35e" 155 - }, 156 - "@std/http@1.0.25": { 157 - "integrity": "577b4252290af1097132812b339fffdd55fb0f4aeb98ff11bdbf67998aa17193", 63 + "@hono/zod-validator@0.7.6_hono@4.11.8_zod@4.3.6": { 64 + "integrity": "sha512-Io1B6d011Gj1KknV4rXYz4le5+5EubcWEU/speUjuw9XMMIaP3n78yXLhjd2A3PXaXaUwEAluOiAyLqhBEJgsw==", 158 65 "dependencies": [ 159 - "jsr:@std/cli", 160 - "jsr:@std/encoding@^1.0.10", 161 - "jsr:@std/fmt@^1.0.9", 162 - "jsr:@std/fs@^1.0.23", 163 - "jsr:@std/html", 164 - "jsr:@std/media-types", 165 - "jsr:@std/net", 166 - "jsr:@std/path@^1.1.4", 167 - "jsr:@std/streams" 66 + "hono", 67 + "zod" 168 68 ] 169 69 }, 170 - "@std/internal@1.0.12": { 171 - "integrity": "972a634fd5bc34b242024402972cd5143eac68d8dffaca5eaa4dba30ce17b027" 172 - }, 173 - "@std/media-types@1.1.0": { 174 - "integrity": "c9d093f0c05c3512932b330e3cc1fe1d627b301db33a4c2c2185c02471d6eaa4" 175 - }, 176 - "@std/net@1.0.6": { 177 - "integrity": "110735f93e95bb9feb95790a8b1d1bf69ec0dc74f3f97a00a76ea5efea25500c" 178 - }, 179 - "@std/path@1.1.4": { 180 - "integrity": "1d2d43f39efb1b42f0b1882a25486647cb851481862dc7313390b2bb044314b5", 181 - "dependencies": [ 182 - "jsr:@std/internal" 183 - ] 184 - }, 185 - "@std/streams@1.0.17": { 186 - "integrity": "7859f3d9deed83cf4b41f19223d4a67661b3d3819e9fc117698f493bf5992140" 187 - }, 188 - "@std/text@1.0.17": { 189 - "integrity": "4b2c4ef67ae5b6c1dfd447c81c83a43718f52e3c7e748d8b33f694aba9895f95" 190 - }, 191 - "@std/toml@1.0.11": { 192 - "integrity": "e084988b872ca4bad6aedfb7350f6eeed0e8ba88e9ee5e1590621c5b5bb8f715", 193 - "dependencies": [ 194 - "jsr:@std/collections@^1.1.3" 195 - ] 196 - }, 197 - "@std/yaml@1.0.12": { 198 - "integrity": "7deabca4545bcedd07c5f69ea53acea71b8b4c67562f224e17b90d75944cb20c" 199 - } 200 - }, 201 - "npm": { 202 - "@esbuild/aix-ppc64@0.25.12": { 203 - "integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==", 204 - "os": ["aix"], 205 - "cpu": ["ppc64"] 206 - }, 207 - "@esbuild/android-arm64@0.25.12": { 208 - "integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==", 209 - "os": ["android"], 210 - "cpu": ["arm64"] 211 - }, 212 - "@esbuild/android-arm@0.25.12": { 213 - "integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==", 214 - "os": ["android"], 215 - "cpu": ["arm"] 216 - }, 217 - "@esbuild/android-x64@0.25.12": { 218 - "integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==", 219 - "os": ["android"], 220 - "cpu": ["x64"] 221 - }, 222 - "@esbuild/darwin-arm64@0.25.12": { 223 - "integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==", 224 - "os": ["darwin"], 225 - "cpu": ["arm64"] 226 - }, 227 - "@esbuild/darwin-x64@0.25.12": { 228 - "integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==", 229 - "os": ["darwin"], 230 - "cpu": ["x64"] 231 - }, 232 - "@esbuild/freebsd-arm64@0.25.12": { 233 - "integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==", 234 - "os": ["freebsd"], 235 - "cpu": ["arm64"] 236 - }, 237 - "@esbuild/freebsd-x64@0.25.12": { 238 - "integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==", 239 - "os": ["freebsd"], 240 - "cpu": ["x64"] 241 - }, 242 - "@esbuild/linux-arm64@0.25.12": { 243 - "integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==", 244 - "os": ["linux"], 245 - "cpu": ["arm64"] 246 - }, 247 - "@esbuild/linux-arm@0.25.12": { 248 - "integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==", 249 - "os": ["linux"], 250 - "cpu": ["arm"] 251 - }, 252 - "@esbuild/linux-ia32@0.25.12": { 253 - "integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==", 254 - "os": ["linux"], 255 - "cpu": ["ia32"] 256 - }, 257 - "@esbuild/linux-loong64@0.25.12": { 258 - "integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==", 259 - "os": ["linux"], 260 - "cpu": ["loong64"] 261 - }, 262 - "@esbuild/linux-mips64el@0.25.12": { 263 - "integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==", 264 - "os": ["linux"], 265 - "cpu": ["mips64el"] 266 - }, 267 - "@esbuild/linux-ppc64@0.25.12": { 268 - "integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==", 269 - "os": ["linux"], 270 - "cpu": ["ppc64"] 271 - }, 272 - "@esbuild/linux-riscv64@0.25.12": { 273 - "integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==", 274 - "os": ["linux"], 275 - "cpu": ["riscv64"] 276 - }, 277 - "@esbuild/linux-s390x@0.25.12": { 278 - "integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==", 279 - "os": ["linux"], 280 - "cpu": ["s390x"] 281 - }, 282 - "@esbuild/linux-x64@0.25.12": { 283 - "integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==", 284 - "os": ["linux"], 285 - "cpu": ["x64"] 286 - }, 287 - "@esbuild/netbsd-arm64@0.25.12": { 288 - "integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==", 289 - "os": ["netbsd"], 290 - "cpu": ["arm64"] 291 - }, 292 - "@esbuild/netbsd-x64@0.25.12": { 293 - "integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==", 294 - "os": ["netbsd"], 295 - "cpu": ["x64"] 296 - }, 297 - "@esbuild/openbsd-arm64@0.25.12": { 298 - "integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==", 299 - "os": ["openbsd"], 300 - "cpu": ["arm64"] 301 - }, 302 - "@esbuild/openbsd-x64@0.25.12": { 303 - "integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==", 304 - "os": ["openbsd"], 305 - "cpu": ["x64"] 306 - }, 307 - "@esbuild/openharmony-arm64@0.25.12": { 308 - "integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==", 309 - "os": ["openharmony"], 310 - "cpu": ["arm64"] 311 - }, 312 - "@esbuild/sunos-x64@0.25.12": { 313 - "integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==", 314 - "os": ["sunos"], 315 - "cpu": ["x64"] 316 - }, 317 - "@esbuild/win32-arm64@0.25.12": { 318 - "integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==", 319 - "os": ["win32"], 320 - "cpu": ["arm64"] 321 - }, 322 - "@esbuild/win32-ia32@0.25.12": { 323 - "integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==", 324 - "os": ["win32"], 325 - "cpu": ["ia32"] 326 - }, 327 - "@esbuild/win32-x64@0.25.12": { 328 - "integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==", 329 - "os": ["win32"], 330 - "cpu": ["x64"] 331 - }, 332 70 "@lit-labs/ssr-dom-shim@1.4.0": { 333 71 "integrity": "sha512-ficsEARKnmmW5njugNYKipTm4SFnbik7CXtoencDZzmzo/dQ+2Q0bgkzJuoJP20Aj0F+izzJjOqsnkd6F/o1bw==" 334 72 }, ··· 338 76 "@lit-labs/ssr-dom-shim" 339 77 ] 340 78 }, 341 - "@tauri-apps/api@2.9.0": { 342 - "integrity": "sha512-qD5tMjh7utwBk9/5PrTA/aGr3i5QaJ/Mlt7p8NilQ45WgbifUNPyKWsA63iQ8YfQq6R8ajMapU+/Q8nMcPRLNw==" 343 - }, 344 - "@tauri-apps/plugin-store@2.4.1": { 345 - "integrity": "sha512-ckGSEzZ5Ii4Hf2D5x25Oqnm2Zf9MfDWAzR+volY0z/OOBz6aucPKEY0F649JvQ0Vupku6UJo7ugpGRDOFOunkA==", 346 - "dependencies": [ 347 - "@tauri-apps/api" 348 - ] 349 - }, 350 79 "@types/trusted-types@2.0.7": { 351 80 "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==" 352 81 }, 353 - "boolbase@1.0.0": { 354 - "integrity": "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==" 355 - }, 356 - "cheerio-select@2.1.0": { 357 - "integrity": "sha512-9v9kG0LvzrlcungtnJtpGNxY+fzECQKhK4EGJX2vByejiMX84MFNQw4UxPJl3bFbTMw+Dfs37XaIkCwTZfLh4g==", 82 + "fetch-blob@3.2.0": { 83 + "integrity": "sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ==", 358 84 "dependencies": [ 359 - "boolbase", 360 - "css-select", 361 - "css-what", 362 - "domelementtype", 363 - "domhandler", 364 - "domutils" 85 + "node-domexception", 86 + "web-streams-polyfill" 365 87 ] 366 88 }, 367 - "cheerio@1.1.2": { 368 - "integrity": "sha512-IkxPpb5rS/d1IiLbHMgfPuS0FgiWTtFIm/Nj+2woXDLTZ7fOT2eqzgYbdMlLweqlHbsZjxEChoVK+7iph7jyQg==", 369 - "dependencies": [ 370 - "cheerio-select", 371 - "dom-serializer", 372 - "domhandler", 373 - "domutils", 374 - "encoding-sniffer", 375 - "htmlparser2", 376 - "parse5", 377 - "parse5-htmlparser2-tree-adapter", 378 - "parse5-parser-stream", 379 - "undici", 380 - "whatwg-mimetype" 381 - ] 382 - }, 383 - "css-select@5.2.2": { 384 - "integrity": "sha512-TizTzUddG/xYLA3NXodFM0fSbNizXjOKhqiQQwvhlspadZokn1KDy0NZFS0wuEubIYAV5/c1/lAr0TaaFXEXzw==", 385 - "dependencies": [ 386 - "boolbase", 387 - "css-what", 388 - "domhandler", 389 - "domutils", 390 - "nth-check" 391 - ] 392 - }, 393 - "css-what@6.2.2": { 394 - "integrity": "sha512-u/O3vwbptzhMs3L1fQE82ZSLHQQfto5gyZzwteVIEyeaY5Fc7R4dapF/BvRoSYFeqfBk4m0V1Vafq5Pjv25wvA==" 395 - }, 396 - "dom-serializer@2.0.0": { 397 - "integrity": "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==", 398 - "dependencies": [ 399 - "domelementtype", 400 - "domhandler", 401 - "entities@4.5.0" 402 - ] 403 - }, 404 - "domelementtype@2.3.0": { 405 - "integrity": "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==" 406 - }, 407 - "domhandler@5.0.3": { 408 - "integrity": "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==", 409 - "dependencies": [ 410 - "domelementtype" 411 - ] 412 - }, 413 - "domutils@3.2.2": { 414 - "integrity": "sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==", 415 - "dependencies": [ 416 - "dom-serializer", 417 - "domelementtype", 418 - "domhandler" 419 - ] 420 - }, 421 - "encoding-sniffer@0.2.1": { 422 - "integrity": "sha512-5gvq20T6vfpekVtqrYQsSCFZ1wEg5+wW0/QaZMWkFr6BqD3NfKs0rLCx4rrVlSWJeZb5NBJgVLswK/w2MWU+Gw==", 423 - "dependencies": [ 424 - "iconv-lite", 425 - "whatwg-encoding" 426 - ] 427 - }, 428 - "entities@4.5.0": { 429 - "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==" 430 - }, 431 - "entities@6.0.1": { 432 - "integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==" 433 - }, 434 - "esbuild@0.25.12": { 435 - "integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==", 436 - "optionalDependencies": [ 437 - "@esbuild/aix-ppc64", 438 - "@esbuild/android-arm", 439 - "@esbuild/android-arm64", 440 - "@esbuild/android-x64", 441 - "@esbuild/darwin-arm64", 442 - "@esbuild/darwin-x64", 443 - "@esbuild/freebsd-arm64", 444 - "@esbuild/freebsd-x64", 445 - "@esbuild/linux-arm", 446 - "@esbuild/linux-arm64", 447 - "@esbuild/linux-ia32", 448 - "@esbuild/linux-loong64", 449 - "@esbuild/linux-mips64el", 450 - "@esbuild/linux-ppc64", 451 - "@esbuild/linux-riscv64", 452 - "@esbuild/linux-s390x", 453 - "@esbuild/linux-x64", 454 - "@esbuild/netbsd-arm64", 455 - "@esbuild/netbsd-x64", 456 - "@esbuild/openbsd-arm64", 457 - "@esbuild/openbsd-x64", 458 - "@esbuild/openharmony-arm64", 459 - "@esbuild/sunos-x64", 460 - "@esbuild/win32-arm64", 461 - "@esbuild/win32-ia32", 462 - "@esbuild/win32-x64" 463 - ], 464 - "scripts": true, 465 - "bin": true 466 - }, 467 89 "hammerjs@2.0.8": { 468 90 "integrity": "sha512-tSQXBXS/MWQOn/RKckawJ61vvsDpCom87JgxiYdGwHdOa0ht0vzUWDlfioofFCRU0L+6NGDt6XzbgoJvZkMeRQ==" 469 91 }, 470 - "htmlparser2@10.0.0": { 471 - "integrity": "sha512-TwAZM+zE5Tq3lrEHvOlvwgj1XLWQCtaaibSN11Q+gGBAS7Y1uZSWwXXRe4iF6OXnaq1riyQAPFOBtYc77Mxq0g==", 472 - "dependencies": [ 473 - "domelementtype", 474 - "domhandler", 475 - "domutils", 476 - "entities@6.0.1" 477 - ] 478 - }, 479 - "iconv-lite@0.6.3": { 480 - "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", 481 - "dependencies": [ 482 - "safer-buffer" 483 - ] 92 + "hono@4.11.8": { 93 + "integrity": "sha512-eVkB/CYCCei7K2WElZW9yYQFWssG0DhaDhVvr7wy5jJ22K+ck8fWW0EsLpB0sITUTvPnc97+rrbQqIr5iqiy9Q==" 484 94 }, 485 95 "lit-element@4.2.1": { 486 96 "integrity": "sha512-WGAWRGzirAgyphK2urmYOV72tlvnxw7YfyLDgQ+OZnM9vQQBQnumQ7jUJe6unEzwGU3ahFOjuz1iz1jjrpCPuw==", ··· 504 114 "lit-html" 505 115 ] 506 116 }, 507 - "nth-check@2.1.1": { 508 - "integrity": "sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==", 509 - "dependencies": [ 510 - "boolbase" 117 + "native-file-system-adapter@3.0.1": { 118 + "integrity": "sha512-ocuhsYk2SY0906LPc3QIMW+rCV3MdhqGiy7wV5Bf0e8/5TsMjDdyIwhNiVPiKxzTJLDrLT6h8BoV9ERfJscKhw==", 119 + "optionalDependencies": [ 120 + "fetch-blob" 511 121 ] 512 122 }, 513 - "parse5-htmlparser2-tree-adapter@7.1.0": { 514 - "integrity": "sha512-ruw5xyKs6lrpo9x9rCZqZZnIUntICjQAd0Wsmp396Ul9lN/h+ifgVV1x1gZHi8euej6wTfpqX8j+BFQxF0NS/g==", 515 - "dependencies": [ 516 - "domhandler", 517 - "parse5" 518 - ] 123 + "node-domexception@1.0.0": { 124 + "integrity": "sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==", 125 + "deprecated": true 519 126 }, 520 - "parse5-parser-stream@7.1.2": { 521 - "integrity": "sha512-JyeQc9iwFLn5TbvvqACIF/VXG6abODeB3Fwmv/TGdLk2LfbWkaySGY72at4+Ty7EkPZj854u4CrICqNk2qIbow==", 127 + "openapi3-ts@4.5.0": { 128 + "integrity": "sha512-jaL+HgTq2Gj5jRcfdutgRGLosCy/hT8sQf6VOy+P+g36cZOjI1iukdPnijC+4CmeRzg/jEllJUboEic2FhxhtQ==", 522 129 "dependencies": [ 523 - "parse5" 524 - ] 525 - }, 526 - "parse5@7.3.0": { 527 - "integrity": "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==", 528 - "dependencies": [ 529 - "entities@6.0.1" 130 + "yaml" 530 131 ] 531 132 }, 532 - "safer-buffer@2.1.2": { 533 - "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==" 534 - }, 535 - "ts-fsrs@5.2.3": { 536 - "integrity": "sha512-R3IjceC9WfnvUin6Nx+DwqEzh3Qil6Gg2yEHqvocUcC7Nbi+xDrFg/1fKaYBT0tJedDnDAguXMSX0hijhi859w==" 537 - }, 538 - "undici@7.16.0": { 539 - "integrity": "sha512-QEg3HPMll0o3t2ourKwOeUAZ159Kn9mx5pnzHRQO8+Wixmh88YdZRiIwat0iNzNNXn0yoEtXJqFpyW7eM8BV7g==" 540 - }, 541 - "whatwg-encoding@3.1.1": { 542 - "integrity": "sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==", 543 - "dependencies": [ 544 - "iconv-lite" 545 - ] 133 + "web-streams-polyfill@3.3.3": { 134 + "integrity": "sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw==" 546 135 }, 547 - "whatwg-mimetype@4.0.0": { 548 - "integrity": "sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==" 136 + "yaml@2.8.2": { 137 + "integrity": "sha512-mplynKqc1C2hTVYxd0PU2xQAc22TI1vShAYGksCCfxbn/dFwnHTNi1bvYsBTkhdUNtGIf5xNOg938rrSSYvS9A==", 138 + "bin": true 549 139 }, 550 140 "zod@4.3.6": { 551 141 "integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==" ··· 554 144 "workspace": { 555 145 "dependencies": [ 556 146 "jsr:@bpev/civility@^0.0.5", 147 + "jsr:@bpev/sync-link@^0.0.17", 557 148 "jsr:@inro/simple-tools@~0.5.2", 558 149 "jsr:@std/assert@^1.0.18", 559 150 "jsr:@std/dotenv@~0.225.6", 560 151 "jsr:@std/path@^1.1.4", 152 + "jsr:@std/testing@^1.0.17", 561 153 "npm:hammerjs@^2.0.8", 562 154 "npm:zod@^4.3.6" 563 155 ]
+2 -2
www/components/climb-header.ts
··· 5 5 sandbagLabel, 6 6 } from '../utils/benchmarks.ts' 7 7 import { getClimbNav } from '../utils/climb-nav.ts' 8 - import { getClimbLog } from '../utils/logbook.ts' 8 + import app from '../models/app.ts' 9 9 10 10 export let activeClimbHeader: ClimbHeader | null = null 11 11 ··· 40 40 } 41 41 42 42 metaHtml(climb: Benchmark): string { 43 - const entry = getClimbLog(climb.id) 43 + const entry = app.getClimbLog(climb.id) 44 44 if (entry) { 45 45 const badge = entry.sent 46 46 ? '<ui-badge variant="success">Sent</ui-badge>'
+175
www/models/app.ts
··· 1 + import State from '@inro/simple-tools/state' 2 + import useJSON from '@bpev/sync-link/json' 3 + import { 4 + AppSettings, 5 + AppState, 6 + ClimbLogEntry, 7 + ClimbSession, 8 + settingsMigrationConfig, 9 + StoreState, 10 + } from './schema.ts' 11 + import { Store } from './store.ts' 12 + 13 + const storage = useJSON<AppSettings>('mb-settings', AppSettings.parse({}), { 14 + migrations: settingsMigrationConfig, 15 + }) 16 + 17 + export class App extends State<AppState> { 18 + store: Store 19 + settings: State<AppSettings> 20 + 21 + constructor() { 22 + super(AppState.parse({})) 23 + this.#migrateLegacy() 24 + this.store = new Store('mb-data') 25 + this.settings = new State(AppSettings.parse({}), { storage }) 26 + this.settings.waitUntilReady().then(() => this.connectStore()) 27 + this.settings.addEventListener(() => this.notify()) 28 + } 29 + 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('mb-data', JSON.stringify(StoreState.parse({ logbook }))) 37 + localStorage.removeItem('mb_logbook') 38 + } catch { /* leave old data in place if migration fails */ } 39 + } 40 + 41 + // ====== SYNCLINK CONNECTION ====== 42 + 43 + async connectStore(): Promise<void> { 44 + if (this.settings.state.syncLinkUrl?.trim()) { 45 + await this.store.connect(this.settings.state.syncLinkUrl) 46 + } else { 47 + this.store.disconnect() 48 + } 49 + } 50 + 51 + // ====== LOGBOOK ACCESSORS ====== 52 + 53 + get logbook(): Record<string, ClimbLogEntry> { 54 + return this.store.logbook 55 + } 56 + 57 + getClimbLog(climbId: number): ClimbLogEntry | null { 58 + return this.store.logbook[climbId.toString()] ?? null 59 + } 60 + 61 + // ====== ASYNC WRITE ====== 62 + 63 + async logSession( 64 + climb: { 65 + id: number 66 + mbType: number 67 + name: string 68 + grade: number 69 + setter: string 70 + }, 71 + session: { attempts: number; sent: boolean; rating: number | null }, 72 + ): Promise<ClimbLogEntry> { 73 + const existing = await this.store.getEntry(climb.id) 74 + 75 + const newSession = ClimbSession.parse({ 76 + date: new Date().toISOString(), 77 + attempts: session.attempts, 78 + sent: session.sent, 79 + rating: session.rating, 80 + }) 81 + 82 + const sessions = existing ? [...existing.sessions, newSession] : [newSession] 83 + const totalAttempts = sessions.reduce((sum, s) => sum + s.attempts, 0) 84 + const sent = sessions.some((s) => s.sent) 85 + const ratedSessions = sessions.filter((s) => s.rating !== null) 86 + const rating = ratedSessions.length > 0 87 + ? ratedSessions[ratedSessions.length - 1].rating 88 + : null 89 + 90 + const entry = ClimbLogEntry.parse({ 91 + climbId: climb.id, 92 + mbType: climb.mbType, 93 + name: climb.name, 94 + grade: climb.grade, 95 + setter: climb.setter, 96 + sent, 97 + totalAttempts, 98 + rating, 99 + sessions, 100 + lastAttempted: newSession.date, 101 + }) 102 + 103 + await this.store.saveEntry(entry) 104 + this.notify() 105 + return entry 106 + } 107 + 108 + // ====== UTILITY ====== 109 + 110 + clearError(): void { 111 + this.state.error = null 112 + this.notify() 113 + } 114 + 115 + async exportStore(filename?: string): Promise<{ 116 + success: boolean 117 + path: string 118 + error?: string 119 + }> { 120 + try { 121 + const exportName = filename || `moonboard-export_${Date.now()}` 122 + return await this.store.exportToFile(exportName) 123 + } catch (error) { 124 + return { 125 + success: false, 126 + path: '', 127 + error: error instanceof Error ? error.message : 'Export failed', 128 + } 129 + } 130 + } 131 + 132 + async importStore(): Promise<{ 133 + success: boolean 134 + path: string 135 + error?: string 136 + }> { 137 + try { 138 + const result = await this.store.importFromFile() 139 + if (!result.success) return result 140 + this.notify() 141 + return result 142 + } catch (error) { 143 + return { 144 + success: false, 145 + path: '', 146 + error: error instanceof Error ? error.message : 'Import failed', 147 + } 148 + } 149 + } 150 + 151 + async resetAppData(): Promise<{ 152 + success: boolean 153 + path: string 154 + error?: string 155 + }> { 156 + try { 157 + await this.store.clearAllData() 158 + this.state.error = null 159 + this.notify() 160 + return { success: true, path: '' } 161 + } catch (error) { 162 + return { 163 + success: false, 164 + path: '', 165 + error: error instanceof Error ? error.message : 'Reset failed', 166 + } 167 + } 168 + } 169 + 170 + dispose(): void { 171 + this.store.dispose() 172 + } 173 + } 174 + 175 + export default new App()
+25
www/models/schema.ts
··· 1 + export { AppSettings, AppState, ClimbLogEntry, ClimbSession, StoreState } from './schema/v0.ts' 2 + import type { AppSettings, StoreState } from './schema/v0.ts' 3 + 4 + export const storeMigrationConfig = { 5 + currentVersion: '0.1.0', 6 + extractVersion: (data: unknown): string | undefined => 7 + data && typeof data === 'object' && 'version' in data 8 + ? (data as { version: string }).version 9 + : undefined, 10 + compareVersions: (_v1: unknown, _v2: unknown): number => 0, 11 + migrations: [], 12 + } 13 + 14 + export const settingsMigrationConfig = { 15 + currentVersion: '0.1.0', 16 + extractVersion: (data: unknown): string | undefined => 17 + data && typeof data === 'object' && 'version' in data 18 + ? (data as { version: string }).version 19 + : undefined, 20 + compareVersions: (_v1: unknown, _v2: unknown): number => 0, 21 + migrations: [], 22 + } 23 + 24 + // Satisfy TypeScript — these imports are referenced by migration config types 25 + export type { AppSettings, StoreState }
+36 -14
www/models/schema/v0.ts
··· 1 1 import { z } from 'zod' 2 2 3 - export const Version = z.string().default(globalThis.__APP_VERSION__ || '1.0.0') 4 - 5 3 export const ClimbSession = z.object({ 6 - date: z.date(), 7 - attempts: z.number(), 8 - sent: z.boolean(), 9 - rating: z.number().nullable(), 4 + date: z.string(), 5 + attempts: z.number().int().nonnegative().default(1), 6 + sent: z.boolean().default(false), 7 + rating: z.number().int().nullable().default(null), 10 8 }) 11 - export type Session = z.infer<typeof Session> 9 + export type ClimbSession = z.infer<typeof ClimbSession> 12 10 13 11 export const ClimbLogEntry = z.object({ 14 - climbId: z.number(), 15 - mbType: z.number(), 12 + climbId: z.number().int(), 13 + mbType: z.number().int().default(0), 16 14 name: z.string(), 17 - grade: z.number(), 15 + grade: z.number().int(), 18 16 setter: z.string(), 19 - sent: z.boolean(), 20 - totalAttempts: z.number(), 21 - rating: z.number().nullable(), 22 - sessions: z.array(ClimbSession), 17 + sent: z.boolean().default(false), 18 + totalAttempts: z.number().int().nonnegative().default(0), 19 + rating: z.number().int().nullable().default(null), 20 + sessions: z.array(ClimbSession).default([]), 23 21 lastAttempted: z.string(), 24 22 }) 25 23 export type ClimbLogEntry = z.infer<typeof ClimbLogEntry> 24 + 25 + export const StoreState = z.object({ 26 + version: z.string().optional(), 27 + logbook: z.record(z.string(), ClimbLogEntry).default({}), 28 + }) 29 + export type StoreState = z.infer<typeof StoreState> 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 + export const AppSettings = z.object({ 34 + syncLinkUrl: z.string().default(''), 35 + mbType: z.number().int().min(0).max(6).default(0), 36 + gradeScale: z.enum(['french', 'v']).default('french'), 37 + homeGradeMin: z.number().int().min(0).max(16).default(0), 38 + homeGradeMax: z.number().int().min(0).max(16).default(16), 39 + homeFilter: z.enum(['all', 'sent', 'unsent']).default('all'), 40 + libraryFilter: z.enum(['all', 'sent', 'unsent']).default('all'), 41 + }) 42 + export type AppSettings = z.infer<typeof AppSettings> 43 + 44 + export const AppState = z.object({ 45 + error: z.string().nullable().default(null), 46 + }) 47 + export type AppState = z.infer<typeof AppState>
+119
www/models/store.ts
··· 1 + import SyncLink from '@bpev/sync-link' 2 + import useJSON from '@bpev/sync-link/json' 3 + import { ClimbLogEntry, storeMigrationConfig, StoreState } from './schema.ts' 4 + 5 + export class Store { 6 + #sync: SyncLink<StoreState> 7 + 8 + constructor(name: string) { 9 + this.#sync = new SyncLink( 10 + useJSON<StoreState>(name, StoreState.parse({}), { 11 + migrations: storeMigrationConfig, 12 + }), 13 + ) 14 + } 15 + 16 + // ====== SYNCLINK CONNECTION ====== 17 + 18 + async connect(url: string): Promise<void> { 19 + if (!url.trim()) { 20 + throw new Error('SyncLink URL is required') 21 + } 22 + const { baseUrl, appId, token } = SyncLink.parseURL(url) 23 + await this.#sync.connect(baseUrl, appId, token) 24 + } 25 + 26 + disconnect(): void { 27 + this.#sync.disconnect() 28 + } 29 + 30 + get isConnected(): boolean { 31 + return this.#sync.isConnected 32 + } 33 + 34 + addEventListener(fn: () => void): void { 35 + this.#sync.addEventListener(fn) 36 + } 37 + 38 + removeEventListener(fn: () => void): void { 39 + this.#sync.removeEventListener(fn) 40 + } 41 + 42 + // ====== STATE ACCESS ====== 43 + 44 + get state() { 45 + return this.#sync.state 46 + } 47 + 48 + get logbook(): Record<string, ClimbLogEntry> { 49 + return this.#sync.state.data.logbook 50 + } 51 + 52 + // ====== LOGBOOK CRUD ====== 53 + 54 + async getEntry(climbId: number): Promise<ClimbLogEntry | null> { 55 + const data = await this.#sync.get() 56 + return data.logbook[climbId.toString()] ?? null 57 + } 58 + 59 + async saveEntry(entry: ClimbLogEntry): Promise<boolean> { 60 + const data = await this.#sync.get() 61 + data.logbook[entry.climbId.toString()] = entry 62 + return await this.#sync.set(data) 63 + } 64 + 65 + async deleteEntry(climbId: number): Promise<boolean> { 66 + const data = await this.#sync.get() 67 + const key = climbId.toString() 68 + if (!(key in data.logbook)) return false 69 + delete data.logbook[key] 70 + return await this.#sync.set(data) 71 + } 72 + 73 + async getAllEntries(): Promise<ClimbLogEntry[]> { 74 + const data = await this.#sync.get() 75 + return Object.values(data.logbook) 76 + } 77 + 78 + // ====== UTILITY ====== 79 + 80 + async clearAllData(): Promise<void> { 81 + await this.#sync.set(StoreState.parse({})) 82 + } 83 + 84 + exportToFile(filename?: string): Promise<{ 85 + success: boolean 86 + path: string 87 + error?: string 88 + }> { 89 + try { 90 + return this.#sync.exportToFile(filename || 'moonboard-data') 91 + } catch (error) { 92 + return Promise.resolve({ 93 + success: false, 94 + path: '', 95 + error: error instanceof Error ? error.message : 'Export failed', 96 + }) 97 + } 98 + } 99 + 100 + importFromFile(): Promise<{ 101 + success: boolean 102 + path: string 103 + error?: string 104 + }> { 105 + try { 106 + return this.#sync.importFromFile() 107 + } catch (error) { 108 + return Promise.resolve({ 109 + success: false, 110 + path: '', 111 + error: error instanceof Error ? error.message : 'Import failed', 112 + }) 113 + } 114 + } 115 + 116 + dispose(): void { 117 + this.#sync.dispose() 118 + } 119 + }
+15 -12
www/routes/climb.ts
··· 10 10 youtubeUrl, 11 11 } from '../utils/benchmarks.ts' 12 12 import { getClimbNav, setClimbNav } from '../utils/climb-nav.ts' 13 - import { getClimbLog, logSession } from '../utils/logbook.ts' 13 + import app from '../models/app.ts' 14 14 import { activeClimbHeader } from '../components/climb-header.ts' 15 15 16 16 export class ClimbPage extends HTMLElement { ··· 147 147 } 148 148 149 149 private logSectionHtml(climb: Benchmark): string { 150 - const entry = getClimbLog(climb.id) 150 + const entry = app.getClimbLog(climb.id) 151 151 if (!entry) { 152 152 return `<button class="action" id="lb-log-btn">Log Attempt</button>` 153 153 } ··· 257 257 const attempts = Math.max(1, parseInt(attemptsInput?.value ?? '1')) 258 258 const sent = box.querySelector<HTMLInputElement>('#lb-sent')?.checked ?? 259 259 false 260 - logSession( 260 + const climb = this.currentDialogClimb 261 + const rating = this.dialogRating 262 + this.hideLogDialog() 263 + void app.logSession( 261 264 { 262 - id: this.currentDialogClimb.id, 263 - mbType: this.currentDialogClimb.mb_type, 264 - name: this.currentDialogClimb.name, 265 - grade: this.currentDialogClimb.grade, 266 - setter: this.currentDialogClimb.setter, 265 + id: climb.id, 266 + mbType: climb.mb_type, 267 + name: climb.name, 268 + grade: climb.grade, 269 + setter: climb.setter, 267 270 }, 268 - { attempts, sent, rating: this.dialogRating }, 269 - ) 270 - this.hideLogDialog() 271 - this.refreshLogSection(this.currentDialogClimb) 271 + { attempts, sent, rating }, 272 + ).then(() => { 273 + if (this.isConnected) this.refreshLogSection(climb) 274 + }) 272 275 } 273 276 } 274 277 }
+3 -3
www/routes/home.ts
··· 6 6 loadBenchmarks, 7 7 } from '../utils/benchmarks.ts' 8 8 import { setClimbNav } from '../utils/climb-nav.ts' 9 - import { loadLogbook } from '../utils/logbook.ts' 9 + import app from '../models/app.ts' 10 10 import { emitter, PAGE_SIZE, state } from '../components/home-filters.ts' 11 11 12 12 export class HomePage extends HTMLElement { ··· 77 77 78 78 private applyFilters(): void { 79 79 const q = state.search.toLowerCase() 80 - const logbook = state.logFilter !== 'all' ? loadLogbook() : null 80 + const logbook = state.logFilter !== 'all' ? app.logbook : null 81 81 this.filtered = this.benchmarks.filter((b) => { 82 82 if (b.grade < state.gradeMin || b.grade > state.gradeMax) return false 83 83 if ( ··· 111 111 112 112 const visible = this.filtered.slice(0, state.shown) 113 113 const remaining = this.filtered.length - state.shown 114 - const logbook = loadLogbook() 114 + const logbook = app.logbook 115 115 116 116 listEl.innerHTML = ` 117 117 <div class="bm-count">
+3 -2
www/routes/library.ts
··· 1 - import { type ClimbLogEntry, loadLogbook } from '../utils/logbook.ts' 1 + import app from '../models/app.ts' 2 + import type { ClimbLogEntry } from '../models/schema.ts' 2 3 import { setClimbNav } from '../utils/climb-nav.ts' 3 4 import { escapeHtml, gradeLabel } from '../utils/benchmarks.ts' 4 5 import { formatDate, starsHtml } from '../utils/format.ts' ··· 37 38 } 38 39 39 40 private sortedEntries(): ClimbLogEntry[] { 40 - return Object.values(loadLogbook()).sort( 41 + return Object.values(app.logbook).sort( 41 42 (a, b) => 42 43 new Date(b.lastAttempted).getTime() - 43 44 new Date(a.lastAttempted).getTime(),
-84
www/utils/logbook.ts
··· 1 - export interface ClimbSession { 2 - date: string 3 - attempts: number 4 - sent: boolean 5 - rating: number | null 6 - } 7 - 8 - export interface ClimbLogEntry { 9 - climbId: number 10 - mbType: number 11 - name: string 12 - grade: number 13 - setter: string 14 - sent: boolean 15 - totalAttempts: number 16 - rating: number | null 17 - sessions: ClimbSession[] 18 - lastAttempted: string 19 - } 20 - 21 - const KEY = 'mb_logbook' 22 - 23 - export function loadLogbook(): Record<string, ClimbLogEntry> { 24 - try { 25 - return JSON.parse(localStorage.getItem(KEY) ?? '{}') 26 - } catch { 27 - return {} 28 - } 29 - } 30 - 31 - function saveLogbook(logbook: Record<string, ClimbLogEntry>): void { 32 - localStorage.setItem(KEY, JSON.stringify(logbook)) 33 - } 34 - 35 - export function getClimbLog(climbId: number): ClimbLogEntry | null { 36 - return loadLogbook()[climbId.toString()] ?? null 37 - } 38 - 39 - export function logSession( 40 - climb: { 41 - id: number 42 - mbType: number 43 - name: string 44 - grade: number 45 - setter: string 46 - }, 47 - session: { attempts: number; sent: boolean; rating: number | null }, 48 - ): ClimbLogEntry { 49 - const logbook = loadLogbook() 50 - const key = climb.id.toString() 51 - const existing = logbook[key] 52 - 53 - const newSession: ClimbSession = { 54 - date: new Date().toISOString(), 55 - attempts: session.attempts, 56 - sent: session.sent, 57 - rating: session.rating, 58 - } 59 - 60 - const sessions = existing ? [...existing.sessions, newSession] : [newSession] 61 - const totalAttempts = sessions.reduce((sum, s) => sum + s.attempts, 0) 62 - const sent = sessions.some((s) => s.sent) 63 - const ratedSessions = sessions.filter((s) => s.rating !== null) 64 - const rating = ratedSessions.length > 0 65 - ? ratedSessions[ratedSessions.length - 1].rating 66 - : null 67 - 68 - const entry: ClimbLogEntry = { 69 - climbId: climb.id, 70 - mbType: climb.mbType, 71 - name: climb.name, 72 - grade: climb.grade, 73 - setter: climb.setter, 74 - sent, 75 - totalAttempts, 76 - rating, 77 - sessions, 78 - lastAttempted: newSession.date, 79 - } 80 - 81 - logbook[key] = entry 82 - saveLogbook(logbook) 83 - return entry 84 - }