Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 9 additions & 1 deletion .claude/codebase/patterns.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
<!-- Copyright 2026 Phillip Cloud -->
<!-- Licensed under the Apache License, Version 2.0 -->
<!-- verified: 2026-04-16 -->
<!-- verified: 2026-04-23 -->

# Code Patterns & Conventions

Expand All @@ -18,6 +18,14 @@ Each entity tab implements TabHandler interface. All entity-specific logic
(Load, Delete, StartAddForm, SubmitForm, etc.) lives in the handler.
No scattered FormKind/TabKind switches outside the handler.

### Drill-down Tab Kind Inheritance
Detail (drill-down) tabs inherit the parent tab's Kind, not the semantic
entity Kind (see `openDetailFromDef` in `model_tabs.go`). E.g. an
Appliance > Documents drill-down has `Tab.Kind = tabAppliances`, not
`tabDocuments`. Always identify entity semantics via the Handler's
`FormKind()` (or a helper like `Tab.isDocumentTab()`) -- never
`tab.Kind == tabX`, which silently breaks drill-down parity.

### Rendering Pipeline
```
View() -> buildView()
Expand Down
5 changes: 1 addition & 4 deletions internal/app/detail_openers_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,10 +20,7 @@ func (m *Model) openServiceLogDetail(maintID string, maintName string) error {
return m.openDetailFromDef(serviceLogDef, maintID, maintName)
}

func (m *Model) openApplianceMaintenanceDetail(
applianceID string, //nolint:unparam // signature mirrors the other openXxx helpers; tests always pass the same fixture ID
applianceName string, //nolint:unparam // signature mirrors the other openXxx helpers; tests always pass the same fixture name
) error {
func (m *Model) openApplianceMaintenanceDetail(applianceID string, applianceName string) error {
return m.openDetailFromDef(applianceMaintenanceDef, applianceID, applianceName)
}

Expand Down
207 changes: 207 additions & 0 deletions internal/app/drilldown_parity_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,207 @@
// Copyright 2026 Phillip Cloud
// Licensed under the Apache License, Version 2.0

package app

import (
"testing"

"github.com/micasa-dev/micasa/internal/data"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)

// TestHardDeleteWorksInMaintenanceDrilldown verifies that Shift+D
// hard-deletes a soft-deleted maintenance item from the
// Appliances > Maintenance drill-down the same way it does from the
// top-level Maintenance tab. Without the fix, both the promptHardDelete
// gate and the confirmHardDelete dispatch keyed on tab.Kind, which in
// the drill-down is tabAppliances -- silently routing or blocking.
func TestHardDeleteWorksInMaintenanceDrilldown(t *testing.T) {
t.Parallel()
m := newTestModelWithStore(t)

// Set up appliance + one maintenance item scoped to it.
require.NoError(t, m.store.CreateAppliance(&data.Appliance{Name: "Furnace"}))
appls, err := m.store.ListAppliances(false)
require.NoError(t, err)
require.NotEmpty(t, appls)
applID := appls[0].ID

cats, err := m.store.MaintenanceCategories()
require.NoError(t, err)
require.NotEmpty(t, cats)
require.NoError(t, m.store.CreateMaintenance(&data.MaintenanceItem{
Name: "Replace filter",
CategoryID: cats[0].ID,
ApplianceID: &applID,
}))
items, err := m.store.ListMaintenanceByAppliance(applID, false)
require.NoError(t, err)
require.Len(t, items, 1)
itemID := items[0].ID

// Open Appliance > Maintenance drill-down.
require.NoError(t, m.openApplianceMaintenanceDetail(applID, "Furnace"))
require.True(t, m.inDetail())
tab := m.effectiveTab()
require.NotNil(t, tab)
assert.Equal(t, formMaintenance, tab.Handler.FormKind(),
"drill-down handler must identify as maintenance via FormKind")

// Select the row, enter edit mode.
require.NotEmpty(t, tab.Rows)
sendKey(m, "i")
require.Equal(t, modeEdit, m.mode)

// Shift+D on a live row must surface the maintenance-specific message.
sendKey(m, "D")
assert.NotEqual(t, confirmHardDelete, m.confirm,
"Shift+D on live row should not prompt hard-delete")
assert.Contains(t, m.statusView(), "Delete the item first",
"message must use 'item' (maintenance), not 'incident'")

// Soft-delete, then hard-delete.
sendKey(m, "d")
require.NoError(t, m.reloadEffectiveTab())

sendKey(m, "D")
assert.Equal(t, confirmHardDelete, m.confirm,
"Shift+D on soft-deleted row should prompt hard-delete in drill-down")
assert.Contains(t, m.statusView(), "Permanently delete this item?",
"prompt label must say 'item' in a maintenance drill-down")

sendKey(m, "y")
assert.Equal(t, confirmNone, m.confirm)
assert.Contains(t, m.statusView(), "Permanently deleted")

// Row must be gone from the store.
_, err = m.store.GetMaintenance(itemID)
assert.Error(t, err, "maintenance item must be hard-deleted from the store")
}

// TestToggleSettledFilterIdentifiesProjectTabByFormKind ensures the
// settled-filter no longer key-cases on tab.Kind, so future project
// drill-downs (none exists today) would inherit the feature and
// non-project drill-downs continue to be no-ops.
func TestToggleSettledFilterIdentifiesProjectTabByFormKind(t *testing.T) {
t.Parallel()
m := newTestModelWithStore(t)

// Top-level Projects tab: toggle should succeed.
m.active = tabIndex(tabProjects)
require.NoError(t, m.reloadActiveTab())
assert.True(t, m.toggleSettledFilter(),
"top-level Projects must still respond to settled filter")

// Non-project drill-down: toggle must be a no-op.
require.NoError(t, m.store.CreateAppliance(&data.Appliance{Name: "Toggle Test"}))
appls, err := m.store.ListAppliances(false)
require.NoError(t, err)
require.NoError(t, m.openApplianceMaintenanceDetail(appls[0].ID, "Toggle Test"))
assert.False(t, m.toggleSettledFilter(),
"settled filter must not fire in a non-project drill-down")
}

// TestApplianceMaintenanceDrilldownAddPrescopesAppliance ensures that
// pressing "a" in an Appliance > Maintenance drill-down pre-populates
// the parent appliance instead of defaulting to the first appliance in
// the store. Without the fix, the drill-down context is lost as soon
// as the add form opens.
func TestApplianceMaintenanceDrilldownAddPrescopesAppliance(t *testing.T) {
t.Parallel()
m := newTestModelWithStore(t)
require.NoError(t, m.store.CreateAppliance(&data.Appliance{Name: "Decoy First"}))
require.NoError(t, m.store.CreateAppliance(&data.Appliance{Name: "Parent Appliance"}))
require.NoError(t, m.loadLookups())

appls, err := m.store.ListAppliances(false)
require.NoError(t, err)
require.Len(t, appls, 2)
var parentID string
for _, a := range appls {
if a.Name == "Parent Appliance" {
parentID = a.ID
break
}
}
require.NotEmpty(t, parentID)

require.NoError(t, m.openApplianceMaintenanceDetail(parentID, "Parent Appliance"))
m.enterEditMode()
sendKey(m, "a")
require.Equal(t, modeForm, m.mode)

fd, ok := m.fs.formData.(*maintenanceFormData)
require.True(t, ok, "expected maintenance form data")
assert.Equal(t, parentID, fd.ApplianceID,
"add form in Appliance > Maintenance must pre-scope to the drilled-into appliance")
}

// TestProjectQuoteDrilldownAddPrescopesProject ensures Project > Quotes
// drill-down pre-selects the parent project on the quote add form.
// Two projects are created so the parent is NOT the default (most
// recently updated) project returned by ListProjects.
func TestProjectQuoteDrilldownAddPrescopesProject(t *testing.T) {
t.Parallel()
m := newTestModelWithStore(t)
types, err := m.store.ProjectTypes()
require.NoError(t, err)
require.NoError(t, m.store.CreateProject(&data.Project{
Title: "Parent Project", ProjectTypeID: types[0].ID, Status: data.ProjectStatusPlanned,
}))
// Create the decoy AFTER the parent so it sorts ahead in
// ListProjects (order: updated_at desc), ensuring the default
// options[0] is the decoy and the test must rely on pre-scoping.
require.NoError(t, m.store.CreateProject(&data.Project{
Title: "Decoy Project", ProjectTypeID: types[0].ID, Status: data.ProjectStatusPlanned,
}))
projects, err := m.store.ListProjects(false)
require.NoError(t, err)
require.Len(t, projects, 2)
var parentID string
for _, p := range projects {
if p.Title == "Parent Project" {
parentID = p.ID
break
}
}
require.NotEmpty(t, parentID)

require.NoError(t, m.openProjectQuoteDetail(parentID, "Parent Project"))
m.enterEditMode()
sendKey(m, "a")
require.Equal(t, modeForm, m.mode)

fd, ok := m.fs.formData.(*quoteFormData)
require.True(t, ok, "expected quote form data")
assert.Equal(t, parentID, fd.ProjectID,
"add form in Project > Quotes must pre-scope to the drilled-into project")
}

// TestVendorQuoteDrilldownAddPrescopesVendor ensures Vendor > Quotes
// drill-down pre-fills the vendor name on the quote add form.
func TestVendorQuoteDrilldownAddPrescopesVendor(t *testing.T) {
t.Parallel()
m := newTestModelWithStore(t)
types, err := m.store.ProjectTypes()
require.NoError(t, err)
require.NoError(t, m.store.CreateProject(&data.Project{
Title: "Any Project", ProjectTypeID: types[0].ID, Status: data.ProjectStatusPlanned,
}))
require.NoError(t, m.store.CreateVendor(&data.Vendor{Name: "Parent Vendor"}))
vendors, err := m.store.ListVendors(false)
require.NoError(t, err)
require.NotEmpty(t, vendors)
parentID := vendors[0].ID

require.NoError(t, m.openVendorQuoteDetail(parentID, "Parent Vendor"))
m.enterEditMode()
sendKey(m, "a")
require.Equal(t, modeForm, m.mode)

fd, ok := m.fs.formData.(*quoteFormData)
require.True(t, ok, "expected quote form data")
assert.Equal(t, "Parent Vendor", fd.VendorName,
"add form in Vendor > Quotes must pre-fill the drilled-into vendor name")
}
14 changes: 10 additions & 4 deletions internal/app/extraction.go
Original file line number Diff line number Diff line change
Expand Up @@ -874,14 +874,20 @@ func (m *Model) acceptDeferredExtraction() error {
doc := ex.pendingDoc

// Apply fields from "create documents" operations to the pending doc.
// When the pending doc already has an entity scope (set by magic-add
// in an entity drill-down), preserve it: the user's explicit scope
// wins over any LLM guess.
preScoped := doc.EntityKind != ""
for _, op := range ex.operations {
if op.Table == tableDocuments {
applyStringField(op.Data, "title", &doc.Title)
applyStringField(op.Data, "notes", &doc.Notes)
applyStringField(op.Data, "entity_kind", &doc.EntityKind)
if v, ok := op.Data["entity_id"]; ok {
if n := extract.ParseStringID(v); n != "" {
doc.EntityID = n
if !preScoped {
applyStringField(op.Data, "entity_kind", &doc.EntityKind)
if v, ok := op.Data["entity_id"]; ok {
if n := extract.ParseStringID(v); n != "" {
doc.EntityID = n
}
}
}
}
Expand Down
41 changes: 40 additions & 1 deletion internal/app/forms.go
Original file line number Diff line number Diff line change
Expand Up @@ -316,16 +316,31 @@ func (m *Model) openProjectForm(values *projectFormData, options []huh.Option[st
}

func (m *Model) startQuoteForm() error {
return m.startQuoteFormScoped("", "")
}

// startQuoteFormScoped opens the add-quote form with optional pre-scope
// for drill-down contexts. Empty strings mean "no pre-scope" and the
// form defaults to the first option (project) / blank (vendor name).
func (m *Model) startQuoteFormScoped(projectID, vendorName string) error {
projects, err := m.store.ListProjects(false)
if err != nil {
return err
}
if len(projects) == 0 {
return errors.New("add a project before adding quotes")
}
values := &quoteFormData{}
values := &quoteFormData{VendorName: vendorName}
options := projectOptions(projects)
values.ProjectID = options[0].Value
if projectID != "" {
for _, opt := range options {
if opt.Value == projectID {
values.ProjectID = projectID
break
}
}
}
form := huh.NewForm(
huh.NewGroup(
huh.NewSelect[string]().
Expand Down Expand Up @@ -422,6 +437,13 @@ func scheduleTypeOptions() []huh.Option[scheduleType] {
}

func (m *Model) startMaintenanceForm() error {
return m.startMaintenanceFormScoped("")
}

// startMaintenanceFormScoped opens the add-maintenance form with an
// optional appliance pre-selected. An empty applianceID means "no
// pre-scope" and the form defaults to no appliance.
func (m *Model) startMaintenanceFormScoped(applianceID string) error {
values := &maintenanceFormData{ScheduleType: schedNone}
catOptions := maintenanceOptions(m.maintenanceCategories)
if len(catOptions) > 0 {
Expand All @@ -432,6 +454,14 @@ func (m *Model) startMaintenanceForm() error {
return fmt.Errorf("list appliances: %w", err)
}
appOpts := applianceOptions(appliances)
if applianceID != "" {
for _, opt := range appOpts {
if opt.Value == applianceID {
values.ApplianceID = applianceID
break
}
}
}
form := huh.NewForm(
huh.NewGroup(
huh.NewInput().
Expand Down Expand Up @@ -2395,8 +2425,17 @@ func (m *Model) startDocumentForm(entityKind string) error {
// startQuickDocumentForm opens a minimal document form that only asks for a
// file path. Title and notes are auto-filled by the extraction pipeline on
// submit, making this the fast path for ingesting files.
//
// When invoked from an entity-scoped document drill-down, the parent
// entity is pre-populated so the created document is attached to that
// entity rather than left unscoped.
func (m *Model) startQuickDocumentForm() {
values := &documentFormData{DeferCreate: true}
if tab := m.effectiveTab(); tab != nil {
if sh, ok := tab.Handler.(scopedHandler); ok && sh.entityKind != "" {
values.EntityRef = entityRef{Kind: sh.entityKind, ID: sh.entityID}
}
}
form := huh.NewForm(
huh.NewGroup(
m.newDocumentFilePicker("File to attach").
Expand Down
Loading
Loading