@squadbase/vantage 0.6.2 → 0.7.0-dev1.1197de7
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/dist/add-4GHF2TX2.js +6 -0
- package/dist/add-4GHF2TX2.js.map +1 -0
- package/dist/{build-O5TVNH4E.js → build-U7GO26HR.js} +9 -9
- package/dist/{build-O5TVNH4E.js.map → build-U7GO26HR.js.map} +1 -1
- package/dist/{check-WFBEDHDR.js → check-MRVAIHG5.js} +7 -7
- package/dist/{check-WFBEDHDR.js.map → check-MRVAIHG5.js.map} +1 -1
- package/dist/{chunk-QDZA5JSL.js → chunk-AABMKHEU.js} +4 -4
- package/dist/{chunk-QDZA5JSL.js.map → chunk-AABMKHEU.js.map} +1 -1
- package/dist/{chunk-PVTHHV7X.js → chunk-ANZLVSEP.js} +28 -7
- package/dist/chunk-ANZLVSEP.js.map +1 -0
- package/dist/chunk-CKWPK4RO.js +50 -0
- package/dist/chunk-CKWPK4RO.js.map +1 -0
- package/dist/chunk-EG4PX726.js +678 -0
- package/dist/chunk-EG4PX726.js.map +1 -0
- package/dist/{chunk-3G43C6OL.js → chunk-GNXCX4WE.js} +46 -3
- package/dist/chunk-GNXCX4WE.js.map +1 -0
- package/dist/chunk-LYUGDHSF.js +61 -0
- package/dist/chunk-LYUGDHSF.js.map +1 -0
- package/dist/{chunk-RPZAALCX.js → chunk-MQUIAP66.js} +321 -165
- package/dist/chunk-MQUIAP66.js.map +1 -0
- package/dist/chunk-P2KIHF65.js +152 -0
- package/dist/chunk-P2KIHF65.js.map +1 -0
- package/dist/{chunk-BCE22JSM.js → chunk-PJ4SPXHS.js} +52 -16
- package/dist/chunk-PJ4SPXHS.js.map +1 -0
- package/dist/{chunk-ZVANIULZ.js → chunk-RNTDBX6S.js} +4 -4
- package/dist/{chunk-ZVANIULZ.js.map → chunk-RNTDBX6S.js.map} +1 -1
- package/dist/{chunk-PXRX5PEC.js → chunk-ROHNXP7J.js} +73 -4
- package/dist/chunk-ROHNXP7J.js.map +1 -0
- package/dist/chunk-S5MDTHH7.js +466 -0
- package/dist/chunk-S5MDTHH7.js.map +1 -0
- package/dist/chunk-SDJTTMLK.js +1877 -0
- package/dist/chunk-SDJTTMLK.js.map +1 -0
- package/dist/{chunk-EP6JHXSR.js → chunk-SHZM3EXB.js} +68 -12
- package/dist/chunk-SHZM3EXB.js.map +1 -0
- package/dist/{chunk-A2UUGASH.js → chunk-U736PVHY.js} +2 -2
- package/dist/chunk-U736PVHY.js.map +1 -0
- package/dist/{chunk-JGRMRXXZ.js → chunk-UWSQUHX6.js} +4 -99
- package/dist/chunk-UWSQUHX6.js.map +1 -0
- package/dist/chunk-XRKSIC5Z.js +176 -0
- package/dist/chunk-XRKSIC5Z.js.map +1 -0
- package/dist/chunk-YJQCB3UP.js +10764 -0
- package/dist/chunk-YJQCB3UP.js.map +1 -0
- package/dist/cli.js +15 -11
- package/dist/cli.js.map +1 -1
- package/dist/client/index.d.ts +34 -3
- package/dist/client/index.js +211 -12
- package/dist/client/index.js.map +1 -1
- package/dist/components/index.d.ts +2389 -31
- package/dist/components/index.js +7 -4
- package/dist/delta-text-tav2Lh5d.d.ts +17 -0
- package/dist/{dev-NZRLOQCL.js → dev-O42JFQ64.js} +24 -13
- package/dist/dev-O42JFQ64.js.map +1 -0
- package/dist/doc/index.d.ts +215 -2
- package/dist/doc/index.js +423 -5
- package/dist/doc/index.js.map +1 -1
- package/dist/{docs-ZGDJUHJI.js → docs-JVP4RPKT.js} +3 -3
- package/dist/{docs-ZGDJUHJI.js.map → docs-JVP4RPKT.js.map} +1 -1
- package/dist/{doctor-VFKEMR54.js → doctor-YD3XAAOT.js} +8 -8
- package/dist/doctor-YD3XAAOT.js.map +1 -0
- package/dist/format-D34piCei.d.ts +250 -0
- package/dist/geo-japan-7BMSOJRX.js +8 -0
- package/dist/geo-japan-7BMSOJRX.js.map +1 -0
- package/dist/geo-us-GVOG7VXH.js +8 -0
- package/dist/geo-us-GVOG7VXH.js.map +1 -0
- package/dist/geo-world-35HL56UU.js +8 -0
- package/dist/geo-world-35HL56UU.js.map +1 -0
- package/dist/markdown/index.js +1 -1
- package/dist/markdown/index.js.map +1 -1
- package/dist/{preview-QDCIVVSR.js → preview-T6CLSQVX.js} +12 -4
- package/dist/preview-T6CLSQVX.js.map +1 -0
- package/dist/query/index.d.ts +0 -11
- package/dist/query/index.js +2 -2
- package/dist/{routes-RHP5FVGD.js → routes-PVKNT777.js} +6 -6
- package/dist/{routes-RHP5FVGD.js.map → routes-PVKNT777.js.map} +1 -1
- package/dist/{search-RFBELGY7.js → search-5LC4XPB5.js} +3 -3
- package/dist/{search-RFBELGY7.js.map → search-5LC4XPB5.js.map} +1 -1
- package/dist/slide/index.d.ts +272 -1
- package/dist/slide/index.js +1086 -19
- package/dist/slide/index.js.map +1 -1
- package/dist/table/index.js +13 -10
- package/dist/table/index.js.map +1 -1
- package/dist/ui/index.d.ts +148 -12
- package/dist/ui/index.js +1339 -88
- package/dist/ui/index.js.map +1 -1
- package/dist/{upgrade-GF2P67M6.js → upgrade-6DT7ADRP.js} +7 -7
- package/dist/{upgrade-GF2P67M6.js.map → upgrade-6DT7ADRP.js.map} +1 -1
- package/dist/vite/index.d.ts +10 -0
- package/dist/vite/index.js +4 -4
- package/docs/en/agent-skills.md +1 -1
- package/docs/en/changelog.md +82 -0
- package/docs/en/cli-reference.md +30 -0
- package/docs/en/components.md +42 -17
- package/docs/en/doc/delta-text.md +40 -0
- package/docs/en/doc/doc-action-items.md +49 -0
- package/docs/en/doc/doc-callout.md +38 -0
- package/docs/en/doc/doc-data-table.md +60 -0
- package/docs/en/doc/doc-executive-summary.md +64 -0
- package/docs/en/doc/doc-figure.md +56 -0
- package/docs/en/doc/doc-key-findings.md +61 -0
- package/docs/en/doc/doc-kpi-scorecard.md +80 -0
- package/docs/en/doc/doc-notes-and-sources.md +43 -0
- package/docs/en/doc/doc-report-header.md +46 -0
- package/docs/en/pages-and-metadata.md +15 -0
- package/docs/en/parts/area-chart.md +99 -0
- package/docs/en/parts/bar-chart.md +91 -0
- package/docs/en/parts/bar-list.md +50 -0
- package/docs/en/parts/category-bar.md +37 -0
- package/docs/en/parts/chart-frame.md +39 -0
- package/docs/en/parts/data-table.md +22 -0
- package/docs/en/parts/delta.md +51 -0
- package/docs/en/parts/donut-chart.md +72 -0
- package/docs/en/parts/draft-chart.md +42 -0
- package/docs/en/parts/format.md +43 -0
- package/docs/en/parts/funnel-steps.md +2 -1
- package/docs/en/parts/heatmap.md +53 -0
- package/docs/en/parts/kpi-card.md +58 -0
- package/docs/en/parts/legend.md +35 -0
- package/docs/en/parts/line-chart.md +94 -0
- package/docs/en/parts/metric-value.md +3 -2
- package/docs/en/parts/page-shell.md +2 -2
- package/docs/en/parts/progress-circle.md +33 -0
- package/docs/en/parts/sample-data.md +39 -0
- package/docs/en/parts/scatter-chart.md +56 -0
- package/docs/en/parts/sparkline.md +1 -1
- package/docs/en/parts/tracker.md +36 -0
- package/docs/en/parts/trend-indicator.md +17 -1
- package/docs/en/parts/updated-at.md +30 -0
- package/docs/en/parts/waterfall-chart.md +56 -0
- package/docs/en/slide/slide-chart-full.md +48 -0
- package/docs/en/slide/slide-chart-insight.md +52 -0
- package/docs/en/slide/slide-columns.md +46 -0
- package/docs/en/slide/slide-comparison.md +68 -0
- package/docs/en/slide/slide-cover.md +39 -0
- package/docs/en/slide/slide-exec-summary.md +47 -0
- package/docs/en/slide/slide-heading.md +38 -0
- package/docs/en/slide/slide-kpi-grid.md +55 -0
- package/docs/en/slide/slide-kpi-hero.md +58 -0
- package/docs/en/slide/slide-next-actions.md +49 -0
- package/docs/en/slide/slide-scorecard-table.md +54 -0
- package/docs/en/slide/slide-section.md +44 -0
- package/docs/en/slide/slide-source-line.md +34 -0
- package/docs/en/ui/alert-dialog.md +42 -0
- package/docs/en/ui/avatar.md +31 -0
- package/docs/en/ui/combobox.md +74 -0
- package/docs/en/ui/drawer.md +41 -0
- package/docs/en/ui/empty.md +1 -1
- package/docs/en/ui/error-state.md +4 -3
- package/docs/en/ui/field.md +42 -0
- package/docs/en/ui/hover-card.md +21 -0
- package/docs/en/ui/kbd.md +17 -0
- package/docs/en/ui/loading.md +1 -1
- package/docs/en/ui/pagination.md +36 -0
- package/docs/en/ui/radio-group.md +21 -0
- package/docs/en/ui/toast.md +44 -0
- package/docs/en/ui/toggle-group.md +4 -3
- package/docs/en/ui-and-theming.md +1 -1
- package/docs/index.json +1222 -132
- package/docs/ja/agent-skills.md +1 -1
- package/docs/ja/changelog.md +84 -0
- package/docs/ja/cli-reference.md +29 -0
- package/docs/ja/components.md +68 -101
- package/docs/ja/doc/delta-text.md +40 -0
- package/docs/ja/doc/doc-action-items.md +49 -0
- package/docs/ja/doc/doc-callout.md +38 -0
- package/docs/ja/doc/doc-data-table.md +60 -0
- package/docs/ja/doc/doc-executive-summary.md +64 -0
- package/docs/ja/doc/doc-figure.md +56 -0
- package/docs/ja/doc/doc-key-findings.md +61 -0
- package/docs/ja/doc/doc-kpi-scorecard.md +80 -0
- package/docs/ja/doc/doc-notes-and-sources.md +43 -0
- package/docs/ja/doc/doc-report-header.md +46 -0
- package/docs/ja/page-types/csv.md +83 -0
- package/docs/ja/page-types/doc.md +83 -0
- package/docs/ja/page-types/excel.md +82 -0
- package/docs/ja/page-types/slide.md +133 -0
- package/docs/ja/page-types.md +47 -0
- package/docs/ja/pages-and-metadata.md +15 -0
- package/docs/ja/parts/area-chart.md +85 -0
- package/docs/ja/parts/bar-chart.md +95 -0
- package/docs/ja/parts/bar-list.md +58 -0
- package/docs/ja/parts/category-bar.md +36 -0
- package/docs/ja/parts/chart-frame.md +36 -0
- package/docs/ja/parts/data-table.md +22 -0
- package/docs/ja/parts/delta.md +51 -0
- package/docs/ja/parts/donut-chart.md +66 -0
- package/docs/ja/parts/draft-chart.md +40 -0
- package/docs/ja/parts/echart.md +3 -0
- package/docs/ja/parts/format.md +44 -0
- package/docs/ja/parts/funnel-steps.md +27 -8
- package/docs/ja/parts/heatmap.md +45 -0
- package/docs/ja/parts/kpi-card.md +56 -0
- package/docs/ja/parts/legend.md +36 -0
- package/docs/ja/parts/line-chart.md +84 -0
- package/docs/ja/parts/metric-value.md +3 -2
- package/docs/ja/parts/page-shell.md +2 -2
- package/docs/ja/parts/progress-circle.md +34 -0
- package/docs/ja/parts/sample-data.md +61 -0
- package/docs/ja/parts/scatter-chart.md +69 -0
- package/docs/ja/parts/sparkline.md +2 -2
- package/docs/ja/parts/tracker.md +32 -0
- package/docs/ja/parts/trend-indicator.md +17 -1
- package/docs/ja/parts/updated-at.md +27 -0
- package/docs/ja/parts/waterfall-chart.md +61 -0
- package/docs/ja/routing.md +8 -86
- package/docs/ja/slide/slide-chart-full.md +48 -0
- package/docs/ja/slide/slide-chart-insight.md +52 -0
- package/docs/ja/slide/slide-columns.md +46 -0
- package/docs/ja/slide/slide-comparison.md +68 -0
- package/docs/ja/slide/slide-cover.md +39 -0
- package/docs/ja/slide/slide-exec-summary.md +47 -0
- package/docs/ja/slide/slide-heading.md +38 -0
- package/docs/ja/slide/slide-kpi-grid.md +55 -0
- package/docs/ja/slide/slide-kpi-hero.md +58 -0
- package/docs/ja/slide/slide-next-actions.md +49 -0
- package/docs/ja/slide/slide-scorecard-table.md +54 -0
- package/docs/ja/slide/slide-section.md +44 -0
- package/docs/ja/slide/slide-source-line.md +34 -0
- package/docs/ja/ui/alert-dialog.md +41 -0
- package/docs/ja/ui/avatar.md +31 -0
- package/docs/ja/ui/combobox.md +74 -0
- package/docs/ja/ui/drawer.md +41 -0
- package/docs/ja/ui/empty.md +1 -1
- package/docs/ja/ui/error-state.md +4 -3
- package/docs/ja/ui/field.md +42 -0
- package/docs/ja/ui/hover-card.md +21 -0
- package/docs/ja/ui/kbd.md +16 -0
- package/docs/ja/ui/loading.md +1 -1
- package/docs/ja/ui/pagination.md +36 -0
- package/docs/ja/ui/radio-group.md +21 -0
- package/docs/ja/ui/toast.md +46 -0
- package/docs/ja/ui/toggle-group.md +4 -3
- package/docs/ja/ui-and-theming.md +14 -7
- package/package.json +43 -29
- package/registry/parts.json +5221 -0
- package/registry/ui/accordion.tsx +76 -0
- package/registry/ui/alert-dialog.tsx +189 -0
- package/registry/ui/alert.tsx +80 -0
- package/registry/ui/app-shell.tsx +424 -0
- package/registry/ui/area-chart.tsx +3960 -0
- package/registry/ui/attention-list.tsx +465 -0
- package/registry/ui/avatar.tsx +110 -0
- package/registry/ui/badge.tsx +56 -0
- package/registry/ui/bar-chart.tsx +3954 -0
- package/registry/ui/bar-list.tsx +3998 -0
- package/registry/ui/box-plot-chart.tsx +4214 -0
- package/registry/ui/breadcrumb.tsx +128 -0
- package/registry/ui/button.tsx +62 -0
- package/registry/ui/calendar.tsx +223 -0
- package/registry/ui/card.tsx +107 -0
- package/registry/ui/category-bar.tsx +1498 -0
- package/registry/ui/chart-frame.tsx +1612 -0
- package/registry/ui/checkbox.tsx +30 -0
- package/registry/ui/collapsible.tsx +23 -0
- package/registry/ui/combo-chart.tsx +1852 -0
- package/registry/ui/combobox.tsx +310 -0
- package/registry/ui/command.tsx +198 -0
- package/registry/ui/dashboard-card.tsx +219 -0
- package/registry/ui/data-table.tsx +899 -158
- package/registry/ui/date-range-picker.tsx +544 -0
- package/registry/ui/delta.tsx +352 -0
- package/registry/ui/dialog.tsx +161 -0
- package/registry/ui/donut-chart.tsx +4006 -0
- package/registry/ui/drawer.tsx +229 -0
- package/registry/ui/dropdown-menu.tsx +274 -0
- package/registry/ui/echart.tsx +1178 -0
- package/registry/ui/field.tsx +240 -0
- package/registry/ui/filter-bar.tsx +553 -0
- package/registry/ui/forecast-chart.tsx +4734 -0
- package/registry/ui/funnel-steps.tsx +3843 -0
- package/registry/ui/heatmap.tsx +2027 -0
- package/registry/ui/histogram-chart.tsx +4284 -0
- package/registry/ui/hover-card.tsx +52 -0
- package/registry/ui/input-group.tsx +160 -0
- package/registry/ui/input.tsx +24 -0
- package/registry/ui/kbd.tsx +30 -0
- package/registry/ui/kpi-card.tsx +907 -0
- package/registry/ui/label.tsx +22 -0
- package/registry/ui/legend.tsx +3817 -0
- package/registry/ui/line-chart.tsx +3958 -0
- package/registry/ui/metric-value.tsx +62 -0
- package/registry/ui/multi-select.tsx +382 -0
- package/registry/ui/page-shell.tsx +441 -0
- package/registry/ui/pagination.tsx +140 -0
- package/registry/ui/paired-chart.tsx +4086 -0
- package/registry/ui/pivot-table.tsx +1260 -0
- package/registry/ui/placeholder.tsx +36 -0
- package/registry/ui/popover.tsx +92 -0
- package/registry/ui/progress-circle.tsx +1033 -0
- package/registry/ui/progress.tsx +85 -0
- package/registry/ui/radio-group.tsx +39 -0
- package/registry/ui/sample-data.tsx +230 -0
- package/registry/ui/scatter-chart.tsx +4503 -0
- package/registry/ui/scroll-area.tsx +56 -0
- package/registry/ui/searchable-select.tsx +352 -0
- package/registry/ui/section-header.tsx +43 -0
- package/registry/ui/segmented-control.tsx +76 -0
- package/registry/ui/select.tsx +255 -0
- package/registry/ui/separator.tsx +27 -0
- package/registry/ui/sheet.tsx +139 -0
- package/registry/ui/sidebar.tsx +725 -0
- package/registry/ui/skeleton.tsx +17 -0
- package/registry/ui/slider.tsx +56 -0
- package/registry/ui/sparkline.tsx +230 -0
- package/registry/ui/spinner.tsx +14 -0
- package/registry/ui/states.tsx +247 -0
- package/registry/ui/status-badge.tsx +286 -0
- package/registry/ui/switch.tsx +34 -0
- package/registry/ui/table.tsx +118 -0
- package/registry/ui/tabs.tsx +84 -0
- package/registry/ui/textarea.tsx +22 -0
- package/registry/ui/toast.tsx +250 -0
- package/registry/ui/toggle-group.tsx +91 -0
- package/registry/ui/toggle.tsx +47 -0
- package/registry/ui/tooltip.tsx +72 -0
- package/registry/ui/tracker.tsx +1015 -0
- package/registry/ui/treemap.tsx +1917 -0
- package/registry/ui/trend-indicator.tsx +217 -0
- package/registry/ui/updated-at.tsx +276 -0
- package/registry/ui/waterfall-chart.tsx +4561 -0
- package/skills/vantage-add-feature/SKILL.md +14 -7
- package/skills/vantage-design/SKILL.md +62 -52
- package/skills/vantage-design/references/extract.md +26 -27
- package/skills/vantage-design/references/format.md +12 -172
- package/skills/vantage-design/references/presets.md +50 -110
- package/skills/vantage-pitfalls/SKILL.md +10 -1
- package/skills/vantage-slide/SKILL.md +71 -36
- package/templates/AGENTS.md +54 -16
- package/templates/agents/api.md +36 -0
- package/templates/agents/csv.md +2 -1
- package/templates/agents/doc.md +52 -3
- package/templates/agents/page.md +91 -10
- package/templates/agents/slide.md +90 -4
- package/templates/agents/xlsx.md +2 -1
- package/theme.css +247 -132
- package/dist/add-EISGSZAH.js +0 -6
- package/dist/add-EISGSZAH.js.map +0 -1
- package/dist/chunk-3G43C6OL.js.map +0 -1
- package/dist/chunk-5ZOP4SQR.js +0 -47
- package/dist/chunk-5ZOP4SQR.js.map +0 -1
- package/dist/chunk-A2UUGASH.js.map +0 -1
- package/dist/chunk-BCE22JSM.js.map +0 -1
- package/dist/chunk-DTDVSFRY.js +0 -29
- package/dist/chunk-DTDVSFRY.js.map +0 -1
- package/dist/chunk-EP6JHXSR.js.map +0 -1
- package/dist/chunk-JGRMRXXZ.js.map +0 -1
- package/dist/chunk-KLEIVDUV.js +0 -165
- package/dist/chunk-KLEIVDUV.js.map +0 -1
- package/dist/chunk-LSRMRIXA.js +0 -2227
- package/dist/chunk-LSRMRIXA.js.map +0 -1
- package/dist/chunk-PVTHHV7X.js.map +0 -1
- package/dist/chunk-PXRX5PEC.js.map +0 -1
- package/dist/chunk-RPZAALCX.js.map +0 -1
- package/dist/chunk-T3FLAGI6.js +0 -316
- package/dist/chunk-T3FLAGI6.js.map +0 -1
- package/dist/dev-NZRLOQCL.js.map +0 -1
- package/dist/doctor-VFKEMR54.js.map +0 -1
- package/dist/preview-QDCIVVSR.js.map +0 -1
|
@@ -0,0 +1,907 @@
|
|
|
1
|
+
// Copied by `vantage add ui kpi-card`. This file is yours to edit.
|
|
2
|
+
// Generated from @squadbase/squadkit registry/components/kpi-card.tsx by `pnpm run registry` (in Vantage, edit packages/squadkit, not registry/).
|
|
3
|
+
"use client"
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* KpiCard — one metric as a card of its own (label, value, delta, target, history), so do not put it in `DashboardCardPreset`.
|
|
7
|
+
*
|
|
8
|
+
* Five tiers, following shadcn dashboard-01 SectionCards:
|
|
9
|
+
*
|
|
10
|
+
* 1. label (CardDescription, muted)
|
|
11
|
+
* 2. value (CardTitle, tabular-nums; text-4xl once the card is ≥ 320px wide (a comparison label is ~180px, so narrower cards would overlap the value))
|
|
12
|
+
* 3. delta (CardAction: arrow + sign + colour + comparison label — `Delta`)
|
|
13
|
+
* 4. previous actual ("前月 ¥4,290万"; on by default, `showPrevious={false}` hides it)
|
|
14
|
+
* 5. conclusion (`summary`: a *template* whose placeholders the card fills)
|
|
15
|
+
*
|
|
16
|
+
* with optional extras: a target bar under the value (a 6px straight bar — actual
|
|
17
|
+
* in `--chart-1`, the pace projection in `--chart-1/30` (`/45` in dark: 1.33:1 → 1.6:1 against the track, as light has), a 1px tick at the target —
|
|
18
|
+
* and one line "目標の 49%・このペースなら 98%"), then in the footer a record badge
|
|
19
|
+
* from `history`, the data source footnote, the last update time and, with ≥ 6
|
|
20
|
+
* points of `history`, an axis-less sparkline (`showHistory={false}` hides it).
|
|
21
|
+
*
|
|
22
|
+
* Everything derivable is computed here — direction, rate, colour, remaining,
|
|
23
|
+
* achievement, projection, record — so no two props can disagree. Pass enums and
|
|
24
|
+
* numbers only. `history` is the series ending at the current period: its last
|
|
25
|
+
* point should equal `value`.
|
|
26
|
+
*
|
|
27
|
+
* - **Cards in a row line up by subgrid, not by fixed heights.** The card spans two rows of its
|
|
28
|
+
* parent grid (`row-span-2` + `grid-template-rows: subgrid`): row 1 is the body (label, value,
|
|
29
|
+
* delta, previous actual, target bar), row 2 the footer. Cards placed directly in one grid
|
|
30
|
+
* (`grid grid-cols-2 lg:grid-cols-4 gap-4`) therefore share the value baseline and the footer's
|
|
31
|
+
* top edge, and the sparkline sits at the footer's bottom. Outside such a grid the card is a
|
|
32
|
+
* plain grid of the same two rows. The label is one line (truncated, full text in `title`).
|
|
33
|
+
* When the card is narrower than 250px the delta moves from the right of the value to
|
|
34
|
+
* under it (container query, no prop).
|
|
35
|
+
* - **Nothing empty is drawn.** A card with no summary, record badge, target line, source,
|
|
36
|
+
* update time or sparkline has no footer in the DOM; there is no placeholder for the target
|
|
37
|
+
* bar either. No `h-*` / `min-h-*` is used to line cards up.
|
|
38
|
+
* - **`size="sm"`** is for two cards side by side in a narrow place (a Sheet): one step smaller
|
|
39
|
+
* value and padding, and no sparkline.
|
|
40
|
+
* - **`source` is at most two lines** (`line-clamp-2`, full text in `title`).
|
|
41
|
+
* - **Labels follow the grain.** The grain is `comparison.grain`, else
|
|
42
|
+
* `historyGrain`, else inferred from `period`; a monthly card reads "前月比" /
|
|
43
|
+
* "前年同月比" and projects to "月末". Without any of them it reads "前期比".
|
|
44
|
+
* Any other baseline is `vs: "custom"` with a `label` ("昨日比"); the previous
|
|
45
|
+
* line takes its name from it ("昨日 617"), the same way a `label` on any `vs` does.
|
|
46
|
+
* - **Two or more comparisons sit under the value**, not beside it, so the value
|
|
47
|
+
* keeps its place and its width whatever the card holds.
|
|
48
|
+
* - **`betterWhen` drives everything.** With `betterWhen="down"` the target
|
|
49
|
+
* is met at or below it, and the record badge marks the lowest value.
|
|
50
|
+
* - **Rates change in points.** With a `percent` format the delta and
|
|
51
|
+
* `{difference}` are in percentage points ("-0.5pt"); `change="relative"` opts
|
|
52
|
+
* back into the change rate. The target line follows: "目標 95% まであと 2.8pt"
|
|
53
|
+
* instead of a ratio of rates ("97.1% 達成"); `targetDisplay="achievement"` opts back.
|
|
54
|
+
* - **The value has a fixed size.** It never shrinks to fit: a long value wraps its
|
|
55
|
+
* 速報 badge instead of truncating, so neighbouring cards keep one baseline.
|
|
56
|
+
* - **A summary longer than 2 lines is clamped** (full text in `title`), so keep it to one fact.
|
|
57
|
+
*
|
|
58
|
+
* Summary placeholders: `{value}` `{delta}` `{difference}` `{previous}` `{label}`
|
|
59
|
+
* `{target}` `{achievement}` `{remaining}`. Unknown placeholders are left as-is.
|
|
60
|
+
* If a placeholder's value is missing (e.g. no previous period), the whole
|
|
61
|
+
* sentence is replaced with "前月の実績がないため、比較できません。" — or dropped
|
|
62
|
+
* when there is no comparison — so "—" never lands inside a sentence. Write the
|
|
63
|
+
* sentence around them and never put numbers in the template itself.
|
|
64
|
+
*
|
|
65
|
+
* The summary says one fact *beyond* the comparison — composition, target, record,
|
|
66
|
+
* rate. A template made only of `{delta}` / `{difference}` repeats what the badge
|
|
67
|
+
* and the previous actual already say, so it logs a dev warning (once).
|
|
68
|
+
*
|
|
69
|
+
* @example
|
|
70
|
+
* import { KpiCard } from "@/components/kpi-card"
|
|
71
|
+
* // Vantage: import { KpiCard } from "@squadbase/vantage/components"
|
|
72
|
+
*
|
|
73
|
+
* <KpiCard
|
|
74
|
+
* label="売上(今月)"
|
|
75
|
+
* value={48_200_000}
|
|
76
|
+
* format="currency"
|
|
77
|
+
* comparison={{ previous: 42_900_000, vs: "prior-period" }}
|
|
78
|
+
* summary="{label}は前月から {difference}({delta})伸びています。"
|
|
79
|
+
* target={60_000_000}
|
|
80
|
+
* period={{ start: "2026-10-01", end: "2026-11-01" }}
|
|
81
|
+
* />
|
|
82
|
+
*/
|
|
83
|
+
import type { ComponentProps } from "react"
|
|
84
|
+
|
|
85
|
+
import { cn } from "@squadbase/vantage/ui"
|
|
86
|
+
import {
|
|
87
|
+
comparisonBaseline,
|
|
88
|
+
computeComparison,
|
|
89
|
+
defaultChangeDisplay,
|
|
90
|
+
formatChange,
|
|
91
|
+
type ChangeDisplay,
|
|
92
|
+
type Comparison,
|
|
93
|
+
type PeriodGrain,
|
|
94
|
+
type Polarity,
|
|
95
|
+
} from "@squadbase/vantage/components"
|
|
96
|
+
import { EMPTY_VALUE, formatDate, formatDatesInText, formatDelta, formatKind, formatPoints, formatValue, type ValueFormat } from "@squadbase/vantage/components"
|
|
97
|
+
import { computeTargetProgress, detectRecord, inferPeriodGrain, type KpiPeriod } from "@squadbase/vantage/components"
|
|
98
|
+
import { formatRelativeTime } from "@squadbase/vantage/components"
|
|
99
|
+
import { Sparkline } from "@squadbase/vantage/components"
|
|
100
|
+
import { Badge } from "@squadbase/vantage/ui"
|
|
101
|
+
import { Card, CardAction, CardDescription, CardFooter, CardHeader, CardTitle } from "@squadbase/vantage/ui"
|
|
102
|
+
import { Skeleton } from "@squadbase/vantage/ui"
|
|
103
|
+
import { Delta } from "@squadbase/vantage/components"
|
|
104
|
+
import { SampleBadge, useSampleMark } from "@squadbase/vantage/components"
|
|
105
|
+
import { useSyncExternalStore } from "react"
|
|
106
|
+
|
|
107
|
+
/* ---------- inlined from @squadbase/squadkit registry/lib/locale.ts (internal to @squadbase/vantage) ---------- */
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* The display language of the parts and the defaults that follow it.
|
|
111
|
+
*
|
|
112
|
+
* The language comes from `<html lang>`: a tag starting with `ja` reads Japanese, anything else English.
|
|
113
|
+
* Without a usable `lang` (missing, invalid, or no `document` — SSR, Node) the locale is `FALLBACK_LOCALE`
|
|
114
|
+
* (`en-US`). A `locale` passed explicitly (a prop, an argument) always wins.
|
|
115
|
+
*
|
|
116
|
+
* Parts read the locale through `useLocale` (`hooks/use-locale`) and pass it down; the pure helpers here
|
|
117
|
+
* and in `format` / `comparison` / `chart-*` take it as an argument and fall back to `documentLocale()`.
|
|
118
|
+
*/
|
|
119
|
+
|
|
120
|
+
/** The language of the built-in wording: Japanese or English. */
|
|
121
|
+
type UiLanguage = "ja" | "en"
|
|
122
|
+
|
|
123
|
+
/** The locale used when `<html lang>` gives none (and on the server). */
|
|
124
|
+
const FALLBACK_LOCALE = "en-US"
|
|
125
|
+
|
|
126
|
+
/** `"ja"` when `locale` starts with `ja` (`ja`, `ja-JP`), else `"en"`. A missing locale is `"en"`. */
|
|
127
|
+
function uiLanguage(locale: string | null | undefined): UiLanguage {
|
|
128
|
+
return typeof locale === "string" && locale.trim().toLowerCase().startsWith("ja") ? "ja" : "en"
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** A BCP 47 tag in its canonical form (`en-gb` → `en-GB`), or `FALLBACK_LOCALE` when it is empty or invalid. */
|
|
132
|
+
function normalizeLocale(tag: string | null | undefined): string {
|
|
133
|
+
const value = tag?.trim()
|
|
134
|
+
if (!value) return FALLBACK_LOCALE
|
|
135
|
+
try {
|
|
136
|
+
return Intl.getCanonicalLocales(value)[0] ?? FALLBACK_LOCALE
|
|
137
|
+
} catch {
|
|
138
|
+
return FALLBACK_LOCALE
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* The locale of the page: `document.documentElement.lang`, canonicalized. `FALLBACK_LOCALE` when it is empty or
|
|
144
|
+
* invalid, or there is no `document`. Parts read it through `useLocale`, never while rendering.
|
|
145
|
+
*/
|
|
146
|
+
function documentLocale(): string {
|
|
147
|
+
if (typeof document === "undefined") return FALLBACK_LOCALE
|
|
148
|
+
return normalizeLocale(document.documentElement?.getAttribute("lang"))
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/** `locale` when given, else the page's locale (`documentLocale()`). */
|
|
152
|
+
function resolveLocale(locale?: string | null): string {
|
|
153
|
+
return locale || documentLocale()
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/** Picks the wording table of the locale's language. */
|
|
157
|
+
function pickLocale<T>(table: { ja: T; en: T }, locale?: string | null): T {
|
|
158
|
+
return table[uiLanguage(resolveLocale(locale))]
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/** The currency `format="currency"` uses without a `currency`: JPY for Japanese, USD otherwise. */
|
|
162
|
+
function defaultCurrency(locale?: string | null): string {
|
|
163
|
+
return uiLanguage(resolveLocale(locale)) === "ja" ? "JPY" : "USD"
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/** The month a fiscal year starts in (1–12) when none is given: April for Japanese, January otherwise. */
|
|
167
|
+
function defaultFiscalYearStartMonth(locale?: string | null): number {
|
|
168
|
+
return uiLanguage(resolveLocale(locale)) === "ja" ? 4 : 1
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/** A day of the week, 0 = Sunday … 6 = Saturday (the `Date#getDay` / date-fns numbering). */
|
|
172
|
+
type WeekDay = 0 | 1 | 2 | 3 | 4 | 5 | 6
|
|
173
|
+
|
|
174
|
+
/** Locales whose week starts on Sunday when the runtime has no week data. */
|
|
175
|
+
const SUNDAY_FIRST = new Set(["en", "en-US", "en-CA"])
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* The first day of the week: Monday for Japanese; otherwise the locale's convention from `Intl.Locale`
|
|
179
|
+
* (`en-US` Sunday, `en-GB` Monday). Without week data in the runtime: Sunday for `en` / `en-US` / `en-CA`,
|
|
180
|
+
* Monday for the rest.
|
|
181
|
+
*/
|
|
182
|
+
function weekStartsOn(locale?: string | null): WeekDay {
|
|
183
|
+
const tag = normalizeLocale(resolveLocale(locale))
|
|
184
|
+
if (uiLanguage(tag) === "ja") return 1
|
|
185
|
+
try {
|
|
186
|
+
const info = new Intl.Locale(tag) as Intl.Locale & {
|
|
187
|
+
getWeekInfo?: () => { firstDay: number }
|
|
188
|
+
weekInfo?: { firstDay: number }
|
|
189
|
+
}
|
|
190
|
+
const firstDay = info.getWeekInfo?.().firstDay ?? info.weekInfo?.firstDay
|
|
191
|
+
if (typeof firstDay === "number" && firstDay >= 1 && firstDay <= 7) return (firstDay % 7) as WeekDay
|
|
192
|
+
} catch {
|
|
193
|
+
// Fall through to the table below.
|
|
194
|
+
}
|
|
195
|
+
return SUNDAY_FIRST.has(tag) ? 0 : 1
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/* ---------- inlined from @squadbase/squadkit registry/hooks/use-locale.ts (internal to @squadbase/vantage) ---------- */
|
|
199
|
+
|
|
200
|
+
const listeners = new Set<() => void>()
|
|
201
|
+
let observer: MutationObserver | null = null
|
|
202
|
+
|
|
203
|
+
/** One observer on `<html lang>` for every subscribed part. */
|
|
204
|
+
function subscribe(onChange: () => void): () => void {
|
|
205
|
+
if (typeof document === "undefined" || typeof MutationObserver === "undefined") return () => {}
|
|
206
|
+
listeners.add(onChange)
|
|
207
|
+
if (!observer) {
|
|
208
|
+
observer = new MutationObserver(() => {
|
|
209
|
+
for (const listener of [...listeners]) listener()
|
|
210
|
+
})
|
|
211
|
+
observer.observe(document.documentElement, { attributes: true, attributeFilter: ["lang"] })
|
|
212
|
+
}
|
|
213
|
+
return () => {
|
|
214
|
+
listeners.delete(onChange)
|
|
215
|
+
if (listeners.size === 0 && observer) {
|
|
216
|
+
observer.disconnect()
|
|
217
|
+
observer = null
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
const serverLocale = () => FALLBACK_LOCALE
|
|
223
|
+
|
|
224
|
+
/**
|
|
225
|
+
* The page's locale (`<html lang>`), following changes made while the page runs. On the server and during
|
|
226
|
+
* hydration it is `FALLBACK_LOCALE` (`en-US`), so the markup matches; it switches right after.
|
|
227
|
+
*/
|
|
228
|
+
function useDocumentLocale(): string {
|
|
229
|
+
return useSyncExternalStore(subscribe, documentLocale, serverLocale)
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/** The locale a part renders in: its `locale` prop when given, else the page's (`useDocumentLocale`). */
|
|
233
|
+
function useLocale(locale?: string | null): string {
|
|
234
|
+
const page = useDocumentLocale()
|
|
235
|
+
return locale || page
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/* ---------- end of inlined code ---------- */
|
|
239
|
+
|
|
240
|
+
export interface KpiCardSource {
|
|
241
|
+
/** Period the data covers (e.g. "2023-04 以降"). ISO year-months / dates in it are written by the card ("2023年4月 以降"). */
|
|
242
|
+
period?: string
|
|
243
|
+
/** Number of rows aggregated; formatted by the card ("48,210件から集計"). */
|
|
244
|
+
count?: number
|
|
245
|
+
/**
|
|
246
|
+
* 集計から除いた行数。出典の脚注に「12件を除外」と添える(ja 以外は「12 excluded」)。0 や負の値は出さない。
|
|
247
|
+
* 除外の理由(テスト用アカウント、欠損など)は `note` に書く。
|
|
248
|
+
* @example source={{ count: 48_210, excluded: 12, note: "テスト用アカウントを除外" }}
|
|
249
|
+
*/
|
|
250
|
+
excluded?: number
|
|
251
|
+
/** Data source name (e.g. "Snowflake"). */
|
|
252
|
+
name?: string
|
|
253
|
+
/** 換算・税抜などの脚注。フッターに出す。例: `note: "税抜、1 ドル=150 円で換算"`。数字を使う説明は summary ではなくここへ。 */
|
|
254
|
+
note?: string
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
export interface KpiCardProps extends Omit<ComponentProps<"div">, "children"> {
|
|
258
|
+
/** Metric name (tier 1). */
|
|
259
|
+
label: string
|
|
260
|
+
/** Current value (tier 2). null / undefined renders "—". */
|
|
261
|
+
value: number | null | undefined
|
|
262
|
+
/**
|
|
263
|
+
* Value format, also used for previous / target / remaining. Default `"compact"`.
|
|
264
|
+
* `"currency"` is JPY in 万・億 on a ja page (USD in K・M・B otherwise); `"percent"` takes a ratio (0.021 → 2.1%) and
|
|
265
|
+
* switches the delta to points. See `formatValue` for the full table.
|
|
266
|
+
*/
|
|
267
|
+
format?: ValueFormat
|
|
268
|
+
/** Locale of the numbers and the built-in wording. Default: the page's (`<html lang>`, else `"en-US"`). */
|
|
269
|
+
locale?: string
|
|
270
|
+
/**
|
|
271
|
+
* What the value is compared against; drives the delta (tier 3) and previous line (tier 4).
|
|
272
|
+
* 前期・前年・目標以外は `vs: "custom"` + `label`(バッジの呼び名。末尾の「比」を外した語が前期実績行の呼び名になる)。
|
|
273
|
+
* @example comparison={{ previous: 617, vs: "custom", label: "昨日比" }}
|
|
274
|
+
*/
|
|
275
|
+
comparison?: Comparison
|
|
276
|
+
/**
|
|
277
|
+
* 前年比と前月比など、複数の比較を併記する。先頭だけが前期実績行と summary に使われ、2 つ目以降は Delta 行のみ。`comparison` も渡したらそれが先頭。バッジは値の下に並ぶ。
|
|
278
|
+
* @example comparisons={[{ previous: 4_290, vs: "prior-period", grain: "month" }, { previous: 3_900, vs: "prior-year", grain: "month" }]} / comparisons={[{ previous: 617, vs: "custom", label: "昨日比" }, { previous: 590, vs: "custom", label: "先週の同じ曜日比" }]}
|
|
279
|
+
*/
|
|
280
|
+
comparisons?: Comparison[]
|
|
281
|
+
/**
|
|
282
|
+
* 確定前の値。値の隣に「速報」バッジを出し、目標の「達成」を言い切らず、最高記録の判定も出さない。
|
|
283
|
+
* @example provisional / provisional={{ label: "暫定" }}
|
|
284
|
+
*/
|
|
285
|
+
provisional?: boolean | { label?: string }
|
|
286
|
+
/** Which direction is good: `"up"` (default) or `"down"` (churn, error rate). Wins over `polarity`. */
|
|
287
|
+
betterWhen?: "up" | "down"
|
|
288
|
+
/** @deprecated Older name of `betterWhen` (`"higher-is-better"` / `"lower-is-better"`). `"neutral"` (never colours the change, skips the target and record logic) is only reachable here. `betterWhen` wins if both are given. */
|
|
289
|
+
polarity?: Polarity
|
|
290
|
+
/** `"relative"` ("+12.4%"), `"points"` ("-0.5pt") or `"difference"` ("+¥530万", in `format`). Default: points for a `percent` format, else relative. */
|
|
291
|
+
change?: ChangeDisplay
|
|
292
|
+
/** Absolute ratio below which the change reads as flat. Default 0.01. */
|
|
293
|
+
neutralBand?: number
|
|
294
|
+
/** Show the previous actual ("前月 400"). Default true; needs `comparison`. */
|
|
295
|
+
showPrevious?: boolean
|
|
296
|
+
/** Conclusion sentence template (tier 5); see the placeholders above. */
|
|
297
|
+
summary?: string
|
|
298
|
+
/**
|
|
299
|
+
* Target for the current period. Under the value it adds a 6px bar (actual, the pace
|
|
300
|
+
* projection, a tick at the target) and one line "目標の 49%・このペースなら 98%"
|
|
301
|
+
* (the projection only while `period` is in progress). With `betterWhen="down"`
|
|
302
|
+
* or a rate target (points) there is no bar: "目標 … まであと …" in the footer.
|
|
303
|
+
*/
|
|
304
|
+
target?: number
|
|
305
|
+
/**
|
|
306
|
+
* 目標行・見込み行での目標の呼び名。既定「目標」。予算・計画・ノルマなど業務の言葉に合わせる。
|
|
307
|
+
* @example targetLabel="予算" // 予算まであと ¥2,934万(98.1% 達成)
|
|
308
|
+
*/
|
|
309
|
+
targetLabel?: string
|
|
310
|
+
/**
|
|
311
|
+
* 目標に対する進み具合の出し方。`"achievement"` は達成率(現在値 ÷ 目標。「98.1% 達成」)、`"points"` は目標との差(「あと 2.8pt」)。
|
|
312
|
+
* 既定は `format` から決まる: `percent`(率)は `"points"`(率を率で割った達成度は読めない)、それ以外は `"achievement"`。
|
|
313
|
+
* `"points"` のとき `{achievement}` は目標との符号付きの差("-2.8pt")になり、見込み行は出ない。
|
|
314
|
+
*/
|
|
315
|
+
targetDisplay?: "achievement" | "points"
|
|
316
|
+
/** Current period; with `target`, adds a pace projection while the period is in progress. */
|
|
317
|
+
period?: KpiPeriod
|
|
318
|
+
/** Series ending at the current period (last point = `value`); adds a record badge. */
|
|
319
|
+
history?: ReadonlyArray<number | null | undefined>
|
|
320
|
+
/** Grain of `history` (and of the comparison labels). Default: inferred from `period`, else `"month"` for the record. */
|
|
321
|
+
historyGrain?: PeriodGrain
|
|
322
|
+
/**
|
|
323
|
+
* `history` が 6 点以上あるとき、フッター末尾に軸なしのスパークライン(高さ 32px・`--chart-1`・最新点だけドット)を出す。既定 true。
|
|
324
|
+
* 推移を出したくないカードは `showHistory={false}`(記録バッジは `history` があれば出る)。
|
|
325
|
+
* @example showHistory={false}
|
|
326
|
+
*/
|
|
327
|
+
showHistory?: boolean
|
|
328
|
+
/** Data source footnote. */
|
|
329
|
+
source?: KpiCardSource
|
|
330
|
+
/** When the data was last refreshed: "3 分前に更新". 出し方は `updatedAtDisplay`。 */
|
|
331
|
+
updatedAt?: Date | string | number
|
|
332
|
+
/**
|
|
333
|
+
* `updatedAt` の出し方。`"relative"`(既定)は「3 分前に更新」、`"absolute"` は「2026/10/20 14:30 に更新」、
|
|
334
|
+
* `"both"` は「2026/10/20 14:30 に更新(3 分前)」。絶対時刻は実行環境のタイムゾーンで出す(`timeZone` で固定できる)。
|
|
335
|
+
* 画面を印刷・スクリーンショットで残すなら、時間が経っても読める `"absolute"` か `"both"`。
|
|
336
|
+
* @example updatedAtDisplay="absolute"
|
|
337
|
+
*/
|
|
338
|
+
updatedAtDisplay?: "relative" | "absolute" | "both"
|
|
339
|
+
/**
|
|
340
|
+
* `updatedAt` の絶対時刻(`updatedAtDisplay` が `"absolute"` / `"both"`)を出すタイムゾーン(IANA 名。例 `"Asia/Tokyo"`)。
|
|
341
|
+
* 既定は実行環境(ブラウザ)のタイムゾーン。サーバーとブラウザで表示が食い違う(SSR)なら固定する。「3 分前」には影響しない。
|
|
342
|
+
* @example updatedAtDisplay="absolute" timeZone="Asia/Tokyo"
|
|
343
|
+
*/
|
|
344
|
+
timeZone?: string
|
|
345
|
+
/** Reference time for the projection and relative time (for deterministic rendering). */
|
|
346
|
+
now?: Date | number
|
|
347
|
+
/** Render a skeleton with the final shape instead of the values. */
|
|
348
|
+
isLoading?: boolean
|
|
349
|
+
/**
|
|
350
|
+
* `"sm"` は Sheet の中など狭い場所で 2 枚を横に並べるときの小さいサイズ。値の文字と余白が一段小さくなり、スパークラインは出さない。既定 `"default"`。
|
|
351
|
+
* @example size="sm"
|
|
352
|
+
*/
|
|
353
|
+
size?: "default" | "sm"
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
/** Points of `history` from which the footer shows a sparkline (the same bar as the record badge). */
|
|
357
|
+
const SPARKLINE_MIN_POINTS = 6
|
|
358
|
+
|
|
359
|
+
/** Height of the footer sparkline, in px. */
|
|
360
|
+
const SPARKLINE_HEIGHT = 32
|
|
361
|
+
|
|
362
|
+
/**
|
|
363
|
+
* The delta sits right of the value once the card is ≥ 320px wide (a comparison label is ~180px, so narrower cards would overlap the value) (the shadcn
|
|
364
|
+
* `CardAction` slot) and drops under the value below that — a container query,
|
|
365
|
+
* so a narrow card needs no prop.
|
|
366
|
+
*/
|
|
367
|
+
const DELTA_PLACEMENT =
|
|
368
|
+
"col-start-1 row-span-1 row-start-3 justify-self-start @[288px]/card:col-start-2 @[288px]/card:row-span-2 @[288px]/card:row-start-1 @[288px]/card:justify-self-end"
|
|
369
|
+
|
|
370
|
+
/**
|
|
371
|
+
* The header is the size container (`@container/card`), not the card: a container sets layout containment, which turns the
|
|
372
|
+
* card's `grid-rows-subgrid` into a plain grid and the rows stop lining up. Its content box is the card minus both paddings
|
|
373
|
+
* (32px; 24px for `sm`), so 218px / 288px here are the 250px / 320px card widths. It also owns the gap above the footer.
|
|
374
|
+
*/
|
|
375
|
+
const HEADER_CLASS = "@container/card pb-(--card-spacing)"
|
|
376
|
+
/** A value this long (a huge standard-notation currency) may wrap between digit groups, and its delta then moves under it so the number gets the full row. Ordinary values stay on one line beside the delta. */
|
|
377
|
+
const LONG_VALUE_CHARS = 10
|
|
378
|
+
const DELTA_UNDER_PLACEMENT = "col-start-1 row-span-1 row-start-3 justify-self-start"
|
|
379
|
+
// No grey footer band: value, comparison and notes share one surface, 8px apart (the footer only keeps its subgrid row).
|
|
380
|
+
const KPI_FOOTER_CLASS = "flex-col items-start gap-2 rounded-none border-t-0 bg-transparent pt-0 text-sm"
|
|
381
|
+
|
|
382
|
+
const PLACEHOLDER = /\{(\w+)\}/g
|
|
383
|
+
|
|
384
|
+
const TEXT = {
|
|
385
|
+
ja: {
|
|
386
|
+
target: "目標",
|
|
387
|
+
provisional: "速報",
|
|
388
|
+
joiner: "・",
|
|
389
|
+
countFrom: (count: string) => `${count}件から集計`,
|
|
390
|
+
excluded: (count: string) => `${count}件を除外`,
|
|
391
|
+
noBaseline: (baseline: string) => `${baseline}の実績がないため、比較できません。`,
|
|
392
|
+
previous: (baseline: string, value: string) => `${baseline} ${value}`,
|
|
393
|
+
noPrevious: (baseline: string) => `${baseline}の実績なし`,
|
|
394
|
+
barLead: (tl: string, pct: string) => `${tl}の ${pct}`,
|
|
395
|
+
pace: (pct: string) => `このペースなら ${pct}`,
|
|
396
|
+
provisionalSuffix: "(確定前)",
|
|
397
|
+
provisionalReached: (tl: string, target: string) => `${tl} ${target} に到達(確定前)`,
|
|
398
|
+
provisionalSoFar: (tl: string, target: string, pct: string) => `${tl} ${target} に対して現時点 ${pct}(確定前)`,
|
|
399
|
+
provisionalToGo: (tl: string, target: string, distance: string) => `${tl} ${target} まであと ${distance}(確定前)`,
|
|
400
|
+
provisionalToGoRate: (tl: string, distance: string, pct: string) => `${tl}まであと ${distance}(現時点 ${pct}・確定前)`,
|
|
401
|
+
met: (tl: string, target: string) => `${tl} ${target} を達成`,
|
|
402
|
+
toGo: (tl: string, target: string, distance: string) => `${tl} ${target} まであと ${distance}`,
|
|
403
|
+
reached: (tl: string, target: string) => `${tl} ${target} に到達`,
|
|
404
|
+
reachedRate: (tl: string, target: string, pct: string) => `${tl} ${target} に到達(${pct} 達成)`,
|
|
405
|
+
toGoRate: (tl: string, distance: string, pct: string) => `${tl}まであと ${distance}(${pct} 達成)`,
|
|
406
|
+
progressLabel: (tl: string) => `${tl}に対する進捗`,
|
|
407
|
+
progressValue: (tl: string, target: string, value: string, pct: string) => `${tl} ${target} に対して ${value}(${pct})`,
|
|
408
|
+
history: (label: string, points: number) => `${label}の推移(直近 ${points} 点)`,
|
|
409
|
+
updatedRelative: (relative: string) => `${relative}に更新`,
|
|
410
|
+
updatedAbsolute: (absolute: string) => `${absolute} に更新`,
|
|
411
|
+
updatedBoth: (absolute: string, relative: string) => `${absolute} に更新(${relative})`,
|
|
412
|
+
summaryExample: "{label}は目標の {achievement} まで来ています。",
|
|
413
|
+
},
|
|
414
|
+
en: {
|
|
415
|
+
target: "Target",
|
|
416
|
+
provisional: "Preliminary",
|
|
417
|
+
joiner: " · ",
|
|
418
|
+
countFrom: (count: string) => `Based on ${count} records`,
|
|
419
|
+
excluded: (count: string) => `${count} excluded`,
|
|
420
|
+
noBaseline: (baseline: string) => `No ${baseline} data to compare against.`,
|
|
421
|
+
previous: (baseline: string, value: string) => `${capitalize(baseline)}: ${value}`,
|
|
422
|
+
noPrevious: (baseline: string) => `No ${baseline} data`,
|
|
423
|
+
barLead: (tl: string, pct: string) => `${tl}: ${pct}`,
|
|
424
|
+
pace: (pct: string) => `On pace for ${pct}`,
|
|
425
|
+
provisionalSuffix: " (preliminary)",
|
|
426
|
+
provisionalReached: (tl: string, target: string) => `${tl} ${target}: reached (preliminary)`,
|
|
427
|
+
provisionalSoFar: (tl: string, target: string, pct: string) => `${tl} ${target}: ${pct} so far (preliminary)`,
|
|
428
|
+
provisionalToGo: (tl: string, target: string, distance: string) => `${tl} ${target}: ${distance} to go (preliminary)`,
|
|
429
|
+
provisionalToGoRate: (tl: string, distance: string, pct: string) => `${tl}: ${distance} to go (${pct} so far, preliminary)`,
|
|
430
|
+
met: (tl: string, target: string) => `${tl} ${target}: met`,
|
|
431
|
+
toGo: (tl: string, target: string, distance: string) => `${tl} ${target}: ${distance} to go`,
|
|
432
|
+
reached: (tl: string, target: string) => `${tl} ${target}: reached`,
|
|
433
|
+
reachedRate: (tl: string, target: string, pct: string) => `${tl} ${target}: reached (${pct} achieved)`,
|
|
434
|
+
toGoRate: (tl: string, distance: string, pct: string) => `${tl}: ${distance} to go (${pct} achieved)`,
|
|
435
|
+
progressLabel: (tl: string) => `Progress toward ${tl}`,
|
|
436
|
+
progressValue: (tl: string, target: string, value: string, pct: string) => `${value} of ${tl} ${target} (${pct})`,
|
|
437
|
+
history: (label: string, points: number) => `${label} trend (last ${points} points)`,
|
|
438
|
+
updatedRelative: (relative: string) => `Updated ${relative}`,
|
|
439
|
+
updatedAbsolute: (absolute: string) => `Updated ${absolute}`,
|
|
440
|
+
updatedBoth: (absolute: string, relative: string) => `Updated ${absolute} (${relative})`,
|
|
441
|
+
summaryExample: "{label} is at {achievement} of target.",
|
|
442
|
+
},
|
|
443
|
+
} satisfies Record<UiLanguage, unknown>
|
|
444
|
+
|
|
445
|
+
type KpiText = (typeof TEXT)[UiLanguage]
|
|
446
|
+
|
|
447
|
+
function capitalize(text: string): string {
|
|
448
|
+
return text.charAt(0).toUpperCase() + text.slice(1)
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
const isMissing = (v: string | null | undefined) => v === null || v === undefined || v === ""
|
|
452
|
+
|
|
453
|
+
/**
|
|
454
|
+
* Fill `{name}` placeholders from `values`. Unknown names stay as written.
|
|
455
|
+
*
|
|
456
|
+
* Without `fallback`, a known name whose value is null / undefined / empty
|
|
457
|
+
* becomes "—". With `fallback`, any such missing value replaces the whole
|
|
458
|
+
* sentence with `fallback` (which may be null to drop it).
|
|
459
|
+
*/
|
|
460
|
+
export function fillKpiTemplate(template: string, values: Readonly<Record<string, string | null | undefined>>): string
|
|
461
|
+
export function fillKpiTemplate(
|
|
462
|
+
template: string,
|
|
463
|
+
values: Readonly<Record<string, string | null | undefined>>,
|
|
464
|
+
fallback: string | null,
|
|
465
|
+
): string | null
|
|
466
|
+
export function fillKpiTemplate(
|
|
467
|
+
template: string,
|
|
468
|
+
values: Readonly<Record<string, string | null | undefined>>,
|
|
469
|
+
fallback?: string | null,
|
|
470
|
+
): string | null {
|
|
471
|
+
let missing = false
|
|
472
|
+
const filled = template.replace(PLACEHOLDER, (match, name: string) => {
|
|
473
|
+
if (!Object.prototype.hasOwnProperty.call(values, name)) return match
|
|
474
|
+
const v = values[name]
|
|
475
|
+
if (isMissing(v)) {
|
|
476
|
+
missing = true
|
|
477
|
+
return EMPTY_VALUE
|
|
478
|
+
}
|
|
479
|
+
return v as string
|
|
480
|
+
})
|
|
481
|
+
return missing && fallback !== undefined ? fallback : filled
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
const SHALLOW_SUMMARY_PLACEHOLDERS = new Set(["delta", "difference"])
|
|
485
|
+
|
|
486
|
+
let shallowSummaryWarned = false
|
|
487
|
+
|
|
488
|
+
/** @internal Lets tests observe the once-only dev warning again. */
|
|
489
|
+
export function resetKpiSummaryWarning() {
|
|
490
|
+
shallowSummaryWarned = false
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
/** True when every placeholder is `{delta}` / `{difference}` — the template only restates the badge. */
|
|
494
|
+
function isShallowSummary(template: string): boolean {
|
|
495
|
+
const names = Array.from(template.matchAll(PLACEHOLDER), (m) => m[1] as string)
|
|
496
|
+
return names.length > 0 && names.every((n) => SHALLOW_SUMMARY_PLACEHOLDERS.has(n))
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
// Bundlers replace `process.env.NODE_ENV` textually; a browser without that define has no `process`.
|
|
500
|
+
declare const process: { env: { NODE_ENV?: string } }
|
|
501
|
+
|
|
502
|
+
function isProduction(): boolean {
|
|
503
|
+
try {
|
|
504
|
+
return process.env.NODE_ENV === "production"
|
|
505
|
+
} catch {
|
|
506
|
+
return true
|
|
507
|
+
}
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
function warnShallowSummary(template: string, t: KpiText) {
|
|
511
|
+
if (shallowSummaryWarned || isProduction() || !isShallowSummary(template)) return
|
|
512
|
+
shallowSummaryWarned = true
|
|
513
|
+
console.warn(
|
|
514
|
+
`[KpiCard] summary="${template}" only repeats the delta badge and the previous actual. Say one fact beyond the comparison — composition, target, record or rate (e.g. "${t.summaryExample}").`,
|
|
515
|
+
)
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
/** Splits formatted text after each thousands separator so a huge standard-notation value wraps between digit groups instead of clipping. The text itself is unchanged. */
|
|
519
|
+
function digitGroups(text: string): string[] {
|
|
520
|
+
return text.match(/[^,,]*[,,]|[^,,]+$/g) ?? [text]
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
export function KpiCard({
|
|
524
|
+
label,
|
|
525
|
+
value,
|
|
526
|
+
format,
|
|
527
|
+
locale: localeProp,
|
|
528
|
+
comparison: comparisonProp,
|
|
529
|
+
comparisons,
|
|
530
|
+
provisional,
|
|
531
|
+
betterWhen,
|
|
532
|
+
polarity: polarityProp,
|
|
533
|
+
change,
|
|
534
|
+
neutralBand,
|
|
535
|
+
showPrevious = true,
|
|
536
|
+
summary,
|
|
537
|
+
target,
|
|
538
|
+
targetLabel,
|
|
539
|
+
targetDisplay,
|
|
540
|
+
period,
|
|
541
|
+
history,
|
|
542
|
+
historyGrain,
|
|
543
|
+
showHistory = true,
|
|
544
|
+
source,
|
|
545
|
+
updatedAt,
|
|
546
|
+
updatedAtDisplay = "relative",
|
|
547
|
+
timeZone,
|
|
548
|
+
now,
|
|
549
|
+
isLoading = false,
|
|
550
|
+
size = "default",
|
|
551
|
+
className,
|
|
552
|
+
...props
|
|
553
|
+
}: KpiCardProps) {
|
|
554
|
+
const locale = useLocale(localeProp)
|
|
555
|
+
const t = TEXT[uiLanguage(locale)]
|
|
556
|
+
const allComparisons = [...(comparisonProp ? [comparisonProp] : []), ...(comparisons ?? [])]
|
|
557
|
+
const comparison = allComparisons[0]
|
|
558
|
+
const isProvisional = provisional !== undefined && provisional !== false
|
|
559
|
+
const provisionalLabel = typeof provisional === "object" && provisional.label ? provisional.label : t.provisional
|
|
560
|
+
const sample = useSampleMark()
|
|
561
|
+
// `betterWhen` is the documented name and wins over the older `polarity`.
|
|
562
|
+
const polarity: Polarity | undefined = betterWhen ? (betterWhen === "down" ? "lower-is-better" : "higher-is-better") : polarityProp
|
|
563
|
+
const neutral = polarity === "neutral"
|
|
564
|
+
const hasPrevious = comparison !== undefined && showPrevious
|
|
565
|
+
// Two or more badges would squeeze the value from the right; they get their own row under it instead.
|
|
566
|
+
const stacked = allComparisons.length > 1
|
|
567
|
+
const hasTarget = target !== undefined
|
|
568
|
+
const grain = comparison?.grain ?? historyGrain ?? inferPeriodGrain(period)
|
|
569
|
+
const record = isProvisional || neutral ? null : detectRecord(history, historyGrain ?? grain, polarity, locale)
|
|
570
|
+
const sourceParts = source
|
|
571
|
+
? [
|
|
572
|
+
source.period !== undefined ? formatDatesInText(source.period, locale) : undefined,
|
|
573
|
+
source.count !== undefined ? t.countFrom(formatValue(source.count, "number", locale)) : undefined,
|
|
574
|
+
source.excluded !== undefined && Number.isFinite(source.excluded) && source.excluded > 0
|
|
575
|
+
? t.excluded(formatValue(source.excluded, "number", locale))
|
|
576
|
+
: undefined,
|
|
577
|
+
source.name,
|
|
578
|
+
source.note,
|
|
579
|
+
].filter((p): p is string => typeof p === "string" && p !== "")
|
|
580
|
+
: []
|
|
581
|
+
const hasUpdated = updatedAt !== undefined
|
|
582
|
+
const historyPoints = (history ?? []).filter((v): v is number => typeof v === "number" && Number.isFinite(v))
|
|
583
|
+
const small = size === "sm"
|
|
584
|
+
const showSparkline = !small && showHistory && historyPoints.length >= SPARKLINE_MIN_POINTS
|
|
585
|
+
const isRate = formatKind(format) === "percent"
|
|
586
|
+
const byPoints = (targetDisplay ?? (isRate ? "points" : "achievement")) === "points"
|
|
587
|
+
const lower = polarity === "lower-is-better"
|
|
588
|
+
// The bar reads "how far toward the target"; a rate (points) or a lower-is-better target has no such fill.
|
|
589
|
+
const targetBar = hasTarget && !neutral && !byPoints && !lower
|
|
590
|
+
if (summary !== undefined) warnShallowSummary(summary, t)
|
|
591
|
+
// Two rows of the parent grid (body, footer), shared by subgrid; a plain two-row grid when standalone.
|
|
592
|
+
const cardClass = cn("row-span-2 grid content-start gap-y-0 grid-rows-subgrid", className)
|
|
593
|
+
const valueSize = small ? "text-xl @[218px]/card:text-2xl" : "text-2xl @[218px]/card:text-4xl"
|
|
594
|
+
|
|
595
|
+
if (isLoading) {
|
|
596
|
+
return (
|
|
597
|
+
<Card
|
|
598
|
+
data-slot="kpi-card"
|
|
599
|
+
data-loading=""
|
|
600
|
+
aria-busy="true"
|
|
601
|
+
size={size}
|
|
602
|
+
className={cardClass}
|
|
603
|
+
{...props}
|
|
604
|
+
>
|
|
605
|
+
<CardHeader className={HEADER_CLASS}>
|
|
606
|
+
<CardDescription className="col-start-1">
|
|
607
|
+
<span className="sr-only">{label}</span>
|
|
608
|
+
<Skeleton aria-hidden="true" className="h-5 w-24" />
|
|
609
|
+
</CardDescription>
|
|
610
|
+
<CardTitle className="col-start-1 flex items-center gap-2">
|
|
611
|
+
<Skeleton aria-hidden="true" className={small ? "h-7 w-28 @[218px]/card:h-8" : "h-8 w-32 @[218px]/card:h-9"} />
|
|
612
|
+
{/* The mark stays while loading, so the header does not shift when the values arrive. */}
|
|
613
|
+
{sample && <SampleBadge label={sample.label} />}
|
|
614
|
+
</CardTitle>
|
|
615
|
+
{comparison &&
|
|
616
|
+
(stacked ? (
|
|
617
|
+
<div className="flex flex-wrap gap-1">
|
|
618
|
+
{allComparisons.map((_, i) => (
|
|
619
|
+
<Skeleton key={i} aria-hidden="true" className="h-5 w-24 rounded-4xl" />
|
|
620
|
+
))}
|
|
621
|
+
</div>
|
|
622
|
+
) : (
|
|
623
|
+
<CardAction className={DELTA_PLACEMENT}>
|
|
624
|
+
<Skeleton aria-hidden="true" className="h-5 w-24 rounded-4xl" />
|
|
625
|
+
</CardAction>
|
|
626
|
+
))}
|
|
627
|
+
{hasPrevious && <Skeleton aria-hidden="true" className="h-5 w-16" />}
|
|
628
|
+
{targetBar ? (
|
|
629
|
+
<div className="col-span-full mt-1 flex flex-col gap-1.5">
|
|
630
|
+
<Skeleton aria-hidden="true" className="h-2 w-full rounded-full" />
|
|
631
|
+
<Skeleton aria-hidden="true" className="h-5 w-3/5" />
|
|
632
|
+
</div>
|
|
633
|
+
) : null}
|
|
634
|
+
</CardHeader>
|
|
635
|
+
{(summary || record || (hasTarget && !targetBar) || sourceParts.length > 0 || hasUpdated || showSparkline) && (
|
|
636
|
+
<CardFooter className={KPI_FOOTER_CLASS}>
|
|
637
|
+
{summary && <Skeleton aria-hidden="true" className="h-5 w-4/5" />}
|
|
638
|
+
{record && <Skeleton aria-hidden="true" className="h-5 w-28 rounded-4xl" />}
|
|
639
|
+
{hasTarget && !targetBar && <Skeleton aria-hidden="true" className="h-5 w-3/5" />}
|
|
640
|
+
{sourceParts.length > 0 && <Skeleton aria-hidden="true" className="h-4 w-3/4" />}
|
|
641
|
+
{hasUpdated && <Skeleton aria-hidden="true" className="h-4 w-20" />}
|
|
642
|
+
{showSparkline && <Skeleton aria-hidden="true" className="mt-auto h-8 w-full" />}
|
|
643
|
+
</CardFooter>
|
|
644
|
+
)}
|
|
645
|
+
</Card>
|
|
646
|
+
)
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
const fmt = (v: number | null | undefined) => formatValue(v, format, locale)
|
|
650
|
+
/** An unsigned distance in the metric's unit — points for a rate. */
|
|
651
|
+
const fmtDistance = (v: number) => (isRate ? formatPoints(v, locale, { signDisplay: "never" }) : fmt(v))
|
|
652
|
+
const pct = (v: number) => formatValue(v, { type: "percent", digits: 0 }, locale)
|
|
653
|
+
const tl = targetLabel ?? t.target
|
|
654
|
+
const display = change ?? defaultChangeDisplay(format)
|
|
655
|
+
// Value digits only carry over to a change in points; a relative change keeps its own fixed digits.
|
|
656
|
+
const digits =
|
|
657
|
+
display === "points" && typeof format === "object" && format !== null && "digits" in format ? format.digits : undefined
|
|
658
|
+
const graded = comparison ? { ...comparison, grain } : undefined
|
|
659
|
+
const result = graded ? computeComparison(value, graded, { polarity, neutralBand, locale }) : null
|
|
660
|
+
const baseline = graded ? comparisonBaseline(graded.vs, grain, graded.label, graded.toDate, locale) : null
|
|
661
|
+
const progress = hasTarget && !neutral ? computeTargetProgress(value, target, { period, now, polarity }) : null
|
|
662
|
+
// A linear pace only makes sense for an accumulating total, not for a rate.
|
|
663
|
+
const projectedValue =
|
|
664
|
+
!isRate && !byPoints && progress?.projected != null && progress.elapsed ? (value as number) / progress.elapsed : null
|
|
665
|
+
|
|
666
|
+
const summaryText = summary
|
|
667
|
+
? fillKpiTemplate(
|
|
668
|
+
summary,
|
|
669
|
+
{
|
|
670
|
+
value: fmt(value),
|
|
671
|
+
label,
|
|
672
|
+
delta: result ? formatChange(result, display, locale, format, digits) : null,
|
|
673
|
+
difference:
|
|
674
|
+
result?.difference != null
|
|
675
|
+
? isRate
|
|
676
|
+
? formatPoints(result.difference, locale)
|
|
677
|
+
: formatDelta(result.difference, format, locale)
|
|
678
|
+
: null,
|
|
679
|
+
previous: result?.previous != null ? fmt(result.previous) : null,
|
|
680
|
+
target: hasTarget ? fmt(target) : null,
|
|
681
|
+
achievement: progress
|
|
682
|
+
? byPoints
|
|
683
|
+
? formatPoints((value as number) - (target as number), locale)
|
|
684
|
+
: pct(progress.achievement)
|
|
685
|
+
: null,
|
|
686
|
+
remaining: progress ? fmtDistance(progress.remaining) : null,
|
|
687
|
+
},
|
|
688
|
+
result && result.direction === "none" && baseline ? t.noBaseline(baseline) : null,
|
|
689
|
+
)
|
|
690
|
+
: null
|
|
691
|
+
|
|
692
|
+
// Target bar: actual, the pace projection and a tick at the target on one scale.
|
|
693
|
+
const bar = targetBar && progress ? progressBar() : null
|
|
694
|
+
|
|
695
|
+
function progressBar() {
|
|
696
|
+
const actual = Math.max(value as number, 0)
|
|
697
|
+
const projected = projectedValue !== null && projectedValue > actual ? projectedValue : actual
|
|
698
|
+
const scale = Math.max(target as number, actual, projected)
|
|
699
|
+
const at = (v: number) => (scale > 0 ? (v / scale) * 100 : 0)
|
|
700
|
+
const parts = [t.barLead(tl, pct(progress!.achievement))]
|
|
701
|
+
if (projectedValue !== null && progress!.projected != null)
|
|
702
|
+
parts.push(t.pace(formatValue(progress!.projected, { type: "percent", digits: 0 }, locale)))
|
|
703
|
+
return {
|
|
704
|
+
actualAt: at(actual),
|
|
705
|
+
projectedAt: at(projected),
|
|
706
|
+
targetAt: at(target as number),
|
|
707
|
+
lead: parts[0] as string,
|
|
708
|
+
pace: parts[1] ?? null,
|
|
709
|
+
}
|
|
710
|
+
}
|
|
711
|
+
|
|
712
|
+
let targetText: string | null = null
|
|
713
|
+
if (hasTarget && !bar) {
|
|
714
|
+
if (!progress) targetText = `${tl} ${fmt(target)}`
|
|
715
|
+
else if (isProvisional)
|
|
716
|
+
targetText = progress.achieved
|
|
717
|
+
? byPoints
|
|
718
|
+
? t.provisionalReached(tl, fmt(target))
|
|
719
|
+
: t.provisionalSoFar(tl, fmt(target), pct(progress.achievement))
|
|
720
|
+
: byPoints
|
|
721
|
+
? t.provisionalToGo(tl, fmt(target), fmtDistance(progress.remaining))
|
|
722
|
+
: t.provisionalToGoRate(tl, fmtDistance(progress.remaining), pct(progress.achievement))
|
|
723
|
+
else if (lower)
|
|
724
|
+
targetText = progress.achieved
|
|
725
|
+
? t.met(tl, fmt(target))
|
|
726
|
+
: t.toGo(tl, fmt(target), fmtDistance(progress.remaining))
|
|
727
|
+
else if (byPoints)
|
|
728
|
+
targetText = progress.achieved
|
|
729
|
+
? t.reached(tl, fmt(target))
|
|
730
|
+
: t.toGo(tl, fmt(target), fmtDistance(progress.remaining))
|
|
731
|
+
else
|
|
732
|
+
targetText = progress.achieved
|
|
733
|
+
? t.reachedRate(tl, fmt(target), pct(progress.achievement))
|
|
734
|
+
: t.toGoRate(tl, fmtDistance(progress.remaining), pct(progress.achievement))
|
|
735
|
+
}
|
|
736
|
+
|
|
737
|
+
const hasFooter =
|
|
738
|
+
Boolean(summaryText) || record !== null || Boolean(targetText) || sourceParts.length > 0 || hasUpdated || showSparkline
|
|
739
|
+
|
|
740
|
+
const deltas = allComparisons.map((c, i) => (
|
|
741
|
+
<Delta
|
|
742
|
+
key={i}
|
|
743
|
+
value={value}
|
|
744
|
+
comparison={i === 0 ? (graded ?? c) : { ...c, grain: c.grain ?? grain }}
|
|
745
|
+
polarity={polarity}
|
|
746
|
+
neutralBand={neutralBand}
|
|
747
|
+
format={format}
|
|
748
|
+
change={display}
|
|
749
|
+
digits={digits}
|
|
750
|
+
locale={locale}
|
|
751
|
+
/>
|
|
752
|
+
))
|
|
753
|
+
|
|
754
|
+
const valueText = fmt(value)
|
|
755
|
+
const longValue = valueText.length > LONG_VALUE_CHARS
|
|
756
|
+
|
|
757
|
+
return (
|
|
758
|
+
<Card data-slot="kpi-card" size={size} className={cardClass} {...props}>
|
|
759
|
+
<CardHeader className={HEADER_CLASS}>
|
|
760
|
+
<CardDescription data-slot="kpi-card-label" title={label} className="col-start-1 min-w-0 truncate text-[13px] leading-snug font-medium text-muted-foreground">
|
|
761
|
+
{label}
|
|
762
|
+
</CardDescription>
|
|
763
|
+
<CardTitle
|
|
764
|
+
data-slot="kpi-card-value"
|
|
765
|
+
title={valueText}
|
|
766
|
+
className={cn(
|
|
767
|
+
"col-start-1 flex min-w-0 flex-wrap items-center gap-x-2 gap-y-1 leading-none font-semibold tracking-tight tabular-nums",
|
|
768
|
+
// Fixed size: the step follows the card width only, never the length of the value.
|
|
769
|
+
valueSize,
|
|
770
|
+
)}
|
|
771
|
+
>
|
|
772
|
+
<span data-slot="kpi-card-value-text" className={longValue ? "min-w-0 break-words" : "whitespace-nowrap"}>
|
|
773
|
+
{longValue
|
|
774
|
+
? digitGroups(valueText).map((group, i) => (
|
|
775
|
+
<span key={i} className="whitespace-nowrap">
|
|
776
|
+
{group}
|
|
777
|
+
<wbr />
|
|
778
|
+
</span>
|
|
779
|
+
))
|
|
780
|
+
: valueText}
|
|
781
|
+
</span>
|
|
782
|
+
{isProvisional && (
|
|
783
|
+
<Badge data-slot="kpi-card-provisional" variant="outline" className="align-middle">
|
|
784
|
+
{provisionalLabel}
|
|
785
|
+
</Badge>
|
|
786
|
+
)}
|
|
787
|
+
{sample && <SampleBadge label={sample.label} />}
|
|
788
|
+
</CardTitle>
|
|
789
|
+
{comparison &&
|
|
790
|
+
(stacked ? (
|
|
791
|
+
<div data-slot="kpi-card-comparisons" className="flex flex-wrap items-center gap-1">
|
|
792
|
+
{deltas}
|
|
793
|
+
</div>
|
|
794
|
+
) : (
|
|
795
|
+
<CardAction className={longValue ? DELTA_UNDER_PLACEMENT : DELTA_PLACEMENT}>{deltas}</CardAction>
|
|
796
|
+
))}
|
|
797
|
+
{hasPrevious && comparison && (
|
|
798
|
+
<div data-slot="kpi-card-previous" className="col-span-full text-sm text-muted-foreground tabular-nums">
|
|
799
|
+
{result?.previous != null ? t.previous(baseline ?? "", fmt(result.previous)) : t.noPrevious(baseline ?? "")}
|
|
800
|
+
</div>
|
|
801
|
+
)}
|
|
802
|
+
{bar && progress && (
|
|
803
|
+
<div data-slot="kpi-card-target-progress" className="col-span-full mt-1 flex min-w-0 flex-col gap-1.5">
|
|
804
|
+
<div
|
|
805
|
+
data-slot="kpi-card-target-bar"
|
|
806
|
+
role="progressbar"
|
|
807
|
+
aria-label={t.progressLabel(tl)}
|
|
808
|
+
aria-valuemin={0}
|
|
809
|
+
aria-valuemax={target}
|
|
810
|
+
aria-valuenow={Math.min(Math.max(value as number, 0), target as number)}
|
|
811
|
+
aria-valuetext={t.progressValue(tl, fmt(target), fmt(value), pct(progress.achievement))}
|
|
812
|
+
className="relative h-2 w-full overflow-hidden rounded-full bg-muted"
|
|
813
|
+
>
|
|
814
|
+
<div className="absolute inset-y-0 left-0 bg-chart-1" style={{ width: `${bar.actualAt}%` }} />
|
|
815
|
+
{bar.projectedAt > bar.actualAt && (
|
|
816
|
+
<div
|
|
817
|
+
data-slot="kpi-card-target-bar-projection"
|
|
818
|
+
className="absolute inset-y-0 bg-chart-1/30 dark:bg-chart-1/45"
|
|
819
|
+
style={{ left: `${bar.actualAt}%`, width: `${bar.projectedAt - bar.actualAt}%` }}
|
|
820
|
+
/>
|
|
821
|
+
)}
|
|
822
|
+
<div
|
|
823
|
+
data-slot="kpi-card-target-bar-tick"
|
|
824
|
+
className="absolute inset-y-0 w-px bg-foreground"
|
|
825
|
+
style={{ left: `${bar.targetAt}%`, transform: bar.targetAt >= 100 ? "translateX(-1px)" : undefined }}
|
|
826
|
+
/>
|
|
827
|
+
</div>
|
|
828
|
+
<p
|
|
829
|
+
data-slot="kpi-card-target"
|
|
830
|
+
title={[bar.lead, bar.pace].filter(Boolean).join(t.joiner)}
|
|
831
|
+
className="truncate text-sm text-muted-foreground tabular-nums"
|
|
832
|
+
>
|
|
833
|
+
{bar.lead}
|
|
834
|
+
{bar.pace && (
|
|
835
|
+
<>
|
|
836
|
+
{t.joiner}
|
|
837
|
+
<span data-slot="kpi-card-projection" data-estimate="">{bar.pace}</span>
|
|
838
|
+
</>
|
|
839
|
+
)}
|
|
840
|
+
{isProvisional && t.provisionalSuffix}
|
|
841
|
+
</p>
|
|
842
|
+
</div>
|
|
843
|
+
)}
|
|
844
|
+
</CardHeader>
|
|
845
|
+
{hasFooter && (
|
|
846
|
+
<CardFooter className={KPI_FOOTER_CLASS}>
|
|
847
|
+
{summaryText && (
|
|
848
|
+
<p data-slot="kpi-card-summary" title={summaryText} className="line-clamp-2 text-sm leading-relaxed text-muted-foreground tabular-nums">
|
|
849
|
+
{summaryText}
|
|
850
|
+
</p>
|
|
851
|
+
)}
|
|
852
|
+
{record && (
|
|
853
|
+
<Badge data-slot="kpi-card-record" variant="outline">
|
|
854
|
+
{record.label}
|
|
855
|
+
</Badge>
|
|
856
|
+
)}
|
|
857
|
+
{targetText && (
|
|
858
|
+
<p data-slot="kpi-card-target" className="text-muted-foreground tabular-nums">
|
|
859
|
+
{targetText}
|
|
860
|
+
</p>
|
|
861
|
+
)}
|
|
862
|
+
{sourceParts.length > 0 && (
|
|
863
|
+
<p
|
|
864
|
+
data-slot="kpi-card-source"
|
|
865
|
+
title={sourceParts.join(t.joiner)}
|
|
866
|
+
className="line-clamp-2 w-full text-xs leading-normal text-muted-foreground tabular-nums"
|
|
867
|
+
>
|
|
868
|
+
{sourceParts.join(t.joiner)}
|
|
869
|
+
</p>
|
|
870
|
+
)}
|
|
871
|
+
{hasUpdated && (
|
|
872
|
+
<p data-slot="kpi-card-updated" className="text-xs leading-normal text-muted-foreground">
|
|
873
|
+
{updatedAtText(updatedAt, updatedAtDisplay, locale, t, now, timeZone)}
|
|
874
|
+
</p>
|
|
875
|
+
)}
|
|
876
|
+
{showSparkline && (
|
|
877
|
+
// mt-auto: the footers of a row are as tall as the tallest, so the sparklines share one bottom edge.
|
|
878
|
+
<div data-slot="kpi-card-history" className="mt-auto w-full pt-2 text-chart-1">
|
|
879
|
+
<Sparkline
|
|
880
|
+
data={historyPoints.map((v) => ({ value: v }))}
|
|
881
|
+
height={SPARKLINE_HEIGHT}
|
|
882
|
+
color="text-chart-1"
|
|
883
|
+
highlight="latest"
|
|
884
|
+
aria-label={t.history(label, historyPoints.length)}
|
|
885
|
+
/>
|
|
886
|
+
</div>
|
|
887
|
+
)}
|
|
888
|
+
</CardFooter>
|
|
889
|
+
)}
|
|
890
|
+
</Card>
|
|
891
|
+
)
|
|
892
|
+
}
|
|
893
|
+
|
|
894
|
+
/** 「3 分前に更新」「2026/10/20 14:30 に更新」「2026/10/20 14:30 に更新(3 分前)」(英語は "Updated 3 minutes ago" など)。 */
|
|
895
|
+
function updatedAtText(
|
|
896
|
+
updatedAt: Date | string | number,
|
|
897
|
+
display: "relative" | "absolute" | "both",
|
|
898
|
+
locale: string,
|
|
899
|
+
t: KpiText,
|
|
900
|
+
now: Date | number | undefined,
|
|
901
|
+
timeZone?: string,
|
|
902
|
+
): string {
|
|
903
|
+
const relative = formatRelativeTime(updatedAt, { now, locale })
|
|
904
|
+
if (display === "relative") return t.updatedRelative(relative)
|
|
905
|
+
const absolute = formatDate(updatedAt, "datetime", locale, { timeZone })
|
|
906
|
+
return display === "absolute" ? t.updatedAbsolute(absolute) : t.updatedBoth(absolute, relative)
|
|
907
|
+
}
|