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:
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user