diff --git a/docs/backend-semantic-contract.md b/docs/backend-semantic-contract.md
index a57329e57..c269845a4 100644
--- a/docs/backend-semantic-contract.md
+++ b/docs/backend-semantic-contract.md
@@ -323,6 +323,26 @@ The Today aggregate is now a complete backend surface (the earlier
- Tested for both empty-state and wired-sections behavior
(`internal/today/handler_test.go`).
- Daily Plan is subsumed by this aggregate (`cmd/app/routes.go` — `GET /api/admin/commitment/today`).
+- **Day-progress is due-based, not plan-based** (2026-06-30). The human Today
+ dashboard's open / completed / overdue figures derive from what is actually
+ scheduled today — due-today + recurring-due + in_progress (open), today's
+ completions incl. recurring `last_completed_on=today` (completed), and
+ past-due-still-open (overdue) — so the strip is meaningful without a committed
+ `daily_plan`. The aggregate exposes the contributing sections (including a new
+ `completed_todos`) and the front end sums their lengths; the old
+ `PlanCompletion` block (which counted `daily_plan_items`, and whose `.status`
+ column has no write path) was removed (`internal/today/today.go`,
+ `internal/today/handler.go::loadCompleted`). The MCP `brief(mode=reflection)`
+ tool **deliberately stays plan-based** — it reports plan-vs-actual over the
+ day's committed `daily_plan_items` for the planner agent
+ (`internal/mcp/brief.go::fillBriefReflection`). The two surfaces answer
+ different questions (human: "what is my actual day"; planner agent: "how did
+ the committed plan resolve"), so this divergence is intentional, not drift.
+- **Routines have a manage-all surface.** Beyond the due-today list, every active
+ recurring schedule is exposed via `GET /api/admin/commitment/todos/recurring`'s
+ `all` bucket (`internal/todo/query.sql::AllRecurringTodoItems`), backing the
+ admin Routines overview — the home for a routine on its off-days, which the
+ due-today list and the recurring-excluding Todos status tabs do not show.
### G. Carried-forward human-resolved decisions (do not re-litigate)
diff --git a/frontend/src/app/admin/admin-layout/admin-nav.config.ts b/frontend/src/app/admin/admin-layout/admin-nav.config.ts
index c3f18c0e7..8d6430a05 100644
--- a/frontend/src/app/admin/admin-layout/admin-nav.config.ts
+++ b/frontend/src/app/admin/admin-layout/admin-nav.config.ts
@@ -10,6 +10,7 @@ import {
Home,
Inbox as InboxIcon,
Layers,
+ Repeat,
Rss,
Search,
Sparkles,
@@ -90,6 +91,13 @@ export const ADMIN_NAV: readonly AdminNavGroup[] = [
shortcutHint: 'G T',
testId: 'admin-nav-todos',
},
+ {
+ label: 'Routines',
+ route: '/admin/daily/routines',
+ icon: Repeat,
+ shortcutHint: '',
+ testId: 'admin-nav-routines',
+ },
],
},
{
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 000000000..8a41fe3d8
--- /dev/null
+++ b/frontend/src/app/admin/commitment/inbox/inbox.page.html
@@ -0,0 +1,133 @@
+
+
+
+
+
+ @if (store.viewError()) {
+
+
+
+ Couldn’t load this view
+
+
+ The todos service didn’t respond. Nothing was lost.
+
+
diff --git a/frontend/src/app/admin/commitment/todos/todo-detail-modal.component.spec.ts b/frontend/src/app/admin/commitment/todos/todo-detail-modal.component.spec.ts
new file mode 100644
index 000000000..d9dfb40f9
--- /dev/null
+++ b/frontend/src/app/admin/commitment/todos/todo-detail-modal.component.spec.ts
@@ -0,0 +1,80 @@
+import { TestBed, type ComponentFixture } from '@angular/core/testing';
+import { TodoDetailModalComponent } from './todo-detail-modal.component';
+import type { TodoRow } from '../../../core/services/todo.service';
+
+function makeRow(partial: Partial = {}): TodoRow {
+ return {
+ id: 't1',
+ title: 'Rewrite the auth handler',
+ state: 'todo',
+ created_at: '2026-06-10T00:00:00Z',
+ updated_at: '2026-06-10T00:00:00Z',
+ ...partial,
+ };
+}
+
+describe('TodoDetailModalComponent', () => {
+ let fixture: ComponentFixture;
+
+ function render(row: TodoRow): void {
+ TestBed.configureTestingModule({ imports: [TodoDetailModalComponent] });
+ fixture = TestBed.createComponent(TodoDetailModalComponent);
+ fixture.componentRef.setInput('item', row);
+ fixture.detectChanges();
+ }
+
+ function el(): HTMLElement {
+ return fixture.nativeElement as HTMLElement;
+ }
+
+ function testid(id: string): HTMLElement | null {
+ return el().querySelector(`[data-testid="${id}"]`);
+ }
+
+ afterEach(() => TestBed.resetTestingModule());
+
+ it('should label the advance verb Start for a todo and show one-time recurrence', () => {
+ render(makeRow({ state: 'todo' }));
+ expect(testid('todo-detail-advance')?.textContent).toContain('Start');
+ expect(testid('todo-detail-recurrence')?.textContent).toContain('One-time');
+ expect(testid('todo-detail-defer')).toBeTruthy();
+ });
+
+ it('should label the advance verb Complete for an in-progress todo', () => {
+ render(makeRow({ state: 'in_progress' }));
+ expect(testid('todo-detail-advance')?.textContent).toContain('Complete');
+ });
+
+ it('should show the recurrence badge and Edit-routine label for a routine', () => {
+ render(makeRow({ recur_interval: 2, recur_unit: 'weeks' }));
+ expect(testid('todo-detail-recurrence')?.textContent).toContain('every 2w');
+ expect(testid('todo-detail-recurrence-edit')?.textContent).toContain(
+ 'Edit routine',
+ );
+ });
+
+ it('should hide Defer and offer Activate on a someday row', () => {
+ render(makeRow({ state: 'someday' }));
+ expect(testid('todo-detail-defer')).toBeNull();
+ expect(testid('todo-detail-advance')?.textContent).toContain('Activate');
+ });
+
+ it('should emit the action outputs when the footer buttons are clicked', () => {
+ render(makeRow({ state: 'todo' }));
+ let advanced = 0;
+ let deferred = 0;
+ let dropped = 0;
+ let editedRecurrence = 0;
+ fixture.componentInstance.advance.subscribe(() => advanced++);
+ fixture.componentInstance.deferRow.subscribe(() => deferred++);
+ fixture.componentInstance.dropRow.subscribe(() => dropped++);
+ fixture.componentInstance.editRecurrence.subscribe(() => editedRecurrence++);
+
+ (testid('todo-detail-advance') as HTMLButtonElement).click();
+ (testid('todo-detail-defer') as HTMLButtonElement).click();
+ (testid('todo-detail-drop') as HTMLButtonElement).click();
+ (testid('todo-detail-recurrence-edit') as HTMLButtonElement).click();
+
+ expect([advanced, deferred, dropped, editedRecurrence]).toEqual([1, 1, 1, 1]);
+ });
+});
diff --git a/frontend/src/app/admin/commitment/todos/todo-detail-modal.component.ts b/frontend/src/app/admin/commitment/todos/todo-detail-modal.component.ts
new file mode 100644
index 000000000..53f7713c6
--- /dev/null
+++ b/frontend/src/app/admin/commitment/todos/todo-detail-modal.component.ts
@@ -0,0 +1,72 @@
+import {
+ ChangeDetectionStrategy,
+ Component,
+ computed,
+ input,
+ output,
+} from '@angular/core';
+import type { TodoRow } from '../../../core/services/todo.service';
+import { ModalComponent } from '../../../shared/components/modal/modal.component';
+import { EnergyMeterComponent } from '../../../shared/components/energy-meter/energy-meter.component';
+import { advanceActionFor, energyOf, recurLabel } from './gtd-view';
+import type { EnergyLevel } from '../../../core/models/workbench.model';
+
+/** The next advance verb's button label, by state; null when there is none. */
+const ADVANCE_LABEL: Record<'start' | 'activate' | 'complete', string> = {
+ start: 'Start',
+ activate: 'Activate',
+ complete: 'Complete',
+};
+
+/**
+ * Todo detail panel — opens on a row click to surface, in one discoverable
+ * place, what the row is (state, project, due, energy, whether it is a recurring
+ * routine) and the actions previously hidden behind hover/keyboard: advance its
+ * state, defer it, edit its recurrence, or drop it. A pure view — the GTD store
+ * owns every round-trip; this emits intent against the open detail target.
+ */
+@Component({
+ selector: 'app-todo-detail-modal',
+ imports: [ModalComponent, EnergyMeterComponent],
+ templateUrl: './todo-detail-modal.component.html',
+ changeDetection: ChangeDetectionStrategy.OnPush,
+})
+export class TodoDetailModalComponent {
+ readonly item = input.required();
+ readonly busy = input(false);
+
+ readonly advance = output();
+ readonly deferRow = output();
+ readonly dropRow = output();
+ readonly editRecurrence = output();
+ readonly closed = output();
+
+ /** Human label for the state pill (in_progress → "in progress"). */
+ protected readonly stateLabel = computed(() =>
+ this.item().state.replaceAll('_', ' '),
+ );
+
+ /** The next advance verb's label, or null at a terminal state. */
+ protected readonly advanceLabel = computed(() => {
+ const action = advanceActionFor(this.item().state);
+ return action ? ADVANCE_LABEL[action] : null;
+ });
+
+ /** Someday rows re-enter the backlog via Activate; they cannot be deferred. */
+ protected readonly showDefer = computed(() => this.item().state !== 'someday');
+
+ /** Recurrence summary ("every 2w" / "Mon Thu" / "daily"), null if one-time. */
+ protected readonly recurrence = computed(() =>
+ recurLabel(
+ this.item().recur_interval,
+ this.item().recur_unit,
+ this.item().recur_weekdays,
+ ),
+ );
+
+ protected readonly energy = computed((): EnergyLevel | null =>
+ energyOf(this.item().energy),
+ );
+
+ protected readonly due = computed(() => this.item().due?.slice(0, 10) ?? null);
+}
diff --git a/frontend/src/app/app.routes.ts b/frontend/src/app/app.routes.ts
index 12d40a7f4..9ba319ee9 100644
--- a/frontend/src/app/app.routes.ts
+++ b/frontend/src/app/app.routes.ts
@@ -111,10 +111,9 @@ export const routes: Routes = [
{
path: 'daily/inbox',
loadComponent: () =>
- import('./admin/commitment/todos/gtd.page').then(
- (m) => m.GtdPageComponent,
+ import('./admin/commitment/inbox/inbox.page').then(
+ (m) => m.InboxPageComponent,
),
- data: { gtdView: 'inbox' },
},
{
path: 'daily/todos',
@@ -122,7 +121,14 @@ export const routes: Routes = [
import('./admin/commitment/todos/gtd.page').then(
(m) => m.GtdPageComponent,
),
- data: { gtdView: 'today' },
+ data: { gtdView: 'pending' },
+ },
+ {
+ path: 'daily/routines',
+ loadComponent: () =>
+ import('./admin/commitment/routines/routines.page').then(
+ (m) => m.RoutinesPageComponent,
+ ),
},
// ── Commitment ───────────────────────────────────────────────
diff --git a/frontend/src/app/core/models/workbench.model.ts b/frontend/src/app/core/models/workbench.model.ts
index b8b6e6e81..9f28db67d 100644
--- a/frontend/src/app/core/models/workbench.model.ts
+++ b/frontend/src/app/core/models/workbench.model.ts
@@ -146,7 +146,17 @@ export interface CellState {
// Todo — shared enums (todo list / plan / detail views).
// ============================================================
-export type TodoState = 'inbox' | 'todo' | 'in_progress' | 'done' | 'someday';
+// Mirrors the backend todo_state enum (internal/todo/todo.go). archived and
+// dismissed are terminal self-close states (filed away / won't do), distinct
+// from done; they surface in the Complete view as "dropped".
+export type TodoState =
+ | 'inbox'
+ | 'todo'
+ | 'in_progress'
+ | 'done'
+ | 'someday'
+ | 'archived'
+ | 'dismissed';
export type EnergyLevel = 'low' | 'medium' | 'high';
export type PriorityLevel = 'low' | 'medium' | 'high';
diff --git a/frontend/src/app/core/services/todo.service.ts b/frontend/src/app/core/services/todo.service.ts
index d81560d6b..987d71506 100644
--- a/frontend/src/app/core/services/todo.service.ts
+++ b/frontend/src/app/core/services/todo.service.ts
@@ -57,24 +57,34 @@ export interface TodoItem {
recur_unit?: string | null;
/** Weekday-mode recurrence mask (Mon=bit0..Sun=bit6); see TodoRow. */
recur_weekdays?: number | null;
+ /** Date of the most recent recurring occurrence (YYYY-MM-DD); null if never. */
+ last_completed_on?: string | null;
description?: string;
created_by: string;
created_at: string;
updated_at: string;
}
-/** GET /todos/recurring: occurrences due today (compute-on-read). */
+/**
+ * GET /todos/recurring. due_today is the compute-on-read occurrences due today;
+ * all is every active recurring schedule (the routines overview / manage-all
+ * view), regardless of whether it is due today.
+ */
export interface RecurringBuckets {
due_today: TodoItem[];
+ all: TodoItem[];
}
/**
- * History row — the common projection of the two history wire shapes
- * (completed-since default path and the ?q= full-text search path).
+ * History row — the common projection of the two history wire shapes (the
+ * resolved-since default path and the ?q= full-text search path). state carries
+ * the resolution kind so the Complete view can badge done / dropped / recurring;
+ * completed_at holds the per-kind resolution instant.
*/
export interface TodoHistoryEntry {
id: string;
title: string;
+ state?: TodoState;
completed_at?: string | null;
project_title: string;
}
diff --git a/internal/db/query.sql.go b/internal/db/query.sql.go
index 637c6b47a..1fb3ecbcd 100644
--- a/internal/db/query.sql.go
+++ b/internal/db/query.sql.go
@@ -455,6 +455,61 @@ func (q *Queries) AllPublishedSlugs(ctx context.Context) ([]AllPublishedSlugsRow
return items, nil
}
+const allRecurringTodoItems = `-- name: AllRecurringTodoItems :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 NOT IN ('done', 'someday', 'inbox', 'archived', 'dismissed')
+ AND (recur_weekdays IS NOT NULL OR recur_interval IS NOT NULL)
+ORDER BY priority NULLS LAST, title
+`
+
+// Every active recurring todo's schedule, for the routines overview (manage all
+// routines, not just today's due ones — distinct from RecurringTodoItemsDueToday).
+// Uses the SAME active-state filter as RecurringTodoItemsDueToday so the overview
+// is a superset of the due-today list and never shows a closed routine: a
+// terminally done, someday-parked, still-in-inbox, or archived/dismissed
+// recurring row is not a live schedule and is reachable on its own surface
+// (Complete / Someday / Inbox tabs). Selects the full todos column set so sqlc
+// returns db.Todo and rowToItem applies.
+func (q *Queries) AllRecurringTodoItems(ctx context.Context) ([]Todo, error) {
+ rows, err := q.db.Query(ctx, allRecurringTodoItems)
+ 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 archiveContentReturning = `-- name: ArchiveContentReturning :one
UPDATE contents SET status = 'archived', review_note = NULL, updated_at = now()
WHERE id = $1
@@ -985,39 +1040,57 @@ func (q *Queries) CompletedMilestonesInWindow(ctx context.Context, arg Completed
return items, nil
}
-const completedTodoDetailSince = `-- name: CompletedTodoDetailSince :many
-SELECT t.id, t.title, t.completed_at, t.project_id,
- COALESCE(p.title, '') AS project_title
-FROM todos t
-LEFT JOIN projects p ON t.project_id = p.id
-WHERE t.state = 'done' AND t.completed_at >= $1
-ORDER BY t.completed_at DESC
-`
-
-type CompletedTodoDetailSinceRow struct {
- ID uuid.UUID `json:"id"`
- Title string `json:"title"`
- CompletedAt *time.Time `json:"completed_at"`
- ProjectID *uuid.UUID `json:"project_id"`
- ProjectTitle string `json:"project_title"`
-}
-
-// Get todo items completed since a given time with project context.
-func (q *Queries) CompletedTodoDetailSince(ctx context.Context, since *time.Time) ([]CompletedTodoDetailSinceRow, error) {
- rows, err := q.db.Query(ctx, completedTodoDetailSince, since)
+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 := []CompletedTodoDetailSinceRow{}
+ items := []Todo{}
for rows.Next() {
- var i CompletedTodoDetailSinceRow
+ var i Todo
if err := rows.Scan(
&i.ID,
&i.Title,
- &i.CompletedAt,
+ &i.State,
+ &i.Due,
&i.ProjectID,
- &i.ProjectTitle,
+ &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
}
@@ -5026,6 +5099,65 @@ func (q *Queries) ResolveTodoByCreator(ctx context.Context, arg ResolveTodoByCre
return i, err
}
+const resolvedTodoDetailSince = `-- name: ResolvedTodoDetailSince :many
+SELECT t.id, t.title, t.state,
+ CASE
+ WHEN t.state = 'done' THEN t.completed_at
+ WHEN t.state IN ('archived', 'dismissed') THEN t.updated_at
+ ELSE t.last_completed_on::timestamptz
+ END AS resolved_at,
+ COALESCE(p.title, '') AS project_title
+FROM todos t
+LEFT JOIN projects p ON t.project_id = p.id
+WHERE (t.state = 'done' AND t.completed_at >= $1)
+ OR (t.state IN ('archived', 'dismissed') AND t.updated_at >= $1)
+ OR ((t.recur_weekdays IS NOT NULL OR t.recur_interval IS NOT NULL)
+ AND t.state NOT IN ('done', 'archived', 'dismissed')
+ AND t.last_completed_on IS NOT NULL
+ AND t.last_completed_on >= $1::date)
+ORDER BY resolved_at DESC NULLS LAST
+`
+
+type ResolvedTodoDetailSinceRow struct {
+ ID uuid.UUID `json:"id"`
+ Title string `json:"title"`
+ State TodoState `json:"state"`
+ ResolvedAt time.Time `json:"resolved_at"`
+ ProjectTitle string `json:"project_title"`
+}
+
+// Resolved ("已了結") todos since a given time, for the Todos page Complete tab:
+// one-time todos done (state=done, by completed_at), todos dropped/filed
+// (archived/dismissed, by updated_at), and recurring routines with a recent
+// occurrence (last_completed_on, while still active so the schedule keeps
+// running). resolved_at is the per-kind resolution instant; state lets the
+// front end badge the kind (done / archived / dismissed / recurring-active).
+func (q *Queries) ResolvedTodoDetailSince(ctx context.Context, since *time.Time) ([]ResolvedTodoDetailSinceRow, error) {
+ rows, err := q.db.Query(ctx, resolvedTodoDetailSince, since)
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+ items := []ResolvedTodoDetailSinceRow{}
+ for rows.Next() {
+ var i ResolvedTodoDetailSinceRow
+ if err := rows.Scan(
+ &i.ID,
+ &i.Title,
+ &i.State,
+ &i.ResolvedAt,
+ &i.ProjectTitle,
+ ); err != nil {
+ return nil, err
+ }
+ items = append(items, i)
+ }
+ if err := rows.Err(); err != nil {
+ return nil, err
+ }
+ return items, nil
+}
+
const retireAgent = `-- name: RetireAgent :execrows
UPDATE agents
SET status = 'retired',
@@ -5285,99 +5417,65 @@ func (q *Queries) SearchContentsCount(ctx context.Context, arg SearchContentsCou
return count, err
}
-const searchTodoItems = `-- name: SearchTodoItems :many
-SELECT t.id, t.title, t.state, t.due, t.project_id,
- t.energy, t.priority, t.recur_interval, t.recur_unit, t.recur_weekdays, t.last_completed_on,
- t.completed_at, t.description, t.created_at, t.updated_at,
- COALESCE(p.title, '') AS project_title,
- COALESCE(p.slug, '') AS project_slug
+const searchResolvedTodoItems = `-- name: SearchResolvedTodoItems :many
+SELECT t.id, t.title, t.state,
+ CASE
+ WHEN t.state = 'done' THEN t.completed_at
+ WHEN t.state IN ('archived', 'dismissed') THEN t.updated_at
+ ELSE t.last_completed_on::timestamptz
+ END AS resolved_at,
+ COALESCE(p.title, '') AS project_title
FROM todos t
LEFT JOIN projects p ON t.project_id = p.id
-WHERE ($1::text IS NULL OR (t.title ILIKE '%' || $1 || '%' OR t.description ILIKE '%' || $1 || '%'))
- AND ($2::text IS NULL OR p.slug = $2)
- AND ($3::text IS NULL OR
- CASE $3
- WHEN 'pending' THEN t.state != 'done'
- WHEN 'done' THEN t.state = 'done'
- ELSE true
- END)
- AND ($4::timestamptz IS NULL OR t.completed_at >= $4)
- AND ($5::timestamptz IS NULL OR t.completed_at < $5)
-ORDER BY
- CASE WHEN t.state != 'done' THEN 0 ELSE 1 END,
- CASE WHEN t.state != 'done' THEN
- CASE WHEN t.due IS NOT NULL THEN 0 ELSE 1 END
- ELSE 2 END,
- t.due ASC NULLS LAST,
- t.completed_at DESC NULLS LAST,
- t.updated_at ASC
-LIMIT $6
+WHERE (t.title ILIKE '%' || $1 || '%' OR t.description ILIKE '%' || $1 || '%')
+ AND (
+ (t.state = 'done' AND t.completed_at >= $2)
+ OR (t.state IN ('archived', 'dismissed') AND t.updated_at >= $2)
+ OR ((t.recur_weekdays IS NOT NULL OR t.recur_interval IS NOT NULL)
+ AND t.state NOT IN ('done', 'archived', 'dismissed')
+ AND t.last_completed_on IS NOT NULL
+ AND t.last_completed_on >= $2::date)
+ )
+ORDER BY resolved_at DESC NULLS LAST
+LIMIT $3
`
-type SearchTodoItemsParams struct {
- Query *string `json:"query"`
- ProjectSlug *string `json:"project_slug"`
- StateFilter *string `json:"state_filter"`
- CompletedAfter *time.Time `json:"completed_after"`
- CompletedBefore *time.Time `json:"completed_before"`
- MaxResults int32 `json:"max_results"`
-}
-
-type SearchTodoItemsRow struct {
- ID uuid.UUID `json:"id"`
- Title string `json:"title"`
- State TodoState `json:"state"`
- Due *time.Time `json:"due"`
- ProjectID *uuid.UUID `json:"project_id"`
- Energy *string `json:"energy"`
- Priority *string `json:"priority"`
- RecurInterval *int32 `json:"recur_interval"`
- RecurUnit *string `json:"recur_unit"`
- RecurWeekdays *int16 `json:"recur_weekdays"`
- LastCompletedOn *time.Time `json:"last_completed_on"`
- CompletedAt *time.Time `json:"completed_at"`
- Description string `json:"description"`
- CreatedAt time.Time `json:"created_at"`
- UpdatedAt time.Time `json:"updated_at"`
- ProjectTitle string `json:"project_title"`
- ProjectSlug string `json:"project_slug"`
+type SearchResolvedTodoItemsParams struct {
+ Query *string `json:"query"`
+ Since *time.Time `json:"since"`
+ MaxResults int32 `json:"max_results"`
}
-// Search todo items by title/description with optional filters.
-func (q *Queries) SearchTodoItems(ctx context.Context, arg SearchTodoItemsParams) ([]SearchTodoItemsRow, error) {
- rows, err := q.db.Query(ctx, searchTodoItems,
- arg.Query,
- arg.ProjectSlug,
- arg.StateFilter,
- arg.CompletedAfter,
- arg.CompletedBefore,
- arg.MaxResults,
- )
+type SearchResolvedTodoItemsRow struct {
+ ID uuid.UUID `json:"id"`
+ Title string `json:"title"`
+ State TodoState `json:"state"`
+ ResolvedAt time.Time `json:"resolved_at"`
+ ProjectTitle string `json:"project_title"`
+}
+
+// The ?q= search counterpart of ResolvedTodoDetailSince: resolved ("已了結")
+// todos since @since whose title or description matches @query. It applies the
+// SAME resolution arms as ResolvedTodoDetailSince (done by completed_at, dropped
+// by updated_at, recurring by last_completed_on) so the Complete tab's search
+// covers done, dropped, AND recurring resolutions — not only done. @query is
+// pre-escaped by the caller (escapeILIKE); the wrapping wildcards are the only
+// ILIKE metacharacters. resolved_at is the per-kind resolution instant.
+func (q *Queries) SearchResolvedTodoItems(ctx context.Context, arg SearchResolvedTodoItemsParams) ([]SearchResolvedTodoItemsRow, error) {
+ rows, err := q.db.Query(ctx, searchResolvedTodoItems, arg.Query, arg.Since, arg.MaxResults)
if err != nil {
return nil, err
}
defer rows.Close()
- items := []SearchTodoItemsRow{}
+ items := []SearchResolvedTodoItemsRow{}
for rows.Next() {
- var i SearchTodoItemsRow
+ var i SearchResolvedTodoItemsRow
if err := rows.Scan(
&i.ID,
&i.Title,
&i.State,
- &i.Due,
- &i.ProjectID,
- &i.Energy,
- &i.Priority,
- &i.RecurInterval,
- &i.RecurUnit,
- &i.RecurWeekdays,
- &i.LastCompletedOn,
- &i.CompletedAt,
- &i.Description,
- &i.CreatedAt,
- &i.UpdatedAt,
+ &i.ResolvedAt,
&i.ProjectTitle,
- &i.ProjectSlug,
); err != nil {
return nil, err
}
diff --git a/internal/today/handler.go b/internal/today/handler.go
index 9ab2105e7..12715b75f 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 fa953df34..a86a0fdd4 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 4a6f95c1e..9c5321be0 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/handler.go b/internal/todo/handler.go
index 73215e6a5..cef2149e0 100644
--- a/internal/todo/handler.go
+++ b/internal/todo/handler.go
@@ -163,10 +163,13 @@ func (h *Handler) List(w http.ResponseWriter, r *http.Request) {
api.Encode(w, http.StatusOK, api.Response{Data: out})
}
-// recurringResponse is the wire shape for GET /todos/recurring. The array is
-// non-nil so an empty result serializes [] not null.
+// recurringResponse is the wire shape for GET /todos/recurring. Both arrays are
+// non-nil so an empty result serializes [] not null. DueToday is the compute-on-
+// read occurrences due today; All is every active recurring schedule, for the
+// routines overview (manage-all view).
type recurringResponse struct {
DueToday []Item `json:"due_today"`
+ All []Item `json:"all"`
}
// Recurring handles GET /api/admin/commitment/todos/recurring — the recurring
@@ -185,7 +188,14 @@ func (h *Handler) Recurring(w http.ResponseWriter, r *http.Request) {
return
}
- resp := recurringResponse{DueToday: ensureItems(dueToday)}
+ all, err := h.store.AllRecurringItems(r.Context())
+ if err != nil {
+ h.logger.Error("listing all recurring todos", "error", err)
+ api.Error(w, http.StatusInternalServerError, "INTERNAL", "failed to list recurring todos")
+ return
+ }
+
+ resp := recurringResponse{DueToday: ensureItems(dueToday), All: ensureItems(all)}
api.Encode(w, http.StatusOK, api.Response{Data: resp})
}
@@ -199,10 +209,11 @@ const (
historyMaxLimit = 100
)
-// History handles GET /api/admin/commitment/todos/history. With ?q= it
-// runs the full-text search path; without it, the completed-since path.
-// Query params: since (YYYY-MM-DD, default 30d ago), q, project (slug),
-// limit (1-100, default 20).
+// History handles GET /api/admin/commitment/todos/history — the Complete
+// ("已了結") view. With ?q= it searches the resolved set (done + dropped +
+// recurring occurrences) by title/description; without it, it lists the same
+// resolved set since the cutoff. Query params: since (YYYY-MM-DD, default 30d
+// ago), q, limit (1-100, default 20).
func (h *Handler) History(w http.ResponseWriter, r *http.Request) {
q := r.URL.Query()
@@ -226,39 +237,36 @@ func (h *Handler) History(w http.ResponseWriter, r *http.Request) {
limit = n
}
- // Search path: ?q= present → full-text search over the historical
- // record, scoped to completed-after the since cutoff so the same
- // window applies to both paths.
+ // Search path: ?q= present → search the SAME resolved set as the default
+ // view (done + dropped + recurring occurrences), title/description match,
+ // within the since window. Matching the default view's arms is what makes a
+ // dropped or recurring resolution searchable in the Complete tab.
if query := q.Get("q"); query != "" {
- var projectSlug *string
- if p := q.Get("project"); p != "" {
- projectSlug = &p
- }
- sinceCopy := since
- results, err := h.store.SearchItems(r.Context(), &query, projectSlug, nil, &sinceCopy, nil, int32(limit)) // #nosec G115 -- limit bounded to [1, 100]
+ results, err := h.store.SearchResolvedItems(r.Context(), query, since, int32(limit)) // #nosec G115 -- limit bounded to [1, 100]
if err != nil {
h.logger.Error("searching todo history", "error", err)
api.Error(w, http.StatusInternalServerError, "INTERNAL", "failed to search todo history")
return
}
if results == nil {
- results = []SearchDetail{}
+ results = []ResolvedDetail{}
}
api.Encode(w, http.StatusOK, api.Response{Data: results})
return
}
- // Completed-since path: the default history view.
- completed, err := h.store.CompletedItemsDetailSince(r.Context(), since)
+ // Resolved-since path: the default Complete-tab view — done, dropped, and
+ // recurring routines' recent occurrences.
+ resolved, err := h.store.ResolvedItemsDetailSince(r.Context(), since)
if err != nil {
- h.logger.Error("listing completed todo history", "error", err)
+ h.logger.Error("listing resolved todo history", "error", err)
api.Error(w, http.StatusInternalServerError, "INTERNAL", "failed to list todo history")
return
}
- if completed == nil {
- completed = []CompletedDetail{}
+ if resolved == nil {
+ resolved = []ResolvedDetail{}
}
- api.Encode(w, http.StatusOK, api.Response{Data: completed})
+ api.Encode(w, http.StatusOK, api.Response{Data: resolved})
}
// ensureItems returns a non-nil slice so empty results serialize as [].
diff --git a/internal/todo/history.go b/internal/todo/history.go
index dc67184b0..4c62e607f 100644
--- a/internal/todo/history.go
+++ b/internal/todo/history.go
@@ -15,60 +15,52 @@ import (
"github.com/Koopa0/koopa/internal/db"
)
-// SearchItems searches todo items with optional filters.
-func (s *Store) SearchItems(ctx context.Context, query, projectSlug, stateFilter *string, completedAfter, completedBefore *time.Time, maxResults int32) ([]SearchDetail, error) {
- var escapedQuery *string
- if query != nil {
- v := escapeILIKE(*query)
- escapedQuery = &v
- }
- rows, err := s.q.SearchTodoItems(ctx, db.SearchTodoItemsParams{
- Query: escapedQuery,
- ProjectSlug: projectSlug,
- StateFilter: stateFilter,
- CompletedAfter: completedAfter,
- CompletedBefore: completedBefore,
- MaxResults: maxResults,
+// SearchResolvedItems searches resolved ("已了結") todos — done, dropped
+// (archived/dismissed), or a recurring routine's recent occurrence — by title
+// or description, for the Complete tab's ?q= path. It shares the resolution
+// arms of ResolvedItemsDetailSince so the search covers the same set the default
+// view shows, not just done items. See SearchResolvedTodoItems in query.sql.
+func (s *Store) SearchResolvedItems(ctx context.Context, query string, since time.Time, maxResults int32) ([]ResolvedDetail, error) {
+ escaped := escapeILIKE(query)
+ rows, err := s.q.SearchResolvedTodoItems(ctx, db.SearchResolvedTodoItemsParams{
+ Query: &escaped,
+ Since: &since,
+ MaxResults: maxResults,
})
if err != nil {
- return nil, fmt.Errorf("searching todo items: %w", err)
+ return nil, fmt.Errorf("searching resolved todo items: %w", err)
}
- items := make([]SearchDetail, len(rows))
+ result := make([]ResolvedDetail, len(rows))
for i := range rows {
- r := &rows[i]
- items[i] = SearchDetail{
- ID: r.ID,
- Title: r.Title,
- State: State(r.State),
- Due: r.Due,
- ProjectTitle: r.ProjectTitle,
- ProjectSlug: r.ProjectSlug,
- Energy: r.Energy,
- Priority: r.Priority,
- RecurInterval: r.RecurInterval,
- RecurUnit: r.RecurUnit,
- CompletedAt: r.CompletedAt,
- Description: r.Description,
- CreatedAt: r.CreatedAt,
- UpdatedAt: r.UpdatedAt,
+ resolvedAt := rows[i].ResolvedAt
+ result[i] = ResolvedDetail{
+ ID: rows[i].ID,
+ Title: rows[i].Title,
+ State: State(rows[i].State),
+ CompletedAt: &resolvedAt,
+ ProjectTitle: rows[i].ProjectTitle,
}
}
- return items, nil
+ return result, nil
}
-// CompletedItemsDetailSince returns todo items completed since the given time.
-func (s *Store) CompletedItemsDetailSince(ctx context.Context, since time.Time) ([]CompletedDetail, error) {
- rows, err := s.q.CompletedTodoDetailSince(ctx, &since)
+// ResolvedItemsDetailSince returns todos resolved ("已了結") since the given
+// time — done, dropped (archived/dismissed), or a recurring routine's recent
+// occurrence — for the Complete tab. See ResolvedTodoDetailSince in query.sql.
+func (s *Store) ResolvedItemsDetailSince(ctx context.Context, since time.Time) ([]ResolvedDetail, error) {
+ rows, err := s.q.ResolvedTodoDetailSince(ctx, &since)
if err != nil {
- return nil, fmt.Errorf("listing completed todo items since %s: %w", since.Format(time.DateOnly), err)
+ return nil, fmt.Errorf("listing resolved todo items since %s: %w", since.Format(time.DateOnly), err)
}
- result := make([]CompletedDetail, len(rows))
- for i, r := range rows {
- result[i] = CompletedDetail{
- ID: r.ID,
- Title: r.Title,
- CompletedAt: r.CompletedAt,
- ProjectTitle: r.ProjectTitle,
+ result := make([]ResolvedDetail, len(rows))
+ for i := range rows {
+ resolvedAt := rows[i].ResolvedAt
+ result[i] = ResolvedDetail{
+ ID: rows[i].ID,
+ Title: rows[i].Title,
+ State: State(rows[i].State),
+ CompletedAt: &resolvedAt,
+ ProjectTitle: rows[i].ProjectTitle,
}
}
return result, nil
diff --git a/internal/todo/integration_test.go b/internal/todo/integration_test.go
index 61b442424..0a709a417 100644
--- a/internal/todo/integration_test.go
+++ b/internal/todo/integration_test.go
@@ -188,6 +188,16 @@ func TestIntegration_Todo_Recurring(t *testing.T) {
doneTodayWeekday := seed("Already done today", iptr(allWeekdays), nil, nil, sptr(today))
dueInterval := seed("Every 3 days, never done", nil, iptr(3), sptr("days"), nil)
+ // A recurring row that was terminally closed (state=done) is no longer a
+ // live schedule — it must appear in neither bucket, including `all`.
+ var doneRecurring uuid.UUID
+ if err := testPool.QueryRow(t.Context(),
+ `INSERT INTO todos (title, state, recur_weekdays, completed_at, created_by)
+ VALUES ('Retired routine', 'done', 127, now(), 'human') RETURNING id`,
+ ).Scan(&doneRecurring); err != nil {
+ t.Fatalf("seeding done recurring todo: %v", err)
+ }
+
req := httptest.NewRequest(http.MethodGet, "/api/admin/commitment/todos/recurring", nil)
rec := serveRead(t, h.Recurring, req)
@@ -204,6 +214,9 @@ func TestIntegration_Todo_Recurring(t *testing.T) {
DueToday []struct {
ID uuid.UUID `json:"id"`
} `json:"due_today"`
+ All []struct {
+ ID uuid.UUID `json:"id"`
+ } `json:"all"`
} `json:"data"`
}
if err := json.Unmarshal(body, &env); err != nil {
@@ -233,6 +246,32 @@ func TestIntegration_Todo_Recurring(t *testing.T) {
t.Errorf("%s (%s) must NOT be in due_today (body=%s)", name, id, body)
}
}
+
+ // The `all` bucket (routines overview) carries every active recurring
+ // schedule, including the off-day and already-done-today ones that the
+ // due_today bucket excludes — that is the whole point of the manage-all view.
+ all := make(map[uuid.UUID]struct{}, len(env.Data.All))
+ for _, a := range env.Data.All {
+ all[a.ID] = struct{}{}
+ }
+ for name, id := range map[string]uuid.UUID{
+ "all-weekday todo": dueWeekday,
+ "today-only weekday": dueTodayOnly,
+ "interval, never done": dueInterval,
+ "weekday excluding today": notTodayWeekday,
+ "already completed today": doneTodayWeekday,
+ } {
+ if _, ok := all[id]; !ok {
+ t.Errorf("%s (%s) missing from all (body=%s)", name, id, body)
+ }
+ }
+ // A terminally done recurring row is not a live schedule — excluded from both.
+ if _, ok := all[doneRecurring]; ok {
+ t.Errorf("done recurring %s must NOT be in all (body=%s)", doneRecurring, body)
+ }
+ if _, ok := due[doneRecurring]; ok {
+ t.Errorf("done recurring %s must NOT be in due_today (body=%s)", doneRecurring, body)
+ }
}
// TestIntegration_Todo_Recurring_Interval exercises the interval-mode
@@ -315,19 +354,35 @@ func TestIntegration_Todo_Recurring_Interval(t *testing.T) {
}
}
-// TestIntegration_Todo_History seeds a completed todo and asserts it appears
-// in the default (completed-since) history view.
+// TestIntegration_Todo_History seeds the three resolution kinds the Complete
+// ("已了結") view collects — a one-time done todo, a dropped (dismissed) todo,
+// and a still-active recurring routine with a recent occurrence — and asserts
+// all three appear, while an untouched pending todo does not.
func TestIntegration_Todo_History(t *testing.T) {
truncate(t)
h := newHandler()
- var done uuid.UUID
+ var done, dropped, recurring uuid.UUID
if err := testPool.QueryRow(t.Context(),
`INSERT INTO todos (title, state, completed_at, created_by)
VALUES ('Shipped the feature', 'done', now(), 'human') RETURNING id`,
).Scan(&done); err != nil {
t.Fatalf("seeding completed todo: %v", err)
}
+ if err := testPool.QueryRow(t.Context(),
+ `INSERT INTO todos (title, state, created_by)
+ VALUES ('Won''t do this', 'dismissed', 'human') RETURNING id`,
+ ).Scan(&dropped); err != nil {
+ t.Fatalf("seeding dismissed todo: %v", err)
+ }
+ if err := testPool.QueryRow(t.Context(),
+ `INSERT INTO todos (title, state, recur_weekdays, last_completed_on, created_by)
+ VALUES ('Morning Japanese', 'todo', 127, current_date, 'human') RETURNING id`,
+ ).Scan(&recurring); err != nil {
+ t.Fatalf("seeding recurring todo: %v", err)
+ }
+ // A pending todo with no resolution must NOT surface in the Complete view.
+ pending := seedTodo(t, "Still to do", "todo", "human")
req := httptest.NewRequest(http.MethodGet, "/api/admin/commitment/todos/history", nil)
rec := serveRead(t, h.History, req)
@@ -341,8 +396,40 @@ func TestIntegration_Todo_History(t *testing.T) {
}
ids := dataIDs(t, body)
- if _, ok := ids[done]; !ok {
- t.Errorf("completed todo %s missing from history (body=%s)", done, body)
+ for name, id := range map[string]uuid.UUID{"done": done, "dropped": dropped, "recurring": recurring} {
+ if _, ok := ids[id]; !ok {
+ t.Errorf("%s todo %s missing from Complete view (body=%s)", name, id, body)
+ }
+ }
+ if _, ok := ids[pending]; ok {
+ t.Errorf("pending todo %s must NOT appear in Complete view (body=%s)", pending, body)
+ }
+
+ // The ?q= search path searches the SAME resolved set: "Japanese" must find
+ // the recurring occurrence (the exact gap the old completed_at-only search
+ // missed), and a token shared by a resolved and a non-resolved row ("do":
+ // dropped "Won't do this" vs pending "Still to do") must return only the
+ // resolved one.
+ searchIDs := func(query string) map[uuid.UUID]struct{} {
+ sreq := httptest.NewRequest(http.MethodGet, "/api/admin/commitment/todos/history?q="+query, nil)
+ srec := serveRead(t, h.History, sreq)
+ sresp := srec.Result()
+ defer sresp.Body.Close()
+ sbody, _ := io.ReadAll(sresp.Body)
+ if sresp.StatusCode != http.StatusOK {
+ t.Fatalf("search status = %d, want 200 (body=%s)", sresp.StatusCode, sbody)
+ }
+ return dataIDs(t, sbody)
+ }
+ if _, ok := searchIDs("Japanese")[recurring]; !ok {
+ t.Errorf("recurring %s not searchable in Complete tab via ?q=Japanese", recurring)
+ }
+ doMatches := searchIDs("do")
+ if _, ok := doMatches[dropped]; !ok {
+ t.Errorf("dropped %s not searchable via ?q=do", dropped)
+ }
+ if _, ok := doMatches[pending]; ok {
+ t.Errorf("pending %s must NOT be searchable in the Complete tab via ?q=do", pending)
}
}
diff --git a/internal/todo/query.sql b/internal/todo/query.sql
index 34dbd33ff..40747af27 100644
--- a/internal/todo/query.sql
+++ b/internal/todo/query.sql
@@ -139,14 +139,64 @@ RETURNING 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;
--- name: CompletedTodoDetailSince :many
--- Get todo items completed since a given time with project context.
-SELECT t.id, t.title, t.completed_at, t.project_id,
+-- name: ResolvedTodoDetailSince :many
+-- Resolved ("已了結") todos since a given time, for the Todos page Complete tab:
+-- one-time todos done (state=done, by completed_at), todos dropped/filed
+-- (archived/dismissed, by updated_at), and recurring routines with a recent
+-- occurrence (last_completed_on, while still active so the schedule keeps
+-- running). resolved_at is the per-kind resolution instant; state lets the
+-- front end badge the kind (done / archived / dismissed / recurring-active).
+SELECT t.id, t.title, t.state,
+ CASE
+ WHEN t.state = 'done' THEN t.completed_at
+ WHEN t.state IN ('archived', 'dismissed') THEN t.updated_at
+ ELSE t.last_completed_on::timestamptz
+ END AS resolved_at,
COALESCE(p.title, '') AS project_title
FROM todos t
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;
+WHERE (t.state = 'done' AND t.completed_at >= @since)
+ OR (t.state IN ('archived', 'dismissed') AND t.updated_at >= @since)
+ OR ((t.recur_weekdays IS NOT NULL OR t.recur_interval IS NOT NULL)
+ AND t.state NOT IN ('done', 'archived', 'dismissed')
+ AND t.last_completed_on IS NOT NULL
+ AND t.last_completed_on >= @since::date)
+ORDER BY resolved_at DESC NULLS LAST;
+
+-- name: AllRecurringTodoItems :many
+-- Every active recurring todo's schedule, for the routines overview (manage all
+-- routines, not just today's due ones — distinct from RecurringTodoItemsDueToday).
+-- Uses the SAME active-state filter as RecurringTodoItemsDueToday so the overview
+-- is a superset of the due-today list and never shows a closed routine: a
+-- terminally done, someday-parked, still-in-inbox, or archived/dismissed
+-- recurring row is not a live schedule and is reachable on its own surface
+-- (Complete / Someday / Inbox tabs). Selects the full todos column set 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 NOT IN ('done', 'someday', 'inbox', 'archived', 'dismissed')
+ AND (recur_weekdays IS NOT NULL OR recur_interval IS NOT NULL)
+ORDER BY priority NULLS LAST, title;
+
+-- 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
@@ -164,34 +214,34 @@ RETURNING 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;
--- name: SearchTodoItems :many
--- Search todo items by title/description with optional filters.
-SELECT t.id, t.title, t.state, t.due, t.project_id,
- t.energy, t.priority, t.recur_interval, t.recur_unit, t.recur_weekdays, t.last_completed_on,
- t.completed_at, t.description, t.created_at, t.updated_at,
- COALESCE(p.title, '') AS project_title,
- COALESCE(p.slug, '') AS project_slug
+-- name: SearchResolvedTodoItems :many
+-- The ?q= search counterpart of ResolvedTodoDetailSince: resolved ("已了結")
+-- todos since @since whose title or description matches @query. It applies the
+-- SAME resolution arms as ResolvedTodoDetailSince (done by completed_at, dropped
+-- by updated_at, recurring by last_completed_on) so the Complete tab's search
+-- covers done, dropped, AND recurring resolutions — not only done. @query is
+-- pre-escaped by the caller (escapeILIKE); the wrapping wildcards are the only
+-- ILIKE metacharacters. resolved_at is the per-kind resolution instant.
+SELECT t.id, t.title, t.state,
+ CASE
+ WHEN t.state = 'done' THEN t.completed_at
+ WHEN t.state IN ('archived', 'dismissed') THEN t.updated_at
+ ELSE t.last_completed_on::timestamptz
+ END AS resolved_at,
+ COALESCE(p.title, '') AS project_title
FROM todos t
LEFT JOIN projects p ON t.project_id = p.id
-WHERE (sqlc.narg('query')::text IS NULL OR (t.title ILIKE '%' || sqlc.narg('query') || '%' OR t.description ILIKE '%' || sqlc.narg('query') || '%'))
- AND (sqlc.narg('project_slug')::text IS NULL OR p.slug = sqlc.narg('project_slug'))
- AND (sqlc.narg('state_filter')::text IS NULL OR
- CASE sqlc.narg('state_filter')
- WHEN 'pending' THEN t.state != 'done'
- WHEN 'done' THEN t.state = 'done'
- ELSE true
- END)
- AND (sqlc.narg('completed_after')::timestamptz IS NULL OR t.completed_at >= sqlc.narg('completed_after'))
- AND (sqlc.narg('completed_before')::timestamptz IS NULL OR t.completed_at < sqlc.narg('completed_before'))
-ORDER BY
- CASE WHEN t.state != 'done' THEN 0 ELSE 1 END,
- CASE WHEN t.state != 'done' THEN
- CASE WHEN t.due IS NOT NULL THEN 0 ELSE 1 END
- ELSE 2 END,
- t.due ASC NULLS LAST,
- t.completed_at DESC NULLS LAST,
- t.updated_at ASC
-LIMIT sqlc.arg('max_results');
+WHERE (t.title ILIKE '%' || @query || '%' OR t.description ILIKE '%' || @query || '%')
+ AND (
+ (t.state = 'done' AND t.completed_at >= @since)
+ OR (t.state IN ('archived', 'dismissed') AND t.updated_at >= @since)
+ OR ((t.recur_weekdays IS NOT NULL OR t.recur_interval IS NOT NULL)
+ AND t.state NOT IN ('done', 'archived', 'dismissed')
+ AND t.last_completed_on IS NOT NULL
+ AND t.last_completed_on >= @since::date)
+ )
+ORDER BY resolved_at DESC NULLS LAST
+LIMIT @max_results;
-- === Recurring todo item queries ===
diff --git a/internal/todo/recurring.go b/internal/todo/recurring.go
index b9a7bffd0..e5aa6635f 100644
--- a/internal/todo/recurring.go
+++ b/internal/todo/recurring.go
@@ -34,6 +34,21 @@ func (s *Store) RecurringItemsDueToday(ctx context.Context, today time.Time) ([]
return items, nil
}
+// AllRecurringItems returns every active recurring todo's schedule, for the
+// routines overview — the manage-all view, distinct from the due-today list.
+// See AllRecurringTodoItems in query.sql.
+func (s *Store) AllRecurringItems(ctx context.Context) ([]Item, error) {
+ rows, err := s.q.AllRecurringTodoItems(ctx)
+ if err != nil {
+ return nil, fmt.Errorf("listing all recurring todo items: %w", err)
+ }
+ items := make([]Item, len(rows))
+ for i := range rows {
+ items[i] = rowToItem(&rows[i])
+ }
+ return items, nil
+}
+
// Recurrence is the schedule passed to SetRecurrence: weekday-mode (Weekdays
// non-nil) or interval-mode (Interval and Unit non-nil), or all-nil to clear.
// The caller validates the combination; chk_todo_recurrence is the backstop.
diff --git a/internal/todo/todo.go b/internal/todo/todo.go
index 08b88c44e..7f5065139 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)
@@ -403,28 +423,15 @@ type PendingDetail struct {
UpdatedAt time.Time `json:"updated_at"`
}
-// SearchDetail is a search hit with project context.
-type SearchDetail struct {
- ID uuid.UUID `json:"id"`
- Title string `json:"title"`
- State State `json:"state"`
- Due *time.Time `json:"due,omitempty"`
- ProjectTitle string `json:"project_title"`
- ProjectSlug string `json:"project_slug"`
- Energy *string `json:"energy,omitempty"`
- Priority *string `json:"priority,omitempty"`
- RecurInterval *int32 `json:"recur_interval,omitempty"`
- RecurUnit *string `json:"recur_unit,omitempty"`
- CompletedAt *time.Time `json:"completed_at,omitempty"`
- Description string `json:"description,omitempty"`
- CreatedAt time.Time `json:"created_at"`
- UpdatedAt time.Time `json:"updated_at"`
-}
-
-// CompletedDetail is a recently completed todo with project context.
-type CompletedDetail struct {
+// ResolvedDetail is a resolved ("已了結") todo with project context for the
+// Complete tab: a one-time todo done, a todo dropped (archived/dismissed), or a
+// recurring routine's recent occurrence. State carries the kind so the front
+// end can badge it; CompletedAt holds the per-kind resolution instant (done →
+// completed_at, dropped → updated_at, recurring → last_completed_on).
+type ResolvedDetail struct {
ID uuid.UUID `json:"id"`
Title string `json:"title"`
+ State State `json:"state"`
CompletedAt *time.Time `json:"completed_at,omitempty"`
ProjectTitle string `json:"project_title"`
}