docs: plan for records card (260509-0913)

This commit is contained in:
2026-05-09 09:47:38 +07:00
parent 7168c96dc9
commit 95e26bdfb5
4 changed files with 276 additions and 0 deletions
@@ -0,0 +1,59 @@
---
phase: 1
title: "Records computation helpers"
status: completed
priority: P1
effort: "1h"
dependencies: []
---
# Phase 1: Records computation helpers
## Overview
Add pure functions that derive the 6 records from `Profile` data already in memory. Live in `internal/card/records.go` next to the card itself — no new public package surface needed.
## Requirements
**Functional**
- `peakDay(days []DailyContribution) (count int, date time.Time)` — argmax; ties → earliest date
- `peakMonth(days []DailyContribution) (count int, ym time.Time)` — sum per `YYYY-MM`, argmax; ties → earliest month
- `firstActiveDay(days []DailyContribution) time.Time` — first day with `Count > 0`; zero time if none
- `activeDaysCount(days []DailyContribution) int` — count of days where `Count > 0`
- `accountAgeYears(createdAt, now time.Time) float64` — fractional years, 1 decimal
- `languagesUsed(stats []LangStat) int``len(stats)` (already deduped upstream)
**Non-functional**
- O(n) over `DailyContributionsAllTime` for all daily-derived records (single pass acceptable)
- Zero allocations for argmax helpers (just iterate)
- All helpers package-private (`unexported`) — only the card uses them
## Architecture
Single file `internal/card/records.go` exporting the `recordsCard` struct (Phase 2) and these helpers. No mutation of `Profile`. Empty-input handling: return zero values; the card layer decides what to display.
## Related Code Files
- Create: `internal/card/records.go` (helpers section)
- Read for context: `internal/github/model.go` (Profile fields), `internal/card/stats.go` (card pattern)
## Implementation Steps
1. Create `internal/card/records.go` with package-private helpers.
2. Implement `peakDay`: iterate, track `(maxCount, earliestDate)`; tie-break on earlier date.
3. Implement `peakMonth`: bucket sum into a `map[time.Time]int` keyed by `time.Date(year,month,1,...)`; argmax with same tie-break.
4. Implement `firstActiveDay`: first index where `Count > 0`; assumes input is chronological (matches existing fetcher).
5. Implement `activeDaysCount`: counter of `Count > 0`.
6. Implement `accountAgeYears`: `now.Sub(createdAt).Hours() / 24 / 365.25`, round to 1 decimal.
7. Implement `languagesUsed`: trivial wrapper for symmetry/testability.
## Success Criteria
- [ ] All 6 helpers compile (`go build ./...`)
- [ ] Helpers produce correct results on hand-rolled fixtures
- [ ] Empty-data inputs return zero values without panic
## Risk Assessment
- **Risk:** `peakMonth` map ordering non-deterministic — Go map iteration is randomized, so two months tied at the same count may produce different "winners" across runs. **Mitigation:** scan keys, sort by date ascending, then argmax on first occurrence. Alternative: track `(count, ym)` during the bucket-fill pass instead of a second pass.
- **Risk:** `accountAgeYears` rounding inconsistent with existing profile-card "age" string. **Mitigation:** match `internal/card/profile.go` convention (check there before implementing).
@@ -0,0 +1,95 @@
---
phase: 2
title: "SVG card rendering"
status: completed
priority: P1
effort: "1.5h"
dependencies: [1]
---
# Phase 2: SVG card rendering
## Overview
Render the 6 records as a `records.svg` card mirroring `stats.svg`'s row layout (icon + label + right-aligned accent value). Register the card in `allCards` so `RenderAll` picks it up.
## Requirements
**Functional**
- File output: `records.svg` (340×200, matches all other cards)
- Title: `Records (all time)`
- 6 rows in this order:
1.**Best day**`{count} on YYYY-MM-DD`
2. 🔥 **Best month**`{count} in MMM YYYY` (e.g. `612 in Mar 2026`)
3. 🌱 **First contribution**`YYYY-MM-DD`
4. 📆 **Active days**`{formatInt(count)}`
5.**On GitHub**`{years.1f} years`
6. 🌐 **Languages used**`{count}`
- Empty-data fallback: render the card with rows showing `—` (em-dash) for missing values, **never panic**
**Non-functional**
- Reuse `header()` / `footer` helpers from `internal/card/svg.go`
- Reuse `escapeXML()` and `formatInt()` from existing helpers
- Octicon paths live in `icons.go` (1-3 new icons may be needed)
## Architecture
```
internal/card/records.go
├── recordsCard struct{}
├── (recordsCard) Filename() string → "records.svg"
├── (recordsCard) SVG(p, t) → builds 6 statRow-style entries, emits SVG
└── helpers from Phase 1
```
Register in `internal/card/card.go`:
```go
var allCards = []Card{
...
contributionsByYearCard{},
recordsCard{}, // new — appended last
}
```
**Icon strategy (minimize new octicon paths):**
- Best day → `iconCommit` (existing, fits "activity peak")
- Best month → `iconStar` (existing, fits "highlight")
- First contribution → **new** `iconCalendar` octicon
- Active days → `iconRepos` (existing, decent fallback) OR **new** `iconHistory`
- On GitHub → `iconClock` (existing)
- Languages used → **new** `iconGlobe` octicon
Net: **2-3 new octicon paths** added to `icons.go` from primer/octicons. Final icon set decided during implementation — fewer-new is fine if reuse looks clean.
## Related Code Files
- Create: `internal/card/records.go`
- Modify: `internal/card/card.go` (register `recordsCard{}` in `allCards`)
- Modify: `internal/card/icons.go` (add 2-3 octicon paths)
- Read for context: `internal/card/stats.go`, `internal/card/svg.go`
## Implementation Steps
1. Add octicon paths to `icons.go` (copy from `primer/octicons` 16×16 viewBox).
2. Create `recordsCard` struct + `Filename()`.
3. Implement `SVG()`:
- Compute 6 records via Phase-1 helpers (`time.Now()` for `accountAgeYears`).
- Build `[]statRow` (or local equivalent) with formatted strings.
- Empty-data branch: if `len(p.DailyContributionsAllTime) == 0`, label/value rows still render but values are `—`.
- Emit header → 6 rows (same coords as stats card: `rowX=20, rowY0=55, rowDY=20, iconSize=12, valueX=320`) → footer.
4. Register in `allCards`.
5. `go build ./...` — confirm clean compile.
6. Run end-to-end render against the demo fixture if available, else inspect generated SVG manually.
## Success Criteria
- [ ] `go build ./...` clean
- [ ] Card filename `records.svg` registered and produced by `RenderAll`
- [ ] SVG validates (no XML parse errors) for all 65 themes
- [ ] Empty-data fixture produces a legible card (no panic, no missing values causing layout shift)
## Risk Assessment
- **Risk:** Row spacing collides at 6 rows × 20px DY + 55px Y0 = 175px; fits within 200px frame. **Mitigation:** verified against stats card (which has 7 rows at the same cadence). Safe.
- **Risk:** Long values (e.g. "612 in Mar 2026") overrun the value column at narrow themes. **Mitigation:** value text uses `text-anchor="end"` at `valueX=320` — overflows go left, never clip. If labels collide with values on a row, shorten label phrasing (e.g. "Best month" not "Best month ever").
- **Risk:** New octicon paths copied incorrectly (broken SVG). **Mitigation:** copy from `primer/octicons` source; smoke-test render in browser.
@@ -0,0 +1,70 @@
---
phase: 3
title: "Tests + README"
status: completed
priority: P2
effort: "45m"
dependencies: [1, 2]
---
# Phase 3: Tests + README
## Overview
Cover the 6 record helpers with unit tests, add a card-level smoke test, and document the new card in `README.md` so it shows up in the gallery preview.
## Requirements
**Functional**
- Helpers tested for: empty input, single-day, ties, all-zero counts
- Card-level test asserts `records.svg` filename + key strings appear (label rows, formatted values)
- README updated:
- New row in card table at the same row index where it lands in `allCards`
- Dracula preview cell pointing at `./demo/dracula/records.svg`
**Non-functional**
- Test file follows existing convention (`*_test.go` in `internal/card`)
- No flaky time-based tests — pass `now` explicitly into `accountAgeYears`
## Architecture
```
internal/card/records_test.go
├── TestPeakDay (incl. ties → earliest)
├── TestPeakMonth (incl. ties → earliest, multi-month)
├── TestFirstActiveDay (incl. all-zero → zero time)
├── TestActiveDaysCount
├── TestAccountAgeYears (fixed createdAt vs fixed now)
├── TestLanguagesUsed
└── TestRecordsCardSVG (smoke: contains title + 6 row labels)
```
## Related Code Files
- Create: `internal/card/records_test.go`
- Modify: `README.md` (card table + dracula gallery cell)
- Read for context: `internal/card/weekday_start_test.go` (existing test patterns)
## Implementation Steps
1. Write helper unit tests with hand-rolled `[]DailyContribution` fixtures.
2. Write card-level test asserting:
- SVG bytes contain `Records (all time)` title
- SVG contains 6 row labels (`Best day`, `Best month`, ...)
- Empty-fixture variant still renders without panic
3. `go test ./...` — confirm pass.
4. Update README:
- Card table: add row `15` (or whatever index) with description
- Gallery: add `<td><img src="./demo/dracula/records.svg" alt="records" /></td>` to dracula preview block
5. Trigger demo workflow regen (or rely on next push).
## Success Criteria
- [ ] All new tests pass (`go test ./...`)
- [ ] README renders correctly (card table + gallery image link)
- [ ] Demo workflow produces `records.svg` for all 65 themes after merge
## Risk Assessment
- **Risk:** Card-level snapshot test gets brittle as themes evolve. **Mitigation:** assert on text fragments only, never on full byte equality.
- **Risk:** README gallery layout breaks if the new image cell forces an odd column count. **Mitigation:** keep dracula gallery's `<table>` row count even (pair with another card or span 2 cols).
+52
View File
@@ -0,0 +1,52 @@
---
title: Add records card
status: completed
created: 2026-05-09
slug: records-card
---
# Add records card
A new SVG card surfacing **personal extremes & milestones** rather than aggregates. Mirrors `stats.svg` layout (icon + label + right-aligned accent value).
## Goal
Show 6 records that tell a story arc — **origin → tenure → peaks → breadth**:
| Row | Record | Source |
|---|---|---|
| 1 | Best day (count + date) | argmax over `DailyContributionsAllTime` |
| 2 | Best month (count + `YYYY-MM`) | bucket-sum over `DailyContributionsAllTime`, argmax |
| 3 | First contribution (date) | first non-zero day in `DailyContributionsAllTime` |
| 4 | Active days (lifetime) | count non-zero days in `DailyContributionsAllTime` |
| 5 | On GitHub (years) | `now Profile.CreatedAt` |
| 6 | Languages used | `len(Profile.CommitsByLanguageAllTime)` |
**No new GraphQL queries.** All data already populated by existing fetchers.
## Non-goals
- Card #2 variant for last-year records (low-value duplication)
- Streak/top-repo/peak-year (already covered by other cards)
- Configurable record set (YAGNI)
## Phases
| # | Phase | Status | File |
|---|---|---|---|
| 1 | Records computation helpers | completed | [phase-01-records-computation.md](./phase-01-records-computation.md) |
| 2 | SVG card rendering | completed | [phase-02-svg-card-rendering.md](./phase-02-svg-card-rendering.md) |
| 3 | Tests + README | completed | [phase-03-tests-and-readme.md](./phase-03-tests-and-readme.md) |
## Key dependencies
- Existing fetched data: `Profile.DailyContributionsAllTime`, `Profile.CommitsByLanguageAllTime`, `Profile.CreatedAt`
- Existing card pattern: `internal/card/stats.go` (icon + label + value rows)
- Existing icon set: `internal/card/icons.go` (may need 1-3 new octicon paths)
## Definition of done
- `records.svg` renders for all 65 themes via demo workflow
- Card registered in `allCards` (renders alongside other cards)
- Unit tests cover record-extraction edge cases (empty data, single-day, ties)
- README updated with new row in card table + dracula preview