diff --git a/common/styles.go b/common/styles.go index 6b349e3..3e149c7 100644 --- a/common/styles.go +++ b/common/styles.go @@ -1,25 +1,29 @@ package common import ( - "errors" "log/slog" - "math" "strconv" "strings" // "github.com/charmbracelet/bubbles/table" + "github.com/charmbracelet/huh" "github.com/charmbracelet/lipgloss" - "tasksquire/components/table" "tasksquire/taskwarrior" ) +type TableStyle struct { + Header lipgloss.Style + Cell lipgloss.Style + Selected lipgloss.Style +} + type Styles struct { - Main lipgloss.Style + Base lipgloss.Style Form *huh.Theme - TableStyle table.Styles + TableStyle TableStyle Active lipgloss.Style Alternate lipgloss.Style @@ -72,13 +76,13 @@ type Styles struct { func NewStyles(config *taskwarrior.TWConfig) *Styles { styles := parseColors(config.GetConfig()) - styles.Main = lipgloss.NewStyle() + styles.Base = lipgloss.NewStyle() - styles.TableStyle = table.Styles{ + styles.TableStyle = TableStyle{ // Header: lipgloss.NewStyle().Bold(true).Padding(0, 1).BorderBottom(true), Cell: lipgloss.NewStyle().Padding(0, 1, 0, 0), Header: lipgloss.NewStyle().Bold(true).Padding(0, 1, 0, 0).Underline(true), - Selected: lipgloss.NewStyle().Foreground(styles.Active.GetForeground()).Background(styles.Active.GetBackground()).Bold(true).Reverse(true), + Selected: lipgloss.NewStyle().Bold(true).Reverse(true), } formTheme := huh.ThemeBase() @@ -104,104 +108,102 @@ func parseColors(config map[string]string) *Styles { for key, value := range config { if strings.HasPrefix(key, "color.") { - if value != "" { - color := strings.Split(key, ".")[1] - switch color { - case "active": - styles.Active = parseColorString(value) - case "alternate": - styles.Alternate = parseColorString(value) - case "blocked": - styles.Blocked = parseColorString(value) - case "blocking": - styles.Blocking = parseColorString(value) - case "burndown.done": - styles.BurndownDone = parseColorString(value) - case "burndown.pending": - styles.BurndownPending = parseColorString(value) - case "burndown.started": - styles.BurndownStarted = parseColorString(value) - case "calendar.due": - styles.CalendarDue = parseColorString(value) - case "calendar.due.today": - styles.CalendarDueToday = parseColorString(value) - case "calendar.holiday": - styles.CalendarHoliday = parseColorString(value) - case "calendar.overdue": - styles.CalendarOverdue = parseColorString(value) - case "calendar.scheduled": - styles.CalendarScheduled = parseColorString(value) - case "calendar.today": - styles.CalendarToday = parseColorString(value) - case "calendar.weekend": - styles.CalendarWeekend = parseColorString(value) - case "calendar.weeknumber": - styles.CalendarWeeknumber = parseColorString(value) - case "completed": - styles.Completed = parseColorString(value) - case "debug": - styles.Debug = parseColorString(value) - case "deleted": - styles.Deleted = parseColorString(value) - case "due": - styles.Due = parseColorString(value) - case "due.today": - styles.DueToday = parseColorString(value) - case "error": - styles.Error = parseColorString(value) - case "footnote": - styles.Footnote = parseColorString(value) - case "header": - styles.Header = parseColorString(value) - case "history.add": - styles.HistoryAdd = parseColorString(value) - case "history.delete": - styles.HistoryDelete = parseColorString(value) - case "history.done": - styles.HistoryDone = parseColorString(value) - case "label": - styles.Label = parseColorString(value) - case "label.sort": - styles.LabelSort = parseColorString(value) - case "overdue": - styles.Overdue = parseColorString(value) - case "project.none": - styles.ProjectNone = parseColorString(value) - case "recurring": - styles.Recurring = parseColorString(value) - case "scheduled": - styles.Scheduled = parseColorString(value) - case "summary.background": - styles.SummaryBackground = parseColorString(value) - case "summary.bar": - styles.SummaryBar = parseColorString(value) - case "sync.added": - styles.SyncAdded = parseColorString(value) - case "sync.changed": - styles.SyncChanged = parseColorString(value) - case "sync.rejected": - styles.SyncRejected = parseColorString(value) - case "tag.next": - styles.TagNext = parseColorString(value) - case "tag.none": - styles.TagNone = parseColorString(value) - case "tagged": - styles.Tagged = parseColorString(value) - case "uda.priority.H": - styles.UdaPriorityH = parseColorString(value) - case "uda.priority.L": - styles.UdaPriorityL = parseColorString(value) - case "uda.priority.M": - styles.UdaPriorityM = parseColorString(value) - case "undo.after": - styles.UndoAfter = parseColorString(value) - case "undo.before": - styles.UndoBefore = parseColorString(value) - case "until": - styles.Until = parseColorString(value) - case "warning": - styles.Warning = parseColorString(value) - } + _, colorValue, _ := strings.Cut(key, ".") + switch colorValue { + case "active": + styles.Active = parseColorString(value) + case "alternate": + styles.Alternate = parseColorString(value) + case "blocked": + styles.Blocked = parseColorString(value) + case "blocking": + styles.Blocking = parseColorString(value) + case "burndown.done": + styles.BurndownDone = parseColorString(value) + case "burndown.pending": + styles.BurndownPending = parseColorString(value) + case "burndown.started": + styles.BurndownStarted = parseColorString(value) + case "calendar.due": + styles.CalendarDue = parseColorString(value) + case "calendar.due.today": + styles.CalendarDueToday = parseColorString(value) + case "calendar.holiday": + styles.CalendarHoliday = parseColorString(value) + case "calendar.overdue": + styles.CalendarOverdue = parseColorString(value) + case "calendar.scheduled": + styles.CalendarScheduled = parseColorString(value) + case "calendar.today": + styles.CalendarToday = parseColorString(value) + case "calendar.weekend": + styles.CalendarWeekend = parseColorString(value) + case "calendar.weeknumber": + styles.CalendarWeeknumber = parseColorString(value) + case "completed": + styles.Completed = parseColorString(value) + case "debug": + styles.Debug = parseColorString(value) + case "deleted": + styles.Deleted = parseColorString(value) + case "due": + styles.Due = parseColorString(value) + case "due.today": + styles.DueToday = parseColorString(value) + case "error": + styles.Error = parseColorString(value) + case "footnote": + styles.Footnote = parseColorString(value) + case "header": + styles.Header = parseColorString(value) + case "history.add": + styles.HistoryAdd = parseColorString(value) + case "history.delete": + styles.HistoryDelete = parseColorString(value) + case "history.done": + styles.HistoryDone = parseColorString(value) + case "label": + styles.Label = parseColorString(value) + case "label.sort": + styles.LabelSort = parseColorString(value) + case "overdue": + styles.Overdue = parseColorString(value) + case "project.none": + styles.ProjectNone = parseColorString(value) + case "recurring": + styles.Recurring = parseColorString(value) + case "scheduled": + styles.Scheduled = parseColorString(value) + case "summary.background": + styles.SummaryBackground = parseColorString(value) + case "summary.bar": + styles.SummaryBar = parseColorString(value) + case "sync.added": + styles.SyncAdded = parseColorString(value) + case "sync.changed": + styles.SyncChanged = parseColorString(value) + case "sync.rejected": + styles.SyncRejected = parseColorString(value) + case "tag.next": + styles.TagNext = parseColorString(value) + case "tag.none": + styles.TagNone = parseColorString(value) + case "tagged": + styles.Tagged = parseColorString(value) + case "uda.priority.H": + styles.UdaPriorityH = parseColorString(value) + case "uda.priority.L": + styles.UdaPriorityL = parseColorString(value) + case "uda.priority.M": + styles.UdaPriorityM = parseColorString(value) + case "undo.after": + styles.UndoAfter = parseColorString(value) + case "undo.before": + styles.UndoBefore = parseColorString(value) + case "until": + styles.Until = parseColorString(value) + case "warning": + styles.Warning = parseColorString(value) } } } @@ -211,6 +213,10 @@ func parseColors(config map[string]string) *Styles { func parseColorString(color string) lipgloss.Style { style := lipgloss.NewStyle() + if color == "" { + return style + } + if strings.Contains(color, "on") { fgbg := strings.Split(color, "on") fg := strings.TrimSpace(fgbg[0]) @@ -230,12 +236,7 @@ func parseColorString(color string) lipgloss.Style { func parseColor(color string) lipgloss.Color { if strings.HasPrefix(color, "rgb") { - rgb, err := parseRGBString(strings.TrimPrefix(color, "rgb")) - if err != nil { - slog.Error("Invalid RGB color format") - return lipgloss.Color("0") - } - return lipgloss.Color(rgbToAnsi(rgb)) + return lipgloss.Color(convertRgbToAnsi(strings.TrimPrefix(color, "rgb"))) } if strings.HasPrefix(color, "color") { return lipgloss.Color(strings.TrimPrefix(color, "color")) @@ -256,36 +257,33 @@ func parseColor(color string) lipgloss.Color { return lipgloss.Color("0") } -type RGB struct { - r int - g int - b int -} - -func parseRGBString(rgbString string) (RGB, error) { +func convertRgbToAnsi(rgbString string) string { var err error - rgb := RGB{} if len(rgbString) != 3 { - return rgb, errors.New("invalid RGB format") + slog.Error("Invalid RGB color format") + return "" } - rgb.r, err = strconv.Atoi(string(rgbString[0])) + r, err := strconv.Atoi(string(rgbString[0])) if err != nil { - return rgb, errors.New("invalid value for R") + slog.Error("Invalid value for R") + return "" } - rgb.g, err = strconv.Atoi(string(rgbString[1])) + g, err := strconv.Atoi(string(rgbString[1])) if err != nil { - return rgb, errors.New("invalid value for G") + slog.Error("Invalid value for G") + return "" } - rgb.b, err = strconv.Atoi(string(rgbString[2])) + b, err := strconv.Atoi(string(rgbString[2])) if err != nil { - return rgb, errors.New("invalid value for B") + slog.Error("Invalid value for B") + return "" } - return rgb, nil + return strconv.Itoa(16 + (36 * r) + (6 * g) + b) } var colorStrings = map[string]int{ @@ -306,79 +304,3 @@ var colorStrings = map[string]int{ "bright cyan": 14, "bright white": 15, } - -var baseColors = []RGB{ - {0, 0, 0}, // Black - {128, 0, 0}, // Red - {0, 128, 0}, // Green - {128, 128, 0}, // Yellow - {0, 0, 128}, // Blue - {128, 0, 128}, // Magenta - {0, 128, 128}, // Cyan - {192, 192, 192}, // White -} - -var highIntensityColors = []RGB{ - {128, 128, 128}, // Bright Black (Gray) - {255, 0, 0}, // Bright Red - {0, 255, 0}, // Bright Green - {255, 255, 0}, // Bright Yellow - {0, 0, 255}, // Bright Blue - {255, 0, 255}, // Bright Magenta - {0, 255, 255}, // Bright Cyan - {255, 255, 255}, // Bright White -} - -// Calculate the Euclidean distance between two colors -func colorDistance(c1, c2 RGB) float64 { - return math.Sqrt(float64((c1.r-c2.r)*(c1.r-c2.r) + (c1.g-c2.g)*(c1.g-c2.g) + (c1.b-c2.b)*(c1.b-c2.b))) -} - -// Convert RGB to the nearest ANSI color code -func rgbToAnsi(rgb RGB) string { - // Check standard and high-intensity colors - allColors := append(baseColors, highIntensityColors...) - bestIndex := 0 - minDist := colorDistance(rgb, allColors[0]) - - for i := 1; i < len(allColors); i++ { - dist := colorDistance(rgb, allColors[i]) - if dist < minDist { - bestIndex = i - minDist = dist - } - } - - if bestIndex < 8 { - return strconv.Itoa(bestIndex) - } else if bestIndex < 16 { - return strconv.Itoa(bestIndex + 8) - } - - // Check 6x6x6 color cube - for i := 0; i < 216; i++ { - cubeColor := RGB{ - (rgb.r / 51) * 51, - (rgb.g / 51) * 51, - (rgb.b / 51) * 51, - } - dist := colorDistance(rgb, cubeColor) - if dist < minDist { - bestIndex = i + 16 - minDist = dist - } - } - - // Check grayscale colors - for i := 0; i < 24; i++ { - gray := i*10 + 8 - grayColor := RGB{gray, gray, gray} - dist := colorDistance(rgb, grayColor) - if dist < minDist { - bestIndex = i + 232 - minDist = dist - } - } - - return strconv.Itoa(bestIndex) -} diff --git a/components/table/table.go b/components/table/table.go index d68de47..61a9dea 100644 --- a/components/table/table.go +++ b/components/table/table.go @@ -2,6 +2,7 @@ package table import ( "strings" + "time" "github.com/charmbracelet/bubbles/key" "github.com/charmbracelet/bubbles/viewport" @@ -23,7 +24,7 @@ type Model struct { rowStyles []lipgloss.Style cursor int focus bool - styles Styles + styles common.TableStyle styleFunc StyleFunc viewport viewport.Model @@ -108,25 +109,8 @@ func DefaultKeyMap() KeyMap { } } -// Styles contains style definitions for this list component. By default, these -// values are generated by DefaultStyles. -type Styles struct { - Header lipgloss.Style - Cell lipgloss.Style - Selected lipgloss.Style -} - -// DefaultStyles returns a set of default style definitions for this table. -func DefaultStyles() Styles { - return Styles{ - Selected: lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("212")), - Header: lipgloss.NewStyle().Bold(true).Padding(0, 1), - Cell: lipgloss.NewStyle().Padding(0, 1), - } -} - // SetStyles sets the table styles. -func (m *Model) SetStyles(s Styles) { +func (m *Model) SetStyles(s common.TableStyle) { m.styles = s m.UpdateViewport() } @@ -139,11 +123,11 @@ type Option func(*Model) // New creates a new model for the table widget. func New(com *common.Common, opts ...Option) Model { m := Model{ + common: com, cursor: 0, viewport: viewport.New(0, 20), KeyMap: DefaultKeyMap(), - styles: DefaultStyles(), } for _, opt := range opts { @@ -158,9 +142,75 @@ func New(com *common.Common, opts ...Option) Model { return m } -func (m *Model) parseRowStyles() +// TODO: dynamically read rule.precedence.color +func (m *Model) parseRowStyles(rows taskwarrior.Tasks) []lipgloss.Style { + styles := make([]lipgloss.Style, len(rows)) + if len(rows) == 0 { + return styles + } + for i, task := range rows { + if task.Status == "deleted" { + styles[i] = m.common.Styles.Deleted.Inherit(m.styles.Cell).Margin(m.styles.Cell.GetMargin()).Padding(m.styles.Cell.GetPadding()) + continue + } + if task.Status == "completed" { + styles[i] = m.common.Styles.Completed.Inherit(m.styles.Cell).Margin(m.styles.Cell.GetMargin()).Padding(m.styles.Cell.GetPadding()) + continue + } + if task.Status == "pending" && task.Start != "" { + styles[i] = m.common.Styles.Active.Inherit(m.styles.Cell).Margin(m.styles.Cell.GetMargin()).Padding(m.styles.Cell.GetPadding()) + continue + } + // TODO: implement keyword + // TODO: implement tag + // TODO: implement project + if !task.GetDate("due").IsZero() && task.GetDate("due").Before(time.Now()) { + styles[i] = m.common.Styles.Overdue.Inherit(m.styles.Cell).Margin(m.styles.Cell.GetMargin()).Padding(m.styles.Cell.GetPadding()) + continue + } + if task.Scheduled != "" { + styles[i] = m.common.Styles.Scheduled.Inherit(m.styles.Cell).Margin(m.styles.Cell.GetMargin()).Padding(m.styles.Cell.GetPadding()) + continue + } + if !task.GetDate("due").IsZero() && task.GetDate("due").Truncate(24*time.Hour).Equal(time.Now().Truncate(24*time.Hour)) { + styles[i] = m.common.Styles.DueToday.Inherit(m.styles.Cell).Margin(m.styles.Cell.GetMargin()).Padding(m.styles.Cell.GetPadding()) + continue + } + if task.Due != "" { + styles[i] = m.common.Styles.Due.Inherit(m.styles.Cell).Margin(m.styles.Cell.GetMargin()).Padding(m.styles.Cell.GetPadding()) + continue + } + if len(task.Depends) > 0 { + styles[i] = m.common.Styles.Blocked.Inherit(m.styles.Cell).Margin(m.styles.Cell.GetMargin()).Padding(m.styles.Cell.GetPadding()) + continue + } + // TODO implement blocking + if task.Recur != "" { + styles[i] = m.common.Styles.Recurring.Inherit(m.styles.Cell).Margin(m.styles.Cell.GetMargin()).Padding(m.styles.Cell.GetPadding()) + continue + } + if len(task.Tags) > 0 { + styles[i] = m.common.Styles.Tagged.Inherit(m.styles.Cell).Margin(m.styles.Cell.GetMargin()).Padding(m.styles.Cell.GetPadding()) + taskIteration: + for _, tag := range task.Tags { + if tag == "next" { + styles[i] = m.common.Styles.TagNext.Inherit(m.styles.Cell).Margin(m.styles.Cell.GetMargin()).Padding(m.styles.Cell.GetPadding()) + break taskIteration + } + } + continue + } + // TODO implement uda + styles[i] = m.common.Styles.Base.Inherit(m.styles.Cell).Margin(m.styles.Cell.GetMargin()).Padding(m.styles.Cell.GetPadding()) + } + return styles +} func (m *Model) parseColumns(cols []Column) []Column { + if len(cols) == 0 { + return cols + } + for i, col := range cols { for _, task := range m.rows { col.ContentWidth = max(col.ContentWidth, lipgloss.Width(task.GetString(col.Name))) @@ -172,9 +222,8 @@ func (m *Model) parseColumns(cols []Column) []Column { nonZeroWidths := 0 descIndex := -1 for i, col := range cols { - col.Width = max(col.ContentWidth, lipgloss.Width(col.Title)) - - if col.Width > 0 { + if col.ContentWidth > 0 { + col.Width = max(col.ContentWidth, lipgloss.Width(col.Title)) nonZeroWidths++ } @@ -251,7 +300,7 @@ func WithFocused(f bool) Option { } // WithStyles sets the table styles. -func WithStyles(s Styles) Option { +func WithStyles(s common.TableStyle) Option { return func(m *Model) { m.styles = s } @@ -497,13 +546,16 @@ func (m *Model) renderRow(r int) string { continue } var cellStyle lipgloss.Style - if m.styleFunc != nil { - cellStyle = m.styleFunc(r, i, m.rows[r].GetString(col.Name)) - if r == m.cursor { - cellStyle.Inherit(m.styles.Selected) - } - } else { - cellStyle = m.rowStyle[r] + // if m.styleFunc != nil { + // cellStyle = m.styleFunc(r, i, m.rows[r].GetString(col.Name)) + // if r == m.cursor { + // cellStyle.Inherit(m.styles.Selected) + // } + // } else { + cellStyle = m.rowStyles[r] + // } + if r == m.cursor { + cellStyle = cellStyle.Inherit(m.styles.Selected) } style := lipgloss.NewStyle().Width(m.cols[i].Width).MaxWidth(m.cols[i].Width).Inline(true) diff --git a/pages/contextPicker.go b/pages/contextPicker.go index a98cd27..2924ed0 100644 --- a/pages/contextPicker.go +++ b/pages/contextPicker.go @@ -96,7 +96,7 @@ func (p *ContextPickerPage) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } func (p *ContextPickerPage) View() string { - return p.common.Styles.Main.Render(p.form.View()) + return p.common.Styles.Base.Render(p.form.View()) } func (p *ContextPickerPage) updateContextCmd() tea.Msg { diff --git a/pages/projectPicker.go b/pages/projectPicker.go index b804d5e..20bd453 100644 --- a/pages/projectPicker.go +++ b/pages/projectPicker.go @@ -92,7 +92,7 @@ func (p *ProjectPickerPage) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } func (p *ProjectPickerPage) View() string { - return p.common.Styles.Main.Render(p.form.View()) + return p.common.Styles.Base.Render(p.form.View()) } func (p *ProjectPickerPage) updateProjectCmd() tea.Msg { diff --git a/pages/report.go b/pages/report.go index b60c528..998d9a8 100644 --- a/pages/report.go +++ b/pages/report.go @@ -2,7 +2,6 @@ package pages import ( - "log/slog" "tasksquire/common" "tasksquire/components/table" "tasksquire/taskwarrior" @@ -34,15 +33,15 @@ func NewReportPage(com *common.Common, report *taskwarrior.Report) *ReportPage { activeReport: report, activeContext: com.TW.GetActiveContext(), activeProject: "", + taskTable: table.New(com), } } func (p *ReportPage) SetSize(width int, height int) { p.common.SetSize(width, height) - slog.Info("FramSize", "vert", p.common.Styles.Main.GetVerticalFrameSize(), "horz", p.common.Styles.Main.GetHorizontalFrameSize()) - p.taskTable.SetWidth(width - p.common.Styles.Main.GetVerticalFrameSize()) - p.taskTable.SetHeight(height - p.common.Styles.Main.GetHorizontalFrameSize()) + p.taskTable.SetWidth(width - p.common.Styles.Base.GetVerticalFrameSize()) + p.taskTable.SetHeight(height - p.common.Styles.Base.GetHorizontalFrameSize()) } func (p *ReportPage) Init() tea.Cmd { @@ -130,9 +129,9 @@ func (p *ReportPage) Update(msg tea.Msg) (tea.Model, tea.Cmd) { func (p *ReportPage) View() string { // return p.common.Styles.Main.Render(p.taskTable.View()) + "\n" if p.tasks == nil || len(p.tasks) == 0 { - return p.common.Styles.Main.Render("No tasks found") + return p.common.Styles.Base.Render("No tasks found") } - return p.common.Styles.Main.Render(p.taskTable.View()) + return p.taskTable.View() } func (p *ReportPage) populateTaskTable(tasks taskwarrior.Tasks) { @@ -151,11 +150,12 @@ func (p *ReportPage) populateTaskTable(tasks taskwarrior.Tasks) { } p.taskTable = table.New( + p.common, table.WithReport(p.activeReport), table.WithTasks(tasks), table.WithFocused(true), - table.WithWidth(p.common.Width()-p.common.Styles.Main.GetVerticalFrameSize()), - table.WithHeight(p.common.Height()-p.common.Styles.Main.GetHorizontalFrameSize()-10), + table.WithWidth(p.common.Width()-p.common.Styles.Base.GetVerticalFrameSize()), + table.WithHeight(p.common.Height()-p.common.Styles.Base.GetHorizontalFrameSize()-1), table.WithStyles(p.common.Styles.TableStyle), ) diff --git a/pages/reportPicker.go b/pages/reportPicker.go index 972b402..573b517 100644 --- a/pages/reportPicker.go +++ b/pages/reportPicker.go @@ -93,7 +93,7 @@ func (p *ReportPickerPage) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } func (p *ReportPickerPage) View() string { - return p.common.Styles.Main.Render(p.form.View()) + return p.common.Styles.Base.Render(p.form.View()) } func (p *ReportPickerPage) updateReportCmd() tea.Msg { diff --git a/pages/taskEditor.go b/pages/taskEditor.go index b8ae634..ae1ce24 100644 --- a/pages/taskEditor.go +++ b/pages/taskEditor.go @@ -314,7 +314,7 @@ func (s *StatusLine) View() string { var mode string switch s.mode { case ModeNormal: - mode = s.common.Styles.Main.Render("NORMAL") + mode = s.common.Styles.Base.Render("NORMAL") case ModeInsert: mode = s.common.Styles.Active.Inline(true).Render("INSERT") } diff --git a/taskwarrior/models.go b/taskwarrior/models.go index 070ea02..7ef4a0f 100644 --- a/taskwarrior/models.go +++ b/taskwarrior/models.go @@ -9,6 +9,10 @@ import ( "time" ) +const ( + dtformat = "20060102T150405Z" +) + type Annotation struct { Entry string `json:"entry,omitempty"` Description string `json:"description,omitempty"` @@ -184,6 +188,15 @@ func (t *Task) GetString(fieldWFormat string) string { } } +func (t *Task) GetDate(dateString string) time.Time { + dt, err := time.Parse(dtformat, dateString) + if err != nil { + slog.Error("Failed to parse time:", err) + return time.Time{} + } + return dt +} + type Tasks []*Task type Context struct { @@ -212,7 +225,6 @@ func formatDate(date string, format string) string { return "" } - dtformat := "20060102T150405Z" dt, err := time.Parse(dtformat, date) if err != nil { slog.Error("Failed to parse time:", err)