@joewinke/jatui 0.59.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.
@@ -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';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@joewinke/jatui",
3
- "version": "0.59.0",
3
+ "version": "0.61.0",
4
4
  "private": false,
5
5
  "description": "Shared Svelte 5 component library for JAT projects",
6
6
  "type": "module",