package models import ( "database/sql/driver" "fmt" "strings" "time" _ "time/tzdata" // Europe/Warsaw must resolve on hosts without zoneinfo. ) // Location is the time zone the programme runs in. Timestamps are stored in // UTC (TIMESTAMPTZ) and shown in this zone; a date or time typed without an // offset is read in it. var Location = mustLoadLocation("Europe/Warsaw") func mustLoadLocation(name string) *time.Location { loc, err := time.LoadLocation(name) if err != nil { panic(fmt.Sprintf("jz.bm: load location %s: %v", name, err)) } return loc } // DateLayout is the textual form of a Date. const DateLayout = "2006-01-02" // dateTimeLayouts are the forms ParseDateTime accepts. RFC 3339 carries its // own offset; the others are wall-clock times in Location. var dateTimeLayouts = []string{ "2006-01-02T15:04", "2006-01-02 15:04", "2006-01-02T15:04:05", "2006-01-02 15:04:05", } // ParseDateTime reads an RFC 3339 timestamp, or a wall-clock time // ("2026-10-03 08:00") in Location. func ParseDateTime(s string) (time.Time, error) { s = strings.TrimSpace(s) if t, err := time.Parse(time.RFC3339, s); err == nil { return t, nil } for _, layout := range dateTimeLayouts { if t, err := time.ParseInLocation(layout, s, Location); err == nil { return t, nil } } return time.Time{}, fmt.Errorf("jz.bm: %q is not a date and time", s) } // ParseDate reads a YYYY-MM-DD calendar date. func ParseDate(s string) (time.Time, error) { t, err := time.ParseInLocation(DateLayout, strings.TrimSpace(s), Location) if err != nil { return time.Time{}, fmt.Errorf("jz.bm: %q is not a YYYY-MM-DD date", s) } return t, nil } // DateTime is a TIMESTAMPTZ column held as RFC 3339 text in Location // ("2026-10-03T08:00:00+02:00"). // // It is a string type rather than time.Time because the admin form has no // date picker and lagoon.Fill cannot write text into a time.Time field, while // cabana treats any struct-typed field as a relation. A string type is filled // from the form's text input, and Value checks and normalises it. // TODO: requires SummerCMS change (a datetime form field type and Fill // support for time.Time) to use time.Time here. type DateTime string // NewDateTime returns t as a DateTime in Location. func NewDateTime(t time.Time) DateTime { return DateTime(t.In(Location).Format(time.RFC3339)) } // Time parses the value. func (d DateTime) Time() (time.Time, error) { return ParseDateTime(string(d)) } // MustTime parses the value and returns the zero time when it does not parse. // Values read from the database always parse. func (d DateTime) MustTime() time.Time { t, _ := d.Time() return t } // Valid reports whether the value parses. func (d DateTime) Valid() bool { _, err := d.Time() return err == nil } // Value stores the instant in UTC. An empty value is SQL NULL. func (d DateTime) Value() (driver.Value, error) { if strings.TrimSpace(string(d)) == "" { return nil, nil } t, err := d.Time() if err != nil { return nil, err } return t.UTC(), nil } // Scan reads a TIMESTAMPTZ (or its text) and normalises it to Location. func (d *DateTime) Scan(src any) error { switch v := src.(type) { case nil: *d = "" case time.Time: *d = NewDateTime(v) case string: return d.scanText(v) case []byte: return d.scanText(string(v)) default: return fmt.Errorf("jz.bm: cannot scan %T into DateTime", src) } return nil } func (d *DateTime) scanText(s string) error { if strings.TrimSpace(s) == "" { *d = "" return nil } t, err := ParseDateTime(s) if err != nil { // Postgres text output: "2026-10-03 06:00:00+00". t, err = time.Parse("2006-01-02 15:04:05-07", s) if err != nil { return err } } *d = NewDateTime(t) return nil } // Date is a DATE column held as YYYY-MM-DD text, for the same reason as // DateTime. type Date string // NewDate returns the calendar date of t in Location. func NewDate(t time.Time) Date { return Date(t.In(Location).Format(DateLayout)) } // Time returns midnight of the date in Location. func (d Date) Time() (time.Time, error) { return ParseDate(string(d)) } // Valid reports whether the value is a YYYY-MM-DD date. func (d Date) Valid() bool { _, err := d.Time() return err == nil } // Value stores the date as text; Postgres casts it to DATE. An empty value is // SQL NULL. func (d Date) Value() (driver.Value, error) { if strings.TrimSpace(string(d)) == "" { return nil, nil } t, err := d.Time() if err != nil { return nil, err } return t.Format(DateLayout), nil } // Scan reads a DATE. The driver hands it over as midnight UTC, so the // calendar fields are taken as they are, without a zone conversion. func (d *Date) Scan(src any) error { switch v := src.(type) { case nil: *d = "" case time.Time: *d = Date(v.Format(DateLayout)) case string: *d = Date(strings.TrimSpace(v)) if len(*d) > len(DateLayout) { *d = (*d)[:len(DateLayout)] } case []byte: return d.Scan(string(v)) default: return fmt.Errorf("jz.bm: cannot scan %T into Date", src) } return nil }