Skip to content

Commit 640aa75

Browse files
committed
feat(ui): add project status filters and compact nav hints
Improve project triage by adding completed/abandoned/settled visibility toggles that keep inactive work out of the default list when desired. Rework nav-mode status hints to be state-first and width-aware so operators keep key discoverability without a noisy, overflowing status bar. Closes #24 Closes #72 Closes #73
1 parent b2f351b commit 640aa75

File tree

9 files changed

+706
-46
lines changed

9 files changed

+706
-46
lines changed

docs/content/guide/projects.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,14 @@ Projects move through these statuses. Each has a distinct color in the table:
4444
- <span class="status-completed">**completed**</span> -- done
4545
- <span class="status-abandoned">**abandoned**</span> -- decided not to do it
4646

47+
## Status filters
48+
49+
In Normal mode on the Projects tab:
50+
51+
- Press `z` to toggle hiding projects with status `completed`
52+
- Press `a` to toggle hiding projects with status `abandoned`
53+
- Press `t` to toggle hiding **settled projects** (`completed` + `abandoned`)
54+
4755
## Description
4856

4957
The edit form includes a `Description` textarea (in the "Timeline" group) for

docs/content/reference/keybindings.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,9 @@ Complete reference of every keybinding in micasa, organized by mode.
4545
|-----|--------|
4646
| `s` | Cycle sort on current column (none -> asc -> desc -> none) |
4747
| `S` | Clear all sorts |
48+
| `z` | Projects tab: toggle hiding completed projects |
49+
| `a` | Projects tab: toggle hiding abandoned projects |
50+
| `t` | Projects tab: toggle hiding settled projects (`completed` + `abandoned`) |
4851
| `/` | Jump to column (fuzzy find) |
4952
| `c` | Hide current column |
5053
| `C` | Show all hidden columns |

images/demo.gif

131 KB
Loading

internal/app/handler_crud_test.go

Lines changed: 182 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -146,6 +146,188 @@ func TestProjectHandlerSnapshot(t *testing.T) {
146146
}
147147
}
148148

149+
func TestProjectTabStatusFiltersRows(t *testing.T) {
150+
m := newTestModelWithStore(t)
151+
types, _ := m.store.ProjectTypes()
152+
153+
for _, p := range []data.Project{
154+
{
155+
Title: "Kitchen Plan",
156+
ProjectTypeID: types[0].ID,
157+
Status: data.ProjectStatusPlanned,
158+
},
159+
{
160+
Title: "Fence Done",
161+
ProjectTypeID: types[0].ID,
162+
Status: data.ProjectStatusCompleted,
163+
},
164+
{
165+
Title: "Basement Work",
166+
ProjectTypeID: types[0].ID,
167+
Status: data.ProjectStatusInProgress,
168+
},
169+
{
170+
Title: "Old Patio Idea",
171+
ProjectTypeID: types[0].ID,
172+
Status: data.ProjectStatusAbandoned,
173+
},
174+
} {
175+
if err := m.store.CreateProject(p); err != nil {
176+
t.Fatalf("CreateProject(%q): %v", p.Title, err)
177+
}
178+
}
179+
180+
m.active = tabIndex(tabProjects)
181+
if err := m.reloadActiveTab(); err != nil {
182+
t.Fatalf("reloadActiveTab: %v", err)
183+
}
184+
tab := m.activeTab()
185+
if tab == nil {
186+
t.Fatal("expected active projects tab")
187+
}
188+
if len(tab.Rows) != 4 {
189+
t.Fatalf("expected 4 rows before filtering, got %d", len(tab.Rows))
190+
}
191+
192+
tab.HideCompleted = true
193+
if err := m.reloadActiveTab(); err != nil {
194+
t.Fatalf("reloadActiveTab with HideCompleted: %v", err)
195+
}
196+
if len(tab.Rows) != 3 {
197+
t.Fatalf("expected 3 rows with completed hidden, got %d", len(tab.Rows))
198+
}
199+
for i, cells := range tab.CellRows {
200+
if len(cells) > 3 && cells[3].Value == data.ProjectStatusCompleted {
201+
t.Fatalf("row %d still has completed status after filtering", i)
202+
}
203+
}
204+
205+
tab.HideCompleted = false
206+
tab.HideAbandoned = true
207+
if err := m.reloadActiveTab(); err != nil {
208+
t.Fatalf("reloadActiveTab with HideAbandoned: %v", err)
209+
}
210+
if len(tab.Rows) != 3 {
211+
t.Fatalf("expected 3 rows with abandoned hidden, got %d", len(tab.Rows))
212+
}
213+
for i, cells := range tab.CellRows {
214+
if len(cells) > 3 && cells[3].Value == data.ProjectStatusAbandoned {
215+
t.Fatalf("row %d still has abandoned status after filtering", i)
216+
}
217+
}
218+
219+
tab.HideAbandoned = false
220+
tab.HideCompleted = true
221+
tab.HideAbandoned = true
222+
if err := m.reloadActiveTab(); err != nil {
223+
t.Fatalf("reloadActiveTab with settled filters: %v", err)
224+
}
225+
if len(tab.Rows) != 2 {
226+
t.Fatalf("expected 2 rows with settled hidden, got %d", len(tab.Rows))
227+
}
228+
for i, cells := range tab.CellRows {
229+
if len(cells) <= 3 {
230+
continue
231+
}
232+
status := cells[3].Value
233+
if status == data.ProjectStatusCompleted || status == data.ProjectStatusAbandoned {
234+
t.Fatalf("row %d still has settled status %q after filtering", i, status)
235+
}
236+
}
237+
238+
tab.HideCompleted = false
239+
tab.HideAbandoned = false
240+
if err := m.reloadActiveTab(); err != nil {
241+
t.Fatalf("reloadActiveTab after clearing filters: %v", err)
242+
}
243+
if len(tab.Rows) != 4 {
244+
t.Fatalf("expected 4 rows after showing all projects, got %d", len(tab.Rows))
245+
}
246+
}
247+
248+
func TestProjectStatusFilterToggleKeysReloadRows(t *testing.T) {
249+
m := newTestModelWithStore(t)
250+
types, _ := m.store.ProjectTypes()
251+
252+
if err := m.store.CreateProject(data.Project{
253+
Title: "Done Project",
254+
ProjectTypeID: types[0].ID,
255+
Status: data.ProjectStatusCompleted,
256+
}); err != nil {
257+
t.Fatalf("CreateProject completed: %v", err)
258+
}
259+
if err := m.store.CreateProject(data.Project{
260+
Title: "Live Project",
261+
ProjectTypeID: types[0].ID,
262+
Status: data.ProjectStatusInProgress,
263+
}); err != nil {
264+
t.Fatalf("CreateProject in-progress: %v", err)
265+
}
266+
if err := m.store.CreateProject(data.Project{
267+
Title: "Abandoned Project",
268+
ProjectTypeID: types[0].ID,
269+
Status: data.ProjectStatusAbandoned,
270+
}); err != nil {
271+
t.Fatalf("CreateProject abandoned: %v", err)
272+
}
273+
274+
m.active = tabIndex(tabProjects)
275+
if err := m.reloadActiveTab(); err != nil {
276+
t.Fatalf("reloadActiveTab: %v", err)
277+
}
278+
if got := len(m.activeTab().Rows); got != 3 {
279+
t.Fatalf("expected 3 rows before toggles, got %d", got)
280+
}
281+
282+
sendKey(m, "z")
283+
if got := len(m.activeTab().Rows); got != 2 {
284+
t.Fatalf("expected 2 rows after hiding completed, got %d", got)
285+
}
286+
if m.activeTab().HideCompleted != true {
287+
t.Fatal("HideCompleted should be enabled after first toggle")
288+
}
289+
290+
sendKey(m, "a")
291+
if got := len(m.activeTab().Rows); got != 1 {
292+
t.Fatalf("expected 1 row after hiding abandoned too, got %d", got)
293+
}
294+
if !m.activeTab().HideAbandoned {
295+
t.Fatal("HideAbandoned should be enabled after toggle")
296+
}
297+
298+
sendKey(m, "t")
299+
if got := len(m.activeTab().Rows); got != 3 {
300+
t.Fatalf("expected 3 rows after clearing settled filters, got %d", got)
301+
}
302+
if m.activeTab().HideCompleted || m.activeTab().HideAbandoned {
303+
t.Fatal("settled toggle should disable both filters when both are active")
304+
}
305+
306+
sendKey(m, "t")
307+
if got := len(m.activeTab().Rows); got != 1 {
308+
t.Fatalf("expected 1 row after settled-only filter, got %d", got)
309+
}
310+
if !m.activeTab().HideCompleted || !m.activeTab().HideAbandoned {
311+
t.Fatal("settled toggle should enable both filters")
312+
}
313+
314+
sendKey(m, "z")
315+
if m.activeTab().HideCompleted {
316+
t.Fatal("HideCompleted should be disabled after toggling z")
317+
}
318+
if got := len(m.activeTab().Rows); got != 2 {
319+
t.Fatalf("expected 2 rows when only abandoned filter is active, got %d", got)
320+
}
321+
322+
sendKey(m, "a")
323+
if m.activeTab().HideAbandoned {
324+
t.Fatal("HideAbandoned should be disabled after toggling a")
325+
}
326+
if got := len(m.activeTab().Rows); got != 3 {
327+
t.Fatalf("expected 3 rows after clearing individual filters, got %d", got)
328+
}
329+
}
330+
149331
// ---------------------------------------------------------------------------
150332
// applianceHandler CRUD
151333
// ---------------------------------------------------------------------------

internal/app/mode_test.go

Lines changed: 76 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -365,13 +365,86 @@ func TestEscClearsStatusInNormalMode(t *testing.T) {
365365
}
366366
}
367367

368+
func TestProjectStatusFilterToggleKeys(t *testing.T) {
369+
m := newTestModel()
370+
tab := m.activeTab()
371+
if tab == nil || tab.Kind != tabProjects {
372+
t.Fatal("expected projects tab to be active")
373+
}
374+
if tab.HideCompleted || tab.HideAbandoned {
375+
t.Fatal("project status filters should start disabled")
376+
}
377+
378+
sendKey(m, "z")
379+
if !tab.HideCompleted {
380+
t.Fatal("expected HideCompleted enabled after first z")
381+
}
382+
if m.status.Text != "Completed projects hidden." {
383+
t.Fatalf("unexpected status after hide: %q", m.status.Text)
384+
}
385+
386+
sendKey(m, "z")
387+
if tab.HideCompleted {
388+
t.Fatal("expected HideCompleted disabled after second z")
389+
}
390+
if m.status.Text != "Completed projects shown." {
391+
t.Fatalf("unexpected status after show: %q", m.status.Text)
392+
}
393+
394+
sendKey(m, "a")
395+
if !tab.HideAbandoned {
396+
t.Fatal("expected HideAbandoned enabled after first a")
397+
}
398+
if m.status.Text != "Abandoned projects hidden." {
399+
t.Fatalf("unexpected status after hide abandoned: %q", m.status.Text)
400+
}
401+
sendKey(m, "a")
402+
if tab.HideAbandoned {
403+
t.Fatal("expected HideAbandoned disabled after second a")
404+
}
405+
406+
sendKey(m, "t")
407+
if !tab.HideCompleted || !tab.HideAbandoned {
408+
t.Fatal("expected settled toggle to enable both completed and abandoned filters")
409+
}
410+
if m.status.Text != "Settled projects hidden." {
411+
t.Fatalf("unexpected status after hide settled: %q", m.status.Text)
412+
}
413+
sendKey(m, "t")
414+
if tab.HideCompleted || tab.HideAbandoned {
415+
t.Fatal("expected settled toggle to disable both filters on second press")
416+
}
417+
}
418+
419+
func TestProjectStatusFilterToggleIgnoredOutsideProjects(t *testing.T) {
420+
m := newTestModel()
421+
m.active = tabIndex(tabQuotes)
422+
tab := m.activeTab()
423+
if tab == nil || tab.Kind != tabQuotes {
424+
t.Fatal("expected quotes tab to be active")
425+
}
426+
427+
for _, key := range []string{"z", "a", "t"} {
428+
_, handled := m.handleNormalKeys(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune(key)})
429+
if handled {
430+
t.Fatalf("expected %s to be ignored outside projects tab", key)
431+
}
432+
}
433+
if tab.HideCompleted || tab.HideAbandoned {
434+
t.Fatal("project status filters should remain disabled on non-project tabs")
435+
}
436+
if m.status.Text != "" {
437+
t.Fatalf("expected no status change, got %q", m.status.Text)
438+
}
439+
}
440+
368441
func TestKeyDispatchEditModeOnly(t *testing.T) {
369442
m := newTestModel()
370443

371-
// 'a' should not be handled in normal mode.
372-
_, handled := m.handleNormalKeys(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("a")})
444+
// 'p' should not be handled in normal mode.
445+
_, handled := m.handleNormalKeys(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("p")})
373446
if handled {
374-
t.Fatal("'a' should not be handled in normal mode")
447+
t.Fatal("'p' should not be handled in normal mode")
375448
}
376449

377450
// 'esc' should be handled in edit mode (back to normal).

0 commit comments

Comments
 (0)