@joewinke/jatui 0.60.0 → 0.61.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 +3 -0
- package/dist/actions/tooltip.d.ts +7 -0
- package/dist/actions/tooltip.js +28 -0
- 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 +6 -0
- package/dist/index.js +4 -0
- package/package.json +1 -1
package/CLAUDE.md
CHANGED
|
@@ -84,6 +84,9 @@ Then import:
|
|
|
84
84
|
| `ChartCard` | "Headline + period switch + chart" shell that draws NO chart (bring layerchart / d3 / SVG). The `chart` snippet gets `{ period, hover, hovered }`; call `hover({value, label, delta?})` as the pointer moves and the headline tweens to that point and the caption shows its label; `hover(null)` (or pointer leave) restores the total. Periods use SegmentedControl. | `label`, `value`, `format?`, `delta?`, `invertDelta?`, `caption?`, `periods?`, `period` (bindable), `onPeriodChange?`, `chartHeight?`, `loading?`, `chart` (snippet), `actions?`/`footer?` (snippets) |
|
|
85
85
|
| `BarList` | Ranked horizontal bars ("top pages / top callers"): label + value as text, a decorative token-tinted bar sized against the max, optional `href` or `onSelect` per row, `hint`, per-row `tone`, `limit` + "Show N more". Pure HTML/CSS, no chart lib. | `items: BarListItem[]`, `sort?` (`desc`), `limit?`, `format?`, `formatValue?(item, total)`, `labelHeader?`/`valueHeader?`, `tone?`, `size?`, `onSelect?`, `label?` (snippet) |
|
|
86
86
|
| `ActivityHeatmap` | GitHub-style activity-per-day calendar (calls/day, tasks closed/day): 7 weekday rows × week columns, default range = last 12 months, `weekStart`, 4-step `color-mix` ramp from one theme `tone` (no hex), one floating tooltip, legend + summary ("1,204 calls in the last year"). `role="grid"` with ONE tab stop: arrows move by day (↑↓) / week (←→), PgUp/PgDn 4 weeks, Home/End row ends, mod+Home/End range ends, Enter/Space select. `fill` (default `true`) sizes the cells to the container — pure CSS (`container-type: inline-size` + cqi), clamped 8–20px (`--hm-cell-min`/`--hm-cell-max`), scrolling only below the floor and the legend parked under the grid's right edge; `fill={false}` is the legacy fixed 12px grid (and the one to pass in a shrink-to-fit host, where an inline-size container collapses). Pure layout helpers exported (`buildHeatmap`, `moveHeatFocus`). | `data` (`{date, count}[]` or `getDate`/`getCount`), `start?`/`end?`, `weekStart?`, `unit?` ('call' or ['call','calls']), `formatLabel?`, `tone?`, `levels?`, `levelOf?`, `compact?`, `fill?` (true), `showLegend?`/`showWeekdays?`/`showMonths?`, `summary?`, `selected` (bindable), `onSelectDay?(date, count)`, `locale?` |
|
|
87
|
+
| `UptimeBar` | One component's 90-day status strip (status-page style): one thin bar per day, coloured by status tone (operational green, degraded amber, partial orange, outage red, `no-data` hatched neutral — never coloured as up), a hover/touch/focus tooltip with date + minutes down + incident note, and the footer "90 days ago ——— 99.97% uptime ——— Today" (uptime % truncated to 2 decimals — never overclaims, e.g. 99.996% shows 99.99% never 100%). `role="grid"` with ONE tab stop and roving focus: arrows move by day, PgUp/PgDn by 7, Home/End to the window ends. Bars size to the container width (pure CSS cqi, clamped 3–14px, `--up-bar-min`/`--up-bar-max`); below the floor the strip scrolls horizontally inside the component, starting at the latest day. Token-only colours (`--jatui-status-*` + `color-mix`). Pure helpers exported (`buildUptime`, `moveUptimeFocus`, `uptimeWord`, `bannerStatus`, `aggregateStatus`, `defaultDownMinutes`, `formatUptimePct`). | `data` (`{date, status, downMinutes?, note?}[]`), `days?` (90), `end?`, `label?`, `formatLabel?`, `showUptime?`/`showRangeLabels?` (true), `locale?`, `class?` |
|
|
88
|
+
| `StatusCard` | One measured component on a status page: name + info tooltip (what is measured), status icon at the right, the 90-day `UptimeBar` strip, and the status word (Normal / Degraded / Outage / No data — from the worst status in the window; `statusWord` overrides). | `name`, `description?`, `data`, `days?`, `end?`, `statusWord?`, `locale?`, `class?` |
|
|
89
|
+
| `StatusBanner` | Status page headline banner: "All systems operational" / degraded / outage, token colours, check / warning / x icon. | `status` ('operational' \| 'degraded' \| 'outage'), `message?`, `info?`, `class?` |
|
|
87
90
|
| `StatusBadge` | Status indicator with dot | `status: StatusValue`, `label` |
|
|
88
91
|
| `Card` | Content card container | `title`, `children` (snippet) |
|
|
89
92
|
| `Modal` | Dialog overlay. `zIndex` accepts `number` OR any CSS `z-index` string, including `"var(--z-lightbox)"`. **On open, focus goes to the first fillable field** (WAI-ARIA APG): a `[data-autofocus]`/`autofocus` element, else the first enabled **rendered** text-entry control (readOnly/disabled textareas excluded, unrendered candidates skipped), else the dialog itself — `initialFocus` overrides the order (`'dialog'` restores the old container focus); the focus landing is verified, else the container is focused. When the dialog is the fallback focus, **Escape is owned by the dialog element itself** (not the window handler), so `trapKeys`' stopPropagation can't swallow Esc; a Modal opened inside a host Drawer closes before the drawer. The component ships its own open-state `.modal-box` opacity rule, so it renders under DaisyUI 5 with no host global override. | `open` (bindable), `title`, `children` (snippet), `zIndex?: string \| number`, `trapKeys?`, `closeOnEscape?`, `initialFocus?` |
|
|
@@ -15,12 +15,19 @@
|
|
|
15
15
|
* Accessibility: sets aria-describedby on the host element.
|
|
16
16
|
* Escape key hides the active tooltip.
|
|
17
17
|
* Focus events (focusin/focusout) trigger alongside mouse hover.
|
|
18
|
+
* Touch/pen: a tap shows the tooltip immediately — only when the host sets
|
|
19
|
+
* `tapToShow` (default false, so plain buttons and badges don't pop on tap);
|
|
20
|
+
* the next tap elsewhere hides it.
|
|
18
21
|
*/
|
|
19
22
|
export interface TooltipOptions {
|
|
20
23
|
text: string;
|
|
21
24
|
placement?: 'top' | 'bottom' | 'left' | 'right';
|
|
22
25
|
/** ms to wait before showing (default 400). 0 = instant. */
|
|
23
26
|
delayMs?: number;
|
|
27
|
+
/** Show the tooltip on a touch/pen TAP (default false — only components
|
|
28
|
+
* that ask for it, e.g. UptimeBar's day bars, pop a tooltip on tap; plain
|
|
29
|
+
* buttons and badges must not). */
|
|
30
|
+
tapToShow?: boolean;
|
|
24
31
|
}
|
|
25
32
|
export declare function tooltip(node: HTMLElement, options: TooltipOptions): {
|
|
26
33
|
update(next: TooltipOptions): void;
|
package/dist/actions/tooltip.js
CHANGED
|
@@ -15,6 +15,9 @@
|
|
|
15
15
|
* Accessibility: sets aria-describedby on the host element.
|
|
16
16
|
* Escape key hides the active tooltip.
|
|
17
17
|
* Focus events (focusin/focusout) trigger alongside mouse hover.
|
|
18
|
+
* Touch/pen: a tap shows the tooltip immediately — only when the host sets
|
|
19
|
+
* `tapToShow` (default false, so plain buttons and badges don't pop on tap);
|
|
20
|
+
* the next tap elsewhere hides it.
|
|
18
21
|
*/
|
|
19
22
|
const COOL_WINDOW_MS = 500;
|
|
20
23
|
const GAP = 8;
|
|
@@ -173,10 +176,29 @@ export function tooltip(node, options) {
|
|
|
173
176
|
if (e.key === 'Escape')
|
|
174
177
|
hide();
|
|
175
178
|
}
|
|
179
|
+
let outTap = null;
|
|
180
|
+
function handlePointerdown(e) {
|
|
181
|
+
// Touch/pen: there is no hover — a TAP shows the tooltip. Only when the
|
|
182
|
+
// host asks for it (tapToShow): plain buttons and badges must not pop a
|
|
183
|
+
// tooltip on every tap. The next tap anywhere else hides it again (the
|
|
184
|
+
// tooltip itself has pointer-events: none, so the tap always reaches
|
|
185
|
+
// whatever is under it).
|
|
186
|
+
if (!opts.tapToShow)
|
|
187
|
+
return;
|
|
188
|
+
if (e.pointerType !== 'touch' && e.pointerType !== 'pen')
|
|
189
|
+
return;
|
|
190
|
+
scheduleShow();
|
|
191
|
+
outTap = (e2) => {
|
|
192
|
+
if (!node.contains(e2.target))
|
|
193
|
+
hide();
|
|
194
|
+
};
|
|
195
|
+
document.addEventListener('pointerdown', outTap, { capture: true, once: true });
|
|
196
|
+
}
|
|
176
197
|
node.addEventListener('mouseenter', scheduleShow);
|
|
177
198
|
node.addEventListener('mouseleave', hide);
|
|
178
199
|
node.addEventListener('focusin', scheduleShow);
|
|
179
200
|
node.addEventListener('focusout', hide);
|
|
201
|
+
node.addEventListener('pointerdown', handlePointerdown);
|
|
180
202
|
document.addEventListener('keydown', handleKeydown);
|
|
181
203
|
return {
|
|
182
204
|
update(next) {
|
|
@@ -188,10 +210,16 @@ export function tooltip(node, options) {
|
|
|
188
210
|
},
|
|
189
211
|
destroy() {
|
|
190
212
|
hide();
|
|
213
|
+
if (outTap) {
|
|
214
|
+
// The once-listener can outlive the node — remove it explicitly.
|
|
215
|
+
document.removeEventListener('pointerdown', outTap, { capture: true });
|
|
216
|
+
outTap = null;
|
|
217
|
+
}
|
|
191
218
|
node.removeEventListener('mouseenter', scheduleShow);
|
|
192
219
|
node.removeEventListener('mouseleave', hide);
|
|
193
220
|
node.removeEventListener('focusin', scheduleShow);
|
|
194
221
|
node.removeEventListener('focusout', hide);
|
|
222
|
+
node.removeEventListener('pointerdown', handlePointerdown);
|
|
195
223
|
document.removeEventListener('keydown', handleKeydown);
|
|
196
224
|
},
|
|
197
225
|
};
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
<!--
|
|
2
|
+
StatusBanner: the overall status page headline (jatui-i3vxo). "All systems
|
|
3
|
+
operational" / degraded / outage, token colours, check / warning / x icon.
|
|
4
|
+
Token-only colours: the tint comes from the status tokens, never a hex.
|
|
5
|
+
-->
|
|
6
|
+
<script lang="ts" module>
|
|
7
|
+
export type BannerStatus = 'operational' | 'degraded' | 'outage';
|
|
8
|
+
</script>
|
|
9
|
+
|
|
10
|
+
<script lang="ts">
|
|
11
|
+
import { tooltip } from '../../actions/tooltip';
|
|
12
|
+
|
|
13
|
+
interface Props {
|
|
14
|
+
/** 'operational' | 'degraded' | 'outage' (see aggregateStatus for a pure helper). */
|
|
15
|
+
status: BannerStatus;
|
|
16
|
+
/** Override the message. */
|
|
17
|
+
message?: string;
|
|
18
|
+
/** Extra detail behind an (i) info button. */
|
|
19
|
+
info?: string;
|
|
20
|
+
class?: string;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
let { status, message, info, class: className = '' }: Props = $props();
|
|
24
|
+
|
|
25
|
+
const text = $derived(
|
|
26
|
+
message ?? (status === 'operational' ? 'All systems operational' : status === 'degraded' ? 'Some systems degraded' : 'Partial outage')
|
|
27
|
+
);
|
|
28
|
+
</script>
|
|
29
|
+
|
|
30
|
+
<div class="jatui-status-banner {className}" role="status" data-status={status}>
|
|
31
|
+
{#if status === 'operational'}
|
|
32
|
+
<svg viewBox="0 0 16 16" aria-hidden="true">
|
|
33
|
+
<circle cx="8" cy="8" r="6.5" fill="none" stroke="currentColor" stroke-width="1.5" />
|
|
34
|
+
<path d="M5.2 8.2 7.1 10l3.7-4" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" />
|
|
35
|
+
</svg>
|
|
36
|
+
{:else if status === 'outage'}
|
|
37
|
+
<svg viewBox="0 0 16 16" aria-hidden="true">
|
|
38
|
+
<circle cx="8" cy="8" r="6.5" fill="none" stroke="currentColor" stroke-width="1.5" />
|
|
39
|
+
<path d="M5.8 5.8l4.4 4.4M10.2 5.8 5.8 10.2" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" />
|
|
40
|
+
</svg>
|
|
41
|
+
{:else}
|
|
42
|
+
<svg viewBox="0 0 16 16" aria-hidden="true">
|
|
43
|
+
<path d="M8 2 14.5 13h-13Z" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linejoin="round" />
|
|
44
|
+
<path d="M8 6.4v3.2" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" />
|
|
45
|
+
<circle cx="8" cy="11.3" r="0.9" fill="currentColor" />
|
|
46
|
+
</svg>
|
|
47
|
+
{/if}
|
|
48
|
+
<span class="sb-text">{text}</span>
|
|
49
|
+
{#if info}
|
|
50
|
+
<button
|
|
51
|
+
type="button"
|
|
52
|
+
class="sb-info"
|
|
53
|
+
aria-label="More about this status"
|
|
54
|
+
use:tooltip={{ text: info, placement: 'top', delayMs: 0 }}
|
|
55
|
+
>
|
|
56
|
+
<svg viewBox="0 0 16 16" aria-hidden="true">
|
|
57
|
+
<circle cx="8" cy="8" r="6.5" fill="none" stroke="currentColor" stroke-width="1.5" />
|
|
58
|
+
<path d="M8 7.2v4" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" />
|
|
59
|
+
<circle cx="8" cy="4.8" r="0.9" fill="currentColor" />
|
|
60
|
+
</svg>
|
|
61
|
+
</button>
|
|
62
|
+
{/if}
|
|
63
|
+
</div>
|
|
64
|
+
|
|
65
|
+
<style>
|
|
66
|
+
.jatui-status-banner {
|
|
67
|
+
--_st: var(--jatui-status-positive, var(--color-success));
|
|
68
|
+
display: flex;
|
|
69
|
+
align-items: center;
|
|
70
|
+
gap: var(--jatui-space-base, 8px);
|
|
71
|
+
padding: var(--jatui-space-base, 8px) var(--jatui-space-lg, 16px);
|
|
72
|
+
border-radius: var(--jatui-radius-box, 0.5rem);
|
|
73
|
+
background: color-mix(in oklch, var(--_st) 12%, var(--color-base-100));
|
|
74
|
+
border: 1px solid color-mix(in oklch, var(--_st) 30%, transparent);
|
|
75
|
+
color: var(--_st);
|
|
76
|
+
font-size: 0.9375rem;
|
|
77
|
+
font-weight: 600;
|
|
78
|
+
}
|
|
79
|
+
.jatui-status-banner[data-status='degraded'] {
|
|
80
|
+
--_st: var(--jatui-status-caution, var(--color-warning));
|
|
81
|
+
}
|
|
82
|
+
.jatui-status-banner[data-status='outage'] {
|
|
83
|
+
--_st: var(--jatui-status-negative, var(--color-error));
|
|
84
|
+
}
|
|
85
|
+
.jatui-status-banner svg {
|
|
86
|
+
width: 16px;
|
|
87
|
+
height: 16px;
|
|
88
|
+
flex: none;
|
|
89
|
+
}
|
|
90
|
+
.sb-text {
|
|
91
|
+
flex: 1;
|
|
92
|
+
min-width: 0;
|
|
93
|
+
}
|
|
94
|
+
.sb-info {
|
|
95
|
+
display: inline-flex;
|
|
96
|
+
align-items: center;
|
|
97
|
+
justify-content: center;
|
|
98
|
+
width: 24px;
|
|
99
|
+
height: 24px;
|
|
100
|
+
padding: 0;
|
|
101
|
+
border: 0;
|
|
102
|
+
background: transparent;
|
|
103
|
+
border-radius: var(--jatui-radius-field, 0.25rem);
|
|
104
|
+
color: inherit;
|
|
105
|
+
opacity: 0.75;
|
|
106
|
+
cursor: help;
|
|
107
|
+
}
|
|
108
|
+
.sb-info svg {
|
|
109
|
+
width: 14px;
|
|
110
|
+
height: 14px;
|
|
111
|
+
}
|
|
112
|
+
.sb-info:hover {
|
|
113
|
+
opacity: 1;
|
|
114
|
+
}
|
|
115
|
+
.sb-info:focus-visible {
|
|
116
|
+
outline: 2px solid var(--color-primary);
|
|
117
|
+
outline-offset: 1px;
|
|
118
|
+
}
|
|
119
|
+
</style>
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
export type BannerStatus = 'operational' | 'degraded' | 'outage';
|
|
2
|
+
interface Props {
|
|
3
|
+
/** 'operational' | 'degraded' | 'outage' (see aggregateStatus for a pure helper). */
|
|
4
|
+
status: BannerStatus;
|
|
5
|
+
/** Override the message. */
|
|
6
|
+
message?: string;
|
|
7
|
+
/** Extra detail behind an (i) info button. */
|
|
8
|
+
info?: string;
|
|
9
|
+
class?: string;
|
|
10
|
+
}
|
|
11
|
+
declare const StatusBanner: import("svelte").Component<Props, {}, "">;
|
|
12
|
+
type StatusBanner = ReturnType<typeof StatusBanner>;
|
|
13
|
+
export default StatusBanner;
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
<!--
|
|
2
|
+
StatusCard: one measured component on a status page (jatui-i3vxo). Name +
|
|
3
|
+
info tooltip (what is measured), the status icon at the right, the
|
|
4
|
+
UptimeBar strip, and the status word (Normal / Degraded / Outage / …).
|
|
5
|
+
Data in: `{ date, status, downMinutes?, note? }[]`. Composes with
|
|
6
|
+
StatusBanner on top of a status page grid.
|
|
7
|
+
-->
|
|
8
|
+
<script lang="ts">
|
|
9
|
+
import { tooltip } from '../../actions/tooltip';
|
|
10
|
+
import { buildUptime, formatUptimePct, uptimeWord, type UptimeDayInput } from './uptime';
|
|
11
|
+
import UptimeBar from './UptimeBar.svelte';
|
|
12
|
+
|
|
13
|
+
interface Props {
|
|
14
|
+
/** Name of the measured thing (e.g. "API"). */
|
|
15
|
+
name: string;
|
|
16
|
+
/** What is measured, shown behind an (i) info button. */
|
|
17
|
+
description?: string;
|
|
18
|
+
data: readonly UptimeDayInput[];
|
|
19
|
+
/** Window length in days. Default 90. */
|
|
20
|
+
days?: number;
|
|
21
|
+
end?: string | Date;
|
|
22
|
+
/** Override the status word. */
|
|
23
|
+
statusWord?: string;
|
|
24
|
+
locale?: string;
|
|
25
|
+
class?: string;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
let {
|
|
29
|
+
name,
|
|
30
|
+
description,
|
|
31
|
+
data,
|
|
32
|
+
days = 90,
|
|
33
|
+
end,
|
|
34
|
+
statusWord,
|
|
35
|
+
locale,
|
|
36
|
+
class: className = ''
|
|
37
|
+
}: Props = $props();
|
|
38
|
+
|
|
39
|
+
const layout = $derived(buildUptime({ data, days, end }));
|
|
40
|
+
const word = $derived(statusWord ?? uptimeWord(layout.worst));
|
|
41
|
+
|
|
42
|
+
const totalDown = $derived(layout.bars.reduce((s, b) => s + b.downMinutes, 0));
|
|
43
|
+
// Truncating formatter — never overclaims (see formatUptimePct).
|
|
44
|
+
const uptimeText = $derived(
|
|
45
|
+
layout.uptimePct == null
|
|
46
|
+
? 'No data'
|
|
47
|
+
: `${formatUptimePct(layout.uptimePct, locale, totalDown)} uptime`
|
|
48
|
+
);
|
|
49
|
+
|
|
50
|
+
/** "operational" | "degraded" | "partial" | "outage" | "no-data" — for the tone classes. */
|
|
51
|
+
const heading = $derived(layout.worst);
|
|
52
|
+
</script>
|
|
53
|
+
|
|
54
|
+
<section class="jatui-status-card st-{heading} {className}" aria-label={name}>
|
|
55
|
+
<header class="sc-head">
|
|
56
|
+
<h3 class="sc-name">
|
|
57
|
+
<span>{name}</span>
|
|
58
|
+
{#if description}
|
|
59
|
+
<button
|
|
60
|
+
type="button"
|
|
61
|
+
class="sc-info"
|
|
62
|
+
aria-label={`About ${name}`}
|
|
63
|
+
use:tooltip={{ text: description, placement: 'top', delayMs: 0 }}
|
|
64
|
+
>
|
|
65
|
+
<svg viewBox="0 0 16 16" aria-hidden="true">
|
|
66
|
+
<circle cx="8" cy="8" r="6.5" fill="none" stroke="currentColor" stroke-width="1.5" />
|
|
67
|
+
<path d="M8 7.2v4" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" />
|
|
68
|
+
<circle cx="8" cy="4.8" r="0.9" fill="currentColor" />
|
|
69
|
+
</svg>
|
|
70
|
+
</button>
|
|
71
|
+
{/if}
|
|
72
|
+
</h3>
|
|
73
|
+
<span class="sc-status">
|
|
74
|
+
{#if heading === 'operational'}
|
|
75
|
+
<svg viewBox="0 0 16 16" aria-hidden="true">
|
|
76
|
+
<circle cx="8" cy="8" r="6.5" fill="none" stroke="currentColor" stroke-width="1.5" />
|
|
77
|
+
<path d="M5.2 8.2 7.1 10l3.7-4" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" />
|
|
78
|
+
</svg>
|
|
79
|
+
{:else if heading === 'outage'}
|
|
80
|
+
<svg viewBox="0 0 16 16" aria-hidden="true">
|
|
81
|
+
<circle cx="8" cy="8" r="6.5" fill="none" stroke="currentColor" stroke-width="1.5" />
|
|
82
|
+
<path d="M5.8 5.8l4.4 4.4M10.2 5.8 5.8 10.2" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" />
|
|
83
|
+
</svg>
|
|
84
|
+
{:else if heading === 'no-data'}
|
|
85
|
+
<svg viewBox="0 0 16 16" aria-hidden="true">
|
|
86
|
+
<circle cx="8" cy="8" r="6.5" fill="none" stroke="currentColor" stroke-width="1.5" />
|
|
87
|
+
<path d="M5.6 8h4.8" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" />
|
|
88
|
+
</svg>
|
|
89
|
+
{:else}
|
|
90
|
+
<svg viewBox="0 0 16 16" aria-hidden="true">
|
|
91
|
+
<path d="M8 2 14.5 13h-13Z" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linejoin="round" />
|
|
92
|
+
<path d="M8 6.4v3.2" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" />
|
|
93
|
+
<circle cx="8" cy="11.3" r="0.9" fill="currentColor" />
|
|
94
|
+
</svg>
|
|
95
|
+
{/if}
|
|
96
|
+
<span class="sc-word">{word}</span>
|
|
97
|
+
</span>
|
|
98
|
+
</header>
|
|
99
|
+
|
|
100
|
+
<UptimeBar {data} {days} {end} {locale} label={name} />
|
|
101
|
+
|
|
102
|
+
<!-- Screen-reader text per bar group: the strip's aria-label already
|
|
103
|
+
carries the summary; this paragraph is the visible fallback for the
|
|
104
|
+
heading structure. -->
|
|
105
|
+
<p class="sr-only">{name}: {word}, {uptimeText}</p>
|
|
106
|
+
</section>
|
|
107
|
+
|
|
108
|
+
<style>
|
|
109
|
+
.jatui-status-card {
|
|
110
|
+
--_st: var(--jatui-status-positive, var(--color-success));
|
|
111
|
+
display: flex;
|
|
112
|
+
flex-direction: column;
|
|
113
|
+
gap: var(--jatui-space-base, 8px);
|
|
114
|
+
padding: var(--jatui-space-lg, 16px);
|
|
115
|
+
border: 1px solid var(--jatui-separator, color-mix(in oklch, var(--color-base-content) 12%, transparent));
|
|
116
|
+
border-radius: var(--jatui-radius-box, 0.5rem);
|
|
117
|
+
background: var(--color-base-100);
|
|
118
|
+
}
|
|
119
|
+
.st-operational { --_st: var(--jatui-status-positive, var(--color-success)); }
|
|
120
|
+
.st-degraded { --_st: var(--jatui-status-caution, var(--color-warning)); }
|
|
121
|
+
.st-partial { --_st: color-mix(in oklch, var(--color-warning) 55%, var(--color-error)); }
|
|
122
|
+
.st-outage { --_st: var(--jatui-status-negative, var(--color-error)); }
|
|
123
|
+
.st-no-data { --_st: var(--jatui-status-neutral, color-mix(in oklab, var(--color-base-content) 40%, transparent)); }
|
|
124
|
+
|
|
125
|
+
.sc-head {
|
|
126
|
+
display: flex;
|
|
127
|
+
align-items: center;
|
|
128
|
+
justify-content: space-between;
|
|
129
|
+
gap: var(--jatui-space-base, 8px);
|
|
130
|
+
}
|
|
131
|
+
.sc-name {
|
|
132
|
+
display: flex;
|
|
133
|
+
align-items: center;
|
|
134
|
+
gap: var(--jatui-space-sm, 4px);
|
|
135
|
+
margin: 0;
|
|
136
|
+
font-size: 0.9375rem;
|
|
137
|
+
font-weight: 600;
|
|
138
|
+
color: var(--color-base-content);
|
|
139
|
+
}
|
|
140
|
+
.sc-info {
|
|
141
|
+
display: inline-flex;
|
|
142
|
+
align-items: center;
|
|
143
|
+
justify-content: center;
|
|
144
|
+
width: 24px;
|
|
145
|
+
height: 24px;
|
|
146
|
+
padding: 0;
|
|
147
|
+
border: 0;
|
|
148
|
+
background: transparent;
|
|
149
|
+
border-radius: var(--jatui-radius-field, 0.25rem);
|
|
150
|
+
color: color-mix(in oklch, var(--color-base-content) 55%, transparent);
|
|
151
|
+
cursor: help;
|
|
152
|
+
}
|
|
153
|
+
.sc-info svg {
|
|
154
|
+
width: 14px;
|
|
155
|
+
height: 14px;
|
|
156
|
+
}
|
|
157
|
+
.sc-info:hover {
|
|
158
|
+
color: var(--color-base-content);
|
|
159
|
+
}
|
|
160
|
+
.sc-info:focus-visible {
|
|
161
|
+
outline: 2px solid var(--color-primary);
|
|
162
|
+
outline-offset: 1px;
|
|
163
|
+
}
|
|
164
|
+
.sc-status {
|
|
165
|
+
display: inline-flex;
|
|
166
|
+
align-items: center;
|
|
167
|
+
gap: var(--jatui-space-sm, 4px);
|
|
168
|
+
color: var(--_st);
|
|
169
|
+
font-size: 0.875rem;
|
|
170
|
+
font-weight: 500;
|
|
171
|
+
}
|
|
172
|
+
.sc-status svg {
|
|
173
|
+
width: 16px;
|
|
174
|
+
height: 16px;
|
|
175
|
+
}
|
|
176
|
+
</style>
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { type UptimeDayInput } from './uptime';
|
|
2
|
+
interface Props {
|
|
3
|
+
/** Name of the measured thing (e.g. "API"). */
|
|
4
|
+
name: string;
|
|
5
|
+
/** What is measured, shown behind an (i) info button. */
|
|
6
|
+
description?: string;
|
|
7
|
+
data: readonly UptimeDayInput[];
|
|
8
|
+
/** Window length in days. Default 90. */
|
|
9
|
+
days?: number;
|
|
10
|
+
end?: string | Date;
|
|
11
|
+
/** Override the status word. */
|
|
12
|
+
statusWord?: string;
|
|
13
|
+
locale?: string;
|
|
14
|
+
class?: string;
|
|
15
|
+
}
|
|
16
|
+
declare const StatusCard: import("svelte").Component<Props, {}, "">;
|
|
17
|
+
type StatusCard = ReturnType<typeof StatusCard>;
|
|
18
|
+
export default StatusCard;
|
|
@@ -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';
|
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';
|