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
296 changes: 292 additions & 4 deletions project-docs/specs/project_management_app.md

Large diffs are not rendered by default.

14 changes: 8 additions & 6 deletions project-docs/specs/task_manager_app.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
> - **`expected_by IS NOT NULL` ⇒ a person actually promised this date.** It stands on its own and is deliberately independent of `due_at`, in both directions: a promise for next week keeps an item past my deadline off the overdue list, and a promise for yesterday flags an item whose deadline is still ahead.
>
> Implementation: all four INSERT sites (`items.py` delegate + clarify-delegate, `capture_email.py` ×2, `sync.py` provider pull) write **no** `expected_by` — at each of them the value on offer was literally the item's own due date under another name. `isWaitingOverdue(item)` judges `expectedBy ?? dueAt` (with `waitingLine()` naming *which* fact, so the row reads "promised by …" or "due …" rather than a bare date). A promise is stated explicitly through **`PATCH /tasks/items/{id}` `{expected_by}`** — the same endpoint, auth and ISO/`""`-clears convention as the due-date edit, applied to the item's OPEN `gtd_waiting` row (it is the only field on that patch that lands on another table); the control is the "Promised by" editor in the Waiting-on section of `ItemDetail.tsx`. **No migration** — mig-48 columns, unchanged. **Data note:** rows delegated between the previous deploy and this change carry a snapshot `expected_by`. They keep it and stay judged on it, exactly as if that date had been promised. There is no backfill and no data migration; clearing one is a normal edit ("Promised by" → ✕).
> **Update 2026-08-10 (S2, detail surface docked — owner ruling: "Projects is canonical, Tasks conforms").** `/tasks` was the only app in the product that opened a record as a pop-up; `DESIGN_SYSTEM.md` §6 has specified the house layout as an optional `w-[380px]` desktop side panel (bottom sheet on mobile) all along, and `/projects` implements it. **Desktop list/board views now dock the detail** as a third column — `page.tsx` renders `<aside className="w-[380px] …"><ItemDetail /></aside>` beside `ItemList`, driven by the store's `selectedItemId`, which is the composition `projects/page.tsx` uses for `TaskPanel`. The pane is present only while something is selected (Projects' behaviour) and closes from its own ✕. **`TaskFocusModal` keeps two lives:** the **phone** branch, full-screen, exactly as Projects goes `fixed inset-0` on a phone; and an explicit **maximise** from the docked pane, because the pane is narrower than the modal's `max-w-3xl` and this detail is far denser than Projects' — that is the mitigation for the reading width the dock costs. Two exports that were imported nowhere came back rather than being deleted: `ItemDetail()` (the pane's entry, which restores the INBOX → `ClarifyPanel` branch) and the "Open full page" button, which now has an unfocused mount to live on. `openFocus` stays the app-wide "open this task" verb — on the docked surface the page drops its focus half so the row selects into the pane, and Inbox/Engage/Calendar/Assistant, which have no pane, keep raising the overlay. **Frontend only; no migration, no API change.** Fences, stated honestly: `npx tsc --noEmit`, `npx vitest run` and `npx vitest run src/lib/theme/` all pass, and `sm:rounded-2xl` on the modal sheet became `sm:rounded-lg` (§4 radius) — but **nothing in this tree tests layout, panel counts or mobile branches**, so "the detail is docked" is advisory, held by review and by the Fluent/Material/Graphite pass, which could not be run here (Playwright cannot install in the build environment).
> **v0.2 review pass:** reconciled the GTD "lightweight project" vs "first-class project" framing (§5.1); clarified the delegation-write vs Action-Broker sequencing (§6, Phase 3); pinned the migration (`48_*`, idempotent, FK-dependency apply order — §4); placed the new GTD tools in `skill-task-gtd` over the canonical store and demoted `skill-clickup-sync` to the reference connector (§3.1); matched the gateway route to the `routes/<app>/` package precedent (§8); de-duplicated horizon levels vs projects/items (§4); aligned F1 capture channels with the phasing (Q3); added a build-order summary (§9).
> **Sibling spec:** [`archive/email_ai_assistant.md`](archive/email_ai_assistant.md) — the Task Manager app deliberately mirrors its architecture (multi-panel client + AI assistant + provider abstraction + Postgres sync + automation engine + follow-up tracking). Read it first; this doc reuses its patterns by reference. (Email's living plan is now [`email_app_master_plan.md`](email_app_master_plan.md).)

Expand Down Expand Up @@ -625,10 +626,10 @@ src/app/tasks/
│ ├── ListsSidebar.tsx — Inbox · Next (by @context) · Waiting · Projects · Calendar · Someday · Horizons
│ ├── CaptureBar.tsx — universal quick-add (global hotkey)
│ ├── ItemList.tsx — processed-task views with a List ⇄ Board toggle
│ ├── TaskCard.tsx — rich PM-tool task card (board card + dense row); opens the focus modal
│ ├── TaskCard.tsx — rich PM-tool task card (board card + dense row); opens the task (docked pane on desktop, focus modal on a phone)
│ ├── TaskBoard.tsx — Kanban board (columns by @context / stage / disposition), native HTML5 drag-to-refile → updateItem (back-syncs to ClickUp)
│ ├── ItemDetail.tsx — editable task detail (+ ClickUp back-sync, comments/attachments/subtasks)
│ ├── TaskFocusModal.tsx — full-page focused task view (the "task card pops up" surface)
│ ├── ItemDetail.tsx — editable task detail (+ ClickUp back-sync, comments/attachments/subtasks); `ItemDetail()` is the docked pane's entry, `TaskDetail` the editor both surfaces share
│ ├── TaskFocusModal.tsx — full-page focused task view: the phone's detail surface, and desktop's **maximise** out of the docked pane
│ ├── ClarifyPanel.tsx — GTD decision-tree UI (agent proposal + approve/edit)
│ ├── ProjectPlanner.tsx — natural-planning flow
│ ├── EngageView.tsx — "Now": filter by context/time/energy/priority
Expand Down Expand Up @@ -717,9 +718,10 @@ extended (`_build_item_update`). (2) **ClickUp back-sync** — `provider.update_
(add/rem assignee delta) + `_push_patch_upstream` best-effort back-sync of a SYNCED
task's edits. (3) **Rich ClickUp detail** — `provider.get_task_detail` + `GET
/items/{id}/detail` → comments/attachments/subtasks rendered in the panel.
(4) **Full-page view** — `TaskFocusModal` (store `focusedItemId`/`openFocus`).
(5) **List ⇄ Board** — `TaskCard` (rich card, board + dense-row variants; click →
focus modal) + `TaskBoard` (Kanban; columns by workflow stage [Next] / provider
(4) **Full-page view** — `TaskFocusModal` (store `focusedItemId`/`openFocus`;
since 2026-08-10 the phone's detail surface and desktop's *maximise*, not the
desktop default — see the status header). (5) **List ⇄ Board** — `TaskCard`
(rich card, board + dense-row variants; click → the detail surface) + `TaskBoard` (Kanban; columns by workflow stage [Next] / provider
stage [Waiting/Someday] / disposition; **native HTML5 drag** — no DnD lib);
`ItemList` gained a sticky List/Board toggle (Calendar/Archive stay list-only).

Expand Down
174 changes: 174 additions & 0 deletions tests/unit/test_gtd_retirement_plan.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,174 @@
"""WS-27h — every legacy column has a named destination before the move runs.

Spec: ``project-docs/specs/project_management_app.md`` §7.5.1.

The retirement plan used to say the move was "a copy rather than a translation."
That was true of the seven overlay columns ``pm_task_personal`` mirrors and false
of the other twenty-odd. Executed on that sentence, WS-27h would have deleted the
founder priority matrix, all of timeboxing and the whole Waiting-For view — and
reported success, because a migration that drops a column nobody mapped looks
exactly like a migration that had nothing to map.

So §7.5.1 names a destination for every column, and this file is the fence that
keeps it honest. It fails when somebody adds a column to the legacy store without
saying where it goes — which is the only way the gap could reopen, since the spec
itself is now complete.

Deliberately a spec test, not a schema test: it compares the SQL on disk against
the prose that governs it. Both are checked in, so a drift between them is a
merge-time failure rather than a discovery on migration day.
"""

from __future__ import annotations

import re
from pathlib import Path

import pytest

REPO = Path(__file__).resolve().parents[2]
MIGRATIONS = REPO / "infra" / "postgres"
SPEC = REPO / "project-docs" / "specs" / "project_management_app.md"

#: Columns that exist in the legacy table but are pure plumbing — a destination
#: for them would be noise. `id` is the row's identity and does not travel; the
#: `pm_tasks` row gets its own.
_PLUMBING: frozenset[str] = frozenset({"id"})


def _read(path: Path) -> str:
# Windows defaults to cp1252 and crashes on this tree's em-dashes.
return path.read_text(encoding="utf-8")


def _columns_of(table: str) -> set[str]:
"""Every column `table` has ever gained, across CREATE and ALTER.

Reads the migrations rather than a live database on purpose: this must run
in CI with no Postgres, and the question is about the *plan*, not about what
some box happens to have applied.
"""
columns: set[str] = set()

for path in sorted(MIGRATIONS.glob("*.sql")):
sql = _read(path)

create = re.search(
rf"CREATE TABLE IF NOT EXISTS {table} \((.*?)\n\);", sql, re.S,
)
if create:
for line in create.group(1).splitlines():
bare = line.strip()
if not bare or bare.startswith("--"):
continue
name = re.match(r"([a-z_]+)\s+[A-Z]", bare)
if name:
columns.add(name.group(1))

# `ALTER TABLE t ADD COLUMN IF NOT EXISTS c TYPE` — one statement may
# carry several ADDs separated by commas across lines, so scan the whole
# statement rather than the matching line.
for statement in re.findall(
rf"ALTER TABLE {table}\b(.*?);", sql, re.S,
):
for name in re.findall(
r"ADD COLUMN(?:\s+IF NOT EXISTS)?\s+([a-z_]+)", statement,
):
columns.add(name)

return columns - _PLUMBING


@pytest.fixture(scope="module")
def destination_section() -> str:
"""§7.5.1, the destination table, as text."""
spec = _read(SPEC)
start = spec.find("#### 7.5.1")
assert start != -1, (
"§7.5.1 (the WS-27h destination table) is missing from the spec — it is "
"what stops the retirement from silently deleting features."
)
end = spec.find("\n### ", start)
return spec[start : end if end != -1 else len(spec)]


@pytest.fixture(scope="module")
def named_in_spec(destination_section: str) -> set[str]:
"""Every identifier the destination table mentions in backticks."""
return set(re.findall(r"`([a-z_]+)`", destination_section))


def test_gtd_items_columns_exist_at_all() -> None:
"""Guards the guard: a parser that silently matches nothing would make every
coverage test below pass vacuously."""
columns = _columns_of("gtd_items")
assert len(columns) >= 25, (
f"only parsed {len(columns)} gtd_items columns ({sorted(columns)}) — the "
"migration parser is probably broken, which would make the coverage "
"tests below pass while checking nothing"
)
for expected in ("disposition", "important", "deep_work", "scheduled_start"):
assert expected in columns, f"parser missed a known column: {expected}"


def test_every_gtd_items_column_has_a_named_destination(
named_in_spec: set[str],
) -> None:
missing = sorted(_columns_of("gtd_items") - named_in_spec)
assert not missing, (
f"{len(missing)} gtd_items column(s) have no destination in spec §7.5.1: "
f"{missing}. WS-27h would drop them and report success. Name where each "
"one goes — pm_tasks if the fact is true for everyone on the task, "
"pm_task_personal if two people on it could legitimately differ."
)


def test_every_gtd_waiting_column_has_a_named_destination(
named_in_spec: set[str],
) -> None:
"""The Waiting-For view (WS-18) rests entirely on this table, and it has no
`pm_*` counterpart — the case most likely to be lost quietly."""
missing = sorted(_columns_of("gtd_waiting") - named_in_spec)
assert not missing, (
f"{len(missing)} gtd_waiting column(s) have no destination in §7.5.1: "
f"{missing}. This table IS the Waiting-For view."
)


def test_the_blocked_destinations_are_still_marked_blocked(
destination_section: str,
) -> None:
"""Two rows are not decisions this ticket may take.

`horizon_id` belongs to WS-21, which is DO-NOT-DISPATCH; the provider columns
cannot drop before WS-27g retires the arm that fills them. If either loses its
marker, an executing agent reads the row as settled and acts on it.
"""
assert "horizon_id" in destination_section
horizon_row = next(
line for line in destination_section.splitlines() if "horizon_id" in line
)
assert "WS-21" in horizon_row and "🔴" in horizon_row, (
"the horizon_id row lost its blocked marker or its owner — it reads as a "
f"decision this ticket may take, which it is not: {horizon_row}"
)

provider_row = next(
line for line in destination_section.splitlines()
if "provider_task_id" in line
)
assert "WS-27g" in provider_row, (
"the provider-column row no longer names WS-27g — the sequencing "
f"constraint is what stops SourceBadge losing its data early: {provider_row}"
)


def test_the_new_schema_is_still_flagged_as_new(destination_section: str) -> None:
"""The columns that do not exist yet are the whole point of §7.5.1. If the
NEW markers go, the section reads as "all destinations already exist" and the
schema step disappears from the ticket."""
assert destination_section.count("**NEW") >= 4, (
"§7.5.1 has lost its 🔴 NEW markers — the twelve new pm_task_personal "
"columns, the pm_tasks flag and the pm_task_waiting table are what make "
"WS-27h a schema change and not just a data move"
)
Loading
Loading