- commit
- 0c32ee22b85aedcb67ceeff288ae7031c198dd26
- parent
- f5c235947f8b88072479f9bb70c6be53e9548077
- author
- Dami <dami.etoile@gmail.com>
| M | app/ | 24 | ++- |
| M | app/ | 30 | ++- |
| M | app/ | 31 | ++- |
| M | app/ | 17 | +- |
| M | app/ | 83 | +++++++- |
| M | app/ | 244 | +++++++++++++++++++++++- |
| M | app/ | 37 | ++++ |
| A | app/ | 311 | ++++++++++++++++++++++++++++++ |
| M | game/ | 7 | +- |
| M | session/ | 6 | +- |
| M | session/ | 1 | + |
| M | stats/ | 38 | ++- |
| M | stats/ | 40 | ++++ |
| M | store/ | 135 | +++++++++++++- |
| M | store/ | 92 | +++++++++ |
| M | store/ | 39 | ++++- |
| M | store/ | 65 | ++++++ |
| M | ui/ | 41 | +++- |
| M | ui/ | 17 | ++ |
| A | weekly/ | 166 | ++++++++++++++++ |
| A | weekly/ | 131 | +++++++++++++ |
@@ -6,28 +6,48 @@ import (
66 "github.com/FelineStateMachine/puzzletea/game"77 sessionflow "github.com/FelineStateMachine/puzzletea/session"88 "github.com/FelineStateMachine/puzzletea/store"9+ "github.com/FelineStateMachine/puzzletea/weekly"910 1011 tea "charm.land/bubbletea/v2"1112 )1213 14+type gameOpenOptions struct {15+ readOnly bool16+ returnState viewState17+ weeklyInfo *weekly.Info18+}19+1320 // activateGame prepares the game with global UI state and moves to game view.14-func (m model) activateGame(g game.Gamer, activeGameID int64, completionSaved bool) model {21+func (m model) activateGame(g game.Gamer, activeGameID int64, completionSaved bool, options gameOpenOptions) model {1522 g, _ = g.Update(game.HelpToggleMsg{Show: m.help.showFull})1623 g, _ = g.Update(tea.WindowSizeMsg{Width: m.width, Height: m.height})1724 1825 m.session.game = g1926 m.session.activeGameID = activeGameID2027 m.session.completionSaved = completionSaved28+ m.session.returnState = options.returnState29+ m.session.weeklyAdvance = options.weeklyInfo2130 m.state = gameView2231 return m2332 }2433 2534 func (m model) importAndActivateRecord(rec store.GameRecord) (model, bool) {35+ return m.importAndActivateRecordWithOptions(rec, gameOpenOptions{returnState: mainMenuView})36+}37+38+func (m model) importAndActivateRecordWithOptions(rec store.GameRecord, options gameOpenOptions) (model, bool) {2639 g, err := sessionflow.ImportRecord(&rec)2740 if err != nil {2841 log.Printf("failed to import game: %v", err)2942 return m, false3043 }3144 32- return m.activateGame(g, rec.ID, rec.Status == store.StatusCompleted), true45+ activeGameID := rec.ID46+ completionSaved := rec.Status == store.StatusCompleted47+ if options.readOnly {48+ activeGameID = 049+ completionSaved = true50+ }51+52+ return m.activateGame(g, activeGameID, completionSaved, options), true3353 }@@ -4,6 +4,7 @@ package app
44 55 import (66 "context"7+ "strconv"78 "time"89 910 "github.com/FelineStateMachine/puzzletea/catalog"@@ -13,6 +14,7 @@ import (
1314 "github.com/FelineStateMachine/puzzletea/store"1415 "github.com/FelineStateMachine/puzzletea/theme"1516 "github.com/FelineStateMachine/puzzletea/ui"17+ "github.com/FelineStateMachine/puzzletea/weekly"1618 1719 "charm.land/bubbles/v2/list"1820 "charm.land/bubbles/v2/spinner"@@ -39,13 +41,6 @@ var mainMenuItems = []ui.MenuItem{
3941 {ItemTitle: "Quit", Desc: "exit puzzletea"},4042 }4143 42-var playMenuItems = []ui.MenuItem{43- {ItemTitle: "Daily Puzzle", Desc: time.Now().Format("Jan _2 06")},44- {ItemTitle: "Generate", Desc: "a new puzzle"},45- {ItemTitle: "Continue", Desc: "a previously played puzzle"},46- {ItemTitle: "Enter Seed", Desc: "for a set puzzle"},47-}48-4944 var optionsMenuItems = []ui.MenuItem{5045 {ItemTitle: "Theme", Desc: "change colors"},5146 {ItemTitle: "Guides", Desc: "learn the rules"},@@ -61,6 +56,7 @@ const (
6156 generatingView6257 gameView6358 continueView59+ weeklyView6460 helpSelectView6561 helpDetailView6662 statsView@@ -78,6 +74,9 @@ type navigationState struct {
7874 selectedModeTitle string7975 continueTable table.Model8076 continueGames []store.GameRecord77+ weeklyTable table.Model78+ weeklyRows []weeklyRow79+ weeklyCursor time.Time8180 seedInput textinput.Model8281 seedModeOptions []seedModeOption8382 seedModeIndex int@@ -90,6 +89,8 @@ type sessionState struct {
9089 game game.Gamer9190 activeGameID int649291 completionSaved bool92+ returnState viewState93+ weeklyAdvance *weekly.Info9394 generating bool9495 spawnJobID int649596 spawnCancel context.CancelFunc@@ -127,6 +128,7 @@ const (
127128 spawnSourceNormal spawnSource = "normal"128129 spawnSourceDaily spawnSource = "daily"129130 spawnSourceSeed spawnSource = "seed"131+ spawnSourceWeekly spawnSource = "weekly"130132 )131133 132134 type spawnRequest struct {@@ -135,6 +137,8 @@ type spawnRequest struct {
135137 gameType string136138 modeTitle string137139 returnState viewState140+ exitState viewState141+ weeklyInfo *weekly.Info138142 }139143 140144 type seedInputFocus int@@ -211,6 +215,7 @@ func InitialModelWithGame(s *store.Store, cfg *config.Config, g game.Gamer, acti
211215 game: g,212216 activeGameID: activeGameID,213217 completionSaved: completionSaved,218+ returnState: mainMenuView,214219 },215220 }216221 }@@ -218,3 +223,14 @@ func InitialModelWithGame(s *store.Store, cfg *config.Config, g game.Gamer, acti
218223 func (m model) Init() tea.Cmd {219224 return nil220225 }226+227+func buildPlayMenuItems(now time.Time, currentWeeklyIndex int) []ui.MenuItem {228+ year, week := now.ISOWeek()229+ return []ui.MenuItem{230+ {ItemTitle: "Create", Desc: "a new puzzle"},231+ {ItemTitle: "Continue", Desc: "a previously played puzzle"},232+ {ItemTitle: "Daily", Desc: now.Format("Jan _2 06")},233+ {ItemTitle: "Weekly", Desc: "Week " + formatTwoDigits(week) + "-" + strconv.Itoa(year) + " #" + formatWeeklyMenuIndex(currentWeeklyIndex)},234+ {ItemTitle: "Seeded", Desc: "enter a specific seed"},235+ }236+}@@ -5,6 +5,7 @@ import (
55 66 "github.com/FelineStateMachine/puzzletea/catalog"77 "github.com/FelineStateMachine/puzzletea/game"8+ "github.com/FelineStateMachine/puzzletea/theme"89 "github.com/FelineStateMachine/puzzletea/ui"910 1011 "charm.land/bubbles/v2/textinput"@@ -146,14 +147,7 @@ func (m model) handleSeedInputUpdate(msg tea.Msg) (model, tea.Cmd) {
146147 }147148 148149 func (m model) seedInputBody() string {149- selector := "< " + m.currentSeedMode().label + " >"150- if m.nav.seedFocus == seedFocusMode {151- selector = ui.SelectedItemStyle().Render(selector)152- } else {153- selector = ui.NormalItemStyle().Render(selector)154- }155-156- modeLabel := ui.DimItemStyle().Render("game ")150+ selector := renderSeedModeTitle(m.currentSeedMode().label, m.nav.seedModeIndex)157151 modePrefix := " "158152 if m.nav.seedFocus == seedFocusMode {159153 modePrefix = ui.CursorStyle().Render("> ")@@ -168,6 +162,25 @@ func (m model) seedInputBody() string {
168162 lipgloss.Left,169163 seedInputView,170164 "",171- modePrefix+modeLabel+selector,165+ modePrefix+selector,172166 )173167 }168+169+func renderSeedModeTitle(label string, colorIndex int) string {170+ colors := theme.Current().ThemeColors()171+ if len(colors) == 0 {172+ return ui.PanelTitle().Render("game: " + label)173+ }174+175+ bg := colors[((colorIndex%len(colors))+len(colors))%len(colors)]176+ chip := lipgloss.NewStyle().177+ Bold(true).178+ Foreground(theme.TextOnBG(bg)).179+ Background(bg).180+ Render(label)181+182+ var b strings.Builder183+ b.WriteString(ui.PanelTitle().Render("game: "))184+ b.WriteString(chip)185+ return b.String()186+}@@ -113,7 +113,10 @@ func (m model) handleSpawnComplete(jobID int64, msg game.SpawnCompleteMsg) (tea.
113113 log.Printf("missing spawn request metadata")114114 return m, nil115115 }116- m = m.activateGame(msg.Game.SetTitle(request.name), 0, false)116+ m = m.activateGame(msg.Game.SetTitle(request.name), 0, false, gameOpenOptions{117+ returnState: request.exitState,118+ weeklyInfo: request.weeklyInfo,119+ })117120 118121 // Capture initial state and create DB record.119122 rec, err := sessionflow.CreateRecord(@@ -133,9 +136,12 @@ func (m model) handleSpawnComplete(jobID int64, msg game.SpawnCompleteMsg) (tea.
133136 134137 // saveCurrentGame saves the current game state to the DB if a game is active.135138 func saveCurrentGame(m model, status store.GameStatus) model {136- if m.session.game == nil || m.session.activeGameID == 0 {139+ if m.session.game == nil {137140 return m138141 }142+ if m.session.activeGameID == 0 {143+ return clearActiveGame(m)144+ }139145 saveData, err := m.session.game.GetSave()140146 if err != nil {141147 log.Printf("failed to get save data: %v", err)@@ -150,7 +156,14 @@ func saveCurrentGame(m model, status store.GameStatus) model {
150156 log.Printf("failed to update game status: %v", err)151157 }152158 }159+ return clearActiveGame(m)160+}161+162+func clearActiveGame(m model) model {153163 m.session.activeGameID = 0154164 m.session.game = nil165+ m.session.completionSaved = false166+ m.session.returnState = mainMenuView167+ m.session.weeklyAdvance = nil155168 return m156169 }@@ -231,6 +231,8 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
231231 m.nav.modeSelectList, cmd = m.nav.modeSelectList.Update(msg)232232 case continueView:233233 m.nav.continueTable, cmd = m.nav.continueTable.Update(msg)234+ case weeklyView:235+ m.nav.weeklyTable, cmd = m.nav.weeklyTable.Update(msg)234236 case helpSelectView:235237 m.nav.helpSelectList, cmd = m.nav.helpSelectList.Update(msg)236238 case helpDetailView:@@ -269,6 +271,9 @@ func (m model) handleWindowSize(msg tea.WindowSizeMsg) model {
269271 visibleRows := min(len(m.nav.continueGames), ui.MaxTableRows)270272 m.nav.continueTable.SetHeight(min(m.height, visibleRows))271273 }274+ if m.state == weeklyView {275+ m = m.refreshWeeklyBrowser()276+ }272277 if m.state == helpSelectView {273278 m.nav.helpSelectList.SetSize(menuW, min(m.height, ui.ListHeight(m.nav.helpSelectList)))274279 }@@ -316,6 +321,17 @@ func (m model) handleGlobalKey(msg tea.KeyPressMsg) (model, tea.Cmd, bool) {
316321 }317322 318323 switch {324+ case m.state == weeklyView && (msg.String() == "left" || msg.String() == "h"):325+ m = m.moveWeeklyWeek(-1)326+ return m, nil, true327+ case m.state == weeklyView && (msg.String() == "right" || msg.String() == "l"):328+ m = m.moveWeeklyWeek(1)329+ return m, nil, true330+ case m.state == gameView && key.Matches(msg, rootKeys.Enter):331+ next, cmd, handled := m.advanceSolvedWeekly()332+ if handled {333+ return next, cmd, true334+ }319335 case m.state == gameSelectView && msg.String() == "pgup":320336 m.nav.categoryDetail.PageUp()321337 return m, nil, true@@ -323,8 +339,12 @@ func (m model) handleGlobalKey(msg tea.KeyPressMsg) (model, tea.Cmd, bool) {
323339 m.nav.categoryDetail.PageDown()324340 return m, nil, true325341 case m.state == gameView && key.Matches(msg, rootKeys.Escape):342+ returnState := m.session.returnState326343 m = saveCurrentGame(m, store.StatusInProgress)327- m.state = mainMenuView344+ m.state = returnState345+ if returnState == weeklyView {346+ m = m.refreshWeeklyBrowser()347+ }328348 m.debug.enabled = false329349 return m, nil, true330350 case key.Matches(msg, rootKeys.Enter):@@ -364,7 +384,8 @@ func (m model) activeSpawnReturnState() viewState {
364384 }365385 366386 func (m model) persistCompletionIfSolved() model {367- if m.session.game == nil || m.session.completionSaved || !m.session.game.IsSolved() {387+ if m.session.game == nil || m.session.activeGameID == 0 ||388+ m.session.completionSaved || !m.session.game.IsSolved() {368389 return m369390 }370391 @@ -410,6 +431,8 @@ func (m model) handleEnter() (tea.Model, tea.Cmd) {
410431 return m.handleModeSelectEnter()411432 case continueView:412433 return m.handleContinueEnter()434+ case weeklyView:435+ return m.handleWeeklyEnter()413436 case helpSelectView:414437 return m.handleHelpSelectEnter()415438 case themeSelectView:@@ -422,7 +445,7 @@ func (m model) handleMainMenuEnter() (tea.Model, tea.Cmd) {
422445 item := m.nav.mainMenu.Selected()423446 switch item.Title() {424447 case "Play":425- m.nav.playMenu = ui.NewMainMenu(playMenuItems)448+ m.nav.playMenu = ui.NewMainMenu(buildPlayMenuItems(time.Now(), m.currentWeeklyMenuIndex()))426449 m.state = playMenuView427450 case "Stats":428451 return m.handleStatsEnter()@@ -435,18 +458,39 @@ func (m model) handleMainMenuEnter() (tea.Model, tea.Cmd) {
435458 return m, nil436459 }437460 461+func (m model) currentWeeklyMenuIndex() int {462+ if m.store == nil {463+ return 1464+ }465+466+ year, week := time.Now().ISOWeek()467+ highestCompleted, err := m.store.GetCurrentWeeklyHighestCompletedIndex(year, week)468+ if err != nil {469+ return 1470+ }471+ if highestCompleted >= weeklyEntryCount {472+ return weeklyEntryCount473+ }474+ if highestCompleted < 1 {475+ return 1476+ }477+ return highestCompleted + 1478+}479+438480 func (m model) handlePlayMenuEnter() (tea.Model, tea.Cmd) {439481 item := m.nav.playMenu.Selected()440482 switch item.Title() {441- case "Daily Puzzle":442- return m.handleDailyPuzzle()443- case "Generate":483+ case "Create":444484 m.state = gameSelectView445485 m = m.updateCategoryDetailViewport()446486 case "Continue":447487 m.nav.continueTable, m.nav.continueGames = ui.InitContinueTable(m.store, m.height)448488 m.state = continueView449- case "Enter Seed":489+ case "Daily":490+ return m.handleDailyPuzzle()491+ case "Weekly":492+ return m.enterWeeklyView()493+ case "Seeded":450494 return m.enterSeedInputView()451495 }452496 return m, nil@@ -518,6 +562,7 @@ func (m model) handleSeedConfirm() (tea.Model, tea.Cmd) {
518562 gameType: gameType,519563 modeTitle: modeTitle,520564 returnState: playMenuView,565+ exitState: mainMenuView,521566 }522567 m.state = generatingView523568 return m, tea.Batch(m.spinner.Tick, spawnSeededCmd(spawner, rng, ctx, jobID))@@ -557,6 +602,7 @@ func (m model) handleDailyPuzzle() (tea.Model, tea.Cmd) {
557602 gameType: gameType,558603 modeTitle: modeTitle,559604 returnState: playMenuView,605+ exitState: mainMenuView,560606 }561607 m.state = generatingView562608 return m, tea.Batch(m.spinner.Tick, spawnSeededCmd(spawner, rng, ctx, jobID))@@ -592,6 +638,7 @@ func (m model) handleModeSelectEnter() (tea.Model, tea.Cmd) {
592638 gameType: m.nav.selectedCategory.Name,593639 modeTitle: m.nav.selectedModeTitle,594640 returnState: modeSelectView,641+ exitState: mainMenuView,595642 }596643 m.state = generatingView597644 return m, tea.Batch(m.spinner.Tick, spawnCmd(spawner, ctx, jobID))@@ -622,7 +669,7 @@ func (m model) handleEscape() (tea.Model, tea.Cmd) {
622669 switch m.state {623670 case playMenuView, optionsMenuView, statsView:624671 m.state = mainMenuView625- case seedInputView, gameSelectView, continueView:672+ case seedInputView, gameSelectView, continueView, weeklyView:626673 m.state = playMenuView627674 case generatingView:628675 returnState := m.activeSpawnReturnState()@@ -713,7 +760,18 @@ func (m model) handleStatsEnter() (tea.Model, tea.Cmd) {
713760 log.Printf("failed to get daily streak dates: %v", err)714761 return m, nil715762 }763+ weekliesCompleted, err := m.store.GetCompletedWeeklyGauntlets()764+ if err != nil {765+ log.Printf("failed to get weekly gauntlet completions: %v", err)766+ return m, nil767+ }716768 now := time.Now()769+ currentYear, currentWeek := now.ISOWeek()770+ thisWeekHighestIndex, err := m.store.GetCurrentWeeklyHighestCompletedIndex(currentYear, currentWeek)771+ if err != nil {772+ log.Printf("failed to get current weekly progress: %v", err)773+ return m, nil774+ }717775 currentDaily := false718776 rec, err := m.store.GetDailyGame(daily.Name(now))719777 if err != nil {@@ -723,7 +781,14 @@ func (m model) handleStatsEnter() (tea.Model, tea.Cmd) {
723781 }724782 725783 m.stats.cards = stats.BuildCards(catStats, modeStats)726- m.stats.profile = stats.BuildProfileBanner(catStats, modeStats, streakDates, currentDaily)784+ m.stats.profile = stats.BuildProfileBanner(785+ catStats,786+ modeStats,787+ streakDates,788+ currentDaily,789+ weekliesCompleted,790+ thisWeekHighestIndex,791+ )727792 728793 statsWidth, statsHeight := statsViewportSize(m.width, m.height, m.stats.cards)729794 m.stats.viewport = viewport.New(@@ -16,6 +16,7 @@ import (
1616 sessionflow "github.com/FelineStateMachine/puzzletea/session"1717 "github.com/FelineStateMachine/puzzletea/store"1818 "github.com/FelineStateMachine/puzzletea/ui"19+ "github.com/FelineStateMachine/puzzletea/weekly"1920 )2021 2122 type escapeTrackingGame struct {@@ -23,6 +24,10 @@ type escapeTrackingGame struct {
2324 sawEscape bool2425 }2526 27+type solvedWeeklyGame struct {28+ save []byte29+}30+2631 func (g *escapeTrackingGame) GetDebugInfo() string {2732 return ""2833 }@@ -64,6 +69,42 @@ func (g *escapeTrackingGame) Update(msg tea.Msg) (game.Gamer, tea.Cmd) {
6469 return g, nil6570 }6671 72+func (g *solvedWeeklyGame) GetDebugInfo() string {73+ return ""74+}75+76+func (g *solvedWeeklyGame) GetFullHelp() [][]key.Binding {77+ return nil78+}79+80+func (g *solvedWeeklyGame) GetSave() ([]byte, error) {81+ return g.save, nil82+}83+84+func (g *solvedWeeklyGame) IsSolved() bool {85+ return true86+}87+88+func (g *solvedWeeklyGame) Reset() game.Gamer {89+ return g90+}91+92+func (g *solvedWeeklyGame) SetTitle(string) game.Gamer {93+ return g94+}95+96+func (g *solvedWeeklyGame) Init() tea.Cmd {97+ return nil98+}99+100+func (g *solvedWeeklyGame) View() string {101+ return ""102+}103+104+func (g *solvedWeeklyGame) Update(msg tea.Msg) (game.Gamer, tea.Cmd) {105+ return g, nil106+}107+67108 func TestGameViewEscapeReturnsToMainMenu(t *testing.T) {68109 tracker := &escapeTrackingGame{}69110 m := model{@@ -197,19 +238,12 @@ func TestSeedInputSelectorCyclesAndPersistsDefault(t *testing.T) {
197238 t.Fatalf("lastSeedModeKey = %q, want %q", got.nav.lastSeedModeKey, options[1].key)198239 }199240 200- playMenu := ui.NewMainMenu(playMenuItems)201- for range 3 {202- playMenu.CursorDown()203- }204-205- reopened, _ := (model{241+ reopenedModel, _ := (model{206242 state: playMenuView,207243 nav: navigationState{208- playMenu: playMenu,209244 lastSeedModeKey: got.nav.lastSeedModeKey,210245 },211- }).handlePlayMenuEnter()212- reopenedModel := reopened.(model)246+ }).enterSeedInputView()213247 214248 if reopenedModel.state != seedInputView {215249 t.Fatalf("state = %d, want %d (seedInputView)", reopenedModel.state, seedInputView)@@ -336,6 +370,198 @@ func TestGameSelectEscapeClearsAppliedFilterBeforeLeavingView(t *testing.T) {
336370 }337371 }338372 373+func TestBuildCurrentWeeklyRowsStartsAtOne(t *testing.T) {374+ rows := buildCurrentWeeklyRows(2026, 1, nil)375+ if len(rows) != 1 {376+ t.Fatalf("len(rows) = %d, want 1", len(rows))377+ }378+ if rows[0].Index != 1 || !rows[0].Playable || rows[0].Status != store.StatusNew {379+ t.Fatalf("row[0] = %+v, want index=1 playable status=new", rows[0])380+ }381+}382+383+func TestBuildCurrentWeeklyRowsUnlocksSequentially(t *testing.T) {384+ games := []store.GameRecord{385+ {Name: weekly.Name(2026, 1, 1), GameType: "Sudoku", Mode: "Easy", Status: store.StatusCompleted},386+ {Name: weekly.Name(2026, 1, 2), GameType: "Sudoku", Mode: "Easy", Status: store.StatusInProgress},387+ {Name: weekly.Name(2026, 1, 4), GameType: "Sudoku", Mode: "Easy", Status: store.StatusCompleted},388+ }389+390+ rows := buildCurrentWeeklyRows(2026, 1, games)391+ if len(rows) != 2 {392+ t.Fatalf("len(rows) = %d, want 2", len(rows))393+ }394+ if rows[0].Index != 2 || !rows[0].Playable {395+ t.Fatalf("row[0] = %+v, want playable #2", rows[0])396+ }397+ if rows[1].Index != 1 || !rows[1].ReadOnly {398+ t.Fatalf("row[1] = %+v, want read-only #1", rows[1])399+ }400+}401+402+func TestBuildReviewWeeklyRowsOnlyIncludesCompleted(t *testing.T) {403+ games := []store.GameRecord{404+ {Name: weekly.Name(2026, 1, 1), GameType: "Sudoku", Mode: "Easy", Status: store.StatusCompleted},405+ {Name: weekly.Name(2026, 1, 2), GameType: "Sudoku", Mode: "Easy", Status: store.StatusInProgress},406+ {Name: weekly.Name(2026, 1, 3), GameType: "Sudoku", Mode: "Easy", Status: store.StatusCompleted},407+ }408+409+ rows := buildReviewWeeklyRows(games)410+ if len(rows) != 2 {411+ t.Fatalf("len(rows) = %d, want 2", len(rows))412+ }413+ if rows[0].Index != 3 || rows[1].Index != 1 {414+ t.Fatalf("rows = %+v, want descending completed rows", rows)415+ }416+}417+418+func TestImportAndActivateWeeklyReviewRecordSkipsSaveBinding(t *testing.T) {419+ loadedGame, err := lightsout.New(3, 3)420+ if err != nil {421+ t.Fatal(err)422+ }423+ save, err := loadedGame.GetSave()424+ if err != nil {425+ t.Fatal(err)426+ }427+428+ rec := store.GameRecord{429+ ID: 42,430+ Name: weekly.Name(2026, 1, 1),431+ GameType: "Lights Out",432+ SaveState: string(save),433+ Status: store.StatusCompleted,434+ }435+436+ next, ok := (model{437+ state: weeklyView,438+ width: 80,439+ height: 24,440+ }).importAndActivateRecordWithOptions(rec, gameOpenOptions{441+ readOnly: true,442+ returnState: weeklyView,443+ })444+ if !ok {445+ t.Fatal("expected import to succeed")446+ }447+ if next.session.activeGameID != 0 {448+ t.Fatalf("activeGameID = %d, want 0", next.session.activeGameID)449+ }450+ if next.session.returnState != weeklyView {451+ t.Fatalf("returnState = %d, want %d", next.session.returnState, weeklyView)452+ }453+}454+455+func TestEnterOnSolvedLatestWeeklyCompletesAndQueuesNext(t *testing.T) {456+ s := openAppTestStore(t)457+ now := time.Now()458+ year, weekNumber := now.ISOWeek()459+ currentName := weekly.Name(year, weekNumber, 1)460+461+ rec := &store.GameRecord{462+ Name: currentName,463+ GameType: "Lights Out",464+ Mode: "Easy",465+ InitialState: "{}",466+ SaveState: "{}",467+ Status: store.StatusInProgress,468+ }469+ if err := s.CreateGame(rec); err != nil {470+ t.Fatal(err)471+ }472+473+ m := model{474+ state: gameView,475+ store: s,476+ spinner: newSpinner(),477+ session: sessionState{478+ game: &solvedWeeklyGame{save: []byte(`{"done":true}`)},479+ activeGameID: rec.ID,480+ returnState: weeklyView,481+ weeklyAdvance: &weekly.Info{Year: year, Week: weekNumber, Index: 1},482+ completionSaved: false,483+ },484+ nav: navigationState{485+ weeklyCursor: weekly.StartOfWeek(year, weekNumber, time.Local),486+ },487+ }488+489+ next, _ := m.Update(tea.KeyPressMsg{Code: tea.KeyEnter})490+ got := next.(model)491+492+ if got.state != generatingView {493+ t.Fatalf("state = %d, want %d (generatingView)", got.state, generatingView)494+ }495+ if got.session.spawn == nil {496+ t.Fatal("expected spawn request")497+ }498+ if got.session.spawn.name != weekly.Name(year, weekNumber, 2) {499+ t.Fatalf("spawn name = %q, want %q", got.session.spawn.name, weekly.Name(year, weekNumber, 2))500+ }501+502+ saved, err := s.GetWeeklyGame(year, weekNumber, 1)503+ if err != nil {504+ t.Fatal(err)505+ }506+ if saved == nil {507+ t.Fatal("expected weekly record")508+ }509+ if saved.Status != store.StatusCompleted {510+ t.Fatalf("status = %q, want %q", saved.Status, store.StatusCompleted)511+ }512+}513+514+func TestMoveWeeklyWeekDoesNotAdvancePastCurrentWeek(t *testing.T) {515+ s := openAppTestStore(t)516+ current := weekly.Current(time.Now())517+ currentCursor := weekly.StartOfWeek(current.Year, current.Week, time.Local)518+519+ m := model{520+ store: s,521+ nav: navigationState{522+ weeklyCursor: currentCursor,523+ },524+ }525+526+ got := m.moveWeeklyWeek(1)527+ if !got.nav.weeklyCursor.Equal(currentCursor) {528+ t.Fatalf("weeklyCursor = %v, want %v", got.nav.weeklyCursor, currentCursor)529+ }530+}531+532+func TestCurrentWeeklyMenuIndexTracksNextPlayableSlot(t *testing.T) {533+ s := openAppTestStore(t)534+ year, weekNumber := time.Now().ISOWeek()535+536+ for index := 1; index <= 2; index++ {537+ rec := &store.GameRecord{538+ Name: weekly.Name(year, weekNumber, index),539+ GameType: "Nonogram",540+ Mode: "Easy",541+ InitialState: "{}",542+ SaveState: "{}",543+ Status: store.StatusCompleted,544+ }545+ if err := s.CreateGame(rec); err != nil {546+ t.Fatal(err)547+ }548+ }549+550+ m := model{store: s}551+ if got := m.currentWeeklyMenuIndex(); got != 3 {552+ t.Fatalf("currentWeeklyMenuIndex() = %d, want 3", got)553+ }554+}555+556+func TestBuildPlayMenuItemsShowsCurrentWeeklyChallenge(t *testing.T) {557+ now := time.Date(2026, time.March, 7, 12, 0, 0, 0, time.Local)558+559+ items := buildPlayMenuItems(now, 7)560+ if got := items[3].Desc; got != "Week 10-2026 # 7" {561+ t.Fatalf("weekly desc = %q, want %q", got, "Week 10-2026 # 7")562+ }563+}564+339565 func openAppTestStore(t *testing.T) *store.Store {340566 t.Helper()341567 s, err := store.Open(filepath.Join(t.TempDir(), "test.db"))@@ -65,6 +65,8 @@ func (m model) View() tea.View {
6565 )6666 }6767 content = ui.CenterView(m.width, m.height, s)68+ case weeklyView:69+ content = ui.CenterView(m.width, m.height, m.weeklyViewContent())6870 case gameView:6971 if m.session.game == nil {7072 content = ""@@ -161,6 +163,41 @@ func (m model) gameSelectViewContent() string {
161163 return ui.CenterView(m.width, m.height, panel)162164 }163165 166+func (m model) weeklyViewContent() string {167+ title := "Weekly Gauntlet — " + m.weeklyPanelTitle()168+ if len(m.nav.weeklyRows) == 0 {169+ body := "No completed puzzles for this week yet."170+ if m.isCurrentWeeklySelection() {171+ body = "No weekly puzzles are available."172+ }173+ return ui.Panel(174+ title,175+ body,176+ "←/→ week • esc back",177+ )178+ }179+180+ footer := "←/→ week • enter open • esc back"181+ if !m.isCurrentWeeklySelection() {182+ footer = "←/→ week • enter review • esc back"183+ }184+ if pg := ui.TablePagination(m.nav.weeklyTable); pg != "" {185+ footer = pg + " " + footer186+ }187+188+ description := m.nav.weeklyTable.View()189+ if !m.isCurrentWeeklySelection() {190+ description = lipgloss.JoinVertical(191+ lipgloss.Left,192+ ui.DimItemStyle().Render("Review only: completed puzzles from this week."),193+ "",194+ description,195+ )196+ }197+198+ return ui.Panel(title, description, footer)199+}200+164201 func categoryPickerListHeight() int {165202 const (166203 maxVisibleItems = 8@@ -0,0 +1,311 @@
1+package app2+3+import (4+ "log"5+ "sort"6+ "strconv"7+ "time"8+9+ "charm.land/bubbles/v2/table"10+ tea "charm.land/bubbletea/v2"11+ sessionflow "github.com/FelineStateMachine/puzzletea/session"12+ "github.com/FelineStateMachine/puzzletea/store"13+ "github.com/FelineStateMachine/puzzletea/ui"14+ "github.com/FelineStateMachine/puzzletea/weekly"15+)16+17+const weeklyEntryCount = 9918+19+type weeklyRow struct {20+ Index int21+ Name string22+ GameType string23+ Mode string24+ BonusXP int25+ Status store.GameStatus26+ Record *store.GameRecord27+ Playable bool28+ ReadOnly bool29+}30+31+func (r weeklyRow) tableRow() table.Row {32+ return table.Row{33+ formatWeeklyIndex(r.Index),34+ r.GameType,35+ r.Mode,36+ formatWeeklyBonus(r.BonusXP),37+ ui.FormatStatus(r.Status),38+ }39+}40+41+func (m model) enterWeeklyView() (tea.Model, tea.Cmd) {42+ current := weekly.Current(time.Now())43+ m.nav.weeklyCursor = weekly.StartOfWeek(current.Year, current.Week, time.Local)44+ m = m.refreshWeeklyBrowser()45+ m.state = weeklyView46+ return m, nil47+}48+49+func (m model) refreshWeeklyBrowser() model {50+ year, weekNumber := m.selectedWeek()51+ games, err := m.store.ListWeeklyGames(year, weekNumber)52+ if err != nil {53+ log.Printf("failed to list weekly games: %v", err)54+ games = nil55+ }56+57+ if m.isCurrentWeeklySelection() {58+ m.nav.weeklyRows = buildCurrentWeeklyRows(year, weekNumber, games)59+ } else {60+ m.nav.weeklyRows = buildReviewWeeklyRows(games)61+ }62+63+ rows := make([]table.Row, 0, len(m.nav.weeklyRows))64+ for _, row := range m.nav.weeklyRows {65+ rows = append(rows, row.tableRow())66+ }67+ m.nav.weeklyTable = ui.InitWeeklyTable(rows, m.height)68+ return m69+}70+71+func (m model) isCurrentWeeklySelection() bool {72+ current := weekly.Current(time.Now())73+ year, weekNumber := m.selectedWeek()74+ return current.Year == year && current.Week == weekNumber75+}76+77+func (m model) selectedWeek() (int, int) {78+ cursor := m.nav.weeklyCursor79+ if cursor.IsZero() {80+ current := weekly.Current(time.Now())81+ cursor = weekly.StartOfWeek(current.Year, current.Week, time.Local)82+ }83+ return cursor.ISOWeek()84+}85+86+func (m model) weeklyPanelTitle() string {87+ year, weekNumber := m.selectedWeek()88+ return "Week " + formatTwoDigits(weekNumber) + "-" + strconv.Itoa(year)89+}90+91+func (m model) moveWeeklyWeek(delta int) model {92+ if m.nav.weeklyCursor.IsZero() {93+ current := weekly.Current(time.Now())94+ m.nav.weeklyCursor = weekly.StartOfWeek(current.Year, current.Week, time.Local)95+ }96+97+ nextCursor := weekly.AddWeeks(m.nav.weeklyCursor, delta)98+ current := weekly.Current(time.Now())99+ currentCursor := weekly.StartOfWeek(current.Year, current.Week, time.Local)100+ if nextCursor.After(currentCursor) {101+ nextCursor = currentCursor102+ }103+104+ m.nav.weeklyCursor = nextCursor105+ return m.refreshWeeklyBrowser()106+}107+108+func (m model) handleWeeklyEnter() (tea.Model, tea.Cmd) {109+ idx := m.nav.weeklyTable.Cursor()110+ if idx < 0 || idx >= len(m.nav.weeklyRows) {111+ return m, nil112+ }113+114+ return m.openWeeklyRow(m.nav.weeklyRows[idx])115+}116+117+func (m model) openWeeklyRow(row weeklyRow) (tea.Model, tea.Cmd) {118+ info, ok := weekly.ParseName(row.Name)119+ if !ok {120+ return m, nil121+ }122+123+ options := gameOpenOptions{124+ returnState: weeklyView,125+ }126+ if row.Playable && !row.ReadOnly {127+ infoCopy := info128+ options.weeklyInfo = &infoCopy129+ }130+131+ if row.Record != nil {132+ if row.ReadOnly {133+ m, _ = m.importAndActivateRecordWithOptions(*row.Record, gameOpenOptions{134+ readOnly: true,135+ returnState: weeklyView,136+ })137+ return m, nil138+ }139+140+ var resumed bool141+ m, resumed = m.importAndActivateRecordWithOptions(*row.Record, options)142+ if resumed {143+ if err := sessionflow.ResumeAbandonedDeterministicRecord(m.store, row.Record); err != nil {144+ log.Printf("%v", err)145+ }146+ }147+ return m, nil148+ }149+150+ if !row.Playable {151+ return m, nil152+ }153+154+ spawner, gameType, modeTitle := weekly.Mode(info.Year, info.Week, info.Index)155+ if spawner == nil {156+ log.Printf("no weekly mode available for %s", row.Name)157+ return m, nil158+ }159+160+ rng := weekly.RNG(info.Year, info.Week, info.Index)161+ ctx, jobID := m.beginSpawnContext()162+ infoCopy := info163+ m.session.spawn = &spawnRequest{164+ source: spawnSourceWeekly,165+ name: row.Name,166+ gameType: gameType,167+ modeTitle: modeTitle,168+ returnState: weeklyView,169+ exitState: weeklyView,170+ weeklyInfo: &infoCopy,171+ }172+ m.state = generatingView173+ return m, tea.Batch(m.spinner.Tick, spawnSeededCmd(spawner, rng, ctx, jobID))174+}175+176+func (m model) advanceSolvedWeekly() (model, tea.Cmd, bool) {177+ if m.state != gameView || m.session.game == nil || !m.session.game.IsSolved() || m.session.weeklyAdvance == nil {178+ return m, nil, false179+ }180+181+ info := *m.session.weeklyAdvance182+ m = m.persistCompletionIfSolved()183+ m.nav.weeklyCursor = weekly.StartOfWeek(info.Year, info.Week, time.Local)184+ m = m.refreshWeeklyBrowser()185+ m.state = weeklyView186+ if len(m.nav.weeklyRows) == 0 || !m.nav.weeklyRows[0].Playable {187+ return m, nil, true188+ }189+190+ next, cmd := m.openWeeklyRow(m.nav.weeklyRows[0])191+ return next.(model), cmd, true192+}193+194+func buildCurrentWeeklyRows(year, weekNumber int, games []store.GameRecord) []weeklyRow {195+ byIndex := make(map[int]store.GameRecord, len(games))196+ completed := make(map[int]store.GameRecord, len(games))197+198+ for _, rec := range games {199+ info, ok := weekly.ParseName(rec.Name)200+ if !ok || info.Year != year || info.Week != weekNumber {201+ continue202+ }203+ byIndex[info.Index] = rec204+ if rec.Status == store.StatusCompleted {205+ completed[info.Index] = rec206+ }207+ }208+209+ prefix := 0210+ for index := 1; index <= weeklyEntryCount; index++ {211+ if _, ok := completed[index]; !ok {212+ break213+ }214+ prefix = index215+ }216+217+ rows := make([]weeklyRow, 0, prefix+1)218+ if prefix < weeklyEntryCount {219+ nextIndex := prefix + 1220+ if rec, ok := byIndex[nextIndex]; ok {221+ recCopy := rec222+ rows = append(rows, weeklyRow{223+ Index: nextIndex,224+ Name: rec.Name,225+ GameType: rec.GameType,226+ Mode: rec.Mode,227+ BonusXP: weekly.BonusXP(nextIndex),228+ Status: rec.Status,229+ Record: &recCopy,230+ Playable: true,231+ })232+ } else {233+ _, gameType, modeTitle := weekly.Mode(year, weekNumber, nextIndex)234+ rows = append(rows, weeklyRow{235+ Index: nextIndex,236+ Name: weekly.Name(year, weekNumber, nextIndex),237+ GameType: gameType,238+ Mode: modeTitle,239+ BonusXP: weekly.BonusXP(nextIndex),240+ Status: store.StatusNew,241+ Playable: true,242+ })243+ }244+ }245+246+ for index := prefix; index >= 1; index-- {247+ rec := completed[index]248+ recCopy := rec249+ rows = append(rows, weeklyRow{250+ Index: index,251+ Name: rec.Name,252+ GameType: rec.GameType,253+ Mode: rec.Mode,254+ BonusXP: weekly.BonusXP(index),255+ Status: rec.Status,256+ Record: &recCopy,257+ ReadOnly: true,258+ })259+ }260+261+ return rows262+}263+264+func buildReviewWeeklyRows(games []store.GameRecord) []weeklyRow {265+ rows := make([]weeklyRow, 0, len(games))266+ for _, rec := range games {267+ info, ok := weekly.ParseName(rec.Name)268+ if !ok || rec.Status != store.StatusCompleted {269+ continue270+ }271+272+ recCopy := rec273+ rows = append(rows, weeklyRow{274+ Index: info.Index,275+ Name: rec.Name,276+ GameType: rec.GameType,277+ Mode: rec.Mode,278+ BonusXP: weekly.BonusXP(info.Index),279+ Status: rec.Status,280+ Record: &recCopy,281+ ReadOnly: true,282+ })283+ }284+285+ sort.Slice(rows, func(i, j int) bool {286+ return rows[i].Index > rows[j].Index287+ })288+ return rows289+}290+291+func formatWeeklyIndex(index int) string {292+ return "#" + formatTwoDigits(index)293+}294+295+func formatWeeklyBonus(bonus int) string {296+ return "+" + strconv.Itoa(bonus)297+}298+299+func formatWeeklyMenuIndex(index int) string {300+ if index < 10 {301+ return " " + strconv.Itoa(index)302+ }303+ return strconv.Itoa(index)304+}305+306+func formatTwoDigits(value int) string {307+ if value < 10 {308+ return "0" + strconv.Itoa(value)309+ }310+ return strconv.Itoa(value)311+}@@ -17,13 +17,14 @@ type GridBorderColors struct {
1717 SolvedBG color.Color1818 }1919 20-func borderColors(colors GridBorderColors, solved bool, bg color.Color) (fg color.Color, background color.Color) {21- fg = colors.BorderFG20+func borderColors(colors GridBorderColors, solved bool, bg color.Color) (color.Color, color.Color) {21+ fg := colors.BorderFG22+ background := bg23+2224 if solved {2325 fg = colors.SolvedBorderFG2426 }2527 26- background = bg2728 if background == nil {2829 background = colors.BackgroundBG2930 if solved {@@ -15,8 +15,10 @@ import (
1515 // NormalizeSeed keeps seeded names distinct from real dailies.1616 func NormalizeSeed(seed string) string {1717 seed = strings.TrimSpace(seed)18- if strings.HasPrefix(strings.ToLower(seed), "daily") {19- return strings.ToLower(seed[:len("daily")]) + seed[len("daily"):]18+ for _, reserved := range []string{"daily", "week"} {19+ if strings.HasPrefix(strings.ToLower(seed), reserved) {20+ return strings.ToLower(seed[:len(reserved)]) + seed[len(reserved):]21+ }2022 }2123 return seed2224 }@@ -77,6 +77,7 @@ func ComputeCategoryXP(gameType string, modeStats []store.ModeStat) int {
7777 normalVictories := ms.Victories - ms.DailyVictories7878 total += normalVictories * baseXP7979 total += ms.DailyVictories * baseXP * 280+ total += ms.WeeklyBonusXP8081 }8182 return total8283 }@@ -181,10 +182,12 @@ func BuildCards(catStats []store.CategoryStat, modeStats []store.ModeStat) []Car
181182 182183 // ProfileBanner holds the summary data shown above the card grid.183184 type ProfileBanner struct {184- ProfileLevel int185- DailyStreak int186- TotalDailies int187- CurrentDaily bool185+ ProfileLevel int186+ DailyStreak int187+ TotalDailies int188+ CurrentDaily bool189+ WeekliesCompleted int190+ ThisWeekHighestIndex int188191 }189192 190193 // BuildProfileBanner constructs the summary banner shown above the card grid.@@ -193,6 +196,8 @@ func BuildProfileBanner(
193196 modeStats []store.ModeStat,194197 streakDates []time.Time,195198 currentDaily bool,199+ weekliesCompleted int,200+ thisWeekHighestIndex int,196201 ) ProfileBanner {197202 profileLevel := 0198203 totalDailies := 0@@ -205,10 +210,12 @@ func BuildProfileBanner(
205210 streak := ComputeDailyStreak(streakDates, time.Now())206211 207212 return ProfileBanner{208- ProfileLevel: profileLevel,209- DailyStreak: streak,210- TotalDailies: totalDailies,211- CurrentDaily: currentDaily,213+ ProfileLevel: profileLevel,214+ DailyStreak: streak,215+ TotalDailies: totalDailies,216+ CurrentDaily: currentDaily,217+ WeekliesCompleted: weekliesCompleted,218+ ThisWeekHighestIndex: thisWeekHighestIndex,212219 }213220 }214221 @@ -396,20 +403,23 @@ func RenderBanner(b ProfileBanner, width int) string {
396403 "\u2500\u2500\u2500 Profile " + strings.Repeat("\u2500", max(width-12, 1)),397404 )398405 399- check := bannerValueStyle().Render("\u2714")400- if !b.CurrentDaily {401- check = lipgloss.NewStyle().Foreground(theme.Current().TextDim).Render("\u2014")402- }403-404406 streakStr := fmt.Sprintf("%d days", b.DailyStreak)405407 if b.DailyStreak == 1 {406408 streakStr = "1 day"407409 }410+ streakSuffix := "( )"411+ if b.CurrentDaily {412+ streakSuffix = "(\u2714)"413+ }414+ streakStr += " " + streakSuffix408415 409416 col1 := bannerLabelStyle().Render("Profile Level: ") + bannerValueStyle().Render(fmt.Sprintf("%d", b.ProfileLevel))410417 col2 := bannerLabelStyle().Render("Daily Streak: ") + bannerValueStyle().Render(streakStr)411418 col3 := bannerLabelStyle().Render("Total Dailies: ") + bannerValueStyle().Render(fmt.Sprintf("%d", b.TotalDailies))412- col4 := bannerLabelStyle().Render("Current daily: ") + check419+ col4 := bannerLabelStyle().Render("Weeklies Completed: ") +420+ bannerValueStyle().Render(421+ fmt.Sprintf("%d total (%2d/99 this week)", b.WeekliesCompleted, b.ThisWeekHighestIndex),422+ )413423 414424 // Two-column layout: left side + gap + right side.415425 gap := width - lipgloss.Width(col1) - lipgloss.Width(col2)@@ -244,6 +244,14 @@ func TestComputeCategoryXP(t *testing.T) {
244244 },245245 want: 1,246246 },247+ {248+ name: "weekly bonus xp added on top",249+ gameType: "Sudoku",250+ modeStats: []store.ModeStat{251+ {GameType: "Sudoku", Mode: "Easy", Victories: 2, WeeklyBonusXP: 5},252+ },253+ want: 7,254+ },247255 {248256 name: "unknown mode defaults to 1 XP",249257 gameType: "Sudoku",@@ -336,6 +344,38 @@ func TestRenderViewEmpty(t *testing.T) {
336344 }337345 }338346 347+func TestRenderBannerAppendsDailyStatusToStreak(t *testing.T) {348+ withCurrent := ansi.Strip(RenderBanner(ProfileBanner{349+ ProfileLevel: 3,350+ DailyStreak: 2,351+ TotalDailies: 5,352+ CurrentDaily: true,353+ WeekliesCompleted: 4,354+ ThisWeekHighestIndex: 17,355+ }, 80))356+ if !strings.Contains(withCurrent, "Daily Streak: 2 days (✔)") {357+ t.Fatalf("expected current daily marker in banner:\n%s", withCurrent)358+ }359+ if !strings.Contains(withCurrent, "Weeklies Completed: 4 total (17/99 this week)") {360+ t.Fatalf("expected weekly completion summary in banner:\n%s", withCurrent)361+ }362+363+ withoutCurrent := ansi.Strip(RenderBanner(ProfileBanner{364+ ProfileLevel: 3,365+ DailyStreak: 2,366+ TotalDailies: 5,367+ CurrentDaily: false,368+ WeekliesCompleted: 0,369+ ThisWeekHighestIndex: 0,370+ }, 80))371+ if !strings.Contains(withoutCurrent, "Daily Streak: 2 days ( )") {372+ t.Fatalf("expected empty daily marker in banner:\n%s", withoutCurrent)373+ }374+ if !strings.Contains(withoutCurrent, "Weeklies Completed: 0 total ( 0/99 this week)") {375+ t.Fatalf("expected weekly completion summary in banner:\n%s", withoutCurrent)376+ }377+}378+339379 // --- Card grid layout (P1) ---340380 341381 func TestCardColumnCount(t *testing.T) {@@ -4,6 +4,8 @@ import (
44 "database/sql"55 "fmt"66 "time"7+8+ "github.com/FelineStateMachine/puzzletea/weekly"79 )810 911 // CategoryStat holds aggregate statistics for a single game category.@@ -23,6 +25,7 @@ type ModeStat struct {
2325 Mode string2426 Victories int2527 DailyVictories int28+ WeeklyBonusXP int2629 }2730 2831 // GetCategoryStats returns aggregate stats per game category from the@@ -78,7 +81,15 @@ func (s *Store) GetModeStats() ([]ModeStat, error) {
7881 }7982 stats = append(stats, ms)8083 }81- return stats, rows.Err()84+ if err := rows.Err(); err != nil {85+ return nil, err86+ }87+88+ stats, err = s.applyWeeklyBonusXP(stats)89+ if err != nil {90+ return nil, err91+ }92+ return stats, nil8293 }8394 8495 // GetDailyStreakDates returns distinct completion dates for daily games,@@ -111,3 +122,125 @@ func (s *Store) GetDailyStreakDates() ([]time.Time, error) {
111122 }112123 return dates, rows.Err()113124 }125+126+// GetCompletedWeeklyGauntlets returns the number of ISO weeks whose highest127+// completed weekly index is 99.128+func (s *Store) GetCompletedWeeklyGauntlets() (int, error) {129+ highestByWeek, err := s.highestCompletedWeeklyIndexByWeek()130+ if err != nil {131+ return 0, err132+ }133+134+ completed := 0135+ for _, highest := range highestByWeek {136+ if highest >= 99 {137+ completed++138+ }139+ }140+ return completed, nil141+}142+143+// GetCurrentWeeklyHighestCompletedIndex returns the highest completed weekly144+// index for the provided ISO week-year.145+func (s *Store) GetCurrentWeeklyHighestCompletedIndex(year, week int) (int, error) {146+ highestByWeek, err := s.highestCompletedWeeklyIndexByWeek()147+ if err != nil {148+ return 0, err149+ }150+ return highestByWeek[weeklyKey{year: year, week: week}], nil151+}152+153+type weeklyKey struct {154+ year int155+ week int156+}157+158+func (s *Store) highestCompletedWeeklyIndexByWeek() (map[weeklyKey]int, error) {159+ rows, err := s.db.Query(160+ `SELECT name161+ FROM games162+ WHERE status = 'completed'163+ AND name LIKE 'Week %'`,164+ )165+ if err != nil {166+ return nil, fmt.Errorf("querying completed weekly names: %w", err)167+ }168+ defer rows.Close()169+170+ highestByWeek := make(map[weeklyKey]int)171+ for rows.Next() {172+ var name string173+ if err := rows.Scan(&name); err != nil {174+ return nil, fmt.Errorf("scanning completed weekly name: %w", err)175+ }176+177+ info, ok := weekly.ParseName(name)178+ if !ok {179+ continue180+ }181+182+ key := weeklyKey{year: info.Year, week: info.Week}183+ if info.Index > highestByWeek[key] {184+ highestByWeek[key] = info.Index185+ }186+ }187+ if err := rows.Err(); err != nil {188+ return nil, fmt.Errorf("reading completed weekly names: %w", err)189+ }190+ return highestByWeek, nil191+}192+193+func (s *Store) applyWeeklyBonusXP(stats []ModeStat) ([]ModeStat, error) {194+ rows, err := s.db.Query(195+ `SELECT name, game_type, mode196+ FROM games197+ WHERE status = 'completed'198+ AND name LIKE 'Week %'`,199+ )200+ if err != nil {201+ return nil, fmt.Errorf("querying completed weekly games: %w", err)202+ }203+ defer rows.Close()204+205+ type key struct {206+ gameType string207+ mode string208+ }209+210+ indexByKey := make(map[key]int, len(stats))211+ for i := range stats {212+ indexByKey[key{gameType: stats[i].GameType, mode: stats[i].Mode}] = i213+ }214+215+ for rows.Next() {216+ var name string217+ var gameType string218+ var mode string219+ if err := rows.Scan(&name, &gameType, &mode); err != nil {220+ return nil, fmt.Errorf("scanning completed weekly game: %w", err)221+ }222+223+ info, ok := weekly.ParseName(name)224+ if !ok {225+ continue226+ }227+228+ k := key{gameType: gameType, mode: mode}229+ i, ok := indexByKey[k]230+ if !ok {231+ stats = append(stats, ModeStat{232+ GameType: gameType,233+ Mode: mode,234+ Victories: 1,235+ })236+ i = len(stats) - 1237+ indexByKey[k] = i238+ }239+ stats[i].WeeklyBonusXP += weekly.BonusXP(info.Index)240+ }241+242+ if err := rows.Err(); err != nil {243+ return nil, fmt.Errorf("reading completed weekly games: %w", err)244+ }245+ return stats, nil246+}@@ -3,6 +3,8 @@ package store
33 import (44 "testing"55 "time"6+7+ "github.com/FelineStateMachine/puzzletea/weekly"68 )79 810 // --- GetCategoryStats (P1) ---@@ -170,6 +172,42 @@ func TestGetModeStats(t *testing.T) {
170172 t.Errorf("Medium DailyVictories = %d, want 0", medium.DailyVictories)171173 }172174 })175+176+ t.Run("weekly bonus xp is aggregated by parsed name", func(t *testing.T) {177+ s := openTestStore(t)178+ games := []*GameRecord{179+ {Name: weekly.Name(2026, 1, 10), GameType: "Sudoku", Mode: "Easy", InitialState: "{}", SaveState: "{}", Status: StatusNew},180+ {Name: weekly.Name(2026, 1, 25), GameType: "Sudoku", Mode: "Easy", InitialState: "{}", SaveState: "{}", Status: StatusNew},181+ }182+ for _, g := range games {183+ if err := s.CreateGame(g); err != nil {184+ t.Fatal(err)185+ }186+ if err := s.UpdateStatus(g.ID, StatusCompleted); err != nil {187+ t.Fatal(err)188+ }189+ }190+191+ stats, err := s.GetModeStats()192+ if err != nil {193+ t.Fatal(err)194+ }195+196+ found := false197+ for _, ms := range stats {198+ if ms.GameType != "Sudoku" || ms.Mode != "Easy" {199+ continue200+ }201+ found = true202+ want := weekly.BonusXP(10) + weekly.BonusXP(25)203+ if ms.WeeklyBonusXP != want {204+ t.Fatalf("WeeklyBonusXP = %d, want %d", ms.WeeklyBonusXP, want)205+ }206+ }207+ if !found {208+ t.Fatal("expected Sudoku/Easy mode stats")209+ }210+ })173211 }174212 175213 // --- GetDailyStreakDates (P1) ---@@ -252,3 +290,57 @@ func TestGetDailyStreakDates(t *testing.T) {
252290 }253291 })254292 }293+294+func TestGetCompletedWeeklyGauntlets(t *testing.T) {295+ s := openTestStore(t)296+297+ games := []*GameRecord{298+ {Name: weekly.Name(2026, 1, 98), GameType: "Sudoku", Mode: "Easy", InitialState: "{}", SaveState: "{}", Status: StatusNew},299+ {Name: weekly.Name(2026, 1, 99), GameType: "Sudoku", Mode: "Easy", InitialState: "{}", SaveState: "{}", Status: StatusNew},300+ {Name: weekly.Name(2026, 2, 50), GameType: "Sudoku", Mode: "Easy", InitialState: "{}", SaveState: "{}", Status: StatusNew},301+ {Name: weekly.Name(2026, 3, 99), GameType: "Sudoku", Mode: "Easy", InitialState: "{}", SaveState: "{}", Status: StatusNew},302+ {Name: "Week 3-2026 - #99", GameType: "Sudoku", Mode: "Easy", InitialState: "{}", SaveState: "{}", Status: StatusNew},303+ }304+ for _, g := range games {305+ if err := s.CreateGame(g); err != nil {306+ t.Fatal(err)307+ }308+ if err := s.UpdateStatus(g.ID, StatusCompleted); err != nil {309+ t.Fatal(err)310+ }311+ }312+313+ got, err := s.GetCompletedWeeklyGauntlets()314+ if err != nil {315+ t.Fatal(err)316+ }317+ if got != 2 {318+ t.Fatalf("GetCompletedWeeklyGauntlets() = %d, want 2", got)319+ }320+}321+322+func TestGetCurrentWeeklyHighestCompletedIndex(t *testing.T) {323+ s := openTestStore(t)324+ games := []*GameRecord{325+ {Name: weekly.Name(2026, 10, 1), GameType: "Sudoku", Mode: "Easy", InitialState: "{}", SaveState: "{}", Status: StatusNew},326+ {Name: weekly.Name(2026, 10, 17), GameType: "Sudoku", Mode: "Easy", InitialState: "{}", SaveState: "{}", Status: StatusNew},327+ {Name: weekly.Name(2026, 10, 9), GameType: "Sudoku", Mode: "Easy", InitialState: "{}", SaveState: "{}", Status: StatusNew},328+ {Name: weekly.Name(2026, 11, 30), GameType: "Sudoku", Mode: "Easy", InitialState: "{}", SaveState: "{}", Status: StatusNew},329+ }330+ for _, g := range games {331+ if err := s.CreateGame(g); err != nil {332+ t.Fatal(err)333+ }334+ if err := s.UpdateStatus(g.ID, StatusCompleted); err != nil {335+ t.Fatal(err)336+ }337+ }338+339+ got, err := s.GetCurrentWeeklyHighestCompletedIndex(2026, 10)340+ if err != nil {341+ t.Fatal(err)342+ }343+ if got != 17 {344+ t.Fatalf("GetCurrentWeeklyHighestCompletedIndex() = %d, want 17", got)345+ }346+}@@ -9,6 +9,7 @@ import (
99 "path/filepath"1010 "time"1111 12+ "github.com/FelineStateMachine/puzzletea/weekly"1213 _ "modernc.org/sqlite"1314 )1415 @@ -218,6 +219,12 @@ func (s *Store) ListGames() ([]GameRecord, error) {
218219 // Daily puzzles should always be resumable regardless of status.219220 // Returns nil, nil if no matching game is found.220221 func (s *Store) GetDailyGame(name string) (*GameRecord, error) {222+ return s.GetDeterministicGame(name)223+}224+225+// GetDeterministicGame looks up an exact deterministic game name, including226+// abandoned rows.227+func (s *Store) GetDeterministicGame(name string) (*GameRecord, error) {221228 g, err := s.getGameByQuery(222229 fmt.Sprintf(`SELECT %s223230 FROM games@@ -225,11 +232,41 @@ func (s *Store) GetDailyGame(name string) (*GameRecord, error) {
225232 name,226233 )227234 if err != nil {228- return nil, fmt.Errorf("querying daily game: %w", err)235+ return nil, fmt.Errorf("querying deterministic game: %w", err)229236 }230237 return g, nil231238 }232239 240+// GetWeeklyGame looks up a weekly game by canonical week and index.241+func (s *Store) GetWeeklyGame(year, weekNumber, index int) (*GameRecord, error) {242+ return s.GetDeterministicGame(weekly.Name(year, weekNumber, index))243+}244+245+// ListWeeklyGames returns all records matching the canonical weekly prefix for246+// the provided ISO week-year, including abandoned rows.247+func (s *Store) ListWeeklyGames(year, weekNumber int) ([]GameRecord, error) {248+ games, err := s.listGamesByQuery(249+ fmt.Sprintf(`SELECT %s250+ FROM games251+ WHERE name LIKE ?252+ ORDER BY name DESC`, gameSelectColumns),253+ weekly.Prefix(year, weekNumber)+"%",254+ )255+ if err != nil {256+ return nil, fmt.Errorf("listing weekly games: %w", err)257+ }258+259+ filtered := make([]GameRecord, 0, len(games))260+ for _, g := range games {261+ info, ok := weekly.ParseName(g.Name)262+ if !ok || info.Year != year || info.Week != weekNumber {263+ continue264+ }265+ filtered = append(filtered, g)266+ }267+ return filtered, nil268+}269+233270 // GetGameByName looks up a single non-abandoned game by its unique name.234271 // Returns nil, nil if no matching game is found.235272 func (s *Store) GetGameByName(name string) (*GameRecord, error) {@@ -4,6 +4,8 @@ import (
44 "path/filepath"55 "testing"66 "time"7+8+ "github.com/FelineStateMachine/puzzletea/weekly"79 )810 911 func openTestStore(t *testing.T) *Store {@@ -507,6 +509,69 @@ func TestGetGameByName(t *testing.T) {
507509 })508510 }509511 512+func TestGetDeterministicGame(t *testing.T) {513+ s := openTestStore(t)514+ rec := newTestRecord("Week 01-2026 - #01")515+ if err := s.CreateGame(rec); err != nil {516+ t.Fatal(err)517+ }518+ if err := s.UpdateStatus(rec.ID, StatusAbandoned); err != nil {519+ t.Fatal(err)520+ }521+522+ got, err := s.GetDeterministicGame(rec.Name)523+ if err != nil {524+ t.Fatal(err)525+ }526+ if got == nil {527+ t.Fatal("expected deterministic record")528+ }529+ if got.Status != StatusAbandoned {530+ t.Fatalf("status = %q, want %q", got.Status, StatusAbandoned)531+ }532+}533+534+func TestWeeklyQueries(t *testing.T) {535+ s := openTestStore(t)536+537+ records := []*GameRecord{538+ newTestRecord(weekly.Name(2026, 1, 1)),539+ newTestRecord(weekly.Name(2026, 1, 2)),540+ newTestRecord(weekly.Name(2026, 2, 1)),541+ newTestRecord("Week 1-2026 - #01"),542+ }543+ for _, rec := range records {544+ if err := s.CreateGame(rec); err != nil {545+ t.Fatal(err)546+ }547+ }548+549+ got, err := s.GetWeeklyGame(2026, 1, 2)550+ if err != nil {551+ t.Fatal(err)552+ }553+ if got == nil || got.Name != weekly.Name(2026, 1, 2) {554+ t.Fatalf("GetWeeklyGame() = %#v, want %q", got, weekly.Name(2026, 1, 2))555+ }556+557+ weeklyGames, err := s.ListWeeklyGames(2026, 1)558+ if err != nil {559+ t.Fatal(err)560+ }561+ if len(weeklyGames) != 2 {562+ t.Fatalf("ListWeeklyGames() len = %d, want 2", len(weeklyGames))563+ }564+ for _, rec := range weeklyGames {565+ info, ok := weekly.ParseName(rec.Name)566+ if !ok {567+ t.Fatalf("ListWeeklyGames() included non-canonical name %q", rec.Name)568+ }569+ if info.Year != 2026 || info.Week != 1 {570+ t.Fatalf("ListWeeklyGames() included wrong week %#v", info)571+ }572+ }573+}574+510575 func TestFullLifecycle(t *testing.T) {511576 s := openTestStore(t)512577 @@ -62,8 +62,6 @@ func InitContinueTable(s *store.Store, height int) (table.Model, []store.GameRec
6262 // Account for column gaps (2 chars between each column).6363 tableWidth += (len(columns) - 1) * 26464 65- p := theme.Current()66-6765 visibleRows := min(len(rows), MaxTableRows)6866 t := table.New(6967 table.WithColumns(columns),@@ -72,6 +70,41 @@ func InitContinueTable(s *store.Store, height int) (table.Model, []store.GameRec
7270 table.WithWidth(tableWidth),7371 table.WithHeight(min(max(height-2, 1), visibleRows)),7472 )73+ t.SetStyles(defaultTableStyles())74+75+ return t, games76+}77+78+// InitWeeklyTable builds the weekly browser table from pre-rendered rows.79+func InitWeeklyTable(rows []table.Row, height int) table.Model {80+ columns := []table.Column{81+ {Title: "Puzzle", Width: 8},82+ {Title: "Game", Width: 15},83+ {Title: "Mode", Width: 18},84+ {Title: "Bonus XP", Width: 10},85+ {Title: "Status", Width: 12},86+ }87+88+ tableWidth := 089+ for _, c := range columns {90+ tableWidth += c.Width91+ }92+ tableWidth += (len(columns) - 1) * 293+94+ tableHeight := min(max(height-2, 1), MaxTableRows)95+ t := table.New(96+ table.WithColumns(columns),97+ table.WithRows(rows),98+ table.WithFocused(true),99+ table.WithWidth(tableWidth),100+ table.WithHeight(tableHeight),101+ )102+ t.SetStyles(defaultTableStyles())103+ return t104+}105+106+func defaultTableStyles() table.Styles {107+ p := theme.Current()75108 76109 st := table.DefaultStyles()77110 st.Header = st.Header.@@ -86,9 +119,7 @@ func InitContinueTable(s *store.Store, height int) (table.Model, []store.GameRec
86119 Bold(true)87120 st.Cell = st.Cell.88121 Foreground(p.FG)89- t.SetStyles(st)90-91- return t, games122+ return st92123 }93124 94125 // TablePagination returns a pagination string like "1-20 of 45" for the@@ -5,6 +5,7 @@ import (
55 "testing"66 "unicode"77 8+ "charm.land/bubbles/v2/table"89 "github.com/FelineStateMachine/puzzletea/store"910 "github.com/FelineStateMachine/puzzletea/theme"1011 @@ -58,6 +59,22 @@ func TestFormatStatus(t *testing.T) {
5859 }5960 }6061 62+func TestInitWeeklyTableKeepsFixedHeight(t *testing.T) {63+ short := InitWeeklyTable([]table.Row{{"#01", "Sudoku", "Easy", "+0", "New"}}, 40)64+ longRows := make([]table.Row, MaxTableRows)65+ for i := range longRows {66+ longRows[i] = table.Row{"#01", "Sudoku", "Easy", "+0", "New"}67+ }68+ long := InitWeeklyTable(longRows, 40)69+70+ if got, want := short.Height(), long.Height(); got != want {71+ t.Fatalf("short Height() = %d, want %d", got, want)72+ }73+ if short.Height() <= 1 {74+ t.Fatalf("Height() = %d, want > 1", short.Height())75+ }76+}77+6178 // --- InitList (P1) ---6279 6380 func TestInitList(t *testing.T) {@@ -0,0 +1,166 @@
1+package weekly2+3+import (4+ "fmt"5+ "hash/fnv"6+ "math/rand/v2"7+ "regexp"8+ "strconv"9+ "time"10+11+ "github.com/FelineStateMachine/puzzletea/catalog"12+ "github.com/FelineStateMachine/puzzletea/game"13+)14+15+// Entry pairs a SeededSpawner with metadata for the eligible weekly pool.16+type Entry struct {17+ Spawner game.SeededSpawner18+ GameType string19+ Mode string20+}21+22+// Info identifies a single weekly gauntlet puzzle.23+type Info struct {24+ Year int25+ Week int26+ Index int27+}28+29+var (30+ eligibleModes = buildEligibleModes()31+ weeklyNamePattern = regexp.MustCompile(`^Week (\d{2})-(\d{4}) - #(\d{2})$`)32+ currentWeekEntries = 9933+)34+35+func buildEligibleModes() []Entry {36+ catalogEntries := catalog.DailyEntries()37+ entries := make([]Entry, 0, len(catalogEntries))38+ for _, entry := range catalogEntries {39+ entries = append(entries, Entry{40+ Spawner: entry.Spawner,41+ GameType: entry.GameType,42+ Mode: entry.Mode,43+ })44+ }45+ return entries46+}47+48+// Name returns the canonical persisted name for a weekly puzzle.49+func Name(year, week, index int) string {50+ return fmt.Sprintf("Week %02d-%04d - #%02d", week, year, index)51+}52+53+// Prefix returns the exact query prefix for all rows in a weekly gauntlet.54+func Prefix(year, week int) string {55+ return fmt.Sprintf("Week %02d-%04d - #", week, year)56+}57+58+// NameForDate returns the weekly puzzle name for the date's ISO week.59+func NameForDate(date time.Time, index int) string {60+ year, week := date.ISOWeek()61+ return Name(year, week, index)62+}63+64+// Current returns the current ISO week identity for the provided date.65+func Current(date time.Time) Info {66+ year, week := date.ISOWeek()67+ return Info{Year: year, Week: week}68+}69+70+// StartOfWeek returns the Monday for the given ISO week.71+func StartOfWeek(year, week int, loc *time.Location) time.Time {72+ if loc == nil {73+ loc = time.Local74+ }75+ jan4 := time.Date(year, time.January, 4, 0, 0, 0, 0, loc)76+ weekdayOffset := (int(jan4.Weekday()) + 6) % 777+ weekOneMonday := jan4.AddDate(0, 0, -weekdayOffset)78+ return weekOneMonday.AddDate(0, 0, (week-1)*7)79+}80+81+// AddWeeks moves to the next or previous ISO week.82+func AddWeeks(weekStart time.Time, delta int) time.Time {83+ return weekStart.AddDate(0, 0, delta*7)84+}85+86+// ParseName parses a canonical weekly name.87+func ParseName(name string) (Info, bool) {88+ matches := weeklyNamePattern.FindStringSubmatch(name)89+ if len(matches) != 4 {90+ return Info{}, false91+ }92+93+ week, err := strconv.Atoi(matches[1])94+ if err != nil {95+ return Info{}, false96+ }97+ year, err := strconv.Atoi(matches[2])98+ if err != nil {99+ return Info{}, false100+ }101+ index, err := strconv.Atoi(matches[3])102+ if err != nil {103+ return Info{}, false104+ }105+ if week < 1 || week > 53 || index < 1 || index > currentWeekEntries {106+ return Info{}, false107+ }108+109+ info := Info{Year: year, Week: week, Index: index}110+ if Name(info.Year, info.Week, info.Index) != name {111+ return Info{}, false112+ }113+ return info, true114+}115+116+// Seed returns a deterministic uint64 seed derived from the weekly identity.117+func Seed(year, week, index int) uint64 {118+ h := fnv.New64a()119+ h.Write([]byte(Name(year, week, index)))120+ return h.Sum64()121+}122+123+// RNG creates a deterministic RNG seeded from the weekly identity.124+func RNG(year, week, index int) *rand.Rand {125+ seed := Seed(year, week, index)126+ return rand.New(rand.NewPCG(seed, ^seed))127+}128+129+// Mode selects the weekly mode from the daily-eligible pool using rendezvous130+// hashing on the canonical weekly name.131+func Mode(year, week, index int) (game.SeededSpawner, string, string) {132+ if len(eligibleModes) == 0 {133+ return nil, "", ""134+ }135+136+ seedName := Name(year, week, index)137+ var best Entry138+ var bestHash uint64139+ found := false140+ for _, entry := range eligibleModes {141+ h := fnv.New64a()142+ h.Write([]byte(seedName))143+ h.Write([]byte{0})144+ h.Write([]byte(entry.GameType))145+ h.Write([]byte{0})146+ h.Write([]byte(entry.Mode))147+ score := h.Sum64()148+ if !found || score > bestHash {149+ bestHash = score150+ best = entry151+ found = true152+ }153+ }154+ if !found {155+ return nil, "", ""156+ }157+ return best.Spawner, best.GameType, best.Mode158+}159+160+// BonusXP returns the slot-based bonus XP for a completed weekly puzzle.161+func BonusXP(index int) int {162+ if index < 10 {163+ return 0164+ }165+ return index / 10166+}@@ -0,0 +1,131 @@
1+package weekly2+3+import (4+ "testing"5+ "time"6+)7+8+func TestNameAndParseRoundTrip(t *testing.T) {9+ name := Name(2026, 1, 1)10+ if name != "Week 01-2026 - #01" {11+ t.Fatalf("Name() = %q, want %q", name, "Week 01-2026 - #01")12+ }13+14+ info, ok := ParseName(name)15+ if !ok {16+ t.Fatal("ParseName() = false, want true")17+ }18+ if info.Year != 2026 || info.Week != 1 || info.Index != 1 {19+ t.Fatalf("ParseName() = %+v, want year=2026 week=1 index=1", info)20+ }21+}22+23+func TestParseNameRejectsInvalidValues(t *testing.T) {24+ tests := []string{25+ "Week 1-2026 - #01",26+ "Week 01-2026 - #1",27+ "Week 00-2026 - #01",28+ "Week 01-2026 - #00",29+ "Week 54-2026 - #01",30+ "Week 01-2026 - #100",31+ "week 01-2026 - #01",32+ }33+34+ for _, name := range tests {35+ t.Run(name, func(t *testing.T) {36+ if _, ok := ParseName(name); ok {37+ t.Fatalf("ParseName(%q) = true, want false", name)38+ }39+ })40+ }41+}42+43+func TestRNGDeterministic(t *testing.T) {44+ rngA := RNG(2026, 12, 7)45+ rngB := RNG(2026, 12, 7)46+47+ for i := range 10 {48+ if got, want := rngA.Int64(), rngB.Int64(); got != want {49+ t.Fatalf("draw %d = %d, want %d", i, got, want)50+ }51+ }52+}53+54+func TestModeDeterministic(t *testing.T) {55+ _, gameTypeA, modeA := Mode(2026, 20, 17)56+ _, gameTypeB, modeB := Mode(2026, 20, 17)57+58+ if gameTypeA != gameTypeB || modeA != modeB {59+ t.Fatalf("Mode() mismatch: (%q,%q) vs (%q,%q)", gameTypeA, modeA, gameTypeB, modeB)60+ }61+}62+63+func TestModeStableOnPoolChange(t *testing.T) {64+ type selection struct {65+ gameType string66+ mode string67+ }68+69+ selections := make([]selection, 0, 20)70+ for index := 1; index <= 20; index++ {71+ _, gameType, mode := Mode(2026, 1, index)72+ selections = append(selections, selection{gameType: gameType, mode: mode})73+ }74+75+ synth := Entry{76+ Spawner: eligibleModes[0].Spawner,77+ GameType: "SyntheticGame",78+ Mode: "SyntheticMode",79+ }80+ eligibleModes = append(eligibleModes, synth)81+ defer func() {82+ eligibleModes = eligibleModes[:len(eligibleModes)-1]83+ }()84+85+ for index := 1; index <= 20; index++ {86+ _, gameType, mode := Mode(2026, 1, index)87+ if gameType == synth.GameType && mode == synth.Mode {88+ continue89+ }90+ want := selections[index-1]91+ if gameType != want.gameType || mode != want.mode {92+ t.Fatalf("index %d selection changed from (%q,%q) to (%q,%q)",93+ index, want.gameType, want.mode, gameType, mode)94+ }95+ }96+}97+98+func TestISOWeekBoundary(t *testing.T) {99+ date := time.Date(2025, time.December, 29, 12, 0, 0, 0, time.UTC)100+ info := Current(date)101+ if info.Year != 2026 || info.Week != 1 {102+ t.Fatalf("Current() = %+v, want year=2026 week=1", info)103+ }104+105+ start := StartOfWeek(2026, 1, time.UTC)106+ if !start.Equal(time.Date(2025, time.December, 29, 0, 0, 0, 0, time.UTC)) {107+ t.Fatalf("StartOfWeek() = %v, want 2025-12-29", start)108+ }109+}110+111+func TestBonusXP(t *testing.T) {112+ tests := []struct {113+ index int114+ want int115+ }{116+ {index: 1, want: 0},117+ {index: 9, want: 0},118+ {index: 10, want: 1},119+ {index: 19, want: 1},120+ {index: 20, want: 2},121+ {index: 99, want: 9},122+ }123+124+ for _, tt := range tests {125+ t.Run(Name(2026, 1, tt.index), func(t *testing.T) {126+ if got := BonusXP(tt.index); got != tt.want {127+ t.Fatalf("BonusXP(%d) = %d, want %d", tt.index, got, tt.want)128+ }129+ })130+ }131+}