diff options
author | T <t@tjp.lol> | 2025-09-29 15:04:44 -0600 |
---|---|---|
committer | T <t@tjp.lol> | 2025-09-30 11:40:45 -0600 |
commit | 7ba68d333bc20b5795ccfd3870546a05eee60470 (patch) | |
tree | 12dc4b017803b7d01844fd42b9e3be281cbbd986 /internal/tui | |
parent | bce8dbb58165e443902d9dae3909225ef42630c4 (diff) |
Diffstat (limited to 'internal/tui')
-rw-r--r-- | internal/tui/app.go | 78 | ||||
-rw-r--r-- | internal/tui/commands.go | 141 | ||||
-rw-r--r-- | internal/tui/history_box.go | 2 | ||||
-rw-r--r-- | internal/tui/keys.go | 74 | ||||
-rw-r--r-- | internal/tui/modal.go | 106 | ||||
-rw-r--r-- | internal/tui/projects_box.go | 210 | ||||
-rw-r--r-- | internal/tui/shared.go | 1 | ||||
-rw-r--r-- | internal/tui/shared_test.go | 2 |
8 files changed, 524 insertions, 90 deletions
diff --git a/internal/tui/app.go b/internal/tui/app.go index fe5f364..38457ff 100644 --- a/internal/tui/app.go +++ b/internal/tui/app.go @@ -5,6 +5,7 @@ import ( "fmt" "time" + "git.tjp.lol/punchcard/internal/actions" "git.tjp.lol/punchcard/internal/queries" tea "github.com/charmbracelet/bubbletea" @@ -180,6 +181,13 @@ func (m AppModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case modalClosed: m.modalBox.deactivate() + case reportGenerationSucceeded: + m.modalBox.deactivate() + + case reportGenerationFailed: + m.modalBox.form.err = msg.err + m.modalBox.Active = true + case openTimeEntryEditor: if m.selectedBox == HistoryBox && m.historyBox.viewLevel == HistoryLevelDetails { m.openEntryEditor() @@ -232,6 +240,32 @@ func (m AppModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.filterHistoryByProjectBox() } cmds = append(cmds, m.refreshCmd) + + case toggleShowArchivedMsg: + if m.selectedBox == ProjectsBox { + m.projectsBox.showArchived = !m.projectsBox.showArchived + m.projectsBox.restoreSelection(msg.restoreClientID, msg.restoreProjectID) + } + + case archiveSelectedMsg: + if m.selectedBox == ProjectsBox { + cmds = append(cmds, m.archiveSelectedClientOrProject(msg.clientID, msg.projectID, msg.restoreClientID, msg.restoreProjectID)) + } + + case unarchiveSelectedMsg: + if m.selectedBox == ProjectsBox { + cmds = append(cmds, m.unarchiveSelectedClientOrProject(msg.clientID, msg.projectID, msg.restoreClientID, msg.restoreProjectID)) + } + + case restoreSelectionMsg: + if m.selectedBox == ProjectsBox { + m.projectsBox.restoreSelection(msg.clientID, msg.projectID) + } + + case showArchivedWarningMsg: + m.modalBox.Active = true + m.modalBox.Type = ModalTypeArchivedWarning + m.modalBox.archivedPunchInParams = msg.params } return m, tea.Batch(cmds...) @@ -249,6 +283,50 @@ func (m *AppModel) filterHistoryByProjectBox() { m.historyBox.resetSelection() } +func (m *AppModel) archiveSelectedClientOrProject(clientID int64, projectID *int64, restoreClientID int64, restoreProjectID *int64) tea.Cmd { + return tea.Sequence( + func() tea.Msg { + a := actions.New(m.queries) + + if projectID == nil { + // Archive client + _ = a.ArchiveClient(context.Background(), clientID) + } else { + // Archive project + _ = a.ArchiveProject(context.Background(), *projectID) + } + + return nil + }, + m.refreshCmd, + func() tea.Msg { + return restoreSelectionMsg{clientID: restoreClientID, projectID: restoreProjectID} + }, + ) +} + +func (m *AppModel) unarchiveSelectedClientOrProject(clientID int64, projectID *int64, restoreClientID int64, restoreProjectID *int64) tea.Cmd { + return tea.Sequence( + func() tea.Msg { + a := actions.New(m.queries) + + if projectID == nil { + // Unarchive client + _ = a.UnarchiveClient(context.Background(), clientID) + } else { + // Unarchive project + _ = a.UnarchiveProject(context.Background(), *projectID) + } + + return nil + }, + m.refreshCmd, + func() tea.Msg { + return restoreSelectionMsg{clientID: restoreClientID, projectID: restoreProjectID} + }, + ) +} + func (m *AppModel) openEntryEditor() { m.modalBox.activate(ModalTypeEntry, m.historyBox.selectedEntry().ID, *m) m.modalBox.form.fields[0].Focus() diff --git a/internal/tui/commands.go b/internal/tui/commands.go index 4fdd9e0..90bc05f 100644 --- a/internal/tui/commands.go +++ b/internal/tui/commands.go @@ -2,9 +2,7 @@ package tui import ( "context" - "fmt" - "strings" - "time" + "errors" "git.tjp.lol/punchcard/internal/actions" "git.tjp.lol/punchcard/internal/queries" @@ -32,6 +30,26 @@ type ( openHistoryFilterModal struct{} openReportModal struct{} updateHistoryFilter HistoryFilter + archiveSelectedMsg struct { + clientID int64 + projectID *int64 + restoreClientID int64 + restoreProjectID *int64 + } + unarchiveSelectedMsg struct { + clientID int64 + projectID *int64 + restoreClientID int64 + restoreProjectID *int64 + } + toggleShowArchivedMsg struct { + restoreClientID int64 + restoreProjectID *int64 + } + restoreSelectionMsg struct{ clientID int64; projectID *int64 } + showArchivedWarningMsg struct{ params *ArchivedPunchInParams } + reportGenerationSucceeded struct{} + reportGenerationFailed struct{ err error } ) func navigate(forward bool) tea.Cmd { @@ -40,8 +58,25 @@ func navigate(forward bool) tea.Cmd { func punchIn(m AppModel) tea.Cmd { return func() tea.Msg { - _, _ = actions.New(m.queries).PunchInMostRecent(context.Background(), "", nil) - // TODO: use the returned TimerSession instead of re-querying everything + a := actions.New(m.queries) + _, err := a.PunchInMostRecent(context.Background(), "", nil, false) + // Handle archived errors by showing modal + if err != nil { + if errors.Is(err, actions.ErrArchivedClient) { + return showArchivedWarningMsg{ + params: &ArchivedPunchInParams{ + EntityType: "client", + }, + } + } else if errors.Is(err, actions.ErrArchivedProject) { + return showArchivedWarningMsg{ + params: &ArchivedPunchInParams{ + EntityType: "project", + }, + } + } + } + return m.refreshCmd() } } @@ -69,8 +104,33 @@ func punchInOnSelection(m AppModel) tea.Cmd { return nil } - _, _ = actions.New(m.queries).PunchIn(context.Background(), clientID, projectID, description, entryRate) - // TODO: use the returned TimerSession instead of re-querying everything + a := actions.New(m.queries) + _, err := a.PunchIn(context.Background(), clientID, projectID, description, entryRate, false) + // Handle archived errors by showing modal + if err != nil { + if errors.Is(err, actions.ErrArchivedClient) { + return showArchivedWarningMsg{ + params: &ArchivedPunchInParams{ + ClientID: clientID, + ProjectID: projectID, + Description: description, + Rate: entryRate, + EntityType: "client", + }, + } + } else if errors.Is(err, actions.ErrArchivedProject) { + return showArchivedWarningMsg{ + params: &ArchivedPunchInParams{ + ClientID: clientID, + ProjectID: projectID, + Description: description, + Rate: entryRate, + EntityType: "project", + }, + } + } + } + return m.refreshCmd() } } @@ -139,51 +199,42 @@ func createReportModal() tea.Cmd { return func() tea.Msg { return openReportModal{} } } -func generateReport(m *ModalBoxModel, am AppModel) tea.Cmd { +func generateReport(am AppModel, genFunc func(context.Context, *queries.Queries, reports.ReportParams) (*reports.ReportResult, error), params reports.ReportParams) tea.Cmd { return func() tea.Msg { - form := &m.form - - dateRange, err := reports.ParseDateRange(form.fields[1].Value()) - if err != nil { - form.fields[1].Err = fmt.Errorf("invalid date range: %v", err) - return reOpenModal() + if _, err := genFunc(context.Background(), am.queries, params); err != nil { + return reportGenerationFailed{err: err} } + return reportGenerationSucceeded{} + } +} - var tz *time.Location - tzstr := form.fields[5].Value() - if tzstr == "" { - tz = time.Local - } else { - zone, err := time.LoadLocation(tzstr) - if err != nil { - form.fields[5].Err = err - return reOpenModal() - } - tz = zone +func archiveClientOrProject(clientID int64, projectID *int64) tea.Cmd { + return func() tea.Msg { + return archiveSelectedMsg{ + clientID: clientID, + projectID: projectID, + restoreClientID: clientID, + restoreProjectID: projectID, } + } +} - var genFunc func(context.Context, *queries.Queries, reports.ReportParams) (*reports.ReportResult, error) - switch strings.ToLower(form.fields[0].Value()) { - case "invoice": - genFunc = reports.GenerateInvoice - case "timesheet": - genFunc = reports.GenerateTimesheet - case "unified": - genFunc = reports.GenerateUnifiedReport +func unarchiveClientOrProject(clientID int64, projectID *int64) tea.Cmd { + return func() tea.Msg { + return unarchiveSelectedMsg{ + clientID: clientID, + projectID: projectID, + restoreClientID: clientID, + restoreProjectID: projectID, } + } +} - params := reports.ReportParams{ - ClientName: form.fields[2].Value(), - ProjectName: form.fields[3].Value(), - DateRange: dateRange, - OutputPath: form.fields[4].Value(), - Timezone: tz, - } - if _, err := genFunc(context.Background(), am.queries, params); err != nil { - form.err = err - return reOpenModal() +func toggleShowArchived(clientID int64, projectID *int64) tea.Cmd { + return func() tea.Msg { + return toggleShowArchivedMsg{ + restoreClientID: clientID, + restoreProjectID: projectID, } - - return nil } } diff --git a/internal/tui/history_box.go b/internal/tui/history_box.go index 760926e..17958c3 100644 --- a/internal/tui/history_box.go +++ b/internal/tui/history_box.go @@ -268,7 +268,7 @@ var ( selectedActiveEntryStyle = lipgloss.NewStyle().Background(lipgloss.Color("196")).Foreground(lipgloss.Color("230")) descriptionStyle = lipgloss.NewStyle() activeDescriptionStyle = lipgloss.NewStyle().Background(lipgloss.Color("62")).Foreground(lipgloss.Color("230")) - filterInfoStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("248")) + filterInfoStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("246")) ) // renderSummaryView renders the summary view (level 1) with date headers and client/project summaries diff --git a/internal/tui/keys.go b/internal/tui/keys.go index c4ccad3..c0c5605 100644 --- a/internal/tui/keys.go +++ b/internal/tui/keys.go @@ -128,6 +128,75 @@ var Bindings map[KeyBindingScope]map[string]KeyBinding = map[KeyBindingScope]map }, Result: func(*AppModel) tea.Cmd { return filterHistoryFromProjectBox() }, }, + "a": KeyBinding{ + Key: "a", + Description: func(m AppModel) string { + visibleClients := m.projectsBox.visibleClients() + if len(visibleClients) == 0 { + return "" + } + if m.projectsBox.selectedClient >= len(visibleClients) { + return "" + } + client := visibleClients[m.projectsBox.selectedClient] + + if m.projectsBox.selectedProject != nil { + // Project selected + visibleProjects := m.projectsBox.visibleProjects(client.ID) + if *m.projectsBox.selectedProject >= len(visibleProjects) { + return "" + } + project := visibleProjects[*m.projectsBox.selectedProject] + if project.Archived != 0 { + return "Unarchive Project" + } + return "Archive Project" + } + + // Client selected + if client.Archived != 0 { + return "Unarchive Client" + } + return "Archive Client" + }, + Result: func(m *AppModel) tea.Cmd { + clientID, projectID := m.projectsBox.getSelectedIDs() + visibleClients := m.projectsBox.visibleClients() + if len(visibleClients) == 0 { + return nil + } + client := visibleClients[m.projectsBox.selectedClient] + + if m.projectsBox.selectedProject != nil { + // Project selected + visibleProjects := m.projectsBox.visibleProjects(client.ID) + project := visibleProjects[*m.projectsBox.selectedProject] + if project.Archived != 0 { + return unarchiveClientOrProject(clientID, projectID) + } + return archiveClientOrProject(clientID, projectID) + } + + // Client selected + if client.Archived != 0 { + return unarchiveClientOrProject(clientID, projectID) + } + return archiveClientOrProject(clientID, projectID) + }, + }, + ".": KeyBinding{ + Key: ".", + Description: func(m AppModel) string { + if m.projectsBox.showArchived { + return "Hide Archived" + } + return "Show Archived" + }, + Result: func(m *AppModel) tea.Cmd { + clientID, projectID := m.projectsBox.getSelectedIDs() + return toggleShowArchived(clientID, projectID) + }, + }, "down": KeyBinding{ Key: "down", Description: func(AppModel) string { return "Down" }, @@ -270,10 +339,7 @@ var Bindings map[KeyBindingScope]map[string]KeyBinding = map[KeyBindingScope]map Key: "Enter", Description: func(AppModel) string { return "Submit" }, Result: func(am *AppModel) tea.Cmd { - return tea.Sequence( - closeModal(), - am.modalBox.SubmitForm(*am), - ) + return am.modalBox.SubmitForm(*am) }, }, "esc": KeyBinding{ diff --git a/internal/tui/modal.go b/internal/tui/modal.go index 248654b..b614d04 100644 --- a/internal/tui/modal.go +++ b/internal/tui/modal.go @@ -3,8 +3,10 @@ package tui import ( "context" "database/sql" + "errors" "fmt" "strconv" + "strings" "time" "git.tjp.lol/punchcard/internal/actions" @@ -26,6 +28,7 @@ const ( ModalTypeHistoryFilter ModalTypeGenerateReport ModalTypeContractor + ModalTypeArchivedWarning ) func (mt ModalType) newForm() Form { @@ -56,6 +59,17 @@ type ModalBoxModel struct { form Form editedID int64 + + // For archived warning modal - store punch-in parameters + archivedPunchInParams *ArchivedPunchInParams +} + +type ArchivedPunchInParams struct { + ClientID string + ProjectID string + Description string + Rate *float64 + EntityType string // "client" or "project" } func (m *ModalBoxModel) HandleKeyPress(msg tea.KeyMsg) tea.Cmd { @@ -94,6 +108,8 @@ func (m ModalBoxModel) Render() string { return m.RenderFormModal("⏰ Time Entry") case ModalTypeDeleteConfirmation: return m.RenderDeleteConfirmation() + case ModalTypeArchivedWarning: + return m.RenderArchivedWarning() case ModalTypeClient: return m.RenderFormModal("👤 Client") case ModalTypeProjectCreate, ModalTypeProjectEdit: @@ -128,6 +144,21 @@ func (m ModalBoxModel) RenderDeleteConfirmation() string { ) } +func (m ModalBoxModel) RenderArchivedWarning() string { + entityType := "client" + if m.archivedPunchInParams != nil { + entityType = m.archivedPunchInParams.EntityType + } + + return fmt.Sprintf( + "%s\n\nThis %s is archived.\n\nContinuing will unarchive it and start tracking time.\n\n%s Continue %s Cancel", + modalTitleStyle.Render("⚠️ Archived "+entityType), + entityType, + boldStyle.Render("[Enter]"), + boldStyle.Render("[Esc]"), + ) +} + func (m *ModalBoxModel) activateCreateProjectModal(am AppModel) { m.activate(ModalTypeProjectCreate, 0, am) if am.selectedBox == ProjectsBox && len(am.projectsBox.clients) > 0 { @@ -184,7 +215,7 @@ func (m *ModalBoxModel) SubmitForm(am AppModel) tea.Cmd { if err != nil { return reOpenModal() } - return tea.Sequence(am.refreshCmd, func() tea.Msg { return recheckBounds{} }) + return tea.Sequence(closeModal(), am.refreshCmd, func() tea.Msg { return recheckBounds{} }) case ModalTypeEntry: if err := m.form.Error(); err != nil { @@ -202,7 +233,7 @@ func (m *ModalBoxModel) SubmitForm(am AppModel) tea.Cmd { return reOpenModal() } - return am.refreshCmd + return tea.Sequence(closeModal(), am.refreshCmd) case ModalTypeClient: if err := m.form.Error(); err != nil { @@ -238,7 +269,7 @@ func (m *ModalBoxModel) SubmitForm(am AppModel) tea.Cmd { } } - return am.refreshCmd + return tea.Sequence(closeModal(), am.refreshCmd) case ModalTypeProjectCreate: if err := m.form.Error(); err != nil { @@ -261,7 +292,7 @@ func (m *ModalBoxModel) SubmitForm(am AppModel) tea.Cmd { return reOpenModal() } - return am.refreshCmd + return tea.Sequence(closeModal(), am.refreshCmd) case ModalTypeProjectEdit: if err := m.form.Error(); err != nil { @@ -284,7 +315,7 @@ func (m *ModalBoxModel) SubmitForm(am AppModel) tea.Cmd { return reOpenModal() } - return am.refreshCmd + return tea.Sequence(closeModal(), am.refreshCmd) case ModalTypeHistoryFilter: if err := m.form.Error(); err != nil { @@ -335,14 +366,55 @@ func (m *ModalBoxModel) SubmitForm(am AppModel) tea.Cmd { } // Return filter update message - return func() tea.Msg { return updateHistoryFilter(newFilter) } + return tea.Sequence(closeModal(), func() tea.Msg { return updateHistoryFilter(newFilter) }) case ModalTypeGenerateReport: if err := m.form.Error(); err != nil { return reOpenModal() } - return generateReport(m, am) + // Validate report type + var genFunc func(context.Context, *queries.Queries, reports.ReportParams) (*reports.ReportResult, error) + switch strings.ToLower(m.form.fields[0].Value()) { + case "invoice": + genFunc = reports.GenerateInvoice + case "timesheet": + genFunc = reports.GenerateTimesheet + case "unified": + genFunc = reports.GenerateUnifiedReport + default: + m.form.fields[0].Err = errors.New("pick one of invoice, timesheet, or unified") + return reOpenModal() + } + + // Parse date range + dateRange, err := reports.ParseDateRange(m.form.fields[1].Value()) + if err != nil { + m.form.fields[1].Err = fmt.Errorf("invalid date range: %v", err) + return reOpenModal() + } + + // Parse timezone + var tz *time.Location + tzstr := m.form.fields[5].Value() + if tzstr == "" { + tz = time.Local + } else { + zone, err := time.LoadLocation(tzstr) + if err != nil { + m.form.fields[5].Err = err + return reOpenModal() + } + tz = zone + } + + return generateReport(am, genFunc, reports.ReportParams{ + ClientName: m.form.fields[2].Value(), + ProjectName: m.form.fields[3].Value(), + DateRange: dateRange, + OutputPath: m.form.fields[4].Value(), + Timezone: tz, + }) case ModalTypeContractor: if err := m.form.Error(); err != nil { @@ -358,7 +430,25 @@ func (m *ModalBoxModel) SubmitForm(am AppModel) tea.Cmd { return reOpenModal() } - return am.refreshCmd + return tea.Sequence(closeModal(), am.refreshCmd) + + case ModalTypeArchivedWarning: + // User confirmed unarchiving - punch in with autoUnarchive=true + if m.archivedPunchInParams == nil { + return nil + } + + a := actions.New(am.queries) + _, _ = a.PunchIn( + context.Background(), + m.archivedPunchInParams.ClientID, + m.archivedPunchInParams.ProjectID, + m.archivedPunchInParams.Description, + m.archivedPunchInParams.Rate, + true, // autoUnarchive + ) + + return tea.Sequence(closeModal(), am.refreshCmd) } return nil diff --git a/internal/tui/projects_box.go b/internal/tui/projects_box.go index 3bf44b5..cd50d5e 100644 --- a/internal/tui/projects_box.go +++ b/internal/tui/projects_box.go @@ -14,6 +14,7 @@ type ClientsProjectsModel struct { projects map[int64][]queries.Project selectedClient int selectedProject *int + showArchived bool } // NewClientsProjectsModel creates a new clients/projects model @@ -21,12 +22,48 @@ func NewClientsProjectsModel() ClientsProjectsModel { return ClientsProjectsModel{} } +// visibleClients returns the list of clients that should be displayed +func (m ClientsProjectsModel) visibleClients() []queries.Client { + if m.showArchived { + return m.clients + } + + visible := make([]queries.Client, 0, len(m.clients)) + for _, client := range m.clients { + if client.Archived == 0 { + visible = append(visible, client) + } + } + return visible +} + +// visibleProjects returns the list of projects for a client that should be displayed +func (m ClientsProjectsModel) visibleProjects(clientID int64) []queries.Project { + allProjects := m.projects[clientID] + if m.showArchived { + return allProjects + } + + visible := make([]queries.Project, 0, len(allProjects)) + for _, project := range allProjects { + if project.Archived == 0 { + visible = append(visible, project) + } + } + return visible +} + // View renders the clients/projects box func (m ClientsProjectsModel) View(width, height int, isSelected bool) string { var content string - if len(m.clients) == 0 { - content = inactiveTimerStyle.Render("No clients found\n\nUse 'punch add client' to\nadd your first client.") + visibleClients := m.visibleClients() + if len(visibleClients) == 0 { + if len(m.clients) == 0 { + content = inactiveTimerStyle.Render("No clients found\n\nUse 'punch add client' to\nadd your first client.") + } else { + content = inactiveTimerStyle.Render("All clients archived\n\nPress '.' to show archived") + } } else { content = m.renderClientsAndProjects() } @@ -47,48 +84,58 @@ func (m ClientsProjectsModel) View(width, height int, isSelected bool) string { // renderClientsAndProjects renders the clients and their projects func (m ClientsProjectsModel) renderClientsAndProjects() string { var content string - absoluteRowIndex := 0 + visibleClients := m.visibleClients() - for i, client := range m.clients { + for i, client := range visibleClients { if i > 0 { content += "\n" } - clientLine := fmt.Sprintf("• %s", client.Name) + // Build client name and rate + clientText := client.Name if client.BillableRate.Valid { rateInDollars := float64(client.BillableRate.Int64) / 100.0 - clientLine += fmt.Sprintf(" ($%.2f/hr)", rateInDollars) + clientText += fmt.Sprintf(" ($%.2f/hr)", rateInDollars) } - // Highlight if this client is selected + // Style for client text clientStyle := lipgloss.NewStyle().Bold(true) if m.selectedClient == i && m.selectedProject == nil { clientStyle = clientStyle.Background(lipgloss.Color("62")).Foreground(lipgloss.Color("230")) + } else if client.Archived != 0 { + // Gray out archived clients + clientStyle = clientStyle.Foreground(lipgloss.Color("246")) } - content += clientStyle.Render(clientLine) + "\n" - absoluteRowIndex++ - clientProjects := m.projects[client.ID] - if len(clientProjects) == 0 { + content += "• " + clientStyle.Render(clientText) + "\n" + + visibleProjects := m.visibleProjects(client.ID) + if len(visibleProjects) == 0 { content += " └── (no projects)\n" } else { - for j, project := range clientProjects { + for j, project := range visibleProjects { prefix := "├──" - if j == len(clientProjects)-1 { + if j == len(visibleProjects)-1 { prefix = "└──" } - projectLine := fmt.Sprintf(" %s %s", prefix, project.Name) + // Build project name and rate + projectText := project.Name if project.BillableRate.Valid { rateInDollars := float64(project.BillableRate.Int64) / 100.0 - projectLine += fmt.Sprintf(" ($%.2f/hr)", rateInDollars) + projectText += fmt.Sprintf(" ($%.2f/hr)", rateInDollars) } + // Style for project text projectStyle := lipgloss.NewStyle() if m.selectedClient == i && m.selectedProject != nil && *m.selectedProject == j { projectStyle = projectStyle.Background(lipgloss.Color("62")).Foreground(lipgloss.Color("230")) + } else if project.Archived != 0 { + // Gray out archived projects + projectStyle = projectStyle.Foreground(lipgloss.Color("246")) } - content += projectStyle.Render(projectLine) + "\n" + + content += fmt.Sprintf(" %s ", prefix) + projectStyle.Render(projectText) + "\n" } } } @@ -105,16 +152,17 @@ func (m *ClientsProjectsModel) changeSelection(forward bool) { } func (m *ClientsProjectsModel) changeSelectionForward() { - if len(m.clients) == 0 { + visibleClients := m.visibleClients() + if len(visibleClients) == 0 { return } - selectedClient := m.clients[m.selectedClient] - projects := m.projects[selectedClient.ID] + selectedClient := visibleClients[m.selectedClient] + visibleProjects := m.visibleProjects(selectedClient.ID) if m.selectedProject == nil { // starting with a client selected - if len(projects) > 0 { + if len(visibleProjects) > 0 { // can jump into the first project zero := 0 m.selectedProject = &zero @@ -122,7 +170,7 @@ func (m *ClientsProjectsModel) changeSelectionForward() { } // there is no next client - at the bottom, no-op - if m.selectedClient == len(m.clients)-1 { + if m.selectedClient == len(visibleClients)-1 { return } @@ -131,10 +179,10 @@ func (m *ClientsProjectsModel) changeSelectionForward() { return } - if *m.selectedProject == len(projects)-1 { + if *m.selectedProject == len(visibleProjects)-1 { // at last project - if m.selectedClient == len(m.clients)-1 { + if m.selectedClient == len(visibleClients)-1 { // also at last client - at the bottom, no-op return } @@ -150,11 +198,12 @@ func (m *ClientsProjectsModel) changeSelectionForward() { } func (m *ClientsProjectsModel) changeSelectionBackward() { - if len(m.clients) == 0 { + visibleClients := m.visibleClients() + if len(visibleClients) == 0 { return } - selectedClient := m.clients[m.selectedClient] + selectedClient := visibleClients[m.selectedClient] if m.selectedProject == nil { // starting with a client selected @@ -164,12 +213,12 @@ func (m *ClientsProjectsModel) changeSelectionBackward() { } m.selectedClient-- - selectedClient = m.clients[m.selectedClient] - projects := m.projects[selectedClient.ID] + selectedClient = visibleClients[m.selectedClient] + visibleProjects := m.visibleProjects(selectedClient.ID) - if len(projects) > 0 { + if len(visibleProjects) > 0 { // previous client has projects, jump to last one - i := len(projects) - 1 + i := len(visibleProjects) - 1 m.selectedProject = &i } @@ -188,18 +237,115 @@ func (m *ClientsProjectsModel) changeSelectionBackward() { } func (m ClientsProjectsModel) selection() (string, string, string, *float64) { - if len(m.clients) == 0 { + visibleClients := m.visibleClients() + if len(visibleClients) == 0 { return "", "", "", nil } - client := m.clients[m.selectedClient] + client := visibleClients[m.selectedClient] clientID := strconv.FormatInt(client.ID, 10) projectID := "" if m.selectedProject != nil { - project := m.projects[client.ID][*m.selectedProject] + visibleProjects := m.visibleProjects(client.ID) + project := visibleProjects[*m.selectedProject] projectID = strconv.FormatInt(project.ID, 10) } return clientID, projectID, "", nil } + +// resetSelection clamps the selection to valid bounds after visibility changes +func (m *ClientsProjectsModel) resetSelection() { + visibleClients := m.visibleClients() + if len(visibleClients) == 0 { + m.selectedClient = 0 + m.selectedProject = nil + return + } + + // Clamp client selection + if m.selectedClient >= len(visibleClients) { + m.selectedClient = len(visibleClients) - 1 + } + + // Clamp project selection + if m.selectedProject != nil { + client := visibleClients[m.selectedClient] + visibleProjects := m.visibleProjects(client.ID) + if len(visibleProjects) == 0 { + m.selectedProject = nil + } else if *m.selectedProject >= len(visibleProjects) { + i := len(visibleProjects) - 1 + m.selectedProject = &i + } + } +} + +// getSelectedIDs returns the currently selected client ID and optional project ID +func (m ClientsProjectsModel) getSelectedIDs() (clientID int64, projectID *int64) { + visibleClients := m.visibleClients() + if len(visibleClients) == 0 { + return 0, nil + } + if m.selectedClient >= len(visibleClients) { + return 0, nil + } + + client := visibleClients[m.selectedClient] + clientID = client.ID + + if m.selectedProject != nil { + visibleProjects := m.visibleProjects(client.ID) + if *m.selectedProject >= len(visibleProjects) { + return clientID, nil + } + project := visibleProjects[*m.selectedProject] + projectID = &project.ID + } + + return clientID, projectID +} + +// restoreSelection tries to restore selection to the given IDs, or resets to top if not found +func (m *ClientsProjectsModel) restoreSelection(clientID int64, projectID *int64) { + visibleClients := m.visibleClients() + if len(visibleClients) == 0 { + m.selectedClient = 0 + m.selectedProject = nil + return + } + + // Try to find the client + clientFound := false + for i, client := range visibleClients { + if client.ID == clientID { + m.selectedClient = i + clientFound = true + + // Try to find the project if one was selected + if projectID != nil { + visibleProjects := m.visibleProjects(client.ID) + for j, project := range visibleProjects { + if project.ID == *projectID { + m.selectedProject = &j + return + } + } + // Project not found, select client only + m.selectedProject = nil + return + } + + // No project was selected, just client + m.selectedProject = nil + return + } + } + + // Client not found, reset to top + if !clientFound { + m.selectedClient = 0 + m.selectedProject = nil + } +} diff --git a/internal/tui/shared.go b/internal/tui/shared.go index 8a36108..7271293 100644 --- a/internal/tui/shared.go +++ b/internal/tui/shared.go @@ -254,6 +254,7 @@ func getAppData( Name: projects[i].Name, ClientID: projects[i].ClientID, BillableRate: projects[i].BillableRate, + Archived: projects[i].Archived, CreatedAt: projects[i].CreatedAt, }, ) diff --git a/internal/tui/shared_test.go b/internal/tui/shared_test.go index 1df3eb9..135e02e 100644 --- a/internal/tui/shared_test.go +++ b/internal/tui/shared_test.go @@ -34,6 +34,7 @@ func setupTestDB(t *testing.T) (*queries.Queries, *sql.DB, func()) { name TEXT NOT NULL UNIQUE, email TEXT, billable_rate INTEGER, + archived INTEGER DEFAULT 0, created_at DATETIME DEFAULT CURRENT_TIMESTAMP ); CREATE TABLE project ( @@ -41,6 +42,7 @@ func setupTestDB(t *testing.T) (*queries.Queries, *sql.DB, func()) { name TEXT NOT NULL, client_id INTEGER NOT NULL, billable_rate INTEGER, + archived INTEGER DEFAULT 0, created_at DATETIME DEFAULT CURRENT_TIMESTAMP, FOREIGN KEY (client_id) REFERENCES client (id) ); |