| 1 | package calendar |
| 2 | |
| 3 | import ( |
| 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. |
| 13 | const ( |
| 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. |
| 57 | var zoneCache sync.Map // name -> *time.Location |
| 58 | |
| 59 | func 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. |
| 79 | func 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. |
| 96 | func 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. |
| 134 | func 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. |
| 157 | func 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. |
| 167 | func 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. |
| 181 | func 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 | |