oficina / calendar Public

Clone
git clone https://amadan.net/oficina/calendar

Plain git — no account needed to clone.

Download

Download this file

1package calendar
2
3import (
4 "fmt"
5 "strings"
6 "sync"
7 "time"
8)
9
10// The two layouts every stored local time uses. Fixed, not configurable:
11// they are a storage format, not a display one, and every display format
12// is a catalog key resolved at render time.
13const (
14 // LayoutDate is an all-day event's date. No zone, no instant: the
15 // 2nd of September is the 2nd of September wherever you read it.
16 LayoutDate = "2006-01-02"
17
18 // LayoutLocal is a timed event's wall-clock time, in its own zone.
19 // Seconds are deliberately absent — a calendar that stores them
20 // invites a UI that shows them.
21 LayoutLocal = "2006-01-02T15:04"
22)
23
24// Ruling 1: store what the person said.
25//
26// An event's record is the wall-clock time they typed plus the IANA
27// zone they meant it in. The UTC instant is DERIVED, an index for range
28// queries and nothing more. This is not a preference — it is the only
29// storage that survives a tzdata update.
30//
31// The failure it prevents: somebody schedules a 09:00 standup in
32// Europe/Dublin for next March, a government moves the DST boundary,
33// and the release ships a new tzdata. Store the instant as truth and
34// the meeting is now at 08:00 or 10:00 and nobody can say what was
35// meant. Store "09:00 Europe/Dublin" and it is still 09:00; only the
36// derived index moves, and the boot sweep moves it.
37//
38// The gate is TestReDeriveSurvivesATzdataBump: it changes the stamp in
39// meta, boots, and asserts every UTC column was recomputed from the
40// local columns rather than carried forward.
41
42// zoneCache memoises time.LoadLocation. Loading a zone reads and parses
43// a file from the embedded tzdata; a month view of a team's calendar
44// asks for the same handful of zones hundreds of times, and the parse
45// is pure overhead after the first.
46//
47// A sync.Map, because Zone is called from every request at once and a
48// plain map under concurrent writes is a data race — the race detector
49// caught it the first time a hundred goroutines asked at the same time.
50//
51// It holds SUCCESSES ONLY, which is what makes "unbounded" safe: the
52// key space is then the IANA database, about six hundred small names.
53// Caching failures would let anyone who can put a string in a ?zone=
54// parameter grow the map by one entry per request, forever. A failed
55// load is repeated instead; the cost is a few missed file probes, paid
56// only by whoever is sending garbage.
57var zoneCache sync.Map // name -> *time.Location
58
59func loadZone(name string) (*time.Location, error) {
60 if v, ok := zoneCache.Load(name); ok {
61 return v.(*time.Location), nil
62 }
63 loc, err := time.LoadLocation(name)
64 if err != nil {
65 return nil, err
66 }
67 // Two goroutines loading the same zone at once both store; the
68 // locations are equivalent and the second store is harmless.
69 zoneCache.Store(name, loc)
70 return loc, nil
71}
72
73// Zone resolves an IANA name to a location.
74//
75// An empty name is UTC, not the server's local zone. A server's zone is
76// an accident of where it was provisioned, and inheriting it would
77// silently shift every unzoned event's day boundaries the first time
78// the fleet moved between regions.
79func Zone(name string) (*time.Location, error) {
80 name = strings.TrimSpace(name)
81 if name == "" || name == "UTC" {
82 return time.UTC, nil
83 }
84 loc, err := loadZone(name)
85 if err != nil {
86 return nil, fmt.Errorf("calendar: unknown time zone %q: %w", name, err)
87 }
88 return loc, nil
89}
90
91// ParseLocal turns a stored local string into a time in loc.
92//
93// It accepts both layouts and reports which it read, because an all-day
94// event and a timed one are different kinds and the caller must not
95// have to guess from the string's length.
96func ParseLocal(s string, loc *time.Location) (t time.Time, allDay bool, err error) {
97 s = strings.TrimSpace(s)
98 if t, err = time.ParseInLocation(LayoutLocal, s, loc); err == nil {
99 return t, false, nil
100 }
101 if t, err = time.ParseInLocation(LayoutDate, s, loc); err == nil {
102 return t, true, nil
103 }
104 return time.Time{}, false, fmt.Errorf("calendar: %q is neither %q nor %q", s, LayoutLocal, LayoutDate)
105}
106
107// ToUTC derives the instant a local time in zone names.
108//
109// DST is where this earns its keep. There is no obviously right answer
110// for a wall-clock time that happens twice or not at all, and Go's
111// documentation says so in as many words: for those two cases "the
112// choice of time zone, and therefore the time, is not guaranteed". So
113// the rule here is not "this is correct", it is "this is what happens,
114// it is written down, and it cannot change without a test failing".
115//
116// Measured against Europe/Dublin 2026 (TestDSTGapAndFold pins both):
117//
118// - The GAP, the hour that does not exist on the spring-forward
119// night. Local 01:00 jumps to 02:00 on 29 March. "01:30" resolves
120// with the offset in force BEFORE the transition and lands at
121// 01:30 UTC, which reads back as 02:30 local — it moves FORWARD.
122// That is the half worth relying on: an event in the gap never
123// silently happens earlier than it was written.
124// - The FOLD, the hour that happens twice on 25 October. "01:30" is
125// ambiguous, and Go returns the SECOND of the two — 01:30 UTC at
126// the post-transition offset, not 00:30 UTC at the pre-transition
127// one.
128//
129// Note that those two are not one rule. An earlier version of this
130// comment claimed both took the pre-transition offset, which is true of
131// the gap and false of the fold; the probe that produced the numbers
132// above is what corrected it. Do not reason about the fold from the
133// gap's behaviour.
134func ToUTC(local string, zone string, allDay bool) (int64, error) {
135 loc, err := Zone(zone)
136 if err != nil {
137 return 0, err
138 }
139 // An all-day event is a date, and its instant is midnight at the
140 // start of that date in the viewing zone. It is an index for range
141 // queries, never a time anybody is shown.
142 if allDay {
143 t, err := time.ParseInLocation(LayoutDate, strings.TrimSpace(local), loc)
144 if err != nil {
145 return 0, fmt.Errorf("calendar: all-day time %q: %w", local, err)
146 }
147 return t.Unix(), nil
148 }
149 t, _, err := ParseLocal(local, loc)
150 if err != nil {
151 return 0, err
152 }
153 return t.Unix(), nil
154}
155
156// FormatLocal renders a time back into the stored form.
157func FormatLocal(t time.Time, allDay bool) string {
158 if allDay {
159 return t.Format(LayoutDate)
160 }
161 return t.Format(LayoutLocal)
162}
163
164// InZone converts a UTC instant into a wall clock in zone. This is the
165// one conversion a view does: everything is stored and queried in UTC,
166// and rendered in the viewer's own zone.
167func InZone(unix int64, zone string) (time.Time, error) {
168 loc, err := Zone(zone)
169 if err != nil {
170 return time.Time{}, err
171 }
172 return time.Unix(unix, 0).In(loc), nil
173}
174
175// DayBounds returns the half-open UTC range [start, end) covering the
176// local day containing t in zone.
177//
178// Half-open, always, everywhere in this app. An inclusive end is how a
179// calendar ends up showing a midnight event on both days, and how
180// "events between Monday and Tuesday" double-counts the boundary.
181func DayBounds(day time.Time, zone string) (startUTC, endUTC int64, err error) {
182 loc, err := Zone(zone)
183 if err != nil {
184 return 0, 0, err
185 }
186 d := day.In(loc)
187 start := time.Date(d.Year(), d.Month(), d.Day(), 0, 0, 0, 0, loc)
188 // AddDate, not Add(24h). A day is not always 24 hours long: on a
189 // DST boundary it is 23 or 25, and adding a fixed duration would
190 // put the end of the day an hour into the next one, or an hour
191 // short of it.
192 end := start.AddDate(0, 0, 1)
193 return start.Unix(), end.Unix(), nil
194}
195