/ grilling-session.org
grilling-session.org
1 #+title: KB-Dashboard — Grilling Session Outcome 2 #+author: Xavier Brinon 3 #+date: [2026-04-25 Sat] 4 #+startup: indent 5 #+options: toc:3 num:t ^:{} 6 7 * Summary 8 9 Personal new-tab dashboard surfacing project status from =~/Projects/*=, 10 offline-first with optional filtered public deploy at =dashboard.brinon.eu=. 11 12 This document records the architectural decisions reached during a structured 13 grilling session over the original [[file:ideas.org][ideas.org]] brainstorm. 14 Each section reflects a locked-in choice with the reasoning that produced it. 15 16 * Architecture 17 18 Three local processes plus a Docker-based deploy pipeline. 19 20 #+begin_example 21 ┌──────────────────── LOCAL DEV MACHINE ────────────────────┐ 22 │ │ 23 │ ┌──────────┐ ┌────────────┐ ┌──────────────┐ │ 24 │ │ Scanner │───→│ PocketBase │←───│ TanStack │ │ 25 │ │ (Bun) │ │ 127.0.0.1 │ │ Start │ │ 26 │ │ │ │ :8090 │ │ 127.0.0.1 │ │ 27 │ │ launchd │ │ │ │ :3000 │ │ 28 │ │ +manual │ │ SQLite │ │ │ │ 29 │ │ button │ │ Admin UI │ │ reads via │ │ 30 │ │ │ │ REST API │ │ PB SDK │ │ 31 │ └──────────┘ └────────────┘ └──────────────┘ │ 32 │ │ │ 33 │ ▼ │ 34 │ bun run build:public │ 35 │ → dist/public/ │ 36 │ │ │ 37 │ ▼ │ 38 │ docker build + push │ 39 └────────────────────────────────────────────┼──────────────┘ 40 │ 41 ▼ 42 ┌───────────────────────────┐ 43 │ VPS — Dokploy │ 44 │ registry.brinon.eu │ 45 │ → nginx serves static │ 46 │ → dashboard.brinon.eu │ 47 └───────────────────────────┘ 48 #+end_example 49 50 ** Process responsibilities 51 52 | Process | Role | Lifetime | 53 |--------------------+----------------------------------------------------+-----------------------------------------| 54 | =pocketbase serve= | Source of truth + admin UI | Always (launchd-managed) | 55 | Scanner (Bun) | Reads =../=, computes derived stats, upserts to PB | Scheduled (every 30 min) + manual | 56 | TanStack Start | Dashboard view; prerenders public build | =bun run dev= or =bun run build:public= | 57 58 * Stack 59 60 | Layer | Choice | 61 |----------------------------+----------------------------------------------------| 62 | Source repo | Radicle (seeded on =garden.bastidehub.xyz=) | 63 | Frontend / SSR / prerender | TanStack Start (Bun runtime) | 64 | Backend / DB / admin UI | PocketBase (local-only, bound to =127.0.0.1:8090=) | 65 | Scanner | Bun script, launchd-scheduled | 66 | Stats | =git=, =rad=, =tokei= | 67 | Env / secrets | =dotenvx= (encrypted =.env= loader, run-anywhere) | 68 | Container deploy | Docker → Dokploy registry on the same VPS | 69 | Web server | =nginx:alpine= | 70 71 * Decisions log 72 73 ** Q1 — What is a "project"? 74 75 *Decision*: A project is a row in the PocketBase =projects= collection. 76 Discovery is DB-driven, not filesystem-marker-driven. 77 78 *Why*: User explicitly preferred dynamic CRUD over filesystem-marker scanning. 79 The =path= field on each row tells the scanner where to read from. 80 81 ** Q2 — Where does the database live? 82 83 *Decision*: Shape A — local SQLite (via PocketBase), CRUD via PocketBase admin 84 UI on =127.0.0.1=, generator pattern preserved for public build. 85 86 *Why*: Keeps the privacy guarantee intact (VPS never sees the DB). No auth 87 needed because the bind is local-only. 88 89 ** Q3 — Frontend stack 90 91 *Decision*: TanStack Start with prerendering for public routes; admin/CRUD 92 handled entirely by PocketBase's built-in admin UI rather than custom routes. 93 94 *Why*: Eliminates the need to build CRUD screens. TanStack Start prerenders 95 =/public/*= routes at build time, leaving =/admin/*= as SSR-only and naturally 96 absent from the static export. 97 98 ** Q4 — Process layout 99 100 *Decision*: Three processes — PocketBase, Bun scanner, TanStack Start. Auth 101 deferred. Scanner triggered by launchd timer + manual refresh button in 102 dashboard. 103 104 *Why*: Separation of concerns. Scanner restarting doesn't affect the dashboard 105 process; PocketBase upgrades don't affect the scanner. 106 107 ** Q5 — Schema 108 109 *Decision*: Two collections plus an error collection. Append-only snapshots 110 (time-series). Relative paths via =PROJECTS_ROOT= env var. =tokei= for LOC. 111 112 *Why*: Structural separation of manual vs derived fields means the scanner 113 *literally cannot* clobber user-authored notes (enforced by PB API rules, not 114 *scanner discipline). Time-series captured from day one because backfill is 115 *impossible. 116 117 *** =projects= (manual, admin UI only) 118 119 | Field | Type | Notes | 120 |-----------------------+-------------------------------------------------+----------------------------------------| 121 | =id= | auto | PB-generated | 122 | =slug= | text, unique, indexed | URL identifier | 123 | =name= | text | Display name | 124 | =path= | text | Relative to =PROJECTS_ROOT= | 125 | =status= | select: active / idle / stuck / done / archived | Public list filters out stuck/archived | 126 | =public= | bool, default false | Opt-in to public build | 127 | =notes= | rich text | Internal scratch | 128 | =public_description= | text, nullable | Polished portfolio blurb | 129 | =stuck_reason= | text, nullable | Only when status=stuck | 130 | =tech_stack= | json (string[]) | Tags | 131 | =links= | json ({label,url,public}[]) | Per-link visibility flag | 132 | =expose_issue_counts= | bool, default false | Opt-in for public issue counts | 133 | =display_order= | number | Manual sort | 134 | =archived_at= | date, nullable | | 135 | =created= / =updated= | auto | PB | 136 137 *** =project_snapshots= (scanner-write, append-only) 138 139 | Field | Type | Notes | 140 |-----------------------+-------------------+-----------------------| 141 | =id= | auto | | 142 | =project= | relation, cascade | → =projects= | 143 | =scanned_at= | date, indexed | | 144 | =last_commit_at= | date | | 145 | =last_commit_sha= | text | | 146 | =last_commit_message= | text | | 147 | =commit_count= | number | All-time | 148 | =commit_count_30d= | number | Velocity proxy | 149 | =commit_count_7d= | number | Short-term velocity | 150 | =loc= | number | Via =tokei= | 151 | =language_breakdown= | json | ={ "ts": 1200, ... }= | 152 | =open_issues= | number | From =rad= | 153 | =closed_issues= | number | | 154 | =solved_issues= | number | | 155 | =branch_count= | number | | 156 157 Composite index on =(project, scanned_at desc)=. 158 159 *** =scan_errors= (scanner health, local-only) 160 161 | Field | Type | Notes | 162 |--------------+--------------------+--------------| 163 | =id= | auto | | 164 | =project= | relation, nullable | → =projects= | 165 | =scanned_at= | date | | 166 | =kind= | text | enum-like | 167 | =message= | text | | 168 | =stack= | text | | 169 170 *** API rules 171 172 - =projects=: superuser-write, open-read. 173 - =project_snapshots=: scanner-token-write, open-read. 174 - =scan_errors=: scanner-token-write, admin-read. 175 176 ** Q6 — Scanner failure model 177 178 *Decision*: Per-project failure isolation, structured error logging via the 179 =scan_errors= collection, sequential scans, three-state freshness on cards 180 (fresh / stale-after-24h / broken). 181 182 | Failure | Policy | 183 |----------------------------------+---------------------------------------------------------------| 184 | =path= doesn't exist | Set status=stuck, stuck_reason="path not found" | 185 | =git= fails | Snapshot inserted with =last_commit_*= null + scan_errors row | 186 | =rad= missing or non-rad project | Issue counts = 0 + flag indicating "rad unavailable" | 187 | =tokei= missing | Hard fail entire scanner (exit 1, surfaces in launchd logs) | 188 | Single project >60s | Kill subprocesses, mark snapshot partial, continue | 189 190 Scanner exit codes: 191 - 0 = scan completed (per-project errors isolated) 192 - 1 = setup failure 193 - 2 = auth failure to PocketBase. 194 195 ** Q7 — Public build redaction policy 196 197 *Decision*: Allowlist-by-field via a single =public-shape.ts= mapper function, 198 backed by a snapshot test. Build-time grep guard against private strings. 199 Rounded numeric fields (=loc= to 100, =commit_count= to 10). 200 201 *Why*: Allowlist ensures new fields default to private. Snapshot test forces an 202 explicit code review to expose anything new. Grep guard catches data-level leaks 203 the mapper missed (e.g., a stuck_reason somehow embedded in a description). 204 205 *** Field-by-field public visibility 206 207 Manual fields: 208 209 | Field | Public? | Notes | 210 |----------------------+----------------+----------------------------------------------| 211 | =id= | no | Use slug | 212 | =slug= | yes | URL | 213 | =name= | yes | | 214 | =path= | *never* | Filesystem leak | 215 | =status= | yes (filtered) | Only active/idle/done; stuck/archived hidden | 216 | =notes= | no | Internal | 217 | =public_description= | yes | | 218 | =stuck_reason= | *never* | By definition internal | 219 | =tech_stack= | yes | | 220 | =links[*]= | per-link | Each link has =public= flag | 221 | =archived_at= | no | Filtered out entirely | 222 | =created=, =updated= | yes | | 223 224 Snapshot fields: 225 226 | Field | Public? | Notes | 227 |-----------------------+--------------------+-----------------------------------| 228 | =last_commit_at= | yes | | 229 | =last_commit_sha= | no | | 230 | =last_commit_message= | *never* | May contain client / private data | 231 | =commit_count= | rounded to 10 | | 232 | =commit_count_30d= | yes | | 233 | =commit_count_7d= | yes | | 234 | =loc= | rounded to 100 | | 235 | =language_breakdown= | yes | | 236 | =open_issues= | opt-in per project | =expose_issue_counts= | 237 | =closed_issues= | opt-in per project | | 238 | =solved_issues= | opt-in per project | | 239 | =branch_count= | no | | 240 241 *** Build-time grep guard 242 243 =bun run guard:public= scans =dist/public/= and fails if any of these patterns 244 appear: 245 246 - Absolute home paths (=/Users/=, =/home/=) 247 - Commit SHAs (=[a-f0-9]{40}=) 248 - Email addresses (=@[\w.-]+\.\w+=) 249 - Any value of =projects.path= from the DB 250 - Any value of =projects.notes= from the DB 251 - Any value of =projects.stuck_reason= from the DB 252 - Any =links[i].url= where =public=false= 253 254 Match → exit 1 → no deploy. 255 256 *** Public list filter 257 258 #+begin_src sql 259 WHERE public = true 260 AND status IN ('active', 'idle', 'done') 261 AND archived_at IS NULL 262 #+end_src 263 264 ** Q8 — Deploy topology 265 266 *Decision*: Shape γ — Docker image push to Dokploy's built-in registry. 267 268 *Why*: Decouples KB-Dashboard's deploy pipeline from =garden.bastidehub.xyz='s 269 availability. The Dokploy registry runs on the same VPS as the dashboard, so its 270 uptime is already a precondition. Source repo stays on Radicle (whichever seeds 271 suit), unaffected. 272 273 Manual deploys only. Pre-deploy: scanner is run first so the public site 274 reflects current truth. No staging environment for v1. 275 276 *** Deploy ritual 277 278 #+begin_src bash 279 bun run scan 280 bun run build:public 281 bun run guard:public # exits non-zero on private-string match 282 SHA=$(git rev-parse --short HEAD) 283 docker build -t registry.brinon.eu/kb-dash:$SHA -f Dockerfile.public . 284 docker tag registry.brinon.eu/kb-dash:$SHA registry.brinon.eu/kb-dash:latest 285 docker push registry.brinon.eu/kb-dash:$SHA 286 docker push registry.brinon.eu/kb-dash:latest 287 # Dokploy webhook on registry-push → redeploy 288 #+end_src 289 290 *** Dockerfile 291 292 #+begin_src dockerfile 293 FROM nginx:alpine 294 COPY dist/public/ /usr/share/nginx/html/ 295 COPY nginx.conf /etc/nginx/conf.d/default.conf 296 #+end_src 297 298 *** Visibility of deployed version 299 300 Build embeds =git rev-parse --short HEAD= into a =<meta>= tag and the page 301 footer. =git log <sha>..HEAD --oneline= locally shows what's pending. 302 303 ** Q9a — Time tracker 304 305 *Decision*: Defer entirely from MVP. Add as v1.1 with =timewarrior= as the 306 chosen tracker. 307 308 *Why*: Time tracker is the least-defined data source. Designing schema for an 309 unknown shape produces a model that fits no real tracker. Adding it later is a 310 single new collection plus a renderer extension. 311 312 ** Q9b — Visual identity 313 314 *Decision*: Editorial-typographic, dark default, monochrome with single warm 315 accent. Spaced cards in a 3-column desktop grid (single column on mobile). 316 Status colours are subtle hue shifts off the same palette, never traffic-light. 317 318 *** First-commit tokens 319 320 #+begin_src css 321 :root { 322 --bg: #0E0E0E; 323 --bg-elev: #161514; 324 --fg: #E8E4DA; 325 --fg-muted: #8A857B; 326 --rule: #2A2724; 327 --accent: #C8A971; 328 --status-active: #C8A971; 329 --status-idle: #8A857B; 330 --status-stuck: #C97A6A; 331 --status-done: #6A9B7A; 332 333 --font-serif: "Source Serif 4", Georgia, serif; 334 --font-sans: "Inter", system-ui, sans-serif; 335 --font-mono: "JetBrains Mono", ui-monospace, monospace; 336 337 --r-1: 0.5rem; 338 --w-card: 22rem; 339 } 340 #+end_src 341 342 * Repo layout 343 344 #+begin_example 345 KB-Dashboard/ 346 ├── src/ 347 │ ├── routes/ 348 │ │ ├── __root.tsx 349 │ │ ├── index.tsx # local dashboard (SSR) 350 │ │ └── public/ # prerendered subtree 351 │ │ ├── index.tsx 352 │ │ └── projects.$slug.tsx 353 │ └── server/ 354 │ ├── pocketbase.ts # PB SDK client 355 │ ├── public-shape.ts # privacy mapper 356 │ └── public-shape.test.ts # snapshot test 357 ├── scanner/ 358 │ ├── index.ts # entry 359 │ ├── git.ts 360 │ ├── rad.ts 361 │ └── tokei.ts 362 ├── pb_migrations/ # committed 363 ├── pb_hooks/ # committed 364 ├── pb_data/ # gitignored — local DB + uploads 365 ├── dist/ # gitignored — build output 366 ├── Dockerfile.public 367 ├── nginx.conf 368 ├── grilling-session.org 369 ├── ideas.org 370 └── package.json 371 #+end_example 372 373 * Implementation sequence 374 375 1. *Scaffold* — TanStack Start + Bun + PocketBase, both =bun run dev= and 376 =pocketbase serve= running, hello-world page reads from PB. 377 2. *Schema* — Define =projects=, =project_snapshots=, =scan_errors= collections 378 in PB admin UI; commit =pb_migrations/=. 379 3. *Scanner v1: git only* — Iterate over projects in PB, run =git log= per 380 project, write snapshots. Add =bun run scan= script. 381 4. *Scanner v2: tokei + rad* — Add LOC + issue counts. 382 5. *Dashboard read view* — Card grid using visual tokens, live data from PB. 383 6. *Manual refresh button* — Trigger scanner from the dashboard. 384 7. *launchd timer* — Schedule scanner every 30 min. 385 8. *Public mapper + test* — =public-shape.ts= + snapshot fixture. 386 9. *Public prerender* — =bun run build:public= outputs =dist/public/=. 387 10. *Grep guard* — =bun run guard:public=. 388 11. *Dockerfile + first deploy* — Push image to Dokploy registry, configure app 389 at =dashboard.brinon.eu=. 390 12. *Footer SHA + polish pass* — Sparklines, responsive layout, dark-mode-only 391 first. 392 393 * Deferred to v1.1+ 394 395 - Time tracker (=timewarrior= adapter) 396 - Annotations write-back from dashboard 397 - Auth on PocketBase (when something needs to write from outside =127.0.0.1=) 398 - Staging environment at =dashboard-staging.brinon.eu= 399 - Realtime PB subscriptions (live-update during scan) 400 - Remote metadata (GitHub stars, CI status) 401 - Project screenshots / OG images via PB file storage