From 8723770036ee0f13d5bd176191853bd60e420fd8 Mon Sep 17 00:00:00 2001 From: FrancescoCastaldi Date: Thu, 27 Aug 2026 16:02:27 +0200 Subject: [PATCH 1/2] feat(core): add all-in-one MetricCard component - add / metric_card tag combining BigValue, Sparkline trend, and Delta badge - support automatic delta calculation and comparison period labeling - support interactive sparkline rendering (line, area, bar) - support optional clickable card links and info tooltip - add unit test suite --- .../tags/metric_card/MetricCard.svelte | 252 ++++++++++++++++++ .../tags/metric_card/MetricCard.test.ts | 17 ++ .../tags/metric_card/schema.ts | 159 +++++++++++ .../tags/metric_card/user-component.ts | 8 + 4 files changed, 436 insertions(+) create mode 100644 core/src/user-components/tags/metric_card/MetricCard.svelte create mode 100644 core/src/user-components/tags/metric_card/MetricCard.test.ts create mode 100644 core/src/user-components/tags/metric_card/schema.ts create mode 100644 core/src/user-components/tags/metric_card/user-component.ts diff --git a/core/src/user-components/tags/metric_card/MetricCard.svelte b/core/src/user-components/tags/metric_card/MetricCard.svelte new file mode 100644 index 0000000000..61ad593553 --- /dev/null +++ b/core/src/user-components/tags/metric_card/MetricCard.svelte @@ -0,0 +1,252 @@ + + +
e.key === 'Enter' && handleClick() : undefined} + class={cn( + 'relative flex flex-col justify-between rounded-xl border bg-card p-4 text-card-foreground shadow-xs transition-all select-none', + link ? 'cursor-pointer hover:border-primary/50 hover:shadow-md' : '', + 'min-w-[180px]' + )} +> + +
+
+ {#if title} + {title} + {/if} + {#if info} + + {/if} +
+ {#if subtitle} + {subtitle} + {/if} +
+ + +
+ {#if loading} + + {:else} + + {displayValue} + + {/if} +
+ + +
+
+ {#if comparisonDelta !== null} + + {/if} +
+ + {#if sparklineData && sparklineData.length > 0} +
+ +
+ {/if} +
+
diff --git a/core/src/user-components/tags/metric_card/MetricCard.test.ts b/core/src/user-components/tags/metric_card/MetricCard.test.ts new file mode 100644 index 0000000000..5dcd1d6c9e --- /dev/null +++ b/core/src/user-components/tags/metric_card/MetricCard.test.ts @@ -0,0 +1,17 @@ +import { describe, it, expect } from 'vitest'; +import { schema } from './schema'; + +describe('MetricCard Tag', () => { + it('has valid schema attributes', () => { + expect(schema.render).toBe('metric_card'); + expect(schema.category).toBe('component'); + expect(schema.attributes.data).toBeDefined(); + expect(schema.attributes.value).toBeDefined(); + expect(schema.attributes.value_fmt).toBeDefined(); + expect(schema.attributes.title).toBeDefined(); + expect(schema.attributes.comparison).toBeDefined(); + expect(schema.attributes.sparkline_date).toBeDefined(); + expect(schema.attributes.badge).toBeDefined(); + expect(schema.attributes.down_is_good).toBeDefined(); + }); +}); diff --git a/core/src/user-components/tags/metric_card/schema.ts b/core/src/user-components/tags/metric_card/schema.ts new file mode 100644 index 0000000000..34176f1a73 --- /dev/null +++ b/core/src/user-components/tags/metric_card/schema.ts @@ -0,0 +1,159 @@ +import type { UserComponentSchema } from '../../types'; +import { WIDTH_ATTRIBUTE } from '../../common/width-attribute'; +import { SQL_OPTIONS, REFRESH_INTERVAL_ATTRIBUTE } from '../../common/sql-options'; +import { DATE_RANGE_ATTRIBUTE } from '../../common/date-options'; + +export const schema = { + render: 'metric_card', + category: 'component', + description: + 'All-in-one responsive KPI metric card combining BigValue, Sparkline trend line, and Delta badge', + keywords: [ + 'metric card', + 'stat card', + 'kpi card', + 'big value card', + 'metric', + 'stat', + 'card' + ], + attributes: { + data: { + type: String, + required: false, + suggested: true, + suggestionType: 'table', + description: 'Data source table or query', + affectsQuery: true, + supportsVariables: true, + variableContext: 'text' + }, + value: { + type: [String, Number], + required: false, + suggested: true, + suggestionType: 'column', + description: 'Column name or numerical value to display', + affectsQuery: true, + supportsVariables: true, + variableContext: 'column' + }, + value_fmt: { + type: String, + required: false, + description: 'Format string for the primary value (e.g. usd, eur, pct, num0)', + affectsQuery: false, + supportsVariables: true, + variableContext: 'text' + }, + title: { + type: String, + required: false, + description: 'Title displayed at the top of the card', + affectsQuery: false, + supportsVariables: true, + variableContext: 'text' + }, + subtitle: { + type: String, + required: false, + description: 'Subtitle displayed below the title', + affectsQuery: false, + supportsVariables: true, + variableContext: 'text' + }, + info: { + type: String, + required: false, + description: 'Tooltip info text displayed next to the title', + affectsQuery: false, + supportsVariables: true, + variableContext: 'text' + }, + comparison: { + type: [String, Number], + required: false, + description: 'Comparison value, target, or column name to compute delta from', + affectsQuery: true, + supportsVariables: true, + variableContext: 'column' + }, + comparison_text: { + type: String, + required: false, + description: 'Label displayed beside the comparison delta (e.g. "vs. prior period")', + affectsQuery: false, + supportsVariables: true, + variableContext: 'text' + }, + comparison_fmt: { + type: String, + required: false, + description: 'Format string for comparison delta (defaults to pct)', + affectsQuery: false, + supportsVariables: true, + variableContext: 'text' + }, + down_is_good: { + type: Boolean, + required: false, + default: false, + description: 'Whether a decrease in the metric is positive (green)', + affectsQuery: false + }, + badge: { + type: Boolean, + required: false, + default: true, + description: 'Whether to display the delta as a styled trend pill badge', + affectsQuery: false + }, + sparkline_date: { + type: String, + required: false, + description: 'Date or time column for the sparkline trend', + affectsQuery: true, + supportsVariables: true, + variableContext: 'column' + }, + sparkline_value: { + type: String, + required: false, + description: 'Value column for the sparkline (defaults to the primary value column)', + affectsQuery: true, + supportsVariables: true, + variableContext: 'column' + }, + sparkline_type: { + type: String, + required: false, + default: 'line', + description: 'Sparkline chart type: "line", "area", or "bar"', + affectsQuery: false + }, + sparkline_color: { + type: String, + required: false, + description: 'Custom hex color or theme token for the sparkline', + affectsQuery: false + }, + link: { + type: String, + required: false, + description: 'Optional URL to navigate to when clicking the card', + affectsQuery: false, + supportsVariables: true, + variableContext: 'text' + }, + filters: { + type: Array, + required: false, + suggestionType: 'filter', + affectsQuery: true + }, + ...DATE_RANGE_ATTRIBUTE, + ...SQL_OPTIONS, + ...REFRESH_INTERVAL_ATTRIBUTE, + ...WIDTH_ATTRIBUTE + } +} as const satisfies UserComponentSchema; diff --git a/core/src/user-components/tags/metric_card/user-component.ts b/core/src/user-components/tags/metric_card/user-component.ts new file mode 100644 index 0000000000..1cc9cc6bf3 --- /dev/null +++ b/core/src/user-components/tags/metric_card/user-component.ts @@ -0,0 +1,8 @@ +import type { UserComponent } from '../../types'; +import { schema } from './schema'; +import MetricCard from './MetricCard.svelte'; + +export const userComponent: UserComponent = { + schema, + Component: MetricCard +}; From 818f91b8d069a25f52b98a98890d840036a6fbf0 Mon Sep 17 00:00:00 2001 From: FrancescoCastaldi Date: Fri, 28 Aug 2026 13:20:13 +0200 Subject: [PATCH 2/2] refactor(core): extend big_value with subtitle, card, and comparison.chip instead of separate metric_card tag --- .../tags/bigvalue/BigValue.svelte | 19 +- .../tags/bigvalue/BigValueModel.svelte.ts | 1 + .../tags/bigvalue/bigvalue.test.ts | 10 + .../user-components/tags/bigvalue/schema.ts | 28 ++ .../tags/metric_card/MetricCard.svelte | 252 ------------------ .../tags/metric_card/MetricCard.test.ts | 17 -- .../tags/metric_card/schema.ts | 159 ----------- .../tags/metric_card/user-component.ts | 8 - 8 files changed, 57 insertions(+), 437 deletions(-) delete mode 100644 core/src/user-components/tags/metric_card/MetricCard.svelte delete mode 100644 core/src/user-components/tags/metric_card/MetricCard.test.ts delete mode 100644 core/src/user-components/tags/metric_card/schema.ts delete mode 100644 core/src/user-components/tags/metric_card/user-component.ts diff --git a/core/src/user-components/tags/bigvalue/BigValue.svelte b/core/src/user-components/tags/bigvalue/BigValue.svelte index 390204714a..dceb01ea61 100644 --- a/core/src/user-components/tags/bigvalue/BigValue.svelte +++ b/core/src/user-components/tags/bigvalue/BigValue.svelte @@ -55,6 +55,7 @@ const comparison = $derived(model.resolvedComparison); // Coerce booleans in case they came from variable interpolation as strings const comparison_delta = $derived(coerceBoolean(comparison?.delta) ?? true); + const comparison_chip = $derived(coerceBoolean(comparison?.chip) ?? false); const sparkline = $derived(model.resolvedSparkline); const sparkline_type = $derived((sparkline?.type ?? 'line') as 'line' | 'area' | 'bar'); const sparkline_color = $derived(sparkline?.color); @@ -81,9 +82,11 @@ ); const down_is_good = $derived(coerceBoolean(comparison?.down_is_good) ?? false); const neutralRange = $derived(comparison?.neutral_range ?? [0, 0]); + const isCard = $derived(props.card ?? false); const max_width = $derived(props.max_width ?? 'none'); const min_width = $derived(props.min_width ?? 'auto'); const title_class = $derived(props.title_class); + const subtitle_class = $derived(props.subtitle_class); const value_class = $derived(props.value_class); const text_size = $derived(props.text_size); const comparison_class = $derived(undefined); @@ -148,11 +151,13 @@ // Use resolved props from model (handles variable interpolation) const resolvedTitle = $derived(model.resolvedTitle); + const resolvedSubtitle = $derived(model.resolvedSubtitle); const info = $derived(model.resolvedInfo); const info_link = $derived(model.resolvedInfo_link); const info_link_title = $derived(model.resolvedInfo_link_title); const title = $derived(resolvedTitle || (props.value ? valueProcessed.displayAlias : '')); + const subtitle = $derived(resolvedSubtitle || ''); // Extract data points from the single-row query results const resultRow = $derived.by(() => { @@ -280,7 +285,13 @@ {/if} + {#if subtitle} + + {subtitle} + + {/if} diff --git a/core/src/user-components/tags/bigvalue/BigValueModel.svelte.ts b/core/src/user-components/tags/bigvalue/BigValueModel.svelte.ts index ee208dcd12..371c6eae38 100644 --- a/core/src/user-components/tags/bigvalue/BigValueModel.svelte.ts +++ b/core/src/user-components/tags/bigvalue/BigValueModel.svelte.ts @@ -59,6 +59,7 @@ export class BigValueModel extends UserComponentModel { if (this.metricCompiled) return this.metricCompiled.displayLabel; return explicit; }); + readonly resolvedSubtitle = $derived(this.resolveText(this.attributes.subtitle)); readonly resolvedInfo = $derived(this.resolveText(this.attributes.info)); readonly resolvedInfo_link = $derived(this.resolveText(this.attributes.info_link)); readonly resolvedInfo_link_title = $derived(this.resolveText(this.attributes.info_link_title)); diff --git a/core/src/user-components/tags/bigvalue/bigvalue.test.ts b/core/src/user-components/tags/bigvalue/bigvalue.test.ts index 12c77c961e..a7583b636a 100644 --- a/core/src/user-components/tags/bigvalue/bigvalue.test.ts +++ b/core/src/user-components/tags/bigvalue/bigvalue.test.ts @@ -1,4 +1,5 @@ import { describe, it, expect } from 'vitest'; +import { schema } from './schema'; import { buildBigValueSQL, type BigValueSQLAttrs } from './build-bigvalue-sql'; import { orderCompatibleWithSingleValue } from '../../validators'; import { @@ -602,3 +603,12 @@ describe('order × value compatibility (author-time guard for the GROUP BY ALL s ).toEqual([]); }); }); + +describe('big_value schema attributes', () => { + it('supports subtitle, card, and comparison.chip', () => { + expect(schema.attributes.title).toBeDefined(); + expect(schema.attributes.subtitle).toBeDefined(); + expect(schema.attributes.card).toBeDefined(); + expect(schema.attributes.comparison).toBeDefined(); + }); +}); diff --git a/core/src/user-components/tags/bigvalue/schema.ts b/core/src/user-components/tags/bigvalue/schema.ts index 64cbf4110c..337e899301 100644 --- a/core/src/user-components/tags/bigvalue/schema.ts +++ b/core/src/user-components/tags/bigvalue/schema.ts @@ -63,6 +63,13 @@ const comparisonSchema = baseComparisonSchema 'Range [min, max] for neutral values. Use null for infinity (e.g., [null, 0] means anything ≤ 0 is neutral)' ), { supportsVariables: true } + ), + chip: setZodMetadata( + booleanVariableSchema + .optional() + .default(false) + .describe('Whether to display the comparison delta as a chip / pill badge'), + { supportsVariables: true } ) }) .optional(); @@ -205,6 +212,21 @@ const attributes = { supportsVariables: true, variableContext: 'text' }, + subtitle: { + type: String, + required: false, + description: 'Subtitle displayed below the title', + affectsQuery: false, + supportsVariables: true, + variableContext: 'text' + }, + card: { + type: Boolean, + required: false, + default: false, + description: 'Whether to style the component as an individual card with border and padding', + affectsQuery: false + }, max_width: { type: String, required: false, @@ -233,6 +255,12 @@ const attributes = { description: 'Additional CSS classes for the title', affectsQuery: false }, + subtitle_class: { + type: String, + required: false, + description: 'Additional CSS classes for the subtitle', + affectsQuery: false + }, value_class: { type: String, required: false, diff --git a/core/src/user-components/tags/metric_card/MetricCard.svelte b/core/src/user-components/tags/metric_card/MetricCard.svelte deleted file mode 100644 index 61ad593553..0000000000 --- a/core/src/user-components/tags/metric_card/MetricCard.svelte +++ /dev/null @@ -1,252 +0,0 @@ - - -
e.key === 'Enter' && handleClick() : undefined} - class={cn( - 'relative flex flex-col justify-between rounded-xl border bg-card p-4 text-card-foreground shadow-xs transition-all select-none', - link ? 'cursor-pointer hover:border-primary/50 hover:shadow-md' : '', - 'min-w-[180px]' - )} -> - -
-
- {#if title} - {title} - {/if} - {#if info} - - {/if} -
- {#if subtitle} - {subtitle} - {/if} -
- - -
- {#if loading} - - {:else} - - {displayValue} - - {/if} -
- - -
-
- {#if comparisonDelta !== null} - - {/if} -
- - {#if sparklineData && sparklineData.length > 0} -
- -
- {/if} -
-
diff --git a/core/src/user-components/tags/metric_card/MetricCard.test.ts b/core/src/user-components/tags/metric_card/MetricCard.test.ts deleted file mode 100644 index 5dcd1d6c9e..0000000000 --- a/core/src/user-components/tags/metric_card/MetricCard.test.ts +++ /dev/null @@ -1,17 +0,0 @@ -import { describe, it, expect } from 'vitest'; -import { schema } from './schema'; - -describe('MetricCard Tag', () => { - it('has valid schema attributes', () => { - expect(schema.render).toBe('metric_card'); - expect(schema.category).toBe('component'); - expect(schema.attributes.data).toBeDefined(); - expect(schema.attributes.value).toBeDefined(); - expect(schema.attributes.value_fmt).toBeDefined(); - expect(schema.attributes.title).toBeDefined(); - expect(schema.attributes.comparison).toBeDefined(); - expect(schema.attributes.sparkline_date).toBeDefined(); - expect(schema.attributes.badge).toBeDefined(); - expect(schema.attributes.down_is_good).toBeDefined(); - }); -}); diff --git a/core/src/user-components/tags/metric_card/schema.ts b/core/src/user-components/tags/metric_card/schema.ts deleted file mode 100644 index 34176f1a73..0000000000 --- a/core/src/user-components/tags/metric_card/schema.ts +++ /dev/null @@ -1,159 +0,0 @@ -import type { UserComponentSchema } from '../../types'; -import { WIDTH_ATTRIBUTE } from '../../common/width-attribute'; -import { SQL_OPTIONS, REFRESH_INTERVAL_ATTRIBUTE } from '../../common/sql-options'; -import { DATE_RANGE_ATTRIBUTE } from '../../common/date-options'; - -export const schema = { - render: 'metric_card', - category: 'component', - description: - 'All-in-one responsive KPI metric card combining BigValue, Sparkline trend line, and Delta badge', - keywords: [ - 'metric card', - 'stat card', - 'kpi card', - 'big value card', - 'metric', - 'stat', - 'card' - ], - attributes: { - data: { - type: String, - required: false, - suggested: true, - suggestionType: 'table', - description: 'Data source table or query', - affectsQuery: true, - supportsVariables: true, - variableContext: 'text' - }, - value: { - type: [String, Number], - required: false, - suggested: true, - suggestionType: 'column', - description: 'Column name or numerical value to display', - affectsQuery: true, - supportsVariables: true, - variableContext: 'column' - }, - value_fmt: { - type: String, - required: false, - description: 'Format string for the primary value (e.g. usd, eur, pct, num0)', - affectsQuery: false, - supportsVariables: true, - variableContext: 'text' - }, - title: { - type: String, - required: false, - description: 'Title displayed at the top of the card', - affectsQuery: false, - supportsVariables: true, - variableContext: 'text' - }, - subtitle: { - type: String, - required: false, - description: 'Subtitle displayed below the title', - affectsQuery: false, - supportsVariables: true, - variableContext: 'text' - }, - info: { - type: String, - required: false, - description: 'Tooltip info text displayed next to the title', - affectsQuery: false, - supportsVariables: true, - variableContext: 'text' - }, - comparison: { - type: [String, Number], - required: false, - description: 'Comparison value, target, or column name to compute delta from', - affectsQuery: true, - supportsVariables: true, - variableContext: 'column' - }, - comparison_text: { - type: String, - required: false, - description: 'Label displayed beside the comparison delta (e.g. "vs. prior period")', - affectsQuery: false, - supportsVariables: true, - variableContext: 'text' - }, - comparison_fmt: { - type: String, - required: false, - description: 'Format string for comparison delta (defaults to pct)', - affectsQuery: false, - supportsVariables: true, - variableContext: 'text' - }, - down_is_good: { - type: Boolean, - required: false, - default: false, - description: 'Whether a decrease in the metric is positive (green)', - affectsQuery: false - }, - badge: { - type: Boolean, - required: false, - default: true, - description: 'Whether to display the delta as a styled trend pill badge', - affectsQuery: false - }, - sparkline_date: { - type: String, - required: false, - description: 'Date or time column for the sparkline trend', - affectsQuery: true, - supportsVariables: true, - variableContext: 'column' - }, - sparkline_value: { - type: String, - required: false, - description: 'Value column for the sparkline (defaults to the primary value column)', - affectsQuery: true, - supportsVariables: true, - variableContext: 'column' - }, - sparkline_type: { - type: String, - required: false, - default: 'line', - description: 'Sparkline chart type: "line", "area", or "bar"', - affectsQuery: false - }, - sparkline_color: { - type: String, - required: false, - description: 'Custom hex color or theme token for the sparkline', - affectsQuery: false - }, - link: { - type: String, - required: false, - description: 'Optional URL to navigate to when clicking the card', - affectsQuery: false, - supportsVariables: true, - variableContext: 'text' - }, - filters: { - type: Array, - required: false, - suggestionType: 'filter', - affectsQuery: true - }, - ...DATE_RANGE_ATTRIBUTE, - ...SQL_OPTIONS, - ...REFRESH_INTERVAL_ATTRIBUTE, - ...WIDTH_ATTRIBUTE - } -} as const satisfies UserComponentSchema; diff --git a/core/src/user-components/tags/metric_card/user-component.ts b/core/src/user-components/tags/metric_card/user-component.ts deleted file mode 100644 index 1cc9cc6bf3..0000000000 --- a/core/src/user-components/tags/metric_card/user-component.ts +++ /dev/null @@ -1,8 +0,0 @@ -import type { UserComponent } from '../../types'; -import { schema } from './schema'; -import MetricCard from './MetricCard.svelte'; - -export const userComponent: UserComponent = { - schema, - Component: MetricCard -};