<script lang="ts">
import {
HealthChart,
type DataPoint
} from "$lib/components/ui/health-chart/index.js";
import { DateTime } from "luxon";
const day = DateTime.now().startOf("day");
const data: DataPoint[] = [
{
status: "orange",
description: "Image processing latency increased",
timestamp: day.minus({ days: 52 }).plus({ hours: 8 }).toJSDate()
},
{
status: "red",
description: "Checkout writes were unavailable",
timestamp: day.minus({ days: 41 }).plus({ hours: 3 }).toJSDate()
},
{
status: "orange",
description: "Checkout writes recovered with degraded queue depth",
timestamp: day.minus({ days: 41 }).plus({ hours: 16 }).toJSDate()
},
{
status: "orange",
description: "Search indexing lagged behind ingestion",
timestamp: day.minus({ days: 24 }).plus({ hours: 11 }).toJSDate()
},
{
status: "red",
description: "Public API returned elevated 5xx responses",
timestamp: day.minus({ days: 9 }).plus({ hours: 5 }).toJSDate()
},
{
status: "green",
description: "Synthetic checks passed after remediation",
timestamp: day.minus({ days: 9 }).plus({ hours: 21 }).toJSDate()
}
];
</script>
<HealthChart
{data}
range="60d"
emptyStatus="green"
emptyText="No incident recorded"
class="max-w-2xl"
/> Each bar covers a fixed time slot within the selected range. A datapoint's timestamp decides which bar it lands in; when several land in the same slot, the latest by timestamp is shown. Empty slots take the emptyStatus color.
Installation
pnpm dlx jsrepo@latest add health-chartnpx jsrepo@latest add health-chartyarn dlx jsrepo@latest add health-chartbunx jsrepo@latest add health-chartInstall runtime dependencies:
pnpm add bits-ui luxon clsx tailwind-mergenpm install bits-ui luxon clsx tailwind-mergeyarn add bits-ui luxon clsx tailwind-mergebun add bits-ui luxon clsx tailwind-mergeInstall the Luxon types and Kura style dependencies:
pnpm add @types/luxon tw-animate-css @fontsource-variable/geist -Dnpm install @types/luxon tw-animate-css @fontsource-variable/geist -Dyarn add @types/luxon tw-animate-css @fontsource-variable/geist -Dbun add @types/luxon tw-animate-css @fontsource-variable/geist -DCopy and paste the following code into your project.
<script module lang="ts">
export type HealthChartStatus = 'red' | 'orange' | 'green' | 'gray';
export type HealthChartRange = '60m' | '24h' | '30d' | '60d';
export type DataPoint = {
status: HealthChartStatus;
description: string;
timestamp: Date;
};
</script>
<script lang="ts">
import * as Tooltip from '$lib/components/ui/tooltip/index.js';
import { cn } from '$lib/utils.js';
import { DateTime, Duration, Interval } from 'luxon';
import type { SvelteHTMLElements } from 'svelte/elements';
type RangeConfig = {
count: number;
unit: Duration;
latestStart: DateTime;
};
type Bucket = {
start: DateTime;
end: DateTime;
point?: DataPoint;
};
type Props = Omit<SvelteHTMLElements['div'], 'children'> & {
data?: Array<DataPoint>;
range?: HealthChartRange;
emptyStatus?: HealthChartStatus;
emptyText?: string;
};
let {
data = [],
range = '60d',
emptyStatus = 'gray',
emptyText = 'No data',
class: className,
...restProps
}: Props = $props();
const statusClasses: Record<HealthChartStatus, string> = {
green: 'bg-success/80 hover:bg-success data-[state=open]:bg-success',
orange: 'bg-warning/85 hover:bg-warning data-[state=open]:bg-warning',
red: 'bg-destructive/85 hover:bg-destructive data-[state=open]:bg-destructive',
gray: 'bg-muted-foreground/25 hover:bg-muted-foreground/35 data-[state=open]:bg-muted-foreground/35'
};
function getRangeConfig(range: HealthChartRange): RangeConfig {
const now = DateTime.now();
switch (range) {
case '60m':
return {
count: 60,
unit: Duration.fromObject({ minute: 1 }),
latestStart: now.startOf('minute')
};
case '24h':
return {
count: 24,
unit: Duration.fromObject({ hour: 1 }),
latestStart: now.startOf('hour')
};
case '30d':
return {
count: 30,
unit: Duration.fromObject({ day: 1 }),
latestStart: now.startOf('day')
};
case '60d':
return {
count: 60,
unit: Duration.fromObject({ day: 1 }),
latestStart: now.startOf('day')
};
}
}
function scaleDuration(duration: Duration, amount: number): Duration {
return duration.mapUnits((value) => value * amount);
}
function isInBucket(timestamp: Date, bucket: Bucket) {
return Interval.fromDateTimes(bucket.start, bucket.end).contains(
DateTime.fromJSDate(timestamp)
);
}
function formatBucketLabel(bucket: Bucket) {
const dateTimeFormat = "d LLLL yyyy 'at' HH:mm";
const dateFormat = 'd LLLL yyyy';
const timeFormat = 'HH:mm';
if (bucket.point) {
return DateTime.fromJSDate(bucket.point.timestamp).toFormat(dateTimeFormat);
}
if (range === '60m' || range === '24h') {
if (bucket.start.hasSame(bucket.end, 'day')) {
return `${bucket.start.toFormat(dateTimeFormat)} - ${bucket.end.toFormat(timeFormat)}`;
}
return `${bucket.start.toFormat(dateTimeFormat)} - ${bucket.end.toFormat(dateTimeFormat)}`;
}
return bucket.start.toFormat(dateFormat);
}
const buckets = $derived.by(() => {
const { count, unit, latestStart } = getRangeConfig(range);
const oldestStart = latestStart.minus(scaleDuration(unit, count - 1));
const newestFirst = data
.toSorted((a, b) => a.timestamp.getTime() - b.timestamp.getTime())
.toReversed();
return Array.from({ length: count }, (_, i): Bucket => {
const start = oldestStart.plus(scaleDuration(unit, i));
const bucket = {
start,
end: start.plus(unit)
};
const point = newestFirst.find((point) => isInBucket(point.timestamp, bucket));
return {
...bucket,
point
};
});
});
</script>
<Tooltip.Provider delayDuration={80} skipDelayDuration={300} disableCloseOnTriggerClick>
<div
data-slot="health-chart"
data-range={range}
class={cn('flex h-10 w-full items-end gap-px', className)}
{...restProps}
>
{#each buckets as bucket (bucket.start.toMillis())}
{const status = bucket.point?.status ?? emptyStatus}
{const dateLabel = formatBucketLabel(bucket)}
{const description = bucket.point?.description ?? emptyText}
{const datetime =
(bucket.point ? DateTime.fromJSDate(bucket.point.timestamp) : bucket.start).toISO() ??
undefined}
<Tooltip.Root>
<Tooltip.Trigger
type="button"
aria-label={`${dateLabel}: ${description}`}
data-health-chart-bar
data-status={status}
data-empty={bucket.point ? undefined : true}
tabindex={bucket.point ? 0 : -1}
class={cn(
'h-full min-w-px flex-1 cursor-default rounded-t-bar rounded-b-bar border border-transparent p-0 opacity-90 transition-[background-color,opacity,transform] duration-200 hover:opacity-100 focus-visible:border-ring focus-visible:ring-1 focus-visible:ring-ring/50 data-[state=open]:opacity-100',
statusClasses[status]
)}
/>
<Tooltip.Content
sideOffset={6}
class="hairline-frame max-w-64 flex-col items-start gap-1.5 px-2.5 py-2 text-left leading-relaxed"
>
<time {datetime} class="font-mono text-[11px]/none text-muted-foreground">
{dateLabel}
</time>
<p class="max-w-60 text-xs/relaxed font-medium text-popover-foreground">
{description}
</p>
</Tooltip.Content>
</Tooltip.Root>
{/each}
</div>
</Tooltip.Provider>
Usage
<script lang="ts">
import { HealthChart, type DataPoint } from '$lib/components/ui/health-chart/index.js';
import { DateTime } from 'luxon';
const day = DateTime.now().startOf('day');
const data: DataPoint[] = [
{
status: 'red',
description: 'Public API returned elevated 5xx responses',
timestamp: day.minus({ days: 9 }).plus({ hours: 5 }).toJSDate()
},
{
status: 'green',
description: 'Synthetic checks passed after remediation',
timestamp: day.minus({ days: 9 }).plus({ hours: 21 }).toJSDate()
}
];
</script>
<HealthChart {data} range="60d" emptyStatus="green" emptyText="No incident recorded" /> Data Model
type DataPoint = {
status: 'red' | 'orange' | 'green' | 'gray';
description: string;
timestamp: Date;
}; Data may be unsorted.
Examples
Ranges
60m
24h
30d
60d
<script lang="ts">
import {
HealthChart,
type DataPoint,
type HealthChartRange
} from "$lib/components/ui/health-chart/index.js";
import { DateTime } from "luxon";
const now = DateTime.now();
const minute = now.startOf("minute");
const hour = now.startOf("hour");
const day = now.startOf("day");
function point(
status: DataPoint["status"],
description: string,
timestamp: DateTime
): DataPoint {
return { status, description, timestamp: timestamp.toJSDate() };
}
const rows: { range: HealthChartRange; data: DataPoint[] }[] = [
{
range: "60m",
data: [
point(
"orange",
"Queue depth rose above threshold",
minute.minus({ minutes: 47 }).plus({ seconds: 18 })
),
point(
"red",
"Worker pool stopped accepting jobs",
minute.minus({ minutes: 21 }).plus({ seconds: 33 })
),
point(
"green",
"Workers recovered",
minute.minus({ minutes: 4 }).plus({ seconds: 12 })
)
]
},
{
range: "24h",
data: [
point(
"orange",
"Deploy verification slowed down",
hour.minus({ hours: 22 }).plus({ minutes: 9 })
),
point(
"red",
"Webhook delivery paused",
hour.minus({ hours: 8 }).plus({ minutes: 24 })
),
point(
"green",
"Webhook delivery resumed",
hour.minus({ hours: 2 }).plus({ minutes: 41 })
)
]
},
{
range: "30d",
data: [
point(
"orange",
"Background jobs delayed",
day.minus({ days: 25 }).plus({ hours: 10 })
),
point(
"red",
"Exports unavailable",
day.minus({ days: 17 }).plus({ hours: 6 })
),
point(
"orange",
"Search freshness delayed",
day.minus({ days: 6 }).plus({ hours: 15 })
)
]
},
{
range: "60d",
data: [
point(
"orange",
"Scheduled maintenance exceeded window",
day.minus({ days: 54 }).plus({ hours: 9 })
),
point(
"red",
"Upload API unavailable",
day.minus({ days: 38 }).plus({ hours: 13 })
),
point(
"orange",
"Notifications degraded",
day.minus({ days: 18 }).plus({ hours: 4 })
)
]
}
];
</script>
<div class="flex w-full max-w-2xl flex-col gap-3">
{#each rows as row (row.range)}
<div class="grid grid-cols-[3rem_1fr] items-center gap-3">
<span class="font-mono text-xs text-muted-foreground">{row.range}</span>
<HealthChart
data={row.data}
range={row.range}
emptyStatus="gray"
emptyText="No sample"
class="h-7"
/>
</div>
{/each}
</div> Minute ranges anchor to the current minute, hour ranges to the current hour, and day ranges to the start of today.
Empty state
emptyStatus="green"
emptyStatus="gray"
<script lang="ts">
import {
HealthChart,
type DataPoint
} from "$lib/components/ui/health-chart/index.js";
import { DateTime } from "luxon";
const day = DateTime.now().startOf("day");
function point(
status: DataPoint["status"],
description: string,
timestamp: DateTime
): DataPoint {
return { status, description, timestamp: timestamp.toJSDate() };
}
const data: DataPoint[] = [
point(
"orange",
"Read replicas lagged behind primary",
day.minus({ days: 24 }).plus({ hours: 7 })
),
point(
"red",
"Billing API requests timed out",
day.minus({ days: 13 }).plus({ hours: 11 })
),
point(
"orange",
"Billing API recovered with elevated latency",
day.minus({ days: 13 }).plus({ hours: 20 })
),
point(
"red",
"Ingestion endpoint rejected events",
day.minus({ days: 4 }).plus({ hours: 3 })
)
];
</script>
<div class="grid w-full max-w-2xl gap-4 sm:grid-cols-2">
<div class="flex flex-col gap-2">
<span class="font-mono text-xs text-muted-foreground"
>emptyStatus="green"</span
>
<HealthChart
{data}
range="30d"
emptyStatus="green"
emptyText="No incident recorded"
/>
</div>
<div class="flex flex-col gap-2">
<span class="font-mono text-xs text-muted-foreground"
>emptyStatus="gray"</span
>
<HealthChart
{data}
range="30d"
emptyStatus="gray"
emptyText="No sample recorded"
/>
</div>
</div> Use emptyStatus="green" when your data records incidents only, an empty slot means nothing went wrong. Use emptyStatus="gray" when your data is sampled, an empty slot means no sample exists.
Props
The component also accepts native div attributes.