@dorsk/tsumikit 0.2.4 → 0.2.6
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.
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
// A timestamp you can read five ways and inspect in one place. Inline it
|
|
3
|
+
// renders in the chosen `mode` (date / time / datetime / relative / iso),
|
|
4
|
+
// in the viewer's zone or UTC via the `utc` flag; by default clicking it
|
|
5
|
+
// opens a popover that lays out the same instant as ISO-UTC, local, relative,
|
|
6
|
+
// the IANA zone and the unix epoch — so the one value answers "when,
|
|
7
|
+
// exactly?" without leaving the row. The popover is read-only
|
|
8
|
+
// unless you opt into `selectable`, which adds buttons to switch the inline
|
|
9
|
+
// display mode (handy in a demo / settings surface, rarely in a data row).
|
|
10
|
+
//
|
|
11
|
+
// All formatting is delegated to the pure helpers in `$lib/timestamp`; this
|
|
12
|
+
// component owns only the mode state, the live tick for relative mode, and
|
|
13
|
+
// the popover chrome. The trigger is a real <time datetime> element for
|
|
14
|
+
// machine-readability and a11y.
|
|
15
|
+
import Popover from './Popover.svelte';
|
|
16
|
+
import {
|
|
17
|
+
formatTimestamp,
|
|
18
|
+
localTimeZone,
|
|
19
|
+
relativeTime,
|
|
20
|
+
toDate,
|
|
21
|
+
toEpochSeconds,
|
|
22
|
+
toISO,
|
|
23
|
+
toLocale,
|
|
24
|
+
type TimeInput,
|
|
25
|
+
type TimestampMode
|
|
26
|
+
} from '../../timestamp';
|
|
27
|
+
|
|
28
|
+
type Tone = 'inherit' | 'default' | 'muted' | 'faint' | 'danger' | 'accent';
|
|
29
|
+
type Size = 'xs' | 'sm' | 'base' | 'md' | 'lg' | 'xl' | '2xl';
|
|
30
|
+
|
|
31
|
+
let {
|
|
32
|
+
value,
|
|
33
|
+
mode = 'datetime',
|
|
34
|
+
utc = false,
|
|
35
|
+
details = true,
|
|
36
|
+
selectable = false,
|
|
37
|
+
mono = false,
|
|
38
|
+
tone = 'muted',
|
|
39
|
+
size,
|
|
40
|
+
tickMs = 30_000
|
|
41
|
+
}: {
|
|
42
|
+
/** The instant: a Date, epoch milliseconds, or an ISO/parseable string.
|
|
43
|
+
* null/undefined (or unparseable input) renders an inert "—". */
|
|
44
|
+
value: TimeInput | null | undefined;
|
|
45
|
+
/** Inline display mode. Default 'datetime'. */
|
|
46
|
+
mode?: TimestampMode;
|
|
47
|
+
/** Render the date/time/datetime modes in UTC rather than the viewer's
|
|
48
|
+
* zone. Default false. Ignored by 'iso' (always UTC) and 'relative'
|
|
49
|
+
* (zoneless). Handy for calendar-date fields, where a local midnight-UTC
|
|
50
|
+
* value would otherwise show the wrong day. */
|
|
51
|
+
utc?: boolean;
|
|
52
|
+
/** Click to open the details popover (UTC / local / relative / zone /
|
|
53
|
+
* epoch). Default true. When false, renders as a bare inline <time>. */
|
|
54
|
+
details?: boolean;
|
|
55
|
+
/** Add mode-switch buttons inside the popover so the viewer can change the
|
|
56
|
+
* inline display mode. Default false. Implies `details`. */
|
|
57
|
+
selectable?: boolean;
|
|
58
|
+
/** Render in the monospace font (tabular figures stay on regardless). */
|
|
59
|
+
mono?: boolean;
|
|
60
|
+
/** Text colour, mirroring <Text> tones. Default 'muted' — timestamps read
|
|
61
|
+
* as subdued metadata; pass 'inherit' to blend with surrounding copy. */
|
|
62
|
+
tone?: Tone;
|
|
63
|
+
/** Font size, mirroring <Text> sizes (maps to --fs-* tokens). Omit to
|
|
64
|
+
* inherit the surrounding size. */
|
|
65
|
+
size?: Size;
|
|
66
|
+
/** How often relative mode re-renders so "3m ago" stays fresh. */
|
|
67
|
+
tickMs?: number;
|
|
68
|
+
} = $props();
|
|
69
|
+
|
|
70
|
+
// User's in-popover choice overrides the `mode` prop; until they pick, we
|
|
71
|
+
// follow the prop (so a parent can still drive it reactively). Deriving —
|
|
72
|
+
// rather than seeding $state from a prop — keeps both behaviours and avoids
|
|
73
|
+
// capturing only the prop's initial value.
|
|
74
|
+
let override = $state<TimestampMode | null>(null);
|
|
75
|
+
const current = $derived(override ?? mode);
|
|
76
|
+
// Live clock for relative mode only — a single timer that exists solely while
|
|
77
|
+
// relative is on screen, so static modes cost nothing.
|
|
78
|
+
let now = $state(Date.now());
|
|
79
|
+
$effect(() => {
|
|
80
|
+
if (current !== 'relative') return;
|
|
81
|
+
const t = setInterval(() => (now = Date.now()), tickMs);
|
|
82
|
+
return () => clearInterval(t);
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
const date = $derived(toDate(value));
|
|
86
|
+
const label = $derived(formatTimestamp(value, current, now, utc));
|
|
87
|
+
// datetime= wants a valid ISO string; omit it entirely on bad input.
|
|
88
|
+
const machine = $derived(date ? toISO(date) : undefined);
|
|
89
|
+
const showPopover = $derived(details || selectable);
|
|
90
|
+
const cls = $derived(`ts tone-${tone}${mono ? ' mono' : ''}`);
|
|
91
|
+
// Size maps straight onto the --fs-* scale; null leaves font-size unset so the
|
|
92
|
+
// timestamp inherits its surrounding run.
|
|
93
|
+
const sizeVar = $derived(size ? `var(--fs-${size})` : null);
|
|
94
|
+
|
|
95
|
+
const MODES: { id: TimestampMode; name: string }[] = [
|
|
96
|
+
{ id: 'date', name: 'Date' },
|
|
97
|
+
{ id: 'time', name: 'Time' },
|
|
98
|
+
{ id: 'datetime', name: 'Date+time' },
|
|
99
|
+
{ id: 'relative', name: 'Relative' },
|
|
100
|
+
{ id: 'iso', name: 'ISO' }
|
|
101
|
+
];
|
|
102
|
+
|
|
103
|
+
const rows = $derived(
|
|
104
|
+
date
|
|
105
|
+
? [
|
|
106
|
+
{ k: 'UTC', v: toISO(date) },
|
|
107
|
+
{ k: 'Local', v: toLocale(date, 'datetime') },
|
|
108
|
+
{ k: 'Relative', v: relativeTime(date, now) },
|
|
109
|
+
{ k: 'Time zone', v: localTimeZone() },
|
|
110
|
+
{ k: 'Unix', v: String(toEpochSeconds(date)) }
|
|
111
|
+
]
|
|
112
|
+
: []
|
|
113
|
+
);
|
|
114
|
+
</script>
|
|
115
|
+
|
|
116
|
+
{#if !date}
|
|
117
|
+
<!-- Unparseable input: degrade to an inert dash rather than empty text. -->
|
|
118
|
+
<time class="{cls} ts-invalid" style:font-size={sizeVar}>—</time>
|
|
119
|
+
{:else if showPopover}
|
|
120
|
+
<Popover label="Timestamp details" placement="bottom-start" bare>
|
|
121
|
+
{#snippet trigger()}
|
|
122
|
+
<time class="{cls} ts-trigger" style:font-size={sizeVar} datetime={machine}>{label}</time>
|
|
123
|
+
{/snippet}
|
|
124
|
+
<div class="ts-panel">
|
|
125
|
+
{#if selectable}
|
|
126
|
+
<div class="ts-modes" role="group" aria-label="Display mode">
|
|
127
|
+
{#each MODES as m (m.id)}
|
|
128
|
+
<button
|
|
129
|
+
type="button"
|
|
130
|
+
class="ts-mode"
|
|
131
|
+
class:active={current === m.id}
|
|
132
|
+
aria-pressed={current === m.id}
|
|
133
|
+
onclick={() => (override = m.id)}
|
|
134
|
+
>
|
|
135
|
+
{m.name}
|
|
136
|
+
</button>
|
|
137
|
+
{/each}
|
|
138
|
+
</div>
|
|
139
|
+
{/if}
|
|
140
|
+
<dl class="ts-details">
|
|
141
|
+
{#each rows as row (row.k)}
|
|
142
|
+
<dt>{row.k}</dt>
|
|
143
|
+
<dd>{row.v}</dd>
|
|
144
|
+
{/each}
|
|
145
|
+
</dl>
|
|
146
|
+
</div>
|
|
147
|
+
</Popover>
|
|
148
|
+
{:else}
|
|
149
|
+
<time class={cls} style:font-size={sizeVar} datetime={machine}>{label}</time>
|
|
150
|
+
{/if}
|
|
151
|
+
|
|
152
|
+
<style>
|
|
153
|
+
.ts {
|
|
154
|
+
font-variant-numeric: tabular-nums;
|
|
155
|
+
}
|
|
156
|
+
.mono {
|
|
157
|
+
font-family: var(--font-mono);
|
|
158
|
+
}
|
|
159
|
+
/* Tones mirror <Text>; 'inherit' deliberately sets no colour so the timestamp
|
|
160
|
+
blends into surrounding copy. */
|
|
161
|
+
.tone-default {
|
|
162
|
+
color: var(--text);
|
|
163
|
+
}
|
|
164
|
+
.tone-muted {
|
|
165
|
+
color: var(--text-muted);
|
|
166
|
+
}
|
|
167
|
+
.tone-faint {
|
|
168
|
+
color: var(--text-faint);
|
|
169
|
+
}
|
|
170
|
+
.tone-danger {
|
|
171
|
+
color: var(--danger);
|
|
172
|
+
}
|
|
173
|
+
.tone-accent {
|
|
174
|
+
color: var(--accent);
|
|
175
|
+
}
|
|
176
|
+
.ts-invalid {
|
|
177
|
+
color: var(--text-muted);
|
|
178
|
+
}
|
|
179
|
+
/* The popover trigger is a plain inline run of text that hints it's clickable
|
|
180
|
+
— no button chrome (we pass `bare`), just an underline on hover/focus. */
|
|
181
|
+
.ts-trigger {
|
|
182
|
+
cursor: pointer;
|
|
183
|
+
border-radius: var(--r-sm);
|
|
184
|
+
text-decoration-line: underline;
|
|
185
|
+
text-decoration-style: dotted;
|
|
186
|
+
text-underline-offset: 2px;
|
|
187
|
+
text-decoration-color: var(--border-strong);
|
|
188
|
+
}
|
|
189
|
+
.ts-trigger:hover,
|
|
190
|
+
.ts-trigger:focus-visible {
|
|
191
|
+
text-decoration-color: currentColor;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
.ts-panel {
|
|
195
|
+
display: flex;
|
|
196
|
+
flex-direction: column;
|
|
197
|
+
gap: var(--sp-2);
|
|
198
|
+
padding: var(--sp-1);
|
|
199
|
+
}
|
|
200
|
+
.ts-modes {
|
|
201
|
+
display: flex;
|
|
202
|
+
gap: var(--sp-1);
|
|
203
|
+
}
|
|
204
|
+
.ts-mode {
|
|
205
|
+
flex: 1;
|
|
206
|
+
padding: var(--sp-1) var(--sp-2);
|
|
207
|
+
border: 1px solid var(--border-strong);
|
|
208
|
+
border-radius: var(--r-sm);
|
|
209
|
+
background: transparent;
|
|
210
|
+
color: var(--text-muted);
|
|
211
|
+
font-size: var(--fs-xs);
|
|
212
|
+
cursor: pointer;
|
|
213
|
+
transition:
|
|
214
|
+
background 0.12s var(--ease),
|
|
215
|
+
color 0.12s var(--ease);
|
|
216
|
+
}
|
|
217
|
+
.ts-mode:hover {
|
|
218
|
+
background: var(--bg-elevated-2);
|
|
219
|
+
color: var(--text);
|
|
220
|
+
}
|
|
221
|
+
.ts-mode.active {
|
|
222
|
+
background: var(--bg-elevated-2);
|
|
223
|
+
color: var(--text);
|
|
224
|
+
border-color: var(--text);
|
|
225
|
+
}
|
|
226
|
+
.ts-details {
|
|
227
|
+
display: grid;
|
|
228
|
+
grid-template-columns: auto 1fr;
|
|
229
|
+
gap: var(--sp-1) var(--sp-2);
|
|
230
|
+
margin: 0;
|
|
231
|
+
font-size: var(--fs-xs);
|
|
232
|
+
}
|
|
233
|
+
.ts-details dt {
|
|
234
|
+
color: var(--text-muted);
|
|
235
|
+
white-space: nowrap;
|
|
236
|
+
}
|
|
237
|
+
.ts-details dd {
|
|
238
|
+
margin: 0;
|
|
239
|
+
color: var(--text);
|
|
240
|
+
font-variant-numeric: tabular-nums;
|
|
241
|
+
overflow-wrap: anywhere;
|
|
242
|
+
}
|
|
243
|
+
</style>
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { type TimeInput, type TimestampMode } from '../../timestamp';
|
|
2
|
+
type Tone = 'inherit' | 'default' | 'muted' | 'faint' | 'danger' | 'accent';
|
|
3
|
+
type Size = 'xs' | 'sm' | 'base' | 'md' | 'lg' | 'xl' | '2xl';
|
|
4
|
+
type $$ComponentProps = {
|
|
5
|
+
/** The instant: a Date, epoch milliseconds, or an ISO/parseable string.
|
|
6
|
+
* null/undefined (or unparseable input) renders an inert "—". */
|
|
7
|
+
value: TimeInput | null | undefined;
|
|
8
|
+
/** Inline display mode. Default 'datetime'. */
|
|
9
|
+
mode?: TimestampMode;
|
|
10
|
+
/** Render the date/time/datetime modes in UTC rather than the viewer's
|
|
11
|
+
* zone. Default false. Ignored by 'iso' (always UTC) and 'relative'
|
|
12
|
+
* (zoneless). Handy for calendar-date fields, where a local midnight-UTC
|
|
13
|
+
* value would otherwise show the wrong day. */
|
|
14
|
+
utc?: boolean;
|
|
15
|
+
/** Click to open the details popover (UTC / local / relative / zone /
|
|
16
|
+
* epoch). Default true. When false, renders as a bare inline <time>. */
|
|
17
|
+
details?: boolean;
|
|
18
|
+
/** Add mode-switch buttons inside the popover so the viewer can change the
|
|
19
|
+
* inline display mode. Default false. Implies `details`. */
|
|
20
|
+
selectable?: boolean;
|
|
21
|
+
/** Render in the monospace font (tabular figures stay on regardless). */
|
|
22
|
+
mono?: boolean;
|
|
23
|
+
/** Text colour, mirroring <Text> tones. Default 'muted' — timestamps read
|
|
24
|
+
* as subdued metadata; pass 'inherit' to blend with surrounding copy. */
|
|
25
|
+
tone?: Tone;
|
|
26
|
+
/** Font size, mirroring <Text> sizes (maps to --fs-* tokens). Omit to
|
|
27
|
+
* inherit the surrounding size. */
|
|
28
|
+
size?: Size;
|
|
29
|
+
/** How often relative mode re-renders so "3m ago" stays fresh. */
|
|
30
|
+
tickMs?: number;
|
|
31
|
+
};
|
|
32
|
+
declare const Timestamp: import("svelte").Component<$$ComponentProps, {}, "">;
|
|
33
|
+
type Timestamp = ReturnType<typeof Timestamp>;
|
|
34
|
+
export default Timestamp;
|
package/dist/index.d.ts
CHANGED
|
@@ -38,6 +38,7 @@ export { default as RadioGroup, type RadioOption, } from './components/molecules
|
|
|
38
38
|
export { default as SelectButton } from './components/molecules/SelectButton.svelte';
|
|
39
39
|
export { default as Tabs, type TabItem } from './components/molecules/Tabs.svelte';
|
|
40
40
|
export { default as ThemePicker } from './components/molecules/ThemePicker.svelte';
|
|
41
|
+
export { default as Timestamp } from './components/molecules/Timestamp.svelte';
|
|
41
42
|
export { default as Toaster } from './components/molecules/Toaster.svelte';
|
|
42
43
|
export { default as Toggle } from './components/molecules/Toggle.svelte';
|
|
43
44
|
export { default as Tooltip } from './components/molecules/Tooltip.svelte';
|
|
@@ -46,4 +47,5 @@ export { type Column, default as DataTable } from './components/organisms/DataTa
|
|
|
46
47
|
export { fontScale, SCALE_LEVELS, type ScaleLevel } from './stores/fontscale.svelte';
|
|
47
48
|
export { type Mode, THEMES, theme } from './stores/theme.svelte';
|
|
48
49
|
export { type Toast, type ToastTone, toasts } from './stores/toast.svelte';
|
|
50
|
+
export { formatTimestamp, localTimeZone, relativeTime, type TimeInput, type TimestampMode, } from './timestamp';
|
|
49
51
|
export { type TruncateMode, type TruncateOptions, truncate } from './truncate';
|
package/dist/index.js
CHANGED
|
@@ -46,6 +46,7 @@ export { default as RadioGroup, } from './components/molecules/RadioGroup.svelte
|
|
|
46
46
|
export { default as SelectButton } from './components/molecules/SelectButton.svelte';
|
|
47
47
|
export { default as Tabs } from './components/molecules/Tabs.svelte';
|
|
48
48
|
export { default as ThemePicker } from './components/molecules/ThemePicker.svelte';
|
|
49
|
+
export { default as Timestamp } from './components/molecules/Timestamp.svelte';
|
|
49
50
|
export { default as Toaster } from './components/molecules/Toaster.svelte';
|
|
50
51
|
export { default as Toggle } from './components/molecules/Toggle.svelte';
|
|
51
52
|
export { default as Tooltip } from './components/molecules/Tooltip.svelte';
|
|
@@ -56,4 +57,5 @@ export { fontScale, SCALE_LEVELS } from './stores/fontscale.svelte';
|
|
|
56
57
|
// ---- stores / actions ----
|
|
57
58
|
export { THEMES, theme } from './stores/theme.svelte';
|
|
58
59
|
export { toasts } from './stores/toast.svelte';
|
|
60
|
+
export { formatTimestamp, localTimeZone, relativeTime, } from './timestamp';
|
|
59
61
|
export { truncate } from './truncate';
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
export type TimeInput = Date | number | string;
|
|
2
|
+
/**
|
|
3
|
+
* Which slice of the instant the inline text shows. Orthogonal to the zone:
|
|
4
|
+
* `date`/`time`/`datetime` honour the `utc` flag, while `iso` is always UTC
|
|
5
|
+
* (by definition) and `relative` is zoneless.
|
|
6
|
+
*/
|
|
7
|
+
export type TimestampMode = 'date' | 'time' | 'datetime' | 'relative' | 'iso';
|
|
8
|
+
/**
|
|
9
|
+
* Coerce a loose input to a Date, or null when it's missing or can't be parsed.
|
|
10
|
+
* Accepting null/undefined lets callers pass an optional field straight through
|
|
11
|
+
* — the empty case lands on the same "no date" path as bad input.
|
|
12
|
+
*/
|
|
13
|
+
export declare function toDate(value: TimeInput | null | undefined): Date | null;
|
|
14
|
+
/** Full ISO 8601, UTC — `2026-06-14T07:30:00.000Z`. */
|
|
15
|
+
export declare function toISO(d: Date): string;
|
|
16
|
+
/**
|
|
17
|
+
* Locale-formatted date and/or time, in the viewer's zone or UTC. The single
|
|
18
|
+
* `Intl`-backed formatter behind the `date`/`time`/`datetime` modes — splitting
|
|
19
|
+
* the slice (what) from the zone (where) so a date-only field can render `utc`
|
|
20
|
+
* without the off-by-one a local midnight-UTC value otherwise shows.
|
|
21
|
+
*/
|
|
22
|
+
export declare function toLocale(d: Date, parts?: 'date' | 'time' | 'datetime', utc?: boolean): string;
|
|
23
|
+
/** Unix epoch in whole seconds. */
|
|
24
|
+
export declare function toEpochSeconds(d: Date): number;
|
|
25
|
+
/** The viewer's IANA time zone — `Asia/Tokyo`, `UTC`, … */
|
|
26
|
+
export declare function localTimeZone(): string;
|
|
27
|
+
/**
|
|
28
|
+
* "3m ago", "2h ago", "5d ago" — past-relative, coarsening as it ages and
|
|
29
|
+
* falling back to a locale date once past ~30 days. Future instants read
|
|
30
|
+
* "in 3m" etc. `now` is injectable so callers (and tests) control the clock.
|
|
31
|
+
*/
|
|
32
|
+
export declare function relativeTime(value: TimeInput | null | undefined, now?: number): string;
|
|
33
|
+
/**
|
|
34
|
+
* Render a date in the chosen inline mode. `utc` selects UTC over the viewer's
|
|
35
|
+
* zone for the date/time/datetime modes (ignored by `iso`, always UTC, and
|
|
36
|
+
* `relative`, zoneless). Returns '' for unparseable input.
|
|
37
|
+
*/
|
|
38
|
+
export declare function formatTimestamp(value: TimeInput | null | undefined, mode: TimestampMode, now?: number, utc?: boolean): string;
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
// Pure date-formatting helpers behind <Timestamp> — the logic lives here (and
|
|
2
|
+
// stays unit-testable / framework-free) while the component owns only the
|
|
3
|
+
// display-mode switching and the popover. Everything accepts the same loose
|
|
4
|
+
// `TimeInput` (Date | epoch ms | ISO string) and tolerates bad input by
|
|
5
|
+
// returning '' rather than throwing, so a single malformed value never takes
|
|
6
|
+
// down a table row.
|
|
7
|
+
/**
|
|
8
|
+
* Coerce a loose input to a Date, or null when it's missing or can't be parsed.
|
|
9
|
+
* Accepting null/undefined lets callers pass an optional field straight through
|
|
10
|
+
* — the empty case lands on the same "no date" path as bad input.
|
|
11
|
+
*/
|
|
12
|
+
export function toDate(value) {
|
|
13
|
+
if (value == null)
|
|
14
|
+
return null;
|
|
15
|
+
const d = value instanceof Date ? value : new Date(value);
|
|
16
|
+
return Number.isNaN(d.getTime()) ? null : d;
|
|
17
|
+
}
|
|
18
|
+
/** Full ISO 8601, UTC — `2026-06-14T07:30:00.000Z`. */
|
|
19
|
+
export function toISO(d) {
|
|
20
|
+
return d.toISOString();
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* Locale-formatted date and/or time, in the viewer's zone or UTC. The single
|
|
24
|
+
* `Intl`-backed formatter behind the `date`/`time`/`datetime` modes — splitting
|
|
25
|
+
* the slice (what) from the zone (where) so a date-only field can render `utc`
|
|
26
|
+
* without the off-by-one a local midnight-UTC value otherwise shows.
|
|
27
|
+
*/
|
|
28
|
+
export function toLocale(d, parts = 'datetime', utc = false) {
|
|
29
|
+
const opts = {};
|
|
30
|
+
if (utc)
|
|
31
|
+
opts.timeZone = 'UTC';
|
|
32
|
+
if (parts !== 'time') {
|
|
33
|
+
opts.year = 'numeric';
|
|
34
|
+
opts.month = 'numeric';
|
|
35
|
+
opts.day = 'numeric';
|
|
36
|
+
}
|
|
37
|
+
if (parts !== 'date') {
|
|
38
|
+
opts.hour = '2-digit';
|
|
39
|
+
opts.minute = '2-digit';
|
|
40
|
+
opts.second = '2-digit';
|
|
41
|
+
}
|
|
42
|
+
return new Intl.DateTimeFormat(undefined, opts).format(d);
|
|
43
|
+
}
|
|
44
|
+
/** Unix epoch in whole seconds. */
|
|
45
|
+
export function toEpochSeconds(d) {
|
|
46
|
+
return Math.floor(d.getTime() / 1000);
|
|
47
|
+
}
|
|
48
|
+
/** The viewer's IANA time zone — `Asia/Tokyo`, `UTC`, … */
|
|
49
|
+
export function localTimeZone() {
|
|
50
|
+
return Intl.DateTimeFormat().resolvedOptions().timeZone;
|
|
51
|
+
}
|
|
52
|
+
/**
|
|
53
|
+
* "3m ago", "2h ago", "5d ago" — past-relative, coarsening as it ages and
|
|
54
|
+
* falling back to a locale date once past ~30 days. Future instants read
|
|
55
|
+
* "in 3m" etc. `now` is injectable so callers (and tests) control the clock.
|
|
56
|
+
*/
|
|
57
|
+
export function relativeTime(value, now = Date.now()) {
|
|
58
|
+
const d = toDate(value);
|
|
59
|
+
if (!d)
|
|
60
|
+
return '';
|
|
61
|
+
const deltaMs = now - d.getTime();
|
|
62
|
+
const future = deltaMs < 0;
|
|
63
|
+
const secs = Math.floor(Math.abs(deltaMs) / 1000);
|
|
64
|
+
const suffix = (s) => (future ? `in ${s}` : `${s} ago`);
|
|
65
|
+
if (secs < 60)
|
|
66
|
+
return suffix(`${secs}s`);
|
|
67
|
+
const mins = Math.floor(secs / 60);
|
|
68
|
+
if (mins < 60)
|
|
69
|
+
return suffix(`${mins}m`);
|
|
70
|
+
const hrs = Math.floor(mins / 60);
|
|
71
|
+
if (hrs < 24)
|
|
72
|
+
return suffix(`${hrs}h`);
|
|
73
|
+
const days = Math.floor(hrs / 24);
|
|
74
|
+
if (days < 30)
|
|
75
|
+
return suffix(`${days}d`);
|
|
76
|
+
return d.toLocaleDateString();
|
|
77
|
+
}
|
|
78
|
+
/**
|
|
79
|
+
* Render a date in the chosen inline mode. `utc` selects UTC over the viewer's
|
|
80
|
+
* zone for the date/time/datetime modes (ignored by `iso`, always UTC, and
|
|
81
|
+
* `relative`, zoneless). Returns '' for unparseable input.
|
|
82
|
+
*/
|
|
83
|
+
export function formatTimestamp(value, mode, now, utc = false) {
|
|
84
|
+
const d = toDate(value);
|
|
85
|
+
if (!d)
|
|
86
|
+
return '';
|
|
87
|
+
switch (mode) {
|
|
88
|
+
case 'iso':
|
|
89
|
+
return toISO(d);
|
|
90
|
+
case 'date':
|
|
91
|
+
case 'time':
|
|
92
|
+
case 'datetime':
|
|
93
|
+
return toLocale(d, mode, utc);
|
|
94
|
+
case 'relative':
|
|
95
|
+
return relativeTime(d, now);
|
|
96
|
+
}
|
|
97
|
+
}
|
package/package.json
CHANGED