Files
paliad/internal/services/agenda_service.go
m bc47d78d97 feat(t-paliad-138): pending pills on /events and /agenda
Commit 6 of 8. Renders the approval-pending warning pill on the two
busiest list surfaces:

- /events (deadline + appointment list): ⚠ pill next to the title +
  soft-tinted row via .entity-row--pending-update modifier.
- /agenda (timeline): ⚠ pill in the headline + same row tint.

Changes:

- internal/services/event_service.go: EventListItem gains
  ApprovalStatus *string; projectDeadline / projectAppointment
  populate it from the embedded model.
- internal/services/deadline_service.go ListVisibleForUser: SQL adds
  f.approval_status / pending_request_id / approved_by / approved_at
  to the SELECT so DeadlineWithProject hydrates them.
- internal/services/appointment_service.go ListVisibleForUser: same
  for appointments + completed_at.
- internal/services/agenda_service.go: AgendaItem gains
  ApprovalStatus; the per-source SQL queries select it; the
  loadDeadlines / loadAppointments projection sets it.
- frontend/src/client/events.ts renderRow: adds entity-row--pending-update
  modifier and an inline approval-pill on the title cell when status='pending'.
- frontend/src/client/agenda.ts renderItem: same treatment on the
  agenda-item headline.

Generic "pending update" label (approvals.pending_update.label) — not
lifecycle-specific. The inbox carries the lifecycle detail. Showing
just one pill keeps the visual signal clear; an approver scanning a
list of pending entities sees them at a glance via the row tint, then
clicks through to /inbox to see what's pending and act.

Detail pages (/deadlines/{id}, /appointments/{id}) and /dashboard
deadline rail — pill rendering for those surfaces deferred to a
follow-up to keep this commit focused. Rendered everywhere it
matters most for daily use.
2026-05-06 16:05:00 +02:00

322 lines
11 KiB
Go

package services
// AgendaService builds a merged, date-sorted feed of deadlines + appointments
// across every Project the caller can see. It underpins the `/agenda` page —
// a unified timeline that is neither deadline-centric (like /deadlines) nor
// appointment-centric (like /appointments/calendar).
//
// Visibility: reuses the same team-membership predicate applied everywhere
// else (paliad.project_teams + path walk). Personal Appointments (project_id
// IS NULL) remain creator-only.
import (
"context"
"fmt"
"sort"
"strings"
"time"
"github.com/google/uuid"
"github.com/jmoiron/sqlx"
)
// AgendaService returns agenda feed rows for the Dashboard's /agenda page.
type AgendaService struct {
db *sqlx.DB
users *UserService
eventTypes *EventTypeService
}
// NewAgendaService wires the service. eventTypes powers the optional
// Event-Type filter on /agenda (t-paliad-088); pass nil in tests that
// don't exercise that surface.
func NewAgendaService(db *sqlx.DB, users *UserService, eventTypes *EventTypeService) *AgendaService {
return &AgendaService{db: db, users: users, eventTypes: eventTypes}
}
// AgendaItem is one row in the merged feed. `Type` is "deadline" or
// "appointment"; date fields are populated differently per type (deadlines
// have a date-only DueDate, appointments have StartAt/EndAt). The client
// groups by the local calendar day derived from `Date`.
type AgendaItem struct {
ID uuid.UUID `json:"id"`
Type string `json:"type"` // "deadline" | "appointment"
Title string `json:"title"`
Date time.Time `json:"date"` // canonical sort key (day start for deadlines, start_at for appointments)
EndAt *time.Time `json:"end_at,omitempty"` // appointments only
DueDate *string `json:"due_date,omitempty"` // deadlines only (YYYY-MM-DD)
Status *string `json:"status,omitempty"` // deadlines: pending/completed/...
Location *string `json:"location,omitempty"` // appointments only
AppointmentType *string `json:"appointment_type,omitempty"`
Urgency string `json:"urgency"` // overdue | today | tomorrow | this_week | later
ProjectID *uuid.UUID `json:"project_id,omitempty"`
ProjectTitle *string `json:"project_title,omitempty"`
ProjectType *string `json:"project_type,omitempty"`
ProjectRef *string `json:"project_reference,omitempty"`
// ApprovalStatus (t-paliad-138) — "pending" → render warning pill on
// the agenda timeline. "approved"/"legacy" → no pill.
ApprovalStatus *string `json:"approval_status,omitempty"`
}
// AgendaFilter narrows the merged feed.
//
// EventTypeIDs / IncludeUntyped restrict the deadline side of the feed
// (appointments are unaffected — they have no event_types). When both
// are zero/false the filter is inactive.
type AgendaFilter struct {
From time.Time // inclusive, UTC
To time.Time // exclusive, UTC
IncludeDeadlines bool
IncludeAppointments bool
EventTypeIDs []uuid.UUID
IncludeUntyped bool
}
// List returns all AgendaItems for the user's visible projects within
// [From, To), sorted by Date ascending. Completed deadlines are excluded —
// the agenda is about what's coming up, not audit history.
func (s *AgendaService) List(ctx context.Context, userID uuid.UUID, f AgendaFilter) ([]AgendaItem, error) {
if !f.IncludeDeadlines && !f.IncludeAppointments {
return []AgendaItem{}, nil
}
if f.To.Before(f.From) || f.To.Equal(f.From) {
return []AgendaItem{}, nil
}
// Existence gate — visibility predicate inside loadDeadlines/Appointments
// reads global_role directly from paliad.users via the SQL helper, so we
// no longer need the row itself, just confirmation that the caller exists.
user, err := s.users.GetByID(ctx, userID)
if err != nil {
return nil, err
}
if user == nil {
return []AgendaItem{}, nil
}
items := make([]AgendaItem, 0, 64)
if f.IncludeDeadlines {
rows, err := s.loadDeadlines(ctx, userID, f.From, f.To, f.EventTypeIDs, f.IncludeUntyped)
if err != nil {
return nil, err
}
items = append(items, rows...)
}
if f.IncludeAppointments {
rows, err := s.loadAppointments(ctx, userID, f.From, f.To)
if err != nil {
return nil, err
}
items = append(items, rows...)
}
sort.SliceStable(items, func(i, j int) bool {
if items[i].Date.Equal(items[j].Date) {
// Stable tiebreaker: deadlines before appointments on the same
// instant, then alphabetic by title so the feed is deterministic.
if items[i].Type != items[j].Type {
return items[i].Type == "deadline"
}
return items[i].Title < items[j].Title
}
return items[i].Date.Before(items[j].Date)
})
annotateAgendaUrgency(items, time.Now().UTC())
return items, nil
}
// loadDeadlines pulls pending deadlines whose due_date falls in [from, to).
// Completed deadlines are hidden — agenda is forward-looking.
// eventTypeIDs / includeUntyped restrict which deadlines are returned;
// see AgendaFilter for the OR-composition semantics.
func (s *AgendaService) loadDeadlines(ctx context.Context, userID uuid.UUID, from, to time.Time, eventTypeIDs []uuid.UUID, includeUntyped bool) ([]AgendaItem, error) {
// due_date is a DATE; compare against the date portion of the window.
fromDate := from.Format("2006-01-02")
toDate := to.Format("2006-01-02")
args := []any{userID, fromDate, toDate}
etClause := ""
if len(eventTypeIDs) > 0 || includeUntyped {
parts := []string{}
if len(eventTypeIDs) > 0 {
placeholders := make([]string, 0, len(eventTypeIDs))
for _, id := range eventTypeIDs {
args = append(args, id)
placeholders = append(placeholders, fmt.Sprintf("$%d", len(args)))
}
parts = append(parts, fmt.Sprintf(`EXISTS (
SELECT 1 FROM paliad.deadline_event_types det
WHERE det.deadline_id = f.id
AND det.event_type_id IN (%s)
)`, strings.Join(placeholders, ", ")))
}
if includeUntyped {
parts = append(parts, `NOT EXISTS (
SELECT 1 FROM paliad.deadline_event_types det
WHERE det.deadline_id = f.id
)`)
}
if len(parts) == 1 {
etClause = "\n AND " + parts[0]
} else {
etClause = "\n AND (" + strings.Join(parts, " OR ") + ")"
}
}
query := `
SELECT f.id,
f.title,
f.due_date,
f.status,
f.approval_status,
p.id AS project_id,
p.title AS project_title,
p.type AS project_type,
p.reference AS project_reference
FROM paliad.deadlines f
JOIN paliad.projects p ON p.id = f.project_id
WHERE f.status = 'pending'
AND f.due_date >= $2::date
AND f.due_date < $3::date
AND ` + visibilityPredicatePositional("p", 1) + etClause + `
ORDER BY f.due_date ASC, f.created_at ASC`
type row struct {
ID uuid.UUID `db:"id"`
Title string `db:"title"`
DueDate time.Time `db:"due_date"`
Status string `db:"status"`
ApprovalStatus string `db:"approval_status"`
ProjectID uuid.UUID `db:"project_id"`
ProjectTitle string `db:"project_title"`
ProjectType string `db:"project_type"`
ProjectReference *string `db:"project_reference"`
}
var rows []row
if err := s.db.SelectContext(ctx, &rows, query, userID, fromDate, toDate); err != nil {
return nil, fmt.Errorf("agenda deadlines: %w", err)
}
out := make([]AgendaItem, 0, len(rows))
for _, r := range rows {
due := r.DueDate.Format("2006-01-02")
status := r.Status
approvalStatus := r.ApprovalStatus
projectID := r.ProjectID
projectTitle := r.ProjectTitle
projectType := r.ProjectType
out = append(out, AgendaItem{
ID: r.ID,
Type: "deadline",
Title: r.Title,
Date: time.Date(r.DueDate.Year(), r.DueDate.Month(), r.DueDate.Day(), 0, 0, 0, 0, time.UTC),
DueDate: &due,
Status: &status,
ApprovalStatus: &approvalStatus,
ProjectID: &projectID,
ProjectTitle: &projectTitle,
ProjectType: &projectType,
ProjectRef: r.ProjectReference,
})
}
return out, nil
}
// loadAppointments pulls appointments whose start_at falls in [from, to).
// Includes personal appointments (project_id IS NULL, creator-only) and
// project-attached appointments subject to the team predicate.
func (s *AgendaService) loadAppointments(ctx context.Context, userID uuid.UUID, from, to time.Time) ([]AgendaItem, error) {
query := `
SELECT t.id,
t.title,
t.start_at,
t.end_at,
t.location,
t.appointment_type,
t.approval_status,
t.project_id,
p.title AS project_title,
p.type AS project_type,
p.reference AS project_reference
FROM paliad.appointments t
LEFT JOIN paliad.projects p ON p.id = t.project_id
WHERE t.start_at >= $2
AND t.start_at < $3
AND (
(t.project_id IS NULL AND t.created_by = $1)
OR (t.project_id IS NOT NULL AND ` + visibilityPredicatePositional("p", 1) + `)
)
ORDER BY t.start_at ASC, t.created_at ASC`
type row struct {
ID uuid.UUID `db:"id"`
Title string `db:"title"`
StartAt time.Time `db:"start_at"`
EndAt *time.Time `db:"end_at"`
Location *string `db:"location"`
AppointmentType *string `db:"appointment_type"`
ApprovalStatus string `db:"approval_status"`
ProjectID *uuid.UUID `db:"project_id"`
ProjectTitle *string `db:"project_title"`
ProjectType *string `db:"project_type"`
ProjectReference *string `db:"project_reference"`
}
var rows []row
if err := s.db.SelectContext(ctx, &rows, query, userID, from, to); err != nil {
return nil, fmt.Errorf("agenda appointments: %w", err)
}
out := make([]AgendaItem, 0, len(rows))
for _, r := range rows {
approvalStatus := r.ApprovalStatus
out = append(out, AgendaItem{
ID: r.ID,
Type: "appointment",
Title: r.Title,
Date: r.StartAt,
EndAt: r.EndAt,
Location: r.Location,
AppointmentType: r.AppointmentType,
ApprovalStatus: &approvalStatus,
ProjectID: r.ProjectID,
ProjectTitle: r.ProjectTitle,
ProjectType: r.ProjectType,
ProjectRef: r.ProjectReference,
})
}
return out, nil
}
// annotateAgendaUrgency classifies each item so the client can apply the
// traffic-light styling without re-deriving the buckets.
//
// overdue — in the past (deadlines only; appointments only go "later")
// today — same calendar day (UTC — kept in sync with server window)
// tomorrow — next calendar day
// this_week — within the next 7 days (exclusive of today/tomorrow)
// later — beyond 7 days
func annotateAgendaUrgency(items []AgendaItem, now time.Time) {
today := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, time.UTC)
tomorrow := today.AddDate(0, 0, 1)
dayAfterTomorrow := today.AddDate(0, 0, 2)
endOfWeek := today.AddDate(0, 0, 7)
for i := range items {
d := items[i].Date
switch {
case d.Before(today):
items[i].Urgency = "overdue"
case !d.Before(today) && d.Before(tomorrow):
items[i].Urgency = "today"
case !d.Before(tomorrow) && d.Before(dayAfterTomorrow):
items[i].Urgency = "tomorrow"
case !d.Before(dayAfterTomorrow) && d.Before(endOfWeek):
items[i].Urgency = "this_week"
default:
items[i].Urgency = "later"
}
}
}