From ce59bdfbec837b498fb85d66788a54cfe865200d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nguy=E1=BB=85n=20V=C4=83n=20Rum=20=28VSF-QTVHNTDVX-PMGTCC?= =?UTF-8?q?=29?= Date: Sun, 7 Jun 2026 02:00:24 +0700 Subject: [PATCH] =?UTF-8?q?feat(web):=20Grove=20=E2=80=94=20contributor=20?= =?UTF-8?q?graph,=20leaderboard,=20i18n=20&=20fonts=20(v0.8.0)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 6 "The Grove": a second-brain view of the repo's shared memory plus a full Vietnamese localization and a Vietnamese-friendly type system. - Grove tab: d3-force + canvas graph (root/trunk + branches + contributors + facts), bamboo-themed, hover/click-to-drill into Branch detail. - Contributor leaderboard with weighted value score (pins x1, graduated x3) and playful badges/streaks (Gardener of the week, Most rooted, Longest streak, First sprout). - Shareable grove card (canvas -> PNG) and a growth time-lapse scrubber. - Solo/unshared fallback: contributors backfilled from `git log` authors. - New read-only endpoints /api/contributors and /api/graph, fed by a read-only .tre-mem/ JSONL reader (author already lives in the committed JSONL — no schema migration). - i18n: EN/VI toggle (persisted, auto-detect), casual/fun Vietnamese voice across every surface; localized timeAgo. - Type system: Baloo 2 (cute rounded display) + Be Vietnam Pro (VN-native body) with VN-capable system fallbacks; fixes diacritic stacking on the old serif. - Fix: .shell grid had 3 children but 2 rows — the nav row ballooned on low-content pages. Now `auto auto 1fr` so main absorbs the slack. Tests: sync-read + web-grove (pure aggregators + HTTP). 326 passing. Co-Authored-By: Claude Opus 4.8 (1M context) --- CHANGELOG.md | 29 +++ PLAN-PHASE6.md | 48 +++++ PLAN.md | 4 +- docs/WEB-UI.md | 11 ++ package.json | 4 +- pnpm-lock.yaml | 39 ++++ scripts/build-web.mjs | 6 + src/git/identity.ts | 27 +++ src/sync/read.ts | 56 ++++++ src/version.ts | 2 +- src/web/api.ts | 97 ++++++++++ src/web/grove.ts | 368 +++++++++++++++++++++++++++++++++++++ test/sync-read.test.ts | 101 ++++++++++ test/web-grove.test.ts | 201 ++++++++++++++++++++ web/app.tsx | 50 ++++- web/i18n.tsx | 334 +++++++++++++++++++++++++++++++++ web/index.tsx | 5 +- web/lib.tsx | 80 +++++++- web/styles.css | 339 ++++++++++++++++++++++++++++++++-- web/views/BranchDetail.tsx | 48 ++--- web/views/GraphCanvas.tsx | 238 ++++++++++++++++++++++++ web/views/Grove.tsx | 193 +++++++++++++++++++ web/views/Leaderboard.tsx | 100 ++++++++++ web/views/Overview.tsx | 74 ++++++-- web/views/Search.tsx | 24 +-- web/views/ShareCard.tsx | 121 ++++++++++++ web/views/TeamMemory.tsx | 45 ++--- 27 files changed, 2543 insertions(+), 101 deletions(-) create mode 100644 PLAN-PHASE6.md create mode 100644 src/sync/read.ts create mode 100644 src/web/grove.ts create mode 100644 test/sync-read.test.ts create mode 100644 test/web-grove.test.ts create mode 100644 web/i18n.tsx create mode 100644 web/views/GraphCanvas.tsx create mode 100644 web/views/Grove.tsx create mode 100644 web/views/Leaderboard.tsx create mode 100644 web/views/ShareCard.tsx diff --git a/CHANGELOG.md b/CHANGELOG.md index 331c8b9..1a2fdf2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,35 @@ All notable changes to **tre-mem** are documented here. The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [0.8.0] — 2026-06-07 + +**The Grove — a second-brain contributor graph for your repo's memory.** A new +`tre web` tab visualizes the team's shared knowledge as an Obsidian-style force +graph (trunk + branches + contributors + facts) and ranks who grew it. + +### Added + +- **Grove tab** in the dashboard: an interactive `d3-force` + canvas graph of the + repo's shared memory. The project trunk (`root`), each branch, each contributor, + and each fact (pins = young shoots, graduated facts = mature culms) are nodes; + edges wire authorship, where a fact lives, and how branches graduate into the + trunk. Bamboo palette driven by the existing theme tokens (light/dark aware). + Hover for a tooltip; click a branch/fact to drill into Branch detail, click a + contributor to highlight their shoots. +- **Contributor leaderboard** with a weighted value score (branch-local pins ×1, + graduated/rooted facts ×3) plus playful badges & streaks — Gardener of the week, + Most rooted, Longest streak, First sprout. +- **Shareable grove card**: one click exports a bamboo-framed PNG of your grove's + headline stats — the viral, post-it-anywhere artifact. +- **Growth time-lapse**: scrub or play back how the grove grew over time. +- **Solo/unshared fallback**: when nothing has been shared yet, the grove backfills + contributors from `git log` authors per branch (flagged as `git-fallback`) so a + fresh repo is never empty. Toggle with `?fallback=` on the new endpoints. +- New read-only endpoints `GET /api/contributors` and `GET /api/graph` + (project-scoped), plus a read-only `.tre-mem/` JSONL reader (`src/sync/read.ts`) + — contributor attribution lives in the committed JSONL `author` field, so no + schema migration was needed. + ## [0.7.1] — 2026-06-06 **Removed the GitHub Action — graduation is now fully vendor-neutral.** The diff --git a/PLAN-PHASE6.md b/PLAN-PHASE6.md new file mode 100644 index 0000000..f8738a4 --- /dev/null +++ b/PLAN-PHASE6.md @@ -0,0 +1,48 @@ +# tre-mem Phase 6 — "The Grove" (contributor graph, leaderboard & viral share) + +> Sibling plan to [`PLAN.md`](./PLAN.md) (v0.1), [`PLAN-PHASE2.md`](./PLAN-PHASE2.md) (v0.2–v0.4), +> [`PLAN-PHASE3.md`](./PLAN-PHASE3.md) (v0.5), [`PLAN-PHASE4.md`](./PLAN-PHASE4.md) (v0.6), +> [`PLAN-PHASE5.md`](./PLAN-PHASE5.md) (v0.7.x). This file is the **Phase 6 SSOT** (v0.8.0). + +## Context + +Through v0.7, tre-mem makes a team's git-shared memory _legible_ (`tre web`: Overview / Team +memory / Search). Phase 6 makes it **emotional and viral**: a second-brain, Obsidian-style force +graph of the repo — the trunk (graduated repo-wide facts), the branches, and the **contributors** +who grew them — plus a "best contributor" leaderboard with playful bamboo badges and a one-click +shareable grove card. + +**Why it was feasible with no migration:** every committed `.tre-mem/` JSONL record +(`PinRecord` / `GraduatedRecord` in `src/sync/format.ts`) already carries `author`, populated from +`git config user.name` at `tre share` / graduate time. The sidecar DB has no author column and the +web layer read only the DB — so the new feature reads the JSONL directly via a new read-only reader. + +**Decisions locked with owner:** ship all four surfaces (graph, leaderboard, share card, time-lapse); +solo/unshared repos fall back to `git log` authors so the grove is never empty; full gamification +(value score + badges & streaks). + +## Tasks + +- [x] **Read-only JSONL reader** — `src/sync/read.ts` (`readSyncRecords` / `readSyncDir`), reusing + `parseSyncLine` + layout helpers. Test: `test/sync-read.test.ts`. +- [x] **Grove core** — `src/web/grove.ts`: `aggregateContributors` (value score = pins×1 + + graduated×3, badges/streaks), `buildGraph`, `buildGitContributors` + `buildGitGraph` (fallback). + `branchAuthors()` git helper in `src/git/identity.ts`. Test: `test/web-grove.test.ts`. +- [x] **Endpoints** — `GET /api/contributors` and `GET /api/graph` added to `ROUTES` in + `src/web/api.ts`; project-scoped; `?fallback=git` (default on) when nothing is attributed. +- [x] **Grove tab + leaderboard** — `web/lib.tsx` types, `web/app.tsx` tab, `web/views/Grove.tsx` + + `Leaderboard.tsx` (badges, drill into Branch detail). +- [x] **Graph canvas** — `web/views/GraphCanvas.tsx`: `d3-force` simulation + HTML canvas, theme + tokens, bamboo sizing, hover tooltip, click-to-drill, `cutoffEpoch` for time-lapse. +- [x] **Share card + time-lapse** — `web/views/ShareCard.tsx` (canvas → PNG) and the time-lapse + scrubber/playback in `Grove.tsx`. +- [x] **Docs + release** — `docs/WEB-UI.md` (Grove tab + endpoints), `CHANGELOG.md` `[0.8.0]`, + version bump `package.json` + `src/version.ts` → `0.8.0`. + +## Notes + +- Graph rendering uses `d3-force` (force sim only) + a hand-rolled canvas renderer — chosen over + cytoscape (~350 KB) and react-force-graph-2d (~80–120 KB) to honor the dependency-light ethos. + Bundle add ≈ 13 KB; SPA raw ≈ 243 KB. +- Edge kinds: `authored` (contributor→fact), `lives_on` (fact→branch), `graduates_into` + (branch→root), `committed` (contributor→branch, git-fallback only). diff --git a/PLAN.md b/PLAN.md index e319ab2..24d853d 100644 --- a/PLAN.md +++ b/PLAN.md @@ -9,7 +9,8 @@ > [`PLAN-PHASE2.md`](./PLAN-PHASE2.md) — Phase 2 (v0.2–v0.4.x, git-native team share) · > [`PLAN-PHASE3.md`](./PLAN-PHASE3.md) — Phase 3 (v0.5.x, team web dashboard) · > [`PLAN-PHASE4.md`](./PLAN-PHASE4.md) — Phase 4 (v0.6.x, cross-tool port) · -> [`PLAN-PHASE5.md`](./PLAN-PHASE5.md) — Phase 5 (v0.7.x, "Share, made obvious"). Current: **v0.7.0**. +> [`PLAN-PHASE5.md`](./PLAN-PHASE5.md) — Phase 5 (v0.7.x, "Share, made obvious") · +> [`PLAN-PHASE6.md`](./PLAN-PHASE6.md) — Phase 6 (v0.8.x, "The Grove" — contributor graph). Current: **v0.8.0**. --- @@ -386,3 +387,4 @@ Parser: `cac` (nhẹ, đủ). - **2026-05-31** — T2D9 (A/B harness) done. `scripts/benchmark.mjs` runs a fixed 10-query set against both **(A) `Fts5SemanticSearcher` baseline** (the closest in-process proxy for raw `claude-mem.search` since claude-mem itself indexes through the same FTS5 table) and **(B) `searchBranchContext`** (full 3-signal rerank), computes `precision@K_branch` (fraction of top-K whose `branch_tag.branch == target`), `Jaccard(A,B)` of top-K id sets, and the B-top-1 score breakdown — then writes `BENCHMARK.md` with per-query table + aggregate. Live numbers on the tre-mem repo (project=`tre-mem`, branch=`main`, K=10, 22 branch-tagged observations): **mean A precision@10 = 0.190, mean B precision@10 = 0.970, mean Jaccard = 0.134**. Interpretation: tre-mem concentrates branch-tagged observations at the top (0.19 → 0.97) and actually re-orders results (low Jaccard = rerank is not a no-op). Single-branch caveat is annotated in the report: precision here measures "fraction branch-tagged in top-K"; once a second branch is backfilled the same metric also captures cross-branch precision. Two T2D9 sub-tasks deferred: (1) `mcpServers.tre-mem` registration into `~/.claude.json` — Claude Code auto-mode blocked the edit (self-modification of CC config); proposed entry is `{type:"stdio", command:"node", args:["/dist/cli.js", "mcp"], env:{}}`, manual `~/.claude.json` backup already captured at `~/.claude.json.bak.tre-mem-1780243463`; (2) live two-branch E2E pending real multi-branch dataset. Lint + typecheck clean. - **2026-05-31** — T2D10 polish (docs + version) done. Added `README.md` (one-liner + why + 3-step install + MCP registration via `claude mcp add -s user tre-mem -- tre mcp` + SessionStart hook + CLI surface + MCP tool table + architecture diagram + status), `CHANGELOG.md` in Keep-a-Changelog format with a single `[0.1.0] — 2026-05-31` entry covering sidecar store, read-only adapter, branch resolver/watcher, reflog backfill, 3-signal rerank, MCP server, SessionStart hook, CLI, benchmark harness, docs. Added MIT `LICENSE`. Bumped `package.json` version `0.0.0 → 0.1.0` and synced `getPackageVersion()` in `src/cli.ts` so `tre --version` prints `tre/0.1.0`. All quality gates remain green (typecheck + lint + 96/96 tests + build). `npm publish v0.1.0`, demo screen-record, and external-dev validation deliberately deferred to user action — those require credentials / hands / a second human and cannot be automated from this session. - **2026-05-31** — T2D8 done. `src/mcp/tools.ts` exposes 5 pure tool functions (`getBranchContext`, `getBranchTimeline`, `listBranches`, `pinFact`, `graduateFact`) plus a `callTool(name, args)` dispatcher and a frozen `TOOL_DEFINITIONS` array carrying name/description/JSON-Schema for each. Tools resolve `project`/`branch` defaults via injectable `ToolDeps.defaultCwd` + `ToolDeps.resolveBranch` (defaults to `process.cwd()` + `currentBranch()`), and `now` is injectable for deterministic tests. `src/mcp/server.ts` uses the lower-level `@modelcontextprotocol/sdk` `Server` (not `McpServer`) because we don't want a zod dependency: `ListToolsRequestSchema` returns `TOOL_DEFINITIONS` 1:1; `CallToolRequestSchema` dispatches to `callTool`, JSON-stringifies into `content[0].text` AND mirrors structured into `structuredContent` so both old + new MCP clients work, and converts thrown errors to `{isError: true}`. `runMcpServer()` runs `migrate()`, opens adapter + repo, wires `StdioServerTransport`, and closes handles on SIGINT/SIGTERM. CLI gains `tre mcp` command. **Live MCP handshake smoke (real `~/.tre-mem/tre-mem.db`)**: `initialize` → `notifications/initialized` → `tools/list` returns all 5 tool descriptors; `tools/call list_branches {project:"tre-mem"}` returns `{branches: [{branch:"main", count:22}]}` — i.e. the same 22-tag count `tre status` shows, proving the full stack works through stdio. 10 new tests (`mcp-tools.test.ts` × 9, `mcp-server.test.ts` × 1); suite 96/96 green; lint + typecheck + build clean. +- **2026-06-07** — **Phase 6 "The Grove" (v0.8.0) done.** New `tre web` Grove tab: an Obsidian-style `d3-force` + canvas graph of the repo's shared memory (root/trunk + branches + contributors + facts) beside a contributor leaderboard. Built on the realization that contributor identity already lives in the committed `.tre-mem/` JSONL `author` field (set at `tre share`/graduate time) — so a new read-only reader (`src/sync/read.ts`) feeds pure aggregators in `src/web/grove.ts` (value score = pins×1 + graduated×3, plus Gardener-of-the-week / Most-rooted / Longest-streak / First-sprout badges) with **no schema migration**. Two read-only endpoints `/api/contributors` + `/api/graph` (project-scoped, `?fallback=git` defaulting on so solo/unshared repos backfill contributors from `git log` authors). Frontend: `Grove.tsx` + `Leaderboard.tsx` + `GraphCanvas.tsx` (theme-token colors, bamboo node sizing, hover/click drill into Branch detail) + `ShareCard.tsx` (canvas→PNG) + growth time-lapse scrubber. `d3-force` (sim only) + hand-rolled canvas chosen over cytoscape/react-force-graph to stay dependency-light (~13 KB add). Tests: `test/sync-read.test.ts`, `test/web-grove.test.ts`. Version → 0.8.0; CHANGELOG `[0.8.0]`; SSOT `PLAN-PHASE6.md`. diff --git a/docs/WEB-UI.md b/docs/WEB-UI.md index c90695c..f0bc3fb 100644 --- a/docs/WEB-UI.md +++ b/docs/WEB-UI.md @@ -49,6 +49,15 @@ in `~/.tre-mem/web.pid` (self-healing: a stale pidfile is cleared automatically) - **Overview** — a branch graph: every branch with its tagged-observation count, pin count, last-active time, and the current `HEAD` highlighted. Click a branch to drill in. +- **Grove** — the second-brain view: an Obsidian-style force graph of the repo's + shared memory (the project trunk + branches + contributors + facts) beside a + contributor leaderboard. Facts are sized as young shoots (pins) and mature culms + (graduated facts); contributor node size tracks their value score (pins ×1, + graduated ×3). Earns playful badges (Gardener of the week, Most rooted, Longest + streak, First sprout), exports a shareable PNG card, and replays grove growth + over time. Hover for tooltips; click a branch/fact to drill into **Branch + detail**, click a contributor to highlight their shoots. When nothing has been + shared yet, it falls back to `git log` authors so the grove is never empty. - **Branch detail** — pinned decisions, facts graduated from that branch, and the tagged activity timeline. - **Team memory** — the point of the dashboard: every pinned decision and @@ -89,6 +98,8 @@ The SPA is served over a dependency-light `node:http` server. Endpoints, all | `/api/pins` | all pins for the project | | `/api/graduated` | all graduated facts for the project | | `/api/share-status` | pending export / shared / graduated counts | +| `/api/contributors` | contributor leaderboard (value score + badges) | +| `/api/graph` | force-graph nodes + edges for the Grove view | | `/api/search?q=&branch=` | branch-aware search (full or degraded) | | `/api/observation/:id` | observation detail (`full` mode only) | | `/api/events` | SSE live-update stream | diff --git a/package.json b/package.json index df4178f..4b1ac0c 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "tre-mem", - "version": "0.7.1", + "version": "0.8.0", "description": "Branch-aware shared memory layer for AI coding tools. Sidecar on top of claude-mem.", "keywords": [ "claude-code", @@ -53,11 +53,13 @@ "cac": "^7.0.0", "chokidar": "^4.0.3", "chromadb": "^3.4.3", + "d3-force": "^3.0.0", "picocolors": "^1.1.1", "simple-git": "^3.36.0" }, "devDependencies": { "@types/better-sqlite3": "^7.6.13", + "@types/d3-force": "^3.0.10", "@types/node": "^22.10.0", "@types/react": "^19.2.16", "@types/react-dom": "^19.2.3", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e6a4cea..eaa6a1c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -23,6 +23,9 @@ importers: chromadb: specifier: ^3.4.3 version: 3.4.3 + d3-force: + specifier: ^3.0.0 + version: 3.0.0 picocolors: specifier: ^1.1.1 version: 1.1.1 @@ -33,6 +36,9 @@ importers: '@types/better-sqlite3': specifier: ^7.6.13 version: 7.6.13 + '@types/d3-force': + specifier: ^3.0.10 + version: 3.0.10 '@types/node': specifier: ^22.10.0 version: 22.19.19 @@ -434,6 +440,9 @@ packages: '@types/chai@5.2.3': resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==} + '@types/d3-force@3.0.10': + resolution: {integrity: sha512-ZYeSaCF3p73RdOKcjj+swRlZfnYpK1EbaDiYICEEp5Q6sUiqFaFQ9qgoshp5CzIyyb/yD09kD9o2zEltCexlgw==} + '@types/deep-eql@4.0.2': resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==} @@ -703,6 +712,22 @@ packages: csstype@3.2.3: resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} + d3-dispatch@3.0.1: + resolution: {integrity: sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg==} + engines: {node: '>=12'} + + d3-force@3.0.0: + resolution: {integrity: sha512-zxV/SsA+U4yte8051P4ECydjD/S+qeYtnaIyAs9tgHCqfguma/aAQDjo85A9Z6EKhBirHRJHXIgJUlffT4wdLg==} + engines: {node: '>=12'} + + d3-quadtree@3.0.1: + resolution: {integrity: sha512-04xDrxQTDTCFwP5H6hRhsRcb9xxv2RzkcsygFzmkSIOJy3PeRJP7sNk3VRIbKXcog561P9oU0/rVH6vDROAgUw==} + engines: {node: '>=12'} + + d3-timer@3.0.1: + resolution: {integrity: sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==} + engines: {node: '>=12'} + debug@4.4.3: resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} engines: {node: '>=6.0'} @@ -1818,6 +1843,8 @@ snapshots: '@types/deep-eql': 4.0.2 assertion-error: 2.0.1 + '@types/d3-force@3.0.10': {} + '@types/deep-eql@4.0.2': {} '@types/esrecurse@4.3.1': {} @@ -2115,6 +2142,18 @@ snapshots: csstype@3.2.3: {} + d3-dispatch@3.0.1: {} + + d3-force@3.0.0: + dependencies: + d3-dispatch: 3.0.1 + d3-quadtree: 3.0.1 + d3-timer: 3.0.1 + + d3-quadtree@3.0.1: {} + + d3-timer@3.0.1: {} + debug@4.4.3: dependencies: ms: 2.1.3 diff --git a/scripts/build-web.mjs b/scripts/build-web.mjs index bb42dba..cef60b0 100644 --- a/scripts/build-web.mjs +++ b/scripts/build-web.mjs @@ -34,6 +34,12 @@ const html = ` tre · shared roots + + + diff --git a/src/git/identity.ts b/src/git/identity.ts index f776b89..6d709ee 100644 --- a/src/git/identity.ts +++ b/src/git/identity.ts @@ -14,3 +14,30 @@ export async function gitAuthor(cwd: string): Promise { return null; } } + +/** + * Commit authors on a branch, most-frequent first, with their commit counts. + * Used as the solo/unshared fallback for the Grove view: when nothing has been + * shared yet, contributors are inferred from `git log --format=%an`. Best-effort + * — returns `[]` on any failure (no repo, unknown branch). Never throws. + */ +export async function branchAuthors( + cwd: string, + branch: string, +): Promise> { + if (!existsSync(cwd)) return []; + try { + const raw = await simpleGit(cwd).raw(['log', '--format=%an', branch]); + const counts = new Map(); + for (const line of raw.split('\n')) { + const name = line.trim(); + if (name === '') continue; + counts.set(name, (counts.get(name) ?? 0) + 1); + } + return [...counts.entries()] + .map(([name, commits]) => ({ name, commits })) + .sort((a, b) => b.commits - a.commits || a.name.localeCompare(b.name)); + } catch { + return []; + } +} diff --git a/src/sync/read.ts b/src/sync/read.ts new file mode 100644 index 0000000..1e27ad3 --- /dev/null +++ b/src/sync/read.ts @@ -0,0 +1,56 @@ +import { existsSync, readFileSync, readdirSync } from 'node:fs'; +import { join } from 'node:path'; + +import { parseSyncLine, type GraduatedRecord, type PinRecord, type SyncRecord } from './format.js'; +import { graduatedFilePath } from './layout.js'; + +/** + * Enumerate the JSONL files in a committed `.tre-mem/` directory: the single + * `graduated.jsonl` plus every `branches/*.jsonl`, in a stable sorted order. + */ +function listJsonlFiles(dir: string): string[] { + const files: string[] = []; + const graduated = graduatedFilePath(dir); + if (existsSync(graduated)) files.push(graduated); + const branchesDir = join(dir, 'branches'); + if (existsSync(branchesDir)) { + for (const name of readdirSync(branchesDir).sort()) { + if (name.endsWith('.jsonl')) files.push(join(branchesDir, name)); + } + } + return files; +} + +/** + * Read every shared pin + graduated record out of a committed `.tre-mem/` + * directory. Read-only counterpart to `importDir` (no DB writes, no SHA state): + * the web layer uses this to surface the `author` field — which lives only in + * the JSONL, never in the sidecar DB. Malformed / newer-schema lines are skipped + * rather than aborting the read. A missing directory yields an empty list. + */ +export function readSyncRecords(dir: string): SyncRecord[] { + if (!existsSync(dir)) return []; + const records: SyncRecord[] = []; + for (const filePath of listJsonlFiles(dir)) { + for (const line of readFileSync(filePath, 'utf8').split('\n')) { + if (line.trim() === '') continue; + try { + records.push(parseSyncLine(line)); + } catch { + /* skip unparseable / newer-schema lines, same as importDir */ + } + } + } + return records; +} + +/** Same read, split into the two record kinds for callers that want them apart. */ +export function readSyncDir(dir: string): { pins: PinRecord[]; graduated: GraduatedRecord[] } { + const pins: PinRecord[] = []; + const graduated: GraduatedRecord[] = []; + for (const record of readSyncRecords(dir)) { + if (record.kind === 'pin') pins.push(record); + else graduated.push(record); + } + return { pins, graduated }; +} diff --git a/src/version.ts b/src/version.ts index c848e02..a42d45e 100644 --- a/src/version.ts +++ b/src/version.ts @@ -4,4 +4,4 @@ * Imported by the CLI (`tre --version`), the MCP server handshake, and the * session digest's compatibility warnings. */ -export const VERSION = '0.7.1'; +export const VERSION = '0.8.0'; diff --git a/src/web/api.ts b/src/web/api.ts index 978408d..a5b7c51 100644 --- a/src/web/api.ts +++ b/src/web/api.ts @@ -2,10 +2,19 @@ import { existsSync } from 'node:fs'; import { join } from 'node:path'; import type { Observation } from '../adapter/types.js'; +import { branchAuthors } from '../git/identity.js'; import { searchBranchContext } from '../retrieval/search.js'; import type { BranchPin, Graduated, TreMemRepo } from '../store/repo.js'; import { SYNC_DIR_NAME } from '../sync/layout.js'; +import { readSyncRecords } from '../sync/read.js'; +import { + aggregateContributors, + buildGitContributors, + buildGitGraph, + buildGraph, + type BranchAuthors, +} from './grove.js'; import { sendError, sendJson } from './respond.js'; import { type Route, type RouteCtx, webMode } from './types.js'; @@ -68,6 +77,27 @@ function snippet(text: string | null, max = 200): string | null { return flat.length > max ? `${flat.slice(0, max - 1)}…` : flat; } +/** `?fallback=git` (default on) lets solo/unshared repos light up via git authors. */ +function fallbackEnabled(ctx: RouteCtx): boolean { + const q = ctx.query.get('fallback'); + return q === null || q === '' || q === 'git' || q === '1' || q === 'true'; +} + +/** Commit authors for every branch tre-mem knows about — the git-fallback source. */ +async function gitBranchAuthors( + repo: TreMemRepo, + cwd: string, + project: string, +): Promise { + const branches = repo.listBranchesForProject(project).map((b) => b.branch); + const out: BranchAuthors[] = []; + for (const branch of branches) { + const authors = await branchAuthors(cwd, branch); + if (authors.length > 0) out.push({ branch, authors }); + } + return out; +} + const ROUTES: Route[] = [ { method: 'GET', @@ -243,6 +273,73 @@ const ROUTES: Route[] = [ }); }, }, + { + method: 'GET', + pattern: '/api/contributors', + handler: async (_req, res, ctx) => { + const project = resolveProject(ctx); + const dir = join(ctx.deps.cwd, SYNC_DIR_NAME); + const records = readSyncRecords(dir); + const { contributors, attributed_total, unattributed_total } = aggregateContributors( + records, + project, + ctx.deps.now(), + ); + if (attributed_total === 0 && fallbackEnabled(ctx)) { + const perBranch = await gitBranchAuthors(ctx.deps.repo, ctx.deps.cwd, project); + if (perBranch.length > 0) { + return sendJson(res, 200, { + project, + source: 'git-fallback', + contributors: buildGitContributors(perBranch, ctx.deps.now()), + attributed_total: 0, + unattributed_total, + has_sync_dir: existsSync(dir), + }); + } + } + return sendJson(res, 200, { + project, + source: 'shared', + contributors, + attributed_total, + unattributed_total, + has_sync_dir: existsSync(dir), + }); + }, + }, + { + method: 'GET', + pattern: '/api/graph', + handler: async (_req, res, ctx) => { + const project = resolveProject(ctx); + const dir = join(ctx.deps.cwd, SYNC_DIR_NAME); + const records = readSyncRecords(dir); + const hasFacts = records.some((r) => r.project === project); + if (!hasFacts && fallbackEnabled(ctx)) { + const perBranch = await gitBranchAuthors(ctx.deps.repo, ctx.deps.cwd, project); + if (perBranch.length > 0) { + const { nodes, edges } = buildGitGraph(perBranch, project); + return sendJson(res, 200, { + project, + source: 'git-fallback', + nodes, + edges, + has_sync_dir: existsSync(dir), + }); + } + } + const extraBranches = ctx.deps.repo.listBranchesForProject(project).map((b) => b.branch); + const { nodes, edges } = buildGraph(records, extraBranches, project); + return sendJson(res, 200, { + project, + source: 'shared', + nodes, + edges, + has_sync_dir: existsSync(dir), + }); + }, + }, ]; /** diff --git a/src/web/grove.ts b/src/web/grove.ts new file mode 100644 index 0000000..837966a --- /dev/null +++ b/src/web/grove.ts @@ -0,0 +1,368 @@ +import type { SyncRecord } from '../sync/format.js'; + +/** + * Grove = the contributor view of a repo's shared memory. Pure, HTTP-free + * helpers so the aggregation, scoring, and graph-building can be unit-tested + * directly. Contributor identity lives only in the committed `.tre-mem/` JSONL + * (`author` per record) — never in the sidecar DB — so everything here reads + * `SyncRecord`s produced by `readSyncRecords`. + */ + +/** Branch-local pins count 1×; graduated (repo-wide, survived a merge) count 3×. */ +export const PIN_WEIGHT = 1; +export const GRADUATED_WEIGHT = 3; + +const WEEK_SECONDS = 7 * 24 * 60 * 60; +const DAY_SECONDS = 24 * 60 * 60; + +/** Bucket label for records whose author was never recorded (shared pre-attribution). */ +export const UNATTRIBUTED = '(unattributed)'; + +export type BadgeId = 'gardener_of_week' | 'most_rooted' | 'longest_streak' | 'first_sprout'; + +export type GroveSource = 'shared' | 'git-fallback'; + +export interface ContributorStat { + author: string; + /** false only for the synthetic UNATTRIBUTED bucket. */ + attributed: boolean; + pins: number; + graduated: number; + facts_total: number; + /** Commits on touched branches — populated only in git-fallback mode. */ + commits: number; + branches: string[]; + branches_touched: number; + first_activity_epoch: number; + last_activity_epoch: number; + /** Longest run of consecutive UTC days with ≥1 contribution. */ + streak_days: number; + value_score: number; + breakdown: { pin: number; graduated: number }; + badges: BadgeId[]; +} + +export interface ContributorsResponse { + project: string; + source: GroveSource; + contributors: ContributorStat[]; + attributed_total: number; + unattributed_total: number; + has_sync_dir: boolean; +} + +export type GraphNodeKind = 'root' | 'branch' | 'contributor' | 'fact'; + +export interface GraphNode { + id: string; + kind: GraphNodeKind; + label: string; + factKind?: 'pin' | 'graduated'; + author?: string | null; + branch?: string; + observation_id?: number | null; + epoch?: number; + /** Relative sizing hint: contributor = value_score, branch = fact/commit count. */ + weight?: number; +} + +export interface GraphEdge { + source: string; + target: string; + kind: 'authored' | 'lives_on' | 'graduates_into' | 'committed'; +} + +export interface GraphResponse { + project: string; + source: GroveSource; + nodes: GraphNode[]; + edges: GraphEdge[]; + has_sync_dir: boolean; +} + +interface Acc { + author: string; + attributed: boolean; + pins: number; + graduated: number; + branches: Set; + epochs: number[]; +} + +function recordEpoch(r: SyncRecord): number { + return r.kind === 'pin' ? r.tagged_at_epoch : r.graduated_at_epoch; +} + +function recordBranch(r: SyncRecord): string { + return r.kind === 'pin' ? r.branch : r.graduated_from_branch; +} + +/** Longest run of consecutive UTC days that appears in the epoch list. */ +function longestStreakDays(epochs: number[]): number { + if (epochs.length === 0) return 0; + const days = [...new Set(epochs.map((e) => Math.floor(e / DAY_SECONDS)))].sort((a, b) => a - b); + let best = 1; + let run = 1; + for (let i = 1; i < days.length; i += 1) { + run = days[i] === (days[i - 1] as number) + 1 ? run + 1 : 1; + if (run > best) best = run; + } + return best; +} + +/** + * Roll up the shared records into a per-author leaderboard, then assign the + * playful "awards" (each badge goes to at most one contributor). Sorted by + * value_score desc, tie-broken by most-recent activity. + */ +export function aggregateContributors( + records: SyncRecord[], + project: string, + now: number, +): { contributors: ContributorStat[]; attributed_total: number; unattributed_total: number } { + const scoped = records.filter((r) => r.project === project); + const accs = new Map(); + let attributed = 0; + let unattributed = 0; + + for (const r of scoped) { + const attributedRow = r.author !== null && r.author.trim() !== ''; + if (attributedRow) attributed += 1; + else unattributed += 1; + const author = attributedRow ? (r.author as string) : UNATTRIBUTED; + let acc = accs.get(author); + if (!acc) { + acc = { + author, + attributed: attributedRow, + pins: 0, + graduated: 0, + branches: new Set(), + epochs: [], + }; + accs.set(author, acc); + } + if (r.kind === 'pin') acc.pins += 1; + else acc.graduated += 1; + acc.branches.add(recordBranch(r)); + acc.epochs.push(recordEpoch(r)); + } + + const contributors: ContributorStat[] = [...accs.values()].map((a) => { + const value_score = a.pins * PIN_WEIGHT + a.graduated * GRADUATED_WEIGHT; + return { + author: a.author, + attributed: a.attributed, + pins: a.pins, + graduated: a.graduated, + facts_total: a.pins + a.graduated, + commits: 0, + branches: [...a.branches].sort((x, y) => x.localeCompare(y)), + branches_touched: a.branches.size, + first_activity_epoch: Math.min(...a.epochs), + last_activity_epoch: Math.max(...a.epochs), + streak_days: longestStreakDays(a.epochs), + value_score, + breakdown: { pin: a.pins, graduated: a.graduated }, + badges: [], + }; + }); + + assignBadges(contributors, accs, now); + contributors.sort( + (a, b) => b.value_score - a.value_score || b.last_activity_epoch - a.last_activity_epoch, + ); + return { contributors, attributed_total: attributed, unattributed_total: unattributed }; +} + +function assignBadges(contributors: ContributorStat[], accs: Map, now: number): void { + const eligible = contributors.filter((c) => c.attributed); + if (eligible.length === 0) return; + const byAuthor = new Map(contributors.map((c) => [c.author, c])); + const award = (winner: ContributorStat | undefined, badge: BadgeId): void => { + if (winner) winner.badges.push(badge); + }; + + // Gardener of the week — most contributions in the last 7 days. + const weeklyScore = new Map(); + for (const acc of accs.values()) { + if (!acc.attributed) continue; + const recent = acc.epochs.filter((e) => now - e <= WEEK_SECONDS).length; + if (recent > 0) weeklyScore.set(acc.author, recent); + } + const topWeekly = [...weeklyScore.entries()].sort((a, b) => b[1] - a[1])[0]; + award(topWeekly ? byAuthor.get(topWeekly[0]) : undefined, 'gardener_of_week'); + + // Most rooted — most graduated facts (must have at least one). + const rooted = eligible + .filter((c) => c.graduated > 0) + .sort((a, b) => b.graduated - a.graduated || b.value_score - a.value_score)[0]; + award(rooted, 'most_rooted'); + + // Longest streak — must be a real run (≥2 days). + const streak = [...eligible].sort((a, b) => b.streak_days - a.streak_days)[0]; + if (streak && streak.streak_days >= 2) award(streak, 'longest_streak'); + + // First sprout — earliest contributor. + const firstSprout = [...eligible].sort( + (a, b) => a.first_activity_epoch - b.first_activity_epoch, + )[0]; + award(firstSprout, 'first_sprout'); +} + +/** + * Build the force-graph from shared records. Node kinds: a single `root` (the + * project trunk), one `branch` per branch, one `contributor` per distinct + * author, and one `fact` per record. Edges wire authorship, where a fact lives, + * and how branches graduate into the trunk. + */ +export function buildGraph( + records: SyncRecord[], + extraBranches: string[], + project: string, +): { nodes: GraphNode[]; edges: GraphEdge[] } { + const scoped = records.filter((r) => r.project === project); + const nodes: GraphNode[] = [{ id: 'root', kind: 'root', label: project }]; + const edges: GraphEdge[] = []; + + const branchFacts = new Map(); + const contributorScore = new Map(); + const seenFact = new Set(); + + for (const r of scoped) { + const branch = recordBranch(r); + branchFacts.set(branch, (branchFacts.get(branch) ?? 0) + 1); + const factId = `fact:${r.content_hash}`; + if (!seenFact.has(factId)) { + seenFact.add(factId); + nodes.push({ + id: factId, + kind: 'fact', + label: (r.title ?? + (r.kind === 'pin' ? r.note : null) ?? + r.content_hash.slice(0, 8)) as string, + factKind: r.kind, + author: r.author, + branch, + observation_id: r.observation_id, + epoch: recordEpoch(r), + weight: r.kind === 'graduated' ? GRADUATED_WEIGHT : PIN_WEIGHT, + }); + edges.push({ source: factId, target: `branch:${branch}`, kind: 'lives_on' }); + if (r.author && r.author.trim() !== '') { + const cid = `contributor:${r.author}`; + edges.push({ source: cid, target: factId, kind: 'authored' }); + contributorScore.set( + r.author, + (contributorScore.get(r.author) ?? 0) + + (r.kind === 'graduated' ? GRADUATED_WEIGHT : PIN_WEIGHT), + ); + } + } + } + + const branchNames = new Set([...branchFacts.keys(), ...extraBranches]); + for (const branch of [...branchNames].sort((a, b) => a.localeCompare(b))) { + nodes.push({ + id: `branch:${branch}`, + kind: 'branch', + label: branch, + branch, + weight: branchFacts.get(branch) ?? 0, + }); + edges.push({ source: `branch:${branch}`, target: 'root', kind: 'graduates_into' }); + } + + for (const [author, score] of [...contributorScore.entries()].sort((a, b) => + a[0].localeCompare(b[0]), + )) { + nodes.push({ + id: `contributor:${author}`, + kind: 'contributor', + label: author, + author, + weight: score, + }); + } + + return { nodes, edges }; +} + +export interface BranchAuthors { + branch: string; + authors: Array<{ name: string; commits: number }>; +} + +/** + * Solo/unshared fallback: derive contributors from git commit authors per + * branch when nothing has been shared yet. value_score is the commit count so + * the leaderboard still ranks. These rows carry `commits` and zero facts. + */ +export function buildGitContributors(perBranch: BranchAuthors[], now: number): ContributorStat[] { + const byAuthor = new Map }>(); + for (const { branch, authors } of perBranch) { + for (const { name, commits } of authors) { + let acc = byAuthor.get(name); + if (!acc) { + acc = { commits: 0, branches: new Set() }; + byAuthor.set(name, acc); + } + acc.commits += commits; + acc.branches.add(branch); + } + } + const contributors: ContributorStat[] = [...byAuthor.entries()].map(([author, a]) => ({ + author, + attributed: true, + pins: 0, + graduated: 0, + facts_total: 0, + commits: a.commits, + branches: [...a.branches].sort((x, y) => x.localeCompare(y)), + branches_touched: a.branches.size, + first_activity_epoch: now, + last_activity_epoch: now, + streak_days: 0, + value_score: a.commits, + breakdown: { pin: 0, graduated: 0 }, + badges: [], + })); + contributors.sort((a, b) => b.value_score - a.value_score || a.author.localeCompare(b.author)); + if (contributors[0]) contributors[0].badges.push('most_rooted'); + return contributors; +} + +/** Fallback graph: root + branches + contributors wired by `committed` edges. */ +export function buildGitGraph( + perBranch: BranchAuthors[], + project: string, +): { + nodes: GraphNode[]; + edges: GraphEdge[]; +} { + const nodes: GraphNode[] = [{ id: 'root', kind: 'root', label: project }]; + const edges: GraphEdge[] = []; + const contributorCommits = new Map(); + + for (const { branch, authors } of perBranch) { + const total = authors.reduce((s, a) => s + a.commits, 0); + nodes.push({ id: `branch:${branch}`, kind: 'branch', label: branch, branch, weight: total }); + edges.push({ source: `branch:${branch}`, target: 'root', kind: 'graduates_into' }); + for (const { name, commits } of authors) { + edges.push({ source: `contributor:${name}`, target: `branch:${branch}`, kind: 'committed' }); + contributorCommits.set(name, (contributorCommits.get(name) ?? 0) + commits); + } + } + for (const [author, commits] of [...contributorCommits.entries()].sort((a, b) => + a[0].localeCompare(b[0]), + )) { + nodes.push({ + id: `contributor:${author}`, + kind: 'contributor', + label: author, + author, + weight: commits, + }); + } + return { nodes, edges }; +} diff --git a/test/sync-read.test.ts b/test/sync-read.test.ts new file mode 100644 index 0000000..41d40c9 --- /dev/null +++ b/test/sync-read.test.ts @@ -0,0 +1,101 @@ +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { afterEach, beforeEach, describe, expect, test } from 'vitest'; + +import { + graduatedContentHash, + pinContentHash, + serializeSyncRecord, + SYNC_SCHEMA_VERSION, + type GraduatedRecord, + type PinRecord, +} from '../src/sync/format.js'; +import { branchFilePath, graduatedFilePath } from '../src/sync/layout.js'; +import { readSyncDir, readSyncRecords } from '../src/sync/read.js'; + +function pin(over: Partial = {}): PinRecord { + const base = { + project: 'p', + branch: 'feature/x', + observation_id: 1 as number | null, + note: null as string | null, + title: 'decision' as string | null, + body: 'body' as string | null, + ...over, + }; + return { + schema: SYNC_SCHEMA_VERSION, + kind: 'pin', + content_hash: pinContentHash(base), + author: 'alice', + tagged_at_epoch: 100, + ...base, + } as PinRecord; +} + +function grad(over: Partial = {}): GraduatedRecord { + const base = { + project: 'p', + observation_id: 9, + graduated_from_branch: 'feature/x', + title: 'rooted fact' as string | null, + body: 'body' as string | null, + ...over, + }; + return { + schema: SYNC_SCHEMA_VERSION, + kind: 'graduated', + content_hash: graduatedContentHash(base), + author: 'bob', + graduated_at_epoch: 200, + ...base, + } as GraduatedRecord; +} + +describe('readSyncRecords', () => { + let tmp: string; + let dir: string; + + beforeEach(() => { + tmp = mkdtempSync(join(tmpdir(), 'tre-read-')); + dir = join(tmp, '.tre-mem'); + }); + + afterEach(() => { + rmSync(tmp, { recursive: true, force: true }); + }); + + test('returns empty for a missing directory', () => { + expect(readSyncRecords(join(tmp, 'nope'))).toEqual([]); + }); + + test('reads pins from branches/*.jsonl and facts from graduated.jsonl', () => { + mkdirSync(join(dir, 'branches'), { recursive: true }); + writeFileSync( + branchFilePath(dir, 'feature/x'), + `${serializeSyncRecord(pin({ observation_id: 1 }))}\n${serializeSyncRecord( + pin({ observation_id: 2, title: 'second' }), + )}\n`, + ); + writeFileSync(graduatedFilePath(dir), `${serializeSyncRecord(grad())}\n`); + + const { pins, graduated } = readSyncDir(dir); + expect(pins).toHaveLength(2); + expect(graduated).toHaveLength(1); + expect(pins[0]?.author).toBe('alice'); + expect(graduated[0]?.author).toBe('bob'); + }); + + test('skips malformed and blank lines without throwing', () => { + mkdirSync(join(dir, 'branches'), { recursive: true }); + writeFileSync( + branchFilePath(dir, 'feature/x'), + `${serializeSyncRecord(pin())}\n\nnot json at all\n{"schema":999,"kind":"pin"}\n`, + ); + + const records = readSyncRecords(dir); + expect(records).toHaveLength(1); + expect(records[0]?.kind).toBe('pin'); + }); +}); diff --git a/test/web-grove.test.ts b/test/web-grove.test.ts new file mode 100644 index 0000000..071889b --- /dev/null +++ b/test/web-grove.test.ts @@ -0,0 +1,201 @@ +import { type Server } from 'node:http'; +import { type AddressInfo } from 'node:net'; +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; + +import { migrate } from '../src/store/migrate.js'; +import { TreMemRepo } from '../src/store/repo.js'; +import { + graduatedContentHash, + pinContentHash, + serializeSyncRecord, + SYNC_SCHEMA_VERSION, + type GraduatedRecord, + type PinRecord, +} from '../src/sync/format.js'; +import { branchFilePath, graduatedFilePath } from '../src/sync/layout.js'; +import { + aggregateContributors, + buildGraph, + GRADUATED_WEIGHT, + PIN_WEIGHT, + UNATTRIBUTED, +} from '../src/web/grove.js'; +import { createWebServer } from '../src/web/server.js'; +import { SseHub } from '../src/web/sse.js'; +import { type WebDeps } from '../src/web/types.js'; + +const PROJECT = 'demo'; +const DAY = 24 * 60 * 60; + +function pin(over: Partial = {}): PinRecord { + const base = { + project: PROJECT, + branch: 'feature/x', + observation_id: 1 as number | null, + note: null as string | null, + title: 'a decision' as string | null, + body: 'body' as string | null, + ...over, + }; + return { + schema: SYNC_SCHEMA_VERSION, + kind: 'pin', + content_hash: pinContentHash(base), + author: 'alice', + tagged_at_epoch: 100, + ...base, + } as PinRecord; +} + +function grad(over: Partial = {}): GraduatedRecord { + const base = { + project: PROJECT, + observation_id: 9, + graduated_from_branch: 'feature/x', + title: 'rooted' as string | null, + body: 'body' as string | null, + ...over, + }; + return { + schema: SYNC_SCHEMA_VERSION, + kind: 'graduated', + content_hash: graduatedContentHash(base), + author: 'bob', + graduated_at_epoch: 200, + ...base, + } as GraduatedRecord; +} + +describe('aggregateContributors (pure)', () => { + it('weights graduated facts 3x and ranks by value_score', () => { + const now = 10 * DAY; + const records = [ + pin({ author: 'alice', observation_id: 1, tagged_at_epoch: now }), + pin({ author: 'alice', observation_id: 2, title: 'b', tagged_at_epoch: now }), + grad({ + author: 'bob', + observation_id: 3, + tagged_at_epoch: undefined, + graduated_at_epoch: now, + }), + ]; + const { contributors } = aggregateContributors(records, PROJECT, now); + const alice = contributors.find((c) => c.author === 'alice')!; + const bob = contributors.find((c) => c.author === 'bob')!; + expect(alice.value_score).toBe(2 * PIN_WEIGHT); + expect(bob.value_score).toBe(1 * GRADUATED_WEIGHT); + // bob (3) outranks alice (2) despite fewer facts → graduated weight wins. + expect(contributors[0]?.author).toBe('bob'); + }); + + it('buckets null-author records under UNATTRIBUTED', () => { + const { contributors, attributed_total, unattributed_total } = aggregateContributors( + [pin({ author: null, observation_id: 5 })], + PROJECT, + 1000, + ); + expect(attributed_total).toBe(0); + expect(unattributed_total).toBe(1); + expect(contributors[0]?.author).toBe(UNATTRIBUTED); + expect(contributors[0]?.attributed).toBe(false); + }); + + it('awards most_rooted to the top graduator and first_sprout to the earliest', () => { + const now = 30 * DAY; + const records = [ + pin({ author: 'alice', observation_id: 1, tagged_at_epoch: 5 * DAY }), + grad({ author: 'bob', observation_id: 2, graduated_at_epoch: 20 * DAY }), + grad({ author: 'bob', observation_id: 3, title: 'g2', graduated_at_epoch: 21 * DAY }), + ]; + const { contributors } = aggregateContributors(records, PROJECT, now); + const bob = contributors.find((c) => c.author === 'bob')!; + const alice = contributors.find((c) => c.author === 'alice')!; + expect(bob.badges).toContain('most_rooted'); + expect(alice.badges).toContain('first_sprout'); + }); +}); + +describe('buildGraph (pure)', () => { + it('emits root + branch + contributor + fact nodes with authored/lives_on/graduates_into edges', () => { + const { nodes, edges } = buildGraph( + [pin({ author: 'alice' }), grad({ author: 'bob' })], + ['main'], + PROJECT, + ); + const kinds = new Set(nodes.map((n) => n.kind)); + expect(kinds).toEqual(new Set(['root', 'branch', 'contributor', 'fact'])); + expect(nodes.some((n) => n.id === 'branch:main')).toBe(true); // extra branch with no facts + expect(edges.some((e) => e.kind === 'authored')).toBe(true); + expect(edges.some((e) => e.kind === 'lives_on')).toBe(true); + expect(edges.some((e) => e.kind === 'graduates_into')).toBe(true); + }); +}); + +describe('web API — grove endpoints', () => { + let tmp: string; + let repo: TreMemRepo; + let server: Server; + let base: string; + + beforeAll(async () => { + tmp = mkdtempSync(join(tmpdir(), 'tre-grove-')); + const dir = join(tmp, '.tre-mem'); + mkdirSync(join(dir, 'branches'), { recursive: true }); + writeFileSync( + branchFilePath(dir, 'feature/x'), + `${serializeSyncRecord(pin({ author: 'alice', observation_id: 1 }))}\n`, + ); + writeFileSync(graduatedFilePath(dir), `${serializeSyncRecord(grad({ author: 'bob' }))}\n`); + + const dbPath = join(tmp, 'tre-mem.db'); + migrate(dbPath); + repo = new TreMemRepo({ dbPath }); + + const deps: WebDeps = { + repo, + adapter: null, + cwd: tmp, + project: PROJECT, + staticDir: join(tmp, 'no-static'), + version: '9.9.9', + now: () => 1000, + sse: new SseHub(), + }; + server = createWebServer(deps); + await new Promise((res) => server.listen(0, '127.0.0.1', () => res())); + base = `http://127.0.0.1:${(server.address() as AddressInfo).port}`; + }); + + afterAll(async () => { + await new Promise((res) => server.close(() => res())); + repo.close(); + rmSync(tmp, { recursive: true, force: true }); + }); + + type Obj = Record; + async function get(path: string): Promise<{ status: number; body: Obj }> { + const r = await fetch(`${base}${path}`); + return { status: r.status, body: (await r.json()) as Obj }; + } + + it('GET /api/contributors aggregates shared JSONL authors', async () => { + const { status, body } = await get('/api/contributors'); + expect(status).toBe(200); + expect(body.source).toBe('shared'); + expect(body.attributed_total).toBe(2); + const authors = (body.contributors as Obj[]).map((c) => c.author); + expect(authors).toContain('alice'); + expect(authors).toContain('bob'); + }); + + it('GET /api/graph returns nodes + edges from the shared layer', async () => { + const { status, body } = await get('/api/graph'); + expect(status).toBe(200); + expect(body.source).toBe('shared'); + expect((body.nodes as Obj[]).some((n) => n.kind === 'contributor')).toBe(true); + expect((body.edges as Obj[]).length).toBeGreaterThan(0); + }); +}); diff --git a/web/app.tsx b/web/app.tsx index 9f3c45d..69651eb 100644 --- a/web/app.tsx +++ b/web/app.tsx @@ -1,12 +1,14 @@ import { useCallback, useEffect, useState } from 'react'; +import { useI18n } from './i18n.js'; import { clsx, useApi, useSse, type BranchesResponse, type Health } from './lib.js'; import { BranchDetail } from './views/BranchDetail.js'; +import { Grove } from './views/Grove.js'; import { Overview } from './views/Overview.js'; import { Search } from './views/Search.js'; import { TeamMemory } from './views/TeamMemory.js'; -type Tab = 'overview' | 'team' | 'search'; +type Tab = 'overview' | 'grove' | 'team' | 'search'; interface ProjectsResponse { current: string; @@ -14,6 +16,7 @@ interface ProjectsResponse { } export function App() { + const { t } = useI18n(); const health = useApi('/api/health', 'init'); const projects = useApi('/api/projects', 'init'); @@ -54,7 +57,7 @@ export function App() {
tre - shared roots + {t('wordmark.tagline')}
{mode ? {mode} : null} @@ -77,9 +80,10 @@ export function App() { ) : null} +
@@ -89,19 +93,22 @@ export function App() { aria-current={tab === 'overview' && !branch} onClick={() => go('overview')} > - Overview + {t('nav.overview')} + +
{health.error ? ( -

Could not reach the dashboard server: {health.error}

+

{t('err.server', { error: health.error })}

) : !project ? (
) : branch ? ( @@ -117,7 +124,10 @@ export function App() { branches={branches} refreshKey={refreshKey} onOpenBranch={openBranch} + onOpenGrove={() => go('grove')} /> + ) : tab === 'grove' ? ( + ) : tab === 'team' ? ( ) : ( @@ -129,6 +139,7 @@ export function App() { } function ThemeToggle() { + const { t } = useI18n(); const [theme, setTheme] = useState( () => document.documentElement.dataset.theme ?? 'light', ); @@ -143,8 +154,29 @@ function ThemeToggle() { setTheme(next); }, [theme]); return ( - ); } + +/** EN ⇄ VI toggle. Shows the language you'll switch TO. */ +function LangToggle() { + const { lang, setLang, t } = useI18n(); + const next = lang === 'vi' ? 'en' : 'vi'; + return ( + + ); +} diff --git a/web/i18n.tsx b/web/i18n.tsx new file mode 100644 index 0000000..2479ec2 --- /dev/null +++ b/web/i18n.tsx @@ -0,0 +1,334 @@ +import { createContext, useCallback, useContext, useEffect, useMemo, useState } from 'react'; + +import { setTimeLang } from './lib.js'; + +export type Lang = 'en' | 'vi'; + +type Vars = Record; + +/** + * English is the source of truth; Vietnamese is intentionally casual and a bit + * cheeky (the user asked for "funnier, not formal"). Keys group by surface. + * `{name}` placeholders are filled by `t(key, { name })`. + */ +const EN: Record = { + 'nav.overview': 'Overview', + 'nav.grove': 'Grove', + 'nav.team': 'Team memory', + 'nav.search': 'Search', + 'wordmark.tagline': 'shared roots', + 'topbar.live': 'live updates connected', + 'topbar.disconnected': 'disconnected', + 'theme.toggle': 'Toggle theme', + 'lang.toggle': 'Switch language', + 'err.server': 'Could not reach the dashboard server: {error}', + + 'ov.lede.a': 'Shared', + 'ov.lede.em': 'roots', + 'ov.lede.b': ' for your codebase.', + 'ov.sub.a': + 'Every branch, every pinned decision, every fact that graduated to repo-wide knowledge — read straight from git. This is what your team knows about ', + 'ov.sub.b': '.', + 'ov.stat.branches': 'branches with memory', + 'ov.stat.pins': 'pinned decisions', + 'ov.stat.graduated': 'graduated repo-wide', + 'ov.stat.pending': 'pins not shared yet', + 'ov.promo.a': 'Explore the ', + 'ov.promo.em': 'Grove', + 'ov.promo.lead': 'See the contributor graph — {author} is leading with {metric}.', + 'ov.promo.empty': + 'See who grew your team’s knowledge as a living graph of branches & contributors.', + 'ov.section.branchGraph': 'Branch graph', + 'ov.hint.synced': '.tre-mem/ present — shared via git', + 'ov.hint.local': 'no .tre-mem/ yet — local only', + 'ov.empty.title': 'No branch memory yet', + 'ov.empty.hint': 'Run `tre backfill` to branch-tag past observations, then pin what matters.', + 'branch.tagged': 'tagged', + 'branch.pins': 'pins', + + 'metric.score': '{n} score', + 'metric.commits': '{n} commits', + + 'grove.title.a': 'The ', + 'grove.title.em': 'grove', + 'grove.sub.a': + 'Every contributor, every fact they grew, every branch it lives on — the second brain of ', + 'grove.sub.b': '.', + 'grove.source.git': 'from git history — run `tre share` to attribute facts', + 'grove.unattributed': '{n} facts shared before attribution', + 'grove.unattributed.one': '1 fact shared before attribution', + 'grove.share': 'Share grove', + 'grove.share.title': 'Download a shareable grove card', + 'grove.contributors': 'Contributors', + 'grove.by.score': 'by value score', + 'grove.by.commits': 'by commits', + 'grove.empty.title': 'Nothing has grown here yet', + 'grove.empty.hint': + 'Pin a decision, then run `tre share` to grow your grove and attribute it to contributors.', + 'grove.board.empty.title': 'No contributors yet', + 'grove.board.empty.hint': 'Share attributed facts to populate the leaderboard.', + 'timelapse.now': 'now', + 'timelapse.growing': 'growing…', + 'timelapse.play': 'Play growth replay', + 'timelapse.pause': 'Pause growth replay', + + 'lb.score': 'score', + 'lb.pins': 'pins', + 'lb.rooted': 'rooted', + 'lb.commits': 'commits', + 'lb.branch': 'branch', + 'lb.branches': 'branches', + 'lb.unattributed': 'unattributed', + + 'badge.gardener_of_week': 'Gardener of the week', + 'badge.most_rooted': 'Most rooted', + 'badge.longest_streak': 'Longest streak', + 'badge.first_sprout': 'First sprout', + + 'tm.title': 'Team memory', + 'tm.sub.a': 'What the team chose to remember about ', + 'tm.sub.b': + ' — pinned decisions and the facts that graduated to repo-wide knowledge. Shared rows live in ', + 'tm.sub.c': ' and travel through git; run ', + 'tm.sub.d': ' to publish them.', + 'tm.callout.a': '{n} pins not shared yet — run ', + 'tm.callout.b': " to push them to your team's git.", + 'tm.empty.title': 'No shared memory yet', + 'tm.empty.hint': "Pin a decision, then run `tre share` to push it to your team's git.", + 'tm.pinned': 'Pinned decisions', + 'tm.graduated': 'Graduated facts', + 'tm.grad.empty.title': 'Nothing graduated yet', + 'tm.grad.empty.hint': 'Merge a PR (or `tre graduate`) to promote a fact repo-wide.', + 'entry.pin': 'pin', + 'entry.graduated': 'graduated', + 'entry.shared.git': 'shared via git ✓', + 'entry.notshared': 'not shared yet', + 'entry.shared': 'shared', + 'entry.local': 'local', + 'entry.note': 'note', + 'entry.from': 'from', + 'entry.pinnedfact': '(pinned fact)', + 'entry.freetext': 'free-text', + 'entry.observation': 'observation', + + 'bd.back': '← branches', + 'bd.sub.a': 'Curated knowledge and tagged activity on this branch of ', + 'bd.sub.b': '.', + 'bd.notfound': 'Branch not found', + 'bd.pins.empty.title': 'No pins on this branch', + 'bd.pins.empty.hint': 'Pin a fact to share it with the team.', + 'bd.gradhere': 'Graduated from here', + 'bd.gradhere.empty': 'Nothing graduated from this branch', + 'bd.activity': 'Tagged activity', + 'bd.activity.empty': 'No tagged observations', + + 's.title': 'Search', + 's.sub.a': 'Branch-aware retrieval', + 's.sub.on': ' on ', + 's.sub.b': '. Each hit shows why it ranked — semantic, branch, recency, graduated, pin.', + 's.placeholder': "Search this codebase's memory…", + 's.sharedonly': 'claude-mem not detected — searching shared pins + graduated only.', + 's.empty.type.title': 'Type to search', + 's.empty.type.hint': 'Results are reranked for the active branch.', + 's.searching': 'Searching…', + 's.nomatch.title': 'No matches', + 's.nomatch.hint': 'Try a different term or branch.', + + 'card.tagline': 'shared roots · tre-mem grove', + 'card.contributors': 'contributors', + 'card.facts': 'facts', + 'card.branches': 'branches', + + 'time.ago': '{v} ago', + 'time.now': 'just now', +}; + +const VI: Record = { + 'nav.overview': 'Tổng quan', + 'nav.grove': 'Khu vườn', + 'nav.team': 'Trí nhớ team', + 'nav.search': 'Lùng sục', + 'wordmark.tagline': 'rễ chung', + 'topbar.live': 'đang sống nha', + 'topbar.disconnected': 'rớt mạng rồi', + 'theme.toggle': 'Đổi sáng/tối', + 'lang.toggle': 'Đổi ngôn ngữ', + 'err.server': 'Hổng kết nối được tới server: {error}', + + 'ov.lede.a': 'Rễ', + 'ov.lede.em': 'chung', + 'ov.lede.b': ' cho cả đống code của bạn 🌳', + 'ov.sub.a': + 'Mọi nhánh, mọi quyết định đã ghim, mọi điều hay ho đã "lên đời" toàn repo — đọc thẳng từ git luôn. Đây là tất tần tật những gì team biết về ', + 'ov.sub.b': '.', + 'ov.stat.branches': 'nhánh có trí nhớ', + 'ov.stat.pins': 'quyết định đã ghim', + 'ov.stat.graduated': 'đã lên đời toàn repo', + 'ov.stat.pending': 'ghim chưa kịp khoe', + 'ov.promo.a': 'Ghé thăm ', + 'ov.promo.em': 'Khu vườn', + 'ov.promo.lead': 'Ngó cái graph đóng góp coi — {author} đang dẫn đầu với {metric} đó.', + 'ov.promo.empty': + 'Coi thử ai đang trồng cây tri thức cho team — graph sống động của nhánh & người đóng góp.', + 'ov.section.branchGraph': 'Bản đồ nhánh', + 'ov.hint.synced': 'có .tre-mem/ rồi — đã chia sẻ qua git', + 'ov.hint.local': 'chưa có .tre-mem/ — máy mình thôi', + 'ov.empty.title': 'Chưa có trí nhớ nhánh nào hết', + 'ov.empty.hint': 'Gõ `tre backfill` để gắn nhánh cho mấy cái cũ, rồi ghim cái nào ngon nha.', + 'branch.tagged': 'đã gắn', + 'branch.pins': 'ghim', + + 'metric.score': '{n} điểm', + 'metric.commits': '{n} commit', + + 'grove.title.a': 'Khu ', + 'grove.title.em': 'vườn tre', + 'grove.sub.a': 'Mọi người đóng góp, mọi điều họ trồng, mọi nhánh nó sống — bộ não thứ hai của ', + 'grove.sub.b': '.', + 'grove.source.git': 'lấy từ lịch sử git — gõ `tre share` để ghi công nha', + 'grove.unattributed': '{n} điều được chia sẻ hồi chưa kịp ghi công', + 'grove.unattributed.one': '1 điều được chia sẻ hồi chưa kịp ghi công', + 'grove.share': 'Khoe vườn', + 'grove.share.title': 'Tải tấm hình khoe vườn về máy', + 'grove.contributors': 'Người đóng góp', + 'grove.by.score': 'theo điểm chất', + 'grove.by.commits': 'theo số commit', + 'grove.empty.title': 'Ở đây chưa mọc cái gì hết trơn 🌱', + 'grove.empty.hint': + 'Ghim một quyết định rồi gõ `tre share` cho vườn xanh tốt và ghi công cho mọi người nha.', + 'grove.board.empty.title': 'Chưa có ai trồng cây hết', + 'grove.board.empty.hint': 'Chia sẻ vài điều có ghi công đi rồi bảng vàng mới có tên nha.', + 'timelapse.now': 'bây giờ', + 'timelapse.growing': 'đang lớn…', + 'timelapse.play': 'Tua lại quá trình lớn', + 'timelapse.pause': 'Tạm dừng', + + 'lb.score': 'điểm', + 'lb.pins': 'ghim', + 'lb.rooted': 'bén rễ', + 'lb.commits': 'commit', + 'lb.branch': 'nhánh', + 'lb.branches': 'nhánh', + 'lb.unattributed': 'vô danh', + + 'badge.gardener_of_week': 'Nông dân của tuần 🧑‍🌾', + 'badge.most_rooted': 'Bén rễ nhất', + 'badge.longest_streak': 'Chăm nhất hệ', + 'badge.first_sprout': 'Mầm đầu tiên', + + 'tm.title': 'Trí nhớ team', + 'tm.sub.a': 'Những gì team chọn để nhớ về ', + 'tm.sub.b': + ' — quyết định đã ghim và mấy điều đã "lên đời" toàn repo. Mấy dòng chia sẻ nằm trong ', + 'tm.sub.c': ' và đi theo git; gõ ', + 'tm.sub.d': ' để khoe ra cho cả team.', + 'tm.callout.a': 'Còn {n} ghim chưa khoe — gõ ', + 'tm.callout.b': ' để đẩy lên git của team nha.', + 'tm.empty.title': 'Chưa có trí nhớ chung nào', + 'tm.empty.hint': 'Ghim một quyết định rồi gõ `tre share` để đẩy lên git của team.', + 'tm.pinned': 'Quyết định đã ghim', + 'tm.graduated': 'Điều đã lên đời', + 'tm.grad.empty.title': 'Chưa có gì lên đời', + 'tm.grad.empty.hint': 'Merge một PR (hoặc `tre graduate`) để cho điều đó lên đời toàn repo.', + 'entry.pin': 'ghim', + 'entry.graduated': 'lên đời', + 'entry.shared.git': 'đã chia sẻ qua git ✓', + 'entry.notshared': 'chưa khoe', + 'entry.shared': 'đã chia sẻ', + 'entry.local': 'máy mình', + 'entry.note': 'ghi chú', + 'entry.from': 'từ', + 'entry.pinnedfact': '(điều đã ghim)', + 'entry.freetext': 'chữ tự do', + 'entry.observation': 'quan sát', + + 'bd.back': '← nhánh', + 'bd.sub.a': 'Kiến thức tuyển chọn và hoạt động đã gắn trên nhánh này của ', + 'bd.sub.b': '.', + 'bd.notfound': 'Hổng thấy nhánh này', + 'bd.pins.empty.title': 'Nhánh này chưa có ghim nào', + 'bd.pins.empty.hint': 'Ghim một điều để chia sẻ với team.', + 'bd.gradhere': 'Lên đời từ đây', + 'bd.gradhere.empty': 'Chưa có gì lên đời từ nhánh này', + 'bd.activity': 'Hoạt động đã gắn', + 'bd.activity.empty': 'Chưa có quan sát nào được gắn', + + 's.title': 'Lùng sục', + 's.sub.a': 'Tìm kiếm biết phân biệt nhánh', + 's.sub.on': ' trên ', + 's.sub.b': '. Mỗi kết quả cho biết vì sao nó lọt top — ngữ nghĩa, nhánh, độ mới, lên đời, ghim.', + 's.placeholder': 'Lục trí nhớ của codebase này…', + 's.sharedonly': 'Hổng thấy claude-mem — chỉ tìm trong ghim + điều đã lên đời thôi.', + 's.empty.type.title': 'Gõ gì đó để tìm', + 's.empty.type.hint': 'Kết quả được xếp lại theo nhánh đang mở.', + 's.searching': 'Đang lùng…', + 's.nomatch.title': 'Hổng có gì khớp', + 's.nomatch.hint': 'Thử từ khác hoặc nhánh khác coi.', + + 'card.tagline': 'rễ chung · vườn tre-mem', + 'card.contributors': 'người đóng góp', + 'card.facts': 'điều', + 'card.branches': 'nhánh', + + 'time.ago': '{v} trước', + 'time.now': 'vừa nãy', +}; + +const STRINGS: Record> = { en: EN, vi: VI }; + +function interpolate(template: string, vars?: Vars): string { + if (!vars) return template; + return template.replace(/\{(\w+)\}/g, (_, k: string) => (k in vars ? String(vars[k]) : `{${k}}`)); +} + +export type TFn = (key: string, vars?: Vars) => string; + +interface I18nValue { + lang: Lang; + setLang: (l: Lang) => void; + t: TFn; +} + +const I18nCtx = createContext({ lang: 'en', setLang: () => {}, t: (k) => k }); + +function initialLang(): Lang { + try { + const saved = localStorage.getItem('tre-lang'); + if (saved === 'en' || saved === 'vi') return saved; + } catch { + /* private mode */ + } + return typeof navigator !== 'undefined' && navigator.language?.toLowerCase().startsWith('vi') + ? 'vi' + : 'en'; +} + +export function LanguageProvider({ children }: { children: React.ReactNode }) { + const [lang, setLangState] = useState(initialLang); + + useEffect(() => { + document.documentElement.lang = lang; + setTimeLang(lang); + }, [lang]); + + const setLang = useCallback((l: Lang) => { + try { + localStorage.setItem('tre-lang', l); + } catch { + /* private mode */ + } + setLangState(l); + }, []); + + const t = useCallback( + (key, vars) => interpolate(STRINGS[lang][key] ?? STRINGS.en[key] ?? key, vars), + [lang], + ); + + const value = useMemo(() => ({ lang, setLang, t }), [lang, setLang, t]); + return {children}; +} + +export function useI18n(): I18nValue { + return useContext(I18nCtx); +} diff --git a/web/index.tsx b/web/index.tsx index 0a0effd..9188208 100644 --- a/web/index.tsx +++ b/web/index.tsx @@ -2,13 +2,16 @@ import { StrictMode } from 'react'; import { createRoot } from 'react-dom/client'; import { App } from './app.js'; +import { LanguageProvider } from './i18n.js'; import './styles.css'; const root = document.getElementById('root'); if (root) { createRoot(root).render( - + + + , ); } diff --git a/web/lib.tsx b/web/lib.tsx index d9af078..62fc404 100644 --- a/web/lib.tsx +++ b/web/lib.tsx @@ -100,6 +100,72 @@ export interface SearchResponse { hits: SearchHit[]; } +/* ---- grove (contributor graph + leaderboard) ------------------------ */ + +export type BadgeId = 'gardener_of_week' | 'most_rooted' | 'longest_streak' | 'first_sprout'; +export type GroveSource = 'shared' | 'git-fallback'; + +export interface ContributorStat { + author: string; + attributed: boolean; + pins: number; + graduated: number; + facts_total: number; + commits: number; + branches: string[]; + branches_touched: number; + first_activity_epoch: number; + last_activity_epoch: number; + streak_days: number; + value_score: number; + breakdown: { pin: number; graduated: number }; + badges: BadgeId[]; +} + +export interface ContributorsResponse { + project: string; + source: GroveSource; + contributors: ContributorStat[]; + attributed_total: number; + unattributed_total: number; + has_sync_dir: boolean; +} + +export type GraphNodeKind = 'root' | 'branch' | 'contributor' | 'fact'; + +export interface GraphNode { + id: string; + kind: GraphNodeKind; + label: string; + factKind?: 'pin' | 'graduated'; + author?: string | null; + branch?: string; + observation_id?: number | null; + epoch?: number; + weight?: number; +} + +export interface GraphEdge { + source: string; + target: string; + kind: 'authored' | 'lives_on' | 'graduates_into' | 'committed'; +} + +export interface GraphResponse { + project: string; + source: GroveSource; + nodes: GraphNode[]; + edges: GraphEdge[]; + has_sync_dir: boolean; +} + +export const BADGE_META: Record = { + gardener_of_week: { icon: '🌱', label: 'Gardener of the week' }, + most_rooted: { icon: '🎋', label: 'Most rooted' }, + longest_streak: { icon: '🔥', label: 'Longest streak' }, + first_sprout: { icon: '🌿', label: 'First sprout' }, +}; + /* ---- fetching ------------------------------------------------------- */ export async function api(path: string): Promise { @@ -165,6 +231,13 @@ export function useSse(onChange: (event: string) => void): boolean { /* ---- format helpers ------------------------------------------------- */ +// Lightweight locale hook for timeAgo so the many existing call sites don't need +// to thread `lang` through. Set by the LanguageProvider whenever the language changes. +let timeLang: 'en' | 'vi' = 'en'; +export function setTimeLang(lang: 'en' | 'vi'): void { + timeLang = lang; +} + export function timeAgo(epochSeconds: number | null): string { if (!epochSeconds) return '—'; const secs = Math.max(0, Math.floor(Date.now() / 1000) - epochSeconds); @@ -177,10 +250,13 @@ export function timeAgo(epochSeconds: number | null): string { ]; let prev = 1; for (const [limit, label] of units) { - if (secs < limit) return `${Math.floor(secs / prev)}${label} ago`; + if (secs < limit) { + const v = `${Math.floor(secs / prev)}${label}`; + return timeLang === 'vi' ? `${v} trước` : `${v} ago`; + } prev = limit; } - return 'just now'; + return timeLang === 'vi' ? 'vừa nãy' : 'just now'; } export function clsx(...parts: Array): string { diff --git a/web/styles.css b/web/styles.css index f4a4394..9bb11f4 100644 --- a/web/styles.css +++ b/web/styles.css @@ -1,6 +1,7 @@ -/* tre-mem dashboard — "shared roots" editorial system. - Light luxury: ink on warm paper, serif display + sans body, a colored spine - for the branch graph. Both themes intentional. Compositor-only motion. */ +/* tre-mem dashboard — "shared roots" system. + Ink on warm paper, a cute rounded display (Baloo 2) + a Vietnamese-native body + (Be Vietnam Pro), a colored spine for the branch graph. Both themes + intentional. Compositor-only motion. */ :root { /* paper + ink */ @@ -23,10 +24,10 @@ --accent-wash: oklch(52% 0.11 55 / 0.08); - /* type */ - --font-serif: 'Iowan Old Style', 'Palatino Linotype', Palatino, Georgia, ui-serif, serif; - --font-sans: - ui-sans-serif, system-ui, -apple-system, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif; + /* type — cute rounded display + a Vietnamese-native body, both with full + diacritics. System fallbacks (system-ui = SF/Segoe) also render VN well. */ + --font-display: 'Baloo 2', system-ui, 'Segoe UI', Roboto, sans-serif; + --font-sans: 'Be Vietnam Pro', system-ui, -apple-system, 'Segoe UI', Roboto, Arial, sans-serif; --font-mono: ui-monospace, 'SF Mono', 'JetBrains Mono', Menlo, Consolas, monospace; --text-xs: 0.75rem; @@ -101,7 +102,7 @@ button { h1, h2, h3 { - font-family: var(--font-serif); + font-family: var(--font-display); font-weight: 600; letter-spacing: -0.01em; line-height: 1.1; @@ -119,7 +120,9 @@ code, .shell { min-height: 100vh; display: grid; - grid-template-rows: auto 1fr; + /* topbar + tabs size to content; main absorbs the remaining height. + (3 children — the 1fr MUST be the third row or the nav balloons.) */ + grid-template-rows: auto auto 1fr; } .topbar { @@ -139,7 +142,7 @@ code, display: flex; align-items: baseline; gap: 0.5rem; - font-family: var(--font-serif); + font-family: var(--font-display); font-size: var(--text-xl); font-weight: 600; letter-spacing: -0.02em; @@ -206,7 +209,7 @@ main { } .lede { - font-family: var(--font-serif); + font-family: var(--font-display); font-size: var(--text-display); line-height: 1.02; letter-spacing: -0.02em; @@ -214,7 +217,7 @@ main { } .lede em { - font-style: italic; + font-style: normal; color: var(--bark); } @@ -268,6 +271,14 @@ main { line-height: 1; } +.lang-toggle { + font-size: var(--text-xs); + font-weight: 700; + letter-spacing: 0.06em; + min-width: 2.4rem; + text-align: center; +} + .dot { width: 8px; height: 8px; @@ -349,7 +360,7 @@ main { } .stat .n { - font-family: var(--font-serif); + font-family: var(--font-display); font-size: 2rem; line-height: 1; } @@ -660,6 +671,308 @@ main { } } +/* ---- grove (contributor graph + leaderboard) ------------------------ */ + +.grove-promo { + display: flex; + align-items: center; + gap: 1rem; + width: 100%; + text-align: left; + font: inherit; + color: var(--ink); + cursor: pointer; + margin: 0 0 1.6rem; + padding: 1rem 1.2rem; + border: 1px solid color-mix(in oklch, var(--growth) 38%, var(--line)); + border-radius: var(--radius); + background: linear-gradient( + 100deg, + var(--accent-wash), + color-mix(in oklch, var(--growth) 10%, transparent) + ); + box-shadow: var(--shadow-1); + transition: + transform var(--dur-fast) var(--ease), + box-shadow var(--dur-fast) var(--ease), + border-color var(--dur-fast) var(--ease); +} + +.grove-promo:hover { + transform: translateY(-2px); + box-shadow: var(--shadow-2); + border-color: var(--growth); +} + +.grove-promo-mark { + font-size: 2rem; + line-height: 1; + flex: none; +} + +.grove-promo-body { + display: flex; + flex-direction: column; + gap: 0.2rem; + min-width: 0; +} + +.grove-promo-title { + font-family: var(--font-display); + font-size: var(--text-lg); + font-weight: 600; +} + +.grove-promo-title em { + font-style: normal; + color: var(--bark); +} + +.grove-promo-sub { + color: var(--ink-soft); + font-size: var(--text-sm); +} + +.grove-promo-go { + margin-left: auto; + flex: none; + font-size: 1.4rem; + color: var(--bark); + transition: transform var(--dur-fast) var(--ease); +} + +.grove-promo:hover .grove-promo-go { + transform: translateX(4px); +} + +.grove-actions { + display: flex; + gap: 0.5rem; +} + +.btn-share { + font: inherit; + font-size: var(--text-sm); + font-weight: 600; + color: var(--surface); + background: var(--growth); + border: none; + border-radius: var(--radius-sm); + padding: 0.5rem 0.9rem; + cursor: pointer; + transition: + transform var(--dur-fast) var(--ease), + filter var(--dur-fast) var(--ease); +} + +.btn-share:hover { + transform: translateY(-1px); + filter: brightness(1.05); +} + +.btn-share:focus-visible { + outline: 2px solid var(--bark); + outline-offset: 2px; +} + +.grove-layout { + display: grid; + grid-template-columns: minmax(0, 1.4fr) minmax(280px, 1fr); + gap: 1rem; + align-items: start; +} + +@media (max-width: 860px) { + .grove-layout { + grid-template-columns: 1fr; + } +} + +.grove-graph-card { + position: relative; + padding: 0; + overflow: hidden; + min-height: 460px; + display: flex; + flex-direction: column; +} + +.grove-canvas { + flex: 1; + width: 100%; + height: 100%; + min-height: 420px; + display: block; + touch-action: none; +} + +.timelapse { + display: flex; + align-items: center; + gap: 0.6rem; + padding: 0.5rem 0.8rem; + border-top: 1px solid var(--line); + background: var(--paper-2); +} + +.timelapse input[type='range'] { + flex: 1; + accent-color: var(--growth); +} + +.timelapse-label { + color: var(--muted); + font-size: var(--text-xs); + min-width: 3.5rem; + text-align: right; +} + +.grove-board { + padding: 1rem 1.1rem; +} + +.leaderboard { + display: flex; + flex-direction: column; + gap: 0.5rem; +} + +.lb-card { + border: 1px solid var(--line); + border-radius: var(--radius-sm); + background: var(--surface); + transition: + border-color var(--dur-fast) var(--ease), + box-shadow var(--dur-fast) var(--ease); +} + +.lb-card.active { + border-color: var(--bark); + box-shadow: var(--shadow-2); +} + +.lb-main { + display: flex; + align-items: stretch; + gap: 0.7rem; + width: 100%; + text-align: left; + background: none; + border: none; + cursor: pointer; + padding: 0.7rem 0.8rem; + font: inherit; + color: inherit; +} + +.lb-rank { + font-size: 1.3rem; + width: 1.8rem; + flex: none; + display: flex; + align-items: center; + justify-content: center; +} + +.lb-culm { + flex: none; + width: 8px; + border-radius: 999px; + background: var(--paper-2); + display: flex; + align-items: flex-end; + overflow: hidden; +} + +.lb-culm i { + width: 100%; + background: linear-gradient(var(--growth), var(--bark)); + border-radius: 999px; + display: block; +} + +.lb-body { + min-width: 0; + display: flex; + flex-direction: column; + gap: 0.25rem; +} + +.lb-name { + font-weight: 700; + display: flex; + align-items: center; + gap: 0.4rem; +} + +.lb-tag { + font-size: var(--text-xs); + font-weight: 500; + color: var(--muted); + border: 1px solid var(--line-strong); + border-radius: 999px; + padding: 0 0.4rem; +} + +.lb-stats { + display: flex; + flex-wrap: wrap; + gap: 0.7rem; + color: var(--muted); + font-size: var(--text-xs); +} + +.lb-stats b { + color: var(--ink); +} + +.lb-rooted b { + color: var(--growth); +} + +.lb-when { + margin-left: auto; +} + +.lb-badges { + display: flex; + flex-wrap: wrap; + gap: 0.35rem; + margin-top: 0.15rem; +} + +.lb-badge { + font-size: var(--text-xs); + font-weight: 600; + color: var(--bark-ink); + background: var(--accent-wash); + border-radius: 999px; + padding: 0.1rem 0.5rem; +} + +.lb-branches { + display: flex; + flex-wrap: wrap; + gap: 0.4rem; + padding: 0 0.8rem 0.7rem 3.2rem; +} + +.lb-branches .who { + font: inherit; + font-size: var(--text-xs); + font-weight: 600; + color: var(--bark-ink); + background: var(--paper-2); + border: 1px solid var(--line); + border-radius: 999px; + padding: 0.15rem 0.6rem; + cursor: pointer; +} + +.lb-branches .who:hover { + border-color: var(--bark); +} + @media (prefers-reduced-motion: reduce) { *, *::before, diff --git a/web/views/BranchDetail.tsx b/web/views/BranchDetail.tsx index 8ed79c3..99ba770 100644 --- a/web/views/BranchDetail.tsx +++ b/web/views/BranchDetail.tsx @@ -1,3 +1,4 @@ +import { useI18n } from '../i18n.js'; import { Badge, Empty, @@ -15,6 +16,7 @@ interface BranchDetailProps { } export function BranchDetail({ project, branch, refreshKey, onBack }: BranchDetailProps) { + const { t } = useI18n(); const q = `project=${encodeURIComponent(project)}`; const detail = useApi( `/api/branch/${encodeURIComponent(branch)}?${q}`, @@ -25,33 +27,37 @@ export function BranchDetail({ project, branch, refreshKey, onBack }: BranchDeta return ( <>

{branch}

- Curated knowledge and tagged activity on this branch of {project}. + {t('bd.sub.a')} + {project} + {t('bd.sub.b')}

{detail.loading ? ( ) : !d ? ( - + ) : ( <> -
+
{d.pins.length === 0 ? ( - + ) : (
{d.pins.map((p) => (
- pin - {p.title ?? p.note ?? '(pinned fact)'} + {t('entry.pin')} + + {p.title ?? p.note ?? t('entry.pinnedfact')} + - {p.shared ? 'shared' : 'local'} + {p.shared ? t('entry.shared') : t('entry.local')}
{p.body && p.body !== p.title ?

{p.body}

: null} @@ -59,7 +65,7 @@ export function BranchDetail({ project, branch, refreshKey, onBack }: BranchDeta {p.observation_id !== null ? ( #{p.observation_id} ) : ( - free-text + {t('entry.freetext')} )} {timeAgo(p.created_at_epoch)}
@@ -69,15 +75,15 @@ export function BranchDetail({ project, branch, refreshKey, onBack }: BranchDeta )}
-
+
{d.graduated.length === 0 ? ( - + ) : (
{d.graduated.map((g) => (
- graduated + {t('entry.graduated')} {g.title ?? `#${g.observation_id}`}
{g.body ?

{g.body}

: null} @@ -91,21 +97,21 @@ export function BranchDetail({ project, branch, refreshKey, onBack }: BranchDeta )}
-
+
{d.timeline.length === 0 ? ( - + ) : (
- {d.timeline.map((t) => ( -
+ {d.timeline.map((row) => ( +
- {t.type ?? 'observation'} - {t.title ?? `#${t.observation_id}`} + {row.type ?? t('entry.observation')} + {row.title ?? `#${row.observation_id}`}
- #{t.observation_id} - {t.source} - {timeAgo(t.tagged_at_epoch)} + #{row.observation_id} + {row.source} + {timeAgo(row.tagged_at_epoch)}
))} diff --git a/web/views/GraphCanvas.tsx b/web/views/GraphCanvas.tsx new file mode 100644 index 0000000..5fd408c --- /dev/null +++ b/web/views/GraphCanvas.tsx @@ -0,0 +1,238 @@ +import { + forceCenter, + forceCollide, + forceLink, + forceManyBody, + forceSimulation, + type SimulationLinkDatum, + type SimulationNodeDatum, +} from 'd3-force'; +import { useEffect, useMemo, useRef } from 'react'; + +import type { GraphNode, GraphResponse } from '../lib.js'; + +interface SimNode extends SimulationNodeDatum, GraphNode {} +type SimEdge = SimulationLinkDatum & { kind: string }; + +interface GraphCanvasProps { + graph: GraphResponse; + /** Highlight every node authored by / belonging to this contributor. */ + highlight: string | null; + /** Time-lapse: only show fact nodes at or before this epoch (null = all). */ + cutoffEpoch: number | null; + onOpenBranch: (branch: string) => void; + onSelectContributor: (author: string | null) => void; +} + +/** Read a CSS custom property so canvas colors track the light/dark theme. */ +function token(name: string): string { + const v = getComputedStyle(document.documentElement).getPropertyValue(name).trim(); + return v || '#888'; +} + +function radius(n: GraphNode): number { + if (n.kind === 'root') return 16; + if (n.kind === 'contributor') return 7 + Math.min(14, (n.weight ?? 0) * 1.4); + if (n.kind === 'branch') return 6 + Math.min(10, (n.weight ?? 0) * 0.8); + return n.factKind === 'graduated' ? 7 : 4.5; // mature culm vs young shoot +} + +export function GraphCanvas({ + graph, + highlight, + cutoffEpoch, + onOpenBranch, + onSelectContributor, +}: GraphCanvasProps) { + const canvasRef = useRef(null); + const hoverRef = useRef(null); + const cbRef = useRef({ onOpenBranch, onSelectContributor }); + cbRef.current = { onOpenBranch, onSelectContributor }; + + // Visible subset honors the time-lapse cutoff (fact nodes only). + const { nodes, edges } = useMemo(() => { + const visible = new Set( + graph.nodes + .filter((n) => n.kind !== 'fact' || cutoffEpoch === null || (n.epoch ?? 0) <= cutoffEpoch) + .map((n) => n.id), + ); + return { + nodes: graph.nodes.filter((n) => visible.has(n.id)), + edges: graph.edges.filter( + (e) => visible.has(e.source as string) && visible.has(e.target as string), + ), + }; + }, [graph, cutoffEpoch]); + + useEffect(() => { + const canvas = canvasRef.current; + if (!canvas) return; + const ctx = canvas.getContext('2d'); + if (!ctx) return; + + const dpr = window.devicePixelRatio || 1; + const resize = (): void => { + const rect = canvas.getBoundingClientRect(); + canvas.width = Math.max(1, Math.floor(rect.width * dpr)); + canvas.height = Math.max(1, Math.floor(rect.height * dpr)); + }; + resize(); + + const simNodes: SimNode[] = nodes.map((n) => ({ ...n })); + const byId = new Map(simNodes.map((n) => [n.id, n])); + const simEdges: SimEdge[] = edges + .filter((e) => byId.has(e.source as string) && byId.has(e.target as string)) + .map((e) => ({ source: e.source as string, target: e.target as string, kind: e.kind })); + + const colors = { + root: token('--bark'), + branch: token('--branch'), + contributor: token('--branch'), + growth: token('--growth'), + pin: token('--pin'), + contrib: token('--bark-ink'), + line: token('--line-strong'), + ink: token('--ink'), + paper: token('--surface'), + }; + const nodeColor = (n: SimNode): string => { + if (n.kind === 'root') return colors.root; + if (n.kind === 'contributor') return colors.contrib; + if (n.kind === 'branch') return colors.branch; + return n.factKind === 'graduated' ? colors.growth : colors.pin; + }; + const isLit = (n: SimNode): boolean => { + if (!highlight) return true; + if (n.kind === 'contributor') return n.author === highlight; + if (n.kind === 'fact') return n.author === highlight; + return false; + }; + + const w = (): number => canvas.width / dpr; + const h = (): number => canvas.height / dpr; + + const sim = forceSimulation(simNodes) + .force( + 'link', + forceLink(simEdges) + .id((d) => d.id) + .distance((l) => ((l as SimEdge).kind === 'graduates_into' ? 70 : 45)) + .strength(0.3), + ) + .force('charge', forceManyBody().strength(-130)) + .force('center', forceCenter(w() / 2, h() / 2)) + .force( + 'collide', + forceCollide().radius((d) => radius(d) + 3), + ); + + const draw = (): void => { + ctx.save(); + ctx.scale(dpr, dpr); + ctx.clearRect(0, 0, w(), h()); + + ctx.lineWidth = 1; + for (const e of simEdges) { + const s = e.source as SimNode; + const t = e.target as SimNode; + if (s.x == null || t.x == null) continue; + const lit = isLit(s) || isLit(t); + ctx.strokeStyle = colors.line; + ctx.globalAlpha = highlight && !lit ? 0.08 : 0.4; + ctx.beginPath(); + ctx.moveTo(s.x, s.y as number); + ctx.lineTo(t.x, t.y as number); + ctx.stroke(); + } + ctx.globalAlpha = 1; + + for (const n of simNodes) { + if (n.x == null || n.y == null) continue; + const r = radius(n); + const lit = isLit(n); + ctx.globalAlpha = highlight && !lit ? 0.18 : 1; + ctx.beginPath(); + ctx.arc(n.x, n.y, r, 0, Math.PI * 2); + ctx.fillStyle = nodeColor(n); + ctx.fill(); + if (n === hoverRef.current || (highlight && lit && n.kind === 'contributor')) { + ctx.lineWidth = 2; + ctx.strokeStyle = colors.ink; + ctx.stroke(); + } + if (n.kind === 'root' || n.kind === 'contributor' || n === hoverRef.current) { + ctx.globalAlpha = highlight && !lit ? 0.18 : 1; + ctx.fillStyle = colors.ink; + ctx.font = '600 11px ui-sans-serif, system-ui, sans-serif'; + ctx.textAlign = 'center'; + ctx.fillText(n.label.slice(0, 28), n.x, n.y - r - 4); + } + } + ctx.globalAlpha = 1; + ctx.restore(); + }; + + sim.on('tick', draw); + + const pick = (ev: PointerEvent): SimNode | null => { + const rect = canvas.getBoundingClientRect(); + const x = ev.clientX - rect.left; + const y = ev.clientY - rect.top; + let best: SimNode | null = null; + let bestD = Infinity; + for (const n of simNodes) { + if (n.x == null || n.y == null) continue; + const d = Math.hypot(n.x - x, n.y - y); + if (d < radius(n) + 4 && d < bestD) { + best = n; + bestD = d; + } + } + return best; + }; + + const onMove = (ev: PointerEvent): void => { + const hit = pick(ev); + hoverRef.current = hit; + canvas.style.cursor = hit && hit.kind !== 'fact' ? 'pointer' : hit ? 'help' : 'default'; + canvas.title = hit ? tooltip(hit) : ''; + draw(); + }; + const onClick = (ev: PointerEvent): void => { + const hit = pick(ev); + if (!hit) return cbRef.current.onSelectContributor(null); + if (hit.kind === 'branch' && hit.branch) cbRef.current.onOpenBranch(hit.branch); + else if (hit.kind === 'contributor' && hit.author) + cbRef.current.onSelectContributor(hit.author); + else if (hit.kind === 'fact' && hit.branch) cbRef.current.onOpenBranch(hit.branch); + }; + + canvas.addEventListener('pointermove', onMove); + canvas.addEventListener('pointerdown', onClick); + const ro = new ResizeObserver(() => { + resize(); + sim.force('center', forceCenter(w() / 2, h() / 2)); + sim.alpha(0.3).restart(); + }); + ro.observe(canvas); + + return () => { + sim.stop(); + ro.disconnect(); + canvas.removeEventListener('pointermove', onMove); + canvas.removeEventListener('pointerdown', onClick); + }; + }, [nodes, edges, highlight]); + + return ; +} + +function tooltip(n: GraphNode): string { + if (n.kind === 'contributor') return `${n.label} — ${n.weight ?? 0} score`; + if (n.kind === 'branch') return `branch ${n.label} — ${n.weight ?? 0} facts`; + if (n.kind === 'fact') { + const who = n.author ? ` · ${n.author}` : ''; + return `${n.factKind === 'graduated' ? 'rooted fact' : 'pin'}: ${n.label}${who}`; + } + return n.label; +} diff --git a/web/views/Grove.tsx b/web/views/Grove.tsx new file mode 100644 index 0000000..859278d --- /dev/null +++ b/web/views/Grove.tsx @@ -0,0 +1,193 @@ +import { useEffect, useMemo, useRef, useState } from 'react'; + +import { useI18n } from '../i18n.js'; +import { Empty, Skeletons, useApi, type ContributorsResponse, type GraphResponse } from '../lib.js'; +import { GraphCanvas } from './GraphCanvas.js'; +import { Leaderboard } from './Leaderboard.js'; +import { ShareCard } from './ShareCard.js'; + +interface GroveProps { + project: string; + refreshKey: string; + onOpenBranch: (branch: string) => void; +} + +const TIMELAPSE_STEPS = 24; +const STEP_MS = 320; + +export function Grove({ project, refreshKey, onOpenBranch }: GroveProps) { + const { t } = useI18n(); + const q = `project=${encodeURIComponent(project)}`; + const contributors = useApi(`/api/contributors?${q}`, refreshKey); + const graph = useApi(`/api/graph?${q}`, refreshKey); + + const [selected, setSelected] = useState(null); + const [cutoff, setCutoff] = useState(null); + const [playing, setPlaying] = useState(false); + const timer = useRef(null); + + const factEpochs = useMemo( + () => + (graph.data?.nodes ?? []) + .filter((n) => n.kind === 'fact' && typeof n.epoch === 'number') + .map((n) => n.epoch as number) + .sort((a, b) => a - b), + [graph.data], + ); + const minE = factEpochs[0] ?? 0; + const maxE = factEpochs[factEpochs.length - 1] ?? 0; + const hasTimeline = factEpochs.length > 1 && maxE > minE; + const span = maxE - minE || 1; + + // Time-lapse playback steps the cutoff from earliest to latest, then resets. + useEffect(() => { + if (!playing || !hasTimeline) return; + timer.current = window.setInterval(() => { + setCutoff((prev) => { + const base = prev ?? minE; + const next = base + span / TIMELAPSE_STEPS; + if (next >= maxE) { + setPlaying(false); + return null; // snap back to the full, live grove + } + return next; + }); + }, STEP_MS); + return () => { + if (timer.current) window.clearInterval(timer.current); + }; + }, [playing, hasTimeline, minE, maxE, span]); + + const c = contributors.data; + const g = graph.data; + const loading = contributors.loading || graph.loading; + const noFacts = (g?.nodes.filter((n) => n.kind === 'fact').length ?? 0) === 0; + const noContributors = (c?.contributors.length ?? 0) === 0; + + const title = ( +

+ {t('grove.title.a')} + {t('grove.title.em')} +

+ ); + + if (loading && !c && !g) { + return ( + <> + {title} + + + ); + } + + if (g && noContributors && noFacts) { + return ( + <> + {title} + + + ); + } + + const factCount = g?.nodes.filter((n) => n.kind === 'fact').length ?? 0; + const branchCount = g?.nodes.filter((n) => n.kind === 'branch').length ?? 0; + + return ( + <> +
+ {title} +
+ {c ? ( + + ) : null} +
+
+

+ {t('grove.sub.a')} + {project} + {t('grove.sub.b')}{' '} + {c?.source === 'git-fallback' ? ( + {t('grove.source.git')} + ) : c && c.unattributed_total > 0 ? ( + + {c.unattributed_total === 1 + ? t('grove.unattributed.one') + : t('grove.unattributed', { n: c.unattributed_total })} + + ) : null} +

+ +
+
+ {g ? ( + + ) : ( + + )} + {hasTimeline ? ( +
+ + { + setPlaying(false); + const v = Number(e.target.value); + setCutoff(v >= maxE ? null : v); + }} + aria-label={t('timelapse.play')} + /> + + {cutoff === null ? t('timelapse.now') : t('timelapse.growing')} + +
+ ) : null} +
+ +
+
+

{t('grove.contributors')}

+ + {c?.source === 'git-fallback' ? t('grove.by.commits') : t('grove.by.score')} + +
+ {c && c.contributors.length > 0 ? ( + + ) : ( + + )} +
+
+ + ); +} diff --git a/web/views/Leaderboard.tsx b/web/views/Leaderboard.tsx new file mode 100644 index 0000000..e96a620 --- /dev/null +++ b/web/views/Leaderboard.tsx @@ -0,0 +1,100 @@ +import { useI18n } from '../i18n.js'; +import { BADGE_META, clsx, timeAgo, type ContributorStat, type GroveSource } from '../lib.js'; + +interface LeaderboardProps { + contributors: ContributorStat[]; + source: GroveSource; + selected: string | null; + onSelect: (author: string | null) => void; + onOpenBranch: (branch: string) => void; +} + +const RANK_SHOOT = ['🥇', '🥈', '🥉']; + +export function Leaderboard({ + contributors, + source, + selected, + onSelect, + onOpenBranch, +}: LeaderboardProps) { + const { t } = useI18n(); + const top = contributors[0]?.value_score ?? 1; + const isGit = source === 'git-fallback'; + + return ( +
+ {contributors.map((c, i) => { + const active = c.author === selected; + const height = Math.max(6, Math.round((c.value_score / top) * 100)); + return ( +
+ + {active && c.branches.length > 0 ? ( +
+ {c.branches.map((b) => ( + + ))} +
+ ) : null} +
+ ); + })} +
+ ); +} diff --git a/web/views/Overview.tsx b/web/views/Overview.tsx index d1cade2..449c6ef 100644 --- a/web/views/Overview.tsx +++ b/web/views/Overview.tsx @@ -1,3 +1,4 @@ +import { useI18n } from '../i18n.js'; import { Empty, Skeletons, @@ -6,6 +7,7 @@ import { useApi, type Async, type BranchesResponse, + type ContributorsResponse, type ShareStatus, } from '../lib.js'; @@ -14,61 +16,95 @@ interface OverviewProps { branches: Async; refreshKey: string; onOpenBranch: (branch: string) => void; + onOpenGrove: () => void; } -export function Overview({ project, branches, refreshKey, onOpenBranch }: OverviewProps) { +export function Overview({ + project, + branches, + refreshKey, + onOpenBranch, + onOpenGrove, +}: OverviewProps) { + const { t } = useI18n(); const q = `project=${encodeURIComponent(project)}`; const share = useApi(`/api/share-status?${q}`, refreshKey); + const contributors = useApi(`/api/contributors?${q}`, refreshKey); const rows = branches.data?.branches ?? []; const maxCount = rows.reduce((m, b) => Math.max(m, b.count), 1); const current = branches.data?.current_branch ?? null; const s = share.data; + const cs = contributors.data; + const topContributor = cs?.contributors[0] ?? null; return ( <>

- Shared roots for your codebase. + {t('ov.lede.a')} {t('ov.lede.em')} + {t('ov.lede.b')}

- Every branch, every pinned decision, every fact that graduated to repo-wide knowledge — read - straight from git. This is what your team knows about {project}. + {t('ov.sub.a')} + {project} + {t('ov.sub.b')}

{rows.length}
-
branches with memory
+
{t('ov.stat.branches')}
{s ? s.total_pins : '—'}
-
pinned decisions
+
{t('ov.stat.pins')}
{s ? s.graduated : '—'}
-
graduated repo-wide
+
{t('ov.stat.graduated')}
{s ? s.pending_export : '—'}
-
pins not shared yet
+
{t('ov.stat.pending')}
-
-

Branch graph

- - {s?.has_sync_dir ? '.tre-mem/ present — shared via git' : 'no .tre-mem/ yet — local only'} + + +
+

{t('ov.section.branchGraph')}

+ {s?.has_sync_dir ? t('ov.hint.synced') : t('ov.hint.local')}
{branches.loading ? ( ) : rows.length === 0 ? ( - + ) : ( rows.map((b) => { const isCurrent = b.branch === current; @@ -77,7 +113,7 @@ export function Overview({ project, branches, refreshKey, onOpenBranch }: Overvi key={b.branch} className={clsx('branch-row', isCurrent && 'current')} onClick={() => onOpenBranch(b.branch)} - aria-label={`Open branch ${b.branch}`} + aria-label={b.branch} > - {b.count} tagged + {b.count} {t('branch.tagged')} - {b.pins} pins + {b.pins} {t('branch.pins')} {timeAgo(b.last_active_epoch)} diff --git a/web/views/Search.tsx b/web/views/Search.tsx index 2457103..212c172 100644 --- a/web/views/Search.tsx +++ b/web/views/Search.tsx @@ -1,5 +1,6 @@ import { useEffect, useState } from 'react'; +import { useI18n } from '../i18n.js'; import { Badge, Empty, clsx, api, type SearchResponse, type Breakdown } from '../lib.js'; interface SearchProps { @@ -10,6 +11,7 @@ interface SearchProps { const SIGNALS: Array = ['semantic', 'branch', 'recency', 'graduated', 'pin']; export function Search({ project, branch }: SearchProps) { + const { t } = useI18n(); const [q, setQ] = useState(''); const [res, setRes] = useState(null); const [loading, setLoading] = useState(false); @@ -41,43 +43,43 @@ export function Search({ project, branch }: SearchProps) { return ( <>

- Search + {t('s.title')}

- Branch-aware retrieval + {t('s.sub.a')} {branch ? ( <> - {' '} - on {branch} + {t('s.sub.on')} + {branch} ) : null} - . Each hit shows why it ranked — semantic, branch, recency, graduated, pin. + {t('s.sub.b')}

setQ(e.target.value)} />
{res && res.mode === 'shared-only' ? (

- claude-mem not detected — searching shared pins + graduated only. + {t('s.sharedonly')}

) : null} {q.trim() === '' ? ( - + ) : loading && hits.length === 0 ? ( - + ) : hits.length === 0 ? ( - + ) : (
{hits.map((h, i) => ( diff --git a/web/views/ShareCard.tsx b/web/views/ShareCard.tsx new file mode 100644 index 0000000..456c3f6 --- /dev/null +++ b/web/views/ShareCard.tsx @@ -0,0 +1,121 @@ +import { useCallback } from 'react'; + +import { useI18n } from '../i18n.js'; +import type { ContributorStat, GroveSource } from '../lib.js'; + +interface ShareCardProps { + project: string; + contributors: ContributorStat[]; + source: GroveSource; + facts: number; + branches: number; +} + +function token(name: string, fallback: string): string { + const v = getComputedStyle(document.documentElement).getPropertyValue(name).trim(); + return v || fallback; +} + +/** + * Render a bamboo-framed summary of the grove to an offscreen canvas and hand it + * back as a PNG download — the shareable, post-it-anywhere artifact. Drawn fresh + * each click so it always reflects current stats and the active theme. + */ +export function ShareCard({ project, contributors, source, facts, branches }: ShareCardProps) { + const { t } = useI18n(); + const onShare = useCallback(async () => { + // Canvas silently falls back to a system font if a face isn't loaded yet — + // pull the weights we draw with so the PNG is crisp on first click. + try { + await Promise.all([ + document.fonts?.load("700 64px 'Baloo 2'"), + document.fonts?.load("800 84px 'Baloo 2'"), + document.fonts?.load("600 40px 'Be Vietnam Pro'"), + document.fonts?.load("400 30px 'Be Vietnam Pro'"), + ]); + } catch { + /* fonts unavailable (offline) — fall back to system, still renders */ + } + const W = 1200; + const H = 630; + const canvas = document.createElement('canvas'); + canvas.width = W; + canvas.height = H; + const ctx = canvas.getContext('2d'); + if (!ctx) return; + + const bark = token('--bark', '#7a5b2e'); + const growth = token('--growth', '#3aa35a'); + const paper = token('--surface', '#ffffff'); + const ink = token('--ink', '#1c1c1c'); + const soft = token('--ink-soft', '#555'); + + ctx.fillStyle = paper; + ctx.fillRect(0, 0, W, H); + // bamboo side-stripe + ctx.fillStyle = growth; + ctx.fillRect(0, 0, 16, H); + ctx.fillStyle = bark; + ctx.fillRect(16, 0, 6, H); + + const display = "'Baloo 2', system-ui, sans-serif"; + const body = "'Be Vietnam Pro', system-ui, sans-serif"; + + ctx.fillStyle = ink; + ctx.font = `700 64px ${display}`; + ctx.fillText('🎋 ' + project, 80, 140); + + ctx.fillStyle = soft; + ctx.font = `400 30px ${body}`; + ctx.fillText(t('card.tagline'), 80, 190); + + const top = contributors[0]; + ctx.fillStyle = ink; + ctx.font = `600 40px ${body}`; + if (top) { + const metric = + source === 'git-fallback' + ? t('metric.commits', { n: top.commits }) + : t('metric.score', { n: top.value_score }); + ctx.fillText(`🥇 ${top.author}`, 80, 300); + ctx.fillStyle = soft; + ctx.font = `400 30px ${body}`; + ctx.fillText(metric, 80, 344); + } + + const stats: Array<[string, string]> = [ + [String(contributors.length), t('card.contributors')], + [String(facts), t('card.facts')], + [String(branches), t('card.branches')], + ]; + stats.forEach(([n, k], i) => { + const x = 80 + i * 360; + ctx.fillStyle = growth; + ctx.font = `800 84px ${display}`; + ctx.fillText(n, x, 500); + ctx.fillStyle = soft; + ctx.font = `400 28px ${body}`; + ctx.fillText(k, x, 540); + }); + + ctx.fillStyle = soft; + ctx.font = '400 24px ui-monospace, monospace'; + ctx.fillText('npm i -g tre-mem', 80, 600); + + canvas.toBlob((blob) => { + if (!blob) return; + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = `${project}-grove.png`; + a.click(); + URL.revokeObjectURL(url); + }, 'image/png'); + }, [project, contributors, source, facts, branches, t]); + + return ( + + ); +} diff --git a/web/views/TeamMemory.tsx b/web/views/TeamMemory.tsx index e5dcb0d..0cf7673 100644 --- a/web/views/TeamMemory.tsx +++ b/web/views/TeamMemory.tsx @@ -1,3 +1,4 @@ +import { useI18n } from '../i18n.js'; import { Badge, Empty, Skeletons, timeAgo, useApi, type Graduated, type Pin } from '../lib.js'; interface TeamMemoryProps { @@ -16,6 +17,7 @@ interface GradResponse { } export function TeamMemory({ project, refreshKey, onOpenBranch }: TeamMemoryProps) { + const { t } = useI18n(); const q = `project=${encodeURIComponent(project)}`; const pins = useApi(`/api/pins?${q}`, refreshKey); const grad = useApi(`/api/graduated?${q}`, refreshKey); @@ -28,48 +30,50 @@ export function TeamMemory({ project, refreshKey, onOpenBranch }: TeamMemoryProp return ( <>

- Team memory + {t('tm.title')}

- What the team chose to remember about {project} — pinned decisions and the - facts that graduated to repo-wide knowledge. Shared rows live in .tre-mem/ and - travel through git; run tre share to publish them. + {t('tm.sub.a')} + {project} + {t('tm.sub.b')} + .tre-mem/ + {t('tm.sub.c')} + tre share + {t('tm.sub.d')}

{!loading && pendingCount > 0 ? (

- {pendingCount} pin{pendingCount === 1 ? '' : 's'} not shared yet — run{' '} - tre share to push {pendingCount === 1 ? 'it' : 'them'} to your team's git. + {t('tm.callout.a', { n: pendingCount })} + tre share + {t('tm.callout.b')}

) : null} {loading ? ( ) : pinRows.length === 0 && gradRows.length === 0 ? ( - + ) : ( <>
-

Pinned decisions

+

{t('tm.pinned')}

{pinRows.length}
{pinRows.map((p) => (
- pin - {p.title ?? p.note ?? '(pinned fact)'} + {t('entry.pin')} + {p.title ?? p.note ?? t('entry.pinnedfact')} - {p.shared ? 'shared via git ✓' : 'not shared yet'} + {p.shared ? t('entry.shared.git') : t('entry.notshared')}
{p.body && p.body !== p.title ?

{p.body}

: null} {p.note && p.note !== p.title ? (

- note · {p.note} + {t('entry.note')} · {p.note}

) : null}
@@ -86,25 +90,22 @@ export function TeamMemory({ project, refreshKey, onOpenBranch }: TeamMemoryProp
-

Graduated facts

+

{t('tm.graduated')}

{gradRows.length}
{gradRows.length === 0 ? ( - + ) : (
{gradRows.map((g) => (
- graduated + {t('entry.graduated')} {g.title ?? `#${g.observation_id}`}
{g.body ?

{g.body}

: null}
- from + {t('entry.from')}