@tuturuuu/ui 0.30.2 → 0.32.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 (92) hide show
  1. package/CHANGELOG.md +49 -0
  2. package/biome.json +1 -1
  3. package/package.json +95 -49
  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/chat/ai-message-markdown.tsx +17 -2
  16. package/src/components/ui/custom/language-dropdown-item.tsx +6 -14
  17. package/src/components/ui/custom/nav-link.tsx +7 -7
  18. package/src/components/ui/custom/nav.tsx +15 -63
  19. package/src/components/ui/custom/notification-card.tsx +361 -0
  20. package/src/components/ui/custom/notification-list.tsx +178 -0
  21. package/src/components/ui/custom/notification-popover-client.tsx +12 -780
  22. package/src/components/ui/custom/notification-runtime.tsx +23 -0
  23. package/src/components/ui/custom/onboarding-settings-panel.tsx +8 -55
  24. package/src/components/ui/custom/satellite-account-switcher-menu.tsx +212 -0
  25. package/src/components/ui/custom/satellite-brand.tsx +122 -0
  26. package/src/components/ui/custom/satellite-content.tsx +148 -0
  27. package/src/components/ui/custom/satellite-footer-actions.tsx +350 -0
  28. package/src/components/ui/custom/satellite-language-item.tsx +26 -0
  29. package/src/components/ui/custom/satellite-navigation.tsx +86 -0
  30. package/src/components/ui/custom/satellite-notification-popover.tsx +272 -0
  31. package/src/components/ui/custom/satellite-onboarding-settings-panel.tsx +80 -0
  32. package/src/components/ui/custom/satellite-settings-dialog-shell.tsx +621 -0
  33. package/src/components/ui/custom/satellite-shell-utils.ts +163 -0
  34. package/src/components/ui/custom/satellite-shell.test.tsx +169 -0
  35. package/src/components/ui/custom/satellite-shell.tsx +50 -0
  36. package/src/components/ui/custom/satellite-theme-dropdown-items.tsx +92 -0
  37. package/src/components/ui/custom/satellite-user-menu-items.tsx +190 -0
  38. package/src/components/ui/custom/satellite-user-menu.tsx +93 -0
  39. package/src/components/ui/custom/satellite-workspace-section.tsx +46 -0
  40. package/src/components/ui/custom/settings/keyboard-shortcuts-settings.tsx +16 -0
  41. package/src/components/ui/custom/settings-dialog-shell.tsx +21 -600
  42. package/src/components/ui/custom/sidebar-context.tsx +5 -14
  43. package/src/components/ui/custom/sidebar-footer-actions.tsx +19 -322
  44. package/src/components/ui/custom/structure.tsx +14 -0
  45. package/src/components/ui/custom/system-language-dropdown-item.tsx +7 -14
  46. package/src/components/ui/custom/theme-dropdown-items.tsx +2 -85
  47. package/src/components/ui/custom/use-satellite-shell.tsx +165 -0
  48. package/src/components/ui/legacy/calendar/agenda-view.tsx +139 -47
  49. package/src/components/ui/legacy/calendar/calendar-content.test.tsx +116 -0
  50. package/src/components/ui/legacy/calendar/calendar-content.tsx +38 -74
  51. package/src/components/ui/legacy/calendar/calendar-event-tone.ts +20 -0
  52. package/src/components/ui/legacy/calendar/calendar-meeting-link.test.ts +27 -0
  53. package/src/components/ui/legacy/calendar/calendar-meeting-link.ts +34 -0
  54. package/src/components/ui/legacy/calendar/calendar-period.ts +28 -0
  55. package/src/components/ui/legacy/calendar/calendar-shortcuts.test.tsx +107 -0
  56. package/src/components/ui/legacy/calendar/calendar-views.test.tsx +169 -0
  57. package/src/components/ui/legacy/calendar/event-modal-header.tsx +52 -0
  58. package/src/components/ui/legacy/calendar/event-modal.tsx +107 -156
  59. package/src/components/ui/legacy/calendar/month-calendar.tsx +242 -933
  60. package/src/components/ui/legacy/calendar/save-calendar-event-drafts.test.ts +25 -0
  61. package/src/components/ui/legacy/calendar/save-calendar-event-drafts.ts +32 -0
  62. package/src/components/ui/legacy/calendar/smart-calendar.tsx +3 -0
  63. package/src/components/ui/legacy/calendar/use-calendar-view-shortcuts.ts +54 -0
  64. package/src/components/ui/legacy/calendar/use-event-draft-session.test.tsx +59 -0
  65. package/src/components/ui/legacy/calendar/use-event-draft-session.ts +20 -0
  66. package/src/components/ui/legacy/calendar/year-calendar.tsx +34 -16
  67. package/src/components/ui/pwa-status.test.tsx +53 -0
  68. package/src/components/ui/pwa-status.tsx +113 -0
  69. package/src/components/ui/report-problem-dialog-content.tsx +350 -0
  70. package/src/components/ui/report-problem-dialog.tsx +6 -975
  71. package/src/components/ui/report-problem-model.ts +133 -0
  72. package/src/components/ui/report-problem-mutation.ts +122 -0
  73. package/src/components/ui/save-notification.test.tsx +36 -0
  74. package/src/components/ui/save-notification.tsx +17 -0
  75. package/src/components/ui/use-report-problem.ts +488 -0
  76. package/src/declarations.d.ts +2 -0
  77. package/src/hooks/__tests__/use-calendar-readonly.test.tsx +46 -4
  78. package/src/hooks/__tests__/use-calendar-sync.test.tsx +24 -0
  79. package/src/hooks/calendar-date-utils.ts +11 -0
  80. package/src/hooks/calendar-event-lookup.test.ts +77 -0
  81. package/src/hooks/calendar-event-lookup.ts +47 -0
  82. package/src/hooks/calendar-range-cache.ts +52 -0
  83. package/src/hooks/pwa-install-prompt.ts +44 -0
  84. package/src/hooks/use-calendar-sync.tsx +23 -100
  85. package/src/hooks/use-calendar.tsx +21 -77
  86. package/src/hooks/use-notifications.ts +9 -24
  87. package/src/hooks/use-open-initial-calendar-event.ts +27 -0
  88. package/src/hooks/use-settings-dialog-shortcut.test.tsx +41 -0
  89. package/src/hooks/use-settings-dialog-shortcut.ts +12 -14
  90. package/src/hooks/use-workspace-presence.ts +0 -18
  91. package/src/lib/tasks-app-url.test.ts +25 -0
  92. package/src/lib/tasks-app-url.ts +8 -0
@@ -0,0 +1,28 @@
1
+ import dayjs from 'dayjs';
2
+ import timezone from 'dayjs/plugin/timezone';
3
+ import utc from 'dayjs/plugin/utc';
4
+
5
+ dayjs.extend(utc);
6
+ dayjs.extend(timezone);
7
+
8
+ /** Endpoints are inclusive days; the event query expands the final day. */
9
+ export function calendarPeriodDates(
10
+ date: Date,
11
+ view: 'year' | 'agenda'
12
+ ): Date[] {
13
+ const start = new Date(date);
14
+ start.setHours(0, 0, 0, 0);
15
+ if (view === 'year') start.setMonth(0, 1);
16
+ const end = new Date(start);
17
+ if (view === 'year') end.setMonth(11, 31);
18
+ else end.setDate(end.getDate() + 29);
19
+ return [start, end];
20
+ }
21
+
22
+ /** Create at 9 AM on the selected calendar day, in its configured zone. */
23
+ export function calendarDraftDate(date: Date, zone?: string): Date {
24
+ const wallTime = `${dayjs(date).format('YYYY-MM-DD')}T09:00:00`;
25
+ return (
26
+ zone && zone !== 'auto' ? dayjs.tz(wallTime, zone) : dayjs(wallTime)
27
+ ).toDate();
28
+ }
@@ -0,0 +1,107 @@
1
+ import { fireEvent, render, screen } from '@testing-library/react';
2
+ import { useState } from 'react';
3
+ import { describe, expect, it } from 'vitest';
4
+ import type { CalendarView } from '../../../../hooks/use-view-transition';
5
+ import { calendarDraftDate, calendarPeriodDates } from './calendar-period';
6
+ import {
7
+ calendarShortcutView,
8
+ useCalendarViewShortcuts,
9
+ } from './use-calendar-view-shortcuts';
10
+
11
+ function ControlledViews({ disabledWeek = false }: { disabledWeek?: boolean }) {
12
+ const [view, setView] = useState<CalendarView>('week');
13
+ useCalendarViewShortcuts({
14
+ enabled: true,
15
+ availableViews: disabledWeek
16
+ ? [
17
+ { value: 'week', disabled: true },
18
+ { value: '4-days', disabled: true },
19
+ { value: 'month' },
20
+ ]
21
+ : undefined,
22
+ day: () => setView('day'),
23
+ '4-days': () => setView('4-days'),
24
+ week: () => setView('week'),
25
+ month: () => setView('month'),
26
+ year: () => setView('year'),
27
+ agenda: () => setView('agenda'),
28
+ });
29
+ return (
30
+ <>
31
+ <output>{view}</output>
32
+ <input aria-label="Event title" />
33
+ </>
34
+ );
35
+ }
36
+ describe('calendar keyboard navigation', () => {
37
+ it('switches views with M and preserves typing', () => {
38
+ render(<ControlledViews />);
39
+ fireEvent.keyDown(window, { key: 'M' });
40
+ expect(screen.getByText('month')).toBeVisible();
41
+ fireEvent.keyDown(screen.getByRole('textbox'), { key: 'y' });
42
+ expect(screen.getByText('month')).toBeVisible();
43
+ fireEvent.keyDown(window, { key: 'a' });
44
+ expect(screen.getByText('agenda')).toBeVisible();
45
+ });
46
+ it.each([
47
+ { ctrlKey: true },
48
+ { metaKey: true },
49
+ { altKey: true },
50
+ { shiftKey: true },
51
+ { isComposing: true },
52
+ { repeat: true },
53
+ ])('ignores competing commands %j', (options) => {
54
+ expect(
55
+ calendarShortcutView(
56
+ new KeyboardEvent('keydown', { key: 'm', ...options })
57
+ )
58
+ ).toBeNull();
59
+ });
60
+ it('does not invoke views disabled for the current screen', () => {
61
+ render(<ControlledViews disabledWeek />);
62
+ fireEvent.keyDown(window, { key: 'm' });
63
+ fireEvent.keyDown(window, { key: 'w' });
64
+ fireEvent.keyDown(window, { key: '4' });
65
+ expect(screen.getByText('month')).toBeVisible();
66
+ });
67
+ it('does not change views behind a modal', () => {
68
+ render(
69
+ <div role="dialog" data-state="open">
70
+ Settings
71
+ </div>
72
+ );
73
+ expect(
74
+ calendarShortcutView(new KeyboardEvent('keydown', { key: 'm' }))
75
+ ).toBeNull();
76
+ });
77
+ });
78
+ describe('event ranges', () => {
79
+ it('creates 9 AM drafts in the selected zone without caller plugin setup', () => {
80
+ expect(
81
+ calendarDraftDate(new Date(2026, 8, 7), 'Asia/Ho_Chi_Minh').toISOString()
82
+ ).toBe('2026-09-07T02:00:00.000Z');
83
+ });
84
+ it('fetches the entire leap year', () => {
85
+ const dates = calendarPeriodDates(new Date(2028, 8, 7), 'year');
86
+ expect(
87
+ dates.map((date) => [date.getFullYear(), date.getMonth(), date.getDate()])
88
+ ).toEqual([
89
+ [2028, 0, 1],
90
+ [2028, 11, 31],
91
+ ]);
92
+ });
93
+ it('fetches all 30 agenda days across a year boundary', () => {
94
+ const dates = calendarPeriodDates(new Date(2026, 11, 20, 15), 'agenda');
95
+ expect(
96
+ dates.map((date) => [
97
+ date.getFullYear(),
98
+ date.getMonth(),
99
+ date.getDate(),
100
+ date.getHours(),
101
+ ])
102
+ ).toEqual([
103
+ [2026, 11, 20, 0],
104
+ [2027, 0, 18, 0],
105
+ ]);
106
+ });
107
+ });
@@ -0,0 +1,169 @@
1
+ import { fireEvent, render, screen, within } from '@testing-library/react';
2
+ import type { CalendarEvent } from '@tuturuuu/types/primitives/calendar-event';
3
+ import { beforeEach, describe, expect, it, vi } from 'vitest';
4
+
5
+ const state = vi.hoisted(() => ({
6
+ openModal: vi.fn(),
7
+ addEmptyEvent: vi.fn(),
8
+ events: [] as CalendarEvent[],
9
+ weekStartsOn: 0,
10
+ }));
11
+ vi.mock('@tuturuuu/ui/hooks/use-calendar', () => ({
12
+ useCalendar: () => ({
13
+ ...state,
14
+ getCurrentEvents: (day: Date) =>
15
+ state.events.filter(
16
+ (event) =>
17
+ new Date(event.start_at).toDateString() === day.toDateString()
18
+ ),
19
+ }),
20
+ }));
21
+ vi.mock('@tuturuuu/ui/hooks/use-calendar-preferences', () => ({
22
+ useCalendarPreferences: () => ({
23
+ timeFormat: '24h',
24
+ weekStartsOn: state.weekStartsOn,
25
+ }),
26
+ }));
27
+ vi.mock('@tuturuuu/ui/hooks/use-user-config', () => ({
28
+ useUserBooleanConfig: () => ({ value: false }),
29
+ }));
30
+ vi.mock('./settings/settings-context', () => ({
31
+ useCalendarSettings: () => ({
32
+ settings: {
33
+ appearance: { showWeekends: true },
34
+ timezone: { timezone: 'America/New_York' },
35
+ },
36
+ }),
37
+ }));
38
+ vi.mock('next-intl', () => ({
39
+ useTranslations: () => (key: string, values?: Record<string, unknown>) =>
40
+ `${key}${values ? ` ${Object.values(values).join(' ')}` : ''}`,
41
+ }));
42
+
43
+ import { AgendaView } from './agenda-view';
44
+ import { MonthCalendar } from './month-calendar';
45
+ import { YearCalendar } from './year-calendar';
46
+
47
+ const date = new Date(2026, 8, 7);
48
+ beforeEach(() => {
49
+ state.weekStartsOn = 0;
50
+ state.openModal.mockClear();
51
+ state.addEmptyEvent.mockClear();
52
+ state.events = Array.from(
53
+ { length: 5 },
54
+ (_, i) =>
55
+ ({
56
+ id: String(i),
57
+ title: `Event ${i}`,
58
+ start_at: new Date(2026, 8, 7, 9 + i).toISOString(),
59
+ end_at: new Date(2026, 8, 7, 10 + i).toISOString(),
60
+ color: 'BLUE',
61
+ location: i === 4 ? 'Library' : undefined,
62
+ }) as CalendarEvent
63
+ );
64
+ });
65
+ describe('calendar view interactions', () => {
66
+ it('month overflow opens every event and creates on the chosen calendar date and timezone', () => {
67
+ render(<MonthCalendar date={date} viewedMonth={date} locale="en" />);
68
+ const day = screen.getByRole('region', {
69
+ name: date.toLocaleDateString('en', { dateStyle: 'full' }),
70
+ });
71
+ expect(within(day).getAllByRole('button', { name: /Event/ })).toHaveLength(
72
+ 3
73
+ );
74
+ fireEvent.click(
75
+ within(day).getByRole('button', { name: /views.open_day/ })
76
+ );
77
+ fireEvent.click(screen.getByRole('button', { name: /Event 4/ }));
78
+ expect(state.openModal).toHaveBeenCalledWith('4');
79
+ fireEvent.click(
80
+ within(day).getByRole('button', { name: /views.create_on_date/ })
81
+ );
82
+ expect(state.addEmptyEvent.mock.calls[0]?.[0].toISOString()).toBe(
83
+ '2026-09-07T13:00:00.000Z'
84
+ );
85
+ });
86
+ it('uses the preferred first weekday unless visible dates specify one', () => {
87
+ const { rerender } = render(<MonthCalendar date={date} locale="en" />);
88
+ expect(screen.getAllByRole('region')[0]).toHaveAccessibleName(
89
+ 'Sunday, August 30, 2026'
90
+ );
91
+ state.weekStartsOn = 1;
92
+ rerender(<MonthCalendar date={date} locale="en" />);
93
+ expect(screen.getAllByRole('region')[0]).toHaveAccessibleName(
94
+ 'Monday, August 31, 2026'
95
+ );
96
+ rerender(
97
+ <MonthCalendar
98
+ date={date}
99
+ locale="en"
100
+ visibleDates={[new Date(2026, 8, 6)]}
101
+ />
102
+ );
103
+ expect(screen.getAllByRole('region')[0]).toHaveAccessibleName(
104
+ 'Sunday, September 6, 2026'
105
+ );
106
+ });
107
+ it('formats month events in the selected timezone', () => {
108
+ state.events = [
109
+ {
110
+ id: 'zoned',
111
+ title: 'Zoned',
112
+ start_at: '2026-09-07T13:00:00Z',
113
+ end_at: '2026-09-07T14:00:00Z',
114
+ },
115
+ ];
116
+ render(<MonthCalendar date={date} locale="en" />);
117
+ expect(screen.getByRole('button', { name: /Zoned/ })).toHaveTextContent(
118
+ '09:00'
119
+ );
120
+ });
121
+ it('disables read-only creation and uses 9am calendar time for agenda drafts', () => {
122
+ const { rerender } = render(<AgendaView startDate={date} readOnly />);
123
+ const create = screen.getByRole('button', { name: 'views.create_event' });
124
+ expect(create).toBeDisabled();
125
+ fireEvent.click(create);
126
+ expect(state.addEmptyEvent).not.toHaveBeenCalled();
127
+ rerender(<AgendaView startDate={date} />);
128
+ fireEvent.click(screen.getByRole('button', { name: 'views.create_event' }));
129
+ expect(state.addEmptyEvent.mock.calls[0]?.[0].toISOString()).toBe(
130
+ '2026-09-07T13:00:00.000Z'
131
+ );
132
+ });
133
+ it('agenda filters by location, opens events, and clears a no-match search', () => {
134
+ render(<AgendaView startDate={date} locale="vi" />);
135
+ fireEvent.change(screen.getByRole('textbox'), {
136
+ target: { value: 'Library' },
137
+ });
138
+ expect(screen.getByText('Event 4')).toBeVisible();
139
+ expect(screen.queryByText('Event 0')).toBeNull();
140
+ fireEvent.click(screen.getByText('Event 4'));
141
+ expect(state.openModal).toHaveBeenCalledWith('4');
142
+ fireEvent.change(screen.getByRole('textbox'), {
143
+ target: { value: 'No match' },
144
+ });
145
+ expect(screen.getByText('views.no_matches')).toBeVisible();
146
+ fireEvent.click(screen.getByRole('button', { name: 'views.clear_search' }));
147
+ expect(screen.getByText('Event 0')).toBeVisible();
148
+ });
149
+ it('year localizes month labels and supports month and day drill-down', () => {
150
+ const onMonthClick = vi.fn();
151
+ const onDayClick = vi.fn();
152
+ render(
153
+ <YearCalendar
154
+ year={2026}
155
+ locale="vi"
156
+ onMonthClick={onMonthClick}
157
+ onDayClick={onDayClick}
158
+ />
159
+ );
160
+ fireEvent.click(screen.getByRole('button', { name: /^Tháng 9/i }));
161
+ expect(onMonthClick.mock.calls[0]?.[0].getMonth()).toBe(8);
162
+ fireEvent.click(
163
+ screen.getByRole('button', {
164
+ name: new RegExp(date.toLocaleDateString('vi', { dateStyle: 'full' })),
165
+ })
166
+ );
167
+ expect(onDayClick.mock.calls[0]?.[0].getDate()).toBe(7);
168
+ });
169
+ });
@@ -0,0 +1,52 @@
1
+ import { ExternalLink, Video } from '@tuturuuu/icons';
2
+ import type { CalendarEvent } from '@tuturuuu/types/primitives/calendar-event';
3
+ import { Button } from '@tuturuuu/ui/button';
4
+ import {
5
+ DialogDescription,
6
+ DialogHeader,
7
+ DialogTitle,
8
+ } from '@tuturuuu/ui/dialog';
9
+ import { getCalendarMeetingUrl } from './calendar-meeting-link';
10
+ import {
11
+ CalendarEventProviderIcon,
12
+ getCalendarEventProviderDisplay,
13
+ } from './event-provider-display';
14
+
15
+ export function EventModalHeader({
16
+ event,
17
+ isEditing,
18
+ }: {
19
+ event: Partial<CalendarEvent>;
20
+ isEditing: boolean;
21
+ }) {
22
+ const providerDisplay = getCalendarEventProviderDisplay(event);
23
+ const meetingUrl = getCalendarMeetingUrl(event);
24
+
25
+ return (
26
+ <DialogHeader className="border-b px-6 pt-6 pb-4">
27
+ <DialogTitle className="flex flex-wrap items-center gap-2 font-semibold text-xl">
28
+ <span>{isEditing ? 'Edit Event' : 'Create Event'}</span>
29
+ {providerDisplay && (
30
+ <span className="ml-3 flex items-center gap-2 rounded-md border bg-muted/40 px-3 py-1 text-sm">
31
+ <CalendarEventProviderIcon className="h-4.5 w-4.5" event={event} />
32
+ <span className="font-medium text-xs">{providerDisplay.label}</span>
33
+ </span>
34
+ )}
35
+ {meetingUrl && (
36
+ <Button asChild className="ml-auto" size="sm" variant="outline">
37
+ <a href={meetingUrl} rel="noreferrer" target="_blank">
38
+ <Video className="size-4" />
39
+ Tuturuuu Meet
40
+ <ExternalLink className="size-3.5" />
41
+ </a>
42
+ </Button>
43
+ )}
44
+ </DialogTitle>
45
+ <DialogDescription>
46
+ {isEditing
47
+ ? 'Make changes to your existing event'
48
+ : 'Add a new event to your calendar'}
49
+ </DialogDescription>
50
+ </DialogHeader>
51
+ );
52
+ }