Files
sm-bm-plugin/models/temporal.go
Jakub Zych 1046237771 feat: add the study data models and schema migrations
Courses, meetings, class sessions, deadlines, readings with per-user
progress, materials with tags and file attachments. Timestamps are
stored in UTC and held as Europe/Warsaw text so the admin text inputs
can write them.
2026-10-02 14:26:08 +02:00

187 lines
4.9 KiB
Go

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
}