@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.
- package/ICONS-LICENSE +24 -2
- package/dist/components/connected-data.d.ts +25 -0
- package/dist/components/connected-data.d.ts.map +1 -0
- package/dist/components/connected-data.js +264 -0
- package/dist/components/connected-data.js.map +1 -0
- package/dist/components/connectors.d.ts +22 -0
- package/dist/components/connectors.d.ts.map +1 -0
- package/dist/components/connectors.js +135 -0
- package/dist/components/connectors.js.map +1 -0
- package/dist/components/household.d.ts +19 -0
- package/dist/components/household.d.ts.map +1 -0
- package/dist/components/household.js +137 -0
- package/dist/components/household.js.map +1 -0
- package/dist/components/index.d.ts +4 -0
- package/dist/components/index.d.ts.map +1 -1
- package/dist/components/index.js +4 -0
- package/dist/components/index.js.map +1 -1
- package/dist/components/life.d.ts +26 -0
- package/dist/components/life.d.ts.map +1 -0
- package/dist/components/life.js +187 -0
- package/dist/components/life.js.map +1 -0
- package/dist/components/types.d.ts +622 -0
- package/dist/components/types.d.ts.map +1 -1
- package/dist/context.d.ts +1 -0
- package/dist/context.d.ts.map +1 -1
- package/dist/context.js +2 -1
- package/dist/context.js.map +1 -1
- package/dist/format.d.ts +5 -0
- package/dist/format.d.ts.map +1 -1
- package/dist/format.js +31 -0
- package/dist/format.js.map +1 -1
- package/dist/icon-library.d.ts +235 -4
- package/dist/icon-library.d.ts.map +1 -1
- package/dist/icon-library.js +467 -15
- package/dist/icon-library.js.map +1 -1
- package/dist/icons.d.ts +2 -0
- package/dist/icons.d.ts.map +1 -1
- package/dist/icons.js.map +1 -1
- package/dist/index.d.ts +2 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -2
- package/dist/index.js.map +1 -1
- package/dist/strings.d.ts +184 -0
- package/dist/strings.d.ts.map +1 -1
- package/dist/strings.js +182 -1
- package/dist/strings.js.map +1 -1
- package/dist/themes.css +16438 -11238
- package/package.json +1 -1
- package/src/components/connected-data.tsx +783 -0
- package/src/components/connectors.tsx +426 -0
- package/src/components/household.tsx +489 -0
- package/src/components/index.ts +39 -0
- package/src/components/life.tsx +679 -0
- package/src/components/types.ts +602 -0
- package/src/context.ts +10 -1
- package/src/format.ts +33 -0
- package/src/icon-library.ts +476 -19
- package/src/icons.ts +2 -0
- package/src/index.ts +5 -0
- package/src/strings.ts +518 -0
|
@@ -0,0 +1,679 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import * as React from "react";
|
|
3
|
+
import { cx } from "../cx.js";
|
|
4
|
+
import { useFormat, useStrings } from "../context.js";
|
|
5
|
+
import { LIFE_STRINGS } from "../strings.js";
|
|
6
|
+
import { Icon } from "../Icon.js";
|
|
7
|
+
import { registerIcons } from "../icons.js";
|
|
8
|
+
import {
|
|
9
|
+
angry,
|
|
10
|
+
flame,
|
|
11
|
+
frown,
|
|
12
|
+
heartPulse,
|
|
13
|
+
laugh,
|
|
14
|
+
meh,
|
|
15
|
+
pill,
|
|
16
|
+
smile,
|
|
17
|
+
target,
|
|
18
|
+
} from "../icon-library.js";
|
|
19
|
+
import { useControlled, useId } from "../hooks.js";
|
|
20
|
+
import { Badge, Button, IconButton, TintIcon } from "./foundation.js";
|
|
21
|
+
import { Sparkline } from "./charts.js";
|
|
22
|
+
import { Switch } from "./inputs.js";
|
|
23
|
+
import { ServiceMark } from "./connectors.js";
|
|
24
|
+
import type {
|
|
25
|
+
ActivityRingsProps,
|
|
26
|
+
ConsentListProps,
|
|
27
|
+
GoalCardProps,
|
|
28
|
+
HabitTrackerProps,
|
|
29
|
+
IconName,
|
|
30
|
+
InsightCardProps,
|
|
31
|
+
JournalEntryProps,
|
|
32
|
+
MedicationListProps,
|
|
33
|
+
MoodLevel,
|
|
34
|
+
MoodPickerProps,
|
|
35
|
+
SleepChartProps,
|
|
36
|
+
SleepStage,
|
|
37
|
+
Tint,
|
|
38
|
+
VitalTileProps,
|
|
39
|
+
} from "./types.js";
|
|
40
|
+
|
|
41
|
+
// the faces, the streak flame and the default icons come from the icon library
|
|
42
|
+
registerIcons({ angry, flame, frown, heartPulse, laugh, meh, pill, smile, target });
|
|
43
|
+
|
|
44
|
+
const MOOD_ICON: Record<MoodLevel, IconName> = {
|
|
45
|
+
1: "angry",
|
|
46
|
+
2: "frown",
|
|
47
|
+
3: "meh",
|
|
48
|
+
4: "smile",
|
|
49
|
+
5: "laugh",
|
|
50
|
+
};
|
|
51
|
+
const MOOD_TINT: Record<MoodLevel, Tint> = {
|
|
52
|
+
1: "blush",
|
|
53
|
+
2: "butter",
|
|
54
|
+
3: "sky",
|
|
55
|
+
4: "mint",
|
|
56
|
+
5: "lilac",
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
/* ── HabitTracker ── */
|
|
60
|
+
|
|
61
|
+
/** Habits with their last days as a row of marks, today's mark is a toggle. */
|
|
62
|
+
export function HabitTracker(p: HabitTrackerProps) {
|
|
63
|
+
const t = useStrings(LIFE_STRINGS);
|
|
64
|
+
const { formatDate } = useFormat();
|
|
65
|
+
const n = Math.max(0, ...p.habits.map((h) => h.days.length));
|
|
66
|
+
const labels =
|
|
67
|
+
p.dayLabels ||
|
|
68
|
+
Array.from({ length: n }, (_, i) => {
|
|
69
|
+
const d = new Date();
|
|
70
|
+
d.setDate(d.getDate() - (n - 1 - i));
|
|
71
|
+
return formatDate(d, { weekday: "narrow" });
|
|
72
|
+
});
|
|
73
|
+
return (
|
|
74
|
+
<div
|
|
75
|
+
style={p.style}
|
|
76
|
+
className={cx("tf-habits", p.className)}
|
|
77
|
+
role="list"
|
|
78
|
+
aria-label={p.label || t.habits}
|
|
79
|
+
>
|
|
80
|
+
<div className="tf-habit-days" aria-hidden>
|
|
81
|
+
{labels.map((l, i) => (
|
|
82
|
+
<span key={i}>{l}</span>
|
|
83
|
+
))}
|
|
84
|
+
</div>
|
|
85
|
+
{p.habits.map((h) => {
|
|
86
|
+
const today = h.days[h.days.length - 1];
|
|
87
|
+
return (
|
|
88
|
+
<div key={h.id} className="tf-habit" role="listitem">
|
|
89
|
+
<TintIcon icon={h.icon || "check"} tint={h.tint || "mint"} size={32} />
|
|
90
|
+
<div className="tf-habit-text">
|
|
91
|
+
<b>{h.label}</b>
|
|
92
|
+
<small>
|
|
93
|
+
{h.streak ? (
|
|
94
|
+
<span className="tf-habit-streak">
|
|
95
|
+
<Icon name="flame" size={12} />
|
|
96
|
+
{t.habitStreak(h.streak)}
|
|
97
|
+
</span>
|
|
98
|
+
) : null}
|
|
99
|
+
{h.goal && <span>{h.goal}</span>}
|
|
100
|
+
</small>
|
|
101
|
+
</div>
|
|
102
|
+
<div className="tf-habit-marks">
|
|
103
|
+
{h.days.map((d, i) => {
|
|
104
|
+
const last = i === h.days.length - 1;
|
|
105
|
+
const state =
|
|
106
|
+
d === true ? t.habitDone : d === false ? t.habitMissed : t.habitNoData;
|
|
107
|
+
const cls = cx("tf-habit-mark", d === true && "done", d === false && "missed");
|
|
108
|
+
return last && p.onToggle ? (
|
|
109
|
+
<button
|
|
110
|
+
key={i}
|
|
111
|
+
type="button"
|
|
112
|
+
className={cx(cls, "today")}
|
|
113
|
+
aria-pressed={today === true}
|
|
114
|
+
aria-label={t.markHabitDone(h.label)}
|
|
115
|
+
onClick={() => p.onToggle?.(h.id, today !== true)}
|
|
116
|
+
>
|
|
117
|
+
{d === true && <Icon name="check" size={12} />}
|
|
118
|
+
{d === false && <Icon name="x" size={10} />}
|
|
119
|
+
</button>
|
|
120
|
+
) : (
|
|
121
|
+
<span
|
|
122
|
+
key={i}
|
|
123
|
+
className={cx(cls, last && "today")}
|
|
124
|
+
role="img"
|
|
125
|
+
aria-label={`${labels[i] ?? ""} ${state}`.trim()}
|
|
126
|
+
>
|
|
127
|
+
{d === true && <Icon name="check" size={12} />}
|
|
128
|
+
{d === false && <Icon name="x" size={10} />}
|
|
129
|
+
</span>
|
|
130
|
+
);
|
|
131
|
+
})}
|
|
132
|
+
</div>
|
|
133
|
+
</div>
|
|
134
|
+
);
|
|
135
|
+
})}
|
|
136
|
+
</div>
|
|
137
|
+
);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/* ── MoodPicker ── */
|
|
141
|
+
|
|
142
|
+
/** A five-step mood scale with faces and words (a radio group). */
|
|
143
|
+
export function MoodPicker(p: MoodPickerProps) {
|
|
144
|
+
const t = useStrings(LIFE_STRINGS);
|
|
145
|
+
const v = useControlled<MoodLevel | null>(p.value, p.defaultValue ?? null, (m) => {
|
|
146
|
+
if (m) p.onChange?.(m);
|
|
147
|
+
});
|
|
148
|
+
const name = useId("mood");
|
|
149
|
+
const words = p.levels || t.moodLevels;
|
|
150
|
+
return (
|
|
151
|
+
<fieldset style={p.style} className={cx("tf-mood", p.compact && "compact", p.className)}>
|
|
152
|
+
<legend className="tf-mood-q">{p.label || t.howAreYou}</legend>
|
|
153
|
+
<div className="tf-mood-scale">
|
|
154
|
+
{([1, 2, 3, 4, 5] as MoodLevel[]).map((m) => (
|
|
155
|
+
<label
|
|
156
|
+
key={m}
|
|
157
|
+
className={cx("tf-mood-opt", v[0] === m && "on", v[0] === m && "tf-t-" + MOOD_TINT[m])}
|
|
158
|
+
>
|
|
159
|
+
<input
|
|
160
|
+
type="radio"
|
|
161
|
+
name={name}
|
|
162
|
+
checked={v[0] === m}
|
|
163
|
+
onChange={() => v[1](m)}
|
|
164
|
+
aria-label={p.compact ? words[m - 1] : undefined}
|
|
165
|
+
/>
|
|
166
|
+
<Icon name={MOOD_ICON[m]} size={22} />
|
|
167
|
+
{!p.compact && <span>{words[m - 1]}</span>}
|
|
168
|
+
</label>
|
|
169
|
+
))}
|
|
170
|
+
</div>
|
|
171
|
+
</fieldset>
|
|
172
|
+
);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/* ── VitalTile ── */
|
|
176
|
+
|
|
177
|
+
/** One health value with its healthy range drawn as a band and a status in words. */
|
|
178
|
+
export function VitalTile(p: VitalTileProps) {
|
|
179
|
+
const t = useStrings(LIFE_STRINGS);
|
|
180
|
+
const { formatNumber } = useFormat();
|
|
181
|
+
const num = (n: number) =>
|
|
182
|
+
formatNumber(n, { minimumFractionDigits: p.digits || 0, maximumFractionDigits: p.digits || 0 });
|
|
183
|
+
const [lo, hi] = p.normal || [p.value, p.value];
|
|
184
|
+
const pad = Math.max((hi - lo) * 0.6, Math.abs(p.value) * 0.05, 1);
|
|
185
|
+
const [min, max] = p.scale || [Math.min(lo - pad, p.value), Math.max(hi + pad, p.value)];
|
|
186
|
+
const pos = (x: number) => Math.max(0, Math.min(100, ((x - min) / (max - min || 1)) * 100));
|
|
187
|
+
const st = !p.normal ? null : p.value < lo ? "low" : p.value > hi ? "high" : "ok";
|
|
188
|
+
return (
|
|
189
|
+
<div
|
|
190
|
+
style={p.style}
|
|
191
|
+
className={cx("tf-vital", p.surface && "tf-" + p.surface, st && "is-" + st, p.className)}
|
|
192
|
+
>
|
|
193
|
+
<div className="tf-vital-head">
|
|
194
|
+
<span className="tf-vital-label">
|
|
195
|
+
<Icon name={p.icon || "heartPulse"} size={14} />
|
|
196
|
+
{p.label}
|
|
197
|
+
</span>
|
|
198
|
+
{st && (
|
|
199
|
+
<Badge tone={st === "ok" ? "success" : "warning"}>
|
|
200
|
+
{st === "ok" ? t.inRange : st === "low" ? t.belowRange : t.aboveRange}
|
|
201
|
+
</Badge>
|
|
202
|
+
)}
|
|
203
|
+
</div>
|
|
204
|
+
<div className="tf-vital-main">
|
|
205
|
+
<span className="tf-vital-value">
|
|
206
|
+
{num(p.value)}
|
|
207
|
+
{p.unit && <small>{p.unit}</small>}
|
|
208
|
+
</span>
|
|
209
|
+
{p.trend && p.trend.length > 1 && (
|
|
210
|
+
<Sparkline
|
|
211
|
+
values={p.trend}
|
|
212
|
+
width={88}
|
|
213
|
+
height={28}
|
|
214
|
+
tint={st === "ok" || !st ? "mint" : "butter"}
|
|
215
|
+
/>
|
|
216
|
+
)}
|
|
217
|
+
</div>
|
|
218
|
+
{p.normal && (
|
|
219
|
+
<div className="tf-vital-band" aria-hidden>
|
|
220
|
+
<span
|
|
221
|
+
className="tf-vital-normal"
|
|
222
|
+
style={{ left: pos(lo) + "%", width: pos(hi) - pos(lo) + "%" }}
|
|
223
|
+
/>
|
|
224
|
+
<span className="tf-vital-dot" style={{ left: pos(p.value) + "%" }} />
|
|
225
|
+
</div>
|
|
226
|
+
)}
|
|
227
|
+
<div className="tf-vital-foot">
|
|
228
|
+
{p.normal && <span>{t.normalRange(num(lo), num(hi))}</span>}
|
|
229
|
+
{p.caption && <span>{p.caption}</span>}
|
|
230
|
+
</div>
|
|
231
|
+
</div>
|
|
232
|
+
);
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/* ── ActivityRings ── */
|
|
236
|
+
|
|
237
|
+
/** Concentric progress rings for daily goals (move, exercise, stand …) with a legend. */
|
|
238
|
+
export function ActivityRings(p: ActivityRingsProps) {
|
|
239
|
+
const t = useStrings(LIFE_STRINGS);
|
|
240
|
+
const { formatNumber } = useFormat();
|
|
241
|
+
const size = p.size || 132,
|
|
242
|
+
sw = Math.max(8, Math.round(size / 11)),
|
|
243
|
+
gap = 3;
|
|
244
|
+
const tints: Tint[] = ["blush", "mint", "sky", "lilac", "butter"];
|
|
245
|
+
const text = (r: ActivityRingsProps["rings"][number]) =>
|
|
246
|
+
t.ofGoal(formatNumber(r.value), formatNumber(r.goal)) + (r.unit ? " " + r.unit : "");
|
|
247
|
+
return (
|
|
248
|
+
<div style={p.style} className={cx("tf-rings", p.className)}>
|
|
249
|
+
<svg
|
|
250
|
+
width={size}
|
|
251
|
+
height={size}
|
|
252
|
+
viewBox={`0 0 ${size} ${size}`}
|
|
253
|
+
role="img"
|
|
254
|
+
aria-label={`${p.label || t.activity}: ${p.rings.map((r) => `${r.label} ${text(r)}`).join(", ")}`}
|
|
255
|
+
>
|
|
256
|
+
{p.rings.map((r, i) => {
|
|
257
|
+
const rad = size / 2 - sw / 2 - i * (sw + gap);
|
|
258
|
+
if (rad <= sw / 2) return null;
|
|
259
|
+
const c = 2 * Math.PI * rad,
|
|
260
|
+
k = Math.max(0, Math.min(1, r.goal ? r.value / r.goal : 0));
|
|
261
|
+
const tint = r.tint || tints[i % tints.length];
|
|
262
|
+
return (
|
|
263
|
+
<g key={r.label}>
|
|
264
|
+
<circle
|
|
265
|
+
className="tf-arc-track"
|
|
266
|
+
cx={size / 2}
|
|
267
|
+
cy={size / 2}
|
|
268
|
+
r={rad}
|
|
269
|
+
strokeWidth={sw}
|
|
270
|
+
/>
|
|
271
|
+
<circle
|
|
272
|
+
className={cx("tf-arc", "tf-arc-" + tint)}
|
|
273
|
+
cx={size / 2}
|
|
274
|
+
cy={size / 2}
|
|
275
|
+
r={rad}
|
|
276
|
+
strokeWidth={sw}
|
|
277
|
+
strokeDasharray={c}
|
|
278
|
+
strokeDashoffset={c * (1 - k)}
|
|
279
|
+
/>
|
|
280
|
+
</g>
|
|
281
|
+
);
|
|
282
|
+
})}
|
|
283
|
+
</svg>
|
|
284
|
+
{p.legend !== false && (
|
|
285
|
+
<ul className="tf-rings-legend" aria-hidden>
|
|
286
|
+
{p.rings.map((r, i) => (
|
|
287
|
+
<li key={r.label}>
|
|
288
|
+
<span
|
|
289
|
+
className={cx("tf-rings-key", "tf-sf-" + (r.tint || tints[i % tints.length]))}
|
|
290
|
+
/>
|
|
291
|
+
<span className="tf-rings-name">{r.label}</span>
|
|
292
|
+
<b>{text(r)}</b>
|
|
293
|
+
</li>
|
|
294
|
+
))}
|
|
295
|
+
</ul>
|
|
296
|
+
)}
|
|
297
|
+
</div>
|
|
298
|
+
);
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
/* ── SleepChart ── */
|
|
302
|
+
|
|
303
|
+
const STAGES: SleepStage[] = ["awake", "rem", "light", "deep"];
|
|
304
|
+
const STAGE_FILL: Record<SleepStage, Tint> = {
|
|
305
|
+
awake: "butter",
|
|
306
|
+
rem: "lilac",
|
|
307
|
+
light: "sky",
|
|
308
|
+
deep: "mint",
|
|
309
|
+
};
|
|
310
|
+
|
|
311
|
+
/** A night as stages over time (one lane per stage) with the time asleep per stage. */
|
|
312
|
+
export function SleepChart(p: SleepChartProps) {
|
|
313
|
+
const t = useStrings(LIFE_STRINGS);
|
|
314
|
+
const { formatDate } = useFormat();
|
|
315
|
+
const ms = (d: Date | string | number) => new Date(d).getTime();
|
|
316
|
+
const segs = p.segments.filter((s) => ms(s.end) > ms(s.start));
|
|
317
|
+
const start = Math.min(...segs.map((s) => ms(s.start))),
|
|
318
|
+
end = Math.max(...segs.map((s) => ms(s.end))),
|
|
319
|
+
span = end - start || 1;
|
|
320
|
+
const total: Record<SleepStage, number> = { awake: 0, rem: 0, light: 0, deep: 0 };
|
|
321
|
+
segs.forEach((s) => (total[s.stage] += ms(s.end) - ms(s.start)));
|
|
322
|
+
const dur = (m: number) => {
|
|
323
|
+
const min = Math.round(m / 60000);
|
|
324
|
+
return t.durationHm(Math.floor(min / 60), min % 60);
|
|
325
|
+
};
|
|
326
|
+
const asleep = total.rem + total.light + total.deep;
|
|
327
|
+
const time = (x: number) => formatDate(x, { hour: "numeric", minute: "2-digit" });
|
|
328
|
+
return (
|
|
329
|
+
<div style={p.style} className={cx("tf-sleep", p.className)}>
|
|
330
|
+
<div
|
|
331
|
+
className="tf-sleep-chart"
|
|
332
|
+
role="img"
|
|
333
|
+
aria-label={`${p.label || t.sleepStages}: ${t.totalSleep(dur(asleep))}, ${STAGES.map(
|
|
334
|
+
(s) => `${t.sleepStageNames[s]} ${dur(total[s])}`,
|
|
335
|
+
).join(", ")}`}
|
|
336
|
+
>
|
|
337
|
+
{STAGES.map((stage) => (
|
|
338
|
+
<div key={stage} className="tf-sleep-lane">
|
|
339
|
+
<span className="tf-sleep-name">{t.sleepStageNames[stage]}</span>
|
|
340
|
+
<span className="tf-sleep-track">
|
|
341
|
+
{segs
|
|
342
|
+
.filter((s) => s.stage === stage)
|
|
343
|
+
.map((s, i) => (
|
|
344
|
+
<span
|
|
345
|
+
key={i}
|
|
346
|
+
className={cx("tf-sleep-seg", "tf-sf-" + STAGE_FILL[stage])}
|
|
347
|
+
style={{
|
|
348
|
+
left: ((ms(s.start) - start) / span) * 100 + "%",
|
|
349
|
+
width: ((ms(s.end) - ms(s.start)) / span) * 100 + "%",
|
|
350
|
+
}}
|
|
351
|
+
/>
|
|
352
|
+
))}
|
|
353
|
+
</span>
|
|
354
|
+
</div>
|
|
355
|
+
))}
|
|
356
|
+
<div className="tf-sleep-axis" aria-hidden>
|
|
357
|
+
<span>{time(start)}</span>
|
|
358
|
+
<span>{time(end)}</span>
|
|
359
|
+
</div>
|
|
360
|
+
</div>
|
|
361
|
+
{p.summary !== false && (
|
|
362
|
+
<div className="tf-sleep-sum">
|
|
363
|
+
<b>{t.totalSleep(dur(asleep))}</b>
|
|
364
|
+
<ul>
|
|
365
|
+
{STAGES.map((s) => (
|
|
366
|
+
<li key={s}>
|
|
367
|
+
<span className={cx("tf-rings-key", "tf-sf-" + STAGE_FILL[s])} />
|
|
368
|
+
{t.sleepStageNames[s]}
|
|
369
|
+
<span>{dur(total[s])}</span>
|
|
370
|
+
</li>
|
|
371
|
+
))}
|
|
372
|
+
</ul>
|
|
373
|
+
</div>
|
|
374
|
+
)}
|
|
375
|
+
</div>
|
|
376
|
+
);
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
/* ── MedicationList ── */
|
|
380
|
+
|
|
381
|
+
/** Today's doses in order, each with its status in words and a "taken" button. */
|
|
382
|
+
export function MedicationList(p: MedicationListProps) {
|
|
383
|
+
const t = useStrings(LIFE_STRINGS);
|
|
384
|
+
const words = { taken: t.medTaken, due: t.medDue, missed: t.medMissed, upcoming: t.medUpcoming };
|
|
385
|
+
return (
|
|
386
|
+
<ul style={p.style} className={cx("tf-meds", p.className)} aria-label={p.label || t.medication}>
|
|
387
|
+
{p.items.map((m) => {
|
|
388
|
+
const st = m.status || (m.taken ? "taken" : "due");
|
|
389
|
+
return (
|
|
390
|
+
<li key={m.id} className={cx("tf-med", "is-" + st)}>
|
|
391
|
+
<span className="tf-med-time">{m.time}</span>
|
|
392
|
+
<TintIcon
|
|
393
|
+
icon={m.icon || "pill"}
|
|
394
|
+
tint={st === "missed" ? "blush" : st === "taken" ? "mint" : "sky"}
|
|
395
|
+
size={32}
|
|
396
|
+
/>
|
|
397
|
+
<span className="tf-med-text">
|
|
398
|
+
<b>{m.name}</b>
|
|
399
|
+
{(m.dose || m.note) && <small>{[m.dose, m.note].filter(Boolean).join(" · ")}</small>}
|
|
400
|
+
</span>
|
|
401
|
+
{st === "taken" ? (
|
|
402
|
+
<Badge tone="success">{words.taken}</Badge>
|
|
403
|
+
) : (
|
|
404
|
+
<span className="tf-med-act">
|
|
405
|
+
<Badge tone={st === "missed" ? "danger" : st === "due" ? "warning" : undefined}>
|
|
406
|
+
{words[st]}
|
|
407
|
+
</Badge>
|
|
408
|
+
{p.onTake && st !== "upcoming" && (
|
|
409
|
+
<IconButton
|
|
410
|
+
icon="check"
|
|
411
|
+
label={t.markTaken(m.name)}
|
|
412
|
+
size="sm"
|
|
413
|
+
onClick={() => p.onTake?.(m.id)}
|
|
414
|
+
/>
|
|
415
|
+
)}
|
|
416
|
+
</span>
|
|
417
|
+
)}
|
|
418
|
+
</li>
|
|
419
|
+
);
|
|
420
|
+
})}
|
|
421
|
+
</ul>
|
|
422
|
+
);
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
/* ── JournalEntry ── */
|
|
426
|
+
|
|
427
|
+
/** A journal or diary entry: date, mood, title, text and tags. */
|
|
428
|
+
export function JournalEntry(p: JournalEntryProps) {
|
|
429
|
+
const t = useStrings(LIFE_STRINGS);
|
|
430
|
+
const { formatDate } = useFormat();
|
|
431
|
+
return (
|
|
432
|
+
<article
|
|
433
|
+
style={p.style}
|
|
434
|
+
className={cx("tf-journal", p.surface && "tf-" + p.surface, p.className)}
|
|
435
|
+
>
|
|
436
|
+
<header className="tf-journal-head">
|
|
437
|
+
<time dateTime={new Date(p.date).toISOString()}>
|
|
438
|
+
{formatDate(p.date, { weekday: "long", day: "numeric", month: "long" })}
|
|
439
|
+
</time>
|
|
440
|
+
{p.mood && (
|
|
441
|
+
<span className={cx("tf-journal-mood", "tf-t-" + MOOD_TINT[p.mood])}>
|
|
442
|
+
<Icon name={MOOD_ICON[p.mood]} size={14} />
|
|
443
|
+
{t.moodLevels[p.mood - 1]}
|
|
444
|
+
</span>
|
|
445
|
+
)}
|
|
446
|
+
</header>
|
|
447
|
+
{p.title && <h3 className="tf-journal-title">{p.title}</h3>}
|
|
448
|
+
{p.children && <div className="tf-journal-body">{p.children}</div>}
|
|
449
|
+
{(p.tags?.length || p.actions) && (
|
|
450
|
+
<footer className="tf-journal-foot">
|
|
451
|
+
<span className="tf-journal-tags">
|
|
452
|
+
{(p.tags || []).map((g) => (
|
|
453
|
+
<Badge key={g}>{g}</Badge>
|
|
454
|
+
))}
|
|
455
|
+
</span>
|
|
456
|
+
{p.actions}
|
|
457
|
+
</footer>
|
|
458
|
+
)}
|
|
459
|
+
</article>
|
|
460
|
+
);
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
/* ── GoalCard ── */
|
|
464
|
+
|
|
465
|
+
/** A goal with its progress toward a target, a due date and milestones. */
|
|
466
|
+
export function GoalCard(p: GoalCardProps) {
|
|
467
|
+
const t = useStrings(LIFE_STRINGS);
|
|
468
|
+
const { formatNumber, formatMoney, formatPercent, formatDate } = useFormat();
|
|
469
|
+
const pct = Math.max(0, Math.min(100, p.target ? (p.value / p.target) * 100 : 0));
|
|
470
|
+
const show = (n: number) =>
|
|
471
|
+
p.format === "money"
|
|
472
|
+
? formatMoney(n, p.currency, { maximumFractionDigits: 0 })
|
|
473
|
+
: p.format === "percent"
|
|
474
|
+
? formatPercent(n)
|
|
475
|
+
: formatNumber(n) + (p.unit ? " " + p.unit : "");
|
|
476
|
+
return (
|
|
477
|
+
<div style={p.style} className={cx("tf-goal", p.surface && "tf-" + p.surface, p.className)}>
|
|
478
|
+
<div className="tf-goal-head">
|
|
479
|
+
<TintIcon icon={p.icon || "target"} tint={p.tint || "lilac"} size={36} />
|
|
480
|
+
<div className="tf-option-text">
|
|
481
|
+
<b>{p.title}</b>
|
|
482
|
+
{p.due != null && (
|
|
483
|
+
<small>{t.dueBy(formatDate(p.due, { month: "short", year: "numeric" }))}</small>
|
|
484
|
+
)}
|
|
485
|
+
</div>
|
|
486
|
+
<span className="tf-goal-pct">{formatPercent(Math.round(pct))}</span>
|
|
487
|
+
</div>
|
|
488
|
+
<div
|
|
489
|
+
className="tf-goal-bar"
|
|
490
|
+
role="progressbar"
|
|
491
|
+
aria-label={p.title}
|
|
492
|
+
aria-valuemin={0}
|
|
493
|
+
aria-valuemax={100}
|
|
494
|
+
aria-valuenow={Math.round(pct)}
|
|
495
|
+
aria-valuetext={t.ofGoal(show(p.value), show(p.target))}
|
|
496
|
+
>
|
|
497
|
+
<span className={cx("tf-sf-" + (p.tint || "lilac"))} style={{ width: pct + "%" }} />
|
|
498
|
+
</div>
|
|
499
|
+
<div className="tf-goal-meta">{t.ofGoal(show(p.value), show(p.target))}</div>
|
|
500
|
+
{p.milestones && p.milestones.length > 0 && (
|
|
501
|
+
<ul className="tf-goal-steps">
|
|
502
|
+
{p.milestones.map((m, i) => (
|
|
503
|
+
<li key={i} className={cx(m.done && "done")}>
|
|
504
|
+
<span className="tf-goal-step" aria-hidden>
|
|
505
|
+
{m.done && <Icon name="check" size={10} />}
|
|
506
|
+
</span>
|
|
507
|
+
{m.label}
|
|
508
|
+
</li>
|
|
509
|
+
))}
|
|
510
|
+
</ul>
|
|
511
|
+
)}
|
|
512
|
+
{p.note && <p className="tf-goal-note">{p.note}</p>}
|
|
513
|
+
</div>
|
|
514
|
+
);
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
/* ── InsightCard ── */
|
|
518
|
+
|
|
519
|
+
const INSIGHT_TINT = {
|
|
520
|
+
info: "sky",
|
|
521
|
+
success: "mint",
|
|
522
|
+
warning: "butter",
|
|
523
|
+
highlight: "lilac",
|
|
524
|
+
} as const;
|
|
525
|
+
|
|
526
|
+
/**
|
|
527
|
+
* Something the assistant noticed, with where it comes from, why it is shown and what to do:
|
|
528
|
+
* accept, snooze or dismiss.
|
|
529
|
+
*/
|
|
530
|
+
export function InsightCard(p: InsightCardProps) {
|
|
531
|
+
const t = useStrings(LIFE_STRINGS);
|
|
532
|
+
const [why, setWhy] = React.useState(false);
|
|
533
|
+
const id = useId("insight");
|
|
534
|
+
const tone = p.tone || "info";
|
|
535
|
+
return (
|
|
536
|
+
<section
|
|
537
|
+
style={p.style}
|
|
538
|
+
className={cx("tf-insight", p.surface && "tf-" + p.surface, "is-" + tone, p.className)}
|
|
539
|
+
aria-labelledby={`${id}-t`}
|
|
540
|
+
>
|
|
541
|
+
<div className="tf-insight-head">
|
|
542
|
+
<TintIcon icon={p.icon || "sparkle"} tint={INSIGHT_TINT[tone]} size={36} />
|
|
543
|
+
<div className="tf-insight-titles">
|
|
544
|
+
{p.meta && <small>{p.meta}</small>}
|
|
545
|
+
<b id={`${id}-t`}>{p.title}</b>
|
|
546
|
+
</div>
|
|
547
|
+
{p.onDismiss && (
|
|
548
|
+
<IconButton icon="x" label={t.dismiss} size="sm" variant="ghost" onClick={p.onDismiss} />
|
|
549
|
+
)}
|
|
550
|
+
</div>
|
|
551
|
+
{p.children && <div className="tf-insight-body">{p.children}</div>}
|
|
552
|
+
{p.sources && p.sources.length > 0 && (
|
|
553
|
+
<div className="tf-msg-sources">
|
|
554
|
+
{p.sources.map((s, i) => (
|
|
555
|
+
<a
|
|
556
|
+
key={i}
|
|
557
|
+
className="tf-source"
|
|
558
|
+
href={s.url || "#"}
|
|
559
|
+
title={s.title}
|
|
560
|
+
onClick={
|
|
561
|
+
s.onClick
|
|
562
|
+
? (e) => {
|
|
563
|
+
e.preventDefault();
|
|
564
|
+
s.onClick?.();
|
|
565
|
+
}
|
|
566
|
+
: undefined
|
|
567
|
+
}
|
|
568
|
+
>
|
|
569
|
+
<span className="tf-source-n">{i + 1}</span>
|
|
570
|
+
<Icon name={s.icon || "book"} size={12} />
|
|
571
|
+
<span className="tf-source-title">{s.title}</span>
|
|
572
|
+
</a>
|
|
573
|
+
))}
|
|
574
|
+
</div>
|
|
575
|
+
)}
|
|
576
|
+
{p.why && (
|
|
577
|
+
<div className="tf-insight-why">
|
|
578
|
+
<button
|
|
579
|
+
type="button"
|
|
580
|
+
aria-expanded={why}
|
|
581
|
+
aria-controls={`${id}-why`}
|
|
582
|
+
onClick={() => setWhy(!why)}
|
|
583
|
+
>
|
|
584
|
+
<Icon name="help" size={14} />
|
|
585
|
+
{t.whyInsight}
|
|
586
|
+
<Icon name="chevronDown" size={14} className="tf-insight-chev" />
|
|
587
|
+
</button>
|
|
588
|
+
{why && (
|
|
589
|
+
<div id={`${id}-why`} className="tf-insight-whytext">
|
|
590
|
+
{p.why}
|
|
591
|
+
</div>
|
|
592
|
+
)}
|
|
593
|
+
</div>
|
|
594
|
+
)}
|
|
595
|
+
{(p.onAccept || p.onSnooze) && (
|
|
596
|
+
<div className="tf-insight-actions">
|
|
597
|
+
{p.onSnooze && (
|
|
598
|
+
<Button variant="ghost" size="sm" icon="clock" onClick={p.onSnooze}>
|
|
599
|
+
{t.insightSnooze}
|
|
600
|
+
</Button>
|
|
601
|
+
)}
|
|
602
|
+
{p.onAccept && (
|
|
603
|
+
<Button variant="primary" size="sm" onClick={p.onAccept}>
|
|
604
|
+
{p.acceptLabel || t.insightAccept}
|
|
605
|
+
</Button>
|
|
606
|
+
)}
|
|
607
|
+
</div>
|
|
608
|
+
)}
|
|
609
|
+
</section>
|
|
610
|
+
);
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
/* ── ConsentList ── */
|
|
614
|
+
|
|
615
|
+
/** What each connected source lets the assistant read or do, switchable per scope, revocable. */
|
|
616
|
+
export function ConsentList(p: ConsentListProps) {
|
|
617
|
+
const t = useStrings(LIFE_STRINGS);
|
|
618
|
+
const { formatDate } = useFormat();
|
|
619
|
+
return (
|
|
620
|
+
<div
|
|
621
|
+
style={p.style}
|
|
622
|
+
className={cx("tf-consent", p.className)}
|
|
623
|
+
role="list"
|
|
624
|
+
aria-label={p.label || t.dataAccess}
|
|
625
|
+
>
|
|
626
|
+
{p.connectors.map((c) => (
|
|
627
|
+
<section
|
|
628
|
+
key={c.id}
|
|
629
|
+
className={cx("tf-consent-item", p.surface && "tf-" + p.surface)}
|
|
630
|
+
role="listitem"
|
|
631
|
+
aria-label={c.name}
|
|
632
|
+
>
|
|
633
|
+
<div className="tf-consent-head">
|
|
634
|
+
<ServiceMark name={c.name} icon={c.icon} logo={c.logo} tint="sky" decorative />
|
|
635
|
+
<div className="tf-option-text">
|
|
636
|
+
<b>{c.name}</b>
|
|
637
|
+
<small>
|
|
638
|
+
{c.lastUsed != null
|
|
639
|
+
? t.lastUsed(formatDate(c.lastUsed, { day: "numeric", month: "short" }))
|
|
640
|
+
: t.notUsedYet}
|
|
641
|
+
</small>
|
|
642
|
+
</div>
|
|
643
|
+
{p.onRevoke && (
|
|
644
|
+
<Button variant="secondary" size="sm" onClick={() => p.onRevoke?.(c.id)}>
|
|
645
|
+
{t.revokeAccess}
|
|
646
|
+
</Button>
|
|
647
|
+
)}
|
|
648
|
+
</div>
|
|
649
|
+
{c.purpose && <p className="tf-consent-purpose">{c.purpose}</p>}
|
|
650
|
+
<ul className="tf-consent-scopes">
|
|
651
|
+
{c.scopes.map((s) => (
|
|
652
|
+
<li key={s.id}>
|
|
653
|
+
<span className="tf-option-text">
|
|
654
|
+
<b>{s.label}</b>
|
|
655
|
+
{s.description && <small>{s.description}</small>}
|
|
656
|
+
{s.level && (
|
|
657
|
+
<span className={cx("tf-scope-level", "is-" + s.level)}>
|
|
658
|
+
{t.scopeLevels[s.level]}
|
|
659
|
+
{s.asksFirst && " · " + t.asksFirst}
|
|
660
|
+
</span>
|
|
661
|
+
)}
|
|
662
|
+
</span>
|
|
663
|
+
{s.required ? (
|
|
664
|
+
<span className="tf-consent-req">{t.requiredScope}</span>
|
|
665
|
+
) : (
|
|
666
|
+
<Switch
|
|
667
|
+
checked={s.granted}
|
|
668
|
+
label={`${c.name}: ${s.label}`}
|
|
669
|
+
onChange={(e) => p.onScopeChange?.(c.id, s.id, e.currentTarget.checked)}
|
|
670
|
+
/>
|
|
671
|
+
)}
|
|
672
|
+
</li>
|
|
673
|
+
))}
|
|
674
|
+
</ul>
|
|
675
|
+
</section>
|
|
676
|
+
))}
|
|
677
|
+
</div>
|
|
678
|
+
);
|
|
679
|
+
}
|