diff options
| author | Paul Buetow <paul@buetow.org> | 2026-03-04 10:50:07 +0200 |
|---|---|---|
| committer | Paul Buetow <paul@buetow.org> | 2026-03-04 10:50:07 +0200 |
| commit | 97aa8a6f666f5f40859c8a9aa4948bde435cf18f (patch) | |
| tree | 0cb5928cd6a1220607dbf64e234a2522acac2848 /internal/tui | |
| parent | c25c9002f3214e07b041aefa26d5d13c26757839 (diff) | |
Rename project to timesamurai and release v0.5.0v0.5.0
Diffstat (limited to 'internal/tui')
| -rw-r--r-- | internal/tui/doc.go | 2 | ||||
| -rw-r--r-- | internal/tui/entries.go | 638 | ||||
| -rw-r--r-- | internal/tui/entries_test.go | 197 | ||||
| -rw-r--r-- | internal/tui/report.go | 11 | ||||
| -rw-r--r-- | internal/tui/report_test.go | 13 | ||||
| -rw-r--r-- | internal/tui/styles.go | 69 | ||||
| -rw-r--r-- | internal/tui/theme.go | 103 | ||||
| -rw-r--r-- | internal/tui/timer.go | 16 | ||||
| -rw-r--r-- | internal/tui/timer_test.go | 4 | ||||
| -rw-r--r-- | internal/tui/tui.go | 191 | ||||
| -rw-r--r-- | internal/tui/tui_test.go | 123 |
11 files changed, 1230 insertions, 137 deletions
diff --git a/internal/tui/doc.go b/internal/tui/doc.go index 624b708..0621595 100644 --- a/internal/tui/doc.go +++ b/internal/tui/doc.go @@ -1,2 +1,2 @@ -// Package tui provides Bubble Tea models used by timr terminal interfaces. +// Package tui provides Bubble Tea models used by timesamurai terminal interfaces. package tui diff --git a/internal/tui/entries.go b/internal/tui/entries.go index e189846..2d35708 100644 --- a/internal/tui/entries.go +++ b/internal/tui/entries.go @@ -4,13 +4,15 @@ import ( "errors" "fmt" "slices" + "sort" "strconv" "strings" "time" "unicode/utf8" - "codeberg.org/snonux/timr/internal/duration" - "codeberg.org/snonux/timr/internal/worktime" + "codeberg.org/snonux/timesamurai/internal/duration" + "codeberg.org/snonux/timesamurai/internal/timefmt" + "codeberg.org/snonux/timesamurai/internal/worktime" tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" ) @@ -20,26 +22,35 @@ type entryEditField int const ( entryEditFieldDescription entryEditField = iota entryEditFieldValue + entryEditFieldDate + entryEditFieldTime + entryEditFieldAction + entryEditFieldCategory ) -var categoryColors = map[string]string{ - "work": "#8BD3DD", - "lunch": "#F6BD60", - "off": "#CDB4DB", - "bank": "#A8DADC", - "sick": "#FFB4A2", - "selfdevelopment": "#B8E1A9", -} +type entriesColumn int + +const ( + entriesColumnDate entriesColumn = iota + entriesColumnTime + entriesColumnAction + entriesColumnCategory + entriesColumnValue + entriesColumnSource + entriesColumnDescription + entriesColumnCount +) // EntriesModel is a chronological worktime entry browser. type EntriesModel struct { allEntries []worktime.Entry visible []worktime.Entry - cursor int - offset int - width int - height int + cursor int + offset int + selectedColumn entriesColumn + width int + height int pendingG bool pendingD bool @@ -49,6 +60,8 @@ type EntriesModel struct { filterMode bool filterQuery string + dayOffMode bool + dayOffDate time.Time editMode bool confirmDelete bool @@ -57,22 +70,31 @@ type EntriesModel struct { editField entryEditField dbDir string + dbHost string statusMessage string statusError bool + mutationCount int + dirty bool + changedHosts map[string]struct{} } // NewEntriesModel creates an entry browser model. func NewEntriesModel(entries []worktime.Entry) EntriesModel { model := EntriesModel{ - height: 16, + height: 16, + selectedColumn: entriesColumnDescription, } model.SetEntries(entries) return model } -// SetPersistence enables edit/delete persistence against dbDir. -func (m *EntriesModel) SetPersistence(dbDir string) { +// SetPersistence enables edit/delete/create persistence against dbDir and host. +func (m *EntriesModel) SetPersistence(dbDir, host string) { m.dbDir = strings.TrimSpace(dbDir) + m.dbHost = strings.TrimSpace(host) + if m.changedHosts == nil { + m.changedHosts = map[string]struct{}{} + } } // SetSize updates viewport size used for scrolling. @@ -96,6 +118,8 @@ func (m *EntriesModel) SetEntries(entries []worktime.Entry) { } return 1 }) + m.dirty = false + m.changedHosts = map[string]struct{}{} m.applyFilters() } @@ -110,6 +134,10 @@ func (m *EntriesModel) Update(msg tea.Msg) (EntriesModel, tea.Cmd) { return *m, nil } + if m.updateDayOffMode(keyMsg) { + return *m, nil + } + if m.updateEditMode(keyMsg) { return *m, nil } @@ -170,6 +198,45 @@ func (m *EntriesModel) updateEditMode(keyMsg tea.KeyMsg) bool { return true } +func (m *EntriesModel) updateDayOffMode(keyMsg tea.KeyMsg) bool { + if !m.dayOffMode { + return false + } + + switch keyMsg.String() { + case "enter": + if addErr := m.addDayOff(m.dayOffDate); addErr != nil { + m.setStatusError("Add day off failed: " + addErr.Error()) + } else { + m.setStatusInfo("Day off added.") + } + + m.dayOffMode = false + case "esc": + m.dayOffMode = false + case "left", "h": + m.dayOffDate = m.dayOffDate.AddDate(0, 0, -1) + case "right", "l": + m.dayOffDate = m.dayOffDate.AddDate(0, 0, 1) + case "up", "k": + m.dayOffDate = m.dayOffDate.AddDate(0, 0, -7) + case "down", "j": + m.dayOffDate = m.dayOffDate.AddDate(0, 0, 7) + case "pgup": + m.dayOffDate = m.dayOffDate.AddDate(0, -1, 0) + case "pgdown": + m.dayOffDate = m.dayOffDate.AddDate(0, 1, 0) + case "home": + m.dayOffDate = time.Date(m.dayOffDate.Year(), m.dayOffDate.Month(), 1, 0, 0, 0, 0, m.dayOffDate.Location()) + case "end": + m.dayOffDate = time.Date(m.dayOffDate.Year(), m.dayOffDate.Month()+1, 0, 0, 0, 0, 0, m.dayOffDate.Location()) + default: + return true + } + + return true +} + func (m *EntriesModel) updateSearchFilterMode(keyMsg tea.KeyMsg) bool { if !m.searchMode && !m.filterMode { return false @@ -213,7 +280,11 @@ func (m *EntriesModel) updateNormalMode(keyMsg tea.KeyMsg) { m.input = m.filterQuery m.pendingG = false m.pendingD = false - case "e", "enter": + case "enter": + m.beginEditSelectedField() + m.pendingG = false + m.pendingD = false + case "e": m.beginEditDescription() m.pendingG = false m.pendingD = false @@ -221,6 +292,14 @@ func (m *EntriesModel) updateNormalMode(keyMsg tea.KeyMsg) { m.beginEditValue() m.pendingG = false m.pendingD = false + case "h", "left": + m.moveColumn(-1) + m.pendingG = false + m.pendingD = false + case "l", "right": + m.moveColumn(1) + m.pendingG = false + m.pendingD = false case "o": m.insertEntry(false) m.pendingG = false @@ -229,6 +308,17 @@ func (m *EntriesModel) updateNormalMode(keyMsg tea.KeyMsg) { m.insertEntry(true) m.pendingG = false m.pendingD = false + case "D": + m.dayOffMode = true + m.dayOffDate = time.Now() + m.pendingG = false + m.pendingD = false + case "s": + if err := m.savePendingChanges(); err != nil { + m.setStatusError("Save failed: " + err.Error()) + } + m.pendingG = false + m.pendingD = false case "d": if m.pendingD { m.confirmDelete = true @@ -282,7 +372,7 @@ func (m *EntriesModel) updateNormalMode(keyMsg tea.KeyMsg) { } } -// View renders the entries list. +// View renders the entries timeline table. func (m *EntriesModel) View(styles Styles) string { title := fmt.Sprintf("Entries [%d/%d]", minInt(m.cursor+1, len(m.visible)), len(m.visible)) if m.filterQuery != "" { @@ -298,11 +388,11 @@ func (m *EntriesModel) View(styles Styles) string { if m.filterMode { return styles.Body.Render(title + "\n\nf " + m.input) } + if m.dayOffMode { + return styles.Body.Render(m.renderDayOffPicker(title, styles)) + } if m.editMode { - prompt := "Edit description: " - if m.editField == entryEditFieldValue { - prompt = "Edit value (e.g. 90m, 3600, -600): " - } + prompt := m.editPrompt() return styles.Body.Render(title + "\n\n" + prompt + m.input) } if m.confirmDelete { @@ -314,32 +404,9 @@ func (m *EntriesModel) View(styles Styles) string { return styles.Body.Render(body + m.renderStatus(styles)) } - maxRows := m.listRows() - end := minInt(len(m.visible), m.offset+maxRows) - lines := make([]string, 0, end-m.offset) - selectedStyle := lipgloss.NewStyle(). - Background(lipgloss.Color("#28323F")). - Bold(true) - - for idx := m.offset; idx < end; idx++ { - entry := m.visible[idx] - cursor := " " - if idx == m.cursor { - cursor = ">" - } - - timestamp := time.Unix(entry.Epoch, 0).Format("2006-01-02 15:04") - category := colorizeCategory(entry.What) - value := formatEntryValue(entry) - line := fmt.Sprintf("%s %s %-7s %-18s %-8s %s", cursor, timestamp, entry.Action, category, value, entry.Descr) - if idx == m.cursor { - line = selectedStyle.Render(line) - } - lines = append(lines, line) - } - - body := title + "\n\n" + strings.Join(lines, "\n") - body += "\n\n" + styles.Hint.Render("j/k move, e edit description, v edit value, dd delete") + rows := m.renderTimelineTable(styles) + body := title + "\n\n" + rows + body += "\n\n" + styles.Hint.Render("j/k rows, h/l columns, Enter edit cell, s save, / search, D day-off datepicker, dd delete") return styles.Body.Render(body + m.renderStatus(styles)) } @@ -386,11 +453,56 @@ func (m *EntriesModel) moveCursor(delta int) { m.ensureCursorVisible() } +func (m *EntriesModel) moveColumn(delta int) { + m.selectedColumn += entriesColumn(delta) + if m.selectedColumn < entriesColumnDate { + m.selectedColumn = entriesColumnDate + } + if m.selectedColumn >= entriesColumnCount { + m.selectedColumn = entriesColumnCount - 1 + } +} + +func (m *EntriesModel) beginEditSelectedField() { + if len(m.visible) == 0 || m.cursor >= len(m.visible) { + return + } + + entry := m.visible[m.cursor] + entryTime := time.Unix(entry.Epoch, 0) + + switch m.selectedColumn { + case entriesColumnDate: + m.editMode = true + m.editField = entryEditFieldDate + m.input = entryTime.Format("2006-01-02") + case entriesColumnTime: + m.editMode = true + m.editField = entryEditFieldTime + m.input = entryTime.Format("15:04") + case entriesColumnAction: + m.editMode = true + m.editField = entryEditFieldAction + m.input = entry.Action + case entriesColumnCategory: + m.editMode = true + m.editField = entryEditFieldCategory + m.input = entry.What + case entriesColumnValue: + m.beginEditValue() + case entriesColumnDescription: + m.beginEditDescription() + case entriesColumnSource: + m.setStatusInfo("Source column is read-only.") + } +} + func (m *EntriesModel) beginEditDescription() { if len(m.visible) == 0 || m.cursor >= len(m.visible) { return } + m.selectedColumn = entriesColumnDescription m.editMode = true m.editField = entryEditFieldDescription m.input = m.visible[m.cursor].Descr @@ -407,6 +519,7 @@ func (m *EntriesModel) beginEditValue() { return } + m.selectedColumn = entriesColumnValue m.editMode = true m.editField = entryEditFieldValue m.input = strconv.FormatInt(entry.Value, 10) @@ -421,6 +534,24 @@ func (m *EntriesModel) saveEdit() error { newEntry := oldEntry switch m.editField { + case entryEditFieldDate: + updatedTime, err := parseEditedDate(m.input, time.Unix(newEntry.Epoch, 0)) + if err != nil { + return err + } + newEntry.Epoch = updatedTime.Unix() + newEntry.Human = updatedTime.Format("Mon 02.01.2006 15:04:05") + case entryEditFieldTime: + updatedTime, err := parseEditedTime(m.input, time.Unix(newEntry.Epoch, 0)) + if err != nil { + return err + } + newEntry.Epoch = updatedTime.Unix() + newEntry.Human = updatedTime.Format("Mon 02.01.2006 15:04:05") + case entryEditFieldAction: + newEntry.Action = strings.TrimSpace(m.input) + case entryEditFieldCategory: + newEntry.What = strings.TrimSpace(m.input) case entryEditFieldValue: if strings.ToLower(strings.TrimSpace(newEntry.Action)) != "add" { return errors.New("only 'add' entries have an editable value") @@ -434,6 +565,23 @@ func (m *EntriesModel) saveEdit() error { newEntry.Descr = strings.TrimSpace(m.input) } + host := strings.TrimSpace(newEntry.Source) + if host == "" { + host = strings.TrimSpace(oldEntry.Source) + } + if host == "" { + host = strings.TrimSpace(m.dbHost) + } + if host == "" { + host = "local" + } + + normalized, err := worktime.NormalizeEditedEntry(newEntry, host) + if err != nil { + return err + } + newEntry = normalized + if err := m.persistReplacement(oldEntry, newEntry); err != nil { return err } @@ -441,6 +589,23 @@ func (m *EntriesModel) saveEdit() error { return nil } +func (m *EntriesModel) editPrompt() string { + switch m.editField { + case entryEditFieldDate: + return "Edit date (e.g. 2026-03-04, today, yesterday): " + case entryEditFieldTime: + return "Edit time (HH:MM or HH:MM:SS): " + case entryEditFieldAction: + return "Edit action (login/logout/add): " + case entryEditFieldCategory: + return "Edit category: " + case entryEditFieldValue: + return "Edit value (e.g. 90m, 3600, -600): " + default: + return "Edit description: " + } +} + func (m *EntriesModel) deleteSelected() error { if len(m.visible) == 0 || m.cursor >= len(m.visible) { return nil @@ -458,15 +623,21 @@ func (m *EntriesModel) deleteSelected() error { m.allEntries = append(m.allEntries[:idx], m.allEntries[idx+1:]...) m.applyFilters() + m.mutationCount++ return nil } func (m *EntriesModel) insertEntry(above bool) { + sourceHost := "local" + if configuredHost := strings.TrimSpace(m.dbHost); configuredHost != "" { + sourceHost = configuredHost + } + newEntry := worktime.Entry{ Action: "add", What: "work", Epoch: time.Now().Unix(), - Source: "local", + Source: sourceHost, Human: time.Now().Format("Mon 02.01.2006 15:04:05"), Value: int64(time.Hour / time.Second), } @@ -484,6 +655,8 @@ func (m *EntriesModel) insertEntry(above bool) { m.allEntries = insertEntryAt(m.allEntries, insertAt, newEntry) m.applyFilters() + m.mutationCount++ + m.markHostChanged(newEntry.Source) if idx := findEntryIndex(m.visible, newEntry); idx >= 0 { m.cursor = idx @@ -494,6 +667,131 @@ func (m *EntriesModel) insertEntry(above bool) { m.input = "" } +func (m *EntriesModel) addDayOff(day time.Time) error { + dayStart := time.Date(day.Year(), day.Month(), day.Day(), 0, 0, 0, 0, day.Location()) + + if m.dbDir != "" { + host := strings.TrimSpace(m.dbHost) + if host == "" { + return errors.New("persistence host is not configured") + } + + entry := worktime.Entry{ + Action: "add", + What: "off", + Epoch: dayStart.Unix(), + Source: host, + Human: dayStart.Format("Mon 02.01.2006 15:04:05"), + Value: int64((8 * time.Hour) / time.Second), + } + m.appendEntry(entry) + return nil + } + + entry := worktime.Entry{ + Action: "add", + What: "off", + Epoch: dayStart.Unix(), + Source: "local", + Human: dayStart.Format("Mon 02.01.2006 15:04:05"), + Value: int64((8 * time.Hour) / time.Second), + } + m.appendEntry(entry) + return nil +} + +func (m *EntriesModel) renderDayOffPicker(title string, styles Styles) string { + selected := m.dayOffDate + if selected.IsZero() { + selected = time.Now() + } + selected = dayAtMidnight(selected) + + lines := []string{ + title, + "", + styles.Hint.Render("Day Off Datepicker"), + selected.Format("2006-01-02 (Mon)"), + "", + renderCalendarMonth(selected), + "", + styles.Hint.Render("h/l or ←/→ day, j/k or ↑/↓ week, PgUp/PgDn month, Enter confirm, Esc cancel"), + } + return strings.Join(lines, "\n") +} + +func renderCalendarMonth(selected time.Time) string { + firstOfMonth := time.Date(selected.Year(), selected.Month(), 1, 0, 0, 0, 0, selected.Location()) + lastOfMonth := time.Date(selected.Year(), selected.Month()+1, 0, 0, 0, 0, 0, selected.Location()) + + weekdayOffset := int(firstOfMonth.Weekday()) + if weekdayOffset == 0 { + weekdayOffset = 7 + } + weekdayOffset-- + + var builder strings.Builder + builder.WriteString(selected.Format("January 2006")) + builder.WriteString("\nMo Tu We Th Fr Sa Su\n") + + for idx := 0; idx < weekdayOffset; idx++ { + builder.WriteString(" ") + } + + for day := 1; day <= lastOfMonth.Day(); day++ { + current := time.Date(selected.Year(), selected.Month(), day, 0, 0, 0, 0, selected.Location()) + cell := fmt.Sprintf("%2d", day) + if sameDay(current, selected) { + cell = "[" + cell + "]" + } else { + cell = " " + cell + " " + } + builder.WriteString(cell) + + column := (weekdayOffset + day) % 7 + if column == 0 { + builder.WriteByte('\n') + } else { + builder.WriteByte(' ') + } + } + + output := strings.TrimRight(builder.String(), " \n") + return output +} + +func dayAtMidnight(value time.Time) time.Time { + year, month, day := value.Date() + return time.Date(year, month, day, 0, 0, 0, 0, value.Location()) +} + +func sameDay(a, b time.Time) bool { + a = dayAtMidnight(a) + b = dayAtMidnight(b) + return a.Equal(b) +} + +func (m *EntriesModel) appendEntry(entry worktime.Entry) { + m.allEntries = append(m.allEntries, entry) + slices.SortFunc(m.allEntries, func(a, b worktime.Entry) int { + if a.Epoch == b.Epoch { + return strings.Compare(a.Action, b.Action) + } + if a.Epoch > b.Epoch { + return -1 + } + return 1 + }) + m.applyFilters() + m.mutationCount++ + m.markHostChanged(entry.Source) + + if idx := findEntryIndex(m.visible, entry); idx >= 0 { + m.cursor = idx + m.ensureCursorVisible() + } +} + func (m *EntriesModel) replaceEntry(oldEntry, newEntry worktime.Entry) { idx := findEntryIndex(m.allEntries, oldEntry) if idx < 0 { @@ -502,6 +800,9 @@ func (m *EntriesModel) replaceEntry(oldEntry, newEntry worktime.Entry) { m.allEntries[idx] = newEntry m.applyFilters() + m.mutationCount++ + m.markHostChanged(oldEntry.Source) + m.markHostChanged(newEntry.Source) if newIdx := findEntryIndex(m.visible, newEntry); newIdx >= 0 { m.cursor = newIdx @@ -519,14 +820,14 @@ func (m *EntriesModel) persistReplacement(oldEntry, newEntry worktime.Entry) err return errors.New("selected entry has no source host") } - index, err := findHostEntryIndex(m.dbDir, host, oldEntry) - if err != nil { - return err + m.markHostChanged(host) + + newHost := strings.TrimSpace(newEntry.Source) + if newHost != "" && newHost != host { + m.markHostChanged(newHost) } - newEntry.Source = host - _, err = worktime.EditEntry(m.dbDir, host, index, newEntry) - return err + return nil } func (m *EntriesModel) persistDelete(target worktime.Entry) error { @@ -539,13 +840,64 @@ func (m *EntriesModel) persistDelete(target worktime.Entry) error { return errors.New("selected entry has no source host") } - index, err := findHostEntryIndex(m.dbDir, host, target) - if err != nil { - return err + m.markHostChanged(host) + return nil +} + +func (m *EntriesModel) markHostChanged(host string) { + normalized := strings.TrimSpace(host) + if normalized == "" { + return } - _, err = worktime.DeleteEntry(m.dbDir, host, index) - return err + if m.changedHosts == nil { + m.changedHosts = map[string]struct{}{} + } + m.changedHosts[normalized] = struct{}{} + m.dirty = true +} + +func (m *EntriesModel) savePendingChanges() error { + if m.dbDir == "" { + return errors.New("persistence is not configured") + } + if !m.dirty || len(m.changedHosts) == 0 { + m.setStatusInfo("No unsaved changes.") + return nil + } + + hosts := make([]string, 0, len(m.changedHosts)) + for host := range m.changedHosts { + hosts = append(hosts, host) + } + sort.Strings(hosts) + + for _, host := range hosts { + hostEntries := make([]worktime.Entry, 0) + for _, entry := range m.allEntries { + if strings.TrimSpace(entry.Source) == host { + hostEntries = append(hostEntries, entry) + } + } + + db := worktime.Database{ + Entries: map[string][]worktime.Entry{ + host: hostEntries, + }, + } + if err := worktime.SaveHost(m.dbDir, host, db); err != nil { + return err + } + } + + m.changedHosts = map[string]struct{}{} + m.dirty = false + m.setStatusInfo(fmt.Sprintf("Saved %d host database(s).", len(hosts))) + return nil +} + +func (m EntriesModel) hasUnsavedChanges() bool { + return m.dirty && len(m.changedHosts) > 0 } func (m *EntriesModel) setStatusInfo(message string) { @@ -564,7 +916,7 @@ func (m *EntriesModel) renderStatus(styles Styles) string { } if m.statusError { - return "\n\n" + lipgloss.NewStyle().Foreground(lipgloss.Color("#FF8B8B")).Render(m.statusMessage) + return "\n\n" + styles.Error.Render(m.statusMessage) } return "\n\n" + styles.Hint.Render(m.statusMessage) @@ -594,7 +946,7 @@ func (m *EntriesModel) ensureCursorVisible() { } func (m *EntriesModel) listRows() int { - rows := m.height - 4 + rows := m.height - 6 if rows < 1 { return 1 } @@ -626,18 +978,6 @@ func formatEntryValue(entry worktime.Entry) string { return fmt.Sprintf("%+.2fh", hours) } -func colorizeCategory(category string) string { - if strings.TrimSpace(category) == "" { - category = "work" - } - - color, ok := categoryColors[category] - if !ok { - color = "#D9D9D9" - } - return lipgloss.NewStyle().Foreground(lipgloss.Color(color)).Render(category) -} - func minInt(a, b int) int { if a < b { return a @@ -654,22 +994,6 @@ func findEntryIndex(entries []worktime.Entry, target worktime.Entry) int { return -1 } -func findHostEntryIndex(dbDir, host string, target worktime.Entry) (int, error) { - db, err := worktime.LoadHost(dbDir, host) - if err != nil { - return -1, err - } - - entries := db.Entries[host] - for idx, entry := range entries { - if sameEntryIdentity(entry, target) { - return idx, nil - } - } - - return -1, fmt.Errorf("entry not found in host db %q", host) -} - func insertEntryAt(entries []worktime.Entry, idx int, entry worktime.Entry) []worktime.Entry { if idx < 0 { idx = 0 @@ -705,6 +1029,138 @@ func entryMatchesSearch(entry worktime.Entry, search string) bool { strings.Contains(strings.ToLower(entry.Descr), search) } +func (m *EntriesModel) renderTimelineTable(styles Styles) string { + widths := m.timelineColumnWidths() + headers := []string{"Date", "Time", "Action", "Category", "Value", "Source", "Description"} + + headerStyles := make([]lipgloss.Style, len(headers)) + for idx := range headers { + headerStyles[idx] = styles.TableHeader + if entriesColumn(idx) == m.selectedColumn { + headerStyles[idx] = styles.TableSelected + } + } + + lines := []string{ + renderTimelineRow(headers, widths, headerStyles), + } + + maxRows := m.listRows() + end := minInt(len(m.visible), m.offset+maxRows) + for idx := m.offset; idx < end; idx++ { + entry := m.visible[idx] + moment := time.Unix(entry.Epoch, 0) + row := []string{ + moment.Format("2006-01-02"), + moment.Format("15:04"), + entry.Action, + entry.What, + formatEntryValue(entry), + entry.Source, + entry.Descr, + } + + cellStyles := make([]lipgloss.Style, len(row)) + for colIdx := range row { + cellStyles[colIdx] = styles.TableCell + if idx == m.cursor { + cellStyles[colIdx] = styles.TableCell.Copy().Bold(true) + if entriesColumn(colIdx) == m.selectedColumn { + cellStyles[colIdx] = styles.TableSelected + } + } + } + lines = append(lines, renderTimelineRow(row, widths, cellStyles)) + } + + return strings.Join(lines, "\n") +} + +func (m *EntriesModel) timelineColumnWidths() []int { + total := m.width + if total <= 0 { + total = 120 + } + + dateWidth := 10 + timeWidth := 5 + actionWidth := 8 + categoryWidth := 12 + valueWidth := 9 + sourceWidth := 12 + + fixed := dateWidth + timeWidth + actionWidth + categoryWidth + valueWidth + sourceWidth + 6 + descriptionWidth := total - fixed + if descriptionWidth < 16 { + descriptionWidth = 16 + } + + return []int{ + dateWidth, + timeWidth, + actionWidth, + categoryWidth, + valueWidth, + sourceWidth, + descriptionWidth, + } +} + +func renderTimelineRow(values []string, widths []int, styles []lipgloss.Style) string { + cells := make([]string, 0, len(values)) + for idx, raw := range values { + width := widths[idx] + cell := lipgloss.NewStyle().Width(width).MaxWidth(width).Render(trimToWidth(raw, width)) + cells = append(cells, styles[idx].Render(cell)) + } + return strings.Join(cells, " ") +} + +func trimToWidth(value string, width int) string { + if width <= 0 { + return "" + } + + runes := []rune(value) + if len(runes) <= width { + return value + } + if width <= 1 { + return string(runes[:width]) + } + return string(runes[:width-1]) + "…" +} + +func parseEditedDate(input string, current time.Time) (time.Time, error) { + parsed, err := timefmt.Parse(strings.TrimSpace(input)) + if err != nil { + return time.Time{}, err + } + + year, month, day := parsed.Date() + hour, minute, second := current.Clock() + return time.Date(year, month, day, hour, minute, second, 0, current.Location()), nil +} + +func parseEditedTime(input string, current time.Time) (time.Time, error) { + candidate := strings.TrimSpace(input) + if candidate == "" { + return time.Time{}, errors.New("time value must not be empty") + } + + layouts := []string{"15:04", "15:04:05"} + for _, layout := range layouts { + parsed, err := time.ParseInLocation(layout, candidate, current.Location()) + if err == nil { + year, month, day := current.Date() + hour, minute, second := parsed.Clock() + return time.Date(year, month, day, hour, minute, second, 0, current.Location()), nil + } + } + + return time.Time{}, fmt.Errorf("unsupported time format %q", input) +} + func sameEntryIdentity(a, b worktime.Entry) bool { return a.Epoch == b.Epoch && a.Action == b.Action && a.Source == b.Source } diff --git a/internal/tui/entries_test.go b/internal/tui/entries_test.go index da5a5c4..f8f9378 100644 --- a/internal/tui/entries_test.go +++ b/internal/tui/entries_test.go @@ -2,10 +2,11 @@ package tui import ( "fmt" + "strings" "testing" "time" - "codeberg.org/snonux/timr/internal/worktime" + "codeberg.org/snonux/timesamurai/internal/worktime" tea "github.com/charmbracelet/bubbletea" ) @@ -25,6 +26,85 @@ func TestEntriesModelSortsChronologically(t *testing.T) { } } +func TestEntriesViewRendersTimelineTableHeaders(t *testing.T) { + model := NewEntriesModel(sampleEntries(2)) + model.SetSize(140, 12) + + view := model.View(DefaultStyles()) + if !strings.Contains(view, "Date") || !strings.Contains(view, "Time") || !strings.Contains(view, "Description") { + t.Fatalf("timeline table headers missing from view: %q", view) + } +} + +func TestEntriesColumnNavigationKeys(t *testing.T) { + model := NewEntriesModel(sampleEntries(1)) + model.SetSize(120, 12) + + if model.selectedColumn != entriesColumnDescription { + t.Fatalf("selectedColumn = %d, want %d", model.selectedColumn, entriesColumnDescription) + } + + model, _ = model.Update(tea.KeyMsg{Type: tea.KeyLeft}) + if model.selectedColumn != entriesColumnSource { + t.Fatalf("selectedColumn after left = %d, want %d", model.selectedColumn, entriesColumnSource) + } + + model, _ = model.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'h'}}) + if model.selectedColumn != entriesColumnValue { + t.Fatalf("selectedColumn after h = %d, want %d", model.selectedColumn, entriesColumnValue) + } + + model, _ = model.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'l'}}) + if model.selectedColumn != entriesColumnSource { + t.Fatalf("selectedColumn after l = %d, want %d", model.selectedColumn, entriesColumnSource) + } +} + +func TestEntriesEnterEditsSelectedColumnValue(t *testing.T) { + model := NewEntriesModel(sampleEntries(1)) + model.SetSize(120, 12) + model.selectedColumn = entriesColumnValue + + model, _ = model.Update(tea.KeyMsg{Type: tea.KeyEnter}) + if !model.editMode { + t.Fatal("editMode = false, want true after Enter on value column") + } + if model.editField != entryEditFieldValue { + t.Fatalf("editField = %d, want %d", model.editField, entryEditFieldValue) + } +} + +func TestEntriesEnterEditsSelectedColumnDateAndTime(t *testing.T) { + model := NewEntriesModel(sampleEntries(1)) + model.SetSize(120, 12) + + original := model.visible[0].Epoch + + model.selectedColumn = entriesColumnDate + model, _ = model.Update(tea.KeyMsg{Type: tea.KeyEnter}) + if !model.editMode || model.editField != entryEditFieldDate { + t.Fatalf("date edit not entered: editMode=%t editField=%d", model.editMode, model.editField) + } + model.input = "2026-02-02" + model, _ = model.Update(tea.KeyMsg{Type: tea.KeyEnter}) + if model.visible[0].Epoch == original { + t.Fatal("epoch did not change after date edit") + } + + model.selectedColumn = entriesColumnTime + model, _ = model.Update(tea.KeyMsg{Type: tea.KeyEnter}) + if !model.editMode || model.editField != entryEditFieldTime { + t.Fatalf("time edit not entered: editMode=%t editField=%d", model.editMode, model.editField) + } + model.input = "13:45" + model, _ = model.Update(tea.KeyMsg{Type: tea.KeyEnter}) + + updatedTime := time.Unix(model.visible[0].Epoch, 0) + if updatedTime.Hour() != 13 || updatedTime.Minute() != 45 { + t.Fatalf("time after edit = %s, want 13:45", updatedTime.Format("15:04")) + } +} + func TestEntriesNavigationKeys(t *testing.T) { model := NewEntriesModel(sampleEntries(20)) model.SetSize(120, 12) @@ -228,7 +308,7 @@ func TestEntriesDeletePersistsToDB(t *testing.T) { } model := NewEntriesModel(entries) - model.SetPersistence(dbDir) + model.SetPersistence(dbDir, host) model.SetSize(120, 12) model, _ = model.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'d'}}) @@ -238,13 +318,124 @@ func TestEntriesDeletePersistsToDB(t *testing.T) { if len(model.visible) != 2 { t.Fatalf("entries len = %d, want 2 after persisted delete", len(model.visible)) } + if !model.hasUnsavedChanges() { + t.Fatal("hasUnsavedChanges = false, want true after delete before save") + } reloaded, err := worktime.LoadHost(dbDir, host) if err != nil { t.Fatalf("LoadHost() error = %v", err) } + if len(reloaded.Entries[host]) != 3 { + t.Fatalf("host entries len before save = %d, want 3", len(reloaded.Entries[host])) + } + + model, _ = model.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'s'}}) + if model.hasUnsavedChanges() { + t.Fatal("hasUnsavedChanges = true, want false after save") + } + + reloaded, err = worktime.LoadHost(dbDir, host) + if err != nil { + t.Fatalf("LoadHost() error = %v", err) + } if len(reloaded.Entries[host]) != 2 { - t.Fatalf("host entries len = %d, want 2", len(reloaded.Entries[host])) + t.Fatalf("host entries len after save = %d, want 2", len(reloaded.Entries[host])) + } +} + +func TestEntriesDayOffPromptPersistsToDB(t *testing.T) { + dbDir := t.TempDir() + host := "host-a" + + db := worktime.Database{ + Entries: map[string][]worktime.Entry{ + host: {}, + }, + } + if err := worktime.SaveHost(dbDir, host, db); err != nil { + t.Fatalf("SaveHost() error = %v", err) + } + + model := NewEntriesModel(nil) + model.SetPersistence(dbDir, host) + model.SetSize(120, 12) + + model, _ = model.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'D'}}) + if !model.dayOffMode { + t.Fatal("dayOffMode = false, want true after D") + } + + model.dayOffDate = time.Date(2026, 1, 22, 12, 34, 0, 0, time.Local) + model, _ = model.Update(tea.KeyMsg{Type: tea.KeyEnter}) + if model.dayOffMode { + t.Fatal("dayOffMode = true, want false after Enter") + } + if !model.hasUnsavedChanges() { + t.Fatal("hasUnsavedChanges = false, want true after day off before save") + } + + db, err := worktime.LoadHost(dbDir, host) + if err != nil { + t.Fatalf("LoadHost() error = %v", err) + } + + entries := db.Entries[host] + if len(entries) != 0 { + t.Fatalf("entries len before save = %d, want 0", len(entries)) + } + + model, _ = model.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'s'}}) + if model.hasUnsavedChanges() { + t.Fatal("hasUnsavedChanges = true, want false after save") + } + + db, err = worktime.LoadHost(dbDir, host) + if err != nil { + t.Fatalf("LoadHost() error = %v", err) + } + + entries = db.Entries[host] + if len(entries) != 1 { + t.Fatalf("entries len after save = %d, want 1", len(entries)) + } + + entry := entries[0] + wantEpoch := time.Date(2026, 1, 22, 0, 0, 0, 0, time.Local).Unix() + if entry.What != "off" || entry.Action != "add" { + t.Fatalf("unexpected day-off entry: %+v", entry) + } + if entry.Value != 8*3600 { + t.Fatalf("entry.Value = %d, want 28800", entry.Value) + } + if entry.Epoch != wantEpoch { + t.Fatalf("entry.Epoch = %d, want %d", entry.Epoch, wantEpoch) + } +} + +func TestEntriesDayOffDatepickerNavigation(t *testing.T) { + model := NewEntriesModel(sampleEntries(1)) + model.SetSize(120, 12) + + model, _ = model.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'D'}}) + if !model.dayOffMode { + t.Fatal("dayOffMode = false, want true after D") + } + + model.dayOffDate = time.Date(2026, 2, 12, 0, 0, 0, 0, time.Local) + model, _ = model.Update(tea.KeyMsg{Type: tea.KeyRight}) + if !sameDay(model.dayOffDate, time.Date(2026, 2, 13, 0, 0, 0, 0, time.Local)) { + t.Fatalf("dayOffDate after right = %v, want 2026-02-13", model.dayOffDate) + } + + model, _ = model.Update(tea.KeyMsg{Type: tea.KeyDown}) + if !sameDay(model.dayOffDate, time.Date(2026, 2, 20, 0, 0, 0, 0, time.Local)) { + t.Fatalf("dayOffDate after down = %v, want 2026-02-20", model.dayOffDate) + } + + model, _ = model.Update(tea.KeyMsg{Type: tea.KeyPgUp}) + if !sameDay(model.dayOffDate, time.Date(2026, 1, 20, 0, 0, 0, 0, time.Local)) { + t.Fatalf("dayOffDate after pgup = %v, want 2026-01-20", model.dayOffDate) } } diff --git a/internal/tui/report.go b/internal/tui/report.go index 205e3e9..3db4ab0 100644 --- a/internal/tui/report.go +++ b/internal/tui/report.go @@ -4,13 +4,14 @@ import ( "fmt" "strings" - "codeberg.org/snonux/timr/internal/worktime" + "codeberg.org/snonux/timesamurai/internal/worktime" tea "github.com/charmbracelet/bubbletea" ) // ReportModel is a weekly report browser screen. type ReportModel struct { weeks []worktime.WeekReport + warn string weekIndex int cursor int @@ -58,6 +59,11 @@ func (m *ReportModel) SetWeeks(weeks []worktime.WeekReport) { m.offset = 0 } +// SetWarning sets a status warning shown at the bottom of the report view. +func (m *ReportModel) SetWarning(warning string) { + m.warn = strings.TrimSpace(warning) +} + // Update handles keyboard interaction. func (m *ReportModel) Update(msg tea.Msg) (ReportModel, tea.Cmd) { keyMsg, ok := msg.(tea.KeyMsg) @@ -150,6 +156,9 @@ func (m *ReportModel) View(styles Styles) string { hint := "j/k scroll, gg/G top/bottom, ]w/[w week nav, v verbose" body := header + "\n\n" + strings.Join(lines, "\n") + "\n\n" + summary + "\n" + hint + if m.warn != "" { + body += "\n" + styles.Warning.Render("Warning: "+m.warn) + } return styles.Body.Render(body) } diff --git a/internal/tui/report_test.go b/internal/tui/report_test.go index cf9a7f9..2bde9ff 100644 --- a/internal/tui/report_test.go +++ b/internal/tui/report_test.go @@ -4,7 +4,7 @@ import ( "strings" "testing" - "codeberg.org/snonux/timr/internal/worktime" + "codeberg.org/snonux/timesamurai/internal/worktime" tea "github.com/charmbracelet/bubbletea" ) @@ -82,6 +82,17 @@ func TestReportSummaryBarInView(t *testing.T) { } } +func TestReportWarningInView(t *testing.T) { + model := NewReportModel(sampleWeeks()) + model.SetWarning("currently logged in: work") + model.SetSize(120, 12) + + view := model.View(DefaultStyles()) + if !strings.Contains(view, "Warning: currently logged in: work") { + t.Fatalf("view missing warning: %q", view) + } +} + func sampleWeeks() []worktime.WeekReport { return []worktime.WeekReport{ { diff --git a/internal/tui/styles.go b/internal/tui/styles.go index 247411c..c131c1f 100644 --- a/internal/tui/styles.go +++ b/internal/tui/styles.go @@ -4,38 +4,79 @@ import "github.com/charmbracelet/lipgloss" // Styles groups visual styles for the root TUI scaffold. type Styles struct { - App lipgloss.Style - Header lipgloss.Style - Tab lipgloss.Style - ActiveTab lipgloss.Style - Body lipgloss.Style - Help lipgloss.Style - Hint lipgloss.Style + App lipgloss.Style + Header lipgloss.Style + Tab lipgloss.Style + ActiveTab lipgloss.Style + Body lipgloss.Style + Help lipgloss.Style + Hint lipgloss.Style + Status lipgloss.Style + TableHeader lipgloss.Style + TableCell lipgloss.Style + TableSelected lipgloss.Style + SearchMatch lipgloss.Style + Error lipgloss.Style + Warning lipgloss.Style } // DefaultStyles returns the default style set. func DefaultStyles() Styles { + return StylesFromTheme(DefaultTheme()) +} + +// StylesFromTheme builds styles from a theme palette. +func StylesFromTheme(theme Theme) Styles { return Styles{ App: lipgloss.NewStyle(). - Padding(1, 2), + Padding(0, 1), Header: lipgloss.NewStyle(). - Foreground(lipgloss.Color("#8A8A8A")). + Foreground(lipgloss.Color(theme.HeaderFG)). + Background(lipgloss.Color(theme.StatusBG)). + Padding(0, 1). Bold(true), Tab: lipgloss.NewStyle(). Padding(0, 1). - Foreground(lipgloss.Color("#B5B5B5")), + Foreground(lipgloss.Color(theme.StatusFG)), ActiveTab: lipgloss.NewStyle(). Padding(0, 1). - Foreground(lipgloss.Color("#101010")). - Background(lipgloss.Color("#8BD3DD")). + Foreground(lipgloss.Color(theme.SelectedFG)). + Background(lipgloss.Color(theme.SelectedBG)). Bold(true), Body: lipgloss.NewStyle(). PaddingTop(1), Help: lipgloss.NewStyle(). PaddingTop(1). - Foreground(lipgloss.Color("#A0D568")), + Foreground(lipgloss.Color(theme.HeaderFG)), Hint: lipgloss.NewStyle(). PaddingTop(1). - Foreground(lipgloss.Color("#777777")), + Foreground(lipgloss.Color("245")), + Status: lipgloss.NewStyle(). + Foreground(lipgloss.Color(theme.StatusFG)). + Background(lipgloss.Color(theme.StatusBG)). + Padding(0, 1), + TableHeader: lipgloss.NewStyle(). + Bold(true). + Foreground(lipgloss.Color(theme.HeaderFG)). + Background(lipgloss.Color(theme.SelectedBG)). + Padding(0, 1), + TableCell: lipgloss.NewStyle(). + Foreground(lipgloss.Color(theme.RowFG)). + Background(lipgloss.Color(theme.RowBG)). + Padding(0, 1), + TableSelected: lipgloss.NewStyle(). + Bold(true). + Foreground(lipgloss.Color(theme.SelectedFG)). + Background(lipgloss.Color(theme.SelectedBG)). + Padding(0, 1), + SearchMatch: lipgloss.NewStyle(). + Foreground(lipgloss.Color(theme.SearchFG)). + Background(lipgloss.Color(theme.SearchBG)), + Error: lipgloss.NewStyle(). + Foreground(lipgloss.Color("196")). + Bold(true), + Warning: lipgloss.NewStyle(). + Foreground(lipgloss.Color("214")). + Bold(true), } } diff --git a/internal/tui/theme.go b/internal/tui/theme.go new file mode 100644 index 0000000..862f193 --- /dev/null +++ b/internal/tui/theme.go @@ -0,0 +1,103 @@ +package tui + +import ( + "math/rand/v2" + "strconv" +) + +// Theme defines the shared color palette used across the TUI. +type Theme struct { + HeaderFG string + SelectedFG string + SelectedBG string + RowFG string + RowBG string + StatusFG string + StatusBG string + SearchFG string + SearchBG string +} + +// DefaultTheme returns the baseline theme inspired by Task Samurai. +func DefaultTheme() Theme { + return Theme{ + HeaderFG: "205", + SelectedFG: "229", + SelectedBG: "57", + RowFG: "15", + RowBG: "236", + StatusFG: "229", + StatusBG: "57", + SearchFG: "21", + SearchBG: "226", + } +} + +// RandomTheme returns a randomized high-contrast palette for disco mode. +func RandomTheme() Theme { + theme := Theme{ + HeaderFG: randomColor(), + SelectedBG: randomColor(), + RowBG: randomColor(), + StatusBG: randomColor(), + SearchBG: randomColor(), + } + + theme.SelectedFG = contrastColor(theme.SelectedBG) + theme.RowFG = contrastColor(theme.RowBG) + theme.StatusFG = contrastColor(theme.StatusBG) + theme.SearchFG = contrastColor(theme.SearchBG) + return theme +} + +func randomColor() string { + return strconv.Itoa(rand.IntN(256)) +} + +func contrastColor(background string) string { + value, err := strconv.Atoi(background) + if err != nil { + return "15" + } + + if xtermBrightness(value) > 128 { + return "0" + } + return "15" +} + +func xtermBrightness(index int) float64 { + red, green, blue := xtermRGB(index) + return 0.299*float64(red) + 0.587*float64(green) + 0.114*float64(blue) +} + +func xtermRGB(index int) (int, int, int) { + if index < 0 { + index = 0 + } + if index > 255 { + index = 255 + } + + if index < 16 { + table := [16][3]int{ + {0, 0, 0}, {205, 0, 0}, {0, 205, 0}, {205, 205, 0}, + {0, 0, 238}, {205, 0, 205}, {0, 205, 205}, {229, 229, 229}, + {127, 127, 127}, {255, 0, 0}, {0, 255, 0}, {255, 255, 0}, + {92, 92, 255}, {255, 0, 255}, {0, 255, 255}, {255, 255, 255}, + } + rgb := table[index] + return rgb[0], rgb[1], rgb[2] + } + + if index <= 231 { + index -= 16 + red := (index / 36) * 51 + green := (index % 36 / 6) * 51 + blue := (index % 6) * 51 + return red, green, blue + } + + gray := (index-232)*10 + 8 + return gray, gray, gray +} diff --git a/internal/tui/timer.go b/internal/tui/timer.go index 4a1af3c..980390a 100644 --- a/internal/tui/timer.go +++ b/internal/tui/timer.go @@ -5,10 +5,10 @@ import ( "strings" "time" - "codeberg.org/snonux/timr/internal/ascii" - "codeberg.org/snonux/timr/internal/config" - timrTimer "codeberg.org/snonux/timr/internal/timer" - "codeberg.org/snonux/timr/internal/worktime" + "codeberg.org/snonux/timesamurai/internal/ascii" + "codeberg.org/snonux/timesamurai/internal/config" + timesamuraiTimer "codeberg.org/snonux/timesamurai/internal/timer" + "codeberg.org/snonux/timesamurai/internal/worktime" tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" "github.com/common-nighthawk/go-figure" @@ -24,7 +24,7 @@ func timerTick() tea.Cmd { // TimerModel is the timer screen model used inside the TUI. type TimerModel struct { - state timrTimer.State + state timesamuraiTimer.State quitting bool helpStyle lipgloss.Style timerStyle lipgloss.Style @@ -47,7 +47,7 @@ type workIntegration struct { // NewTimerModel builds the timer screen model. func NewTimerModel(font string, cfg config.Config) (TimerModel, error) { - state, err := timrTimer.LoadState() + state, err := timesamuraiTimer.LoadState() if err != nil { return TimerModel{}, err } @@ -146,8 +146,8 @@ func (m TimerModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, nil case "r": - m.state = timrTimer.State{} - if _, err := timrTimer.ResetTimer(); err != nil { + m.state = timesamuraiTimer.State{} + if _, err := timesamuraiTimer.ResetTimer(); err != nil { m.work.status = "reset error: " + err.Error() } return m, nil diff --git a/internal/tui/timer_test.go b/internal/tui/timer_test.go index 3eb39b2..8bc9432 100644 --- a/internal/tui/timer_test.go +++ b/internal/tui/timer_test.go @@ -3,8 +3,8 @@ package tui import ( "testing" - "codeberg.org/snonux/timr/internal/config" - "codeberg.org/snonux/timr/internal/worktime" + "codeberg.org/snonux/timesamurai/internal/config" + "codeberg.org/snonux/timesamurai/internal/worktime" tea "github.com/charmbracelet/bubbletea" ) diff --git a/internal/tui/tui.go b/internal/tui/tui.go index eaa1dd4..926c331 100644 --- a/internal/tui/tui.go +++ b/internal/tui/tui.go @@ -1,11 +1,13 @@ package tui import ( + "fmt" "strings" "time" - "codeberg.org/snonux/timr/internal/config" - "codeberg.org/snonux/timr/internal/worktime" + timesamurai "codeberg.org/snonux/timesamurai/internal" + "codeberg.org/snonux/timesamurai/internal/config" + "codeberg.org/snonux/timesamurai/internal/worktime" tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" ) @@ -35,11 +37,14 @@ type Model struct { width int height int - showHelp bool - pendingG bool - pendingZ bool + showHelp bool + confirmQuit bool + pendingG bool + pendingZ bool styles Styles + theme Theme + disco bool entries EntriesModel report ReportModel @@ -59,9 +64,17 @@ func NewModel() *Model { // NewModelWithConfig creates a data-backed root model from config. func NewModelWithConfig(cfg config.Config) (*Model, error) { + return NewModelWithConfigAndDisco(cfg, false) +} + +// NewModelWithConfigAndDisco creates a data-backed root model and optionally enables disco mode. +func NewModelWithConfigAndDisco(cfg config.Config, disco bool) (*Model, error) { + theme := DefaultTheme() model := &Model{ activeTab: tabEntries, - styles: DefaultStyles(), + styles: StylesFromTheme(theme), + theme: theme, + disco: disco, entries: NewEntriesModel(nil), report: NewReportModel(nil), } @@ -70,14 +83,21 @@ func NewModelWithConfig(cfg config.Config) (*Model, error) { if err != nil { model.entriesErr = err.Error() } else { + host, hostErr := cfg.EffectiveHostname() + if hostErr != nil { + host = strings.TrimSpace(cfg.Hostname) + } model.entries.SetEntries(entries) - model.entries.SetPersistence(cfg.WorktimeDBDir) + model.entries.SetPersistence(cfg.WorktimeDBDir, host) weeks, reportErr := worktime.BuildReport(entries, cfg) if reportErr != nil { model.reportErr = reportErr.Error() } else { model.report.SetWeeks(weeks) } + if warning := reportOpenSessionWarning(entries); warning != "" { + model.report.SetWarning(warning) + } } timerModel, timerErr := NewTimerModel("doom", cfg) @@ -87,6 +107,10 @@ func NewModelWithConfig(cfg config.Config) (*Model, error) { model.timer = timerModel } + if model.disco { + model.randomizeTheme() + } + return model, nil } @@ -114,10 +138,31 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case tea.KeyMsg: key := msg.String() + if m.confirmQuit { + switch key { + case "s": + if err := m.entries.savePendingChanges(); err != nil { + m.entries.setStatusError("Save failed: " + err.Error()) + m.confirmQuit = false + return m, nil + } + m.confirmQuit = false + return m, tea.Quit + case "d", "n": + m.confirmQuit = false + return m, tea.Quit + case "esc": + m.confirmQuit = false + return m, nil + default: + return m, nil + } + } + if m.pendingZ { m.pendingZ = false if key == "Q" { - return m, tea.Quit + return m.requestQuit() } } @@ -140,9 +185,26 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, m.switchTab(tabReport) case "3": return m, m.switchTab(tabTimer) - case "?": + case "?", "H": m.showHelp = !m.showHelp return m, nil + case "esc": + if m.showHelp { + m.showHelp = false + return m, nil + } + case "c": + m.randomizeTheme() + return m, nil + case "C": + m.resetTheme() + return m, nil + case "x": + m.disco = !m.disco + if m.disco { + m.randomizeTheme() + } + return m, nil case "g": m.pendingG = true return m, nil @@ -150,7 +212,7 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.pendingZ = true return m, nil case "q", "ctrl+c": - return m, tea.Quit + return m.requestQuit() } } @@ -161,13 +223,39 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { func (m *Model) View() string { header := m.renderTabs() body := m.renderBody() + status := m.renderStatusLine() + + if m.confirmQuit { + body = m.styles.Help.Render(strings.Join([]string{ + "Unsaved entry changes detected.", + "", + "Save before quitting?", + "", + "s : save and quit", + "d : discard changes and quit", + "Esc : cancel", + }, "\n")) + } - help := m.styles.Hint.Render("Press ? for help") - if m.showHelp { - help = m.styles.Help.Render("Tab/gt/gT/1/2/3 switch tabs, ? toggles help, q or ZQ quits") + if !m.confirmQuit && m.showHelp { + body = m.styles.Help.Render(strings.Join([]string{ + "Global keys", + "", + "Tab / gt / gT / 1 / 2 / 3 : switch tabs", + "? / H : toggle help", + "c / C : random/reset theme", + "x : toggle disco mode", + "q / ZQ : quit", + "", + "Entries", + "", + "j/k rows, h/l columns, Enter edit selected cell", + "/ search, f category filter, e/v quick edit, s save, dd delete entry", + "D day-off datepicker (8h off entry)", + }, "\n")) } - content := lipgloss.JoinVertical(lipgloss.Left, header, body, help) + content := lipgloss.JoinVertical(lipgloss.Left, header, body, status) rendered := m.styles.App.Render(content) if m.width > 0 && m.height > 0 { @@ -190,6 +278,7 @@ func (m *Model) prevTab() tea.Cmd { func (m *Model) renderTabs() string { parts := make([]string, 0, len(tabLabels)) + parts = append(parts, lipgloss.NewStyle().Bold(true).Render("timesamurai "+timesamurai.Version)) for idx, label := range tabLabels { if tab(idx) == m.activeTab { parts = append(parts, m.styles.ActiveTab.Render(label)) @@ -197,7 +286,14 @@ func (m *Model) renderTabs() string { } parts = append(parts, m.styles.Tab.Render(label)) } - return m.styles.Header.Render(strings.Join(parts, " ")) + if m.disco { + parts = append(parts, m.styles.ActiveTab.Render("DISCO")) + } + if m.entries.hasUnsavedChanges() { + parts = append(parts, m.styles.ActiveTab.Render("UNSAVED")) + } + line := strings.Join(parts, " ") + return m.styles.Header.Width(m.statusWidth()).Render(line) } func (m *Model) renderBody() string { @@ -261,8 +357,12 @@ func (m *Model) bodySize() (width int, height int) { func (m *Model) updateActiveTab(msg tea.Msg) (tea.Model, tea.Cmd) { switch m.activeTab { case tabEntries: + beforeMutations := m.entries.mutationCount updated, cmd := m.entries.Update(msg) m.entries = updated + if m.disco && m.entries.mutationCount != beforeMutations { + m.randomizeTheme() + } return m, cmd case tabReport: updated, cmd := m.report.Update(msg) @@ -290,3 +390,64 @@ func (m *Model) startRootTimerTicker() tea.Cmd { m.timerTickScheduled = true return rootTimerTick() } + +func (m *Model) randomizeTheme() { + m.theme = RandomTheme() + m.styles = StylesFromTheme(m.theme) +} + +func (m *Model) resetTheme() { + m.theme = DefaultTheme() + m.styles = StylesFromTheme(m.theme) +} + +func (m *Model) renderStatusLine() string { + status := fmt.Sprintf( + "Entries timeline table | unsaved:%t | disco:%t | H help | c/C theme | x disco | q quit", + m.entries.hasUnsavedChanges(), + m.disco, + ) + if m.showHelp { + status = "Help mode active (press H or ? to close)" + } + if m.confirmQuit { + status = "Unsaved changes: s save+quit, d discard+quit, Esc cancel" + } + + return m.styles.Status.Width(m.statusWidth()).Render(status) +} + +func (m *Model) statusWidth() int { + if m.width <= 0 { + return 80 + } + if m.width <= 2 { + return m.width + } + return m.width - 2 +} + +func (m *Model) requestQuit() (tea.Model, tea.Cmd) { + if m.entries.hasUnsavedChanges() { + m.confirmQuit = true + return m, nil + } + return m, tea.Quit +} + +func reportOpenSessionWarning(entries []worktime.Entry) string { + openSessions := worktime.OpenSessions(entries) + if len(openSessions) == 0 { + return "" + } + + items := make([]string, 0, len(openSessions)) + for _, session := range openSessions { + items = append(items, fmt.Sprintf( + "%s (since %s)", + session.Category, + time.Unix(session.Login.Epoch, 0).Format("2006-01-02 15:04"), + )) + } + return "currently logged in: " + strings.Join(items, ", ") +} diff --git a/internal/tui/tui_test.go b/internal/tui/tui_test.go index 26fe216..eb64f14 100644 --- a/internal/tui/tui_test.go +++ b/internal/tui/tui_test.go @@ -3,8 +3,10 @@ package tui import ( "strings" "testing" + "time" - "codeberg.org/snonux/timr/internal/config" + "codeberg.org/snonux/timesamurai/internal/config" + "codeberg.org/snonux/timesamurai/internal/worktime" tea "github.com/charmbracelet/bubbletea" ) @@ -46,6 +48,18 @@ func TestHelpToggle(t *testing.T) { if model.showHelp { t.Fatal("showHelp = true, want false after second ?") } + + modelAny, _ = model.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'H'}}) + model = modelAny.(*Model) + if !model.showHelp { + t.Fatal("showHelp = false, want true after H") + } + + modelAny, _ = model.Update(tea.KeyMsg{Type: tea.KeyEsc}) + model = modelAny.(*Model) + if model.showHelp { + t.Fatal("showHelp = true, want false after Esc") + } } func TestQuitKeys(t *testing.T) { @@ -72,6 +86,70 @@ func TestQuitKeys(t *testing.T) { } } +func TestQuitWithUnsavedChangesPromptsConfirmation(t *testing.T) { + model := newRootModelForTest(t) + + modelAny, _ := model.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'o'}}) + model = modelAny.(*Model) + if !model.entries.hasUnsavedChanges() { + t.Fatal("entries.hasUnsavedChanges() = false, want true after insertion") + } + + modelAny, cmd := model.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'q'}}) + model = modelAny.(*Model) + if cmd != nil { + t.Fatal("quit command should be deferred until quit confirmation") + } + if !model.confirmQuit { + t.Fatal("confirmQuit = false, want true after q with unsaved changes") + } + + modelAny, cmd = model.Update(tea.KeyMsg{Type: tea.KeyEsc}) + model = modelAny.(*Model) + if cmd != nil { + t.Fatal("Esc in quit confirmation should not quit") + } + if model.confirmQuit { + t.Fatal("confirmQuit = true, want false after Esc") + } +} + +func TestQuitConfirmationSaveAndQuitPersistsEntries(t *testing.T) { + model := newRootModelForTest(t) + + modelAny, _ := model.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'o'}}) + model = modelAny.(*Model) + if !model.entries.hasUnsavedChanges() { + t.Fatal("entries.hasUnsavedChanges() = false, want true after insertion") + } + + modelAny, _ = model.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'q'}}) + model = modelAny.(*Model) + if !model.confirmQuit { + t.Fatal("confirmQuit = false, want true after q with unsaved changes") + } + + modelAny, cmd := model.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'s'}}) + model = modelAny.(*Model) + if cmd == nil { + t.Fatal("quit cmd is nil for save-and-quit") + } + if _, ok := cmd().(tea.QuitMsg); !ok { + t.Fatal("save-and-quit did not return tea.Quit command") + } + if model.entries.hasUnsavedChanges() { + t.Fatal("entries.hasUnsavedChanges() = true, want false after save-and-quit") + } + + db, err := worktime.LoadHost(model.entries.dbDir, model.entries.dbHost) + if err != nil { + t.Fatalf("LoadHost() error = %v", err) + } + if got := len(db.Entries[model.entries.dbHost]); got != 1 { + t.Fatalf("saved entries len = %d, want 1", got) + } +} + func TestViewContainsTabLabels(t *testing.T) { model := newRootModelForTest(t) view := model.View() @@ -88,6 +166,49 @@ func TestEntriesTabUsesEntriesModelView(t *testing.T) { } } +func TestDiscoToggleAndThemeResetKeys(t *testing.T) { + model := newRootModelForTest(t) + + modelAny, _ := model.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'x'}}) + model = modelAny.(*Model) + if !model.disco { + t.Fatal("disco = false, want true after x") + } + + modelAny, _ = model.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'C'}}) + model = modelAny.(*Model) + if model.theme != DefaultTheme() { + t.Fatalf("theme after C = %+v, want default theme", model.theme) + } +} + +func TestModelSetsReportWarningForOpenSession(t *testing.T) { + tempDir := t.TempDir() + t.Setenv("XDG_CONFIG_HOME", tempDir) + t.Setenv("HOME", tempDir) + + cfg := config.Default() + cfg.WorktimeDBDir = tempDir + cfg.Hostname = "host-a" + + if _, err := worktime.Login(cfg.WorktimeDBDir, cfg.Hostname, "work", localTime(2026, 3, 4, 10, 0), ""); err != nil { + t.Fatalf("Login() error = %v", err) + } + + model, err := NewModelWithConfig(cfg) + if err != nil { + t.Fatalf("NewModelWithConfig() error = %v", err) + } + + if !strings.Contains(model.report.warn, "currently logged in") { + t.Fatalf("report warning = %q, want currently logged in warning", model.report.warn) + } +} + +func localTime(year int, month time.Month, day, hour, minute int) time.Time { + return time.Date(year, month, day, hour, minute, 0, 0, time.Local) +} + func newRootModelForTest(t *testing.T) *Model { t.Helper() |
