Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions docs/backend-semantic-contract.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
8 changes: 8 additions & 0 deletions frontend/src/app/admin/admin-layout/admin-nav.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import {
Home,
Inbox as InboxIcon,
Layers,
Repeat,
Rss,
Search,
Sparkles,
Expand Down Expand Up @@ -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',
},
],
},
{
Expand Down
133 changes: 133 additions & 0 deletions frontend/src/app/admin/commitment/inbox/inbox.page.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
<!-- Persistent capture bar -->
<form
class="flex h-[42px] shrink-0 items-center gap-2.5 border-b border-border bg-panel px-5"
(submit)="$event.preventDefault(); submitCapture()"
data-testid="gtd-capture-form"
>
<lucide-icon
[img]="PlusIcon"
[size]="15"
class="shrink-0 text-fg-subtle"
aria-hidden="true"
/>
<label for="gtd-capture" class="sr-only">Capture a thought</label>
<input
#captureInput
id="gtd-capture"
type="text"
[value]="captureDraft()"
(input)="captureDraft.set(readInput($event))"
placeholder="Capture a thought — it lands in the inbox, unclarified…"
class="h-full min-w-0 flex-1 bg-transparent text-[13px] text-fg outline-hidden placeholder:text-fg-faint"
data-testid="gtd-capture-input"
/>
<kbd
class="shrink-0 rounded-sm border border-border border-b-2 bg-elevated px-1.5 font-mono text-[10px] whitespace-nowrap text-fg-muted"
>
↵ capture
</kbd>
</form>

<!-- Scrollable inbox list -->
<div class="min-h-0 flex-1 overflow-y-auto" data-testid="gtd-list">
@if (store.viewError()) {
<div
class="flex flex-col items-center gap-2 p-10 text-center"
data-testid="gtd-error"
>
<lucide-icon
[img]="HexagonIcon"
[size]="28"
class="mb-1 text-error opacity-70"
aria-hidden="true"
/>
<h2 class="font-display text-sm font-semibold text-fg">
Couldn’t load this view
</h2>
<p class="max-w-[280px] text-xs leading-relaxed text-fg-subtle">
The todos service didn’t respond. Nothing was lost.
</p>
<button
type="button"
(click)="store.reloadActive()"
data-testid="gtd-retry"
class="mt-2 rounded-sm border border-border bg-elevated px-3 py-1.5 text-xs text-fg-muted transition-colors hover:border-brand hover:text-brand"
>
Retry
</button>
</div>
} @else if (store.viewLoading()) {
<div
class="flex flex-col gap-1 px-5 py-3"
data-testid="gtd-loading"
aria-busy="true"
aria-label="Loading inbox"
>
@for (row of [1, 2, 3, 4, 5, 6]; track row) {
<div class="h-8 animate-pulse rounded-sm bg-elevated"></div>
}
</div>
} @else {
@if (store.rows().length === 0) {
<app-empty-state
[icon]="HexagonIcon"
[title]="store.emptyCopy().title"
[description]="store.emptyCopy().description"
/>
}
<div role="list">
@for (row of store.rows(); let i = $index; track row.id) {
<app-gtd-row
[item]="row"
[view]="view"
[selected]="i === store.selection()"
[busy]="store.busy()"
(rowHover)="store.selectedIndex.set(i)"
(openDetail)="store.openClarify(row)"
(clarify)="store.openClarify(row)"
(advance)="store.advanceRow(row)"
(deferRow)="store.deferRow(row)"
(dropRow)="store.dropRow(row)"
(pull)="store.pullRow(row)"
[attr.data-testid]="'gtd-row-' + i"
/>
}
</div>
}
</div>

<!-- Contextual keyboard hint bar -->
<footer
class="flex shrink-0 items-center gap-4 border-t border-border bg-panel px-5 py-2 font-mono text-[11px] text-fg-subtle"
data-testid="gtd-kbar"
>
@for (hint of store.legend(); track hint.keys) {
<span class="inline-flex items-center gap-1.5">
<kbd
class="rounded-sm border border-border border-b-2 bg-elevated px-1.5 font-mono text-[10px] text-fg-muted"
>
{{ hint.keys }}
</kbd>
{{ hint.label }}
</span>
}
<span
class="ml-auto tabular-nums"
aria-live="polite"
data-testid="gtd-count"
>
{{ store.itemCount() }} {{ store.itemCount() === 1 ? 'item' : 'items' }}
</span>
</footer>

<!-- Clarify dialog -->
@if (store.clarifyTarget(); as target) {
<app-clarify-modal
[item]="target"
[busy]="store.busy()"
(clarified)="store.clarified($event)"
(deferInstead)="store.deferInstead()"
(dropInstead)="store.dropInstead()"
(closed)="store.closeClarify()"
/>
}
155 changes: 155 additions & 0 deletions frontend/src/app/admin/commitment/inbox/inbox.page.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,155 @@
import { TestBed, type ComponentFixture } from '@angular/core/testing';
import { provideHttpClient, withXhr } from '@angular/common/http';
import {
HttpTestingController,
provideHttpClientTesting,
} from '@angular/common/http/testing';
import { InboxPageComponent } from './inbox.page';
import type { TodoRow } from '../../../core/services/todo.service';

// Pins the dedicated Inbox page against the commitment contract: the
// backlog list feeds the inbox view (per_page=200, every live state), the
// capture bar POSTs a raw todo, and an inbox row's Clarify opens the
// clarify dialog. The store spins up its three resources, so the plan /
// history reads are flushed even though the inbox surface never shows them.

const TODOS_URL = '/api/admin/commitment/todos';
const PLAN_URL = '/api/admin/commitment/daily-plan';
const PROJECTS_URL = '/api/admin/commitment/projects';

const backlogRows: TodoRow[] = [
{
id: 'inbox-1',
title: 'Raw capture',
state: 'inbox',
created_by: 'human',
created_at: '2026-06-10T07:00:00Z',
updated_at: '2026-06-10T07:00:00Z',
},
{
id: 'inbox-2',
title: 'Second capture',
state: 'inbox',
created_by: 'hermes',
created_at: '2026-06-10T07:30:00Z',
updated_at: '2026-06-10T07:30:00Z',
},
];

const planFixture = {
date: '2026-06-30',
items: [],
total: 0,
done: 0,
overdue_count: 0,
};

const historyFixture: unknown[] = [];

describe('InboxPageComponent', () => {
let fixture: ComponentFixture<InboxPageComponent>;
let httpMock: HttpTestingController;

afterEach(() => {
httpMock.verify();
TestBed.resetTestingModule();
});

function el(): HTMLElement {
return fixture.nativeElement as HTMLElement;
}

function testid(id: string): HTMLElement | null {
return el().querySelector(`[data-testid="${id}"]`);
}

async function settle(): Promise<void> {
await fixture.whenStable();
fixture.detectChanges();
}

function flushBacklog(): void {
httpMock
.expectOne(
(r) =>
r.url.endsWith(TODOS_URL) &&
r.params.get('per_page') === '200' &&
r.params.get('state') === 'inbox,todo,in_progress,someday',
)
.flush({ data: backlogRows });
}

// Flush the three initial backlog-store loads, then settle (whenStable
// must not be awaited while a request is still open in zoneless mode).
async function render(): Promise<void> {
TestBed.configureTestingModule({
imports: [InboxPageComponent],
providers: [provideHttpClient(withXhr()), provideHttpClientTesting()],
});
httpMock = TestBed.inject(HttpTestingController);
fixture = TestBed.createComponent(InboxPageComponent);
fixture.detectChanges();
flushBacklog();
httpMock
.expectOne((r) => r.url.includes(PLAN_URL))
.flush({ data: planFixture });
httpMock
.expectOne((r) => r.url.endsWith(`${TODOS_URL}/history`))
.flush({ data: historyFixture });
await settle();
}

it('should render the inbox rows with the inbox count when loaded', async () => {
await render();

expect(testid('gtd-row-0')?.textContent).toContain('Raw capture');
expect(testid('gtd-row-1')?.textContent).toContain('Second capture');
expect(testid('gtd-count')?.textContent).toContain('2 items');
});

it('should post the capture to the create endpoint and clear the bar', async () => {
await render();

const input = testid('gtd-capture-input') as HTMLInputElement;
input.value = 'Look into NATS JetStream';
input.dispatchEvent(new Event('input'));
fixture.detectChanges();
(testid('gtd-capture-form') as HTMLFormElement).dispatchEvent(
new Event('submit'),
);

const post = httpMock.expectOne(
(r) => r.url.endsWith(TODOS_URL) && r.method === 'POST',
);
expect(post.request.body).toEqual({ title: 'Look into NATS JetStream' });
post.flush({
data: {
id: 'new-1',
title: 'Look into NATS JetStream',
state: 'inbox',
created_by: 'human',
created_at: '2026-06-10T08:00:00Z',
updated_at: '2026-06-10T08:00:00Z',
},
});
TestBed.tick();
flushBacklog();
await settle();

expect((testid('gtd-capture-input') as HTMLInputElement).value).toBe('');
expect(testid('gtd-list')?.textContent).toContain('Raw capture');
});

it('should open the clarify modal when an inbox row Clarify is clicked', async () => {
await render();

(testid('gtd-row-clarify') as HTMLButtonElement).click();
fixture.detectChanges();
httpMock
.expectOne((r) => r.url.includes(PROJECTS_URL))
.flush({ data: [] });
await settle();

expect(testid('clarify-modal')).toBeTruthy();
});
});
Loading
Loading