@twentyfirstmedia/ui-core 0.6.0 → 0.8.0

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.
Files changed (60) hide show
  1. package/ICONS-LICENSE +24 -2
  2. package/dist/components/connected-data.d.ts +25 -0
  3. package/dist/components/connected-data.d.ts.map +1 -0
  4. package/dist/components/connected-data.js +264 -0
  5. package/dist/components/connected-data.js.map +1 -0
  6. package/dist/components/connectors.d.ts +22 -0
  7. package/dist/components/connectors.d.ts.map +1 -0
  8. package/dist/components/connectors.js +135 -0
  9. package/dist/components/connectors.js.map +1 -0
  10. package/dist/components/household.d.ts +19 -0
  11. package/dist/components/household.d.ts.map +1 -0
  12. package/dist/components/household.js +137 -0
  13. package/dist/components/household.js.map +1 -0
  14. package/dist/components/index.d.ts +4 -0
  15. package/dist/components/index.d.ts.map +1 -1
  16. package/dist/components/index.js +4 -0
  17. package/dist/components/index.js.map +1 -1
  18. package/dist/components/life.d.ts +26 -0
  19. package/dist/components/life.d.ts.map +1 -0
  20. package/dist/components/life.js +187 -0
  21. package/dist/components/life.js.map +1 -0
  22. package/dist/components/types.d.ts +622 -0
  23. package/dist/components/types.d.ts.map +1 -1
  24. package/dist/context.d.ts +1 -0
  25. package/dist/context.d.ts.map +1 -1
  26. package/dist/context.js +2 -1
  27. package/dist/context.js.map +1 -1
  28. package/dist/format.d.ts +5 -0
  29. package/dist/format.d.ts.map +1 -1
  30. package/dist/format.js +31 -0
  31. package/dist/format.js.map +1 -1
  32. package/dist/icon-library.d.ts +235 -4
  33. package/dist/icon-library.d.ts.map +1 -1
  34. package/dist/icon-library.js +467 -15
  35. package/dist/icon-library.js.map +1 -1
  36. package/dist/icons.d.ts +2 -0
  37. package/dist/icons.d.ts.map +1 -1
  38. package/dist/icons.js.map +1 -1
  39. package/dist/index.d.ts +2 -2
  40. package/dist/index.d.ts.map +1 -1
  41. package/dist/index.js +2 -2
  42. package/dist/index.js.map +1 -1
  43. package/dist/strings.d.ts +184 -0
  44. package/dist/strings.d.ts.map +1 -1
  45. package/dist/strings.js +182 -1
  46. package/dist/strings.js.map +1 -1
  47. package/dist/themes.css +16438 -11238
  48. package/package.json +1 -1
  49. package/src/components/connected-data.tsx +783 -0
  50. package/src/components/connectors.tsx +426 -0
  51. package/src/components/household.tsx +489 -0
  52. package/src/components/index.ts +39 -0
  53. package/src/components/life.tsx +679 -0
  54. package/src/components/types.ts +602 -0
  55. package/src/context.ts +10 -1
  56. package/src/format.ts +33 -0
  57. package/src/icon-library.ts +476 -19
  58. package/src/icons.ts +2 -0
  59. package/src/index.ts +5 -0
  60. package/src/strings.ts +518 -0
@@ -0,0 +1,783 @@
1
+ "use client";
2
+ import * as React from "react";
3
+ import { cx } from "../cx.js";
4
+ import { useFormat, useStrings } from "../context.js";
5
+ import { CONNECTED_DATA_STRINGS } from "../strings.js";
6
+ import { Icon } from "../Icon.js";
7
+ import { registerIcons, type IconName } from "../icons.js";
8
+ import {
9
+ activity,
10
+ battery,
11
+ batteryCharging,
12
+ bedDouble,
13
+ carFront,
14
+ cctv,
15
+ circleDot,
16
+ fan,
17
+ lampCeiling,
18
+ lockOpen,
19
+ mapPin,
20
+ phoneCall,
21
+ phoneIncoming,
22
+ phoneMissed,
23
+ phoneOutgoing,
24
+ plane,
25
+ plug2,
26
+ radar,
27
+ skipBack,
28
+ skipForward,
29
+ thermometer,
30
+ ticket,
31
+ trainFront,
32
+ } from "../icon-library.js";
33
+ import { Badge, Button, IconButton, TintIcon } from "./foundation.js";
34
+ import { Switch } from "./inputs.js";
35
+ import { ServiceMark } from "./connectors.js";
36
+ import type {
37
+ CallLogProps,
38
+ CallItem,
39
+ DeviceTileProps,
40
+ HoldingsListProps,
41
+ NowPlayingProps,
42
+ ReservationCardProps,
43
+ ScorecardProps,
44
+ ServiceRef,
45
+ VehicleCardProps,
46
+ WorkoutCardProps,
47
+ } from "./types.js";
48
+
49
+ // default icons of these components come from the icon library
50
+ registerIcons({
51
+ activity,
52
+ battery,
53
+ batteryCharging,
54
+ bedDouble,
55
+ carFront,
56
+ cctv,
57
+ circleDot,
58
+ fan,
59
+ lampCeiling,
60
+ lockOpen,
61
+ mapPin,
62
+ phoneCall,
63
+ phoneIncoming,
64
+ phoneMissed,
65
+ phoneOutgoing,
66
+ plane,
67
+ plug2,
68
+ radar,
69
+ skipBack,
70
+ skipForward,
71
+ thermometer,
72
+ ticket,
73
+ trainFront,
74
+ });
75
+
76
+ function Source({ s }: { s?: ServiceRef }) {
77
+ if (!s) return null;
78
+ return <ServiceMark name={s.name} icon={s.icon} logo={s.logo} size="sm" />;
79
+ }
80
+
81
+ const DEVICE_ICON: Record<NonNullable<DeviceTileProps["kind"]>, IconName> = {
82
+ switch: "plug2",
83
+ light: "lampCeiling",
84
+ thermostat: "thermometer",
85
+ vacuum: "circleDot",
86
+ purifier: "fan",
87
+ camera: "cctv",
88
+ lock: "lock",
89
+ sensor: "radar",
90
+ };
91
+
92
+ /**
93
+ * A smart-home device: its reading, state and the controls its kind needs (a switch, a
94
+ * thermostat stepper, quick actions), with battery and an offline state.
95
+ */
96
+ export function DeviceTile(p: DeviceTileProps) {
97
+ const t = useStrings(CONNECTED_DATA_STRINGS);
98
+ const { formatNumber, formatPercent } = useFormat();
99
+ const kind = p.kind || "switch";
100
+ const step = p.step ?? 0.5;
101
+ const temp = (v: number) => formatNumber(v, { maximumFractionDigits: 1 }) + " °C";
102
+ return (
103
+ <div
104
+ style={p.style}
105
+ className={cx(
106
+ "tf-device",
107
+ "kind-" + kind,
108
+ p.offline ? "is-offline" : p.on ? "is-on" : p.on === false && "is-off",
109
+ p.className,
110
+ )}
111
+ >
112
+ <div className="tf-device-head">
113
+ <span className={cx("tf-device-ico", p.on && !p.offline && "is-on")} aria-hidden>
114
+ <Icon name={p.icon || DEVICE_ICON[kind]} size={18} />
115
+ </span>
116
+ <span className="tf-option-text">
117
+ <b>{p.name}</b>
118
+ {p.room && <small>{p.room}</small>}
119
+ </span>
120
+ <Source s={p.service} />
121
+ {p.onToggle && !p.offline && (
122
+ <Switch
123
+ checked={!!p.on}
124
+ label={p.name}
125
+ onChange={(e) => p.onToggle?.(e.currentTarget.checked)}
126
+ />
127
+ )}
128
+ </div>
129
+ <div className="tf-device-body">
130
+ <span className="tf-device-value">
131
+ {p.offline ? t.deviceOffline : (p.value ?? (p.on ? t.deviceOn : t.deviceOff))}
132
+ </span>
133
+ {p.status && !p.offline && <span className="tf-device-status">{p.status}</span>}
134
+ </div>
135
+ {p.target != null && !p.offline && (
136
+ <div className="tf-device-stepper">
137
+ {p.onTarget && (
138
+ <IconButton
139
+ icon="minus"
140
+ label={t.lowerTarget}
141
+ size="sm"
142
+ variant="soft"
143
+ onClick={() => p.onTarget?.(p.target! - step)}
144
+ />
145
+ )}
146
+ <span aria-live="polite">{t.targetTemp(temp(p.target))}</span>
147
+ {p.onTarget && (
148
+ <IconButton
149
+ icon="plus"
150
+ label={t.raiseTarget}
151
+ size="sm"
152
+ variant="soft"
153
+ onClick={() => p.onTarget?.(p.target! + step)}
154
+ />
155
+ )}
156
+ </div>
157
+ )}
158
+ {(p.battery != null || (p.actions && p.actions.length > 0)) && !p.offline && (
159
+ <div className="tf-device-foot">
160
+ {p.battery != null && (
161
+ <span className={cx("tf-device-battery", p.battery <= 15 && "is-low")}>
162
+ <Icon name="battery" size={14} />
163
+ {t.batteryLevel(formatPercent(p.battery))}
164
+ </span>
165
+ )}
166
+ {p.actions?.map((a) => (
167
+ <Button key={a.label} variant="secondary" size="sm" icon={a.icon} onClick={a.onClick}>
168
+ {a.label}
169
+ </Button>
170
+ ))}
171
+ </div>
172
+ )}
173
+ </div>
174
+ );
175
+ }
176
+
177
+ /** An electric car: charge with its limit, range, charging, lock, climate and where it is. */
178
+ export function VehicleCard(p: VehicleCardProps) {
179
+ const t = useStrings(CONNECTED_DATA_STRINGS);
180
+ const { formatNumber, formatPercent, formatDate } = useFormat();
181
+ const pct = Math.max(0, Math.min(100, p.battery));
182
+ const state = p.charging;
183
+ return (
184
+ <div style={p.style} className={cx("tf-vehicle", p.className)}>
185
+ <div className="tf-vehicle-head">
186
+ <span className="tf-option-text">
187
+ <b>{p.name}</b>
188
+ {p.model && <small>{p.model}</small>}
189
+ </span>
190
+ <Source s={p.service} />
191
+ </div>
192
+ <div className="tf-vehicle-charge">
193
+ <span className="tf-vehicle-pct">{formatPercent(pct)}</span>
194
+ {p.range != null && (
195
+ <span className="tf-vehicle-range">
196
+ {t.rangeLeft(formatNumber(p.range, { style: "unit", unit: "kilometer" }))}
197
+ </span>
198
+ )}
199
+ </div>
200
+ <div className="tf-vehicle-bar">
201
+ <div
202
+ className={cx("tf-vehicle-fill", state === "charging" && "is-charging")}
203
+ role="progressbar"
204
+ aria-label={t.batteryLevel(formatPercent(pct))}
205
+ aria-valuemin={0}
206
+ aria-valuemax={100}
207
+ aria-valuenow={Math.round(pct)}
208
+ style={{ width: pct + "%" }}
209
+ />
210
+ {p.limit != null && (
211
+ <span className="tf-vehicle-limit" style={{ left: p.limit + "%" }} aria-hidden />
212
+ )}
213
+ </div>
214
+ <p className="tf-vehicle-line">
215
+ {state && (
216
+ <span className={cx("tf-vehicle-state", "is-" + state)}>
217
+ <Icon name={state === "charging" ? "batteryCharging" : "battery"} size={14} />
218
+ {t.chargingStates[state]}
219
+ {state === "charging" && p.power != null && (
220
+ <> · {formatNumber(p.power, { maximumFractionDigits: 1 }) + " kW"}</>
221
+ )}
222
+ </span>
223
+ )}
224
+ {state === "charging" && p.until != null && (
225
+ <span>{t.chargedBy(formatDate(p.until, { hour: "numeric", minute: "2-digit" }))}</span>
226
+ )}
227
+ {p.limit != null && <span>{t.chargeLimit(formatPercent(p.limit))}</span>}
228
+ </p>
229
+ {p.location && (
230
+ <p className="tf-vehicle-line">
231
+ <span>
232
+ <Icon name="mapPin" size={14} />
233
+ {p.location}
234
+ </span>
235
+ </p>
236
+ )}
237
+ <div className="tf-vehicle-controls">
238
+ {p.locked != null && (
239
+ <Button
240
+ variant="secondary"
241
+ size="sm"
242
+ icon={p.locked ? "lock" : "lockOpen"}
243
+ aria-pressed={p.locked}
244
+ onClick={p.onLock ? () => p.onLock?.(!p.locked) : undefined}
245
+ disabled={!p.onLock}
246
+ >
247
+ {t.lockAction}
248
+ </Button>
249
+ )}
250
+ {p.climateOn != null && (
251
+ <Button
252
+ variant="secondary"
253
+ size="sm"
254
+ icon="fan"
255
+ aria-pressed={p.climateOn}
256
+ onClick={p.onClimate ? () => p.onClimate?.(!p.climateOn) : undefined}
257
+ disabled={!p.onClimate}
258
+ >
259
+ {t.climateLabel}
260
+ </Button>
261
+ )}
262
+ <span className="tf-vehicle-inside">
263
+ {[
264
+ p.locked != null ? (p.locked ? t.lockedState : t.unlockedState) : null,
265
+ p.climateOn != null ? (p.climateOn ? t.climateOnLabel : t.climateOffLabel) : null,
266
+ p.inside != null
267
+ ? t.insideTemp(formatNumber(p.inside, { maximumFractionDigits: 0 }) + " °C")
268
+ : null,
269
+ ]
270
+ .filter(Boolean)
271
+ .join(" · ")}
272
+ </span>
273
+ </div>
274
+ </div>
275
+ );
276
+ }
277
+
278
+ function clock(sec: number): string {
279
+ const s = Math.max(0, Math.round(sec));
280
+ return Math.floor(s / 60) + ":" + String(s % 60).padStart(2, "0");
281
+ }
282
+
283
+ /** What is playing: cover, title, artist, where it plays, progress and the transport buttons. */
284
+ export function NowPlaying(p: NowPlayingProps) {
285
+ const t = useStrings(CONNECTED_DATA_STRINGS);
286
+ const pos = p.position ?? 0;
287
+ const dur = p.duration ?? 0;
288
+ const pct = dur ? Math.min(100, (pos / dur) * 100) : 0;
289
+ return (
290
+ <div style={p.style} className={cx("tf-nowplaying", p.className)}>
291
+ <div className="tf-nowplaying-art" aria-hidden={!p.art}>
292
+ {p.art || <Icon name="play" size={22} />}
293
+ </div>
294
+ <div className="tf-nowplaying-main">
295
+ <div className="tf-nowplaying-head">
296
+ <span className="tf-option-text">
297
+ <b>{p.title}</b>
298
+ {(p.artist || p.album) && (
299
+ <small>{[p.artist, p.album].filter(Boolean).join(" · ")}</small>
300
+ )}
301
+ </span>
302
+ <Source s={p.service} />
303
+ </div>
304
+ {dur > 0 && (
305
+ <div className="tf-nowplaying-progress">
306
+ <div
307
+ className="tf-nowplaying-bar"
308
+ role="progressbar"
309
+ aria-label={p.title}
310
+ aria-valuemin={0}
311
+ aria-valuemax={Math.round(dur)}
312
+ aria-valuenow={Math.round(pos)}
313
+ aria-valuetext={`${clock(pos)} / ${clock(dur)}`}
314
+ >
315
+ <span style={{ width: pct + "%" }} />
316
+ </div>
317
+ <span className="tf-nowplaying-times" aria-hidden>
318
+ <span>{clock(pos)}</span>
319
+ <span>{clock(dur)}</span>
320
+ </span>
321
+ </div>
322
+ )}
323
+ <div className="tf-nowplaying-foot">
324
+ {p.device && <span className="tf-nowplaying-device">{t.playingOn(p.device)}</span>}
325
+ <span className="tf-nowplaying-controls">
326
+ {p.onPrevious && (
327
+ <IconButton
328
+ icon="skipBack"
329
+ label={t.previousTrack}
330
+ size="sm"
331
+ variant="ghost"
332
+ onClick={p.onPrevious}
333
+ />
334
+ )}
335
+ {p.onPlayPause && (
336
+ <IconButton
337
+ icon={p.playing ? "pause" : "play"}
338
+ label={p.playing ? t.pauseAction : t.playAction}
339
+ variant="solid"
340
+ onClick={p.onPlayPause}
341
+ />
342
+ )}
343
+ {p.onNext && (
344
+ <IconButton
345
+ icon="skipForward"
346
+ label={t.nextTrack}
347
+ size="sm"
348
+ variant="ghost"
349
+ onClick={p.onNext}
350
+ />
351
+ )}
352
+ </span>
353
+ </div>
354
+ </div>
355
+ </div>
356
+ );
357
+ }
358
+
359
+ const ZONE_FILL = ["sky", "mint", "butter", "lilac", "blush"] as const;
360
+
361
+ /** One workout: type, when, duration, distance, calories, heart rate and time in each zone. */
362
+ export function WorkoutCard(p: WorkoutCardProps) {
363
+ const t = useStrings(CONNECTED_DATA_STRINGS);
364
+ const { formatNumber, formatDate } = useFormat();
365
+ const zones = p.zones || [];
366
+ const zoneTotal = zones.reduce((a, b) => a + b, 0);
367
+ const stats: [string, string][] = [
368
+ [t.durationLabel, t.durationHm(Math.floor(p.duration / 60), Math.round(p.duration % 60))],
369
+ ];
370
+ if (p.distance != null)
371
+ stats.push([
372
+ t.distanceLabel,
373
+ formatNumber(p.distance, { style: "unit", unit: "kilometer", maximumFractionDigits: 1 }),
374
+ ]);
375
+ if (p.calories != null) stats.push([t.caloriesLabel, formatNumber(p.calories) + " kcal"]);
376
+ if (p.heartRate != null) stats.push([t.avgHeartRate, formatNumber(p.heartRate) + " bpm"]);
377
+ return (
378
+ <div style={p.style} className={cx("tf-workout", p.className)}>
379
+ <div className="tf-workout-head">
380
+ <TintIcon icon={p.icon || "activity"} tint="mint" size={36} />
381
+ <span className="tf-option-text">
382
+ <b>{p.type}</b>
383
+ <small>
384
+ {formatDate(p.date, {
385
+ weekday: "short",
386
+ day: "numeric",
387
+ month: "short",
388
+ hour: "numeric",
389
+ minute: "2-digit",
390
+ })}
391
+ </small>
392
+ </span>
393
+ <Source s={p.service} />
394
+ </div>
395
+ <dl className="tf-workout-stats">
396
+ {stats.map(([k, v]) => (
397
+ <div key={k}>
398
+ <dt>{k}</dt>
399
+ <dd>{v}</dd>
400
+ </div>
401
+ ))}
402
+ </dl>
403
+ {zoneTotal > 0 && (
404
+ <div className="tf-workout-zones">
405
+ <span className="tf-workout-zones-title">{t.heartRateZones}</span>
406
+ <div className="tf-workout-zonebar" aria-hidden>
407
+ {zones.map((m, i) =>
408
+ m > 0 ? (
409
+ <span key={i} className={"tf-sf-" + ZONE_FILL[i % 5]} style={{ flexGrow: m }} />
410
+ ) : null,
411
+ )}
412
+ </div>
413
+ <ul className="tf-workout-legend">
414
+ {zones.map((m, i) => (
415
+ <li key={i}>
416
+ <i className={"tf-sf-" + ZONE_FILL[i % 5]} aria-hidden />
417
+ {t.zoneMinutes(i + 1, Math.round(m))}
418
+ </li>
419
+ ))}
420
+ </ul>
421
+ </div>
422
+ )}
423
+ </div>
424
+ );
425
+ }
426
+
427
+ const DAY = 86400000;
428
+ function dayStart(v: Date | string | number): number {
429
+ const d = new Date(v);
430
+ return Date.UTC(d.getFullYear(), d.getMonth(), d.getDate());
431
+ }
432
+ const CALL_ICON: Record<CallItem["direction"], IconName> = {
433
+ incoming: "phoneIncoming",
434
+ outgoing: "phoneOutgoing",
435
+ missed: "phoneMissed",
436
+ };
437
+
438
+ /** Your calls, newest first by day: who, which direction, how long, what it was about. */
439
+ export function CallLog(p: CallLogProps) {
440
+ const t = useStrings(CONNECTED_DATA_STRINGS);
441
+ const { formatDate, locale } = useFormat();
442
+ const now = p.now ?? Date.now();
443
+ const sorted = [...p.calls].sort((a, b) => +new Date(b.at) - +new Date(a.at));
444
+ const groups: { key: number; items: CallItem[] }[] = [];
445
+ for (const c of sorted) {
446
+ const k = dayStart(c.at);
447
+ const last = groups[groups.length - 1];
448
+ if (last && last.key === k) last.items.push(c);
449
+ else groups.push({ key: k, items: [c] });
450
+ }
451
+ const heading = (k: number, sample: CallItem["at"]) => {
452
+ const diff = Math.round((k - dayStart(now)) / DAY);
453
+ if (diff === 0 || diff === -1) {
454
+ try {
455
+ const w = new Intl.RelativeTimeFormat(locale, { numeric: "auto" }).format(diff, "day");
456
+ return w.charAt(0).toLocaleUpperCase(locale) + w.slice(1);
457
+ } catch {
458
+ /* fall through to the date */
459
+ }
460
+ }
461
+ return formatDate(sample, { weekday: "long", day: "numeric", month: "long" });
462
+ };
463
+ const length = (s: number) =>
464
+ t.durationHm(Math.floor(s / 3600), Math.max(1, Math.round((s % 3600) / 60)));
465
+ return (
466
+ <div style={p.style} className={cx("tf-calls", p.className)}>
467
+ {groups.map((g) => (
468
+ <section key={g.key} className="tf-calls-day" aria-label={heading(g.key, g.items[0]!.at)}>
469
+ <h3 className="tf-agentlog-title" aria-hidden>
470
+ {heading(g.key, g.items[0]!.at)}
471
+ </h3>
472
+ <ul aria-label={p.label || t.calls}>
473
+ {g.items.map((c) => (
474
+ <li key={c.id} className={cx("tf-call", "is-" + c.direction)}>
475
+ <span className="tf-call-ico" aria-hidden>
476
+ <Icon name={CALL_ICON[c.direction]} size={15} />
477
+ </span>
478
+ <div className="tf-call-main">
479
+ {p.onOpen ? (
480
+ <button type="button" className="tf-call-name" onClick={() => p.onOpen?.(c.id)}>
481
+ {c.name}
482
+ </button>
483
+ ) : (
484
+ <b className="tf-call-name">{c.name}</b>
485
+ )}
486
+ <span className="tf-call-meta">
487
+ {[
488
+ t.callDirections[c.direction],
489
+ formatDate(c.at, { hour: "numeric", minute: "2-digit" }),
490
+ c.direction !== "missed" && c.duration ? length(c.duration) : null,
491
+ c.number,
492
+ ]
493
+ .filter(Boolean)
494
+ .join(" · ")}
495
+ </span>
496
+ {c.summary && <p className="tf-call-summary">{c.summary}</p>}
497
+ </div>
498
+ {p.onCall && (
499
+ <IconButton
500
+ icon="phoneCall"
501
+ label={t.callPerson(c.name)}
502
+ size="sm"
503
+ variant="soft"
504
+ onClick={() => p.onCall?.(c.id)}
505
+ />
506
+ )}
507
+ </li>
508
+ ))}
509
+ </ul>
510
+ </section>
511
+ ))}
512
+ </div>
513
+ );
514
+ }
515
+
516
+ const RES_ICON: Record<ReservationCardProps["kind"], IconName> = {
517
+ stay: "bedDouble",
518
+ flight: "plane",
519
+ train: "trainFront",
520
+ ride: "carFront",
521
+ order: "package",
522
+ event: "ticket",
523
+ };
524
+ const RES_TINT = {
525
+ stay: "lilac",
526
+ flight: "sky",
527
+ train: "mint",
528
+ ride: "butter",
529
+ order: "butter",
530
+ event: "blush",
531
+ } as const;
532
+ const RES_TONE = {
533
+ confirmed: "success",
534
+ pending: "neutral",
535
+ cancelled: "danger",
536
+ delayed: "warning",
537
+ onTheWay: "info",
538
+ delivered: "success",
539
+ } as const;
540
+
541
+ /** A booking or order (stay, flight, train, ride, delivery, ticket) with its time, place, code and status. */
542
+ export function ReservationCard(p: ReservationCardProps) {
543
+ const t = useStrings(CONNECTED_DATA_STRINGS);
544
+ const { formatDate } = useFormat();
545
+ const day = { weekday: "short", day: "numeric", month: "short" } as const;
546
+ const time = { hour: "numeric", minute: "2-digit" } as const;
547
+ const sameDay = p.end != null && dayStart(p.start) === dayStart(p.end);
548
+ const when =
549
+ p.kind === "stay" && p.end != null
550
+ ? `${formatDate(p.start, day)} – ${formatDate(p.end, day)}`
551
+ : p.end != null
552
+ ? sameDay
553
+ ? `${formatDate(p.start, day)}, ${formatDate(p.start, time)} – ${formatDate(p.end, time)}`
554
+ : `${formatDate(p.start, { ...day, ...time })} – ${formatDate(p.end, { ...day, ...time })}`
555
+ : formatDate(p.start, { ...day, ...time });
556
+ return (
557
+ <div
558
+ style={p.style}
559
+ className={cx("tf-booking", "kind-" + p.kind, p.status && "is-" + p.status, p.className)}
560
+ >
561
+ <div className="tf-booking-head">
562
+ <TintIcon icon={RES_ICON[p.kind]} tint={RES_TINT[p.kind]} size={36} />
563
+ <span className="tf-option-text">
564
+ <b>{p.title}</b>
565
+ {p.subtitle && <small>{p.subtitle}</small>}
566
+ </span>
567
+ <Source s={p.service} />
568
+ </div>
569
+ <p className="tf-booking-when">{when}</p>
570
+ {p.place && (
571
+ <p className="tf-booking-place">
572
+ <Icon name="mapPin" size={14} />
573
+ {p.place}
574
+ </p>
575
+ )}
576
+ {(p.status || p.code) && (
577
+ <div className="tf-booking-foot">
578
+ {p.status && (
579
+ <Badge tone={RES_TONE[p.status]} dot>
580
+ {t.reservationStatus[p.status]}
581
+ {p.statusText && " · " + p.statusText}
582
+ </Badge>
583
+ )}
584
+ {p.code && (
585
+ <span className="tf-booking-code">
586
+ {t.bookingCode} <b>{p.code}</b>
587
+ </span>
588
+ )}
589
+ </div>
590
+ )}
591
+ {p.actions && <div className="tf-booking-actions">{p.actions}</div>}
592
+ </div>
593
+ );
594
+ }
595
+
596
+ function scoreName(diff: number): "eagle" | "birdie" | "par" | "bogey" | "double" {
597
+ return diff <= -2
598
+ ? "eagle"
599
+ : diff === -1
600
+ ? "birdie"
601
+ : diff === 0
602
+ ? "par"
603
+ : diff === 1
604
+ ? "bogey"
605
+ : "double";
606
+ }
607
+
608
+ /**
609
+ * A round of golf as a scorecard: par and strokes per hole with the usual marks (circle for a
610
+ * birdie, square for a bogey), putts, Out, In and the total against par.
611
+ */
612
+ export function Scorecard(p: ScorecardProps) {
613
+ const t = useStrings(CONNECTED_DATA_STRINGS);
614
+ const { formatDate } = useFormat();
615
+ const halves = p.holes.length > 9 ? [p.holes.slice(0, 9), p.holes.slice(9)] : [p.holes];
616
+ const sum = (hs: typeof p.holes, k: "par" | "strokes" | "putts") =>
617
+ hs.reduce((a, h) => a + (h[k] ?? 0), 0);
618
+ const played = p.holes.filter((h) => h.strokes != null);
619
+ const strokes = sum(played, "strokes");
620
+ const parPlayed = sum(played, "par");
621
+ const hasPutts = p.holes.some((h) => h.putts != null);
622
+ return (
623
+ <div style={p.style} className={cx("tf-scorecard", p.className)}>
624
+ <div className="tf-scorecard-head">
625
+ <span className="tf-option-text">
626
+ <b>{p.course}</b>
627
+ <small>
628
+ {[
629
+ p.date != null
630
+ ? formatDate(p.date, { day: "numeric", month: "short", year: "numeric" })
631
+ : null,
632
+ p.tee,
633
+ ]
634
+ .filter(Boolean)
635
+ .join(" · ")}
636
+ </small>
637
+ </span>
638
+ <Source s={p.service} />
639
+ <span className="tf-scorecard-total">
640
+ <b>{strokes}</b>
641
+ <small>{t.toPar(strokes - parPlayed)}</small>
642
+ </span>
643
+ </div>
644
+ {halves.map((hs, half) => {
645
+ const first = half * 9;
646
+ return (
647
+ <div key={half} className="tf-scorecard-scroll">
648
+ <table className="tf-scorecard-table">
649
+ <caption className="tf-sidebar-sr">
650
+ {p.course} {half ? t.scoreLabels.in : t.scoreLabels.out}
651
+ </caption>
652
+ <thead>
653
+ <tr>
654
+ <th scope="row">{t.scoreLabels.hole}</th>
655
+ {hs.map((_, i) => (
656
+ <th key={i} scope="col">
657
+ {first + i + 1}
658
+ </th>
659
+ ))}
660
+ <th scope="col">
661
+ {half
662
+ ? t.scoreLabels.in
663
+ : halves.length > 1
664
+ ? t.scoreLabels.out
665
+ : t.scoreLabels.total}
666
+ </th>
667
+ {half === 1 && <th scope="col">{t.scoreLabels.total}</th>}
668
+ </tr>
669
+ </thead>
670
+ <tbody>
671
+ <tr className="tf-scorecard-par">
672
+ <th scope="row">{t.scoreLabels.par}</th>
673
+ {hs.map((h, i) => (
674
+ <td key={i}>{h.par}</td>
675
+ ))}
676
+ <td>{sum(hs, "par")}</td>
677
+ {half === 1 && <td>{sum(p.holes, "par")}</td>}
678
+ </tr>
679
+ <tr>
680
+ <th scope="row">{t.scoreLabels.score}</th>
681
+ {hs.map((h, i) => {
682
+ if (h.strokes == null) return <td key={i} />;
683
+ const n = scoreName(h.strokes - h.par);
684
+ return (
685
+ <td key={i}>
686
+ <span className={cx("tf-score", "is-" + n)} aria-hidden>
687
+ {h.strokes}
688
+ </span>
689
+ <span className="tf-sidebar-sr">
690
+ {h.strokes}, {t.scoreNames[n]}
691
+ </span>
692
+ </td>
693
+ );
694
+ })}
695
+ <td>
696
+ <b>{sum(hs, "strokes")}</b>
697
+ </td>
698
+ {half === 1 && (
699
+ <td>
700
+ <b>{strokes}</b>
701
+ </td>
702
+ )}
703
+ </tr>
704
+ {hasPutts && (
705
+ <tr className="tf-scorecard-putts">
706
+ <th scope="row">{t.scoreLabels.putts}</th>
707
+ {hs.map((h, i) => (
708
+ <td key={i}>{h.putts ?? ""}</td>
709
+ ))}
710
+ <td>{sum(hs, "putts")}</td>
711
+ {half === 1 && <td>{sum(p.holes, "putts")}</td>}
712
+ </tr>
713
+ )}
714
+ </tbody>
715
+ </table>
716
+ </div>
717
+ );
718
+ })}
719
+ <ul className="tf-scorecard-legend" aria-hidden>
720
+ {(["eagle", "birdie", "bogey", "double"] as const).map((n) => (
721
+ <li key={n}>
722
+ <span className={cx("tf-score", "is-" + n)}> </span>
723
+ {t.scoreNames[n]}
724
+ </li>
725
+ ))}
726
+ </ul>
727
+ </div>
728
+ );
729
+ }
730
+
731
+ /** Positions in a portfolio: value, change over a period in words and colour, and their share. */
732
+ export function HoldingsList(p: HoldingsListProps) {
733
+ const t = useStrings(CONNECTED_DATA_STRINGS);
734
+ const { formatMoney, formatPercent } = useFormat();
735
+ return (
736
+ <ul
737
+ style={p.style}
738
+ className={cx("tf-holdings", p.className)}
739
+ aria-label={p.label || t.holdings}
740
+ >
741
+ {p.holdings.map((h) => {
742
+ const dir = h.change == null || h.change === 0 ? "flat" : h.change > 0 ? "up" : "down";
743
+ const body = (
744
+ <>
745
+ <span className="tf-option-text">
746
+ <b>{h.name}</b>
747
+ {h.symbol && <small>{h.symbol}</small>}
748
+ </span>
749
+ <span className="tf-holding-nums">
750
+ <b>{formatMoney(h.value, p.currency, { maximumFractionDigits: 0 })}</b>
751
+ {h.change != null && (
752
+ <small className={cx("tf-holding-change", "is-" + dir)}>
753
+ {dir !== "flat" && <Icon name="arrowUp" size={11} />}
754
+ {formatPercent(h.change, { signDisplay: "exceptZero", maximumFractionDigits: 1 })}
755
+ {p.period && " " + p.period}
756
+ </small>
757
+ )}
758
+ </span>
759
+ {h.weight != null && (
760
+ <span className="tf-holding-weight">
761
+ <span className="tf-holding-bar" aria-hidden>
762
+ <span style={{ width: Math.min(100, h.weight) + "%" }} />
763
+ </span>
764
+ <small>{t.portfolioShare(formatPercent(h.weight))}</small>
765
+ </span>
766
+ )}
767
+ </>
768
+ );
769
+ return (
770
+ <li key={h.id}>
771
+ {p.onSelect ? (
772
+ <button type="button" className="tf-holding" onClick={() => p.onSelect?.(h.id)}>
773
+ {body}
774
+ </button>
775
+ ) : (
776
+ <div className="tf-holding">{body}</div>
777
+ )}
778
+ </li>
779
+ );
780
+ })}
781
+ </ul>
782
+ );
783
+ }