@remit/calendar-service 0.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +32 -0
- package/src/errors.ts +44 -0
- package/src/etag.test.ts +54 -0
- package/src/etag.ts +12 -0
- package/src/expand.test.ts +333 -0
- package/src/expand.ts +186 -0
- package/src/fixtures.ts +52 -0
- package/src/index.ts +29 -0
- package/src/parse.test.ts +253 -0
- package/src/parse.ts +132 -0
- package/src/project.test.ts +247 -0
- package/src/project.ts +104 -0
- package/src/put.test.ts +512 -0
- package/src/put.ts +123 -0
- package/src/time.ts +194 -0
- package/tsconfig.json +8 -0
package/src/time.ts
ADDED
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
import type { CalendarObjectItem } from "@remit/data-ports";
|
|
2
|
+
import { ZoneCertainty } from "@remit/domain-enums";
|
|
3
|
+
import ICAL from "ical.js";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* A resolved iCalendar time, in the two forms the store keeps.
|
|
7
|
+
*
|
|
8
|
+
* `isoUtc` is what a sort key holds: fixed width, so lexicographic order is
|
|
9
|
+
* chronological order. `isoOffset` is what a client displays: the event's own
|
|
10
|
+
* wall time with the offset it was written in, which `isoUtc` cannot express
|
|
11
|
+
* for an all-day event without turning a civil date into an instant.
|
|
12
|
+
*
|
|
13
|
+
* `certainty` says how much either of them is worth. A zone that could not be
|
|
14
|
+
* resolved still produces an instant — there is nothing else to produce — and
|
|
15
|
+
* without this marker that guess is indistinguishable from a fact.
|
|
16
|
+
*/
|
|
17
|
+
export interface ResolvedTime {
|
|
18
|
+
instantMs: number;
|
|
19
|
+
isoUtc: string;
|
|
20
|
+
isoOffset: string;
|
|
21
|
+
isDate: boolean;
|
|
22
|
+
certainty: CalendarObjectItem["zoneCertainty"];
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const pad = (value: number, width = 2): string =>
|
|
26
|
+
String(Math.abs(value)).padStart(width, "0");
|
|
27
|
+
|
|
28
|
+
const civilOf = (
|
|
29
|
+
time: ICAL.Time,
|
|
30
|
+
): {
|
|
31
|
+
year: number;
|
|
32
|
+
month: number;
|
|
33
|
+
day: number;
|
|
34
|
+
hour: number;
|
|
35
|
+
minute: number;
|
|
36
|
+
second: number;
|
|
37
|
+
} => ({
|
|
38
|
+
year: time.year,
|
|
39
|
+
month: time.month,
|
|
40
|
+
day: time.day,
|
|
41
|
+
hour: time.isDate ? 0 : time.hour,
|
|
42
|
+
minute: time.isDate ? 0 : time.minute,
|
|
43
|
+
second: time.isDate ? 0 : time.second,
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* The offset an IANA zone was at on a given instant, in minutes.
|
|
48
|
+
*
|
|
49
|
+
* Read from the platform's own zone database through `Intl` rather than from a
|
|
50
|
+
* bundled copy: ical.js ships no zone data, and a resource whose TZID names a
|
|
51
|
+
* zone it carries no VTIMEZONE for is otherwise unresolvable — which is how a
|
|
52
|
+
* DST-crossing series silently drifts by an hour.
|
|
53
|
+
*/
|
|
54
|
+
const zoneOffsetMinutes = (timeZone: string, instantMs: number): number => {
|
|
55
|
+
const parts = new Intl.DateTimeFormat("en-US", {
|
|
56
|
+
timeZone,
|
|
57
|
+
hourCycle: "h23",
|
|
58
|
+
year: "numeric",
|
|
59
|
+
month: "2-digit",
|
|
60
|
+
day: "2-digit",
|
|
61
|
+
hour: "2-digit",
|
|
62
|
+
minute: "2-digit",
|
|
63
|
+
second: "2-digit",
|
|
64
|
+
}).formatToParts(new Date(instantMs));
|
|
65
|
+
const field = (type: string): number =>
|
|
66
|
+
Number(parts.find((part) => part.type === type)?.value ?? "0");
|
|
67
|
+
const asUtc = Date.UTC(
|
|
68
|
+
field("year"),
|
|
69
|
+
field("month") - 1,
|
|
70
|
+
field("day"),
|
|
71
|
+
field("hour"),
|
|
72
|
+
field("minute"),
|
|
73
|
+
field("second"),
|
|
74
|
+
);
|
|
75
|
+
return (asUtc - instantMs) / 60_000;
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* The zones this platform can be asked about.
|
|
80
|
+
*
|
|
81
|
+
* Read once from the runtime rather than probed per value: an unusable TZID —
|
|
82
|
+
* a Windows zone name, a typo — makes `Intl` throw, and a resource written by a
|
|
83
|
+
* client with a zone we cannot name is a resource to read in the next fallback,
|
|
84
|
+
* never a crashed write. The list is the canonical IANA set, so a deprecated
|
|
85
|
+
* alias falls through to the collection's own zone.
|
|
86
|
+
*/
|
|
87
|
+
const KNOWN_ZONES = new Set(Intl.supportedValuesOf("timeZone"));
|
|
88
|
+
|
|
89
|
+
const isKnownZone = (timeZone: string): boolean => KNOWN_ZONES.has(timeZone);
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* The offset a civil wall time carries in a zone. Applied twice because the
|
|
93
|
+
* first lookup asks the zone about the wrong instant — the wall time read as if
|
|
94
|
+
* it were UTC — which lands in the wrong side of a DST transition for wall
|
|
95
|
+
* times within an hour or so of the cutover.
|
|
96
|
+
*/
|
|
97
|
+
const offsetForCivil = (
|
|
98
|
+
timeZone: string,
|
|
99
|
+
civil: ReturnType<typeof civilOf>,
|
|
100
|
+
): number => {
|
|
101
|
+
const asUtc = Date.UTC(
|
|
102
|
+
civil.year,
|
|
103
|
+
civil.month - 1,
|
|
104
|
+
civil.day,
|
|
105
|
+
civil.hour,
|
|
106
|
+
civil.minute,
|
|
107
|
+
civil.second,
|
|
108
|
+
);
|
|
109
|
+
const firstPass = zoneOffsetMinutes(timeZone, asUtc);
|
|
110
|
+
return zoneOffsetMinutes(timeZone, asUtc - firstPass * 60_000);
|
|
111
|
+
};
|
|
112
|
+
|
|
113
|
+
const formatOffset = (offsetMinutes: number): string => {
|
|
114
|
+
const sign = offsetMinutes < 0 ? "-" : "+";
|
|
115
|
+
return `${sign}${pad(Math.trunc(offsetMinutes / 60))}:${pad(offsetMinutes % 60)}`;
|
|
116
|
+
};
|
|
117
|
+
|
|
118
|
+
const formatCivil = (
|
|
119
|
+
civil: ReturnType<typeof civilOf>,
|
|
120
|
+
offsetMinutes: number,
|
|
121
|
+
): string =>
|
|
122
|
+
`${pad(civil.year, 4)}-${pad(civil.month)}-${pad(civil.day)}T${pad(civil.hour)}:${pad(civil.minute)}:${pad(civil.second)}${formatOffset(offsetMinutes)}`;
|
|
123
|
+
|
|
124
|
+
export const toUtcIso = (instantMs: number): string =>
|
|
125
|
+
`${new Date(instantMs).toISOString().slice(0, 19)}Z`;
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Resolves one iCalendar time to an instant and to its own wall-clock form.
|
|
129
|
+
*
|
|
130
|
+
* The zone is taken from the value itself when ical.js could bind one — a
|
|
131
|
+
* VTIMEZONE defined in this very resource, or UTC. A floating value, and a
|
|
132
|
+
* value whose TZID has no VTIMEZONE, falls back to `tzidHint` (the property's
|
|
133
|
+
* own TZID parameter) and then to the collection's timezone, so a resource
|
|
134
|
+
* written by a client that assumes the server knows the zone database still
|
|
135
|
+
* lands on the right instant.
|
|
136
|
+
*
|
|
137
|
+
* Which of those happened is reported, never swallowed: a named zone that no
|
|
138
|
+
* fallback could resolve comes back `Ambiguous`, so a caller can say the event
|
|
139
|
+
* may be hours out instead of drawing it in the wrong place in silence.
|
|
140
|
+
*/
|
|
141
|
+
export const resolveTime = (
|
|
142
|
+
time: ICAL.Time,
|
|
143
|
+
tzidHint: string,
|
|
144
|
+
collectionTimezone: string,
|
|
145
|
+
): ResolvedTime => {
|
|
146
|
+
const civil = civilOf(time);
|
|
147
|
+
|
|
148
|
+
let offsetMinutes: number;
|
|
149
|
+
let certainty: ResolvedTime["certainty"];
|
|
150
|
+
if (time.zone === ICAL.Timezone.utcTimezone) {
|
|
151
|
+
offsetMinutes = 0;
|
|
152
|
+
certainty = ZoneCertainty.Explicit;
|
|
153
|
+
} else if (time.zone !== ICAL.Timezone.localTimezone) {
|
|
154
|
+
offsetMinutes = time.utcOffset() / 60;
|
|
155
|
+
certainty = ZoneCertainty.Explicit;
|
|
156
|
+
} else {
|
|
157
|
+
const fallback = [tzidHint, collectionTimezone].find(isKnownZone) ?? "UTC";
|
|
158
|
+
offsetMinutes = offsetForCivil(fallback, civil);
|
|
159
|
+
if (tzidHint === "") {
|
|
160
|
+
// No zone was ever named. RFC 5545 floating time, read where the
|
|
161
|
+
// collection lives.
|
|
162
|
+
certainty = ZoneCertainty.Local;
|
|
163
|
+
} else {
|
|
164
|
+
certainty = isKnownZone(tzidHint)
|
|
165
|
+
? ZoneCertainty.Explicit
|
|
166
|
+
: ZoneCertainty.Ambiguous;
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
const instantMs =
|
|
171
|
+
Date.UTC(
|
|
172
|
+
civil.year,
|
|
173
|
+
civil.month - 1,
|
|
174
|
+
civil.day,
|
|
175
|
+
civil.hour,
|
|
176
|
+
civil.minute,
|
|
177
|
+
civil.second,
|
|
178
|
+
) -
|
|
179
|
+
offsetMinutes * 60_000;
|
|
180
|
+
|
|
181
|
+
return {
|
|
182
|
+
instantMs,
|
|
183
|
+
isoUtc: toUtcIso(instantMs),
|
|
184
|
+
isoOffset: formatCivil(civil, offsetMinutes),
|
|
185
|
+
isDate: time.isDate,
|
|
186
|
+
certainty,
|
|
187
|
+
};
|
|
188
|
+
};
|
|
189
|
+
|
|
190
|
+
/** The TZID a property was written with, or `""` when it names none. */
|
|
191
|
+
export const tzidOf = (property: ICAL.Property | null): string => {
|
|
192
|
+
const tzid = property?.getParameter("tzid");
|
|
193
|
+
return typeof tzid === "string" ? tzid : "";
|
|
194
|
+
};
|