Merge: t-paliad-106 — Deadline summary 5-bucket harmonization

This commit is contained in:
m
2026-05-04 12:04:20 +02:00
11 changed files with 272 additions and 73 deletions

View File

@@ -11,8 +11,9 @@ interface DashboardUser {
interface DeadlineSummary {
overdue: number;
today: number;
this_week: number;
upcoming: number;
next_week: number;
completed_this_week: number;
}
@@ -129,14 +130,15 @@ function renderGreeting(user: DashboardUser | null): void {
function renderSummary(s: DeadlineSummary): void {
setCount("dashboard-count-overdue", s.overdue);
setCount("dashboard-count-today", s.today);
setCount("dashboard-count-this-week", s.this_week);
setCount("dashboard-count-upcoming", s.upcoming);
setCount("dashboard-count-next-week", s.next_week);
setCount("dashboard-count-completed", s.completed_this_week);
// Ü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
// 3 cards) and trip the alarm styling when there's anything overdue. See
// t-paliad-105.
// 4 cards) and trip the alarm styling when there's anything overdue. See
// t-paliad-105 / t-paliad-106.
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);

View File

@@ -35,8 +35,9 @@ interface Project {
interface Summary {
overdue: number;
today: number;
this_week: number;
upcoming: number;
next_week: number;
completed: number;
total: number;
}
@@ -92,8 +93,9 @@ async function loadSummary() {
if (!resp.ok) return;
const sum: Summary = await resp.json();
setCount("sum-overdue", sum.overdue);
setCount("sum-today", sum.today);
setCount("sum-week", sum.this_week);
setCount("sum-upcoming", sum.upcoming);
setCount("sum-next-week", sum.next_week);
setCount("sum-completed", sum.completed);
applyOverdueState(sum.overdue);
} catch {

View File

@@ -518,20 +518,22 @@ const translations: Record<Lang, Record<string, string>> = {
// Phase E — Fristen (persistent deadlines)
"deadlines.list.title": "Fristen \u2014 Paliad",
"deadlines.list.heading": "Fristen",
"deadlines.list.subtitle": "Persistente Fristen f\u00fcr Ihre Akten. \u00dcberf\u00e4llig, diese Woche, sp\u00e4ter \u2014 auf einen Blick.",
"deadlines.list.subtitle": "Persistente Fristen f\u00fcr Ihre Akten. \u00dcberf\u00e4llig, heute, diese Woche, n\u00e4chste Woche \u2014 auf einen Blick.",
"deadlines.list.new": "Neue Frist",
"deadlines.list.calendar": "Kalenderansicht",
"deadlines.summary.overdue": "\u00dcberf\u00e4llig",
"deadlines.summary.today": "Heute",
"deadlines.summary.thisweek": "Diese Woche",
"deadlines.summary.upcoming": "Sp\u00e4ter",
"deadlines.summary.nextweek": "N\u00e4chste Woche",
"deadlines.summary.completed": "Erledigt",
"deadlines.filter.status": "Status",
"deadlines.filter.akte": "Projekt",
"deadlines.filter.all": "Alle (offen & erledigt)",
"deadlines.filter.pending": "Alle offenen",
"deadlines.filter.overdue": "\u00dcberf\u00e4llig",
"deadlines.filter.today": "Heute",
"deadlines.filter.thisweek": "Diese Woche",
"deadlines.filter.upcoming": "Sp\u00e4ter",
"deadlines.filter.nextweek": "N\u00e4chste Woche",
"deadlines.filter.completed": "Erledigt",
"deadlines.filter.akte.all": "Alle Projekte",
"deadlines.col.due": "F\u00e4llig",
@@ -659,9 +661,10 @@ const translations: Record<Lang, Record<string, string>> = {
"dashboard.onboarding": "Bitte schlie\u00dfen Sie das Onboarding ab, damit Ihnen Fristen und Akten angezeigt werden k\u00f6nnen.",
"dashboard.summary.heading": "Fristen auf einen Blick",
"dashboard.summary.overdue": "\u00dcberf\u00e4llig",
"dashboard.summary.today": "Heute",
"dashboard.summary.this_week": "Diese Woche",
"dashboard.summary.upcoming": "Kommend",
"dashboard.summary.completed": "Abgeschlossen (7\u202fT.)",
"dashboard.summary.next_week": "N\u00e4chste Woche",
"dashboard.summary.completed": "Erledigt",
"dashboard.matters.heading": "Meine Akten",
"dashboard.matters.active": "Aktiv",
"dashboard.matters.archived": "Archiviert",
@@ -1985,21 +1988,23 @@ const translations: Record<Lang, Record<string, string>> = {
// Phase E — Fristen (persistent deadlines)
"deadlines.list.title": "Deadlines \u2014 Paliad",
"deadlines.list.heading": "Deadlines",
"deadlines.list.subtitle": "Persistent deadlines for your matters. Overdue, this week, later \u2014 at a glance.",
"deadlines.list.subtitle": "Persistent deadlines for your matters. Overdue, today, this week, next week \u2014 at a glance.",
"deadlines.list.new": "New deadline",
"deadlines.list.calendar": "Calendar view",
"deadlines.summary.overdue": "Overdue",
"deadlines.summary.today": "Today",
"deadlines.summary.thisweek": "This week",
"deadlines.summary.upcoming": "Later",
"deadlines.summary.completed": "Completed",
"deadlines.summary.nextweek": "Next week",
"deadlines.summary.completed": "Done",
"deadlines.filter.status": "Status",
"deadlines.filter.akte": "Matter",
"deadlines.filter.all": "All (open & completed)",
"deadlines.filter.pending": "All open",
"deadlines.filter.overdue": "Overdue",
"deadlines.filter.today": "Today",
"deadlines.filter.thisweek": "This week",
"deadlines.filter.upcoming": "Later",
"deadlines.filter.completed": "Completed",
"deadlines.filter.nextweek": "Next week",
"deadlines.filter.completed": "Done",
"deadlines.filter.akte.all": "All matters",
"deadlines.col.due": "Due",
"deadlines.col.title": "Title",
@@ -2126,9 +2131,10 @@ const translations: Record<Lang, Record<string, string>> = {
"dashboard.onboarding": "Please complete onboarding before deadlines and matters are shown.",
"dashboard.summary.heading": "Deadlines at a glance",
"dashboard.summary.overdue": "Overdue",
"dashboard.summary.today": "Today",
"dashboard.summary.this_week": "This week",
"dashboard.summary.upcoming": "Upcoming",
"dashboard.summary.completed": "Completed (7d)",
"dashboard.summary.next_week": "Next week",
"dashboard.summary.completed": "Done",
"dashboard.matters.heading": "My matters",
"dashboard.matters.active": "Active",
"dashboard.matters.archived": "Archived",

View File

@@ -67,17 +67,21 @@ export function renderDashboard(): string {
<div className="dashboard-card-count" id="dashboard-count-overdue">0</div>
<div className="dashboard-card-label" data-i18n="dashboard.summary.overdue">&Uuml;berf&auml;llig</div>
</a>
<a href="/deadlines?status=today" className="dashboard-card dashboard-card-today" id="dashboard-card-today">
<div className="dashboard-card-count" id="dashboard-count-today">0</div>
<div className="dashboard-card-label" data-i18n="dashboard.summary.today">Heute</div>
</a>
<a href="/deadlines?status=this_week" className="dashboard-card dashboard-card-amber" id="dashboard-card-thisweek">
<div className="dashboard-card-count" id="dashboard-count-this-week">0</div>
<div className="dashboard-card-label" data-i18n="dashboard.summary.this_week">Diese Woche</div>
</a>
<a href="/deadlines?status=upcoming" className="dashboard-card dashboard-card-green" id="dashboard-card-upcoming">
<div className="dashboard-card-count" id="dashboard-count-upcoming">0</div>
<div className="dashboard-card-label" data-i18n="dashboard.summary.upcoming">Kommend</div>
<a href="/deadlines?status=next_week" className="dashboard-card dashboard-card-green" id="dashboard-card-nextweek">
<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">Abgeschlossen (7&#8239;T.)</div>
<div className="dashboard-card-label" data-i18n="dashboard.summary.completed">Erledigt</div>
</a>
</div>
</section>

View File

@@ -29,7 +29,7 @@ export function renderDeadlines(): string {
<div>
<h1 data-i18n="deadlines.list.heading">Fristen</h1>
<p className="tool-subtitle" data-i18n="deadlines.list.subtitle">
Persistente Fristen f&uuml;r Ihre Akten. &Uuml;berf&auml;llig, diese Woche, sp&auml;ter &mdash; auf einen Blick.
Persistente Fristen f&uuml;r Ihre Akten. &Uuml;berf&auml;llig, heute, diese Woche, n&auml;chste Woche &mdash; auf einen Blick.
</p>
</div>
<div className="fristen-header-actions">
@@ -49,15 +49,20 @@ export function renderDeadlines(): string {
<span className="frist-summary-count" id="sum-overdue">0</span>
<span className="frist-summary-label" data-i18n="deadlines.summary.overdue">&Uuml;berf&auml;llig</span>
</button>
<button type="button" className="frist-summary-card frist-card-today" data-status="today">
<span className="frist-summary-dot" />
<span className="frist-summary-count" id="sum-today">0</span>
<span className="frist-summary-label" data-i18n="deadlines.summary.today">Heute</span>
</button>
<button type="button" className="frist-summary-card frist-card-week" data-status="this_week">
<span className="frist-summary-dot" />
<span className="frist-summary-count" id="sum-week">0</span>
<span className="frist-summary-label" data-i18n="deadlines.summary.thisweek">Diese Woche</span>
</button>
<button type="button" className="frist-summary-card frist-card-upcoming" data-status="upcoming">
<button type="button" className="frist-summary-card frist-card-next-week" data-status="next_week">
<span className="frist-summary-dot" />
<span className="frist-summary-count" id="sum-upcoming">0</span>
<span className="frist-summary-label" data-i18n="deadlines.summary.upcoming">Sp&auml;ter</span>
<span className="frist-summary-count" id="sum-next-week">0</span>
<span className="frist-summary-label" data-i18n="deadlines.summary.nextweek">N&auml;chste Woche</span>
</button>
<button type="button" className="frist-summary-card frist-card-completed" data-status="completed">
<span className="frist-summary-dot" />
@@ -73,8 +78,9 @@ export function renderDeadlines(): string {
<option value="all" data-i18n="deadlines.filter.all">Alle offenen &amp; erledigten</option>
<option value="pending" data-i18n="deadlines.filter.pending">Alle offenen</option>
<option value="overdue" data-i18n="deadlines.filter.overdue">&Uuml;berf&auml;llig</option>
<option value="today" data-i18n="deadlines.filter.today">Heute</option>
<option value="this_week" data-i18n="deadlines.filter.thisweek">Diese Woche</option>
<option value="upcoming" data-i18n="deadlines.filter.upcoming">Sp&auml;ter</option>
<option value="next_week" data-i18n="deadlines.filter.nextweek">N&auml;chste Woche</option>
<option value="completed" data-i18n="deadlines.filter.completed">Erledigt</option>
</select>

View File

@@ -517,9 +517,10 @@ export type I18nKey =
| "dashboard.onboarding"
| "dashboard.summary.completed"
| "dashboard.summary.heading"
| "dashboard.summary.next_week"
| "dashboard.summary.overdue"
| "dashboard.summary.this_week"
| "dashboard.summary.upcoming"
| "dashboard.summary.today"
| "dashboard.title"
| "dashboard.unavailable"
| "dashboard.urgency.overdue"
@@ -617,11 +618,12 @@ export type I18nKey =
| "deadlines.filter.all"
| "deadlines.filter.completed"
| "deadlines.filter.event_type"
| "deadlines.filter.nextweek"
| "deadlines.filter.overdue"
| "deadlines.filter.pending"
| "deadlines.filter.status"
| "deadlines.filter.thisweek"
| "deadlines.filter.upcoming"
| "deadlines.filter.today"
| "deadlines.flag.ccr"
| "deadlines.heading"
| "deadlines.kalender.empty"
@@ -676,9 +678,10 @@ export type I18nKey =
| "deadlines.step3"
| "deadlines.subtitle"
| "deadlines.summary.completed"
| "deadlines.summary.nextweek"
| "deadlines.summary.overdue"
| "deadlines.summary.thisweek"
| "deadlines.summary.upcoming"
| "deadlines.summary.today"
| "deadlines.title"
| "deadlines.trigger.date"
| "deadlines.trigger.event"

View File

@@ -5189,10 +5189,12 @@ input[type="range"]::-moz-range-thumb {
color: var(--color-text-muted);
}
.frist-card-overdue { border-left-color: var(--frist-red); color: var(--frist-red); }
.frist-card-week { border-left-color: var(--frist-amber); color: var(--frist-amber); }
.frist-card-upcoming { border-left-color: var(--frist-green); color: var(--frist-green); }
.frist-card-completed { border-left-color: var(--frist-grey); color: var(--frist-grey); }
.frist-card-overdue { border-left-color: var(--frist-red); color: var(--frist-red); }
.frist-card-today { border-left-color: var(--status-amber-fg-2); color: var(--status-amber-fg-2); }
.frist-card-week { border-left-color: var(--frist-amber); color: var(--frist-amber); }
.frist-card-next-week { border-left-color: var(--frist-green); color: var(--frist-green); }
.frist-card-upcoming { border-left-color: var(--frist-green); color: var(--frist-green); }
.frist-card-completed { border-left-color: var(--frist-grey); color: var(--frist-grey); }
.fristen-table .frist-col-check {
width: 28px;
@@ -5662,6 +5664,11 @@ input[type="range"]::-moz-range-thumb {
.dashboard-card-red .dashboard-card-count { color: var(--status-red-fg); }
.dashboard-card-red { border-left: 3px solid var(--status-red-fg); }
/* Heute uses the same burnt-amber as .termin-card-today / status-amber-fg-2 —
sits between Überfällig (red) and Diese Woche (amber) on the urgency
gradient, t-paliad-106. */
.dashboard-card-today .dashboard-card-count { color: var(--status-amber-fg); }
.dashboard-card-today { border-left: 3px solid var(--status-amber-fg); }
.dashboard-card-amber .dashboard-card-count { color: var(--status-amber-fg-2); }
.dashboard-card-amber { border-left: 3px solid var(--status-amber-fg-2); }
.dashboard-card-green .dashboard-card-count { color: var(--status-green-fg); }
@@ -5669,14 +5676,16 @@ input[type="range"]::-moz-range-thumb {
.dashboard-card-done .dashboard-card-count { color: var(--status-neutral-fg-2); }
.dashboard-card-done { border-left: 3px solid var(--status-neutral-fg-2); }
/* Überfällig alarm — see t-paliad-105.
/* Überfällig alarm — see t-paliad-105 / t-paliad-106.
When overdue > 0 the card flips from a calm border-left accent to a
saturated red surface with a soft pulsing ring. The matching JS toggles
`.dashboard-card-alarm` (Dashboard) and `.frist-card-alarm` (Fristen);
when overdue === 0 the card is hidden entirely instead of dimmed. */
saturated red surface with bold white text and a pulsing ring. The matching
JS toggles `.dashboard-card-alarm` (Dashboard) and `.frist-card-alarm`
(Fristen); when overdue === 0 the card is hidden entirely. Uses the pulse
animation only (no static glow) to stay below the visual-noise threshold
the spec calls for. */
@keyframes paliad-alarm-pulse {
0%, 100% { box-shadow: 0 0 0 0 rgb(220 38 38 / 0.55); }
50% { box-shadow: 0 0 0 10px rgb(220 38 38 / 0); }
50% { box-shadow: 0 0 0 12px rgb(220 38 38 / 0); }
}
.dashboard-card-red.dashboard-card-alarm,
@@ -5693,6 +5702,7 @@ input[type="range"]::-moz-range-thumb {
.frist-summary-card.frist-card-overdue.frist-card-alarm .frist-summary-count,
.frist-summary-card.frist-card-overdue.frist-card-alarm .frist-summary-label {
color: #ffffff;
font-weight: 700;
}
.frist-summary-card.frist-card-overdue.frist-card-alarm .frist-summary-dot {

View File

@@ -48,10 +48,18 @@ type DashboardUser struct {
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).
// CompletedThisWeek keeps the 7-day window the Dashboard has always used —
// the /deadlines surface still shows the all-time completed count.
type DeadlineSummary struct {
Overdue int `json:"overdue" db:"overdue"`
Today int `json:"today" db:"today"`
ThisWeek int `json:"this_week" db:"this_week"`
Upcoming int `json:"upcoming" db:"upcoming"`
NextWeek int `json:"next_week" db:"next_week"`
CompletedThisWeek int `json:"completed_this_week" db:"completed_this_week"`
}
@@ -128,8 +136,9 @@ func (s *DashboardService) Get(ctx context.Context, userID uuid.UUID) (*Dashboar
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, today, endOfWindow, sevenDaysAgo); err != nil {
if err := s.loadSummary(ctx, data, user, bounds, sevenDaysAgo); err != nil {
return nil, err
}
if err := s.loadUpcomingDeadlines(ctx, data, user, today, endOfWindow); err != nil {
@@ -148,10 +157,16 @@ func (s *DashboardService) Get(ctx context.Context, userID uuid.UUID) (*Dashboar
// loadSummary fills DeadlineSummary + 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.
//
// 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, today, endOfWeek string, sevenDaysAgo time.Time) error {
func (s *DashboardService) loadSummary(ctx context.Context, data *DashboardData, user *models.User, bounds deadlineBucketBounds, sevenDaysAgo time.Time) error {
query := `
WITH visible_projekte AS (
SELECT p.id, p.status
@@ -160,10 +175,11 @@ WITH visible_projekte AS (
),
deadline_stats AS (
SELECT
COUNT(*) FILTER (WHERE f.due_date < $2::date AND f.status = 'pending') AS overdue,
COUNT(*) FILTER (WHERE f.due_date >= $2::date AND f.due_date <= $3::date AND f.status = 'pending') AS this_week,
COUNT(*) FILTER (WHERE f.due_date > $3::date AND f.status = 'pending') AS upcoming,
COUNT(*) FILTER (WHERE f.status = 'completed' AND f.completed_at >= $4) AS completed_this_week
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 = 'completed' AND f.completed_at >= $6) AS completed_this_week
FROM paliad.deadlines f
JOIN visible_projekte v ON v.id = f.project_id
),
@@ -174,7 +190,7 @@ matter_stats AS (
COUNT(*) AS total
FROM visible_projekte
)
SELECT ds.overdue, ds.this_week, ds.upcoming, ds.completed_this_week,
SELECT ds.overdue, ds.today, ds.this_week, ds.next_week, ds.completed_this_week,
ms.active, ms.archived, ms.total
FROM deadline_stats ds, matter_stats ms`
@@ -183,7 +199,7 @@ SELECT ds.overdue, ds.this_week, ds.upcoming, ds.completed_this_week,
MatterSummary
}
err := s.db.GetContext(ctx, &row, query,
user.ID, today, endOfWeek, sevenDaysAgo)
user.ID, bounds.today, bounds.tomorrow, bounds.nextMonday, bounds.weekAfter, sevenDaysAgo)
if errors.Is(err, sql.ErrNoRows) {
return nil
}

View File

@@ -0,0 +1,88 @@
package services
import (
"testing"
"time"
)
// TestComputeDeadlineBucketBounds checks the 5-bucket pivots for every
// weekday — Heute / Diese Woche / Nächste Woche must be disjoint and
// every day from today through next Sunday must land in exactly one
// bucket (overdue stays before today; everything past next Sunday is
// "later" and not visible in the four pending cards).
func TestComputeDeadlineBucketBounds(t *testing.T) {
type expectation struct {
name string
now time.Time
wantSunday string // upcoming Sunday including today, YYYY-MM-DD
wantNextMon string // Monday of next week
}
cases := []expectation{
{"Mon 2026-05-04", time.Date(2026, 5, 4, 12, 0, 0, 0, time.UTC), "2026-05-10", "2026-05-11"},
{"Tue 2026-05-05", time.Date(2026, 5, 5, 12, 0, 0, 0, time.UTC), "2026-05-10", "2026-05-11"},
{"Wed 2026-05-06", time.Date(2026, 5, 6, 12, 0, 0, 0, time.UTC), "2026-05-10", "2026-05-11"},
{"Thu 2026-05-07", time.Date(2026, 5, 7, 12, 0, 0, 0, time.UTC), "2026-05-10", "2026-05-11"},
{"Fri 2026-05-08", time.Date(2026, 5, 8, 12, 0, 0, 0, time.UTC), "2026-05-10", "2026-05-11"},
{"Sat 2026-05-09", time.Date(2026, 5, 9, 12, 0, 0, 0, time.UTC), "2026-05-10", "2026-05-11"},
// Sunday: spec says "Diese Woche" inclusive of today's Sunday
// itself ⇒ Sunday-of-this-week == today, so Diese Woche has
// no future-eligible entries beyond Heute.
{"Sun 2026-05-10", time.Date(2026, 5, 10, 12, 0, 0, 0, time.UTC), "2026-05-10", "2026-05-11"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
b := computeDeadlineBucketBounds(tc.now)
if got := b.today.Format("2006-01-02"); got != tc.now.UTC().Format("2006-01-02") {
t.Errorf("today = %s, want %s", got, tc.now.Format("2006-01-02"))
}
if got := b.tomorrow.Format("2006-01-02"); got != tc.now.AddDate(0, 0, 1).UTC().Format("2006-01-02") {
t.Errorf("tomorrow = %s, want %s", got, tc.now.AddDate(0, 0, 1).Format("2006-01-02"))
}
sunday := b.nextMonday.AddDate(0, 0, -1).Format("2006-01-02")
if sunday != tc.wantSunday {
t.Errorf("Sunday-of-this-week = %s, want %s", sunday, tc.wantSunday)
}
if got := b.nextMonday.Format("2006-01-02"); got != tc.wantNextMon {
t.Errorf("nextMonday = %s, want %s", got, tc.wantNextMon)
}
if got := b.weekAfter.Sub(b.nextMonday); got != 7*24*time.Hour {
t.Errorf("weekAfter - nextMonday = %v, want 7d", got)
}
})
}
}
// TestBucketsAreDisjoint walks 21 consecutive days from a known Monday
// and asserts that every day falls into at most one of the three
// pending-future buckets (today / this_week / next_week). Days past
// next Sunday must fall into none of them.
func TestBucketsAreDisjoint(t *testing.T) {
now := time.Date(2026, 5, 4, 12, 0, 0, 0, time.UTC) // Monday
b := computeDeadlineBucketBounds(now)
for i := range 21 {
day := b.today.AddDate(0, 0, i)
isToday := day.Equal(b.today)
isThisWeek := day.After(b.today) && day.Before(b.nextMonday)
isNextWeek := !day.Before(b.nextMonday) && day.Before(b.weekAfter)
hits := 0
if isToday {
hits++
}
if isThisWeek {
hits++
}
if isNextWeek {
hits++
}
// First 14 days from "today" must each hit exactly one bucket.
// Days 14..20 (the third week and beyond) must hit none.
want := 1
if i >= 14 {
want = 0
}
if hits != want {
t.Errorf("offset +%dd (%s): %d bucket hits, want %d (today=%v thisWeek=%v nextWeek=%v)",
i, day.Format("2006-01-02"), hits, want, isToday, isThisWeek, isNextWeek)
}
}
}

View File

@@ -67,12 +67,23 @@ type UpdateDeadlineInput struct {
}
// DeadlineStatusFilter is a server-side bucket for ListVisibleForUser.
//
// Bucket math (t-paliad-106): Heute / Diese Woche / Nächste Woche are disjoint
// by date; "this_week" runs from tomorrow through the upcoming Sunday
// inclusive, "next_week" from the following Monday through that Sunday
// inclusive. Items further out than next Sunday are not in any of the four
// active buckets — they only show up under "all" / "pending" / "upcoming"
// (the latter is kept as a server-side back-compat alias for "everything
// pending after this Sunday", but the dashboard + /deadlines summary cards
// no longer expose it).
type DeadlineStatusFilter string
const (
DeadlineFilterAll DeadlineStatusFilter = "all"
DeadlineFilterOverdue DeadlineStatusFilter = "overdue"
DeadlineFilterToday DeadlineStatusFilter = "today"
DeadlineFilterThisWeek DeadlineStatusFilter = "this_week"
DeadlineFilterNextWeek DeadlineStatusFilter = "next_week"
DeadlineFilterUpcoming DeadlineStatusFilter = "upcoming"
DeadlineFilterCompleted DeadlineStatusFilter = "completed"
DeadlineFilterPending DeadlineStatusFilter = "pending"
@@ -115,21 +126,30 @@ func (s *DeadlineService) ListVisibleForUser(ctx context.Context, userID uuid.UU
conds = append(conds, etCond)
}
now := time.Now().UTC()
today := now.Truncate(24 * time.Hour)
endOfWeek := today.AddDate(0, 0, 7)
b := computeDeadlineBucketBounds(time.Now().UTC())
switch filter.Status {
case DeadlineFilterOverdue:
conds = append(conds, `f.status = 'pending' AND f.due_date < :today`)
args["today"] = today
args["today"] = b.today
case DeadlineFilterToday:
conds = append(conds, `f.status = 'pending' AND f.due_date = :today`)
args["today"] = b.today
case DeadlineFilterThisWeek:
conds = append(conds, `f.status = 'pending' AND f.due_date >= :today AND f.due_date < :endweek`)
args["today"] = today
args["endweek"] = endOfWeek
conds = append(conds, `f.status = 'pending' AND f.due_date > :today AND f.due_date < :next_monday`)
args["today"] = b.today
args["next_monday"] = b.nextMonday
case DeadlineFilterNextWeek:
conds = append(conds, `f.status = 'pending' AND f.due_date >= :next_monday AND f.due_date < :week_after`)
args["next_monday"] = b.nextMonday
args["week_after"] = b.weekAfter
case DeadlineFilterUpcoming:
conds = append(conds, `f.status = 'pending' AND f.due_date >= :endweek`)
args["endweek"] = endOfWeek
// Back-compat: "upcoming" used to mean "anything pending past this week".
// Kept so legacy bookmarks / third-party links don't 4xx; the new UI
// surfaces the disjoint Heute / Diese Woche / Nächste Woche buckets
// instead.
conds = append(conds, `f.status = 'pending' AND f.due_date >= :next_monday`)
args["next_monday"] = b.nextMonday
case DeadlineFilterCompleted:
conds = append(conds, `f.status = 'completed'`)
case DeadlineFilterPending:
@@ -563,11 +583,22 @@ func (s *DeadlineService) Delete(ctx context.Context, userID, deadlineID uuid.UU
}
// SummaryCounts returns traffic-light counts across the user's visible Deadlines.
//
// The five buckets are disjoint (t-paliad-106):
// - Overdue: pending AND due_date < today
// - Today: pending AND due_date == today
// - ThisWeek: pending AND today < due_date <= upcoming Sunday (inclusive)
// - NextWeek: pending AND Mon next week <= due_date <= Sun next week
// - Completed: status = 'completed' (no time bound — full history)
//
// "Total" is the count of all visible Deadlines (any status). Items with a
// pending due_date past next Sunday are not in any of the four pending buckets;
// they're still counted in Total and in the all-pending list.
type SummaryCounts struct {
Overdue int `json:"overdue" db:"overdue"`
Today int `json:"today" db:"today"`
ThisWeek int `json:"this_week" db:"this_week"`
Upcoming int `json:"upcoming" db:"upcoming"`
NextWeek int `json:"next_week" db:"next_week"`
Completed int `json:"completed" db:"completed"`
Total int `json:"total" db:"total"`
}
@@ -582,17 +613,15 @@ func (s *DeadlineService) SummaryCounts(ctx context.Context, userID uuid.UUID, p
if user == nil {
return &SummaryCounts{}, nil
}
now := time.Now().UTC()
today := now.Truncate(24 * time.Hour)
tomorrow := today.AddDate(0, 0, 1)
endWeek := today.AddDate(0, 0, 7)
b := computeDeadlineBucketBounds(time.Now().UTC())
conds := []string{visibilityPredicate("p")}
args := map[string]any{
"user_id": userID,
"today": today,
"tomorrow": tomorrow,
"endweek": endWeek,
"user_id": userID,
"today": b.today,
"tomorrow": b.tomorrow,
"next_monday": b.nextMonday,
"week_after": b.weekAfter,
}
if projectID != nil {
conds = append(conds, `f.project_id = :project_id`)
@@ -601,12 +630,12 @@ func (s *DeadlineService) SummaryCounts(ctx context.Context, userID uuid.UUID, p
query := `
SELECT
COUNT(*) FILTER (WHERE f.status = 'pending' AND f.due_date < :today) AS overdue,
COUNT(*) FILTER (WHERE f.status = 'pending' AND f.due_date >= :today AND f.due_date < :tomorrow) AS today,
COUNT(*) FILTER (WHERE f.status = 'pending' AND f.due_date >= :today AND f.due_date < :endweek) AS this_week,
COUNT(*) FILTER (WHERE f.status = 'pending' AND f.due_date >= :endweek) AS upcoming,
COUNT(*) FILTER (WHERE f.status = 'completed') AS completed,
COUNT(*) AS total
COUNT(*) FILTER (WHERE f.status = 'pending' AND f.due_date < :today) AS overdue,
COUNT(*) FILTER (WHERE f.status = 'pending' AND f.due_date = :today) AS today,
COUNT(*) FILTER (WHERE f.status = 'pending' AND f.due_date >= :tomorrow AND f.due_date < :next_monday) AS this_week,
COUNT(*) FILTER (WHERE f.status = 'pending' AND f.due_date >= :next_monday AND f.due_date < :week_after) AS next_week,
COUNT(*) FILTER (WHERE f.status = 'completed') AS completed,
COUNT(*) AS total
FROM paliad.deadlines f
JOIN paliad.projects p ON p.id = f.project_id
WHERE ` + strings.Join(conds, " AND ")
@@ -624,6 +653,39 @@ func (s *DeadlineService) SummaryCounts(ctx context.Context, userID uuid.UUID, p
return &c, nil
}
// deadlineBucketBounds carries the date pivots used by both
// SummaryCounts and ListVisibleForUser to keep the cutoff math in
// exactly one place. All values are UTC-day boundaries (00:00:00 UTC),
// matching the existing convention — see the package-level note on
// timezone in the original SummaryCounts implementation.
type deadlineBucketBounds struct {
today time.Time
tomorrow time.Time
nextMonday time.Time // Monday of next week (= Sunday-of-this-week + 1d)
weekAfter time.Time // Monday of the week after next (exclusive end of NextWeek)
}
// computeDeadlineBucketBounds derives the four pivots from `now`.
//
// Encoding of weekdays in Go: 0=Sunday, 1=Monday, …, 6=Saturday.
// "Sunday of this week" is the upcoming Sunday including today, so when
// today is Sunday daysToSunday = 0 (Diese Woche has no future-eligible
// entries beyond Heute, which is exactly what the spec calls for).
func computeDeadlineBucketBounds(now time.Time) deadlineBucketBounds {
today := now.Truncate(24 * time.Hour)
tomorrow := today.AddDate(0, 0, 1)
daysToSunday := (7 - int(today.Weekday())) % 7
sunday := today.AddDate(0, 0, daysToSunday)
nextMonday := sunday.AddDate(0, 0, 1)
weekAfter := nextMonday.AddDate(0, 0, 7)
return deadlineBucketBounds{
today: today,
tomorrow: tomorrow,
nextMonday: nextMonday,
weekAfter: weekAfter,
}
}
// insert performs one INSERT in its own transaction.
func (s *DeadlineService) insert(ctx context.Context, userID, projectID uuid.UUID, input CreateDeadlineInput) (uuid.UUID, error) {
tx, err := s.db.BeginTxx(ctx, nil)

View File

@@ -223,7 +223,7 @@ func TestVisibilityPredicate_DashboardAgendaForGlobalAdmin(t *testing.T) {
if data.MatterSummary.Total < 1 {
t.Errorf("MatterSummary.Total = %d, want >= 1 (global_admin must see the seeded project)", data.MatterSummary.Total)
}
if data.DeadlineSummary.ThisWeek+data.DeadlineSummary.Upcoming+data.DeadlineSummary.Overdue < 1 {
if data.DeadlineSummary.Today+data.DeadlineSummary.ThisWeek+data.DeadlineSummary.NextWeek+data.DeadlineSummary.Overdue < 1 {
t.Errorf("DeadlineSummary has 0 pending; want >= 1 (global_admin must see the seeded deadline)")
}
if !containsDeadline(data.UpcomingDeadlines, deadlineID) {