@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.
- package/CHANGELOG.md +49 -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/chat/ai-message-markdown.tsx +17 -2
- 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
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import type { CalendarEvent } from '@tuturuuu/types/primitives/calendar-event';
|
|
2
|
+
import { expect, it, vi } from 'vitest';
|
|
3
|
+
import { saveCalendarEventDrafts } from './save-calendar-event-drafts';
|
|
4
|
+
|
|
5
|
+
it('retains rejected or unconfirmed drafts and retries only those events', async () => {
|
|
6
|
+
const drafts = [
|
|
7
|
+
{ title: 'Saved' },
|
|
8
|
+
{ title: 'Rejected', description: 'Keep this content' },
|
|
9
|
+
{ title: 'Unconfirmed' },
|
|
10
|
+
];
|
|
11
|
+
const addEvent = vi
|
|
12
|
+
.fn()
|
|
13
|
+
.mockResolvedValueOnce({ id: 'event-1' } as CalendarEvent)
|
|
14
|
+
.mockRejectedValueOnce(new Error('Offline'))
|
|
15
|
+
.mockResolvedValueOnce(undefined);
|
|
16
|
+
const result = await saveCalendarEventDrafts(drafts, addEvent, undefined);
|
|
17
|
+
expect(result.savedEvents).toEqual([{ id: 'event-1' }]);
|
|
18
|
+
expect(result.failedEvents).toEqual(drafts.slice(1));
|
|
19
|
+
addEvent.mockResolvedValue({ id: 'retried-event' });
|
|
20
|
+
await saveCalendarEventDrafts(result.failedEvents, addEvent, undefined);
|
|
21
|
+
expect(addEvent.mock.calls.slice(3).map(([event]) => event.title)).toEqual([
|
|
22
|
+
'Rejected',
|
|
23
|
+
'Unconfirmed',
|
|
24
|
+
]);
|
|
25
|
+
});
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import type { CalendarEvent } from '@tuturuuu/types/primitives/calendar-event';
|
|
2
|
+
|
|
3
|
+
/** Keep only unconfirmed drafts after a batch so retry never resends saved events. */
|
|
4
|
+
export async function saveCalendarEventDrafts(
|
|
5
|
+
drafts: Partial<CalendarEvent>[],
|
|
6
|
+
addEvent: (
|
|
7
|
+
event: Omit<CalendarEvent, 'id'>
|
|
8
|
+
) => Promise<CalendarEvent | undefined>,
|
|
9
|
+
source: CalendarEvent['source']
|
|
10
|
+
) {
|
|
11
|
+
const savedEvents: CalendarEvent[] = [];
|
|
12
|
+
const failedEvents: Partial<CalendarEvent>[] = [];
|
|
13
|
+
for (const draft of drafts) {
|
|
14
|
+
try {
|
|
15
|
+
const saved = await addEvent({
|
|
16
|
+
title: draft.title || 'New Event',
|
|
17
|
+
description: draft.description || '',
|
|
18
|
+
start_at: draft.start_at || '',
|
|
19
|
+
end_at: draft.end_at || '',
|
|
20
|
+
color: draft.color || 'BLUE',
|
|
21
|
+
location: draft.location || '',
|
|
22
|
+
locked: draft.locked || false,
|
|
23
|
+
source,
|
|
24
|
+
});
|
|
25
|
+
if (saved?.id) savedEvents.push(saved);
|
|
26
|
+
else failedEvents.push(draft);
|
|
27
|
+
} catch {
|
|
28
|
+
failedEvents.push(draft);
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
return { savedEvents, failedEvents };
|
|
32
|
+
}
|
|
@@ -27,6 +27,7 @@ export const SmartCalendar = ({
|
|
|
27
27
|
disabled,
|
|
28
28
|
enableHeader = true,
|
|
29
29
|
experimentalGoogleToken,
|
|
30
|
+
initialEventId,
|
|
30
31
|
externalState,
|
|
31
32
|
extras,
|
|
32
33
|
overlay,
|
|
@@ -46,6 +47,7 @@ export const SmartCalendar = ({
|
|
|
46
47
|
disabled?: boolean;
|
|
47
48
|
enableHeader?: boolean;
|
|
48
49
|
experimentalGoogleToken?: WorkspaceCalendarGoogleTokenClient | null;
|
|
50
|
+
initialEventId?: string;
|
|
49
51
|
externalState?: {
|
|
50
52
|
date: Date;
|
|
51
53
|
setDate: React.Dispatch<React.SetStateAction<Date>>;
|
|
@@ -84,6 +86,7 @@ export const SmartCalendar = ({
|
|
|
84
86
|
useQuery={useQuery}
|
|
85
87
|
useQueryClient={useQueryClient}
|
|
86
88
|
experimentalGoogleToken={experimentalGoogleToken}
|
|
89
|
+
initialEventId={initialEventId}
|
|
87
90
|
eventAdapter={eventAdapter}
|
|
88
91
|
readOnly={disabled}
|
|
89
92
|
>
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { isPageShortcutBlocked } from '@tuturuuu/utils/keyboard-shortcuts';
|
|
4
|
+
import { useEffect, useEffectEvent } from 'react';
|
|
5
|
+
import type { CalendarView } from '../../../../hooks/use-view-transition';
|
|
6
|
+
|
|
7
|
+
export const CALENDAR_VIEW_KEYS: Record<string, CalendarView> = {
|
|
8
|
+
d: 'day',
|
|
9
|
+
'4': '4-days',
|
|
10
|
+
w: 'week',
|
|
11
|
+
m: 'month',
|
|
12
|
+
y: 'year',
|
|
13
|
+
a: 'agenda',
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
export function calendarShortcutView(
|
|
17
|
+
event: KeyboardEvent
|
|
18
|
+
): CalendarView | null {
|
|
19
|
+
if (
|
|
20
|
+
isPageShortcutBlocked(event) ||
|
|
21
|
+
event.metaKey ||
|
|
22
|
+
event.ctrlKey ||
|
|
23
|
+
event.altKey ||
|
|
24
|
+
event.shiftKey
|
|
25
|
+
)
|
|
26
|
+
return null;
|
|
27
|
+
return CALENDAR_VIEW_KEYS[event.key.toLowerCase()] ?? null;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function useCalendarViewShortcuts(
|
|
31
|
+
actions: Record<CalendarView, () => void> & {
|
|
32
|
+
enabled: boolean;
|
|
33
|
+
availableViews?: { value: string; disabled?: boolean }[];
|
|
34
|
+
}
|
|
35
|
+
) {
|
|
36
|
+
const onKeyDown = useEffectEvent((event: KeyboardEvent) => {
|
|
37
|
+
const view = calendarShortcutView(event);
|
|
38
|
+
if (!view) return;
|
|
39
|
+
const available = actions.availableViews;
|
|
40
|
+
if (
|
|
41
|
+
available?.length &&
|
|
42
|
+
!available.some((item) => item.value === view && !item.disabled)
|
|
43
|
+
)
|
|
44
|
+
return;
|
|
45
|
+
event.preventDefault();
|
|
46
|
+
actions[view]();
|
|
47
|
+
});
|
|
48
|
+
useEffect(() => {
|
|
49
|
+
if (!actions.enabled) return;
|
|
50
|
+
const listener = (event: KeyboardEvent) => onKeyDown(event);
|
|
51
|
+
window.addEventListener('keydown', listener);
|
|
52
|
+
return () => window.removeEventListener('keydown', listener);
|
|
53
|
+
}, [actions.enabled]);
|
|
54
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { act, renderHook } from '@testing-library/react';
|
|
2
|
+
import { useState } from 'react';
|
|
3
|
+
import { describe, expect, it } from 'vitest';
|
|
4
|
+
import { useEventDraftSession } from './use-event-draft-session';
|
|
5
|
+
|
|
6
|
+
describe('calendar draft session', () => {
|
|
7
|
+
it('preserves typed content through background refreshes and initializes on reopening or switching events', () => {
|
|
8
|
+
const { result, rerender } = renderHook(
|
|
9
|
+
({ open, eventId, workspaceId, title }) => {
|
|
10
|
+
const [draft, setDraft] = useState('');
|
|
11
|
+
useEventDraftSession({
|
|
12
|
+
isOpen: open,
|
|
13
|
+
eventId,
|
|
14
|
+
workspaceId,
|
|
15
|
+
initialize: () => setDraft(title),
|
|
16
|
+
});
|
|
17
|
+
return { draft, setDraft };
|
|
18
|
+
},
|
|
19
|
+
{
|
|
20
|
+
initialProps: {
|
|
21
|
+
open: true,
|
|
22
|
+
eventId: 'new',
|
|
23
|
+
workspaceId: 'ws-1',
|
|
24
|
+
title: '',
|
|
25
|
+
},
|
|
26
|
+
}
|
|
27
|
+
);
|
|
28
|
+
act(() => result.current.setDraft('Unsaved meeting details'));
|
|
29
|
+
rerender({
|
|
30
|
+
open: true,
|
|
31
|
+
eventId: 'new',
|
|
32
|
+
workspaceId: 'ws-1',
|
|
33
|
+
title: 'Refetched server state',
|
|
34
|
+
});
|
|
35
|
+
expect(result.current.draft).toBe('Unsaved meeting details');
|
|
36
|
+
rerender({ open: false, eventId: 'new', workspaceId: 'ws-1', title: '' });
|
|
37
|
+
rerender({
|
|
38
|
+
open: true,
|
|
39
|
+
eventId: 'new',
|
|
40
|
+
workspaceId: 'ws-1',
|
|
41
|
+
title: 'Next draft',
|
|
42
|
+
});
|
|
43
|
+
expect(result.current.draft).toBe('Next draft');
|
|
44
|
+
rerender({
|
|
45
|
+
open: true,
|
|
46
|
+
eventId: 'event-2',
|
|
47
|
+
workspaceId: 'ws-1',
|
|
48
|
+
title: 'Existing meeting',
|
|
49
|
+
});
|
|
50
|
+
expect(result.current.draft).toBe('Existing meeting');
|
|
51
|
+
rerender({
|
|
52
|
+
open: true,
|
|
53
|
+
eventId: 'event-2',
|
|
54
|
+
workspaceId: 'ws-2',
|
|
55
|
+
title: 'Other workspace',
|
|
56
|
+
});
|
|
57
|
+
expect(result.current.draft).toBe('Other workspace');
|
|
58
|
+
});
|
|
59
|
+
});
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { useEffect, useEffectEvent } from 'react';
|
|
2
|
+
|
|
3
|
+
/** Initialize a draft only on opening or switching the selected event/workspace. */
|
|
4
|
+
export function useEventDraftSession({
|
|
5
|
+
isOpen,
|
|
6
|
+
eventId,
|
|
7
|
+
workspaceId,
|
|
8
|
+
initialize,
|
|
9
|
+
}: {
|
|
10
|
+
isOpen: boolean;
|
|
11
|
+
eventId?: string;
|
|
12
|
+
workspaceId?: string;
|
|
13
|
+
initialize: () => void;
|
|
14
|
+
}) {
|
|
15
|
+
const initializeDraft = useEffectEvent(initialize);
|
|
16
|
+
// biome-ignore lint/correctness/useExhaustiveDependencies: Event and workspace identity define a new editing session.
|
|
17
|
+
useEffect(() => {
|
|
18
|
+
if (isOpen) initializeDraft();
|
|
19
|
+
}, [isOpen, eventId, workspaceId]);
|
|
20
|
+
}
|
|
@@ -7,11 +7,11 @@ import { cn } from '@tuturuuu/utils/format';
|
|
|
7
7
|
import {
|
|
8
8
|
eachDayOfInterval,
|
|
9
9
|
endOfMonth,
|
|
10
|
-
format,
|
|
11
10
|
getDay,
|
|
12
11
|
isToday,
|
|
13
12
|
startOfMonth,
|
|
14
13
|
} from 'date-fns';
|
|
14
|
+
import { useTranslations } from 'next-intl';
|
|
15
15
|
import { useMemo } from 'react';
|
|
16
16
|
import {
|
|
17
17
|
formatLunarDay,
|
|
@@ -57,9 +57,12 @@ function getEventDotColor(color: string): string {
|
|
|
57
57
|
|
|
58
58
|
function getWeekdayLabels(
|
|
59
59
|
firstDayOfWeek: number,
|
|
60
|
-
showWeekends: boolean
|
|
60
|
+
showWeekends: boolean,
|
|
61
|
+
locale: string
|
|
61
62
|
): string[] {
|
|
62
|
-
const days =
|
|
63
|
+
const days = Array.from({ length: 7 }, (_, day) =>
|
|
64
|
+
new Date(2026, 0, 4 + day).toLocaleDateString(locale, { weekday: 'narrow' })
|
|
65
|
+
);
|
|
63
66
|
|
|
64
67
|
const reordered: { label: string; dayIndex: number }[] = [];
|
|
65
68
|
for (let i = 0; i < 7; i++) {
|
|
@@ -93,14 +96,15 @@ function MiniMonth({
|
|
|
93
96
|
onDayClick?: (date: Date) => void;
|
|
94
97
|
onMonthClick?: (date: Date) => void;
|
|
95
98
|
}) {
|
|
99
|
+
const t = useTranslations('calendar');
|
|
96
100
|
const { getCurrentEvents } = useCalendar();
|
|
97
101
|
const monthStart = startOfMonth(monthDate);
|
|
98
102
|
const monthEnd = endOfMonth(monthDate);
|
|
99
103
|
const days = eachDayOfInterval({ start: monthStart, end: monthEnd });
|
|
100
104
|
|
|
101
105
|
const weekdayLabels = useMemo(
|
|
102
|
-
() => getWeekdayLabels(firstDayOfWeek, showWeekends),
|
|
103
|
-
[firstDayOfWeek, showWeekends]
|
|
106
|
+
() => getWeekdayLabels(firstDayOfWeek, showWeekends, locale),
|
|
107
|
+
[firstDayOfWeek, showWeekends, locale]
|
|
104
108
|
);
|
|
105
109
|
|
|
106
110
|
const numCols = showWeekends ? 7 : 5;
|
|
@@ -157,17 +161,25 @@ function MiniMonth({
|
|
|
157
161
|
}, [days, getCurrentEvents]);
|
|
158
162
|
|
|
159
163
|
return (
|
|
160
|
-
<
|
|
164
|
+
<section
|
|
165
|
+
className={cn(
|
|
166
|
+
'flex flex-col gap-2 rounded-xl border p-4 transition-colors hover:bg-muted/20',
|
|
167
|
+
isCurrentMonth ? 'border-primary/35 bg-primary/5' : 'bg-background'
|
|
168
|
+
)}
|
|
169
|
+
>
|
|
161
170
|
<button
|
|
162
171
|
type="button"
|
|
163
172
|
onClick={() => onMonthClick?.(monthDate)}
|
|
164
173
|
className={cn(
|
|
165
174
|
'rounded-md px-1 py-1 text-left font-semibold text-sm transition-colors',
|
|
166
|
-
'hover:bg-
|
|
175
|
+
'hover:bg-accent focus-visible:outline-2 focus-visible:outline-ring',
|
|
167
176
|
isCurrentMonth && 'text-primary'
|
|
168
177
|
)}
|
|
169
178
|
>
|
|
170
|
-
{
|
|
179
|
+
<span>{monthDate.toLocaleDateString(locale, { month: 'long' })}</span>
|
|
180
|
+
<span className="ml-2 font-normal text-muted-foreground text-xs">
|
|
181
|
+
{t('views.busy_days', { count: dayEventData.size })}
|
|
182
|
+
</span>
|
|
171
183
|
</button>
|
|
172
184
|
|
|
173
185
|
<div
|
|
@@ -199,7 +211,7 @@ function MiniMonth({
|
|
|
199
211
|
/>
|
|
200
212
|
))}
|
|
201
213
|
</div>
|
|
202
|
-
</
|
|
214
|
+
</section>
|
|
203
215
|
);
|
|
204
216
|
}
|
|
205
217
|
|
|
@@ -216,8 +228,9 @@ function DayCell({
|
|
|
216
228
|
onDayClick?: (date: Date) => void;
|
|
217
229
|
dayEventData?: { count: number; dots: string[] };
|
|
218
230
|
}) {
|
|
231
|
+
const t = useTranslations('calendar');
|
|
219
232
|
if (!day) {
|
|
220
|
-
return <div className={cn('h-
|
|
233
|
+
return <div className={cn('h-9', showLunar && 'h-11')} />;
|
|
221
234
|
}
|
|
222
235
|
|
|
223
236
|
const today = isToday(day);
|
|
@@ -233,10 +246,13 @@ function DayCell({
|
|
|
233
246
|
<button
|
|
234
247
|
type="button"
|
|
235
248
|
onClick={() => onDayClick?.(day)}
|
|
249
|
+
aria-label={`${day.toLocaleDateString(locale, { dateStyle: 'full' })}, ${t('agenda_event_count', { count: dayEventData?.count ?? 0 })}`}
|
|
250
|
+
aria-current={today ? 'date' : undefined}
|
|
236
251
|
className={cn(
|
|
237
252
|
'group relative flex flex-col items-center justify-start rounded-md transition-colors',
|
|
238
|
-
showLunar ? 'h-
|
|
239
|
-
'hover:bg-
|
|
253
|
+
showLunar ? 'h-11 py-1' : 'h-9 py-1',
|
|
254
|
+
'hover:bg-accent focus-visible:outline-2 focus-visible:outline-ring',
|
|
255
|
+
hasEvents && 'bg-primary/5',
|
|
240
256
|
today && 'font-bold'
|
|
241
257
|
)}
|
|
242
258
|
>
|
|
@@ -294,8 +310,7 @@ function DayCell({
|
|
|
294
310
|
)}
|
|
295
311
|
{hasEvents && dayEventData && (
|
|
296
312
|
<div className="text-muted-foreground">
|
|
297
|
-
{dayEventData.count}
|
|
298
|
-
{dayEventData.count !== 1 ? 's' : ''}
|
|
313
|
+
{t('agenda_event_count', { count: dayEventData.count })}
|
|
299
314
|
</div>
|
|
300
315
|
)}
|
|
301
316
|
</div>
|
|
@@ -325,8 +340,11 @@ export function YearCalendar({
|
|
|
325
340
|
}, [year]);
|
|
326
341
|
|
|
327
342
|
return (
|
|
328
|
-
<div
|
|
329
|
-
|
|
343
|
+
<div
|
|
344
|
+
className="h-full overflow-auto bg-muted/10 p-4 sm:p-6"
|
|
345
|
+
data-calendar-view="year"
|
|
346
|
+
>
|
|
347
|
+
<div className="mx-auto grid max-w-7xl grid-cols-1 gap-4 sm:grid-cols-2 xl:grid-cols-3">
|
|
330
348
|
{months.map((monthDate) => (
|
|
331
349
|
<MiniMonth
|
|
332
350
|
key={monthDate.getMonth()}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { act, fireEvent, render, screen } from '@testing-library/react';
|
|
2
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
3
|
+
import {
|
|
4
|
+
clearPwaInstallPrompt,
|
|
5
|
+
initializePwaInstall,
|
|
6
|
+
} from '../../hooks/pwa-install-prompt';
|
|
7
|
+
import { PwaStatus } from './pwa-status';
|
|
8
|
+
|
|
9
|
+
const labels = {
|
|
10
|
+
offline_status: 'offline_status',
|
|
11
|
+
install: 'install',
|
|
12
|
+
install_help: 'install_help',
|
|
13
|
+
cache_help: 'cache_help',
|
|
14
|
+
};
|
|
15
|
+
beforeEach(() => {
|
|
16
|
+
initializePwaInstall();
|
|
17
|
+
clearPwaInstallPrompt();
|
|
18
|
+
vi.stubGlobal('matchMedia', () => ({ matches: false }));
|
|
19
|
+
});
|
|
20
|
+
afterEach(() => {
|
|
21
|
+
vi.unstubAllGlobals();
|
|
22
|
+
vi.restoreAllMocks();
|
|
23
|
+
});
|
|
24
|
+
describe('PWA status and installation', () => {
|
|
25
|
+
it('shows offline limitations without offering an unavailable install', () => {
|
|
26
|
+
vi.spyOn(navigator, 'onLine', 'get').mockReturnValue(false);
|
|
27
|
+
render(<PwaStatus labels={labels} />);
|
|
28
|
+
expect(screen.getByRole('status')).toHaveTextContent('offline_status');
|
|
29
|
+
expect(screen.queryByRole('button', { name: 'install' })).toBeNull();
|
|
30
|
+
});
|
|
31
|
+
it('provides Home Screen instructions when a browser has no install prompt', () => {
|
|
32
|
+
render(<PwaStatus labels={labels} />);
|
|
33
|
+
fireEvent.click(screen.getByRole('button', { name: 'install' }));
|
|
34
|
+
expect(screen.getByRole('dialog')).toHaveTextContent('install_help');
|
|
35
|
+
});
|
|
36
|
+
it('only invokes the native install prompt after an explicit click', async () => {
|
|
37
|
+
const prompt = vi.fn(async () => {});
|
|
38
|
+
const event = Object.assign(
|
|
39
|
+
new Event('beforeinstallprompt', { cancelable: true }),
|
|
40
|
+
{ prompt, userChoice: Promise.resolve({ outcome: 'accepted' }) }
|
|
41
|
+
);
|
|
42
|
+
act(() => {
|
|
43
|
+
window.dispatchEvent(event);
|
|
44
|
+
});
|
|
45
|
+
render(<PwaStatus labels={labels} />);
|
|
46
|
+
expect(prompt).not.toHaveBeenCalled();
|
|
47
|
+
await act(async () => {
|
|
48
|
+
fireEvent.click(screen.getByRole('button', { name: 'install' }));
|
|
49
|
+
});
|
|
50
|
+
expect(prompt).toHaveBeenCalledOnce();
|
|
51
|
+
expect(screen.queryByRole('button', { name: 'install' })).toBeNull();
|
|
52
|
+
});
|
|
53
|
+
});
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
import { Download, WifiOff } from '@tuturuuu/icons';
|
|
3
|
+
import { useEffect, useState, useSyncExternalStore } from 'react';
|
|
4
|
+
import {
|
|
5
|
+
clearPwaInstallPrompt,
|
|
6
|
+
getPwaInstallPrompt,
|
|
7
|
+
initializePwaInstall,
|
|
8
|
+
subscribePwaInstall,
|
|
9
|
+
} from '../../hooks/pwa-install-prompt';
|
|
10
|
+
import { Button } from './button';
|
|
11
|
+
import {
|
|
12
|
+
Dialog,
|
|
13
|
+
DialogContent,
|
|
14
|
+
DialogDescription,
|
|
15
|
+
DialogHeader,
|
|
16
|
+
DialogTitle,
|
|
17
|
+
} from './dialog';
|
|
18
|
+
|
|
19
|
+
function subscribeOnline(callback: () => void) {
|
|
20
|
+
window.addEventListener('online', callback);
|
|
21
|
+
window.addEventListener('offline', callback);
|
|
22
|
+
return () => {
|
|
23
|
+
window.removeEventListener('online', callback);
|
|
24
|
+
window.removeEventListener('offline', callback);
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
export function PwaStatus({
|
|
28
|
+
labels,
|
|
29
|
+
}: {
|
|
30
|
+
labels: {
|
|
31
|
+
offline_status: string;
|
|
32
|
+
install: string;
|
|
33
|
+
install_help: string;
|
|
34
|
+
cache_help: string;
|
|
35
|
+
};
|
|
36
|
+
}) {
|
|
37
|
+
const t = (key: keyof typeof labels) => labels[key];
|
|
38
|
+
const online = useSyncExternalStore(
|
|
39
|
+
subscribeOnline,
|
|
40
|
+
() => navigator.onLine,
|
|
41
|
+
() => true
|
|
42
|
+
);
|
|
43
|
+
const installPrompt = useSyncExternalStore(
|
|
44
|
+
subscribePwaInstall,
|
|
45
|
+
getPwaInstallPrompt,
|
|
46
|
+
() => null
|
|
47
|
+
);
|
|
48
|
+
const [installed, setInstalled] = useState(false);
|
|
49
|
+
const [showHelp, setShowHelp] = useState(false);
|
|
50
|
+
useEffect(() => {
|
|
51
|
+
setInstalled(
|
|
52
|
+
window.matchMedia('(display-mode: standalone)').matches ||
|
|
53
|
+
('standalone' in navigator && navigator.standalone === true)
|
|
54
|
+
);
|
|
55
|
+
initializePwaInstall();
|
|
56
|
+
const onInstalled = () => {
|
|
57
|
+
setInstalled(true);
|
|
58
|
+
clearPwaInstallPrompt();
|
|
59
|
+
};
|
|
60
|
+
window.addEventListener('appinstalled', onInstalled);
|
|
61
|
+
return () => {
|
|
62
|
+
window.removeEventListener('appinstalled', onInstalled);
|
|
63
|
+
};
|
|
64
|
+
}, []);
|
|
65
|
+
const install = async () => {
|
|
66
|
+
if (!installPrompt) {
|
|
67
|
+
setShowHelp(true);
|
|
68
|
+
return;
|
|
69
|
+
}
|
|
70
|
+
try {
|
|
71
|
+
await installPrompt.prompt();
|
|
72
|
+
const choice = await installPrompt.userChoice;
|
|
73
|
+
if (choice.outcome === 'accepted') setInstalled(true);
|
|
74
|
+
} catch {
|
|
75
|
+
setShowHelp(true);
|
|
76
|
+
} finally {
|
|
77
|
+
clearPwaInstallPrompt();
|
|
78
|
+
}
|
|
79
|
+
};
|
|
80
|
+
return (
|
|
81
|
+
<>
|
|
82
|
+
{!online && (
|
|
83
|
+
<div
|
|
84
|
+
role="status"
|
|
85
|
+
className="fixed inset-x-3 bottom-3 z-50 mx-auto flex max-w-xl items-start gap-2 rounded-lg border bg-background p-3 text-sm shadow-lg"
|
|
86
|
+
>
|
|
87
|
+
<WifiOff className="mt-0.5 size-4 shrink-0 text-muted-foreground" />
|
|
88
|
+
<p>{t('offline_status')}</p>
|
|
89
|
+
</div>
|
|
90
|
+
)}
|
|
91
|
+
{online && !installed && (
|
|
92
|
+
<Button
|
|
93
|
+
variant="outline"
|
|
94
|
+
size="sm"
|
|
95
|
+
className="fixed right-3 bottom-3 z-30 gap-2 rounded-full bg-background shadow-sm"
|
|
96
|
+
onClick={() => void install()}
|
|
97
|
+
>
|
|
98
|
+
<Download className="size-4" />
|
|
99
|
+
{t('install')}
|
|
100
|
+
</Button>
|
|
101
|
+
)}
|
|
102
|
+
<Dialog open={showHelp} onOpenChange={setShowHelp}>
|
|
103
|
+
<DialogContent className="max-w-md">
|
|
104
|
+
<DialogHeader>
|
|
105
|
+
<DialogTitle>{t('install')}</DialogTitle>
|
|
106
|
+
<DialogDescription>{t('install_help')}</DialogDescription>
|
|
107
|
+
</DialogHeader>
|
|
108
|
+
<p className="text-muted-foreground text-sm">{t('cache_help')}</p>
|
|
109
|
+
</DialogContent>
|
|
110
|
+
</Dialog>
|
|
111
|
+
</>
|
|
112
|
+
);
|
|
113
|
+
}
|