feat(t-paliad-171): SmartTimeline backend skeleton — ProjectionService + /timeline endpoint

Slice 1 of the SmartTimeline (Verlauf-tab redesign). Adds a new service
layer + two HTTP endpoints; no projection logic yet (Slice 2). The wire
shape (TimelineEvent) is frozen so future slices add Kind="projected"
rows additively without breaking the frontend consumer.

ProjectionService.For composes three actuals streams for one project:
  - paliad.deadlines           → Kind="deadline"
  - paliad.appointments        → Kind="appointment"
  - paliad.project_events with
    timeline_kind IS NOT NULL  → Kind="milestone"

Visibility goes through the existing inline mirror of
paliad.can_see_project on each underlying service — no new RLS surface.
DirectOnly mirrors the existing "Inkl. Unterprojekte" toggle on
/projects/{id}; IncludeAuditFull broadens project_events to the full
audit log behind the upcoming "Audit-Log anzeigen" header toggle.

ProjectionService.RecordCustomMilestone backs POST /timeline/milestone
("Eigener Meilenstein") — the only write path in Slice 1.

Tests: unit (sort order, status mapping, kind tiebreak — runs by default)
plus a live integration test that seeds one project + dl + appt +
milestone and asserts the merge surfaces all three with the right
ordering. Live test gated on TEST_DATABASE_URL per the existing
convention.

Design ref: docs/design-smart-timeline-2026-05-08.md §2.3 + §9.2 + §10.
This commit is contained in:
m
2026-05-08 23:34:06 +02:00
parent 49c260b888
commit afd3aab2b2
7 changed files with 981 additions and 0 deletions

View File

@@ -0,0 +1,439 @@
package services
// ProjectionService composes the SmartTimeline read view for a project —
// the merged stream of past actuals (deadlines + appointments + opted-in
// project_events) plus, in later slices, future projections from the
// fristenrechner.
//
// Slice 1 (t-paliad-171) returns ONLY actuals — the four-zone vision in
// the design doc lands incrementally. The wire shape (TimelineEvent) is
// frozen so Slice 2 just adds Kind="projected" rows without breaking
// frontend consumers. See docs/design-smart-timeline-2026-05-08.md
// §2.3 + §10.
import (
"context"
"encoding/json"
"fmt"
"sort"
"time"
"github.com/google/uuid"
"github.com/jmoiron/sqlx"
)
// TimelineEvent is one row in the SmartTimeline merge. The struct is
// the wire contract of GET /api/projects/{id}/timeline; new slices
// MUST extend it additively (new fields, never rename / repurpose).
//
// Provenance fields (DeadlineID, AppointmentID, ProjectEventID) — exactly
// one is non-nil for actual rows. All three are nil for Kind="projected"
// rows (Slice 2). Frontend deep-links via the populated id.
type TimelineEvent struct {
Kind string `json:"kind"` // "deadline" | "appointment" | "milestone" | "projected"
Status string `json:"status"` // "done" | "open" | "overdue" | "court_set" | "predicted" | "off_script"
Track string `json:"track"` // "parent" | "counterclaim" | "child:<project_id>" | "off_script"
// Date is nil for undated rows (court-set decisions, counterclaim-pending
// milestones). Undated rows sort to the end.
Date *time.Time `json:"date,omitempty"`
Title string `json:"title"`
Description string `json:"description,omitempty"`
RuleCode string `json:"rule_code,omitempty"`
DeadlineID *uuid.UUID `json:"deadline_id,omitempty"`
AppointmentID *uuid.UUID `json:"appointment_id,omitempty"`
ProjectEventID *uuid.UUID `json:"project_event_id,omitempty"`
// Reserved for Slice 2 (projected rows + click-to-anchor affordance);
// always empty / nil in Slice 1, kept here so the wire shape doesn't
// change between slices.
DeadlineRuleID *uuid.UUID `json:"deadline_rule_id,omitempty"`
DeadlineRuleParty string `json:"deadline_rule_party,omitempty"`
// Reserved for Slice 3 (counterclaim sub-projects); populated when
// the row belongs to a child project rendered alongside the parent.
SubProjectID *uuid.UUID `json:"sub_project_id,omitempty"`
SubProjectTitle string `json:"sub_project_title,omitempty"`
}
// ProjectionOpts narrows the SmartTimeline read.
//
// IncludeAuditFull — when true, project_events are loaded WITHOUT the
// timeline_kind filter (i.e. every audit row, the legacy Verlauf list
// behaviour). Backs the "Audit-Log anzeigen" toggle in the timeline
// header.
//
// DirectOnly — when true, narrows to rows whose project_id exactly
// matches; default (false) aggregates the project + every descendant,
// matching the existing "Inkl. Unterprojekte" toggle behaviour on
// /projects/{id}.
type ProjectionOpts struct {
IncludeAuditFull bool
DirectOnly bool
}
// ProjectionService composes the SmartTimeline. Read-only in Slice 1
// (RecordCustomMilestone is the one write path — for "Eigener
// Meilenstein" entries from the timeline header).
type ProjectionService struct {
db *sqlx.DB
projects *ProjectService
deadlines *DeadlineService
appointments *AppointmentService
}
// NewProjectionService wires the read-side dependencies. Slice 2 will
// add *FristenrechnerService here; we keep the constructor minimal
// today so the call site in cmd/server/main.go stays small.
func NewProjectionService(
db *sqlx.DB,
projects *ProjectService,
deadlines *DeadlineService,
appointments *AppointmentService,
) *ProjectionService {
return &ProjectionService{
db: db,
projects: projects,
deadlines: deadlines,
appointments: appointments,
}
}
// For builds a SmartTimeline for one project. Visibility is delegated
// to the underlying services (DeadlineService / AppointmentService /
// the project_events query reuses the project visibility predicate),
// so this layer adds no new RLS surface.
//
// Returns rows sorted by Date ASC, undated rows last, with a stable
// title-then-id tiebreaker.
func (s *ProjectionService) For(ctx context.Context, userID, projectID uuid.UUID, opts ProjectionOpts) ([]TimelineEvent, error) {
// Visibility check — GetByID returns ErrNotVisible when the user
// can't see the project, which the handler maps to a 404. After
// this, the deadline / appointment list calls also gate on
// visibility, so any descendant rows surfaced via DirectOnly=false
// are similarly scoped.
if _, err := s.projects.GetByID(ctx, userID, projectID); err != nil {
return nil, err
}
out := make([]TimelineEvent, 0, 32)
// --- Deadlines -----------------------------------------------------
deadlineRows, err := s.deadlines.ListVisibleForUser(ctx, userID, ListFilter{
ProjectID: &projectID,
DirectOnly: opts.DirectOnly,
})
if err != nil {
return nil, fmt.Errorf("projection: deadlines: %w", err)
}
for _, d := range deadlineRows {
ev := TimelineEvent{
Kind: "deadline",
Status: deadlineStatus(d.Status, d.DueDate),
Track: "parent",
Date: timePtr(time.Date(d.DueDate.Year(), d.DueDate.Month(), d.DueDate.Day(), 0, 0, 0, 0, time.UTC)),
Title: d.Title,
DeadlineID: &d.ID,
}
if d.Description != nil {
ev.Description = *d.Description
}
if d.RuleCode != nil {
ev.RuleCode = *d.RuleCode
}
out = append(out, ev)
}
// --- Appointments --------------------------------------------------
apptRows, err := s.appointments.ListVisibleForUser(ctx, userID, AppointmentListFilter{
ProjectID: &projectID,
DirectOnly: opts.DirectOnly,
})
if err != nil {
return nil, fmt.Errorf("projection: appointments: %w", err)
}
now := time.Now().UTC()
for _, a := range apptRows {
startCopy := a.StartAt
ev := TimelineEvent{
Kind: "appointment",
Status: appointmentStatus(startCopy, now),
Track: "parent",
Date: &startCopy,
Title: a.Title,
AppointmentID: &a.ID,
}
if a.Description != nil {
ev.Description = *a.Description
}
out = append(out, ev)
}
// --- project_events (timeline-opted-in by default; full audit when
// the IncludeAuditFull toggle is on) ---------------------------------
milestoneRows, err := s.listProjectEvents(ctx, userID, projectID, opts)
if err != nil {
return nil, fmt.Errorf("projection: milestones: %w", err)
}
out = append(out, milestoneRows...)
sortTimeline(out)
return out, nil
}
// listProjectEvents reads the audit table directly so we can apply the
// timeline_kind filter. We can't go through ProjectService.ListEvents
// because that path is paginated for the legacy Verlauf list and has
// no way to express "kind IS NOT NULL"; replicating its descendant
// aggregation predicate keeps this service self-contained.
//
// Visibility is enforced inline via the same pattern the rest of the
// services use (visibility.go) — application-layer mirror of
// paliad.can_see_project, since the service-role connection has no
// auth.uid() the SQL RLS function would need.
func (s *ProjectionService) listProjectEvents(ctx context.Context, userID, projectID uuid.UUID, opts ProjectionOpts) ([]TimelineEvent, error) {
// $1 = projectID, $2 = userID. The visibility predicate references
// $2 twice (admin shortcut + team-membership existence) — that is
// the standard Postgres positional-binding pattern, see
// visibilityPredicatePositional doc for the rationale.
var projectFilter string
if opts.DirectOnly {
projectFilter = `pe.project_id = $1 AND ` + visibilityPredicatePositional("p", 2)
} else {
// Aggregate self + descendants, gated by visibility on each
// row's joined project. p.path is materialised so the
// descendant check is a path-membership predicate.
projectFilter = `$1 = ANY(string_to_array(p.path, '.')::uuid[]) AND ` + visibilityPredicatePositional("p", 2)
}
kindFilter := `AND pe.timeline_kind IS NOT NULL`
if opts.IncludeAuditFull {
kindFilter = ``
}
type row struct {
ID uuid.UUID `db:"id"`
ProjectID uuid.UUID `db:"project_id"`
EventType *string `db:"event_type"`
Title string `db:"title"`
Description *string `db:"description"`
EventDate *time.Time `db:"event_date"`
CreatedAt time.Time `db:"created_at"`
Metadata json.RawMessage `db:"metadata"`
TimelineKind *string `db:"timeline_kind"`
}
query := `
SELECT pe.id, pe.project_id, pe.event_type, pe.title, pe.description,
pe.event_date, pe.created_at, pe.metadata, pe.timeline_kind
FROM paliad.project_events pe
JOIN paliad.projects p ON p.id = pe.project_id
WHERE ` + projectFilter + ` ` + kindFilter + `
ORDER BY COALESCE(pe.event_date, pe.created_at) DESC, pe.id DESC`
var rows []row
if err := s.db.SelectContext(ctx, &rows, query, projectID, userID); err != nil {
return nil, fmt.Errorf("list project_events: %w", err)
}
out := make([]TimelineEvent, 0, len(rows))
for _, r := range rows {
// Prefer event_date when set; otherwise fall back to created_at
// so the audit row still anchors somewhere on the timeline.
var when time.Time
if r.EventDate != nil {
when = *r.EventDate
} else {
when = r.CreatedAt
}
whenCopy := when
ev := TimelineEvent{
Kind: "milestone",
Status: milestoneStatus(r.TimelineKind, r.EventType),
Track: "parent",
Date: &whenCopy,
Title: r.Title,
ProjectEventID: &r.ID,
}
if r.Description != nil {
ev.Description = *r.Description
}
out = append(out, ev)
}
return out, nil
}
// RecordCustomMilestone writes a "Eigener Meilenstein" project_event
// (event_type='custom_milestone', timeline_kind='custom_milestone')
// and returns the resulting TimelineEvent so the caller can append it
// directly to the rendered list without a re-fetch.
//
// The user must already have visibility on the project — checked via
// ProjectService.GetByID. occurredAt is optional; when nil, the event
// surfaces as undated (sorts to the end) but is still findable via
// created_at on the audit log.
func (s *ProjectionService) RecordCustomMilestone(
ctx context.Context,
userID, projectID uuid.UUID,
title string,
description *string,
occurredAt *time.Time,
) (*TimelineEvent, error) {
if _, err := s.projects.GetByID(ctx, userID, projectID); err != nil {
return nil, err
}
if title == "" {
return nil, fmt.Errorf("%w: title is required", ErrInvalidInput)
}
id := uuid.New()
now := time.Now().UTC()
var eventDate *time.Time
if occurredAt != nil {
ts := occurredAt.UTC()
eventDate = &ts
}
_, err := s.db.ExecContext(ctx,
`INSERT INTO paliad.project_events
(id, project_id, event_type, title, description, event_date,
created_by, metadata, created_at, updated_at, timeline_kind)
VALUES ($1, $2, 'custom_milestone', $3, $4, $5, $6, '{}'::jsonb, $7, $7, 'custom_milestone')`,
id, projectID, title, description, eventDate, userID, now)
if err != nil {
return nil, fmt.Errorf("insert custom_milestone: %w", err)
}
when := now
if eventDate != nil {
when = *eventDate
}
whenCopy := when
ev := &TimelineEvent{
Kind: "milestone",
Status: "off_script",
Track: "parent",
Date: &whenCopy,
Title: title,
ProjectEventID: &id,
}
if description != nil {
ev.Description = *description
}
return ev, nil
}
// ----------------------------------------------------------------------
// Pure helpers — kept package-private and testable without DB.
// ----------------------------------------------------------------------
// deadlineStatus maps the deadline row's status + due date into the
// SmartTimeline status vocabulary. "completed" → done; pending →
// overdue (past) or open (today/future).
func deadlineStatus(rawStatus string, due time.Time) string {
if rawStatus == "completed" {
return "done"
}
today := startOfUTCDay(time.Now().UTC())
dueDay := startOfUTCDay(due)
if dueDay.Before(today) {
return "overdue"
}
return "open"
}
// appointmentStatus is "done" once the start has passed, "open" otherwise.
// Appointments don't carry a completion bit (CalDAV-source-of-truth) so
// the timeline classifies past ones as done by date alone — same model
// the dashboard uses today.
func appointmentStatus(start, now time.Time) string {
if start.Before(now) {
return "done"
}
return "open"
}
// milestoneStatus picks a status for project_event timeline rows. The
// "off_script" status applies to user-added free-form milestones; all
// other audit rows get "done" because they record something that already
// happened. Slice 1 doesn't surface predicted milestones — that lands
// with the projected-row Kind in Slice 2.
func milestoneStatus(timelineKind, eventType *string) string {
if timelineKind != nil && *timelineKind == "custom_milestone" {
return "off_script"
}
if eventType != nil && *eventType == "custom_milestone" {
return "off_script"
}
return "done"
}
// sortTimeline orders rows by Date ASC with undated rows pinned at the
// end; ties break on title then provenance id so the order is fully
// deterministic across requests.
func sortTimeline(rows []TimelineEvent) {
sort.SliceStable(rows, func(i, j int) bool {
di, dj := rows[i].Date, rows[j].Date
if di == nil && dj == nil {
return timelineTiebreak(rows[i], rows[j])
}
if di == nil {
return false
}
if dj == nil {
return true
}
if di.Equal(*dj) {
return timelineTiebreak(rows[i], rows[j])
}
return di.Before(*dj)
})
}
// timelineTiebreak: deadlines before appointments before milestones
// before projected; same-kind ties fall back to title then to the
// stringified provenance UUID (which is always non-empty for actuals).
func timelineTiebreak(a, b TimelineEvent) bool {
if a.Kind != b.Kind {
return kindOrder(a.Kind) < kindOrder(b.Kind)
}
if a.Title != b.Title {
return a.Title < b.Title
}
return timelineRowID(a) < timelineRowID(b)
}
func kindOrder(kind string) int {
switch kind {
case "deadline":
return 0
case "appointment":
return 1
case "milestone":
return 2
case "projected":
return 3
}
return 4
}
func timelineRowID(ev TimelineEvent) string {
switch {
case ev.DeadlineID != nil:
return ev.DeadlineID.String()
case ev.AppointmentID != nil:
return ev.AppointmentID.String()
case ev.ProjectEventID != nil:
return ev.ProjectEventID.String()
}
return ""
}
func startOfUTCDay(t time.Time) time.Time {
t = t.UTC()
return time.Date(t.Year(), t.Month(), t.Day(), 0, 0, 0, 0, time.UTC)
}
func timePtr(t time.Time) *time.Time { return &t }

View File

@@ -0,0 +1,253 @@
package services
// Live-DB integration test for ProjectionService — applies migrations,
// seeds one project + one deadline + one appointment + one
// timeline_kind-tagged project_event, and asserts the merge returns
// three rows in the right order. Skipped when TEST_DATABASE_URL is
// unset, mirroring the convention of the other live tests in this
// package.
import (
"context"
"os"
"testing"
"time"
"github.com/google/uuid"
"github.com/jmoiron/sqlx"
_ "github.com/lib/pq"
"mgit.msbls.de/m/paliad/internal/db"
)
func TestProjectionService_For_MergesActuals_Live(t *testing.T) {
url := os.Getenv("TEST_DATABASE_URL")
if url == "" {
t.Skip("TEST_DATABASE_URL not set — skipping live DB test")
}
if err := db.ApplyMigrations(url); err != nil {
t.Fatalf("apply migrations: %v", err)
}
pool, err := sqlx.Connect("postgres", url)
if err != nil {
t.Fatalf("connect: %v", err)
}
defer pool.Close()
ctx := context.Background()
userID := uuid.New()
projectID := uuid.New()
deadlineID := uuid.New()
apptID := uuid.New()
milestoneID := uuid.New()
auditOnlyID := uuid.New() // timeline_kind=NULL — must NOT surface in default read
cleanup := func() {
pool.ExecContext(ctx, `DELETE FROM paliad.appointments WHERE id = $1`, apptID)
pool.ExecContext(ctx, `DELETE FROM paliad.deadlines WHERE id = $1`, deadlineID)
pool.ExecContext(ctx, `DELETE FROM paliad.project_events WHERE id IN ($1, $2)`, milestoneID, auditOnlyID)
pool.ExecContext(ctx, `DELETE FROM paliad.project_teams WHERE project_id = $1`, projectID)
pool.ExecContext(ctx, `DELETE FROM paliad.projects WHERE id = $1`, projectID)
pool.ExecContext(ctx, `DELETE FROM paliad.users WHERE id = $1`, userID)
pool.ExecContext(ctx, `DELETE FROM auth.users WHERE id = $1`, userID)
}
cleanup()
defer cleanup()
if _, err := pool.ExecContext(ctx,
`INSERT INTO auth.users (id, email) VALUES ($1, 'projection-test@hlc.com')`,
userID); err != nil {
t.Fatalf("seed auth.users: %v", err)
}
if _, err := pool.ExecContext(ctx,
`INSERT INTO paliad.users (id, email, display_name, office, global_role, lang)
VALUES ($1, 'projection-test@hlc.com', 'Projection Test', 'munich', 'global_admin', 'de')`,
userID); err != nil {
t.Fatalf("seed paliad.users: %v", err)
}
if _, err := pool.ExecContext(ctx,
`INSERT INTO paliad.projects (id, type, path, title, reference, status, created_by)
VALUES ($1, 'case', $1::text, 'Projection Test Project', '2026/9993', 'active', $2)`,
projectID, userID); err != nil {
t.Fatalf("seed paliad.projects: %v", err)
}
now := time.Now().UTC()
deadlineDate := now.AddDate(0, 0, 7) // a week from now
apptDate := now.AddDate(0, 0, 14) // two weeks from now
milestoneDate := now.AddDate(0, 0, -3) // three days ago
if _, err := pool.ExecContext(ctx,
`INSERT INTO paliad.deadlines
(id, project_id, title, due_date, source, status, created_by)
VALUES ($1, $2, 'Test Deadline', $3::date, 'manual', 'pending', $4)`,
deadlineID, projectID, deadlineDate.Format("2006-01-02"), userID); err != nil {
t.Fatalf("seed deadline: %v", err)
}
if _, err := pool.ExecContext(ctx,
`INSERT INTO paliad.appointments
(id, project_id, title, start_at, appointment_type, created_by)
VALUES ($1, $2, 'Test Appointment', $3, 'meeting', $4)`,
apptID, projectID, apptDate, userID); err != nil {
t.Fatalf("seed appointment: %v", err)
}
// Two project_events: one with timeline_kind set (must surface), one
// without (must be filtered out unless include_audit_full).
if _, err := pool.ExecContext(ctx,
`INSERT INTO paliad.project_events
(id, project_id, event_type, title, event_date, created_by, metadata,
created_at, updated_at, timeline_kind)
VALUES ($1, $2, 'custom_milestone', 'Test Milestone', $3, $4,
'{}'::jsonb, $5, $5, 'custom_milestone')`,
milestoneID, projectID, milestoneDate, userID, now); err != nil {
t.Fatalf("seed milestone project_event: %v", err)
}
if _, err := pool.ExecContext(ctx,
`INSERT INTO paliad.project_events
(id, project_id, event_type, title, event_date, created_by, metadata,
created_at, updated_at)
VALUES ($1, $2, 'project_created', 'Audit-Only Event', $3, $4,
'{}'::jsonb, $5, $5)`,
auditOnlyID, projectID, milestoneDate.Add(-1*time.Hour), userID, now); err != nil {
t.Fatalf("seed audit-only project_event: %v", err)
}
users := NewUserService(pool)
projects := NewProjectService(pool, users)
eventTypes := NewEventTypeService(pool, users)
deadlines := NewDeadlineService(pool, projects, eventTypes)
appointments := NewAppointmentService(pool, projects)
projection := NewProjectionService(pool, projects, deadlines, appointments)
t.Run("default — only timeline_kind milestones surface", func(t *testing.T) {
rows, err := projection.For(ctx, userID, projectID, ProjectionOpts{})
if err != nil {
t.Fatalf("For: %v", err)
}
// Filter to seed rows so unrelated rows in the live DB don't
// confuse the assertions. We reference rows by provenance ID.
seen := map[string]TimelineEvent{}
for _, r := range rows {
switch {
case r.DeadlineID != nil && *r.DeadlineID == deadlineID:
seen["deadline"] = r
case r.AppointmentID != nil && *r.AppointmentID == apptID:
seen["appointment"] = r
case r.ProjectEventID != nil && *r.ProjectEventID == milestoneID:
seen["milestone"] = r
case r.ProjectEventID != nil && *r.ProjectEventID == auditOnlyID:
t.Errorf("audit-only project_event leaked into default read")
}
}
if len(seen) != 3 {
t.Fatalf("expected 3 seed rows, saw %d: %v", len(seen), seen)
}
// Sort order: milestone (3 days ago) → deadline (+7d) → appointment (+14d).
// Find the indices of our seeded rows in the result and check the
// relative ordering.
idx := func(id uuid.UUID) int {
for i, r := range rows {
switch {
case r.DeadlineID != nil && *r.DeadlineID == id:
return i
case r.AppointmentID != nil && *r.AppointmentID == id:
return i
case r.ProjectEventID != nil && *r.ProjectEventID == id:
return i
}
}
return -1
}
if !(idx(milestoneID) < idx(deadlineID) && idx(deadlineID) < idx(apptID)) {
t.Errorf("wrong sort: milestone=%d deadline=%d appt=%d (want asc)",
idx(milestoneID), idx(deadlineID), idx(apptID))
}
// Field shape — kind, status, deep-link IDs.
dl := seen["deadline"]
if dl.Kind != "deadline" {
t.Errorf("deadline.Kind = %q, want deadline", dl.Kind)
}
if dl.Status != "open" {
t.Errorf("deadline.Status = %q, want open (future date)", dl.Status)
}
if dl.Title != "Test Deadline" {
t.Errorf("deadline.Title = %q", dl.Title)
}
ap := seen["appointment"]
if ap.Kind != "appointment" || ap.Status != "open" {
t.Errorf("appointment kind/status = %q/%q", ap.Kind, ap.Status)
}
ms := seen["milestone"]
if ms.Kind != "milestone" || ms.Status != "off_script" {
t.Errorf("milestone kind/status = %q/%q (want milestone/off_script)",
ms.Kind, ms.Status)
}
})
t.Run("IncludeAuditFull — both project_events surface", func(t *testing.T) {
rows, err := projection.For(ctx, userID, projectID, ProjectionOpts{IncludeAuditFull: true})
if err != nil {
t.Fatalf("For audit_full: %v", err)
}
var sawAudit bool
for _, r := range rows {
if r.ProjectEventID != nil && *r.ProjectEventID == auditOnlyID {
sawAudit = true
break
}
}
if !sawAudit {
t.Errorf("audit-only project_event should surface with IncludeAuditFull=true")
}
})
t.Run("RecordCustomMilestone writes a row with timeline_kind set", func(t *testing.T) {
title := "Live-Test Custom Milestone"
desc := "from RecordCustomMilestone test"
when := time.Date(2026, 6, 1, 0, 0, 0, 0, time.UTC)
ev, err := projection.RecordCustomMilestone(ctx, userID, projectID, title, &desc, &when)
if err != nil {
t.Fatalf("RecordCustomMilestone: %v", err)
}
if ev == nil || ev.ProjectEventID == nil {
t.Fatalf("RecordCustomMilestone returned nil id")
}
// Defer cleanup so the row doesn't leak into other tests.
defer pool.ExecContext(ctx, `DELETE FROM paliad.project_events WHERE id = $1`, *ev.ProjectEventID)
// Verify the row landed with the expected discriminators.
var (
eventType string
timelineKind *string
)
if err := pool.QueryRowContext(ctx,
`SELECT event_type, timeline_kind FROM paliad.project_events WHERE id = $1`,
*ev.ProjectEventID).Scan(&eventType, &timelineKind); err != nil {
t.Fatalf("read back: %v", err)
}
if eventType != "custom_milestone" {
t.Errorf("event_type = %q, want custom_milestone", eventType)
}
if timelineKind == nil || *timelineKind != "custom_milestone" {
t.Errorf("timeline_kind = %v, want custom_milestone", timelineKind)
}
// And it must surface in the next read.
rows, err := projection.For(ctx, userID, projectID, ProjectionOpts{})
if err != nil {
t.Fatalf("For after milestone: %v", err)
}
var found bool
for _, r := range rows {
if r.ProjectEventID != nil && *r.ProjectEventID == *ev.ProjectEventID {
found = true
break
}
}
if !found {
t.Errorf("newly recorded milestone did not surface in For()")
}
})
}

View File

@@ -0,0 +1,153 @@
package services
// Pure-function tests for ProjectionService — no DB required, runs by
// default. Validates the deterministic sort order and status-mapping
// behaviour; the live integration test in projection_service_test.go
// covers the SQL paths.
import (
"testing"
"time"
"github.com/google/uuid"
)
func TestSortTimeline_DateAscUndatedLast(t *testing.T) {
d1 := uuid.New()
d2 := uuid.New()
a1 := uuid.New()
pe1 := uuid.New()
mar1 := time.Date(2026, 3, 1, 0, 0, 0, 0, time.UTC)
mar5 := time.Date(2026, 3, 5, 12, 0, 0, 0, time.UTC)
mar10 := time.Date(2026, 3, 10, 0, 0, 0, 0, time.UTC)
rows := []TimelineEvent{
{Kind: "milestone", Title: "Undated milestone", ProjectEventID: &pe1}, // Date nil
{Kind: "deadline", Date: &mar10, Title: "Mar10 deadline", DeadlineID: &d2},
{Kind: "deadline", Date: &mar1, Title: "Mar1 deadline", DeadlineID: &d1},
{Kind: "appointment", Date: &mar5, Title: "Mar5 appointment", AppointmentID: &a1},
}
sortTimeline(rows)
// Date ASC (Mar1, Mar5, Mar10), undated last.
if rows[0].Title != "Mar1 deadline" {
t.Errorf("rows[0] = %q, want Mar1 deadline", rows[0].Title)
}
if rows[1].Title != "Mar5 appointment" {
t.Errorf("rows[1] = %q, want Mar5 appointment", rows[1].Title)
}
if rows[2].Title != "Mar10 deadline" {
t.Errorf("rows[2] = %q, want Mar10 deadline", rows[2].Title)
}
if rows[3].Title != "Undated milestone" {
t.Errorf("rows[3] = %q, want Undated milestone", rows[3].Title)
}
}
func TestSortTimeline_SameDateTiebreak(t *testing.T) {
mar5 := time.Date(2026, 3, 5, 0, 0, 0, 0, time.UTC)
d1 := uuid.New()
a1 := uuid.New()
pe1 := uuid.New()
rows := []TimelineEvent{
{Kind: "milestone", Date: &mar5, Title: "C", ProjectEventID: &pe1},
{Kind: "appointment", Date: &mar5, Title: "B", AppointmentID: &a1},
{Kind: "deadline", Date: &mar5, Title: "A", DeadlineID: &d1},
}
sortTimeline(rows)
// Tiebreak: deadline > appointment > milestone (kindOrder).
if rows[0].Kind != "deadline" {
t.Errorf("rows[0].Kind = %q, want deadline", rows[0].Kind)
}
if rows[1].Kind != "appointment" {
t.Errorf("rows[1].Kind = %q, want appointment", rows[1].Kind)
}
if rows[2].Kind != "milestone" {
t.Errorf("rows[2].Kind = %q, want milestone", rows[2].Kind)
}
}
func TestDeadlineStatus(t *testing.T) {
today := time.Now().UTC()
yesterday := today.AddDate(0, 0, -1)
tomorrow := today.AddDate(0, 0, 1)
cases := []struct {
name string
status string
due time.Time
want string
}{
{"completed regardless of date", "completed", yesterday, "done"},
{"completed even if future", "completed", tomorrow, "done"},
{"pending past = overdue", "pending", yesterday, "overdue"},
{"pending today = open", "pending", today, "open"},
{"pending future = open", "pending", tomorrow, "open"},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
got := deadlineStatus(c.status, c.due)
if got != c.want {
t.Errorf("deadlineStatus(%q, %v) = %q, want %q",
c.status, c.due, got, c.want)
}
})
}
}
func TestAppointmentStatus(t *testing.T) {
now := time.Date(2026, 5, 8, 12, 0, 0, 0, time.UTC)
past := now.Add(-1 * time.Hour)
future := now.Add(1 * time.Hour)
if got := appointmentStatus(past, now); got != "done" {
t.Errorf("past appointment status = %q, want done", got)
}
if got := appointmentStatus(future, now); got != "open" {
t.Errorf("future appointment status = %q, want open", got)
}
}
func TestMilestoneStatus(t *testing.T) {
custom := "custom_milestone"
other := "counterclaim_filed"
cases := []struct {
name string
timelineKind *string
eventType *string
want string
}{
{"custom_milestone via timeline_kind", &custom, nil, "off_script"},
{"custom_milestone via event_type fallback", nil, &custom, "off_script"},
{"structural milestone = done", nil, &other, "done"},
{"both nil = done", nil, nil, "done"},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
got := milestoneStatus(c.timelineKind, c.eventType)
if got != c.want {
t.Errorf("milestoneStatus = %q, want %q", got, c.want)
}
})
}
}
func TestKindOrder(t *testing.T) {
// Lock the exact ordering — frontend assumes deadline before
// appointment before milestone before projected on same-date ties.
if kindOrder("deadline") >= kindOrder("appointment") {
t.Error("deadline should sort before appointment")
}
if kindOrder("appointment") >= kindOrder("milestone") {
t.Error("appointment should sort before milestone")
}
if kindOrder("milestone") >= kindOrder("projected") {
t.Error("milestone should sort before projected")
}
}