package timestampeditor import ( "log/slog" "strings" "tasksquire/common" "time" "github.com/charmbracelet/bubbles/key" tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" ) const ( timeFormat = "20060102T150405Z" // Timewarrior format ) // Field represents which field is currently focused type Field int const ( TimeField Field = iota DateField ) // TimestampEditor is a component for editing timestamps with separate time and date fields type TimestampEditor struct { common *common.Common // Current timestamp value timestamp time.Time isEmpty bool // Track if timestamp is unset // UI state focused bool currentField Field // Dimensions width int height int // Title and description title string description string // Validation validate func(time.Time) error err error } // New creates a new TimestampEditor with no initial timestamp func New(com *common.Common) *TimestampEditor { return &TimestampEditor{ common: com, timestamp: time.Time{}, // Zero time isEmpty: true, // Start empty focused: false, currentField: TimeField, validate: func(time.Time) error { return nil }, } } // Title sets the title of the timestamp editor func (t *TimestampEditor) Title(title string) *TimestampEditor { t.title = title return t } // Description sets the description of the timestamp editor func (t *TimestampEditor) Description(description string) *TimestampEditor { t.description = description return t } // Value sets the initial timestamp value func (t *TimestampEditor) Value(timestamp time.Time) *TimestampEditor { t.timestamp = timestamp t.isEmpty = timestamp.IsZero() return t } // ValueFromString sets the initial timestamp from a timewarrior format string func (t *TimestampEditor) ValueFromString(s string) *TimestampEditor { if s == "" { t.timestamp = time.Time{} t.isEmpty = true return t } parsed, err := time.Parse(timeFormat, s) if err != nil { slog.Error("Failed to parse timestamp", "error", err) t.timestamp = time.Time{} t.isEmpty = true return t } t.timestamp = parsed.Local() t.isEmpty = false return t } // GetValue returns the current timestamp func (t *TimestampEditor) GetValue() time.Time { return t.timestamp } // GetValueString returns the timestamp in timewarrior format, or empty string if unset func (t *TimestampEditor) GetValueString() string { if t.isEmpty { return "" } return t.timestamp.UTC().Format(timeFormat) } // Validate sets the validation function func (t *TimestampEditor) Validate(validate func(time.Time) error) *TimestampEditor { t.validate = validate return t } // Error returns the validation error func (t *TimestampEditor) Error() error { return t.err } // Focus focuses the timestamp editor func (t *TimestampEditor) Focus() tea.Cmd { t.focused = true return nil } // Blur blurs the timestamp editor func (t *TimestampEditor) Blur() tea.Cmd { t.focused = false t.err = t.validate(t.timestamp) return nil } // SetSize sets the size of the timestamp editor func (t *TimestampEditor) SetSize(width, height int) { t.width = width t.height = height } // Init initializes the timestamp editor func (t *TimestampEditor) Init() tea.Cmd { return nil } // Update handles messages for the timestamp editor func (t *TimestampEditor) Update(msg tea.Msg) (tea.Model, tea.Cmd) { if !t.focused { return t, nil } switch msg := msg.(type) { case tea.KeyMsg: t.err = nil switch msg.String() { // Navigation between fields case "h", "left": t.currentField = TimeField case "l", "right": t.currentField = DateField // Time field adjustments (lowercase - 5 minutes) case "j": // Set current time on first edit if empty if t.isEmpty { t.setCurrentTime() } if t.currentField == TimeField { t.adjustTime(-5) } else { t.adjustDate(-1) } case "k": // Set current time on first edit if empty if t.isEmpty { t.setCurrentTime() } if t.currentField == TimeField { t.adjustTime(5) } else { t.adjustDate(1) } // Time field adjustments (uppercase - 30 minutes) or date adjustments (week) case "J": // Set current time on first edit if empty if t.isEmpty { t.setCurrentTime() } if t.currentField == TimeField { t.adjustTime(-30) } else { t.adjustDate(-7) } case "K": // Set current time on first edit if empty if t.isEmpty { t.setCurrentTime() } if t.currentField == TimeField { t.adjustTime(30) } else { t.adjustDate(7) } // Remove timestamp case "d": t.timestamp = time.Time{} t.isEmpty = true } } return t, nil } // setCurrentTime sets the timestamp to the current time and marks it as not empty func (t *TimestampEditor) setCurrentTime() { now := time.Now() // Snap to nearest 5 minutes minute := now.Minute() remainder := minute % 5 if remainder != 0 { if remainder < 3 { // Round down now = now.Add(-time.Duration(remainder) * time.Minute) } else { // Round up now = now.Add(time.Duration(5-remainder) * time.Minute) } } // Zero out seconds and nanoseconds t.timestamp = time.Date( now.Year(), now.Month(), now.Day(), now.Hour(), now.Minute(), 0, 0, now.Location(), ) t.isEmpty = false } // adjustTime adjusts the time by the given number of minutes and snaps to nearest 5 minutes func (t *TimestampEditor) adjustTime(minutes int) { // Add the minutes t.timestamp = t.timestamp.Add(time.Duration(minutes) * time.Minute) // Snap to nearest 5 minutes minute := t.timestamp.Minute() remainder := minute % 5 if remainder != 0 { if remainder < 3 { // Round down t.timestamp = t.timestamp.Add(-time.Duration(remainder) * time.Minute) } else { // Round up t.timestamp = t.timestamp.Add(time.Duration(5-remainder) * time.Minute) } } // Zero out seconds and nanoseconds t.timestamp = time.Date( t.timestamp.Year(), t.timestamp.Month(), t.timestamp.Day(), t.timestamp.Hour(), t.timestamp.Minute(), 0, 0, t.timestamp.Location(), ) } // adjustDate adjusts the date by the given number of days func (t *TimestampEditor) adjustDate(days int) { t.timestamp = t.timestamp.AddDate(0, 0, days) } // View renders the timestamp editor func (t *TimestampEditor) View() string { var sb strings.Builder styles := t.getStyles() // Render title if present if t.title != "" { sb.WriteString(styles.title.Render(t.title)) if t.err != nil { sb.WriteString(styles.errorIndicator.String()) } sb.WriteString("\n") } // Render description if present if t.description != "" { sb.WriteString(styles.description.Render(t.description)) sb.WriteString("\n") } // Render the time and date fields side by side var timeStr, dateStr string if t.isEmpty { timeStr = "--:--" dateStr = "--- ----------" } else { timeStr = t.timestamp.Format("15:04") dateStr = t.timestamp.Format("Mon 2006-01-02") } var timeField, dateField string if t.currentField == TimeField { timeField = styles.selectedField.Render(timeStr) dateField = styles.unselectedField.Render(dateStr) } else { timeField = styles.unselectedField.Render(timeStr) dateField = styles.selectedField.Render(dateStr) } fieldsRow := lipgloss.JoinHorizontal(lipgloss.Top, timeField, " ", dateField) sb.WriteString(fieldsRow) return styles.base.Render(sb.String()) } // getHelpText returns the help text based on the current field func (t *TimestampEditor) getHelpText() string { if t.currentField == TimeField { return "h/l: switch field • j/k: ±5min • J/K: ±30min • d: remove" } return "h/l: switch field • j/k: ±1day • J/K: ±1week • d: remove" } // Styles for the timestamp editor type timestampEditorStyles struct { base lipgloss.Style title lipgloss.Style description lipgloss.Style errorIndicator lipgloss.Style selectedField lipgloss.Style unselectedField lipgloss.Style help lipgloss.Style } // getStyles returns the styles for the timestamp editor func (t *TimestampEditor) getStyles() timestampEditorStyles { theme := t.common.Styles.Form var styles timestampEditorStyles if t.focused { styles.base = lipgloss.NewStyle() styles.title = theme.Focused.Title styles.description = theme.Focused.Description styles.errorIndicator = theme.Focused.ErrorIndicator styles.selectedField = lipgloss.NewStyle(). Bold(true). Padding(0, 2). Border(lipgloss.RoundedBorder()). BorderForeground(lipgloss.Color("12")) styles.unselectedField = lipgloss.NewStyle(). Padding(0, 2). Border(lipgloss.RoundedBorder()). BorderForeground(lipgloss.Color("8")) styles.help = lipgloss.NewStyle(). Foreground(lipgloss.Color("8")). Italic(true) } else { styles.base = lipgloss.NewStyle() styles.title = theme.Blurred.Title styles.description = theme.Blurred.Description styles.errorIndicator = theme.Blurred.ErrorIndicator styles.selectedField = lipgloss.NewStyle(). Padding(0, 2). Border(lipgloss.RoundedBorder()). BorderForeground(lipgloss.Color("8")) styles.unselectedField = lipgloss.NewStyle(). Padding(0, 2). Border(lipgloss.HiddenBorder()) styles.help = lipgloss.NewStyle(). Foreground(lipgloss.Color("8")) } return styles } // Skip returns whether the timestamp editor should be skipped func (t *TimestampEditor) Skip() bool { return false } // Zoom returns whether the timestamp editor should be zoomed func (t *TimestampEditor) Zoom() bool { return false } // KeyBinds returns the key bindings for the timestamp editor func (t *TimestampEditor) KeyBinds() []key.Binding { return []key.Binding{ t.common.Keymap.Left, t.common.Keymap.Right, t.common.Keymap.Up, t.common.Keymap.Down, } }