@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.
- package/CHANGELOG.md +49 -0
- package/biome.json +1 -1
- package/package.json +103 -57
- package/src/components/ui/calendar-app/calendar-client-page.test.tsx +57 -0
- package/src/components/ui/calendar-app/calendar-client-page.tsx +13 -4
- package/src/components/ui/calendar-app/calendar-page-shell.tsx +7 -1
- package/src/components/ui/calendar-app/components/calendar-connections-compact.tsx +47 -39
- package/src/components/ui/calendar-app/components/calendar-connections-settings-content.tsx +14 -0
- package/src/components/ui/calendar-app/components/calendar-connections-unified.tsx +4 -0
- package/src/components/ui/calendar-app/components/calendar-sync-attention-button.tsx +47 -0
- package/src/components/ui/calendar-app/components/calendar-sync-recovery.test.tsx +112 -0
- package/src/components/ui/calendar-app/components/calendar-sync-recovery.tsx +206 -0
- package/src/components/ui/calendar-app/components/calendar-types.ts +1 -1
- package/src/components/ui/calendar-app/components/use-calendar-connections-manager.ts +86 -90
- package/src/components/ui/custom/language-dropdown-item.tsx +6 -14
- package/src/components/ui/custom/nav-link.tsx +7 -7
- package/src/components/ui/custom/nav.tsx +15 -63
- package/src/components/ui/custom/notification-card.tsx +361 -0
- package/src/components/ui/custom/notification-list.tsx +178 -0
- package/src/components/ui/custom/notification-popover-client.tsx +12 -780
- package/src/components/ui/custom/notification-runtime.tsx +23 -0
- package/src/components/ui/custom/onboarding-settings-panel.tsx +8 -55
- package/src/components/ui/custom/satellite-account-switcher-menu.tsx +212 -0
- package/src/components/ui/custom/satellite-brand.tsx +122 -0
- package/src/components/ui/custom/satellite-content.tsx +148 -0
- package/src/components/ui/custom/satellite-footer-actions.tsx +350 -0
- package/src/components/ui/custom/satellite-language-item.tsx +26 -0
- package/src/components/ui/custom/satellite-navigation.tsx +86 -0
- package/src/components/ui/custom/satellite-notification-popover.tsx +272 -0
- package/src/components/ui/custom/satellite-onboarding-settings-panel.tsx +80 -0
- package/src/components/ui/custom/satellite-settings-dialog-shell.tsx +621 -0
- package/src/components/ui/custom/satellite-shell-utils.ts +163 -0
- package/src/components/ui/custom/satellite-shell.test.tsx +169 -0
- package/src/components/ui/custom/satellite-shell.tsx +50 -0
- package/src/components/ui/custom/satellite-theme-dropdown-items.tsx +92 -0
- package/src/components/ui/custom/satellite-user-menu-items.tsx +190 -0
- package/src/components/ui/custom/satellite-user-menu.tsx +93 -0
- package/src/components/ui/custom/satellite-workspace-section.tsx +46 -0
- package/src/components/ui/custom/settings/keyboard-shortcuts-settings.tsx +16 -0
- package/src/components/ui/custom/settings-dialog-shell.tsx +21 -600
- package/src/components/ui/custom/sidebar-context.tsx +5 -14
- package/src/components/ui/custom/sidebar-footer-actions.tsx +19 -322
- package/src/components/ui/custom/structure.tsx +14 -0
- package/src/components/ui/custom/system-language-dropdown-item.tsx +7 -14
- package/src/components/ui/custom/theme-dropdown-items.tsx +2 -85
- package/src/components/ui/custom/use-satellite-shell.tsx +165 -0
- package/src/components/ui/finance/invoices/subscription-invoice.test.tsx +49 -13
- package/src/components/ui/finance/invoices/subscription-invoice.tsx +4 -12
- package/src/components/ui/finance/invoices/utils.test.ts +6 -5
- package/src/components/ui/finance/invoices/utils.ts +3 -1
- package/src/components/ui/finance/transactions/columns.test.tsx +25 -1
- package/src/components/ui/finance/transactions/columns.tsx +45 -16
- package/src/components/ui/legacy/calendar/agenda-view.tsx +139 -47
- package/src/components/ui/legacy/calendar/calendar-content.test.tsx +116 -0
- package/src/components/ui/legacy/calendar/calendar-content.tsx +38 -74
- package/src/components/ui/legacy/calendar/calendar-event-tone.ts +20 -0
- package/src/components/ui/legacy/calendar/calendar-meeting-link.test.ts +27 -0
- package/src/components/ui/legacy/calendar/calendar-meeting-link.ts +34 -0
- package/src/components/ui/legacy/calendar/calendar-period.ts +28 -0
- package/src/components/ui/legacy/calendar/calendar-shortcuts.test.tsx +107 -0
- package/src/components/ui/legacy/calendar/calendar-views.test.tsx +169 -0
- package/src/components/ui/legacy/calendar/event-modal-header.tsx +52 -0
- package/src/components/ui/legacy/calendar/event-modal.tsx +107 -156
- package/src/components/ui/legacy/calendar/month-calendar.tsx +242 -933
- package/src/components/ui/legacy/calendar/save-calendar-event-drafts.test.ts +25 -0
- package/src/components/ui/legacy/calendar/save-calendar-event-drafts.ts +32 -0
- package/src/components/ui/legacy/calendar/smart-calendar.tsx +3 -0
- package/src/components/ui/legacy/calendar/use-calendar-view-shortcuts.ts +54 -0
- package/src/components/ui/legacy/calendar/use-event-draft-session.test.tsx +59 -0
- package/src/components/ui/legacy/calendar/use-event-draft-session.ts +20 -0
- package/src/components/ui/legacy/calendar/year-calendar.tsx +34 -16
- package/src/components/ui/pwa-status.test.tsx +53 -0
- package/src/components/ui/pwa-status.tsx +113 -0
- package/src/components/ui/report-problem-dialog-content.tsx +350 -0
- package/src/components/ui/report-problem-dialog.tsx +6 -975
- package/src/components/ui/report-problem-model.ts +133 -0
- package/src/components/ui/report-problem-mutation.ts +122 -0
- package/src/components/ui/save-notification.test.tsx +36 -0
- package/src/components/ui/save-notification.tsx +17 -0
- package/src/components/ui/text-editor/task-item-checkbox-view.test.tsx +85 -0
- package/src/components/ui/text-editor/task-item-checkbox-view.tsx +28 -18
- package/src/components/ui/use-report-problem.ts +488 -0
- package/src/declarations.d.ts +2 -0
- package/src/hooks/__tests__/use-calendar-readonly.test.tsx +46 -4
- package/src/hooks/__tests__/use-calendar-sync.test.tsx +24 -0
- package/src/hooks/calendar-date-utils.ts +11 -0
- package/src/hooks/calendar-event-lookup.test.ts +77 -0
- package/src/hooks/calendar-event-lookup.ts +47 -0
- package/src/hooks/calendar-range-cache.ts +52 -0
- package/src/hooks/pwa-install-prompt.ts +44 -0
- package/src/hooks/use-calendar-sync.tsx +23 -100
- package/src/hooks/use-calendar.tsx +21 -77
- package/src/hooks/use-notifications.ts +9 -24
- package/src/hooks/use-open-initial-calendar-event.ts +27 -0
- package/src/hooks/use-settings-dialog-shortcut.test.tsx +41 -0
- package/src/hooks/use-settings-dialog-shortcut.ts +12 -14
- package/src/hooks/use-workspace-presence.ts +0 -18
- package/src/lib/tasks-app-url.test.ts +25 -0
- package/src/lib/tasks-app-url.ts +8 -0
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
import { fireEvent, render, screen } from '@testing-library/react';
|
|
2
|
+
import { useState } from 'react';
|
|
3
|
+
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
|
4
|
+
import type { CalendarView } from '../../../../hooks/use-view-transition';
|
|
5
|
+
|
|
6
|
+
const mocks = vi.hoisted(() => ({
|
|
7
|
+
setDates: vi.fn(),
|
|
8
|
+
transition: (_view: string, action: () => void) => action(),
|
|
9
|
+
dates: [new Date(2026, 8, 7)],
|
|
10
|
+
settings: { appearance: { firstDayOfWeek: 'monday' } },
|
|
11
|
+
}));
|
|
12
|
+
vi.mock('@tuturuuu/ui/hooks/use-calendar-sync', () => ({
|
|
13
|
+
useCalendarSync: () => ({
|
|
14
|
+
dates: mocks.dates,
|
|
15
|
+
setDates: mocks.setDates,
|
|
16
|
+
isLoading: false,
|
|
17
|
+
}),
|
|
18
|
+
}));
|
|
19
|
+
vi.mock('@tuturuuu/ui/hooks/use-view-transition', () => ({
|
|
20
|
+
useViewTransition: () => ({ transition: mocks.transition }),
|
|
21
|
+
}));
|
|
22
|
+
vi.mock('./settings/settings-context', () => ({
|
|
23
|
+
useCalendarSettings: () => ({ settings: mocks.settings }),
|
|
24
|
+
}));
|
|
25
|
+
vi.mock('./calendar-header', () => ({
|
|
26
|
+
CalendarHeader: ({
|
|
27
|
+
view,
|
|
28
|
+
onViewChange,
|
|
29
|
+
}: {
|
|
30
|
+
view: string;
|
|
31
|
+
onViewChange: (view: string) => void;
|
|
32
|
+
}) => (
|
|
33
|
+
<button type="button" onClick={() => onViewChange('month')}>
|
|
34
|
+
Header {view}
|
|
35
|
+
</button>
|
|
36
|
+
),
|
|
37
|
+
}));
|
|
38
|
+
vi.mock('./agenda-view', () => ({ AgendaView: () => <div>Agenda</div> }));
|
|
39
|
+
vi.mock('./calendar-loading-skeleton', () => ({
|
|
40
|
+
CalendarLoadingSkeleton: () => null,
|
|
41
|
+
}));
|
|
42
|
+
vi.mock('./calendar-view-with-trail', () => ({
|
|
43
|
+
CalendarViewWithTrail: () => null,
|
|
44
|
+
}));
|
|
45
|
+
vi.mock('./event-modal', () => ({ EventModal: () => null }));
|
|
46
|
+
vi.mock('./event-preview-popover', () => ({ EventPreviewPopover: () => null }));
|
|
47
|
+
vi.mock('./weekday-bar', () => ({ WeekdayBar: () => null }));
|
|
48
|
+
vi.mock('./month-calendar', () => ({
|
|
49
|
+
MonthCalendar: ({ onDayClick }: { onDayClick: (date: Date) => void }) => (
|
|
50
|
+
<button type="button" onClick={() => onDayClick(new Date(2026, 8, 22))}>
|
|
51
|
+
Open September 22
|
|
52
|
+
</button>
|
|
53
|
+
),
|
|
54
|
+
}));
|
|
55
|
+
vi.mock('./year-calendar', () => ({
|
|
56
|
+
YearCalendar: ({ onMonthClick }: { onMonthClick: (date: Date) => void }) => (
|
|
57
|
+
<button type="button" onClick={() => onMonthClick(new Date(2026, 10, 1))}>
|
|
58
|
+
Open November
|
|
59
|
+
</button>
|
|
60
|
+
),
|
|
61
|
+
}));
|
|
62
|
+
|
|
63
|
+
import { CalendarContent } from './calendar-content';
|
|
64
|
+
|
|
65
|
+
const translate = (key: string) => key;
|
|
66
|
+
function ControlledCalendar({
|
|
67
|
+
initialView = 'week',
|
|
68
|
+
}: {
|
|
69
|
+
initialView?: CalendarView;
|
|
70
|
+
}) {
|
|
71
|
+
const [view, setView] = useState(initialView);
|
|
72
|
+
const [date, setDate] = useState(new Date(2026, 8, 7));
|
|
73
|
+
return (
|
|
74
|
+
<>
|
|
75
|
+
<output>
|
|
76
|
+
{view}:{date.getMonth() + 1}:{date.getDate()}
|
|
77
|
+
</output>
|
|
78
|
+
<CalendarContent
|
|
79
|
+
t={translate}
|
|
80
|
+
locale="en"
|
|
81
|
+
externalState={{ view, setView, date, setDate, availableViews: [] }}
|
|
82
|
+
/>
|
|
83
|
+
</>
|
|
84
|
+
);
|
|
85
|
+
}
|
|
86
|
+
beforeEach(() => {
|
|
87
|
+
localStorage.clear();
|
|
88
|
+
mocks.setDates.mockClear();
|
|
89
|
+
});
|
|
90
|
+
describe('satellite controlled calendar views', () => {
|
|
91
|
+
it('M updates the parent state, persists the month, and supports day drill-down', () => {
|
|
92
|
+
render(<ControlledCalendar />);
|
|
93
|
+
fireEvent.keyDown(window, { key: 'm' });
|
|
94
|
+
expect(screen.getByText('month:9:1')).toBeVisible();
|
|
95
|
+
expect(localStorage.getItem('calendar-view-mode')).toBe('month');
|
|
96
|
+
fireEvent.click(screen.getByText('Open September 22'));
|
|
97
|
+
expect(screen.getByText('day:9:22')).toBeVisible();
|
|
98
|
+
});
|
|
99
|
+
it('the menu enters month and an uncontrolled calendar restores the saved month', () => {
|
|
100
|
+
const mounted = render(<ControlledCalendar />);
|
|
101
|
+
fireEvent.click(screen.getByText('Header week'));
|
|
102
|
+
expect(screen.getByText('month:9:1')).toBeVisible();
|
|
103
|
+
mounted.unmount();
|
|
104
|
+
render(<CalendarContent t={translate} locale="en" />);
|
|
105
|
+
expect(screen.getByText('Header month')).toBeVisible();
|
|
106
|
+
});
|
|
107
|
+
it('year drill-down opens the chosen month', () => {
|
|
108
|
+
render(<ControlledCalendar initialView="year" />);
|
|
109
|
+
fireEvent.click(screen.getByText('Open November'));
|
|
110
|
+
expect(screen.getByText('month:11:1')).toBeVisible();
|
|
111
|
+
const dates = mocks.setDates.mock.calls.at(-1)?.[0] as Date[];
|
|
112
|
+
expect(
|
|
113
|
+
dates.some((date) => date.getMonth() === 10 && date.getDate() === 30)
|
|
114
|
+
).toBe(true);
|
|
115
|
+
});
|
|
116
|
+
});
|
|
@@ -10,11 +10,13 @@ import { useCallback, useEffect, useRef, useState } from 'react';
|
|
|
10
10
|
import { AgendaView } from './agenda-view';
|
|
11
11
|
import { CalendarHeader } from './calendar-header';
|
|
12
12
|
import { CalendarLoadingSkeleton } from './calendar-loading-skeleton';
|
|
13
|
+
import { calendarPeriodDates } from './calendar-period';
|
|
13
14
|
import { CalendarViewWithTrail } from './calendar-view-with-trail';
|
|
14
15
|
import { EventModal } from './event-modal';
|
|
15
16
|
import { EventPreviewPopover } from './event-preview-popover';
|
|
16
17
|
import { MonthCalendar } from './month-calendar';
|
|
17
18
|
import { useCalendarSettings } from './settings/settings-context';
|
|
19
|
+
import { useCalendarViewShortcuts } from './use-calendar-view-shortcuts';
|
|
18
20
|
import { WeekdayBar } from './weekday-bar';
|
|
19
21
|
import { YearCalendar } from './year-calendar';
|
|
20
22
|
|
|
@@ -255,11 +257,11 @@ export const CalendarContent = ({
|
|
|
255
257
|
);
|
|
256
258
|
const newDate = new Date(date);
|
|
257
259
|
newDate.setDate(1);
|
|
258
|
-
|
|
259
|
-
|
|
260
|
+
handleSetView('month');
|
|
261
|
+
handleSetDate(newDate);
|
|
260
262
|
const gridDates = getMonthGridDates(newDate, firstDayNumber);
|
|
261
263
|
setDates(gridDates);
|
|
262
|
-
}, [date, locale, setDates]);
|
|
264
|
+
}, [date, locale, setDates, handleSetView, handleSetDate]);
|
|
263
265
|
|
|
264
266
|
const enableYearView = useCallback(() => {
|
|
265
267
|
const newDate = new Date(date);
|
|
@@ -269,7 +271,7 @@ export const CalendarContent = ({
|
|
|
269
271
|
|
|
270
272
|
transition('year', () => {
|
|
271
273
|
handleSetView('year');
|
|
272
|
-
setDates(
|
|
274
|
+
setDates(calendarPeriodDates(newDate, 'year'));
|
|
273
275
|
});
|
|
274
276
|
}, [date, transition, handleSetView, setDates]);
|
|
275
277
|
|
|
@@ -279,7 +281,7 @@ export const CalendarContent = ({
|
|
|
279
281
|
|
|
280
282
|
transition('agenda', () => {
|
|
281
283
|
handleSetView('agenda');
|
|
282
|
-
setDates(
|
|
284
|
+
setDates(calendarPeriodDates(newDate, 'agenda'));
|
|
283
285
|
});
|
|
284
286
|
}, [date, transition, handleSetView, setDates]);
|
|
285
287
|
|
|
@@ -342,19 +344,9 @@ export const CalendarContent = ({
|
|
|
342
344
|
const isMobile =
|
|
343
345
|
typeof window !== 'undefined' && window.innerWidth <= 768;
|
|
344
346
|
|
|
345
|
-
//
|
|
347
|
+
// Restore the saved view, adapting wide time grids for mobile.
|
|
346
348
|
if (savedView) {
|
|
347
|
-
if (savedView === '
|
|
348
|
-
// Month view exception: show day on mobile, week on larger screens
|
|
349
|
-
if (isMobile) {
|
|
350
|
-
enableDayView();
|
|
351
|
-
} else {
|
|
352
|
-
enableWeekView();
|
|
353
|
-
}
|
|
354
|
-
} else if (
|
|
355
|
-
(savedView === 'week' || savedView === '4-days') &&
|
|
356
|
-
isMobile
|
|
357
|
-
) {
|
|
349
|
+
if ((savedView === 'week' || savedView === '4-days') && isMobile) {
|
|
358
350
|
// Week and 4-day views are disabled on mobile
|
|
359
351
|
enableDayView();
|
|
360
352
|
} else {
|
|
@@ -362,6 +354,7 @@ export const CalendarContent = ({
|
|
|
362
354
|
if (savedView === 'day') enableDayView();
|
|
363
355
|
else if (savedView === '4-days') enable4DayView();
|
|
364
356
|
else if (savedView === 'week') enableWeekView();
|
|
357
|
+
else if (savedView === 'month') enableMonthView();
|
|
365
358
|
else if (savedView === 'year') enableYearView();
|
|
366
359
|
else if (savedView === 'agenda') enableAgendaView();
|
|
367
360
|
else enableWeekView();
|
|
@@ -493,16 +486,8 @@ export const CalendarContent = ({
|
|
|
493
486
|
);
|
|
494
487
|
const gridDates = getMonthGridDates(date, firstDayNumber);
|
|
495
488
|
setDates(gridDates);
|
|
496
|
-
} else if (view === 'year') {
|
|
497
|
-
|
|
498
|
-
newDate.setMonth(0);
|
|
499
|
-
newDate.setDate(1);
|
|
500
|
-
newDate.setHours(0, 0, 0, 0);
|
|
501
|
-
setDates([newDate]);
|
|
502
|
-
} else if (view === 'agenda') {
|
|
503
|
-
const newDate = new Date(date);
|
|
504
|
-
newDate.setHours(0, 0, 0, 0);
|
|
505
|
-
setDates([newDate]);
|
|
489
|
+
} else if (view === 'year' || view === 'agenda') {
|
|
490
|
+
setDates(calendarPeriodDates(date, view));
|
|
506
491
|
}
|
|
507
492
|
}, [
|
|
508
493
|
date,
|
|
@@ -531,49 +516,16 @@ export const CalendarContent = ({
|
|
|
531
516
|
return () => window.removeEventListener('resize', handleResize);
|
|
532
517
|
}, [enableDayView, enableWeekView, view]);
|
|
533
518
|
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
switch (e.key.toLowerCase()) {
|
|
546
|
-
case 'd':
|
|
547
|
-
enableDayView();
|
|
548
|
-
break;
|
|
549
|
-
case '4':
|
|
550
|
-
enable4DayView();
|
|
551
|
-
break;
|
|
552
|
-
case 'w':
|
|
553
|
-
enableWeekView();
|
|
554
|
-
break;
|
|
555
|
-
case 'm':
|
|
556
|
-
enableMonthView();
|
|
557
|
-
break;
|
|
558
|
-
case 'y':
|
|
559
|
-
enableYearView();
|
|
560
|
-
break;
|
|
561
|
-
case 'a':
|
|
562
|
-
enableAgendaView();
|
|
563
|
-
break;
|
|
564
|
-
}
|
|
565
|
-
};
|
|
566
|
-
|
|
567
|
-
window.addEventListener('keydown', handleKeyDown);
|
|
568
|
-
return () => window.removeEventListener('keydown', handleKeyDown);
|
|
569
|
-
}, [
|
|
570
|
-
enableDayView,
|
|
571
|
-
enable4DayView,
|
|
572
|
-
enableWeekView,
|
|
573
|
-
enableMonthView,
|
|
574
|
-
enableYearView,
|
|
575
|
-
enableAgendaView,
|
|
576
|
-
]);
|
|
519
|
+
useCalendarViewShortcuts({
|
|
520
|
+
enabled: !disabled,
|
|
521
|
+
availableViews,
|
|
522
|
+
day: enableDayView,
|
|
523
|
+
'4-days': enable4DayView,
|
|
524
|
+
week: enableWeekView,
|
|
525
|
+
month: enableMonthView,
|
|
526
|
+
year: enableYearView,
|
|
527
|
+
agenda: enableAgendaView,
|
|
528
|
+
});
|
|
577
529
|
|
|
578
530
|
if (!initialized || !view || !dates.length) return null;
|
|
579
531
|
|
|
@@ -581,7 +533,7 @@ export const CalendarContent = ({
|
|
|
581
533
|
<div
|
|
582
534
|
className={cn(
|
|
583
535
|
'grid h-full min-h-0 w-full',
|
|
584
|
-
view === 'month' || view === 'year'
|
|
536
|
+
view === 'month' || view === 'year' || view === 'agenda'
|
|
585
537
|
? 'grid-rows-[auto_1fr]'
|
|
586
538
|
: 'grid-rows-[auto_auto_1fr]'
|
|
587
539
|
)}
|
|
@@ -638,10 +590,16 @@ export const CalendarContent = ({
|
|
|
638
590
|
<CalendarLoadingSkeleton dates={dates} view={view} />
|
|
639
591
|
) : view === 'month' && dates?.[0] ? (
|
|
640
592
|
<MonthCalendar
|
|
593
|
+
readOnly={disabled}
|
|
641
594
|
date={dates[0]}
|
|
642
595
|
workspace={workspace}
|
|
643
596
|
visibleDates={dates}
|
|
644
597
|
viewedMonth={date}
|
|
598
|
+
onDayClick={(day) => {
|
|
599
|
+
handleSetDate(day);
|
|
600
|
+
handleSetView('day');
|
|
601
|
+
setDates([day]);
|
|
602
|
+
}}
|
|
645
603
|
locale={locale}
|
|
646
604
|
/>
|
|
647
605
|
) : view === 'year' ? (
|
|
@@ -655,15 +613,21 @@ export const CalendarContent = ({
|
|
|
655
613
|
)}
|
|
656
614
|
onDayClick={(d) => {
|
|
657
615
|
handleSetDate(d);
|
|
658
|
-
|
|
616
|
+
handleSetView('day');
|
|
617
|
+
setDates([d]);
|
|
659
618
|
}}
|
|
660
619
|
onMonthClick={(d) => {
|
|
661
620
|
handleSetDate(d);
|
|
662
|
-
|
|
621
|
+
handleSetView('month');
|
|
663
622
|
}}
|
|
664
623
|
/>
|
|
665
624
|
) : view === 'agenda' && dates?.[0] ? (
|
|
666
|
-
<AgendaView
|
|
625
|
+
<AgendaView
|
|
626
|
+
readOnly={disabled}
|
|
627
|
+
startDate={dates[0]}
|
|
628
|
+
workspace={workspace}
|
|
629
|
+
locale={locale}
|
|
630
|
+
/>
|
|
667
631
|
) : (
|
|
668
632
|
<CalendarViewWithTrail dates={dates} overlay={overlay} />
|
|
669
633
|
)}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
const tones: Record<string, string> = {
|
|
2
|
+
blue: 'bg-dynamic-blue/10 text-dynamic-blue',
|
|
3
|
+
red: 'bg-dynamic-red/10 text-dynamic-red',
|
|
4
|
+
green: 'bg-dynamic-green/10 text-dynamic-green',
|
|
5
|
+
purple: 'bg-dynamic-purple/10 text-dynamic-purple',
|
|
6
|
+
yellow: 'bg-dynamic-yellow/10 text-dynamic-yellow',
|
|
7
|
+
orange: 'bg-dynamic-orange/10 text-dynamic-orange',
|
|
8
|
+
pink: 'bg-dynamic-pink/10 text-dynamic-pink',
|
|
9
|
+
cyan: 'bg-dynamic-cyan/10 text-dynamic-cyan',
|
|
10
|
+
indigo: 'bg-dynamic-indigo/10 text-dynamic-indigo',
|
|
11
|
+
gray: 'bg-muted text-muted-foreground',
|
|
12
|
+
};
|
|
13
|
+
export function calendarEventTone(color: string | null | undefined) {
|
|
14
|
+
const normalized = color?.trim().toLowerCase() ?? '';
|
|
15
|
+
return (
|
|
16
|
+
tones[
|
|
17
|
+
normalized === '#6b7280' || normalized === 'grey' ? 'gray' : normalized
|
|
18
|
+
] ?? 'bg-primary/10 text-primary'
|
|
19
|
+
);
|
|
20
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { getCalendarMeetingUrl } from './calendar-meeting-link';
|
|
3
|
+
|
|
4
|
+
describe('getCalendarMeetingUrl', () => {
|
|
5
|
+
it('returns the trusted Meet URL from a scheduled meeting event', () => {
|
|
6
|
+
expect(
|
|
7
|
+
getCalendarMeetingUrl({
|
|
8
|
+
scheduling_metadata: {
|
|
9
|
+
type: 'tuturuuu_meeting',
|
|
10
|
+
meeting_url: 'https://meet.tuturuuu.com/personal/meetings/meeting-1',
|
|
11
|
+
},
|
|
12
|
+
})
|
|
13
|
+
).toBe('https://meet.tuturuuu.com/personal/meetings/meeting-1');
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
it('rejects untrusted and unrelated event URLs', () => {
|
|
17
|
+
expect(
|
|
18
|
+
getCalendarMeetingUrl({
|
|
19
|
+
scheduling_metadata: {
|
|
20
|
+
type: 'tuturuuu_meeting',
|
|
21
|
+
meeting_url: 'https://example.test/phishing',
|
|
22
|
+
},
|
|
23
|
+
})
|
|
24
|
+
).toBeNull();
|
|
25
|
+
expect(getCalendarMeetingUrl({ scheduling_metadata: null })).toBeNull();
|
|
26
|
+
});
|
|
27
|
+
});
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import type { CalendarEvent } from '@tuturuuu/types/primitives/calendar-event';
|
|
2
|
+
|
|
3
|
+
type CalendarEventWithScheduling = Partial<CalendarEvent> & {
|
|
4
|
+
scheduling_metadata?: Record<string, unknown> | null;
|
|
5
|
+
};
|
|
6
|
+
|
|
7
|
+
export function getCalendarMeetingMetadata(event: Partial<CalendarEvent>) {
|
|
8
|
+
return (event as CalendarEventWithScheduling).scheduling_metadata;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function getCalendarMeetingUrl(event: CalendarEventWithScheduling) {
|
|
12
|
+
const metadata = getCalendarMeetingMetadata(event);
|
|
13
|
+
if (
|
|
14
|
+
metadata?.type !== 'tuturuuu_meeting' ||
|
|
15
|
+
typeof metadata.meeting_url !== 'string'
|
|
16
|
+
) {
|
|
17
|
+
return null;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
try {
|
|
21
|
+
const url = new URL(metadata.meeting_url);
|
|
22
|
+
const isMeetHost =
|
|
23
|
+
url.hostname === 'meet.tuturuuu.com' ||
|
|
24
|
+
url.hostname === 'meet.tuturuuu.localhost' ||
|
|
25
|
+
(url.hostname === 'localhost' && url.port === '7807');
|
|
26
|
+
return url.protocol === 'https:' || url.protocol === 'http:'
|
|
27
|
+
? isMeetHost
|
|
28
|
+
? url.toString()
|
|
29
|
+
: null
|
|
30
|
+
: null;
|
|
31
|
+
} catch {
|
|
32
|
+
return null;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
@@ -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
|
+
});
|