internal/household provides Today, WeekStart, WeekDays, WeekLabel, PeriodKey and LocalTime, all in the household IANA zone and never the process zone. Weeks are ISO Monday to Sunday; calendar arithmetic runs on UTC midnights so DST never shifts dates. LocalTime reads a skipped or repeated wall time with the UTC offset in effect before the transition: a spring-forward gap moves forward by its length, and a fall-back repeat is its first occurrence. store gains LocalDateOf and LocalDate.Date so household builds dates without reparsing, and LocalDate.Value rejects years outside 0000-9999. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
135 lines
3.9 KiB
Go
135 lines
3.9 KiB
Go
package store
|
||
|
||
import (
|
||
"database/sql/driver"
|
||
"errors"
|
||
"fmt"
|
||
"time"
|
||
)
|
||
|
||
// timestampLayout is RFC 3339 in UTC with fixed millisecond precision, so
|
||
// stored timestamps sort lexicographically in time order.
|
||
const timestampLayout = "2006-01-02T15:04:05.000Z"
|
||
|
||
// Timestamp is an instant stored as UTC RFC 3339 TEXT with millisecond
|
||
// precision. Sub-millisecond precision is dropped on write. Use
|
||
// sql.Null[Timestamp] for a nullable column.
|
||
type Timestamp struct {
|
||
time.Time
|
||
}
|
||
|
||
// Now returns the current instant as a Timestamp, in UTC at millisecond
|
||
// precision, so it equals its stored form.
|
||
func Now() Timestamp {
|
||
return Timestamp{time.Now().UTC().Truncate(time.Millisecond)}
|
||
}
|
||
|
||
// String returns the stored TEXT form.
|
||
func (t Timestamp) String() string {
|
||
return t.UTC().Format(timestampLayout)
|
||
}
|
||
|
||
// Value implements driver.Valuer. The zero Timestamp is an error, as is a UTC
|
||
// year outside 0000–9999, which the four-digit stored form cannot represent.
|
||
func (t Timestamp) Value() (driver.Value, error) {
|
||
if t.IsZero() {
|
||
return nil, errors.New("store: zero Timestamp")
|
||
}
|
||
if y := t.UTC().Year(); y < 0 || y > 9999 {
|
||
return nil, fmt.Errorf("store: Timestamp year %d outside 0000-9999", y)
|
||
}
|
||
return t.String(), nil
|
||
}
|
||
|
||
// Scan implements sql.Scanner. It accepts only TEXT in the stored form, and
|
||
// rejects the zero instant, which Value refuses to write.
|
||
func (t *Timestamp) Scan(src any) error {
|
||
s, ok := src.(string)
|
||
if !ok {
|
||
return fmt.Errorf("store: scan Timestamp: want TEXT, got %T", src)
|
||
}
|
||
parsed, err := time.Parse(timestampLayout, s)
|
||
if err != nil {
|
||
return fmt.Errorf("store: scan Timestamp %q: %w", s, err)
|
||
}
|
||
if parsed.Format(timestampLayout) != s {
|
||
return fmt.Errorf("store: scan Timestamp %q: want %s", s, timestampLayout)
|
||
}
|
||
if parsed.IsZero() {
|
||
return fmt.Errorf("store: scan Timestamp %q: zero Timestamp", s)
|
||
}
|
||
t.Time = parsed
|
||
return nil
|
||
}
|
||
|
||
// localDateLayout is the household-local calendar date form.
|
||
const localDateLayout = "2006-01-02"
|
||
|
||
// LocalDate is a household-local calendar date with no time or zone, stored
|
||
// as YYYY-MM-DD TEXT. The zero LocalDate is invalid and cannot be stored.
|
||
type LocalDate struct {
|
||
year int
|
||
month time.Month
|
||
day int
|
||
}
|
||
|
||
// ParseLocalDate parses a YYYY-MM-DD date, rejecting any other form and any
|
||
// date that does not exist on the calendar.
|
||
func ParseLocalDate(s string) (LocalDate, error) {
|
||
t, err := time.Parse(localDateLayout, s)
|
||
if err != nil {
|
||
return LocalDate{}, fmt.Errorf("store: invalid date %q: %w", s, err)
|
||
}
|
||
if t.Format(localDateLayout) != s {
|
||
return LocalDate{}, fmt.Errorf("store: invalid date %q: want YYYY-MM-DD", s)
|
||
}
|
||
return LocalDate{t.Year(), t.Month(), t.Day()}, nil
|
||
}
|
||
|
||
// LocalDateOf returns the calendar date of t in t's own location.
|
||
func LocalDateOf(t time.Time) LocalDate {
|
||
y, m, d := t.Date()
|
||
return LocalDate{y, m, d}
|
||
}
|
||
|
||
// Date returns the year, month and day of d.
|
||
func (d LocalDate) Date() (year int, month time.Month, day int) {
|
||
return d.year, d.month, d.day
|
||
}
|
||
|
||
// String returns the YYYY-MM-DD form.
|
||
func (d LocalDate) String() string {
|
||
return fmt.Sprintf("%04d-%02d-%02d", d.year, int(d.month), d.day)
|
||
}
|
||
|
||
// IsZero reports whether d is the zero LocalDate.
|
||
func (d LocalDate) IsZero() bool {
|
||
return d == LocalDate{}
|
||
}
|
||
|
||
// Value implements driver.Valuer. The zero LocalDate is an error, as is a
|
||
// year outside 0000–9999, which the four-digit stored form cannot represent.
|
||
func (d LocalDate) Value() (driver.Value, error) {
|
||
if d.IsZero() {
|
||
return nil, errors.New("store: zero LocalDate")
|
||
}
|
||
if d.year < 0 || d.year > 9999 {
|
||
return nil, fmt.Errorf("store: LocalDate year %d outside 0000-9999", d.year)
|
||
}
|
||
return d.String(), nil
|
||
}
|
||
|
||
// Scan implements sql.Scanner. It accepts only YYYY-MM-DD TEXT.
|
||
func (d *LocalDate) Scan(src any) error {
|
||
s, ok := src.(string)
|
||
if !ok {
|
||
return fmt.Errorf("store: scan LocalDate: want TEXT, got %T", src)
|
||
}
|
||
parsed, err := ParseLocalDate(s)
|
||
if err != nil {
|
||
return fmt.Errorf("store: scan LocalDate: %w", err)
|
||
}
|
||
*d = parsed
|
||
return nil
|
||
}
|