# CLAUDE.md This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. ## Project Overview **moments** is a personal activity timeline and portfolio site. It ingests developer activity from multiple forges (GitHub, Gitea, Mercurial, Bugzilla), stores raw JSON payloads in PostgreSQL, and serves a React frontend showing contribution graphs, a ranked project dashboard, and a filterable activity timeline. ## Architecture Hexagonal (ports & adapters) Rust backend with a React/TypeScript frontend. ### Crate Dependency Graph ``` moments-entities — pure types/DTOs, no DB or HTTP deps ^ moments-core — port traits (EventReader, EventWriter, EventSource, PollerStateStore) + presentation reshape + poller loop ^ moments-data — sole adapter: PgStore implements all core traits + EventSource impls (github, gitea, hg, bugzilla) + SQL migrations ^ moments-api — axum HTTP API binary (read-only, connects as moments_ro) moments-worker — ingestion daemon binary (runs migrations, connects as moments_rw) ``` ### Key Design Decisions - **Raw payload storage**: upstream JSON is stored verbatim in `events.payload` (JSONB). The `reshape()` function in `moments-core/src/presentation.rs` transforms payloads into `TimelineItem` at request time — no re-ingestion needed to change presentation. - **Public/private gate**: `events.public` boolean controls API visibility. Only `public = true` rows are served. - **Wire types are hand-maintained**: `ui/src/api/client.ts` mirrors Rust entity types manually. - **Migrations**: run automatically on worker startup via `sqlx::migrate!`. The API binary never runs migrations. ### Frontend React 19 + Vite 6 (SWC) + TypeScript + Bootstrap 5. State/data via `@tanstack/react-query`. Package manager is **pnpm**. Routes: `/` (dashboard), `/activity` (timeline), `/project/:source/*` (project detail), `/blog` + `/blog/:slug` (blog), `/cv` (resume). ## Build & Dev Commands ### Rust ```sh cargo build --workspace # build all crates cargo build --workspace --release # release build cargo clippy --workspace # lint cargo fmt --check # format check cargo test --workspace # run tests # Run binaries (need DATABASE_URL) DATABASE_URL=postgres://localhost/moments cargo run -p moments-api DATABASE_URL=postgres://localhost/moments cargo run -p moments-worker ``` ### Frontend ```sh cd ui pnpm install # install deps pnpm dev # dev server on :5173 (proxies /api/* to localhost:8080) pnpm lint # tsc --noEmit type-check pnpm build # production build: client bundle, then prerender ``` The build is three steps (see `ui/package.json`): `tsc -b` → `vite build` (client SPA) → `pnpm run prerender` (an SSR build of `src/entry-server.tsx`, driven by `run-prerender.mjs`, that bakes one static `index.html` per route into `ui/dist/`). The prerender fetches data at build time from `VITE_API_BASE` (default `https://rob.tn/api/v1`) and inlines the dehydrated react-query cache as `window.__RQ_STATE__`; the client hydrates it and refetches live. So a plain `curl` of any route returns full content (for crawlers / AI screeners), while the browser keeps full interactivity. Date formatting in the shared tree is pinned to UTC + explicit field widths so SSR and client hydration match byte-for-byte. ## Database PostgreSQL with three migrations in `crates/moments-data/migrations/`. Two roles: `moments_rw` (worker, full access) and `moments_ro` (API, SELECT-only). ## API Endpoints All under `/v1/`: `healthz`, `events`, `sources`, `projects`, `blog`, `blog/{slug}`, `activity/daily`, `forge/{source}/*`, `og/contributions.png`. Blog posts are markdown files with YAML frontmatter (`title`, `slug`, `date`; optional `draft`/`public`) in the `grenade/blog` Gitea repo. The worker's `BlogSource` polls the repo (branch-tip sha as change detection) and upserts posts into `events` with `source='blog'` and `occurred_at` from the frontmatter date, so imported posts keep their original publish dates. The repo is the source of truth for the full set of posts: publishing, editing, renaming, and deleting are all just pushes — each poll upserts the current tree and prunes `source='blog'` rows that are no longer in it. ## Deployment CI-driven via **Gitea Actions** (`.gitea/workflows/`), the source of infra truth (hosts/ports/paths live in the workflow `env`, not a manifest): - `deploy.yml` — on push to `main` (or manual dispatch): lint/test gate, build the api + worker as static musl binaries (pure-rustls, so no glibc skew) and the prerendered web bundle, then deploy each component over SSH as the `gitea_ci` user with scoped sudo (`asset/sudoers.d/`). Services run under systemd with hardened units; the api/worker reach postgres over mTLS using the host cert. - `refresh.yml` — daily `schedule:` (+ manual): rebuilds and redeploys only the web tier, re-baking the prerendered crawler snapshot from the current gist (CV) and activity API without bouncing the api/worker. One-time per-host provisioning (the `gitea_ci` user, its `authorized_keys`, the scoped sudoers drop-in) is `script/infra-setup.sh`, run once per host by an operator. Gitea repo secrets: `RSYNC_SSH_KEY`, `QUERY_GITHUB_TOKEN`, `QUERY_GITEA_TOKEN` (the bare `GITHUB_TOKEN`/`GITEA_TOKEN` names are reserved by Actions, so the worker poller's tokens use the `QUERY_` prefix). Nginx reverse-proxies `/api/` to the API host and serves the per-route static files via `try_files $uri $uri/ /index.html`. `./script/deploy.sh` is the legacy operator-driven path (workstation + `pass`); it still works and the Gitea workflow supersedes it. Remove it once the workflow is validated on the live hosts.