Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion apps/web/src/page/kit/widget-shell.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ const headerLegend = <M>(h: HtmlBuilder<M>, items: ReadonlyArray<FixedMetricSeri
align: "center",
shrink: true,
as: "span",
className: "shrink text-xs text-foreground",
className: "shrink text-xs text-muted-foreground",
attributes: [h.Key(item.key)],
},
[
Expand Down
26 changes: 25 additions & 1 deletion packages/ui/src/lib/__tests__/format.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -209,7 +209,31 @@ describe("bucket helpers normalize warehouse timestamps", () => {
it("formatBucketLabel names a midnight tick on a multi-day axis by its day alone", () => {
const context = { rangeMs: 7 * 86_400_000, bucketSeconds: 3 * 3600, timeZone: "UTC", locale: "en-US" }
expect(formatBucketLabel("2026-10-20T00:00:00Z", context, "tick")).toBe("Oct 20")
expect(formatBucketLabel("2026-10-20T12:00:00Z", context, "tick")).toBe("Oct 20, 12:00 PM")
expect(formatBucketLabel("2026-10-20T12:00:00Z", context, "tick")).toMatch(/^Oct 20, 12\sPM$/)
})

it("formatBucketLabel drops the minutes of a tick on the hour, on a 12-hour clock only", () => {
const day = { rangeMs: 6 * 3600 * 1000, bucketSeconds: 300, timeZone: "UTC" }
const us = { ...day, locale: "en-US" }
expect(formatBucketLabel("2026-10-20T06:00:00Z", us, "tick")).toMatch(/^6\sAM$/)
expect(formatBucketLabel("2026-10-20T06:30:00Z", us, "tick")).toMatch(/^6:30\sAM$/)
expect(formatBucketLabel("2026-10-20T06:00:00Z", { ...day, locale: "en-GB" }, "tick")).toBe("06:00")
// A :30 zone's hour is not UTC's.
expect(
formatBucketLabel("2026-10-20T06:30:00Z", { ...us, timeZone: "Asia/Kolkata" }, "tick"),
).toMatch(/^12\sPM$/)
})

it("formatBucketLabel finds a tick on the hour in a locale that prints its own digits", () => {
const context = { rangeMs: 6 * 3600 * 1000, bucketSeconds: 300, timeZone: "UTC" }
const arabic = { ...context, locale: "en-US-u-nu-arab" }
expect(formatBucketLabel("2026-10-20T06:00:00Z", arabic, "tick")).toMatch(/^\u0666\sAM$/)
expect(formatBucketLabel("2026-10-20T06:30:00Z", arabic, "tick")).toMatch(/^\u0666:\u0663\u0660\sAM$/)
})

it("formatBucketLabel keeps the seconds of a tick on a window of minutes", () => {
const context = { rangeMs: 10 * 60 * 1000, bucketSeconds: 10, timeZone: "UTC", locale: "en-US" }
expect(formatBucketLabel("2026-10-20T06:00:00Z", context, "tick")).toMatch(/^6:00:00\sAM$/)
})

it("infers spacing and range from tz-less buckets", () => {
Expand Down
83 changes: 57 additions & 26 deletions packages/ui/src/lib/format.ts
Original file line number Diff line number Diff line change
Expand Up @@ -211,12 +211,64 @@ const isZonedMidnight = (date: Date, timeZone: string | undefined): boolean =>
date.toLocaleTimeString("en-GB", { timeZone, hour: "2-digit", minute: "2-digit", hourCycle: "h23" }) ===
"00:00"

const tickFormatters = new Map<string, Intl.DateTimeFormat>()

const tickFormatter = (
locale: string | undefined,
timeZone: string | undefined,
options: Intl.DateTimeFormatOptions,
): Intl.DateTimeFormat => {
const key = `${locale ?? ""}|${timeZone ?? ""}|${options.month ?? ""}|${options.hour ?? ""}|${options.minute ?? ""}|${options.second ?? ""}`
const cached = tickFormatters.get(key)
if (cached !== undefined) return cached
const formatter = new Intl.DateTimeFormat(locale, { timeZone, ...options })
tickFormatters.set(key, formatter)
return formatter
}

/** A 12-hour clock reads "6:00 AM"; a 24-hour one keeps its leading zero ("06:00"). */
const isTwelveHourClock = (locale: string | undefined): boolean =>
tickFormatter(locale, undefined, { hour: "numeric" }).resolvedOptions().hour12 === true

/**
* A tick's clock, as short as the locale allows: a 12-hour clock drops the leading zero and, on
* the hour, the minutes ("6 AM", "6:30 AM"); a 24-hour clock keeps "06:00".
*/
const formatTickClock = (
date: Date,
options: {
readonly locale: string | undefined
readonly timeZone: string | undefined
readonly withDate: boolean
readonly withSeconds: boolean
},
): string => {
const day: Intl.DateTimeFormatOptions = options.withDate ? { month: "short", day: "numeric" } : {}
const twelveHour = isTwelveHourClock(options.locale)
const full = tickFormatter(options.locale, options.timeZone, {
...day,
hour: twelveHour ? "numeric" : "2-digit",
minute: "2-digit",
second: options.withSeconds ? "2-digit" : undefined,
})
// Read off a Latin-digit formatter: the label's own parts print in the locale's digits.
const onTheHour =
twelveHour &&
!options.withSeconds &&
tickFormatter("en-US", options.timeZone, { minute: "numeric" }).format(date) === "0"
const formatter = onTheHour
? tickFormatter(options.locale, options.timeZone, { ...day, hour: "numeric" })
: full
// An h24 clock prints midnight as "24:00".
return Str.replace(/(^|\s)24:/, "$100:")(formatter.format(date))
}

/**
* Format a bucket timestamp label that adapts based on the overall time range:
* - >= 24h with daily buckets, or a tick on midnight: "Feb 14"
* - >= 24h with sub-day buckets: "Feb 14, 02:00 PM"
* - 30min - 24h: "02:00 PM"
* - <= 30min: "02:00:30 PM"
* - >= 24h with sub-day buckets: "Feb 14, 2 PM"
* - 30min - 24h: "2 PM", "2:30 PM" ("14:00" on a 24-hour clock)
* - <= 30min: "2:00:30 PM"
*/
export function formatBucketLabel(
value: unknown,
Expand Down Expand Up @@ -256,31 +308,10 @@ export function formatBucketLabel(
if ((context.bucketSeconds ?? 0) >= 24 * 60 * 60 || isZonedMidnight(date, timeZone)) {
return date.toLocaleDateString(locale, { timeZone, month: "short", day: "numeric" })
}
return Str.replace(
/ 24:/,
" 00:",
)(
date.toLocaleString(locale, {
timeZone,
month: "short",
day: "numeric",
hour: "2-digit",
minute: "2-digit",
}),
)
return formatTickClock(date, { locale, timeZone, withDate: true, withSeconds: false })
}

return Str.replace(
/^24:/,
"00:",
)(
date.toLocaleTimeString(locale, {
timeZone,
hour: "2-digit",
minute: "2-digit",
second: includeSeconds ? "2-digit" : undefined,
}),
)
return formatTickClock(date, { locale, timeZone, withDate: false, withSeconds: includeSeconds })
}

const bucketLabelMap: Record<number, string> = {
Expand Down
34 changes: 7 additions & 27 deletions packages/views/src/charts/bucket-tooltip.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import type { Attribute, Html, HtmlBuilder } from "foldkit/html"
import type { ChartTooltip } from "./chart-spec"
import { hstack, hstackClass } from "../ui/stack"
import { swatch } from "../ui/swatch"
import { typography } from "../ui/typography"
import { Array as Arr } from "effect"

Expand All @@ -18,14 +19,7 @@ export const bucketTooltipBody = <Message>(
h.div(
[h.Class("grid min-w-[9rem] max-w-[22rem] items-start gap-1.5"), portal],
[
h.div(
[
h.Class(
"border-border-subtle border-b pb-1 font-medium text-muted-foreground tracking-tight",
),
],
[tooltip.heading],
),
h.div([h.Class("border-border-subtle border-b pb-1.5 text-muted-foreground")], [tooltip.heading]),
h.div(
[h.Class("grid gap-1.5")],
Arr.map(tooltip.rows, (row, index) =>
Expand All @@ -39,18 +33,7 @@ export const bucketTooltipBody = <Message>(
),
],
[
h.span([
h.Class(
row.dashed
? "size-2.5 shrink-0 rounded-xs border border-dashed"
: "size-2.5 shrink-0 rounded-xs",
),
h.Style(
row.dashed
? { "border-color": row.color }
: { "background-color": row.color },
),
]),
swatch(h, { color: row.color, dashed: row.dashed }),
hstack(
h,
{
Expand All @@ -75,8 +58,7 @@ export const bucketTooltipBody = <Message>(
h,
{
tone: "foreground",
weight: "semibold",
mono: true,
weight: "medium",
numeric: true,
className: "shrink-0",
},
Expand All @@ -101,11 +83,9 @@ export const bucketTooltipBody = <Message>(
},
[
typography(h, { tone: "muted" }, ["Total"]),
typography(
h,
{ tone: "foreground", weight: "semibold", mono: true, numeric: true },
[tooltip.total],
),
typography(h, { tone: "foreground", weight: "medium", numeric: true }, [
tooltip.total,
]),
],
),
]),
Expand Down
4 changes: 2 additions & 2 deletions packages/views/src/charts/definitions/alert-rule.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { focusCrosshair } from "../plot/plot-focus"
import { DASHED_Y_GRID } from "../plot/plot-grid"
import { Y_GRID } from "../plot/plot-grid"
import { seriesRuns, silenceBridges } from "../plot/series-gaps"
import { bucketTimeScale } from "../plot/plot-scales"
import { formatBucketLabel } from "@maple/ui/lib/format"
Expand Down Expand Up @@ -221,7 +221,7 @@ export const buildAlertRule = (spec: AlertRuleSpec): BuiltChart<SignalPoint, Cha
},
},
y: {
grid: DASHED_Y_GRID,
grid: Y_GRID,
scale: scaleLinear().domain(yDomain),
axis: {
line: false,
Expand Down
4 changes: 2 additions & 2 deletions packages/views/src/charts/definitions/analytics-traffic.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { formatNumber, formatPercent } from "@maple/ui/lib/format"
import { ANALYTICS_METRICS, findMetric } from "../../lib/analytics-metrics"
import { bucketDate, makeBucketAxis } from "../plot/bucket-axis"
import { focusCrosshair, focusDot } from "../plot/plot-focus"
import { DASHED_Y_GRID } from "../plot/plot-grid"
import { Y_GRID } from "../plot/plot-grid"
import { seriesRuns, silenceBridges } from "../plot/series-gaps"
import { areaY, d3Curve, defineChart, dot, lineY, type ChartPoint, type ChartValue } from "@tanstack/charts"
import { scaleLinear } from "@tanstack/charts-scales/linear"
Expand Down Expand Up @@ -113,7 +113,7 @@ export const buildAnalyticsTraffic = (spec: TrafficSpec): BuiltChart<TrafficPoin
scales: {
x: axis.x,
y: {
grid: DASHED_Y_GRID,
grid: Y_GRID,
scale: scaleLinear,
axis: {
line: false,
Expand Down
4 changes: 2 additions & 2 deletions packages/views/src/charts/definitions/anomaly-signal.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { bucketDate, makeBucketAxis } from "../plot/bucket-axis"
import { focusCrosshair, focusDot } from "../plot/plot-focus"
import { DASHED_Y_GRID } from "../plot/plot-grid"
import { Y_GRID } from "../plot/plot-grid"
import { toEpochMs } from "@maple/ui/lib/time-format"
import { formatSignalValue } from "@maple/web-data/anomalies/format"
import { areaY, type ChartValue, d3Curve, defineChart, lineY, rect } from "@tanstack/charts"
Expand Down Expand Up @@ -116,7 +116,7 @@ export const buildAnomalySignal = (spec: AnomalySignalSpec): BuiltChart<SignalPo
scales: {
x: axis.x,
y: {
grid: DASHED_Y_GRID,
grid: Y_GRID,
scale: scaleLinear().domain(yDomain),
axis: {
line: false,
Expand Down
14 changes: 9 additions & 5 deletions packages/views/src/charts/definitions/area.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,9 +34,11 @@ import { gradientId, gradientUrl } from "./gradient-id"
type AreaSpec = Extract<ChartSpec, { readonly _tag: "QueryBuilderArea" }>

const STROKE_WIDTH = SERIES_STROKE_WIDTH
const STACKED_STROKE_WIDTH = 1
const STACKED_FILL_OPACITY = 0.55
const PARTIAL_STACKED_FILL_OPACITY = 0.2
/** A band's top edge: thinner than a lone line, since a stack draws one per series. */
const STACKED_STROKE_WIDTH = 1.25
/** Light enough that the edges carry the shape and the grid reads through the stack. */
const STACKED_FILL_OPACITY = 0.3
const PARTIAL_STACKED_FILL_OPACITY = 0.12
const PARTIAL_FILL_GRADIENT = [AREA_FILL_PARTIAL, AREA_FILL_END] as const

interface Series {
Expand Down Expand Up @@ -170,6 +172,8 @@ export const buildQueryBuilderArea = (spec: AreaSpec): BuiltChart<TimeseriesRow,
: []),
]

// A stack's edges paint top band first: a band at 0 leaves the edge of the one under it visible.
const edgeOrder = isStacked ? Arr.reverse(visible) : visible
const paths = isLine
? [
...Arr.map(visible, (entry) => edge(solid, entry, false)),
Expand All @@ -178,8 +182,8 @@ export const buildQueryBuilderArea = (spec: AreaSpec): BuiltChart<TimeseriesRow,
: [
...Arr.map(visible, (entry) => band(solid, entry, false)),
...(hasDashed ? Arr.map(visible, (entry) => band(dashed, entry, true)) : []),
...(isStacked ? [] : Arr.map(visible, (entry) => edge(solid, entry, false))),
...(hasDashed ? Arr.map(visible, (entry) => edge(dashed, entry, true)) : []),
...Arr.map(edgeOrder, (entry) => edge(solid, entry, false)),
...(hasDashed ? Arr.map(edgeOrder, (entry) => edge(dashed, entry, true)) : []),
]

const definition = defineChart({
Expand Down
47 changes: 47 additions & 0 deletions packages/views/src/charts/definitions/bar.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import { Array as Arr } from "effect"
import { describe, expect, test } from "vitest"
import type { TimeseriesRow } from "../timeseries-rows"
import { cellsOf } from "./bar"

const SERIES = [
{ key: "s1", label: "api", color: "red" },
{ key: "s2", label: "web", color: "green" },
{ key: "s3", label: "worker", color: "blue" },
]

const row = (minute: number, values: Readonly<Record<string, number>>): TimeseriesRow => ({
bucket: `2026-10-01 12:0${minute}:00`,
date: new Date(Date.UTC(2026, 9, 1, 12, minute)),
...values,
})

/** The series keys whose cell is the top of its column, one entry per bucket. */
const topsOf = (rows: ReadonlyArray<TimeseriesRow>): ReadonlyArray<ReadonlyArray<string>> =>
Arr.map(rows, (bucket) =>
Arr.map(
Arr.filter(cellsOf(rows, SERIES, -1, true), (cell) => cell.row === bucket && cell.top),
(cell) => cell.key,
),
)

describe("cellsOf", () => {
test("the top of a stack is its last series with a value, not the last series", () => {
const rows = [
row(0, { s1: 5, s2: 3, s3: 0 }),
row(1, { s1: 0, s2: 0, s3: 2 }),
row(2, { s1: 4, s2: 0, s3: 0 }),
]

expect(topsOf(rows)).toEqual([["s2"], ["s3"], ["s1"]])
})

test("an empty bucket has no top", () => {
expect(topsOf([row(0, { s1: 0, s2: 0, s3: 0 })])).toEqual([[]])
})

test("a stacked cell starts where the series under it end", () => {
const cells = cellsOf([row(0, { s1: 5, s2: 3, s3: 2 })], SERIES, -1, true)

expect(Arr.map(cells, (cell) => cell.base)).toEqual([0, 5, 8])
})
})
Loading
Loading