@tuturuuu/ui 0.32.0 → 0.34.0

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 (104) hide show
  1. package/CHANGELOG.md +62 -0
  2. package/biome.json +1 -1
  3. package/package.json +53 -51
  4. package/src/components/ui/calendar-app/components/calendar-sync-attention-button.tsx +58 -24
  5. package/src/components/ui/calendar-app/components/calendar-sync-recovery.test.tsx +57 -0
  6. package/src/components/ui/calendar-app/components/calendar-sync-recovery.tsx +28 -14
  7. package/src/components/ui/calendar-app/components/refresh-calendar-sync-status.test.tsx +321 -0
  8. package/src/components/ui/calendar-app/components/refresh-calendar-sync-status.ts +182 -0
  9. package/src/components/ui/calendar-app/components/use-calendar-connections-manager.ts +12 -14
  10. package/src/components/ui/chat/ai-message-parts.test.tsx +47 -0
  11. package/src/components/ui/chat/ai-message-parts.tsx +23 -16
  12. package/src/components/ui/chat/ai-message-render-utils.ts +19 -1
  13. package/src/components/ui/commercial-comparison-style.ts +104 -0
  14. package/src/components/ui/commercial-comparison-table.tsx +159 -0
  15. package/src/components/ui/commercial-comparison-toolbar.tsx +205 -0
  16. package/src/components/ui/commercial-comparison.test.tsx +86 -0
  17. package/src/components/ui/commercial-comparison.tsx +224 -0
  18. package/src/components/ui/comparison-value.test.tsx +32 -0
  19. package/src/components/ui/comparison-value.tsx +72 -0
  20. package/src/components/ui/custom/__tests__/report-preview-pagination-progress.test.ts +129 -0
  21. package/src/components/ui/custom/__tests__/report-preview-viewport.test.tsx +56 -0
  22. package/src/components/ui/custom/account-assurance-refresh.test.tsx +42 -0
  23. package/src/components/ui/custom/account-assurance-refresh.tsx +30 -0
  24. package/src/components/ui/custom/education/modules/resources/pdf-viewer.tsx +13 -2
  25. package/src/components/ui/custom/report-preview-pagination.ts +5 -3
  26. package/src/components/ui/custom/report-preview-viewport.tsx +40 -0
  27. package/src/components/ui/custom/report-preview.tsx +2 -3
  28. package/src/components/ui/finance/categories-tags-tabs.tsx +3 -0
  29. package/src/components/ui/finance/invoices/charts/subscription-payment-chart.tsx +137 -0
  30. package/src/components/ui/finance/invoices/charts/subscription-payment-details.tsx +111 -0
  31. package/src/components/ui/finance/invoices/columns.tsx +2 -0
  32. package/src/components/ui/finance/invoices/components/invoice-checkout-summary.tsx +6 -3
  33. package/src/components/ui/finance/invoices/components/invoice-data-state.test.tsx +27 -0
  34. package/src/components/ui/finance/invoices/components/invoice-data-state.tsx +34 -0
  35. package/src/components/ui/finance/invoices/components/subscription-group-selector.tsx +29 -6
  36. package/src/components/ui/finance/invoices/export-data.test.ts +121 -0
  37. package/src/components/ui/finance/invoices/export-data.ts +229 -0
  38. package/src/components/ui/finance/invoices/export-dialog-content.test.tsx +245 -0
  39. package/src/components/ui/finance/invoices/export-dialog-content.tsx +108 -292
  40. package/src/components/ui/finance/invoices/export-pagination.test.ts +131 -0
  41. package/src/components/ui/finance/invoices/export-pagination.ts +66 -0
  42. package/src/components/ui/finance/invoices/hooks/use-invoice-customer-search.test.tsx +89 -0
  43. package/src/components/ui/finance/invoices/hooks/use-invoice-customer-search.ts +117 -0
  44. package/src/components/ui/finance/invoices/hooks/use-subscription-auto-selection.test.tsx +255 -0
  45. package/src/components/ui/finance/invoices/hooks/use-subscription-auto-selection.ts +6 -92
  46. package/src/components/ui/finance/invoices/hooks/use-subscription-invoice-content.test.tsx +93 -0
  47. package/src/components/ui/finance/invoices/hooks/use-subscription-invoice-content.ts +11 -15
  48. package/src/components/ui/finance/invoices/hooks.ts +5 -110
  49. package/src/components/ui/finance/invoices/internal-api.test.ts +122 -0
  50. package/src/components/ui/finance/invoices/internal-api.ts +19 -1
  51. package/src/components/ui/finance/invoices/invoice-analytics.tsx +24 -1
  52. package/src/components/ui/finance/invoices/invoice-discount.test.tsx +107 -0
  53. package/src/components/ui/finance/invoices/invoice-discount.ts +12 -0
  54. package/src/components/ui/finance/invoices/invoice-history-filters.tsx +121 -0
  55. package/src/components/ui/finance/invoices/invoice-history-pagination.tsx +81 -0
  56. package/src/components/ui/finance/invoices/invoice-history-row.tsx +159 -0
  57. package/src/components/ui/finance/invoices/invoice-history.test.tsx +170 -0
  58. package/src/components/ui/finance/invoices/invoice-history.tsx +300 -0
  59. package/src/components/ui/finance/invoices/invoice-page.tsx +18 -2
  60. package/src/components/ui/finance/invoices/invoiceId/full-invoice-template.tsx +3 -7
  61. package/src/components/ui/finance/invoices/invoiceId/invoice-details-page.test.tsx +16 -0
  62. package/src/components/ui/finance/invoices/invoiceId/invoice-details-page.tsx +9 -0
  63. package/src/components/ui/finance/invoices/invoices-table.tsx +3 -0
  64. package/src/components/ui/finance/invoices/promotion-form.tsx +5 -4
  65. package/src/components/ui/finance/invoices/query-invalidation.test.ts +28 -0
  66. package/src/components/ui/finance/invoices/query-invalidation.ts +3 -0
  67. package/src/components/ui/finance/invoices/row-actions.tsx +35 -4
  68. package/src/components/ui/finance/invoices/standard-invoice.tsx +27 -16
  69. package/src/components/ui/finance/invoices/subscription-coverage.test.ts +74 -0
  70. package/src/components/ui/finance/invoices/subscription-coverage.ts +109 -0
  71. package/src/components/ui/finance/invoices/subscription-invoice-props.ts +20 -0
  72. package/src/components/ui/finance/invoices/subscription-invoice.test.tsx +71 -4
  73. package/src/components/ui/finance/invoices/subscription-invoice.tsx +47 -51
  74. package/src/components/ui/finance/invoices/subscription-payment-analytics.test.tsx +168 -0
  75. package/src/components/ui/finance/invoices/subscription-payment-analytics.tsx +253 -0
  76. package/src/components/ui/finance/invoices/utils.ts +48 -106
  77. package/src/components/ui/finance/shared/charts/category-breakdown-chart-body.tsx +73 -80
  78. package/src/components/ui/finance/shared/charts/category-breakdown-chart-controls.tsx +3 -1
  79. package/src/components/ui/finance/shared/charts/category-breakdown-chart-legend.test.tsx +30 -0
  80. package/src/components/ui/finance/shared/charts/category-breakdown-chart-legend.tsx +31 -24
  81. package/src/components/ui/finance/shared/charts/category-breakdown-chart-tooltip.tsx +9 -6
  82. package/src/components/ui/finance/shared/charts/category-breakdown-chart-types.ts +2 -0
  83. package/src/components/ui/finance/shared/charts/category-breakdown-chart-utils.test.ts +59 -0
  84. package/src/components/ui/finance/shared/charts/category-breakdown-chart-utils.ts +51 -37
  85. package/src/components/ui/finance/shared/charts/category-breakdown-chart.tsx +37 -7
  86. package/src/components/ui/legacy/calendar/all-day-event-bar.tsx +5 -5
  87. package/src/components/ui/legacy/calendar/location-timeline.tsx +2 -14
  88. package/src/components/ui/legacy/calendar/working-location.test.ts +47 -0
  89. package/src/components/ui/legacy/calendar/working-location.ts +35 -0
  90. package/src/components/ui/public-workspace-prices.tsx +13 -0
  91. package/src/components/ui/pwa-status.test.tsx +13 -2
  92. package/src/components/ui/pwa-status.tsx +15 -3
  93. package/src/components/ui/text-editor/editor-readonly-transition.test.tsx +86 -0
  94. package/src/components/ui/text-editor/editor.tsx +11 -3
  95. package/src/components/ui/text-editor/task-item-checkbox-view.test.tsx +188 -65
  96. package/src/components/ui/text-editor/task-item-checkbox-view.tsx +178 -164
  97. package/src/components/ui/text-editor/task-item-status-picker.tsx +130 -0
  98. package/src/hooks/__tests__/use-calendar-sync.test.tsx +20 -1
  99. package/src/hooks/__tests__/use-notifications-subscription.test.tsx +99 -180
  100. package/src/hooks/calendar-provider-sync.ts +33 -0
  101. package/src/hooks/notification-subscription.ts +74 -0
  102. package/src/hooks/use-calendar-sync.tsx +11 -14
  103. package/src/hooks/use-calendar.tsx +2 -2
  104. package/src/hooks/use-notifications.ts +5 -141
@@ -1,13 +1,4 @@
1
- import type { LegendPayload } from 'recharts';
2
- import {
3
- Bar,
4
- BarChart,
5
- CartesianGrid,
6
- Legend,
7
- Tooltip,
8
- XAxis,
9
- YAxis,
10
- } from 'recharts';
1
+ import { Bar, BarChart, CartesianGrid, Tooltip, XAxis, YAxis } from 'recharts';
11
2
  import type { ChartConfig } from '../../../chart';
12
3
  import { ChartContainer } from '../../../chart';
13
4
  import { CategoryBreakdownLegend } from './category-breakdown-chart-legend';
@@ -28,7 +19,7 @@ interface CategoryBreakdownChartBodyProps {
28
19
  hiddenCategories: Set<string>;
29
20
  interval: ChartInterval;
30
21
  locale: string;
31
- onToggleCategory: (entry: LegendPayload) => void;
22
+ onToggleCategory: (key: string) => void;
32
23
  }
33
24
 
34
25
  export function CategoryBreakdownChartBody({
@@ -43,76 +34,78 @@ export function CategoryBreakdownChartBody({
43
34
  onToggleCategory,
44
35
  }: CategoryBreakdownChartBodyProps) {
45
36
  return (
46
- <ChartContainer config={chartConfig} className="h-80 w-full">
47
- <BarChart data={chartData}>
48
- <CartesianGrid vertical={false} strokeDasharray="3 3" opacity={0.3} />
49
- <XAxis
50
- dataKey="period"
51
- tickLine={false}
52
- axisLine={false}
53
- tickFormatter={(value) =>
54
- formatCategoryBreakdownXAxisTick(String(value), interval, locale)
55
- }
56
- tick={{ fill: 'hsl(var(--foreground))', opacity: 0.7 }}
57
- />
58
- <YAxis
59
- tickLine={false}
60
- axisLine={false}
61
- tickFormatter={(value) =>
62
- typeof value === 'number' ? formatCompactValue(value) : value
63
- }
64
- tick={{ fill: 'hsl(var(--foreground))', opacity: 0.7 }}
65
- width={60}
66
- />
67
- <Legend
68
- content={(props) => (
69
- <CategoryBreakdownLegend
70
- hiddenCategories={hiddenCategories}
71
- onToggleCategory={onToggleCategory}
72
- payload={props.payload}
73
- />
74
- )}
75
- iconType="rect"
76
- iconSize={12}
77
- />
78
- <Tooltip
79
- cursor={{ fill: 'hsl(var(--foreground))', opacity: 0.05 }}
80
- wrapperStyle={{
81
- outline: 'none',
82
- zIndex: 100,
83
- }}
84
- contentStyle={{
85
- backgroundColor: 'transparent',
86
- border: 'none',
87
- padding: 0,
88
- boxShadow: 'none',
89
- }}
90
- content={
91
- <CategoryBreakdownTooltipContent
92
- locale={locale}
93
- interval={interval}
94
- formatValue={formatValue}
95
- categories={categories}
96
- hiddenCategories={hiddenCategories}
97
- />
98
- }
99
- />
100
- {categories.map((category) => (
101
- <Bar
102
- key={category.id || 'uncategorized'}
103
- dataKey={category.name}
104
- stackId="categories"
105
- fill={
106
- hiddenCategories.has(category.name)
107
- ? 'transparent'
108
- : category.color
37
+ <div className="grid min-w-0 gap-5 xl:grid-cols-[minmax(0,1fr)_18rem]">
38
+ <ChartContainer
39
+ config={chartConfig}
40
+ className="aspect-auto h-72 w-full min-w-0 sm:h-80"
41
+ >
42
+ <BarChart data={chartData}>
43
+ <CartesianGrid vertical={false} strokeDasharray="3 3" opacity={0.3} />
44
+ <XAxis
45
+ dataKey="period"
46
+ tickLine={false}
47
+ axisLine={false}
48
+ tickFormatter={(value) =>
49
+ formatCategoryBreakdownXAxisTick(String(value), interval, locale)
50
+ }
51
+ tick={{ fill: 'var(--foreground)', opacity: 0.7 }}
52
+ />
53
+ <YAxis
54
+ tickLine={false}
55
+ axisLine={false}
56
+ tickFormatter={(value) =>
57
+ typeof value === 'number' ? formatCompactValue(value) : value
109
58
  }
110
- radius={[0, 0, 0, 0]}
111
- maxBarSize={50}
112
- hide={hiddenCategories.has(category.name)}
59
+ tick={{ fill: 'var(--foreground)', opacity: 0.7 }}
60
+ width={60}
113
61
  />
114
- ))}
115
- </BarChart>
116
- </ChartContainer>
62
+ <Tooltip
63
+ cursor={{ fill: 'var(--foreground)', opacity: 0.05 }}
64
+ wrapperStyle={{
65
+ outline: 'none',
66
+ zIndex: 100,
67
+ }}
68
+ contentStyle={{
69
+ backgroundColor: 'transparent',
70
+ border: 'none',
71
+ padding: 0,
72
+ boxShadow: 'none',
73
+ }}
74
+ content={
75
+ <CategoryBreakdownTooltipContent
76
+ locale={locale}
77
+ interval={interval}
78
+ formatValue={formatValue}
79
+ categories={categories}
80
+ hiddenCategories={hiddenCategories}
81
+ />
82
+ }
83
+ />
84
+ {categories.map((category) => (
85
+ <Bar
86
+ key={category.key}
87
+ dataKey={category.key}
88
+ name={category.name}
89
+ stackId="categories"
90
+ fill={
91
+ hiddenCategories.has(category.key)
92
+ ? 'transparent'
93
+ : category.color
94
+ }
95
+ radius={[0, 0, 0, 0]}
96
+ maxBarSize={50}
97
+ isAnimationActive={false}
98
+ hide={hiddenCategories.has(category.key)}
99
+ />
100
+ ))}
101
+ </BarChart>
102
+ </ChartContainer>
103
+ <CategoryBreakdownLegend
104
+ categories={categories}
105
+ hiddenCategories={hiddenCategories}
106
+ onToggleCategory={onToggleCategory}
107
+ formatValue={formatValue}
108
+ />
109
+ </div>
117
110
  );
118
111
  }
@@ -50,7 +50,7 @@ export function CategoryBreakdownChartControls({
50
50
  const t = useTranslations();
51
51
 
52
52
  return (
53
- <div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
53
+ <div className="flex flex-col gap-3 xl:flex-row xl:items-center xl:justify-between">
54
54
  <div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:gap-4">
55
55
  <CardTitle className="text-base sm:text-lg">{chartTitle}</CardTitle>
56
56
  <ToggleGroup
@@ -103,6 +103,7 @@ export function CategoryBreakdownChartControls({
103
103
  onClick={onPreviousPeriod}
104
104
  className="h-7 w-7"
105
105
  title={t('finance-analytics.previous-period')}
106
+ aria-label={t('finance-analytics.previous-period')}
106
107
  >
107
108
  <ChevronLeft className="h-4 w-4" />
108
109
  </Button>
@@ -116,6 +117,7 @@ export function CategoryBreakdownChartControls({
116
117
  disabled={dateOffset === 0}
117
118
  className="h-7 w-7"
118
119
  title={t('finance-analytics.next-period')}
120
+ aria-label={t('finance-analytics.next-period')}
119
121
  >
120
122
  <ChevronRight className="h-4 w-4" />
121
123
  </Button>
@@ -0,0 +1,30 @@
1
+ import { fireEvent, render, screen } from '@testing-library/react';
2
+ import { describe, expect, it, vi } from 'vitest';
3
+ import { CategoryBreakdownLegend } from './category-breakdown-chart-legend';
4
+
5
+ describe('category legend', () => {
6
+ it('toggles a category by stable id and exposes visibility to keyboard users', () => {
7
+ const toggle = vi.fn();
8
+ render(
9
+ <CategoryBreakdownLegend
10
+ categories={[
11
+ {
12
+ key: 'a',
13
+ id: 'a',
14
+ name: 'Office',
15
+ total: 50,
16
+ color: 'var(--chart-1)',
17
+ },
18
+ ]}
19
+ hiddenCategories={new Set(['a'])}
20
+ onToggleCategory={toggle}
21
+ formatValue={() => '•••'}
22
+ />
23
+ );
24
+ const button = screen.getByRole('button', { name: 'Office •••' });
25
+ expect(button.getAttribute('aria-pressed')).toBe('false');
26
+ fireEvent.click(button);
27
+ expect(toggle).toHaveBeenCalledWith('a');
28
+ expect(screen.queryByText('50')).toBeNull();
29
+ });
30
+ });
@@ -1,45 +1,52 @@
1
1
  import { cn } from '@tuturuuu/utils/format';
2
- import type { LegendPayload } from 'recharts';
2
+ import type { CategoryBreakdownCategory } from './category-breakdown-chart-types';
3
3
 
4
4
  interface CategoryBreakdownLegendProps {
5
+ categories: CategoryBreakdownCategory[];
5
6
  hiddenCategories: Set<string>;
6
- onToggleCategory: (entry: LegendPayload) => void;
7
- payload?: readonly LegendPayload[];
7
+ onToggleCategory: (key: string) => void;
8
+ formatValue: (value: number) => string;
8
9
  }
9
10
 
10
11
  export function CategoryBreakdownLegend({
12
+ categories,
11
13
  hiddenCategories,
12
14
  onToggleCategory,
13
- payload,
15
+ formatValue,
14
16
  }: CategoryBreakdownLegendProps) {
15
- if (!payload) return null;
16
-
17
17
  return (
18
- <div className="flex flex-wrap justify-center gap-3 pt-5">
19
- {payload.map((entry) => {
20
- const isHidden = hiddenCategories.has(entry.value as string);
18
+ <div className="grid max-h-64 content-start gap-1 overflow-y-auto rounded-lg border bg-muted/20 p-2 sm:grid-cols-2 xl:max-h-80 xl:grid-cols-1">
19
+ {categories.map((category) => {
20
+ const hidden = hiddenCategories.has(category.key);
21
21
  return (
22
22
  <button
23
- key={String(entry.value)}
23
+ key={category.key}
24
24
  type="button"
25
- onClick={() => onToggleCategory(entry)}
25
+ aria-pressed={!hidden}
26
+ aria-label={`${category.name} ${formatValue(category.total)}`}
27
+ onClick={() => onToggleCategory(category.key)}
26
28
  className={cn(
27
- 'flex cursor-pointer items-center gap-1.5 rounded px-2 py-1 text-sm transition-all hover:bg-accent',
28
- isHidden && 'opacity-40'
29
+ 'flex min-w-0 items-start gap-2 rounded-md p-2 text-left text-xs transition-colors hover:bg-accent focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring',
30
+ hidden && 'opacity-50'
29
31
  )}
30
32
  >
31
- <div
32
- className="h-3 w-3 rounded-sm"
33
- style={{
34
- backgroundColor: entry.color,
35
- opacity: isHidden ? 0.4 : 1,
36
- }}
37
- />
38
33
  <span
39
- className={cn(isHidden && 'line-through')}
40
- style={{ color: 'hsl(var(--foreground))' }}
41
- >
42
- {entry.value}
34
+ className="mt-1 size-2.5 shrink-0 rounded-sm"
35
+ style={{ backgroundColor: category.color }}
36
+ />
37
+ <span className="min-w-0 flex-1">
38
+ <span
39
+ className={cn(
40
+ 'line-clamp-2 break-words font-medium',
41
+ hidden && 'line-through'
42
+ )}
43
+ title={category.name}
44
+ >
45
+ {category.name}
46
+ </span>
47
+ <span className="mt-1 block text-muted-foreground tabular-nums">
48
+ {formatValue(category.total)}
49
+ </span>
43
50
  </span>
44
51
  </button>
45
52
  );
@@ -6,6 +6,7 @@ import type {
6
6
 
7
7
  interface TooltipPayloadItem {
8
8
  name: string;
9
+ dataKey?: string;
9
10
  value: number;
10
11
  color?: string;
11
12
  }
@@ -36,7 +37,7 @@ export function CategoryBreakdownTooltipContent({
36
37
  if (!active || !payload || payload.length === 0) return null;
37
38
 
38
39
  const sortedPayload = [...payload]
39
- .filter((item) => !hiddenCategories.has(item.name))
40
+ .filter((item) => !hiddenCategories.has(item.dataKey ?? item.name))
40
41
  .sort((a, b) => (b.value || 0) - (a.value || 0));
41
42
 
42
43
  if (sortedPayload.length === 0) return null;
@@ -78,16 +79,16 @@ export function CategoryBreakdownTooltipContent({
78
79
  }
79
80
 
80
81
  return (
81
- <div className="rounded-lg border border-border bg-popover p-3 text-popover-foreground shadow-xl">
82
+ <div className="max-w-sm rounded-lg border border-border bg-popover p-3 text-popover-foreground shadow-xl">
82
83
  <p className="mb-2 font-semibold">{formattedLabel}</p>
83
- <div className="space-y-1">
84
+ <div className="max-h-64 space-y-1 overflow-y-auto">
84
85
  {sortedPayload.map((item) => {
85
- const category = categories.find((c) => c.name === item.name);
86
+ const category = categories.find((c) => c.key === item.dataKey);
86
87
  const color = category?.color || item.color;
87
88
 
88
89
  return (
89
90
  <div
90
- key={item.name}
91
+ key={item.dataKey ?? item.name}
91
92
  className="flex items-center justify-between gap-4"
92
93
  >
93
94
  <div className="flex items-center gap-2">
@@ -95,7 +96,9 @@ export function CategoryBreakdownTooltipContent({
95
96
  className="h-3 w-3 rounded-sm"
96
97
  style={{ backgroundColor: color }}
97
98
  />
98
- <span className="text-sm">{item.name}</span>
99
+ <span className="line-clamp-2 break-words text-sm">
100
+ {item.name}
101
+ </span>
99
102
  </div>
100
103
  <span className="font-semibold text-sm" style={{ color }}>
101
104
  {formatValue(item.value)}
@@ -3,6 +3,8 @@ export type TransactionType = 'expense' | 'income';
3
3
  export type ChartInterval = 'daily' | 'weekly' | 'monthly' | 'yearly';
4
4
 
5
5
  export interface CategoryBreakdownCategory {
6
+ key: string;
7
+ total: number;
6
8
  color: string;
7
9
  id: string | null;
8
10
  name: string;
@@ -0,0 +1,59 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { buildCategoryBreakdownChartData } from './category-breakdown-chart-utils';
3
+
4
+ const point = (
5
+ id: string,
6
+ name: string,
7
+ total: number,
8
+ period = '2026-09-01'
9
+ ) => ({
10
+ category_id: id,
11
+ category_name: name,
12
+ category_color: null,
13
+ category_icon: null,
14
+ total,
15
+ period,
16
+ });
17
+
18
+ describe('category breakdown series', () => {
19
+ it('keeps categories with the same name separate and sums repeated points', () => {
20
+ const result = buildCategoryBreakdownChartData([
21
+ point('a', 'Office', 10),
22
+ point('b', 'Office', 20),
23
+ point('a', 'Office', 5),
24
+ ]);
25
+ expect(result.categories.map((category) => category.total)).toEqual([
26
+ 20, 15,
27
+ ]);
28
+ expect(result.chartData[0]).toMatchObject({
29
+ category_a: 15,
30
+ category_b: 20,
31
+ });
32
+ });
33
+ it('groups smaller categories without losing any period totals', () => {
34
+ const result = buildCategoryBreakdownChartData(
35
+ [
36
+ point('a', 'A', 100),
37
+ point('b', 'B', 20),
38
+ point('c', 'C', 5),
39
+ point('b', 'B', 3, '2026-08-01'),
40
+ ],
41
+ { limit: 1, otherLabel: 'Other' }
42
+ );
43
+ expect(
44
+ result.categories.map((category) => [category.name, category.total])
45
+ ).toEqual([
46
+ ['A', 100],
47
+ ['Other', 28],
48
+ ]);
49
+ expect(result.chartData).toEqual([
50
+ { period: '2026-08-01', other_categories: 3 },
51
+ { period: '2026-09-01', category_a: 100, other_categories: 25 },
52
+ ]);
53
+ });
54
+ it('keeps arbitrary names out of chart data keys', () => {
55
+ expect(
56
+ buildCategoryBreakdownChartData([point('a', 'period', 10)]).chartData[0]
57
+ ).toEqual({ period: '2026-09-01', category_a: 10 });
58
+ });
59
+ });
@@ -131,50 +131,64 @@ export function getActualCategoryBreakdownDisplayRange(
131
131
  }
132
132
 
133
133
  export function buildCategoryBreakdownChartData(
134
- rawData: FinanceCategoryBreakdownPoint[]
134
+ rawData: FinanceCategoryBreakdownPoint[],
135
+ options?: { limit: number; otherLabel: string }
135
136
  ): {
136
137
  categories: CategoryBreakdownCategory[];
137
138
  chartData: CategoryBreakdownChartDatum[];
138
139
  } {
139
- if (rawData.length === 0) {
140
- return { chartData: [], categories: [] };
141
- }
142
-
143
140
  const categoryMap = new Map<string, CategoryBreakdownCategory>();
144
- let colorIndex = 0;
145
-
146
- rawData.forEach((item) => {
147
- const key = item.category_id || 'uncategorized';
148
- if (categoryMap.has(key)) return;
149
-
150
- const color =
151
- item.category_color ||
152
- DEFAULT_CATEGORY_COLORS[colorIndex % DEFAULT_CATEGORY_COLORS.length] ||
153
- DEFAULT_CATEGORY_COLORS[0]!;
154
-
155
- categoryMap.set(key, {
156
- id: item.category_id,
157
- name: item.category_name,
158
- color,
159
- });
160
- colorIndex += 1;
161
- });
162
-
141
+ for (const item of rawData) {
142
+ const key = `category_${item.category_id ?? 'uncategorized'}`;
143
+ const existing = categoryMap.get(key);
144
+ if (existing) {
145
+ existing.total += Number(item.total) || 0;
146
+ } else {
147
+ categoryMap.set(key, {
148
+ key,
149
+ id: item.category_id,
150
+ name: item.category_name,
151
+ total: Number(item.total) || 0,
152
+ color:
153
+ item.category_color ||
154
+ DEFAULT_CATEGORY_COLORS[
155
+ categoryMap.size % DEFAULT_CATEGORY_COLORS.length
156
+ ]!,
157
+ });
158
+ }
159
+ }
160
+ const ranked = [...categoryMap.values()].sort(
161
+ (a, b) => b.total - a.total || a.key.localeCompare(b.key)
162
+ );
163
+ const visible = options ? ranked.slice(0, options.limit) : ranked;
164
+ const visibleKeys = new Set(visible.map((category) => category.key));
165
+ const remainder = ranked.slice(visible.length);
166
+ const categories =
167
+ remainder.length > 0
168
+ ? [
169
+ ...visible,
170
+ {
171
+ key: 'other_categories',
172
+ id: null,
173
+ name: options!.otherLabel,
174
+ total: remainder.reduce((sum, category) => sum + category.total, 0),
175
+ color: 'var(--muted-foreground)',
176
+ },
177
+ ]
178
+ : visible;
163
179
  const periodMap = new Map<string, CategoryBreakdownChartDatum>();
164
-
165
- rawData.forEach((item) => {
166
- if (!periodMap.has(item.period)) {
180
+ for (const item of rawData) {
181
+ if (!periodMap.has(item.period))
167
182
  periodMap.set(item.period, { period: item.period });
168
- }
169
- const periodData = periodMap.get(item.period)!;
170
- periodData[item.category_name] = Number(item.total) || 0;
171
- });
172
-
173
- const chartData = Array.from(periodMap.values()).sort((a, b) => {
174
- return new Date(a.period).getTime() - new Date(b.period).getTime();
175
- });
176
-
177
- return { chartData, categories: Array.from(categoryMap.values()) };
183
+ const period = periodMap.get(item.period)!;
184
+ const categoryKey = `category_${item.category_id ?? 'uncategorized'}`;
185
+ const key = visibleKeys.has(categoryKey) ? categoryKey : 'other_categories';
186
+ period[key] = Number(period[key] || 0) + (Number(item.total) || 0);
187
+ }
188
+ const chartData = [...periodMap.values()].sort(
189
+ (a, b) => new Date(a.period).getTime() - new Date(b.period).getTime()
190
+ );
191
+ return { categories, chartData };
178
192
  }
179
193
 
180
194
  export function formatCategoryBreakdownXAxisTick(
@@ -5,7 +5,7 @@ import { listFinanceCategoryBreakdown } from '@tuturuuu/internal-api/finance';
5
5
  import { cn, getCurrencyLocale } from '@tuturuuu/utils/format';
6
6
  import { useLocale, useTranslations } from 'next-intl';
7
7
  import { type ReactNode, useCallback, useMemo, useState } from 'react';
8
- import type { LegendPayload } from 'recharts';
8
+ import { Button } from '../../../button';
9
9
  import { Card, CardContent, CardHeader } from '../../../card';
10
10
  import type { ChartConfig } from '../../../chart';
11
11
  import { Skeleton } from '../../../skeleton';
@@ -47,6 +47,7 @@ export function CategoryBreakdownChart({
47
47
  const { isConfidential, toggleConfidential } =
48
48
  useFinanceConfidentialVisibility();
49
49
  const shouldHideAmounts = isConfidential || !includeConfidential;
50
+ const [showAll, setShowAll] = useState(false);
50
51
  const [dateOffset, setDateOffset] = useState(0);
51
52
  const [hiddenCategories, setHiddenCategories] = useState<Set<string>>(
52
53
  new Set()
@@ -110,8 +111,14 @@ export function CategoryBreakdownChart({
110
111
  );
111
112
 
112
113
  const { chartData, categories } = useMemo(
113
- () => buildCategoryBreakdownChartData(rawData),
114
- [rawData]
114
+ () =>
115
+ buildCategoryBreakdownChartData(
116
+ rawData,
117
+ showAll
118
+ ? undefined
119
+ : { limit: 7, otherLabel: t('finance-analytics.other-categories') }
120
+ ),
121
+ [rawData, showAll, t]
115
122
  );
116
123
 
117
124
  const intervalLabels: Record<ChartInterval, string> = useMemo(
@@ -133,7 +140,7 @@ export function CategoryBreakdownChart({
133
140
 
134
141
  const chartConfig = useMemo<ChartConfig>(() => {
135
142
  return categories.reduce((config, category) => {
136
- config[category.name] = {
143
+ config[category.key] = {
137
144
  label: category.name,
138
145
  color: category.color,
139
146
  };
@@ -166,8 +173,7 @@ export function CategoryBreakdownChart({
166
173
  [shouldHideAmounts, locale]
167
174
  );
168
175
 
169
- const handleLegendClick = useCallback((entry: LegendPayload) => {
170
- const categoryName = entry.value as string;
176
+ const handleLegendClick = useCallback((categoryName: string) => {
171
177
  setHiddenCategories((prev) => {
172
178
  const next = new Set(prev);
173
179
  if (next.has(categoryName)) {
@@ -246,7 +252,31 @@ export function CategoryBreakdownChart({
246
252
  }
247
253
 
248
254
  return renderCard(
249
- <CardContent className="px-2 pb-4">
255
+ <CardContent className="min-w-0 space-y-3 px-4 pb-4 sm:px-6">
256
+ <div className="flex flex-wrap items-center justify-between gap-2 text-muted-foreground text-xs">
257
+ <p>
258
+ {t(
259
+ showAll
260
+ ? 'finance-analytics.all-categories-description'
261
+ : 'finance-analytics.top-categories-description'
262
+ )}
263
+ </p>
264
+ <Button
265
+ variant="ghost"
266
+ size="sm"
267
+ onClick={() => {
268
+ setShowAll(!showAll);
269
+ setHiddenCategories(new Set());
270
+ }}
271
+ aria-expanded={showAll}
272
+ >
273
+ {t(
274
+ showAll
275
+ ? 'finance-analytics.show-top-categories'
276
+ : 'finance-analytics.show-all-categories'
277
+ )}
278
+ </Button>
279
+ </div>
250
280
  <CategoryBreakdownChartBody
251
281
  categories={categories}
252
282
  chartConfig={chartConfig}
@@ -12,8 +12,9 @@ import timezone from 'dayjs/plugin/timezone';
12
12
  import React, { useCallback, useMemo, useRef, useState } from 'react';
13
13
  import { MIN_COLUMN_WIDTH } from './config';
14
14
  import { CalendarEventProviderIcon } from './event-provider-display';
15
- import { getLocationType, LocationTimeline } from './location-timeline';
15
+ import { LocationTimeline } from './location-timeline';
16
16
  import { useCalendarSettings } from './settings/settings-context';
17
+ import { getEventLocationType } from './working-location';
17
18
 
18
19
  dayjs.extend(isBetween);
19
20
  dayjs.extend(isSameOrAfter);
@@ -22,7 +23,6 @@ dayjs.extend(timezone);
22
23
 
23
24
  const MAX_EVENTS_DISPLAY = 2;
24
25
 
25
- // Define types for better type safety
26
26
  interface EventSpan {
27
27
  event: CalendarEvent;
28
28
  startIndex: number;
@@ -490,11 +490,11 @@ export const AllDayEventBar = ({ dates }: { dates: Date[] }) => {
490
490
  // Location events will be rendered separately as a compact timeline strip
491
491
  // Extract location spans before filtering them out for row assignment
492
492
  const locationSpansFromMerge = mergedTempSpans
493
- .filter((span) => getLocationType(span.event.title ?? '') !== null)
493
+ .filter((span) => getEventLocationType(span.event) !== null)
494
494
  .map((span) => ({ ...span, row: 0 })); // Give them row 0 as placeholder
495
495
 
496
496
  const nonLocationSpans = mergedTempSpans.filter(
497
- (span) => getLocationType(span.event.title ?? '') === null
497
+ (span) => getEventLocationType(span.event) === null
498
498
  );
499
499
 
500
500
  // Sort: 1) by span length (longer events first for better packing), 2) by start date
@@ -628,7 +628,7 @@ export const AllDayEventBar = ({ dates }: { dates: Date[] }) => {
628
628
  // Get non-location spans for regular event display
629
629
  const regularSpans = useMemo(() => {
630
630
  return eventLayout.spans.filter(
631
- (span) => getLocationType(span.event.title ?? '') === null
631
+ (span) => getEventLocationType(span.event) === null
632
632
  );
633
633
  }, [eventLayout.spans]);
634
634