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.
187 lines
4.9 KiB
Go
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
|
|
}
|