From 7ba68d333bc20b5795ccfd3870546a05eee60470 Mon Sep 17 00:00:00 2001 From: T Date: Mon, 29 Sep 2025 15:04:44 -0600 Subject: Support for archiving clients and projects. --- internal/tui/projects_box.go | 210 ++++++++++++++++++++++++++++++++++++------- 1 file changed, 178 insertions(+), 32 deletions(-) (limited to 'internal/tui/projects_box.go') 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 + } +} -- cgit v1.2.3