Compare commits

...

6 Commits

5 changed files with 369 additions and 246 deletions

View File

@@ -6,6 +6,8 @@ Create Date: 2026-07-12 00:00:00.000000
"""
from __future__ import annotations
from typing import Any
from alembic import op
import sqlalchemy as sa
@@ -55,6 +57,62 @@ _NOTIFICATION_INDEXES = {
}
def _slot_extension_problems(inspector: Any, slot_columns: set[str]) -> list[str]:
problems: list[str] = []
missing_columns = _CALENDAR_SLOT_COLUMNS - slot_columns
if missing_columns:
problems.append(
"scheduling_candidate_slots missing columns: "
f"{', '.join(sorted(missing_columns))}"
)
indexes = {item["name"] for item in inspector.get_indexes("scheduling_candidate_slots")}
missing_indexes = _CALENDAR_SLOT_INDEXES - indexes
if missing_indexes:
problems.append(
"scheduling_candidate_slots missing indexes: "
f"{', '.join(sorted(missing_indexes))}"
)
return problems
def _has_cascading_notification_request_foreign_key(inspector: Any) -> bool:
return any(
tuple(item.get("constrained_columns") or ()) == ("request_id",)
and item.get("referred_table") == "scheduling_requests"
and tuple(item.get("referred_columns") or ()) == ("id",)
and str((item.get("options") or {}).get("ondelete", "")).upper() == "CASCADE"
for item in inspector.get_foreign_keys("scheduling_notifications")
)
def _notification_extension_problems(inspector: Any) -> list[str]:
problems: list[str] = []
columns = {item["name"] for item in inspector.get_columns("scheduling_notifications")}
missing_columns = _NOTIFICATION_COLUMNS - columns
if missing_columns:
problems.append(
"scheduling_notifications missing columns: "
f"{', '.join(sorted(missing_columns))}"
)
indexes = {item["name"] for item in inspector.get_indexes("scheduling_notifications")}
missing_indexes = _NOTIFICATION_INDEXES - indexes
if missing_indexes:
problems.append(
"scheduling_notifications missing indexes: "
f"{', '.join(sorted(missing_indexes))}"
)
primary_key = tuple(
inspector.get_pk_constraint("scheduling_notifications").get("constrained_columns") or ()
)
if primary_key != ("id",):
problems.append(f"scheduling_notifications has unexpected primary key: {primary_key!r}")
if not _has_cascading_notification_request_foreign_key(inspector):
problems.append("scheduling_notifications is missing its cascading request_id foreign key")
return problems
def _adopt_existing_calendar_notifications() -> bool:
"""Adopt only the complete calendar/notification extension."""
@@ -71,59 +129,11 @@ def _adopt_existing_calendar_notifications() -> bool:
if not present_slot_columns and not notification_exists:
return False
problems: list[str] = []
missing_slot_columns = _CALENDAR_SLOT_COLUMNS - slot_columns
if missing_slot_columns:
problems.append(
"scheduling_candidate_slots missing columns: "
f"{', '.join(sorted(missing_slot_columns))}"
)
slot_indexes = {item["name"] for item in inspector.get_indexes("scheduling_candidate_slots")}
missing_slot_indexes = _CALENDAR_SLOT_INDEXES - slot_indexes
if missing_slot_indexes:
problems.append(
"scheduling_candidate_slots missing indexes: "
f"{', '.join(sorted(missing_slot_indexes))}"
)
problems = _slot_extension_problems(inspector, slot_columns)
if not notification_exists:
problems.append("missing table: scheduling_notifications")
else:
notification_columns = {
item["name"] for item in inspector.get_columns("scheduling_notifications")
}
missing_notification_columns = _NOTIFICATION_COLUMNS - notification_columns
if missing_notification_columns:
problems.append(
"scheduling_notifications missing columns: "
f"{', '.join(sorted(missing_notification_columns))}"
)
notification_indexes = {
item["name"] for item in inspector.get_indexes("scheduling_notifications")
}
missing_notification_indexes = _NOTIFICATION_INDEXES - notification_indexes
if missing_notification_indexes:
problems.append(
"scheduling_notifications missing indexes: "
f"{', '.join(sorted(missing_notification_indexes))}"
)
primary_key = tuple(
inspector.get_pk_constraint("scheduling_notifications").get("constrained_columns") or ()
)
if primary_key != ("id",):
problems.append(f"scheduling_notifications has unexpected primary key: {primary_key!r}")
request_foreign_key_exists = any(
tuple(item.get("constrained_columns") or ()) == ("request_id",)
and item.get("referred_table") == "scheduling_requests"
and tuple(item.get("referred_columns") or ()) == ("id",)
and str((item.get("options") or {}).get("ondelete", "")).upper() == "CASCADE"
for item in inspector.get_foreign_keys("scheduling_notifications")
)
if not request_foreign_key_exists:
problems.append("scheduling_notifications is missing its cascading request_id foreign key")
problems.extend(_notification_extension_problems(inspector))
if problems:
detail = "; ".join(problems)

View File

@@ -2,8 +2,9 @@ from __future__ import annotations
import hashlib
import json
from dataclasses import dataclass
from datetime import datetime, timezone
from typing import Any
from typing import Any, cast
from sqlalchemy.orm import Session, object_session
@@ -787,16 +788,9 @@ def list_visible_scheduling_notifications(
return visible
def refresh_participant_response_state(
session: Session,
*,
request: SchedulingRequest,
actor_ids: tuple[str, ...] = (),
reset_missing: bool = False,
) -> None:
if request.poll_id is None:
return
participants = _active_participants(request)
def _participant_response_indexes(
participants: list[SchedulingParticipant],
) -> tuple[dict[str, SchedulingParticipant], dict[str, SchedulingParticipant]]:
by_invitation_id = {
participant.poll_invitation_id: participant
for participant in participants
@@ -808,6 +802,97 @@ def refresh_participant_response_state(
for identity in (participant.respondent_id, stored_identity):
if isinstance(identity, str) and identity:
by_respondent_id[identity] = participant
return by_invitation_id, by_respondent_id
def _response_participant(
response: PollResponseRef,
*,
by_invitation_id: dict[str, SchedulingParticipant],
by_respondent_id: dict[str, SchedulingParticipant],
actor_ids: set[str],
actor_participants: list[SchedulingParticipant],
) -> SchedulingParticipant | None:
participant = by_invitation_id.get(response.invitation_id)
if participant is None and response.respondent_id is not None:
participant = by_respondent_id.get(response.respondent_id)
# Poll deliberately ignores client-supplied invitation identifiers on
# authenticated submissions. While reconciling for that same actor, map
# the server-authenticated respondent to their unique Scheduling row.
if participant is None and response.respondent_id in actor_ids and len(actor_participants) == 1:
return actor_participants[0]
return participant
def _apply_participant_response(
session: Session,
*,
request: SchedulingRequest,
participant: SchedulingParticipant,
response: PollResponseRef,
) -> bool:
changed = False
if response.respondent_id:
metadata = participant.metadata_ or {}
if metadata.get("poll_response_respondent_id") != response.respondent_id:
participant.metadata_ = {
**metadata,
"poll_response_respondent_id": response.respondent_id,
}
changed = True
if participant.status == "responded":
return changed
participant.status = "responded"
participant.responded_at = response_datetime(response.submitted_at)
_emit_scheduling_center_notification(
session,
request=request,
participant=participant,
event_kind="scheduling.participant_responded",
subject=f"Scheduling response: {request.title}",
body_text=f"{participant.display_name or participant.email or 'A participant'} responded to {request.title}.",
)
return True
def _reset_missing_participant_responses(
participants: list[SchedulingParticipant],
*,
matched_participant_ids: set[str],
unmatched_response_count: int,
) -> bool:
changed = False
for participant in participants:
if participant.status != "responded" or participant.id in matched_participant_ids:
continue
stored_identity = (participant.metadata_ or {}).get("poll_response_respondent_id")
if not isinstance(stored_identity, str) and unmatched_response_count:
# Preserve an older projection while an active response exists
# that cannot yet be mapped safely to a participant.
continue
participant.status = "invited" if participant.poll_invitation_id else "draft"
participant.responded_at = None
if isinstance(stored_identity, str):
participant.metadata_ = {
key: value
for key, value in (participant.metadata_ or {}).items()
if key != "poll_response_respondent_id"
}
changed = True
return changed
def refresh_participant_response_state(
session: Session,
*,
request: SchedulingRequest,
actor_ids: tuple[str, ...] = (),
reset_missing: bool = False,
) -> None:
if request.poll_id is None:
return
participants = _active_participants(request)
by_invitation_id, by_respondent_id = _participant_response_indexes(participants)
try:
responses = _poll_provider().list_responses(
session,
@@ -826,61 +911,29 @@ def refresh_participant_response_state(
matched_participant_ids: set[str] = set()
unmatched_response_count = 0
for response in responses:
participant = by_invitation_id.get(response.invitation_id)
if participant is None and response.respondent_id is not None:
participant = by_respondent_id.get(response.respondent_id)
# Poll deliberately ignores client-supplied invitation identifiers on
# authenticated submissions. While reconciling for that same actor,
# map the server-authenticated respondent id to their unique Scheduling
# participant row (which may have been invited by email only).
if (
participant is None
and response.respondent_id in ids
and len(actor_participants) == 1
):
participant = actor_participants[0]
participant = _response_participant(
response,
by_invitation_id=by_invitation_id,
by_respondent_id=by_respondent_id,
actor_ids=ids,
actor_participants=actor_participants,
)
if participant is None:
unmatched_response_count += 1
continue
matched_participant_ids.add(participant.id)
if response.respondent_id:
metadata = participant.metadata_ or {}
if metadata.get("poll_response_respondent_id") != response.respondent_id:
participant.metadata_ = {
**metadata,
"poll_response_respondent_id": response.respondent_id,
}
changed = True
if participant.status != "responded":
participant.status = "responded"
participant.responded_at = response_datetime(response.submitted_at)
_emit_scheduling_center_notification(
session,
request=request,
participant=participant,
event_kind="scheduling.participant_responded",
subject=f"Scheduling response: {request.title}",
body_text=f"{participant.display_name or participant.email or 'A participant'} responded to {request.title}.",
)
changed = True
if reset_missing:
for participant in participants:
if participant.status != "responded" or participant.id in matched_participant_ids:
continue
stored_identity = (participant.metadata_ or {}).get("poll_response_respondent_id")
if not isinstance(stored_identity, str) and unmatched_response_count:
# Do not reset an older projection while an active response
# exists that cannot yet be mapped safely to a participant.
continue
participant.status = "invited" if participant.poll_invitation_id else "draft"
participant.responded_at = None
if isinstance(stored_identity, str):
participant.metadata_ = {
key: value
for key, value in (participant.metadata_ or {}).items()
if key != "poll_response_respondent_id"
}
changed = True
changed = _apply_participant_response(
session,
request=request,
participant=participant,
response=response,
) or changed
if reset_missing and _reset_missing_participant_responses(
participants,
matched_participant_ids=matched_participant_ids,
unmatched_response_count=unmatched_response_count,
):
changed = True
if changed:
session.flush()
@@ -1364,6 +1417,121 @@ def update_scheduling_request(
return request
@dataclass(frozen=True, slots=True)
class _CandidateSlotChanges:
label: str
description: str | None
start_at: datetime
end_at: datetime
timezone: str
location: str | None
metadata: dict[str, Any]
normalized_start: datetime
normalized_end: datetime
def _candidate_slot_changes(
slot: SchedulingCandidateSlot,
payload: SchedulingCandidateSlotUpdateRequest,
) -> _CandidateSlotChanges:
supplied = payload.model_fields_set
for field in ("label", "start_at", "end_at", "timezone"):
if field in supplied and getattr(payload, field) is None:
raise SchedulingError(f"{field} cannot be null")
label = cast(str, payload.label if "label" in supplied else slot.label)
description = payload.description if "description" in supplied else slot.description
start_at = cast(datetime, payload.start_at if "start_at" in supplied else slot.start_at)
end_at = cast(datetime, payload.end_at if "end_at" in supplied else slot.end_at)
timezone_name = cast(str, payload.timezone if "timezone" in supplied else slot.timezone)
location = payload.location if "location" in supplied else slot.location
metadata = (payload.metadata or {}) if "metadata" in supplied else (slot.metadata_ or {})
normalized_start = cast(datetime, response_datetime(start_at))
normalized_end = cast(datetime, response_datetime(end_at))
if normalized_end <= normalized_start:
raise SchedulingError("end_at must be after start_at")
return _CandidateSlotChanges(
label=label,
description=description,
start_at=start_at,
end_at=end_at,
timezone=timezone_name,
location=location,
metadata=metadata,
normalized_start=normalized_start,
normalized_end=normalized_end,
)
def _candidate_slot_change_flags(
slot: SchedulingCandidateSlot,
changes: _CandidateSlotChanges,
) -> tuple[bool, bool]:
temporal_or_location_changed = (
changes.normalized_start != response_datetime(slot.start_at)
or changes.normalized_end != response_datetime(slot.end_at)
or changes.timezone != slot.timezone
or changes.location != slot.location
)
changed = (
changes.label != slot.label
or changes.description != slot.description
or temporal_or_location_changed
or changes.metadata != (slot.metadata_ or {})
)
return changed, temporal_or_location_changed
def _update_candidate_poll_option(
session: Session,
*,
tenant_id: str,
request: SchedulingRequest,
slot: SchedulingCandidateSlot,
changes: _CandidateSlotChanges,
) -> None:
try:
_poll_provider().update_option(
session,
tenant_id=tenant_id,
poll_id=cast(str, request.poll_id),
option_id=cast(str, slot.poll_option_id),
command=PollOptionUpdateCommand(
label=changes.label,
description=changes.description,
value={
"slot_id": slot.id,
"start_at": changes.normalized_start.isoformat(),
"end_at": changes.normalized_end.isoformat(),
"timezone": changes.timezone,
"location": changes.location,
},
metadata=changes.metadata,
),
)
except PollCapabilityError as exc:
raise SchedulingError(str(exc)) from exc
def _apply_candidate_slot_changes(
slot: SchedulingCandidateSlot,
changes: _CandidateSlotChanges,
*,
temporal_or_location_changed: bool,
) -> None:
slot.label = changes.label
slot.description = changes.description
slot.start_at = changes.start_at
slot.end_at = changes.end_at
slot.timezone = changes.timezone
slot.location = changes.location
slot.metadata_ = changes.metadata
if temporal_or_location_changed:
slot.freebusy_checked_at = None
slot.freebusy_status = None
slot.freebusy_conflicts = []
def update_scheduling_candidate_slot(
session: Session,
*,
@@ -1388,35 +1556,8 @@ def update_scheduling_candidate_slot(
if slot.poll_option_id is None:
raise SchedulingError("Scheduling slot has no backing poll option")
supplied = payload.model_fields_set
for field in ("label", "start_at", "end_at", "timezone"):
if field in supplied and getattr(payload, field) is None:
raise SchedulingError(f"{field} cannot be null")
label = payload.label if "label" in supplied else slot.label
description = payload.description if "description" in supplied else slot.description
start_at = payload.start_at if "start_at" in supplied else slot.start_at
end_at = payload.end_at if "end_at" in supplied else slot.end_at
timezone_name = payload.timezone if "timezone" in supplied else slot.timezone
location = payload.location if "location" in supplied else slot.location
metadata = (payload.metadata or {}) if "metadata" in supplied else (slot.metadata_ or {})
normalized_start = response_datetime(start_at)
normalized_end = response_datetime(end_at)
if normalized_end <= normalized_start:
raise SchedulingError("end_at must be after start_at")
temporal_or_location_changed = (
normalized_start != response_datetime(slot.start_at)
or normalized_end != response_datetime(slot.end_at)
or timezone_name != slot.timezone
or location != slot.location
)
changed = (
label != slot.label
or description != slot.description
or temporal_or_location_changed
or metadata != (slot.metadata_ or {})
)
changes = _candidate_slot_changes(slot, payload)
changed, temporal_or_location_changed = _candidate_slot_change_flags(slot, changes)
if not changed:
return request
if temporal_or_location_changed and slot.tentative_hold_event_id:
@@ -1424,27 +1565,13 @@ def update_scheduling_candidate_slot(
"A slot with a tentative calendar hold cannot change time, timezone, or location"
)
try:
_poll_provider().update_option(
session,
tenant_id=tenant_id,
poll_id=request.poll_id,
option_id=slot.poll_option_id,
command=PollOptionUpdateCommand(
label=label,
description=description,
value={
"slot_id": slot.id,
"start_at": normalized_start.isoformat(),
"end_at": normalized_end.isoformat(),
"timezone": timezone_name,
"location": location,
},
metadata=metadata,
),
)
except PollCapabilityError as exc:
raise SchedulingError(str(exc)) from exc
_update_candidate_poll_option(
session,
tenant_id=tenant_id,
request=request,
slot=slot,
changes=changes,
)
refresh_participant_response_state(
session,
@@ -1452,17 +1579,11 @@ def update_scheduling_candidate_slot(
reset_missing=True,
)
slot.label = label
slot.description = description
slot.start_at = start_at
slot.end_at = end_at
slot.timezone = timezone_name
slot.location = location
slot.metadata_ = metadata
if temporal_or_location_changed:
slot.freebusy_checked_at = None
slot.freebusy_status = None
slot.freebusy_conflicts = []
_apply_candidate_slot_changes(
slot,
changes,
temporal_or_location_changed=temporal_or_location_changed,
)
session.flush()
return request

View File

@@ -12,6 +12,7 @@ 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, /Card,[\s\S]*DataGrid,[\s\S]*DataGridEmptyAction,[\s\S]*DataGridRowActions,[\s\S]*ModuleSubnav,[\s\S]*ToggleSwitch,[\s\S]*from "@govoplan\/core-webui"/);
assert.match(page, /SelectionList,[\s\S]*SelectionListItem,[\s\S]*from "@govoplan\/core-webui"/);
assert.doesNotMatch(page, /@govoplan\/core-webui\/src\//);
assert.match(page, /className="scheduling-full-editor"/);
assert.match(page, /className="scheduling-page-actions"/);
@@ -36,6 +37,9 @@ assert.match(browseBranch, /<h1>\{I18N\.requests\}<\/h1>/);
assert.match(browseBranch, /<Plus aria-hidden="true" size=\{16\} \/> \{I18N\.add\}/);
assert.match(page, /title=\{I18N\.myRequests\}/);
assert.match(page, /title=\{I18N\.invitedRequests\}/);
assert.match(page, /<SelectionList label=\{title\} className="scheduling-request-list">/);
assert.match(page, /<SelectionListItem[\s\S]*selected=\{selectedId === request\.id\}[\s\S]*className="scheduling-list-item"/);
assert.doesNotMatch(page, /<button[\s\S]{0,160}scheduling-list-item/);
assert.match(createBranch, /<Card title=\{I18N\.basicInformation\}>/);
assert.match(createBranch, /<Card title=\{I18N\.calendarIntegration\}>/);
assert.match(page, /<Card title=\{I18N\.candidateSlots\}>/);
@@ -52,7 +56,7 @@ assert.match(page, /<DataGridEmptyAction/);
assert.doesNotMatch(page, /EmailAddressInput|MailboxAddress|addressSuggestions|addressLookupQuery/);
assert.match(page, /type="email"[\s\S]*aria-label=\{I18N\.participantEmail\}/);
assert.doesNotMatch(page, /header: I18N\.required|<table|scheduling-table|scheduling-card(?:\s|"|`)/);
assert.match(page, /aria-label=\{i18nMessage\("i18n:govoplan-scheduling\.decide_on_value/);
assert.match(page, /<TableActionGroup[\s\S]*label: i18nMessage\("i18n:govoplan-scheduling\.decide_on_value/);
assert.match(page, /submitSchedulingAvailability\(settings, selected\.id/);
assert.match(page, /option_revision: slot\.revision/);
assert.match(page, /getSchedulingAvailabilityResponse\(settings, selected\.id\)/);

View File

@@ -11,12 +11,18 @@ import {
XCircle
} from "lucide-react";
import {
AdminIconButton,
Button,
Card,
DataGrid,
DataGridEmptyAction,
DataGridRowActions,
DateTimeField,
formatDateTime,
ModuleSubnav,
SelectionList,
SelectionListItem,
TableActionGroup,
ToggleSwitch,
hasScope,
i18nMessage,
@@ -26,6 +32,7 @@ import {
type AuthInfo,
type CalendarPickerUiCapability,
type DataGridColumn,
type FormatDateTimeOptions,
type ModuleSubnavGroup
} from "@govoplan/core-webui";
import {
@@ -578,9 +585,7 @@ export default function SchedulingPage({ settings, auth }: { settings: ApiSettin
<h1>{I18N.requests}</h1>
</div>
<div className="scheduling-page-actions">
<Button type="button" className="scheduling-icon-button" onClick={() => void loadRequests()} title={I18N.refresh} aria-label={I18N.refresh}>
<RefreshCw aria-hidden="true" size={16} />
</Button>
<AdminIconButton label={I18N.refresh} icon={<RefreshCw aria-hidden="true" size={16} />} onClick={() => void loadRequests()} disabled={loading} />
{canWrite ? (
<Button type="button" variant="primary" onClick={beginCreate}>
<Plus aria-hidden="true" size={16} /> {I18N.add}
@@ -809,16 +814,20 @@ function RequestGroup({
return (
<Card title={title}>
<div className="scheduling-list-group">
{requests.length ? requests.map((request) => (
<button
key={request.id}
className={`scheduling-list-item ${selectedId === request.id ? "is-selected" : ""}`}
type="button"
onClick={() => onSelect(request.id)}>
<span><strong>{request.title}</strong><small>{formatRelevantDate(request)}</small></span>
<small className="scheduling-list-status">{requestStatusLabel(request, actor)}</small>
</button>
)) : <p className="scheduling-list-empty">{I18N.noRequestsInGroup}</p>}
{requests.length ? (
<SelectionList label={title} className="scheduling-request-list">
{requests.map((request) => (
<SelectionListItem
key={request.id}
selected={selectedId === request.id}
className="scheduling-list-item"
onClick={() => onSelect(request.id)}>
<span><strong>{request.title}</strong><small>{formatRelevantDate(request)}</small></span>
<small className="scheduling-list-status">{requestStatusLabel(request, actor)}</small>
</SelectionListItem>
))}
</SelectionList>
) : <p className="scheduling-list-empty">{I18N.noRequestsInGroup}</p>}
</div>
</Card>
);
@@ -883,33 +892,31 @@ function EditableSlots({
{
id: "start",
header: I18N.start,
minWidth: 200,
minWidth: 280,
resizable: true,
value: (slot) => slot.start_at,
render: (slot, index) => (
<input
className="scheduling-grid-input"
<DateTimeField
className="scheduling-grid-date-time"
required
type="datetime-local"
aria-label={I18N.start}
value={slot.start_at}
onChange={(event) => onChange(index, "start_at", event.target.value)} />
onChange={(value) => onChange(index, "start_at", value)} />
)
},
{
id: "end",
header: I18N.end,
minWidth: 200,
minWidth: 280,
resizable: true,
value: (slot) => slot.end_at,
render: (slot, index) => (
<input
className="scheduling-grid-input"
<DateTimeField
className="scheduling-grid-date-time"
required
type="datetime-local"
aria-label={I18N.end}
value={slot.end_at}
onChange={(event) => onChange(index, "end_at", event.target.value)} />
onChange={(value) => onChange(index, "end_at", value)} />
)
},
{
@@ -1059,8 +1066,8 @@ function CandidateSlotsGrid({
</span>
)
},
{ id: "start", header: I18N.start, minWidth: 160, resizable: true, value: (slot) => slot.start_at, render: (slot) => formatDateTime(slot.start_at) },
{ id: "end", header: I18N.end, minWidth: 160, resizable: true, value: (slot) => slot.end_at, render: (slot) => formatDateTime(slot.end_at) },
{ id: "start", header: I18N.start, minWidth: 160, resizable: true, value: (slot) => slot.start_at, render: (slot) => formatDateTime(slot.start_at, SHORT_DATE_TIME_OPTIONS) },
{ id: "end", header: I18N.end, minWidth: 160, resizable: true, value: (slot) => slot.end_at, render: (slot) => formatDateTime(slot.end_at, SHORT_DATE_TIME_OPTIONS) },
{
id: "available",
header: I18N.available,
@@ -1087,17 +1094,14 @@ function CandidateSlotsGrid({
header: I18N.actions,
width: 72,
sticky: "end",
render: (slot) => canDecide ? (
<Button
className="scheduling-icon-button"
type="button"
title={i18nMessage("i18n:govoplan-scheduling.decide_on_value.196409cd", { value0: slot.label })}
aria-label={i18nMessage("i18n:govoplan-scheduling.decide_on_value.196409cd", { value0: slot.label })}
disabled={saving}
onClick={() => onDecide(slot)}>
<Check aria-hidden="true" size={16} />
</Button>
) : null
render: (slot) => <TableActionGroup actions={[{
id: "decide",
label: i18nMessage("i18n:govoplan-scheduling.decide_on_value.196409cd", { value0: slot.label }),
icon: <Check aria-hidden="true" size={16} />,
applicable: canDecide,
disabled: saving,
onClick: () => onDecide(slot)
}]} />
}
];
@@ -1233,17 +1237,24 @@ function isoFromLocal(value: string): string {
return new Date(value).toISOString();
}
function formatDateTime(value: string): string {
return new Intl.DateTimeFormat(undefined, { dateStyle: "short", timeStyle: "short" }).format(new Date(value));
}
const SHORT_DATE_TIME_OPTIONS: FormatDateTimeOptions = {
year: undefined,
month: undefined,
day: undefined,
hour: undefined,
minute: undefined,
timeZoneName: undefined,
dateStyle: "short",
timeStyle: "short"
};
function formatRange(start: string, end: string): string {
return `${formatDateTime(start)} ${formatDateTime(end)}`;
return `${formatDateTime(start, SHORT_DATE_TIME_OPTIONS)} ${formatDateTime(end, SHORT_DATE_TIME_OPTIONS)}`;
}
function formatRelevantDate(request: SchedulingRequest): string {
const timestamp = schedulingRelevantTimestamp(request);
return Number.isFinite(timestamp) ? formatDateTime(new Date(timestamp).toISOString()) : "";
return Number.isFinite(timestamp) ? formatDateTime(new Date(timestamp).toISOString(), SHORT_DATE_TIME_OPTIONS) : "";
}
function errorMessage(error: unknown, fallback: string): string {

View File

@@ -89,15 +89,6 @@
gap: 6px;
}
.scheduling-icon-button.btn {
width: 34px;
min-width: 34px;
height: 34px;
min-height: 34px;
padding: 0;
justify-content: center;
}
.scheduling-alert,
.scheduling-success {
margin: 10px 14px 0;
@@ -107,8 +98,8 @@
}
.scheduling-alert {
border-color: var(--danger);
color: var(--danger);
border-color: var(--danger-border-strong);
color: var(--danger-text-strong);
background: var(--danger-soft);
}
@@ -145,30 +136,16 @@
overflow: auto;
}
.scheduling-request-list {
gap: 4px;
}
.scheduling-list-item {
width: 100%;
min-height: 50px;
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
gap: 10px;
align-items: center;
margin: 0 0 4px;
padding: 8px 10px;
border: 1px solid transparent;
border-radius: 7px;
color: var(--text);
background: transparent;
text-align: left;
cursor: pointer;
}
.scheduling-list-item:hover {
background: var(--hover-bg);
}
.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 {
@@ -247,7 +224,7 @@
.scheduling-freebusy.busy,
.scheduling-freebusy.error {
color: var(--danger);
color: var(--danger-text-strong);
}
.scheduling-columns {
@@ -317,7 +294,7 @@
border: var(--border-line);
border-radius: 6px;
color: var(--text);
background: var(--input-bg);
background: var(--control-bg);
font: inherit;
}