feat(v0.6): auto-group Projects by namespace + Active-now band + cross-device prefs

The flat Projects grid was overwhelming at 50-100 repos. Explored the UX with
4 parallel agents (auto-grouping / manual folders / density-segmentation /
competitive research); all converged on namespace auto-grouping as the primary
fix — the dotted repo names (Billo.Platform.*, Billo.Infrastructure.*) already
encode the hierarchy, so it's a string-split, not a taxonomy the user builds.

Frontend:
- groupProjects(): depth-2 namespace grouping, >=2-member threshold (singletons
  fall to "Other"), degrade-to-flat when repos share no prefix, running projects
  duplicated into a pinned "Active now" section.
- displayLabel(): cards show the namespace tail (Payment, not
  Billo.Platform.Payment); full name in Active/Other.
- Collapsible sticky group headers with an "N active" badge so a collapsed
  section never hides running work; search force-expands matching groups.
- public/prefs.ts: server-first load with a localStorage offline mirror, migrates
  the legacy per-device proj-favs.

Backend (server-side prefs so favourites + collapse-state follow the user across
devices — localStorage was per-device, which broke the multi-device premise):
- src/http/prefs-store.ts: ~/.web-terminal-prefs.json store (mirrors push-store),
  every field bounded + sanitized.
- GET /prefs (read-only) + PUT /prefs (Origin-guarded); UiPrefs type +
  PREFS_STORE_PATH config.

Tests: +12 prefs-store, +21 groupProjects/displayLabel. Full suite 1227 passed.
Verified live: grouping renders, collapse persists to the server and survives a
full page reload (cross-device sync proven end-to-end).
This commit is contained in:
Yaojia Wang
2026-07-01 04:25:57 +02:00
parent 7b3ba8491a
commit 03612323c0
10 changed files with 581 additions and 1 deletions

114
public/prefs.ts Normal file
View File

@@ -0,0 +1,114 @@
/**
* public/prefs.ts — client for the cross-device Projects UI prefs (GET/PUT /prefs).
*
* Favourites + group collapse-state live server-side (`~/.web-terminal-prefs.json`)
* so they follow the user from laptop to phone — the whole point of this app.
* localStorage is kept as an OFFLINE MIRROR: writes go there synchronously so a
* reload works while the server is briefly unreachable, and a device that had the
* legacy per-device favourites (`web-terminal:proj-favs`) is migrated on first load.
*
* loadPrefs/savePrefs are the only surface projects.ts needs. Both are defensive:
* a failed fetch degrades to the local mirror rather than throwing.
*/
import type { UiPrefs } from '../src/types.js'
const LS_KEY = 'web-terminal:proj-prefs'
const LEGACY_FAV_KEY = 'web-terminal:proj-favs' // pre-v0.6 per-device favourites
export function emptyPrefs(): UiPrefs {
return { favourites: [], collapsed: {} }
}
/** Coerce untrusted data (localStorage or a /prefs response) into a safe UiPrefs. */
export function sanitizePrefs(input: unknown): UiPrefs {
if (input === null || typeof input !== 'object') return emptyPrefs()
const o = input as Record<string, unknown>
const favourites: string[] = []
const seen = new Set<string>()
if (Array.isArray(o['favourites'])) {
for (const v of o['favourites']) {
if (typeof v !== 'string' || v.length === 0 || seen.has(v)) continue
seen.add(v)
favourites.push(v)
}
}
const collapsed: Record<string, boolean> = {}
const c = o['collapsed']
if (c !== null && typeof c === 'object' && !Array.isArray(c)) {
for (const [k, v] of Object.entries(c as Record<string, unknown>)) {
if (k.length > 0 && v === true) collapsed[k] = true
}
}
return { favourites, collapsed }
}
function isEmpty(p: UiPrefs): boolean {
return p.favourites.length === 0 && Object.keys(p.collapsed).length === 0
}
/** Read the local mirror, migrating legacy per-device favourites when present. */
function readLocal(): UiPrefs {
try {
const raw = localStorage.getItem(LS_KEY)
if (raw !== null) return sanitizePrefs(JSON.parse(raw))
} catch {
// fall through to legacy / empty
}
try {
const legacy = localStorage.getItem(LEGACY_FAV_KEY)
if (legacy !== null) {
const arr: unknown = JSON.parse(legacy)
if (Array.isArray(arr)) return sanitizePrefs({ favourites: arr, collapsed: {} })
}
} catch {
// ignore — corrupt legacy data
}
return emptyPrefs()
}
function writeLocal(prefs: UiPrefs): void {
try {
localStorage.setItem(LS_KEY, JSON.stringify(prefs))
} catch {
// localStorage unavailable — run without an offline mirror
}
}
/** Load prefs: server is source of truth; fall back to the local mirror offline.
* When the server is empty but this device has local/legacy prefs, seed the server. */
export async function loadPrefs(): Promise<UiPrefs> {
const local = readLocal()
try {
const res = await fetch('/prefs')
if (res.ok) {
const server = sanitizePrefs(await res.json())
if (isEmpty(server) && !isEmpty(local)) {
void savePrefs(local) // migrate this device's prefs up to the server
return local
}
writeLocal(server) // refresh the offline mirror
return server
}
} catch {
// offline — use the local mirror
}
return local
}
/** Persist prefs: write the local mirror synchronously, then PUT best-effort. */
export async function savePrefs(prefs: UiPrefs): Promise<void> {
writeLocal(prefs)
try {
await fetch('/prefs', {
method: 'PUT',
headers: { 'content-type': 'application/json' },
body: JSON.stringify(prefs),
})
} catch {
// offline — the local mirror keeps the change until the next successful PUT
}
}