@escape-game-over/atlas 0.1.98 → 0.1.100

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@escape-game-over/atlas",
3
- "version": "0.1.98",
3
+ "version": "0.1.100",
4
4
  "type": "module",
5
5
  "description": "Typed, data-driven machinery for static multi-locale, multi-deployment Astro sites.",
6
6
  "private": false,
package/src/hours.ts CHANGED
@@ -217,6 +217,97 @@ export function formatHours(
217
217
  ): readonly FormattedHours[] {
218
218
  const between = format.between ?? "–";
219
219
  const and = format.and ?? ", ";
220
+ const { dayName, time } = printer(format);
221
+
222
+ return groupHours(hours, format.weekStart).map((group) => ({
223
+ days: group.runs.map((run) => runName(run, dayName, between)).join(and),
224
+ weekdays: group.days,
225
+ times: timesOf(group, format.closed, time, between, and),
226
+ }));
227
+ }
228
+
229
+ /** How to say a week in a sentence: `HoursFormat` without its separators. */
230
+ export type HoursSentenceFormat = Omit<HoursFormat, "and">;
231
+
232
+ /**
233
+ * Each language's semicolon, between one group of days and the next. `Intl`
234
+ * has no list form for it, and it is punctuation rather than copy, so it lives
235
+ * here. A language missing from this table throws rather than borrowing the
236
+ * English one: add it here.
237
+ */
238
+ const SEMICOLONS: Readonly<Record<string, string>> = {
239
+ ar: "؛ ",
240
+ de: "; ",
241
+ el: "· ",
242
+ en: "; ",
243
+ es: "; ",
244
+ // A narrow no-break space before it, as French sets it.
245
+ fr: "\u202F; ",
246
+ hu: "; ",
247
+ it: "; ",
248
+ pt: "; ",
249
+ ru: "; ",
250
+ sv: "; ",
251
+ };
252
+
253
+ function semicolonFor(locale: string): string {
254
+ const { language } = new Intl.Locale(locale);
255
+ const semicolon = Object.hasOwn(SEMICOLONS, language)
256
+ ? SEMICOLONS[language]
257
+ : undefined;
258
+ if (semicolon === undefined) {
259
+ throw new Error(
260
+ `hoursSentence has no semicolon for "${language}" (${locale}). Add it to SEMICOLONS in hours.ts.`
261
+ );
262
+ }
263
+ return semicolon;
264
+ }
265
+
266
+ /**
267
+ * The week as one sentence, for copy that quotes it: `Monday–Thursday and
268
+ * Sunday 14:00–23:30; Friday–Saturday 14:00–00:45`.
269
+ *
270
+ * Days and times join with the locale's own "and" (`Intl.ListFormat`), so a
271
+ * sentence in Arabic reads `الاثنين–الخميس والأحد` rather than borrowing an
272
+ * English comma; groups with the language's semicolon (`؛` in Arabic). No
273
+ * closing full stop: the copy around it decides that.
274
+ */
275
+ export function hoursSentence(
276
+ hours: WeeklyHours,
277
+ format: HoursSentenceFormat
278
+ ): string {
279
+ const between = format.between ?? "–";
280
+ const semicolon = semicolonFor(format.locale);
281
+ const { dayName, time } = printer(format);
282
+ const list = new Intl.ListFormat(format.locale, {
283
+ type: "conjunction",
284
+ style: "long",
285
+ });
286
+ const joined = (parts: readonly string[]) => list.format(parts);
287
+
288
+ return groupHours(hours, format.weekStart)
289
+ .map((group) => {
290
+ const days = joined(
291
+ group.runs.map((run) => runName(run, dayName, between))
292
+ );
293
+ const times =
294
+ group.hours === "closed"
295
+ ? format.closed
296
+ : joined(
297
+ group.hours.map(
298
+ (range) =>
299
+ `${time(range.opens)}${between}${time(range.closes)}`
300
+ )
301
+ );
302
+ return `${days} ${times}`;
303
+ })
304
+ .join(semicolon);
305
+ }
306
+
307
+ /** Day names and times as `format` prints them. */
308
+ function printer(
309
+ format: Pick<HoursFormat, "locale" | "weekday" | "hourCycle">
310
+ ) {
220
311
  // 2024-01-01 was a Monday, so `WEEK[i]` falls on the 1st plus `i`.
221
312
  const names = new Intl.DateTimeFormat(format.locale, {
222
313
  weekday: format.weekday ?? "short",
@@ -247,26 +338,35 @@ export function formatHours(
247
338
  )
248
339
  )
249
340
  );
341
+ return { dayName, time };
342
+ }
250
343
 
251
- return groupHours(hours, format.weekStart).map((group) => ({
252
- days: group.runs
253
- .map(([first, last]) =>
254
- first === last
255
- ? dayName(first)
256
- : `${dayName(first)}${between}${dayName(last)}`
257
- )
258
- .join(and),
259
- weekdays: group.days,
260
- times:
261
- group.hours === "closed"
262
- ? format.closed
263
- : group.hours
264
- .map(
265
- (range) =>
266
- `${time(range.opens)}${between}${time(range.closes)}`
267
- )
268
- .join(and),
269
- }));
344
+ /** A run of one day prints as one name — `Sun`, not `Sun–Sun`. */
345
+ function runName(
346
+ [first, last]: HoursGroup["runs"][number],
347
+ dayName: (day: Weekday) => string,
348
+ between: string
349
+ ): string {
350
+ return first === last
351
+ ? dayName(first)
352
+ : `${dayName(first)}${between}${dayName(last)}`;
353
+ }
354
+
355
+ function timesOf(
356
+ group: HoursGroup,
357
+ closed: string,
358
+ time: (at: TimeOfDay) => string,
359
+ between: string,
360
+ and: string
361
+ ): string {
362
+ return group.hours === "closed"
363
+ ? closed
364
+ : group.hours
365
+ .map(
366
+ (range) =>
367
+ `${time(range.opens)}${between}${time(range.closes)}`
368
+ )
369
+ .join(and);
270
370
  }
271
371
 
272
372
  /** Minutes since midnight, for comparing two times. */
package/src/index.ts CHANGED
@@ -123,6 +123,8 @@ export {
123
123
  groupHours,
124
124
  type HoursFormat,
125
125
  type HoursGroup,
126
+ type HoursSentenceFormat,
127
+ hoursSentence,
126
128
  type OpenStatus,
127
129
  openStatus,
128
130
  type TimeOfDay,