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
6 changes: 3 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
- Show each timesheet period's current status and highlight the period containing the selected day
- Center the application within the terminal viewport
- Move between days and weeks, and jump back to today
- Create project time by choosing a project and task
- Create project time from recently used project/task combinations or by browsing all projects
- Create time off by choosing an available leave type
- Enter the date, hours, and notes
- Review an entry before it is sent to ClickTime
Expand Down Expand Up @@ -53,7 +53,7 @@ Running without `CLICKTIME_TOKEN` exits with an error before the TUI starts.
| `kj↑↓` | Select a project/task row |
| `hl←→` | Select a day |
| `[]` | Previous or next week |
| `n` | Add an entry in the selected day |
| `n` | Add an entry in the selected day; recent projects are suggested first |
| `e` `enter` | Edit the selected cell; quick-add time if it is empty |
| `d` | Review and delete all entries in the selected cell |
| `s` | Review and submit the timesheet containing the selected day |
Expand All @@ -63,7 +63,7 @@ Running without `CLICKTIME_TOKEN` exits with an error before the TUI starts.

### Entry workflow

- Adding time first asks whether it is **Projects** or **Time Off**.
- Pressing `n` looks back four weeks from the selected date and offers recently used project/task combinations. **Browse all projects** and **Time Off** remain available in that picker.
- Pressing `e` on an empty project/task cell opens a new entry for that date and row.
- In a picker, `/` starts filtering, `enter` selects, `esc` goes back one page, and `q` cancels the entry flow.
- The selected date is read-only in the entry form.
Expand Down
114 changes: 113 additions & 1 deletion internal/tui/model.go
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ const (
pickerTask
pickerTimeOff
pickerEntry
pickerRecentProject
)

type entryKind int
Expand Down Expand Up @@ -171,6 +172,11 @@ type tasksMsg struct {
tasks []clicktime.Task
}

type recentProjectsMsg struct {
entries []clicktime.TimeEntry
err error
}

type timesheetReadyMsg struct {
timesheet clicktime.Timesheet
actions []clicktime.TimesheetAction
Expand Down Expand Up @@ -335,6 +341,14 @@ func (m Model) Update(message tea.Msg) (tea.Model, tea.Cmd) {
}
m.openTaskPicker(resolved)
return m, nil
case recentProjectsMsg:
if msg.err != nil {
m.status = "Couldn't load recent projects; choose a project instead."
m.openCategoryPicker()
return m, nil
}
m.openRecentProjectPicker(msg.entries)
return m, nil
case timesheetReadyMsg:
m.timesheetToSubmit = msg.timesheet
m.submissionEntries = sortedEntries(msg.entries)
Expand Down Expand Up @@ -492,7 +506,14 @@ func (m Model) updateDashboard(key tea.KeyMsg) (tea.Model, tea.Cmd) {
m.attestationStatement = ""
return m, m.withSpinner(loadTimesheetForSubmissionCmd(m.api, date))
case "n":
m.beginNewEntry(m.selectedDate())
date := m.selectedDate()
m.status = ""
m.lastError = nil
m.draft = draft{date: date.Format(time.DateOnly)}
m.availableTasks = nil
m.screen = screenTaskLoading
m.loadingText = "Finding recently used projects"
return m, m.withSpinner(loadRecentProjectsCmd(m.api, date))
case "e", "enter":
entries := m.selectedEntries()
switch len(entries) {
Expand Down Expand Up @@ -599,6 +620,54 @@ func (m *Model) openCategoryPicker() {
m.openPicker(pickerCategory, "What kind of time are you adding?", items)
}

func (m *Model) openRecentProjectPicker(entries []clicktime.TimeEntry) {
type recentProject struct {
entry clicktime.TimeEntry
date time.Time
}
entries = append([]clicktime.TimeEntry(nil), entries...)
sort.SliceStable(entries, func(i, j int) bool {
return dateString(entries[i].Date) > dateString(entries[j].Date)
})
seen := make(map[string]bool)
recent := make([]recentProject, 0, len(entries))
for _, entry := range entries {
if entry.JobID == "" || entry.TaskID == "" {
continue
}
key := entry.JobID + "\x00" + entry.TaskID
if seen[key] {
continue
}
seen[key] = true
date, err := time.Parse(time.DateOnly, dateString(entry.Date))
if err != nil {
continue
}
recent = append(recent, recentProject{entry: entry, date: date})
}
sort.SliceStable(recent, func(i, j int) bool { return recent[i].date.After(recent[j].date) })

items := make([]list.Item, 0, len(recent)+2)
for _, suggestion := range recent {
entry := suggestion.entry
job := m.jobByID(entry.JobID)
if job.ID == "" {
continue // The project is no longer active or available to this user.
}
client := m.clientByID(job.ClientID)
task := m.taskByID(entry.TaskID)
description := firstDisplayValue(client.Label(), "—") + " · " + firstDisplayValue(task.Label(), entry.TaskID)
description += " · used " + suggestion.date.Format("Jan 2")
items = append(items, pickerItem{id: "recent:" + entry.JobID + "\x00" + entry.TaskID, title: job.Label(), description: description})
}
items = append(items,
pickerItem{id: "browse-projects", title: "Browse all projects", description: "Choose a project and task"},
pickerItem{id: "time-off", title: "Time Off", description: "Vacation, sick leave, and other leave types"},
)
m.openPicker(pickerRecentProject, "Choose a recent project", items)
}

func (m *Model) openEntryPicker(entries []trackedEntry) {
items := make([]list.Item, 0, len(entries))
for _, entry := range entries {
Expand Down Expand Up @@ -780,6 +849,38 @@ func (m Model) updatePicker(message tea.Msg, key tea.KeyMsg) (tea.Model, tea.Cmd
}
m.beginEditEntry(entry)
return m, nil
case pickerRecentProject:
switch selected.id {
case "browse-projects":
m.draft.kind = projectEntry
m.openJobPicker()
return m, nil
case "time-off":
m.draft.kind = timeOffEntry
m.openTimeOffPicker()
return m, nil
}
ids := strings.Split(strings.TrimPrefix(selected.id, "recent:"), "\x00")
if len(ids) != 2 {
return m, nil
}
job := m.jobByID(ids[0])
if job.ID == "" {
m.status = "That recent project is no longer available."
m.openRecentProjectPicker(nil)
return m, nil
}
client := m.clientByID(job.ClientID)
task := m.taskByID(ids[1])
m.draft = draft{
kind: projectEntry, date: m.draft.date,
clientID: client.ID, clientName: firstDisplayValue(client.Label(), "—"),
jobID: job.ID, jobName: firstDisplayValue(job.Label(), job.ID),
taskID: ids[1], taskName: firstDisplayValue(task.Label(), ids[1]),
returnDashboard: true,
}
m.openForm()
return m, nil
}
}
var cmd tea.Cmd
Expand All @@ -794,6 +895,8 @@ func (m *Model) backFromPicker() {
m.openCategoryPicker()
case pickerTask:
m.openJobPicker()
case pickerRecentProject:
m.screen = screenDashboard
default:
m.screen = screenDashboard
}
Expand Down Expand Up @@ -1955,6 +2058,15 @@ func loadTasksCmd(api *clicktime.Client, jobID string) tea.Cmd {
}
}

func loadRecentProjectsCmd(api *clicktime.Client, date time.Time) tea.Cmd {
return func() tea.Msg {
ctx, cancel := context.WithTimeout(context.Background(), 25*time.Second)
defer cancel()
entries, err := api.TimeEntries(ctx, date.AddDate(0, 0, -28), date)
return recentProjectsMsg{entries: entries, err: err}
}
}

func saveEntryCmd(api *clicktime.Client, value draft) tea.Cmd {
return func() tea.Msg {
ctx, cancel := context.WithTimeout(context.Background(), 25*time.Second)
Expand Down
36 changes: 36 additions & 0 deletions internal/tui/model_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -173,6 +173,42 @@ func TestNewEntryCategoryFlow(t *testing.T) {
}
}

func TestRecentProjectsPickerUsesLatestProjectTaskCombinations(t *testing.T) {
t.Parallel()
date := time.Date(2026, time.July, 29, 0, 0, 0, 0, time.UTC)
model := NewAt(nil, func() time.Time { return date })
model.draft = draft{date: date.Format(time.DateOnly)}
model.clients = []clicktime.ClientResource{{ID: "client-1", Name: "Space"}}
model.jobs = []clicktime.Job{{ID: "job-1", ClientID: "client-1", Name: "Apollo"}, {ID: "job-2", ClientID: "client-1", Name: "Gemini"}}
model.tasks = []clicktime.Task{{ID: "task-1", Name: "Labor"}, {ID: "task-2", Name: "Review"}}
model.openRecentProjectPicker([]clicktime.TimeEntry{
{Date: "2026-07-03", JobID: "job-1", TaskID: "task-1"},
{Date: "2026-07-28", JobID: "job-1", TaskID: "task-1"},
{Date: "2026-07-27", JobID: "job-2", TaskID: "task-2"},
{Date: "2026-07-29", JobID: "inactive", TaskID: "task-1"},
})

items := model.picker.Items()
if model.pickerKind != pickerRecentProject || len(items) != 4 {
t.Fatalf("recent picker = kind %v, items %#v", model.pickerKind, items)
}
first := items[0].(pickerItem)
if first.id != "recent:job-1\x00task-1" || first.title != "Apollo" || !strings.Contains(first.description, "used Jul 28") {
t.Fatalf("first recent item = %#v", first)
}
second := items[1].(pickerItem)
if second.id != "recent:job-2\x00task-2" || !strings.Contains(second.description, "used Jul 27") {
t.Fatalf("second recent item = %#v", second)
}

model.picker.Select(0)
updated, _ := model.updatePicker(tea.KeyMsg{Type: tea.KeyEnter}, tea.KeyMsg{Type: tea.KeyEnter})
form := updated.(Model)
if form.screen != screenForm || form.draft.jobID != "job-1" || form.draft.taskID != "task-1" || !form.draft.returnDashboard {
t.Fatalf("recent selection draft = %#v, screen = %v", form.draft, form.screen)
}
}

func TestProjectEntryEscapeMovesBackOnePage(t *testing.T) {
t.Parallel()
date := time.Date(2026, time.July, 29, 0, 0, 0, 0, time.UTC)
Expand Down