feat(suggestions): full suggestion workflow with per-step notes

DB migration h5c6d7e8f9a0:
- Extends tdstatus enum: submitted → analyse → plan → implement →
  test → review → finished (+ wont_fix remains)
- New td_notes table: td_id FK (CASCADE), step, author, content, created_at

API:
- TDNote model + TDNoteCreate/TDNoteRead schemas
- TDRead includes notes[] (selectin loaded)
- New routes: GET/POST /technical-debt/{id}/notes/
- list_td status filter accepts str (all enum values)

Modal: new submissions use status="submitted" instead of "open"

UI Feedback page revamp:
- Visual step-by-step stepper (submitted→analyse→plan→implement→test→review→finished)
- Per-step notes: view all notes, add note inline
- Action buttons: advance to next step, won't fix
- Review step highlighted as awaiting original suggester confirmation
- Closed items (finished/wont_fix) shown with last 2 notes

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-18 00:57:34 +01:00
parent 7566851335
commit 1f1da56533
6 changed files with 355 additions and 126 deletions

View File

@@ -1,19 +1,51 @@
import enum import enum
import uuid 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.dialects.postgresql import UUID
from sqlalchemy.orm import Mapped, mapped_column, relationship from sqlalchemy.orm import Mapped, mapped_column, relationship
from sqlalchemy.sql import func
from api.models.base import Base, TimestampMixin, new_uuid from api.models.base import Base, TimestampMixin, new_uuid
class TDStatus(str, enum.Enum): class TDStatus(str, enum.Enum):
# Legacy general statuses
open = "open" open = "open"
in_progress = "in_progress" in_progress = "in_progress"
resolved = "resolved" resolved = "resolved"
deferred = "deferred" deferred = "deferred"
wont_fix = "wont_fix" 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): class TechnicalDebt(Base, TimestampMixin):
@@ -51,6 +83,10 @@ class TechnicalDebt(Base, TimestampMixin):
domain: Mapped["Domain"] = relationship("Domain", lazy="selectin") # noqa: F821 domain: Mapped["Domain"] = relationship("Domain", lazy="selectin") # noqa: F821
topic: Mapped["Topic"] = relationship("Topic", lazy="selectin") # noqa: F821 topic: Mapped["Topic"] = relationship("Topic", lazy="selectin") # noqa: F821
workstream: Mapped["Workstream"] = relationship("Workstream", 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 @property
def domain_slug(self) -> str: def domain_slug(self) -> str:

View File

@@ -6,8 +6,8 @@ from sqlalchemy.ext.asyncio import AsyncSession
from api.database import get_session from api.database import get_session
from api.models.domain import Domain from api.models.domain import Domain
from api.models.technical_debt import TDStatus, TechnicalDebt from api.models.technical_debt import TDNote, TDStatus, TechnicalDebt
from api.schemas.technical_debt import TDCreate, TDRead, TDUpdate from api.schemas.technical_debt import TDCreate, TDNoteCreate, TDNoteRead, TDRead, TDUpdate
router = APIRouter(prefix="/technical-debt", tags=["technical-debt"]) 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]) @router.get("/", response_model=list[TDRead])
async def list_td( async def list_td(
domain: str | None = None, 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, debt_type: str | None = None,
severity: str | None = None, severity: str | None = None,
session: AsyncSession = Depends(get_session), session: AsyncSession = Depends(get_session),
@@ -106,3 +106,35 @@ async def defer_td(
await session.commit() await session.commit()
await session.refresh(td) await session.refresh(td)
return 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

View File

@@ -8,6 +8,23 @@ from api.models.technical_debt import TDStatus
VALID_SEVERITIES = {"low", "medium", "high", "critical"} 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): class TDCreate(BaseModel):
td_id: str | None = None td_id: str | None = None
domain: str # slug; router resolves to domain_id FK domain: str # slug; router resolves to domain_id FK
@@ -47,3 +64,4 @@ class TDRead(BaseModel):
workstream_id: uuid.UUID | None = None workstream_id: uuid.UUID | None = None
created_at: datetime created_at: datetime
updated_at: datetime updated_at: datetime
notes: list[TDNoteRead] = []

View File

@@ -342,6 +342,7 @@ export function initImprovementModal({ apiBase = "http://127.0.0.1:8000", domain
description: suggestion, description: suggestion,
debt_type: "dashboard-improvement", debt_type: "dashboard-improvement",
severity: "low", severity: "low",
status: "submitted",
location, location,
}; };

View File

@@ -6,6 +6,45 @@ title: UI Feedback
import {API, POLL} from "./components/config.js"; 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 ```js
const feedbackState = (async function*() { const feedbackState = (async function*() {
while (true) { while (true) {
@@ -18,8 +57,8 @@ const feedbackState = (async function*() {
data = items data = items
.filter(t => t.debt_type === "dashboard-improvement") .filter(t => t.debt_type === "dashboard-improvement")
.sort((a, b) => { .sort((a, b) => {
const st = {open: 0, in_progress: 1, deferred: 2, resolved: 3, wont_fix: 4}; 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] ?? 9) - (st[b.status] ?? 9); return (st[a.status] ?? 99) - (st[b.status] ?? 99);
}); });
} }
} catch {} } catch {}
@@ -41,27 +80,23 @@ const _ts = feedbackState.ts;
import {injectTocTop} from "./components/toc-sidebar.js"; import {injectTocTop} from "./components/toc-sidebar.js";
import {withDocHelp} from "./components/doc-overlay.js"; import {withDocHelp} from "./components/doc-overlay.js";
const _open = data.filter(t => t.status === "open" || t.status === "in_progress"); const _active = data.filter(t => t.status !== "finished" && t.status !== "wont_fix");
const _resolved = data.filter(t => t.status === "resolved"); const _finished = data.filter(t => t.status === "finished");
const _wontFix = data.filter(t => t.status === "wont_fix"); const _wontfix = data.filter(t => t.status === "wont_fix");
const _kpiBox = html`<div class="kpi-infobox"> const _kpiBox = html`<div class="kpi-infobox">
<div class="kpi-infobox-title">UI Feedback</div> <div class="kpi-infobox-title">UI Feedback</div>
<div class="kpi-row"> <div class="kpi-row">
<span class="kpi-row-label">open</span> <span class="kpi-row-label">active</span>
<div class="kpi-row-right"><div class="kpi-row-value">${_open.length}</div></div> <div class="kpi-row-right"><div class="kpi-row-value" style="color:${_active.length > 0 ? '#6366f1' : 'inherit'}">${_active.length}</div></div>
</div> </div>
<div class="kpi-row"> <div class="kpi-row">
<span class="kpi-row-label">resolved</span> <span class="kpi-row-label">finished</span>
<div class="kpi-row-right"><div class="kpi-row-value">${_resolved.length}</div></div> <div class="kpi-row-right"><div class="kpi-row-value">${_finished.length}</div></div>
</div> </div>
<div class="kpi-row"> <div class="kpi-row">
<span class="kpi-row-label">won't fix</span> <span class="kpi-row-label">won't fix</span>
<div class="kpi-row-right"><div class="kpi-row-value">${_wontFix.length}</div></div> <div class="kpi-row-right"><div class="kpi-row-value">${_wontfix.length}</div></div>
</div>
<div class="kpi-row" style="border-top:1px solid var(--theme-foreground-faint,#eee)">
<span class="kpi-row-label">total</span>
<div class="kpi-row-right"><div class="kpi-row-value">${data.length}</div></div>
</div> </div>
</div>`; </div>`;
@@ -71,84 +106,146 @@ const _liveEl = html`<div class="live-indicator">
? `Live · updated ${_ts?.toLocaleTimeString()}` ? `Live · updated ${_ts?.toLocaleTimeString()}`
: html`<span style="color:red">Offline — run: <code>make api</code></span>`} : html`<span style="color:red">Offline — run: <code>make api</code></span>`}
</div>`; </div>`;
withDocHelp(_liveEl, "/docs/live-data");
injectTocTop("fb-kpi-box", _kpiBox); injectTocTop("fb-kpi-box", _kpiBox);
injectTocTop("live-indicator", _liveEl); injectTocTop("live-indicator", _liveEl);
``` ```
> Right-click any widget or section on any dashboard page to submit a suggestion. > Shift+click any widget on any dashboard page to submit a suggestion.
> Items appear here for review and can be resolved or dismissed. > 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 ```js
async function _setStatus(td_id, status) { async function _advance(td, newStatus) {
try { return fetch(`${API}/technical-debt/${td.id}`, {
const r = await fetch(`${API}/technical-debt/${td_id}`, { method: "PATCH",
method: "PATCH", headers: {"Content-Type": "application/json"},
headers: {"Content-Type": "application/json"}, body: JSON.stringify({status: newStatus}),
body: JSON.stringify({status}), });
});
if (!r.ok) alert(`Failed to update status (HTTP ${r.status})`);
} catch {
alert("API unreachable");
}
} }
```
```js async function _addNote(td_id, step, content) {
const _openItems = data.filter(t => t.status === "open" || t.status === "in_progress"); return fetch(`${API}/technical-debt/${td_id}/notes/`, {
method: "POST",
if (_openItems.length === 0) { headers: {"Content-Type": "application/json"},
display(html`<p class="dim">No open suggestions. Right-click any dashboard widget to submit one.</p>`); body: JSON.stringify({step, content, author: "custodian"}),
} else { });
display(html`<div class="fb-list">${_openItems.map(t => {
const card = html`<div class="fb-card fb-status-${t.status}">
<div class="fb-card-header">
${t.td_id ? html`<span class="td-ref">${t.td_id}</span>` : ""}
<span class="td-badge td-badge-${t.status}">${t.status.replace("_", " ")}</span>
<span class="fb-location">${t.location ?? ""}</span>
<div class="fb-actions">
<button class="fb-btn fb-btn-resolve"
onclick=${async () => { await _setStatus(t.id, "resolved"); card.remove(); }}>
✓ resolve
</button>
<button class="fb-btn fb-btn-wontfix"
onclick=${async () => { await _setStatus(t.id, "wont_fix"); card.remove(); }}>
✕ won't fix
</button>
${t.status === "open" ? html`<button class="fb-btn fb-btn-inprogress"
onclick=${async () => { await _setStatus(t.id, "in_progress"); card.style.opacity = "0.7"; }}>
→ in progress
</button>` : ""}
</div>
</div>
<div class="fb-suggestion">${t.description}</div>
</div>`;
return card;
})}</div>`);
} }
```
## 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 // ── Stepper ─────────────────────────────────────────────────────────
const _doneItems = data.filter(t => t.status === "resolved" || t.status === "wont_fix"); const stepperEl = html`<div class="fb-stepper">
${STEPS.map((s, i) => {
if (_doneItems.length === 0) { const done = i < stepIdx;
display(html`<p class="dim">Nothing resolved yet.</p>`); const current = i === stepIdx;
} else { return html`<div class="fb-step ${done ? 'fb-step-done' : ''} ${current ? 'fb-step-current' : ''}">
display(html`<div class="fb-list fb-done">${_doneItems.map(t => html` <div class="fb-step-dot" style="${current ? `background:${color};border-color:${color}` : done ? 'background:#16a34a;border-color:#16a34a' : ''}"></div>
<div class="fb-card fb-status-${t.status}"> <div class="fb-step-label">${STEP_LABEL[s]}</div>
<div class="fb-card-header">
${t.td_id ? html`<span class="td-ref">${t.td_id}</span>` : ""}
<span class="td-badge td-badge-${t.status}">${t.status.replace("_", " ")}</span>
<span class="fb-location">${t.location ?? ""}</span>
</div> </div>
<div class="fb-suggestion">${t.description}</div> ${i < STEPS.length - 1 ? html`<div class="fb-step-line ${done || current && i < stepIdx ? 'fb-step-line-done' : ''}"></div>` : ""}`;
})}
</div>`;
// ── Notes ────────────────────────────────────────────────────────────
const notesEl = html`<div class="fb-notes">
${(t.notes ?? []).length === 0
? html`<p class="fb-no-notes">No notes yet for this step.</p>`
: (t.notes ?? []).map(n => html`<div class="fb-note">
<span class="fb-note-step" style="background:${STEP_COLOR[n.step] ?? '#94a3b8'}20;color:${STEP_COLOR[n.step] ?? '#64748b'}">${STEP_LABEL[n.step] ?? n.step}</span>
<span class="fb-note-author">${n.author ?? "anon"}</span>
<span class="fb-note-time">${new Date(n.created_at).toLocaleString()}</span>
<div class="fb-note-content">${n.content}</div>
</div>`)}
</div>`;
// ── Add note form ────────────────────────────────────────────────────
const noteTA = html`<textarea class="fb-note-ta" rows="2" placeholder="Add a note for this step…"></textarea>`;
const noteBtn = html`<button class="fb-btn fb-btn-note">+ note</button>`;
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`<div class="fb-note-form">${noteTA}${noteBtn}</div>`;
// ── Actions ──────────────────────────────────────────────────────────
const actionsEl = html`<div class="fb-actions">
${t.status === "review"
? html`<span class="fb-review-hint">⬆ Awaiting confirmation from the original suggester</span>`
: ""}
${next ? html`<button class="fb-btn fb-btn-advance" style="border-color:${STEP_COLOR[next]};color:${STEP_COLOR[next]}"
onclick=${async (e) => { e.currentTarget.disabled = true; await _advance(t, next); }}>
${STEP_LABEL[next]}
</button>` : ""}
<button class="fb-btn fb-btn-wontfix"
onclick=${async (e) => { e.currentTarget.disabled = true; await _advance(t, "wont_fix"); }}>
✕ won't fix
</button>
</div>`;
return html`<div class="fb-card" style="border-left-color:${color}">
<div class="fb-card-header">
${t.td_id ? html`<span class="td-ref">${t.td_id}</span>` : ""}
<span class="fb-step-badge" style="background:${color}20;color:${color}">${STEP_LABEL[t.status] ?? t.status}</span>
<span class="fb-location">${t.location ?? ""}</span>
</div> </div>
`)}</div>`); <div class="fb-title">${t.title.replace(/^UI:\s*/, "")}</div>
<div class="fb-suggestion">${t.description ?? ""}</div>
${stepperEl}
<details class="fb-notes-section" open>
<summary class="fb-notes-toggle">Notes (${(t.notes ?? []).length})</summary>
${notesEl}
${noteForm}
</details>
${actionsEl}
</div>`;
}
if (_active.length === 0) {
display(html`<p class="dim">No active suggestions. Shift+click any dashboard widget to submit one.</p>`);
} else {
display(html`<div class="fb-list">${_active.map(renderSuggestionCard)}</div>`);
}
```
## Finished & Won't Fix
```js
const _closed = [..._finished, ..._wontfix];
if (_closed.length === 0) {
display(html`<p class="dim">Nothing closed yet.</p>`);
} else {
display(html`<div class="fb-list fb-list-closed">${_closed.map(t => {
const color = STEP_COLOR[t.status] ?? "#94a3b8";
return html`<div class="fb-card fb-card-closed" style="border-left-color:${color}">
<div class="fb-card-header">
${t.td_id ? html`<span class="td-ref">${t.td_id}</span>` : ""}
<span class="fb-step-badge" style="background:${color}20;color:${color}">${STEP_LABEL[t.status] ?? t.status}</span>
<span class="fb-location">${t.location ?? ""}</span>
</div>
<div class="fb-title">${t.title.replace(/^UI:\s*/, "")}</div>
<div class="fb-suggestion">${t.description ?? ""}</div>
${(t.notes ?? []).length > 0 ? html`<div class="fb-notes fb-notes-compact">
${t.notes.slice(-2).map(n => html`<div class="fb-note">
<span class="fb-note-step" style="background:${STEP_COLOR[n.step] ?? '#94a3b8'}20;color:${STEP_COLOR[n.step] ?? '#64748b'}">${STEP_LABEL[n.step] ?? n.step}</span>
<div class="fb-note-content">${n.content}</div>
</div>`)}
</div>` : ""}
</div>`;
})}</div>`);
} }
``` ```
@@ -160,52 +257,55 @@ if (_doneItems.length === 0) {
.kpi-row-right { text-align: right; } .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; } .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; } /* ── Cards ─────────────────────────────────────────────────────────── */
.fb-done { opacity: 0.7; } .fb-list { display: flex; flex-direction: column; gap: 1rem; }
.fb-list-closed { opacity: 0.72; }
.fb-card { .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; }
border-left: 3px solid #6366f1; .fb-card-closed { gap: 0.4rem; }
border-radius: 0 8px 8px 0; .fb-card-header { display: flex; flex-wrap: wrap; align-items: center; gap: 0.45rem; }
background: var(--theme-background-alt); .fb-location { font-size: 0.72rem; color: var(--theme-foreground-faint); font-style: italic; flex: 1; }
padding: 0.7rem 1rem; .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-status-resolved { border-left-color: #16a34a; } .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; }
.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 */
.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-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; } /* ── Stepper ────────────────────────────────────────────────────────── */
.td-badge-in_progress { background: #fef3c7; color: #92400e; } .fb-stepper { display: flex; align-items: center; flex-wrap: nowrap; overflow-x: auto; padding: 0.5rem 0; gap: 0; }
.td-badge-resolved { background: #dcfce7; color: #166534; } .fb-step { display: flex; flex-direction: column; align-items: center; flex-shrink: 0; min-width: 54px; }
.td-badge-wont_fix { background: #f3f4f6; color: #9ca3af; } .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; } .dim { color: gray; font-style: italic; }
</style> </style>

View File

@@ -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.