package tui import ( "fmt" "strconv" "git.tjp.lol/punchcard/internal/queries" "github.com/charmbracelet/lipgloss/v2" ) type ClientsProjectsModel struct { clients []queries.Client projects map[int64][]queries.Project selectedClient int selectedProject *int showArchived bool } // NewClientsProjectsModel creates a new clients/projects model 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 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() } // Apply box styling style := unselectedBoxStyle if isSelected { style = selectedBoxStyle } title := titleStyle.Render("👥 Clients & Projects") return style.Width(width).Height(height).Render( fmt.Sprintf("%s\n\n%s", title, content), ) } // renderClientsAndProjects renders the clients and their projects func (m ClientsProjectsModel) renderClientsAndProjects() string { var content string visibleClients := m.visibleClients() for i, client := range visibleClients { if i > 0 { content += "\n" } // Build client name and rate clientText := client.Name if client.BillableRate.Valid { rateInDollars := float64(client.BillableRate.Int64) / 100.0 clientText += fmt.Sprintf(" ($%.2f/hr)", rateInDollars) } // 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(clientText) + "\n" visibleProjects := m.visibleProjects(client.ID) if len(visibleProjects) == 0 { content += " └── (no projects)\n" } else { for j, project := range visibleProjects { prefix := "├──" if j == len(visibleProjects)-1 { prefix = "└──" } // Build project name and rate projectText := project.Name if project.BillableRate.Valid { rateInDollars := float64(project.BillableRate.Int64) / 100.0 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 += fmt.Sprintf(" %s ", prefix) + projectStyle.Render(projectText) + "\n" } } } return content } func (m *ClientsProjectsModel) changeSelection(forward bool) { if forward { m.changeSelectionForward() } else { m.changeSelectionBackward() } } func (m *ClientsProjectsModel) changeSelectionForward() { visibleClients := m.visibleClients() if len(visibleClients) == 0 { return } selectedClient := visibleClients[m.selectedClient] visibleProjects := m.visibleProjects(selectedClient.ID) if m.selectedProject == nil { // starting with a client selected if len(visibleProjects) > 0 { // can jump into the first project zero := 0 m.selectedProject = &zero return } // there is no next client - at the bottom, no-op if m.selectedClient == len(visibleClients)-1 { return } // jump to next client m.selectedClient++ return } if *m.selectedProject == len(visibleProjects)-1 { // at last project if m.selectedClient == len(visibleClients)-1 { // also at last client - at the bottom, no-op return } // jump to next client, no project m.selectedClient++ m.selectedProject = nil return } // jump to next project *m.selectedProject++ } func (m *ClientsProjectsModel) changeSelectionBackward() { visibleClients := m.visibleClients() if len(visibleClients) == 0 { return } selectedClient := visibleClients[m.selectedClient] if m.selectedProject == nil { // starting with a client selected if m.selectedClient == 0 { // at first client - at the start, no-op return } m.selectedClient-- selectedClient = visibleClients[m.selectedClient] visibleProjects := m.visibleProjects(selectedClient.ID) if len(visibleProjects) > 0 { // previous client has projects, jump to last one i := len(visibleProjects) - 1 m.selectedProject = &i } // otherwise selectedProject is already nil return } if *m.selectedProject == 0 { // selected first project - jump up to client m.selectedProject = nil return } // otherwise, jump to previous project in same client *m.selectedProject-- } func (m ClientsProjectsModel) selection() (string, string, string, *float64) { visibleClients := m.visibleClients() if len(visibleClients) == 0 { return "", "", "", nil } client := visibleClients[m.selectedClient] clientID := strconv.FormatInt(client.ID, 10) projectID := "" if m.selectedProject != nil { 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 } }