summaryrefslogtreecommitdiff
path: root/internal/tui
diff options
context:
space:
mode:
authorPaul Buetow <paul@buetow.org>2026-03-04 10:50:07 +0200
committerPaul Buetow <paul@buetow.org>2026-03-04 10:50:07 +0200
commit97aa8a6f666f5f40859c8a9aa4948bde435cf18f (patch)
tree0cb5928cd6a1220607dbf64e234a2522acac2848 /internal/tui
parentc25c9002f3214e07b041aefa26d5d13c26757839 (diff)
Rename project to timesamurai and release v0.5.0v0.5.0
Diffstat (limited to 'internal/tui')
-rw-r--r--internal/tui/doc.go2
-rw-r--r--internal/tui/entries.go638
-rw-r--r--internal/tui/entries_test.go197
-rw-r--r--internal/tui/report.go11
-rw-r--r--internal/tui/report_test.go13
-rw-r--r--internal/tui/styles.go69
-rw-r--r--internal/tui/theme.go103
-rw-r--r--internal/tui/timer.go16
-rw-r--r--internal/tui/timer_test.go4
-rw-r--r--internal/tui/tui.go191
-rw-r--r--internal/tui/tui_test.go123
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()