@tuturuuu/ui 0.36.1 → 0.37.1
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 +31 -0
- package/package.json +7 -5
- package/src/components/ui/date-time-picker-timezone.test.tsx +258 -0
- package/src/components/ui/date-time-picker-values.ts +205 -0
- package/src/components/ui/date-time-picker.tsx +70 -167
- package/src/components/ui/legacy/calendar/agenda-view.tsx +29 -94
- package/src/components/ui/legacy/calendar/all-day-color.test.tsx +48 -0
- package/src/components/ui/legacy/calendar/all-day-event-bar.tsx +29 -41
- package/src/components/ui/legacy/calendar/calendar-cell-color.test.tsx +70 -0
- package/src/components/ui/legacy/calendar/calendar-cell.tsx +4 -5
- package/src/components/ui/legacy/calendar/calendar-content.test.tsx +82 -3
- package/src/components/ui/legacy/calendar/calendar-content.tsx +37 -34
- package/src/components/ui/legacy/calendar/calendar-day-bridge.test.tsx +94 -0
- package/src/components/ui/legacy/calendar/calendar-day-display.test.tsx +106 -0
- package/src/components/ui/legacy/calendar/calendar-header.tsx +14 -5
- package/src/components/ui/legacy/calendar/calendar-matrix.tsx +4 -9
- package/src/components/ui/legacy/calendar/calendar-views.test.tsx +120 -6
- package/src/components/ui/legacy/calendar/day-title.tsx +6 -6
- package/src/components/ui/legacy/calendar/dynamic-island.test.tsx +140 -0
- package/src/components/ui/legacy/calendar/dynamic-island.tsx +27 -24
- package/src/components/ui/legacy/calendar/event-card.test.tsx +206 -7
- package/src/components/ui/legacy/calendar/event-card.tsx +27 -49
- package/src/components/ui/legacy/calendar/event-modal.tsx +6 -26
- package/src/components/ui/legacy/calendar/event-picker-bounds.ts +16 -0
- package/src/components/ui/legacy/calendar/event-preview-popover.test.tsx +139 -0
- package/src/components/ui/legacy/calendar/event-preview-popover.tsx +13 -17
- package/src/components/ui/legacy/calendar/event-preview-time.ts +41 -0
- package/src/components/ui/legacy/calendar/location-timeline.tsx +9 -17
- package/src/components/ui/legacy/calendar/month-calendar.tsx +20 -10
- package/src/components/ui/legacy/calendar/time-indicator-line.tsx +3 -20
- package/src/components/ui/legacy/calendar/time-indicator.tsx +11 -3
- package/src/components/ui/legacy/calendar/weekday-bar.tsx +2 -8
- package/src/components/ui/legacy/calendar/year-calendar.tsx +27 -39
- package/src/hooks/__tests__/use-calendar-sync.test.tsx +45 -0
- package/src/hooks/calendar-event-lookup.test.ts +39 -0
- package/src/hooks/calendar-event-lookup.ts +22 -8
- package/src/hooks/calendar-sync-range.test.ts +92 -0
- package/src/hooks/calendar-sync-range.ts +40 -0
- package/src/hooks/use-calendar-clock.ts +49 -0
- package/src/hooks/use-calendar-day-zone.tsx +12 -0
- package/src/hooks/use-calendar-sync.tsx +42 -80
- package/src/hooks/use-calendar.tsx +3 -3
- package/src/lib/calendar-day.ts +67 -0
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import { describe, expect, it, vi } from 'vitest';
|
|
2
|
+
import { calendarQueryRange } from '../lib/calendar-day';
|
|
3
|
+
import {
|
|
4
|
+
calendarEventInQueryRange,
|
|
5
|
+
calendarRangeCacheKey,
|
|
6
|
+
calendarRangeIncludesToday,
|
|
7
|
+
} from './calendar-sync-range';
|
|
8
|
+
|
|
9
|
+
describe('calendar query date carriers', () => {
|
|
10
|
+
it('uses the effective device zone for auto cache identity', () => {
|
|
11
|
+
const dates = [new Date(2026, 0, 1)];
|
|
12
|
+
vi.stubEnv('TZ', 'Asia/Tokyo');
|
|
13
|
+
try {
|
|
14
|
+
expect(calendarRangeCacheKey(dates, 'auto')).toBe(
|
|
15
|
+
calendarRangeCacheKey(dates, 'Asia/Tokyo')
|
|
16
|
+
);
|
|
17
|
+
vi.stubEnv('TZ', 'America/Los_Angeles');
|
|
18
|
+
expect(calendarRangeCacheKey(dates, 'auto')).toBe(
|
|
19
|
+
calendarRangeCacheKey(dates, 'America/Los_Angeles')
|
|
20
|
+
);
|
|
21
|
+
expect(calendarRangeCacheKey(dates, 'auto')).not.toBe(
|
|
22
|
+
calendarRangeCacheKey(dates, 'Asia/Tokyo')
|
|
23
|
+
);
|
|
24
|
+
} finally {
|
|
25
|
+
vi.unstubAllEnvs();
|
|
26
|
+
}
|
|
27
|
+
});
|
|
28
|
+
it.each([
|
|
29
|
+
[2026, 2, 8, '2026-03-08T05:00:00.000Z', '2026-03-09T04:00:00.000Z', 23],
|
|
30
|
+
[2026, 10, 1, '2026-11-01T04:00:00.000Z', '2026-11-02T05:00:00.000Z', 25],
|
|
31
|
+
] as const)(
|
|
32
|
+
'resolves independent New York midnights for %i/%i/%i',
|
|
33
|
+
(year, month, day, start, end, hours) => {
|
|
34
|
+
vi.stubEnv('TZ', 'Asia/Tokyo');
|
|
35
|
+
try {
|
|
36
|
+
const range = calendarQueryRange(
|
|
37
|
+
[new Date(year, month, day, 18)],
|
|
38
|
+
'America/New_York'
|
|
39
|
+
);
|
|
40
|
+
expect(range.start.toISOString()).toBe(start);
|
|
41
|
+
expect(range.end.toISOString()).toBe(end);
|
|
42
|
+
expect(range.end.getTime() - range.start.getTime()).toBe(
|
|
43
|
+
hours * 60 * 60_000
|
|
44
|
+
);
|
|
45
|
+
} finally {
|
|
46
|
+
vi.unstubAllEnvs();
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
);
|
|
50
|
+
|
|
51
|
+
it('partitions equal selected days by calendar timezone and ignores carrier clock fields', () => {
|
|
52
|
+
const dates = [new Date(2026, 0, 1)];
|
|
53
|
+
expect(calendarRangeCacheKey(dates, 'Asia/Tokyo')).not.toBe(
|
|
54
|
+
calendarRangeCacheKey(dates, 'America/Los_Angeles')
|
|
55
|
+
);
|
|
56
|
+
expect(calendarRangeCacheKey(dates, 'Asia/Tokyo')).toBe(
|
|
57
|
+
calendarRangeCacheKey([new Date(2026, 0, 1, 19)], 'Asia/Tokyo')
|
|
58
|
+
);
|
|
59
|
+
expect(calendarRangeCacheKey([], 'Asia/Tokyo')).toBe('');
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it('detects today in the calendar zone across a year boundary', () => {
|
|
63
|
+
const now = new Date('2025-12-31T16:00:00Z');
|
|
64
|
+
const dates = [new Date(2026, 0, 1)];
|
|
65
|
+
expect(calendarRangeIncludesToday(dates, 'Asia/Tokyo', now)).toBe(true);
|
|
66
|
+
expect(calendarRangeIncludesToday(dates, 'America/Los_Angeles', now)).toBe(
|
|
67
|
+
false
|
|
68
|
+
);
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it('filters optimistic events against the same zoned half-open query range', () => {
|
|
72
|
+
const dates = [new Date(2026, 0, 1)];
|
|
73
|
+
const zone = 'Asia/Tokyo';
|
|
74
|
+
expect(
|
|
75
|
+
calendarEventInQueryRange(
|
|
76
|
+
{ start_at: '2025-12-31T15:00:00Z', end_at: '2025-12-31T16:00:00Z' },
|
|
77
|
+
dates,
|
|
78
|
+
zone
|
|
79
|
+
)
|
|
80
|
+
).toBe(true);
|
|
81
|
+
expect(
|
|
82
|
+
calendarEventInQueryRange(
|
|
83
|
+
{ start_at: '2026-01-01T15:00:00Z', end_at: '2026-01-01T16:00:00Z' },
|
|
84
|
+
dates,
|
|
85
|
+
zone
|
|
86
|
+
)
|
|
87
|
+
).toBe(false);
|
|
88
|
+
expect(
|
|
89
|
+
calendarEventInQueryRange({ start_at: 'invalid' }, dates, zone)
|
|
90
|
+
).toBe(true);
|
|
91
|
+
});
|
|
92
|
+
});
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { resolveTaskTimezone } from '@tuturuuu/utils/task-date-timezone';
|
|
2
|
+
import {
|
|
3
|
+
calendarDayKey,
|
|
4
|
+
calendarQueryRange,
|
|
5
|
+
calendarToday,
|
|
6
|
+
} from '../lib/calendar-day';
|
|
7
|
+
|
|
8
|
+
/** Range identity uses date-only carriers and the zone that resolves boundaries. */
|
|
9
|
+
export function calendarRangeCacheKey(dates: Date[], timezone?: string) {
|
|
10
|
+
const first = dates[0];
|
|
11
|
+
const last = dates[dates.length - 1];
|
|
12
|
+
return first && last
|
|
13
|
+
? `${resolveTaskTimezone(timezone ?? 'auto')}:${calendarDayKey(first)}-${calendarDayKey(last)}`
|
|
14
|
+
: '';
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function calendarRangeIncludesToday(
|
|
18
|
+
dates: Date[],
|
|
19
|
+
timezone?: string,
|
|
20
|
+
now?: Date
|
|
21
|
+
) {
|
|
22
|
+
const first = dates[0];
|
|
23
|
+
const last = dates[dates.length - 1];
|
|
24
|
+
if (!first || !last) return false;
|
|
25
|
+
const today = calendarDayKey(calendarToday(timezone, now));
|
|
26
|
+
return calendarDayKey(first) <= today && calendarDayKey(last) >= today;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function calendarEventInQueryRange(
|
|
30
|
+
event: { start_at?: string; end_at?: string },
|
|
31
|
+
dates: Date[],
|
|
32
|
+
timezone?: string
|
|
33
|
+
) {
|
|
34
|
+
if (!dates.length || (!event.start_at && !event.end_at)) return true;
|
|
35
|
+
const { start, end } = calendarQueryRange(dates, timezone);
|
|
36
|
+
const startAt = event.start_at ? new Date(event.start_at).getTime() : NaN;
|
|
37
|
+
const endAt = event.end_at ? new Date(event.end_at).getTime() : startAt;
|
|
38
|
+
if (Number.isNaN(startAt) || Number.isNaN(endAt)) return true;
|
|
39
|
+
return startAt < end.getTime() && endAt >= start.getTime();
|
|
40
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useSyncExternalStore } from 'react';
|
|
4
|
+
|
|
5
|
+
const MINUTE_MS = 60_000;
|
|
6
|
+
const listeners = new Set<() => void>();
|
|
7
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
8
|
+
|
|
9
|
+
function getSnapshot() {
|
|
10
|
+
return Math.floor(Date.now() / MINUTE_MS);
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function getServerSnapshot() {
|
|
14
|
+
// Server and hydration must share a deterministic snapshot; React then reads now.
|
|
15
|
+
return 0;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function scheduleTick() {
|
|
19
|
+
timer = setTimeout(
|
|
20
|
+
() => {
|
|
21
|
+
timer = undefined;
|
|
22
|
+
for (const listener of listeners) listener();
|
|
23
|
+
if (listeners.size) scheduleTick();
|
|
24
|
+
},
|
|
25
|
+
MINUTE_MS - (Date.now() % MINUTE_MS)
|
|
26
|
+
);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function subscribe(listener: () => void) {
|
|
30
|
+
listeners.add(listener);
|
|
31
|
+
if (timer === undefined) scheduleTick();
|
|
32
|
+
return () => {
|
|
33
|
+
listeners.delete(listener);
|
|
34
|
+
if (!listeners.size && timer !== undefined) {
|
|
35
|
+
clearTimeout(timer);
|
|
36
|
+
timer = undefined;
|
|
37
|
+
}
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** One minute clock shared by every calendar view; selected days stay immutable. */
|
|
42
|
+
export function useCalendarClock(): Date {
|
|
43
|
+
const minute = useSyncExternalStore(
|
|
44
|
+
subscribe,
|
|
45
|
+
getSnapshot,
|
|
46
|
+
getServerSnapshot
|
|
47
|
+
);
|
|
48
|
+
return new Date(minute * MINUTE_MS);
|
|
49
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
import { createContext, useContext } from 'react';
|
|
3
|
+
export type CalendarDayZone = {
|
|
4
|
+
timezone?: string;
|
|
5
|
+
setTimezone: (zone: string) => void;
|
|
6
|
+
goToToday?: () => void;
|
|
7
|
+
};
|
|
8
|
+
const CalendarDayZoneContext = createContext<CalendarDayZone | null>(null);
|
|
9
|
+
export const CalendarDayZoneBridgeProvider = CalendarDayZoneContext.Provider;
|
|
10
|
+
export function useCalendarDayZone() {
|
|
11
|
+
return useContext(CalendarDayZoneContext);
|
|
12
|
+
}
|
|
@@ -9,7 +9,6 @@ import type {
|
|
|
9
9
|
} from '@tuturuuu/types';
|
|
10
10
|
import type { CalendarEvent } from '@tuturuuu/types/primitives/calendar-event';
|
|
11
11
|
import { isAllDayEvent } from '@tuturuuu/utils/calendar-utils';
|
|
12
|
-
import dayjs from 'dayjs';
|
|
13
12
|
import {
|
|
14
13
|
createContext,
|
|
15
14
|
type SetStateAction,
|
|
@@ -21,12 +20,18 @@ import {
|
|
|
21
20
|
useState,
|
|
22
21
|
} from 'react';
|
|
23
22
|
import { toast } from '../components/ui/sonner';
|
|
23
|
+
import { calendarQueryRange } from '../lib/calendar-day';
|
|
24
24
|
import { runCalendarProviderSync } from './calendar-provider-sync';
|
|
25
25
|
import {
|
|
26
26
|
type CacheUpdate,
|
|
27
27
|
type CalendarCache,
|
|
28
28
|
updateCalendarRangeCache,
|
|
29
29
|
} from './calendar-range-cache';
|
|
30
|
+
import {
|
|
31
|
+
calendarEventInQueryRange,
|
|
32
|
+
calendarRangeCacheKey,
|
|
33
|
+
calendarRangeIncludesToday,
|
|
34
|
+
} from './calendar-sync-range';
|
|
30
35
|
|
|
31
36
|
// Type for calendar connection
|
|
32
37
|
type CalendarConnection = {
|
|
@@ -91,6 +96,8 @@ const CalendarSyncContext = createContext<{
|
|
|
91
96
|
googleData: WorkspaceCalendarEvent[] | null;
|
|
92
97
|
error: Error | null;
|
|
93
98
|
dates: Date[];
|
|
99
|
+
timezone: string | undefined;
|
|
100
|
+
setTimezone: (timezone: string | undefined) => void;
|
|
94
101
|
|
|
95
102
|
setDates: (dates: Date[]) => void;
|
|
96
103
|
currentView: 'day' | '4-day' | 'week' | 'month';
|
|
@@ -141,6 +148,8 @@ const CalendarSyncContext = createContext<{
|
|
|
141
148
|
googleData: null,
|
|
142
149
|
error: null,
|
|
143
150
|
dates: [],
|
|
151
|
+
timezone: undefined,
|
|
152
|
+
setTimezone: () => {},
|
|
144
153
|
setDates: () => {},
|
|
145
154
|
currentView: 'day',
|
|
146
155
|
setCurrentView: () => {},
|
|
@@ -196,6 +205,7 @@ export const CalendarSyncProvider = ({
|
|
|
196
205
|
|
|
197
206
|
const [error, setError] = useState<Error | null>(null);
|
|
198
207
|
const [dates, setDates] = useState<Date[]>([]);
|
|
208
|
+
const [timezone, setTimezone] = useState<string>();
|
|
199
209
|
const [currentView, setCurrentView] = useState<
|
|
200
210
|
'day' | '4-day' | 'week' | 'month'
|
|
201
211
|
>('day');
|
|
@@ -257,53 +267,18 @@ export const CalendarSyncProvider = ({
|
|
|
257
267
|
|
|
258
268
|
const setCalendarConnections = setCalendarConnectionsState;
|
|
259
269
|
|
|
260
|
-
// Helper to generate cache key from dates
|
|
261
|
-
const getCacheKey = useCallback((dateRange: Date[]) => {
|
|
262
|
-
if (!dateRange || dateRange.length === 0) {
|
|
263
|
-
return '';
|
|
264
|
-
}
|
|
265
|
-
return `${dateRange[0]!.toISOString()}-${dateRange[dateRange.length - 1]!.toISOString()}`;
|
|
266
|
-
}, []);
|
|
267
|
-
|
|
268
270
|
const activeCacheKey = useMemo(
|
|
269
|
-
() =>
|
|
270
|
-
|
|
271
|
+
() =>
|
|
272
|
+
dates.length ? `${wsId}:${calendarRangeCacheKey(dates, timezone)}` : '',
|
|
273
|
+
[dates, timezone, wsId]
|
|
271
274
|
);
|
|
272
275
|
const activeCachedDatabaseEvents = activeCacheKey
|
|
273
276
|
? calendarCache[activeCacheKey]?.dbEvents
|
|
274
277
|
: undefined;
|
|
275
278
|
|
|
276
|
-
// Helper to check if a date range includes today (current week issue)
|
|
277
|
-
const includesCurrentWeek = useCallback((dateRange: Date[]) => {
|
|
278
|
-
if (!dateRange || dateRange.length === 0) return false;
|
|
279
|
-
const today = new Date();
|
|
280
|
-
const startOfToday = new Date(
|
|
281
|
-
today.getFullYear(),
|
|
282
|
-
today.getMonth(),
|
|
283
|
-
today.getDate()
|
|
284
|
-
);
|
|
285
|
-
const endOfToday = new Date(
|
|
286
|
-
today.getFullYear(),
|
|
287
|
-
today.getMonth(),
|
|
288
|
-
today.getDate(),
|
|
289
|
-
23,
|
|
290
|
-
59,
|
|
291
|
-
59
|
|
292
|
-
);
|
|
293
|
-
|
|
294
|
-
const firstDate = dateRange[0];
|
|
295
|
-
const lastDate = dateRange[dateRange.length - 1];
|
|
296
|
-
if (!firstDate || !lastDate) return false;
|
|
297
|
-
|
|
298
|
-
const rangeStart = new Date(firstDate);
|
|
299
|
-
const rangeEnd = new Date(lastDate);
|
|
300
|
-
|
|
301
|
-
return rangeStart <= endOfToday && rangeEnd >= startOfToday;
|
|
302
|
-
}, []);
|
|
303
|
-
|
|
304
279
|
// Enhanced cache staleness check - shorter staleness for current week
|
|
305
280
|
const isCacheStaleEnhanced = (lastUpdated: number, dateRange: Date[]) => {
|
|
306
|
-
const isCurrentWeek =
|
|
281
|
+
const isCurrentWeek = calendarRangeIncludesToday(dateRange, timezone);
|
|
307
282
|
// 30 seconds for current week, 5 minutes for other weeks
|
|
308
283
|
const staleTime = isCurrentWeek ? 30 * 1000 : 5 * 60 * 1000; // 30 seconds
|
|
309
284
|
return Date.now() - lastUpdated >= staleTime;
|
|
@@ -316,27 +291,9 @@ export const CalendarSyncProvider = ({
|
|
|
316
291
|
}, []);
|
|
317
292
|
|
|
318
293
|
const isVisibleInCurrentRange = useCallback(
|
|
319
|
-
(event: { start_at?: string; end_at?: string }) =>
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
const firstDate = dates[0];
|
|
323
|
-
const lastDate = dates[dates.length - 1];
|
|
324
|
-
if (!firstDate || !lastDate) return true;
|
|
325
|
-
|
|
326
|
-
if (!event.start_at && !event.end_at) return true;
|
|
327
|
-
|
|
328
|
-
const rangeStart = dayjs(firstDate).startOf('day').valueOf();
|
|
329
|
-
const rangeEnd = dayjs(lastDate).endOf('day').valueOf();
|
|
330
|
-
const startAt = event.start_at ? dayjs(event.start_at).valueOf() : NaN;
|
|
331
|
-
const endAt = event.end_at ? dayjs(event.end_at).valueOf() : startAt;
|
|
332
|
-
|
|
333
|
-
if (Number.isNaN(startAt) || Number.isNaN(endAt)) {
|
|
334
|
-
return true;
|
|
335
|
-
}
|
|
336
|
-
|
|
337
|
-
return startAt <= rangeEnd && endAt >= rangeStart;
|
|
338
|
-
},
|
|
339
|
-
[dates]
|
|
294
|
+
(event: { start_at?: string; end_at?: string }) =>
|
|
295
|
+
calendarEventInQueryRange(event, dates, timezone),
|
|
296
|
+
[dates, timezone]
|
|
340
297
|
);
|
|
341
298
|
|
|
342
299
|
const patchVisibleEvents = useCallback(
|
|
@@ -417,10 +374,10 @@ export const CalendarSyncProvider = ({
|
|
|
417
374
|
}
|
|
418
375
|
|
|
419
376
|
// Otherwise fetch fresh data via API (which handles E2EE decryption)
|
|
420
|
-
const startDate =
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
377
|
+
const { start: startDate, end: endDate } = calendarQueryRange(
|
|
378
|
+
dates,
|
|
379
|
+
timezone
|
|
380
|
+
);
|
|
424
381
|
|
|
425
382
|
try {
|
|
426
383
|
const response = await fetch(
|
|
@@ -494,10 +451,10 @@ export const CalendarSyncProvider = ({
|
|
|
494
451
|
staleTime: 2 * 60_000,
|
|
495
452
|
gcTime: 30 * 60_000,
|
|
496
453
|
queryFn: async () => {
|
|
497
|
-
const startDate =
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
454
|
+
const { start: startDate, end: endDate } = calendarQueryRange(
|
|
455
|
+
dates,
|
|
456
|
+
timezone
|
|
457
|
+
);
|
|
501
458
|
|
|
502
459
|
try {
|
|
503
460
|
const response = await fetch(
|
|
@@ -534,15 +491,18 @@ export const CalendarSyncProvider = ({
|
|
|
534
491
|
});
|
|
535
492
|
|
|
536
493
|
// Helper to check if dates have actually changed
|
|
537
|
-
const areDatesEqual = useCallback(
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
494
|
+
const areDatesEqual = useCallback(
|
|
495
|
+
(newDates: Date[]) => {
|
|
496
|
+
const newDatesStr = calendarRangeCacheKey(newDates, timezone);
|
|
497
|
+
const prevDatesStr = prevDatesRef.current;
|
|
498
|
+
const areEqual = newDatesStr === prevDatesStr;
|
|
499
|
+
if (!areEqual) {
|
|
500
|
+
prevDatesRef.current = newDatesStr;
|
|
501
|
+
}
|
|
502
|
+
return areEqual;
|
|
503
|
+
},
|
|
504
|
+
[timezone]
|
|
505
|
+
);
|
|
546
506
|
|
|
547
507
|
// Invalidate and refetch events
|
|
548
508
|
const refresh = useCallback(() => {
|
|
@@ -654,7 +614,7 @@ export const CalendarSyncProvider = ({
|
|
|
654
614
|
const cacheData = calendarCache[activeCacheKey];
|
|
655
615
|
|
|
656
616
|
// For current week, force a fresh database fetch
|
|
657
|
-
const isCurrentWeek =
|
|
617
|
+
const isCurrentWeek = calendarRangeIncludesToday(dates, timezone);
|
|
658
618
|
|
|
659
619
|
if (cacheData && isCurrentWeek) {
|
|
660
620
|
isForcedRef.current = true;
|
|
@@ -666,7 +626,7 @@ export const CalendarSyncProvider = ({
|
|
|
666
626
|
}, [
|
|
667
627
|
dates,
|
|
668
628
|
calendarCache,
|
|
669
|
-
|
|
629
|
+
timezone,
|
|
670
630
|
areDatesEqual,
|
|
671
631
|
activeCacheKey,
|
|
672
632
|
updateCache,
|
|
@@ -841,6 +801,8 @@ export const CalendarSyncProvider = ({
|
|
|
841
801
|
error,
|
|
842
802
|
dates,
|
|
843
803
|
setDates,
|
|
804
|
+
timezone,
|
|
805
|
+
setTimezone,
|
|
844
806
|
currentView,
|
|
845
807
|
setCurrentView,
|
|
846
808
|
syncToTuturuuu,
|
|
@@ -437,7 +437,7 @@ export const CalendarProvider = ({
|
|
|
437
437
|
const updateQueueRef = useRef<PendingEventUpdate[]>([]);
|
|
438
438
|
const isProcessingQueueRef = useRef<boolean>(false);
|
|
439
439
|
|
|
440
|
-
const { events, refresh, patchVisibleEvents } = useCalendarSync();
|
|
440
|
+
const { events, refresh, patchVisibleEvents, timezone } = useCalendarSync();
|
|
441
441
|
|
|
442
442
|
const [activeEventId, setActiveEventId] = useState<string | null>(null);
|
|
443
443
|
const [previewEventId, setPreviewEventId] = useState<string | null>(null);
|
|
@@ -503,8 +503,8 @@ export const CalendarProvider = ({
|
|
|
503
503
|
);
|
|
504
504
|
|
|
505
505
|
const getCurrentEvents = useMemo(
|
|
506
|
-
() => createCalendarEventLookup(events),
|
|
507
|
-
[events]
|
|
506
|
+
() => createCalendarEventLookup(events, timezone),
|
|
507
|
+
[events, timezone]
|
|
508
508
|
);
|
|
509
509
|
|
|
510
510
|
const getUpcomingEvent = useCallback(() => {
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import {
|
|
2
|
+
buildDateInTimezone,
|
|
3
|
+
getDatePartsInTimezone,
|
|
4
|
+
resolveTaskTimezone,
|
|
5
|
+
} from '@tuturuuu/utils/task-date-timezone';
|
|
6
|
+
|
|
7
|
+
/** Local fields carry a calendar day; this Date is never an event instant. */
|
|
8
|
+
export function calendarDay(date: Date): Date {
|
|
9
|
+
return new Date(date.getFullYear(), date.getMonth(), date.getDate());
|
|
10
|
+
}
|
|
11
|
+
export function calendarDayKey(date: Date): string {
|
|
12
|
+
return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`;
|
|
13
|
+
}
|
|
14
|
+
export function calendarInstantDay(instant: Date, zone?: string): Date {
|
|
15
|
+
const parts = getDatePartsInTimezone(
|
|
16
|
+
instant,
|
|
17
|
+
resolveTaskTimezone(zone ?? 'auto')
|
|
18
|
+
);
|
|
19
|
+
return new Date(parts.year, parts.month - 1, parts.day);
|
|
20
|
+
}
|
|
21
|
+
export function calendarToday(zone?: string, now = new Date()): Date {
|
|
22
|
+
return calendarInstantDay(now, zone);
|
|
23
|
+
}
|
|
24
|
+
/** Construct each midnight independently so DST days can span 23 or 25 hours. */
|
|
25
|
+
export function calendarDayBoundary(day: Date, zone?: string): Date {
|
|
26
|
+
return buildDateInTimezone(
|
|
27
|
+
day.getFullYear(),
|
|
28
|
+
day.getMonth() + 1,
|
|
29
|
+
day.getDate(),
|
|
30
|
+
0,
|
|
31
|
+
0,
|
|
32
|
+
resolveTaskTimezone(zone ?? 'auto')
|
|
33
|
+
);
|
|
34
|
+
}
|
|
35
|
+
export function calendarQueryRange(dates: Date[], zone?: string) {
|
|
36
|
+
const first = dates[0];
|
|
37
|
+
const last = dates.at(-1);
|
|
38
|
+
if (!first || !last) throw new Error('Calendar range needs at least one day');
|
|
39
|
+
const afterLast = new Date(
|
|
40
|
+
last.getFullYear(),
|
|
41
|
+
last.getMonth(),
|
|
42
|
+
last.getDate() + 1
|
|
43
|
+
);
|
|
44
|
+
return {
|
|
45
|
+
start: calendarDayBoundary(first, zone),
|
|
46
|
+
end: calendarDayBoundary(afterLast, zone),
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
/** Date-only navigation preserves its YMD; timestamp navigation projects an instant. */
|
|
50
|
+
export function calendarNavigationDate(
|
|
51
|
+
value: string,
|
|
52
|
+
zone?: string
|
|
53
|
+
): Date | null {
|
|
54
|
+
const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(value);
|
|
55
|
+
if (match) {
|
|
56
|
+
const date = new Date(
|
|
57
|
+
Number(match[1]),
|
|
58
|
+
Number(match[2]) - 1,
|
|
59
|
+
Number(match[3])
|
|
60
|
+
);
|
|
61
|
+
return calendarDayKey(date) === value ? date : null;
|
|
62
|
+
}
|
|
63
|
+
const instant = new Date(value);
|
|
64
|
+
return Number.isFinite(instant.getTime())
|
|
65
|
+
? calendarInstantDay(instant, zone)
|
|
66
|
+
: null;
|
|
67
|
+
}
|