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).
344 lines
13 KiB
Go
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"
|
|
}
|
|
}
|
|
}
|