@tuturuuu/ui 0.30.2 → 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 +41 -0
- package/biome.json +1 -1
- package/package.json +95 -49
- 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/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/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
|
@@ -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 || '
|
|
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
|
-
{
|
|
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
|
-
{
|
|
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 {
|
|
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
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
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
|
-
|
|
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
|
+
}
|