feat(t-paliad-123): apply date-bucket Status filter to appointments
Until now, /events hid the Status dropdown when Type=Termine. The
date-bucket filters (Heute, Diese Woche, Nächste Woche, Später) only
worked on the deadline rail — m wanted them on appointments too, even
without a "completed" dimension.
Frontend (events.ts):
- New populateStatusFilter() rebuilds the Status <select> options based
on currentType: deadlines get the full 8-option set, appointments
narrow to 5 (Alle + 4 buckets). The "completed/pending/overdue"
options drop because they have no appointment analogue.
- applyTypeVisibility() no longer hides the Status filter for
appointments; it calls the populator instead. The populator runs on
type-chip click and on language change so labels translate live.
- When switching type while a now-invalid status is selected (e.g.
Termine + status=completed via URL), the populator falls back to the
per-type default (deadline → pending, appointment → all) and updates
URL params.
- syncURLParams + isFilterPristine + initFilters use a per-type default
so the appointment view treats `all` as pristine and stays out of the
URL until the user picks a bucket.
- loadList always sends `status` to /api/events; backend already
applies bucket-aware appointment filtering via
bucketAppointmentWindow().
events.tsx:
- The static <option> list collapses to a single placeholder; the
populator owns the option set at hydration.
i18n:
- New `events.filter.status.all` ("Alle"/"All") for the appointment-only
case — `deadlines.filter.all` says "Alle (offen & erledigt)" which is
wrong for appointments (they don't have a completed/pending state).
Backend (event_service_test.go):
- Three new live subtests confirming type=appointment + status=today
narrows to today's appointments, status=later narrows to far-future,
and status=completed collapses the appointment rail (defensive vs.
URL-hacking — the dropdown excludes that value for appointments).
This commit is contained in:
@@ -88,6 +88,40 @@ interface Me {
|
||||
|
||||
const PERSONAL = "__personal__";
|
||||
|
||||
// Status options per Type. Deadlines carry the full set; appointments
|
||||
// only get the date-bucket subset (no pending/overdue/completed — those
|
||||
// are deadline-only concepts). type=all reuses the deadline set so users
|
||||
// keep all deadline-side filters available in Beides mode.
|
||||
type StatusOption = { value: string; key: string };
|
||||
|
||||
const STATUS_OPTIONS_DEADLINE: StatusOption[] = [
|
||||
{ value: "all", key: "deadlines.filter.all" },
|
||||
{ value: "pending", key: "deadlines.filter.pending" },
|
||||
{ value: "overdue", key: "deadlines.filter.overdue" },
|
||||
{ value: "today", key: "deadlines.filter.today" },
|
||||
{ value: "this_week", key: "deadlines.filter.thisweek" },
|
||||
{ value: "next_week", key: "deadlines.filter.nextweek" },
|
||||
{ value: "later", key: "deadlines.filter.later" },
|
||||
{ value: "completed", key: "deadlines.filter.completed" },
|
||||
];
|
||||
|
||||
const STATUS_OPTIONS_APPOINTMENT: StatusOption[] = [
|
||||
{ value: "all", key: "events.filter.status.all" },
|
||||
{ value: "today", key: "deadlines.filter.today" },
|
||||
{ value: "this_week", key: "deadlines.filter.thisweek" },
|
||||
{ value: "next_week", key: "deadlines.filter.nextweek" },
|
||||
{ value: "later", key: "deadlines.filter.later" },
|
||||
];
|
||||
|
||||
function statusOptionsFor(type: EventTypeChoice): StatusOption[] {
|
||||
if (type === "appointment") return STATUS_OPTIONS_APPOINTMENT;
|
||||
return STATUS_OPTIONS_DEADLINE;
|
||||
}
|
||||
|
||||
function defaultStatusFor(type: EventTypeChoice): string {
|
||||
return type === "appointment" ? "all" : "pending";
|
||||
}
|
||||
|
||||
let currentType: EventTypeChoice = "deadline";
|
||||
let currentView: EventView = "cards";
|
||||
let statusFilter = "pending";
|
||||
@@ -315,10 +349,13 @@ async function loadList() {
|
||||
try {
|
||||
const params = new URLSearchParams();
|
||||
params.set("type", currentType);
|
||||
if (currentType !== "appointment" && statusFilter) {
|
||||
// Status filter is deadline-only. In Beides mode the status still
|
||||
// maps onto the deadline side and the EventService handles bucket-
|
||||
// aware appointment filtering for Heute/Diese Woche/etc.
|
||||
if (statusFilter) {
|
||||
// Status carries either a deadline filter (pending/overdue/completed,
|
||||
// which only make sense when deadlines are in scope) or a date-bucket
|
||||
// (today/this_week/next_week/later) which the backend applies to
|
||||
// both rails. EventService.bucketAppointmentWindow turns bucket
|
||||
// values into a start_at window; non-bucket values (all/"") are a
|
||||
// no-op on the appointment side.
|
||||
params.set("status", statusFilter);
|
||||
}
|
||||
if (projectFilter && projectFilter !== PERSONAL) {
|
||||
@@ -686,7 +723,7 @@ function wireRowHandlers(tbody: HTMLElement) {
|
||||
|
||||
function isFilterPristine(): boolean {
|
||||
return (
|
||||
statusFilter === "pending" &&
|
||||
statusFilter === defaultStatusFor(currentType) &&
|
||||
!projectFilter &&
|
||||
!appointmentTypeFilter &&
|
||||
(eventTypeFilter?.toQueryValue() ?? "") === ""
|
||||
@@ -714,9 +751,12 @@ function applyTypeVisibility() {
|
||||
toggleDisplay("events-empty-cta-deadline", isDeadline || isAll, "inline-flex");
|
||||
toggleDisplay("events-empty-cta-appointment", isAppointment || isAll, "inline-flex");
|
||||
|
||||
// Status filter is deadline-only.
|
||||
toggleFilterPair("events-filter-status", !isAppointment);
|
||||
// Event-type multi-select also deadline-only (appointments have no event_types).
|
||||
// Status filter applies to all types — for appointments the option set
|
||||
// narrows to date buckets only (Heute / Diese Woche / Nächste Woche /
|
||||
// Später / Alle). populateStatusFilter handles the option swap and may
|
||||
// reset statusFilter when switching from a deadline-only value.
|
||||
populateStatusFilter();
|
||||
// Event-type multi-select is deadline-only (appointments have no event_types).
|
||||
toggleFilterPair("events-filter-event-type", !isAppointment);
|
||||
// The panel is a popup the trigger owns via `panel.hidden`. Never stamp
|
||||
// `display: block` on it from the type filter — that overrides the
|
||||
@@ -800,7 +840,7 @@ function syncURLParams() {
|
||||
// surface in the URL when the user opted into something else.
|
||||
if (currentType !== defaultType()) url.searchParams.set("type", currentType);
|
||||
if (currentView !== "cards") url.searchParams.set("view", currentView);
|
||||
if (currentType !== "appointment" && statusFilter && statusFilter !== "pending") {
|
||||
if (statusFilter && statusFilter !== defaultStatusFor(currentType)) {
|
||||
url.searchParams.set("status", statusFilter);
|
||||
}
|
||||
if (projectFilter) url.searchParams.set("project_id", projectFilter);
|
||||
@@ -814,6 +854,26 @@ function syncURLParams() {
|
||||
window.history.replaceState(null, "", url.toString());
|
||||
}
|
||||
|
||||
// populateStatusFilter rebuilds the Status `<select>` options for the
|
||||
// current type. Called from applyTypeVisibility (type chip click +
|
||||
// initial paint) and onLangChange (option labels are localised). When
|
||||
// the previously selected status isn't valid for the new option set
|
||||
// (e.g. user had `completed` selected and switches to Termine), it
|
||||
// falls back to the per-type default and updates the URL params.
|
||||
function populateStatusFilter() {
|
||||
const sel = document.getElementById("events-filter-status") as HTMLSelectElement | null;
|
||||
if (!sel) return;
|
||||
const opts = statusOptionsFor(currentType);
|
||||
sel.innerHTML = opts
|
||||
.map((o) => `<option value="${esc(o.value)}">${esc(t(o.key))}</option>`)
|
||||
.join("");
|
||||
if (!opts.some((o) => o.value === statusFilter)) {
|
||||
statusFilter = defaultStatusFor(currentType);
|
||||
syncURLParams();
|
||||
}
|
||||
sel.value = statusFilter;
|
||||
}
|
||||
|
||||
function populateProjectFilter() {
|
||||
const sel = document.getElementById("events-filter-project") as HTMLSelectElement;
|
||||
const options: string[] = [
|
||||
@@ -836,7 +896,13 @@ function initFilters() {
|
||||
if (rawView === "cards" || rawView === "list" || rawView === "calendar") {
|
||||
currentView = rawView;
|
||||
}
|
||||
if (params.has("status")) statusFilter = params.get("status")!;
|
||||
// Default depends on currentType: appointments default to "all" (no
|
||||
// bucket filter), deadlines default to "pending". URL value wins.
|
||||
if (params.has("status")) {
|
||||
statusFilter = params.get("status")!;
|
||||
} else {
|
||||
statusFilter = defaultStatusFor(currentType);
|
||||
}
|
||||
if (params.has("project_id")) projectFilter = params.get("project_id")!;
|
||||
if (params.has("type_filter")) appointmentTypeFilter = params.get("type_filter")!;
|
||||
|
||||
|
||||
@@ -1122,6 +1122,7 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
"events.toggle.deadline": "Fristen",
|
||||
"events.toggle.appointment": "Termine",
|
||||
"events.toggle.all": "Beides",
|
||||
"events.filter.status.all": "Alle",
|
||||
"events.summary.later": "Sp\u00e4ter",
|
||||
"events.col.date": "Datum",
|
||||
"events.col.location": "Ort",
|
||||
@@ -2625,6 +2626,7 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
"events.toggle.deadline": "Deadlines",
|
||||
"events.toggle.appointment": "Appointments",
|
||||
"events.toggle.all": "Both",
|
||||
"events.filter.status.all": "All",
|
||||
"events.summary.later": "Later",
|
||||
"events.col.date": "Date",
|
||||
"events.col.location": "Location",
|
||||
|
||||
@@ -172,14 +172,11 @@ export function renderEvents(): string {
|
||||
<div className="filter-group">
|
||||
<label className="filter-label" htmlFor="events-filter-status" data-i18n="deadlines.filter.status">Status</label>
|
||||
<select id="events-filter-status" className="entity-select">
|
||||
<option value="all" data-i18n="deadlines.filter.all">Alle (offen & erledigt)</option>
|
||||
{/* Options are populated at hydration by populateStatusFilter */}
|
||||
{/* in client/events.ts — the active set depends on the */}
|
||||
{/* current Type chip (deadlines vs appointments). The static */}
|
||||
{/* placeholder keeps the select non-empty before JS runs. */}
|
||||
<option value="pending" data-i18n="deadlines.filter.pending">Alle offenen</option>
|
||||
<option value="overdue" data-i18n="deadlines.filter.overdue">Überfällig</option>
|
||||
<option value="today" data-i18n="deadlines.filter.today">Heute</option>
|
||||
<option value="this_week" data-i18n="deadlines.filter.thisweek">Diese Woche</option>
|
||||
<option value="next_week" data-i18n="deadlines.filter.nextweek">Nächste Woche</option>
|
||||
<option value="later" data-i18n="deadlines.filter.later">Später</option>
|
||||
<option value="completed" data-i18n="deadlines.filter.completed">Erledigt</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -841,6 +841,7 @@ export type I18nKey =
|
||||
| "events.empty.filtered"
|
||||
| "events.empty.hint"
|
||||
| "events.empty.title"
|
||||
| "events.filter.status.all"
|
||||
| "events.row.type.appointment"
|
||||
| "events.row.type.deadline"
|
||||
| "events.summary.later"
|
||||
|
||||
Reference in New Issue
Block a user