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/entries.go | |
| parent | c25c9002f3214e07b041aefa26d5d13c26757839 (diff) | |
Rename project to timesamurai and release v0.5.0v0.5.0
Diffstat (limited to 'internal/tui/entries.go')
| -rw-r--r-- | internal/tui/entries.go | 638 |
1 files changed, 547 insertions, 91 deletions
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 } |
