@tuturuuu/ui 0.38.0 → 0.39.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 +27 -0
- package/package.json +6 -6
- package/src/components/ui/finance/invoices/attendance-calendar.test.tsx +294 -0
- package/src/components/ui/finance/invoices/attendance-calendar.tsx +14 -6
- package/src/components/ui/finance/invoices/components/invoice-history-actor.test.tsx +341 -0
- package/src/components/ui/finance/invoices/components/invoice-history-date.test.tsx +173 -0
- package/src/components/ui/finance/invoices/components/invoice-history-error.test.tsx +269 -0
- package/src/components/ui/finance/invoices/components/invoice-history-recovery.tsx +47 -0
- package/src/components/ui/finance/invoices/components/invoice-user-history-accordion.tsx +43 -10
- package/src/components/ui/finance/invoices/components/subscription-create-action.tsx +42 -0
- package/src/components/ui/finance/invoices/hooks/customer-search-scope.test.tsx +404 -0
- package/src/components/ui/finance/invoices/hooks/invoice-actor-lifetime-key.ts +11 -0
- package/src/components/ui/finance/invoices/hooks/use-infinite-user-invoices.ts +48 -0
- package/src/components/ui/finance/invoices/hooks/use-invoice-customer-search.test.tsx +6 -1
- package/src/components/ui/finance/invoices/hooks/use-invoice-customer-search.ts +58 -6
- package/src/components/ui/finance/invoices/hooks/use-subscription-auto-selection.test.tsx +34 -0
- package/src/components/ui/finance/invoices/hooks/use-subscription-auto-selection.ts +8 -2
- package/src/components/ui/finance/invoices/hooks/use-subscription-create.ts +219 -0
- package/src/components/ui/finance/invoices/hooks/use-subscription-invoice-context.ts +59 -0
- package/src/components/ui/finance/invoices/hooks/use-user-groups.ts +38 -0
- package/src/components/ui/finance/invoices/hooks.ts +3 -100
- package/src/components/ui/finance/invoices/invoiceId/compact-invoice-template.tsx +2 -7
- package/src/components/ui/finance/invoices/invoiceId/format-invoice-template-date.ts +13 -0
- package/src/components/ui/finance/invoices/invoiceId/full-invoice-template.tsx +2 -7
- package/src/components/ui/finance/invoices/invoiceId/invoice-card-date.test.tsx +169 -0
- package/src/components/ui/finance/invoices/invoiceId/invoice-card-storage.test.tsx +120 -0
- package/src/components/ui/finance/invoices/invoiceId/invoice-card.tsx +25 -62
- package/src/components/ui/finance/invoices/invoiceId/invoice-details-page.tsx +1 -0
- package/src/components/ui/finance/invoices/invoiceId/invoice-png-lifetime.test.tsx +438 -0
- package/src/components/ui/finance/invoices/invoiceId/use-invoice-png-export.ts +184 -0
- package/src/components/ui/finance/invoices/subscription-admission.test.ts +77 -0
- package/src/components/ui/finance/invoices/subscription-admission.ts +101 -0
- package/src/components/ui/finance/invoices/subscription-billing-groups.test.ts +162 -1
- package/src/components/ui/finance/invoices/subscription-billing-groups.ts +50 -9
- package/src/components/ui/finance/invoices/subscription-context-actor.test.tsx +246 -0
- package/src/components/ui/finance/invoices/subscription-create-scope.test.tsx +562 -0
- package/src/components/ui/finance/invoices/subscription-invoice.test.tsx +88 -6
- package/src/components/ui/finance/invoices/subscription-invoice.tsx +76 -135
- package/src/components/ui/finance/invoices/types.ts +3 -3
- package/src/components/ui/finance/invoices/user-groups-actor.test.tsx +334 -0
- package/src/components/ui/finance/wallets/checkpoints/wallet-checkpoint-adjustment-dialog.tsx +47 -10
- package/src/components/ui/finance/wallets/checkpoints/wallet-checkpoints.test.tsx +95 -1
- package/src/components/ui/legacy/calendar/agenda-view.tsx +4 -1
- package/src/components/ui/legacy/calendar/all-day-event-bar.tsx +33 -369
- package/src/components/ui/legacy/calendar/all-day-event-layout.test.ts +94 -0
- package/src/components/ui/legacy/calendar/all-day-event-layout.ts +322 -0
- package/src/components/ui/legacy/calendar/calendar-views.test.tsx +58 -27
- package/src/components/ui/legacy/calendar/event-card.test.tsx +25 -1
- package/src/components/ui/legacy/calendar/event-card.tsx +10 -12
- package/src/components/ui/legacy/calendar/event-modal-header.tsx +17 -4
- package/src/components/ui/legacy/calendar/event-modal.tsx +26 -1554
- package/src/components/ui/legacy/calendar/legacy-event-modal.tsx +1559 -0
- package/src/components/ui/legacy/calendar/month-calendar.tsx +5 -2
- package/src/components/ui/legacy/calendar/native-recurrence-context-menu.tsx +21 -0
- package/src/components/ui/legacy/calendar/native-recurrence-dialog.test.tsx +245 -0
- package/src/components/ui/legacy/calendar/native-recurrence-dialog.tsx +95 -0
- package/src/components/ui/legacy/calendar/native-recurrence-fields.tsx +137 -0
- package/src/components/ui/legacy/calendar/native-recurrence-form.tsx +340 -0
- package/src/components/ui/legacy/calendar/native-recurrence-model.test.ts +234 -0
- package/src/components/ui/legacy/calendar/native-recurrence-model.ts +245 -0
- package/src/components/ui/legacy/calendar/native-recurrence-pattern-fields.test.tsx +142 -0
- package/src/components/ui/legacy/calendar/native-recurrence-pattern-fields.tsx +178 -0
- package/src/components/ui/legacy/calendar/native-recurrence-pattern-model.test.ts +205 -0
- package/src/components/ui/legacy/calendar/native-recurrence-provider-routing.test.tsx +255 -0
- package/src/components/ui/legacy/calendar/past-event-treatment.test.ts +137 -0
- package/src/components/ui/legacy/calendar/past-event-treatment.ts +25 -0
- package/src/components/ui/legacy/calendar/provider-readonly-dialog.test.tsx +52 -0
- package/src/components/ui/legacy/calendar/provider-readonly-dialog.tsx +48 -0
- package/src/components/ui/legacy/calendar/use-calendar-draft-save.test.tsx +1 -1
- package/src/components/ui/legacy/calendar/use-calendar-draft-save.tsx +1 -1
- package/src/components/ui/legacy/calendar/use-provider-recurrence-operation.test.tsx +185 -0
- package/src/components/ui/legacy/calendar/use-provider-recurrence-operation.ts +186 -0
- package/src/components/ui/text-editor/editor.tsx +3 -0
- package/src/components/ui/text-editor/fixed-toolbar-contents.test.tsx +59 -0
- package/src/components/ui/text-editor/fixed-toolbar-contents.tsx +156 -0
- package/src/components/ui/text-editor/tool-bar.tsx +18 -74
- package/src/hooks/__tests__/calendar-event-write-payload-readonly.test.ts +40 -0
- package/src/hooks/__tests__/native-calendar-mutation-guard.test.ts +45 -0
- package/src/hooks/__tests__/supabase-provider.test.ts +101 -7
- package/src/hooks/__tests__/use-calendar-readonly.test.tsx +27 -0
- package/src/hooks/__tests__/use-local-storage.test.tsx +42 -0
- package/src/hooks/__tests__/use-native-calendar-occurrences.test.tsx +140 -0
- package/src/hooks/__tests__/use-yjs-collaboration.test.tsx +39 -0
- package/src/hooks/calendar-connection-types.ts +49 -0
- package/src/hooks/calendar-event-update-queue-types.ts +12 -0
- package/src/hooks/calendar-event-write-payload.ts +33 -0
- package/src/hooks/collaboration-document-hydration.ts +35 -0
- package/src/hooks/supabase-provider.ts +39 -41
- package/src/hooks/use-calendar-sync.tsx +41 -62
- package/src/hooks/use-calendar.tsx +24 -38
- package/src/hooks/use-local-storage.ts +9 -11
- package/src/hooks/use-native-calendar-occurrences.ts +85 -0
- package/src/hooks/use-yjs-collaboration.ts +23 -0
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import { isAllDayEvent } from '@tuturuuu/utils/calendar-utils';
|
|
2
|
+
import { describe, expect, it } from 'vitest';
|
|
3
|
+
import { calculateAllDayEventLayout } from './all-day-event-layout';
|
|
4
|
+
|
|
5
|
+
const date = (day: number) => new Date(2030, 0, day);
|
|
6
|
+
describe('all-day civil spans', () => {
|
|
7
|
+
it.each([
|
|
8
|
+
'America/Los_Angeles',
|
|
9
|
+
'Pacific/Honolulu',
|
|
10
|
+
'Asia/Ho_Chi_Minh',
|
|
11
|
+
'Pacific/Kiritimati',
|
|
12
|
+
])('retains UTC date-only import civil dates in %s', (zone) => {
|
|
13
|
+
const layout = calculateAllDayEventLayout(
|
|
14
|
+
[
|
|
15
|
+
{
|
|
16
|
+
id: 'synthetic',
|
|
17
|
+
title: 'Synthetic span',
|
|
18
|
+
start_at: '2030-01-15T00:00:00Z',
|
|
19
|
+
end_at: '2030-01-17T00:00:00Z',
|
|
20
|
+
},
|
|
21
|
+
],
|
|
22
|
+
[date(14), date(15), date(16), date(17)],
|
|
23
|
+
zone,
|
|
24
|
+
[]
|
|
25
|
+
);
|
|
26
|
+
expect(
|
|
27
|
+
layout.spans.map(({ startIndex, endIndex }) => [startIndex, endIndex])
|
|
28
|
+
).toEqual([[1, 2]]);
|
|
29
|
+
});
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it.each([
|
|
33
|
+
['2026-03-08T05:00:00Z', '2026-03-09T04:00:00Z', 8],
|
|
34
|
+
['2026-11-01T04:00:00Z', '2026-11-02T05:00:00Z', 1],
|
|
35
|
+
])(
|
|
36
|
+
'renders DST all-day midnights without fixed 24-hour arithmetic (%s)',
|
|
37
|
+
(start_at, end_at, day) => {
|
|
38
|
+
const event = {
|
|
39
|
+
id: 'synthetic-dst',
|
|
40
|
+
title: 'Synthetic DST',
|
|
41
|
+
start_at: start_at as string,
|
|
42
|
+
end_at: end_at as string,
|
|
43
|
+
};
|
|
44
|
+
const month = day === 8 ? 2 : 10;
|
|
45
|
+
const dates = [
|
|
46
|
+
new Date(2026, month, Number(day) - 1),
|
|
47
|
+
new Date(2026, month, Number(day)),
|
|
48
|
+
new Date(2026, month, Number(day) + 1),
|
|
49
|
+
];
|
|
50
|
+
expect(isAllDayEvent(event, 'America/New_York')).toBe(true);
|
|
51
|
+
expect(
|
|
52
|
+
calculateAllDayEventLayout(
|
|
53
|
+
[event],
|
|
54
|
+
dates,
|
|
55
|
+
'America/New_York',
|
|
56
|
+
[]
|
|
57
|
+
).spans.map(({ startIndex, endIndex }) => [startIndex, endIndex])
|
|
58
|
+
).toEqual([[1, 1]]);
|
|
59
|
+
}
|
|
60
|
+
);
|
|
61
|
+
it('clips a long span with an exclusive end and retains cutoff flags', () => {
|
|
62
|
+
const layout = calculateAllDayEventLayout(
|
|
63
|
+
[
|
|
64
|
+
{
|
|
65
|
+
id: 'synthetic-long',
|
|
66
|
+
title: 'Synthetic long',
|
|
67
|
+
start_at: '2029-12-01T00:00:00Z',
|
|
68
|
+
end_at: '2030-02-01T00:00:00Z',
|
|
69
|
+
},
|
|
70
|
+
],
|
|
71
|
+
[date(15), date(16), date(17)],
|
|
72
|
+
'America/Los_Angeles',
|
|
73
|
+
[]
|
|
74
|
+
);
|
|
75
|
+
expect(layout.spans[0]).toMatchObject({
|
|
76
|
+
startIndex: 0,
|
|
77
|
+
endIndex: 2,
|
|
78
|
+
isCutOffStart: true,
|
|
79
|
+
isCutOffEnd: true,
|
|
80
|
+
});
|
|
81
|
+
});
|
|
82
|
+
it('ignores invalid ranges and exclusive-end-only contact', () => {
|
|
83
|
+
const events = [
|
|
84
|
+
{ id: 'invalid', start_at: 'invalid', end_at: 'invalid' },
|
|
85
|
+
{
|
|
86
|
+
id: 'ended',
|
|
87
|
+
start_at: '2030-01-14T00:00:00Z',
|
|
88
|
+
end_at: '2030-01-15T00:00:00Z',
|
|
89
|
+
},
|
|
90
|
+
];
|
|
91
|
+
expect(
|
|
92
|
+
calculateAllDayEventLayout(events, [date(15)], 'Pacific/Honolulu', []).spans
|
|
93
|
+
).toEqual([]);
|
|
94
|
+
});
|
|
@@ -0,0 +1,322 @@
|
|
|
1
|
+
import type { CalendarEvent } from '@tuturuuu/types/primitives/calendar-event';
|
|
2
|
+
import { allDayEventCivilDates } from '@tuturuuu/utils/calendar-utils';
|
|
3
|
+
import dayjs from 'dayjs';
|
|
4
|
+
import isSameOrAfter from 'dayjs/plugin/isSameOrAfter';
|
|
5
|
+
import isSameOrBefore from 'dayjs/plugin/isSameOrBefore';
|
|
6
|
+
import timezone from 'dayjs/plugin/timezone';
|
|
7
|
+
import utc from 'dayjs/plugin/utc';
|
|
8
|
+
import { calendarDayKey } from '../../../../lib/calendar-day';
|
|
9
|
+
import { getEventLocationType } from './working-location';
|
|
10
|
+
|
|
11
|
+
dayjs.extend(utc);
|
|
12
|
+
dayjs.extend(timezone);
|
|
13
|
+
dayjs.extend(isSameOrAfter);
|
|
14
|
+
dayjs.extend(isSameOrBefore);
|
|
15
|
+
export const MAX_EVENTS_DISPLAY = 2;
|
|
16
|
+
function getZonedDay(date: Date, _zone?: string) {
|
|
17
|
+
return dayjs(date).startOf('day');
|
|
18
|
+
}
|
|
19
|
+
export interface EventSpan {
|
|
20
|
+
event: CalendarEvent;
|
|
21
|
+
startIndex: number;
|
|
22
|
+
endIndex: number;
|
|
23
|
+
span: number;
|
|
24
|
+
isCutOffStart: boolean; // Event starts before visible range
|
|
25
|
+
isCutOffEnd: boolean; // Event ends after visible range
|
|
26
|
+
actualStartDate: dayjs.Dayjs; // Actual start date of the event
|
|
27
|
+
actualEndDate: dayjs.Dayjs; // Actual end date of the event
|
|
28
|
+
row: number; // Add row property for proper stacking
|
|
29
|
+
isMerged?: boolean;
|
|
30
|
+
mergedEventIds?: string[];
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export interface EventLayout {
|
|
34
|
+
spans: EventSpan[];
|
|
35
|
+
maxVisibleEventsPerDay: number;
|
|
36
|
+
eventsByDay: EventSpan[][];
|
|
37
|
+
locationSpansFromMerge: EventSpan[];
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function calculateAllDayEventLayout(
|
|
41
|
+
allDayEvents: CalendarEvent[],
|
|
42
|
+
visibleDates: Date[],
|
|
43
|
+
tz: string | undefined,
|
|
44
|
+
expandedDates: string[]
|
|
45
|
+
): EventLayout {
|
|
46
|
+
const spans: EventSpan[] = [];
|
|
47
|
+
const eventsByDay: EventSpan[][] = Array(visibleDates.length)
|
|
48
|
+
.fill(null)
|
|
49
|
+
.map(() => []);
|
|
50
|
+
|
|
51
|
+
// First pass: create event spans without row assignment
|
|
52
|
+
const tempSpans: Omit<EventSpan, 'row'>[] = [];
|
|
53
|
+
|
|
54
|
+
// Process each all-day event
|
|
55
|
+
(visibleDates.length ? allDayEvents : []).forEach((event) => {
|
|
56
|
+
const civilDates = allDayEventCivilDates(event, tz);
|
|
57
|
+
if (!civilDates) return;
|
|
58
|
+
const eventStart = dayjs(civilDates.start);
|
|
59
|
+
const eventEnd = dayjs(civilDates.end);
|
|
60
|
+
|
|
61
|
+
// Find the start and end indices within our visible dates
|
|
62
|
+
let startIndex = -1;
|
|
63
|
+
let endIndex = -1;
|
|
64
|
+
|
|
65
|
+
// First pass: find any overlap with visible dates
|
|
66
|
+
const firstVisibleDate = getZonedDay(visibleDates[0]!, tz);
|
|
67
|
+
const lastVisibleDate = getZonedDay(
|
|
68
|
+
visibleDates[visibleDates.length - 1]!,
|
|
69
|
+
tz
|
|
70
|
+
);
|
|
71
|
+
|
|
72
|
+
// Check if event overlaps with our visible date range at all
|
|
73
|
+
// Event overlaps if: event_start < visible_end AND event_end > visible_start
|
|
74
|
+
const eventOverlaps =
|
|
75
|
+
eventStart.isBefore(lastVisibleDate.add(1, 'day'), 'day') &&
|
|
76
|
+
eventEnd.isAfter(firstVisibleDate, 'day');
|
|
77
|
+
|
|
78
|
+
if (!eventOverlaps) {
|
|
79
|
+
return; // Skip this event if it doesn't overlap with visible dates
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// Improved logic for multi-day events
|
|
83
|
+
// Start index: first visible date that overlaps with the event
|
|
84
|
+
if (eventStart.isSameOrBefore(firstVisibleDate, 'day')) {
|
|
85
|
+
// Event starts before or on the first visible date
|
|
86
|
+
startIndex = 0;
|
|
87
|
+
} else {
|
|
88
|
+
// Find the first visible date that matches the event start
|
|
89
|
+
for (let i = 0; i < visibleDates.length; i++) {
|
|
90
|
+
const currentDate = getZonedDay(visibleDates[i]!, tz);
|
|
91
|
+
if (
|
|
92
|
+
currentDate.isSameOrAfter(eventStart, 'day') &&
|
|
93
|
+
currentDate.isBefore(eventEnd, 'day')
|
|
94
|
+
) {
|
|
95
|
+
startIndex = i;
|
|
96
|
+
break;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// End index: last visible date that overlaps with the event
|
|
102
|
+
if (eventEnd.isAfter(lastVisibleDate.add(1, 'day'), 'day')) {
|
|
103
|
+
// Event ends after the last visible date
|
|
104
|
+
endIndex = visibleDates.length - 1;
|
|
105
|
+
} else {
|
|
106
|
+
// Find the last visible date that the event covers
|
|
107
|
+
for (let i = visibleDates.length - 1; i >= 0; i--) {
|
|
108
|
+
const currentDate = getZonedDay(visibleDates[i]!, tz);
|
|
109
|
+
if (
|
|
110
|
+
currentDate.isBefore(eventEnd, 'day') &&
|
|
111
|
+
currentDate.isSameOrAfter(eventStart, 'day')
|
|
112
|
+
) {
|
|
113
|
+
endIndex = i;
|
|
114
|
+
break;
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// Include events that have at least one day visible in our date range
|
|
120
|
+
if (startIndex !== -1 && endIndex !== -1) {
|
|
121
|
+
const span = endIndex - startIndex + 1;
|
|
122
|
+
|
|
123
|
+
// Fix: Proper cut-off logic for all-day events
|
|
124
|
+
// For all-day events, end_at is typically start of next day (exclusive)
|
|
125
|
+
// So we need to check actual duration, not just date comparison
|
|
126
|
+
const actualDurationDays = eventEnd.diff(eventStart, 'day');
|
|
127
|
+
const isActuallyMultiDay = actualDurationDays > 1;
|
|
128
|
+
|
|
129
|
+
// Only show cut-off indicators for events that actually span multiple days
|
|
130
|
+
// AND are cut off by the visible range
|
|
131
|
+
const isCutOffStart =
|
|
132
|
+
isActuallyMultiDay && eventStart.isBefore(firstVisibleDate, 'day');
|
|
133
|
+
const isCutOffEnd =
|
|
134
|
+
isActuallyMultiDay &&
|
|
135
|
+
eventEnd.isAfter(lastVisibleDate.add(1, 'day'), 'day');
|
|
136
|
+
|
|
137
|
+
const eventSpan: Omit<EventSpan, 'row'> = {
|
|
138
|
+
event,
|
|
139
|
+
startIndex,
|
|
140
|
+
endIndex,
|
|
141
|
+
span,
|
|
142
|
+
// Add indicators for cut-off events
|
|
143
|
+
isCutOffStart,
|
|
144
|
+
isCutOffEnd,
|
|
145
|
+
actualStartDate: eventStart,
|
|
146
|
+
actualEndDate: eventEnd,
|
|
147
|
+
};
|
|
148
|
+
|
|
149
|
+
tempSpans.push(eventSpan);
|
|
150
|
+
}
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
// Second pass: Merge consecutive SINGLE-DAY events with same title and color
|
|
154
|
+
// Optimized O(n log n) approach: group by mergeKey, sort groups, linear scan per group
|
|
155
|
+
const mergedTempSpans: Omit<EventSpan, 'row'>[] = [];
|
|
156
|
+
|
|
157
|
+
// Group single-day spans by mergeKey; multi-day events go directly to output
|
|
158
|
+
const groups = new Map<string, Omit<EventSpan, 'row'>[]>();
|
|
159
|
+
|
|
160
|
+
for (const span of tempSpans) {
|
|
161
|
+
if (span.span !== 1) {
|
|
162
|
+
// Multi-day events - don't merge, just add directly
|
|
163
|
+
mergedTempSpans.push(span);
|
|
164
|
+
continue;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
const mergeKey = `${(span.event.title ?? '').toLowerCase().trim()}|${span.event.color ?? 'BLUE'}`;
|
|
168
|
+
const group = groups.get(mergeKey) ?? [];
|
|
169
|
+
group.push(span);
|
|
170
|
+
groups.set(mergeKey, group);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// Process each group with linear merge
|
|
174
|
+
for (const [, group] of groups) {
|
|
175
|
+
// Sort by startIndex (O(k log k) per group, where k is group size)
|
|
176
|
+
group.sort((a, b) => a.startIndex - b.startIndex);
|
|
177
|
+
|
|
178
|
+
let currentMerged: Omit<EventSpan, 'row'> | null = null;
|
|
179
|
+
let mergedIds: string[] = [];
|
|
180
|
+
|
|
181
|
+
for (const span of group) {
|
|
182
|
+
if (!currentMerged) {
|
|
183
|
+
// Start a new potential merge chain
|
|
184
|
+
currentMerged = { ...span };
|
|
185
|
+
mergedIds = [span.event.id];
|
|
186
|
+
} else if (span.startIndex === currentMerged.endIndex + 1) {
|
|
187
|
+
// Adjacent - extend the merge
|
|
188
|
+
currentMerged.endIndex = span.endIndex;
|
|
189
|
+
currentMerged.span =
|
|
190
|
+
currentMerged.endIndex - currentMerged.startIndex + 1;
|
|
191
|
+
currentMerged.isCutOffEnd =
|
|
192
|
+
currentMerged.isCutOffEnd || span.isCutOffEnd;
|
|
193
|
+
currentMerged.actualEndDate = currentMerged.actualEndDate.isAfter(
|
|
194
|
+
span.actualEndDate
|
|
195
|
+
)
|
|
196
|
+
? currentMerged.actualEndDate
|
|
197
|
+
: span.actualEndDate;
|
|
198
|
+
mergedIds.push(span.event.id);
|
|
199
|
+
} else {
|
|
200
|
+
// Gap - push current merged span and start a new one
|
|
201
|
+
if (mergedIds.length > 1) {
|
|
202
|
+
currentMerged.isMerged = true;
|
|
203
|
+
currentMerged.mergedEventIds = mergedIds;
|
|
204
|
+
}
|
|
205
|
+
mergedTempSpans.push(currentMerged);
|
|
206
|
+
currentMerged = { ...span };
|
|
207
|
+
mergedIds = [span.event.id];
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
// Push the final merged span from this group
|
|
212
|
+
if (currentMerged) {
|
|
213
|
+
if (mergedIds.length > 1) {
|
|
214
|
+
currentMerged.isMerged = true;
|
|
215
|
+
currentMerged.mergedEventIds = mergedIds;
|
|
216
|
+
}
|
|
217
|
+
mergedTempSpans.push(currentMerged);
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
// Sort final output by startIndex for consistent ordering
|
|
222
|
+
mergedTempSpans.sort((a, b) => a.startIndex - b.startIndex);
|
|
223
|
+
|
|
224
|
+
// Third pass: assign rows (ONLY for non-location events)
|
|
225
|
+
// Location events will be rendered separately as a compact timeline strip
|
|
226
|
+
// Extract location spans before filtering them out for row assignment
|
|
227
|
+
const locationSpansFromMerge = mergedTempSpans
|
|
228
|
+
.filter((span) => getEventLocationType(span.event) !== null)
|
|
229
|
+
.map((span) => ({ ...span, row: 0 })); // Give them row 0 as placeholder
|
|
230
|
+
|
|
231
|
+
const nonLocationSpans = mergedTempSpans.filter(
|
|
232
|
+
(span) => getEventLocationType(span.event) === null
|
|
233
|
+
);
|
|
234
|
+
|
|
235
|
+
// Sort: 1) by span length (longer events first for better packing), 2) by start date
|
|
236
|
+
const sortedTempSpans = nonLocationSpans.sort((a, b) => {
|
|
237
|
+
// Longer events first (they need to claim their full row first)
|
|
238
|
+
const spanDiff = b.span - a.span;
|
|
239
|
+
if (spanDiff !== 0) return spanDiff;
|
|
240
|
+
|
|
241
|
+
// Then by start date
|
|
242
|
+
return a.actualStartDate.diff(b.actualStartDate);
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
// Track occupied rows for each day
|
|
246
|
+
const occupiedRows: boolean[][] = Array(visibleDates.length)
|
|
247
|
+
.fill(null)
|
|
248
|
+
.map(() => []);
|
|
249
|
+
|
|
250
|
+
// Assign rows to events
|
|
251
|
+
sortedTempSpans.forEach((tempSpan) => {
|
|
252
|
+
// Find the first available row that works for all days this event spans
|
|
253
|
+
let row = 0;
|
|
254
|
+
let rowFound = false;
|
|
255
|
+
|
|
256
|
+
while (!rowFound) {
|
|
257
|
+
// Check if this row is available for all days the event spans
|
|
258
|
+
let canUseRow = true;
|
|
259
|
+
for (
|
|
260
|
+
let dayIndex = tempSpan.startIndex;
|
|
261
|
+
dayIndex <= tempSpan.endIndex;
|
|
262
|
+
dayIndex++
|
|
263
|
+
) {
|
|
264
|
+
if (occupiedRows[dayIndex]?.[row]) {
|
|
265
|
+
canUseRow = false;
|
|
266
|
+
break;
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
if (canUseRow) {
|
|
271
|
+
// Mark this row as occupied for all days the event spans
|
|
272
|
+
for (
|
|
273
|
+
let dayIndex = tempSpan.startIndex;
|
|
274
|
+
dayIndex <= tempSpan.endIndex;
|
|
275
|
+
dayIndex++
|
|
276
|
+
) {
|
|
277
|
+
if (!occupiedRows[dayIndex]) {
|
|
278
|
+
occupiedRows[dayIndex] = [];
|
|
279
|
+
}
|
|
280
|
+
occupiedRows[dayIndex]![row] = true;
|
|
281
|
+
}
|
|
282
|
+
rowFound = true;
|
|
283
|
+
} else {
|
|
284
|
+
row++;
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
// Create the final event span with row assignment
|
|
289
|
+
const eventSpan: EventSpan = {
|
|
290
|
+
...tempSpan,
|
|
291
|
+
row,
|
|
292
|
+
};
|
|
293
|
+
|
|
294
|
+
spans.push(eventSpan);
|
|
295
|
+
|
|
296
|
+
// Add this event to each day it spans
|
|
297
|
+
for (let i = tempSpan.startIndex; i <= tempSpan.endIndex; i++) {
|
|
298
|
+
eventsByDay[i]?.push(eventSpan);
|
|
299
|
+
}
|
|
300
|
+
});
|
|
301
|
+
|
|
302
|
+
// Calculate max visible events per day for layout purposes
|
|
303
|
+
let maxVisibleEventsPerDay = 0;
|
|
304
|
+
eventsByDay.forEach((dayEvents, dayIndex) => {
|
|
305
|
+
const dateKey = calendarDayKey(visibleDates[dayIndex]!);
|
|
306
|
+
|
|
307
|
+
const shouldShowAll = dayEvents.length === MAX_EVENTS_DISPLAY + 1;
|
|
308
|
+
const isExpanded = expandedDates.includes(dateKey) || shouldShowAll;
|
|
309
|
+
const visibleCount = isExpanded
|
|
310
|
+
? dayEvents.length
|
|
311
|
+
: Math.min(dayEvents.length, MAX_EVENTS_DISPLAY);
|
|
312
|
+
|
|
313
|
+
maxVisibleEventsPerDay = Math.max(maxVisibleEventsPerDay, visibleCount);
|
|
314
|
+
});
|
|
315
|
+
|
|
316
|
+
return {
|
|
317
|
+
spans,
|
|
318
|
+
maxVisibleEventsPerDay,
|
|
319
|
+
eventsByDay,
|
|
320
|
+
locationSpansFromMerge,
|
|
321
|
+
};
|
|
322
|
+
}
|
|
@@ -215,34 +215,65 @@ describe('calendar view interactions', () => {
|
|
|
215
215
|
});
|
|
216
216
|
|
|
217
217
|
describe('calendar view opaque effective colors', () => {
|
|
218
|
-
it(
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
218
|
+
it.each([false, true])(
|
|
219
|
+
'preserves provider contrast and accessible month treatment, past=%s',
|
|
220
|
+
(past) => {
|
|
221
|
+
vi.useFakeTimers({ toFake: ['Date'] });
|
|
222
|
+
vi.setSystemTime(new Date(2026, 8, past ? 8 : 7, 9));
|
|
223
|
+
state.events = [
|
|
224
|
+
{
|
|
225
|
+
id: 'borderline-rgb',
|
|
226
|
+
title: 'Borderline RGB',
|
|
227
|
+
color: 'BLUE',
|
|
228
|
+
start_at: new Date(2026, 8, 7, 10).toISOString(),
|
|
229
|
+
end_at: new Date(2026, 8, 7, 11).toISOString(),
|
|
230
|
+
scheduling_metadata: {
|
|
231
|
+
google_color: {
|
|
232
|
+
version: 1,
|
|
233
|
+
inherited: false,
|
|
234
|
+
background: '#757575',
|
|
235
|
+
},
|
|
236
|
+
},
|
|
228
237
|
},
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
238
|
+
];
|
|
239
|
+
render(<MonthCalendar date={date} viewedMonth={date} locale="en" />);
|
|
240
|
+
const card = screen.getByRole('button', { name: /Borderline RGB/ });
|
|
241
|
+
expect(card.style.backgroundColor).toBe('rgb(117, 117, 117)');
|
|
242
|
+
expect(card.style.color).toBe('rgb(255, 255, 255)');
|
|
243
|
+
fireEvent.mouseEnter(card);
|
|
244
|
+
expect(card).toHaveClass('hover:ring-1', 'hover:ring-current');
|
|
245
|
+
const wholeCardDimming = card.className.split(/\s+/).filter((token) => {
|
|
246
|
+
const variants = token.split(':');
|
|
247
|
+
return (
|
|
248
|
+
!variants.includes('after') &&
|
|
249
|
+
/^(?:brightness|opacity)-/.test(variants.at(-1) ?? '')
|
|
250
|
+
);
|
|
251
|
+
});
|
|
252
|
+
expect(wholeCardDimming).toEqual([]);
|
|
253
|
+
expect(card.style.opacity).toBe('');
|
|
254
|
+
expect(card).toHaveClass(
|
|
255
|
+
'focus-visible:outline-2',
|
|
256
|
+
'focus-visible:outline-ring'
|
|
257
|
+
);
|
|
258
|
+
if (past) {
|
|
259
|
+
expect(card).toHaveClass(
|
|
260
|
+
'after:bg-background/50',
|
|
261
|
+
'after:pointer-events-none',
|
|
262
|
+
'hover:after:opacity-0',
|
|
263
|
+
'focus-visible:after:opacity-0'
|
|
264
|
+
);
|
|
265
|
+
} else {
|
|
266
|
+
expect(card).not.toHaveClass('after:bg-background/50');
|
|
267
|
+
}
|
|
268
|
+
expect(card.style.filter).toBe('');
|
|
269
|
+
expect(card.style.backgroundColor).toBe('rgb(117, 117, 117)');
|
|
270
|
+
expect(card.style.color).toBe('rgb(255, 255, 255)');
|
|
271
|
+
const luminance = ((117 / 255 + 0.055) / 1.055) ** 2.4;
|
|
272
|
+
expect(1.05 / (luminance + 0.05)).toBeGreaterThanOrEqual(4.5);
|
|
273
|
+
fireEvent.click(card);
|
|
274
|
+
expect(state.openModal).toHaveBeenCalledWith('borderline-rgb');
|
|
275
|
+
}
|
|
276
|
+
);
|
|
246
277
|
|
|
247
278
|
it.each([false, true])(
|
|
248
279
|
'uses provider RGB in month and agenda, inherited=%s',
|
|
@@ -113,6 +113,30 @@ describe('EventCard read-only adapter events', () => {
|
|
|
113
113
|
expect(card.style.opacity).toBe('1');
|
|
114
114
|
});
|
|
115
115
|
|
|
116
|
+
it('fades past first-party cards above an opaque fill and restores on hover/focus', () => {
|
|
117
|
+
calendarMocks.preservePastEventOpacity = false;
|
|
118
|
+
renderEventCard({
|
|
119
|
+
id: 'past-native',
|
|
120
|
+
title: 'Past event',
|
|
121
|
+
color: 'BLUE',
|
|
122
|
+
start_at: '2026-06-26T08:30:00Z',
|
|
123
|
+
end_at: '2026-06-26T09:30:00Z',
|
|
124
|
+
});
|
|
125
|
+
const card = screen.getByTestId('calendar-event-past-native');
|
|
126
|
+
expect(card.style.opacity).toBe('1');
|
|
127
|
+
expect(card.style.backgroundColor).toBe('rgb(33, 150, 243)');
|
|
128
|
+
expect(card).toHaveClass(
|
|
129
|
+
'after:bg-background/50',
|
|
130
|
+
'hover:after:opacity-0',
|
|
131
|
+
'focus-visible:after:opacity-0'
|
|
132
|
+
);
|
|
133
|
+
fireEvent.mouseEnter(card);
|
|
134
|
+
expect(card.style.opacity).toBe('1');
|
|
135
|
+
expect(card).toHaveClass('hover:after:opacity-0');
|
|
136
|
+
fireEvent.mouseLeave(card);
|
|
137
|
+
expect(card).toHaveClass('after:bg-background/50');
|
|
138
|
+
});
|
|
139
|
+
|
|
116
140
|
it('updates the same mounted card when only provider metadata changes', () => {
|
|
117
141
|
const event: CalendarEvent = {
|
|
118
142
|
id: 'metadata-rerender',
|
|
@@ -176,7 +200,7 @@ describe('EventCard read-only adapter events', () => {
|
|
|
176
200
|
expect(card.style.visibility).toBe('hidden');
|
|
177
201
|
expect(card.style.backgroundColor).toBe('rgb(0, 255, 136)');
|
|
178
202
|
expect(card.className).not.toMatch(/opacity-(?:30|50|60|80)/);
|
|
179
|
-
expect(card).toHaveClass('line-through');
|
|
203
|
+
expect(card).not.toHaveClass('line-through');
|
|
180
204
|
rendered.unmount();
|
|
181
205
|
calendarMocks.hoveredBaseEventId = null;
|
|
182
206
|
calendarMocks.hoveredEventColumn = null;
|
|
@@ -37,6 +37,7 @@ import {
|
|
|
37
37
|
} from '../../context-menu';
|
|
38
38
|
import { GRID_SNAP, HOUR_HEIGHT, MAX_HOURS, MIN_EVENT_HEIGHT } from './config';
|
|
39
39
|
import { CalendarEventProviderIcon } from './event-provider-display';
|
|
40
|
+
import { pastEventTreatment } from './past-event-treatment';
|
|
40
41
|
import { useCalendarSettings } from './settings/settings-context';
|
|
41
42
|
|
|
42
43
|
dayjs.extend(timezone);
|
|
@@ -67,7 +68,6 @@ function EventCardComponent({ dates, event, level = 0 }: EventCardProps) {
|
|
|
67
68
|
title,
|
|
68
69
|
description,
|
|
69
70
|
// start_at,
|
|
70
|
-
end_at,
|
|
71
71
|
color = 'BLUE',
|
|
72
72
|
locked = false,
|
|
73
73
|
_isMultiDay,
|
|
@@ -957,9 +957,6 @@ function EventCardComponent({ dates, event, level = 0 }: EventCardProps) {
|
|
|
957
957
|
return d.format(timeFormat === '24h' ? 'HH:mm' : 'h:mm a');
|
|
958
958
|
};
|
|
959
959
|
|
|
960
|
-
// Check if the event is in the past
|
|
961
|
-
const isPastEvent = new Date(end_at) < new Date();
|
|
962
|
-
|
|
963
960
|
// Check if this event is affected by preview (will be modified/deleted)
|
|
964
961
|
const isAffectedByPreview =
|
|
965
962
|
affectedEventIds?.has(id) || affectedEventIds?.has(event._originalId || '');
|
|
@@ -1005,7 +1002,6 @@ function EventCardComponent({ dates, event, level = 0 }: EventCardProps) {
|
|
|
1005
1002
|
});
|
|
1006
1003
|
};
|
|
1007
1004
|
|
|
1008
|
-
// Determine if this event has calendar source info
|
|
1009
1005
|
const hasCalendarInfo =
|
|
1010
1006
|
google_calendar_id && google_calendar_id !== 'primary';
|
|
1011
1007
|
const calendarDisplayName =
|
|
@@ -1014,8 +1010,6 @@ function EventCardComponent({ dates, event, level = 0 }: EventCardProps) {
|
|
|
1014
1010
|
? 'Primary Calendar'
|
|
1015
1011
|
: google_calendar_id);
|
|
1016
1012
|
|
|
1017
|
-
// Calculate stacking position for visual effects
|
|
1018
|
-
// Note: Actual column-based positioning is handled in the useEffect
|
|
1019
1013
|
const hasOverlaps = overlapCount > 1;
|
|
1020
1014
|
|
|
1021
1015
|
// Check if an event in the same group is being hovered
|
|
@@ -1051,10 +1045,6 @@ function EventCardComponent({ dates, event, level = 0 }: EventCardProps) {
|
|
|
1051
1045
|
'transform shadow-md': isDragging || isResizing, // Subtle transform during interaction
|
|
1052
1046
|
'shadow-sm': hasOverlaps && !isDragging && !isResizing, // Subtle shadow for stacked events
|
|
1053
1047
|
'hover:shadow-md': hasOverlaps, // Enhanced shadow on hover for stacked events
|
|
1054
|
-
'line-through':
|
|
1055
|
-
isPastEvent &&
|
|
1056
|
-
!isAffectedByPreview &&
|
|
1057
|
-
!preservePastEventOpacity, // Mark past events in the text
|
|
1058
1048
|
'rounded-l-none border-l-4': showStartIndicator, // Special styling for continuation from previous day
|
|
1059
1049
|
'rounded-r-none border-r-4': showEndIndicator, // Special styling for continuation to next day
|
|
1060
1050
|
'border-l-[3px]': hasCalendarInfo, // Thicker border for calendar events
|
|
@@ -1068,7 +1058,15 @@ function EventCardComponent({ dates, event, level = 0 }: EventCardProps) {
|
|
|
1068
1058
|
isOptimisticallyPending && !isOptimisticallyMutating,
|
|
1069
1059
|
},
|
|
1070
1060
|
level ? 'border border-l-2' : 'border-l-2',
|
|
1071
|
-
border
|
|
1061
|
+
border,
|
|
1062
|
+
pastEventTreatment(
|
|
1063
|
+
event,
|
|
1064
|
+
preservePastEventOpacity,
|
|
1065
|
+
isDragging ||
|
|
1066
|
+
isResizing ||
|
|
1067
|
+
!!isAffectedByPreview ||
|
|
1068
|
+
updateStatus === 'error'
|
|
1069
|
+
)
|
|
1072
1070
|
)}
|
|
1073
1071
|
style={{
|
|
1074
1072
|
transition:
|
|
@@ -6,6 +6,8 @@ import {
|
|
|
6
6
|
DialogHeader,
|
|
7
7
|
DialogTitle,
|
|
8
8
|
} from '@tuturuuu/ui/dialog';
|
|
9
|
+
import { isProviderRecurrenceReadonly } from '@tuturuuu/utils/calendar-provider-readonly';
|
|
10
|
+
import { useTranslations } from 'next-intl';
|
|
9
11
|
import { getCalendarMeetingUrl } from './calendar-meeting-link';
|
|
10
12
|
import {
|
|
11
13
|
CalendarEventProviderIcon,
|
|
@@ -19,19 +21,28 @@ export function EventModalHeader({
|
|
|
19
21
|
event: Partial<CalendarEvent>;
|
|
20
22
|
isEditing: boolean;
|
|
21
23
|
}) {
|
|
24
|
+
const t = useTranslations('calendar.recurrence');
|
|
25
|
+
const readonly = isProviderRecurrenceReadonly(event);
|
|
22
26
|
const providerDisplay = getCalendarEventProviderDisplay(event);
|
|
23
27
|
const meetingUrl = getCalendarMeetingUrl(event);
|
|
24
28
|
|
|
25
29
|
return (
|
|
26
30
|
<DialogHeader className="border-b px-6 pt-6 pb-4">
|
|
27
31
|
<DialogTitle className="flex flex-wrap items-center gap-2 font-semibold text-xl">
|
|
28
|
-
<span>
|
|
32
|
+
<span>
|
|
33
|
+
{readonly ? event.title : isEditing ? 'Edit Event' : 'Create Event'}
|
|
34
|
+
</span>
|
|
29
35
|
{providerDisplay && (
|
|
30
36
|
<span className="ml-3 flex items-center gap-2 rounded-md border bg-muted/40 px-3 py-1 text-sm">
|
|
31
37
|
<CalendarEventProviderIcon className="h-4.5 w-4.5" event={event} />
|
|
32
38
|
<span className="font-medium text-xs">{providerDisplay.label}</span>
|
|
33
39
|
</span>
|
|
34
40
|
)}
|
|
41
|
+
{readonly && (
|
|
42
|
+
<span className="rounded-md border bg-muted px-2 py-1 text-xs">
|
|
43
|
+
{t('provider_readonly')}
|
|
44
|
+
</span>
|
|
45
|
+
)}
|
|
35
46
|
{meetingUrl && (
|
|
36
47
|
<Button asChild className="ml-auto" size="sm" variant="outline">
|
|
37
48
|
<a href={meetingUrl} rel="noreferrer" target="_blank">
|
|
@@ -43,9 +54,11 @@ export function EventModalHeader({
|
|
|
43
54
|
)}
|
|
44
55
|
</DialogTitle>
|
|
45
56
|
<DialogDescription>
|
|
46
|
-
{
|
|
47
|
-
? '
|
|
48
|
-
:
|
|
57
|
+
{readonly
|
|
58
|
+
? t('provider_readonly_description')
|
|
59
|
+
: isEditing
|
|
60
|
+
? 'Make changes to your existing event'
|
|
61
|
+
: 'Add a new event to your calendar'}
|
|
49
62
|
</DialogDescription>
|
|
50
63
|
</DialogHeader>
|
|
51
64
|
);
|