The summary cards only ever counted public activity, so a period spent
mostly or entirely in private repos read as near-idle — or vanished
altogether — while the contribution graph directly above it showed that
period as busy. The two views disagreed with no explanation on the page.
`activity/summary` now emits, alongside the named per-repo rows, at most
one row per period with `private = true` and a null source/repo, holding
that period's private-repo change count. `source` and `repo` on
`RepoPeriodCount` become nullable to carry it. The card counts it towards
the period's changes but never towards its repository count (the lump
covers an unknown number of repos), renders it last, unlinked, and
without a language bar — a language mix would narrow the lump back down
to the repo it was hiding.
Not split by forge: the per-period total is already derivable from
`activity/daily`, which counts private activity, so publishing it adds no
information that isn't on the contribution graph already. A per-forge
breakdown would be new. `?source=` does narrow the aggregate, which makes
per-forge counts recoverable by diffing two requests — chosen knowingly,
because a summary that contradicts the filter it was given is worse than
a forge attribution on an unattributed count.
`include_private` keeps its meaning: with it set, the named branch takes
everything and the aggregate is empty, so a future authenticated view
sees repos rather than a lump.
Verified against postgres 16 with seeded public/private history: the
summary now reconciles with `activity/daily` per period (3 public + 7
private = the graph's 10), a private-only period appears at all where it
previously did not, no aggregate row is emitted for a period with nothing
private, and the row sorts last within its period. Prerendered `/activity`
against that data renders "8 changes in 1 repository + private work"
with a muted `private 6` row, "4 changes in private repositories" for the
private-only day, and contains zero occurrences of the private repo's
name in either the markup or the dehydrated query cache.
Closes #7
`events.public` was decided once, when a row was ingested, from whatever
the forge reported at that moment — and every poller is incremental (the
github events feed caps at 90 days, search at its top-1000 window, the
per-repo scanner at a `since` cursor, the gitea feed at page 1 after the
first run). Nothing ever revisited a repo, so flipping one to private
upstream only relabelled whatever activity happened afterwards: its
history kept serving commit messages, issue titles and the repo name
indefinitely. The reverse flip was equally frozen.
The github and gitea sources now run a reconciliation pass before
ingesting. github reuses its existing repo discovery, which already
re-reads `private`/`isPrivate` for everything reachable, and only spends
a request on repos we're still exposing that discovery didn't return;
gitea has no equivalent bulk endpoint, so it asks per repo, once a day
rather than once a tick. Repos already hidden are skipped — they can't
leak, and staying hidden is the safe direction to err in. A 404 counts
as private (with the user's own token, a repo still in reach answers 200
even when private, so 404 means gone or transferred), while rate limits
and transient errors flip nothing; the pass runs in both directions, so
a spurious hide is undone by the next successful poll.
That needs a repo key the worker can UPDATE against, hence `events.repo`
— a stored generated column, and now the single definition of the
payload -> repo mapping that list_events, list_projects,
activity_summary and language_daily_counts each carried their own copy
of. Consolidating them fixes an attribution gap along the way:
/search/issues items carry neither `repo.name` nor
`repository.full_name`, only `repository_url`, so every issue and PR
backfilled through search resolved to NULL in all four queries. Those
events now attach to their repo, which both makes them reconcilable and
means they show up in /projects and /activity/summary.
Also closes a leak that predates the flip problem: `/v1/languages/repos`
had no visibility gate at all, and repo_languages is populated for every
repo the worker discovers, private ones included. Repo names were on the
wire (and baked into the prerendered HTML via the dehydrated query
cache) regardless of what `events.public` said. Rather than a second
visibility column to keep in sync, the response now derives it — a
repo's languages are exposed exactly when at least one of its events is.
Verified against postgres 16: the generated column extracts every
payload shape the four sources produce (and NULLs a non-github
`repository_url`), the reconciliation UPDATE flips all three github
event shapes for a repo in one statement and is a no-op on re-run, the
gitea host filter excludes other hosts while treating rows predating the
`_host` stamp as local, and /v1/languages/repos drops a repo once its
events go private.
Closes #6
The bare /activity route (no timespan modifier) now shows one timeline
card per period — each listing the repos touched and the number of
changes in each — with a daily/weekly/monthly/yearly bucket selector,
so the view answers "where did my time go" at a glance. Each card
drills down to the existing per-event view via /activity/from..to,
which is unchanged.
Backed by a new GET /v1/activity/summary?from&to&bucket&source
endpoint: public events only (private repo names never leak), bucketed
with date_trunc on the UTC-shifted timestamp, reusing the same
per-source repo extraction as /v1/projects. Blog events carry no repo
and are excluded.
The /activity prerender now bakes the daily summary instead of the
event list. On the first deploy the live API won't have the endpoint
yet when build-web prerenders; that route degrades to the client-side
fallback and self-heals on the daily refresh re-bake.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LY4nbfHC9qt726gVrdAh3X
posts are markdown files with yaml frontmatter (title, slug, date;
optional draft/public) in the grenade/blog repo. the worker's new
BlogSource polls the repo — one branch-tip request when nothing
changed — and upserts posts into events with source='blog' and
occurred_at from the frontmatter date, so imported posts keep their
original publish dates and backfill the contribution graph.
- new /v1/blog and /v1/blog/{slug} endpoints over the existing
EventReader port; drafts stay hidden via the public gate
- new /blog and /blog/:slug routes, nav link, activity-feed entry
with post icon and filter toggle; relative image srcs resolve to
gitea raw urls
- shared Markdown component extracted from ProjectPage
- vite proxy target overridable via API_PROXY_TARGET for local dev
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Complements the existing avg-by-weekday chart with its orthogonal
partner: which hour of the day the user typically commits. The api
buckets events by EXTRACT(hour FROM occurred_at AT TIME ZONE $tz) so
the chart matches the clock the user sees rather than UTC; the UI
passes the browser's resolved IANA timezone. Renders as 24 mini-bars
below the weekday chart with labels every 4 hours and per-bar
tooltips showing the average events/day at that hour.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Full-stack feature showing programming languages by commit activity
as a stream graph on the dashboard.
Backend:
- migration: repo_languages table (source, repo, language, bytes, color)
- worker: fetch language breakdowns via GitHub GraphQL (batched,
20 repos/request) and Gitea REST API during poll cycles
- API: GET /v1/languages/daily (daily commit counts per language),
GET /v1/languages/repos (all stored repo language data)
- fix timezone bug in daily_counts and language_daily_counts: the
PostgreSQL server timezone (Europe/Sofia, UTC+3) shifted day
boundaries, miscounting events near midnight. Now uses explicit
UTC boundaries in generate_series JOINs.
- use per-source CASE for repo name extraction in language query
to match gitea payload structure (repo.full_name vs repo.name)
- Gitea languages use GitHub colors via COALESCE fallback
Frontend:
- LanguageStreamGraph component: pure SVG stream graph, weekly
buckets, centered baseline, top 8 languages + Other, GitHub
canonical language colors, legend with color dots
- DashPage/ProjectPage: fetch repo languages once via new endpoint
instead of per-repo forge proxy calls (eliminates 200+ GitHub
API calls and 403 rate limit errors)
- removed fetchLanguages forge proxy wrapper (dead code)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Add /v1/activity/daily endpoint returning per-day event counts via
generate_series + LEFT JOIN. Frontend renders an SVG contribution
graph with circles colored by quantile-based thresholds. Clicking a
day navigates to /activity/YYYY-MM-DD showing that day's events.
New /activity/:timespan route parses single dates (YYYY-MM-DD) and
ranges (YYYY-MM-DD..YYYY-MM-DD) from the URL to initialize the
activity timeline filter.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Add repo filter param to /v1/events (SQL COALESCE across payload
shapes per source). New /project/:source/* route renders a filtered
activity timeline for a single repo. Dashboard cards link to the
drill-down page.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Restructure routes: / and /dash show a project overview dashboard,
/activity hosts the existing timeline, /cv remains. Shared Layout
component provides consistent nav header and footer across all routes.
New /v1/projects endpoint aggregates per-repo activity stats (commits,
issues, PRs, date range) from existing event data via SQL. Dashboard
ranks projects by weighted recency + volume score and renders a card
grid.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
The DB now stores everything GitHub will give us, the API only ever
returns public events (for now).
Endpoint switch in the github poller: when GITHUB_TOKEN is set we
hit /users/{u}/events (public + private), otherwise fall back to
/users/{u}/events/public. Either way each event's top-level `public`
boolean is captured into a new column.
Schema:
migration 0003_event_public.sql adds events.public BOOLEAN NOT NULL
DEFAULT true, plus an index on (public, occurred_at DESC).
Wire:
Event gains a `public: bool` field.
EventQuery gains `include_private: bool` (default false).
list_events and source_summaries gate on it.
moments-api pins include_private = false at every call site —
threading it as a query param is a future-auth concern, not now.
The default-true on the column keeps existing rows correct: the 11
events already in the DB came from /events/public and are genuinely
public.
After this change, clear poller_state so the next worker run does a
fresh backfill via /events:
DELETE FROM poller_state WHERE source = 'github';
Tests: +2 in github poller (private flag captured, default-public
on missing field) — 10 total green.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
GET /v1/events now returns the presentation form rather than raw
upstream payloads. The frontend stays dumb: it renders title /
subtitle / body segments and picks an icon from a small kebab-case
enum. Title and subtitle are arrays of {text} | {text, url} segments
so the UI can interleave plain copy with anchors without parsing.
New entities (in moments-entities):
TimelineItem — id, source, action, occurred_at, icon, title, subtitle, body
TitleSegment — Text | Link
TimelineBody — Markdown | Commits | Links
CommitSummary — sha, short_sha, message, url, author
TimelineIcon — kebab-case enum; UI falls back to Generic on unknowns
Reshape lives in moments-core::presentation, dispatched by source.
github.rs covers the event types observed on grenade's feed:
PushEvent, PullRequest{,Review,ReviewComment}Event, Issues{,Comment}Event,
Create/Delete/Fork/Watch/Release/CommitComment/PublicEvent.
Anything else falls back to a generic "<action> on <repo>" line.
Other sources (gitea, hg, bugzilla) currently use a stub fallback;
they get their own reshape modules in steps 5 and 6.
4 unit tests cover the load-bearing cases (push commit list, merged
PR icon swap, issue-comment markdown body, unknown-event fallback).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>