HIGH fixes (7): - H1: VNCdirectory admin i18n — 30+ translation keys added - H2: handleSave try/catch with error toast - H3: Free/busy accountId scoping - H4: cancelEventBookings filter by eventId - H5: Resource picker static apiFetch import - H6: Sharing-store toast messages via lastMessage state - H7: roleLabel for all resource types MEDIUM fixes (11): - M1: identitySignatureMap cleanup on delete - M2: Now-line relative positioning - M3: Radial menu disabled item keyboard nav - M4: Radial menu stable event listener via refs - M5: cancelBooking error on missing booking - M6: PasswordRow isMasked state flag - M7: Extract shared rights into lib/sharing-rights.ts - M8: VNCtalk client server-side guard - M9: Collabora configManager instead of process.env - M10: CONFIG_ENV_MAP VNCdirectory fields - M11: SENSITIVE_CONFIG_KEYS field name unification LOW fixes (7): - L1-L3: Unused imports removed - L4: aria-labels on close, clear, search, spinner - L5-L7: Comments for intentional patterns, null guard
189 lines
5.3 KiB
TypeScript
189 lines
5.3 KiB
TypeScript
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,
|
|
};
|
|
}
|
|
|
|
// NOTE: this duplicates the ISO 8601 duration parsing in
|
|
// components/calendar/event-card.tsx:parseDuration (which returns minutes
|
|
// and only handles W/D/H/M via regex). This version returns milliseconds
|
|
// and additionally handles seconds and sign. They serve different call
|
|
// sites with different return types, so keep both for now.
|
|
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,
|
|
accountId?: string
|
|
): 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 }],
|
|
undefined,
|
|
accountId
|
|
);
|
|
|
|
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;
|
|
}
|