@tuturuuu/ui 0.30.1 → 0.31.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 (99) hide show
  1. package/CHANGELOG.md +49 -0
  2. package/biome.json +1 -1
  3. package/package.json +103 -57
  4. package/src/components/ui/calendar-app/calendar-client-page.test.tsx +57 -0
  5. package/src/components/ui/calendar-app/calendar-client-page.tsx +13 -4
  6. package/src/components/ui/calendar-app/calendar-page-shell.tsx +7 -1
  7. package/src/components/ui/calendar-app/components/calendar-connections-compact.tsx +47 -39
  8. package/src/components/ui/calendar-app/components/calendar-connections-settings-content.tsx +14 -0
  9. package/src/components/ui/calendar-app/components/calendar-connections-unified.tsx +4 -0
  10. package/src/components/ui/calendar-app/components/calendar-sync-attention-button.tsx +47 -0
  11. package/src/components/ui/calendar-app/components/calendar-sync-recovery.test.tsx +112 -0
  12. package/src/components/ui/calendar-app/components/calendar-sync-recovery.tsx +206 -0
  13. package/src/components/ui/calendar-app/components/calendar-types.ts +1 -1
  14. package/src/components/ui/calendar-app/components/use-calendar-connections-manager.ts +86 -90
  15. package/src/components/ui/custom/language-dropdown-item.tsx +6 -14
  16. package/src/components/ui/custom/nav-link.tsx +7 -7
  17. package/src/components/ui/custom/nav.tsx +15 -63
  18. package/src/components/ui/custom/notification-card.tsx +361 -0
  19. package/src/components/ui/custom/notification-list.tsx +178 -0
  20. package/src/components/ui/custom/notification-popover-client.tsx +12 -780
  21. package/src/components/ui/custom/notification-runtime.tsx +23 -0
  22. package/src/components/ui/custom/onboarding-settings-panel.tsx +8 -55
  23. package/src/components/ui/custom/satellite-account-switcher-menu.tsx +212 -0
  24. package/src/components/ui/custom/satellite-brand.tsx +122 -0
  25. package/src/components/ui/custom/satellite-content.tsx +148 -0
  26. package/src/components/ui/custom/satellite-footer-actions.tsx +350 -0
  27. package/src/components/ui/custom/satellite-language-item.tsx +26 -0
  28. package/src/components/ui/custom/satellite-navigation.tsx +86 -0
  29. package/src/components/ui/custom/satellite-notification-popover.tsx +272 -0
  30. package/src/components/ui/custom/satellite-onboarding-settings-panel.tsx +80 -0
  31. package/src/components/ui/custom/satellite-settings-dialog-shell.tsx +621 -0
  32. package/src/components/ui/custom/satellite-shell-utils.ts +163 -0
  33. package/src/components/ui/custom/satellite-shell.test.tsx +169 -0
  34. package/src/components/ui/custom/satellite-shell.tsx +50 -0
  35. package/src/components/ui/custom/satellite-theme-dropdown-items.tsx +92 -0
  36. package/src/components/ui/custom/satellite-user-menu-items.tsx +190 -0
  37. package/src/components/ui/custom/satellite-user-menu.tsx +93 -0
  38. package/src/components/ui/custom/satellite-workspace-section.tsx +46 -0
  39. package/src/components/ui/custom/settings/keyboard-shortcuts-settings.tsx +16 -0
  40. package/src/components/ui/custom/settings-dialog-shell.tsx +21 -600
  41. package/src/components/ui/custom/sidebar-context.tsx +5 -14
  42. package/src/components/ui/custom/sidebar-footer-actions.tsx +19 -322
  43. package/src/components/ui/custom/structure.tsx +14 -0
  44. package/src/components/ui/custom/system-language-dropdown-item.tsx +7 -14
  45. package/src/components/ui/custom/theme-dropdown-items.tsx +2 -85
  46. package/src/components/ui/custom/use-satellite-shell.tsx +165 -0
  47. package/src/components/ui/finance/invoices/subscription-invoice.test.tsx +49 -13
  48. package/src/components/ui/finance/invoices/subscription-invoice.tsx +4 -12
  49. package/src/components/ui/finance/invoices/utils.test.ts +6 -5
  50. package/src/components/ui/finance/invoices/utils.ts +3 -1
  51. package/src/components/ui/finance/transactions/columns.test.tsx +25 -1
  52. package/src/components/ui/finance/transactions/columns.tsx +45 -16
  53. package/src/components/ui/legacy/calendar/agenda-view.tsx +139 -47
  54. package/src/components/ui/legacy/calendar/calendar-content.test.tsx +116 -0
  55. package/src/components/ui/legacy/calendar/calendar-content.tsx +38 -74
  56. package/src/components/ui/legacy/calendar/calendar-event-tone.ts +20 -0
  57. package/src/components/ui/legacy/calendar/calendar-meeting-link.test.ts +27 -0
  58. package/src/components/ui/legacy/calendar/calendar-meeting-link.ts +34 -0
  59. package/src/components/ui/legacy/calendar/calendar-period.ts +28 -0
  60. package/src/components/ui/legacy/calendar/calendar-shortcuts.test.tsx +107 -0
  61. package/src/components/ui/legacy/calendar/calendar-views.test.tsx +169 -0
  62. package/src/components/ui/legacy/calendar/event-modal-header.tsx +52 -0
  63. package/src/components/ui/legacy/calendar/event-modal.tsx +107 -156
  64. package/src/components/ui/legacy/calendar/month-calendar.tsx +242 -933
  65. package/src/components/ui/legacy/calendar/save-calendar-event-drafts.test.ts +25 -0
  66. package/src/components/ui/legacy/calendar/save-calendar-event-drafts.ts +32 -0
  67. package/src/components/ui/legacy/calendar/smart-calendar.tsx +3 -0
  68. package/src/components/ui/legacy/calendar/use-calendar-view-shortcuts.ts +54 -0
  69. package/src/components/ui/legacy/calendar/use-event-draft-session.test.tsx +59 -0
  70. package/src/components/ui/legacy/calendar/use-event-draft-session.ts +20 -0
  71. package/src/components/ui/legacy/calendar/year-calendar.tsx +34 -16
  72. package/src/components/ui/pwa-status.test.tsx +53 -0
  73. package/src/components/ui/pwa-status.tsx +113 -0
  74. package/src/components/ui/report-problem-dialog-content.tsx +350 -0
  75. package/src/components/ui/report-problem-dialog.tsx +6 -975
  76. package/src/components/ui/report-problem-model.ts +133 -0
  77. package/src/components/ui/report-problem-mutation.ts +122 -0
  78. package/src/components/ui/save-notification.test.tsx +36 -0
  79. package/src/components/ui/save-notification.tsx +17 -0
  80. package/src/components/ui/text-editor/task-item-checkbox-view.test.tsx +85 -0
  81. package/src/components/ui/text-editor/task-item-checkbox-view.tsx +28 -18
  82. package/src/components/ui/use-report-problem.ts +488 -0
  83. package/src/declarations.d.ts +2 -0
  84. package/src/hooks/__tests__/use-calendar-readonly.test.tsx +46 -4
  85. package/src/hooks/__tests__/use-calendar-sync.test.tsx +24 -0
  86. package/src/hooks/calendar-date-utils.ts +11 -0
  87. package/src/hooks/calendar-event-lookup.test.ts +77 -0
  88. package/src/hooks/calendar-event-lookup.ts +47 -0
  89. package/src/hooks/calendar-range-cache.ts +52 -0
  90. package/src/hooks/pwa-install-prompt.ts +44 -0
  91. package/src/hooks/use-calendar-sync.tsx +23 -100
  92. package/src/hooks/use-calendar.tsx +21 -77
  93. package/src/hooks/use-notifications.ts +9 -24
  94. package/src/hooks/use-open-initial-calendar-event.ts +27 -0
  95. package/src/hooks/use-settings-dialog-shortcut.test.tsx +41 -0
  96. package/src/hooks/use-settings-dialog-shortcut.ts +12 -14
  97. package/src/hooks/use-workspace-presence.ts +0 -18
  98. package/src/lib/tasks-app-url.test.ts +25 -0
  99. package/src/lib/tasks-app-url.ts +8 -0
@@ -30,15 +30,28 @@ const testState = vi.hoisted(() => {
30
30
 
31
31
  return {
32
32
  InvoicePaymentSettings: vi.fn(),
33
+ SubscriptionAttendanceSummary: vi.fn(),
33
34
  categories: [] as Array<{ id: string; name: string }>,
34
35
  groupIds: ['group-1'],
35
36
  month: '2026-07',
37
+ setMonth: vi.fn(),
36
38
  productSelectionInjected: false,
37
39
  products: [product],
38
40
  selectedProducts: [{ inventory, product, quantity: 1 }],
39
41
  useCategories: vi.fn(),
40
42
  useWallets: vi.fn(),
41
43
  userId: 'user-1',
44
+ userGroups: [
45
+ {
46
+ workspace_user_groups: {
47
+ ending_date: null as string | null,
48
+ id: 'group-1',
49
+ name: 'Group 1',
50
+ sessions: ['2026-07-05'],
51
+ starting_date: '2026-07-01',
52
+ },
53
+ },
54
+ ],
42
55
  wallets: [] as Array<{
43
56
  currency: string;
44
57
  id: string;
@@ -67,7 +80,7 @@ vi.mock('nuqs', () => ({
67
80
  useQueryState: (key: string, options?: { defaultValue?: unknown }) => {
68
81
  if (key === 'user_id') return [testState.userId, vi.fn()];
69
82
  if (key === 'group_ids') return [testState.groupIds, vi.fn()];
70
- if (key === 'month') return [testState.month, vi.fn()];
83
+ if (key === 'month') return [testState.month, testState.setMonth];
71
84
  return [options?.defaultValue ?? '', vi.fn()];
72
85
  },
73
86
  }));
@@ -113,7 +126,10 @@ vi.mock('./components/invoice-products-permission-warning', () => ({
113
126
  }));
114
127
 
115
128
  vi.mock('./components/subscription-attendance-summary', () => ({
116
- SubscriptionAttendanceSummary: () => null,
129
+ SubscriptionAttendanceSummary: (props: unknown) => {
130
+ testState.SubscriptionAttendanceSummary(props);
131
+ return null;
132
+ },
117
133
  }));
118
134
 
119
135
  vi.mock('./components/subscription-group-selector', () => ({
@@ -178,17 +194,7 @@ vi.mock('./hooks', () => ({
178
194
  isLoading: false,
179
195
  }),
180
196
  useUserGroups: () => ({
181
- data: [
182
- {
183
- workspace_user_groups: {
184
- ending_date: null,
185
- id: 'group-1',
186
- name: 'Group 1',
187
- sessions: ['2026-07-05'],
188
- starting_date: '2026-07-01',
189
- },
190
- },
191
- ],
197
+ data: testState.userGroups,
192
198
  isLoading: false,
193
199
  }),
194
200
  useUserLinkedPromotions: () => ({ data: [] }),
@@ -262,9 +268,14 @@ function renderSubscriptionInvoice(
262
268
  describe('SubscriptionInvoice checkout defaults', () => {
263
269
  beforeEach(() => {
264
270
  testState.InvoicePaymentSettings.mockClear();
271
+ testState.SubscriptionAttendanceSummary.mockClear();
265
272
  testState.useCategories.mockClear();
266
273
  testState.useWallets.mockClear();
267
274
  testState.productSelectionInjected = false;
275
+ testState.month = '2026-07';
276
+ testState.setMonth.mockClear();
277
+ testState.userGroups[0]!.workspace_user_groups.ending_date = null;
278
+ testState.userGroups[0]!.workspace_user_groups.sessions = ['2026-07-05'];
268
279
  testState.products[0]!.finance_category_id = null;
269
280
  testState.wallets = [];
270
281
  testState.categories = [];
@@ -328,4 +339,29 @@ describe('SubscriptionInvoice checkout defaults', () => {
328
339
  )
329
340
  );
330
341
  });
342
+
343
+ it('keeps an advance month selected after the group schedule end', async () => {
344
+ testState.month = '2026-09';
345
+ testState.userGroups[0]!.workspace_user_groups.ending_date = '2026-08-31';
346
+ testState.userGroups[0]!.workspace_user_groups.sessions = [
347
+ '2026-07-05',
348
+ '2026-08-05',
349
+ '2026-09-05',
350
+ ];
351
+
352
+ renderSubscriptionInvoice();
353
+
354
+ await waitFor(() =>
355
+ expect(testState.SubscriptionAttendanceSummary).toHaveBeenCalledWith(
356
+ expect.objectContaining({
357
+ availableMonths: expect.arrayContaining([
358
+ expect.objectContaining({ value: '2026-09' }),
359
+ ]),
360
+ selectedMonth: '2026-09',
361
+ })
362
+ )
363
+ );
364
+ expect(testState.setMonth).not.toHaveBeenCalledWith('2026-07');
365
+ expect(testState.setMonth).not.toHaveBeenCalledWith('2026-08');
366
+ });
331
367
  });
@@ -728,30 +728,22 @@ export function SubscriptionInvoice({
728
728
  }
729
729
  lastMonthSyncRef.current = null;
730
730
 
731
- const { earliestStart, latestEnd } = getGroupsDateRange(
732
- userGroups,
733
- selectedGroupIds
734
- );
731
+ const { earliestStart } = getGroupsDateRange(userGroups, selectedGroupIds);
735
732
 
736
- if (!earliestStart || !latestEnd) return;
733
+ if (!earliestStart) return;
737
734
 
738
735
  const currentMonth = getMonthStartDate(selectedMonth);
739
736
  if (Number.isNaN(currentMonth.getTime())) return;
740
737
 
741
738
  const earliestMonthStart = getMonthStartDate(earliestStart);
742
- const latestMonthStart = getMonthStartDate(latestEnd);
743
739
 
744
- if (currentMonth < earliestMonthStart || currentMonth > latestMonthStart) {
740
+ if (currentMonth < earliestMonthStart) {
745
741
  // Only correct once per groups-selection to avoid update loops
746
742
  if (lastValidatedKeyRef.current === monthValidationKey) return;
747
743
  lastValidatedKeyRef.current = monthValidationKey;
748
744
 
749
745
  const now = getCurrentBillingDate(workspaceTimezone);
750
- let defaultMonth: Date;
751
-
752
- if (now >= earliestStart && now <= latestEnd) defaultMonth = now;
753
- else if (now > latestEnd) defaultMonth = latestEnd;
754
- else defaultMonth = earliestStart;
746
+ const defaultMonth = now >= earliestStart ? now : earliestStart;
755
747
 
756
748
  const nextMonth = formatMonthValue(defaultMonth);
757
749
  if (nextMonth !== selectedMonth) {
@@ -233,7 +233,7 @@ describe('subscription invoice coverage', () => {
233
233
  ).toBe(true);
234
234
  });
235
235
 
236
- it('generates prepaid future month options capped by group ending dates', () => {
236
+ it('keeps future billing months available after a group ending date', () => {
237
237
  const groups = [
238
238
  {
239
239
  workspace_user_groups: {
@@ -247,10 +247,11 @@ describe('subscription invoice coverage', () => {
247
247
  ] satisfies UserGroup[];
248
248
 
249
249
  expect(
250
- getAvailableMonths(groups, [groupId], [], 'en-US', '2026-06', 12).map(
251
- (month) => month.value
252
- )
253
- ).toEqual(['2026-06', '2026-07', '2026-08']);
250
+ getAvailableMonths(groups, [groupId], [], 'en-US', '2026-06', 3, {
251
+ now: new Date(2026, 5, 15),
252
+ workspaceTimezone: 'UTC',
253
+ }).map((month) => month.value)
254
+ ).toEqual(['2026-06', '2026-07', '2026-08', '2026-09']);
254
255
  });
255
256
 
256
257
  it('skips paid months and uses scheduled sessions for future prepaid quantities', () => {
@@ -524,7 +524,9 @@ export const getAvailableMonths = (
524
524
  ? getMonthStartDate(selectedMonthFallback)
525
525
  : currentMonthStart;
526
526
  const resolvedRangeEnd =
527
- futureMonthHorizon > 0 && !latestEnd ? futureHorizonEnd : resolvedLatestEnd;
527
+ futureMonthHorizon > 0 && futureHorizonEnd > resolvedLatestEnd
528
+ ? futureHorizonEnd
529
+ : resolvedLatestEnd;
528
530
 
529
531
  const months: AvailableMonthOption[] = [];
530
532
  const currentDate = new Date(earliestStart);
@@ -1,6 +1,7 @@
1
1
  import { render, screen, waitFor } from '@testing-library/react';
2
2
  import type { ColumnDef } from '@tuturuuu/ui/custom/tables/data-table';
3
- import { beforeEach, describe, expect, it, vi } from 'vitest';
3
+ import { renderToString } from 'react-dom/server';
4
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
4
5
  import { transactionColumns } from './columns';
5
6
 
6
7
  vi.mock('next-intl', () => ({
@@ -82,6 +83,10 @@ describe('transactionColumns', () => {
82
83
  'finance-confidential-mode=;expires=Thu, 01 Jan 1970 00:00:00 GMT;path=/;Secure';
83
84
  });
84
85
 
86
+ afterEach(() => {
87
+ vi.useRealTimers();
88
+ });
89
+
85
90
  it('masks the amount column when finance numbers are globally hidden', () => {
86
91
  render(<TransactionAmountCell amount={123} />);
87
92
 
@@ -116,4 +121,23 @@ describe('transactionColumns', () => {
116
121
  expect(screen.getByText('Cash')).toBeVisible();
117
122
  expect(screen.getByText('Food & Beverage')).toBeVisible();
118
123
  });
124
+
125
+ it('keeps server-rendered transaction dates stable as time advances', () => {
126
+ vi.useFakeTimers();
127
+ vi.setSystemTime('2026-08-31T00:59:00.000Z');
128
+
129
+ const transaction = {
130
+ taken_at: '2026-08-31T00:00:00.000Z',
131
+ };
132
+ const firstRender = renderToString(
133
+ <TransactionColumnCell accessorKey="taken_at" original={transaction} />
134
+ );
135
+
136
+ vi.setSystemTime('2026-08-31T02:01:00.000Z');
137
+ const laterRender = renderToString(
138
+ <TransactionColumnCell accessorKey="taken_at" original={transaction} />
139
+ );
140
+
141
+ expect(laterRender).toBe(firstRender);
142
+ });
119
143
  });
@@ -12,6 +12,7 @@ import { formatCurrency } from '@tuturuuu/utils/format';
12
12
  import moment from 'moment';
13
13
  import 'moment/locale/vi';
14
14
  import { useLocale } from 'next-intl';
15
+ import { useSyncExternalStore } from 'react';
15
16
  import { DataTableColumnHeader } from '../../custom/tables/data-table-column-header';
16
17
  import {
17
18
  FINANCE_HIDDEN_AMOUNT,
@@ -27,6 +28,44 @@ interface TransactionExtraData {
27
28
  isPersonalWorkspace?: boolean;
28
29
  }
29
30
 
31
+ const subscribeToHydration = () => () => undefined;
32
+ const getHydratedSnapshot = () => true;
33
+ const getServerSnapshot = () => false;
34
+
35
+ function TransactionDateCell({
36
+ includeTime = false,
37
+ locale,
38
+ value,
39
+ }: {
40
+ includeTime?: boolean;
41
+ locale: string;
42
+ value: unknown;
43
+ }) {
44
+ const isHydrated = useSyncExternalStore(
45
+ subscribeToHydration,
46
+ getHydratedSnapshot,
47
+ getServerSnapshot
48
+ );
49
+
50
+ if (!value) return <div className="min-w-32">-</div>;
51
+
52
+ const localDate = moment(value);
53
+ if (!localDate.isValid()) return <div className="min-w-32">-</div>;
54
+
55
+ const format = includeTime ? 'DD/MM/YYYY, HH:mm:ss' : 'DD/MM/YYYY';
56
+ const absoluteDate = isHydrated
57
+ ? localDate.locale(locale).format(format)
58
+ : moment.utc(value).locale(locale).format(format);
59
+
60
+ return (
61
+ <div className="min-w-32">
62
+ {isHydrated && !includeTime
63
+ ? `${localDate.locale(locale).fromNow()}, ${absoluteDate}`
64
+ : absoluteDate}
65
+ </div>
66
+ );
67
+ }
68
+
30
69
  function TransactionAmountCell({
31
70
  amount,
32
71
  currency,
@@ -265,15 +304,7 @@ export const transactionColumns = ({
265
304
  />
266
305
  ),
267
306
  cell: ({ row }) => (
268
- <div className="min-w-32">
269
- {row.getValue('taken_at')
270
- ? `${moment(row.getValue('taken_at')).locale(locale).fromNow()}, ${moment(
271
- row.getValue('taken_at')
272
- )
273
- .locale(locale)
274
- .format('DD/MM/YYYY')}`
275
- : '-'}
276
- </div>
307
+ <TransactionDateCell locale={locale} value={row.getValue('taken_at')} />
277
308
  ),
278
309
  },
279
310
  {
@@ -286,13 +317,11 @@ export const transactionColumns = ({
286
317
  />
287
318
  ),
288
319
  cell: ({ row }) => (
289
- <div className="min-w-32">
290
- {row.getValue('created_at')
291
- ? moment(row.getValue('created_at'))
292
- .locale(locale)
293
- .format('DD/MM/YYYY, HH:mm:ss')
294
- : '-'}
295
- </div>
320
+ <TransactionDateCell
321
+ includeTime
322
+ locale={locale}
323
+ value={row.getValue('created_at')}
324
+ />
296
325
  ),
297
326
  },
298
327
  {
@@ -1,6 +1,6 @@
1
1
  'use client';
2
2
 
3
- import { CalendarX2, Clock, MapPin, Sun } from '@tuturuuu/icons';
3
+ import { CalendarX2, Clock, MapPin, Plus, Search, Sun } from '@tuturuuu/icons';
4
4
  import type { Workspace } from '@tuturuuu/types';
5
5
  import type { CalendarEvent } from '@tuturuuu/types/primitives/calendar-event';
6
6
  import { useCalendar } from '@tuturuuu/ui/hooks/use-calendar';
@@ -17,16 +17,27 @@ import {
17
17
  isYesterday,
18
18
  startOfDay,
19
19
  } from 'date-fns';
20
+ import dayjs from 'dayjs';
21
+ import timezone from 'dayjs/plugin/timezone';
22
+ import utc from 'dayjs/plugin/utc';
20
23
  import { useTranslations } from 'next-intl';
21
- import { useMemo } from 'react';
24
+ import { useMemo, useState } from 'react';
22
25
  import {
23
26
  formatLunarDay,
24
27
  getLunarDate,
25
28
  getLunarHolidayName,
26
29
  isSpecialLunarDate,
27
30
  } from '../../../../lib/lunar-calendar';
31
+ import { Button } from '../../button';
32
+ import { Input } from '../../input';
33
+ import { calendarDraftDate } from './calendar-period';
34
+ import { useCalendarSettings } from './settings/settings-context';
35
+
36
+ dayjs.extend(utc);
37
+ dayjs.extend(timezone);
28
38
 
29
39
  interface AgendaViewProps {
40
+ readOnly?: boolean;
30
41
  startDate: Date;
31
42
  workspace?: Workspace;
32
43
  locale?: string;
@@ -173,7 +184,7 @@ function EventCard({
173
184
  {/* Content */}
174
185
  <div className="min-w-0 flex-1">
175
186
  <span className="line-clamp-1 font-medium text-foreground text-sm leading-tight">
176
- {event.title || 'Untitled event'}
187
+ {event.title || t('views.untitled_event')}
177
188
  </span>
178
189
 
179
190
  {(event.location || (!isAllDay && event.description)) && (
@@ -258,7 +269,7 @@ function DateHeader({
258
269
  today ? 'text-primary-foreground/70' : 'text-muted-foreground'
259
270
  )}
260
271
  >
261
- {format(date, 'EEE')}
272
+ {date.toLocaleDateString(locale, { weekday: 'short' })}
262
273
  </span>
263
274
  </div>
264
275
 
@@ -271,7 +282,11 @@ function DateHeader({
271
282
  today ? 'text-primary' : 'text-foreground'
272
283
  )}
273
284
  >
274
- {format(date, 'EEEE, MMMM d')}
285
+ {date.toLocaleDateString(locale, {
286
+ weekday: 'long',
287
+ month: 'long',
288
+ day: 'numeric',
289
+ })}
275
290
  </span>
276
291
  {relativeLabel && (
277
292
  <span
@@ -313,11 +328,24 @@ function DateHeader({
313
328
 
314
329
  export const AgendaView = ({
315
330
  startDate,
331
+ readOnly = false,
316
332
  locale = 'en',
317
333
  daysToShow = 30,
318
334
  }: AgendaViewProps) => {
319
335
  const t = useTranslations('calendar');
320
- const { getCurrentEvents, openModal } = useCalendar();
336
+ const {
337
+ getCurrentEvents,
338
+ openModal,
339
+ addEmptyEvent,
340
+ readOnly: providerReadOnly,
341
+ } = useCalendar();
342
+ const { settings } = useCalendarSettings();
343
+ const cannotCreate = readOnly || providerReadOnly;
344
+ const createEvent = () => {
345
+ if (cannotCreate) return;
346
+ addEmptyEvent(calendarDraftDate(startDate, settings?.timezone?.timezone));
347
+ };
348
+ const [query, setQuery] = useState('');
321
349
  const { timeFormat: rawTimeFormat } = useCalendarPreferences();
322
350
  const { value: showLunar } = useUserBooleanConfig(
323
351
  'SHOW_LUNAR_CALENDAR',
@@ -331,7 +359,11 @@ export const AgendaView = ({
331
359
 
332
360
  for (let i = 0; i < daysToShow; i++) {
333
361
  const currentDate = addDays(startOfDay(startDate), i);
334
- const events = getCurrentEvents(currentDate);
362
+ const events = getCurrentEvents(currentDate).filter((event) =>
363
+ `${event.title ?? ''} ${event.location ?? ''}`
364
+ .toLocaleLowerCase(locale)
365
+ .includes(query.toLocaleLowerCase(locale))
366
+ );
335
367
 
336
368
  if (events.length > 0) {
337
369
  const sortedEvents = [...events].sort((a, b) => {
@@ -348,50 +380,110 @@ export const AgendaView = ({
348
380
  }
349
381
 
350
382
  return groups;
351
- }, [startDate, daysToShow, getCurrentEvents]);
352
-
353
- if (groupedEvents.length === 0) {
354
- return (
355
- <div className="flex h-full flex-col items-center justify-center p-8 text-center">
356
- <div className="mb-4 flex h-16 w-16 items-center justify-center rounded-2xl bg-muted">
357
- <CalendarX2 className="h-8 w-8 text-muted-foreground" />
358
- </div>
359
- <h3 className="mb-1 font-semibold text-lg">
360
- {t('agenda_empty_title')}
361
- </h3>
362
- <p className="max-w-xs text-muted-foreground text-sm">
363
- {t('agenda_empty_subtitle', { days: daysToShow })}
364
- </p>
365
- </div>
366
- );
367
- }
383
+ }, [startDate, daysToShow, getCurrentEvents, query, locale]);
368
384
 
385
+ const uniqueEvents = new Set(
386
+ groupedEvents.flatMap((group) => group.events.map((event) => event.id))
387
+ ).size;
369
388
  return (
370
- <div className="h-full overflow-auto">
371
- <div className="mx-auto max-w-2xl space-y-1 p-4">
372
- {groupedEvents.map(({ date, events }) => (
373
- <div key={date.toISOString()}>
374
- <DateHeader
375
- date={date}
376
- eventCount={events.length}
377
- locale={locale}
378
- showLunar={showLunar}
379
- t={t as any}
380
- />
381
- <div className="space-y-1 pb-4 pl-14">
382
- {events.map((event) => (
383
- <EventCard
384
- key={event.id}
385
- event={event}
386
- timePattern={timePattern}
387
- timeFormat={timeFormat}
388
- t={t as any}
389
- onOpen={openModal}
390
- />
391
- ))}
389
+ <div
390
+ className="h-full overflow-auto bg-background"
391
+ data-calendar-view="agenda"
392
+ >
393
+ <div className="mx-auto max-w-4xl px-4 py-5 sm:px-8">
394
+ <div className="mb-6 flex flex-wrap items-end justify-between gap-4 border-b pb-5">
395
+ <div className="space-y-1">
396
+ <h2 className="font-semibold text-xl tracking-tight">
397
+ {t('agenda')}
398
+ </h2>
399
+ <p className="text-muted-foreground text-sm tabular-nums">
400
+ {startDate.toLocaleDateString(locale, {
401
+ month: 'short',
402
+ day: 'numeric',
403
+ })}{' '}
404
+ {' '}
405
+ {addDays(startDate, daysToShow - 1).toLocaleDateString(locale, {
406
+ month: 'short',
407
+ day: 'numeric',
408
+ year: 'numeric',
409
+ })}
410
+ </p>
411
+ <p className="text-sm">
412
+ {t('agenda_event_count', { count: uniqueEvents })}
413
+ </p>
414
+ </div>
415
+ <div className="flex w-full items-center gap-2 sm:w-auto">
416
+ <div className="relative flex-1">
417
+ <Search className="absolute top-2.5 left-3 size-4 text-muted-foreground" />
418
+ <Input
419
+ className="pl-9"
420
+ value={query}
421
+ onChange={(event) => setQuery(event.target.value)}
422
+ placeholder={t('views.search_events')}
423
+ aria-label={t('views.search_events')}
424
+ />
392
425
  </div>
426
+ <Button
427
+ variant="outline"
428
+ size="icon"
429
+ aria-label={t('views.create_event')}
430
+ disabled={cannotCreate}
431
+ onClick={createEvent}
432
+ >
433
+ <Plus className="size-4" />
434
+ </Button>
393
435
  </div>
394
- ))}
436
+ </div>
437
+ {groupedEvents.length ? (
438
+ groupedEvents.map(({ date, events }) => (
439
+ <section key={date.toISOString()} className="relative mb-5">
440
+ <DateHeader
441
+ date={date}
442
+ eventCount={events.length}
443
+ locale={locale}
444
+ showLunar={showLunar}
445
+ t={t}
446
+ />
447
+ <div className="ml-5 space-y-2 border-l pb-2 pl-4 sm:ml-5 sm:pl-8">
448
+ {events.map((event) => (
449
+ <EventCard
450
+ key={event.id}
451
+ event={event}
452
+ timePattern={timePattern}
453
+ timeFormat={timeFormat}
454
+ t={t}
455
+ onOpen={openModal}
456
+ />
457
+ ))}
458
+ </div>
459
+ </section>
460
+ ))
461
+ ) : (
462
+ <div className="flex min-h-64 flex-col items-center justify-center gap-3 text-center">
463
+ <CalendarX2 className="size-8 text-muted-foreground" />
464
+ <h3 className="font-semibold">
465
+ {t(query ? 'views.no_matches' : 'agenda_empty_title')}
466
+ </h3>
467
+ {!query && (
468
+ <p className="max-w-sm text-muted-foreground text-sm">
469
+ {t('agenda_empty_subtitle', { days: daysToShow })}
470
+ </p>
471
+ )}
472
+ {query ? (
473
+ <Button variant="outline" onClick={() => setQuery('')}>
474
+ {t('views.clear_search')}
475
+ </Button>
476
+ ) : (
477
+ <Button
478
+ variant="outline"
479
+ disabled={cannotCreate}
480
+ onClick={createEvent}
481
+ >
482
+ {t('views.create_event')}
483
+ </Button>
484
+ )}
485
+ </div>
486
+ )}
395
487
  </div>
396
488
  </div>
397
489
  );