@squadbase/vantage 0.6.2-dev1.48b34e1 → 0.6.2-dev1.51e401a

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.
Files changed (71) hide show
  1. package/dist/{chunk-57GHR5LN.js → chunk-3RJEOP3J.js} +29 -18
  2. package/dist/chunk-3RJEOP3J.js.map +1 -0
  3. package/dist/{chunk-QOXXOFGL.js → chunk-77CISBMG.js} +994 -464
  4. package/dist/chunk-77CISBMG.js.map +1 -0
  5. package/dist/{chunk-JXFCSF3K.js → chunk-EG4PX726.js} +12 -7
  6. package/dist/chunk-EG4PX726.js.map +1 -0
  7. package/dist/{chunk-2N72FKLB.js → chunk-NZ5QIIG4.js} +33 -11
  8. package/dist/chunk-NZ5QIIG4.js.map +1 -0
  9. package/dist/{chunk-7S52M2SL.js → chunk-QNOX7WLZ.js} +3 -3
  10. package/dist/{chunk-7S52M2SL.js.map → chunk-QNOX7WLZ.js.map} +1 -1
  11. package/dist/{chunk-PDOBHMUQ.js → chunk-ROHNXP7J.js} +72 -3
  12. package/dist/chunk-ROHNXP7J.js.map +1 -0
  13. package/dist/{chunk-6OTTGAPO.js → chunk-TRMHNMMZ.js} +24 -7
  14. package/dist/chunk-TRMHNMMZ.js.map +1 -0
  15. package/dist/components/index.d.ts +49 -29
  16. package/dist/components/index.js +5 -5
  17. package/dist/{delta-text-CvwBGJ95.d.ts → delta-text-CbrOaW83.d.ts} +4 -2
  18. package/dist/doc/index.d.ts +14 -14
  19. package/dist/doc/index.js +198 -84
  20. package/dist/doc/index.js.map +1 -1
  21. package/dist/{format-U0cQl069.d.ts → format-DihQkL1N.d.ts} +17 -11
  22. package/dist/slide/index.d.ts +2 -2
  23. package/dist/slide/index.js +95 -44
  24. package/dist/slide/index.js.map +1 -1
  25. package/dist/table/index.js +6 -6
  26. package/dist/ui/index.d.ts +1 -1
  27. package/dist/ui/index.js +5 -5
  28. package/package.json +2 -2
  29. package/registry/ui/app-shell.tsx +138 -1
  30. package/registry/ui/area-chart.tsx +344 -85
  31. package/registry/ui/attention-list.tsx +177 -15
  32. package/registry/ui/bar-chart.tsx +341 -85
  33. package/registry/ui/bar-list.tsx +332 -77
  34. package/registry/ui/box-plot-chart.tsx +405 -97
  35. package/registry/ui/category-bar.tsx +248 -40
  36. package/registry/ui/chart-frame.tsx +257 -46
  37. package/registry/ui/data-table.tsx +229 -37
  38. package/registry/ui/date-range-picker.tsx +190 -36
  39. package/registry/ui/delta.tsx +171 -12
  40. package/registry/ui/donut-chart.tsx +339 -84
  41. package/registry/ui/echart.tsx +142 -4
  42. package/registry/ui/filter-bar.tsx +179 -20
  43. package/registry/ui/funnel-steps.tsx +354 -80
  44. package/registry/ui/heatmap.tsx +293 -52
  45. package/registry/ui/histogram-chart.tsx +395 -97
  46. package/registry/ui/kpi-card.tsx +245 -44
  47. package/registry/ui/legend.tsx +330 -76
  48. package/registry/ui/line-chart.tsx +344 -85
  49. package/registry/ui/multi-select.tsx +159 -9
  50. package/registry/ui/page-shell.tsx +141 -3
  51. package/registry/ui/paired-chart.tsx +349 -90
  52. package/registry/ui/progress-circle.tsx +205 -39
  53. package/registry/ui/sample-data.tsx +153 -9
  54. package/registry/ui/scatter-chart.tsx +391 -100
  55. package/registry/ui/searchable-select.tsx +145 -7
  56. package/registry/ui/states.tsx +170 -13
  57. package/registry/ui/status-badge.tsx +193 -28
  58. package/registry/ui/tracker.tsx +205 -39
  59. package/registry/ui/trend-indicator.tsx +149 -6
  60. package/registry/ui/updated-at.tsx +170 -16
  61. package/registry/ui/waterfall-chart.tsx +415 -102
  62. package/skills/vantage-add-feature/SKILL.md +5 -0
  63. package/templates/AGENTS.md +10 -8
  64. package/templates/agents/api.md +7 -0
  65. package/templates/agents/page.md +36 -0
  66. package/dist/chunk-2N72FKLB.js.map +0 -1
  67. package/dist/chunk-57GHR5LN.js.map +0 -1
  68. package/dist/chunk-6OTTGAPO.js.map +0 -1
  69. package/dist/chunk-JXFCSF3K.js.map +0 -1
  70. package/dist/chunk-PDOBHMUQ.js.map +0 -1
  71. package/dist/chunk-QOXXOFGL.js.map +0 -1
@@ -91,7 +91,7 @@ import {
91
91
  type PeriodGrain,
92
92
  type Polarity,
93
93
  } from "@squadbase/vantage/components"
94
- import { DEFAULT_LOCALE, EMPTY_VALUE, formatDate, formatDatesInText, formatDelta, formatKind, formatPoints, formatValue, type ValueFormat } from "@squadbase/vantage/components"
94
+ import { EMPTY_VALUE, formatDate, formatDatesInText, formatDelta, formatKind, formatPoints, formatValue, type ValueFormat } from "@squadbase/vantage/components"
95
95
  import { computeTargetProgress, detectRecord, inferPeriodGrain, type KpiPeriod } from "@squadbase/vantage/components"
96
96
  import { formatRelativeTime } from "@squadbase/vantage/components"
97
97
  import { Sparkline } from "@squadbase/vantage/components"
@@ -100,6 +100,140 @@ import { Card, CardAction, CardDescription, CardFooter, CardHeader, CardTitle }
100
100
  import { Skeleton } from "@squadbase/vantage/ui"
101
101
  import { Delta } from "@squadbase/vantage/components"
102
102
  import { SampleBadge, useSampleMark } from "@squadbase/vantage/components"
103
+ import { useSyncExternalStore } from "react"
104
+
105
+ /* ---------- inlined from @squadbase/squadkit registry/lib/locale.ts (internal to @squadbase/vantage) ---------- */
106
+
107
+ /**
108
+ * The display language of the parts and the defaults that follow it.
109
+ *
110
+ * The language comes from `<html lang>`: a tag starting with `ja` reads Japanese, anything else English.
111
+ * Without a usable `lang` (missing, invalid, or no `document` — SSR, Node) the locale is `FALLBACK_LOCALE`
112
+ * (`en-US`). A `locale` passed explicitly (a prop, an argument) always wins.
113
+ *
114
+ * Parts read the locale through `useLocale` (`hooks/use-locale`) and pass it down; the pure helpers here
115
+ * and in `format` / `comparison` / `chart-*` take it as an argument and fall back to `documentLocale()`.
116
+ */
117
+
118
+ /** The language of the built-in wording: Japanese or English. */
119
+ type UiLanguage = "ja" | "en"
120
+
121
+ /** The locale used when `<html lang>` gives none (and on the server). */
122
+ const FALLBACK_LOCALE = "en-US"
123
+
124
+ /** `"ja"` when `locale` starts with `ja` (`ja`, `ja-JP`), else `"en"`. A missing locale is `"en"`. */
125
+ function uiLanguage(locale: string | null | undefined): UiLanguage {
126
+ return typeof locale === "string" && locale.trim().toLowerCase().startsWith("ja") ? "ja" : "en"
127
+ }
128
+
129
+ /** A BCP 47 tag in its canonical form (`en-gb` → `en-GB`), or `FALLBACK_LOCALE` when it is empty or invalid. */
130
+ function normalizeLocale(tag: string | null | undefined): string {
131
+ const value = tag?.trim()
132
+ if (!value) return FALLBACK_LOCALE
133
+ try {
134
+ return Intl.getCanonicalLocales(value)[0] ?? FALLBACK_LOCALE
135
+ } catch {
136
+ return FALLBACK_LOCALE
137
+ }
138
+ }
139
+
140
+ /**
141
+ * The locale of the page: `document.documentElement.lang`, canonicalized. `FALLBACK_LOCALE` when it is empty or
142
+ * invalid, or there is no `document`. Parts read it through `useLocale`, never while rendering.
143
+ */
144
+ function documentLocale(): string {
145
+ if (typeof document === "undefined") return FALLBACK_LOCALE
146
+ return normalizeLocale(document.documentElement?.getAttribute("lang"))
147
+ }
148
+
149
+ /** `locale` when given, else the page's locale (`documentLocale()`). */
150
+ function resolveLocale(locale?: string | null): string {
151
+ return locale || documentLocale()
152
+ }
153
+
154
+ /** Picks the wording table of the locale's language. */
155
+ function pickLocale<T>(table: { ja: T; en: T }, locale?: string | null): T {
156
+ return table[uiLanguage(resolveLocale(locale))]
157
+ }
158
+
159
+ /** The currency `format="currency"` uses without a `currency`: JPY for Japanese, USD otherwise. */
160
+ function defaultCurrency(locale?: string | null): string {
161
+ return uiLanguage(resolveLocale(locale)) === "ja" ? "JPY" : "USD"
162
+ }
163
+
164
+ /** The month a fiscal year starts in (1–12) when none is given: April for Japanese, January otherwise. */
165
+ function defaultFiscalYearStartMonth(locale?: string | null): number {
166
+ return uiLanguage(resolveLocale(locale)) === "ja" ? 4 : 1
167
+ }
168
+
169
+ /** A day of the week, 0 = Sunday … 6 = Saturday (the `Date#getDay` / date-fns numbering). */
170
+ type WeekDay = 0 | 1 | 2 | 3 | 4 | 5 | 6
171
+
172
+ /** Locales whose week starts on Sunday when the runtime has no week data. */
173
+ const SUNDAY_FIRST = new Set(["en", "en-US", "en-CA"])
174
+
175
+ /**
176
+ * The first day of the week: Monday for Japanese; otherwise the locale's convention from `Intl.Locale`
177
+ * (`en-US` Sunday, `en-GB` Monday). Without week data in the runtime: Sunday for `en` / `en-US` / `en-CA`,
178
+ * Monday for the rest.
179
+ */
180
+ function weekStartsOn(locale?: string | null): WeekDay {
181
+ const tag = normalizeLocale(resolveLocale(locale))
182
+ if (uiLanguage(tag) === "ja") return 1
183
+ try {
184
+ const info = new Intl.Locale(tag) as Intl.Locale & {
185
+ getWeekInfo?: () => { firstDay: number }
186
+ weekInfo?: { firstDay: number }
187
+ }
188
+ const firstDay = info.getWeekInfo?.().firstDay ?? info.weekInfo?.firstDay
189
+ if (typeof firstDay === "number" && firstDay >= 1 && firstDay <= 7) return (firstDay % 7) as WeekDay
190
+ } catch {
191
+ // Fall through to the table below.
192
+ }
193
+ return SUNDAY_FIRST.has(tag) ? 0 : 1
194
+ }
195
+
196
+ /* ---------- inlined from @squadbase/squadkit registry/hooks/use-locale.ts (internal to @squadbase/vantage) ---------- */
197
+
198
+ const listeners = new Set<() => void>()
199
+ let observer: MutationObserver | null = null
200
+
201
+ /** One observer on `<html lang>` for every subscribed part. */
202
+ function subscribe(onChange: () => void): () => void {
203
+ if (typeof document === "undefined" || typeof MutationObserver === "undefined") return () => {}
204
+ listeners.add(onChange)
205
+ if (!observer) {
206
+ observer = new MutationObserver(() => {
207
+ for (const listener of [...listeners]) listener()
208
+ })
209
+ observer.observe(document.documentElement, { attributes: true, attributeFilter: ["lang"] })
210
+ }
211
+ return () => {
212
+ listeners.delete(onChange)
213
+ if (listeners.size === 0 && observer) {
214
+ observer.disconnect()
215
+ observer = null
216
+ }
217
+ }
218
+ }
219
+
220
+ const serverLocale = () => FALLBACK_LOCALE
221
+
222
+ /**
223
+ * The page's locale (`<html lang>`), following changes made while the page runs. On the server and during
224
+ * hydration it is `FALLBACK_LOCALE` (`en-US`), so the markup matches; it switches right after.
225
+ */
226
+ function useDocumentLocale(): string {
227
+ return useSyncExternalStore(subscribe, documentLocale, serverLocale)
228
+ }
229
+
230
+ /** The locale a part renders in: its `locale` prop when given, else the page's (`useDocumentLocale`). */
231
+ function useLocale(locale?: string | null): string {
232
+ const page = useDocumentLocale()
233
+ return locale || page
234
+ }
235
+
236
+ /* ---------- end of inlined code ---------- */
103
237
 
104
238
  export interface KpiCardSource {
105
239
  /** Period the data covers (e.g. "2023-04 以降"). ISO year-months / dates in it are written by the card ("2023年4月 以降"). */
@@ -125,11 +259,11 @@ export interface KpiCardProps extends Omit<ComponentProps<"div">, "children"> {
125
259
  value: number | null | undefined
126
260
  /**
127
261
  * Value format, also used for previous / target / remaining. Default `"compact"`.
128
- * `"currency"` is JPY in 万・億; `"percent"` takes a ratio (0.021 → 2.1%) and
262
+ * `"currency"` is JPY in 万・億 on a ja page (USD in K・M・B otherwise); `"percent"` takes a ratio (0.021 → 2.1%) and
129
263
  * switches the delta to points. See `formatValue` for the full table.
130
264
  */
131
265
  format?: ValueFormat
132
- /** Default `"ja-JP"`. */
266
+ /** Locale of the numbers and the built-in wording. Default: the page's (`<html lang>`, else `"en-US"`). */
133
267
  locale?: string
134
268
  /**
135
269
  * What the value is compared against; drives the delta (tier 3) and previous line (tier 4).
@@ -245,7 +379,72 @@ const KPI_FOOTER_CLASS = "flex-col items-start gap-2 rounded-none border-t-0 bg-
245
379
 
246
380
  const PLACEHOLDER = /\{(\w+)\}/g
247
381
 
248
- const isJapanese = (locale: string) => locale.toLowerCase().startsWith("ja")
382
+ const TEXT = {
383
+ ja: {
384
+ target: "目標",
385
+ provisional: "速報",
386
+ joiner: "・",
387
+ countFrom: (count: string) => `${count}件から集計`,
388
+ excluded: (count: string) => `${count}件を除外`,
389
+ noBaseline: (baseline: string) => `${baseline}の実績がないため、比較できません。`,
390
+ previous: (baseline: string, value: string) => `${baseline} ${value}`,
391
+ noPrevious: (baseline: string) => `${baseline}の実績なし`,
392
+ barLead: (tl: string, pct: string) => `${tl}の ${pct}`,
393
+ pace: (pct: string) => `このペースなら ${pct}`,
394
+ provisionalSuffix: "(確定前)",
395
+ provisionalReached: (tl: string, target: string) => `${tl} ${target} に到達(確定前)`,
396
+ provisionalSoFar: (tl: string, target: string, pct: string) => `${tl} ${target} に対して現時点 ${pct}(確定前)`,
397
+ provisionalToGo: (tl: string, target: string, distance: string) => `${tl} ${target} まであと ${distance}(確定前)`,
398
+ provisionalToGoRate: (tl: string, distance: string, pct: string) => `${tl}まであと ${distance}(現時点 ${pct}・確定前)`,
399
+ met: (tl: string, target: string) => `${tl} ${target} を達成`,
400
+ toGo: (tl: string, target: string, distance: string) => `${tl} ${target} まであと ${distance}`,
401
+ reached: (tl: string, target: string) => `${tl} ${target} に到達`,
402
+ reachedRate: (tl: string, target: string, pct: string) => `${tl} ${target} に到達(${pct} 達成)`,
403
+ toGoRate: (tl: string, distance: string, pct: string) => `${tl}まであと ${distance}(${pct} 達成)`,
404
+ progressLabel: (tl: string) => `${tl}に対する進捗`,
405
+ progressValue: (tl: string, target: string, value: string, pct: string) => `${tl} ${target} に対して ${value}(${pct})`,
406
+ history: (label: string, points: number) => `${label}の推移(直近 ${points} 点)`,
407
+ updatedRelative: (relative: string) => `${relative}に更新`,
408
+ updatedAbsolute: (absolute: string) => `${absolute} に更新`,
409
+ updatedBoth: (absolute: string, relative: string) => `${absolute} に更新(${relative})`,
410
+ summaryExample: "{label}は目標の {achievement} まで来ています。",
411
+ },
412
+ en: {
413
+ target: "Target",
414
+ provisional: "Preliminary",
415
+ joiner: " · ",
416
+ countFrom: (count: string) => `Based on ${count} records`,
417
+ excluded: (count: string) => `${count} excluded`,
418
+ noBaseline: (baseline: string) => `No ${baseline} data to compare against.`,
419
+ previous: (baseline: string, value: string) => `${capitalize(baseline)}: ${value}`,
420
+ noPrevious: (baseline: string) => `No ${baseline} data`,
421
+ barLead: (tl: string, pct: string) => `${tl}: ${pct}`,
422
+ pace: (pct: string) => `On pace for ${pct}`,
423
+ provisionalSuffix: " (preliminary)",
424
+ provisionalReached: (tl: string, target: string) => `${tl} ${target}: reached (preliminary)`,
425
+ provisionalSoFar: (tl: string, target: string, pct: string) => `${tl} ${target}: ${pct} so far (preliminary)`,
426
+ provisionalToGo: (tl: string, target: string, distance: string) => `${tl} ${target}: ${distance} to go (preliminary)`,
427
+ provisionalToGoRate: (tl: string, distance: string, pct: string) => `${tl}: ${distance} to go (${pct} so far, preliminary)`,
428
+ met: (tl: string, target: string) => `${tl} ${target}: met`,
429
+ toGo: (tl: string, target: string, distance: string) => `${tl} ${target}: ${distance} to go`,
430
+ reached: (tl: string, target: string) => `${tl} ${target}: reached`,
431
+ reachedRate: (tl: string, target: string, pct: string) => `${tl} ${target}: reached (${pct} achieved)`,
432
+ toGoRate: (tl: string, distance: string, pct: string) => `${tl}: ${distance} to go (${pct} achieved)`,
433
+ progressLabel: (tl: string) => `Progress toward ${tl}`,
434
+ progressValue: (tl: string, target: string, value: string, pct: string) => `${value} of ${tl} ${target} (${pct})`,
435
+ history: (label: string, points: number) => `${label} trend (last ${points} points)`,
436
+ updatedRelative: (relative: string) => `Updated ${relative}`,
437
+ updatedAbsolute: (absolute: string) => `Updated ${absolute}`,
438
+ updatedBoth: (absolute: string, relative: string) => `Updated ${absolute} (${relative})`,
439
+ summaryExample: "{label} is at {achievement} of target.",
440
+ },
441
+ } satisfies Record<UiLanguage, unknown>
442
+
443
+ type KpiText = (typeof TEXT)[UiLanguage]
444
+
445
+ function capitalize(text: string): string {
446
+ return text.charAt(0).toUpperCase() + text.slice(1)
447
+ }
249
448
 
250
449
  const isMissing = (v: string | null | undefined) => v === null || v === undefined || v === ""
251
450
 
@@ -306,11 +505,11 @@ function isProduction(): boolean {
306
505
  }
307
506
  }
308
507
 
309
- function warnShallowSummary(template: string) {
508
+ function warnShallowSummary(template: string, t: KpiText) {
310
509
  if (shallowSummaryWarned || isProduction() || !isShallowSummary(template)) return
311
510
  shallowSummaryWarned = true
312
511
  console.warn(
313
- `[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. "{label}は目標の {achievement} まで来ています。").`,
512
+ `[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}").`,
314
513
  )
315
514
  }
316
515
 
@@ -323,7 +522,7 @@ export function KpiCard({
323
522
  label,
324
523
  value,
325
524
  format,
326
- locale = DEFAULT_LOCALE,
525
+ locale: localeProp,
327
526
  comparison: comparisonProp,
328
527
  comparisons,
329
528
  provisional,
@@ -334,7 +533,7 @@ export function KpiCard({
334
533
  showPrevious = true,
335
534
  summary,
336
535
  target,
337
- targetLabel = "目標",
536
+ targetLabel,
338
537
  targetDisplay,
339
538
  period,
340
539
  history,
@@ -350,10 +549,12 @@ export function KpiCard({
350
549
  className,
351
550
  ...props
352
551
  }: KpiCardProps) {
552
+ const locale = useLocale(localeProp)
553
+ const t = TEXT[uiLanguage(locale)]
353
554
  const allComparisons = [...(comparisonProp ? [comparisonProp] : []), ...(comparisons ?? [])]
354
555
  const comparison = allComparisons[0]
355
556
  const isProvisional = provisional !== undefined && provisional !== false
356
- const provisionalLabel = typeof provisional === "object" && provisional.label ? provisional.label : "速報"
557
+ const provisionalLabel = typeof provisional === "object" && provisional.label ? provisional.label : t.provisional
357
558
  const sample = useSampleMark()
358
559
  // `betterWhen` is the documented name and wins over the older `polarity`.
359
560
  const polarity: Polarity | undefined = betterWhen ? (betterWhen === "down" ? "lower-is-better" : "higher-is-better") : polarityProp
@@ -363,15 +564,13 @@ export function KpiCard({
363
564
  const stacked = allComparisons.length > 1
364
565
  const hasTarget = target !== undefined
365
566
  const grain = comparison?.grain ?? historyGrain ?? inferPeriodGrain(period)
366
- const record = isProvisional || neutral ? null : detectRecord(history, historyGrain ?? grain, polarity)
567
+ const record = isProvisional || neutral ? null : detectRecord(history, historyGrain ?? grain, polarity, locale)
367
568
  const sourceParts = source
368
569
  ? [
369
570
  source.period !== undefined ? formatDatesInText(source.period, locale) : undefined,
370
- source.count !== undefined ? `${formatValue(source.count, "number", locale)}件から集計` : undefined,
571
+ source.count !== undefined ? t.countFrom(formatValue(source.count, "number", locale)) : undefined,
371
572
  source.excluded !== undefined && Number.isFinite(source.excluded) && source.excluded > 0
372
- ? isJapanese(locale)
373
- ? `${formatValue(source.excluded, "number", locale)}件を除外`
374
- : `${formatValue(source.excluded, "number", locale)} excluded`
573
+ ? t.excluded(formatValue(source.excluded, "number", locale))
375
574
  : undefined,
376
575
  source.name,
377
576
  source.note,
@@ -386,7 +585,7 @@ export function KpiCard({
386
585
  const lower = polarity === "lower-is-better"
387
586
  // The bar reads "how far toward the target"; a rate (points) or a lower-is-better target has no such fill.
388
587
  const targetBar = hasTarget && !neutral && !byPoints && !lower
389
- if (summary !== undefined) warnShallowSummary(summary)
588
+ if (summary !== undefined) warnShallowSummary(summary, t)
390
589
  // Two rows of the parent grid (body, footer), shared by subgrid; a plain two-row grid when standalone.
391
590
  const cardClass = cn("row-span-2 grid content-start gap-y-0 grid-rows-subgrid", className)
392
591
  const valueSize = small ? "text-xl @[218px]/card:text-2xl" : "text-2xl @[218px]/card:text-4xl"
@@ -449,14 +648,14 @@ export function KpiCard({
449
648
  /** An unsigned distance in the metric's unit — points for a rate. */
450
649
  const fmtDistance = (v: number) => (isRate ? formatPoints(v, locale, { signDisplay: "never" }) : fmt(v))
451
650
  const pct = (v: number) => formatValue(v, { type: "percent", digits: 0 }, locale)
452
- const tl = targetLabel
651
+ const tl = targetLabel ?? t.target
453
652
  const display = change ?? defaultChangeDisplay(format)
454
653
  // Value digits only carry over to a change in points; a relative change keeps its own fixed digits.
455
654
  const digits =
456
655
  display === "points" && typeof format === "object" && format !== null && "digits" in format ? format.digits : undefined
457
656
  const graded = comparison ? { ...comparison, grain } : undefined
458
- const result = graded ? computeComparison(value, graded, { polarity, neutralBand }) : null
459
- const baseline = graded ? comparisonBaseline(graded.vs, grain, graded.label, graded.toDate) : null
657
+ const result = graded ? computeComparison(value, graded, { polarity, neutralBand, locale }) : null
658
+ const baseline = graded ? comparisonBaseline(graded.vs, grain, graded.label, graded.toDate, locale) : null
460
659
  const progress = hasTarget && !neutral ? computeTargetProgress(value, target, { period, now, polarity }) : null
461
660
  // A linear pace only makes sense for an accumulating total, not for a rate.
462
661
  const projectedValue =
@@ -484,7 +683,7 @@ export function KpiCard({
484
683
  : null,
485
684
  remaining: progress ? fmtDistance(progress.remaining) : null,
486
685
  },
487
- result && result.direction === "none" && baseline ? `${baseline}の実績がないため、比較できません。` : null,
686
+ result && result.direction === "none" && baseline ? t.noBaseline(baseline) : null,
488
687
  )
489
688
  : null
490
689
 
@@ -496,9 +695,9 @@ export function KpiCard({
496
695
  const projected = projectedValue !== null && projectedValue > actual ? projectedValue : actual
497
696
  const scale = Math.max(target as number, actual, projected)
498
697
  const at = (v: number) => (scale > 0 ? (v / scale) * 100 : 0)
499
- const parts = [`${tl}の ${pct(progress!.achievement)}`]
698
+ const parts = [t.barLead(tl, pct(progress!.achievement))]
500
699
  if (projectedValue !== null && progress!.projected != null)
501
- parts.push(`このペースなら ${formatValue(progress!.projected, { type: "percent", digits: 0 }, locale)}`)
700
+ parts.push(t.pace(formatValue(progress!.projected, { type: "percent", digits: 0 }, locale)))
502
701
  return {
503
702
  actualAt: at(actual),
504
703
  projectedAt: at(projected),
@@ -514,23 +713,23 @@ export function KpiCard({
514
713
  else if (isProvisional)
515
714
  targetText = progress.achieved
516
715
  ? byPoints
517
- ? `${tl} ${fmt(target)} に到達(確定前)`
518
- : `${tl} ${fmt(target)} に対して現時点 ${pct(progress.achievement)}(確定前)`
716
+ ? t.provisionalReached(tl, fmt(target))
717
+ : t.provisionalSoFar(tl, fmt(target), pct(progress.achievement))
519
718
  : byPoints
520
- ? `${tl} ${fmt(target)} まであと ${fmtDistance(progress.remaining)}(確定前)`
521
- : `${tl}まであと ${fmtDistance(progress.remaining)}(現時点 ${pct(progress.achievement)}・確定前)`
719
+ ? t.provisionalToGo(tl, fmt(target), fmtDistance(progress.remaining))
720
+ : t.provisionalToGoRate(tl, fmtDistance(progress.remaining), pct(progress.achievement))
522
721
  else if (lower)
523
722
  targetText = progress.achieved
524
- ? `${tl} ${fmt(target)} を達成`
525
- : `${tl} ${fmt(target)} まであと ${fmtDistance(progress.remaining)}`
723
+ ? t.met(tl, fmt(target))
724
+ : t.toGo(tl, fmt(target), fmtDistance(progress.remaining))
526
725
  else if (byPoints)
527
726
  targetText = progress.achieved
528
- ? `${tl} ${fmt(target)} に到達`
529
- : `${tl} ${fmt(target)} まであと ${fmtDistance(progress.remaining)}`
727
+ ? t.reached(tl, fmt(target))
728
+ : t.toGo(tl, fmt(target), fmtDistance(progress.remaining))
530
729
  else
531
730
  targetText = progress.achieved
532
- ? `${tl} ${fmt(target)} に到達(${pct(progress.achievement)} 達成)`
533
- : `${tl}まであと ${fmtDistance(progress.remaining)}(${pct(progress.achievement)} 達成)`
731
+ ? t.reachedRate(tl, fmt(target), pct(progress.achievement))
732
+ : t.toGoRate(tl, fmtDistance(progress.remaining), pct(progress.achievement))
534
733
  }
535
734
 
536
735
  const hasFooter =
@@ -595,7 +794,7 @@ export function KpiCard({
595
794
  ))}
596
795
  {hasPrevious && comparison && (
597
796
  <div data-slot="kpi-card-previous" className="col-span-full text-sm text-muted-foreground tabular-nums">
598
- {result?.previous != null ? `${baseline} ${fmt(result.previous)}` : `${baseline}の実績なし`}
797
+ {result?.previous != null ? t.previous(baseline ?? "", fmt(result.previous)) : t.noPrevious(baseline ?? "")}
599
798
  </div>
600
799
  )}
601
800
  {bar && progress && (
@@ -603,11 +802,11 @@ export function KpiCard({
603
802
  <div
604
803
  data-slot="kpi-card-target-bar"
605
804
  role="progressbar"
606
- aria-label={`${tl}に対する進捗`}
805
+ aria-label={t.progressLabel(tl)}
607
806
  aria-valuemin={0}
608
807
  aria-valuemax={target}
609
808
  aria-valuenow={Math.min(Math.max(value as number, 0), target as number)}
610
- aria-valuetext={`${tl} ${fmt(target)} に対して ${fmt(value)}(${pct(progress.achievement)})`}
809
+ aria-valuetext={t.progressValue(tl, fmt(target), fmt(value), pct(progress.achievement))}
611
810
  className="relative h-2 w-full overflow-hidden rounded-full bg-muted"
612
811
  >
613
812
  <div className="absolute inset-y-0 left-0 bg-chart-1" style={{ width: `${bar.actualAt}%` }} />
@@ -626,16 +825,17 @@ export function KpiCard({
626
825
  </div>
627
826
  <p
628
827
  data-slot="kpi-card-target"
629
- title={[bar.lead, bar.pace].filter(Boolean).join("・")}
828
+ title={[bar.lead, bar.pace].filter(Boolean).join(t.joiner)}
630
829
  className="truncate text-sm text-muted-foreground tabular-nums"
631
830
  >
632
831
  {bar.lead}
633
832
  {bar.pace && (
634
833
  <>
635
- ・<span data-slot="kpi-card-projection" data-estimate="">{bar.pace}</span>
834
+ {t.joiner}
835
+ <span data-slot="kpi-card-projection" data-estimate="">{bar.pace}</span>
636
836
  </>
637
837
  )}
638
- {isProvisional && "(確定前)"}
838
+ {isProvisional && t.provisionalSuffix}
639
839
  </p>
640
840
  </div>
641
841
  )}
@@ -660,15 +860,15 @@ export function KpiCard({
660
860
  {sourceParts.length > 0 && (
661
861
  <p
662
862
  data-slot="kpi-card-source"
663
- title={sourceParts.join("・")}
863
+ title={sourceParts.join(t.joiner)}
664
864
  className="line-clamp-2 w-full text-xs leading-normal text-muted-foreground tabular-nums"
665
865
  >
666
- {sourceParts.join("・")}
866
+ {sourceParts.join(t.joiner)}
667
867
  </p>
668
868
  )}
669
869
  {hasUpdated && (
670
870
  <p data-slot="kpi-card-updated" className="text-xs leading-normal text-muted-foreground">
671
- {updatedAtText(updatedAt, updatedAtDisplay, locale, now, timeZone)}
871
+ {updatedAtText(updatedAt, updatedAtDisplay, locale, t, now, timeZone)}
672
872
  </p>
673
873
  )}
674
874
  {showSparkline && (
@@ -679,7 +879,7 @@ export function KpiCard({
679
879
  height={SPARKLINE_HEIGHT}
680
880
  color="text-chart-1"
681
881
  highlight="latest"
682
- aria-label={`${label}の推移(直近 ${historyPoints.length} 点)`}
882
+ aria-label={t.history(label, historyPoints.length)}
683
883
  />
684
884
  </div>
685
885
  )}
@@ -689,16 +889,17 @@ export function KpiCard({
689
889
  )
690
890
  }
691
891
 
692
- /** 「3 分前に更新」「2026/10/20 14:30 に更新」「2026/10/20 14:30 に更新(3 分前)」。 */
892
+ /** 「3 分前に更新」「2026/10/20 14:30 に更新」「2026/10/20 14:30 に更新(3 分前)」(英語は "Updated 3 minutes ago" など)。 */
693
893
  function updatedAtText(
694
894
  updatedAt: Date | string | number,
695
895
  display: "relative" | "absolute" | "both",
696
896
  locale: string,
897
+ t: KpiText,
697
898
  now: Date | number | undefined,
698
899
  timeZone?: string,
699
900
  ): string {
700
901
  const relative = formatRelativeTime(updatedAt, { now, locale })
701
- if (display === "relative") return `${relative}に更新`
902
+ if (display === "relative") return t.updatedRelative(relative)
702
903
  const absolute = formatDate(updatedAt, "datetime", locale, { timeZone })
703
- return display === "absolute" ? `${absolute} に更新` : `${absolute} に更新(${relative})`
904
+ return display === "absolute" ? t.updatedAbsolute(absolute) : t.updatedBoth(absolute, relative)
704
905
  }