Files
paliad/internal/services/event_service.go
mAi 045accc6d9 mAi: #89 - deadline rule field binary Auto/Custom + canonical rule-label display
t-paliad-258. m's verdict on t-paliad-251's rule UI: "too many options"
(4 'Oral hearings' across courts, etc.). Replace the full deadline_rules
catalog dropdown + sort selector with a binary model and unify the rule
display contract across every surface that prints a rule label.

Binary Rule field on the deadline form
- Auto (default): rule_id is derived from the chosen Type. The resolved
  rule renders read-only as 'Auto | <Name · Citation>' next to the
  field. No catalog picker, no sort options.
- Custom: free-text input. Stored as deadlines.custom_rule_text (new
  nullable column, migration 122). Mutually exclusive with rule_id at
  the persistence boundary.
- Toggle link flips between modes. Re-toggling to Auto re-resolves from
  the current Type — no stale state.

Schema + service (additive)
- migration 122 adds paliad.deadlines.custom_rule_text (nullable).
  Existing rows: empty custom_rule_text + non-null rule_id = Auto-
  equivalent. Both NULL = "keine Regel" (consistent with today).
- models.Deadline.CustomRuleText + service SELECTs include the column.
- CreateDeadlineInput accepts custom_rule_text; the service drops it
  when rule_id is set (catalog wins; simple invariant at the boundary).
- UpdateDeadlineInput grows a {RuleSet, RuleID, CustomRuleText} triple.
  RuleSet=true is the discriminator so absent fields don't overwrite
  the row (PATCH semantics). RuleID and CustomRuleText are mutually
  exclusive in one request; service rejects "both set".
- EventListItem (the /api/events union) carries CustomRuleText so list
  surfaces can render it.

Frontend: deadlines-new
- Drop the rule <select>, the by_proceeding/by_court/alpha sort
  dropdown, the override-warning slot, and the collapsed-by-Regel Typ
  view. Strip the (Rule→Type) auto-fill machinery — direction is now
  one-way (Type → Auto-resolved Rule).
- Keep Type→Rule resolution: resolveAutoRuleForType picks the canonical
  rule by project's proceeding, then jurisdiction match, then first
  candidate. Same logic, just re-aimed at the read-only display.
- Standardtitel preserves the chain (event type → Auto rule label →
  Custom text → proceeding → fallback) so the recipe still produces a
  sensible title even when Custom is used.

Frontend: deadlines-detail
- Read-only display: catalog rule → Name · Citation, else
  custom_rule_text + Custom badge, else legacy rule_code, else "—".
- Edit mode: mirror the create form with the Auto/Custom toggle.
  enterEdit initialises the mode from the persisted deadline; Save
  PATCHes with rule_set:true + the chosen rule pointer.

Rule-label addendum (m's 14:31 follow-up)
- Canonical contract everywhere: Name primary, Citation muted secondary
  ("Notice of Appeal · UPC.RoP.220.1"). Custom rules render the text
  with a "Custom" pill.
- New frontend/src/client/rule-label.ts exports formatRuleLabel /
  formatRuleLabelHTML / formatCustomRuleLabelHTML — one helper per
  shape (plain text vs muted-citation HTML).
- Wired into: deadlines-new Auto display, deadlines-detail read +
  Standardtitel, events.ts ruleDisplay (REGEL column on /events),
  projects-detail.ts Fristen table, views/shape-list.ts generic
  rule column.
- Verfahrensablauf (views/verfahrensablauf-core.ts) already renders
  name + citation chip separately and matches the canonical pattern;
  no change needed. Schriftsätze table is column-shaped (name + code
  in distinct columns) and out of scope per the addendum.

CSS
- New .rule-mode-auto / .rule-mode-custom / .rule-label-* family.
- Drop the dead .rule-sort-select rule and the .event-type-collapsed*
  family (retired with the catalog dropdown).

i18n
- DE+EN. Remove 10 stale keys (rule.none, autofill, autofill_inline,
  mismatch, override, override_warn, sort.*). Add 6 (auto_no_match,
  auto_pick_type, custom_badge, custom_placeholder,
  mode.toggle_to_auto, mode.toggle_to_custom).

Build hygiene
- go build + go test ./internal/... clean.
- frontend bun build clean (2803 keys, scan clean).

Out of scope (per issue)
- Promoting Custom entries back to the catalog ("save as new rule").
- Filtering/searching custom_rule_text in deadline lists.
- Touching the event-type browse modal (Part 1 of #82 — that stays).

Files
- internal/db/migrations/122_deadlines_custom_rule_text.{up,down}.sql
- internal/models/models.go
- internal/services/deadline_service.go (Create+Update+SELECT)
- internal/services/event_service.go (union projection)
- frontend/src/client/rule-label.ts (new helper)
- frontend/src/client/deadlines-new.ts (rewrite)
- frontend/src/client/deadlines-detail.ts (Auto/Custom editor + display)
- frontend/src/client/events.ts (REGEL column)
- frontend/src/client/projects-detail.ts (Fristen table cell)
- frontend/src/client/views/shape-list.ts (generic rule column)
- frontend/src/client/i18n.ts + i18n-keys.ts (DE+EN delta)
- frontend/src/deadlines-new.tsx (strip dropdown+sort, add toggle)
- frontend/src/deadlines-detail.tsx (Auto/Custom edit slots)
- frontend/src/styles/global.css (rule-mode + rule-label families)
2026-05-25 14:54:51 +02:00

567 lines
20 KiB
Go

package services
// EventService is the unified read facade over Deadlines + Appointments.
// /deadlines and /appointments both render one EventsPage that calls
// /api/events?type=deadline|appointment|all — this service is what backs
// that endpoint and the matching summary counts.
//
// Visibility, validation, and event_type hydration are all delegated to
// DeadlineService / AppointmentService — this layer adds nothing on top
// other than the projection to EventListItem and the bucket math used by
// SummaryCounts. Mutations stay on the type-specific services; the
// handlers call them directly. See docs/design-events-unification-2026-05-04.md
// (t-paliad-109) for the design rationale.
import (
"context"
"fmt"
"sort"
"strings"
"time"
"github.com/google/uuid"
"github.com/jmoiron/sqlx"
"mgit.msbls.de/m/paliad/internal/models"
)
// EventTypeFilter selects which side of the union ListVisibleForUser
// returns. Empty string means "both"; the constants spell out the
// allowed values.
type EventTypeFilter string
const (
EventTypeAll EventTypeFilter = ""
EventTypeDeadline EventTypeFilter = "deadline"
EventTypeAppointment EventTypeFilter = "appointment"
)
// EventService wraps the deadline + appointment services.
type EventService struct {
db *sqlx.DB
deadlines *DeadlineService
appointments *AppointmentService
}
func NewEventService(db *sqlx.DB, deadlines *DeadlineService, appointments *AppointmentService) *EventService {
return &EventService{db: db, deadlines: deadlines, appointments: appointments}
}
// EventListFilter narrows ListVisibleForUser. Most fields are type-specific;
// passing them with Type=Appointment (or vice versa) is a no-op rather than
// an error so the handler can stay shape-stable across type switches.
//
// PersonalOnly narrows BOTH rails to rows whose `created_by` matches the
// caller — backs the "Nur persönliche" filter on /events (t-paliad-128).
// When set, ProjectID is ignored (the two are contradictory: personal
// means "items I created", not "items in some project I picked").
//
// DirectOnly narrows the ProjectID filter from "this project + every
// descendant" (t-paliad-139 subtree default) to "this project only"
// (t-paliad-152). Backs the "direkt / inkl. Unterprojekte" toggle on
// /projects/{id} Fristen + Termine. No effect when ProjectID is nil or
// PersonalOnly is set.
type EventListFilter struct {
Type EventTypeFilter
// Deadline-only. AppointmentType applies only to appointments.
Status DeadlineStatusFilter
EventTypeIDs []uuid.UUID
IncludeUntyped bool
AppointmentType *string
// Common.
ProjectID *uuid.UUID
From *time.Time
To *time.Time
PersonalOnly bool
DirectOnly bool
}
// EventListItem is one row of the unified events list. Type-specific
// columns are pointers so the JSON shape carries only the fields that
// apply; the frontend type-narrows on `type`.
type EventListItem struct {
Type string `json:"type"` // "deadline" | "appointment"
ID uuid.UUID `json:"id"`
Title string `json:"title"`
Description *string `json:"description,omitempty"`
EventDate time.Time `json:"event_date"` // canonical sort key (deadline: due_date 00:00 UTC; appointment: start_at)
ProjectID *uuid.UUID `json:"project_id,omitempty"`
ProjectReference *string `json:"project_reference,omitempty"`
ProjectTitle *string `json:"project_title,omitempty"`
ProjectType *string `json:"project_type,omitempty"`
CreatedBy *uuid.UUID `json:"created_by,omitempty"`
// Approval workflow (t-paliad-138). ApprovalStatus is "approved"
// (default), "pending" (in-flight 4-eye request — pill rendered on
// every list surface), or "legacy" (pre-4-eye row, no pill).
ApprovalStatus *string `json:"approval_status,omitempty"`
// RequesterKind is the kind of the in-flight approval request when
// approval_status='pending': 'user' or 'agent' (Paliadin-drafted —
// t-paliad-161). NULL otherwise. Drives the ✨ glyph alongside 👀.
RequesterKind *string `json:"requester_kind,omitempty"`
// Deadline-only.
DueDate *string `json:"due_date,omitempty"` // YYYY-MM-DD
Status *string `json:"status,omitempty"`
CompletedAt *time.Time `json:"completed_at,omitempty"`
Source *string `json:"source,omitempty"`
RuleID *uuid.UUID `json:"rule_id,omitempty"`
RuleCode *string `json:"rule_code,omitempty"`
RuleName *string `json:"rule_name,omitempty"`
RuleNameEN *string `json:"rule_name_en,omitempty"`
// CustomRuleText surfaces the lawyer's free-text rule label when the
// deadline was created via the Custom rule path (t-paliad-258).
// Display surfaces fall back to it when RuleName is absent.
CustomRuleText *string `json:"custom_rule_text,omitempty"`
EventTypeIDs []uuid.UUID `json:"event_type_ids,omitempty"`
// Appointment-only.
StartAt *time.Time `json:"start_at,omitempty"`
EndAt *time.Time `json:"end_at,omitempty"`
Location *string `json:"location,omitempty"`
AppointmentType *string `json:"appointment_type,omitempty"`
}
// ListVisibleForUser returns events the user can see, sorted by event_date
// ascending. Deadlines and appointments are merged when Type is "" / "all".
func (s *EventService) ListVisibleForUser(ctx context.Context, userID uuid.UUID, filter EventListFilter) ([]EventListItem, error) {
wantDeadlines := filter.Type == EventTypeAll || filter.Type == EventTypeDeadline
wantAppointments := filter.Type == EventTypeAll || filter.Type == EventTypeAppointment
out := make([]EventListItem, 0, 64)
if wantDeadlines {
df := ListFilter{
Status: filter.Status,
ProjectID: filter.ProjectID,
EventTypeIDs: filter.EventTypeIDs,
IncludeUntyped: filter.IncludeUntyped,
DirectOnly: filter.DirectOnly,
}
if filter.PersonalOnly {
uid := userID
df.CreatedBy = &uid
df.ProjectID = nil
}
rows, err := s.deadlines.ListVisibleForUser(ctx, userID, df)
if err != nil {
return nil, err
}
for _, r := range rows {
if !inDateWindow(r.DueDate, filter.From, filter.To) {
continue
}
out = append(out, projectDeadline(r))
}
}
if wantAppointments {
// 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,
DirectOnly: filter.DirectOnly,
}
if filter.PersonalOnly {
uid := userID
af.CreatedBy = &uid
af.ProjectID = nil
}
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))
}
}
}
sort.SliceStable(out, func(i, j int) bool {
if out[i].EventDate.Equal(out[j].EventDate) {
// Stable tiebreaker: deadlines before appointments on the same
// instant, then alphabetic by title — matches AgendaService.
if out[i].Type != out[j].Type {
return out[i].Type == "deadline"
}
return out[i].Title < out[j].Title
}
return out[i].EventDate.Before(out[j].EventDate)
})
return out, nil
}
// projectDeadline projects a DeadlineWithProject row into the union shape.
func projectDeadline(d models.DeadlineWithProject) EventListItem {
pid := d.ProjectID
pt := d.ProjectTitle
ptype := d.ProjectType
due := d.DueDate.Format("2006-01-02")
status := d.Status
src := d.Source
approvalStatus := d.ApprovalStatus
return EventListItem{
Type: "deadline",
ID: d.ID,
Title: d.Title,
Description: d.Description,
EventDate: time.Date(d.DueDate.Year(), d.DueDate.Month(), d.DueDate.Day(), 0, 0, 0, 0, time.UTC),
ProjectID: &pid,
ProjectReference: d.ProjectReference,
ProjectTitle: &pt,
ProjectType: &ptype,
CreatedBy: d.CreatedBy,
ApprovalStatus: &approvalStatus,
RequesterKind: d.RequesterKind,
DueDate: &due,
Status: &status,
CompletedAt: d.CompletedAt,
Source: &src,
RuleID: d.RuleID,
RuleCode: d.RuleCode,
RuleName: d.RuleName,
RuleNameEN: d.RuleNameEN,
CustomRuleText: d.CustomRuleText,
EventTypeIDs: d.EventTypeIDs,
}
}
// projectAppointment projects an AppointmentWithProject row into the union shape.
func projectAppointment(a models.AppointmentWithProject) EventListItem {
startCopy := a.StartAt
approvalStatus := a.ApprovalStatus
return EventListItem{
Type: "appointment",
ID: a.ID,
Title: a.Title,
Description: a.Description,
EventDate: a.StartAt,
ProjectID: a.ProjectID,
ProjectReference: a.ProjectReference,
ProjectTitle: a.ProjectTitle,
ProjectType: a.ProjectType,
CreatedBy: a.CreatedBy,
ApprovalStatus: &approvalStatus,
RequesterKind: a.RequesterKind,
StartAt: &startCopy,
EndAt: a.EndAt,
Location: a.Location,
AppointmentType: a.AppointmentType,
}
}
// 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 /
// "" / overdue / completed — those are handled separately).
//
// DeadlineFilterUpcoming maps to "start_at >= today" so legacy
// `?status=upcoming` URLs hide past appointments instead of falling
// through to the unfiltered query (m/paliad#54 — the UI option that
// surfaced this status has been removed, but bookmarks may persist).
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
case DeadlineFilterUpcoming:
return &b.today, 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.
func inDateWindow(due time.Time, from, to *time.Time) bool {
if from != nil && due.Before(from.UTC()) {
return false
}
if to != nil && due.After(to.UTC()) {
return false
}
return true
}
// EventSummaryFilter narrows SummaryCounts. Today only `Type`,
// `ProjectID`, and `PersonalOnly` matter; status/event_type filters
// intentionally don't shape the bucket counts (the cards are global
// "what's coming?" indicators).
//
// PersonalOnly mirrors EventListFilter.PersonalOnly — narrows both
// bucket queries to rows the caller created. ProjectID is ignored when
// PersonalOnly is set.
//
// DirectOnly mirrors EventListFilter.DirectOnly — narrows ProjectID from
// "this project + every descendant" to "this project only". No effect
// when ProjectID is nil or PersonalOnly is set.
type EventSummaryFilter struct {
Type EventTypeFilter
ProjectID *uuid.UUID
PersonalOnly bool
DirectOnly bool
}
// EventSummary is the response shape of /api/events/summary. Either side
// is omitted when the matching Type filter excludes it; the frontend reads
// presence and renders the appropriate rail.
//
// The four universal cards are Heute / Diese Woche / Nächste Woche /
// Später. Überfällig is deadline-only and conditional (count > 0). Erledigt
// stays in the response so the dropdown filter can render the unread badge
// but is no longer rendered as a card (t-paliad-110, supersedes t-106).
type EventSummary struct {
Deadlines *DeadlineBuckets `json:"deadlines,omitempty"`
Appointments *AppointmentBuckets `json:"appointments,omitempty"`
}
// DeadlineBuckets counts deadlines across the five disjoint pending
// buckets plus the all-time completed total.
type DeadlineBuckets struct {
Overdue int `json:"overdue" db:"overdue"`
Today int `json:"today" db:"today"`
ThisWeek int `json:"this_week" db:"this_week"`
NextWeek int `json:"next_week" db:"next_week"`
Later int `json:"later" db:"later"`
Completed int `json:"completed" db:"completed"`
Total int `json:"total" db:"total"`
}
// AppointmentBuckets counts appointments by start-date bucket. Past
// appointments do not get a bucket (per t-paliad-110 §F Q14: appointments
// have no completed_at; past ones are reachable via filter / pagination
// but don't contribute to a card).
type AppointmentBuckets struct {
Today int `json:"today" db:"today"`
ThisWeek int `json:"this_week" db:"this_week"`
NextWeek int `json:"next_week" db:"next_week"`
Later int `json:"later" db:"later"`
Total int `json:"total" db:"total"`
}
// SummaryCounts returns the bucket counts for the user's visible events.
//
// The five disjoint buckets share their cutoffs with computeDeadlineBucketBounds
// (deadline_service.go) so /api/events/summary, /api/deadlines/summary, and
// the dashboard's deadline rail can never disagree.
//
// Overdue — pending AND due_date < today (deadlines only)
// Today — pending AND due_date = today (deadlines)
// start_at within [today, tomorrow) (appointments)
// ThisWeek — pending AND tomorrow <= due_date <= upcoming Sunday (deadlines)
// tomorrow <= start_at < Mon-next-week (appointments)
// NextWeek — Mon-next-week <= due_date < Mon-week-after (deadlines)
// Mon-next-week <= start_at < Mon-week-after (appointments)
// Later — due_date >= Mon-week-after (deadlines)
// start_at >= Mon-week-after (appointments)
// Completed — status='completed' (deadlines only; all-time count)
func (s *EventService) SummaryCounts(ctx context.Context, userID uuid.UUID, filter EventSummaryFilter) (*EventSummary, error) {
user, err := s.deadlines.users().GetByID(ctx, userID)
if err != nil {
return nil, err
}
if user == nil {
return &EventSummary{}, nil
}
out := &EventSummary{}
wantDeadlines := filter.Type == EventTypeAll || filter.Type == EventTypeDeadline
wantAppointments := filter.Type == EventTypeAll || filter.Type == EventTypeAppointment
bounds := computeDeadlineBucketBounds(time.Now().UTC())
projectID := filter.ProjectID
if filter.PersonalOnly {
// PersonalOnly is mutually exclusive with ProjectID — see the
// EventSummaryFilter doc comment.
projectID = nil
}
if wantDeadlines {
buckets, err := s.deadlineBuckets(ctx, userID, projectID, bounds, filter.PersonalOnly, filter.DirectOnly)
if err != nil {
return nil, err
}
out.Deadlines = buckets
}
if wantAppointments {
buckets, err := s.appointmentBuckets(ctx, userID, projectID, bounds, filter.PersonalOnly, filter.DirectOnly)
if err != nil {
return nil, err
}
out.Appointments = buckets
}
return out, nil
}
func (s *EventService) deadlineBuckets(ctx context.Context, userID uuid.UUID, projectID *uuid.UUID, b deadlineBucketBounds, personalOnly, directOnly bool) (*DeadlineBuckets, error) {
conds := []string{visibilityPredicate("p")}
args := map[string]any{
"user_id": userID,
"today": b.today,
"tomorrow": b.tomorrow,
"next_monday": b.nextMonday,
"week_after": b.weekAfter,
}
if projectID != nil {
if directOnly {
conds = append(conds, `f.project_id = :project_id`)
} else {
conds = append(conds, projectDescendantPredicate("p"))
}
args["project_id"] = *projectID
}
if personalOnly {
conds = append(conds, `f.created_by = :user_id`)
}
query := `
SELECT
COUNT(*) FILTER (WHERE f.status = 'pending' AND f.due_date < :today) AS overdue,
COUNT(*) FILTER (WHERE f.status = 'pending' AND f.due_date = :today) AS today,
COUNT(*) FILTER (WHERE f.status = 'pending' AND f.due_date >= :tomorrow AND f.due_date < :next_monday) AS this_week,
COUNT(*) FILTER (WHERE f.status = 'pending' AND f.due_date >= :next_monday AND f.due_date < :week_after) AS next_week,
COUNT(*) FILTER (WHERE f.status = 'pending' AND f.due_date >= :week_after) AS later,
COUNT(*) FILTER (WHERE f.status = 'completed') AS completed,
COUNT(*) AS total
FROM paliad.deadlines f
JOIN paliad.projects p ON p.id = f.project_id
WHERE ` + strings.Join(conds, " AND ")
stmt, err := s.db.PrepareNamedContext(ctx, query)
if err != nil {
return nil, fmt.Errorf("prepare deadline summary: %w", err)
}
defer stmt.Close()
var c DeadlineBuckets
if err := stmt.GetContext(ctx, &c, args); err != nil {
return nil, fmt.Errorf("event deadline summary: %w", err)
}
return &c, nil
}
func (s *EventService) appointmentBuckets(ctx context.Context, userID uuid.UUID, projectID *uuid.UUID, b deadlineBucketBounds, personalOnly, directOnly bool) (*AppointmentBuckets, error) {
visibility := `(
(t.project_id IS NULL AND t.created_by = :user_id)
OR (t.project_id IS NOT NULL AND ` + visibilityPredicate("p") + `)
)`
conds := []string{visibility}
args := map[string]any{
"user_id": userID,
"today": b.today,
"tomorrow": b.tomorrow,
"next_monday": b.nextMonday,
"week_after": b.weekAfter,
}
if projectID != nil {
if directOnly {
conds = append(conds, `t.project_id = :project_id`)
} else {
conds = append(conds, projectDescendantPredicate("p"))
}
args["project_id"] = *projectID
}
if personalOnly {
// Narrow to rows the caller created. AND-joined with the visibility
// predicate above so an appointment a user created on a team they
// have since left still doesn't leak through.
conds = append(conds, `t.created_by = :user_id`)
}
query := `
SELECT
COUNT(*) FILTER (WHERE t.start_at >= :today AND t.start_at < :tomorrow) AS today,
COUNT(*) FILTER (WHERE t.start_at >= :tomorrow AND t.start_at < :next_monday) AS this_week,
COUNT(*) FILTER (WHERE t.start_at >= :next_monday AND t.start_at < :week_after) AS next_week,
COUNT(*) FILTER (WHERE t.start_at >= :week_after) AS later,
COUNT(*) FILTER (WHERE t.start_at >= :today) AS total
FROM paliad.appointments t
LEFT JOIN paliad.projects p ON p.id = t.project_id
WHERE ` + strings.Join(conds, " AND ")
stmt, err := s.db.PrepareNamedContext(ctx, query)
if err != nil {
return nil, fmt.Errorf("prepare appointment summary: %w", err)
}
defer stmt.Close()
var c AppointmentBuckets
if err := stmt.GetContext(ctx, &c, args); err != nil {
return nil, fmt.Errorf("event appointment summary: %w", err)
}
return &c, nil
}