diff --git a/frontend/src/client/dashboard.ts b/frontend/src/client/dashboard.ts
index 8fb298e..b7fb4ec 100644
--- a/frontend/src/client/dashboard.ts
+++ b/frontend/src/client/dashboard.ts
@@ -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);
diff --git a/frontend/src/client/i18n.ts b/frontend/src/client/i18n.ts
index b158bcb..6caaaec 100644
--- a/frontend/src/client/i18n.ts
+++ b/frontend/src/client/i18n.ts
@@ -667,6 +667,8 @@ const translations: Record> = {
"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> = {
"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",
diff --git a/frontend/src/dashboard.tsx b/frontend/src/dashboard.tsx
index ddf736d..5ebd8c1 100644
--- a/frontend/src/dashboard.tsx
+++ b/frontend/src/dashboard.tsx
@@ -57,7 +57,7 @@ export function renderDashboard(): string {
- {/* Traffic-light deadline summary */}
+ {/* Traffic-light deadline summary (4+1: Überfällig conditional + 4 universal — t-paliad-110) */}
+
+ {/* Termine summary rail — 3 cards: Heute · Diese Woche · Später (t-paliad-110) */}
+
+
+ Termine auf einen Blick
+
+
diff --git a/frontend/src/i18n-keys.ts b/frontend/src/i18n-keys.ts
index 34cbac8..553dcc1 100644
--- a/frontend/src/i18n-keys.ts
+++ b/frontend/src/i18n-keys.ts
@@ -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"
diff --git a/frontend/src/styles/global.css b/frontend/src/styles/global.css
index 9ec60fd..dad090d 100644
--- a/frontend/src/styles/global.css
+++ b/frontend/src/styles/global.css
@@ -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
diff --git a/internal/services/dashboard_service.go b/internal/services/dashboard_service.go
index baf0918..4f023dd 100644
--- a/internal/services/dashboard_service.go
+++ b/internal/services/dashboard_service.go
@@ -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
}