/ 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