diff --git a/docs/INTERFACE_PATTERN_MIGRATION.md b/docs/INTERFACE_PATTERN_MIGRATION.md new file mode 100644 index 0000000..e558883 --- /dev/null +++ b/docs/INTERFACE_PATTERN_MIGRATION.md @@ -0,0 +1,24 @@ +# Calendar interface pattern migration + +Calendar uses the platform workspace pattern without changing ownership of calendar or synchronization state. + +## Surfaces + +- `calendar.page` is the route-level workspace. +- `calendar.page.sidebar` contains calendar visibility and collection actions; `calendar.page.agenda` is its event list. +- `calendar.page.workspace` contains continuous, month, week, workweek, and day views. +- `calendar.event-editor` owns event create, edit, occurrence/series selection, and deletion confirmation. +- `calendar.collection-editor` owns local and external collection configuration. Its `calendar.sync-status`, `calendar.outbox`, and `calendar.migration` children expose synchronization state and recovery. +- `calendar.settings.preferences` and `calendar.widget.upcoming` remain composed Settings and Dashboard surfaces. + +The backend and WebUI manifests publish the same identifiers and parent hierarchy so Views can filter the route and contributed surfaces consistently. + +## Consequences and recovery + +Event and collection drafts use the shared unsaved-change guard. A write that completes but whose refresh fails is not repeated blindly by Calendar synchronization workers; durable outbox and migration state remain the source of recovery evidence. Event deletion uses the shared confirmation dialog. Collection removal and destructive remote moves keep their specialized confirmation because they must expose event counts, transfer choices, authorization text, and evidence. + +Unavailable consequential actions remain visible where possible and explain the missing permission, input, active write, or migration lock. Contextual help resolves through `govoplan-docs` when installed and otherwise uses the hosted documentation fallback. + +## Optional boundaries + +Calendar does not import optional Mail, Campaign, Scheduling, Notifications, Connectors, Audit, or Ops implementations. Integrations continue through declared capabilities, interfaces, and stable references. Local calendars and the Calendar route remain usable without those optional modules. diff --git a/src/govoplan_calendar/backend/manifest.py b/src/govoplan_calendar/backend/manifest.py index 401a430..01f2b1f 100644 --- a/src/govoplan_calendar/backend/manifest.py +++ b/src/govoplan_calendar/backend/manifest.py @@ -545,7 +545,23 @@ manifest = ModuleManifest( documentation_types=("user",), audience=("user", "calendar_manager"), related_modules=("scheduling", "notifications"), - metadata={"kind": "reference"}, + metadata={ + "kind": "reference", + "help_contexts": [ + "calendar.page", + "calendar.page.sidebar", + "calendar.page.agenda", + "calendar.page.workspace", + "calendar.event-editor", + "calendar.settings.preferences", + "calendar.widget.upcoming", + "calendar.state.read-only", + ], + "consequence_classes": { + "save_event": "create or update the authoritative event and queue synchronized writes when required", + "delete_event": "delete the selected occurrence or series and queue synchronized deletion when required", + }, + }, ), DocumentationTopic( id="calendar.external-sources-and-sync", @@ -555,7 +571,22 @@ manifest = ModuleManifest( documentation_types=("admin", "user"), audience=("user", "calendar_manager", "operator"), related_modules=("connectors", "audit", "ops"), - metadata={"kind": "reference"}, + metadata={ + "kind": "reference", + "help_contexts": [ + "calendar.collection-editor", + "calendar.sync-status", + "calendar.migration", + "calendar.state.source-admin-required", + ], + "consequence_classes": { + "change_collection": "change calendar identity, source configuration, credentials, and synchronization policy", + "delete_or_remove_collection": "delete a local calendar or remove an external source after explicit event handling", + "synchronize_source": "read and, where configured, write remote state using bounded synchronization evidence", + "force_full_sync": "re-read the complete remote source and reconcile it against local state", + "execute_remote_move": "copy all destination resources before conditionally deleting source resources", + }, + }, ), DocumentationTopic( id="calendar.campaign-invitations-and-replies", @@ -593,7 +624,16 @@ manifest = ModuleManifest( documentation_types=("admin", "user"), audience=("calendar_manager", "operator", "tenant_admin"), related_modules=("ops", "audit"), - metadata={"kind": "runbook"}, + metadata={ + "kind": "runbook", + "help_contexts": [ + "calendar.outbox", + "calendar.state.outbox-recovery", + ], + "consequence_classes": { + "reconcile_outbox": "compare the latest desired generation with remote state before retry or discard", + }, + }, ), ), capability_factories={ @@ -616,6 +656,84 @@ manifest = ModuleManifest( ), nav_items=(NavItem(path="/calendar", label="Calendar", icon="calendar", required_any=("calendar:event:read",), order=55),), view_surfaces=( + ViewSurface( + id="calendar.navigation", + module_id="calendar", + kind="navigation", + label="Calendar navigation", + order=10, + ), + ViewSurface( + id="calendar.page", + module_id="calendar", + kind="route", + label="Calendar workspace", + order=20, + ), + ViewSurface( + id="calendar.page.sidebar", + module_id="calendar", + kind="section", + label="Calendar list", + parent_id="calendar.page", + order=10, + ), + ViewSurface( + id="calendar.page.agenda", + module_id="calendar", + kind="section", + label="Calendar agenda", + parent_id="calendar.page.sidebar", + order=20, + ), + ViewSurface( + id="calendar.page.workspace", + module_id="calendar", + kind="section", + label="Calendar view", + parent_id="calendar.page", + order=30, + ), + ViewSurface( + id="calendar.event-editor", + module_id="calendar", + kind="action", + label="Event editor", + parent_id="calendar.page", + order=40, + ), + ViewSurface( + id="calendar.collection-editor", + module_id="calendar", + kind="action", + label="Calendar source editor", + parent_id="calendar.page.sidebar", + order=50, + ), + ViewSurface( + id="calendar.sync-status", + module_id="calendar", + kind="section", + label="Calendar synchronization status", + parent_id="calendar.collection-editor", + order=60, + ), + ViewSurface( + id="calendar.outbox", + module_id="calendar", + kind="action", + label="Outbound calendar changes", + parent_id="calendar.sync-status", + order=70, + ), + ViewSurface( + id="calendar.migration", + module_id="calendar", + kind="action", + label="Remote calendar move", + parent_id="calendar.sync-status", + order=80, + ), ViewSurface( id="calendar.widget.upcoming", module_id="calendar", diff --git a/tests/test_interface_documentation_contract.py b/tests/test_interface_documentation_contract.py new file mode 100644 index 0000000..7994f5d --- /dev/null +++ b/tests/test_interface_documentation_contract.py @@ -0,0 +1,70 @@ +from __future__ import annotations + +from pathlib import Path +import unittest + +from govoplan_calendar.backend.manifest import get_manifest + + +REPO_ROOT = Path(__file__).resolve().parents[1] + + +class CalendarInterfaceDocumentationContractTests(unittest.TestCase): + def test_backend_surfaces_and_hierarchy_remain_declared(self) -> None: + frontend = get_manifest().frontend + self.assertIsNotNone(frontend) + surfaces = {item.id: item for item in frontend.view_surfaces} # type: ignore[union-attr] + expected = { + "calendar.navigation", + "calendar.page", + "calendar.page.sidebar", + "calendar.page.agenda", + "calendar.page.workspace", + "calendar.event-editor", + "calendar.collection-editor", + "calendar.sync-status", + "calendar.outbox", + "calendar.migration", + "calendar.settings.preferences", + "calendar.widget.upcoming", + } + self.assertEqual(expected, set(surfaces)) + self.assertEqual("calendar.page", surfaces["calendar.page.sidebar"].parent_id) + self.assertEqual("calendar.page.sidebar", surfaces["calendar.page.agenda"].parent_id) + self.assertEqual("calendar.page", surfaces["calendar.page.workspace"].parent_id) + self.assertEqual("calendar.page", surfaces["calendar.event-editor"].parent_id) + self.assertEqual("calendar.page.sidebar", surfaces["calendar.collection-editor"].parent_id) + self.assertEqual("calendar.collection-editor", surfaces["calendar.sync-status"].parent_id) + self.assertEqual("calendar.sync-status", surfaces["calendar.outbox"].parent_id) + self.assertEqual("calendar.sync-status", surfaces["calendar.migration"].parent_id) + + def test_help_and_consequence_metadata_remain_published(self) -> None: + topics = {topic.id: topic for topic in get_manifest().documentation} + events = topics["calendar.manage-calendars-and-events"] + sources = topics["calendar.external-sources-and-sync"] + recovery = topics["calendar.outbound-change-recovery"] + + self.assertIn("calendar.event-editor", events.metadata["help_contexts"]) + self.assertIn("save_event", events.metadata["consequence_classes"]) + self.assertIn("delete_event", events.metadata["consequence_classes"]) + self.assertIn("calendar.collection-editor", sources.metadata["help_contexts"]) + self.assertIn("force_full_sync", sources.metadata["consequence_classes"]) + self.assertIn("execute_remote_move", sources.metadata["consequence_classes"]) + self.assertIn("calendar.outbox", recovery.metadata["help_contexts"]) + self.assertIn("reconcile_outbox", recovery.metadata["consequence_classes"]) + + def test_webui_uses_shared_help_guard_and_confirmation_components(self) -> None: + event_dialog = (REPO_ROOT / "webui/src/features/calendar/CalendarEventDialog.tsx").read_text(encoding="utf-8") + collection_dialog = (REPO_ROOT / "webui/src/features/calendar/CalendarCollectionDialogs.tsx").read_text(encoding="utf-8") + settings_panel = (REPO_ROOT / "webui/src/features/calendar/CalendarSettingsPanel.tsx").read_text(encoding="utf-8") + + for component in ("ActionBlockerHint", "ConfirmDialog", "DocumentationHelpLink", "useUnsavedDraftGuard"): + self.assertIn(component, event_dialog) + for component in ("ActionBlockerHint", "DocumentationHelpLink", "useUnsavedDraftGuard"): + self.assertIn(component, collection_dialog) + for component in ("DocumentationHelpLink", "useUnsavedDraftGuard"): + self.assertIn(component, settings_panel) + + +if __name__ == "__main__": + unittest.main() diff --git a/webui/src/features/calendar/CalendarCollectionDialogs.tsx b/webui/src/features/calendar/CalendarCollectionDialogs.tsx index 99be022..33fbba0 100644 --- a/webui/src/features/calendar/CalendarCollectionDialogs.tsx +++ b/webui/src/features/calendar/CalendarCollectionDialogs.tsx @@ -7,9 +7,11 @@ import { } from "react"; import { ArrowRightLeft, ListChecks, RefreshCw, Trash2 } from "lucide-react"; import { + ActionBlockerHint, Button, ColorPickerField, Dialog, + DocumentationHelpLink, PasswordField, SegmentedControl, ToggleSwitch, @@ -41,6 +43,10 @@ import { errorText, normalizeHexColor, } from "./calendarViewModel"; +import { + CALENDAR_I18N, + CALENDAR_SOURCE_DOCUMENTATION, +} from "./interfacePatterns"; export type CalendarSourceMode = "local" | CalendarSyncSourceKind | "open_xchange"; type CalendarSourceSwitchMode = @@ -161,12 +167,18 @@ export function CalendarCollectionDialog({ !isExistingSyncSource && !canEditSource || canEditSource && (!effectiveCollectionUrl || effectiveAuthType === "basic" && !username.trim() || needsSourceSecret)); - const saveDisabled = - saving || - migrationLocked || - !canWrite || - !name.trim() || - sourceDetailsInvalid; + const saveDisabledReason = saving + ? CALENDAR_I18N.saving + : migrationLocked + ? CALENDAR_I18N.migrationLocked + : !canWrite + ? CALENDAR_I18N.calendarWriteRequired + : !name.trim() + ? CALENDAR_I18N.calendarNameRequired + : sourceDetailsInvalid + ? CALENDAR_I18N.sourceDetailsRequired + : undefined; + const saveDisabled = Boolean(saveDisabledReason); const syncing = source ? syncingSourceId === source.id : false; @@ -348,25 +360,41 @@ export function CalendarCollectionDialog({ <>
{calendar && canDelete && - }
- +
}>
- {migrationLocked && -

- Calendar and event changes are locked while the destructive remote move is being reconciled. -

- } - {sourceMode !== "local" && !canManageSources &&

i18n:govoplan-calendar.managing_sync_sources_requires_calendar_administ.835e29fa

} +
+ +
+ {migrationLocked && ( + + )} + {sourceMode !== "local" && !canManageSources && ( + + )} {!isEdit && {calendarSourceUsesCalDav(sourceMode) && - } @@ -562,11 +590,12 @@ export function CalendarCollectionDialog({ type="button" className={syncing ? "calendar-sync-button is-syncing" : "calendar-sync-button"} onClick={() => void onSync(source, syncTransientPayload(effectiveAuthType, password, bearerToken))} - disabled={saving || syncing || migrationLocked || !canSyncSources}> + disabled={saving || syncing || migrationLocked || !canSyncSources} + disabledReason={saving ? CALENDAR_I18N.saving : syncing ? CALENDAR_I18N.syncing : migrationLocked ? CALENDAR_I18N.migrationLocked : !canSyncSources ? CALENDAR_I18N.syncPermissionRequired : undefined}> {syncing ? "i18n:govoplan-calendar.syncing.e5c7727a" : "i18n:govoplan-calendar.sync_now.2b7d938e"} - {source.source_kind === "caldav" && canManageSources && ( @@ -660,6 +689,9 @@ export function CalendarCollectionDeleteDialog({ }>
+
+ +

{loadingEventCount ? "i18n:govoplan-calendar.loading_event_count.716ad3c2" : diff --git a/webui/src/features/calendar/CalendarEventDialog.tsx b/webui/src/features/calendar/CalendarEventDialog.tsx index e56c882..b12c81f 100644 --- a/webui/src/features/calendar/CalendarEventDialog.tsx +++ b/webui/src/features/calendar/CalendarEventDialog.tsx @@ -5,12 +5,16 @@ import { } from "react"; import { Trash2 } from "lucide-react"; import { + ActionBlockerHint, Button, + ConfirmDialog, DateField, Dialog, + DocumentationHelpLink, SegmentedControl, TimeField, ToggleSwitch, + i18nMessage, useUnsavedDraftGuard, } from "@govoplan/core-webui"; import type { @@ -29,6 +33,10 @@ import { toInputDate, toInputTime, } from "./calendarViewModel"; +import { + CALENDAR_DOCUMENTATION, + CALENDAR_I18N, +} from "./interfacePatterns"; type CalendarEventEndMode = "end" | "duration"; type CalendarEventEditScope = "occurrence" | "series"; @@ -160,6 +168,13 @@ export function CalendarEventDialog({ }; const initialEventDraftKey = useMemo(() => calendarDraftKey(eventDraft), []); const eventDirty = calendarDraftKey(eventDraft) !== initialEventDraftKey; + const saveDisabledReason = saving + ? CALENDAR_I18N.saving + : !canWrite + ? CALENDAR_I18N.readOnly + : !summary.trim() + ? CALENDAR_I18N.eventTitleRequired + : undefined; useUnsavedDraftGuard({ dirty: eventDirty, @@ -258,14 +273,11 @@ export function CalendarEventDialog({ function requestDelete() { if (!event) return; - if (!confirmingDelete) { - setConfirmingDelete(true); - return; - } - void onDelete(event, editScope); + setConfirmingDelete(true); } return ( + <>

{event && canDelete && - }
- +
}> +
+ +
+ {!canWrite && ( + + )} {canChooseSeries && ( -
); + + setConfirmingDelete(false)} + onConfirm={() => event && void onDelete(event, editScope)} + /> + ); } diff --git a/webui/src/features/calendar/CalendarMigrationDialog.tsx b/webui/src/features/calendar/CalendarMigrationDialog.tsx index bbf1889..58e4acf 100644 --- a/webui/src/features/calendar/CalendarMigrationDialog.tsx +++ b/webui/src/features/calendar/CalendarMigrationDialog.tsx @@ -4,6 +4,7 @@ import { Button, Dialog, DismissibleAlert, + DocumentationHelpLink, LoadingFrame, StatusBadge, type ApiSettings, @@ -14,6 +15,10 @@ import { type CalendarMigrationBatch, } from "../../api/calendar"; import { dateTimeLabel, errorText } from "./calendarViewModel"; +import { + CALENDAR_I18N, + CALENDAR_SOURCE_DOCUMENTATION, +} from "./interfacePatterns"; export function CalendarMigrationDialog({ settings, @@ -107,6 +112,7 @@ export function CalendarMigrationDialog({
) : (
+ {error && ( {error} @@ -166,6 +172,7 @@ export function CalendarMigrationDialog({ variant="danger" onClick={() => void cancelMigration()} disabled={working || cancellationEvidence.trim().length < 10} + disabledReason={working ? CALENDAR_I18N.saving : cancellationEvidence.trim().length < 10 ? CALENDAR_I18N.cancellationEvidenceRequired : undefined} > Cancel remote move diff --git a/webui/src/features/calendar/CalendarOutboxDialog.tsx b/webui/src/features/calendar/CalendarOutboxDialog.tsx index 6bcc577..1fb5cb3 100644 --- a/webui/src/features/calendar/CalendarOutboxDialog.tsx +++ b/webui/src/features/calendar/CalendarOutboxDialog.tsx @@ -1,10 +1,12 @@ import { useCallback, useEffect, useMemo, useState } from "react"; import { RefreshCw, RotateCcw, ScanSearch, Trash2 } from "lucide-react"; import { + ActionBlockerHint, Button, ConfirmDialog, Dialog, DismissibleAlert, + DocumentationHelpLink, SegmentedControl, StatusBadge, type ApiSettings, @@ -17,6 +19,10 @@ import { type CalendarSyncSource, } from "../../api/calendar"; import { dateTimeLabel, errorText } from "./calendarViewModel"; +import { + CALENDAR_I18N, + CALENDAR_RECOVERY_DOCUMENTATION, +} from "./interfacePatterns"; type OutboxFilter = "unresolved" | "all"; type RecoveryAction = "retry" | "reconcile" | "discard"; @@ -94,7 +100,7 @@ export function CalendarOutboxDialog({ onClose={onClose} footer={ <> - )} {available.includes("reconcile") && ( - )} {available.includes("discard") && ( - )}
)} - {!available.length && unavailableReason &&

{unavailableReason}

} + {!available.length && unavailableReason && ( + + )}
); } diff --git a/webui/src/features/calendar/CalendarPage.tsx b/webui/src/features/calendar/CalendarPage.tsx index b50fa2d..51f4413 100644 --- a/webui/src/features/calendar/CalendarPage.tsx +++ b/webui/src/features/calendar/CalendarPage.tsx @@ -12,6 +12,7 @@ import { AdminIconButton, Button, DismissibleAlert, + DocumentationHelpLink, LoadingFrame, SegmentedControl, TableActionGroup, @@ -98,6 +99,10 @@ import { type CalendarViewPreferences, type ContinuousViewport, } from "./calendarViewModel"; +import { + CALENDAR_DOCUMENTATION, + CALENDAR_I18N, +} from "./interfacePatterns"; type EventEditScope = "occurrence" | "series"; type EventDialogState = @@ -797,7 +802,8 @@ export default function CalendarPage({ settings, auth }: {settings: ApiSettings; label: syncing ? i18nMessage("i18n:govoplan-calendar.syncing_value.ca80b487", { value0: calendar.name }) : i18nMessage("i18n:govoplan-calendar.sync_value.72e4ba66", { value0: calendar.name }), icon: , onClick: () => void handleSyncSource(source), - disabled: saving || syncing + disabled: saving || syncing, + disabledReason: saving || syncing ? CALENDAR_I18N.saving : undefined }, (canManageCalendars || canDeleteCalendars) && { id: "edit", @@ -815,7 +821,7 @@ export default function CalendarPage({ settings, auth }: {settings: ApiSettings; } {canManageCalendars &&
-
@@ -889,13 +895,14 @@ export default function CalendarPage({ settings, auth }: {settings: ApiSettings;
+ } onClick={() => void loadEvents()} /> {canWrite && - } diff --git a/webui/src/features/calendar/CalendarSettingsPanel.tsx b/webui/src/features/calendar/CalendarSettingsPanel.tsx index 3e26255..92245d2 100644 --- a/webui/src/features/calendar/CalendarSettingsPanel.tsx +++ b/webui/src/features/calendar/CalendarSettingsPanel.tsx @@ -4,8 +4,10 @@ import { Button, Card, DismissibleAlert, + DocumentationHelpLink, FormField, ToggleSwitch, + useUnsavedDraftGuard, type ApiSettings, type AuthInfo } from "@govoplan/core-webui"; @@ -14,6 +16,10 @@ import { updateCalendarViewPreferences, type CalendarViewPreferences } from "../../api/calendar"; +import { + CALENDAR_DOCUMENTATION, + CALENDAR_I18N, +} from "./interfacePatterns"; type CalendarPreferenceDraft = Pick< CalendarViewPreferences, @@ -74,11 +80,11 @@ export default function CalendarSettingsPanel({ } } - async function savePreferences() { + async function savePreferences(): Promise { if (draft.workday_end_hour <= draft.workday_start_hour) { setTone("warning"); setMessage("Workday end must be after workday start."); - return; + return false; } setSaving(true); setMessage(""); @@ -92,17 +98,36 @@ export default function CalendarSettingsPanel({ window.dispatchEvent( new CustomEvent("govoplan:calendar-preferences-changed") ); + return true; } catch (error) { setTone("warning"); setMessage(errorText(error)); + return false; } finally { setSaving(false); } } const disabled = loading || saving; + const saveDisabledReason = loading + ? CALENDAR_I18N.loading + : saving + ? CALENDAR_I18N.saving + : !dirty + ? CALENDAR_I18N.noChanges + : undefined; + + useUnsavedDraftGuard({ + dirty, + onSave: savePreferences, + onDiscard: () => setDraft(loaded ?? FALLBACK_DRAFT) + }); + return (
+
+ +
void savePreferences()} > {saving ? "Saving" : "Save preferences"} diff --git a/webui/src/features/calendar/interfacePatterns.ts b/webui/src/features/calendar/interfacePatterns.ts new file mode 100644 index 0000000..03a2694 --- /dev/null +++ b/webui/src/features/calendar/interfacePatterns.ts @@ -0,0 +1,33 @@ +import type { DocumentationHelpReference } from "@govoplan/core-webui"; + +export const CALENDAR_DOCUMENTATION = { + topicId: "calendar.manage-calendars-and-events", + documentationType: "user" +} satisfies DocumentationHelpReference; + +export const CALENDAR_SOURCE_DOCUMENTATION = { + topicId: "calendar.external-sources-and-sync", + documentationType: "admin" +} satisfies DocumentationHelpReference; + +export const CALENDAR_RECOVERY_DOCUMENTATION = { + topicId: "calendar.outbound-change-recovery", + documentationType: "admin" +} satisfies DocumentationHelpReference; + +export const CALENDAR_I18N = { + loading: "i18n:govoplan-calendar.reason.loading", + saving: "i18n:govoplan-calendar.reason.saving", + syncing: "i18n:govoplan-calendar.reason.syncing", + readOnly: "i18n:govoplan-calendar.reason.read_only", + calendarWriteRequired: "i18n:govoplan-calendar.reason.calendar_write_required", + calendarAdminRequired: "i18n:govoplan-calendar.reason.calendar_admin_required", + syncPermissionRequired: "i18n:govoplan-calendar.reason.sync_permission_required", + migrationLocked: "i18n:govoplan-calendar.reason.migration_locked", + targetCalendarRequired: "i18n:govoplan-calendar.reason.target_calendar_required", + eventTitleRequired: "i18n:govoplan-calendar.reason.event_title_required", + calendarNameRequired: "i18n:govoplan-calendar.reason.calendar_name_required", + sourceDetailsRequired: "i18n:govoplan-calendar.reason.source_details_required", + noChanges: "i18n:govoplan-calendar.reason.no_changes", + cancellationEvidenceRequired: "i18n:govoplan-calendar.reason.cancellation_evidence_required" +} as const; diff --git a/webui/src/i18n/generatedTranslations.ts b/webui/src/i18n/generatedTranslations.ts index db86aa2..c51698b 100644 --- a/webui/src/i18n/generatedTranslations.ts +++ b/webui/src/i18n/generatedTranslations.ts @@ -2,6 +2,33 @@ import type { PlatformTranslations } from "@govoplan/core-webui"; export const generatedTranslations: PlatformTranslations = { "en": { + "i18n:govoplan-calendar.surface.navigation": "Calendar navigation", + "i18n:govoplan-calendar.surface.page": "Calendar workspace", + "i18n:govoplan-calendar.surface.sidebar": "Calendar list", + "i18n:govoplan-calendar.surface.agenda": "Agenda", + "i18n:govoplan-calendar.surface.workspace": "Calendar view", + "i18n:govoplan-calendar.surface.event_editor": "Event editor", + "i18n:govoplan-calendar.surface.collection_editor": "Calendar source editor", + "i18n:govoplan-calendar.surface.sync_status": "Synchronization status", + "i18n:govoplan-calendar.surface.outbox": "Outbound changes", + "i18n:govoplan-calendar.surface.migration": "Remote calendar move", + "i18n:govoplan-calendar.reason.loading": "Calendar data is loading.", + "i18n:govoplan-calendar.reason.saving": "A calendar change is already being saved.", + "i18n:govoplan-calendar.reason.syncing": "This calendar is already synchronizing.", + "i18n:govoplan-calendar.reason.read_only": "You can view this calendar, but your account cannot change events.", + "i18n:govoplan-calendar.reason.calendar_write_required": "Calendar write permission is required.", + "i18n:govoplan-calendar.reason.calendar_admin_required": "Calendar administration permission is required.", + "i18n:govoplan-calendar.reason.sync_permission_required": "Calendar import permission is required to synchronize this source.", + "i18n:govoplan-calendar.reason.migration_locked": "Calendar changes are locked while the remote move is active or unresolved.", + "i18n:govoplan-calendar.reason.target_calendar_required": "Create or select a calendar before adding an event.", + "i18n:govoplan-calendar.reason.event_title_required": "Enter an event title before saving.", + "i18n:govoplan-calendar.reason.calendar_name_required": "Enter a calendar name before saving.", + "i18n:govoplan-calendar.reason.source_details_required": "Complete the required source URL and credential fields before saving.", + "i18n:govoplan-calendar.reason.no_changes": "There are no unsaved changes.", + "i18n:govoplan-calendar.reason.cancellation_evidence_required": "Record at least 10 characters of cancellation evidence.", + "i18n:govoplan-calendar.delete_event_title": "Delete event?", + "i18n:govoplan-calendar.delete_event_occurrence_message": "Delete the selected occurrence of {value0}? A remote deletion is queued when the calendar is synchronized.", + "i18n:govoplan-calendar.delete_event_series_message": "Delete the whole series {value0}? A remote deletion is queued when the calendar is synchronized.", "i18n:govoplan-calendar.add_calendar.124c55eb": "Add calendar...", "i18n:govoplan-calendar.add_calendar.8fadb5bc": "Add calendar", "i18n:govoplan-calendar.add.61cc55aa": "Add", @@ -213,6 +240,33 @@ export const generatedTranslations: PlatformTranslations = { "i18n:govoplan-calendar.workweek.2fef6ea4": "Workweek" }, "de": { + "i18n:govoplan-calendar.surface.navigation": "Kalendernavigation", + "i18n:govoplan-calendar.surface.page": "Kalenderarbeitsbereich", + "i18n:govoplan-calendar.surface.sidebar": "Kalenderliste", + "i18n:govoplan-calendar.surface.agenda": "Agenda", + "i18n:govoplan-calendar.surface.workspace": "Kalenderansicht", + "i18n:govoplan-calendar.surface.event_editor": "Termineditor", + "i18n:govoplan-calendar.surface.collection_editor": "Kalenderquellen bearbeiten", + "i18n:govoplan-calendar.surface.sync_status": "Synchronisierungsstatus", + "i18n:govoplan-calendar.surface.outbox": "Ausgehende Änderungen", + "i18n:govoplan-calendar.surface.migration": "Entfernten Kalender verschieben", + "i18n:govoplan-calendar.reason.loading": "Kalenderdaten werden geladen.", + "i18n:govoplan-calendar.reason.saving": "Eine Kalenderänderung wird bereits gespeichert.", + "i18n:govoplan-calendar.reason.syncing": "Dieser Kalender wird bereits synchronisiert.", + "i18n:govoplan-calendar.reason.read_only": "Sie können diesen Kalender anzeigen, aber Ihr Konto darf Termine nicht ändern.", + "i18n:govoplan-calendar.reason.calendar_write_required": "Die Berechtigung zum Bearbeiten von Kalendern ist erforderlich.", + "i18n:govoplan-calendar.reason.calendar_admin_required": "Die Berechtigung zur Kalenderverwaltung ist erforderlich.", + "i18n:govoplan-calendar.reason.sync_permission_required": "Zum Synchronisieren dieser Quelle ist die Kalender-Importberechtigung erforderlich.", + "i18n:govoplan-calendar.reason.migration_locked": "Kalenderänderungen sind gesperrt, solange die entfernte Verschiebung aktiv oder ungeklärt ist.", + "i18n:govoplan-calendar.reason.target_calendar_required": "Erstellen oder wählen Sie einen Kalender, bevor Sie einen Termin hinzufügen.", + "i18n:govoplan-calendar.reason.event_title_required": "Geben Sie vor dem Speichern einen Termintitel ein.", + "i18n:govoplan-calendar.reason.calendar_name_required": "Geben Sie vor dem Speichern einen Kalendernamen ein.", + "i18n:govoplan-calendar.reason.source_details_required": "Vervollständigen Sie vor dem Speichern die erforderlichen Quellen- und Zugangsdaten.", + "i18n:govoplan-calendar.reason.no_changes": "Es gibt keine ungespeicherten Änderungen.", + "i18n:govoplan-calendar.reason.cancellation_evidence_required": "Dokumentieren Sie die Abbruchbegründung mit mindestens 10 Zeichen.", + "i18n:govoplan-calendar.delete_event_title": "Termin löschen?", + "i18n:govoplan-calendar.delete_event_occurrence_message": "Das ausgewählte Vorkommen von {value0} löschen? Bei einem synchronisierten Kalender wird eine entfernte Löschung eingeplant.", + "i18n:govoplan-calendar.delete_event_series_message": "Die gesamte Serie {value0} löschen? Bei einem synchronisierten Kalender wird eine entfernte Löschung eingeplant.", "i18n:govoplan-calendar.add_calendar.124c55eb": "Add calendar...", "i18n:govoplan-calendar.add_calendar.8fadb5bc": "Add calendar", "i18n:govoplan-calendar.add.61cc55aa": "Hinzufügen", diff --git a/webui/src/module.ts b/webui/src/module.ts index e82d6ac..c6da746 100644 --- a/webui/src/module.ts +++ b/webui/src/module.ts @@ -99,6 +99,16 @@ export const calendarModule: PlatformWebModule = { optionalDependencies: ["mail", "tasks", "scheduling", "appointments", "workflow", "notifications", "dms", "connectors"], translations, viewSurfaces: [ + { id: "calendar.navigation", moduleId: "calendar", kind: "navigation", label: "i18n:govoplan-calendar.surface.navigation", order: 10 }, + { id: "calendar.page", moduleId: "calendar", kind: "route", label: "i18n:govoplan-calendar.surface.page", order: 20 }, + { id: "calendar.page.sidebar", moduleId: "calendar", kind: "section", label: "i18n:govoplan-calendar.surface.sidebar", parentId: "calendar.page", order: 10 }, + { id: "calendar.page.agenda", moduleId: "calendar", kind: "section", label: "i18n:govoplan-calendar.surface.agenda", parentId: "calendar.page.sidebar", order: 20 }, + { id: "calendar.page.workspace", moduleId: "calendar", kind: "section", label: "i18n:govoplan-calendar.surface.workspace", parentId: "calendar.page", order: 30 }, + { id: "calendar.event-editor", moduleId: "calendar", kind: "action", label: "i18n:govoplan-calendar.surface.event_editor", parentId: "calendar.page", order: 40 }, + { id: "calendar.collection-editor", moduleId: "calendar", kind: "action", label: "i18n:govoplan-calendar.surface.collection_editor", parentId: "calendar.page.sidebar", order: 50 }, + { id: "calendar.sync-status", moduleId: "calendar", kind: "section", label: "i18n:govoplan-calendar.surface.sync_status", parentId: "calendar.collection-editor", order: 60 }, + { id: "calendar.outbox", moduleId: "calendar", kind: "action", label: "i18n:govoplan-calendar.surface.outbox", parentId: "calendar.sync-status", order: 70 }, + { id: "calendar.migration", moduleId: "calendar", kind: "action", label: "i18n:govoplan-calendar.surface.migration", parentId: "calendar.sync-status", order: 80 }, { id: "calendar.widget.upcoming", moduleId: "calendar", @@ -114,9 +124,9 @@ export const calendarModule: PlatformWebModule = { order: 45 } ], - navItems: [{ to: "/calendar", label: "i18n:govoplan-calendar.calendar.adab5090", iconName: "calendar", anyOf: eventRead, order: 55 }], + navItems: [{ to: "/calendar", label: "i18n:govoplan-calendar.calendar.adab5090", iconName: "calendar", anyOf: eventRead, order: 55, surfaceId: "calendar.navigation" }], routes: [ - { path: "/calendar", anyOf: eventRead, order: 55, render: ({ settings, auth }) => createElement(CalendarPage, { settings, auth }) }], + { path: "/calendar", anyOf: eventRead, order: 55, surfaceId: "calendar.page", render: ({ settings, auth }) => createElement(CalendarPage, { settings, auth }) }], uiCapabilities: { "calendar.picker": calendarPicker, "dashboard.widgets": calendarDashboardWidgets, diff --git a/webui/src/styles/calendar.css b/webui/src/styles/calendar.css index fcacf19..b132166 100644 --- a/webui/src/styles/calendar.css +++ b/webui/src/styles/calendar.css @@ -863,6 +863,16 @@ gap: 14px; } +.calendar-dialog-documentation, +.calendar-settings-documentation { + display: flex; + justify-content: flex-end; +} + +.calendar-settings-documentation { + grid-column: 1 / -1; +} + .calendar-dialog-form label { display: grid; gap: 5px;