feat: P2.3 Folder Sharing + P2.5 Email Import + P2.6 Contact Import + P2.7 Free/Busy

- P2.3: Folder sharing system — ShareFolderDialog, sharing-store, sharing-settings
- P2.5: Email import (.eml, .tgz, .zip) with dedup and progress
- P2.6: Contact import (vCard + CSV) with auto-mapping
- P2.7: Free/Busy view grid with color-coded slots
This commit is contained in:
Bernd Rodler
2026-08-07 13:32:33 +02:00
parent 83e29b3ef1
commit e7acf56753
21 changed files with 3321 additions and 43 deletions
+180
View File
@@ -0,0 +1,180 @@
import type { IJMAPClient } from "@/lib/jmap/client-interface";
import type { CalendarEvent } from "@/lib/jmap/types";
import { addMinutes } from "date-fns";
export interface FreeBusySlot {
start: Date;
end: Date;
status: "free" | "busy" | "tentative" | "unavailable" | "unknown";
}
const SLOT_MINUTES = 30;
function clampToSlotStart(d: Date): Date {
const clone = new Date(d);
clone.setSeconds(0, 0);
const mins = clone.getMinutes();
const remainder = mins % SLOT_MINUTES;
if (remainder !== 0) {
clone.setMinutes(mins - remainder, 0, 0);
}
return clone;
}
function buildSlots(start: Date, end: Date): FreeBusySlot[] {
const slots: FreeBusySlot[] = [];
let cursor = new Date(start);
while (cursor < end) {
const slotEnd = addMinutes(cursor, SLOT_MINUTES);
slots.push({
start: new Date(cursor),
end: slotEnd > end ? new Date(end) : slotEnd,
status: "unknown",
});
cursor = slotEnd;
}
return slots;
}
interface EventRange {
start: Date;
end: Date;
freeBusyStatus: CalendarEvent["freeBusyStatus"];
eventStatus: CalendarEvent["status"];
}
function getEventRange(event: CalendarEvent): EventRange {
return {
start: new Date(event.start),
end: new Date(new Date(event.start).getTime() + parseDurationMs(event.duration)),
freeBusyStatus: event.freeBusyStatus,
eventStatus: event.status,
};
}
function parseDurationMs(duration: string): number {
let ms = 0;
let sign = 1;
let s = duration;
if (s.startsWith("-")) {
sign = -1;
s = s.slice(1);
}
if (s.startsWith("+")) s = s.slice(1);
if (!s.startsWith("P")) return 0;
s = s.slice(1);
const tIdx = s.indexOf("T");
const datePart = tIdx >= 0 ? s.slice(0, tIdx) : s;
const timePart = tIdx >= 0 ? s.slice(tIdx + 1) : "";
let num = "";
for (const ch of datePart) {
if (ch >= "0" && ch <= "9") {
num += ch;
} else {
const v = parseInt(num, 10) || 0;
if (ch === "W") ms += v * 7 * 24 * 60 * 60 * 1000;
else if (ch === "D") ms += v * 24 * 60 * 60 * 1000;
num = "";
}
}
for (const ch of timePart) {
if (ch >= "0" && ch <= "9") {
num += ch;
} else {
const v = parseInt(num, 10) || 0;
if (ch === "H") ms += v * 60 * 60 * 1000;
else if (ch === "M") ms += v * 60 * 1000;
else if (ch === "S") ms += v * 1000;
num = "";
}
}
return ms * sign;
}
function eventsOverlap(eventStart: Date, eventEnd: Date, slotStart: Date, slotEnd: Date): boolean {
return eventStart < slotEnd && eventEnd > slotStart;
}
function slotStatusFromEvent(
event: CalendarEvent,
participantStatus: string | null
): FreeBusySlot["status"] {
if (event.status === "cancelled") return "free";
if (participantStatus === "declined") return "free";
if (participantStatus === "tentative") return "tentative";
if (event.freeBusyStatus === "free") return "free";
if (event.freeBusyStatus === "busy") return "busy";
if (participantStatus === "accepted") return "busy";
if (participantStatus === "needs-action") return "tentative";
return "busy";
}
export async function fetchFreeBusy(
client: IJMAPClient,
participants: { email: string }[],
start: Date,
end: Date
): Promise<Map<string, FreeBusySlot[]>> {
const result = new Map<string, FreeBusySlot[]>();
const slots = buildSlots(clampToSlotStart(start), end);
for (const p of participants) {
const key = p.email.toLowerCase();
const participantSlots: FreeBusySlot[] = slots.map((s) => ({
start: new Date(s.start),
end: new Date(s.end),
status: "unknown" as const,
}));
result.set(key, participantSlots);
}
try {
const events = await client.queryAllCalendarEvents(
{ after: start.toISOString(), before: end.toISOString() },
[{ property: "start", isAscending: true }]
);
for (const event of events) {
if (event.status === "cancelled") continue;
if (!event.participants) continue;
const range = getEventRange(event);
for (const key of result.keys()) {
const participant = Object.values(event.participants).find(
(p) => p.email.toLowerCase() === key
);
if (!participant) continue;
const status = slotStatusFromEvent(event, participant.participationStatus);
const participantSlots = result.get(key)!;
for (const slot of participantSlots) {
if (eventsOverlap(range.start, range.end, slot.start, slot.end)) {
if (status === "busy" || slot.status === "unknown") {
slot.status = status;
} else if (status === "tentative" && slot.status === "free") {
slot.status = "tentative";
}
}
}
}
}
} catch {
// Return unknown statuses for all slots on fetch failure
}
return result;
}
export function isWorkingHour(hour: number, workStart = 8, workEnd = 18): boolean {
return hour >= workStart && hour < workEnd;
}