Merge: t-paliad-110 PR-4 — Dashboard Termine rail + Fristen rail refactor

This commit is contained in:
m
2026-05-04 13:52:56 +02:00
6 changed files with 114 additions and 27 deletions

View File

@@ -14,7 +14,13 @@ interface DeadlineSummary {
today: number;
this_week: number;
next_week: number;
completed_this_week: number;
later: number;
}
interface AppointmentSummary {
today: number;
this_week: number;
later: number;
}
interface MatterSummary {
@@ -60,6 +66,7 @@ interface ActivityEntry {
interface DashboardData {
user: DashboardUser | null;
deadline_summary: DeadlineSummary;
appointment_summary: AppointmentSummary;
matter_summary: MatterSummary;
upcoming_deadlines: UpcomingDeadline[];
upcoming_appointments: UpcomingAppointment[];
@@ -98,6 +105,7 @@ function render(): void {
if (!data) return;
renderGreeting(data.user);
renderSummary(data.deadline_summary);
renderAppointmentSummary(data.appointment_summary);
renderMatters(data.matter_summary);
renderDeadlines(data.upcoming_deadlines);
renderAppointments(data.upcoming_appointments);
@@ -133,17 +141,23 @@ function renderSummary(s: DeadlineSummary): void {
setCount("dashboard-count-today", s.today);
setCount("dashboard-count-this-week", s.this_week);
setCount("dashboard-count-next-week", s.next_week);
setCount("dashboard-count-completed", s.completed_this_week);
setCount("dashboard-count-later", s.later);
// Überfällig is an emergency category — hide the card entirely on a clean
// slate (the .dashboard-summary-grid uses auto-fit so the row re-flows to
// 4 cards) and trip the alarm styling when there's anything overdue. See
// t-paliad-105 / t-paliad-106.
// t-paliad-105 / t-paliad-106 / t-paliad-110.
const overdueCard = document.getElementById("dashboard-card-overdue")!;
overdueCard.classList.toggle("dashboard-card-overdue-hidden", s.overdue === 0);
overdueCard.classList.toggle("dashboard-card-alarm", s.overdue > 0);
}
function renderAppointmentSummary(s: AppointmentSummary): void {
setCount("dashboard-count-appt-today", s.today);
setCount("dashboard-count-appt-this-week", s.this_week);
setCount("dashboard-count-appt-later", s.later);
}
function renderMatters(s: MatterSummary): void {
setCount("dashboard-matter-active", s.active);
setCount("dashboard-matter-archived", s.archived);

View File

@@ -667,6 +667,8 @@ const translations: Record<Lang, Record<string, string>> = {
"dashboard.summary.this_week": "Diese Woche",
"dashboard.summary.next_week": "N\u00e4chste Woche",
"dashboard.summary.completed": "Erledigt",
"dashboard.summary.later": "Später",
"dashboard.appointment_summary.heading": "Termine auf einen Blick",
"dashboard.matters.heading": "Meine Akten",
"dashboard.matters.active": "Aktiv",
"dashboard.matters.archived": "Archiviert",
@@ -2165,6 +2167,8 @@ const translations: Record<Lang, Record<string, string>> = {
"dashboard.summary.this_week": "This week",
"dashboard.summary.next_week": "Next week",
"dashboard.summary.completed": "Done",
"dashboard.summary.later": "Later",
"dashboard.appointment_summary.heading": "Appointments at a glance",
"dashboard.matters.heading": "My matters",
"dashboard.matters.active": "Active",
"dashboard.matters.archived": "Archived",

View File

@@ -57,7 +57,7 @@ export function renderDashboard(): string {
</p>
</div>
{/* Traffic-light deadline summary */}
{/* Traffic-light deadline summary (4+1: Überfällig conditional + 4 universal — t-paliad-110) */}
<section className="dashboard-summary" aria-labelledby="dashboard-summary-heading">
<h2 id="dashboard-summary-heading" className="dashboard-section-heading" data-i18n="dashboard.summary.heading">
Fristen auf einen Blick
@@ -79,9 +79,30 @@ export function renderDashboard(): string {
<div className="dashboard-card-count" id="dashboard-count-next-week">0</div>
<div className="dashboard-card-label" data-i18n="dashboard.summary.next_week">N&auml;chste Woche</div>
</a>
<a href="/deadlines?status=completed" className="dashboard-card dashboard-card-done" id="dashboard-card-completed">
<div className="dashboard-card-count" id="dashboard-count-completed">0</div>
<div className="dashboard-card-label" data-i18n="dashboard.summary.completed">Erledigt</div>
<a href="/deadlines?status=later" className="dashboard-card dashboard-card-later" id="dashboard-card-later">
<div className="dashboard-card-count" id="dashboard-count-later">0</div>
<div className="dashboard-card-label" data-i18n="dashboard.summary.later">Sp&auml;ter</div>
</a>
</div>
</section>
{/* Termine summary rail — 3 cards: Heute · Diese Woche · Später (t-paliad-110) */}
<section className="dashboard-summary" aria-labelledby="dashboard-appointment-summary-heading">
<h2 id="dashboard-appointment-summary-heading" className="dashboard-section-heading" data-i18n="dashboard.appointment_summary.heading">
Termine auf einen Blick
</h2>
<div className="dashboard-summary-grid">
<a href="/appointments?status=today" className="dashboard-card dashboard-card-appt-today" id="dashboard-card-appt-today">
<div className="dashboard-card-count" id="dashboard-count-appt-today">0</div>
<div className="dashboard-card-label" data-i18n="dashboard.summary.today">Heute</div>
</a>
<a href="/appointments?status=this_week" className="dashboard-card dashboard-card-appt-week" id="dashboard-card-appt-thisweek">
<div className="dashboard-card-count" id="dashboard-count-appt-this-week">0</div>
<div className="dashboard-card-label" data-i18n="dashboard.summary.this_week">Diese Woche</div>
</a>
<a href="/appointments?status=later" className="dashboard-card dashboard-card-appt-later" id="dashboard-card-appt-later">
<div className="dashboard-card-count" id="dashboard-count-appt-later">0</div>
<div className="dashboard-card-label" data-i18n="dashboard.summary.later">Sp&auml;ter</div>
</a>
</div>
</section>

View File

@@ -505,6 +505,7 @@ export type I18nKey =
| "dashboard.activity.event"
| "dashboard.activity.heading"
| "dashboard.activity.system"
| "dashboard.appointment_summary.heading"
| "dashboard.appointments.empty"
| "dashboard.appointments.heading"
| "dashboard.deadlines.empty"
@@ -517,6 +518,7 @@ export type I18nKey =
| "dashboard.onboarding"
| "dashboard.summary.completed"
| "dashboard.summary.heading"
| "dashboard.summary.later"
| "dashboard.summary.next_week"
| "dashboard.summary.overdue"
| "dashboard.summary.this_week"

View File

@@ -5702,6 +5702,17 @@ input[type="range"]::-moz-range-thumb {
.dashboard-card-green { border-left: 3px solid var(--bucket-next-week); }
.dashboard-card-done .dashboard-card-count { color: var(--bucket-done); }
.dashboard-card-done { border-left: 3px solid var(--bucket-done); }
/* Später bucket on the Fristen rail — muted blue (t-paliad-110). */
.dashboard-card-later .dashboard-card-count { color: var(--bucket-later); }
.dashboard-card-later { border-left: 3px solid var(--bucket-later); }
/* Termine rail uses the same urgency token family — keep the palette
consistent across rails so users read both at the same glance. */
.dashboard-card-appt-today .dashboard-card-count { color: var(--bucket-today); }
.dashboard-card-appt-today { border-left: 3px solid var(--bucket-today); }
.dashboard-card-appt-week .dashboard-card-count { color: var(--bucket-week); }
.dashboard-card-appt-week { border-left: 3px solid var(--bucket-week); }
.dashboard-card-appt-later .dashboard-card-count { color: var(--bucket-later); }
.dashboard-card-appt-later { border-left: 3px solid var(--bucket-later); }
/* Überfällig alarm — see t-paliad-105 / t-paliad-106.
When overdue > 0 the card flips from a calm border-left accent to a

View File

@@ -33,6 +33,7 @@ func NewDashboardService(db *sqlx.DB, users *UserService) *DashboardService {
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"`
@@ -52,15 +53,26 @@ type DashboardUser struct {
//
// 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).
// CompletedThisWeek keeps the 7-day window the Dashboard has always used —
// the /deadlines surface still shows the all-time completed count.
// /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"`
CompletedThisWeek int `json:"completed_this_week" db:"completed_this_week"`
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
@@ -135,10 +147,9 @@ func (s *DashboardService) Get(ctx context.Context, userID uuid.UUID) (*Dashboar
now := time.Now()
today := now.Format("2006-01-02")
endOfWindow := now.AddDate(0, 0, 7).Format("2006-01-02")
sevenDaysAgo := now.AddDate(0, 0, -7).UTC()
bounds := computeDeadlineBucketBounds(now.UTC())
if err := s.loadSummary(ctx, data, user, bounds, sevenDaysAgo); err != nil {
if err := s.loadSummary(ctx, data, user, bounds); err != nil {
return nil, err
}
if err := s.loadUpcomingDeadlines(ctx, data, user, today, endOfWindow); err != nil {
@@ -155,18 +166,23 @@ func (s *DashboardService) Get(ctx context.Context, userID uuid.UUID) (*Dashboar
return data, nil
}
// loadSummary fills DeadlineSummary + MatterSummary.
// loadSummary fills DeadlineSummary + AppointmentSummary + MatterSummary.
//
// Bucket math comes from computeDeadlineBucketBounds (deadline_service.go) so
// the four pending buckets stay in lockstep with /api/deadlines/summary; the
// "completed" column on the Dashboard remains the 7-day rolling window
// (sevenDaysAgo) — the /deadlines surface intentionally shows all-time
// completed instead.
// 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, sevenDaysAgo time.Time) error {
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
@@ -179,10 +195,20 @@ deadline_stats AS (
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 = 'completed' AND f.completed_at >= $6) AS completed_this_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,
@@ -190,16 +216,20 @@ matter_stats AS (
COUNT(*) AS total
FROM visible_projekte
)
SELECT ds.overdue, ds.today, ds.this_week, ds.next_week, ds.completed_this_week,
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, matter_stats ms`
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, sevenDaysAgo)
user.ID, bounds.today, bounds.tomorrow, bounds.nextMonday, bounds.weekAfter)
if errors.Is(err, sql.ErrNoRows) {
return nil
}
@@ -207,6 +237,11 @@ SELECT ds.overdue, ds.today, ds.this_week, ds.next_week, ds.completed_this_week,
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
}