diff --git a/api/models/technical_debt.py b/api/models/technical_debt.py index 7aee735..9a64260 100644 --- a/api/models/technical_debt.py +++ b/api/models/technical_debt.py @@ -1,19 +1,51 @@ import enum import uuid -from sqlalchemy import Enum, ForeignKey, String, Text +from sqlalchemy import DateTime, Enum, ForeignKey, String, Text from sqlalchemy.dialects.postgresql import UUID from sqlalchemy.orm import Mapped, mapped_column, relationship +from sqlalchemy.sql import func from api.models.base import Base, TimestampMixin, new_uuid class TDStatus(str, enum.Enum): + # Legacy general statuses open = "open" in_progress = "in_progress" resolved = "resolved" deferred = "deferred" wont_fix = "wont_fix" + # Dashboard-improvement workflow steps + submitted = "submitted" + analyse = "analyse" + plan = "plan" + implement = "implement" + test = "test" + review = "review" + finished = "finished" + + +# Ordered workflow steps for dashboard-improvement suggestions +SUGGESTION_STEPS = ["submitted", "analyse", "plan", "implement", "test", "review", "finished"] + + +class TDNote(Base): + __tablename__ = "td_notes" + + id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=new_uuid) + td_id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), ForeignKey("technical_debt.id", ondelete="CASCADE"), + nullable=False, index=True, + ) + step: Mapped[str] = mapped_column(String(30), nullable=False) + author: Mapped[str | None] = mapped_column(String(100), nullable=True) + content: Mapped[str] = mapped_column(Text, nullable=False) + created_at: Mapped[DateTime] = mapped_column( + DateTime(timezone=True), server_default=func.now(), nullable=False + ) + + td: Mapped["TechnicalDebt"] = relationship("TechnicalDebt", back_populates="notes") class TechnicalDebt(Base, TimestampMixin): @@ -51,6 +83,10 @@ class TechnicalDebt(Base, TimestampMixin): domain: Mapped["Domain"] = relationship("Domain", lazy="selectin") # noqa: F821 topic: Mapped["Topic"] = relationship("Topic", lazy="selectin") # noqa: F821 workstream: Mapped["Workstream"] = relationship("Workstream", lazy="selectin") # noqa: F821 + notes: Mapped[list["TDNote"]] = relationship( + "TDNote", back_populates="td", lazy="selectin", + order_by="TDNote.created_at", + ) @property def domain_slug(self) -> str: diff --git a/api/routers/technical_debt.py b/api/routers/technical_debt.py index eb8a4de..902d68c 100644 --- a/api/routers/technical_debt.py +++ b/api/routers/technical_debt.py @@ -6,8 +6,8 @@ from sqlalchemy.ext.asyncio import AsyncSession from api.database import get_session from api.models.domain import Domain -from api.models.technical_debt import TDStatus, TechnicalDebt -from api.schemas.technical_debt import TDCreate, TDRead, TDUpdate +from api.models.technical_debt import TDNote, TDStatus, TechnicalDebt +from api.schemas.technical_debt import TDCreate, TDNoteCreate, TDNoteRead, TDRead, TDUpdate router = APIRouter(prefix="/technical-debt", tags=["technical-debt"]) @@ -32,7 +32,7 @@ async def _resolve_domain_id(slug: str, session: AsyncSession) -> uuid.UUID: @router.get("/", response_model=list[TDRead]) async def list_td( domain: str | None = None, - status: TDStatus | None = None, + status: str | None = None, # str to accept both legacy and workflow values debt_type: str | None = None, severity: str | None = None, session: AsyncSession = Depends(get_session), @@ -106,3 +106,35 @@ async def defer_td( await session.commit() await session.refresh(td) return td + + +# ── Notes ───────────────────────────────────────────────────────────────────── + +@router.get("/{td_id}/notes/", response_model=list[TDNoteRead]) +async def list_notes( + td_id: uuid.UUID, + session: AsyncSession = Depends(get_session), +) -> list[TDNote]: + td = await session.get(TechnicalDebt, td_id) + if td is None: + raise HTTPException(status_code=404, detail="Technical debt item not found") + result = await session.execute( + select(TDNote).where(TDNote.td_id == td_id).order_by(TDNote.created_at) + ) + return list(result.scalars().all()) + + +@router.post("/{td_id}/notes/", response_model=TDNoteRead, status_code=status.HTTP_201_CREATED) +async def add_note( + td_id: uuid.UUID, + body: TDNoteCreate, + session: AsyncSession = Depends(get_session), +) -> TDNote: + td = await session.get(TechnicalDebt, td_id) + if td is None: + raise HTTPException(status_code=404, detail="Technical debt item not found") + note = TDNote(td_id=td_id, **body.model_dump()) + session.add(note) + await session.commit() + await session.refresh(note) + return note diff --git a/api/schemas/technical_debt.py b/api/schemas/technical_debt.py index f5d920e..6926c20 100644 --- a/api/schemas/technical_debt.py +++ b/api/schemas/technical_debt.py @@ -8,6 +8,23 @@ from api.models.technical_debt import TDStatus VALID_SEVERITIES = {"low", "medium", "high", "critical"} +class TDNoteCreate(BaseModel): + step: str + author: str | None = None + content: str + + +class TDNoteRead(BaseModel): + model_config = ConfigDict(from_attributes=True) + + id: uuid.UUID + td_id: uuid.UUID + step: str + author: str | None = None + content: str + created_at: datetime + + class TDCreate(BaseModel): td_id: str | None = None domain: str # slug; router resolves to domain_id FK @@ -47,3 +64,4 @@ class TDRead(BaseModel): workstream_id: uuid.UUID | None = None created_at: datetime updated_at: datetime + notes: list[TDNoteRead] = [] diff --git a/dashboard/src/components/improvement-modal.js b/dashboard/src/components/improvement-modal.js index a71f4e8..0f9ba4d 100644 --- a/dashboard/src/components/improvement-modal.js +++ b/dashboard/src/components/improvement-modal.js @@ -342,6 +342,7 @@ export function initImprovementModal({ apiBase = "http://127.0.0.1:8000", domain description: suggestion, debt_type: "dashboard-improvement", severity: "low", + status: "submitted", location, }; diff --git a/dashboard/src/ui-feedback.md b/dashboard/src/ui-feedback.md index ba9fe95..dff6702 100644 --- a/dashboard/src/ui-feedback.md +++ b/dashboard/src/ui-feedback.md @@ -6,6 +6,45 @@ title: UI Feedback import {API, POLL} from "./components/config.js"; ``` +```js +// Ordered workflow steps for dashboard-improvement suggestions +const STEPS = ["submitted", "analyse", "plan", "implement", "test", "review", "finished"]; +const STEP_LABEL = { + submitted: "Submitted", + analyse: "Analyse", + plan: "Plan", + implement: "Implement", + test: "Test", + review: "Review", + finished: "Finished", + wont_fix: "Won't Fix", +}; +const STEP_COLOR = { + submitted: "#94a3b8", + analyse: "#3b82f6", + plan: "#8b5cf6", + implement: "#f59e0b", + test: "#06b6d4", + review: "#6366f1", + finished: "#16a34a", + wont_fix: "#9ca3af", +}; +const STEP_HINT = { + submitted: "New suggestion — not yet triaged.", + analyse: "Investigating the issue and its scope.", + plan: "Designing the solution approach.", + implement: "Building the change.", + test: "Verifying the implementation works correctly.", + review: "Awaiting review by the original suggester.", + finished: "Shipped and confirmed.", + wont_fix: "Decided not to implement.", +}; +function nextStep(current) { + const i = STEPS.indexOf(current); + return i >= 0 && i < STEPS.length - 1 ? STEPS[i + 1] : null; +} +``` + ```js const feedbackState = (async function*() { while (true) { @@ -18,8 +57,8 @@ const feedbackState = (async function*() { data = items .filter(t => t.debt_type === "dashboard-improvement") .sort((a, b) => { - const st = {open: 0, in_progress: 1, deferred: 2, resolved: 3, wont_fix: 4}; - return (st[a.status] ?? 9) - (st[b.status] ?? 9); + const st = {submitted:0, analyse:1, plan:2, implement:3, test:4, review:5, finished:6, wont_fix:7, open:8, in_progress:9, resolved:10}; + return (st[a.status] ?? 99) - (st[b.status] ?? 99); }); } } catch {} @@ -41,27 +80,23 @@ const _ts = feedbackState.ts; import {injectTocTop} from "./components/toc-sidebar.js"; import {withDocHelp} from "./components/doc-overlay.js"; -const _open = data.filter(t => t.status === "open" || t.status === "in_progress"); -const _resolved = data.filter(t => t.status === "resolved"); -const _wontFix = data.filter(t => t.status === "wont_fix"); +const _active = data.filter(t => t.status !== "finished" && t.status !== "wont_fix"); +const _finished = data.filter(t => t.status === "finished"); +const _wontfix = data.filter(t => t.status === "wont_fix"); const _kpiBox = html`
UI Feedback
- open -
${_open.length}
+ active +
${_active.length}
- resolved -
${_resolved.length}
+ finished +
${_finished.length}
won't fix -
${_wontFix.length}
-
-
- total -
${data.length}
+
${_wontfix.length}
`; @@ -71,84 +106,146 @@ const _liveEl = html`
? `Live · updated ${_ts?.toLocaleTimeString()}` : html`Offline — run: make api`}
`; -withDocHelp(_liveEl, "/docs/live-data"); injectTocTop("fb-kpi-box", _kpiBox); injectTocTop("live-indicator", _liveEl); ``` -> Right-click any widget or section on any dashboard page to submit a suggestion. -> Items appear here for review and can be resolved or dismissed. +> Shift+click any widget on any dashboard page to submit a suggestion. +> Each suggestion moves through the workflow below; the **Review** step +> is for the original suggester to confirm the implementation is correct. -## Open Suggestions +## Active ```js -async function _setStatus(td_id, status) { - try { - const r = await fetch(`${API}/technical-debt/${td_id}`, { - method: "PATCH", - headers: {"Content-Type": "application/json"}, - body: JSON.stringify({status}), - }); - if (!r.ok) alert(`Failed to update status (HTTP ${r.status})`); - } catch { - alert("API unreachable"); - } +async function _advance(td, newStatus) { + return fetch(`${API}/technical-debt/${td.id}`, { + method: "PATCH", + headers: {"Content-Type": "application/json"}, + body: JSON.stringify({status: newStatus}), + }); } -``` -```js -const _openItems = data.filter(t => t.status === "open" || t.status === "in_progress"); - -if (_openItems.length === 0) { - display(html`

No open suggestions. Right-click any dashboard widget to submit one.

`); -} else { - display(html`
${_openItems.map(t => { - const card = html`
-
- ${t.td_id ? html`${t.td_id}` : ""} - ${t.status.replace("_", " ")} - ${t.location ?? ""} -
- - - ${t.status === "open" ? html`` : ""} -
-
-
${t.description}
-
`; - return card; - })}
`); +async function _addNote(td_id, step, content) { + return fetch(`${API}/technical-debt/${td_id}/notes/`, { + method: "POST", + headers: {"Content-Type": "application/json"}, + body: JSON.stringify({step, content, author: "custodian"}), + }); } -``` -## Resolved / Won't Fix +function renderSuggestionCard(t) { + const isWorkflow = STEPS.includes(t.status); + const next = nextStep(t.status); + const color = STEP_COLOR[t.status] ?? "#94a3b8"; + const stepIdx = STEPS.indexOf(t.status); -```js -const _doneItems = data.filter(t => t.status === "resolved" || t.status === "wont_fix"); - -if (_doneItems.length === 0) { - display(html`

Nothing resolved yet.

`); -} else { - display(html`
${_doneItems.map(t => html` -
-
- ${t.td_id ? html`${t.td_id}` : ""} - ${t.status.replace("_", " ")} - ${t.location ?? ""} + // ── Stepper ───────────────────────────────────────────────────────── + const stepperEl = html`
+ ${STEPS.map((s, i) => { + const done = i < stepIdx; + const current = i === stepIdx; + return html`
+
+
${STEP_LABEL[s]}
-
${t.description}
+ ${i < STEPS.length - 1 ? html`
` : ""}`; + })} +
`; + + // ── Notes ──────────────────────────────────────────────────────────── + const notesEl = html`
+ ${(t.notes ?? []).length === 0 + ? html`

No notes yet for this step.

` + : (t.notes ?? []).map(n => html`
+ ${STEP_LABEL[n.step] ?? n.step} + ${n.author ?? "anon"} + ${new Date(n.created_at).toLocaleString()} +
${n.content}
+
`)} +
`; + + // ── Add note form ──────────────────────────────────────────────────── + const noteTA = html``; + const noteBtn = html``; + noteBtn.addEventListener("click", async () => { + const txt = noteTA.value.trim(); + if (!txt) return; + noteBtn.disabled = true; + const r = await _addNote(t.id, t.status, txt); + if (r.ok) { + noteTA.value = ""; + // Refresh: remove and re-fetch by reloading feedbackState on next poll + } + noteBtn.disabled = false; + }); + const noteForm = html`
${noteTA}${noteBtn}
`; + + // ── Actions ────────────────────────────────────────────────────────── + const actionsEl = html`
+ ${t.status === "review" + ? html`⬆ Awaiting confirmation from the original suggester` + : ""} + ${next ? html`` : ""} + +
`; + + return html`
+
+ ${t.td_id ? html`${t.td_id}` : ""} + ${STEP_LABEL[t.status] ?? t.status} + ${t.location ?? ""}
- `)}
`); +
${t.title.replace(/^UI:\s*/, "")}
+
${t.description ?? ""}
+ ${stepperEl} +
+ Notes (${(t.notes ?? []).length}) + ${notesEl} + ${noteForm} +
+ ${actionsEl} +
`; +} + +if (_active.length === 0) { + display(html`

No active suggestions. Shift+click any dashboard widget to submit one.

`); +} else { + display(html`
${_active.map(renderSuggestionCard)}
`); +} +``` + +## Finished & Won't Fix + +```js +const _closed = [..._finished, ..._wontfix]; +if (_closed.length === 0) { + display(html`

Nothing closed yet.

`); +} else { + display(html`
${_closed.map(t => { + const color = STEP_COLOR[t.status] ?? "#94a3b8"; + return html`
+
+ ${t.td_id ? html`${t.td_id}` : ""} + ${STEP_LABEL[t.status] ?? t.status} + ${t.location ?? ""} +
+
${t.title.replace(/^UI:\s*/, "")}
+
${t.description ?? ""}
+ ${(t.notes ?? []).length > 0 ? html`
+ ${t.notes.slice(-2).map(n => html`
+ ${STEP_LABEL[n.step] ?? n.step} +
${n.content}
+
`)} +
` : ""} +
`; + })}
`); } ``` @@ -160,52 +257,55 @@ if (_doneItems.length === 0) { .kpi-row-right { text-align: right; } .kpi-row-value { font-size: 1.25rem; font-weight: 700; font-variant-numeric: tabular-nums; line-height: 1.1; } -.fb-list { display: flex; flex-direction: column; gap: 0.55rem; } -.fb-done { opacity: 0.7; } - -.fb-card { - border-left: 3px solid #6366f1; - border-radius: 0 8px 8px 0; - background: var(--theme-background-alt); - padding: 0.7rem 1rem; -} -.fb-status-resolved { border-left-color: #16a34a; } -.fb-status-wont_fix { border-left-color: #94a3b8; } -.fb-status-in_progress { border-left-color: #d97706; } - -.fb-card-header { - display: flex; flex-wrap: wrap; align-items: center; gap: 0.45rem; - margin-bottom: 0.45rem; -} -.fb-location { - font-size: 0.75rem; color: var(--theme-foreground-faint); - font-style: italic; flex: 1; -} -.fb-suggestion { - font-size: 0.88rem; line-height: 1.55; - color: var(--theme-foreground); - white-space: pre-wrap; -} -.fb-actions { display: flex; gap: 0.35rem; margin-left: auto; } -.fb-btn { - padding: 0.2rem 0.65rem; border-radius: 5px; font-size: 0.75rem; - font-family: inherit; cursor: pointer; font-weight: 600; border: 1px solid transparent; - transition: background 0.1s, opacity 0.1s; -} -.fb-btn-resolve { background: #dcfce7; color: #166534; border-color: #bbf7d0; } -.fb-btn-resolve:hover { background: #bbf7d0; } -.fb-btn-wontfix { background: #f1f5f9; color: #475569; border-color: #cbd5e1; } -.fb-btn-wontfix:hover { background: #e2e8f0; } -.fb-btn-inprogress { background: #fef3c7; color: #92400e; border-color: #fde68a; } -.fb-btn-inprogress:hover { background: #fde68a; } - -/* reuse td-badge styles */ +/* ── Cards ─────────────────────────────────────────────────────────── */ +.fb-list { display: flex; flex-direction: column; gap: 1rem; } +.fb-list-closed { opacity: 0.72; } +.fb-card { border-left: 3px solid #6366f1; border-radius: 0 8px 8px 0; background: var(--theme-background-alt); padding: 0.85rem 1rem; display: flex; flex-direction: column; gap: 0.6rem; } +.fb-card-closed { gap: 0.4rem; } +.fb-card-header { display: flex; flex-wrap: wrap; align-items: center; gap: 0.45rem; } +.fb-location { font-size: 0.72rem; color: var(--theme-foreground-faint); font-style: italic; flex: 1; } +.fb-title { font-weight: 700; font-size: 1rem; } +.fb-suggestion { font-size: 0.85rem; color: var(--theme-foreground-muted); line-height: 1.5; white-space: pre-wrap; } +.fb-step-badge { display: inline-block; padding: 0.12rem 0.55rem; border-radius: 10px; font-size: 0.72rem; font-weight: 700; text-transform: uppercase; letter-spacing: 0.04em; } .td-ref { font-family: monospace; font-size: 0.72rem; color: var(--theme-foreground-muted); background: var(--theme-background); border: 1px solid var(--theme-foreground-faint, #ddd); border-radius: 4px; padding: 0.05rem 0.35rem; } -.td-badge { display: inline-block; padding: 0.1rem 0.45rem; border-radius: 10px; font-size: 0.7rem; font-weight: 500; } -.td-badge-open { background: #dbeafe; color: #1e40af; } -.td-badge-in_progress { background: #fef3c7; color: #92400e; } -.td-badge-resolved { background: #dcfce7; color: #166534; } -.td-badge-wont_fix { background: #f3f4f6; color: #9ca3af; } + +/* ── Stepper ────────────────────────────────────────────────────────── */ +.fb-stepper { display: flex; align-items: center; flex-wrap: nowrap; overflow-x: auto; padding: 0.5rem 0; gap: 0; } +.fb-step { display: flex; flex-direction: column; align-items: center; flex-shrink: 0; min-width: 54px; } +.fb-step-dot { width: 10px; height: 10px; border-radius: 50%; border: 2px solid var(--theme-foreground-faint, #ccc); background: var(--theme-background); } +.fb-step-done .fb-step-dot { background: #16a34a; border-color: #16a34a; } +.fb-step-current .fb-step-dot { width: 12px; height: 12px; } +.fb-step-label { font-size: 0.62rem; color: var(--theme-foreground-faint); margin-top: 0.25rem; text-align: center; white-space: nowrap; } +.fb-step-done .fb-step-label { color: #16a34a; } +.fb-step-current .fb-step-label { font-weight: 700; color: var(--theme-foreground); } +.fb-step-line { flex: 1; height: 2px; background: var(--theme-foreground-faint, #ddd); min-width: 12px; margin-bottom: 18px; } +.fb-step-line-done { background: #16a34a; } + +/* ── Notes ──────────────────────────────────────────────────────────── */ +.fb-notes-section { margin-top: 0.1rem; } +.fb-notes-toggle { cursor: pointer; font-size: 0.75rem; color: var(--theme-foreground-muted); user-select: none; padding: 0.15rem 0; } +.fb-notes { display: flex; flex-direction: column; gap: 0.4rem; margin: 0.4rem 0 0.5rem; } +.fb-notes-compact { margin-top: 0.3rem; } +.fb-no-notes { font-size: 0.75rem; color: var(--theme-foreground-faint); font-style: italic; margin: 0.2rem 0 0.4rem; } +.fb-note { background: var(--theme-background); border-radius: 5px; padding: 0.4rem 0.6rem; font-size: 0.82rem; line-height: 1.45; } +.fb-note-step { display: inline-block; padding: 0.05rem 0.4rem; border-radius: 8px; font-size: 0.65rem; font-weight: 700; text-transform: uppercase; margin-right: 0.35rem; } +.fb-note-author { font-size: 0.7rem; color: var(--theme-foreground-muted); margin-right: 0.35rem; } +.fb-note-time { font-size: 0.68rem; color: var(--theme-foreground-faint); } +.fb-note-content { margin-top: 0.2rem; white-space: pre-wrap; } +.fb-note-form { display: flex; gap: 0.4rem; align-items: flex-start; margin-top: 0.25rem; } +.fb-note-ta { flex: 1; font-size: 0.82rem; font-family: inherit; border: 1px solid var(--theme-foreground-faint, #ccc); border-radius: 5px; padding: 0.35rem 0.5rem; background: var(--theme-background); resize: vertical; line-height: 1.45; color: inherit; } + +/* ── Actions ────────────────────────────────────────────────────────── */ +.fb-actions { display: flex; flex-wrap: wrap; align-items: center; gap: 0.4rem; margin-top: 0.1rem; } +.fb-review-hint { font-size: 0.75rem; color: #6366f1; font-style: italic; flex: 1; } +.fb-btn { padding: 0.3rem 0.8rem; border-radius: 6px; font-size: 0.78rem; font-family: inherit; cursor: pointer; font-weight: 600; border: 1px solid transparent; transition: opacity 0.1s; background: var(--theme-background); } +.fb-btn:disabled { opacity: 0.4; cursor: not-allowed; } +.fb-btn-advance { border-width: 1.5px; } +.fb-btn-advance:hover:not(:disabled) { opacity: 0.75; } +.fb-btn-note { border-color: var(--theme-foreground-faint, #ccc); color: var(--theme-foreground-muted); } +.fb-btn-note:hover:not(:disabled) { background: var(--theme-background-alt); } +.fb-btn-wontfix { color: #9ca3af; border-color: #e5e7eb; } +.fb-btn-wontfix:hover:not(:disabled) { background: #f3f4f6; } .dim { color: gray; font-style: italic; } diff --git a/migrations/versions/h5c6d7e8f9a0_suggestion_workflow.py b/migrations/versions/h5c6d7e8f9a0_suggestion_workflow.py new file mode 100644 index 0000000..e942ce6 --- /dev/null +++ b/migrations/versions/h5c6d7e8f9a0_suggestion_workflow.py @@ -0,0 +1,42 @@ +"""suggestion workflow: extend tdstatus enum + td_notes table + +Revision ID: h5c6d7e8f9a0 +Revises: g4b5c6d7e8f9 +Create Date: 2026-03-18 +""" +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects import postgresql + +revision = "h5c6d7e8f9a0" +down_revision = "g4b5c6d7e8f9" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + # Extend tdstatus enum with workflow step values. + # IF NOT EXISTS prevents errors on re-runs; values cannot be removed in downgrade. + for val in ("submitted", "analyse", "plan", "implement", "test", "review", "finished"): + op.execute(f"ALTER TYPE tdstatus ADD VALUE IF NOT EXISTS '{val}'") + + # Per-step notes for technical debt items (used by dashboard-improvement workflow) + op.create_table( + "td_notes", + sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True, + server_default=sa.text("gen_random_uuid()")), + sa.Column("td_id", postgresql.UUID(as_uuid=True), + sa.ForeignKey("technical_debt.id", ondelete="CASCADE"), + nullable=False, index=True), + sa.Column("step", sa.String(30), nullable=False), + sa.Column("author", sa.String(100), nullable=True), + sa.Column("content", sa.Text, nullable=False), + sa.Column("created_at", sa.DateTime(timezone=True), + server_default=sa.text("now()"), nullable=False), + ) + + +def downgrade() -> None: + op.drop_table("td_notes") + # Note: PostgreSQL does not support removing enum values; + # the added tdstatus values remain after downgrade.