@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
@@ -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
- setView('month');
259
- setDate(newDate);
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([newDate]);
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([newDate]);
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
- // Handle saved view with special case for month
347
+ // Restore the saved view, adapting wide time grids for mobile.
346
348
  if (savedView) {
347
- if (savedView === 'month') {
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
- const newDate = new Date(date);
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
- // Keyboard shortcut to change view
535
- useEffect(() => {
536
- const handleKeyDown = (e: KeyboardEvent) => {
537
- if (
538
- e.target instanceof HTMLInputElement ||
539
- e.target instanceof HTMLTextAreaElement
540
- ) {
541
- return;
542
- // Ignore if typing in a form field
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
- enableDayView();
616
+ handleSetView('day');
617
+ setDates([d]);
659
618
  }}
660
619
  onMonthClick={(d) => {
661
620
  handleSetDate(d);
662
- enableMonthView();
621
+ handleSetView('month');
663
622
  }}
664
623
  />
665
624
  ) : view === 'agenda' && dates?.[0] ? (
666
- <AgendaView startDate={dates[0]} workspace={workspace} />
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
+ });