feat: Linear clone showcase app (M0–M2) — workspace, auth, issues, projects, cycles - #4
Open
croto-bot wants to merge 28 commits into
Open
feat: Linear clone showcase app (M0–M2) — workspace, auth, issues, projects, cycles#4croto-bot wants to merge 28 commits into
croto-bot wants to merge 28 commits into
Conversation
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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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
itemsexample is gone;template/backend+template/frontendare the Linear app now.It is built directly inside
template/on branchfeat/linear-clone, branched fromcode-health.What's implemented (Milestones M0–M2)
✅ M0 — Foundation & Workspace
itemsmodule (backend routers/tests + all frontend item routes/components/hooks).Team(id, name, key,issue_sequencecounter) +TeamMembership(user↔team, roleadmin/member/guest). A default team + admin membership is auto-created on registration.Navigationcomponent everywhere.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).localStorage, applied before first paint (no flash), survives reload + logout/login.tscconfig drift invite.config.ts.✅ M1 — Issues Core
IssueLabelassociation. Default 5 states seeded per team.Issuewith 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).401; invalid token forces logout; cross-team data blocked (403/404); guest blocked from writes, member blocked from admin actions; sidebar team switcher.refetchInterval) + skeleton loaders + empty states established here and reused.✅ M2 — Projects & Cycles
ProjectPickerin the issue drawer with cross-surface badges.starts_at/ends_at,end > startenforced) + CRUD + frontend: cycles list, cycle detail with progress (X of Y done from terminal statuses), create dialog,CyclePickerin the issue drawer. An issue belongs to at most one cycle (reassign moves it).Architecture
Backend — strict 3-layer (
Router → Service → Repository) reusing the template'sBaseService[T]+SQLAlchemyRepository[T],fastapi_filter+fastapi_pagination, session/Bearer auth. New entities live inapp/modules/{teams,workflows,labels,issues,projects,cycles}/.Issueis the hub with auto identifier, team scoping, andselectinloadfor labels.Frontend — Linear app shell; routes under TanStack file-based router; data via React Query with
staleTime+ shortrefetchIntervalpolling and optimistic updates on every mutation.How to run it
Prerequisites
Python 3.12+,
uv,bun(or npm), Docker.1. Start Postgres
2. Configure backend env
Create
template/backend/.env:(Local dev only — credentials
app/app. The app normalizespostgres://→postgresql+asyncpg://automatically.)3. Install & migrate the backend
cd template/backend uv sync DB_PORT=5433 uv run alembic upgrade head4. Run the backend
5. Install & run the frontend
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.
How to test / validate
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 checkis advisory-only on this template — pre-existing infra tech debt; do not block on it.)What's left (M3–M5)
Notes