fix: add configurable date format in settings (#665)

Mohith32 created

Change summary

config/config.go      | 81 +++++++++++++++++++++++++++++++++++++++++++++
config/config_test.go | 47 ++++++++++++++++++++++++++
main.go               |  1 
tui/folder_inbox.go   |  7 +++
tui/inbox.go          | 25 ++++++++++++-
5 files changed, 158 insertions(+), 3 deletions(-)

Detailed changes

config/config.go 🔗

@@ -5,6 +5,7 @@ import (
 	"fmt"
 	"os"
 	"path/filepath"
+	"strings"
 
 	"github.com/google/uuid"
 	"github.com/zalando/go-keyring"
@@ -12,6 +13,21 @@ import (
 
 const keyringServiceName = "matcha-email-client"
 
+// Date format presets use human-readable tokens. Supported tokens:
+//
+//	YYYY (4-digit year), YY (2-digit year)
+//	MM   (month, or minutes when following an hour token + colon)
+//	mm   (minutes, explicit)
+//	DD   (day)
+//	HH   (24-hour), hh (12-hour, zero-padded)
+//	SS, ss (seconds)
+//	AM, PM (meridiem marker)
+const (
+	DateFormatISO = "YYYY-MM-DD HH:MM"
+	DateFormatUS  = "MM/DD/YYYY hh:MM AM"
+	DateFormatEU  = "DD/MM/YYYY HH:MM"
+)
+
 // Account stores the configuration for a single email account.
 type Account struct {
 	ID              string `json:"id"`
@@ -69,6 +85,67 @@ type Config struct {
 	DisableNotifications bool          `json:"disable_notifications,omitempty"`
 	Theme                string        `json:"theme,omitempty"`
 	MailingLists         []MailingList `json:"mailing_lists,omitempty"`
+	DateFormat           string        `json:"date_format,omitempty"`
+}
+
+// GetDateFormat returns the Go time reference layout translated from the
+// user's configured human-readable format. Defaults to EU when unset.
+func (c *Config) GetDateFormat() string {
+	f := c.DateFormat
+	if f == "" {
+		f = DateFormatEU
+	}
+	return translateDateFormat(f)
+}
+
+// translateDateFormat converts a human-readable format string (e.g.
+// "DD/MM/YYYY HH:MM") into a Go reference-time layout usable by
+// time.Format. MM is disambiguated by context: when it directly follows
+// an hour token plus ":", it maps to minutes; otherwise to month.
+func translateDateFormat(f string) string {
+	var b strings.Builder
+	i := 0
+	for i < len(f) {
+		rest := f[i:]
+		switch {
+		case strings.HasPrefix(rest, "YYYY"):
+			b.WriteString("2006")
+			i += 4
+		case strings.HasPrefix(rest, "YY"):
+			b.WriteString("06")
+			i += 2
+		case strings.HasPrefix(rest, "DD"):
+			b.WriteString("02")
+			i += 2
+		case strings.HasPrefix(rest, "HH"):
+			b.WriteString("15")
+			i += 2
+		case strings.HasPrefix(rest, "hh"):
+			b.WriteString("03")
+			i += 2
+		case strings.HasPrefix(rest, "mm"):
+			b.WriteString("04")
+			i += 2
+		case strings.HasPrefix(rest, "SS"), strings.HasPrefix(rest, "ss"):
+			b.WriteString("05")
+			i += 2
+		case strings.HasPrefix(rest, "MM"):
+			cur := b.String()
+			if strings.HasSuffix(cur, "15:") || strings.HasSuffix(cur, "03:") {
+				b.WriteString("04")
+			} else {
+				b.WriteString("01")
+			}
+			i += 2
+		case strings.HasPrefix(rest, "AM"), strings.HasPrefix(rest, "PM"):
+			b.WriteString("PM")
+			i += 2
+		default:
+			b.WriteByte(f[i])
+			i++
+		}
+	}
+	return b.String()
 }
 
 // GetIMAPServer returns the IMAP server address for the account.
@@ -291,6 +368,7 @@ type secureDiskConfig struct {
 	DisableNotifications bool                `json:"disable_notifications,omitempty"`
 	Theme                string              `json:"theme,omitempty"`
 	MailingLists         []MailingList       `json:"mailing_lists,omitempty"`
+	DateFormat           string              `json:"date_format,omitempty"`
 }
 
 // SaveConfig saves the given configuration to the config file and passwords to the keyring.
@@ -326,6 +404,7 @@ func SaveConfig(config *Config) error {
 			DisableNotifications: config.DisableNotifications,
 			Theme:                config.Theme,
 			MailingLists:         config.MailingLists,
+			DateFormat:           config.DateFormat,
 		}
 		for _, acc := range config.Accounts {
 			sdc.Accounts = append(sdc.Accounts, secureDiskAccount{
@@ -417,6 +496,7 @@ func LoadConfig() (*Config, error) {
 		DisableNotifications bool          `json:"disable_notifications,omitempty"`
 		Theme                string        `json:"theme,omitempty"`
 		MailingLists         []MailingList `json:"mailing_lists,omitempty"`
+		DateFormat           string        `json:"date_format,omitempty"`
 	}
 
 	var raw diskConfig
@@ -449,6 +529,7 @@ func LoadConfig() (*Config, error) {
 	config.DisableNotifications = raw.DisableNotifications
 	config.Theme = raw.Theme
 	config.MailingLists = raw.MailingLists
+	config.DateFormat = raw.DateFormat
 	for _, rawAcc := range raw.Accounts {
 		acc := Account{
 			ID:                 rawAcc.ID,

config/config_test.go 🔗

@@ -3,6 +3,7 @@ package config
 import (
 	"reflect"
 	"testing"
+	"time"
 
 	"github.com/zalando/go-keyring"
 )
@@ -312,3 +313,49 @@ func TestAccountSendIdentityHelpers(t *testing.T) {
 		}
 	})
 }
+
+func TestTranslateDateFormat(t *testing.T) {
+	testCases := []struct {
+		name   string
+		input  string
+		want   string
+		sample string // expected output of time.Format for a fixed sample instant
+	}{
+		{"EU preset", DateFormatEU, "02/01/2006 15:04", "17/04/2026 09:05"},
+		{"US preset", DateFormatUS, "01/02/2006 03:04 PM", "04/17/2026 09:05 AM"},
+		{"ISO preset", DateFormatISO, "2006-01-02 15:04", "2026-04-17 09:05"},
+		{"seconds", "HH:MM:SS", "15:04:05", "09:05:00"},
+		{"explicit minutes", "YYYY-MM-DD HH:mm", "2006-01-02 15:04", "2026-04-17 09:05"},
+		{"2-digit year", "DD/MM/YY", "02/01/06", "17/04/26"},
+		{"literal passthrough", "some text", "some text", "some text"},
+	}
+
+	sample := time.Date(2026, 4, 17, 9, 5, 0, 0, time.UTC)
+	for _, tc := range testCases {
+		t.Run(tc.name, func(t *testing.T) {
+			got := translateDateFormat(tc.input)
+			if got != tc.want {
+				t.Fatalf("translateDateFormat(%q) = %q, want %q", tc.input, got, tc.want)
+			}
+			if rendered := sample.Format(got); rendered != tc.sample {
+				t.Fatalf("sample.Format(%q) = %q, want %q", got, rendered, tc.sample)
+			}
+		})
+	}
+}
+
+func TestConfigGetDateFormatDefault(t *testing.T) {
+	c := &Config{}
+	got := c.GetDateFormat()
+	want := translateDateFormat(DateFormatEU)
+	if got != want {
+		t.Fatalf("GetDateFormat() with empty DateFormat = %q, want %q", got, want)
+	}
+}
+
+func TestConfigGetDateFormatCustom(t *testing.T) {
+	c := &Config{DateFormat: "DD/MM/YYYY HH:MM"}
+	if got, want := c.GetDateFormat(), "02/01/2006 15:04"; got != want {
+		t.Fatalf("GetDateFormat() = %q, want %q", got, want)
+	}
+}

main.go 🔗

@@ -381,6 +381,7 @@ func (m *mainModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 			cachedFolders = []string{"INBOX"}
 		}
 		m.folderInbox = tui.NewFolderInbox(cachedFolders, m.config.Accounts)
+		m.folderInbox.SetDateFormat(m.config.GetDateFormat())
 		// Use cached INBOX emails for instant display (memory first, then disk)
 		if cached, ok := m.folderEmails["INBOX"]; ok && len(cached) > 0 {
 			m.folderInbox.SetEmails(cached, m.config.Accounts)

tui/folder_inbox.go 🔗

@@ -96,6 +96,13 @@ func sortFolders(folders []string) []string {
 	return sorted
 }
 
+// SetDateFormat propagates the configured date layout to the inner inbox.
+func (m *FolderInbox) SetDateFormat(layout string) {
+	if m.inbox != nil {
+		m.inbox.SetDateFormat(layout)
+	}
+}
+
 // NewFolderInbox creates a new FolderInbox with the given folders and accounts.
 func NewFolderInbox(folders []string, accounts []config.Account) *FolderInbox {
 	folders = sortFolders(folders)

tui/inbox.go 🔗

@@ -74,7 +74,11 @@ func (d itemDelegate) Render(w io.Writer, m list.Model, index int, listItem list
 	}
 
 	// Format and right-align date
-	dateStr := formatRelativeDate(i.date)
+	layout := ""
+	if d.inbox != nil {
+		layout = d.inbox.dateFormat
+	}
+	dateStr := formatRelativeDate(i.date, layout)
 	styledDate := dateStyle.Render(dateStr)
 	if i.isRead {
 		styledDate = readEmailStyle.Render(dateStr)
@@ -159,8 +163,9 @@ func (d itemDelegate) Render(w io.Writer, m list.Model, index int, listItem list
 }
 
 // formatRelativeDate formats a time as relative if within the last week,
-// otherwise as an absolute date.
-func formatRelativeDate(t time.Time) string {
+// otherwise as an absolute date using the caller-supplied Go time layout.
+// When layout is empty, falls back to the built-in short/long defaults.
+func formatRelativeDate(t time.Time, layout string) string {
 	if t.IsZero() {
 		return ""
 	}
@@ -189,6 +194,9 @@ func formatRelativeDate(t time.Time) string {
 		}
 		return fmt.Sprintf("%d days ago", days)
 	default:
+		if layout != "" {
+			return t.Format(layout)
+		}
 		if t.Year() == now.Year() {
 			return t.Format("Jan 02")
 		}
@@ -269,6 +277,17 @@ type Inbox struct {
 	visualAnchor   int               // Index where visual selection started
 	selectedUIDs   map[uint32]string // map[uid]accountID for selected emails
 	selectionOrder []uint32          // Ordered list of UIDs for display
+
+	// dateFormat is the Go reference-time layout used for absolute dates
+	// older than a week. When empty, the built-in defaults apply.
+	dateFormat string
+}
+
+// SetDateFormat configures the Go time layout used to render absolute
+// dates in the email list. Pass the value returned by
+// config.Config.GetDateFormat.
+func (m *Inbox) SetDateFormat(layout string) {
+	m.dateFormat = layout
 }
 
 func NewInbox(emails []fetcher.Email, accounts []config.Account) *Inbox {