diff --git a/plans/260509-0913-records-card/phase-01-records-computation.md b/plans/260509-0913-records-card/phase-01-records-computation.md new file mode 100644 index 00000000..8c961774 --- /dev/null +++ b/plans/260509-0913-records-card/phase-01-records-computation.md @@ -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). diff --git a/plans/260509-0913-records-card/phase-02-svg-card-rendering.md b/plans/260509-0913-records-card/phase-02-svg-card-rendering.md new file mode 100644 index 00000000..6581d21e --- /dev/null +++ b/plans/260509-0913-records-card/phase-02-svg-card-rendering.md @@ -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. diff --git a/plans/260509-0913-records-card/phase-03-tests-and-readme.md b/plans/260509-0913-records-card/phase-03-tests-and-readme.md new file mode 100644 index 00000000..80330270 --- /dev/null +++ b/plans/260509-0913-records-card/phase-03-tests-and-readme.md @@ -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 `