Skip to content

Commit 0d591ac

Browse files
cpcloudclaude
andauthored
feat(domain): seasonal tagging for maintenance items (#733)
## Summary - Add `Season` string field to `MaintenanceItem` with constants: spring, summer, fall, winter (empty = no season) - New "Season" table column with `cellStatus` badge rendering and pin-filtering support - Season select field in add/edit forms and inline edit overlay - "Seasonal" dashboard section showing items tagged with the current calendar season (Mar-May spring, Jun-Aug summer, Sep-Nov fall, Dec-Feb winter) - Short labels in compact mode: spr/sum/fall/win - Status styles for all four seasons (Wong palette colors) closes #686 --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 16ac236 commit 0d591ac

17 files changed

Lines changed: 680 additions & 15 deletions

AGENTS.md

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -236,10 +236,12 @@ details; do not duplicate that detail here.
236236
model fields directly. Internal/unit tests are permitted only after
237237
user-interaction tests exist and only when you judge them genuinely
238238
necessary as supplements.
239-
- **Regression tests are strict TDD**: Write a test that reproduces the
240-
bug first, confirm it fails, then iterate on the fix until the test
241-
passes. Do not game this by wildly mutating code just to satisfy the
242-
test -- fix the actual root cause.
239+
- **Test-first for all feature work and bug fixes**: Write tests that
240+
fully describe the desired behavior before writing the implementation.
241+
Confirm they fail, then implement to make them pass. Tests are the
242+
spec -- if the tests pass but the feature is incomplete or the bug
243+
still reproduces, the tests are wrong. Do not game this by wildly
244+
mutating code just to satisfy the test -- fix the actual root cause.
243245
- **Use `testify/assert` and `testify/require`**: `require` for
244246
preconditions, `assert` for assertions. No bare `t.Fatal`/`t.Error`.
245247
- **Test every error path**: Every function that can fail needs at least

internal/app/compact.go

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,11 @@ var statusLabels = map[string]string{
2727
"urgent": "urg",
2828
"soon": "soon",
2929
"whenever": "low",
30+
// Seasons.
31+
"spring": "spr",
32+
"summer": "sum",
33+
"fall": "fall",
34+
"winter": "win",
3035
}
3136

3237
// statusLabel returns the short display label for a status value.

internal/app/dashboard.go

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ const (
2020
dashSectionIncidents = "Incidents"
2121
dashSectionOverdue = "Overdue"
2222
dashSectionUpcoming = "Upcoming"
23+
dashSectionSeasonal = "Seasonal"
2324
dashSectionProjects = "Active Projects"
2425
dashSectionExpiring = "Expiring Soon"
2526
)
@@ -41,6 +42,7 @@ func (m *Model) dashboardHeader() string {
4142
type dashboardData struct {
4243
Overdue []maintenanceUrgency
4344
Upcoming []maintenanceUrgency
45+
Seasonal []data.MaintenanceItem
4446
ActiveProjects []data.Project
4547
OpenIncidents []data.Incident
4648
ExpiringWarranties []warrantyStatus
@@ -50,6 +52,7 @@ type dashboardData struct {
5052
func (d dashboardData) empty() bool {
5153
return len(d.Overdue) == 0 &&
5254
len(d.Upcoming) == 0 &&
55+
len(d.Seasonal) == 0 &&
5356
len(d.ActiveProjects) == 0 &&
5457
len(d.OpenIncidents) == 0 &&
5558
len(d.ExpiringWarranties) == 0 &&
@@ -281,6 +284,13 @@ func (m *Model) loadDashboardAt(now time.Time) error {
281284
d.Overdue = capSlice(d.Overdue, 10)
282285
d.Upcoming = capSlice(d.Upcoming, 10)
283286

287+
// Seasonal maintenance (items tagged with the current season).
288+
currentSeason := data.SeasonForMonth(now.Month())
289+
d.Seasonal, err = m.store.ListMaintenanceBySeason(currentSeason)
290+
if err != nil {
291+
return fmt.Errorf("load seasonal maintenance: %w", err)
292+
}
293+
284294
// Active projects.
285295
d.ActiveProjects, err = m.store.ListActiveProjects()
286296
if err != nil {
@@ -380,6 +390,10 @@ func (m *Model) buildDashNav() {
380390
d.Upcoming, tabMaintenance, dashSectionUpcoming,
381391
func(e maintenanceUrgency) uint { return e.Item.ID },
382392
))
393+
add(dashSectionSeasonal, dashNavSection(
394+
d.Seasonal, tabMaintenance, dashSectionSeasonal,
395+
func(item data.MaintenanceItem) uint { return item.ID },
396+
))
383397
add(dashSectionProjects, dashNavSection(
384398
d.ActiveProjects, tabProjects, dashSectionProjects,
385399
func(p data.Project) uint { return p.ID },
@@ -475,6 +489,14 @@ func (m *Model) dashboardView(budget, maxWidth int) string {
475489
})
476490
}
477491

492+
if seasonalRows := m.dashSeasonalRows(); len(seasonalRows) > 0 {
493+
sections = append(sections, dashSection{
494+
title: dashSectionSeasonal,
495+
headers: []string{"", "category"},
496+
rows: seasonalRows,
497+
})
498+
}
499+
478500
if projRows := m.dashProjectRows(); len(projRows) > 0 {
479501
sections = append(sections, dashSection{
480502
title: dashSectionProjects,
@@ -667,6 +689,21 @@ func (m *Model) maintUrgencyRows(
667689
return rows
668690
}
669691

692+
func (m *Model) dashSeasonalRows() []dashRow {
693+
d := m.dash.data
694+
rows := make([]dashRow, 0, len(d.Seasonal))
695+
for _, item := range d.Seasonal {
696+
rows = append(rows, dashRow{
697+
Cells: []dashCell{
698+
{Text: item.Name, Style: m.styles.DashValue()},
699+
{Text: item.Category.Name, Style: m.styles.DashLabel()},
700+
},
701+
Target: &dashNavEntry{Tab: tabMaintenance, ID: item.ID},
702+
})
703+
}
704+
return rows
705+
}
706+
670707
func (m *Model) dashProjectRows() []dashRow {
671708
d := m.dash.data
672709
now := time.Now()

internal/app/dashboard_test.go

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -247,6 +247,45 @@ func TestDashboardViewWithData(t *testing.T) {
247247
assert.Contains(t, view, "overdue")
248248
}
249249

250+
func TestDashboardViewSeasonalSection(t *testing.T) {
251+
t.Parallel()
252+
m := newTestModel(t)
253+
m.width = 120
254+
m.height = 40
255+
256+
m.dash.data = dashboardData{
257+
Seasonal: []data.MaintenanceItem{
258+
{ID: 1, Name: "Clean Gutters", Season: data.SeasonSpring},
259+
{ID: 2, Name: "Service AC", Season: data.SeasonSpring},
260+
},
261+
}
262+
m.dash.expanded = map[string]bool{
263+
dashSectionSeasonal: true,
264+
}
265+
m.prepareDashboardView()
266+
267+
view := m.dashboardView(50, 120)
268+
assert.Contains(t, view, "Clean Gutters")
269+
assert.Contains(t, view, "Service AC")
270+
assert.Contains(t, view, "Seasonal")
271+
}
272+
273+
func TestDashboardSeasonalEmpty(t *testing.T) {
274+
t.Parallel()
275+
m := newTestModel(t)
276+
m.width = 120
277+
m.height = 40
278+
279+
m.dash.data = dashboardData{
280+
Seasonal: nil,
281+
}
282+
m.prepareDashboardView()
283+
284+
view := m.dashboardView(50, 120)
285+
assert.NotContains(t, view, "Seasonal",
286+
"seasonal section should not appear when there are no seasonal items")
287+
}
288+
250289
func TestDashboardViewIncidentsFirst(t *testing.T) {
251290
t.Parallel()
252291
m := newTestModel(t)

internal/app/form_save_test.go

Lines changed: 153 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -488,6 +488,159 @@ func TestUserEditsMaintenanceFromIntervalToNone(t *testing.T) {
488488
assert.Empty(t, cells[int(maintenanceColEvery)].Value)
489489
}
490490

491+
// User creates a maintenance item with a season tag.
492+
func TestUserCreatesMaintenanceWithSeason(t *testing.T) {
493+
t.Parallel()
494+
m := newTestModelWithStore(t)
495+
m.active = tabIndex(tabMaintenance)
496+
openAddForm(m)
497+
498+
values, ok := m.fs.formData.(*maintenanceFormData)
499+
require.True(t, ok)
500+
values.Name = "Clean Gutters"
501+
values.Season = data.SeasonSpring
502+
sendKey(m, "ctrl+s")
503+
sendKey(m, "esc")
504+
505+
// Verify persisted to DB.
506+
items, err := m.store.ListMaintenance(false)
507+
require.NoError(t, err)
508+
require.Len(t, items, 1)
509+
assert.Equal(t, data.SeasonSpring, items[0].Season)
510+
511+
// Verify table column renders the season.
512+
m.reloadAll()
513+
require.NoError(t, m.reloadActiveTab())
514+
tab := m.activeTab()
515+
require.NotEmpty(t, tab.CellRows)
516+
seasonCell := tab.CellRows[0][int(maintenanceColSeason)]
517+
assert.Equal(t, data.SeasonSpring, seasonCell.Value)
518+
assert.Equal(t, cellStatus, seasonCell.Kind)
519+
}
520+
521+
// User edits a maintenance item to change its season.
522+
func TestUserEditsMaintenanceSeason(t *testing.T) {
523+
t.Parallel()
524+
m := newTestModelWithStore(t)
525+
m.active = tabIndex(tabMaintenance)
526+
527+
// Create with spring.
528+
openAddForm(m)
529+
values, ok := m.fs.formData.(*maintenanceFormData)
530+
require.True(t, ok)
531+
values.Name = "Service AC"
532+
values.Season = data.SeasonSpring
533+
sendKey(m, "ctrl+s")
534+
sendKey(m, "esc")
535+
536+
m.reloadAll()
537+
require.NoError(t, m.reloadActiveTab())
538+
tab := m.activeTab()
539+
require.NotEmpty(t, tab.Rows)
540+
tab.Table.SetCursor(0)
541+
id := tab.Rows[0].ID
542+
543+
// Open full edit form via ID column.
544+
sendKey(m, "i")
545+
tab.ColCursor = int(maintenanceColID)
546+
sendKey(m, "e")
547+
require.Equal(t, modeForm, m.mode)
548+
549+
editValues, ok := m.fs.formData.(*maintenanceFormData)
550+
require.True(t, ok)
551+
assert.Equal(t, data.SeasonSpring, editValues.Season,
552+
"edit form should pre-fill existing season")
553+
554+
// Change to fall.
555+
editValues.Season = data.SeasonFall
556+
sendKey(m, "ctrl+s")
557+
sendKey(m, "esc")
558+
559+
item, err := m.store.GetMaintenance(id)
560+
require.NoError(t, err)
561+
assert.Equal(t, data.SeasonFall, item.Season)
562+
}
563+
564+
// User creates a maintenance item without a season (defaults to none).
565+
func TestUserCreatesMaintenanceWithoutSeason(t *testing.T) {
566+
t.Parallel()
567+
m := newTestModelWithStore(t)
568+
m.active = tabIndex(tabMaintenance)
569+
openAddForm(m)
570+
571+
values, ok := m.fs.formData.(*maintenanceFormData)
572+
require.True(t, ok)
573+
values.Name = "General Checkup"
574+
// Season left empty (default).
575+
sendKey(m, "ctrl+s")
576+
sendKey(m, "esc")
577+
578+
items, err := m.store.ListMaintenance(false)
579+
require.NoError(t, err)
580+
require.Len(t, items, 1)
581+
assert.Empty(t, items[0].Season)
582+
583+
// Table cell should be null.
584+
m.reloadAll()
585+
require.NoError(t, m.reloadActiveTab())
586+
tab := m.activeTab()
587+
require.NotEmpty(t, tab.CellRows)
588+
seasonCell := tab.CellRows[0][int(maintenanceColSeason)]
589+
assert.True(t, seasonCell.Null, "empty season should produce a null cell")
590+
}
591+
592+
// User inline-edits the Season column on a maintenance item.
593+
func TestUserInlineEditsMaintenanceSeason(t *testing.T) {
594+
t.Parallel()
595+
m := newTestModelWithStore(t)
596+
m.active = tabIndex(tabMaintenance)
597+
598+
// Create with spring season.
599+
openAddForm(m)
600+
values, ok := m.fs.formData.(*maintenanceFormData)
601+
require.True(t, ok)
602+
values.Name = "Service AC"
603+
values.Season = data.SeasonSpring
604+
sendKey(m, "ctrl+s")
605+
sendKey(m, "esc")
606+
607+
// Reload and position on the maintenance tab.
608+
m.reloadAll()
609+
require.NoError(t, m.reloadActiveTab())
610+
tab := m.activeTab()
611+
require.NotEmpty(t, tab.Rows)
612+
tab.Table.SetCursor(0)
613+
id := tab.Rows[0].ID
614+
615+
// Enter edit mode, position on the Season column, press 'e'.
616+
sendKey(m, "i")
617+
tab.ColCursor = int(maintenanceColSeason)
618+
sendKey(m, "e")
619+
require.Equal(t, modeForm, m.mode, "Season inline edit should open form overlay")
620+
621+
// The form data should reflect the current season.
622+
fd, ok := m.fs.formData.(*maintenanceFormData)
623+
require.True(t, ok)
624+
assert.Equal(t, data.SeasonSpring, fd.Season)
625+
626+
// Change to winter and save.
627+
fd.Season = data.SeasonWinter
628+
sendKey(m, "ctrl+s")
629+
630+
// Verify DB was updated.
631+
item, err := m.store.GetMaintenance(id)
632+
require.NoError(t, err)
633+
assert.Equal(t, data.SeasonWinter, item.Season)
634+
635+
// Verify table cell updated after reload.
636+
m.reloadAll()
637+
require.NoError(t, m.reloadActiveTab())
638+
tab = m.activeTab()
639+
require.NotEmpty(t, tab.CellRows)
640+
seasonCell := tab.CellRows[0][int(maintenanceColSeason)]
641+
assert.Equal(t, data.SeasonWinter, seasonCell.Value)
642+
}
643+
491644
// When ScheduleType is "due_date", stale IntervalMonths values are ignored.
492645
func TestScheduleTypeDueDateIgnoresStaleInterval(t *testing.T) {
493646
t.Parallel()

0 commit comments

Comments
 (0)