@joewinke/jatui 0.60.0 → 0.62.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/CLAUDE.md +4 -1
- package/dist/actions/tooltip.d.ts +7 -0
- package/dist/actions/tooltip.js +28 -0
- package/dist/ambience/index.d.ts +2 -2
- package/dist/ambience/index.js +2 -2
- package/dist/ambience/scapes/playlist.d.ts +31 -0
- package/dist/ambience/scapes/playlist.js +82 -1
- package/dist/ambience/shared.d.ts +33 -2
- package/dist/ambience/shared.js +32 -5
- package/dist/components/AmbienceToggle.svelte +203 -3
- package/dist/components/AmbienceToggle.svelte.d.ts +11 -1
- package/dist/components/stats/StatusBanner.svelte +119 -0
- package/dist/components/stats/StatusBanner.svelte.d.ts +13 -0
- package/dist/components/stats/StatusCard.svelte +176 -0
- package/dist/components/stats/StatusCard.svelte.d.ts +18 -0
- package/dist/components/stats/UptimeBar.svelte +254 -0
- package/dist/components/stats/UptimeBar.svelte.d.ts +20 -0
- package/dist/components/stats/uptime.d.ts +105 -0
- package/dist/components/stats/uptime.js +213 -0
- package/dist/index.d.ts +7 -0
- package/dist/index.js +8 -0
- package/package.json +1 -1
|
@@ -0,0 +1,254 @@
|
|
|
1
|
+
<!--
|
|
2
|
+
UptimeBar: one component's 90-day status strip (jatui-i3vxo). One thin bar
|
|
3
|
+
per day, coloured by status tone (operational / degraded / partial /
|
|
4
|
+
outage / no-data — no-data is hatched neutral, never coloured as up), a
|
|
5
|
+
hover/touch/focus tooltip with date + minutes down + incident note, and the
|
|
6
|
+
footer "90 days ago ——— 99.97% uptime ——— Today" (truncated to 2 decimals,
|
|
7
|
+
never overclaims — see formatUptimePct).
|
|
8
|
+
|
|
9
|
+
Bars are a role="grid" with ONE tab stop and roving focus (arrows move by
|
|
10
|
+
day, PageUp/Down by 7, Home/End to the window ends). Pure CSS sizing:
|
|
11
|
+
the component is an inline-size container and --up-bar is derived from
|
|
12
|
+
the container width (cqi), clamped 3–14px — on a phone the strip scrolls
|
|
13
|
+
horizontally inside the component, starting at the latest day. All colour
|
|
14
|
+
comes from theme tokens (status tokens + color-mix), no hex.
|
|
15
|
+
-->
|
|
16
|
+
<script lang="ts">
|
|
17
|
+
import { tooltip } from '../../actions/tooltip';
|
|
18
|
+
import {
|
|
19
|
+
buildUptime,
|
|
20
|
+
formatUptimePct,
|
|
21
|
+
moveUptimeFocus,
|
|
22
|
+
parseDay,
|
|
23
|
+
uptimeWord,
|
|
24
|
+
type UptimeBar as UptimeBarDay,
|
|
25
|
+
type UptimeDayInput
|
|
26
|
+
} from './uptime';
|
|
27
|
+
|
|
28
|
+
interface Props {
|
|
29
|
+
/** One entry per day: `{ date, status, downMinutes?, note? }`. */
|
|
30
|
+
data: readonly UptimeDayInput[];
|
|
31
|
+
/** Window length in days. Default 90. */
|
|
32
|
+
days?: number;
|
|
33
|
+
/** Inclusive window end. Default: today. */
|
|
34
|
+
end?: string | Date;
|
|
35
|
+
/** Name of the measured thing, for the screen-reader label. */
|
|
36
|
+
label?: string;
|
|
37
|
+
/** Custom tooltip / accessible label for a day. */
|
|
38
|
+
formatLabel?: (bar: UptimeBarDay) => string;
|
|
39
|
+
showUptime?: boolean;
|
|
40
|
+
showRangeLabels?: boolean;
|
|
41
|
+
locale?: string;
|
|
42
|
+
class?: string;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
let {
|
|
46
|
+
data,
|
|
47
|
+
days = 90,
|
|
48
|
+
end,
|
|
49
|
+
label = 'Uptime',
|
|
50
|
+
formatLabel,
|
|
51
|
+
showUptime = true,
|
|
52
|
+
showRangeLabels = true,
|
|
53
|
+
locale,
|
|
54
|
+
class: className = ''
|
|
55
|
+
}: Props = $props();
|
|
56
|
+
|
|
57
|
+
const layout = $derived(buildUptime({ data, days, end }));
|
|
58
|
+
const byDate = $derived(new Map(layout.bars.map((b) => [b.date, b])));
|
|
59
|
+
|
|
60
|
+
const nf = $derived(new Intl.NumberFormat(locale, { maximumFractionDigits: 2 }));
|
|
61
|
+
const df = $derived(
|
|
62
|
+
new Intl.DateTimeFormat(locale, { weekday: 'short', month: 'short', day: 'numeric', year: 'numeric' })
|
|
63
|
+
);
|
|
64
|
+
|
|
65
|
+
const totalDown = $derived(layout.bars.reduce((s, b) => s + b.downMinutes, 0));
|
|
66
|
+
// Truncating formatter — never overclaims (see formatUptimePct).
|
|
67
|
+
const uptimeText = $derived(
|
|
68
|
+
layout.uptimePct == null
|
|
69
|
+
? 'No data'
|
|
70
|
+
: `${formatUptimePct(layout.uptimePct, locale, totalDown)} uptime`
|
|
71
|
+
);
|
|
72
|
+
const gridLabel = $derived(`${label}, last ${layout.days} days: ${uptimeText}`);
|
|
73
|
+
|
|
74
|
+
const labelFor = (bar: UptimeBarDay) => {
|
|
75
|
+
if (formatLabel) return formatLabel(bar);
|
|
76
|
+
const parts = [`${df.format(parseDay(bar.date))}: ${uptimeWord(bar.status)}`];
|
|
77
|
+
if (bar.downMinutes > 0) parts.push(`${nf.format(bar.downMinutes)} min down`);
|
|
78
|
+
if (bar.note) parts.push(bar.note);
|
|
79
|
+
return parts.join(' · ');
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
// Roving focus: the focused day, else the last day in range.
|
|
83
|
+
let focusDate = $state<string | null>(null);
|
|
84
|
+
const tabDate = $derived(focusDate && byDate.has(focusDate) ? focusDate : layout.end);
|
|
85
|
+
|
|
86
|
+
let gridEl: HTMLDivElement | undefined = $state();
|
|
87
|
+
let scrollEl: HTMLDivElement | undefined = $state();
|
|
88
|
+
|
|
89
|
+
function cellEl(date: string): HTMLElement | null {
|
|
90
|
+
return gridEl?.querySelector<HTMLElement>(`[data-date="${date}"]`) ?? null;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function onKeydown(e: KeyboardEvent) {
|
|
94
|
+
const t = e.target as HTMLElement;
|
|
95
|
+
const date = t.dataset.date;
|
|
96
|
+
if (!date) return;
|
|
97
|
+
const next = moveUptimeFocus(date, e.key, layout);
|
|
98
|
+
if (!next) return;
|
|
99
|
+
e.preventDefault();
|
|
100
|
+
focusDate = next;
|
|
101
|
+
queueMicrotask(() => {
|
|
102
|
+
const el = cellEl(next);
|
|
103
|
+
el?.focus();
|
|
104
|
+
el?.scrollIntoView?.({ block: 'nearest', inline: 'nearest' });
|
|
105
|
+
});
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// The latest day is the interesting one: start scrolled to the end.
|
|
109
|
+
$effect(() => {
|
|
110
|
+
void layout.bars;
|
|
111
|
+
if (scrollEl) scrollEl.scrollLeft = scrollEl.scrollWidth;
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
</script>
|
|
115
|
+
|
|
116
|
+
<div class="jatui-uptime {className}" style:--days={layout.days}>
|
|
117
|
+
<div class="up-scroll" bind:this={scrollEl}>
|
|
118
|
+
<div
|
|
119
|
+
class="up-grid"
|
|
120
|
+
role="grid"
|
|
121
|
+
aria-label={gridLabel}
|
|
122
|
+
tabindex="-1"
|
|
123
|
+
bind:this={gridEl}
|
|
124
|
+
onkeydown={onKeydown}
|
|
125
|
+
>
|
|
126
|
+
<div class="up-row" role="row">
|
|
127
|
+
{#each layout.bars as bar (bar.date)}
|
|
128
|
+
<!-- Keys are handled on the grid (roving focus); click focuses the day. -->
|
|
129
|
+
<!-- svelte-ignore a11y_click_events_have_key_events -->
|
|
130
|
+
<div
|
|
131
|
+
role="gridcell"
|
|
132
|
+
class="up-bar st-{bar.status}"
|
|
133
|
+
data-date={bar.date}
|
|
134
|
+
tabindex={bar.date === tabDate ? 0 : -1}
|
|
135
|
+
aria-label={labelFor(bar)}
|
|
136
|
+
use:tooltip={{ text: labelFor(bar), placement: 'top', delayMs: 0, tapToShow: true }}
|
|
137
|
+
onfocus={() => {
|
|
138
|
+
focusDate = bar.date;
|
|
139
|
+
}}
|
|
140
|
+
onclick={() => {
|
|
141
|
+
focusDate = bar.date;
|
|
142
|
+
}}
|
|
143
|
+
></div>
|
|
144
|
+
{/each}
|
|
145
|
+
</div>
|
|
146
|
+
</div>
|
|
147
|
+
</div>
|
|
148
|
+
|
|
149
|
+
{#if showUptime || showRangeLabels}
|
|
150
|
+
<div class="up-foot" aria-hidden="true">
|
|
151
|
+
{#if showRangeLabels}<span class="up-range">{layout.days} days ago</span>{/if}
|
|
152
|
+
{#if showUptime && showRangeLabels}<span class="up-rule"></span>{/if}
|
|
153
|
+
{#if showUptime}<strong class="up-uptime">{uptimeText}</strong>{/if}
|
|
154
|
+
{#if showUptime && showRangeLabels}<span class="up-rule"></span>{/if}
|
|
155
|
+
{#if showRangeLabels}<span class="up-range">Today</span>{/if}
|
|
156
|
+
</div>
|
|
157
|
+
{/if}
|
|
158
|
+
</div>
|
|
159
|
+
|
|
160
|
+
<style>
|
|
161
|
+
.jatui-uptime {
|
|
162
|
+
--up-bar-min: 3px;
|
|
163
|
+
--up-bar-max: 14px;
|
|
164
|
+
--up-gap: 2px;
|
|
165
|
+
--up-pad: 2px;
|
|
166
|
+
--up-fs: 0.75rem;
|
|
167
|
+
--up-empty: color-mix(in oklch, var(--color-base-content) 8%, var(--color-base-100));
|
|
168
|
+
position: relative;
|
|
169
|
+
display: flex;
|
|
170
|
+
flex-direction: column;
|
|
171
|
+
gap: var(--jatui-space-base, 8px);
|
|
172
|
+
min-width: 0;
|
|
173
|
+
max-width: 100%;
|
|
174
|
+
font-size: var(--up-fs);
|
|
175
|
+
color: color-mix(in oklch, var(--color-base-content) 55%, transparent);
|
|
176
|
+
container-type: inline-size;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/* Bars size to the width the host gives: (container - focus padding) /
|
|
180
|
+
* days - gap, clamped 3–14px. Below the 3px floor the strip overflows and
|
|
181
|
+
* .up-scroll scrolls horizontally inside the component. */
|
|
182
|
+
.jatui-uptime .up-scroll {
|
|
183
|
+
--up-bar: clamp(
|
|
184
|
+
var(--up-bar-min),
|
|
185
|
+
calc((100cqi - 2 * var(--up-pad)) / var(--days) - var(--up-gap)),
|
|
186
|
+
var(--up-bar-max)
|
|
187
|
+
);
|
|
188
|
+
overflow-x: auto;
|
|
189
|
+
overscroll-behavior-x: contain;
|
|
190
|
+
scrollbar-width: thin;
|
|
191
|
+
padding: var(--up-pad);
|
|
192
|
+
}
|
|
193
|
+
.up-grid {
|
|
194
|
+
display: flex;
|
|
195
|
+
outline: none;
|
|
196
|
+
}
|
|
197
|
+
.up-row {
|
|
198
|
+
display: flex;
|
|
199
|
+
gap: var(--up-gap);
|
|
200
|
+
}
|
|
201
|
+
.up-bar {
|
|
202
|
+
flex: none;
|
|
203
|
+
width: var(--up-bar);
|
|
204
|
+
height: var(--up-h, 0.875rem);
|
|
205
|
+
border-radius: calc(var(--up-bar) * 0.25);
|
|
206
|
+
background: var(--up-empty);
|
|
207
|
+
outline: none;
|
|
208
|
+
cursor: default;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/* Status tones — token-only. 'partial' is warning mixed toward error
|
|
212
|
+
* (orange), never a hardcoded hex. */
|
|
213
|
+
.st-operational { background: var(--jatui-status-positive, var(--color-success)); }
|
|
214
|
+
.st-degraded { background: var(--jatui-status-caution, var(--color-warning)); }
|
|
215
|
+
.st-partial {
|
|
216
|
+
background: color-mix(in oklch, var(--color-warning) 55%, var(--color-error));
|
|
217
|
+
}
|
|
218
|
+
.st-outage { background: var(--jatui-status-negative, var(--color-error)); }
|
|
219
|
+
/* No-data: hatched neutral — clearly different from any incident colour. */
|
|
220
|
+
.st-no-data {
|
|
221
|
+
background: repeating-linear-gradient(
|
|
222
|
+
135deg,
|
|
223
|
+
var(--up-empty) 0 2px,
|
|
224
|
+
color-mix(in oklch, var(--up-empty) 55%, transparent) 2px 4px
|
|
225
|
+
);
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
.up-bar:focus-visible {
|
|
229
|
+
box-shadow:
|
|
230
|
+
0 0 0 1.5px var(--color-base-100),
|
|
231
|
+
0 0 0 3px var(--color-primary);
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
.up-foot {
|
|
235
|
+
display: flex;
|
|
236
|
+
align-items: center;
|
|
237
|
+
gap: var(--jatui-space-sm, 4px);
|
|
238
|
+
white-space: nowrap;
|
|
239
|
+
}
|
|
240
|
+
.up-range {
|
|
241
|
+
flex: none;
|
|
242
|
+
}
|
|
243
|
+
.up-rule {
|
|
244
|
+
flex: 1 1 0;
|
|
245
|
+
min-width: 0;
|
|
246
|
+
height: 1px;
|
|
247
|
+
background: color-mix(in oklch, var(--color-base-content) 15%, transparent);
|
|
248
|
+
}
|
|
249
|
+
.up-uptime {
|
|
250
|
+
flex: none;
|
|
251
|
+
font-weight: 600;
|
|
252
|
+
color: var(--color-base-content);
|
|
253
|
+
}
|
|
254
|
+
</style>
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { type UptimeBar as UptimeBarDay, type UptimeDayInput } from './uptime';
|
|
2
|
+
interface Props {
|
|
3
|
+
/** One entry per day: `{ date, status, downMinutes?, note? }`. */
|
|
4
|
+
data: readonly UptimeDayInput[];
|
|
5
|
+
/** Window length in days. Default 90. */
|
|
6
|
+
days?: number;
|
|
7
|
+
/** Inclusive window end. Default: today. */
|
|
8
|
+
end?: string | Date;
|
|
9
|
+
/** Name of the measured thing, for the screen-reader label. */
|
|
10
|
+
label?: string;
|
|
11
|
+
/** Custom tooltip / accessible label for a day. */
|
|
12
|
+
formatLabel?: (bar: UptimeBarDay) => string;
|
|
13
|
+
showUptime?: boolean;
|
|
14
|
+
showRangeLabels?: boolean;
|
|
15
|
+
locale?: string;
|
|
16
|
+
class?: string;
|
|
17
|
+
}
|
|
18
|
+
declare const UptimeBar: import("svelte").Component<Props, {}, "">;
|
|
19
|
+
type UptimeBar = ReturnType<typeof UptimeBar>;
|
|
20
|
+
export default UptimeBar;
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* UptimeBar / StatusHistory layout + keyboard math (jatui-i3vxo). Pure and
|
|
3
|
+
* DOM-free.
|
|
4
|
+
*
|
|
5
|
+
* One thin bar per day over a 90-day (default) window, coloured by status
|
|
6
|
+
* tone (operational / degraded / partial / outage — `no-data` is never
|
|
7
|
+
* coloured as up). Days are local calendar dates, keyed "YYYY-MM-DD".
|
|
8
|
+
*
|
|
9
|
+
* Uptime % is minute-based: a day with no `downMinutes` uses a default
|
|
10
|
+
* (outage = full day, partial = half day, degraded = still up), so a feed
|
|
11
|
+
* that only carries statuses still gets a sensible percentage. `no-data`
|
|
12
|
+
* days are excluded from the percentage entirely.
|
|
13
|
+
*/
|
|
14
|
+
export type DayStatus = 'operational' | 'degraded' | 'partial' | 'outage' | 'no-data';
|
|
15
|
+
/** Incidents rank worst-first. `no-data` is not an incident — it is the LOWEST rank. */
|
|
16
|
+
export declare const DAY_SEVERITY: readonly DayStatus[];
|
|
17
|
+
export interface UptimeDayInput {
|
|
18
|
+
date: string | Date;
|
|
19
|
+
status: DayStatus;
|
|
20
|
+
/** Minutes down that day. Optional — a default is used per status. */
|
|
21
|
+
downMinutes?: number;
|
|
22
|
+
/** Incident note (e.g. "Scheduled maintenance"), shown in the tooltip. */
|
|
23
|
+
note?: string;
|
|
24
|
+
}
|
|
25
|
+
export interface UptimeBar {
|
|
26
|
+
/** "YYYY-MM-DD" (local). */
|
|
27
|
+
date: string;
|
|
28
|
+
status: DayStatus;
|
|
29
|
+
/** Minutes down that day (0 when up). */
|
|
30
|
+
downMinutes: number;
|
|
31
|
+
/** Incident note, if any. */
|
|
32
|
+
note?: string;
|
|
33
|
+
}
|
|
34
|
+
export interface UptimeLayout {
|
|
35
|
+
/** Every day in the window, oldest → newest. */
|
|
36
|
+
bars: UptimeBar[];
|
|
37
|
+
/** Window length in days (30 / 90 …). */
|
|
38
|
+
days: number;
|
|
39
|
+
/** Days with a status (no-data days excluded). */
|
|
40
|
+
dataDays: number;
|
|
41
|
+
/** Fraction of uptime over days with data, 0..1 — null when there is no data. */
|
|
42
|
+
uptimePct: number | null;
|
|
43
|
+
/** Worst incident status in the window ('operational' when none). */
|
|
44
|
+
worst: DayStatus;
|
|
45
|
+
start: string;
|
|
46
|
+
end: string;
|
|
47
|
+
}
|
|
48
|
+
export interface BuildUptimeOptions<T> {
|
|
49
|
+
data: readonly T[];
|
|
50
|
+
getDate?: (d: T) => string | Date;
|
|
51
|
+
getStatus?: (d: T) => DayStatus;
|
|
52
|
+
getDownMinutes?: (d: T) => number | undefined;
|
|
53
|
+
getNote?: (d: T) => string | undefined;
|
|
54
|
+
/** Window length in days. Default 90. */
|
|
55
|
+
days?: number;
|
|
56
|
+
/** Inclusive window end. Default: today. */
|
|
57
|
+
end?: string | Date;
|
|
58
|
+
}
|
|
59
|
+
/** Local "YYYY-MM-DD". */
|
|
60
|
+
export declare function isoDay(d: Date): string;
|
|
61
|
+
/** Parse a day as LOCAL noon (DST-safe arithmetic). */
|
|
62
|
+
export declare function parseDay(input: string | Date): Date;
|
|
63
|
+
export declare function addDays(day: string, n: number): string;
|
|
64
|
+
/** Whole days from a to b (b - a). */
|
|
65
|
+
export declare function daysBetween(a: string, b: string): number;
|
|
66
|
+
/** Rank of a status in DAY_SEVERITY (higher = worse). */
|
|
67
|
+
export declare function statusRank(status: DayStatus): number;
|
|
68
|
+
/** The worse of two statuses. */
|
|
69
|
+
export declare function worstStatus(a: DayStatus, b: DayStatus): DayStatus;
|
|
70
|
+
/** Default downtime minutes per status when the feed doesn't carry them. */
|
|
71
|
+
export declare function defaultDownMinutes(status: DayStatus): number;
|
|
72
|
+
/** One-word status: 'Normal' / 'Degraded' / 'Partial' / 'Outage' / 'No data'. */
|
|
73
|
+
export declare function uptimeWord(status: DayStatus): string;
|
|
74
|
+
/**
|
|
75
|
+
* Format the uptime % for display. TRUNCATED toward zero to 2 decimals — a
|
|
76
|
+
* status page must never overclaim (99.996% shows as 99.99%, never 100% when
|
|
77
|
+
* real downtime exists). Formatted with Intl `style: 'percent'` so the locale
|
|
78
|
+
* places the sign and spacing correctly ("99.99%" in en, "99,99 %" in de).
|
|
79
|
+
* `downMinutes > 0` forces a 99.99 cap even when the fraction is exactly 1
|
|
80
|
+
* (defensive: a feed with inconsistent data must not claim perfection).
|
|
81
|
+
* Returns "No data" for null.
|
|
82
|
+
*/
|
|
83
|
+
export declare function formatUptimePct(pct: number | null, locale?: string, downMinutes?: number): string;
|
|
84
|
+
/** Roll a status up to the banner's three states: operational / degraded / outage. */
|
|
85
|
+
export declare function bannerStatus(status: DayStatus): 'operational' | 'degraded' | 'outage';
|
|
86
|
+
/** Worst of many statuses, rolled up to the banner's three states. */
|
|
87
|
+
export declare function aggregateStatus(statuses: readonly DayStatus[]): 'operational' | 'degraded' | 'outage';
|
|
88
|
+
/**
|
|
89
|
+
* Build the window: `days` (default 90) ending at `end` (default today).
|
|
90
|
+
* Days without data become `no-data` bars. Several entries for one day are
|
|
91
|
+
* merged: worst status wins, downMinutes are summed, the first note kept.
|
|
92
|
+
*/
|
|
93
|
+
export declare function buildUptime<T>(opts: BuildUptimeOptions<T>): UptimeLayout;
|
|
94
|
+
/**
|
|
95
|
+
* Keyboard move along the 1-row strip. Returns the new focused day clamped
|
|
96
|
+
* to [start, end], or null for keys it does not handle.
|
|
97
|
+
*
|
|
98
|
+
* ArrowLeft/Right previous / next day
|
|
99
|
+
* PageUp/PageDown 7 days back / forward
|
|
100
|
+
* Home / End first / last day in range
|
|
101
|
+
*/
|
|
102
|
+
export declare function moveUptimeFocus(date: string, key: string, layout: {
|
|
103
|
+
start: string;
|
|
104
|
+
end: string;
|
|
105
|
+
}): string | null;
|
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* UptimeBar / StatusHistory layout + keyboard math (jatui-i3vxo). Pure and
|
|
3
|
+
* DOM-free.
|
|
4
|
+
*
|
|
5
|
+
* One thin bar per day over a 90-day (default) window, coloured by status
|
|
6
|
+
* tone (operational / degraded / partial / outage — `no-data` is never
|
|
7
|
+
* coloured as up). Days are local calendar dates, keyed "YYYY-MM-DD".
|
|
8
|
+
*
|
|
9
|
+
* Uptime % is minute-based: a day with no `downMinutes` uses a default
|
|
10
|
+
* (outage = full day, partial = half day, degraded = still up), so a feed
|
|
11
|
+
* that only carries statuses still gets a sensible percentage. `no-data`
|
|
12
|
+
* days are excluded from the percentage entirely.
|
|
13
|
+
*/
|
|
14
|
+
/** Incidents rank worst-first. `no-data` is not an incident — it is the LOWEST rank. */
|
|
15
|
+
export const DAY_SEVERITY = ['no-data', 'operational', 'degraded', 'partial', 'outage'];
|
|
16
|
+
const pad = (n) => String(n).padStart(2, '0');
|
|
17
|
+
/** Local "YYYY-MM-DD". */
|
|
18
|
+
export function isoDay(d) {
|
|
19
|
+
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`;
|
|
20
|
+
}
|
|
21
|
+
/** Parse a day as LOCAL noon (DST-safe arithmetic). */
|
|
22
|
+
export function parseDay(input) {
|
|
23
|
+
if (input instanceof Date)
|
|
24
|
+
return new Date(input.getFullYear(), input.getMonth(), input.getDate(), 12);
|
|
25
|
+
const m = /^(\d{4})-(\d{2})-(\d{2})/.exec(input);
|
|
26
|
+
if (m)
|
|
27
|
+
return new Date(Number(m[1]), Number(m[2]) - 1, Number(m[3]), 12);
|
|
28
|
+
const d = new Date(input);
|
|
29
|
+
return new Date(d.getFullYear(), d.getMonth(), d.getDate(), 12);
|
|
30
|
+
}
|
|
31
|
+
export function addDays(day, n) {
|
|
32
|
+
const d = parseDay(day);
|
|
33
|
+
d.setDate(d.getDate() + n);
|
|
34
|
+
return isoDay(d);
|
|
35
|
+
}
|
|
36
|
+
/** Whole days from a to b (b - a). */
|
|
37
|
+
export function daysBetween(a, b) {
|
|
38
|
+
return Math.round((parseDay(b).getTime() - parseDay(a).getTime()) / 86_400_000);
|
|
39
|
+
}
|
|
40
|
+
/** Rank of a status in DAY_SEVERITY (higher = worse). */
|
|
41
|
+
export function statusRank(status) {
|
|
42
|
+
return DAY_SEVERITY.indexOf(status);
|
|
43
|
+
}
|
|
44
|
+
/** The worse of two statuses. */
|
|
45
|
+
export function worstStatus(a, b) {
|
|
46
|
+
return statusRank(a) >= statusRank(b) ? a : b;
|
|
47
|
+
}
|
|
48
|
+
/** Default downtime minutes per status when the feed doesn't carry them. */
|
|
49
|
+
export function defaultDownMinutes(status) {
|
|
50
|
+
switch (status) {
|
|
51
|
+
case 'outage':
|
|
52
|
+
return 1440;
|
|
53
|
+
case 'partial':
|
|
54
|
+
return 720;
|
|
55
|
+
default:
|
|
56
|
+
return 0;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
/** One-word status: 'Normal' / 'Degraded' / 'Partial' / 'Outage' / 'No data'. */
|
|
60
|
+
export function uptimeWord(status) {
|
|
61
|
+
switch (status) {
|
|
62
|
+
case 'operational':
|
|
63
|
+
return 'Normal';
|
|
64
|
+
case 'degraded':
|
|
65
|
+
return 'Degraded';
|
|
66
|
+
case 'partial':
|
|
67
|
+
return 'Partial';
|
|
68
|
+
case 'outage':
|
|
69
|
+
return 'Outage';
|
|
70
|
+
default:
|
|
71
|
+
return 'No data';
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
/**
|
|
75
|
+
* Format the uptime % for display. TRUNCATED toward zero to 2 decimals — a
|
|
76
|
+
* status page must never overclaim (99.996% shows as 99.99%, never 100% when
|
|
77
|
+
* real downtime exists). Formatted with Intl `style: 'percent'` so the locale
|
|
78
|
+
* places the sign and spacing correctly ("99.99%" in en, "99,99 %" in de).
|
|
79
|
+
* `downMinutes > 0` forces a 99.99 cap even when the fraction is exactly 1
|
|
80
|
+
* (defensive: a feed with inconsistent data must not claim perfection).
|
|
81
|
+
* Returns "No data" for null.
|
|
82
|
+
*/
|
|
83
|
+
export function formatUptimePct(pct, locale, downMinutes = 0) {
|
|
84
|
+
if (pct == null)
|
|
85
|
+
return 'No data';
|
|
86
|
+
// Truncate to 2 decimals — never round up past real downtime.
|
|
87
|
+
let n = Math.trunc(pct * 10000) / 100;
|
|
88
|
+
if (downMinutes > 0)
|
|
89
|
+
n = Math.min(n, 99.99);
|
|
90
|
+
return new Intl.NumberFormat(locale, { style: 'percent', maximumFractionDigits: 2 }).format(n / 100);
|
|
91
|
+
}
|
|
92
|
+
/** Roll a status up to the banner's three states: operational / degraded / outage. */
|
|
93
|
+
export function bannerStatus(status) {
|
|
94
|
+
switch (status) {
|
|
95
|
+
case 'outage':
|
|
96
|
+
return 'outage';
|
|
97
|
+
case 'degraded':
|
|
98
|
+
case 'partial':
|
|
99
|
+
return 'degraded';
|
|
100
|
+
default:
|
|
101
|
+
return 'operational';
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
/** Worst of many statuses, rolled up to the banner's three states. */
|
|
105
|
+
export function aggregateStatus(statuses) {
|
|
106
|
+
let worst = 'operational';
|
|
107
|
+
for (const s of statuses) {
|
|
108
|
+
if (s === 'no-data')
|
|
109
|
+
continue;
|
|
110
|
+
worst = worstStatus(worst, s);
|
|
111
|
+
}
|
|
112
|
+
return bannerStatus(worst);
|
|
113
|
+
}
|
|
114
|
+
/**
|
|
115
|
+
* Build the window: `days` (default 90) ending at `end` (default today).
|
|
116
|
+
* Days without data become `no-data` bars. Several entries for one day are
|
|
117
|
+
* merged: worst status wins, downMinutes are summed, the first note kept.
|
|
118
|
+
*/
|
|
119
|
+
export function buildUptime(opts) {
|
|
120
|
+
const getDate = opts.getDate ?? ((d) => d.date);
|
|
121
|
+
const getStatus = opts.getStatus ?? ((d) => d.status);
|
|
122
|
+
const getDownMinutes = opts.getDownMinutes ?? ((d) => d.downMinutes);
|
|
123
|
+
const getNote = opts.getNote ?? ((d) => d.note);
|
|
124
|
+
const days = Math.max(1, opts.days ?? 90);
|
|
125
|
+
const end = isoDay(parseDay(opts.end ?? new Date()));
|
|
126
|
+
const start = addDays(end, -(days - 1));
|
|
127
|
+
const merged = new Map();
|
|
128
|
+
for (const d of opts.data) {
|
|
129
|
+
const key = isoDay(parseDay(getDate(d)));
|
|
130
|
+
if (daysBetween(start, key) < 0 || daysBetween(key, end) < 0)
|
|
131
|
+
continue; // outside the window
|
|
132
|
+
const status = getStatus(d);
|
|
133
|
+
const minutes = Math.max(0, Number(getDownMinutes(d)) || 0);
|
|
134
|
+
const note = getNote(d);
|
|
135
|
+
const prev = merged.get(key);
|
|
136
|
+
if (!prev) {
|
|
137
|
+
merged.set(key, { date: key, status, downMinutes: minutes, note });
|
|
138
|
+
}
|
|
139
|
+
else {
|
|
140
|
+
merged.set(key, {
|
|
141
|
+
date: key,
|
|
142
|
+
status: worstStatus(prev.status, status),
|
|
143
|
+
downMinutes: prev.downMinutes + minutes,
|
|
144
|
+
note: prev.note ?? note
|
|
145
|
+
});
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
const bars = [];
|
|
149
|
+
const cursor = parseDay(start);
|
|
150
|
+
for (let i = 0; i < days; i++) {
|
|
151
|
+
const date = isoDay(cursor);
|
|
152
|
+
const entry = merged.get(date);
|
|
153
|
+
bars.push(entry ?? { date, status: 'no-data', downMinutes: 0 });
|
|
154
|
+
cursor.setDate(cursor.getDate() + 1);
|
|
155
|
+
}
|
|
156
|
+
const dataDays = bars.filter((b) => b.status !== 'no-data').length;
|
|
157
|
+
const totalMinutes = bars.reduce((sum, b) => sum + (b.downMinutes || defaultDownMinutes(b.status)), 0);
|
|
158
|
+
const cap = dataDays * 1440;
|
|
159
|
+
let worst = 'operational';
|
|
160
|
+
for (const b of bars) {
|
|
161
|
+
if (b.status === 'no-data')
|
|
162
|
+
continue;
|
|
163
|
+
worst = worstStatus(worst, b.status);
|
|
164
|
+
}
|
|
165
|
+
// No data at all: the card shows the neutral 'No data' state, not 'Normal'.
|
|
166
|
+
if (dataDays === 0)
|
|
167
|
+
worst = 'no-data';
|
|
168
|
+
return {
|
|
169
|
+
bars,
|
|
170
|
+
days,
|
|
171
|
+
dataDays,
|
|
172
|
+
uptimePct: dataDays === 0 ? null : Math.min(1, Math.max(0, 1 - totalMinutes / cap)),
|
|
173
|
+
worst,
|
|
174
|
+
start,
|
|
175
|
+
end
|
|
176
|
+
};
|
|
177
|
+
}
|
|
178
|
+
/**
|
|
179
|
+
* Keyboard move along the 1-row strip. Returns the new focused day clamped
|
|
180
|
+
* to [start, end], or null for keys it does not handle.
|
|
181
|
+
*
|
|
182
|
+
* ArrowLeft/Right previous / next day
|
|
183
|
+
* PageUp/PageDown 7 days back / forward
|
|
184
|
+
* Home / End first / last day in range
|
|
185
|
+
*/
|
|
186
|
+
export function moveUptimeFocus(date, key, layout) {
|
|
187
|
+
let next;
|
|
188
|
+
switch (key) {
|
|
189
|
+
case 'ArrowLeft':
|
|
190
|
+
next = addDays(date, -1);
|
|
191
|
+
break;
|
|
192
|
+
case 'ArrowRight':
|
|
193
|
+
next = addDays(date, 1);
|
|
194
|
+
break;
|
|
195
|
+
case 'PageUp':
|
|
196
|
+
next = addDays(date, -7);
|
|
197
|
+
break;
|
|
198
|
+
case 'PageDown':
|
|
199
|
+
next = addDays(date, 7);
|
|
200
|
+
break;
|
|
201
|
+
case 'Home':
|
|
202
|
+
return layout.start;
|
|
203
|
+
case 'End':
|
|
204
|
+
return layout.end;
|
|
205
|
+
default:
|
|
206
|
+
return null;
|
|
207
|
+
}
|
|
208
|
+
if (daysBetween(layout.start, next) < 0)
|
|
209
|
+
return layout.start;
|
|
210
|
+
if (daysBetween(next, layout.end) < 0)
|
|
211
|
+
return layout.end;
|
|
212
|
+
return next;
|
|
213
|
+
}
|
package/dist/index.d.ts
CHANGED
|
@@ -401,6 +401,12 @@ export type { BarListItem } from './components/stats/BarList.svelte';
|
|
|
401
401
|
export { default as ActivityHeatmap } from './components/stats/ActivityHeatmap.svelte';
|
|
402
402
|
export { buildHeatmap, moveHeatFocus, isoDay as heatmapIsoDay, defaultLevelOf as heatmapLevelOf } from './components/stats/heatmap';
|
|
403
403
|
export type { HeatDay, HeatmapLayout, BuildHeatmapOptions } from './components/stats/heatmap';
|
|
404
|
+
export { default as UptimeBar } from './components/stats/UptimeBar.svelte';
|
|
405
|
+
export { default as StatusCard } from './components/stats/StatusCard.svelte';
|
|
406
|
+
export { default as StatusBanner } from './components/stats/StatusBanner.svelte';
|
|
407
|
+
export type { BannerStatus } from './components/stats/StatusBanner.svelte';
|
|
408
|
+
export { buildUptime, formatUptimePct, moveUptimeFocus, isoDay as uptimeIsoDay, aggregateStatus, bannerStatus, defaultDownMinutes, uptimeWord, worstStatus } from './components/stats/uptime';
|
|
409
|
+
export type { DayStatus, UptimeBar as UptimeBarDay, UptimeDayInput, UptimeLayout, BuildUptimeOptions } from './components/stats/uptime';
|
|
404
410
|
export { default as SegmentedControl } from './components/SegmentedControl.svelte';
|
|
405
411
|
export type { SegmentedOption } from './components/SegmentedControl.svelte';
|
|
406
412
|
export { default as SettingsSection } from './components/settings/SettingsSection.svelte';
|
|
@@ -417,4 +423,5 @@ export type { SharedAmbience, SharedAmbienceState, SharedAmbienceOptions } from
|
|
|
417
423
|
export type { Ambience, AmbienceGroupDef, AmbienceItem, AmbienceLink, AmbienceOptions, AmbienceState, BreathState, Scape, ScapeContext, ScapeGroup, ScapeInstance } from './ambience';
|
|
418
424
|
export { createPlaylistScape } from './ambience';
|
|
419
425
|
export { isPlaylistScape, trackAttribution } from './ambience';
|
|
426
|
+
export { trackLicenseAllowsRedistribution, isTrackDownloadable, trackDownloadFilename } from './ambience';
|
|
420
427
|
export type { PlaylistScape, PlaylistScapeOptions, PlaylistManifest, PlaylistState, PlaylistStatus, PlaylistTrack } from './ambience';
|
package/dist/index.js
CHANGED
|
@@ -449,6 +449,10 @@ export { default as ChartCard } from './components/stats/ChartCard.svelte';
|
|
|
449
449
|
export { default as BarList } from './components/stats/BarList.svelte';
|
|
450
450
|
export { default as ActivityHeatmap } from './components/stats/ActivityHeatmap.svelte';
|
|
451
451
|
export { buildHeatmap, moveHeatFocus, isoDay as heatmapIsoDay, defaultLevelOf as heatmapLevelOf } from './components/stats/heatmap';
|
|
452
|
+
export { default as UptimeBar } from './components/stats/UptimeBar.svelte';
|
|
453
|
+
export { default as StatusCard } from './components/stats/StatusCard.svelte';
|
|
454
|
+
export { default as StatusBanner } from './components/stats/StatusBanner.svelte';
|
|
455
|
+
export { buildUptime, formatUptimePct, moveUptimeFocus, isoDay as uptimeIsoDay, aggregateStatus, bannerStatus, defaultDownMinutes, uptimeWord, worstStatus } from './components/stats/uptime';
|
|
452
456
|
export { default as SegmentedControl } from './components/SegmentedControl.svelte';
|
|
453
457
|
export { default as SettingsSection } from './components/settings/SettingsSection.svelte';
|
|
454
458
|
export { default as SettingsRow } from './components/settings/SettingsRow.svelte';
|
|
@@ -465,3 +469,7 @@ export { createAmbience, createAmbienceLocal, createSharedAmbience, AMBIENCE_CHA
|
|
|
465
469
|
// createPlaylistScape() and register on the engine yourself.
|
|
466
470
|
export { createPlaylistScape } from './ambience';
|
|
467
471
|
export { isPlaylistScape, trackAttribution } from './ambience';
|
|
472
|
+
// Download gate + filename builder (jatui-0vxcu) — the licence allowlist
|
|
473
|
+
// (CC0 / public domain / CC-BY / CC-BY-SA), the per-track override and the
|
|
474
|
+
// "Composer - Title.ext" name attribution travels under.
|
|
475
|
+
export { trackLicenseAllowsRedistribution, isTrackDownloadable, trackDownloadFilename } from './ambience';
|