feat(t-paliad-110): add shared EventsPage component + bucket-aware backend tweaks

PR-2 of the Fristen+Termine unification. Pure additive change — the existing
deadlines.tsx + appointments.tsx pages stay live; this PR introduces the new
events.tsx shell + client/events.ts runtime that PR-3 will mount onto the
two routes.

Frontend (new):
- frontend/src/events.tsx — shared shell with the 3-chip type toggle
  (Fristen / Termine / Beides), the 5-card summary row (Überfällig
  conditional + 4 universal cards), the union filter row, and the unified
  table that renders a discriminated row per type. Two header CTAs ("Neue
  Frist" + "Neuer Termin") collapse to the relevant one in single-type mode.
- frontend/src/client/events.ts — runtime. Reads window.__PALIAD_EVENTS__
  (PR-3 will inject defaultType from the Go handler), derives the rest from
  ?type query param. Card click sets status filter; the events endpoint
  takes care of bucket-aware appointment-side date windowing so both rails
  stay in sync in Beides mode. Hide-on-uniform pattern applied per column
  (rule, event_type, location, appointment_type, status, row-type chip).
- frontend/build.ts — emits events-deadlines.html + events-appointments.html
  from one renderEvents(currentPath) so each output gets the right Sidebar
  highlight; client/events.ts bundle added.
- 16 i18n keys (DE+EN): events.toggle.*, events.summary.later,
  events.col.*, events.row.type.*, events.empty.*, events.unavailable plus
  the new deadlines.summary.later / deadlines.filter.later pair for the
  Später bucket.
- CSS: --bucket-later (#1d4ed8 light / #60a5fa dark) for the Später card,
  matching events-table--hide-* column hiders, .events-row-type-chip
  styling, .event-type-chip-row spacing.

Backend tweaks (small):
- DeadlineFilterLater (`later`): pending deadlines past Mon-week-after.
  Click-target for the Später card.
- EventService.ListVisibleForUser now derives an appointment-side date
  window from a bucket-style status (today/this_week/next_week/later) so
  card clicks filter both rails consistently. Overdue/Completed exclude
  appointments entirely (no appointment analogue).
- pickLater / pickEarlier helpers intersect the bucket-derived window with
  any caller-supplied from/to.

go build/vet/test ./... clean. bun run build clean (1394 keys, IIFE prologue
guard passes).
This commit is contained in:
m
2026-05-04 13:46:33 +02:00
parent 2102dfd07d
commit fe9c1b7de2
8 changed files with 1146 additions and 12 deletions

View File

@@ -84,6 +84,7 @@ const (
DeadlineFilterToday DeadlineStatusFilter = "today"
DeadlineFilterThisWeek DeadlineStatusFilter = "this_week"
DeadlineFilterNextWeek DeadlineStatusFilter = "next_week"
DeadlineFilterLater DeadlineStatusFilter = "later"
DeadlineFilterUpcoming DeadlineStatusFilter = "upcoming"
DeadlineFilterCompleted DeadlineStatusFilter = "completed"
DeadlineFilterPending DeadlineStatusFilter = "pending"
@@ -143,6 +144,12 @@ func (s *DeadlineService) ListVisibleForUser(ctx context.Context, userID uuid.UU
conds = append(conds, `f.status = 'pending' AND f.due_date >= :next_monday AND f.due_date < :week_after`)
args["next_monday"] = b.nextMonday
args["week_after"] = b.weekAfter
case DeadlineFilterLater:
// "Später" — pending deadlines past next Sunday (t-paliad-110).
// The card on /deadlines clicks through to this filter; the dropdown
// also exposes it as the always-future-of-bucketed-window option.
conds = append(conds, `f.status = 'pending' AND f.due_date >= :week_after`)
args["week_after"] = b.weekAfter
case DeadlineFilterUpcoming:
// Back-compat: "upcoming" used to mean "anything pending past this week".
// Kept so legacy bookmarks / third-party links don't 4xx; the new UI

View File

@@ -126,18 +126,33 @@ func (s *EventService) ListVisibleForUser(ctx context.Context, userID uuid.UUID,
}
if wantAppointments {
af := AppointmentListFilter{
ProjectID: filter.ProjectID,
From: filter.From,
To: filter.To,
Type: filter.AppointmentType,
}
rows, err := s.appointments.ListVisibleForUser(ctx, userID, af)
if err != nil {
return nil, err
}
for _, r := range rows {
out = append(out, projectAppointment(r))
// Status is a deadline-only filter, but it doubles as the bucket
// click-through target on the unified events page. So when the
// caller set a bucket-style status (today / this_week / next_week
// / later), apply the matching date window to the appointment side
// too — clicking "Heute" then shows today's deadlines AND today's
// appointments. For overdue/completed (no appointment analogue),
// skip the appointment query entirely.
if shouldExcludeAppointmentsForStatus(filter.Status) {
// no-op
} else {
af := AppointmentListFilter{
ProjectID: filter.ProjectID,
From: filter.From,
To: filter.To,
Type: filter.AppointmentType,
}
bounds := computeDeadlineBucketBounds(time.Now().UTC())
from, to := bucketAppointmentWindow(filter.Status, bounds)
af.From = pickLater(af.From, from)
af.To = pickEarlier(af.To, to)
rows, err := s.appointments.ListVisibleForUser(ctx, userID, af)
if err != nil {
return nil, err
}
for _, r := range rows {
out = append(out, projectAppointment(r))
}
}
}
@@ -209,6 +224,70 @@ func projectAppointment(a models.AppointmentWithProject) EventListItem {
}
}
// shouldExcludeAppointmentsForStatus returns true for deadline-status
// values that have no appointment analogue (overdue, completed). When
// the user sets one of those, the appointment rail collapses to empty.
func shouldExcludeAppointmentsForStatus(status DeadlineStatusFilter) bool {
switch status {
case DeadlineFilterOverdue, DeadlineFilterCompleted:
return true
}
return false
}
// bucketAppointmentWindow returns the [from, to) date window that
// matches a bucket-style deadline status — used to filter the
// appointment side when the user clicks a card on the unified events
// page. Returns (nil, nil) for non-bucket statuses (pending / all /
// upcoming / "" / overdue / completed — those are handled separately).
func bucketAppointmentWindow(status DeadlineStatusFilter, b deadlineBucketBounds) (*time.Time, *time.Time) {
switch status {
case DeadlineFilterToday:
t := b.tomorrow
return &b.today, &t
case DeadlineFilterThisWeek:
t := b.nextMonday
return &b.tomorrow, &t
case DeadlineFilterNextWeek:
t := b.weekAfter
return &b.nextMonday, &t
case DeadlineFilterLater:
return &b.weekAfter, nil
}
return nil, nil
}
// pickLater returns whichever of the two times is later (nil propagates
// the non-nil value; both nil → nil). Used to intersect bucket-derived
// windows with caller-supplied from filters.
func pickLater(a, b *time.Time) *time.Time {
if a == nil {
return b
}
if b == nil {
return a
}
if a.After(*b) {
return a
}
return b
}
// pickEarlier returns whichever of the two times is earlier (nil
// propagates the non-nil value; both nil → nil).
func pickEarlier(a, b *time.Time) *time.Time {
if a == nil {
return b
}
if b == nil {
return a
}
if a.Before(*b) {
return a
}
return b
}
// inDateWindow returns true when due is inside [from, to]. Both ends are
// optional. The deadline ListFilter has no date-range support today, so we
// post-filter in memory — fine because the per-user deadline set is small.