refactor: split calendar page components

This commit is contained in:
2026-07-29 19:53:11 +02:00
parent 4011e61a63
commit 56b387a784
9 changed files with 3207 additions and 2264 deletions

View File

@@ -14,7 +14,8 @@
"./styles/calendar.css": "./src/styles/calendar.css" "./styles/calendar.css": "./src/styles/calendar.css"
}, },
"scripts": { "scripts": {
"test:calendar-picker": "rm -rf .calendar-picker-test-build && mkdir -p .calendar-picker-test-build && printf '{\"type\":\"commonjs\"}\\n' > .calendar-picker-test-build/package.json && tsc -p tsconfig.calendar-picker-tests.json && node .calendar-picker-test-build/tests/calendar-picker.test.js && node tests/calendar-picker-structure.test.mjs" "test:calendar-picker": "rm -rf .calendar-picker-test-build && mkdir -p .calendar-picker-test-build && printf '{\"type\":\"commonjs\"}\\n' > .calendar-picker-test-build/package.json && tsc -p tsconfig.calendar-picker-tests.json && node .calendar-picker-test-build/tests/calendar-picker.test.js && node tests/calendar-picker-structure.test.mjs",
"test:calendar-page": "tsc -p tsconfig.calendar-page-tests.json && node --experimental-strip-types tests/calendar-view-model.test.ts && node tests/calendar-page-structure.test.mjs"
}, },
"peerDependencies": { "peerDependencies": {
"@govoplan/core-webui": "^0.1.9", "@govoplan/core-webui": "^0.1.9",

View File

@@ -0,0 +1,827 @@
import {
useEffect,
useMemo,
useState,
type ChangeEvent,
type FormEvent,
} from "react";
import { RefreshCw, Trash2 } from "lucide-react";
import {
Button,
ColorPickerField,
Dialog,
PasswordField,
SegmentedControl,
ToggleSwitch,
i18nMessage,
useUnsavedDraftGuard,
type ApiSettings,
} from "@govoplan/core-webui";
import {
listCalendarCredentials,
type CalendarBulkMoveExternalAction,
type CalendarCalDavAuthType,
type CalendarCalDavConflictPolicy,
type CalendarCalDavDiscoveryCandidate,
type CalendarCalDavDiscoveryPayload,
type CalendarCalDavSyncDirection,
type CalendarCollection,
type CalendarCollectionDeletePayload,
type CalendarCredentialEnvelope,
type CalendarDeleteEventAction,
type CalendarSyncSource,
type CalendarSyncSourceCreatePayload,
type CalendarSyncSourceKind,
type CalendarSyncSourceUpdatePayload,
} from "../../api/calendar";
import {
DEFAULT_CALENDAR_COLOR,
calendarDraftKey,
dateTimeLabel,
errorText,
normalizeHexColor,
} from "./calendarViewModel";
export type CalendarSourceMode = "local" | CalendarSyncSourceKind;
type CalendarSourceSwitchMode =
| "local"
| "caldav"
| "ics"
| "graph"
| "ews";
export type CalendarCollectionDialogState =
| { kind: "create" }
| {
kind: "edit";
calendar: CalendarCollection;
eventCount: number | null;
loadingEventCount: boolean;
};
export type CalendarDeleteDialogState = {
calendar: CalendarCollection;
eventCount: number | null;
loadingEventCount: boolean;
};
export type CalendarCalDavFormPayload = {
collection_url: string;
dav_url: string;
display_name: string;
auth_type: CalendarCalDavAuthType;
username: string;
credential_envelope_id: string;
password: string;
bearer_token: string;
sync_enabled: boolean;
sync_interval_seconds: number;
sync_direction: CalendarCalDavSyncDirection;
conflict_policy: CalendarCalDavConflictPolicy;
};
export type CalendarCollectionFormPayload = {
sourceMode: CalendarSourceMode;
name: string;
color: string;
caldav: CalendarCalDavFormPayload;
};
export function CalendarCollectionDialog({
state,
settings,
source,
saving,
syncingSourceId,
canWrite,
canDelete,
canManageSources,
canSyncSources,
onCancel,
onSave,
onRequestDelete,
onSync,
onDiscover
}: {state: CalendarCollectionDialogState;settings: ApiSettings;source: CalendarSyncSource | null;saving: boolean;syncingSourceId: string;canWrite: boolean;canDelete: boolean;canManageSources: boolean;canSyncSources: boolean;onCancel: () => void;onSave: (payload: CalendarCollectionFormPayload) => Promise<boolean>;onRequestDelete: (calendar: CalendarCollection, eventCount: number | null, loadingEventCount: boolean) => void;onSync: (source: CalendarSyncSource, payload?: {password?: string | null;bearer_token?: string | null;force_full?: boolean;}) => Promise<void>;onDiscover: (payload: CalendarCalDavDiscoveryPayload) => Promise<{calendars: CalendarCalDavDiscoveryCandidate[];}>;}) {
const calendar = state.kind === "edit" ? state.calendar : null;
const isEdit = Boolean(calendar);
const [sourceMode, setSourceMode] = useState<CalendarSourceMode>(source ? source.source_kind : "local");
const [name, setName] = useState(calendar?.name ?? "");
const [color, setColor] = useState(normalizeHexColor(calendar?.color) || DEFAULT_CALENDAR_COLOR);
const [davUrl, setDavUrl] = useState(source?.collection_url ?? "");
const [collectionUrl, setCollectionUrl] = useState(source?.collection_url ?? "");
const [displayName, setDisplayName] = useState(source?.display_name ?? "");
const [authType, setAuthType] = useState<CalendarCalDavAuthType>(source?.auth_type ?? "basic");
const [username, setUsername] = useState(source?.username ?? "");
const [credentialEnvelopeId, setCredentialEnvelopeId] = useState(source?.credential_envelope_id ?? "");
const [availableCredentials, setAvailableCredentials] = useState<CalendarCredentialEnvelope[]>([]);
const [credentialsError, setCredentialsError] = useState("");
const [password, setPassword] = useState("");
const [bearerToken, setBearerToken] = useState("");
const [syncEnabled, setSyncEnabled] = useState(source?.sync_enabled ?? true);
const [syncIntervalMinutes, setSyncIntervalMinutes] = useState(Math.max(1, Math.round((source?.sync_interval_seconds ?? 900) / 60)));
const [syncDirection, setSyncDirection] = useState<CalendarCalDavSyncDirection>(source?.sync_direction ?? "two_way");
const [conflictPolicy, setConflictPolicy] = useState<CalendarCalDavConflictPolicy>(source?.conflict_policy ?? "etag");
const [discoveredCalendars, setDiscoveredCalendars] = useState<CalendarCalDavDiscoveryCandidate[]>([]);
const [selectedDiscoveredUrl, setSelectedDiscoveredUrl] = useState(source?.collection_url ?? "");
const [discovering, setDiscovering] = useState(false);
const [discoveryError, setDiscoveryError] = useState("");
const formId = "calendar-collection-form";
const isExistingSyncSource = isEdit && Boolean(source);
const canEditSource = canManageSources;
const canEditMutableSourceSettings = canEditSource && !isExistingSyncSource;
const effectiveCollectionUrl = (collectionUrl || davUrl).trim();
const effectiveAuthType = sourceMode === "graph" ? "bearer" : authType;
const needsSourceSecret = sourceMode !== "local" && (
effectiveAuthType === "basic" && !source?.has_credential && !credentialEnvelopeId && !password.trim() ||
effectiveAuthType === "bearer" && !source?.has_credential && !credentialEnvelopeId && !bearerToken.trim());
const sourceDetailsInvalid = sourceMode !== "local" && (
!isExistingSyncSource && !canEditSource ||
canEditSource && (!effectiveCollectionUrl || effectiveAuthType === "basic" && !username.trim() || needsSourceSecret));
const saveDisabled =
saving ||
!canWrite ||
!name.trim() ||
sourceDetailsInvalid;
const syncing = source ? syncingSourceId === source.id : false;
const collectionDraft = {
sourceMode,
name,
color,
davUrl,
collectionUrl,
displayName,
authType,
username,
credentialEnvelopeId,
password,
bearerToken,
syncEnabled,
syncIntervalMinutes,
syncDirection,
conflictPolicy
};
const initialCollectionDraftKey = useMemo(() => calendarDraftKey(collectionDraft), []);
const collectionDirty = calendarDraftKey(collectionDraft) !== initialCollectionDraftKey;
useUnsavedDraftGuard({
dirty: collectionDirty,
onSave: saveCurrent,
onDiscard: onCancel
});
useEffect(() => {
if (sourceMode === "local" || !canManageSources) {
setAvailableCredentials([]);
setCredentialsError("");
return;
}
let active = true;
listCalendarCredentials(settings, source?.id)
.then((response) => {
if (active) setAvailableCredentials(response.credentials);
})
.catch((err) => {
if (active) {
setAvailableCredentials([]);
setCredentialsError(errorText(err));
}
});
return () => {
active = false;
};
}, [canManageSources, settings.accessToken, settings.apiBaseUrl, settings.apiKey, source?.id, sourceMode]);
function currentPayload(): CalendarCollectionFormPayload {
return {
sourceMode,
name,
color,
caldav: {
collection_url: effectiveCollectionUrl,
dav_url: davUrl,
display_name: displayName,
auth_type: effectiveAuthType,
username,
credential_envelope_id: credentialEnvelopeId,
password,
bearer_token: bearerToken,
sync_enabled: syncEnabled,
sync_interval_seconds: syncIntervalMinutes * 60,
sync_direction: syncDirection,
conflict_policy: conflictPolicy
}
};
}
async function saveCurrent(): Promise<boolean> {
if (saveDisabled) return false;
return onSave(currentPayload());
}
function submit(formEvent: FormEvent<HTMLFormElement>) {
formEvent.preventDefault();
void saveCurrent();
}
function selectSourceMode(next: CalendarSourceMode) {
setSourceMode(next);
setDiscoveryError("");
setDiscoveredCalendars([]);
if (next === "graph") {
setAuthType("bearer");
setSyncDirection("inbound");
if (!davUrl.trim()) handleDavUrlChange("me/calendar");
return;
}
if (next === "ews") {
setAuthType("basic");
setSyncDirection("inbound");
if (!davUrl.trim()) handleDavUrlChange("https://exchange.example.org/EWS/Exchange.asmx");
return;
}
if (next === "ics" || next === "webcal") {
setAuthType("none");
setSyncDirection("inbound");
return;
}
if (next === "caldav") {
setSyncDirection("two_way");
}
}
function handleDavUrlChange(next: string) {
setDavUrl(next);
setCollectionUrl(next);
setSelectedDiscoveredUrl("");
setDiscoveredCalendars([]);
setDiscoveryError("");
}
function applyDiscoveredCalendar(candidate: CalendarCalDavDiscoveryCandidate) {
setSelectedDiscoveredUrl(candidate.collection_url);
setCollectionUrl(candidate.collection_url);
if (candidate.display_name) {
if (!isExistingSyncSource) setDisplayName(candidate.display_name);
if (!isEdit && !name.trim()) setName(candidate.display_name);
}
const discoveredColor = normalizeHexColor(candidate.color);
if (!isEdit && discoveredColor) setColor(discoveredColor);
}
function handleDiscoveredCalendarChange(event: ChangeEvent<HTMLSelectElement>) {
const candidate = discoveredCalendars.find((item) => item.collection_url === event.target.value);
if (candidate) applyDiscoveredCalendar(candidate);
}
async function handleDiscover() {
const url = davUrl.trim();
if (!url) return;
setDiscovering(true);
setDiscoveryError("");
try {
const payload: CalendarCalDavDiscoveryPayload = {
url,
source_id: source?.id ?? null,
auth_type: authType,
username: authType === "basic" ? username.trim() || null : null
};
if (credentialEnvelopeId) payload.credential_ref = credentialEnvelopeRef(credentialEnvelopeId);
if (!credentialEnvelopeId && authType === "basic" && password.trim()) payload.password = password;
if (!credentialEnvelopeId && authType === "bearer" && bearerToken.trim()) payload.bearer_token = bearerToken;
const response = await onDiscover(payload);
setDiscoveredCalendars(response.calendars);
if (response.calendars.length > 0) {
applyDiscoveredCalendar(response.calendars[0]);
} else {
setDiscoveryError("i18n:govoplan-calendar.no_calendar_collections_found.6453624a");
}
} catch (err) {
setDiscoveryError(errorText(err));
} finally {
setDiscovering(false);
}
}
return (
<Dialog
open
title={isEdit ? "i18n:govoplan-calendar.edit_calendar.a47a2a7a" : "i18n:govoplan-calendar.add_calendar.8fadb5bc"}
className="calendar-event-dialog"
footerClassName="calendar-event-dialog-footer"
closeDisabled={saving}
onClose={onCancel}
footer={
<>
<div>
{calendar && canDelete &&
<Button type="button" variant="danger" onClick={() => onRequestDelete(calendar, state.kind === "edit" ? state.eventCount : null, state.kind === "edit" ? state.loadingEventCount : true)} disabled={saving}>
<Trash2 size={16} /> {calendarDeleteActionLabel(calendar)}
</Button>
}
</div>
<div className="calendar-dialog-actions">
<Button type="button" onClick={onCancel} disabled={saving}>i18n:govoplan-calendar.cancel.77dfd213</Button>
<Button type="submit" form={formId} variant="primary" disabled={saveDisabled}>{saving ? "i18n:govoplan-calendar.saving.ae7e8875" : isEdit ? "i18n:govoplan-calendar.save.efc007a3" : "i18n:govoplan-calendar.add.61cc55aa"}</Button>
</div>
</>
}>
<form id={formId} className="calendar-dialog-form" onSubmit={submit}>
{sourceMode !== "local" && !canManageSources && <p className="calendar-form-note">i18n:govoplan-calendar.managing_sync_sources_requires_calendar_administ.835e29fa</p>}
{!isEdit &&
<SegmentedControl
className="calendar-source-switch"
size="equal"
width="fill"
ariaLabel="i18n:govoplan-calendar.calendar_source_type.92cdb42f"
value={sourceSwitchMode(sourceMode)}
disabled={saving}
onChange={selectSourceMode}
options={[
{ id: "local", label: "i18n:govoplan-calendar.local.dc99d54d" },
{ id: "caldav", label: "i18n:govoplan-calendar.caldav.64f9720e", disabled: !canManageSources },
{ id: "ics", label: "i18n:govoplan-calendar.ics_webcal.9c55b570", disabled: !canManageSources },
{ id: "graph", label: "i18n:govoplan-calendar.graph.9a7405eb", disabled: !canManageSources },
{ id: "ews", label: "i18n:govoplan-calendar.exchange.5b13eac7", disabled: !canManageSources }
]}
/>
}
<div className="calendar-dialog-name-color-row">
<label>
<span>i18n:govoplan-calendar.name.709a2322</span>
<input value={name} onChange={(item) => setName(item.target.value)} required maxLength={255} autoFocus disabled={saving || !canWrite} />
</label>
<label className="calendar-dialog-color-label">
<span>i18n:govoplan-calendar.color.1d0c8304</span>
<ColorPickerField value={color} onChange={setColor} disabled={saving || !canWrite} />
</label>
</div>
{sourceMode === "local" ?
<section className="calendar-source-pane">
<h3>i18n:govoplan-calendar.local_calendar.ed3f72f8</h3>
<p className="calendar-form-note">i18n:govoplan-calendar.events_are_stored_in_govoplan_and_are_not_synced.3660e504</p>
</section> :
<section className="calendar-source-pane">
<div className="calendar-source-pane-heading">
<h3>{calendarSourcePaneTitle(sourceMode)}</h3>
{source &&
<span className={[
"calendar-sync-status",
syncing ? "is-syncing" : "",
source.last_status === "error" && !syncing ? "is-error" : ""].
filter(Boolean).join(" ")}>
{syncing ? "i18n:govoplan-calendar.syncing.4ae6fa22" : source.last_status || "i18n:govoplan-calendar.not_synced.4c205136"}
</span>
}
</div>
<div className="calendar-caldav-setup">
<label className="calendar-dialog-wide">
<span>{calendarSourceUrlLabel(sourceMode)}</span>
<input
value={davUrl}
onChange={(item) => handleDavUrlChange(item.target.value)}
placeholder={calendarSourceUrlPlaceholder(sourceMode)}
required
maxLength={1000}
disabled={saving || !canEditSource} />
</label>
<label>
<span>i18n:govoplan-calendar.authentication.ee1acfa5</span>
<select value={effectiveAuthType} onChange={(item) => setAuthType(item.target.value as CalendarCalDavAuthType)} disabled={saving || !canEditSource || sourceMode === "graph"}>
{sourceMode !== "graph" && <option value="basic">i18n:govoplan-calendar.basic.aa2c96da</option>}
{sourceMode !== "graph" && sourceMode !== "ews" && <option value="none">i18n:govoplan-calendar.none.6eef6648</option>}
<option value="bearer">i18n:govoplan-calendar.bearer_token.ffa64bcf</option>
</select>
</label>
{effectiveAuthType !== "none" &&
<label>
<span>Reusable credential</span>
<select
value={credentialEnvelopeId}
disabled={saving || !canEditSource}
onChange={(event) => {
const credentialId = event.target.value;
setCredentialEnvelopeId(credentialId);
setPassword("");
setBearerToken("");
const credential = availableCredentials.find((item) => item.id === credentialId);
const credentialUsername = credential?.public_data?.username;
if (credentialUsername) setUsername(String(credentialUsername));
}}>
<option value="">{source?.has_credential && !source.credential_envelope_id ? "Keep source-specific credential" : "Enter credentials below"}</option>
{availableCredentials.map((credential) =>
<option key={credential.id} value={credential.id}>
{credential.name}{credential.public_data?.username ? ` (${String(credential.public_data.username)})` : ""}
</option>
)}
</select>
</label>
}
{credentialsError && <p className="calendar-form-error calendar-dialog-wide">{credentialsError}</p>}
{effectiveAuthType === "basic" &&
<>
<label>
<span>i18n:govoplan-calendar.username.84c29015</span>
<input value={username} onChange={(item) => setUsername(item.target.value)} required maxLength={255} disabled={saving || !canEditSource || Boolean(credentialEnvelopeId)} />
</label>
{!credentialEnvelopeId &&
<label>
<span>{source?.has_credential ? "i18n:govoplan-calendar.replace_password.3f912c9c" : "i18n:govoplan-calendar.password.8be3c943"}</span>
<PasswordField value={password} onValueChange={setPassword} disabled={saving || !canEditSource} autoComplete="new-password" />
</label>
}
</>
}
{effectiveAuthType === "bearer" && !credentialEnvelopeId &&
<label>
<span>{source?.has_credential ? "i18n:govoplan-calendar.replace_token.bbeee6a9" : "i18n:govoplan-calendar.bearer_token.ffa64bcf"}</span>
<PasswordField value={bearerToken} onValueChange={setBearerToken} disabled={saving || !canEditSource} autoComplete="new-password" />
</label>
}
<div className={sourceMode === "caldav" ? "calendar-discovery-actions" : "calendar-discovery-actions is-readonly"}>
{sourceMode === "caldav" &&
<Button type="button" onClick={() => void handleDiscover()} disabled={saving || discovering || !canEditSource || !davUrl.trim() || effectiveAuthType === "basic" && !username.trim()}>
<RefreshCw size={16} /> {discovering ? "i18n:govoplan-calendar.discovering.1884f689" : "i18n:govoplan-calendar.discover.4827ea22"}
</Button>
}
{effectiveCollectionUrl && <span>{effectiveCollectionUrl}</span>}
</div>
</div>
{discoveryError && <p className="calendar-form-error">{discoveryError}</p>}
{sourceMode === "caldav" && discoveredCalendars.length > 0 &&
<label>
<span>i18n:govoplan-calendar.calendar.adab5090</span>
<select value={selectedDiscoveredUrl} onChange={handleDiscoveredCalendarChange} disabled={saving || !canEditSource}>
{discoveredCalendars.map((candidate) =>
<option key={candidate.collection_url} value={candidate.collection_url}>
{candidate.display_name || candidate.collection_url}
</option>
)}
</select>
</label>
}
<details className="calendar-advanced-settings">
<summary>i18n:govoplan-calendar.advanced.4d064726</summary>
<div className="calendar-dialog-grid-two">
<label>
<span>i18n:govoplan-calendar.display_name.c7874aaa</span>
<input value={displayName} onChange={(item) => setDisplayName(item.target.value)} maxLength={255} disabled={saving || !canEditMutableSourceSettings} />
</label>
</div>
<div className="calendar-sync-settings">
<ToggleSwitch label="i18n:govoplan-calendar.automatic_sync.084644b2" checked={syncEnabled} disabled={saving || !canEditMutableSourceSettings} onChange={setSyncEnabled} />
<label>
<span>i18n:govoplan-calendar.interval.011efcd5</span>
<input type="number" min={1} step={1} value={syncIntervalMinutes} onChange={(item) => setSyncIntervalMinutes(Math.max(1, Number(item.target.value) || 1))} disabled={saving || !canEditMutableSourceSettings} />
</label>
<label>
<span>i18n:govoplan-calendar.direction.fd8e45ba</span>
<select value={sourceMode === "caldav" ? syncDirection : "inbound"} onChange={(item) => setSyncDirection(item.target.value as CalendarCalDavSyncDirection)} disabled={saving || !canEditMutableSourceSettings || sourceMode !== "caldav"}>
<option value="two_way">i18n:govoplan-calendar.two_way.ee50a3e6</option>
<option value="inbound">i18n:govoplan-calendar.inbound_only.bf4269b0</option>
</select>
</label>
<label>
<span>i18n:govoplan-calendar.conflict_policy.5810e150</span>
<select value={conflictPolicy} onChange={(item) => setConflictPolicy(item.target.value as CalendarCalDavConflictPolicy)} disabled={saving || !canEditMutableSourceSettings || sourceMode !== "caldav"}>
<option value="etag">i18n:govoplan-calendar.require_matching_etag.0ab1ffe1</option>
<option value="overwrite">i18n:govoplan-calendar.overwrite_remote.39625e32</option>
</select>
</label>
</div>
</details>
{source &&
<div className="calendar-sync-status-panel">
<dl>
<div><dt>i18n:govoplan-calendar.last_attempt.82aee111</dt><dd>{source.last_attempt_at ? dateTimeLabel(new Date(source.last_attempt_at)) : "i18n:govoplan-calendar.never.80c3052d"}</dd></div>
<div><dt>i18n:govoplan-calendar.last_sync.ef0ef267</dt><dd>{source.last_synced_at ? dateTimeLabel(new Date(source.last_synced_at)) : "i18n:govoplan-calendar.never.80c3052d"}</dd></div>
<div><dt>i18n:govoplan-calendar.next_sync.88c7af72</dt><dd>{source.next_sync_at && source.sync_enabled ? dateTimeLabel(new Date(source.next_sync_at)) : "i18n:govoplan-calendar.not_scheduled.9c367369"}</dd></div>
<div><dt>i18n:govoplan-calendar.credential.8bede3ea</dt><dd>{source.has_credential ? "i18n:govoplan-calendar.configured.668c5fff" : "i18n:govoplan-calendar.not_configured.811931bb"}</dd></div>
</dl>
{source.last_error && <p className="calendar-form-error">{source.last_error}</p>}
<div className="calendar-sync-actions">
<Button
type="button"
className={syncing ? "calendar-sync-button is-syncing" : "calendar-sync-button"}
onClick={() => void onSync(source, syncTransientPayload(effectiveAuthType, password, bearerToken))}
disabled={saving || syncing || !canSyncSources}>
<RefreshCw size={16} className={syncing ? "calendar-sync-spin" : undefined} /> {syncing ? "i18n:govoplan-calendar.syncing.e5c7727a" : "i18n:govoplan-calendar.sync_now.2b7d938e"}
</Button>
<Button type="button" onClick={() => void onSync(source, { ...syncTransientPayload(effectiveAuthType, password, bearerToken), force_full: true })} disabled={saving || syncing || !canSyncSources}>
i18n:govoplan-calendar.full_sync.21b89c76
</Button>
</div>
</div>
}
{needsSourceSecret && <p className="calendar-form-note">i18n:govoplan-calendar.enter_a_password_or_token_for_this_source.74c09a54</p>}
</section>
}
</form>
</Dialog>);
}
export function CalendarCollectionDeleteDialog({
state,
calendars,
syncSources,
saving,
onCancel,
onDelete
}: {state: CalendarDeleteDialogState;calendars: CalendarCollection[];syncSources: CalendarSyncSource[];saving: boolean;onCancel: () => void;onDelete: (calendar: CalendarCollection, payload: CalendarCollectionDeletePayload) => Promise<void>;}) {
const { calendar, eventCount, loadingEventCount } = state;
const syncSourceByCalendarId = new Map(syncSources.map((source) => [source.calendar_id, source]));
const source = syncSourceByCalendarId.get(calendar.id) ?? null;
const moveTargets = calendars.filter((item) =>
item.id !== calendar.id && calendarMoveTargetIsSupported(source, syncSourceByCalendarId.get(item.id) ?? null)
);
const firstMoveTargetId = moveTargets[0]?.id || "";
const hasEvents = (eventCount ?? 0) > 0;
const canMoveEvents = hasEvents && moveTargets.length > 0;
const [eventAction, setEventAction] = useState<CalendarDeleteEventAction>("delete");
const [targetCalendarId, setTargetCalendarId] = useState(firstMoveTargetId);
const [makeTargetDefault, setMakeTargetDefault] = useState(calendar.is_default && Boolean(firstMoveTargetId));
const effectiveEventAction: CalendarDeleteEventAction = canMoveEvents ? eventAction : "delete";
const confirmDisabled = saving || loadingEventCount || canMoveEvents && effectiveEventAction === "move" && !targetCalendarId;
const actionLabel = calendarDeleteActionLabel(calendar);
const targetSource = syncSourceByCalendarId.get(targetCalendarId) ?? null;
const externalAction = calendarBulkMoveExternalAction(source, targetSource);
function confirm() {
void onDelete(calendar, {
event_action: effectiveEventAction,
target_calendar_id: effectiveEventAction === "move" ? targetCalendarId : null,
make_target_default: effectiveEventAction === "move" && calendar.is_default && makeTargetDefault,
external_action: effectiveEventAction === "move" ? externalAction : null
});
}
return (
<Dialog
open
role="alertdialog"
title={`${actionLabel} calendar`}
className="calendar-delete-dialog"
footerClassName="calendar-event-dialog-footer"
closeDisabled={saving}
onClose={onCancel}
footer={
<>
<Button type="button" onClick={onCancel} disabled={saving}>i18n:govoplan-calendar.cancel.77dfd213</Button>
<Button type="button" variant="danger" onClick={confirm} disabled={confirmDisabled}>
<Trash2 size={16} /> {saving ? "i18n:govoplan-calendar.working.049ac820" : actionLabel}
</Button>
</>
}>
<div className="calendar-delete-dialog-body">
<p className="calendar-delete-warning">
{loadingEventCount ?
"i18n:govoplan-calendar.loading_event_count.716ad3c2" :
calendarDeleteWarning(calendar, eventCount ?? 0, effectiveEventAction, targetCalendarId, moveTargets)}
</p>
{!loadingEventCount && eventCount === null && <p className="calendar-form-note">i18n:govoplan-calendar.event_count_could_not_be_loaded_the_backend_will.163ff055</p>}
{canMoveEvents &&
<fieldset className="calendar-delete-options" disabled={saving || loadingEventCount}>
<legend>{eventCount} event{eventCount === 1 ? "" : "s"}</legend>
<label>
<input
type="radio"
name="calendar-delete-event-action"
value="delete"
checked={eventAction === "delete"}
onChange={() => setEventAction("delete")} />
<span>{calendarEventDeleteOptionLabel(calendar)}</span>
</label>
<label>
<input
type="radio"
name="calendar-delete-event-action"
value="move"
checked={eventAction === "move"}
onChange={() => setEventAction("move")} />
<span>{calendarBulkMoveOptionLabel(externalAction)}</span>
</label>
{eventAction === "move" &&
<>
<label className="calendar-delete-target-label">
<span>i18n:govoplan-calendar.target_calendar.e533fe1d</span>
<select value={targetCalendarId} onChange={(item) => setTargetCalendarId(item.target.value)} required>
{moveTargets.map((item) =>
<option key={item.id} value={item.id}>{item.name}</option>
)}
</select>
</label>
{calendar.is_default &&
<ToggleSwitch label="i18n:govoplan-calendar.make_target_calendar_the_default.10a3977b" checked={makeTargetDefault} onChange={setMakeTargetDefault} />
}
<p className="calendar-form-note">{calendarBulkMoveConsequence(externalAction)}</p>
</>
}
</fieldset>
}
{!loadingEventCount && hasEvents && moveTargets.length === 0 &&
<p className="calendar-form-note">{source ?
"i18n:govoplan-calendar.no_local_target_calendar_is_available_add_a_local_.fad3cb65" :
"i18n:govoplan-calendar.no_compatible_target_calendar_is_available_add_a_l.1cf6e47b"}</p>
}
</div>
</Dialog>);
}
export function syncSourceCreatePayload(sourceMode: CalendarSourceMode, payload: CalendarCalDavFormPayload): Omit<CalendarSyncSourceCreatePayload, "calendar_id"> {
const sourceKind = syncSourceKindForMode(sourceMode, payload.collection_url);
const result: Omit<CalendarSyncSourceCreatePayload, "calendar_id"> = {
source_kind: sourceKind,
collection_url: payload.collection_url.trim(),
display_name: payload.display_name.trim() || null,
auth_type: sourceKind === "graph" ? "bearer" : payload.auth_type,
username: sourceKind !== "graph" && payload.auth_type === "basic" ? payload.username.trim() || null : null,
sync_enabled: payload.sync_enabled,
sync_interval_seconds: Math.max(60, payload.sync_interval_seconds),
sync_direction: sourceKind === "caldav" ? payload.sync_direction : "inbound",
conflict_policy: payload.conflict_policy,
metadata: {}
};
if (payload.credential_envelope_id) {
result.credential_ref = credentialEnvelopeRef(payload.credential_envelope_id);
} else if (result.auth_type === "basic" && payload.password.trim()) {
result.password = payload.password;
} else if (result.auth_type === "bearer" && payload.bearer_token.trim()) {
result.bearer_token = payload.bearer_token;
}
return result;
}
export function syncSourceConnectionUpdatePayload(payload: CalendarCalDavFormPayload): CalendarSyncSourceUpdatePayload {
const result: CalendarSyncSourceUpdatePayload = {
collection_url: payload.collection_url.trim(),
auth_type: payload.auth_type,
username: payload.auth_type === "basic" ? payload.username.trim() || null : null
};
if (payload.credential_envelope_id) {
result.credential_ref = credentialEnvelopeRef(payload.credential_envelope_id);
} else if (payload.auth_type === "basic" && payload.password.trim()) {
result.password = payload.password;
} else if (payload.auth_type === "bearer" && payload.bearer_token.trim()) {
result.bearer_token = payload.bearer_token;
}
return result;
}
function credentialEnvelopeRef(credentialId: string): string {
return `credential-envelope:${credentialId}`;
}
function syncSourceKindForMode(sourceMode: CalendarSourceMode, collectionUrl: string): CalendarSyncSourceKind {
if (sourceMode === "ics" && collectionUrl.trim().toLowerCase().startsWith("webcal://")) return "webcal";
return sourceMode === "local" ? "caldav" : sourceMode;
}
function sourceSwitchMode(sourceMode: CalendarSourceMode): CalendarSourceSwitchMode {
return sourceMode === "webcal" ? "ics" : sourceMode;
}
function syncTransientPayload(authType: CalendarCalDavAuthType, password: string, bearerToken: string) {
if (authType === "basic" && password.trim()) return { password };
if (authType === "bearer" && bearerToken.trim()) return { bearer_token: bearerToken };
return {};
}
function calendarSourcePaneTitle(sourceMode: CalendarSourceMode): string {
if (sourceMode === "caldav") return "i18n:govoplan-calendar.caldav_source.459ed16a";
if (sourceMode === "ics" || sourceMode === "webcal") return "i18n:govoplan-calendar.ics_webcal_subscription.03bc0d63";
if (sourceMode === "graph") return "i18n:govoplan-calendar.microsoft_graph_source.ec4f1383";
if (sourceMode === "ews") return "i18n:govoplan-calendar.exchange_web_services_source.53caabf3";
return "i18n:govoplan-calendar.local_calendar.ed3f72f8";
}
function calendarSourceUrlLabel(sourceMode: CalendarSourceMode): string {
if (sourceMode === "caldav") return "i18n:govoplan-calendar.dav_url.4205e180";
if (sourceMode === "graph") return "i18n:govoplan-calendar.graph_calendar_url.e59607f3";
if (sourceMode === "ews") return "i18n:govoplan-calendar.ews_endpoint.a3273983";
return "i18n:govoplan-calendar.subscription_url.b8b6a1a0";
}
function calendarSourceUrlPlaceholder(sourceMode: CalendarSourceMode): string {
if (sourceMode === "caldav") return "https://cloud.example.org/remote.php/dav";
if (sourceMode === "graph") return "me/calendar or https://graph.microsoft.com/v1.0/me/calendar/events/delta";
if (sourceMode === "ews") return "https://exchange.example.org/EWS/Exchange.asmx";
return "webcal://example.org/calendar.ics or https://example.org/calendar.ics";
}
function calendarDeleteActionLabel(calendar: CalendarCollection): string {
return calendarIsExternal(calendar) ? "i18n:govoplan-calendar.remove.e963907d" : "i18n:govoplan-calendar.delete.f6fdbe48";
}
function calendarEventDeleteOptionLabel(calendar: CalendarCollection): string {
return calendarIsExternal(calendar) ? "i18n:govoplan-calendar.remove_local_events_with_this_calendar.fb8831e1" : "i18n:govoplan-calendar.delete_events_with_this_calendar.cc0e1287";
}
function calendarMoveTargetIsSupported(
source: CalendarSyncSource | null,
targetSource: CalendarSyncSource | null)
: boolean {
if (source) return targetSource === null;
return targetSource === null || calendarSyncSourceAcceptsCopies(targetSource);
}
function calendarSyncSourceAcceptsCopies(source: CalendarSyncSource): boolean {
return source.source_kind === "caldav" && source.sync_enabled && source.sync_direction === "two_way";
}
function calendarBulkMoveExternalAction(
source: CalendarSyncSource | null,
targetSource: CalendarSyncSource | null)
: CalendarBulkMoveExternalAction | undefined {
if (source && !targetSource) return "detach_keep_remote";
if (!source && targetSource && calendarSyncSourceAcceptsCopies(targetSource)) return "copy_to_remote";
return undefined;
}
function calendarBulkMoveOptionLabel(action: CalendarBulkMoveExternalAction | undefined): string {
if (action === "detach_keep_remote") return "i18n:govoplan-calendar.detach_local_events_and_keep_remote_events.c58ad4e7";
if (action === "copy_to_remote") return "i18n:govoplan-calendar.move_and_copy_events_to_the_external_calendar.23c3a004";
return "i18n:govoplan-calendar.move_events_to_another_calendar.830c7b09";
}
function calendarBulkMoveConsequence(action: CalendarBulkMoveExternalAction | undefined): string {
if (action === "detach_keep_remote") return "i18n:govoplan-calendar.govoplan_moves_the_local_event_copies_to_the_targe.4863e46b";
if (action === "copy_to_remote") return "i18n:govoplan-calendar.govoplan_moves_the_events_locally_and_queues_durab.da075b16";
return "i18n:govoplan-calendar.events_remain_in_govoplan_no_external_calendar_is_.62cb5937";
}
function calendarDeleteWarning(
calendar: CalendarCollection,
eventCount: number,
eventAction: CalendarDeleteEventAction,
targetCalendarId: string,
moveTargets: CalendarCollection[])
: string {
const action = calendarIsExternal(calendar) ? "i18n:govoplan-calendar.removing.b7d2c38b" : "i18n:govoplan-calendar.deleting.2cda36c9";
const eventWord = eventCount === 1 ? "i18n:govoplan-calendar.event" : "i18n:govoplan-calendar.events";
if (eventAction === "move") {
const target = moveTargets.find((item) => item.id === targetCalendarId);
return i18nMessage("i18n:govoplan-calendar.value_this_calendar_will_move_value_value_to_val.6c87e1c1", { value0: action, value1: eventCount, value2: eventWord, value3: target?.name || "i18n:govoplan-calendar.the_selected_calendar.67caa12f" });
}
if (calendarIsExternal(calendar)) {
return i18nMessage("i18n:govoplan-calendar.value_this_calendar_will_remove_value_local_valu.8f21a59e", { value0: action, value1: eventCount, value2: eventWord });
}
return i18nMessage("i18n:govoplan-calendar.value_this_calendar_will_delete_value_value.7869d1f1", { value0: action, value1: eventCount, value2: eventWord });
}
function calendarIsExternal(calendar: CalendarCollection): boolean {
const metadata = calendar.metadata;
if (!metadata || typeof metadata !== "object") return false;
const sourceKind = metadataText(metadata, "source_kind") || metadataText(metadata, "sourceKind") || metadataText(metadata, "connector_kind") || metadataText(metadata, "connectorKind");
if (sourceKind && sourceKind !== "local") return true;
const connectionKind = metadataText(metadata, "connection_kind") || metadataText(metadata, "connectionKind");
if (connectionKind && connectionKind !== "local") return true;
return Boolean(
metadataFlag(metadata, "external") ||
metadataFlag(metadata, "remote") ||
metadataFlag(metadata, "sync") ||
metadataFlag(metadata, "caldav") ||
metadataText(metadata, "connector_id") ||
metadataText(metadata, "connectorId") ||
metadataText(metadata, "sync_href") ||
metadataText(metadata, "syncHref")
);
}
function metadataText(metadata: Record<string, unknown>, key: string): string {
const value = metadata[key];
return typeof value === "string" ? value.trim().toLowerCase() : "";
}
function metadataFlag(metadata: Record<string, unknown>, key: string): boolean {
return metadata[key] === true;
}

View File

@@ -0,0 +1,562 @@
import {
useMemo,
useState,
type FormEvent,
} from "react";
import { Trash2 } from "lucide-react";
import {
Button,
DateField,
Dialog,
TimeField,
ToggleSwitch,
useUnsavedDraftGuard,
} from "@govoplan/core-webui";
import type {
CalendarCollection,
CalendarEvent,
CalendarEventCreatePayload,
} from "../../api/calendar";
import {
addDays,
addHours,
addMinutesToTime,
calendarDraftKey,
errorText,
localDateTime,
startOfDay,
toInputDate,
toInputTime,
} from "./calendarViewModel";
type CalendarEventEndMode = "end" | "duration";
type CalendarEventAdvancedPayload = Pick<
CalendarEventCreatePayload,
| "organizer"
| "attendees"
| "categories"
| "rrule"
| "rdate"
| "exdate"
| "reminders"
| "attachments"
| "related_to"
| "icalendar"
| "metadata"
>;
export function CalendarEventDialog({
calendars,
defaultCalendarId,
event,
focusDate,
saving,
canWrite,
canDelete,
onCancel,
onSave,
onDelete
}: {calendars: CalendarCollection[];defaultCalendarId: string;event: CalendarEvent | null;focusDate: Date;saving: boolean;canWrite: boolean;canDelete: boolean;onCancel: () => void;onSave: (payload: CalendarEventCreatePayload, event: CalendarEvent | null) => Promise<boolean>;onDelete: (event: CalendarEvent) => Promise<void>;}) {
const initialStart = event ? new Date(event.start_at) : focusDate;
const initialEnd = event?.end_at ? new Date(event.end_at) : addHours(initialStart, 1);
const initialAllDayEnd = event?.all_day && event.end_at ? addDays(startOfDay(initialEnd), -1) : initialEnd;
const initialDurationSeconds = event?.duration_seconds ?? Math.max(3600, Math.round((initialEnd.getTime() - initialStart.getTime()) / 1000));
const [summary, setSummary] = useState(event?.summary ?? "");
const [calendarId, setCalendarId] = useState(event?.calendar_id ?? defaultCalendarId);
const [description, setDescription] = useState(event?.description ?? "");
const [location, setLocation] = useState(event?.location ?? "");
const [startDate, setStartDate] = useState(toInputDate(initialStart));
const [endDate, setEndDate] = useState(toInputDate(initialAllDayEnd < initialStart ? initialStart : initialAllDayEnd));
const [startTime, setStartTime] = useState(toInputTime(initialStart));
const [endTime, setEndTime] = useState(toInputTime(initialEnd));
const [allDay, setAllDay] = useState(event?.all_day ?? false);
const [endMode, setEndMode] = useState<CalendarEventEndMode>(event && eventUsesDuration(event) ? "duration" : "end");
const [durationSeconds, setDurationSeconds] = useState(String(initialDurationSeconds));
const [uid, setUid] = useState(event?.uid ?? "");
const [recurrenceId, setRecurrenceId] = useState(event?.recurrence_id ?? "");
const [sequence, setSequence] = useState(String(event?.sequence ?? 0));
const [status, setStatus] = useState(event?.status ?? "CONFIRMED");
const [transparency, setTransparency] = useState(event?.transparency ?? "OPAQUE");
const [classification, setClassification] = useState(event?.classification ?? "PUBLIC");
const [timezone, setTimezone] = useState(event?.timezone ?? Intl.DateTimeFormat().resolvedOptions().timeZone ?? "UTC");
const [categoriesText, setCategoriesText] = useState((event?.categories ?? []).join(", "));
const [organizerJson, setOrganizerJson] = useState(jsonTextareaValue(event?.organizer ?? null));
const [attendeesJson, setAttendeesJson] = useState(jsonTextareaValue(event?.attendees ?? []));
const [rruleText, setRruleText] = useState(event?.rrule ? serializeRRuleText(event.rrule) : "");
const [rdateJson, setRdateJson] = useState(jsonTextareaValue(event?.rdate ?? []));
const [exdateJson, setExdateJson] = useState(jsonTextareaValue(event?.exdate ?? []));
const [remindersJson, setRemindersJson] = useState(jsonTextareaValue(event?.reminders ?? []));
const [attachmentsJson, setAttachmentsJson] = useState(jsonTextareaValue(event?.attachments ?? []));
const [relatedToJson, setRelatedToJson] = useState(jsonTextareaValue(event?.related_to ?? []));
const [sourceKind, setSourceKind] = useState(event?.source_kind ?? "local");
const [sourceHref, setSourceHref] = useState(event?.source_href ?? "");
const [etag, setEtag] = useState(event?.etag ?? "");
const [icalendarJson, setICalendarJson] = useState(jsonTextareaValue(event?.icalendar ?? {}));
const [metadataJson, setMetadataJson] = useState(jsonTextareaValue(event?.metadata ?? {}));
const [formError, setFormError] = useState("");
const [confirmingDelete, setConfirmingDelete] = useState(false);
const formId = "calendar-event-form";
const eventDraft = {
summary,
calendarId,
description,
location,
startDate,
endDate,
startTime,
endTime,
allDay,
endMode,
durationSeconds,
uid,
recurrenceId,
sequence,
status,
transparency,
classification,
timezone,
categoriesText,
organizerJson,
attendeesJson,
rruleText,
rdateJson,
exdateJson,
remindersJson,
attachmentsJson,
relatedToJson,
sourceKind,
sourceHref,
etag,
icalendarJson,
metadataJson
};
const initialEventDraftKey = useMemo(() => calendarDraftKey(eventDraft), []);
const eventDirty = calendarDraftKey(eventDraft) !== initialEventDraftKey;
useUnsavedDraftGuard({
dirty: eventDirty,
onSave: saveCurrent,
onDiscard: onCancel
});
function handleAllDayChange(next: boolean) {
setAllDay(next);
if (next) {
setStartTime("00:00");
setEndTime("00:00");
} else if (startTime === "00:00" && endTime === "00:00") {
setStartTime("09:00");
setEndTime("10:00");
}
}
function handleStartDateChange(next: string) {
setStartDate(next);
if (endDate < next) setEndDate(next);
}
function handleStartTimeChange(next: string) {
setStartTime(next);
if (startDate === endDate && endTime <= next) setEndTime(addMinutesToTime(next, 60));
}
async function saveCurrent(): Promise<boolean> {
setFormError("");
const startAt = allDay ? localDateTime(startDate, "00:00") : localDateTime(startDate, startTime);
const parsedDurationSeconds = Math.max(0, Number(durationSeconds) || 0);
const usesDuration = !allDay && endMode === "duration";
const endAt = allDay ?
addDays(localDateTime(endDate, "00:00"), 1) :
usesDuration ?
new Date(startAt.getTime() + parsedDurationSeconds * 1000) :
localDateTime(endDate, endTime);
if (usesDuration && parsedDurationSeconds <= 0) {
setFormError("i18n:govoplan-calendar.duration_must_be_greater_than_zero.519292f7");
return false;
}
if (endAt <= startAt) {
setFormError(allDay ? "i18n:govoplan-calendar.end_date_must_be_on_or_after_start_date.a2518865" : "i18n:govoplan-calendar.end_date_and_time_must_be_after_start_date_and_t.daf31831");
return false;
}
let advanced: CalendarEventAdvancedPayload;
try {
advanced = {
organizer: parseJsonObjectOrNull(organizerJson, "i18n:govoplan-calendar.organizer.debd1720"),
attendees: parseJsonArray(attendeesJson, "i18n:govoplan-calendar.attendees.a45a0962"),
categories: commaSeparatedValues(categoriesText),
rrule: parseRRuleInput(rruleText),
rdate: parseJsonArray(rdateJson, "RDATE"),
exdate: parseJsonArray(exdateJson, "EXDATE"),
reminders: parseJsonArray(remindersJson, "i18n:govoplan-calendar.reminders.ae8c3939"),
attachments: parseJsonArray(attachmentsJson, "i18n:govoplan-calendar.attachments.6771ade6"),
related_to: parseJsonArray(relatedToJson, "i18n:govoplan-calendar.related_to.0e7989ff"),
icalendar: withDurationMode(parseJsonObject(icalendarJson, "i18n:govoplan-calendar.icalendar.f388476d"), usesDuration),
metadata: parseJsonObject(metadataJson, "i18n:govoplan-calendar.metadata.251edc0e")
};
} catch (err) {
setFormError(errorText(err));
return false;
}
const payload: CalendarEventCreatePayload = {
calendar_id: calendarId,
summary,
description: description || null,
location: location || null,
sequence: Math.max(0, Number(sequence) || 0),
status,
transparency,
classification,
start_at: startAt.toISOString(),
end_at: endAt.toISOString(),
duration_seconds: usesDuration ? parsedDurationSeconds : null,
all_day: allDay,
timezone: timezone.trim() || null,
source_kind: sourceKind.trim() || "local",
source_href: sourceHref.trim() || null,
etag: etag.trim() || null,
...advanced
};
if (!event) {
if (uid.trim()) payload.uid = uid.trim();
if (recurrenceId.trim()) payload.recurrence_id = recurrenceId.trim();
}
return onSave(payload, event);
}
function submit(formEvent: FormEvent<HTMLFormElement>) {
formEvent.preventDefault();
void saveCurrent();
}
function requestDelete() {
if (!event) return;
if (!confirmingDelete) {
setConfirmingDelete(true);
return;
}
void onDelete(event);
}
return (
<Dialog
open
title={event ? "i18n:govoplan-calendar.edit_event.a7028454" : "i18n:govoplan-calendar.new_event.2ef3795c"}
className="calendar-event-dialog calendar-vevent-dialog"
footerClassName="calendar-event-dialog-footer"
closeDisabled={saving}
onClose={onCancel}
footer={
<>
<div>
{event && canDelete &&
<Button type="button" variant="danger" onClick={requestDelete} disabled={saving}>
<Trash2 size={16} /> {confirmingDelete ? "i18n:govoplan-calendar.confirm_delete.c9f2829e" : "i18n:govoplan-calendar.delete.f6fdbe48"}
</Button>
}
</div>
<div className="calendar-dialog-actions">
<Button type="button" onClick={onCancel} disabled={saving}>i18n:govoplan-calendar.cancel.77dfd213</Button>
<Button type="submit" form={formId} variant="primary" disabled={saving || !canWrite}>{saving ? "i18n:govoplan-calendar.saving.ae7e8875" : "i18n:govoplan-calendar.save.efc007a3"}</Button>
</div>
</>
}>
<form id={formId} className="calendar-dialog-form" onSubmit={submit}>
{formError && <p className="calendar-form-error">{formError}</p>}
<label>
<span>i18n:govoplan-calendar.title.768e0c1c</span>
<input value={summary} onChange={(item) => setSummary(item.target.value)} required maxLength={500} autoFocus disabled={saving || !canWrite} />
</label>
<label>
<span>i18n:govoplan-calendar.calendar.adab5090</span>
<select value={calendarId} onChange={(item) => setCalendarId(item.target.value)} required disabled={saving || !canWrite}>
{calendars.map((calendar) =>
<option key={calendar.id} value={calendar.id}>{calendar.name}</option>
)}
</select>
</label>
<label>
<span>i18n:govoplan-calendar.description.55f8ebc8</span>
<textarea value={description} onChange={(item) => setDescription(item.target.value)} rows={4} disabled={saving || !canWrite} />
</label>
<label>
<span>i18n:govoplan-calendar.location.d219c681</span>
<input value={location} onChange={(item) => setLocation(item.target.value)} maxLength={500} disabled={saving || !canWrite} />
</label>
<ToggleSwitch label="i18n:govoplan-calendar.whole_day.951c82d1" checked={allDay} disabled={saving || !canWrite} onChange={handleAllDayChange} />
<div className="calendar-dialog-date-row">
<label>
<span>i18n:govoplan-calendar.start_date.ff99f5b5</span>
<DateField value={startDate} onChange={handleStartDateChange} required disabled={saving || !canWrite} />
</label>
<label>
<span>i18n:govoplan-calendar.start_time.88d8206d</span>
<TimeField value={startTime} onChange={handleStartTimeChange} disabled={saving || !canWrite || allDay} />
</label>
</div>
{!allDay &&
<div className="calendar-dialog-date-row">
<label>
<span>i18n:govoplan-calendar.end_mode.5a06de37</span>
<select value={endMode} onChange={(item) => setEndMode(item.target.value as CalendarEventEndMode)} disabled={saving || !canWrite}>
<option value="end">i18n:govoplan-calendar.end_time.cd7800da</option>
<option value="duration">i18n:govoplan-calendar.duration.1370004d</option>
</select>
</label>
{endMode === "duration" &&
<label>
<span>i18n:govoplan-calendar.duration_seconds.19d42eeb</span>
<input type="number" min={1} step={60} value={durationSeconds} onChange={(item) => setDurationSeconds(item.target.value)} required disabled={saving || !canWrite} />
</label>
}
</div>
}
{(allDay || endMode === "end") &&
<div className="calendar-dialog-date-row">
<label>
<span>i18n:govoplan-calendar.end_date.89d10cd6</span>
<DateField value={endDate} min={startDate} onChange={setEndDate} required disabled={saving || !canWrite} />
</label>
<label>
<span>i18n:govoplan-calendar.end_time.cd7800da</span>
<TimeField value={endTime} min={!allDay && startDate === endDate ? startTime : undefined} onChange={setEndTime} disabled={saving || !canWrite || allDay} />
</label>
</div>
}
<details className="calendar-advanced-settings calendar-vevent-details">
<summary>i18n:govoplan-calendar.vevent.9cf5be75</summary>
<section className="calendar-vevent-section">
<h3>i18n:govoplan-calendar.identity.7e5a975b</h3>
<div className="calendar-dialog-grid-three">
<label>
<span>i18n:govoplan-calendar.uid.d946adf5</span>
<input value={uid} onChange={(item) => setUid(item.target.value)} maxLength={255} disabled={saving || !canWrite || Boolean(event)} />
</label>
<label>
<span>i18n:govoplan-calendar.recurrence_id.e0b780ba</span>
<input value={recurrenceId} onChange={(item) => setRecurrenceId(item.target.value)} maxLength={255} disabled={saving || !canWrite || Boolean(event)} />
</label>
<label>
<span>i18n:govoplan-calendar.sequence.5c8f4e0e</span>
<input type="number" min={0} step={1} value={sequence} onChange={(item) => setSequence(item.target.value)} disabled={saving || !canWrite} />
</label>
</div>
</section>
<section className="calendar-vevent-section">
<h3>i18n:govoplan-calendar.state.a7250206</h3>
<div className="calendar-dialog-grid-three">
<label>
<span>i18n:govoplan-calendar.status.bae7d5be</span>
<select value={status} onChange={(item) => setStatus(item.target.value)} disabled={saving || !canWrite}>
<option value="CONFIRMED">i18n:govoplan-calendar.confirmed.0542404a</option>
<option value="TENTATIVE">i18n:govoplan-calendar.tentative.d19f9022</option>
<option value="CANCELLED">i18n:govoplan-calendar.cancelled.5587b0af</option>
</select>
</label>
<label>
<span>i18n:govoplan-calendar.transparency.7cb338a9</span>
<select value={transparency} onChange={(item) => setTransparency(item.target.value)} disabled={saving || !canWrite}>
<option value="OPAQUE">i18n:govoplan-calendar.opaque.3e1d0194</option>
<option value="TRANSPARENT">i18n:govoplan-calendar.transparent.ea4efcae</option>
</select>
</label>
<label>
<span>i18n:govoplan-calendar.class.41ff354b</span>
<select value={classification} onChange={(item) => setClassification(item.target.value)} disabled={saving || !canWrite}>
<option value="PUBLIC">i18n:govoplan-calendar.public.d1785ca2</option>
<option value="PRIVATE">i18n:govoplan-calendar.private.b0b7ba46</option>
<option value="CONFIDENTIAL">i18n:govoplan-calendar.confidential.84c9cc88</option>
</select>
</label>
<label>
<span>i18n:govoplan-calendar.timezone.d1f7dc89</span>
<input value={timezone} onChange={(item) => setTimezone(item.target.value)} maxLength={100} disabled={saving || !canWrite} />
</label>
<label className="calendar-dialog-wide">
<span>i18n:govoplan-calendar.categories.6ccb6007</span>
<input value={categoriesText} onChange={(item) => setCategoriesText(item.target.value)} disabled={saving || !canWrite} />
</label>
</div>
</section>
<section className="calendar-vevent-section">
<h3>i18n:govoplan-calendar.participants.cd56e083</h3>
<div className="calendar-dialog-grid-two">
<label>
<span>i18n:govoplan-calendar.organizer_json.3add6f9f</span>
<textarea value={organizerJson} onChange={(item) => setOrganizerJson(item.target.value)} rows={5} disabled={saving || !canWrite} />
</label>
<label>
<span>i18n:govoplan-calendar.attendees_json.aeb487bb</span>
<textarea value={attendeesJson} onChange={(item) => setAttendeesJson(item.target.value)} rows={5} disabled={saving || !canWrite} />
</label>
</div>
</section>
<section className="calendar-vevent-section">
<h3>i18n:govoplan-calendar.recurrence.f7ad40f5</h3>
<label>
<span>i18n:govoplan-calendar.rrule.c7b2f8a3</span>
<input value={rruleText} onChange={(item) => setRruleText(item.target.value)} disabled={saving || !canWrite} />
</label>
<div className="calendar-dialog-grid-two">
<label>
<span>i18n:govoplan-calendar.rdate_json.5b51fca4</span>
<textarea value={rdateJson} onChange={(item) => setRdateJson(item.target.value)} rows={5} disabled={saving || !canWrite} />
</label>
<label>
<span>i18n:govoplan-calendar.exdate_json.7d0c538d</span>
<textarea value={exdateJson} onChange={(item) => setExdateJson(item.target.value)} rows={5} disabled={saving || !canWrite} />
</label>
</div>
</section>
<section className="calendar-vevent-section">
<h3>i18n:govoplan-calendar.related.917df91e</h3>
<div className="calendar-dialog-grid-three">
<label>
<span>i18n:govoplan-calendar.reminders_json.ca25e08f</span>
<textarea value={remindersJson} onChange={(item) => setRemindersJson(item.target.value)} rows={5} disabled={saving || !canWrite} />
</label>
<label>
<span>i18n:govoplan-calendar.attachments_json.d91abf3c</span>
<textarea value={attachmentsJson} onChange={(item) => setAttachmentsJson(item.target.value)} rows={5} disabled={saving || !canWrite} />
</label>
<label>
<span>i18n:govoplan-calendar.related_to_json.2d4e8f59</span>
<textarea value={relatedToJson} onChange={(item) => setRelatedToJson(item.target.value)} rows={5} disabled={saving || !canWrite} />
</label>
</div>
</section>
<section className="calendar-vevent-section">
<h3>i18n:govoplan-calendar.source.6da13add</h3>
<div className="calendar-dialog-grid-three">
<label>
<span>i18n:govoplan-calendar.source_kind.7eda9bc4</span>
<input value={sourceKind} onChange={(item) => setSourceKind(item.target.value)} maxLength={30} disabled={saving || !canWrite} />
</label>
<label>
<span>i18n:govoplan-calendar.source_href.819fa147</span>
<input value={sourceHref} onChange={(item) => setSourceHref(item.target.value)} maxLength={1000} disabled={saving || !canWrite} />
</label>
<label>
<span>i18n:govoplan-calendar.etag.11d00f6e</span>
<input value={etag} onChange={(item) => setEtag(item.target.value)} maxLength={255} disabled={saving || !canWrite} />
</label>
</div>
</section>
<section className="calendar-vevent-section">
<h3>i18n:govoplan-calendar.raw.da433cd4</h3>
<div className="calendar-dialog-grid-two">
<label>
<span>i18n:govoplan-calendar.icalendar_json.fb6cc33e</span>
<textarea value={icalendarJson} onChange={(item) => setICalendarJson(item.target.value)} rows={8} disabled={saving || !canWrite} />
</label>
<label>
<span>i18n:govoplan-calendar.metadata_json.b0e4c283</span>
<textarea value={metadataJson} onChange={(item) => setMetadataJson(item.target.value)} rows={8} disabled={saving || !canWrite} />
</label>
</div>
</section>
</details>
</form>
</Dialog>);
}
function jsonTextareaValue(value: unknown): string {
return JSON.stringify(value, null, 2);
}
function parseJsonObject(text: string, label: string): Record<string, unknown> {
const value = parseJsonText(text, label, {});
if (!isPlainObject(value)) throw new Error(`${label} must be a JSON object.`);
return value;
}
function parseJsonObjectOrNull(text: string, label: string): Record<string, unknown> | null {
if (!text.trim()) return null;
const value = parseJsonText(text, label, null);
if (value === null) return null;
if (!isPlainObject(value)) throw new Error(`${label} must be a JSON object or null.`);
return value;
}
function parseJsonArray(text: string, label: string): Record<string, unknown>[] {
const value = parseJsonText(text, label, []);
if (!Array.isArray(value)) throw new Error(`${label} must be a JSON array.`);
return value.map((item, index) => {
if (!isPlainObject(item)) throw new Error(`${label} item ${index + 1} must be a JSON object.`);
return item;
});
}
function parseJsonText(text: string, label: string, fallback: unknown): unknown {
const trimmed = text.trim();
if (!trimmed) return fallback;
try {
return JSON.parse(trimmed) as unknown;
} catch (err) {
throw new Error(`${label} contains invalid JSON: ${errorText(err)}`);
}
}
function parseRRuleInput(text: string): Record<string, unknown> | null {
const trimmed = text.trim();
if (!trimmed) return null;
if (trimmed.startsWith("{")) return parseJsonObject(trimmed, "RRULE");
const result: Record<string, unknown> = {};
for (const item of trimmed.split(";")) {
const [rawKey, ...rawValueParts] = item.split("=");
const key = rawKey.trim().toUpperCase();
const rawValue = rawValueParts.join("=").trim();
if (!key || !rawValue) continue;
const values = rawValue.split(",").map((value) => value.trim()).filter(Boolean);
result[key] = values.length > 1 ? values : values[0] ?? rawValue;
}
return Object.keys(result).length ? result : null;
}
function serializeRRuleText(value: Record<string, unknown>): string {
return Object.entries(value).
map(([key, item]) => `${key.toUpperCase()}=${Array.isArray(item) ? item.join(",") : String(item)}`).
join(";");
}
function commaSeparatedValues(text: string): string[] {
return text.split(",").map((item) => item.trim()).filter(Boolean);
}
function withDurationMode(value: Record<string, unknown>, usesDuration: boolean): Record<string, unknown> {
const next = { ...value };
const standard = isPlainObject(next.standard) ? { ...next.standard } : {};
if (usesDuration) {
standard.uses_duration = true;
} else {
delete standard.uses_duration;
}
if (Object.keys(standard).length > 0) {
next.standard = standard;
} else {
delete next.standard;
}
return next;
}
function eventUsesDuration(event: CalendarEvent): boolean {
const standard = isPlainObject(event.icalendar?.standard) ? event.icalendar.standard : null;
return standard?.uses_duration === true;
}
function isPlainObject(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,666 @@
import {
useEffect,
useMemo,
useRef,
type DragEvent as ReactDragEvent,
} from "react";
import { i18nMessage } from "@govoplan/core-webui";
import type { CalendarEvent } from "../../api/calendar";
import {
HOUR_ROW_HEIGHT,
INITIAL_SCROLL_HOUR,
calendarEventColorStyle,
chunk,
continuousVirtualWindow,
dayKey,
dayMonthLabel,
dropSlotMinuteOfDay,
eventTimeLabel,
isWeekend,
layoutTimedEventsForDay,
minuteOfDayLabel,
monthYearLabel,
sameDay,
sameMonth,
timedEventStyle,
timedOverflowStyle,
timeGridStyle,
weekdayLong,
type CalendarDropTarget,
type CalendarMode,
type CalendarResizeEdge,
type CalendarTimeDropTarget,
type CalendarViewPreferences,
type ContinuousViewport,
} from "./calendarViewModel";
type CalendarViewCallbacks = {
onEventSelect: (event: CalendarEvent) => void;
onEventHover: (eventId: string) => void;
onEventDragStart: (
dragEvent: ReactDragEvent<HTMLElement>,
event: CalendarEvent,
) => void;
onEventDragEnd: () => void;
onEventDragOverDay: (
dragEvent: ReactDragEvent<HTMLElement>,
day: Date,
) => void;
onDropTargetLeave: (
dragEvent: ReactDragEvent<HTMLElement>,
) => void;
onEventDropOnDay: (
dropEvent: ReactDragEvent<HTMLElement>,
day: Date,
) => void;
};
type CalendarWeekRowsProps = CalendarViewCallbacks & {
days: Date[];
eventsByDay: Map<string, CalendarEvent[]>;
calendarColorById: Map<string, string>;
focusDate: Date;
variant: "month" | "continuous";
preferences: CalendarViewPreferences;
canWrite: boolean;
draggingEventId: string;
hoveredEventId: string;
dropTarget: CalendarDropTarget;
viewport?: ContinuousViewport;
};
export function CalendarWeekRows({
days,
eventsByDay,
calendarColorById,
focusDate,
variant,
preferences,
canWrite,
draggingEventId,
hoveredEventId,
dropTarget,
viewport,
onEventSelect,
onEventHover,
onEventDragStart,
onEventDragEnd,
onEventDragOverDay,
onDropTargetLeave,
onEventDropOnDay,
}: CalendarWeekRowsProps) {
const weeks = chunk(days, 7);
const virtualWindow =
variant === "continuous" && preferences.continuousVirtualization
? continuousVirtualWindow(
weeks.length,
viewport ?? { scrollTop: 0, height: 0 },
preferences.continuousOverscanWeeks,
)
: {
start: 0,
end: weeks.length,
topSpacerHeight: 0,
bottomSpacerHeight: 0,
};
const visibleWeeks = weeks.slice(
virtualWindow.start,
virtualWindow.end,
);
const eventLimit = variant === "month" ? 5 : 3;
return (
<div className={`calendar-week-rows is-${variant}`}>
<div className="calendar-weekday-header">
{[
"i18n:govoplan-calendar.mon.24b2a099",
"i18n:govoplan-calendar.tue.529541bb",
"i18n:govoplan-calendar.wed.23408b19",
"i18n:govoplan-calendar.thu.3593ccd9",
"i18n:govoplan-calendar.fri.bbd6e32e",
"i18n:govoplan-calendar.sat.6b782d41",
"i18n:govoplan-calendar.sun.48c98cab",
].map((label) => (
<span key={label}>{label}</span>
))}
</div>
{virtualWindow.topSpacerHeight > 0 && (
<div
className="calendar-week-spacer"
style={{ height: virtualWindow.topSpacerHeight }}
aria-hidden="true"
/>
)}
{visibleWeeks.map((week) => (
<div className="calendar-week-row" key={dayKey(week[0])}>
{week.map((day) => {
const key = dayKey(day);
const dayEvents = eventsByDay.get(key) ?? [];
const outsideFocusMonth =
variant === "month" && !sameMonth(day, focusDate);
return (
<section
key={key}
className={[
"calendar-day-cell",
outsideFocusMonth ? "is-muted" : "",
sameDay(day, new Date()) ? "is-today" : "",
dropTarget?.kind === "day" &&
dropTarget.key === key
? "is-drop-target"
: "",
variant === "continuous" &&
preferences.alternateContinuousMonths
? day.getMonth() % 2 === 0
? "is-even-month"
: "is-odd-month"
: "",
]
.filter(Boolean)
.join(" ")}
onDragOver={
canWrite
? (event) => onEventDragOverDay(event, day)
: undefined
}
onDragLeave={
canWrite ? onDropTargetLeave : undefined
}
onDrop={
canWrite
? (event) => onEventDropOnDay(event, day)
: undefined
}
>
<header>
<span className="calendar-day-number">
{day.getDate()}
</span>
{day.getDate() === 1 && (
<small>{monthYearLabel(day)}</small>
)}
</header>
<div className="calendar-event-stack">
{dayEvents.slice(0, eventLimit).map((event) => (
<CalendarEventChip
key={event.id}
event={event}
color={calendarColorById.get(event.calendar_id)}
canDrag={canWrite}
dragging={draggingEventId === event.id}
linkedHover={hoveredEventId === event.id}
onSelect={onEventSelect}
onHover={onEventHover}
onDragStart={onEventDragStart}
onDragEnd={onEventDragEnd}
/>
))}
{dayEvents.length > eventLimit && (
<span className="calendar-more-events">
+{dayEvents.length - eventLimit}
</span>
)}
</div>
</section>
);
})}
</div>
))}
{virtualWindow.bottomSpacerHeight > 0 && (
<div
className="calendar-week-spacer"
style={{ height: virtualWindow.bottomSpacerHeight }}
aria-hidden="true"
/>
)}
</div>
);
}
type CalendarTimeGridProps = CalendarViewCallbacks & {
days: Date[];
eventsByDay: Map<string, CalendarEvent[]>;
calendarColorById: Map<string, string>;
mode: CalendarMode;
preferences: CalendarViewPreferences;
canWrite: boolean;
draggingEventId: string;
hoveredEventId: string;
dropTarget: CalendarDropTarget;
onEventResizeDragStart: (
dragEvent: ReactDragEvent<HTMLElement>,
event: CalendarEvent,
edge: CalendarResizeEdge,
) => void;
onEventDragOverTime: (
dragEvent: ReactDragEvent<HTMLElement>,
day: Date,
) => void;
onEventDropOnTime: (
dropEvent: ReactDragEvent<HTMLElement>,
day: Date,
minuteOfDay: number,
) => void;
};
export function CalendarTimeGrid({
days,
eventsByDay,
calendarColorById,
mode,
preferences,
canWrite,
draggingEventId,
hoveredEventId,
dropTarget,
onEventSelect,
onEventHover,
onEventDragStart,
onEventResizeDragStart,
onEventDragEnd,
onEventDragOverDay,
onEventDragOverTime,
onDropTargetLeave,
onEventDropOnDay,
onEventDropOnTime,
}: CalendarTimeGridProps) {
const scrollRef = useRef<HTMLDivElement | null>(null);
const hours = Array.from({ length: 24 }, (_item, index) => index);
const columns = `72px repeat(${days.length}, minmax(132px, 1fr))`;
const dimWeekends = preferences.dimWeekends && mode === "week";
const gridStyle = timeGridStyle(columns, preferences);
const allDayEventsByDay = days.map((day) => ({
key: dayKey(day),
date: day,
events: (eventsByDay.get(dayKey(day)) ?? []).filter(
(event) => event.all_day,
),
}));
const hasAllDayEvents = allDayEventsByDay.some(
(day) => day.events.length > 0,
);
useEffect(() => {
if (scrollRef.current) {
scrollRef.current.scrollTop =
INITIAL_SCROLL_HOUR * HOUR_ROW_HEIGHT;
}
}, [days.length, days[0]?.getTime()]);
return (
<div className="calendar-time-view">
<div
className="calendar-time-header"
style={{ gridTemplateColumns: columns }}
>
<div className="calendar-time-corner" />
{days.map((day) => (
<header
key={dayKey(day)}
className={[
sameDay(day, new Date()) ? "is-today" : "",
dimWeekends && isWeekend(day) ? "is-weekend" : "",
]
.filter(Boolean)
.join(" ")}
>
<strong>{weekdayLong(day)}</strong>
<span>{dayMonthLabel(day)}</span>
</header>
))}
</div>
{hasAllDayEvents && (
<div
className="calendar-all-day-strip"
style={{ gridTemplateColumns: columns }}
>
<div className="calendar-all-day-label">
i18n:govoplan-calendar.all_day.11457433
</div>
{allDayEventsByDay.map((day) => (
<div
key={day.key}
className={[
"calendar-all-day-cell",
dimWeekends && isWeekend(day.date)
? "is-weekend"
: "",
dropTarget?.kind === "day" &&
dropTarget.key === day.key
? "is-drop-target"
: "",
]
.filter(Boolean)
.join(" ")}
onDragOver={
canWrite
? (event) => onEventDragOverDay(event, day.date)
: undefined
}
onDragLeave={
canWrite ? onDropTargetLeave : undefined
}
onDrop={
canWrite
? (event) => onEventDropOnDay(event, day.date)
: undefined
}
>
{day.events.map((event) => (
<CalendarEventChip
key={event.id}
event={event}
color={calendarColorById.get(event.calendar_id)}
compact
canDrag={canWrite}
dragging={draggingEventId === event.id}
linkedHover={hoveredEventId === event.id}
onSelect={onEventSelect}
onHover={onEventHover}
onDragStart={onEventDragStart}
onDragEnd={onEventDragEnd}
/>
))}
</div>
))}
</div>
)}
<div className="calendar-time-scroll" ref={scrollRef}>
<div className="calendar-time-grid" style={gridStyle}>
<div className="calendar-hour-label-column">
{hours.map((hour) => (
<div className="calendar-hour-label" key={hour}>
{String(hour).padStart(2, "0")}:00
</div>
))}
</div>
{days.map((day) => (
<TimedDayColumn
key={dayKey(day)}
day={day}
events={eventsByDay.get(dayKey(day)) ?? []}
calendarColorById={calendarColorById}
dimWeekend={dimWeekends && isWeekend(day)}
canWrite={canWrite}
draggingEventId={draggingEventId}
hoveredEventId={hoveredEventId}
dropTarget={
dropTarget?.kind === "time" &&
dropTarget.key === dayKey(day)
? dropTarget
: null
}
onEventSelect={onEventSelect}
onEventHover={onEventHover}
onEventDragStart={onEventDragStart}
onEventResizeDragStart={onEventResizeDragStart}
onEventDragEnd={onEventDragEnd}
onEventDragOverTime={onEventDragOverTime}
onDropTargetLeave={onDropTargetLeave}
onEventDropOnTime={onEventDropOnTime}
/>
))}
</div>
</div>
</div>
);
}
type TimedDayColumnProps = {
day: Date;
events: CalendarEvent[];
calendarColorById: Map<string, string>;
dimWeekend: boolean;
canWrite: boolean;
draggingEventId: string;
hoveredEventId: string;
dropTarget: CalendarTimeDropTarget | null;
onEventSelect: (event: CalendarEvent) => void;
onEventHover: (eventId: string) => void;
onEventDragStart: (
dragEvent: ReactDragEvent<HTMLElement>,
event: CalendarEvent,
) => void;
onEventResizeDragStart: (
dragEvent: ReactDragEvent<HTMLElement>,
event: CalendarEvent,
edge: CalendarResizeEdge,
) => void;
onEventDragEnd: () => void;
onEventDragOverTime: (
dragEvent: ReactDragEvent<HTMLElement>,
day: Date,
) => void;
onDropTargetLeave: (
dragEvent: ReactDragEvent<HTMLElement>,
) => void;
onEventDropOnTime: (
dropEvent: ReactDragEvent<HTMLElement>,
day: Date,
minuteOfDay: number,
) => void;
};
function TimedDayColumn({
day,
events,
calendarColorById,
dimWeekend,
canWrite,
draggingEventId,
hoveredEventId,
dropTarget,
onEventSelect,
onEventHover,
onEventDragStart,
onEventResizeDragStart,
onEventDragEnd,
onEventDragOverTime,
onDropTargetLeave,
onEventDropOnTime,
}: TimedDayColumnProps) {
const layout = useMemo(
() => layoutTimedEventsForDay(events, day),
[day, events],
);
return (
<div
className={[
"calendar-day-time-column",
dimWeekend ? "is-weekend" : "",
dropTarget ? "is-drop-target" : "",
]
.filter(Boolean)
.join(" ")}
onDragOver={
canWrite
? (event) => onEventDragOverTime(event, day)
: undefined
}
onDragLeave={canWrite ? onDropTargetLeave : undefined}
onDrop={
canWrite
? (event) =>
onEventDropOnTime(event, day, dropSlotMinuteOfDay(event))
: undefined
}
>
{dropTarget && (
<div
className="calendar-time-drop-marker"
style={{
top: `${
(dropTarget.minuteOfDay / 60) * HOUR_ROW_HEIGHT
}px`,
}}
aria-hidden="true"
>
<span>{minuteOfDayLabel(dropTarget.minuteOfDay)}</span>
</div>
)}
{layout.items.map((item) => (
<div
key={item.event.id}
className={[
"calendar-timed-event",
draggingEventId === item.event.id ? "is-dragging" : "",
hoveredEventId === item.event.id
? "is-linked-hover"
: "",
]
.filter(Boolean)
.join(" ")}
style={timedEventStyle(
item,
calendarColorById.get(item.event.calendar_id),
)}
draggable={canWrite}
onMouseEnter={() => onEventHover(item.event.id)}
onMouseLeave={() => onEventHover("")}
onDragStart={
canWrite
? (event) => onEventDragStart(event, item.event)
: undefined
}
onDragEnd={canWrite ? onEventDragEnd : undefined}
title={i18nMessage(
"i18n:govoplan-calendar.edit_value.fad75899",
{ value0: item.event.summary },
)}
>
{canWrite && (
<span
className="calendar-event-resize-handle is-start"
draggable
title="i18n:govoplan-calendar.change_start_time.06eea3d3"
onDragStart={(event) =>
onEventResizeDragStart(event, item.event, "start")
}
onDragEnd={onEventDragEnd}
/>
)}
<button
type="button"
className="calendar-timed-event-content"
onClick={() => onEventSelect(item.event)}
onFocus={() => onEventHover(item.event.id)}
onBlur={() => onEventHover("")}
title={i18nMessage(
"i18n:govoplan-calendar.edit_value.fad75899",
{ value0: item.event.summary },
)}
>
<EventInlineLabel event={item.event} />
</button>
{canWrite && (
<span
className="calendar-event-resize-handle is-end"
draggable
title="i18n:govoplan-calendar.change_end_time.db66ef4b"
onDragStart={(event) =>
onEventResizeDragStart(event, item.event, "end")
}
onDragEnd={onEventDragEnd}
/>
)}
</div>
))}
{layout.overflows.map((overflow) => (
<button
key={overflow.key}
type="button"
className="calendar-timed-overflow"
style={timedOverflowStyle(overflow)}
title={overflow.events
.map((event) => event.summary)
.join(", ")}
onClick={() => onEventSelect(overflow.events[0])}
>
+{overflow.events.length}
</button>
))}
</div>
);
}
type CalendarEventChipProps = {
event: CalendarEvent;
color?: string | null;
compact?: boolean;
canDrag?: boolean;
dragging?: boolean;
linkedHover?: boolean;
onSelect: (event: CalendarEvent) => void;
onHover?: (eventId: string) => void;
onDragStart?: (
dragEvent: ReactDragEvent<HTMLElement>,
event: CalendarEvent,
) => void;
onDragEnd?: () => void;
};
function CalendarEventChip({
event,
color,
compact = false,
canDrag = false,
dragging = false,
linkedHover = false,
onSelect,
onHover,
onDragStart,
onDragEnd,
}: CalendarEventChipProps) {
return (
<button
type="button"
className={[
compact
? "calendar-event-chip is-compact"
: "calendar-event-chip",
dragging ? "is-dragging" : "",
linkedHover ? "is-linked-hover" : "",
]
.filter(Boolean)
.join(" ")}
style={calendarEventColorStyle(color)}
draggable={canDrag}
onClick={() => onSelect(event)}
onMouseEnter={onHover ? () => onHover(event.id) : undefined}
onMouseLeave={onHover ? () => onHover("") : undefined}
onFocus={onHover ? () => onHover(event.id) : undefined}
onBlur={onHover ? () => onHover("") : undefined}
onDragStart={
canDrag && onDragStart
? (dragEvent) => onDragStart(dragEvent, event)
: undefined
}
onDragEnd={canDrag ? onDragEnd : undefined}
title={i18nMessage(
"i18n:govoplan-calendar.edit_value.fad75899",
{ value0: event.summary },
)}
>
<EventInlineLabel event={event} />
</button>
);
}
export function EventInlineLabel({
event,
}: {
event: CalendarEvent;
}) {
return (
<span className="calendar-event-line">
<span className="calendar-event-time">
{eventTimeLabel(event)}
</span>
<span className="calendar-event-separator">-</span>
<strong>{event.summary}</strong>
</span>
);
}

View File

@@ -0,0 +1,889 @@
import type {
CSSProperties,
DragEvent as ReactDragEvent,
} from "react";
import type {
CalendarEvent,
CalendarEventDeltaResponse,
} from "../../api/calendar";
export type CalendarMode =
| "continuous"
| "month"
| "week"
| "workweek"
| "day";
export type Range = { start: Date; end: Date };
export type AgendaGroup = {
key: string;
date: Date;
events: CalendarEvent[];
};
export type ContinuousViewport = { scrollTop: number; height: number };
export type CalendarDayDropTarget = { kind: "day"; key: string };
export type CalendarTimeDropTarget = {
kind: "time";
key: string;
minuteOfDay: number;
};
export type CalendarDropTarget =
| CalendarDayDropTarget
| CalendarTimeDropTarget
| null;
export type CalendarDragAction =
| { kind: "move"; event: CalendarEvent }
| { kind: "resize-start"; event: CalendarEvent }
| { kind: "resize-end"; event: CalendarEvent };
export type CalendarResizeEdge = "start" | "end";
export type CalendarViewPreferences = {
dimWeekends: boolean;
dimOffHours: boolean;
workdayStartHour: number;
workdayEndHour: number;
continuousVirtualization: boolean;
continuousOverscanWeeks: number;
alternateContinuousMonths: boolean;
};
export type TimedEventLayoutItem = TimedEventSegment & {
column: number;
columns: number;
};
export type TimedOverflowLayoutItem = {
key: string;
top: number;
column: number;
columns: number;
events: CalendarEvent[];
};
type TimedEventSegment = {
event: CalendarEvent;
start: Date;
end: Date;
top: number;
height: number;
};
export const HOUR_ROW_HEIGHT = 64;
export const INITIAL_SCROLL_HOUR = 7;
export const CONTINUOUS_WEEK_ROW_HEIGHT = 92;
export const DEFAULT_CALENDAR_COLOR = "#5aa99b";
const MAX_TIMED_EVENT_COLUMNS = 3;
const CALENDAR_MODE_STORAGE_KEY = "govoplan.calendar.mode";
const CALENDAR_VIEW_PREFERENCES_STORAGE_KEY =
"govoplan.calendar.viewPreferences";
const DEFAULT_CALENDAR_VIEW_PREFERENCES: CalendarViewPreferences = {
dimWeekends: true,
dimOffHours: true,
workdayStartHour: 6,
workdayEndHour: 20,
continuousVirtualization: true,
continuousOverscanWeeks: 6,
alternateContinuousMonths: true,
};
export function rangeForMode(
mode: CalendarMode,
focusDate: Date,
continuousWeeks: { before: number; after: number },
): Range {
if (mode === "month") {
const start = startOfWeek(startOfMonth(focusDate));
return { start, end: addDays(start, 42) };
}
if (mode === "day") {
return {
start: startOfDay(focusDate),
end: addDays(startOfDay(focusDate), 1),
};
}
if (mode === "continuous") {
const start = addDays(
startOfWeek(focusDate),
-continuousWeeks.before * 7,
);
const end = addDays(
startOfWeek(focusDate),
continuousWeeks.after * 7,
);
return { start, end };
}
const start = startOfWeek(focusDate);
return { start, end: addDays(start, mode === "workweek" ? 5 : 7) };
}
export function daysForMode(
mode: CalendarMode,
focusDate: Date,
continuousWeeks: { before: number; after: number },
): Date[] {
const range = rangeForMode(mode, focusDate, continuousWeeks);
return daysBetween(range.start, range.end);
}
export function groupEventsByDay(
events: CalendarEvent[],
): Map<string, CalendarEvent[]> {
const grouped = new Map<string, CalendarEvent[]>();
for (const event of events) {
const start = new Date(event.start_at);
const end = event.end_at ? new Date(event.end_at) : start;
let cursor = startOfDay(start);
let last =
event.all_day && event.end_at
? addDays(startOfDay(end), -1)
: startOfDay(end);
if (last < cursor) last = cursor;
while (cursor <= last) {
const key = dayKey(cursor);
grouped.set(key, [...(grouped.get(key) ?? []), event]);
cursor = addDays(cursor, 1);
}
}
for (const [key, items] of grouped.entries()) {
grouped.set(
key,
items.sort(
(left, right) =>
left.start_at.localeCompare(right.start_at) ||
left.summary.localeCompare(right.summary),
),
);
}
return grouped;
}
export function agendaGroupsForDays(
days: Date[],
eventsByDay: Map<string, CalendarEvent[]>,
): AgendaGroup[] {
const groups: AgendaGroup[] = [];
for (const day of days) {
const key = dayKey(day);
const events = (eventsByDay.get(key) ?? [])
.slice()
.sort(compareAgendaEvents);
if (events.length > 0) groups.push({ key, date: day, events });
}
return groups;
}
export function eventTimeLabel(event: CalendarEvent): string {
if (event.all_day) return "i18n:govoplan-calendar.all_day.11457433";
const start = new Date(event.start_at);
const end = event.end_at ? new Date(event.end_at) : null;
return end ? `${timeLabel(start)}-${timeLabel(end)}` : timeLabel(start);
}
export function dateTimeLabel(value: Date): string {
return new Intl.DateTimeFormat(undefined, {
dateStyle: "medium",
timeStyle: "short",
}).format(value);
}
export function headingForMode(
mode: CalendarMode,
focusDate: Date,
range: Range,
): string {
if (mode === "day") return dayMonthYearLabel(focusDate);
if (mode === "month") return monthYearLabel(focusDate);
return `${dayMonthYearLabel(range.start)} - ${dayMonthYearLabel(
addDays(range.end, -1),
)}`;
}
export function chunk<T>(items: T[], size: number): T[][] {
const rows: T[][] = [];
for (let index = 0; index < items.length; index += size) {
rows.push(items.slice(index, index + size));
}
return rows;
}
export function startOfDay(value: Date): Date {
return new Date(value.getFullYear(), value.getMonth(), value.getDate());
}
export function startOfWeek(value: Date): Date {
const day = value.getDay() || 7;
return addDays(startOfDay(value), 1 - day);
}
export function startOfMonth(value: Date): Date {
return new Date(value.getFullYear(), value.getMonth(), 1);
}
export function addDays(value: Date, days: number): Date {
const next = new Date(value);
next.setDate(next.getDate() + days);
return next;
}
export function addMonths(value: Date, months: number): Date {
const next = new Date(value);
next.setMonth(next.getMonth() + months);
return next;
}
export function addHours(value: Date, hours: number): Date {
const next = new Date(value);
next.setHours(next.getHours() + hours);
return next;
}
export function addMinutes(value: Date, minutes: number): Date {
const next = new Date(value);
next.setMinutes(next.getMinutes() + minutes);
return next;
}
export function daysBetween(start: Date, end: Date): Date[] {
const days: Date[] = [];
for (
let cursor = startOfDay(start);
cursor < end;
cursor = addDays(cursor, 1)
) {
days.push(cursor);
}
return days;
}
export function dayKey(value: Date): string {
return toInputDate(value);
}
export function sameDay(left: Date, right: Date): boolean {
return (
left.getFullYear() === right.getFullYear() &&
left.getMonth() === right.getMonth() &&
left.getDate() === right.getDate()
);
}
export function sameMonth(left: Date, right: Date): boolean {
return (
left.getFullYear() === right.getFullYear() &&
left.getMonth() === right.getMonth()
);
}
export function toInputDate(value: Date): string {
return `${value.getFullYear()}-${String(value.getMonth() + 1).padStart(
2,
"0",
)}-${String(value.getDate()).padStart(2, "0")}`;
}
export function toInputTime(value: Date): string {
return `${String(value.getHours()).padStart(2, "0")}:${String(
value.getMinutes(),
).padStart(2, "0")}`;
}
export function localDateTime(date: string, time: string): Date {
return new Date(`${date}T${time || "00:00"}:00`);
}
export function loadCalendarViewPreferences(): CalendarViewPreferences {
if (typeof window === "undefined") return DEFAULT_CALENDAR_VIEW_PREFERENCES;
try {
const raw = window.localStorage.getItem(
CALENDAR_VIEW_PREFERENCES_STORAGE_KEY,
);
if (!raw) return DEFAULT_CALENDAR_VIEW_PREFERENCES;
const parsed = JSON.parse(raw) as Partial<CalendarViewPreferences>;
let workdayStartHour = clampNumber(
parsed.workdayStartHour,
0,
23,
DEFAULT_CALENDAR_VIEW_PREFERENCES.workdayStartHour,
);
let workdayEndHour = clampNumber(
parsed.workdayEndHour,
1,
24,
DEFAULT_CALENDAR_VIEW_PREFERENCES.workdayEndHour,
);
if (workdayEndHour <= workdayStartHour) {
workdayStartHour =
DEFAULT_CALENDAR_VIEW_PREFERENCES.workdayStartHour;
workdayEndHour = DEFAULT_CALENDAR_VIEW_PREFERENCES.workdayEndHour;
}
return {
dimWeekends:
typeof parsed.dimWeekends === "boolean"
? parsed.dimWeekends
: DEFAULT_CALENDAR_VIEW_PREFERENCES.dimWeekends,
dimOffHours:
typeof parsed.dimOffHours === "boolean"
? parsed.dimOffHours
: DEFAULT_CALENDAR_VIEW_PREFERENCES.dimOffHours,
workdayStartHour,
workdayEndHour,
continuousVirtualization:
typeof parsed.continuousVirtualization === "boolean"
? parsed.continuousVirtualization
: DEFAULT_CALENDAR_VIEW_PREFERENCES.continuousVirtualization,
continuousOverscanWeeks: clampNumber(
parsed.continuousOverscanWeeks,
2,
20,
DEFAULT_CALENDAR_VIEW_PREFERENCES.continuousOverscanWeeks,
),
alternateContinuousMonths:
typeof parsed.alternateContinuousMonths === "boolean"
? parsed.alternateContinuousMonths
: DEFAULT_CALENDAR_VIEW_PREFERENCES.alternateContinuousMonths,
};
} catch {
return DEFAULT_CALENDAR_VIEW_PREFERENCES;
}
}
export function loadCalendarMode(): CalendarMode {
if (typeof window === "undefined") return "continuous";
try {
const storedMode = window.localStorage.getItem(
CALENDAR_MODE_STORAGE_KEY,
);
return isCalendarMode(storedMode) ? storedMode : "continuous";
} catch {
return "continuous";
}
}
export function saveCalendarMode(mode: CalendarMode): void {
if (typeof window === "undefined") return;
try {
window.localStorage.setItem(CALENDAR_MODE_STORAGE_KEY, mode);
} catch {
// Storage can be unavailable in locked-down browsers.
}
}
export function normalizeHexColor(
value: string | null | undefined,
): string | null {
if (!value) return null;
const trimmed = value.trim();
if (/^#[0-9a-fA-F]{8}$/.test(trimmed)) {
return trimmed.slice(0, 7).toLowerCase();
}
return /^#[0-9a-fA-F]{6}$/.test(trimmed)
? trimmed.toLowerCase()
: null;
}
export function calendarEventColorStyle(
color: string | null | undefined,
): CSSProperties {
const normalizedColor =
normalizeHexColor(color) || DEFAULT_CALENDAR_COLOR;
return {
"--calendar-event-color": normalizedColor,
"--calendar-event-bg": mixHexWithWhite(normalizedColor, 0.88),
"--calendar-event-border": mixHexWithWhite(normalizedColor, 0.58),
} as CSSProperties;
}
export function continuousVirtualWindow(
weekCount: number,
viewport: ContinuousViewport,
overscanWeeks: number,
): {
start: number;
end: number;
topSpacerHeight: number;
bottomSpacerHeight: number;
} {
if (weekCount === 0) {
return {
start: 0,
end: 0,
topSpacerHeight: 0,
bottomSpacerHeight: 0,
};
}
const visibleStart = Math.floor(
viewport.scrollTop / CONTINUOUS_WEEK_ROW_HEIGHT,
);
const visibleCount = Math.max(
1,
Math.ceil(
(viewport.height || CONTINUOUS_WEEK_ROW_HEIGHT * 8) /
CONTINUOUS_WEEK_ROW_HEIGHT,
),
);
const start = Math.max(0, visibleStart - overscanWeeks);
const end = Math.min(
weekCount,
visibleStart + visibleCount + overscanWeeks,
);
return {
start,
end,
topSpacerHeight: start * CONTINUOUS_WEEK_ROW_HEIGHT,
bottomSpacerHeight: Math.max(
0,
(weekCount - end) * CONTINUOUS_WEEK_ROW_HEIGHT,
),
};
}
export function timeGridStyle(
columns: string,
preferences: CalendarViewPreferences,
): CSSProperties {
return {
gridTemplateColumns: columns,
"--calendar-work-start-y": `${
preferences.workdayStartHour * HOUR_ROW_HEIGHT
}px`,
"--calendar-work-end-y": `${
preferences.workdayEndHour * HOUR_ROW_HEIGHT
}px`,
"--calendar-off-hours-opacity": preferences.dimOffHours ? "1" : "0",
} as CSSProperties;
}
export function moveEventToDay(
event: CalendarEvent,
day: Date,
): { startAt: Date; endAt: Date | null; allDay: boolean } {
const start = new Date(event.start_at);
const end = event.end_at ? new Date(event.end_at) : null;
if (event.all_day) {
const durationDays = Math.max(
1,
Math.round(
daysBetweenCount(
startOfDay(start),
end ? startOfDay(end) : addDays(startOfDay(start), 1),
),
),
);
const startAt = startOfDay(day);
return {
startAt,
endAt: addDays(startAt, durationDays),
allDay: true,
};
}
const startAt = new Date(
day.getFullYear(),
day.getMonth(),
day.getDate(),
start.getHours(),
start.getMinutes(),
);
const durationMs = eventDurationMs(event, 60 * 60 * 1000);
return {
startAt,
endAt: new Date(startAt.getTime() + durationMs),
allDay: false,
};
}
export function moveEventToTime(
event: CalendarEvent,
day: Date,
minuteOfDay: number,
): { startAt: Date; endAt: Date | null; allDay: boolean } {
const snappedMinute = Math.min(
23 * 60 + 45,
Math.max(0, snapMinute(minuteOfDay)),
);
const startAt = dateAtMinuteOfDay(day, snappedMinute);
const durationMs = event.all_day
? 60 * 60 * 1000
: eventDurationMs(event, 60 * 60 * 1000);
return {
startAt,
endAt: new Date(startAt.getTime() + durationMs),
allDay: false,
};
}
export function resizeEventToTime(
event: CalendarEvent,
edge: CalendarResizeEdge,
day: Date,
minuteOfDay: number,
): { startAt: Date; endAt: Date | null; allDay: boolean } {
const target = dateAtMinuteOfDay(
day,
Math.min(23 * 60 + 45, Math.max(0, snapMinute(minuteOfDay))),
);
const currentStart = new Date(event.start_at);
const fallbackEnd = addMinutes(currentStart, 30);
const currentEnd =
event.end_at && new Date(event.end_at) > currentStart
? new Date(event.end_at)
: fallbackEnd;
const minimumDurationMs = 15 * 60_000;
if (edge === "start") {
const latestStart = new Date(
currentEnd.getTime() - minimumDurationMs,
);
return {
startAt: target < latestStart ? target : latestStart,
endAt: currentEnd,
allDay: false,
};
}
const earliestEnd = new Date(
currentStart.getTime() + minimumDurationMs,
);
return {
startAt: currentStart,
endAt: target > earliestEnd ? target : earliestEnd,
allDay: false,
};
}
export function dropMinuteOfDay(
event: ReactDragEvent<HTMLElement>,
): number {
const rect = event.currentTarget.getBoundingClientRect();
const offset = Math.min(
Math.max(event.clientY - rect.top, 0),
HOUR_ROW_HEIGHT * 24,
);
return Math.floor((offset / HOUR_ROW_HEIGHT) * 60);
}
export function dropSlotMinuteOfDay(
event: ReactDragEvent<HTMLElement>,
): number {
return Math.min(
23 * 60 + 45,
Math.max(0, snapMinute(dropMinuteOfDay(event))),
);
}
export function minuteOfDayLabel(value: number): string {
const minute = Math.min(23 * 60 + 45, Math.max(0, value));
return `${String(Math.floor(minute / 60)).padStart(2, "0")}:${String(
minute % 60,
).padStart(2, "0")}`;
}
export function isWeekend(value: Date): boolean {
const day = value.getDay();
return day === 0 || day === 6;
}
export function daysBetweenCount(start: Date, end: Date): number {
return Math.round(
(startOfDay(end).getTime() - startOfDay(start).getTime()) / 86_400_000,
);
}
export function addMinutesToTime(
time: string,
minutes: number,
): string {
const [hourPart, minutePart] = time.split(":");
const total = Math.min(
23 * 60 + 59,
Math.max(
0,
Number(hourPart || 0) * 60 + Number(minutePart || 0) + minutes,
),
);
return `${String(Math.floor(total / 60)).padStart(2, "0")}:${String(
total % 60,
).padStart(2, "0")}`;
}
export function layoutTimedEventsForDay(
events: CalendarEvent[],
day: Date,
): {
items: TimedEventLayoutItem[];
overflows: TimedOverflowLayoutItem[];
} {
const segments = timedSegmentsForDay(events, day);
const clusters = clusterTimedSegments(segments);
const items: TimedEventLayoutItem[] = [];
const overflows: TimedOverflowLayoutItem[] = [];
for (const cluster of clusters) {
const columnEnds: Date[] = [];
const assigned = cluster.map((segment) => {
let column = columnEnds.findIndex((end) => end <= segment.start);
if (column === -1) column = columnEnds.length;
columnEnds[column] = segment.end;
return { ...segment, column };
});
const totalColumns = Math.max(1, columnEnds.length);
const visibleColumns =
totalColumns > MAX_TIMED_EVENT_COLUMNS
? MAX_TIMED_EVENT_COLUMNS
: totalColumns;
const overflowColumn = MAX_TIMED_EVENT_COLUMNS - 1;
const hidden =
totalColumns > MAX_TIMED_EVENT_COLUMNS
? assigned.filter((item) => item.column >= overflowColumn)
: [];
for (const item of assigned) {
if (hidden.includes(item)) continue;
items.push({ ...item, columns: visibleColumns });
}
if (hidden.length > 0) {
overflows.push({
key: `${dayKey(day)}-${hidden[0].event.id}-overflow`,
top: Math.min(...hidden.map((item) => item.top)),
column: overflowColumn,
columns: MAX_TIMED_EVENT_COLUMNS,
events: hidden.map((item) => item.event),
});
}
}
return { items, overflows };
}
export function timedEventStyle(
item: TimedEventLayoutItem,
color?: string | null,
): CSSProperties {
return {
top: `${item.top}px`,
height: `${item.height}px`,
left: `calc(${(item.column * 100) / item.columns}% + 4px)`,
width: `calc(${100 / item.columns}% - 8px)`,
...calendarEventColorStyle(color),
};
}
export function timedOverflowStyle(
item: TimedOverflowLayoutItem,
): CSSProperties {
return {
top: `${item.top}px`,
left: `calc(${(item.column * 100) / item.columns}% + 4px)`,
width: `calc(${100 / item.columns}% - 8px)`,
};
}
export function monthYearLabel(value: Date): string {
return new Intl.DateTimeFormat(undefined, {
month: "long",
year: "numeric",
}).format(value);
}
export function dayMonthLabel(value: Date): string {
return new Intl.DateTimeFormat(undefined, {
day: "2-digit",
month: "short",
}).format(value);
}
export function dayMonthYearLabel(value: Date): string {
return new Intl.DateTimeFormat(undefined, {
day: "2-digit",
month: "short",
year: "numeric",
}).format(value);
}
export function agendaDateLabel(value: Date): string {
return new Intl.DateTimeFormat(undefined, {
weekday: "short",
day: "2-digit",
month: "short",
year: "numeric",
}).format(value);
}
export function weekdayLong(value: Date): string {
return new Intl.DateTimeFormat(undefined, {
weekday: "short",
}).format(value);
}
export function calendarDraftKey(value: unknown): string {
return JSON.stringify(value);
}
export function mergeCalendarEventDelta(
current: CalendarEvent[],
response: CalendarEventDeltaResponse,
): CalendarEvent[] {
if (response.full) {
return response.events.slice().sort(compareCalendarEvents);
}
const rows = new Map(current.map((event) => [event.id, event]));
for (const deleted of response.deleted) {
if (
!deleted.resource_type ||
deleted.resource_type === "calendar_event"
) {
rows.delete(deleted.id);
}
}
for (const event of response.events) rows.set(event.id, event);
return Array.from(rows.values()).sort(compareCalendarEvents);
}
export function errorText(error: unknown): string {
if (error instanceof Error) return error.message;
return "i18n:govoplan-calendar.calendar_request_failed.ae8a54f4";
}
function isCalendarMode(value: unknown): value is CalendarMode {
return (
value === "continuous" ||
value === "month" ||
value === "week" ||
value === "workweek" ||
value === "day"
);
}
function mixHexWithWhite(hex: string, whiteRatio: number): string {
const normalized = normalizeHexColor(hex) || DEFAULT_CALENDAR_COLOR;
const ratio = Math.min(1, Math.max(0, whiteRatio));
const red = Number.parseInt(normalized.slice(1, 3), 16);
const green = Number.parseInt(normalized.slice(3, 5), 16);
const blue = Number.parseInt(normalized.slice(5, 7), 16);
return `rgb(${Math.round(red * (1 - ratio) + 255 * ratio)}, ${Math.round(
green * (1 - ratio) + 255 * ratio,
)}, ${Math.round(blue * (1 - ratio) + 255 * ratio)})`;
}
function eventDurationMs(event: CalendarEvent, fallback: number): number {
if (!event.end_at) return fallback;
const start = new Date(event.start_at);
const end = new Date(event.end_at);
return Math.max(30 * 60 * 1000, end.getTime() - start.getTime());
}
function dateAtMinuteOfDay(day: Date, minuteOfDay: number): Date {
return new Date(
day.getFullYear(),
day.getMonth(),
day.getDate(),
Math.floor(minuteOfDay / 60),
minuteOfDay % 60,
);
}
function snapMinute(value: number): number {
return Math.round(value / 15) * 15;
}
function clampNumber(
value: unknown,
min: number,
max: number,
fallback: number,
): number {
if (typeof value !== "number" || Number.isNaN(value)) return fallback;
return Math.min(max, Math.max(min, Math.round(value)));
}
function timedSegmentsForDay(
events: CalendarEvent[],
day: Date,
): TimedEventSegment[] {
const dayStart = startOfDay(day);
const dayEnd = addDays(dayStart, 1);
return events
.filter((event) => !event.all_day)
.map((event): TimedEventSegment | null => {
const eventStart = new Date(event.start_at);
const rawEventEnd = event.end_at
? new Date(event.end_at)
: addMinutes(eventStart, 30);
const eventEnd =
rawEventEnd > eventStart ? rawEventEnd : addMinutes(eventStart, 30);
const start = eventStart < dayStart ? dayStart : eventStart;
const end = eventEnd > dayEnd ? dayEnd : eventEnd;
if (end <= start) return null;
const startMinutes = minutesBetween(dayStart, start);
const durationMinutes = minutesBetween(start, end);
return {
event,
start,
end,
top: Math.max(0, (startMinutes / 60) * HOUR_ROW_HEIGHT),
height: Math.max(
26,
(durationMinutes / 60) * HOUR_ROW_HEIGHT,
),
};
})
.filter(
(segment): segment is TimedEventSegment => segment !== null,
)
.sort(
(left, right) =>
left.start.getTime() - right.start.getTime() ||
right.end.getTime() - left.end.getTime(),
);
}
function clusterTimedSegments(
segments: TimedEventSegment[],
): TimedEventSegment[][] {
const clusters: TimedEventSegment[][] = [];
let current: TimedEventSegment[] = [];
let currentEnd: Date | null = null;
for (const segment of segments) {
if (!current.length || (currentEnd && segment.start < currentEnd)) {
current.push(segment);
if (!currentEnd || segment.end.getTime() > currentEnd.getTime()) {
currentEnd = segment.end;
}
continue;
}
clusters.push(current);
current = [segment];
currentEnd = segment.end;
}
if (current.length) clusters.push(current);
return clusters;
}
function minutesBetween(start: Date, end: Date): number {
return Math.max(0, (end.getTime() - start.getTime()) / 60_000);
}
function compareAgendaEvents(
left: CalendarEvent,
right: CalendarEvent,
): number {
if (left.all_day !== right.all_day) return left.all_day ? -1 : 1;
return (
left.start_at.localeCompare(right.start_at) ||
left.summary.localeCompare(right.summary)
);
}
function timeLabel(value: Date): string {
return new Intl.DateTimeFormat(undefined, {
hour: "2-digit",
minute: "2-digit",
}).format(value);
}
function compareCalendarEvents(
left: CalendarEvent,
right: CalendarEvent,
): number {
return (
new Date(left.start_at).getTime() -
new Date(right.start_at).getTime() ||
left.summary.localeCompare(right.summary) ||
left.id.localeCompare(right.id)
);
}

View File

@@ -0,0 +1,78 @@
import fs from "node:fs";
function assert(condition, message) {
if (!condition) throw new Error(message);
}
const page = fs.readFileSync(
new URL(
"../src/features/calendar/CalendarPage.tsx",
import.meta.url,
),
"utf8",
);
const views = fs.readFileSync(
new URL(
"../src/features/calendar/CalendarViews.tsx",
import.meta.url,
),
"utf8",
);
const model = fs.readFileSync(
new URL(
"../src/features/calendar/calendarViewModel.ts",
import.meta.url,
),
"utf8",
);
const collectionDialogs = fs.readFileSync(
new URL(
"../src/features/calendar/CalendarCollectionDialogs.tsx",
import.meta.url,
),
"utf8",
);
const eventDialog = fs.readFileSync(
new URL(
"../src/features/calendar/CalendarEventDialog.tsx",
import.meta.url,
),
"utf8",
);
assert(
page.includes('from "./CalendarViews"'),
"CalendarPage must compose the focused calendar view components",
);
assert(
!page.includes("function CalendarWeekRows("),
"CalendarPage must not own month/continuous rendering",
);
assert(
!page.includes("function CalendarTimeGrid("),
"CalendarPage must not own time-grid rendering",
);
assert(
views.includes("export function CalendarWeekRows(") &&
views.includes("export function CalendarTimeGrid("),
"CalendarViews must own both view families",
);
assert(
model.includes("export function layoutTimedEventsForDay(") &&
model.includes("export function continuousVirtualWindow("),
"calendar layout and virtualization must remain independently testable",
);
assert(
!page.includes("function CalendarCollectionDialog(") &&
!page.includes("function CalendarEventDialog("),
"CalendarPage must not own source or event form dialogs",
);
assert(
collectionDialogs.includes(
"export function CalendarCollectionDialog(",
) &&
eventDialog.includes("export function CalendarEventDialog("),
"calendar dialogs must remain focused components",
);
console.log("Calendar page decomposition checks passed.");

View File

@@ -0,0 +1,96 @@
import {
continuousVirtualWindow,
groupEventsByDay,
layoutTimedEventsForDay,
moveEventToDay,
rangeForMode,
resizeEventToTime,
} from "../src/features/calendar/calendarViewModel.ts";
function assert(condition: unknown, message: string): void {
if (!condition) throw new Error(message);
}
function event(
id: string,
start: Date,
end: Date,
allDay = false,
) {
return {
id,
calendar_id: "calendar-1",
summary: id,
start_at: start.toISOString(),
end_at: end.toISOString(),
all_day: allDay,
} as never;
}
const focus = new Date(2026, 6, 15, 12);
const month = rangeForMode("month", focus, {
before: 8,
after: 12,
});
assert(
(month.end.getTime() - month.start.getTime()) / 86_400_000 === 42,
"month view should retain its six-week window",
);
const virtual = continuousVirtualWindow(
100,
{ scrollTop: 20 * 92, height: 8 * 92 },
3,
);
assert(virtual.start === 17, "virtualization should retain overscan above");
assert(virtual.end === 31, "virtualization should retain overscan below");
assert(
virtual.topSpacerHeight + virtual.bottomSpacerHeight > 0,
"virtualization should preserve offscreen geometry",
);
const day = new Date(2026, 6, 7);
const parallel = [0, 1, 2, 3].map((index) =>
event(
`event-${index}`,
new Date(2026, 6, 7, 9),
new Date(2026, 6, 7, 10),
),
);
const layout = layoutTimedEventsForDay(parallel, day);
assert(
layout.items.length === 2 && layout.overflows.length === 1,
"parallel events should use bounded columns plus one overflow control",
);
assert(
layout.overflows[0].events.length === 2,
"overflow should retain every hidden event",
);
const allDay = event(
"all-day",
new Date(2026, 6, 7),
new Date(2026, 6, 9),
true,
);
const grouped = groupEventsByDay([allDay]);
assert(grouped.size === 2, "all-day end dates should remain exclusive");
const moved = moveEventToDay(allDay, new Date(2026, 6, 20));
assert(
moved.endAt?.getDate() === 22,
"moving an all-day event should preserve its duration",
);
const timed = event(
"timed",
new Date(2026, 6, 7, 10),
new Date(2026, 6, 7, 11),
);
const resized = resizeEventToTime(timed, "end", day, 9 * 60);
assert(
resized.endAt !== null &&
resized.endAt.getTime() > resized.startAt.getTime(),
"resizing must not invert an event",
);
console.log("Calendar view model interaction checks passed.");

View File

@@ -0,0 +1,38 @@
{
"compilerOptions": {
"target": "ES2020",
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"strict": true,
"skipLibCheck": true,
"esModuleInterop": true,
"allowSyntheticDefaultImports": true,
"module": "ESNext",
"moduleResolution": "Bundler",
"noEmit": true,
"jsx": "react-jsx",
"baseUrl": ".",
"paths": {
"@govoplan/core-webui": [
"../../govoplan-core/webui/src/index.ts"
],
"lucide-react": [
"../../govoplan-core/webui/node_modules/lucide-react/dist/lucide-react.d.ts"
],
"react": [
"../../govoplan-core/webui/node_modules/@types/react/index.d.ts"
],
"react/jsx-runtime": [
"../../govoplan-core/webui/node_modules/@types/react/jsx-runtime.d.ts"
]
}
},
"include": [
"../../govoplan-core/webui/src/vite-env.d.ts",
"src/api/calendar.ts",
"src/features/calendar/CalendarPage.tsx",
"src/features/calendar/CalendarViews.tsx",
"src/features/calendar/CalendarCollectionDialogs.tsx",
"src/features/calendar/CalendarEventDialog.tsx",
"src/features/calendar/calendarViewModel.ts"
]
}