diff --git a/.forgejo/workflows/ci.yml b/.forgejo/workflows/ci.yml index 5ce0712..8180ffb 100644 --- a/.forgejo/workflows/ci.yml +++ b/.forgejo/workflows/ci.yml @@ -81,6 +81,11 @@ jobs: - name: Cache npm download cache uses: actions/cache@v4 + # Non-fatal: a transient cache-backend hiccup must NOT fail the whole + # typecheck job (it was skipping install + type check and reporting red + # on backend-only pushes — see issue task #828). On cache miss/error the + # job just installs without the cache. + continue-on-error: true with: path: ~/.npm key: npm-cache-${{ hashFiles('frontend/package-lock.json') }} diff --git a/alembic/versions/0065_issues_and_systems.py b/alembic/versions/0065_issues_and_systems.py new file mode 100644 index 0000000..6a9aa52 --- /dev/null +++ b/alembic/versions/0065_issues_and_systems.py @@ -0,0 +1,103 @@ +"""issues + systems: task_kind=issue, systems, record_systems, arose_from_id + +Revision ID: 0065 +Revises: 0064 +Create Date: 2026-06-14 + +Adds the corrective-work 'issue' task_kind (same-change CHECK expand per the +'new CHECK-enum values need a same-change migration' rule), a per-project +self-describing System entity, a many-to-many record<->system join (any +note/task/issue), and an issue->originating-task provenance FK. +""" +from alembic import op +import sqlalchemy as sa + + +revision = "0065" +down_revision = "0064" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + # 1. task_kind gains 'issue' (corrective work). DROP+ADD the CHECK in the + # same change that introduces the value. + op.drop_constraint("notes_task_kind_check", "notes", type_="check") + op.create_check_constraint( + "notes_task_kind_check", "notes", "task_kind IN ('work','plan','issue')", + ) + + # 2. Provenance: an issue can point back at the task/feature it arose from. + # Distinct from parent_id (sub-task hierarchy). + op.add_column("notes", sa.Column("arose_from_id", sa.Integer(), nullable=True)) + op.create_foreign_key( + "fk_notes_arose_from_id", "notes", "notes", + ["arose_from_id"], ["id"], ondelete="SET NULL", + ) + op.create_index("ix_notes_arose_from_id", "notes", ["arose_from_id"]) + + # 3. systems: per-project, reusable, self-describing subsystem/area. + op.create_table( + "systems", + sa.Column("id", sa.Integer(), primary_key=True), + sa.Column( + "user_id", sa.Integer(), + sa.ForeignKey("users.id", ondelete="CASCADE"), nullable=False, + ), + sa.Column( + "project_id", sa.Integer(), + sa.ForeignKey("projects.id", ondelete="CASCADE"), nullable=False, + ), + sa.Column("name", sa.Text(), nullable=False, server_default=""), + sa.Column("description", sa.Text(), nullable=True), + sa.Column("color", sa.Text(), nullable=True), + sa.Column("status", sa.Text(), nullable=False, server_default="active"), + sa.Column("order_index", sa.Integer(), nullable=False, server_default="0"), + sa.Column( + "created_at", sa.DateTime(timezone=True), nullable=False, + server_default=sa.text("now()"), + ), + sa.Column( + "updated_at", sa.DateTime(timezone=True), nullable=False, + server_default=sa.text("now()"), + ), + sa.Column("deleted_at", sa.DateTime(timezone=True), nullable=True), + sa.Column("deleted_batch_id", sa.Text(), nullable=True), + ) + op.create_index("ix_systems_project_id", "systems", ["project_id"]) + op.create_check_constraint( + "systems_status_check", "systems", "status IN ('active','archived')", + ) + + # 4. record_systems: M2M join — any note/task/issue <-> system. + op.create_table( + "record_systems", + sa.Column("id", sa.Integer(), primary_key=True), + sa.Column( + "note_id", sa.Integer(), + sa.ForeignKey("notes.id", ondelete="CASCADE"), nullable=False, + ), + sa.Column( + "system_id", sa.Integer(), + sa.ForeignKey("systems.id", ondelete="CASCADE"), nullable=False, + ), + sa.Column( + "created_at", sa.DateTime(timezone=True), nullable=False, + server_default=sa.text("now()"), + ), + sa.UniqueConstraint("note_id", "system_id", name="uq_record_systems_note_system"), + ) + op.create_index("ix_record_systems_note_id", "record_systems", ["note_id"]) + op.create_index("ix_record_systems_system_id", "record_systems", ["system_id"]) + + +def downgrade() -> None: + op.drop_table("record_systems") + op.drop_table("systems") + op.drop_index("ix_notes_arose_from_id", table_name="notes") + op.drop_constraint("fk_notes_arose_from_id", "notes", type_="foreignkey") + op.drop_column("notes", "arose_from_id") + op.drop_constraint("notes_task_kind_check", "notes", type_="check") + op.create_check_constraint( + "notes_task_kind_check", "notes", "task_kind IN ('work','plan')", + ) diff --git a/alembic/versions/0066_milestone_body.py b/alembic/versions/0066_milestone_body.py new file mode 100644 index 0000000..5f3fb0f --- /dev/null +++ b/alembic/versions/0066_milestone_body.py @@ -0,0 +1,32 @@ +"""milestone-as-plan-container: milestones.body holds the plan/design + +Revision ID: 0066 +Revises: 0065 +Create Date: 2026-06-14 + +T3 of plan #819. The milestone becomes the plan container: its `body` holds +the design/intent/purpose (markdown), `description` stays the one-liner, and +individual steps live as first-class child tasks (milestone_id) instead of +checkboxes crammed into a kind=plan task body. start_planning is reworked to +create a milestone instead of a kind=plan task (hard retirement going forward; +the 'plan' task_kind enum value stays valid so the historical plan-tasks are +left readable in place — no body-shredding backfill). + +Schema change is just one nullable column; no data migration. +""" +from alembic import op +import sqlalchemy as sa + + +revision = "0066" +down_revision = "0065" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.add_column("milestones", sa.Column("body", sa.Text(), nullable=True)) + + +def downgrade() -> None: + op.drop_column("milestones", "body") diff --git a/frontend/src/api/systems.ts b/frontend/src/api/systems.ts new file mode 100644 index 0000000..1fa194d --- /dev/null +++ b/frontend/src/api/systems.ts @@ -0,0 +1,64 @@ +import { apiGet, apiPost, apiPatch, apiDelete } from "@/api/client"; + +export interface System { + id: number; + project_id: number; + name: string; + description: string; + color: string | null; + status: "active" | "archived"; + order_index: number; + open_issue_count: number; + created_at: string | null; + updated_at: string | null; +} + +export async function listSystems(projectId: number): Promise { + const data = await apiGet<{ systems: System[] }>(`/api/projects/${projectId}/systems`); + return data.systems; +} + +export async function createSystem( + projectId: number, + data: { name: string; description?: string; color?: string }, +): Promise { + return apiPost(`/api/projects/${projectId}/systems`, data); +} + +export async function updateSystem( + projectId: number, + systemId: number, + data: Partial<{ + name: string; + description: string; + color: string | null; + status: "active" | "archived"; + order_index: number; + }>, +): Promise { + return apiPatch(`/api/projects/${projectId}/systems/${systemId}`, data); +} + +export async function deleteSystem(projectId: number, systemId: number): Promise { + return apiDelete(`/api/projects/${projectId}/systems/${systemId}`); +} + +// Lightweight issue shape returned by the project-issues list endpoint. +export interface TaskLike { + id: number; + title: string; + status: string; + priority: string; + systems?: System[]; + updated_at?: string | null; +} + +export async function getProjectIssues( + projectId: number, + openOnly = true, +): Promise { + const data = await apiGet<{ issues: TaskLike[] }>( + `/api/projects/${projectId}/issues?open_only=${openOnly}`, + ); + return data.issues; +} diff --git a/frontend/src/components/SystemsSection.vue b/frontend/src/components/SystemsSection.vue new file mode 100644 index 0000000..46ebb2a --- /dev/null +++ b/frontend/src/components/SystemsSection.vue @@ -0,0 +1,560 @@ + + + + + diff --git a/frontend/src/stores/systems.ts b/frontend/src/stores/systems.ts new file mode 100644 index 0000000..0fbb2d5 --- /dev/null +++ b/frontend/src/stores/systems.ts @@ -0,0 +1,73 @@ +import { ref } from "vue"; +import { defineStore } from "pinia"; +import * as api from "@/api/systems"; +import type { System } from "@/api/systems"; +import { useToastStore } from "@/stores/toast"; + +export const useSystemsStore = defineStore("systems", () => { + const systemsByProject = ref>({}); + const loading = ref(false); + + async function fetchSystems(projectId: number) { + loading.value = true; + try { + systemsByProject.value[projectId] = await api.listSystems(projectId); + } catch (e) { + useToastStore().show("Failed to load systems", "error"); + throw e; + } finally { + loading.value = false; + } + } + + async function createSystem( + projectId: number, + data: { name: string; description?: string; color?: string }, + ) { + const system = await api.createSystem(projectId, data); + if (!systemsByProject.value[projectId]) systemsByProject.value[projectId] = []; + systemsByProject.value[projectId].push(system); + return system; + } + + async function updateSystem( + projectId: number, + systemId: number, + data: Partial>, + ) { + const system = await api.updateSystem(projectId, systemId, data); + const list = systemsByProject.value[projectId]; + if (list) { + const idx = list.findIndex((s) => s.id === systemId); + if (idx >= 0) list[idx] = system; + } + return system; + } + + async function archiveSystem(projectId: number, systemId: number) { + return updateSystem(projectId, systemId, { status: "archived" }); + } + + async function unarchiveSystem(projectId: number, systemId: number) { + return updateSystem(projectId, systemId, { status: "active" }); + } + + async function deleteSystem(projectId: number, systemId: number) { + await api.deleteSystem(projectId, systemId); + const list = systemsByProject.value[projectId]; + if (list) { + systemsByProject.value[projectId] = list.filter((s) => s.id !== systemId); + } + } + + return { + systemsByProject, + loading, + fetchSystems, + createSystem, + updateSystem, + archiveSystem, + unarchiveSystem, + deleteSystem, + }; +}); diff --git a/frontend/src/stores/tasks.ts b/frontend/src/stores/tasks.ts index f778d23..c9f27c3 100644 --- a/frontend/src/stores/tasks.ts +++ b/frontend/src/stores/tasks.ts @@ -3,6 +3,16 @@ import { defineStore } from "pinia"; import { apiGet, apiPost, apiPut, apiPatch, apiDelete } from "@/api/client"; import { useToastStore } from "@/stores/toast"; import type { Task, TaskStatus, TaskPriority, StartPlanningResult } from "@/types/task"; +import type { TaskKind } from "@/types/note"; + +// Issues + Systems write-only fields accepted by the task create/update API. +// `kind` selects work/plan/issue; system_ids / arose_from_id are not mirrored +// 1:1 on the Task model (the read side exposes `systems` + `arose_from_id`). +interface IssueFields { + kind?: TaskKind; + system_ids?: number[]; + arose_from_id?: number | null; +} // Single-task + mutation surface. The list/filter/sort/pagination surface that // backed the removed /tasks list view was dropped in the 2026-06-02 drift-audit @@ -34,7 +44,7 @@ export const useTasksStore = defineStore("tasks", () => { milestone_id?: number | null; parent_id?: number | null; recurrence_rule?: Record | null; - }): Promise { + } & IssueFields): Promise { try { return await apiPost("/api/tasks", data); } catch (e) { @@ -47,7 +57,7 @@ export const useTasksStore = defineStore("tasks", () => { id: number, data: Partial< Pick - > + > & IssueFields ): Promise { try { const task = await apiPut(`/api/tasks/${id}`, data); diff --git a/frontend/src/types/note.ts b/frontend/src/types/note.ts index cb97688..7a9a4de 100644 --- a/frontend/src/types/note.ts +++ b/frontend/src/types/note.ts @@ -1,5 +1,8 @@ +import type { System } from "@/api/systems"; + export type TaskStatus = "todo" | "in_progress" | "done" | "cancelled"; export type TaskPriority = "none" | "low" | "medium" | "high"; +export type TaskKind = "work" | "plan" | "issue"; export type NoteType = "note" | "person" | "place" | "list" | "process"; export interface Note { @@ -22,7 +25,9 @@ export interface Note { recurrence_next_spawn_at: string | null; is_task: boolean; note_type: NoteType; - task_kind?: "work" | "plan"; + task_kind?: TaskKind; + systems?: System[]; + arose_from_id?: number | null; metadata: Record; created_at: string; updated_at: string; diff --git a/frontend/src/types/task.ts b/frontend/src/types/task.ts index 8b55262..93d6aeb 100644 --- a/frontend/src/types/task.ts +++ b/frontend/src/types/task.ts @@ -5,8 +5,18 @@ export interface TaskListResponse { total: number; } +// start_planning now creates a MILESTONE (the plan container), not a kind=plan +// task. The milestone's `body` holds the design; steps live as child tasks. export interface StartPlanningResult { - task: import("./note").Note; + milestone: { + id: number; + project_id: number; + title: string; + description: string | null; + body: string | null; + status: string; + order_index: number; + }; applicable_rules: { id: number; title: string; diff --git a/frontend/src/views/DashboardView.vue b/frontend/src/views/DashboardView.vue index f71eb9a..524bb81 100644 --- a/frontend/src/views/DashboardView.vue +++ b/frontend/src/views/DashboardView.vue @@ -13,10 +13,12 @@ interface ActiveProject { interface DoneItem { id: number; title: string; project_title: string | null; completed_at: string } interface UpcomingEvent { id: number; title: string; start_dt: string | null; all_day: boolean } interface WeekStats { completed_this_week: number; open_total: number; in_progress: number; active_plans: number } +interface IssueRow { id: number; title: string; status: string; priority: string; project_id: number | null; project_title: string | null } interface DashboardData { active_projects: ActiveProject[]; recently_completed: DoneItem[]; upcoming_events: UpcomingEvent[]; + open_issues: IssueRow[]; week_stats: WeekStats; } @@ -29,7 +31,7 @@ onMounted(async () => { try { data.value = await apiGet("/api/dashboard"); } catch { - data.value = { active_projects: [], recently_completed: [], upcoming_events: [], week_stats: { completed_this_week: 0, open_total: 0, in_progress: 0, active_plans: 0 } }; + data.value = { active_projects: [], recently_completed: [], upcoming_events: [], open_issues: [], week_stats: { completed_this_week: 0, open_total: 0, in_progress: 0, active_plans: 0 } }; } finally { loading.value = false; } @@ -132,6 +134,22 @@ function fmtEvent(e: UpcomingEvent): string {