Files
paliad/internal/services/dashboard_service.go
m 57237a55a3 feat(t-paliad-110): refactor Dashboard rails — drop Erledigt card, add Später + Termine rail
PR-4 of the Fristen+Termine unification, closing out t-paliad-110.

Fristen rail (was 5 cards):
- Erledigt card removed (status=completed stays reachable via the EventsPage
  filter dropdown — no card on the rail per the new model)
- Später card added (pending deadlines past Mon-week-after, click filters
  to /deadlines?status=later)
- 4+1 final shape: Überfällig (conditional alarm) · Heute · Diese Woche ·
  Nächste Woche · Später

Termine rail (new): 3 cards — Heute · Diese Woche · Später. No Überfällig
(past appointments aren't urgent), no Nächste Woche (low-value distinction
for appointments per the design rationale). Cards click through to
/appointments?status=… so users land in the matching EventsPage view.

Backend (DashboardService.loadSummary):
- DeadlineSummary.CompletedThisWeek dropped, .Later added
- AppointmentSummary added (Today / ThisWeek / Later)
- One CTE-based query computes both rails alongside MatterSummary; bucket
  cutoffs share computeDeadlineBucketBounds with /api/events/summary +
  /api/deadlines/summary so all three surfaces stay in lockstep

Frontend:
- dashboard.tsx: Erledigt card removed, Später card + Termine section added
- client/dashboard.ts: types updated, renderAppointmentSummary added
- 4 new i18n keys (DE+EN): dashboard.summary.later +
  dashboard.appointment_summary.heading
- CSS: .dashboard-card-later (muted blue) + 3 .dashboard-card-appt-* rules
  reusing the existing --bucket-* tokens

go build/vet/test ./... clean. bun run build clean (1396 keys).
2026-05-04 13:52:49 +02:00

344 lines
13 KiB
Go

package services
// DashboardService aggregates the logged-in landing-page payload. Scoped to
// Projects the caller can see — same predicate as ProjectService (team-based,
// v2 data model, t-paliad-024).
import (
"context"
"database/sql"
"encoding/json"
"errors"
"fmt"
"time"
"github.com/google/uuid"
"github.com/jmoiron/sqlx"
"mgit.msbls.de/m/paliad/internal/models"
)
// DashboardService reads paliad.projects/deadlines/appointments/project_events for
// the Dashboard page.
type DashboardService struct {
db *sqlx.DB
users *UserService
}
func NewDashboardService(db *sqlx.DB, users *UserService) *DashboardService {
return &DashboardService{db: db, users: users}
}
// DashboardData is the full payload returned to the frontend.
type DashboardData struct {
User *DashboardUser `json:"user"`
DeadlineSummary DeadlineSummary `json:"deadline_summary"`
AppointmentSummary AppointmentSummary `json:"appointment_summary"`
MatterSummary MatterSummary `json:"matter_summary"`
UpcomingDeadlines []UpcomingDeadline `json:"upcoming_deadlines"`
UpcomingAppointments []UpcomingAppointment `json:"upcoming_appointments"`
RecentActivity []ActivityEntry `json:"recent_activity"`
}
type DashboardUser struct {
ID uuid.UUID `json:"id"`
Email string `json:"email"`
DisplayName string `json:"display_name"`
Office string `json:"office"`
JobTitle *string `json:"job_title"`
GlobalRole string `json:"global_role"`
}
// DeadlineSummary feeds the Dashboard "Fristen auf einen Blick" cards.
//
// Bucket math is identical to DeadlineService.SummaryCounts (single source
// of truth via computeDeadlineBucketBounds) so the Dashboard and the
// /deadlines summary cards always show the same numbers (t-paliad-106 →
// t-paliad-110). The Erledigt card is gone; status=completed stays
// reachable via the dropdown filter on the EventsPage but is no longer
// rendered as a card on the dashboard rail.
type DeadlineSummary 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"`
}
// AppointmentSummary feeds the Dashboard "Termine auf einen Blick" rail
// (t-paliad-110). Three cards: Heute / Diese Woche / Später. No Überfällig
// (past appointments aren't urgent — they happened) and no Nächste Woche
// (low-value distinction for appointments per the design doc).
type AppointmentSummary struct {
Today int `json:"today" db:"today"`
ThisWeek int `json:"this_week" db:"this_week"`
Later int `json:"later" db:"later"`
}
// MatterSummary counts visible Projects by status. Field names kept as
// "matter" for JSON API compatibility with the dashboard client.
type MatterSummary struct {
Active int `json:"active" db:"active"`
Archived int `json:"archived" db:"archived"`
Total int `json:"total" db:"total"`
}
// UpcomingDeadline is one row for "Kommende Deadlines".
type UpcomingDeadline struct {
ID uuid.UUID `json:"id" db:"id"`
Title string `json:"title" db:"title"`
DueDate string `json:"due_date" db:"due_date"`
ProjectID uuid.UUID `json:"project_id" db:"project_id"`
ProjectTitle string `json:"project_title" db:"project_title"`
ProjectRef string `json:"project_reference" db:"project_reference"`
Urgency string `json:"urgency"`
}
// UpcomingAppointment is one row for "Kommende Appointments".
type UpcomingAppointment struct {
ID uuid.UUID `json:"id" db:"id"`
Title string `json:"title" db:"title"`
StartAt time.Time `json:"start_at" db:"start_at"`
EndAt *time.Time `json:"end_at" db:"end_at"`
Type *string `json:"type" db:"appointment_type"`
ProjectID *uuid.UUID `json:"project_id" db:"project_id"`
ProjectTitle *string `json:"project_title" db:"project_title"`
ProjectRef *string `json:"project_reference" db:"project_reference"`
}
// ActivityEntry is one row in the "Letzte Aktivität" feed.
type ActivityEntry struct {
Timestamp time.Time `json:"timestamp" db:"timestamp"`
ActorEmail *string `json:"actor_email" db:"actor_email"`
ActorName *string `json:"actor_name" db:"actor_name"`
ProjectID uuid.UUID `json:"project_id" db:"project_id"`
ProjectTitle string `json:"project_title" db:"project_title"`
ProjectRef string `json:"project_reference" db:"project_reference"`
Action *string `json:"action" db:"action"`
Details string `json:"details" db:"details"`
Description *string `json:"description" db:"description"`
Metadata json.RawMessage `json:"metadata" db:"metadata"`
}
// Get builds the full dashboard payload.
func (s *DashboardService) Get(ctx context.Context, userID uuid.UUID) (*DashboardData, error) {
user, err := s.users.GetByID(ctx, userID)
if err != nil {
return nil, err
}
data := &DashboardData{
UpcomingDeadlines: []UpcomingDeadline{},
UpcomingAppointments: []UpcomingAppointment{},
RecentActivity: []ActivityEntry{},
}
if user == nil {
return data, nil
}
data.User = &DashboardUser{
ID: user.ID,
Email: user.Email,
DisplayName: user.DisplayName,
Office: user.Office,
JobTitle: user.JobTitle,
GlobalRole: user.GlobalRole,
}
now := time.Now()
today := now.Format("2006-01-02")
endOfWindow := now.AddDate(0, 0, 7).Format("2006-01-02")
bounds := computeDeadlineBucketBounds(now.UTC())
if err := s.loadSummary(ctx, data, user, bounds); err != nil {
return nil, err
}
if err := s.loadUpcomingDeadlines(ctx, data, user, today, endOfWindow); err != nil {
return nil, err
}
if err := s.loadUpcomingAppointments(ctx, data, user, now); err != nil {
return nil, err
}
if err := s.loadRecentActivity(ctx, data, user); err != nil {
return nil, err
}
annotateUrgency(data.UpcomingDeadlines, now)
return data, nil
}
// loadSummary fills DeadlineSummary + AppointmentSummary + MatterSummary.
//
// Bucket math comes from computeDeadlineBucketBounds (deadline_service.go) so
// the buckets stay in lockstep with /api/deadlines/summary and the unified
// /api/events/summary used by the EventsPage (t-paliad-110).
//
// Bucket model on the Dashboard:
// - Fristen rail: Überfällig (conditional alarm) · Heute · Diese Woche ·
// Nächste Woche · Später. Erledigt is no longer rendered as a card —
// status=completed stays reachable via the EventsPage filter dropdown.
// - Termine rail: Heute · Diese Woche · Später. No Überfällig, no
// Nächste Woche (low-value distinction for appointments).
//
// Visibility predicate: see internal/services/visibility.go — global_admin
// shortcut OR any ancestor-or-direct team membership. Applied once via a CTE;
// downstream queries reuse the same helper.
func (s *DashboardService) loadSummary(ctx context.Context, data *DashboardData, user *models.User, bounds deadlineBucketBounds) error {
query := `
WITH visible_projekte AS (
SELECT p.id, p.status
FROM paliad.projects p
WHERE ` + visibilityPredicatePositional("p", 1) + `
),
deadline_stats AS (
SELECT
COUNT(*) FILTER (WHERE f.status = 'pending' AND f.due_date < $2::date) AS overdue,
COUNT(*) FILTER (WHERE f.status = 'pending' AND f.due_date = $2::date) AS today,
COUNT(*) FILTER (WHERE f.status = 'pending' AND f.due_date >= $3::date AND f.due_date < $4::date) AS this_week,
COUNT(*) FILTER (WHERE f.status = 'pending' AND f.due_date >= $4::date AND f.due_date < $5::date) AS next_week,
COUNT(*) FILTER (WHERE f.status = 'pending' AND f.due_date >= $5::date) AS later
FROM paliad.deadlines f
JOIN visible_projekte v ON v.id = f.project_id
),
appointment_stats AS (
SELECT
COUNT(*) FILTER (WHERE t.start_at >= $2 AND t.start_at < $3) AS today,
COUNT(*) FILTER (WHERE t.start_at >= $3 AND t.start_at < $4) AS this_week,
COUNT(*) FILTER (WHERE t.start_at >= $5) AS later
FROM paliad.appointments t
LEFT JOIN visible_projekte v ON v.id = t.project_id
WHERE (t.project_id IS NULL AND t.created_by = $1)
OR (t.project_id IS NOT NULL AND v.id IS NOT NULL)
),
matter_stats AS (
SELECT
COUNT(*) FILTER (WHERE status = 'active') AS active,
COUNT(*) FILTER (WHERE status = 'archived') AS archived,
COUNT(*) AS total
FROM visible_projekte
)
SELECT ds.overdue, ds.today, ds.this_week, ds.next_week, ds.later,
aps.today AS appt_today, aps.this_week AS appt_this_week, aps.later AS appt_later,
ms.active, ms.archived, ms.total
FROM deadline_stats ds, appointment_stats aps, matter_stats ms`
var row struct {
DeadlineSummary
ApptToday int `db:"appt_today"`
ApptThisWeek int `db:"appt_this_week"`
ApptLater int `db:"appt_later"`
MatterSummary
}
err := s.db.GetContext(ctx, &row, query,
user.ID, bounds.today, bounds.tomorrow, bounds.nextMonday, bounds.weekAfter)
if errors.Is(err, sql.ErrNoRows) {
return nil
}
if err != nil {
return fmt.Errorf("dashboard summary: %w", err)
}
data.DeadlineSummary = row.DeadlineSummary
data.AppointmentSummary = AppointmentSummary{
Today: row.ApptToday,
ThisWeek: row.ApptThisWeek,
Later: row.ApptLater,
}
data.MatterSummary = row.MatterSummary
return nil
}
func (s *DashboardService) loadUpcomingDeadlines(ctx context.Context, data *DashboardData, user *models.User, today, endOfWeek string) error {
query := `
SELECT f.id,
f.title,
to_char(f.due_date, 'YYYY-MM-DD') AS due_date,
p.id AS project_id,
p.title AS project_title,
COALESCE(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) + `
ORDER BY f.due_date ASC
LIMIT 10`
if err := s.db.SelectContext(ctx, &data.UpcomingDeadlines, query,
user.ID, today, endOfWeek); err != nil {
return fmt.Errorf("dashboard upcoming deadlines: %w", err)
}
return nil
}
func (s *DashboardService) loadUpcomingAppointments(ctx context.Context, data *DashboardData, user *models.User, now time.Time) error {
query := `
SELECT t.id,
t.title,
t.start_at,
t.end_at,
t.appointment_type,
t.project_id,
p.title AS project_title,
COALESCE(p.reference, NULL) 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 < ($2 + interval '7 days')
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
LIMIT 10`
if err := s.db.SelectContext(ctx, &data.UpcomingAppointments, query,
user.ID, now); err != nil {
return fmt.Errorf("dashboard upcoming appointments: %w", err)
}
return nil
}
func (s *DashboardService) loadRecentActivity(ctx context.Context, data *DashboardData, user *models.User) error {
query := `
SELECT COALESCE(e.event_date, e.created_at) AS timestamp,
u.email AS actor_email,
u.display_name AS actor_name,
e.project_id,
p.title AS project_title,
COALESCE(p.reference, '') AS project_reference,
e.event_type AS action,
e.title AS details,
e.description,
e.metadata AS metadata
FROM paliad.project_events e
JOIN paliad.projects p ON p.id = e.project_id
LEFT JOIN paliad.users u ON u.id = e.created_by
WHERE ` + visibilityPredicatePositional("p", 1) + `
ORDER BY COALESCE(e.event_date, e.created_at) DESC
LIMIT 10`
if err := s.db.SelectContext(ctx, &data.RecentActivity, query, user.ID); err != nil {
return fmt.Errorf("dashboard recent activity: %w", err)
}
return nil
}
func annotateUrgency(deadlines []UpcomingDeadline, now time.Time) {
today := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location())
for i := range deadlines {
due, err := time.ParseInLocation("2006-01-02", deadlines[i].DueDate, now.Location())
if err != nil {
deadlines[i].Urgency = "soon"
continue
}
days := int(due.Sub(today).Hours() / 24)
switch {
case days < 0:
deadlines[i].Urgency = "overdue"
case days == 0:
deadlines[i].Urgency = "today"
case days <= 2:
deadlines[i].Urgency = "urgent"
default:
deadlines[i].Urgency = "soon"
}
}
}