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)
567 lines
20 KiB
Go
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
|
|
}
|