Compare commits

..

3 Commits

14 changed files with 3656 additions and 646 deletions

View File

@@ -3,6 +3,7 @@ from __future__ import annotations
from pathlib import Path
from govoplan_core.core.access import CAPABILITY_AUTH_PERMISSION_EVALUATOR, CAPABILITY_AUTH_PRINCIPAL_RESOLVER
from govoplan_core.core.calendar import CAPABILITY_CALENDAR_SCHEDULING
from govoplan_core.core.module_guards import drop_table_retirement_provider, persistent_table_uninstall_guard
from govoplan_core.core.modules import (
DocumentationTopic,
@@ -17,6 +18,7 @@ from govoplan_core.core.modules import (
RoleTemplate,
)
from govoplan_core.db.base import Base
from govoplan_core.core.poll import CAPABILITY_POLL_SCHEDULING
from govoplan_scheduling.backend.db import models as scheduling_models # noqa: F401 - populate Scheduling ORM metadata
MODULE_ID = "scheduling"
@@ -128,7 +130,12 @@ manifest = ModuleManifest(
version=MODULE_VERSION,
dependencies=("poll",),
optional_dependencies=("access", "calendar", "appointments", "evaluation", "mail", "notifications", "portal", "workflow", "tasks", "idm", "organizations", "addresses"),
optional_capabilities=(CAPABILITY_AUTH_PRINCIPAL_RESOLVER, CAPABILITY_AUTH_PERMISSION_EVALUATOR),
optional_capabilities=(
CAPABILITY_AUTH_PRINCIPAL_RESOLVER,
CAPABILITY_AUTH_PERMISSION_EVALUATOR,
CAPABILITY_CALENDAR_SCHEDULING,
),
required_capabilities=(CAPABILITY_POLL_SCHEDULING,),
provides_interfaces=(
ModuleInterfaceProvider(name="scheduling.candidate_slots", version="0.1.8"),
ModuleInterfaceProvider(name="scheduling.decision_handoff", version="0.1.8"),
@@ -140,6 +147,7 @@ manifest = ModuleManifest(
ModuleInterfaceRequirement(name="evaluation.feedback", version_min="0.1.8", version_max_exclusive="0.2.0", optional=True),
ModuleInterfaceRequirement(name="notifications.dispatch", version_min="0.1.8", version_max_exclusive="0.2.0", optional=True),
ModuleInterfaceRequirement(name="addresses.lookup", version_min="0.1.0", version_max_exclusive="0.2.0", optional=True),
ModuleInterfaceRequirement(name="calendar.scheduling", version_min="0.1.8", version_max_exclusive="0.2.0", optional=True),
),
permissions=PERMISSIONS,
role_templates=ROLE_TEMPLATES,

View File

@@ -7,10 +7,11 @@ from fastapi import APIRouter, Depends, HTTPException, Query, status
from sqlalchemy.orm import Session
from govoplan_core.auth import ApiPrincipal, get_api_principal, has_scope
from govoplan_core.core.calendar import CALENDAR_AVAILABILITY_READ_SCOPE, CALENDAR_EVENT_WRITE_SCOPE
from govoplan_core.db.session import get_session
from govoplan_poll.backend.schemas import PollResultSummaryResponse
from govoplan_scheduling.backend.manifest import READ_SCOPE, WRITE_SCOPE
from govoplan_scheduling.backend.manifest import ADMIN_SCOPE, READ_SCOPE, RESPOND_SCOPE, WRITE_SCOPE
from govoplan_scheduling.backend.schemas import (
SchedulingAvailabilityResponseRequest,
SchedulingAddressLookupCandidate,
SchedulingAddressLookupResponse,
SchedulingCalendarActionResponse,
@@ -22,12 +23,14 @@ from govoplan_scheduling.backend.schemas import (
SchedulingRequestListResponse,
SchedulingRequestResponse,
SchedulingRequestUpdateRequest,
SchedulingPollSummaryResponse,
SchedulingStatusResponse,
SchedulingSummaryResponse,
)
from govoplan_scheduling.backend.runtime import get_registry
from govoplan_scheduling.backend.service import (
SchedulingError,
SchedulingPermissionError,
cancel_scheduling_request,
close_scheduling_request,
create_final_calendar_event,
@@ -36,13 +39,16 @@ from govoplan_scheduling.backend.service import (
create_tentative_calendar_holds,
decide_scheduling_request,
evaluate_calendar_freebusy,
get_scheduling_request,
list_scheduling_notifications,
list_scheduling_requests,
get_visible_scheduling_request,
list_visible_scheduling_notifications,
list_visible_scheduling_requests,
open_scheduling_request,
refresh_participant_response_state,
require_visible_scheduling_results,
scheduling_notification_response,
scheduling_request_response,
scheduling_request_summary,
submit_scheduling_availability,
update_scheduling_request,
)
@@ -88,15 +94,45 @@ def _require_scope(principal: ApiPrincipal, scope: str) -> None:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=f"Missing scope: {scope}")
def _principal_actor_ids(principal: ApiPrincipal) -> tuple[str, ...]:
candidates = (
principal.account_id,
principal.membership_id,
getattr(principal.user, "id", None),
principal.identity_id,
principal.principal.service_account_id,
principal.email,
)
return tuple(dict.fromkeys(str(value) for value in candidates if value))
def _can_manage_scheduling(principal: ApiPrincipal) -> bool:
return has_scope(principal, WRITE_SCOPE) or has_scope(principal, ADMIN_SCOPE)
def _scheduling_http_error(exc: SchedulingError) -> HTTPException:
if isinstance(exc, SchedulingPermissionError):
return HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=str(exc))
if str(exc) == "Scheduling request not found":
return HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc))
if str(exc) == "Scheduling results are not visible":
return HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=str(exc))
return HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc))
def _request_response(request, *, invitation_tokens: dict[str, str] | None = None) -> SchedulingRequestResponse:
def _request_response(
request,
*,
principal: ApiPrincipal,
invitation_tokens: dict[str, str] | None = None,
) -> SchedulingRequestResponse:
return SchedulingRequestResponse.model_validate(
scheduling_request_response(request, invitation_tokens=invitation_tokens)
scheduling_request_response(
request,
invitation_tokens=invitation_tokens,
actor_ids=_principal_actor_ids(principal),
can_manage=_can_manage_scheduling(principal),
)
)
@@ -125,8 +161,27 @@ def api_list_scheduling_requests(
principal: ApiPrincipal = Depends(get_api_principal),
) -> SchedulingRequestListResponse:
_require_scope(principal, READ_SCOPE)
requests = list_scheduling_requests(session, tenant_id=principal.tenant_id, status=status_filter)
return SchedulingRequestListResponse(requests=[_request_response(request) for request in requests])
requests = list_visible_scheduling_requests(
session,
tenant_id=principal.tenant_id,
actor_ids=_principal_actor_ids(principal),
can_manage=_can_manage_scheduling(principal),
status=status_filter,
)
actor_ids = _principal_actor_ids(principal)
for request in requests:
refresh_participant_response_state(
session,
request=request,
actor_ids=actor_ids,
)
response = SchedulingRequestListResponse(
requests=[_request_response(request, principal=principal) for request in requests]
)
# Poll responses are authoritative, while Scheduling keeps a durable
# participant projection used by its task-oriented list.
session.commit()
return response
@router.post("/requests", response_model=SchedulingRequestResponse, status_code=status.HTTP_201_CREATED)
@@ -145,7 +200,36 @@ def api_create_scheduling_request(
)
except SchedulingError as exc:
raise _scheduling_http_error(exc) from exc
return _request_response(request, invitation_tokens=invitation_tokens)
response = _request_response(request, principal=principal, invitation_tokens=invitation_tokens)
session.commit()
return response
@router.post("/requests/{request_id}/responses", response_model=SchedulingStatusResponse)
def api_submit_scheduling_availability(
request_id: str,
payload: SchedulingAvailabilityResponseRequest,
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(get_api_principal),
) -> SchedulingStatusResponse:
_require_scope(principal, RESPOND_SCOPE)
try:
request = submit_scheduling_availability(
session,
tenant_id=principal.tenant_id,
request_id=request_id,
actor_ids=_principal_actor_ids(principal),
respondent_id=principal.account_id,
respondent_label=principal.display_name or principal.email,
payload=payload,
)
except SchedulingError as exc:
raise _scheduling_http_error(exc) from exc
response = SchedulingStatusResponse(
request=_request_response(request, principal=principal)
)
session.commit()
return response
@router.get("/requests/{request_id}", response_model=SchedulingRequestResponse)
@@ -156,9 +240,23 @@ def api_get_scheduling_request(
) -> SchedulingRequestResponse:
_require_scope(principal, READ_SCOPE)
try:
return _request_response(get_scheduling_request(session, tenant_id=principal.tenant_id, request_id=request_id))
request = get_visible_scheduling_request(
session,
tenant_id=principal.tenant_id,
request_id=request_id,
actor_ids=_principal_actor_ids(principal),
can_manage=_can_manage_scheduling(principal),
)
refresh_participant_response_state(
session,
request=request,
actor_ids=_principal_actor_ids(principal),
)
except SchedulingError as exc:
raise _scheduling_http_error(exc) from exc
response = _request_response(request, principal=principal)
session.commit()
return response
@router.patch("/requests/{request_id}", response_model=SchedulingRequestResponse)
@@ -170,11 +268,17 @@ def api_update_scheduling_request(
) -> SchedulingRequestResponse:
_require_scope(principal, WRITE_SCOPE)
try:
return _request_response(
update_scheduling_request(session, tenant_id=principal.tenant_id, request_id=request_id, payload=payload)
request = update_scheduling_request(
session,
tenant_id=principal.tenant_id,
request_id=request_id,
payload=payload,
)
except SchedulingError as exc:
raise _scheduling_http_error(exc) from exc
response = _request_response(request, principal=principal)
session.commit()
return response
@router.post("/requests/{request_id}/open", response_model=SchedulingStatusResponse)
@@ -188,7 +292,9 @@ def api_open_scheduling_request(
request = open_scheduling_request(session, tenant_id=principal.tenant_id, request_id=request_id)
except SchedulingError as exc:
raise _scheduling_http_error(exc) from exc
return SchedulingStatusResponse(request=_request_response(request))
response = SchedulingStatusResponse(request=_request_response(request, principal=principal))
session.commit()
return response
@router.post("/requests/{request_id}/close", response_model=SchedulingStatusResponse)
@@ -202,7 +308,9 @@ def api_close_scheduling_request(
request = close_scheduling_request(session, tenant_id=principal.tenant_id, request_id=request_id)
except SchedulingError as exc:
raise _scheduling_http_error(exc) from exc
return SchedulingStatusResponse(request=_request_response(request))
response = SchedulingStatusResponse(request=_request_response(request, principal=principal))
session.commit()
return response
@router.post("/requests/{request_id}/decide", response_model=SchedulingStatusResponse)
@@ -220,10 +328,16 @@ def api_decide_scheduling_request(
request_id=request_id,
payload=payload,
user_id=principal.account_id,
allow_calendar_handoff=has_scope(
principal,
CALENDAR_EVENT_WRITE_SCOPE,
),
)
except SchedulingError as exc:
raise _scheduling_http_error(exc) from exc
return SchedulingStatusResponse(request=_request_response(request))
response = SchedulingStatusResponse(request=_request_response(request, principal=principal))
session.commit()
return response
@router.post("/requests/{request_id}/calendar/freebusy", response_model=SchedulingCalendarActionResponse)
@@ -233,15 +347,18 @@ def api_evaluate_calendar_freebusy(
principal: ApiPrincipal = Depends(get_api_principal),
) -> SchedulingCalendarActionResponse:
_require_scope(principal, WRITE_SCOPE)
_require_scope(principal, CALENDAR_AVAILABILITY_READ_SCOPE)
try:
request, warnings = evaluate_calendar_freebusy(session, tenant_id=principal.tenant_id, request_id=request_id)
except SchedulingError as exc:
raise _scheduling_http_error(exc) from exc
return SchedulingCalendarActionResponse(
request=_request_response(request),
response = SchedulingCalendarActionResponse(
request=_request_response(request, principal=principal),
updated_slot_ids=[slot.id for slot in request.slots if slot.freebusy_checked_at is not None],
warnings=warnings,
)
session.commit()
return response
@router.post("/requests/{request_id}/calendar/holds", response_model=SchedulingCalendarActionResponse)
@@ -251,6 +368,7 @@ def api_create_tentative_calendar_holds(
principal: ApiPrincipal = Depends(get_api_principal),
) -> SchedulingCalendarActionResponse:
_require_scope(principal, WRITE_SCOPE)
_require_scope(principal, CALENDAR_EVENT_WRITE_SCOPE)
try:
request, created_event_ids, warnings = create_tentative_calendar_holds(
session,
@@ -260,12 +378,14 @@ def api_create_tentative_calendar_holds(
)
except SchedulingError as exc:
raise _scheduling_http_error(exc) from exc
return SchedulingCalendarActionResponse(
request=_request_response(request),
response = SchedulingCalendarActionResponse(
request=_request_response(request, principal=principal),
created_event_ids=created_event_ids,
updated_slot_ids=[slot.id for slot in request.slots if slot.tentative_hold_event_id in created_event_ids],
warnings=warnings,
)
session.commit()
return response
@router.post("/requests/{request_id}/calendar/event", response_model=SchedulingCalendarActionResponse)
@@ -275,6 +395,7 @@ def api_create_final_calendar_event(
principal: ApiPrincipal = Depends(get_api_principal),
) -> SchedulingCalendarActionResponse:
_require_scope(principal, WRITE_SCOPE)
_require_scope(principal, CALENDAR_EVENT_WRITE_SCOPE)
try:
request, event_id, warnings = create_final_calendar_event(
session,
@@ -284,11 +405,13 @@ def api_create_final_calendar_event(
)
except SchedulingError as exc:
raise _scheduling_http_error(exc) from exc
return SchedulingCalendarActionResponse(
request=_request_response(request),
response = SchedulingCalendarActionResponse(
request=_request_response(request, principal=principal),
created_event_ids=[event_id] if event_id else [],
warnings=warnings,
)
session.commit()
return response
@router.post("/requests/{request_id}/cancel", response_model=SchedulingStatusResponse)
@@ -302,7 +425,9 @@ def api_cancel_scheduling_request(
request = cancel_scheduling_request(session, tenant_id=principal.tenant_id, request_id=request_id)
except SchedulingError as exc:
raise _scheduling_http_error(exc) from exc
return SchedulingStatusResponse(request=_request_response(request))
response = SchedulingStatusResponse(request=_request_response(request, principal=principal))
session.commit()
return response
@router.get("/requests/{request_id}/summary", response_model=SchedulingSummaryResponse)
@@ -313,14 +438,30 @@ def api_scheduling_summary(
) -> SchedulingSummaryResponse:
_require_scope(principal, READ_SCOPE)
try:
request = get_scheduling_request(session, tenant_id=principal.tenant_id, request_id=request_id)
request = get_visible_scheduling_request(
session,
tenant_id=principal.tenant_id,
request_id=request_id,
actor_ids=_principal_actor_ids(principal),
can_manage=_can_manage_scheduling(principal),
)
require_visible_scheduling_results(
session,
request=request,
actor_ids=_principal_actor_ids(principal),
can_manage=_can_manage_scheduling(principal),
)
summary = scheduling_request_summary(session, tenant_id=principal.tenant_id, request_id=request_id)
except SchedulingError as exc:
raise _scheduling_http_error(exc) from exc
return SchedulingSummaryResponse(
request=_request_response(request),
poll_summary=PollResultSummaryResponse.model_validate(summary),
response = SchedulingSummaryResponse(
request=_request_response(request, principal=principal),
poll_summary=SchedulingPollSummaryResponse.model_validate(summary),
)
# Refreshing the summary synchronizes participant response state and can
# enqueue response notifications, so persist that work before teardown.
session.commit()
return response
@router.get("/notifications", response_model=SchedulingNotificationListResponse)
@@ -331,9 +472,11 @@ def api_list_scheduling_notifications(
principal: ApiPrincipal = Depends(get_api_principal),
) -> SchedulingNotificationListResponse:
_require_scope(principal, READ_SCOPE)
notifications = list_scheduling_notifications(
notifications = list_visible_scheduling_notifications(
session,
tenant_id=principal.tenant_id,
actor_ids=_principal_actor_ids(principal),
can_manage=_can_manage_scheduling(principal),
request_id=request_id,
status=status_filter,
)
@@ -364,9 +507,11 @@ def api_create_scheduling_notifications(
)
except SchedulingError as exc:
raise _scheduling_http_error(exc) from exc
return SchedulingNotificationListResponse(
response = SchedulingNotificationListResponse(
notifications=[
SchedulingNotificationResponse.model_validate(scheduling_notification_response(notification))
for notification in notifications
]
)
session.commit()
return response

View File

@@ -5,13 +5,12 @@ from typing import Any, Literal
from pydantic import BaseModel, ConfigDict, Field, model_validator
from govoplan_poll.backend.schemas import PollResultSummaryResponse
SchedulingStatus = Literal["draft", "collecting", "closed", "decided", "handed_off", "cancelled", "archived"]
SchedulingParticipantType = Literal["internal", "external", "resource"]
SchedulingParticipantStatus = Literal["draft", "invited", "responded", "declined", "removed"]
SchedulingResultVisibility = Literal["organizer", "after_response", "after_close", "public"]
SchedulingAvailabilityValue = Literal["available", "maybe", "unavailable"]
class SchedulingCalendarPreferences(BaseModel):
@@ -164,9 +163,48 @@ class SchedulingDecisionRequest(BaseModel):
handoff_to_calendar: bool | None = None
class SchedulingAvailabilityAnswerInput(BaseModel):
model_config = ConfigDict(extra="forbid")
slot_id: str
value: SchedulingAvailabilityValue
class SchedulingAvailabilityResponseRequest(BaseModel):
model_config = ConfigDict(extra="forbid")
answers: list[SchedulingAvailabilityAnswerInput] = Field(min_length=1)
@model_validator(mode="after")
def validate_unique_slots(self) -> "SchedulingAvailabilityResponseRequest":
slot_ids = [answer.slot_id for answer in self.answers]
if len(slot_ids) != len(set(slot_ids)):
raise ValueError("Each scheduling slot can be answered only once")
return self
class SchedulingPollOptionResultResponse(BaseModel):
option_id: str
option_key: str
label: str
count: int = 0
score: int = 0
values: dict[str, int] = Field(default_factory=dict)
ranks: dict[int, int] = Field(default_factory=dict)
class SchedulingPollSummaryResponse(BaseModel):
poll_id: str
kind: str
status: str
response_count: int
option_results: list[SchedulingPollOptionResultResponse] = Field(default_factory=list)
leading_option_ids: list[str] = Field(default_factory=list)
class SchedulingSummaryResponse(BaseModel):
request: SchedulingRequestResponse
poll_summary: PollResultSummaryResponse
poll_summary: SchedulingPollSummaryResponse
class SchedulingCalendarActionResponse(BaseModel):

File diff suppressed because it is too large Load Diff

View File

@@ -16,8 +16,10 @@ class SchedulingManifestTests(unittest.TestCase):
self.assertNotIn("access", manifest.dependencies)
self.assertIn("access", manifest.optional_dependencies)
self.assertIn("addresses", manifest.optional_dependencies)
self.assertFalse(manifest.required_capabilities)
self.assertEqual(("poll.scheduling",), manifest.required_capabilities)
self.assertIn("auth.principalResolver", manifest.optional_capabilities)
self.assertIn("poll.scheduling", manifest.required_capabilities)
self.assertIn("calendar.scheduling", manifest.optional_capabilities)
self.assertIn("evaluation", manifest.optional_dependencies)
self.assertIsNotNone(manifest.route_factory)
self.assertIsNotNone(manifest.migration_spec)
@@ -27,6 +29,7 @@ class SchedulingManifestTests(unittest.TestCase):
self.assertIn("poll.signed_participation", {interface.name for interface in manifest.requires_interfaces})
self.assertIn("notifications.dispatch", {interface.name for interface in manifest.requires_interfaces})
self.assertIn("addresses.lookup", {interface.name for interface in manifest.requires_interfaces})
self.assertIn("calendar.scheduling", {interface.name for interface in manifest.requires_interfaces})
if __name__ == "__main__":

File diff suppressed because it is too large Load Diff

View File

@@ -13,6 +13,10 @@
},
"./styles/scheduling.css": "./src/styles/scheduling.css"
},
"scripts": {
"test:view-model": "node --experimental-strip-types --test tests/scheduling-view-model.test.ts",
"test:ui-structure": "node scripts/test-scheduling-page-structure.mjs"
},
"peerDependencies": {
"@govoplan/core-webui": "^0.1.8",
"lucide-react": "^1.23.0",

View File

@@ -0,0 +1,34 @@
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import { fileURLToPath } from "node:url";
const pagePath = fileURLToPath(new URL("../src/features/scheduling/SchedulingPage.tsx", import.meta.url));
const apiPath = fileURLToPath(new URL("../src/api/scheduling.ts", import.meta.url));
const page = readFileSync(pagePath, "utf8");
const api = readFileSync(apiPath, "utf8");
assert.match(page, /usePlatformUiCapability<CalendarPickerUiCapability>\("calendar\.picker"\)/);
assert.match(page, /hasScope\(auth, "calendar:calendar:read"\)/);
assert.match(page, /Boolean\(calendarPickerCapability\) && canReadCalendars && canReadAvailability && canWriteCalendarEvent/);
assert.doesNotMatch(page, /@govoplan\/calendar-webui|govoplan-calendar\/webui/);
assert.match(page, /className="scheduling-full-editor"/);
assert.match(page, /className="scheduling-page-actions"/);
const editorActions = page.slice(
page.indexOf('<div className="scheduling-page-actions">'),
page.indexOf('</div>', page.indexOf('<div className="scheduling-page-actions">'))
);
assert.ok(editorActions.indexOf("I18N.discard") < editorActions.indexOf("I18N.save"));
assert.match(page, /<Plus aria-hidden="true" size=\{16\} \/> \{I18N\.addRequest\}/);
assert.match(page, /title=\{I18N\.myRequests\}/);
assert.match(page, /title=\{I18N\.invitedRequests\}/);
assert.match(page, /className="scheduling-table-actions"/);
assert.match(page, /aria-label=\{i18nMessage\("i18n:govoplan-scheduling\.decide_on_value/);
assert.match(page, /submitSchedulingAvailability\(settings, selected\.id/);
assert.doesNotMatch(page, /<p>\{selected\.calendar_id\}<\/p>/);
assert.match(page, /className="scheduling-selected-calendar"/);
assert.match(page, /showPlanningCalendarActions/);
assert.match(page, /title=\{!canReadAvailability \? I18N\.requiresAvailabilityRead/);
assert.match(api, /\/api\/v1\/scheduling\/requests\/\$\{requestId\}\/responses/);
console.log("Scheduling page structure satisfies the focused list/create/respond contract.");

View File

@@ -115,6 +115,15 @@ export type SchedulingDecisionPayload = {
handoff_to_calendar?: boolean | null;
};
export type SchedulingAvailabilityValue = "available" | "maybe" | "unavailable";
export type SchedulingAvailabilityPayload = {
answers: Array<{
slot_id: string;
value: SchedulingAvailabilityValue;
}>;
};
export type SchedulingCalendarActionResponse = {
request: SchedulingRequest;
created_event_ids: string[];
@@ -197,6 +206,18 @@ export function createSchedulingRequest(settings: ApiSettings, payload: Scheduli
return apiFetch<SchedulingRequest>(settings, "/api/v1/scheduling/requests", json(payload));
}
export function submitSchedulingAvailability(
settings: ApiSettings,
requestId: string,
payload: SchedulingAvailabilityPayload
): Promise<SchedulingStatusResponse> {
return apiFetch<SchedulingStatusResponse>(
settings,
`/api/v1/scheduling/requests/${requestId}/responses`,
json(payload)
);
}
export function openSchedulingRequest(settings: ApiSettings, requestId: string): Promise<SchedulingStatusResponse> {
return apiFetch<SchedulingStatusResponse>(settings, `/api/v1/scheduling/requests/${requestId}/open`, json({}));
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,146 @@
import type { SchedulingParticipant, SchedulingRequest } from "../../api/scheduling";
export type SchedulingActor = {
accountId?: string | null;
userId?: string | null;
membershipId?: string | null;
identityId?: string | null;
email?: string | null;
};
export type SchedulingRequestGroups = {
owned: SchedulingRequest[];
invited: SchedulingRequest[];
other: SchedulingRequest[];
};
export type SchedulingSortPhase =
| "unanswered"
| "answered"
| "closed"
| "determined"
| "past";
export function schedulingActorIds(actor: SchedulingActor): string[] {
return Array.from(new Set([
actor.accountId,
actor.userId,
actor.membershipId,
actor.identityId,
actor.email
].filter((value): value is string => Boolean(value))));
}
export function schedulingParticipantForActor(
request: SchedulingRequest,
actor: SchedulingActor
): SchedulingParticipant | null {
const ids = new Set(schedulingActorIds(actor));
return request.participants.find((participant) =>
Boolean(
(participant.respondent_id && ids.has(participant.respondent_id)) ||
(participant.email && ids.has(participant.email))
)
) ?? null;
}
export function schedulingRequestIsOwned(
request: SchedulingRequest,
actor: SchedulingActor
): boolean {
return Boolean(
request.organizer_user_id &&
schedulingActorIds(actor).includes(request.organizer_user_id)
);
}
export function schedulingSortPhase(
request: SchedulingRequest,
actor: SchedulingActor,
now = new Date()
): SchedulingSortPhase {
if (schedulingRequestIsPast(request, now)) return "past";
if (["decided", "handed_off"].includes(request.status)) return "determined";
if (["closed", "cancelled", "archived"].includes(request.status)) return "closed";
if (schedulingRequestIsOwned(request, actor)) return "unanswered";
const participant = schedulingParticipantForActor(request, actor);
return participant && ["responded", "declined"].includes(participant.status)
? "answered"
: "unanswered";
}
export function compareSchedulingRequests(
left: SchedulingRequest,
right: SchedulingRequest,
actor: SchedulingActor,
now = new Date()
): number {
const leftPhase = schedulingSortPhase(left, actor, now);
const rightPhase = schedulingSortPhase(right, actor, now);
const phaseDifference = SORT_PHASE_ORDER[leftPhase] - SORT_PHASE_ORDER[rightPhase];
if (phaseDifference !== 0) return phaseDifference;
const leftDate = schedulingRelevantTimestamp(left, now);
const rightDate = schedulingRelevantTimestamp(right, now);
const dateDifference = leftPhase === "past"
? rightDate - leftDate
: leftDate - rightDate;
if (dateDifference !== 0) return dateDifference;
const titleDifference = left.title.localeCompare(right.title);
return titleDifference || left.id.localeCompare(right.id);
}
export function groupSchedulingRequests(
requests: SchedulingRequest[],
actor: SchedulingActor,
now = new Date()
): SchedulingRequestGroups {
const groups: SchedulingRequestGroups = { owned: [], invited: [], other: [] };
for (const request of requests) {
if (schedulingRequestIsOwned(request, actor)) {
groups.owned.push(request);
} else if (schedulingParticipantForActor(request, actor)) {
groups.invited.push(request);
} else {
groups.other.push(request);
}
}
for (const group of Object.values(groups)) {
group.sort((left, right) => compareSchedulingRequests(left, right, actor, now));
}
return groups;
}
export function schedulingRelevantTimestamp(
request: SchedulingRequest,
now = new Date()
): number {
const nowValue = now.getTime();
const futureStarts = request.slots
.map((slot) => Date.parse(slot.start_at))
.filter((value) => Number.isFinite(value) && value >= nowValue)
.sort((left, right) => left - right);
if (futureStarts[0] !== undefined) return futureStarts[0];
const slotEnds = request.slots
.map((slot) => Date.parse(slot.end_at))
.filter(Number.isFinite);
if (slotEnds.length) return Math.max(...slotEnds);
const fallback = Date.parse(request.deadline_at || request.updated_at || request.created_at);
return Number.isFinite(fallback) ? fallback : Number.MAX_SAFE_INTEGER;
}
export function schedulingRequestIsPast(
request: SchedulingRequest,
now = new Date()
): boolean {
if (!request.slots.length) return false;
const slotEnds = request.slots.map((slot) => Date.parse(slot.end_at));
return slotEnds.every((value) => Number.isFinite(value) && value < now.getTime());
}
const SORT_PHASE_ORDER: Record<SchedulingSortPhase, number> = {
unanswered: 0,
answered: 1,
closed: 2,
determined: 3,
past: 4
};

View File

@@ -1,4 +1,210 @@
export const generatedTranslations = {
en: {},
de: {}
en: {
"i18n:govoplan-scheduling.add_participant.6cff957d": "Add participant",
"i18n:govoplan-scheduling.add_scheduling_request.3c71be15": "Add scheduling request",
"i18n:govoplan-scheduling.add_slot.9fcff3ed": "Add slot",
"i18n:govoplan-scheduling.answered.e0aafffa": "Answered",
"i18n:govoplan-scheduling.available.7c62a142": "Available",
"i18n:govoplan-scheduling.awaiting_response.ee646ea9": "Awaiting response",
"i18n:govoplan-scheduling.basic_information.d8bc7383": "Basic information",
"i18n:govoplan-scheduling.busy.592a9d16": "Busy",
"i18n:govoplan-scheduling.calendar.adab5090": "Calendar",
"i18n:govoplan-scheduling.calendar_coordination.291ab00a": "Calendar coordination",
"i18n:govoplan-scheduling.configured_calendar.e2e8ebd5": "Configured calendar",
"i18n:govoplan-scheduling.calendar_integration.181ad18b": "Calendar integration",
"i18n:govoplan-scheduling.calendar_integration_requires_the_calendar_module_plus_c.f892cb1e": "Calendar integration requires the Calendar module plus calendar-read, availability-read, and event-write access.",
"i18n:govoplan-scheduling.candidate_availability.9541c4b5": "Candidate availability",
"i18n:govoplan-scheduling.candidate_slots.c414946b": "Candidate slots",
"i18n:govoplan-scheduling.check_free_busy.e9700e00": "Check free/busy",
"i18n:govoplan-scheduling.choose_availability.ac95b8f6": "Choose availability",
"i18n:govoplan-scheduling.choose_availability_for_at_least_one_candidate_slot.28d2111f": "Choose availability for at least one candidate slot.",
"i18n:govoplan-scheduling.close_poll.a6a18916": "Close poll",
"i18n:govoplan-scheduling.close_stops_accepting_new_availability_responses.a1d57519": "Close stops accepting new availability responses.",
"i18n:govoplan-scheduling.closed.88d86b77": "Closed",
"i18n:govoplan-scheduling.create_and_open_scheduling_request.1dbc9345": "Create and open scheduling request",
"i18n:govoplan-scheduling.create_calendar_event.0b87cfcf": "Create calendar event",
"i18n:govoplan-scheduling.create_tentative_holds.51c4744e": "Create tentative holds",
"i18n:govoplan-scheduling.cancellation.319aaae4": "Cancellation",
"i18n:govoplan-scheduling.decision.7f59a1f1": "Decision",
"i18n:govoplan-scheduling.declined.ff59b80f": "Declined",
"i18n:govoplan-scheduling.decide_on_value.196409cd": "Decide on {value0}",
"i18n:govoplan-scheduling.description.55f8ebc8": "Description",
"i18n:govoplan-scheduling.determined.9f23293d": "Determined",
"i18n:govoplan-scheduling.discard.36fff63c": "Discard",
"i18n:govoplan-scheduling.discard_this_unsaved_scheduling_request.4a956be2": "Discard this unsaved scheduling request?",
"i18n:govoplan-scheduling.draft.23d33e22": "Draft",
"i18n:govoplan-scheduling.end.a2bb9d34": "End",
"i18n:govoplan-scheduling.error.7f2f6a15": "Error",
"i18n:govoplan-scheduling.every_candidate_slot_must_end_after_it_starts.47836010": "Every candidate slot must end after it starts.",
"i18n:govoplan-scheduling.failed.09fef5d8": "Failed",
"i18n:govoplan-scheduling.free.75f52718": "Free",
"i18n:govoplan-scheduling.free_busy_checks_each_candidate_slot_against_the_selecte.50f0371a": "Free/busy checks each candidate slot against the selected calendar.",
"i18n:govoplan-scheduling.loading_scheduling_requests.f42be95d": "Loading scheduling requests…",
"i18n:govoplan-scheduling.invitation.6306ef74": "Invitation",
"i18n:govoplan-scheduling.invited.53469df1": "Invited",
"i18n:govoplan-scheduling.location.d219c681": "Location",
"i18n:govoplan-scheduling.managed_scheduling_requests.7a45972f": "Managed scheduling requests",
"i18n:govoplan-scheduling.maybe.56dd8d0b": "Maybe",
"i18n:govoplan-scheduling.my_scheduling_requests.d28ef235": "My scheduling requests",
"i18n:govoplan-scheduling.name.709a2322": "Name",
"i18n:govoplan-scheduling.new_scheduling_request.2080f675": "New scheduling request",
"i18n:govoplan-scheduling.no_notifications_have_been_created.c8d43ca3": "No notifications have been created.",
"i18n:govoplan-scheduling.no_participants.73a89101": "No participants",
"i18n:govoplan-scheduling.no_requests_in_this_group.a63c1b40": "No requests in this group",
"i18n:govoplan-scheduling.no_scheduling_request_selected.ac940664": "No scheduling request selected",
"i18n:govoplan-scheduling.notifications.753a22b2": "Notifications",
"i18n:govoplan-scheduling.open.cf9b7706": "Open",
"i18n:govoplan-scheduling.open_poll.2beac9a7": "Open poll",
"i18n:govoplan-scheduling.open_starts_collecting_availability_responses.c8ec34e5": "Open starts collecting availability responses.",
"i18n:govoplan-scheduling.option_value.3f643dc0": "Option {value0}",
"i18n:govoplan-scheduling.other_scheduling_requests.5cb6eb30": "Other scheduling requests",
"i18n:govoplan-scheduling.participant_email.2cadfd9e": "Participant email",
"i18n:govoplan-scheduling.participants.cd56e083": "Participants",
"i18n:govoplan-scheduling.past.405c12fb": "Past",
"i18n:govoplan-scheduling.pending.96f608c1": "Pending",
"i18n:govoplan-scheduling.queued.6a599877": "Queued",
"i18n:govoplan-scheduling.refresh_requests.0a3ed7a1": "Refresh requests",
"i18n:govoplan-scheduling.reminder_creates_a_notification_job_for_every_active_par.7ec68797": "Reminder creates a notification job for every active participant.",
"i18n:govoplan-scheduling.remove.e963907d": "Remove",
"i18n:govoplan-scheduling.remove_participant_value.e55f2b70": "Remove participant {value0}",
"i18n:govoplan-scheduling.remove_slot_value.3adc7576": "Remove slot {value0}",
"i18n:govoplan-scheduling.removed.b5e77c5c": "Removed",
"i18n:govoplan-scheduling.reminder.b87a1929": "Reminder",
"i18n:govoplan-scheduling.request_failed.9fcda32c": "Request failed",
"i18n:govoplan-scheduling.required.eed6bfb4": "Required",
"i18n:govoplan-scheduling.requires_calendar_availability_read_access.b48ed91b": "Requires Calendar availability-read access.",
"i18n:govoplan-scheduling.requires_calendar_event_write_access.887b0763": "Requires Calendar event-write access.",
"i18n:govoplan-scheduling.responses_may_be_updated_while_this_request_remains_open.4faecbbe": "Responses may be updated while this request remains open.",
"i18n:govoplan-scheduling.responded.4f218211": "Responded",
"i18n:govoplan-scheduling.save.efc007a3": "Save",
"i18n:govoplan-scheduling.saving.56a2285c": "Saving…",
"i18n:govoplan-scheduling.scheduling_requests.b3c12f4d": "Scheduling requests",
"i18n:govoplan-scheduling.scheduling_request_title_is_required.a27338be": "Scheduling request title is required.",
"i18n:govoplan-scheduling.scheduling_requests_for_me.1d521aba": "Scheduling requests for me",
"i18n:govoplan-scheduling.select_a_calendar.f6af95bb": "Select a calendar",
"i18n:govoplan-scheduling.select_a_calendar_or_turn_off_calendar_integration.cc3652a9": "Select a calendar or turn off Calendar integration.",
"i18n:govoplan-scheduling.send_reminder.cf5eb3bf": "Send reminder",
"i18n:govoplan-scheduling.sending.ceafde86": "Sending",
"i18n:govoplan-scheduling.sent.35f49dcf": "Sent",
"i18n:govoplan-scheduling.skipped.5a000ad7": "Skipped",
"i18n:govoplan-scheduling.start.952f3754": "Start",
"i18n:govoplan-scheduling.submit_response.a5f0c053": "Submit response",
"i18n:govoplan-scheduling.tentative_holds_create_one_provisional_calendar_event_pe.ff3f1884": "Tentative holds create one provisional calendar event per candidate slot.",
"i18n:govoplan-scheduling.the_final_event_is_created_only_for_the_selected_slot.e3621252": "The final event is created only for the selected slot.",
"i18n:govoplan-scheduling.the_response_replaces_your_previous_availability_choices.74c16d53": "The response replaces your previous availability choices.",
"i18n:govoplan-scheduling.title.768e0c1c": "Title",
"i18n:govoplan-scheduling.unavailable.2c9c1f79": "Unavailable",
"i18n:govoplan-scheduling.unchecked.1b927dec": "Unchecked",
"i18n:govoplan-scheduling.update_response.346233cf": "Update response",
"i18n:govoplan-scheduling.use_a_calendar_for_availability_checks_tentative_holds_a.20ccc1fa": "Use a calendar for availability checks, tentative holds, and the final event.",
"i18n:govoplan-scheduling.value_participants.e776b092": "{value0} participants",
"i18n:govoplan-scheduling.value_responses.ba17af9a": "{value0} responses",
"i18n:govoplan-scheduling.what_do_these_actions_do.9a9aee0e": "What do these actions do?",
"i18n:govoplan-scheduling.you_can_respond_here_or_use_the_invitation_link_you_rece.1a25fd53": "You can respond here or use the invitation link you received.",
"i18n:govoplan-scheduling.your_response_has_been_recorded.b855088d": "Your response has been recorded."
},
de: {
"i18n:govoplan-scheduling.add_participant.6cff957d": "Teilnehmende Person hinzufügen",
"i18n:govoplan-scheduling.add_scheduling_request.3c71be15": "Terminanfrage hinzufügen",
"i18n:govoplan-scheduling.add_slot.9fcff3ed": "Terminvorschlag hinzufügen",
"i18n:govoplan-scheduling.answered.e0aafffa": "Beantwortet",
"i18n:govoplan-scheduling.available.7c62a142": "Verfügbar",
"i18n:govoplan-scheduling.awaiting_response.ee646ea9": "Antwort ausstehend",
"i18n:govoplan-scheduling.basic_information.d8bc7383": "Grunddaten",
"i18n:govoplan-scheduling.busy.592a9d16": "Belegt",
"i18n:govoplan-scheduling.calendar.adab5090": "Kalender",
"i18n:govoplan-scheduling.calendar_coordination.291ab00a": "Kalenderabgleich",
"i18n:govoplan-scheduling.configured_calendar.e2e8ebd5": "Ausgewählter Kalender",
"i18n:govoplan-scheduling.calendar_integration.181ad18b": "Kalenderintegration",
"i18n:govoplan-scheduling.calendar_integration_requires_the_calendar_module_plus_c.f892cb1e": "Die Kalenderintegration benötigt das Kalendermodul sowie Leserechte für Kalender und Verfügbarkeiten und Schreibrechte für Termine.",
"i18n:govoplan-scheduling.candidate_availability.9541c4b5": "Verfügbarkeit zu den Vorschlägen",
"i18n:govoplan-scheduling.candidate_slots.c414946b": "Terminvorschläge",
"i18n:govoplan-scheduling.check_free_busy.e9700e00": "Frei/Belegt prüfen",
"i18n:govoplan-scheduling.choose_availability.ac95b8f6": "Verfügbarkeit auswählen",
"i18n:govoplan-scheduling.choose_availability_for_at_least_one_candidate_slot.28d2111f": "Wählen Sie für mindestens einen Terminvorschlag eine Verfügbarkeit aus.",
"i18n:govoplan-scheduling.close_poll.a6a18916": "Abstimmung schließen",
"i18n:govoplan-scheduling.close_stops_accepting_new_availability_responses.a1d57519": "Schließen beendet die Annahme neuer Verfügbarkeitsantworten.",
"i18n:govoplan-scheduling.closed.88d86b77": "Geschlossen",
"i18n:govoplan-scheduling.create_and_open_scheduling_request.1dbc9345": "Terminanfrage erstellen und öffnen",
"i18n:govoplan-scheduling.create_calendar_event.0b87cfcf": "Kalendertermin erstellen",
"i18n:govoplan-scheduling.create_tentative_holds.51c4744e": "Vorläufige Reservierungen erstellen",
"i18n:govoplan-scheduling.cancellation.319aaae4": "Stornierung",
"i18n:govoplan-scheduling.decision.7f59a1f1": "Entscheidung",
"i18n:govoplan-scheduling.declined.ff59b80f": "Abgelehnt",
"i18n:govoplan-scheduling.decide_on_value.196409cd": "{value0} als Termin festlegen",
"i18n:govoplan-scheduling.description.55f8ebc8": "Beschreibung",
"i18n:govoplan-scheduling.determined.9f23293d": "Festgelegt",
"i18n:govoplan-scheduling.discard.36fff63c": "Verwerfen",
"i18n:govoplan-scheduling.discard_this_unsaved_scheduling_request.4a956be2": "Diese ungespeicherte Terminanfrage verwerfen?",
"i18n:govoplan-scheduling.draft.23d33e22": "Entwurf",
"i18n:govoplan-scheduling.end.a2bb9d34": "Ende",
"i18n:govoplan-scheduling.error.7f2f6a15": "Fehler",
"i18n:govoplan-scheduling.every_candidate_slot_must_end_after_it_starts.47836010": "Jeder Terminvorschlag muss nach seinem Beginn enden.",
"i18n:govoplan-scheduling.failed.09fef5d8": "Fehlgeschlagen",
"i18n:govoplan-scheduling.free.75f52718": "Frei",
"i18n:govoplan-scheduling.free_busy_checks_each_candidate_slot_against_the_selecte.50f0371a": "Frei/Belegt prüft jeden Terminvorschlag gegen den ausgewählten Kalender.",
"i18n:govoplan-scheduling.loading_scheduling_requests.f42be95d": "Terminanfragen werden geladen …",
"i18n:govoplan-scheduling.invitation.6306ef74": "Einladung",
"i18n:govoplan-scheduling.invited.53469df1": "Eingeladen",
"i18n:govoplan-scheduling.location.d219c681": "Ort",
"i18n:govoplan-scheduling.managed_scheduling_requests.7a45972f": "Verwaltete Terminanfragen",
"i18n:govoplan-scheduling.maybe.56dd8d0b": "Vielleicht",
"i18n:govoplan-scheduling.my_scheduling_requests.d28ef235": "Meine Terminanfragen",
"i18n:govoplan-scheduling.name.709a2322": "Name",
"i18n:govoplan-scheduling.new_scheduling_request.2080f675": "Neue Terminanfrage",
"i18n:govoplan-scheduling.no_notifications_have_been_created.c8d43ca3": "Es wurden noch keine Benachrichtigungen erstellt.",
"i18n:govoplan-scheduling.no_participants.73a89101": "Keine Teilnehmenden",
"i18n:govoplan-scheduling.no_requests_in_this_group.a63c1b40": "Keine Anfragen in dieser Gruppe",
"i18n:govoplan-scheduling.no_scheduling_request_selected.ac940664": "Keine Terminanfrage ausgewählt",
"i18n:govoplan-scheduling.notifications.753a22b2": "Benachrichtigungen",
"i18n:govoplan-scheduling.open.cf9b7706": "Offen",
"i18n:govoplan-scheduling.open_poll.2beac9a7": "Abstimmung öffnen",
"i18n:govoplan-scheduling.open_starts_collecting_availability_responses.c8ec34e5": "Öffnen startet die Erfassung von Verfügbarkeitsantworten.",
"i18n:govoplan-scheduling.option_value.3f643dc0": "Vorschlag {value0}",
"i18n:govoplan-scheduling.other_scheduling_requests.5cb6eb30": "Andere Terminanfragen",
"i18n:govoplan-scheduling.participant_email.2cadfd9e": "E-Mail der teilnehmenden Person",
"i18n:govoplan-scheduling.participants.cd56e083": "Teilnehmende",
"i18n:govoplan-scheduling.past.405c12fb": "Vergangen",
"i18n:govoplan-scheduling.pending.96f608c1": "Ausstehend",
"i18n:govoplan-scheduling.queued.6a599877": "Eingereiht",
"i18n:govoplan-scheduling.refresh_requests.0a3ed7a1": "Anfragen aktualisieren",
"i18n:govoplan-scheduling.reminder_creates_a_notification_job_for_every_active_par.7ec68797": "Erinnern erstellt für jede aktive teilnehmende Person einen Benachrichtigungsauftrag.",
"i18n:govoplan-scheduling.remove.e963907d": "Entfernen",
"i18n:govoplan-scheduling.remove_participant_value.e55f2b70": "Teilnehmende Person {value0} entfernen",
"i18n:govoplan-scheduling.remove_slot_value.3adc7576": "Terminvorschlag {value0} entfernen",
"i18n:govoplan-scheduling.removed.b5e77c5c": "Entfernt",
"i18n:govoplan-scheduling.reminder.b87a1929": "Erinnerung",
"i18n:govoplan-scheduling.request_failed.9fcda32c": "Anfrage fehlgeschlagen",
"i18n:govoplan-scheduling.required.eed6bfb4": "Erforderlich",
"i18n:govoplan-scheduling.requires_calendar_availability_read_access.b48ed91b": "Erfordert Leserechte für Kalenderverfügbarkeiten.",
"i18n:govoplan-scheduling.requires_calendar_event_write_access.887b0763": "Erfordert Schreibrechte für Kalendertermine.",
"i18n:govoplan-scheduling.responses_may_be_updated_while_this_request_remains_open.4faecbbe": "Antworten können aktualisiert werden, solange diese Anfrage offen ist.",
"i18n:govoplan-scheduling.responded.4f218211": "Geantwortet",
"i18n:govoplan-scheduling.save.efc007a3": "Speichern",
"i18n:govoplan-scheduling.saving.56a2285c": "Wird gespeichert …",
"i18n:govoplan-scheduling.scheduling_requests.b3c12f4d": "Terminanfragen",
"i18n:govoplan-scheduling.scheduling_request_title_is_required.a27338be": "Für die Terminanfrage ist ein Titel erforderlich.",
"i18n:govoplan-scheduling.scheduling_requests_for_me.1d521aba": "Terminanfragen an mich",
"i18n:govoplan-scheduling.select_a_calendar.f6af95bb": "Kalender auswählen",
"i18n:govoplan-scheduling.select_a_calendar_or_turn_off_calendar_integration.cc3652a9": "Wählen Sie einen Kalender aus oder schalten Sie die Kalenderintegration aus.",
"i18n:govoplan-scheduling.send_reminder.cf5eb3bf": "Erinnerung senden",
"i18n:govoplan-scheduling.sending.ceafde86": "Wird gesendet",
"i18n:govoplan-scheduling.sent.35f49dcf": "Gesendet",
"i18n:govoplan-scheduling.skipped.5a000ad7": "Übersprungen",
"i18n:govoplan-scheduling.start.952f3754": "Beginn",
"i18n:govoplan-scheduling.submit_response.a5f0c053": "Antwort senden",
"i18n:govoplan-scheduling.tentative_holds_create_one_provisional_calendar_event_pe.ff3f1884": "Vorläufige Reservierungen erstellen je Terminvorschlag einen provisorischen Kalendereintrag.",
"i18n:govoplan-scheduling.the_final_event_is_created_only_for_the_selected_slot.e3621252": "Der endgültige Kalendereintrag wird nur für den ausgewählten Termin erstellt.",
"i18n:govoplan-scheduling.the_response_replaces_your_previous_availability_choices.74c16d53": "Die Antwort ersetzt Ihre vorherige Verfügbarkeitsauswahl.",
"i18n:govoplan-scheduling.title.768e0c1c": "Titel",
"i18n:govoplan-scheduling.unavailable.2c9c1f79": "Nicht verfügbar",
"i18n:govoplan-scheduling.unchecked.1b927dec": "Nicht geprüft",
"i18n:govoplan-scheduling.update_response.346233cf": "Antwort aktualisieren",
"i18n:govoplan-scheduling.use_a_calendar_for_availability_checks_tentative_holds_a.20ccc1fa": "Einen Kalender für Verfügbarkeitsprüfungen, vorläufige Reservierungen und den endgültigen Termin verwenden.",
"i18n:govoplan-scheduling.value_participants.e776b092": "{value0} Teilnehmende",
"i18n:govoplan-scheduling.value_responses.ba17af9a": "{value0} Antworten",
"i18n:govoplan-scheduling.what_do_these_actions_do.9a9aee0e": "Was bewirken diese Aktionen?",
"i18n:govoplan-scheduling.you_can_respond_here_or_use_the_invitation_link_you_rece.1a25fd53": "Sie können hier oder über den erhaltenen Einladungslink antworten.",
"i18n:govoplan-scheduling.your_response_has_been_recorded.b855088d": "Ihre Antwort wurde gespeichert."
}
};

View File

@@ -2,7 +2,6 @@
box-sizing: border-box;
height: calc(100vh - 115px);
min-height: 0;
padding: 0;
overflow: hidden;
color: var(--text);
background: var(--bg);
@@ -15,48 +14,87 @@
}
.scheduling-shell {
min-height: 0;
height: 100%;
display: grid;
grid-template-columns: minmax(250px, 310px) minmax(0, 1fr);
grid-template-columns: minmax(300px, 360px) minmax(0, 1fr);
width: 100%;
height: 100%;
min-height: 0;
overflow: hidden;
border: var(--border-line);
background: var(--panel);
overflow: hidden;
}
.scheduling-sidebar {
.scheduling-sidebar,
.scheduling-workspace,
.scheduling-full-editor {
min-width: 0;
min-height: 0;
display: flex;
flex-direction: column;
}
.scheduling-sidebar {
border-right: var(--border-line);
background: var(--panel-soft);
}
.scheduling-sidebar-bar,
.scheduling-topbar,
.scheduling-panel-heading,
.scheduling-mini-heading,
.scheduling-status-row,
.scheduling-page-header {
min-height: 58px;
padding: 10px 14px;
border-bottom: var(--border-line);
background: var(--panel-header);
}
.scheduling-sidebar-bar,
.scheduling-topbar,
.scheduling-page-header,
.scheduling-section-heading,
.scheduling-sidebar-actions,
.scheduling-page-actions,
.scheduling-card-actions,
.scheduling-actions,
.scheduling-title-line {
.scheduling-title-line,
.scheduling-page-title,
.scheduling-status-row {
display: flex;
align-items: center;
gap: 8px;
}
.scheduling-sidebar-bar {
.scheduling-sidebar-bar,
.scheduling-topbar,
.scheduling-page-header,
.scheduling-section-heading {
justify-content: space-between;
min-height: 54px;
padding: 10px 12px;
border-bottom: var(--border-line);
background: var(--panel-header);
}
.scheduling-sidebar-actions,
.scheduling-page-actions,
.scheduling-card-actions,
.scheduling-actions {
flex-wrap: wrap;
justify-content: flex-end;
}
.scheduling-sidebar-actions .btn,
.scheduling-page-actions .btn,
.scheduling-card-actions .btn,
.scheduling-actions .btn,
.scheduling-section-heading .btn,
.scheduling-response-card .btn {
min-height: 34px;
display: inline-flex;
align-items: center;
gap: 6px;
}
.scheduling-icon-button.btn {
width: 34px;
min-width: 34px;
height: 34px;
min-height: 34px;
padding: 0;
justify-content: center;
}
@@ -67,30 +105,61 @@
padding: 8px;
}
.scheduling-list-group + .scheduling-list-group {
margin-top: 14px;
padding-top: 12px;
border-top: var(--border-line);
}
.scheduling-list-group h2 {
margin: 0 8px 6px;
color: var(--muted);
font-size: 12px;
font-weight: 800;
letter-spacing: .025em;
text-transform: uppercase;
}
.scheduling-list-item {
width: 100%;
min-height: 50px;
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
gap: 8px;
gap: 10px;
align-items: center;
min-height: 40px;
margin: 0 0 4px;
padding: 8px 9px;
border: 0;
border-radius: 6px;
padding: 8px 10px;
border: 1px solid transparent;
border-radius: 7px;
color: var(--text);
background: transparent;
text-align: left;
cursor: pointer;
}
.scheduling-list-item:hover,
.scheduling-list-item.is-selected {
.scheduling-list-item:hover {
background: var(--hover-bg);
}
.scheduling-list-item span,
.scheduling-slot-row strong,
.scheduling-list-item.is-selected {
border-color: color-mix(in srgb, var(--accent) 45%, transparent);
background: color-mix(in srgb, var(--accent) 10%, var(--panel));
}
.scheduling-list-item > span,
.scheduling-title-line > div,
.scheduling-page-title > div,
.scheduling-section-heading > div {
min-width: 0;
}
.scheduling-list-item > span {
display: grid;
gap: 3px;
}
.scheduling-list-item strong,
.scheduling-list-item small,
.scheduling-compact-row span {
min-width: 0;
overflow: hidden;
@@ -99,121 +168,110 @@
}
.scheduling-list-item small,
.scheduling-slot-row small,
.scheduling-compact-row small,
.scheduling-note {
.scheduling-title-line small,
.scheduling-page-title p,
.scheduling-section-heading p,
.scheduling-note,
.scheduling-list-empty,
.scheduling-compact-row small {
color: var(--muted);
font-size: 12px;
}
.scheduling-list-status {
max-width: 110px;
padding: 3px 7px;
border-radius: 999px;
background: var(--panel);
font-weight: 700;
}
.scheduling-list-empty {
margin: 3px 8px 8px;
}
.scheduling-workspace {
min-width: 0;
min-height: 0;
display: flex;
flex-direction: column;
overflow: hidden;
}
.scheduling-topbar {
justify-content: space-between;
min-height: 54px;
padding: 9px 12px;
border-bottom: var(--border-line);
background: var(--panel-header);
}
.scheduling-title-line {
min-width: 180px;
min-width: 0;
}
.scheduling-actions {
flex-wrap: wrap;
justify-content: flex-end;
.scheduling-title-line > div {
display: grid;
gap: 2px;
}
.scheduling-actions .btn,
.scheduling-create-panel .btn,
.scheduling-slot-row .btn {
min-height: 32px;
display: inline-flex;
align-items: center;
gap: 6px;
.scheduling-alert,
.scheduling-success {
margin: 10px 14px 0;
padding: 9px 11px;
border: var(--border-line);
border-radius: 7px;
}
.scheduling-alert {
margin: 8px 12px 0;
padding: 8px 10px;
border: var(--border-line);
border-color: var(--danger);
border-radius: 6px;
color: var(--danger);
background: var(--danger-soft);
}
.scheduling-content {
min-height: 0;
display: grid;
grid-template-columns: minmax(280px, 360px) minmax(0, 1fr);
gap: 0;
overflow: hidden;
.scheduling-success {
border-color: var(--success);
color: var(--success);
background: color-mix(in srgb, var(--success) 10%, var(--panel));
}
.scheduling-create-panel,
.scheduling-detail {
.scheduling-detail,
.scheduling-editor-scroll {
min-height: 0;
overflow: auto;
padding: 12px;
padding: 14px;
}
.scheduling-create-panel {
.scheduling-detail {
display: grid;
align-content: start;
gap: 10px;
border-right: var(--border-line);
background: var(--panel-soft);
gap: 12px;
}
.scheduling-create-panel label,
.scheduling-form-section {
display: grid;
gap: 5px;
}
.scheduling-create-panel label span,
.scheduling-mini-heading span {
color: var(--muted);
font-size: 12px;
font-weight: 700;
}
.scheduling-create-panel input {
.scheduling-card {
width: 100%;
min-height: 34px;
padding: 6px 8px;
min-width: 0;
padding: 14px;
border: var(--border-line);
border-radius: 6px;
color: var(--text);
background: var(--input-bg);
border-radius: 8px;
background: var(--panel);
}
.scheduling-checkbox {
grid-template-columns: auto minmax(0, 1fr);
align-items: center;
.scheduling-card h2,
.scheduling-page-header h1,
.scheduling-section-heading h2 {
margin: 0;
color: var(--text-strong);
}
.scheduling-slot-draft,
.scheduling-participant-draft {
display: grid;
gap: 6px;
.scheduling-card h2,
.scheduling-section-heading h2 {
font-size: 15px;
}
.scheduling-slot-draft {
grid-template-columns: minmax(0, 1fr);
.scheduling-page-header h1 {
font-size: 19px;
}
.scheduling-page-title p,
.scheduling-section-heading p {
margin: 3px 0 0;
}
.scheduling-summary-card > p:last-child {
margin-bottom: 0;
}
.scheduling-status-row {
flex-wrap: wrap;
margin-bottom: 10px;
}
.scheduling-status {
@@ -222,7 +280,7 @@
color: var(--text-strong);
background: var(--panel-soft);
font-size: 12px;
font-weight: 700;
font-weight: 800;
}
.scheduling-status-collecting,
@@ -230,33 +288,76 @@
background: color-mix(in srgb, var(--accent) 18%, transparent);
}
.scheduling-slot-table {
display: grid;
gap: 6px;
.scheduling-table-card {
padding: 0;
overflow: hidden;
}
.scheduling-slot-row {
display: grid;
grid-template-columns: minmax(180px, 1fr) auto auto auto auto auto;
gap: 8px;
align-items: center;
min-height: 46px;
padding: 8px;
.scheduling-table-card > .scheduling-section-heading {
min-height: 52px;
padding: 9px 14px;
border-bottom: var(--border-line);
background: var(--panel-header);
}
.scheduling-slot-row:hover {
background: var(--hover-bg);
.scheduling-table-scroll {
width: 100%;
overflow: auto;
}
.scheduling-table {
width: 100%;
border-collapse: collapse;
table-layout: auto;
}
.scheduling-table th,
.scheduling-table td {
padding: 9px 10px;
border-bottom: var(--border-line);
text-align: left;
vertical-align: middle;
}
.scheduling-table tr:last-child td {
border-bottom: 0;
}
.scheduling-table th {
color: var(--muted);
background: var(--panel-soft);
font-size: 12px;
font-weight: 800;
white-space: nowrap;
}
.scheduling-table td {
font-size: 13px;
}
.scheduling-table td:first-child {
min-width: 190px;
}
.scheduling-table td > strong {
display: block;
}
.scheduling-table-actions {
width: 1%;
text-align: right !important;
white-space: nowrap;
}
.scheduling-freebusy {
min-width: 74px;
padding: 3px 7px;
border-radius: 6px;
text-align: center;
font-size: 12px;
font-weight: 700;
display: inline-block;
margin-top: 3px;
padding: 2px 6px;
border-radius: 5px;
color: var(--muted);
background: var(--panel-soft);
font-size: 11px;
font-weight: 700;
}
.scheduling-freebusy.free {
@@ -272,37 +373,218 @@
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 12px;
margin-top: 16px;
}
.scheduling-compact-row {
min-height: 36px;
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
gap: 8px;
min-height: 34px;
align-items: center;
border-bottom: var(--border-line);
}
.scheduling-empty {
padding: 20px;
.scheduling-compact-row:last-child {
border-bottom: 0;
}
.scheduling-response-card {
display: grid;
gap: 12px;
}
.scheduling-response-grid {
display: grid;
gap: 6px;
}
.scheduling-response-grid label {
min-width: 0;
display: grid;
grid-template-columns: minmax(220px, 1fr) minmax(160px, 240px);
gap: 12px;
align-items: center;
padding: 8px 0;
border-bottom: var(--border-line);
}
.scheduling-response-grid label:last-child {
border-bottom: 0;
}
.scheduling-response-grid label > span {
min-width: 0;
display: grid;
gap: 3px;
}
.scheduling-response-grid small {
color: var(--muted);
}
@media (max-width: 980px) {
.scheduling-shell,
.scheduling-content,
.scheduling-columns {
grid-template-columns: minmax(0, 1fr);
.scheduling-response-grid select,
.scheduling-field input,
.scheduling-field textarea,
.scheduling-edit-table input:not([type="checkbox"]) {
width: 100%;
min-height: 36px;
padding: 7px 9px;
border: var(--border-line);
border-radius: 6px;
color: var(--text);
background: var(--input-bg);
font: inherit;
}
.scheduling-sidebar,
.scheduling-create-panel {
.scheduling-action-help summary {
cursor: pointer;
font-weight: 700;
}
.scheduling-action-help ul {
margin-bottom: 0;
}
.scheduling-empty {
padding: 28px;
color: var(--muted);
text-align: center;
}
.scheduling-editor-page {
background: var(--panel);
}
.scheduling-full-editor {
width: 100%;
height: 100%;
overflow: hidden;
border: var(--border-line);
background: var(--bg);
}
.scheduling-page-header {
flex: 0 0 auto;
}
.scheduling-page-actions {
margin-left: auto;
}
.scheduling-editor-scroll {
display: grid;
align-content: start;
gap: 12px;
}
.scheduling-form-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 12px;
}
.scheduling-form-grid h2,
.scheduling-field-wide {
grid-column: 1 / -1;
}
.scheduling-field {
display: grid;
gap: 5px;
}
.scheduling-field > span {
color: var(--muted);
font-size: 12px;
font-weight: 800;
}
.scheduling-toggle {
display: inline-flex;
align-items: center;
gap: 7px;
font-weight: 700;
}
.scheduling-capability-note {
margin: 10px 0 0;
color: var(--muted);
font-size: 12px;
}
.scheduling-edit-table input[type="checkbox"] {
width: 18px;
height: 18px;
}
@media (max-width: 1050px) {
.scheduling-shell {
grid-template-columns: minmax(260px, 310px) minmax(0, 1fr);
}
.scheduling-sidebar-bar {
align-items: flex-start;
}
.scheduling-sidebar-actions {
align-items: flex-end;
}
}
@media (max-width: 820px) {
.scheduling-page {
height: auto;
min-height: calc(100vh - 115px);
overflow: auto;
}
.scheduling-shell {
height: auto;
grid-template-columns: minmax(0, 1fr);
overflow: visible;
}
.scheduling-sidebar {
max-height: 46vh;
border-right: 0;
border-bottom: var(--border-line);
}
.scheduling-slot-row {
grid-template-columns: minmax(0, 1fr) auto;
.scheduling-workspace,
.scheduling-full-editor {
overflow: visible;
}
.scheduling-detail,
.scheduling-editor-scroll {
overflow: visible;
}
.scheduling-columns,
.scheduling-form-grid {
grid-template-columns: minmax(0, 1fr);
}
.scheduling-page-header,
.scheduling-topbar,
.scheduling-section-heading {
align-items: flex-start;
flex-wrap: wrap;
}
.scheduling-page-actions,
.scheduling-card-actions,
.scheduling-actions {
width: 100%;
}
.scheduling-page-actions .btn:last-child,
.scheduling-card-actions .btn:last-child,
.scheduling-actions .btn:last-child {
margin-left: auto;
}
.scheduling-response-grid label {
grid-template-columns: minmax(0, 1fr);
}
}

View File

@@ -0,0 +1,111 @@
import assert from "node:assert/strict";
import test from "node:test";
import type { SchedulingRequest } from "../src/api/scheduling.ts";
import {
groupSchedulingRequests,
schedulingSortPhase,
type SchedulingActor
} from "../src/features/scheduling/schedulingViewModel.ts";
const now = new Date("2026-07-20T10:00:00Z");
const actor: SchedulingActor = {
accountId: "account-1",
membershipId: "membership-1",
email: "person@example.test"
};
function request(
id: string,
options: {
organizer?: string;
participantStatus?: string;
status?: SchedulingRequest["status"];
start?: string;
end?: string;
} = {}
): SchedulingRequest {
return {
id,
tenant_id: "tenant-1",
title: id,
timezone: "UTC",
status: options.status ?? "collecting",
organizer_user_id: options.organizer ?? "organizer-elsewhere",
allow_external_participants: true,
allow_participant_updates: true,
result_visibility: "after_close",
calendar_integration_enabled: false,
calendar_freebusy_enabled: false,
calendar_hold_enabled: false,
create_calendar_event_on_decision: false,
created_at: "2026-07-01T00:00:00Z",
updated_at: "2026-07-01T00:00:00Z",
metadata: {},
slots: [{
id: `slot-${id}`,
label: id,
start_at: options.start ?? "2026-07-21T09:00:00Z",
end_at: options.end ?? "2026-07-21T10:00:00Z",
timezone: "UTC",
position: 0,
freebusy_conflicts: [],
metadata: {}
}],
participants: options.participantStatus ? [{
id: `participant-${id}`,
respondent_id: "membership-1",
email: "person@example.test",
participant_type: "internal",
required: true,
status: options.participantStatus,
metadata: {}
}] : []
};
}
test("groups owned, invited, and administrator-only requests without hiding any", () => {
const groups = groupSchedulingRequests([
request("managed"),
request("mine", { organizer: "account-1" }),
request("invited", { participantStatus: "invited" })
], actor, now);
assert.deepEqual(groups.owned.map((item) => item.id), ["mine"]);
assert.deepEqual(groups.invited.map((item) => item.id), ["invited"]);
assert.deepEqual(groups.other.map((item) => item.id), ["managed"]);
});
test("keeps an organizer's active request in the open phase even if they also responded", () => {
const owned = request("mine-and-invited", {
organizer: "account-1",
participantStatus: "responded"
});
assert.equal(schedulingSortPhase(owned, actor, now), "unanswered");
});
test("orders unanswered by nearest slot before answered, closed, determined, and past", () => {
const groups = groupSchedulingRequests([
request("past", {
participantStatus: "invited",
start: "2026-07-18T09:00:00Z",
end: "2026-07-18T10:00:00Z"
}),
request("determined", { participantStatus: "invited", status: "decided" }),
request("closed", { participantStatus: "invited", status: "closed" }),
request("answered", { participantStatus: "responded" }),
request("later", { participantStatus: "invited", start: "2026-07-23T09:00:00Z" }),
request("nearer", { participantStatus: "invited", start: "2026-07-21T09:00:00Z" })
], actor, now);
assert.deepEqual(groups.invited.map((item) => item.id), [
"nearer",
"later",
"answered",
"closed",
"determined",
"past"
]);
assert.equal(schedulingSortPhase(groups.invited[0], actor, now), "unanswered");
assert.equal(schedulingSortPhase(groups.invited.at(-1)!, actor, now), "past");
});