@tuturuuu/ui 0.37.0 → 0.37.2

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 (63) hide show
  1. package/CHANGELOG.md +26 -0
  2. package/package.json +7 -5
  3. package/src/components/ui/custom/__tests__/workspace-picker-content.test.tsx +181 -0
  4. package/src/components/ui/custom/__tests__/workspace-select-helpers.test.ts +24 -38
  5. package/src/components/ui/custom/hidden-workspaces-settings.tsx +49 -0
  6. package/src/components/ui/custom/mobile-header-height.ts +16 -0
  7. package/src/components/ui/custom/structure.tsx +37 -5
  8. package/src/components/ui/custom/user-filters.tsx +2 -1
  9. package/src/components/ui/custom/visible-workspace-filter.tsx +41 -0
  10. package/src/components/ui/custom/workspace-picker-content.tsx +293 -0
  11. package/src/components/ui/custom/workspace-select-helpers.ts +11 -0
  12. package/src/components/ui/custom/workspace-select-invitations.tsx +3 -0
  13. package/src/components/ui/custom/workspace-select.tsx +87 -359
  14. package/src/components/ui/date-time-picker-timezone.test.tsx +258 -0
  15. package/src/components/ui/date-time-picker-values.ts +205 -0
  16. package/src/components/ui/date-time-picker.tsx +70 -167
  17. package/src/components/ui/legacy/calendar/agenda-view.tsx +29 -94
  18. package/src/components/ui/legacy/calendar/all-day-color.test.tsx +48 -0
  19. package/src/components/ui/legacy/calendar/all-day-event-bar.tsx +29 -41
  20. package/src/components/ui/legacy/calendar/calendar-cell-color.test.tsx +70 -0
  21. package/src/components/ui/legacy/calendar/calendar-cell.tsx +4 -5
  22. package/src/components/ui/legacy/calendar/calendar-content.test.tsx +82 -3
  23. package/src/components/ui/legacy/calendar/calendar-content.tsx +37 -34
  24. package/src/components/ui/legacy/calendar/calendar-day-bridge.test.tsx +94 -0
  25. package/src/components/ui/legacy/calendar/calendar-day-display.test.tsx +106 -0
  26. package/src/components/ui/legacy/calendar/calendar-header.tsx +14 -5
  27. package/src/components/ui/legacy/calendar/calendar-matrix.tsx +4 -9
  28. package/src/components/ui/legacy/calendar/calendar-views.test.tsx +120 -6
  29. package/src/components/ui/legacy/calendar/day-title.tsx +6 -6
  30. package/src/components/ui/legacy/calendar/dynamic-island.test.tsx +140 -0
  31. package/src/components/ui/legacy/calendar/dynamic-island.tsx +27 -24
  32. package/src/components/ui/legacy/calendar/event-card.test.tsx +206 -7
  33. package/src/components/ui/legacy/calendar/event-card.tsx +27 -49
  34. package/src/components/ui/legacy/calendar/event-modal.tsx +6 -26
  35. package/src/components/ui/legacy/calendar/event-picker-bounds.ts +16 -0
  36. package/src/components/ui/legacy/calendar/event-preview-popover.test.tsx +139 -0
  37. package/src/components/ui/legacy/calendar/event-preview-popover.tsx +13 -17
  38. package/src/components/ui/legacy/calendar/event-preview-time.ts +41 -0
  39. package/src/components/ui/legacy/calendar/location-timeline.tsx +9 -17
  40. package/src/components/ui/legacy/calendar/month-calendar.tsx +20 -10
  41. package/src/components/ui/legacy/calendar/settings/calendar-sync-dashboard.tsx +4 -23
  42. package/src/components/ui/legacy/calendar/settings/sync-logs-table.tsx +3 -6
  43. package/src/components/ui/legacy/calendar/time-indicator-line.tsx +3 -20
  44. package/src/components/ui/legacy/calendar/time-indicator.tsx +11 -3
  45. package/src/components/ui/legacy/calendar/weekday-bar.tsx +2 -8
  46. package/src/components/ui/legacy/calendar/year-calendar.tsx +27 -39
  47. package/src/components/ui/resizable.tsx +2 -1
  48. package/src/hooks/__tests__/use-calendar-sync.test.tsx +45 -0
  49. package/src/hooks/__tests__/use-visible-workspaces.test.tsx +76 -0
  50. package/src/hooks/__tests__/use-workspace-visibility.test.tsx +155 -0
  51. package/src/hooks/__tests__/workspace-actor-lifetime.test.tsx +103 -0
  52. package/src/hooks/calendar-event-lookup.test.ts +39 -0
  53. package/src/hooks/calendar-event-lookup.ts +22 -8
  54. package/src/hooks/calendar-sync-range.test.ts +92 -0
  55. package/src/hooks/calendar-sync-range.ts +40 -0
  56. package/src/hooks/use-calendar-clock.ts +49 -0
  57. package/src/hooks/use-calendar-day-zone.tsx +12 -0
  58. package/src/hooks/use-calendar-sync.tsx +42 -80
  59. package/src/hooks/use-calendar.tsx +3 -3
  60. package/src/hooks/use-visible-workspaces.ts +57 -0
  61. package/src/hooks/use-workspace-user.ts +15 -3
  62. package/src/hooks/use-workspace-visibility.ts +234 -0
  63. package/src/lib/calendar-day.ts +67 -0
@@ -0,0 +1,76 @@
1
+ import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
2
+ import { renderHook, waitFor } from '@testing-library/react';
3
+ import type { ReactNode } from 'react';
4
+ import { beforeEach, expect, it, vi } from 'vitest';
5
+ import { useVisibleWorkspaces } from '../use-visible-workspaces';
6
+ import { WorkspaceVisibilityProvider } from '../use-workspace-visibility';
7
+
8
+ const mocks = vi.hoisted(() => ({ list: vi.fn(), hidden: vi.fn() }));
9
+ vi.mock('@tuturuuu/internal-api/workspaces', () => ({
10
+ listWorkspaces: mocks.list,
11
+ }));
12
+ vi.mock('@tuturuuu/internal-api/users', () => ({
13
+ getCurrentUserHiddenWorkspaces: mocks.hidden,
14
+ updateCurrentUserHiddenWorkspace: vi.fn(),
15
+ }));
16
+ beforeEach(() => {
17
+ vi.clearAllMocks();
18
+ mocks.list.mockResolvedValue([{ id: 'visible' }, { id: 'hidden' }]);
19
+ mocks.hidden.mockResolvedValue({ hiddenWorkspaceIds: ['hidden'] });
20
+ });
21
+ function fixture() {
22
+ const client = new QueryClient({
23
+ defaultOptions: { queries: { retry: false, retryDelay: 0 } },
24
+ });
25
+ const wrapper = ({ children }: { children: ReactNode }) => (
26
+ <QueryClientProvider client={client}>
27
+ <WorkspaceVisibilityProvider actorId="A">
28
+ {children}
29
+ </WorkspaceVisibilityProvider>
30
+ </QueryClientProvider>
31
+ );
32
+ return { client, wrapper };
33
+ }
34
+ it('retains an explicit zero bound and only exposes known visible choices', async () => {
35
+ const { client, wrapper } = fixture();
36
+ const { result } = renderHook(() => useVisibleWorkspaces(true, 0), {
37
+ wrapper,
38
+ });
39
+ await waitFor(() => expect(result.current.data).toEqual([{ id: 'visible' }]));
40
+ expect(mocks.list).toHaveBeenCalledWith({ limit: 0 });
41
+ expect(client.getQueryData(['workspace-ui-list', 'A', 0])).toHaveLength(2);
42
+ });
43
+ it('settled private discovery failure is an error rather than permanent loading', async () => {
44
+ mocks.hidden.mockRejectedValue(new Error('offline'));
45
+ const { client, wrapper } = fixture();
46
+ const { result } = renderHook(() => useVisibleWorkspaces(), { wrapper });
47
+ await waitFor(() =>
48
+ expect(client.getQueryState(['workspace-hidden', 'A'])?.status).toBe(
49
+ 'error'
50
+ )
51
+ );
52
+ await waitFor(() => expect(result.current.isLoading).toBe(false));
53
+ expect(result.current.isError).toBe(true);
54
+ expect(result.current.error?.message).toBe('offline');
55
+ expect(result.current.data).toBeUndefined();
56
+ });
57
+
58
+ it('distinguishes an absent personal workspace from one excluded by Hidden preferences', async () => {
59
+ mocks.list.mockResolvedValue([{ id: 'team', personal: false }]);
60
+ const { client, wrapper } = fixture();
61
+ const { result } = renderHook(() => useVisibleWorkspaces(), { wrapper });
62
+ await waitFor(() =>
63
+ expect(result.current.personalWorkspaceMissing).toBe(true)
64
+ );
65
+ mocks.list.mockResolvedValue([
66
+ { id: 'team', personal: false },
67
+ { id: 'personal', personal: true },
68
+ ]);
69
+ mocks.hidden.mockResolvedValue({ hiddenWorkspaceIds: ['personal'] });
70
+ await result.current.refetch();
71
+ await waitFor(() =>
72
+ expect(result.current.data).toEqual([{ id: 'team', personal: false }])
73
+ );
74
+ expect(result.current.personalWorkspaceMissing).toBe(false);
75
+ expect(client.getQueryData(['workspace-ui-list', 'A'])).toHaveLength(2);
76
+ });
@@ -0,0 +1,155 @@
1
+ import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
2
+ import { act, renderHook, waitFor } from '@testing-library/react';
3
+ import type { ReactNode } from 'react';
4
+ import { beforeEach, describe, expect, it, vi } from 'vitest';
5
+ import {
6
+ useWorkspaceVisibility,
7
+ WorkspaceVisibilityProvider,
8
+ } from '../use-workspace-visibility';
9
+
10
+ const mocks = vi.hoisted(() => ({ get: vi.fn(), put: vi.fn() }));
11
+ vi.mock('@tuturuuu/internal-api/users', () => ({
12
+ getCurrentUserHiddenWorkspaces: mocks.get,
13
+ updateCurrentUserHiddenWorkspace: mocks.put,
14
+ }));
15
+ function deferred<T>() {
16
+ let resolve!: (v: T) => void;
17
+ let reject!: (e: Error) => void;
18
+ const promise = new Promise<T>((a, b) => {
19
+ resolve = a;
20
+ reject = b;
21
+ });
22
+ return { promise, resolve, reject };
23
+ }
24
+ function setup(
25
+ actor = 'A',
26
+ client = new QueryClient({
27
+ defaultOptions: { queries: { retry: false }, mutations: { retry: false } },
28
+ })
29
+ ) {
30
+ const wrapper = ({ children }: { children: ReactNode }) => (
31
+ <QueryClientProvider client={client}>
32
+ <WorkspaceVisibilityProvider actorId={actor}>
33
+ {children}
34
+ </WorkspaceVisibilityProvider>
35
+ </QueryClientProvider>
36
+ );
37
+ return { ...renderHook(() => useWorkspaceVisibility(), { wrapper }), client };
38
+ }
39
+ beforeEach(() => {
40
+ vi.clearAllMocks();
41
+ mocks.get.mockResolvedValue({ hiddenWorkspaceIds: [] });
42
+ mocks.put.mockResolvedValue({});
43
+ });
44
+ describe('private owner-scoped workspace visibility', () => {
45
+ it('optimistically hides and rolls back only the failed workspace', async () => {
46
+ const write = deferred<object>();
47
+ mocks.put.mockReturnValue(write.promise);
48
+ const hook = setup();
49
+ await waitFor(() => expect(hook.result.current.known).toBe(true));
50
+ let operation!: Promise<void>;
51
+ act(() => {
52
+ operation = hook.result.current.setHidden('ws-1', true);
53
+ });
54
+ await waitFor(() =>
55
+ expect(hook.result.current.hiddenIds).toEqual(['ws-1'])
56
+ );
57
+ await act(async () => {
58
+ write.reject(new Error('offline'));
59
+ await expect(operation).rejects.toThrow('offline');
60
+ });
61
+ expect(hook.result.current.hiddenIds).toEqual([]);
62
+ expect(hook.result.current.updateError).not.toBeNull();
63
+ expect(mocks.put).toHaveBeenCalledWith('ws-1', true, 'A');
64
+ await act(async () => {
65
+ await hook.result.current.refetch();
66
+ });
67
+ expect(hook.result.current.updateError).toBeNull();
68
+ });
69
+ it('a stale refresh cannot replace optimistic state', async () => {
70
+ const hook = setup();
71
+ await waitFor(() => expect(hook.result.current.known).toBe(true));
72
+ const read = deferred<{ hiddenWorkspaceIds: string[] }>();
73
+ mocks.get
74
+ .mockImplementationOnce(() => read.promise)
75
+ .mockResolvedValue({ hiddenWorkspaceIds: ['ws-1'] });
76
+ act(() => {
77
+ void hook.result.current.refetch();
78
+ });
79
+ await act(async () => {
80
+ await hook.result.current.setHidden('ws-1', true);
81
+ });
82
+ await act(async () => {
83
+ read.resolve({ hiddenWorkspaceIds: [] });
84
+ });
85
+ expect(hook.result.current.hiddenIds).toEqual(['ws-1']);
86
+ });
87
+ it('suppresses duplicate writes for the same pending workspace', async () => {
88
+ const write = deferred<object>();
89
+ mocks.put.mockReturnValue(write.promise);
90
+ const hook = setup();
91
+ await waitFor(() => expect(hook.result.current.known).toBe(true));
92
+ let operation!: Promise<void>;
93
+ act(() => {
94
+ operation = hook.result.current.setHidden('ws-1', true);
95
+ });
96
+ await waitFor(() =>
97
+ expect(hook.result.current.pending.has('ws-1')).toBe(true)
98
+ );
99
+ await act(async () => {
100
+ await hook.result.current.setHidden('ws-1', true);
101
+ });
102
+ expect(mocks.put).toHaveBeenCalledTimes(1);
103
+ await act(async () => {
104
+ write.resolve({});
105
+ await operation;
106
+ });
107
+ });
108
+ it('removes private owner caches on logout and ignores late response', async () => {
109
+ const hook = setup();
110
+ await waitFor(() => expect(hook.result.current.known).toBe(true));
111
+ const write = deferred<object>();
112
+ mocks.put.mockReturnValue(write.promise);
113
+ let operation!: Promise<void>;
114
+ act(() => {
115
+ operation = hook.result.current.setHidden('ws-1', true);
116
+ });
117
+ await waitFor(() =>
118
+ expect(hook.result.current.hiddenIds).toEqual(['ws-1'])
119
+ );
120
+ hook.unmount();
121
+ write.resolve({});
122
+ await operation;
123
+ expect(hook.client.getQueryData(['workspace-hidden', 'A'])).toBeUndefined();
124
+ expect(hook.client.getQueryData(['workspace-hidden', 'B'])).toBeUndefined();
125
+ });
126
+ it('two actors use independent hidden cache keys', async () => {
127
+ mocks.get.mockImplementation(async (actor: string) => ({
128
+ hiddenWorkspaceIds: actor === 'A' ? ['ws-1'] : [],
129
+ }));
130
+ const client = new QueryClient({
131
+ defaultOptions: { queries: { retry: false } },
132
+ });
133
+ const a = setup('A', client);
134
+ const b = setup('B', client);
135
+ await waitFor(() => expect(a.result.current.hiddenIds).toEqual(['ws-1']));
136
+ await waitFor(() => expect(b.result.current.known).toBe(true));
137
+ expect(b.result.current.hiddenIds).toEqual([]);
138
+ });
139
+ });
140
+
141
+ it('settles a refetch safely after logout without returning private old-actor data', async () => {
142
+ const hook = setup();
143
+ await waitFor(() => expect(hook.result.current.known).toBe(true));
144
+ const read = deferred<{ hiddenWorkspaceIds: string[] }>();
145
+ mocks.get.mockReturnValue(read.promise);
146
+ let operation!: ReturnType<typeof hook.result.current.refetch>;
147
+ act(() => {
148
+ operation = hook.result.current.refetch();
149
+ });
150
+ await waitFor(() => expect(mocks.get).toHaveBeenCalledTimes(2));
151
+ hook.unmount();
152
+ read.resolve({ hiddenWorkspaceIds: ['old-private'] });
153
+ await expect(operation).resolves.toBeNull();
154
+ expect(hook.client.getQueryData(['workspace-hidden', 'A'])).toBeUndefined();
155
+ });
@@ -0,0 +1,103 @@
1
+ import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
2
+ import { act, render, screen, waitFor } from '@testing-library/react';
3
+ import { beforeEach, expect, it, vi } from 'vitest';
4
+ import { useWorkspaceUser } from '../use-workspace-user';
5
+ import {
6
+ useWorkspaceVisibility,
7
+ WorkspaceVisibilityProvider,
8
+ } from '../use-workspace-visibility';
9
+
10
+ const mocks = vi.hoisted(() => ({ hidden: vi.fn(), profile: vi.fn() }));
11
+ vi.mock('@tuturuuu/internal-api/users', () => ({
12
+ getCurrentUserHiddenWorkspaces: mocks.hidden,
13
+ getCurrentUserProfile: mocks.profile,
14
+ updateCurrentUserHiddenWorkspace: vi.fn(),
15
+ }));
16
+ beforeEach(() => {
17
+ vi.clearAllMocks();
18
+ mocks.hidden.mockResolvedValue({ hiddenWorkspaceIds: ['private'] });
19
+ mocks.profile.mockResolvedValue({ id: 'A' });
20
+ });
21
+ function Probe() {
22
+ const hidden = useWorkspaceVisibility();
23
+ const user = useWorkspaceUser();
24
+ return (
25
+ <output>
26
+ {JSON.stringify({
27
+ known: hidden.known,
28
+ ids: hidden.hiddenIds,
29
+ user: user.data?.id,
30
+ })}
31
+ </output>
32
+ );
33
+ }
34
+ function fixture() {
35
+ return new QueryClient({
36
+ defaultOptions: { queries: { retry: false, retryDelay: 0 } },
37
+ });
38
+ }
39
+ it('retains the root owner cache across same-actor route unmount and remount', async () => {
40
+ const client = fixture();
41
+ function Tree({ route }: { route: boolean }) {
42
+ return (
43
+ <QueryClientProvider client={client}>
44
+ <WorkspaceVisibilityProvider actorId="A">
45
+ {route && (
46
+ <WorkspaceVisibilityProvider actorId="A">
47
+ <Probe />
48
+ </WorkspaceVisibilityProvider>
49
+ )}
50
+ </WorkspaceVisibilityProvider>
51
+ </QueryClientProvider>
52
+ );
53
+ }
54
+ const tree = render(<Tree route />);
55
+ await waitFor(() =>
56
+ expect(screen.getByRole('status').textContent).toContain('"known":true')
57
+ );
58
+ await waitFor(() =>
59
+ expect(client.getQueryData(['workspace-user', 'A'])).toMatchObject({
60
+ id: 'A',
61
+ })
62
+ );
63
+ tree.rerender(<Tree route={false} />);
64
+ expect(client.getQueryData(['workspace-hidden', 'A'])).toEqual(['private']);
65
+ tree.rerender(<Tree route />);
66
+ await waitFor(() =>
67
+ expect(screen.getByRole('status').textContent).toContain('private')
68
+ );
69
+ expect(mocks.hidden).toHaveBeenCalledTimes(1);
70
+ expect(mocks.profile).toHaveBeenCalledTimes(1);
71
+ expect(client.getQueryData(['workspace-user'])).toBeUndefined();
72
+ tree.unmount();
73
+ expect(client.getQueryData(['workspace-hidden', 'A'])).toBeUndefined();
74
+ expect(client.getQueryData(['workspace-user', 'A'])).toBeUndefined();
75
+ });
76
+ it('does not read profiles or private preferences without verified identity', async () => {
77
+ const client = fixture();
78
+ render(
79
+ <QueryClientProvider client={client}>
80
+ <Probe />
81
+ </QueryClientProvider>
82
+ );
83
+ await act(async () => {});
84
+ expect(mocks.profile).not.toHaveBeenCalled();
85
+ expect(mocks.hidden).not.toHaveBeenCalled();
86
+ expect(screen.getByRole('status').textContent).toContain('"known":false');
87
+ });
88
+ it('fails a mismatched route actor closed under a verified root', async () => {
89
+ const client = fixture();
90
+ render(
91
+ <QueryClientProvider client={client}>
92
+ <WorkspaceVisibilityProvider actorId="B">
93
+ <WorkspaceVisibilityProvider actorId="A">
94
+ <Probe />
95
+ </WorkspaceVisibilityProvider>
96
+ </WorkspaceVisibilityProvider>
97
+ </QueryClientProvider>
98
+ );
99
+ await act(async () => {});
100
+ expect(mocks.profile).not.toHaveBeenCalled();
101
+ expect(mocks.hidden).not.toHaveBeenCalled();
102
+ expect(screen.getByRole('status').textContent).toContain('"known":false');
103
+ });
@@ -12,6 +12,45 @@ const event = (id: string, start: string, end: string): CalendarEvent => ({
12
12
  end_at: end,
13
13
  });
14
14
  describe('indexed calendar lookup', () => {
15
+ it.each([
16
+ ['Asia/Tokyo', 'America/Los_Angeles', '2026-01-01T07:30:00Z', 2025, 11, 31],
17
+ ['America/Los_Angeles', 'Asia/Tokyo', '2025-12-31T15:30:00Z', 2026, 0, 1],
18
+ ] as const)(
19
+ 'groups timed instants in %s browser with %s calendar days',
20
+ (browserZone, calendarZone, instant, year, month, day) => {
21
+ vi.stubEnv('TZ', browserZone);
22
+ try {
23
+ const end = new Date(
24
+ new Date(instant).getTime() + 15 * 60_000
25
+ ).toISOString();
26
+ const lookup = createCalendarEventLookup(
27
+ [event('timed', instant, end)],
28
+ calendarZone
29
+ );
30
+ expect(
31
+ lookup(new Date(year, month, day, 18)).map((item) => item.id)
32
+ ).toEqual(['timed']);
33
+ expect(lookup(new Date(year, month, day + 1))).toEqual([]);
34
+ } finally {
35
+ vi.unstubAllEnvs();
36
+ }
37
+ }
38
+ );
39
+ it('preserves the existing all-day carrier identity when the calendar zone changes', () => {
40
+ vi.stubEnv('TZ', 'UTC');
41
+ try {
42
+ const events = [
43
+ event('all-day', '2026-01-01T00:00:00Z', '2026-01-02T00:00:00Z'),
44
+ ];
45
+ for (const zone of ['America/Los_Angeles', 'Asia/Tokyo']) {
46
+ const lookup = createCalendarEventLookup(events, zone);
47
+ expect(lookup(new Date(2026, 0, 1))).toHaveLength(1);
48
+ expect(lookup(new Date(2026, 0, 2))).toHaveLength(0);
49
+ }
50
+ } finally {
51
+ vi.unstubAllEnvs();
52
+ }
53
+ });
15
54
  it('preserves event order and exclusive all-day ends across a year boundary', () => {
16
55
  const events = [
17
56
  event('timed', '2026-12-31T23:00:00', '2027-01-01T01:00:00'),
@@ -1,23 +1,37 @@
1
1
  import type { CalendarEvent } from '@tuturuuu/types/primitives/calendar-event';
2
2
  import { isAllDayEvent } from '@tuturuuu/utils/calendar-utils';
3
+ import { calendarInstantDay, calendarToday } from '../lib/calendar-day';
3
4
 
4
5
  const dayStamp = (date: Date) =>
5
6
  new Date(date.getFullYear(), date.getMonth(), date.getDate()).getTime();
6
7
  /** Parse once, binary-search starts, and prune intervals that already ended. */
7
- export function createCalendarEventLookup(events: CalendarEvent[]) {
8
+ export function createCalendarEventLookup(
9
+ events: CalendarEvent[],
10
+ timezone?: string
11
+ ) {
8
12
  const intervals = events
9
- .map((event, order) => ({
10
- event,
11
- order,
12
- start: dayStamp(new Date(event.start_at)),
13
- end: dayStamp(new Date(event.end_at)) - (isAllDayEvent(event) ? 1 : 0),
14
- }))
13
+ .map((event, order) => {
14
+ const allDay = isAllDayEvent(event);
15
+ const eventDay = (value: string) => {
16
+ const instant = new Date(value);
17
+ if (!Number.isFinite(instant.getTime())) return NaN;
18
+ return dayStamp(
19
+ allDay ? instant : calendarInstantDay(instant, timezone)
20
+ );
21
+ };
22
+ return {
23
+ event,
24
+ order,
25
+ start: eventDay(event.start_at),
26
+ end: eventDay(event.end_at) - (allDay ? 1 : 0),
27
+ };
28
+ })
15
29
  .filter((item) => Number.isFinite(item.start) && Number.isFinite(item.end))
16
30
  .sort((a, b) => a.start - b.start);
17
31
  let max = -Infinity;
18
32
  const latestEnd = intervals.map((item) => (max = Math.max(max, item.end)));
19
33
  const cache = new Map<number, CalendarEvent[]>();
20
- return (date = new Date()) => {
34
+ return (date = calendarToday(timezone)) => {
21
35
  const target = dayStamp(date);
22
36
  const cached = cache.get(target);
23
37
  if (cached) return cached;
@@ -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
+ }