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:
m
2026-05-04 18:56:25 +02:00
parent 0587fc2296
commit 1bba9cb3ce
5 changed files with 150 additions and 17 deletions

View File

@@ -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")!;

View File

@@ -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",

View File

@@ -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 &amp; 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">&Uuml;berf&auml;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&auml;chste Woche</option>
<option value="later" data-i18n="deadlines.filter.later">Sp&auml;ter</option>
<option value="completed" data-i18n="deadlines.filter.completed">Erledigt</option>
</select>
</div>

View File

@@ -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"

View File

@@ -307,6 +307,73 @@ func TestEventService_ListAndSummary_Live(t *testing.T) {
}
})
// t-paliad-123: the date-bucket Status filter (today/this_week/next_week/
// later) must apply to appointments too, narrowing by start_at. The
// frontend exposes these bucket values in the Termine view's Status
// dropdown — backend must honour them.
t.Run("ListVisibleForUser type=appointment + status=today narrows to today's appointments", func(t *testing.T) {
rows, err := events.ListVisibleForUser(ctx, adminID, EventListFilter{
Type: EventTypeAppointment,
Status: DeadlineFilterToday,
})
if err != nil {
t.Fatalf("List appointment+today: %v", err)
}
sawA1 := false
for _, r := range rows {
if r.ID == a1 {
sawA1 = true
}
if r.ID == a2 {
t.Errorf("status=today must exclude far-future appointment %s", a2)
}
}
if !sawA1 {
t.Errorf("status=today must include today's appointment %s", a1)
}
})
t.Run("ListVisibleForUser type=appointment + status=later narrows to far-future", func(t *testing.T) {
rows, err := events.ListVisibleForUser(ctx, adminID, EventListFilter{
Type: EventTypeAppointment,
Status: DeadlineFilterLater,
})
if err != nil {
t.Fatalf("List appointment+later: %v", err)
}
sawA2 := false
for _, r := range rows {
if r.ID == a1 {
t.Errorf("status=later must exclude today's appointment %s", a1)
}
if r.ID == a2 {
sawA2 = true
}
}
if !sawA2 {
t.Errorf("status=later must include far-future appointment %s", a2)
}
})
// Defensive: deadline-only statuses (overdue, completed) collapse the
// appointment rail — useful when type=all so the appointment side
// disappears alongside the deadline filter (the dropdown excludes
// these for type=appointment, but URL-hacking still must behave).
t.Run("ListVisibleForUser type=appointment + status=completed returns no appointments", func(t *testing.T) {
rows, err := events.ListVisibleForUser(ctx, adminID, EventListFilter{
Type: EventTypeAppointment,
Status: DeadlineFilterCompleted,
})
if err != nil {
t.Fatalf("List appointment+completed: %v", err)
}
for _, r := range rows {
if r.ID == a1 || r.ID == a2 {
t.Errorf("status=completed must collapse appointments rail (got %s)", r.ID)
}
}
})
t.Run("SummaryCounts type=all has both rails populated", func(t *testing.T) {
s, err := events.SummaryCounts(ctx, adminID, EventSummaryFilter{Type: EventTypeAll, ProjectID: &projectID})
if err != nil {