From 2d83454790ce2bfc05194f4ca264b3e578a30b87 Mon Sep 17 00:00:00 2001 From: Koopa Date: Tue, 30 Jun 2026 11:02:41 +0800 Subject: [PATCH 1/7] feat(today): make the day-progress hero due-based MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Today strip counted only the committed daily plan (daily_plan_items), so it read 0/0/0 whenever no plan was built — the normal case for a recurring/due-driven workflow. Recompute it from what is actually scheduled today: Open = due-today + recurring-due + started, Overdue = past-due still open, Completed = finished today (one-time done today plus recurring occurrences stamped today). The front end derives the strip from the section lengths, so completing an item updates it live without a refetch and without a committed plan existing. Adds CompletedTodoItemsOn / Store.CompletedItemsOn for the done-today section and removes the dead plan-based PlanCompletion counting — the daily_plan_items.status column it read has no write path, so it always reported planned. --- .../today/today-page.component.html | 14 ++-- .../today/today-page.component.spec.ts | 23 +++--- .../commitment/today/today-page.component.ts | 15 ++-- .../app/admin/commitment/today/today-view.ts | 73 ++++++++++--------- .../commitment/today/today.service.spec.ts | 2 +- .../admin/commitment/today/today.service.ts | 23 ++++-- internal/db/query.sql.go | 62 ++++++++++++++++ internal/today/handler.go | 47 ++++++++---- internal/today/handler_test.go | 69 ++++++++++++------ internal/today/today.go | 18 ++--- internal/todo/query.sql | 18 +++++ internal/todo/todo.go | 20 +++++ 12 files changed, 276 insertions(+), 108 deletions(-) diff --git a/frontend/src/app/admin/commitment/today/today-page.component.html b/frontend/src/app/admin/commitment/today/today-page.component.html index a5a16bf3..a8ef4da3 100644 --- a/frontend/src/app/admin/commitment/today/today-page.component.html +++ b/frontend/src/app/admin/commitment/today/today-page.component.html @@ -141,12 +141,12 @@

- {{ figures().planned }} + {{ figures().open }}

- Planned + Open

@@ -164,15 +164,15 @@

- {{ figures().deferred }} + {{ figures().overdue }}

- Deferred + Overdue

@@ -184,7 +184,7 @@

>
{ expect(testid('today-plan-ip-dot')).toBeTruthy(); }); - it('should derive day-progress figures live from the committed rows', async () => { + it('should derive due-based day-progress figures from the section lengths', async () => { await render(populatedBrief()); + const strip = testid('today-plan-completion'); + // open = due-today(1) + recurring(1) + active(1) = 3; overdue(1); completed(0) + // → percent = round(0 / (3+1+0) * 100) = 0%. The committed plan does not count. + expect(strip?.textContent).toContain('Open'); + expect(strip?.textContent).toContain('Completed'); + expect(strip?.textContent).toContain('Overdue'); + expect(strip?.textContent).not.toContain('Planned'); + expect(strip?.textContent).not.toContain('Deferred'); expect(testid('today-percent')?.textContent).toContain('0%'); - expect(testid('today-plan-completion')?.textContent).toContain('Planned'); - expect(testid('today-plan-completion')?.textContent).toContain( - 'Completed', - ); - expect(testid('today-plan-completion')?.textContent).toContain('Deferred'); }); it('should group loose todos and style the due chips per bucket', async () => { @@ -252,7 +255,9 @@ describe('TodayPageComponent', () => { req.flush({}); await settle(); - expect(testid('today-percent')?.textContent).toContain('100%'); + // Completing the one planned row adds it to today's completions: the strip is + // due-based, so completed becomes 1 of (open 3 + overdue 1 + completed 1) = 20%. + expect(testid('today-percent')?.textContent).toContain('20%'); const notifications = TestBed.inject(NotificationService).notifications(); expect(notifications.some((n) => n.message.includes('Marked done'))).toBe( true, diff --git a/frontend/src/app/admin/commitment/today/today-page.component.ts b/frontend/src/app/admin/commitment/today/today-page.component.ts index dd45715b..b0c77fe0 100644 --- a/frontend/src/app/admin/commitment/today/today-page.component.ts +++ b/frontend/src/app/admin/commitment/today/today-page.component.ts @@ -27,10 +27,9 @@ import { energyOf, greetingForHour, isQuietBrief, + markTodoCompleted, planAdvanceAction, recurrenceSummary, - removeLooseTodo, - removeRecurringTodo, truncateTitle, } from './today-view'; import { AdminTopbarService } from '../../admin-layout/admin-topbar.service'; @@ -177,7 +176,13 @@ export class TodayPageComponent { this.todoService.advance(item.todo_id, action).subscribe({ next: () => { this._busy.set(false); - this.brief.update((v) => (v ? applyPlanAdvance(v, item.id, action) : v)); + this.brief.update((v) => { + if (!v) return v; + const advanced = applyPlanAdvance(v, item.id, action); + return action === 'complete' + ? markTodoCompleted(advanced, item.todo_id, item.todo_title) + : advanced; + }); if (action === 'complete') { this.notifications.success( `Marked done · ${truncateTitle(item.todo_title)}`, @@ -206,7 +211,7 @@ export class TodayPageComponent { complete$.subscribe({ next: () => { this._busy.set(false); - this.brief.update((v) => (v ? removeLooseTodo(v, todo.id) : v)); + this.brief.update((v) => (v ? markTodoCompleted(v, todo.id, todo.title) : v)); this.notifications.success('Completed'); }, error: () => { @@ -228,7 +233,7 @@ export class TodayPageComponent { this.todoService.advance(todo.id, 'complete').subscribe({ next: () => { this._busy.set(false); - this.brief.update((v) => (v ? removeRecurringTodo(v, todo.id) : v)); + this.brief.update((v) => (v ? markTodoCompleted(v, todo.id, todo.title) : v)); this.notifications.success('Done for today'); }, error: () => { diff --git a/frontend/src/app/admin/commitment/today/today-view.ts b/frontend/src/app/admin/commitment/today/today-view.ts index 2ea056c9..ab1d7e82 100644 --- a/frontend/src/app/admin/commitment/today/today-view.ts +++ b/frontend/src/app/admin/commitment/today/today-view.ts @@ -22,14 +22,14 @@ export interface LooseGroup { items: PendingDetail[]; } -/** Live day-progress figures derived from the committed plan rows. */ -export interface PlanFigures { - planned: number; +/** Due-based day-progress figures for the strip, derived from section lengths. */ +export interface ProgressFigures { + open: number; completed: number; - deferred: number; + overdue: number; percent: number; doneWidth: number; - deferredWidth: number; + overdueWidth: number; } export const GOAL_VARIANT: Record = { @@ -75,34 +75,28 @@ export function planAdvanceAction( } /** - * Day-progress figures, counted live from the committed rows so the - * strip and the plan stay on one source. plan_completion is only the - * fallback when the plan section degraded to an empty list. Dropped - * rows count toward no bucket, matching the backend aggregation. + * Day-progress figures for the strip, derived from the section lengths the page + * already renders: open = due-today + recurring-due + started work (the backend + * dedups these against each other), overdue = the overdue section, completed = + * what was finished today. Deriving from the same lists the optimistic mutators + * patch keeps the strip live without a committed plan and without re-counting. */ -export function computeFigures(v: TodayBrief | undefined): PlanFigures { - let planned = 0; - let completed = 0; - let deferred = 0; - if (v && v.committed_todos.length > 0) { - for (const item of v.committed_todos) { - if (item.status === 'planned') planned++; - else if (item.status === 'done') completed++; - else if (item.status === 'deferred') deferred++; - } - } else if (v) { - ({ planned, completed, deferred } = v.plan_completion); - } - const total = planned + completed + deferred; +export function computeFigures(v: TodayBrief | undefined): ProgressFigures { + const open = v + ? v.today_todos.length + v.recurring_todos.length + v.active_todos.length + : 0; + const overdue = v ? v.overdue_todos.length : 0; + const completed = v ? v.completed_todos.length : 0; + const total = open + overdue + completed; const ratio = (n: number): number => total === 0 ? 0 : (n / total) * PERCENT; return { - planned, + open, completed, - deferred, + overdue, percent: Math.round(ratio(completed)), doneWidth: ratio(completed), - deferredWidth: ratio(deferred), + overdueWidth: ratio(overdue), }; } @@ -130,6 +124,7 @@ export function isQuietBrief(v: TodayBrief): boolean { v.today_todos.length === 0 && v.active_todos.length === 0 && v.recurring_todos.length === 0 && + v.completed_todos.length === 0 && v.upcoming_todos.length === 0 && v.active_goals.length === 0 && v.rss_highlights.length === 0 @@ -178,21 +173,29 @@ export function applyPlanAdvance( }; } -/** Drops a completed loose todo from every due bucket, including In progress. */ -export function removeLooseTodo(v: TodayBrief, todoId: string): TodayBrief { +/** + * Reflects a server-confirmed completion on the local brief: drops the todo + * from every still-to-do section (the due buckets and today's recurring list) + * and adds it to completed_todos, so the day-progress strip moves one unit from + * open/overdue to completed without a refetch. Idempotent — a todo already in + * completed_todos is not added twice (e.g. completing it from the plan list + * after it was already dropped from a due bucket). + */ +export function markTodoCompleted( + v: TodayBrief, + todoId: string, + title: string, +): TodayBrief { + const alreadyCounted = v.completed_todos.some((t) => t.id === todoId); return { ...v, overdue_todos: v.overdue_todos.filter((t) => t.id !== todoId), today_todos: v.today_todos.filter((t) => t.id !== todoId), active_todos: v.active_todos.filter((t) => t.id !== todoId), upcoming_todos: v.upcoming_todos.filter((t) => t.id !== todoId), - }; -} - -/** Drops a completed recurring occurrence from today's recurring list. */ -export function removeRecurringTodo(v: TodayBrief, todoId: string): TodayBrief { - return { - ...v, recurring_todos: v.recurring_todos.filter((t) => t.id !== todoId), + completed_todos: alreadyCounted + ? v.completed_todos + : [{ id: todoId, title, state: 'done' as const }, ...v.completed_todos], }; } diff --git a/frontend/src/app/admin/commitment/today/today.service.spec.ts b/frontend/src/app/admin/commitment/today/today.service.spec.ts index 6296552e..580504d5 100644 --- a/frontend/src/app/admin/commitment/today/today.service.spec.ts +++ b/frontend/src/app/admin/commitment/today/today.service.spec.ts @@ -15,9 +15,9 @@ function emptyBrief(): TodayBrief { today_todos: [], active_todos: [], recurring_todos: [], + completed_todos: [], committed_todos: [], upcoming_todos: [], - plan_completion: { planned: 0, completed: 0, deferred: 0 }, active_goals: [], rss_highlights: [], }; diff --git a/frontend/src/app/admin/commitment/today/today.service.ts b/frontend/src/app/admin/commitment/today/today.service.ts index a5501ca5..e934e60c 100644 --- a/frontend/src/app/admin/commitment/today/today.service.ts +++ b/frontend/src/app/admin/commitment/today/today.service.ts @@ -71,10 +71,23 @@ export interface CommittedItem { updated_at: string; } -export interface PlanCompletion { - planned: number; - completed: number; - deferred: number; +/** + * A todo finished today (todo.Item on the wire): a one-time todo done today, or + * a recurring occurrence stamped today (last_completed_on = today). No project + * join — the done-today query selects the bare todo row. + */ +export interface CompletedTodo { + id: string; + title: string; + state: TodoState; + due?: string; + completed_at?: string; + last_completed_on?: string; + recur_interval?: number; + recur_unit?: string; + recur_weekdays?: number; + energy?: EnergyLevel; + priority?: string; } /** Active goal with milestone rollup (goal.ActiveGoalSummary). */ @@ -107,9 +120,9 @@ export interface TodayBrief { today_todos: PendingDetail[]; active_todos: PendingDetail[]; recurring_todos: RecurringTodo[]; + completed_todos: CompletedTodo[]; committed_todos: CommittedItem[]; upcoming_todos: PendingDetail[]; - plan_completion: PlanCompletion; active_goals: ActiveGoalSummary[]; rss_highlights: RssHighlight[]; } diff --git a/internal/db/query.sql.go b/internal/db/query.sql.go index 637c6b47..58508f9f 100644 --- a/internal/db/query.sql.go +++ b/internal/db/query.sql.go @@ -1029,6 +1029,68 @@ func (q *Queries) CompletedTodoDetailSince(ctx context.Context, since *time.Time return items, nil } +const completedTodoItemsOn = `-- name: CompletedTodoItemsOn :many +SELECT id, title, state, due, project_id, + completed_at, energy, priority, recur_interval, recur_unit, recur_weekdays, last_completed_on, + description, created_by, created_at, updated_at +FROM todos +WHERE (state = 'done' AND completed_at >= $1 AND completed_at < $2) + OR ((recur_weekdays IS NOT NULL OR recur_interval IS NOT NULL) + AND last_completed_on = $3::date) +ORDER BY completed_at DESC NULLS LAST, last_completed_on DESC NULLS LAST, title +` + +type CompletedTodoItemsOnParams struct { + DayStart *time.Time `json:"day_start"` + DayEnd *time.Time `json:"day_end"` + Today time.Time `json:"today"` +} + +// Todos completed on @today, for the Today dashboard's done-today count and +// list. Two arms: a one-time todo finished within the day window [@day_start, +// @day_end) (state=done, completed_at in range), OR a recurring todo whose +// occurrence was stamped on @today (last_completed_on = @today). A recurring +// todo terminally closed the same day it had an occurrence stamped satisfies +// both arms, but a WHERE-OR over a base table emits each row once, so the done +// count is never inflated. Selects the full todos column set (no join) so sqlc +// returns db.Todo and rowToItem applies. +func (q *Queries) CompletedTodoItemsOn(ctx context.Context, arg CompletedTodoItemsOnParams) ([]Todo, error) { + rows, err := q.db.Query(ctx, completedTodoItemsOn, arg.DayStart, arg.DayEnd, arg.Today) + if err != nil { + return nil, err + } + defer rows.Close() + items := []Todo{} + for rows.Next() { + var i Todo + if err := rows.Scan( + &i.ID, + &i.Title, + &i.State, + &i.Due, + &i.ProjectID, + &i.CompletedAt, + &i.Energy, + &i.Priority, + &i.RecurInterval, + &i.RecurUnit, + &i.RecurWeekdays, + &i.LastCompletedOn, + &i.Description, + &i.CreatedBy, + &i.CreatedAt, + &i.UpdatedAt, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + const completedTodosInWindow = `-- name: CompletedTodosInWindow :many SELECT diff --git a/internal/today/handler.go b/internal/today/handler.go index 9ab2105e..12715b75 100644 --- a/internal/today/handler.go +++ b/internal/today/handler.go @@ -38,12 +38,20 @@ type todoActiveReader interface { RecurringItemsDueToday(ctx context.Context, today time.Time) ([]todo.Item, error) } +// todoCompletedReader returns the todos completed today — one-time todos done +// within the day window plus recurring occurrences stamped today — backing the +// Today Completed count and review list. Backed by *todo.Store. +type todoCompletedReader interface { + CompletedItemsOn(ctx context.Context, today, dayStart, dayEnd time.Time) ([]todo.Item, error) +} + // TodoReader is the todo-views surface the Today aggregate composes — the // consumer-boundary subset of *todo.Store it depends on, split by role so each // part stays small (interfaces.md). type TodoReader interface { todoDateReader todoActiveReader + todoCompletedReader } // PlanItemReader returns the day's committed daily plan items. Backed by @@ -130,6 +138,7 @@ func (h *Handler) Today(w http.ResponseWriter, r *http.Request) { TodayTodos: []todo.PendingDetail{}, ActiveTodos: []todo.PendingDetail{}, RecurringTodos: []todo.Item{}, + CompletedTodos: []todo.Item{}, CommittedTodos: []daily.Item{}, UpcomingTodos: []todo.PendingDetail{}, ActiveGoals: []goal.ActiveGoalSummary{}, @@ -137,6 +146,7 @@ func (h *Handler) Today(w http.ResponseWriter, r *http.Request) { } h.loadTodos(ctx, date, &resp) + h.loadCompleted(ctx, date, &resp) h.loadPlan(ctx, date, &resp) // Active dedups against the date sections, the plan, and recurring, so it // must run after loadTodos + loadPlan have populated them. @@ -216,29 +226,36 @@ func (h *Handler) loadActive(ctx context.Context, resp *Response) { resp.ActiveTodos = active } +// loadCompleted fills CompletedTodos with what was finished today — one-time +// todos done within [date, date+1d) plus recurring occurrences stamped today — +// feeding the front end's Completed count (derived from len(CompletedTodos)) +// and the "completed today" list. +func (h *Handler) loadCompleted(ctx context.Context, date time.Time, resp *Response) { + if h.todos == nil { + return + } + dayEnd := date.AddDate(0, 0, 1) + if rows, err := h.todos.CompletedItemsOn(ctx, date, date, dayEnd); err != nil { + h.logger.Warn("today: completed todos failed", "error", err) + } else if rows != nil { + resp.CompletedTodos = rows + } +} + +// loadPlan fills CommittedTodos with the day's committed plan. The plan is now +// an optional pin: it no longer drives the progress counts (the front end +// derives those from the due/recurring/completed section lengths), so a missing +// plan does not zero the dashboard — hence a failure logs at Warn like the other +// optional sections, not Error. func (h *Handler) loadPlan(ctx context.Context, date time.Time, resp *Response) { items, err := h.planItems.ItemsByDate(ctx, date) if err != nil { - h.logger.Error("today: plan items failed", "error", err) + h.logger.Warn("today: plan items failed", "error", err) return } if items != nil { resp.CommittedTodos = items } - // Completion derives from the backing todo's state (and recurring-occurrence - // completion), NOT daily_plan_items.status — that column has no write path, - // so it is always 'planned'. daily.Item.IsCompletedOn/IsDeferred is the - // single source brief(reflection) also uses, so the two surfaces agree. - for i := range items { - switch { - case items[i].IsCompletedOn(date): - resp.PlanCompletion.Completed++ - case items[i].IsDeferred(): - resp.PlanCompletion.Deferred++ - default: - resp.PlanCompletion.Planned++ - } - } } func (h *Handler) loadGoals(ctx context.Context, resp *Response) { diff --git a/internal/today/handler_test.go b/internal/today/handler_test.go index fa953df3..a86a0fdd 100644 --- a/internal/today/handler_test.go +++ b/internal/today/handler_test.go @@ -40,6 +40,7 @@ type fakeTodos struct { inRange []todo.PendingDetail inProgress []todo.PendingDetail recurring []todo.Item + completed []todo.Item } func (f *fakeTodos) OverdueItems(context.Context, time.Time) ([]todo.PendingDetail, error) { @@ -62,6 +63,10 @@ func (f *fakeTodos) RecurringItemsDueToday(context.Context, time.Time) ([]todo.I return f.recurring, nil } +func (f *fakeTodos) CompletedItemsOn(context.Context, time.Time, time.Time, time.Time) ([]todo.Item, error) { + return f.completed, nil +} + // fakeGoals satisfies ActiveGoalReader. type fakeGoals struct { goals []goal.ActiveGoalSummary @@ -114,6 +119,7 @@ func TestToday_EmptyStateReturnsEmptyArrays(t *testing.T) { TodayTodos: []todo.PendingDetail{}, ActiveTodos: []todo.PendingDetail{}, RecurringTodos: []todo.Item{}, + CompletedTodos: []todo.Item{}, CommittedTodos: []daily.Item{}, UpcomingTodos: []todo.PendingDetail{}, ActiveGoals: []goal.ActiveGoalSummary{}, @@ -127,7 +133,7 @@ func TestToday_EmptyStateReturnsEmptyArrays(t *testing.T) { raw := rec.Body.String() for _, field := range []string{ "overdue_todos", "today_todos", "active_todos", "recurring_todos", - "committed_todos", "upcoming_todos", + "completed_todos", "committed_todos", "upcoming_todos", "active_goals", "rss_highlights", } { if strings.Contains(raw, `"`+field+`":null`) { @@ -137,33 +143,42 @@ func TestToday_EmptyStateReturnsEmptyArrays(t *testing.T) { } // TestToday_WiredSectionsPopulate proves each wired reader contributes its -// section and plan completion counts derive from the committed items. +// section and that the day-progress strip is due-based: the front end derives +// open from due-today + recurring + started work, overdue from the overdue +// section, and completed from today's completions — independent of any +// committed plan. This test pins the section lengths those figures sum. func TestToday_WiredSectionsPopulate(t *testing.T) { t.Parallel() overdue := []todo.PendingDetail{{ID: uuid.New(), Title: "ship audit memo"}} dueOn := []todo.PendingDetail{{ID: uuid.New(), Title: "review draft"}} inRange := []todo.PendingDetail{{ID: uuid.New(), Title: "next week task"}} + started := todo.PendingDetail{ID: uuid.New(), Title: "started, undated", State: todo.StateInProgress} + recurring := []todo.Item{ + {ID: uuid.New(), Title: "morning Japanese"}, + {ID: uuid.New(), Title: "standup"}, + } + completed := []todo.Item{ + {ID: uuid.New(), Title: "shipped one-time"}, + {ID: uuid.New(), Title: "morning Japanese (done today)"}, + } goals := []goal.ActiveGoalSummary{{Goal: goal.Goal{ID: uuid.New(), Title: "GDE application"}}} - // Completion derives from the backing todo's state (+ recurring-occurrence - // completion), not daily_plan_items.status (which has no write path). Each - // item carries Status=planned (the only value the dead column ever holds) to - // prove it is IGNORED; the TodoState / recurrence fields drive the counts. - now := time.Now().UTC() - todayUTC := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, time.UTC) - recurInterval := int32(1) - planItems := []daily.Item{ - {ID: uuid.New(), Status: daily.StatusPlanned, TodoState: "todo"}, // → Planned - {ID: uuid.New(), Status: daily.StatusPlanned, TodoState: "done"}, // → Completed (terminal) - {ID: uuid.New(), Status: daily.StatusPlanned, TodoState: "someday"}, // → Deferred - {ID: uuid.New(), Status: daily.StatusPlanned, TodoState: "in_progress", // recurring occurrence completed today → Completed - TodoRecurInterval: &recurInterval, TodoLastCompletedOn: &todayUTC}, - } + // A committed plan is present but must NOT drive progress — it is an optional + // pin now. If progress regressed to plan-based counting this planned item + // would skew Open, so the assertion below would catch the regression. + planItems := []daily.Item{{ID: uuid.New(), TodoID: uuid.New(), Status: daily.StatusPlanned, TodoState: "todo"}} h := NewHandler(fakePlanItems{items: planItems}, time.UTC, slog.New(slog.NewTextHandler(io.Discard, nil))). WithSources( - &fakeTodos{overdue: overdue, dueOn: dueOn, inRange: inRange}, + &fakeTodos{ + overdue: overdue, + dueOn: dueOn, + inRange: inRange, + inProgress: []todo.PendingDetail{started}, + recurring: recurring, + completed: completed, + }, fakeGoals{goals: goals}, nil, // rss left nil — its section must stay [] ) @@ -184,12 +199,22 @@ func TestToday_WiredSectionsPopulate(t *testing.T) { t.Errorf("active_goals mismatch (-want +got):\n%s", diff) } - wantCompletion := PlanCompletion{Planned: 1, Completed: 2, Deferred: 1} - if diff := cmp.Diff(wantCompletion, got.PlanCompletion); diff != "" { - t.Errorf("plan_completion mismatch (-want +got):\n%s", diff) + // The day-progress strip is derived (front end) from these section lengths: + // open = due-today(1) + recurring(2) + started(1) = 4 — the upcoming-week + // section is NOT today's work and must not leak in; overdue = 1; completed = + // today's completions = 2. Pin every contributing section so a regression in + // the active-dedup or completed wiring is caught here. + if n := len(got.ActiveTodos); n != 1 { + t.Errorf("active_todos len = %d, want 1", n) + } + if n := len(got.RecurringTodos); n != 2 { + t.Errorf("recurring_todos len = %d, want 2", n) + } + if n := len(got.CompletedTodos); n != 2 { + t.Errorf("completed_todos len = %d, want 2", n) } - if n := len(got.CommittedTodos); n != 4 { - t.Errorf("committed_todos len = %d, want 4", n) + if n := len(got.CommittedTodos); n != 1 { + t.Errorf("committed_todos len = %d, want 1", n) } if n := len(got.RSSHighlights); n != 0 { t.Errorf("rss_highlights len = %d, want 0 (reader is nil)", n) diff --git a/internal/today/today.go b/internal/today/today.go index 4a6f95c1..9c5321be 100644 --- a/internal/today/today.go +++ b/internal/today/today.go @@ -27,14 +27,6 @@ type RSSHighlight struct { CreatedAt string `json:"created_at"` } -// PlanCompletion is the small counts panel derived from the day's -// committed plan items: how many are still planned, done, or deferred. -type PlanCompletion struct { - Planned int `json:"planned"` - Completed int `json:"completed"` - Deferred int `json:"deferred"` -} - // Response is the wire shape for GET /api/admin/commitment/today. It // carries the same morning sections as brief(mode=morning), exposed via // the admin API. List fields always marshal as [] (never null). @@ -43,15 +35,23 @@ type PlanCompletion struct { // and due-today routines were invisible on the day's surfaces: ActiveTodos is // in_progress work not already shown by a date section or the plan; RecurringTodos // is the compute-on-read due-today routines (mirrors brief(morning)). +// +// CompletedTodos is what was finished today (one-time todos done today plus +// recurring occurrences stamped today): it backs the "completed today" review +// list, and the admin Today strip derives its done count from this section's +// length. The day-progress strip is due-based — its open / overdue / completed +// figures are the lengths of the today+recurring+active, overdue, and completed +// sections, so it never depends on a committed plan existing. CommittedTodos is +// the day's committed plan, retained as an optional pin only. type Response struct { Date string `json:"date"` OverdueTodos []todo.PendingDetail `json:"overdue_todos"` TodayTodos []todo.PendingDetail `json:"today_todos"` ActiveTodos []todo.PendingDetail `json:"active_todos"` RecurringTodos []todo.Item `json:"recurring_todos"` + CompletedTodos []todo.Item `json:"completed_todos"` CommittedTodos []daily.Item `json:"committed_todos"` UpcomingTodos []todo.PendingDetail `json:"upcoming_todos"` - PlanCompletion PlanCompletion `json:"plan_completion"` ActiveGoals []goal.ActiveGoalSummary `json:"active_goals"` RSSHighlights []RSSHighlight `json:"rss_highlights"` } diff --git a/internal/todo/query.sql b/internal/todo/query.sql index 34dbd33f..b0b37e98 100644 --- a/internal/todo/query.sql +++ b/internal/todo/query.sql @@ -148,6 +148,24 @@ LEFT JOIN projects p ON t.project_id = p.id WHERE t.state = 'done' AND t.completed_at >= @since ORDER BY t.completed_at DESC; +-- name: CompletedTodoItemsOn :many +-- Todos completed on @today, for the Today dashboard's done-today count and +-- list. Two arms: a one-time todo finished within the day window [@day_start, +-- @day_end) (state=done, completed_at in range), OR a recurring todo whose +-- occurrence was stamped on @today (last_completed_on = @today). A recurring +-- todo terminally closed the same day it had an occurrence stamped satisfies +-- both arms, but a WHERE-OR over a base table emits each row once, so the done +-- count is never inflated. Selects the full todos column set (no join) so sqlc +-- returns db.Todo and rowToItem applies. +SELECT id, title, state, due, project_id, + completed_at, energy, priority, recur_interval, recur_unit, recur_weekdays, last_completed_on, + description, created_by, created_at, updated_at +FROM todos +WHERE (state = 'done' AND completed_at >= @day_start AND completed_at < @day_end) + OR ((recur_weekdays IS NOT NULL OR recur_interval IS NOT NULL) + AND last_completed_on = @today::date) +ORDER BY completed_at DESC NULLS LAST, last_completed_on DESC NULLS LAST, title; + -- name: UpdateTodoItem :one -- Update editable todo item fields. State transitions go through -- UpdateTodoItemState, never here. Only non-null parameters are applied. diff --git a/internal/todo/todo.go b/internal/todo/todo.go index 08b88c44..0f3895c5 100644 --- a/internal/todo/todo.go +++ b/internal/todo/todo.go @@ -106,6 +106,26 @@ func (s *Store) Items(ctx context.Context) ([]Item, error) { return items, nil } +// CompletedItemsOn returns the todos completed on the given day, for the Today +// dashboard's done-today count and list: one-time todos finished within +// [dayStart, dayEnd) plus recurring todos whose occurrence was stamped on today. +// See CompletedTodoItemsOn in query.sql. +func (s *Store) CompletedItemsOn(ctx context.Context, today, dayStart, dayEnd time.Time) ([]Item, error) { + rows, err := s.q.CompletedTodoItemsOn(ctx, db.CompletedTodoItemsOnParams{ + DayStart: &dayStart, + DayEnd: &dayEnd, + Today: today, + }) + if err != nil { + return nil, fmt.Errorf("listing todos completed on %s: %w", today.Format(time.DateOnly), err) + } + items := make([]Item, len(rows)) + for i := range rows { + items[i] = rowToItem(&rows[i]) + } + return items, nil +} + // ItemByID returns a single todo item by its ID. func (s *Store) ItemByID(ctx context.Context, id uuid.UUID) (*Item, error) { r, err := s.q.TodoItemByID(ctx, id) From 2f9847228a28e714eb6db05c6459877d86e365bf Mon Sep 17 00:00:00 2001 From: Koopa Date: Tue, 30 Jun 2026 11:36:18 +0800 Subject: [PATCH 2/7] feat(todos): split Inbox into its own page; Todos = status-flow tabs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Inbox and Todos were the same GtdPageComponent differing only by a route data flag. Inbox now has its own page (capture + triage), reusing the GTD store, row, and clarify modal locked to the inbox view. The Todos page becomes a pure status-flow surface: Pending / In Progress / Someday / Complete. The Today and Recurring tabs and the capture bar are gone — "today" lives on the Today dashboard, routines in their own overview and each row's detail, and capture in the Inbox. The Complete tab is backed by a resolved-since query (done + dropped/ archived + recurring occurrences), so a recurring routine's daily completion is visible there; rows badge their resolution kind (done / dropped / recurring). Recurring routines are excluded from the working status tabs so the flow stays free of daily routines. Also adds AllRecurringTodoItems for the upcoming routines overview, and widens the frontend TodoState to match the backend enum (archived, dismissed). --- .../admin/commitment/inbox/inbox.page.html | 133 ++++++++++ .../admin/commitment/inbox/inbox.page.spec.ts | 155 +++++++++++ .../app/admin/commitment/inbox/inbox.page.ts | 116 ++++++++ .../todos/gtd-row.component.spec.ts | 2 +- .../admin/commitment/todos/gtd-view.spec.ts | 94 ++++--- .../app/admin/commitment/todos/gtd-view.ts | 127 ++++----- .../app/admin/commitment/todos/gtd.page.html | 93 +------ .../admin/commitment/todos/gtd.page.spec.ts | 250 ++++-------------- .../app/admin/commitment/todos/gtd.page.ts | 74 +----- .../admin/commitment/todos/gtd.store.spec.ts | 13 +- .../app/admin/commitment/todos/gtd.store.ts | 47 +--- frontend/src/app/app.routes.ts | 7 +- .../src/app/core/models/workbench.model.ts | 12 +- .../src/app/core/services/todo.service.ts | 7 +- internal/db/query.sql.go | 155 ++++++++--- internal/todo/handler.go | 29 +- internal/todo/history.go | 26 +- internal/todo/integration_test.go | 31 ++- internal/todo/query.sql | 39 ++- internal/todo/recurring.go | 15 ++ internal/todo/todo.go | 9 +- 21 files changed, 851 insertions(+), 583 deletions(-) create mode 100644 frontend/src/app/admin/commitment/inbox/inbox.page.html create mode 100644 frontend/src/app/admin/commitment/inbox/inbox.page.spec.ts create mode 100644 frontend/src/app/admin/commitment/inbox/inbox.page.ts diff --git a/frontend/src/app/admin/commitment/inbox/inbox.page.html b/frontend/src/app/admin/commitment/inbox/inbox.page.html new file mode 100644 index 00000000..8a41fe3d --- /dev/null +++ b/frontend/src/app/admin/commitment/inbox/inbox.page.html @@ -0,0 +1,133 @@ + +
+