@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
@@ -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
  );
@@ -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
+ }