clone

fifteen puzzle games for the terminal, with daily puzzles, weekly ladders and xp, built on bubble tea

add stats view with XP/level system and VHS recording

Dami committed 487aa43
Implement a Stats screen accessible from the main menu that shows
per-category progress cards with XP-based leveling, a profile banner
with daily streak tracking, and a scrollable card grid layout.

Stats are computed purely from existing game history using SQL views
(category_stats, mode_stats) rather than storing derived data.

Includes comprehensive unit tests for XP/level math, streak logic,
card building, and database query methods.

Closes #2
commit
487aa43984f7556539fd658ac028685db31d61db
parent
aff3bfff3c42597e95f3c0d4be60aa419e63ef7b
author
Dami <dami.etoile@gmail.com>
10 files changed, +1351 -0
Mjustfile1+
Mmodel.go7+
Astats.go462++++++++++++++++++++++++++++++
Astats_test.go336++++++++++++++++++++++
Astore/stats.go113+++++++
Astore/stats_test.go254++++++++++++++++
Mstore/store.go40+++
Mupdate.go46+++
Avhs/stats.tape70+++++
Mview.go22+
M justfile +1 -0
M model.go +7 -0
A stats.go +462 -0
@@ -0,0 +1,462 @@
1+package main2+3+import (4+ "fmt"5+ "math"6+ "slices"7+ "strings"8+ "time"9+10+ "github.com/FelineStateMachine/puzzletea/daily"11+ "github.com/FelineStateMachine/puzzletea/game"12+ "github.com/FelineStateMachine/puzzletea/store"13+ "github.com/FelineStateMachine/puzzletea/ui"14+15+ "charm.land/lipgloss/v2"16+)17+18+// modeKey identifies a specific mode within a game category.19+type modeKey struct {20+ gameType string21+ mode string22+}23+24+// modeXP maps each mode to its base XP award per completion.25+var modeXP map[modeKey]int26+27+func init() {28+ modeXP = make(map[modeKey]int, 64)29+ for _, item := range GameCategories {30+ cat := item.(game.Category)31+ count := len(cat.Modes)32+ for i, m := range cat.Modes {33+ mode := m.(game.Mode)34+ xp := int(math.Round(float64(i) / float64(count) * 10))35+ if xp < 1 {36+ xp = 137+ }38+ modeXP[modeKey{cat.Name, mode.Title()}] = xp39+ }40+ }41+}42+43+// levelFromXP returns the level for the given total XP.44+// Uses the inverse of xpForLevel: level = floor((xp / 5) ^ (1/1.6))45+func levelFromXP(xp int) int {46+ if xp <= 0 {47+ return 048+ }49+ return int(math.Floor(math.Pow(float64(xp)/5.0, 1.0/1.6)))50+}51+52+// xpForLevel returns the total XP required to reach the given level.53+// Formula: ceil(5 * level^1.6)54+func xpForLevel(level int) int {55+ if level <= 0 {56+ return 057+ }58+ return int(math.Ceil(5.0 * math.Pow(float64(level), 1.6)))59+}60+61+// computeCategoryXP calculates total XP for a game category by summing62+// XP across all modes. Daily victories earn 2x XP.63+func computeCategoryXP(gameType string, modeStats []store.ModeStat) int {64+ total := 065+ for _, ms := range modeStats {66+ if ms.GameType != gameType {67+ continue68+ }69+ baseXP := modeXP[modeKey{ms.GameType, ms.Mode}]70+ if baseXP == 0 {71+ baseXP = 172+ }73+ normalVictories := ms.Victories - ms.DailyVictories74+ total += normalVictories * baseXP75+ total += ms.DailyVictories * baseXP * 276+ }77+ return total78+}79+80+// computeDailyStreak calculates the length of the current daily completion81+// streak. The streak is "alive" if the most recent completion is today or82+// yesterday (giving the player until end of day to extend it).83+func computeDailyStreak(dates []time.Time, now time.Time) int {84+ if len(dates) == 0 {85+ return 086+ }87+88+ today := truncateToDate(now)89+ yesterday := today.AddDate(0, 0, -1)90+91+ most := truncateToDate(dates[0])92+ var start time.Time93+ switch {94+ case most.Equal(today):95+ start = today96+ case most.Equal(yesterday):97+ start = yesterday98+ default:99+ return 0100+ }101+102+ streak := 0103+ expected := start104+ for _, d := range dates {105+ dt := truncateToDate(d)106+ if dt.Equal(expected) {107+ streak++108+ expected = expected.AddDate(0, 0, -1)109+ } else if dt.Before(expected) {110+ break111+ }112+ }113+ return streak114+}115+116+// truncateToDate strips the time component, returning midnight local time.117+func truncateToDate(t time.Time) time.Time {118+ y, m, d := t.Date()119+ return time.Date(y, m, d, 0, 0, 0, 0, t.Location())120+}121+122+// --- Stats card ---123+124+type statsCard struct {125+ gameType string126+ level int127+ preferredMode string128+ victories int129+ attempts int130+ dailyPlayed int131+ currentXP int132+ nextLevelXP int133+}134+135+// buildStatsCards constructs a statsCard for each category the player has136+// interacted with. Categories with zero attempts are excluded.137+func buildStatsCards(catStats []store.CategoryStat, modeStats []store.ModeStat) []statsCard {138+ var cards []statsCard139+ for _, cs := range catStats {140+ if cs.TotalAttempts == 0 {141+ continue142+ }143+ totalXP := computeCategoryXP(cs.GameType, modeStats)144+ lvl := levelFromXP(totalXP)145+ nextXP := xpForLevel(lvl + 1)146+147+ preferred := cs.PreferredMode148+ if preferred == "" {149+ preferred = "\u2014" // em dash150+ }151+152+ cards = append(cards, statsCard{153+ gameType: cs.GameType,154+ level: lvl,155+ preferredMode: preferred,156+ victories: cs.TotalVictories,157+ attempts: cs.TotalAttempts,158+ dailyPlayed: cs.TimesDaily,159+ currentXP: totalXP,160+ nextLevelXP: nextXP,161+ })162+ }163+164+ // Sort by level descending, then by XP descending for ties.165+ slices.SortStableFunc(cards, func(a, b statsCard) int {166+ if a.level != b.level {167+ return b.level - a.level168+ }169+ return b.currentXP - a.currentXP170+ })171+172+ return cards173+}174+175+// --- Profile banner ---176+177+type profileBanner struct {178+ profileLevel int179+ dailyStreak int180+ totalDailies int181+ currentDaily bool182+}183+184+// buildProfileBanner constructs the summary banner shown above the card grid.185+func buildProfileBanner(186+ catStats []store.CategoryStat,187+ modeStats []store.ModeStat,188+ streakDates []time.Time,189+ s *store.Store,190+) profileBanner {191+ profileLevel := 0192+ totalDailies := 0193+ for _, cs := range catStats {194+ xp := computeCategoryXP(cs.GameType, modeStats)195+ profileLevel += levelFromXP(xp)196+ totalDailies += cs.TimesDaily197+ }198+199+ now := time.Now()200+ streak := computeDailyStreak(streakDates, now)201+202+ // Check if today's daily exists in the DB.203+ rng := daily.RNG(now)204+ todayName := daily.Name(now, rng)205+ rec, _ := s.GetDailyGame(todayName)206+ currentDaily := rec != nil207+208+ return profileBanner{209+ profileLevel: profileLevel,210+ dailyStreak: streak,211+ totalDailies: totalDailies,212+ currentDaily: currentDaily,213+ }214+}215+216+// --- Rendering ---217+218+const (219+ cardInnerWidth = 30220+ // cardHeight is the rendered height of a single stats card in lines.221+ // 7 inner lines (title + 4 stats + blank + XP bar) + 2 border lines = 9.222+ cardHeight = 9223+)224+225+// Panel chrome: border top (1) + padding top (1) + title (1) + blank (1)226+// ... content ...227+// blank (1) + footer (1) + padding bottom (1) + border bottom (1) = 8 total.228+const panelChrome = 8229+230+// bannerHeight is the rendered banner (4 lines) plus a blank separator line.231+const bannerHeight = 5232+233+// statsStaticHeight returns the total lines consumed by non-scrollable parts234+// of the stats view: panel chrome + banner (when cards exist).235+func statsStaticHeight(cards []statsCard) int {236+ if len(cards) == 0 {237+ return panelChrome238+ }239+ return panelChrome + bannerHeight240+}241+242+// cardFullWidth is the rendered width of a single card including border + padding.243+const cardFullWidth = cardInnerWidth + 4 // 34244+245+// statsContentWidth returns the inner content width for the stats panel.246+// In 2-column mode the content is two cards wide; in 1-column mode it is247+// one card wide. The result is clamped so it never exceeds the available248+// terminal width minus the panel chrome (border + padding = 6 horizontal chars).249+func statsContentWidth(termWidth int) int {250+ twoCol := cardFullWidth * 2 // 68251+ available := termWidth - 6 // panel border (1+1) + padding (2+2)252+ if available >= twoCol {253+ return twoCol254+ }255+ return max(cardFullWidth, min(available, cardFullWidth))256+}257+258+// statsViewportHeight computes the viewport height for the card grid.259+// Shows at most 2.5 card rows and at least 1 full card row, clamped260+// to the available terminal height minus the static chrome.261+func statsViewportHeight(availableHeight int) int {262+ maxRows := cardHeight*2 + cardHeight/2 // ~2.5 cards = 22263+ minRows := cardHeight // 1 full card = 9264+ h := min(availableHeight, maxRows)265+ return max(h, minRows)266+}267+268+var (269+ cardFrame = lipgloss.NewStyle().270+ Border(lipgloss.RoundedBorder()).271+ BorderForeground(ui.MenuDim).272+ Padding(0, 1).273+ Width(cardInnerWidth + 4) // +4 for border + padding274+275+ cardTitle = lipgloss.NewStyle().276+ Bold(true).277+ Foreground(ui.MenuAccent)278+279+ statLabel = lipgloss.NewStyle().280+ Foreground(ui.MenuText)281+282+ statValue = lipgloss.NewStyle().283+ Foreground(ui.MenuAccent).284+ Bold(true)285+286+ xpBarFilled = lipgloss.NewStyle().287+ Foreground(ui.MenuAccent)288+289+ xpBarEmpty = lipgloss.NewStyle().290+ Foreground(ui.MenuDim)291+292+ xpFraction = lipgloss.NewStyle().293+ Foreground(ui.MenuTextDim)294+295+ bannerRule = lipgloss.NewStyle().296+ Foreground(ui.MenuDim)297+298+ bannerLabel = lipgloss.NewStyle().299+ Foreground(ui.MenuText)300+301+ bannerValue = lipgloss.NewStyle().302+ Foreground(ui.MenuAccent).303+ Bold(true)304+)305+306+func renderCard(c statsCard) string {307+ title := cardTitle.Render(308+ fmt.Sprintf("%s \u2014 LVL %d", strings.ToUpper(c.gameType), c.level),309+ )310+311+ lines := []string{312+ title,313+ renderStatLine("Preferred Mode:", c.preferredMode),314+ renderStatLine("Total Victories:", fmt.Sprintf("%d", c.victories)),315+ renderStatLine("Total Attempts:", fmt.Sprintf("%d", c.attempts)),316+ renderStatLine("Daily Played:", fmt.Sprintf("%d", c.dailyPlayed)),317+ "",318+ renderXPBar(c.currentXP, c.level),319+ }320+321+ inner := strings.Join(lines, "\n")322+ return cardFrame.Render(inner)323+}324+325+func renderStatLine(label, value string) string {326+ l := statLabel.Render(label)327+ v := statValue.Render(value)328+ // Right-align value within card width.329+ gap := cardInnerWidth - lipgloss.Width(l) - lipgloss.Width(v)330+ if gap < 1 {331+ gap = 1332+ }333+ return l + strings.Repeat(" ", gap) + v334+}335+336+func renderXPBar(currentXP, level int) string {337+ currentLevelXP := xpForLevel(level)338+ nextLevelXP := xpForLevel(level + 1)339+ xpIntoLevel := currentXP - currentLevelXP340+ xpNeeded := nextLevelXP - currentLevelXP341+ if xpNeeded <= 0 {342+ xpNeeded = 1343+ }344+345+ const barWidth = 12346+ filled := 0347+ if xpNeeded > 0 {348+ filled = int(float64(xpIntoLevel) / float64(xpNeeded) * barWidth)349+ }350+ if filled > barWidth {351+ filled = barWidth352+ }353+ if filled < 0 {354+ filled = 0355+ }356+357+ bar := xpBarFilled.Render(strings.Repeat("\u2588", filled)) +358+ xpBarEmpty.Render(strings.Repeat("\u2591", barWidth-filled))359+360+ frac := xpFraction.Render(fmt.Sprintf(" %d/%d", currentXP, nextLevelXP))361+362+ return statLabel.Render("XP ") + bar + frac363+}364+365+func renderBanner(b profileBanner, width int) string {366+ if width < 20 {367+ width = 40368+ }369+370+ ruleStr := bannerRule.Render(371+ "\u2500\u2500\u2500 Profile " + strings.Repeat("\u2500", max(width-12, 1)),372+ )373+374+ check := bannerValue.Render("\u2714")375+ if !b.currentDaily {376+ check = lipgloss.NewStyle().Foreground(ui.MenuTextDim).Render("\u2014")377+ }378+379+ streakStr := fmt.Sprintf("%d days", b.dailyStreak)380+ if b.dailyStreak == 1 {381+ streakStr = "1 day"382+ }383+384+ col1 := bannerLabel.Render("Profile Level: ") + bannerValue.Render(fmt.Sprintf("%d", b.profileLevel))385+ col2 := bannerLabel.Render("Daily Streak: ") + bannerValue.Render(streakStr)386+ col3 := bannerLabel.Render("Total Dailies: ") + bannerValue.Render(fmt.Sprintf("%d", b.totalDailies))387+ col4 := bannerLabel.Render("Current daily: ") + check388+389+ // Two-column layout: left side + gap + right side.390+ gap := width - lipgloss.Width(col1) - lipgloss.Width(col2)391+ if gap < 2 {392+ gap = 2393+ }394+ row1 := col1 + strings.Repeat(" ", gap) + col2395+396+ gap2 := width - lipgloss.Width(col3) - lipgloss.Width(col4)397+ if gap2 < 2 {398+ gap2 = 2399+ }400+ row2 := col3 + strings.Repeat(" ", gap2) + col4401+402+ bottomRule := bannerRule.Render(strings.Repeat("\u2500", max(width, 1)))403+404+ return strings.Join([]string{ruleStr, row1, row2, bottomRule}, "\n")405+}406+407+// renderStatsCardGrid renders just the card grid (no banner). This is the408+// content placed inside the scrollable viewport.409+func renderStatsCardGrid(cards []statsCard, width int) string {410+ if len(cards) == 0 {411+ return lipgloss.NewStyle().412+ Foreground(ui.MenuTextDim).413+ Render("No stats yet \u2014 play some puzzles!")414+ }415+416+ if width >= cardFullWidth*2 {417+ return renderCardColumns(cards, 2)418+ }419+ return renderCardColumns(cards, 1)420+}421+422+// renderStatsView renders the full stats view (banner + card grid) as a single423+// string. Used only by tests and the empty-state path.424+func renderStatsView(banner profileBanner, cards []statsCard, width int) string {425+ if len(cards) == 0 {426+ return lipgloss.NewStyle().427+ Foreground(ui.MenuTextDim).428+ Render("No stats yet \u2014 play some puzzles!")429+ }430+431+ bannerWidth := width432+ if bannerWidth > 70 {433+ bannerWidth = 70434+ }435+ bannerStr := renderBanner(banner, bannerWidth)436+ cardGrid := renderStatsCardGrid(cards, width)437+438+ return lipgloss.JoinVertical(lipgloss.Left,439+ bannerStr,440+ "",441+ cardGrid,442+ )443+}444+445+func renderCardColumns(cards []statsCard, cols int) string {446+ if cols < 1 {447+ cols = 1448+ }449+450+ columns := make([][]string, cols)451+ for i, c := range cards {452+ col := i % cols453+ columns[col] = append(columns[col], renderCard(c))454+ }455+456+ rendered := make([]string, cols)457+ for i, col := range columns {458+ rendered[i] = lipgloss.JoinVertical(lipgloss.Left, col...)459+ }460+461+ return lipgloss.JoinHorizontal(lipgloss.Top, rendered...)462+}
A stats_test.go +336 -0
@@ -0,0 +1,336 @@
1+package main2+3+import (4+ "testing"5+ "time"6+7+ "github.com/FelineStateMachine/puzzletea/game"8+ "github.com/FelineStateMachine/puzzletea/store"9+)10+11+// --- modeXP map (P0) ---12+13+func TestModeXPMap(t *testing.T) {14+ t.Run("all modes have positive XP", func(t *testing.T) {15+ if len(modeXP) == 0 {16+ t.Fatal("modeXP map is empty")17+ }18+ for k, v := range modeXP {19+ if v <= 0 {20+ t.Errorf("modeXP[%q/%q] = %d, want > 0", k.gameType, k.mode, v)21+ }22+ }23+ })24+25+ t.Run("every category mode is represented", func(t *testing.T) {26+ expected := 027+ for _, item := range GameCategories {28+ cat := item.(game.Category)29+ expected += len(cat.Modes)30+ }31+ if len(modeXP) != expected {32+ t.Errorf("modeXP has %d entries, want %d (total modes)", len(modeXP), expected)33+ }34+ })35+}36+37+// --- levelFromXP (P0) ---38+39+func TestLevelFromXP(t *testing.T) {40+ tests := []struct {41+ name string42+ xp int43+ want int44+ }{45+ {name: "zero XP", xp: 0, want: 0},46+ {name: "negative XP", xp: -5, want: 0},47+ {name: "1 XP", xp: 1, want: 0},48+ {name: "4 XP", xp: 4, want: 0},49+ {name: "5 XP = level 1", xp: 5, want: 1},50+ {name: "15 XP still level 1", xp: 15, want: 1},51+ {name: "16 XP = level 2", xp: 16, want: 2},52+ {name: "28 XP still level 2", xp: 28, want: 2},53+ {name: "29 XP = level 3", xp: 29, want: 3},54+ {name: "45 XP still level 3", xp: 45, want: 3},55+ {name: "46 XP = level 4", xp: 46, want: 4},56+ {name: "65 XP still level 4", xp: 65, want: 4},57+ {name: "66 XP = level 5", xp: 66, want: 5},58+ }59+ for _, tt := range tests {60+ t.Run(tt.name, func(t *testing.T) {61+ got := levelFromXP(tt.xp)62+ if got != tt.want {63+ t.Errorf("levelFromXP(%d) = %d, want %d", tt.xp, got, tt.want)64+ }65+ })66+ }67+}68+69+// --- xpForLevel (P0) ---70+71+func TestXPForLevel(t *testing.T) {72+ tests := []struct {73+ name string74+ level int75+ want int76+ }{77+ {name: "level 0", level: 0, want: 0},78+ {name: "level 1", level: 1, want: 5},79+ {name: "level 2", level: 2, want: 16},80+ {name: "level 3", level: 3, want: 29},81+ {name: "level 4", level: 4, want: 46},82+ {name: "level 5", level: 5, want: 66},83+ }84+ for _, tt := range tests {85+ t.Run(tt.name, func(t *testing.T) {86+ got := xpForLevel(tt.level)87+ if got != tt.want {88+ t.Errorf("xpForLevel(%d) = %d, want %d", tt.level, got, tt.want)89+ }90+ })91+ }92+}93+94+// --- Level/XP round-trip consistency (P0) ---95+96+func TestLevelXPRoundTrip(t *testing.T) {97+ // For each level, xpForLevel should be the threshold that levelFromXP98+ // returns that level. One XP less should return level-1.99+ for lvl := 1; lvl <= 30; lvl++ {100+ threshold := xpForLevel(lvl)101+ if levelFromXP(threshold) != lvl {102+ t.Errorf("levelFromXP(xpForLevel(%d)=%d) = %d, want %d",103+ lvl, threshold, levelFromXP(threshold), lvl)104+ }105+ if threshold > 0 && levelFromXP(threshold-1) != lvl-1 {106+ t.Errorf("levelFromXP(%d-1=%d) = %d, want %d",107+ threshold, threshold-1, levelFromXP(threshold-1), lvl-1)108+ }109+ }110+}111+112+// --- computeDailyStreak (P0) ---113+114+func TestComputeDailyStreak(t *testing.T) {115+ // Helper: date at midnight local time.116+ d := func(year int, month time.Month, day int) time.Time {117+ return time.Date(year, month, day, 0, 0, 0, 0, time.Local)118+ }119+ now := time.Date(2026, time.February, 16, 15, 30, 0, 0, time.Local)120+121+ tests := []struct {122+ name string123+ now time.Time124+ dates []time.Time125+ want int126+ }{127+ {128+ name: "empty dates",129+ now: now,130+ dates: nil,131+ want: 0,132+ },133+ {134+ name: "streak of 1 today",135+ now: now,136+ dates: []time.Time{d(2026, 2, 16)},137+ want: 1,138+ },139+ {140+ name: "streak of 1 yesterday",141+ now: now,142+ dates: []time.Time{d(2026, 2, 15)},143+ want: 1,144+ },145+ {146+ name: "streak broken two days ago",147+ now: now,148+ dates: []time.Time{d(2026, 2, 14)},149+ want: 0,150+ },151+ {152+ name: "3-day streak ending today",153+ now: now,154+ dates: []time.Time{155+ d(2026, 2, 16),156+ d(2026, 2, 15),157+ d(2026, 2, 14),158+ },159+ want: 3,160+ },161+ {162+ name: "3-day streak ending yesterday",163+ now: now,164+ dates: []time.Time{165+ d(2026, 2, 15),166+ d(2026, 2, 14),167+ d(2026, 2, 13),168+ },169+ want: 3,170+ },171+ {172+ name: "gap breaks streak",173+ now: now,174+ dates: []time.Time{175+ d(2026, 2, 16),176+ d(2026, 2, 15),177+ // gap: Feb 14 missing178+ d(2026, 2, 13),179+ d(2026, 2, 12),180+ },181+ want: 2,182+ },183+ }184+ for _, tt := range tests {185+ t.Run(tt.name, func(t *testing.T) {186+ got := computeDailyStreak(tt.dates, tt.now)187+ if got != tt.want {188+ t.Errorf("computeDailyStreak() = %d, want %d", got, tt.want)189+ }190+ })191+ }192+}193+194+// --- computeCategoryXP (P1) ---195+196+func TestComputeCategoryXP(t *testing.T) {197+ // Override modeXP for test.198+ origXP := modeXP199+ defer func() { modeXP = origXP }()200+201+ modeXP = map[modeKey]int{202+ {"Sudoku", "Easy"}: 1,203+ {"Sudoku", "Medium"}: 3,204+ {"Sudoku", "Hard"}: 5,205+ }206+207+ tests := []struct {208+ name string209+ gameType string210+ modeStats []store.ModeStat211+ want int212+ }{213+ {214+ name: "no stats",215+ gameType: "Sudoku",216+ modeStats: nil,217+ want: 0,218+ },219+ {220+ name: "normal victories only",221+ gameType: "Sudoku",222+ modeStats: []store.ModeStat{223+ {GameType: "Sudoku", Mode: "Easy", Victories: 5, DailyVictories: 0},224+ {GameType: "Sudoku", Mode: "Hard", Victories: 2, DailyVictories: 0},225+ },226+ want: 5*1 + 2*5, // 15227+ },228+ {229+ name: "daily 2x multiplier",230+ gameType: "Sudoku",231+ modeStats: []store.ModeStat{232+ {GameType: "Sudoku", Mode: "Easy", Victories: 3, DailyVictories: 1},233+ },234+ // 2 normal × 1 + 1 daily × 1 × 2 = 4235+ want: 4,236+ },237+ {238+ name: "ignores other game types",239+ gameType: "Sudoku",240+ modeStats: []store.ModeStat{241+ {GameType: "Sudoku", Mode: "Easy", Victories: 1, DailyVictories: 0},242+ {GameType: "Nonogram", Mode: "Mini", Victories: 10, DailyVictories: 0},243+ },244+ want: 1,245+ },246+ {247+ name: "unknown mode defaults to 1 XP",248+ gameType: "Sudoku",249+ modeStats: []store.ModeStat{250+ {GameType: "Sudoku", Mode: "Unknown", Victories: 3, DailyVictories: 0},251+ },252+ want: 3,253+ },254+ }255+ for _, tt := range tests {256+ t.Run(tt.name, func(t *testing.T) {257+ got := computeCategoryXP(tt.gameType, tt.modeStats)258+ if got != tt.want {259+ t.Errorf("computeCategoryXP(%q) = %d, want %d", tt.gameType, got, tt.want)260+ }261+ })262+ }263+}264+265+// --- buildStatsCards (P1) ---266+267+func TestBuildStatsCards(t *testing.T) {268+ origXP := modeXP269+ defer func() { modeXP = origXP }()270+271+ modeXP = map[modeKey]int{272+ {"Sudoku", "Easy"}: 2,273+ {"Sudoku", "Hard"}: 8,274+ }275+276+ catStats := []store.CategoryStat{277+ {278+ GameType: "Sudoku",279+ TotalAttempts: 10,280+ TotalVictories: 7,281+ TotalAbandoned: 1,282+ TimesDaily: 3,283+ DailyVictories: 2,284+ PreferredMode: "Easy",285+ },286+ }287+ modeStats := []store.ModeStat{288+ {GameType: "Sudoku", Mode: "Easy", Victories: 5, DailyVictories: 2},289+ {GameType: "Sudoku", Mode: "Hard", Victories: 2, DailyVictories: 0},290+ }291+292+ cards := buildStatsCards(catStats, modeStats)293+ if len(cards) != 1 {294+ t.Fatalf("expected 1 card, got %d", len(cards))295+ }296+297+ c := cards[0]298+ if c.gameType != "Sudoku" {299+ t.Errorf("gameType = %q, want %q", c.gameType, "Sudoku")300+ }301+ if c.victories != 7 {302+ t.Errorf("victories = %d, want 7", c.victories)303+ }304+ if c.attempts != 10 {305+ t.Errorf("attempts = %d, want 10", c.attempts)306+ }307+ if c.dailyPlayed != 3 {308+ t.Errorf("dailyPlayed = %d, want 3", c.dailyPlayed)309+ }310+ if c.preferredMode != "Easy" {311+ t.Errorf("preferredMode = %q, want %q", c.preferredMode, "Easy")312+ }313+314+ // XP: Easy: (5-2)*2 + 2*2*2 = 6+8 = 14; Hard: 2*8 = 16; total = 30315+ if c.currentXP != 30 {316+ t.Errorf("currentXP = %d, want 30", c.currentXP)317+ }318+}319+320+// --- buildStatsCards empty state (P2) ---321+322+func TestBuildStatsCardsEmpty(t *testing.T) {323+ cards := buildStatsCards(nil, nil)324+ if len(cards) != 0 {325+ t.Errorf("expected 0 cards for nil stats, got %d", len(cards))326+ }327+}328+329+// --- renderStatsView empty state (P2) ---330+331+func TestRenderStatsViewEmpty(t *testing.T) {332+ result := renderStatsView(profileBanner{}, nil, 80)333+ if result == "" {334+ t.Error("expected non-empty output for empty state")335+ }336+}
A store/stats.go +113 -0
@@ -0,0 +1,113 @@
1+package store2+3+import (4+ "database/sql"5+ "fmt"6+ "time"7+)8+9+// CategoryStat holds aggregate statistics for a single game category.10+type CategoryStat struct {11+ GameType string12+ TotalAttempts int13+ TotalVictories int14+ TotalAbandoned int15+ TimesDaily int16+ DailyVictories int17+ PreferredMode string18+}19+20+// ModeStat holds per-mode victory counts for XP computation.21+type ModeStat struct {22+ GameType string23+ Mode string24+ Victories int25+ DailyVictories int26+}27+28+// GetCategoryStats returns aggregate stats per game category from the29+// category_stats view.30+func (s *Store) GetCategoryStats() ([]CategoryStat, error) {31+ rows, err := s.db.Query(32+ `SELECT game_type, total_attempts, total_victories, total_abandoned,33+ times_daily, daily_victories, preferred_mode34+ FROM category_stats`,35+ )36+ if err != nil {37+ return nil, fmt.Errorf("querying category_stats: %w", err)38+ }39+ defer rows.Close()40+41+ var stats []CategoryStat42+ for rows.Next() {43+ var cs CategoryStat44+ var preferredMode sql.NullString45+ if err := rows.Scan(46+ &cs.GameType, &cs.TotalAttempts, &cs.TotalVictories,47+ &cs.TotalAbandoned, &cs.TimesDaily, &cs.DailyVictories,48+ &preferredMode,49+ ); err != nil {50+ return nil, fmt.Errorf("scanning category_stats row: %w", err)51+ }52+ if preferredMode.Valid {53+ cs.PreferredMode = preferredMode.String54+ }55+ stats = append(stats, cs)56+ }57+ return stats, rows.Err()58+}59+60+// GetModeStats returns per-mode victory counts from the mode_stats view.61+func (s *Store) GetModeStats() ([]ModeStat, error) {62+ rows, err := s.db.Query(63+ `SELECT game_type, mode, victories, daily_victories64+ FROM mode_stats`,65+ )66+ if err != nil {67+ return nil, fmt.Errorf("querying mode_stats: %w", err)68+ }69+ defer rows.Close()70+71+ var stats []ModeStat72+ for rows.Next() {73+ var ms ModeStat74+ if err := rows.Scan(75+ &ms.GameType, &ms.Mode, &ms.Victories, &ms.DailyVictories,76+ ); err != nil {77+ return nil, fmt.Errorf("scanning mode_stats row: %w", err)78+ }79+ stats = append(stats, ms)80+ }81+ return stats, rows.Err()82+}83+84+// GetDailyStreakDates returns distinct completion dates for daily games,85+// ordered most recent first. Dates are in local time with time set to midnight.86+func (s *Store) GetDailyStreakDates() ([]time.Time, error) {87+ rows, err := s.db.Query(88+ `SELECT DISTINCT DATE(REPLACE(SUBSTR(completed_at, 1, 19), 'T', ' '), 'localtime') AS d89+ FROM games90+ WHERE name LIKE 'Daily %'91+ AND status = 'completed'92+ AND completed_at IS NOT NULL93+ ORDER BY d DESC`,94+ )95+ if err != nil {96+ return nil, fmt.Errorf("querying daily streak dates: %w", err)97+ }98+ defer rows.Close()99+100+ var dates []time.Time101+ for rows.Next() {102+ var dateStr string103+ if err := rows.Scan(&dateStr); err != nil {104+ return nil, fmt.Errorf("scanning daily streak date: %w", err)105+ }106+ t, err := time.ParseInLocation("2006-01-02", dateStr, time.Local)107+ if err != nil {108+ return nil, fmt.Errorf("parsing date %q: %w", dateStr, err)109+ }110+ dates = append(dates, t)111+ }112+ return dates, rows.Err()113+}
A store/stats_test.go +254 -0
@@ -0,0 +1,254 @@
1+package store2+3+import (4+ "testing"5+ "time"6+)7+8+// --- GetCategoryStats (P1) ---9+10+func TestGetCategoryStats(t *testing.T) {11+ t.Run("empty database", func(t *testing.T) {12+ s := openTestStore(t)13+ stats, err := s.GetCategoryStats()14+ if err != nil {15+ t.Fatal(err)16+ }17+ if len(stats) != 0 {18+ t.Errorf("expected 0 stats, got %d", len(stats))19+ }20+ })21+22+ t.Run("counts by category", func(t *testing.T) {23+ s := openTestStore(t)24+ games := []*GameRecord{25+ {Name: "a", GameType: "Sudoku", Mode: "Easy", InitialState: "{}", SaveState: "{}", Status: StatusNew},26+ {Name: "b", GameType: "Sudoku", Mode: "Medium", InitialState: "{}", SaveState: "{}", Status: StatusNew},27+ {Name: "c", GameType: "Nonogram", Mode: "Mini", InitialState: "{}", SaveState: "{}", Status: StatusNew},28+ }29+ for _, g := range games {30+ if err := s.CreateGame(g); err != nil {31+ t.Fatal(err)32+ }33+ }34+ // Complete one Sudoku.35+ if err := s.UpdateStatus(games[0].ID, StatusCompleted); err != nil {36+ t.Fatal(err)37+ }38+ // Abandon one Sudoku.39+ if err := s.UpdateStatus(games[1].ID, StatusAbandoned); err != nil {40+ t.Fatal(err)41+ }42+43+ stats, err := s.GetCategoryStats()44+ if err != nil {45+ t.Fatal(err)46+ }47+48+ byType := make(map[string]CategoryStat)49+ for _, cs := range stats {50+ byType[cs.GameType] = cs51+ }52+53+ sudoku := byType["Sudoku"]54+ if sudoku.TotalAttempts != 2 {55+ t.Errorf("Sudoku TotalAttempts = %d, want 2", sudoku.TotalAttempts)56+ }57+ if sudoku.TotalVictories != 1 {58+ t.Errorf("Sudoku TotalVictories = %d, want 1", sudoku.TotalVictories)59+ }60+ if sudoku.TotalAbandoned != 1 {61+ t.Errorf("Sudoku TotalAbandoned = %d, want 1", sudoku.TotalAbandoned)62+ }63+ if sudoku.PreferredMode != "Easy" {64+ t.Errorf("Sudoku PreferredMode = %q, want %q", sudoku.PreferredMode, "Easy")65+ }66+67+ nonogram := byType["Nonogram"]68+ if nonogram.TotalAttempts != 1 {69+ t.Errorf("Nonogram TotalAttempts = %d, want 1", nonogram.TotalAttempts)70+ }71+ if nonogram.TotalVictories != 0 {72+ t.Errorf("Nonogram TotalVictories = %d, want 0", nonogram.TotalVictories)73+ }74+ if nonogram.PreferredMode != "" {75+ t.Errorf("Nonogram PreferredMode = %q, want empty", nonogram.PreferredMode)76+ }77+ })78+79+ t.Run("daily games counted", func(t *testing.T) {80+ s := openTestStore(t)81+ games := []*GameRecord{82+ {Name: "Daily Feb 16 26 - amber-fox", GameType: "Sudoku", Mode: "Easy", InitialState: "{}", SaveState: "{}", Status: StatusNew},83+ {Name: "Daily Feb 15 26 - blue-cat", GameType: "Sudoku", Mode: "Medium", InitialState: "{}", SaveState: "{}", Status: StatusNew},84+ {Name: "regular-game", GameType: "Sudoku", Mode: "Easy", InitialState: "{}", SaveState: "{}", Status: StatusNew},85+ }86+ for _, g := range games {87+ if err := s.CreateGame(g); err != nil {88+ t.Fatal(err)89+ }90+ }91+ if err := s.UpdateStatus(games[0].ID, StatusCompleted); err != nil {92+ t.Fatal(err)93+ }94+95+ stats, err := s.GetCategoryStats()96+ if err != nil {97+ t.Fatal(err)98+ }99+ if len(stats) != 1 {100+ t.Fatalf("expected 1 category, got %d", len(stats))101+ }102+ cs := stats[0]103+ if cs.TimesDaily != 2 {104+ t.Errorf("TimesDaily = %d, want 2", cs.TimesDaily)105+ }106+ if cs.DailyVictories != 1 {107+ t.Errorf("DailyVictories = %d, want 1", cs.DailyVictories)108+ }109+ })110+}111+112+// --- GetModeStats (P1) ---113+114+func TestGetModeStats(t *testing.T) {115+ t.Run("empty database", func(t *testing.T) {116+ s := openTestStore(t)117+ stats, err := s.GetModeStats()118+ if err != nil {119+ t.Fatal(err)120+ }121+ if len(stats) != 0 {122+ t.Errorf("expected 0 stats, got %d", len(stats))123+ }124+ })125+126+ t.Run("victory counts per mode", func(t *testing.T) {127+ s := openTestStore(t)128+ games := []*GameRecord{129+ {Name: "a", GameType: "Sudoku", Mode: "Easy", InitialState: "{}", SaveState: "{}", Status: StatusNew},130+ {Name: "b", GameType: "Sudoku", Mode: "Easy", InitialState: "{}", SaveState: "{}", Status: StatusNew},131+ {Name: "c", GameType: "Sudoku", Mode: "Medium", InitialState: "{}", SaveState: "{}", Status: StatusNew},132+ {Name: "Daily Feb 16 26 - x", GameType: "Sudoku", Mode: "Easy", InitialState: "{}", SaveState: "{}", Status: StatusNew},133+ }134+ for _, g := range games {135+ if err := s.CreateGame(g); err != nil {136+ t.Fatal(err)137+ }138+ }139+ // Complete two Easy (one daily) and one Medium.140+ for _, id := range []int64{games[0].ID, games[2].ID, games[3].ID} {141+ if err := s.UpdateStatus(id, StatusCompleted); err != nil {142+ t.Fatal(err)143+ }144+ }145+146+ stats, err := s.GetModeStats()147+ if err != nil {148+ t.Fatal(err)149+ }150+151+ type key struct{ gt, mode string }152+ byKey := make(map[key]ModeStat)153+ for _, ms := range stats {154+ byKey[key{ms.GameType, ms.Mode}] = ms155+ }156+157+ easy := byKey[key{"Sudoku", "Easy"}]158+ if easy.Victories != 2 {159+ t.Errorf("Easy Victories = %d, want 2", easy.Victories)160+ }161+ if easy.DailyVictories != 1 {162+ t.Errorf("Easy DailyVictories = %d, want 1", easy.DailyVictories)163+ }164+165+ medium := byKey[key{"Sudoku", "Medium"}]166+ if medium.Victories != 1 {167+ t.Errorf("Medium Victories = %d, want 1", medium.Victories)168+ }169+ if medium.DailyVictories != 0 {170+ t.Errorf("Medium DailyVictories = %d, want 0", medium.DailyVictories)171+ }172+ })173+}174+175+// --- GetDailyStreakDates (P1) ---176+177+func TestGetDailyStreakDates(t *testing.T) {178+ t.Run("empty database", func(t *testing.T) {179+ s := openTestStore(t)180+ dates, err := s.GetDailyStreakDates()181+ if err != nil {182+ t.Fatal(err)183+ }184+ if len(dates) != 0 {185+ t.Errorf("expected 0 dates, got %d", len(dates))186+ }187+ })188+189+ t.Run("returns distinct dates descending", func(t *testing.T) {190+ s := openTestStore(t)191+ now := time.Now().UTC()192+ yesterday := now.AddDate(0, 0, -1)193+194+ games := []*GameRecord{195+ {Name: "Daily Feb 16 26 - a", GameType: "Sudoku", Mode: "Easy", InitialState: "{}", SaveState: "{}", Status: StatusNew},196+ {Name: "Daily Feb 15 26 - b", GameType: "Nonogram", Mode: "Mini", InitialState: "{}", SaveState: "{}", Status: StatusNew},197+ {Name: "regular-game", GameType: "Sudoku", Mode: "Easy", InitialState: "{}", SaveState: "{}", Status: StatusNew},198+ }199+ for _, g := range games {200+ if err := s.CreateGame(g); err != nil {201+ t.Fatal(err)202+ }203+ }204+205+ // Complete both daily games with specific completed_at times.206+ if _, err := s.db.Exec(`UPDATE games SET status = 'completed', completed_at = ? WHERE id = ?`, now, games[0].ID); err != nil {207+ t.Fatal(err)208+ }209+ if _, err := s.db.Exec(`UPDATE games SET status = 'completed', completed_at = ? WHERE id = ?`, yesterday, games[1].ID); err != nil {210+ t.Fatal(err)211+ }212+ // Complete regular game too (should not appear in results).213+ if _, err := s.db.Exec(`UPDATE games SET status = 'completed', completed_at = ? WHERE id = ?`, now, games[2].ID); err != nil {214+ t.Fatal(err)215+ }216+217+ dates, err := s.GetDailyStreakDates()218+ if err != nil {219+ t.Fatal(err)220+ }221+ if len(dates) != 2 {222+ t.Fatalf("expected 2 dates, got %d", len(dates))223+ }224+ // Most recent first.225+ if !dates[0].After(dates[1]) {226+ t.Errorf("expected descending order: %v, %v", dates[0], dates[1])227+ }228+ })229+230+ t.Run("excludes non-completed dailies", func(t *testing.T) {231+ s := openTestStore(t)232+ games := []*GameRecord{233+ {Name: "Daily Feb 16 26 - a", GameType: "Sudoku", Mode: "Easy", InitialState: "{}", SaveState: "{}", Status: StatusNew},234+ {Name: "Daily Feb 15 26 - b", GameType: "Sudoku", Mode: "Easy", InitialState: "{}", SaveState: "{}", Status: StatusNew},235+ }236+ for _, g := range games {237+ if err := s.CreateGame(g); err != nil {238+ t.Fatal(err)239+ }240+ }241+ // Only abandon, don't complete.242+ if err := s.UpdateStatus(games[0].ID, StatusAbandoned); err != nil {243+ t.Fatal(err)244+ }245+246+ dates, err := s.GetDailyStreakDates()247+ if err != nil {248+ t.Fatal(err)249+ }250+ if len(dates) != 0 {251+ t.Errorf("expected 0 dates for non-completed dailies, got %d", len(dates))252+ }253+ })254+}
M store/store.go +40 -0
@@ -27,6 +27,36 @@ CREATE TABLE IF NOT EXISTS games (
2727 completed_at DATETIME2828 );`2929 30+const createCategoryStatsViewSQL = `31+CREATE VIEW IF NOT EXISTS category_stats AS32+SELECT33+ game_type,34+ COUNT(*) AS total_attempts,35+ COUNT(*) FILTER (WHERE status = 'completed') AS total_victories,36+ COUNT(*) FILTER (WHERE status = 'abandoned') AS total_abandoned,37+ COUNT(*) FILTER (WHERE name LIKE 'Daily %') AS times_daily,38+ COUNT(*) FILTER (WHERE status = 'completed'39+ AND name LIKE 'Daily %') AS daily_victories,40+ (SELECT mode FROM games g241+ WHERE g2.game_type = games.game_type42+ AND g2.status = 'completed'43+ GROUP BY mode44+ ORDER BY COUNT(*) DESC, mode ASC45+ LIMIT 1) AS preferred_mode46+FROM games47+GROUP BY game_type;`48+49+const createModeStatsViewSQL = `50+CREATE VIEW IF NOT EXISTS mode_stats AS51+SELECT52+ game_type,53+ mode,54+ COUNT(*) FILTER (WHERE status = 'completed') AS victories,55+ COUNT(*) FILTER (WHERE status = 'completed'56+ AND name LIKE 'Daily %') AS daily_victories57+FROM games58+GROUP BY game_type, mode;`59+3060 type Store struct {3161 db *sql.DB3262 }
@@ -60,6 +90,16 @@ func Open(dbPath string) (*Store, error) {
6090 return nil, fmt.Errorf("creating table: %w", err)6191 }6292 93+ if _, err := db.Exec(createCategoryStatsViewSQL); err != nil {94+ db.Close()95+ return nil, fmt.Errorf("creating category_stats view: %w", err)96+ }97+98+ if _, err := db.Exec(createModeStatsViewSQL); err != nil {99+ db.Close()100+ return nil, fmt.Errorf("creating mode_stats view: %w", err)101+ }102+63103 return &Store{db: db}, nil64104 }65105
M update.go +46 -0
@@ -42,6 +42,13 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
4242 m.helpViewport.SetWidth(m.width)4343 m.helpViewport.SetHeight(m.height - 2)4444 }45+ if m.state == statsView {46+ cw := statsContentWidth(m.width)47+ m.statsViewport.SetWidth(cw)48+ vpH := statsViewportHeight(m.height - statsStaticHeight(m.statsCards))49+ m.statsViewport.SetHeight(vpH)50+ m.statsViewport.SetContent(renderStatsCardGrid(m.statsCards, cw))51+ }4552 4653 case tea.KeyPressMsg:4754 // During generation, only allow Escape (to cancel) and Ctrl+C (to quit).
@@ -123,6 +130,8 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
123130 m.helpSelectList, cmd = m.helpSelectList.Update(msg)124131 case helpDetailView:125132 m.helpViewport, cmd = m.helpViewport.Update(msg)133+ case statsView:134+ m.statsViewport, cmd = m.statsViewport.Update(msg)126135 }127136 128137 return m, cmd
@@ -154,6 +163,8 @@ func (m model) handleMainMenuEnter() (tea.Model, tea.Cmd) {
154163 case "Continue":155164 m.continueTable, m.continueGames = ui.InitContinueTable(m.store, m.height)156165 m.state = continueView166+ case "Stats":167+ return m.handleStatsEnter()157168 case "Guides":158169 m.helpSelectList = ui.InitList(GameCategories, "How to Play")159170 m.helpSelectList.SetSize(min(m.width, 64), min(m.height, ui.ListHeight(m.helpSelectList)))
@@ -303,6 +314,41 @@ func (m model) handleEscape() (tea.Model, tea.Cmd) {
303314 m.state = helpSelectView304315 case helpSelectView:305316 m.state = mainMenuView317+ case statsView:318+ m.state = mainMenuView319+ }320+ return m, nil321+}322+323+func (m model) handleStatsEnter() (tea.Model, tea.Cmd) {324+ catStats, err := m.store.GetCategoryStats()325+ if err != nil {326+ log.Printf("failed to get category stats: %v", err)327+ return m, nil306328 }329+ modeStats, err := m.store.GetModeStats()330+ if err != nil {331+ log.Printf("failed to get mode stats: %v", err)332+ return m, nil333+ }334+ streakDates, err := m.store.GetDailyStreakDates()335+ if err != nil {336+ log.Printf("failed to get daily streak dates: %v", err)337+ return m, nil338+ }339+340+ m.statsCards = buildStatsCards(catStats, modeStats)341+ m.statsProfile = buildProfileBanner(catStats, modeStats, streakDates, m.store)342+343+ // Banner is rendered as static chrome above the viewport; only the344+ // card grid scrolls. Compute available height for the viewport after345+ // accounting for the panel frame and banner.346+ vpHeight := statsViewportHeight(m.height - statsStaticHeight(m.statsCards))347+ m.statsViewport = viewport.New(348+ viewport.WithWidth(statsContentWidth(m.width)),349+ viewport.WithHeight(vpHeight),350+ )351+ m.statsViewport.SetContent(renderStatsCardGrid(m.statsCards, statsContentWidth(m.width)))352+ m.state = statsView307353 return m, nil308354 }
A vhs/stats.tape +70 -0
M view.go +22 -0