Skip to content

feat: Linear clone showcase app (M0–M2) — workspace, auth, issues, projects, cycles - #4

Open
croto-bot wants to merge 28 commits into
code-healthfrom
feat/linear-clone
Open

feat: Linear clone showcase app (M0–M2) — workspace, auth, issues, projects, cycles#4
croto-bot wants to merge 28 commits into
code-healthfrom
feat/linear-clone

Conversation

@croto-bot

Copy link
Copy Markdown
Collaborator

What this is

This branch turns the fullstack template into a real, production-grade Linear clone — a showcase app that proves the template (FastAPI + TanStack Router/Query + React 19 + Postgres 18) can ship a fast, polished, complex product. The old items example is gone; template/backend + template/frontend are the Linear app now.

It is built directly inside template/ on branch feat/linear-clone, branched from code-health.

What's implemented (Milestones M0–M2)

✅ M0 — Foundation & Workspace

  • Removed the items module (backend routers/tests + all frontend item routes/components/hooks).
  • Teams module: Team (id, name, key, issue_sequence counter) + TeamMembership (user↔team, role admin/member/guest). A default team + admin membership is auto-created on registration.
  • App shell: Linear-style Sidebar (team identity + nav: Issues / Board / Projects / Cycles / Views), Topbar (command/search trigger, theme toggle, user menu with logout), replaces the old Navigation component everywhere.
  • Auth flows: register/login/logout, Bearer token in localStorage, redirect-after-login preserving deep links, session persists across reload, 401 → logout + redirect, full Zod form validation (email format, password strength, empty fields, duplicate email).
  • Dark mode: light/dark/system toggle persisted in localStorage, applied before first paint (no flash), survives reload + logout/login.
  • Shared loading-skeleton, empty-state, and graceful-error patterns for all async surfaces. Fixed the pre-existing tsc config drift in vite.config.ts.

✅ M1 — Issues Core

  • WorkflowState module (canonical 5 states: backlog/unstarted/started/completed/canceled) + Label module + IssueLabel association. Default 5 states seeded per team.
  • Issues module — the hub: Issue with auto-generated team-scoped identifier (TEAM-NN, atomic concurrency-safe monotonic increment), status/priority/assignee/creator/parent/labels, filter + search + sort + pagination, labels eager-loaded (no N+1).
  • Frontend: issues list grouped by workflow status in canonical order with accurate per-group counts; create-issue dialog (validated, optimistic insert + rollback); issue detail drawer with inline editing (title/description/status/priority/assignee/label pickers, optimistic + rollback); delete with confirm.
  • Team scoping & roles: data team-scoped end-to-end; direct API calls without a token return 401; invalid token forces logout; cross-team data blocked (403/404); guest blocked from writes, member blocked from admin actions; sidebar team switcher.
  • Polling (refetchInterval) + skeleton loaders + empty states established here and reused.

✅ M2 — Projects & Cycles

  • Projects module (name, non-terminal default status, lead, target date) + CRUD + frontend: projects list (empty state + CTA), project detail listing only its issues, create dialog, ProjectPicker in the issue drawer with cross-surface badges.
  • Cycles module (name, starts_at/ends_at, end > start enforced) + CRUD + frontend: cycles list, cycle detail with progress (X of Y done from terminal statuses), create dialog, CyclePicker in the issue drawer. An issue belongs to at most one cycle (reassign moves it).

Architecture

template/
├── backend/   FastAPI · async SQLAlchemy 2 · Alembic · Pydantic 2 · argon2
└── frontend/  TanStack Router · React Query · React 19 · Tailwind v4 · shadcn/ui · react-hook-form/Zod

Backend — strict 3-layer (Router → Service → Repository) reusing the template's BaseService[T] + SQLAlchemyRepository[T], fastapi_filter + fastapi_pagination, session/Bearer auth. New entities live in app/modules/{teams,workflows,labels,issues,projects,cycles}/. Issue is the hub with auto identifier, team scoping, and selectinload for labels.

Frontend — Linear app shell; routes under TanStack file-based router; data via React Query with staleTime + short refetchInterval polling and optimistic updates on every mutation.

Backend modules:  teams · workflows · labels · issues · projects · cycles (+ reused user/auth)
Frontend routes:  /login · /register · /_authed/workspace · /<team>/{issues,board,projects,cycles,views,settings}
                   · /<team>/project/$id · /<team>/cycle/$id

How to run it

Ports: DB 5433, backend 9095, frontend 3000 (set via DB_PORT to avoid clashing with any existing local Postgres on 5432).

Prerequisites

Python 3.12+, uv, bun (or npm), Docker.

1. Start Postgres

cd template
DB_PORT=5433 docker compose up -d db
# wait until healthy
docker compose exec -T db pg_isready -U app

2. Configure backend env

Create template/backend/.env:

ENVIRONMENT=local
DEBUG_MODE=true
DB_URL=postgresql://app:app@localhost:5433/app
FRONTEND_URL=http://localhost:3000
AI_PROVIDER=mock

(Local dev only — credentials app/app. The app normalizes postgres://postgresql+asyncpg:// automatically.)

3. Install & migrate the backend

cd template/backend
uv sync
DB_PORT=5433 uv run alembic upgrade head

4. Run the backend

cd template/backend
DB_PORT=5433 uv run uvicorn --factory app.main:create_app --host 0.0.0.0 --port 9095
# health check → curl http://localhost:9095/health  (returns "FastAPI running!")
# API docs     → http://localhost:9095/docs

⚠️ This app uses a factory — must use --factory app.main:create_app, NOT app.main:app.

5. Install & run the frontend

cd template/frontend
bun install
bun run dev          # serves on http://localhost:3000

6. Use it

Open http://localhost:3000, Register a new user (any deliverable email + a password ≥ 8 chars), and you'll land in your auto-created default-team workspace. Create issues, set status/priority/assignee/labels, open the detail drawer, create a project and a cycle, and assign issues to them.

Note: the backend's Pydantic EmailStr rejects the reserved .test TLD — use a deliverable-looking domain (e.g. you@example.com).

How to test / validate

# Backend
cd template/backend && uv run pytest tests/ -q          # tests
cd template/backend && uv run ruff check app             # lint (must be clean)

# Frontend
cd template/frontend && bun run test                     # vitest
cd template/frontend && bunx tsc --noEmit                # typecheck (must be clean)
cd template/frontend && bun run lint                     # eslint

End-to-end the app is exercised through the browser SPA (Playwright/agent-browser) against the real backend + Postgres — register → create issues → organize → assign to projects/cycles. (Backend ty check is advisory-only on this template — pre-existing infra tech debt; do not block on it.)

What's left (M3–M5)

  • M3 — Board (kanban drag-and-drop), saved Views, Sub-issues, Dependencies.
  • M4 — Comments, Activity feed, Cmd+K command palette, keyboard shortcuts.
  • M5 — Performance/polish pass + update this README to document the showcase app.

Notes

  • All npm/uv dependencies follow the repo's supply-chain policy (exact pins, ≥7 days old, no open CVEs).
  • Conventional Commits throughout; one commit per logical feature.
  • Built and validated against the real FastAPI + Postgres 18.1 (no mocks).

Croto Bot and others added 28 commits August 6, 2026 14:15
M0 foundation: replace the items example backend with the teams module
that all later Linear domain modules depend on.

Removed:
- app/modules/items/ entirely (model, repo, service, router, schemas,
  filters, dependencies, __init__)
- All items tests (model, service, router, filter, repository, schema)
- app/routers.py items import/registration
- items model import from migration env.py

Added (app/modules/teams/):
- Team model (id, name, key prefix, issue_sequence counter, TimestampMixin)
- TeamMembership model (user_id, team_id, role enum {admin,member,guest})
  with unique constraint on (user_id, team_id)
- Full CRUD: schemas, repository, service (team-scoped), filters,
  dependencies, router (POST/GET/PATCH/DELETE, auth-guarded)
- Team-scoping helpers: get_team_ids_for_user, get_teams_for_user
  (used by all later modules to scope queries)

Register hook:
- AuthService.register now creates a default Team + admin TeamMembership
  for the new user via an injected TeamService (defaults to None so
  existing tests are unaffected)

Migration e0224f807aee: creates team + team_membership tables (with
team_role enum), drops the legacy item table.

Tests: 610 passed (removed 6 items test files, added 6 team test files,
updated infrastructure tests that referenced items to use Team model).

Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>
Add the auth foundation that makes all VAL-AUTH assertions testable against
the new Linear shell. Backend auth (register/login/logout, duplicate-email
400, malformed-email 422, 401 on wrong/unknown/expired credentials, default
team on register) was verified correct and is unchanged.

Frontend additions:
- `_authed` pathless layout with a client-aware beforeLoad guard
  (redirects unauthenticated users to /login, preserving the deep link as a
  `redirect` search param) plus a useEffect fallback for hard-navigation /
  reload. The guard is SSR-safe: the token lives in localStorage, so it is
  skipped during server render and enforced after hydration — this keeps
  sessions persistent across reload (VAL-AUTH-013) while still gating routes.
- `/workspace` authenticated landing (under the guard) that fetches
  /users/me to prove the token and provide a 401 surface. Minimal skeleton
  for the m0-app-shell feature to extend.
- Root index redirects authenticated users to /workspace (beforeLoad + useEffect).
- login/register default redirects to /workspace (deep-link param honored).
- useUser hook, User type, USERS.ME endpoint + query key.

All 16 VAL-AUTH behaviors verified end-to-end in agent-browser; backend
(610) + frontend (239) tests green; eslint/ruff clean; tsc clean in src/.

Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>
M0 WORKSPACE SHELL: replaces the old template Navigation/Layout and items
frontend with a Linear-style workspace shell.

Shell components:
- Sidebar: team identity (name + key), primary nav (Issues/Board/Projects/
  Cycles/Views), saved-views area
- Topbar: search/command trigger placeholder (⌘K, full palette is M4),
  theme toggle (Light/Dark/System), user menu with logout + settings
- AppShell: composes Sidebar + Topbar around the routed page, reading the
  $team param for team-scoped navigation

Theme system:
- lib/theme.ts: light/dark/system modes, persisted in localStorage,
  applied before first paint via inline head script (no flash)
- app.css: proper light :root + dark .dark override (was dark-only)
- RootDocument useEffect re-applies theme after React hydration
- Survives reload and logout/login (preference is auth-independent)

Shared async-state patterns:
- LoadingSkeleton, EmptyState, ErrorState — used by all later async surfaces
- Skeleton shadcn primitive + lightweight accessible DropdownMenu (no new deps)

Routing:
- index → /workspace → /$team/issues redirect chain for authed users
- Team-scoped routes: /$team/{issues,board,projects,cycles,views,settings}
- Each renders an intentional empty state for M0

Data layer:
- types/team.ts, hooks/useTeams.ts (GET /teams), lib/api-endpoints TEAMS,
  query-keys teams namespace

tsc config drift resolved (Known Pre-Existing Issue):
- vite.config.ts: removed test block (vitest config lives in vitest.config.ts)
- vitest.config.ts: uses vitest/config defineConfig, removed conflicting
  @vitejs/plugin-react (esbuild handles JSX via tsconfig)
- tsconfig.json: added vitest/globals to types
- bunx tsc --noEmit now passes with ZERO errors

Removed all items frontend:
- routes/items.tsx, components/{Create,Edit,Delete,ItemForm}Dialog,
  hooks/useItems, features/items, types/item, and their tests
- components/Navigation.tsx + Layout.tsx (replaced by Sidebar + Topbar)

Tests: 224 FE pass (40 new), 610 BE pass, tsc/eslint/ruff clean.
Verified end-to-end via agent-browser: register → workspace shell, sidebar
nav, theme toggle + persistence, user menu logout, empty states.

Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>
Verify the two critical routing pieces that make first-visit onboarding
work (VAL-CROSS-001, VAL-CROSS-004):

- _authed/workspace: resolves default team via useTeams/pickDefaultTeam
  and navigates to /$team/issues; shows skeleton while loading, error
  state on failure, and no-workspace state when teams are empty.
- _authed/$team/: redirects bare team URL to /$team/issues.

6 new tests; full suite 230 pass, tsc + eslint clean.

Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>
VAL-WORKSPACE-005: the _authed layout rendered the full AppShell during SSR,
but the server cannot read the localStorage auth token, so client hydration
resolved auth/data state differently and React warned "Hydration failed
because the server rendered HTML didn't match the client" on every
authenticated hard-reload of a protected route.

Gate the authenticated shell behind a useIsHydrated() mounted check: during
SSR and the matching first client paint we render a neutral, client-state-free
AppShellSkeleton, so server HTML and the initial client render are
byte-identical. After hydration the real AppShell (and its data queries)
renders. The existing auth redirect (useEffect) is preserved, so
VAL-AUTH-013 (session persists across reload) and VAL-AUTH-014 (protected
route redirect when unauthenticated) still pass.

- add useIsHydrated hook (false until mounted) + tests
- add AppShellSkeleton (deterministic loading shell) + tests
- add _authed layout hydration gate + tests
- verified via agent-browser: zero hydration/mismatch/error console output
  on authenticated hard-reload of /HYDR/issues

Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>
M1 foundation: WorkflowState and Label backend modules, team-scoped and
auth-guarded, plus the issue_label association table.

- workflows module: WorkflowState (team_id, name, type enum
  {backlog,unstarted,started,completed,canceled}, position, color) with full
  CRUD (Router -> Service -> Repository), filters (default order_by position),
  and the workflow_state_type enum.
- labels module: Label (team_id, name, color) with full CRUD + filters, and the
  issue_label M2M association table (composite PK issue_id/label_id).
- Seeding: every new team is seeded with the 5 canonical WorkflowStates
  (Backlog/Todo/In Progress/Done/Canceled) on creation, wired through the
  default-team-on-register path. TeamService gains an optional
  workflow_state_repo + a public require_team_access() scoping helper reused by
  both new services.
- Migration adds workflow_state, label, issue_label tables.

Coordination note for m1-issue-backend: issue_label.issue_id is a plain UUID
column with no DB FK yet (issue table doesn't exist). m1-issue-backend must add
ForeignKey("issue.id", ondelete="CASCADE") to the column and create the
issue_label_issue_id_issue_fkey constraint after creating the issue table.

Workflows/labels package __init__ files are intentionally lightweight (no eager
router imports) to avoid a module-load circular dependency with teams; the
workflows imports in TeamService are deferred to runtime for the same reason.

Backend tests: 652 passed (was 610); ruff clean. Verified end-to-end: register
creates exactly one team with the 5 canonical workflow states (correct
types/positions/colors); label CRUD + unique-per-team constraint enforced;
unauthenticated requests rejected.

Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>
…CRUD, labels

Add the Issue entity (hub of the data model) with full CRUD:
- Issue model: team_id, auto-generated identifier (TEAM-NN), title, description,
  status_id (FK WorkflowState), priority (0-4), assignee_id, creator_id,
  project_id/cycle_id (nullable, M2), parent_id (self-ref, M3), sort_order,
  estimate, due_date, TimestampMixin
- Concurrency-safe identifier allocation via atomic UPDATE...RETURNING on
  Team.issue_sequence (row-level lock serialises concurrent creates)
- Labels eager-loaded via selectinload (no N+1)
- Labels sub-resource: POST/DELETE /issues/{id}/labels/{label_id}
- Default status falls back to team's first workflow state (by position)
- Whitespace-only title validation, priority range (0-4) enforcement
- IssueFilter: search (title ilike), status/priority/assignee filters, sort
- Wired deferred issue_label.issue_id → issue.id FK constraint
- Migration excludes pre-existing user-table drift
- Sort via explicit Query(order_by) param (FastAPI doesn't parse list[str]
  filter fields via Depends())

37 new tests (21 service + 16 router); 689 total passing; ruff clean.
Verified end-to-end via live API smoke test (register → create → filter →
sort → update → label add/remove → delete → no-id-reuse).

Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>
Build the team-scoped Issues list grouped by workflow status in canonical
order (by position) with accurate per-group counts, and the create-issue
dialog with validated title (required, whitespace/length), optional
multiline description, and documented defaults (first status, no priority,
unassigned, no labels). All five workflow status types are available in
the status picker.

New issues appear optimistically before the POST resolves (placeholder
identifier) and roll back on error with a toast; the dialog closes and
resets to defaults on success. Establishes the reusable patterns for later
features: polling (refetchInterval 8s), skeleton loader, empty state, and
controlled Status/Priority/Assignee/Label pickers.

- types: issue, workflow-state, label
- hooks: useIssues (poll + optimistic create + rollback), useWorkflowStates,
  useLabels
- components: CreateIssueDialog, IssueList (+ grouping/counts), IssueRow,
  IssueListSkeleton, StatusDot, PriorityIcon, and the four pickers
- zod schema for the create form (title trim/min/max, optional description)
- route $team/issues wires data, loading/empty/error states, create CTA

All gates green: FE 285 tests / tsc / eslint clean; BE 689 tests unchanged.
Verified end-to-end in agent-browser (register → empty state → create
UIBR-1/2/3 with monotonic identifiers, grouped list + counts, polling syncs
external change, dialog resets on reopen).

Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>
Build the issues filter bar (status, priority, assignee incl. Unassigned,
label), combined filters with AND semantics, clear/reset, debounced
case-insensitive title search, and sort by created (default newest-first)/
updated/priority. Long lists paginate via "load more" (useInfiniteQuery),
keeping the visible total == backend total. Filter/search no-results shows
a distinct empty state with a clear-filters action.

All filters/search/sort are applied server-side. The hook moves from a single
useQuery to useInfiniteQuery; the create mutation's optimistic insert is
updated for the infinite-query cache shape (prepends only to the default
unfiltered view; filtered views reconcile via invalidation).

Backend: add an `unassigned` query param to GET /issues (assignee_id IS NULL)
so the "Unassigned" assignee filter works (VAL-ISSUES-021).

Tests: backend (unassigned passthrough + service), frontend (useIssues
infinite/params/create, IssueFiltersBar, issue type helpers). All gates
green (BE 692, FE 315, tsc/eslint/ruff clean).

Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>
Add the issue detail drawer that opens on row click, showing all fields
(identifier, title, description, status/priority/assignee/labels pickers,
timestamps, creator) with a skeleton layout while the issue GET is in flight.

Inline editing with optimistic updates that propagate instantly across the
drawer, list rows, status grouping, and per-group counts, rolling back on
backend error:
- title (click/Enter to edit), multiline description (edit + Save)
- StatusPicker / PriorityPicker / AssigneePicker / LabelPicker
- status change re-groups the row and updates source/target counts
- completed/canceled rows visually distinguished (strikethrough/muted)
- delete with a confirm guard; optimistic removal + rollback

New hooks: useIssue (detail), useUpdateIssue, useDeleteIssue,
useAddIssueLabel, useRemoveIssueLabel. Each patches both the detail and all
list cache variants onMutate, restores snapshots onError, and invalidates
onSettled so drawer/list/counts stay consistent with no full page reload.

Fixes a pre-existing template bug: Button declared `asChild` but never
implemented it, producing nested buttons in AlertDialog Action/Cancel. Button
now supports asChild via @radix-ui/react-slot (added as a direct dep at the
version already resolved transitively, 1.2.3, no known CVEs, >=7 days old).

Verified end-to-end in agent-browser against the live backend (open drawer,
edit title/description/status/priority/assignee/labels, regroup + count
update, terminal strikethrough, delete confirm + removal).

Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>
Claims the cross-area auth/scoping assertions (VAL-CROSS-009/022/023/024/025):

Backend:
- Add ForbiddenError (403); TeamService.require_team_access now distinguishes
  role-based denial (member present, insufficient role → 403) from cross-team
  isolation (not a member → 404, no existence leak).
- Enrich GET /teams list items with my_role (the requesting user's per-team
  role) so the SPA can gate UI without a second request.
- Custom _Bearer401 maps FastAPI's missing-credential 403 → 401 so all
  team-scoped endpoints return 401 without a token (was 403).
- Tests for 403-vs-404, role map, and my_role; tightened no-token guards to 401.

Frontend:
- Sidebar team switcher (dropdown listing the user's teams; switching re-scopes
  to the selected team's issues — no cross-team leak).
- useTeamRole + useTeamAccessGuard hooks; guest write-blocking (New issue
  disabled + read-only empty state), admin-only workflow-states management
  surface in Settings (reads for all, Add state gated to admins).
- Cross-team URL guard redirects a foreign team segment to the default team.

Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>
…alidation

Blocking security defect (VAL-CROSS-025): IssueService.add_label/
remove_label enforced team membership but not the member role, so a
guest-role user could add/remove labels (200 instead of 403). Add
require_team_access(min_role=TeamRole.member) to both methods, mirroring
update()/delete().

Defense-in-depth cross-team field validation: validate that a referenced
label/status/assignee belongs to the issue's team in create/update/
add_label/attach_labels, so a member cannot attach a foreign-team entity
by UUID. LabelRepository is now injected into IssueService.

Frontend: useRemoveIssueLabel no longer fabricates an invalid Issue via
`as unknown as Issue` — generalized ApiClient.delete to delete<T> (void
by default; parses the JSON body for endpoints that return one, e.g. the
label sub-resource DELETE).

Tests: add guest-403 cases for add/remove label, cross-team field
validation rejection cases for create/update/add_label, and a delete<T>
JSON-parsing test.

Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>
…cross-surface badges (M2)

Backend:
- Project model (team_id, name, status non-terminal default 'planned',
  lead_id nullable FK, target_date nullable, TimestampMixin) + ProjectStatus enum
- Full CRUD service with team scoping, member-role enforcement, and lead
  cross-team validation (defense-in-depth, 404 on foreign reference)
- Migration creating the project table + wiring the deferred
  issue.project_id → project.id FK (SET NULL on delete)
- Issue integration: project_id added to IssueUpdate schema + IssueFilter
  (for project-detail issue filtering), cross-team project validation in
  IssueService.update

Frontend:
- Projects list route with empty state + CTA (VAL-PROJECTS-005)
- Create-project dialog (name required, status picker, lead picker,
  target date formatted/optional) (VAL-PROJECTS-001–004)
- Project detail route listing only that project's issues (VAL-PROJECTS-006)
- ProjectPicker in the issue drawer to assign/remove an issue to/from a
  project (VAL-PROJECTS-007, 008)
- Project badge on issue row, list, and drawer everywhere (VAL-PROJECTS-009)
- useProjects hooks with optimistic create/update/delete + polling

Tests: 736 backend (24 new project + 4 issue project-validation), 371
frontend (24 new). ruff/tsc/eslint clean. Verified end-to-end in
agent-browser (create, assign, badge, project detail, unassign).

Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>
…ress (M2)

Backend:
- Cycle model (team_id, name, starts_at, ends_at, completed_at nullable,
  TimestampMixin) with end>start enforced at schema (422) and service layers
- Full CRUD module (models/schemas/repository/service/filters/dependencies/routers)
  registered in app/routers.py; migration 8d11d0d28d6f creates cycle table +
  wires issue.cycle_id FK (SET NULL)
- Issue model: cycle_id now FK to cycle.id; IssueService validates cycle
  belongs to issue's team (cross-team defense-in-depth, 404)
- IssueFilter supports cycle_id; IssueCreate/IssueUpdate accept cycle_id
- 25 new backend tests (cycle service + issue cycle assignment validation)

Frontend:
- useCycles hooks (list/detail/create/update/delete/lookup) with polling
- Cycles list route with empty state, date window, past/active/upcoming phase
  badges; create-cycle dialog (name+start+end required, end>start Zod validated)
- Cycle detail route listing only its issues with progress bar (X of Y done
  derived from terminal statuses)
- CyclePicker in issue drawer for assign/remove (reassign moves; at most one
  cycle per issue); cycle badge in drawer
- cycle_id support in IssuesQueryParams and UpdateIssueInput

Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>
…PROJECTS-002)

VAL-PROJECTS-002 failed because no route/component triggered the PATCH
endpoint. This adds the missing edit surface:

- ProjectFormDialog now accepts an optional `project` prop for edit mode.
  When provided, it pre-fills name/status/lead/target_date and submits via
  useUpdateProject (PATCH) instead of useCreateProject (POST). The dialog
  title, description, and submit label reflect edit mode.
- Project detail page header gets an Edit button (role-gated via canWrite)
  that opens ProjectFormDialog in edit mode.
- useUpdateProject now uses proper optimistic updates (onMutate patches
  detail + list caches; onError rolls back) matching the useUpdateIssue
  pattern, so edits reflect instantly on detail + list and reconcile on
  settle.
- Fixed DropdownMenuItem rendering a <button> without type="button", which
  caused premature form submission when selecting status/assignee/label
  options inside any form.

Verified end-to-end in agent-browser: edit status→In Progress, set lead,
set target date; changes persisted across hard reload on both detail and
list. Frontend vitest (413), tsc, eslint all clean.

Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>
…oard toggle (M3)

Build the Board view with one column per workflow status (ordered by
position), cards showing identifier/title/priority/assignee/labels, and
native HTML5 drag-and-drop between columns to change status (optimistic +
persisted PATCH via useUpdateIssue; rollback on failure). The non-DnD
move path uses the detail drawer's status picker.

Filters now live in URL search params (shared validateSearch on both
routes) so the list↔board toggle preserves scope + filters bidirectionally.
The board reflects active filters, empty columns are valid drop targets,
and board↔list↔detail status consistency is maintained via shared React
Query cache + polling.

New components: IssueBoard, IssueCard, ViewToggle, IssueBoardSkeleton.
New lib: issue-search (URL search validation). New hook: useIssueFilters.
Refactored issues.tsx to use URL search params + ViewToggle.

No new dependencies (native HTML5 DnD used instead of a drag library).

Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>
…lete (M3)

Add the `views` backend module (View: owner_id, team_id, name, filters JSONB,
group_by, order_by) + team-scoped CRUD + migration. Frontend: save the current
view (filters+group_by+sort, name required) via SaveViewDialog, saved-views
list in the sidebar (empty state), switching to a saved view applies its full
config (active view highlighted), delete a saved view (confirm guard), and
dirty/modified local changes do not silently overwrite the saved view.

Backend: app/modules/views/{models,schemas,repository,service,filters,
dependencies,routers}.py + migration 2026_08_07_add_views; registered in
app/routers.py. Service is team-scoped (member-write), injects owner_id from
the authenticated user, and exposes PATCH only for explicit re-saves.

Frontend: src/types/view.ts, src/lib/view-config.ts (save/apply/match/dirty
serialization over the shared issue URL search params), src/hooks/useViews.ts
(list/create/update/delete), src/components/SaveViewDialog.tsx +
SavedViews.tsx, "Save view" button on Issues + Board headers, and the Views
page now lists saved views with config summaries + empty state.

Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>
…(M3)

Backend:
- Add parent_id and top_level query params to issue list endpoint for
  fetching children by parent or top-level issues only
- Validate parent_id on create/update: same-team check, self-reference
  guard, and circular-dependency detection (walks parent ancestry)
- ondelete=SET NULL on parent_id FK promotes children to top-level when
  a parent is deleted (VAL-CROSS-015)
- 11 new unit tests (TestSubIssues) covering validation, cycle guard,
  filtering; router tests for parent_id/top_level params

Frontend:
- SubIssuesPanel: list children with aggregate progress (count + bar),
  add new sub-issue (inline title), link existing (dropdown picker),
  detach child (PATCH parent_id=null), click child to open its drawer
- IssueList: nest children under parent with expand/collapse + child
  count badge; top-level issues only in status groups (children excluded)
- IssueRow: indent support for nested sub-issues
- useIssues: useSubIssues + useTopLevelIssues hooks; extend
  useUpdateIssue/useCreateIssue for parent_id; invalidate subIssues cache
- IssueDetailDrawer: integrate SubIssuesPanel, onSelectIssue for children
- 18 new frontend tests (SubIssuesPanel + IssueList nesting)

All backend (801) and frontend (484) tests green; ruff/eslint/tsc clean.

Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>
…le guard (M3)

Add an issue-dependencies concept: IssueDependency (blocker_id, blocked_id,
relation='blocks') with a compound unique constraint on the pair, team-scoped
CRUD, and a circular-dependency guard.

Backend (app/modules/issue_dependencies/):
- Model with CASCADE FKs to issue on both endpoints + unique pair constraint
- Service enforces same-team endpoints (404 on cross-team, no leak), member
  role on writes, rejects self-dependency (422) and cycle-forming edges (409)
  via a bounded DFS over the team's existing edges
- GET/POST/DELETE /issue-dependencies with ?issue_id= for reciprocal listing
- Alembic migration (excludes pre-existing user/project drift)

Frontend:
- useIssueDependencies hooks (list/create/delete + splitDependencies helper)
  with optimistic invalidation of both endpoints' caches
- DependenciesPanel in the issue drawer: reciprocal 'Blocking' / 'Blocked by'
  sections, add via same-team picker, delete from either side removes from both
- Picker offers only same-team issues (excludes current + already-linked)

Verified end-to-end in agent-browser: mark A blocks B from the drawer,
reciprocal display on both issues, cycle rejected (409), delete removes from
both, picker team-scoped. Backend 825 / frontend 497 tests green; ruff/eslint/
tsc clean.

Fulfills VAL-DEPS-001..005, VAL-CROSS-013.

Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>
Backend: app/modules/comments/ (Comment: issue_id, author_id, body,
TimestampMixin) following the 3-layer pattern. Service is team-scoped via the
issue's team, requires member role to create, rejects empty/whitespace bodies
(422), and restricts edit/delete to the author or a team admin (403 otherwise)
— author eager-loaded to avoid N+1. CRUD router at /comments (list supports
issue_id filter, newest-first default). Migration d9ead1664eef adds the comment
table (FKs to issue + user, CASCADE; excludes pre-existing user/project drift).

Frontend: CommentsThread on the issue drawer — newest-first list with author
(initials avatar + name) and human-readable timestamp, explicit empty state,
composer that blocks empty/whitespace, edit-in-place + delete with confirm.
useComments hooks use optimistic create/update/delete (cache patch + rollback +
settle invalidation) so comments appear/persist without a full reload. Role
resolved from the teams list (by team id); guests are read-only.

Tests: 29 backend (service + router, team scoping/ownership/empty-body) and 18
frontend (hook optimistic behavior + component author/admin gating, empty
state, edit-in-place, delete-confirm). All gates green: 854 BE / 515 FE,
ruff/tsc/eslint clean. Verified end-to-end in agent-browser (add → newest-first
→ author+time → edit in place → delete → persist across reload).

Fulfills VAL-COMMENTS-001..008.

Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>
…d (M4)

Add the `activity` backend module (Activity: issue_id, actor_id, type,
payload JSONB, TimestampMixin) following the comments 3-layer pattern.
Entries are auto-generated by IssueService on qualifying issue mutations
and exposed read-only (no create/update/delete endpoints):

- status change (from→to), assignee change, priority change, title rename
- label added / label removed (via add_label/remove_label sub-resource)

Activity generation is best-effort: the issue mutation commits first, and a
failure recording activity is logged + swallowed so it never surfaces as a
failed mutation. Each entry eager-loads the actor (ActorBrief) and carries
a structured payload with resolved names (status/assignee/label).

Frontend: ActivityFeed in the issue drawer showing actor + type + time,
newest-first; polls every 8s and is invalidated on issue mutations so it
updates within the polling window without a manual reload. Read-only (no
composer). Extracts a shared lib/format-time util reused by comments.

Backend 884 tests, frontend 534 tests, ruff/eslint/tsc clean. Verified
end-to-end in agent-browser (status + priority changes appear in the feed
with actor + description + time, auto-updated).

Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>
…ns (M4)

Build the workspace command palette (shadcn command/cmdk):
- Cmd+K / Ctrl+K global shortcut + topbar trigger open the palette
  (VAL-CMDK-001, VAL-CMDK-002)
- cmdk fuzzy search narrows navigation targets + quick actions as you type
  (VAL-CMDK-003); empty query shows the default command set (VAL-CMDK-007);
  no-match shows an explicit "No results" state (VAL-CMDK-008)
- Selecting a nav target (Issues/Board/Projects/Cycles/Views) closes the
  palette and routes without a full reload (VAL-CMDK-004)
- Selecting Create Issue/Project/Cycle closes the palette and opens the
  create dialog, hosted at the AppShell level so it works from any route
  (VAL-CMDK-005); actions gated by canWrite so guests can't trigger writes
- Esc closes the palette without side effects via the underlying Radix
  Dialog (VAL-CMDK-006)

Adds: ui/command.tsx (shadcn command primitive, accessible DialogTitle/
Description), CommandPalette + CommandPaletteProvider, wired into AppShell;
SearchTrigger now opens the palette instead of the placeholder toast.

New dep: cmdk@1.1.1 (latest stable, ~9 months old, no known critical/high
CVEs; React as peer dep only — no bundled React copy). Per supply-chain
policy: exact-pinned, lockfile committed together.

Tests: 19 new (palette behavior + context state + trigger wiring) + jsdom
polyfills (ResizeObserver, scrollIntoView) for cmdk. Full FE gate green
(550 tests, tsc, eslint).

Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>
…nce (M4)

Add a KeyboardShortcutsProvider owning the M4 global shortcut set:
- c opens Create Issue; g then i navigates to Issues; [ / ] move the
  open detail drawer to the prev/next issue; ? opens the shortcuts help;
  Esc closes the topmost overlay (via the existing Radix Dialog/Sheet
  layer); Cmd+K toggles the palette (unchanged, in CommandPaletteContext).
- Single-key shortcuts are suppressed while typing in inputs/textarea/
  select/contenteditable, and while a registered modal (palette, help) is
  open. The issue drawer is intentionally unregistered so c/[/] keep working.
- Issues + Board routes publish their flattened issue order for [ / ] nav.

Surface shortcut hints in the UI: a reusable Kbd badge shows "G I" on the
sidebar Issues entry, "C" on the New issue buttons, and the palette already
shows "C"/"⌘K". Add a ShortcutsHelpDialog reachable via the topbar keyboard
button, the palette "Keyboard shortcuts" command, and the ? shortcut.

Tests: keyboard helpers (8), KeyboardShortcutsContext (11), ShortcutsHelpDialog
(2); updated CommandPalette test harness for the new provider. tsc/eslint
clean; full frontend suite green (571).

Fulfills VAL-SHORTCUTS-001..007.

Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>
KeyboardShortcutsContext.onKeyDown had no modifier-key guard, so Cmd+C /
Ctrl+C (copy outside a text input) matched the `c` branch and spuriously
opened Create Issue + preventDefault, blocking native copy. The same
class affected Cmd+G (armed the g-sequence) and Cmd+[ / Cmd+] (hijacked
browser back/forward).

Add `if (event.metaKey || event.ctrlKey || event.altKey) return;` after
the Escape branch and before the single-key dispatch. Add tests asserting
Cmd/Ctrl/Alt + c does not open Create Issue or preventDefault, Cmd/Ctrl+G
does not arm the g-sequence, and Cmd+[ / Cmd+] do not navigate issues.

Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>
M5 final polish pass across the showcase app:

- Board "Load more": the kanban board shared the Issues list's server-side
  pagination but rendered no affordance, silently capping at the first page
  (50 issues). Add a "Load more" bar mirroring the list so hundreds of issues
  are reachable on the board (VAL-PERF-012, VAL-BOARD-001).
- Preserve unauth deep-link redirect target: useTeamAccessGuard fired for
  unauthenticated users (empty teams list → "foreign" team), mutating the URL
  to /issues before the _authed auth guard captured the original path, so the
  login redirect target was lost. Guard now no-ops when unauthenticated,
  letting the auth guard preserve the intended route (VAL-AUTH-016,
  VAL-CROSS-008). Added a regression test.
- Rewrite template/README.md to document the Linear clone showcase app
  (features, stack, quick start, structure, API, testing) — replacing the
  stale template/items boilerplate.

Verified end-to-end in the browser against the real backend + Postgres:
empty states on all list surfaces, clean console across core flows, dark mode
legibility (semantic tokens resolve correctly, no hardcoded colors), theme
persistence across reload, list/board load <~1s (backend ~17ms, polling 8s),
back/forward history, deep-link restore, unauth redirects, cross-surface
label/priority consistency, polling reconciliation, and the full happy path.

All gates green: BE pytest 884, FE vitest 579, tsc/eslint/ruff clean.

Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>
Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>
- Frontend Dockerfile: use bun for install + build (was npm, which fails
  on peer dep resolution ERESOLVE for vite@7/@types/node)
- Frontend Dockerfile: use bun runtime too — building under bun makes
  nitro/srvx emit a Bun.serve-targeted server bundle, so node:22-slim
  crashed with "Bun is not defined". Run with oven/bun:1.3.14-slim.
- Pinned bun image to 1.3.14-debian / 1.3.14-slim (matches local
  bun.lock; tag is >7 days old, no known CVEs).
- Copy bun.lock alongside package.json for reproducible frozen installs.
- Expand .dockerignore files for frontend and backend (exclude tests,
  caches, VCS, build output, scorecard.png, etc.).
- docker-compose.yml DB_URL already correct (postgresql://app:app@db:5432/app,
  plain scheme so the app's async + migration's sync URL converters each
  add the right driver) — no change needed.
- Verified: docker compose build + up works; backend /health returns
  "FastAPI running!", frontend returns 200 with full SPA HTML.

Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants