summaryrefslogtreecommitdiff
path: root/internal/tui
diff options
context:
space:
mode:
Diffstat (limited to 'internal/tui')
-rw-r--r--internal/tui/app.go78
-rw-r--r--internal/tui/commands.go141
-rw-r--r--internal/tui/history_box.go2
-rw-r--r--internal/tui/keys.go74
-rw-r--r--internal/tui/modal.go106
-rw-r--r--internal/tui/projects_box.go210
-rw-r--r--internal/tui/shared.go1
-rw-r--r--internal/tui/shared_test.go2
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)
);