summaryrefslogtreecommitdiff
path: root/internal/tui/entries.go
diff options
context:
space:
mode:
Diffstat (limited to 'internal/tui/entries.go')
-rw-r--r--internal/tui/entries.go638
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
}