Merge: t-paliad-106 — Deadline summary 5-bucket harmonization
This commit is contained in:
@@ -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);
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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">Überfä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ä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 T.)</div>
|
||||
<div className="dashboard-card-label" data-i18n="dashboard.summary.completed">Erledigt</div>
|
||||
</a>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -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ür Ihre Akten. Überfällig, diese Woche, später — auf einen Blick.
|
||||
Persistente Fristen für Ihre Akten. Überfällig, heute, diese Woche, nächste Woche — 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">Überfä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äter</span>
|
||||
<span className="frist-summary-count" id="sum-next-week">0</span>
|
||||
<span className="frist-summary-label" data-i18n="deadlines.summary.nextweek">Nä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 & erledigten</option>
|
||||
<option value="pending" data-i18n="deadlines.filter.pending">Alle offenen</option>
|
||||
<option value="overdue" data-i18n="deadlines.filter.overdue">Überfä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äter</option>
|
||||
<option value="next_week" data-i18n="deadlines.filter.nextweek">Nächste Woche</option>
|
||||
<option value="completed" data-i18n="deadlines.filter.completed">Erledigt</option>
|
||||
</select>
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
88
internal/services/deadline_buckets_test.go
Normal file
88
internal/services/deadline_buckets_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user