docs(12): create phase plan
This commit is contained in:
@@ -83,7 +83,9 @@ Plans:
|
|||||||
2. The lightest candidate column is highlighted and all other columns show their weight difference relative to it; the cheapest candidate is highlighted similarly for price
|
2. The lightest candidate column is highlighted and all other columns show their weight difference relative to it; the cheapest candidate is highlighted similarly for price
|
||||||
3. The comparison table scrolls horizontally on a narrow viewport without breaking layout; the attribute label column stays fixed on the left
|
3. The comparison table scrolls horizontally on a narrow viewport without breaking layout; the attribute label column stays fixed on the left
|
||||||
4. A resolved thread shows the comparison table in read-only mode with the winning candidate visually marked
|
4. A resolved thread shows the comparison table in read-only mode with the winning candidate visually marked
|
||||||
**Plans**: TBD
|
**Plans:** 1 plan
|
||||||
|
Plans:
|
||||||
|
- [ ] 12-01-PLAN.md — ComparisonTable component + compare toggle wiring in thread detail
|
||||||
|
|
||||||
### Phase 13: Setup Impact Preview
|
### Phase 13: Setup Impact Preview
|
||||||
**Goal**: Users can select any setup and see exactly how much weight and cost each candidate would add or subtract
|
**Goal**: Users can select any setup and see exactly how much weight and cost each candidate would add or subtract
|
||||||
@@ -111,5 +113,5 @@ Plans:
|
|||||||
| 9. Weight Classification and Visualization | v1.2 | 2/2 | Complete | 2026-03-16 |
|
| 9. Weight Classification and Visualization | v1.2 | 2/2 | Complete | 2026-03-16 |
|
||||||
| 10. Schema Foundation + Pros/Cons Fields | v1.3 | 1/1 | Complete | 2026-03-16 |
|
| 10. Schema Foundation + Pros/Cons Fields | v1.3 | 1/1 | Complete | 2026-03-16 |
|
||||||
| 11. Candidate Ranking | 2/2 | Complete | 2026-03-16 | - |
|
| 11. Candidate Ranking | 2/2 | Complete | 2026-03-16 | - |
|
||||||
| 12. Comparison View | v1.3 | 0/TBD | Not started | - |
|
| 12. Comparison View | v1.3 | 0/1 | Not started | - |
|
||||||
| 13. Setup Impact Preview | v1.3 | 0/TBD | Not started | - |
|
| 13. Setup Impact Preview | v1.3 | 0/TBD | Not started | - |
|
||||||
|
|||||||
321
.planning/phases/12-comparison-view/12-01-PLAN.md
Normal file
321
.planning/phases/12-comparison-view/12-01-PLAN.md
Normal file
@@ -0,0 +1,321 @@
|
|||||||
|
---
|
||||||
|
phase: 12-comparison-view
|
||||||
|
plan: 01
|
||||||
|
type: execute
|
||||||
|
wave: 1
|
||||||
|
depends_on: []
|
||||||
|
files_modified:
|
||||||
|
- src/client/components/ComparisonTable.tsx
|
||||||
|
- src/client/stores/uiStore.ts
|
||||||
|
- src/client/routes/threads/$threadId.tsx
|
||||||
|
autonomous: true
|
||||||
|
requirements: [COMP-01, COMP-02, COMP-03, COMP-04]
|
||||||
|
|
||||||
|
must_haves:
|
||||||
|
truths:
|
||||||
|
- "User can toggle to a Compare view when a thread has 2+ candidates"
|
||||||
|
- "Comparison table shows all candidates side-by-side with weight, price, images, notes, links, status, pros, and cons"
|
||||||
|
- "The lightest candidate weight cell has a blue highlight; the cheapest candidate price cell has a green highlight"
|
||||||
|
- "Non-best cells show a gray +delta string; best cells show no delta"
|
||||||
|
- "The table scrolls horizontally on narrow viewports while the attribute label column stays fixed on the left"
|
||||||
|
- "Missing weight or price data displays a dash, never a misleading zero"
|
||||||
|
- "A resolved thread shows the comparison read-only with the winner column visually marked (amber tint + trophy)"
|
||||||
|
artifacts:
|
||||||
|
- path: "src/client/components/ComparisonTable.tsx"
|
||||||
|
provides: "Tabular side-by-side comparison component"
|
||||||
|
min_lines: 120
|
||||||
|
- path: "src/client/stores/uiStore.ts"
|
||||||
|
provides: "Extended candidateViewMode union type including 'compare'"
|
||||||
|
contains: "compare"
|
||||||
|
- path: "src/client/routes/threads/$threadId.tsx"
|
||||||
|
provides: "Compare toggle button and ComparisonTable rendering branch"
|
||||||
|
contains: "ComparisonTable"
|
||||||
|
key_links:
|
||||||
|
- from: "src/client/routes/threads/$threadId.tsx"
|
||||||
|
to: "src/client/components/ComparisonTable.tsx"
|
||||||
|
via: "import and conditional render when candidateViewMode === 'compare'"
|
||||||
|
pattern: "candidateViewMode.*compare"
|
||||||
|
- from: "src/client/components/ComparisonTable.tsx"
|
||||||
|
to: "src/client/lib/formatters.ts"
|
||||||
|
via: "formatWeight and formatPrice for cell values and delta strings"
|
||||||
|
pattern: "formatWeight|formatPrice"
|
||||||
|
- from: "src/client/components/ComparisonTable.tsx"
|
||||||
|
to: "src/client/components/CandidateListItem.tsx"
|
||||||
|
via: "RankBadge import for rank row"
|
||||||
|
pattern: "RankBadge"
|
||||||
|
- from: "src/client/routes/threads/$threadId.tsx"
|
||||||
|
to: "src/client/stores/uiStore.ts"
|
||||||
|
via: "candidateViewMode state read and setCandidateViewMode action"
|
||||||
|
pattern: "candidateViewMode"
|
||||||
|
---
|
||||||
|
|
||||||
|
<objective>
|
||||||
|
Build the side-by-side candidate comparison table for research threads. Users toggle into compare mode from the existing view-mode bar and see all candidates as columns in a horizontally-scrollable table with sticky attribute labels, weight/price delta highlighting, and resolved-thread winner marking.
|
||||||
|
|
||||||
|
Purpose: Enables users to directly compare candidates on weight, price, status, notes, pros, and cons without switching between cards -- the key decision-support view for the Research & Decision Tools milestone.
|
||||||
|
|
||||||
|
Output: One new component (`ComparisonTable.tsx`), two modified files (`uiStore.ts`, `$threadId.tsx`).
|
||||||
|
</objective>
|
||||||
|
|
||||||
|
<execution_context>
|
||||||
|
@/home/jlmak/.claude/get-shit-done/workflows/execute-plan.md
|
||||||
|
@/home/jlmak/.claude/get-shit-done/templates/summary.md
|
||||||
|
</execution_context>
|
||||||
|
|
||||||
|
<context>
|
||||||
|
@.planning/PROJECT.md
|
||||||
|
@.planning/ROADMAP.md
|
||||||
|
@.planning/STATE.md
|
||||||
|
@.planning/phases/12-comparison-view/12-CONTEXT.md
|
||||||
|
@.planning/phases/12-comparison-view/12-RESEARCH.md
|
||||||
|
|
||||||
|
@src/client/stores/uiStore.ts
|
||||||
|
@src/client/routes/threads/$threadId.tsx
|
||||||
|
@src/client/hooks/useThreads.ts
|
||||||
|
@src/client/lib/formatters.ts
|
||||||
|
@src/client/components/CandidateListItem.tsx
|
||||||
|
|
||||||
|
<interfaces>
|
||||||
|
<!-- Key types and contracts the executor needs. Extracted from codebase. -->
|
||||||
|
|
||||||
|
From src/client/hooks/useThreads.ts:
|
||||||
|
```typescript
|
||||||
|
interface CandidateWithCategory {
|
||||||
|
id: number;
|
||||||
|
threadId: number;
|
||||||
|
name: string;
|
||||||
|
weightGrams: number | null;
|
||||||
|
priceCents: number | null;
|
||||||
|
categoryId: number;
|
||||||
|
notes: string | null;
|
||||||
|
productUrl: string | null;
|
||||||
|
imageFilename: string | null;
|
||||||
|
status: "researching" | "ordered" | "arrived";
|
||||||
|
pros: string | null;
|
||||||
|
cons: string | null;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
categoryName: string;
|
||||||
|
categoryIcon: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ThreadWithCandidates {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
status: "active" | "resolved";
|
||||||
|
resolvedCandidateId: number | null;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
candidates: CandidateWithCategory[];
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
From src/client/lib/formatters.ts:
|
||||||
|
```typescript
|
||||||
|
export type WeightUnit = "g" | "oz" | "lb" | "kg";
|
||||||
|
export type Currency = "USD" | "EUR" | "GBP" | "JPY" | "CAD" | "AUD";
|
||||||
|
export function formatWeight(grams: number | null | undefined, unit?: WeightUnit): string; // returns "--" for null
|
||||||
|
export function formatPrice(cents: number | null | undefined, currency?: Currency): string; // returns "--" for null
|
||||||
|
```
|
||||||
|
|
||||||
|
From src/client/components/CandidateListItem.tsx:
|
||||||
|
```typescript
|
||||||
|
export function RankBadge({ rank }: { rank: number }): JSX.Element | null;
|
||||||
|
// Returns null for rank > 3, renders gold/silver/bronze medal icon for 1/2/3
|
||||||
|
```
|
||||||
|
|
||||||
|
From src/client/stores/uiStore.ts (lines 52-54, current state):
|
||||||
|
```typescript
|
||||||
|
// Current type (will be extended):
|
||||||
|
candidateViewMode: "list" | "grid";
|
||||||
|
setCandidateViewMode: (mode: "list" | "grid") => void;
|
||||||
|
```
|
||||||
|
|
||||||
|
From src/client/lib/iconData.tsx:
|
||||||
|
```typescript
|
||||||
|
export function LucideIcon({ name, size, className, style }: LucideIconProps): JSX.Element;
|
||||||
|
// Renders any Lucide icon by kebab-case name string
|
||||||
|
```
|
||||||
|
|
||||||
|
From src/client/hooks/useWeightUnit.ts:
|
||||||
|
```typescript
|
||||||
|
export function useWeightUnit(): WeightUnit; // reads from settings API
|
||||||
|
```
|
||||||
|
|
||||||
|
From src/client/hooks/useCurrency.ts:
|
||||||
|
```typescript
|
||||||
|
export function useCurrency(): Currency; // reads from settings API
|
||||||
|
```
|
||||||
|
</interfaces>
|
||||||
|
</context>
|
||||||
|
|
||||||
|
<tasks>
|
||||||
|
|
||||||
|
<task type="auto">
|
||||||
|
<name>Task 1: Build ComparisonTable component</name>
|
||||||
|
<files>src/client/components/ComparisonTable.tsx</files>
|
||||||
|
<action>
|
||||||
|
Create `src/client/components/ComparisonTable.tsx` — a self-contained comparison table component.
|
||||||
|
|
||||||
|
**Props interface:**
|
||||||
|
```typescript
|
||||||
|
interface ComparisonTableProps {
|
||||||
|
candidates: CandidateWithCategory[];
|
||||||
|
resolvedCandidateId: number | null;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
Import `CandidateWithCategory` type inline (duplicate the interface locally or import from useThreads — match project convention for component-local interfaces as seen in CandidateListItem.tsx which declares its own `CandidateWithCategory`).
|
||||||
|
|
||||||
|
**Delta computation (useMemo):**
|
||||||
|
- Weight deltas: Filter candidates with non-null `weightGrams`. Find the minimum. For each candidate, compute `delta = weightGrams - min`. If `delta === 0` (this IS the best), store `null` as delta string. Otherwise store `+${formatWeight(delta, unit)}`. Track `bestWeightId`. If all candidates have null weight, `bestWeightId = null`.
|
||||||
|
- Price deltas: Same logic for `priceCents` with `formatPrice(delta, currency)`. Track `bestPriceId`.
|
||||||
|
- Use `useWeightUnit()` and `useCurrency()` hooks for unit/currency-aware formatting.
|
||||||
|
|
||||||
|
**Table structure:**
|
||||||
|
- Outer: `<div className="overflow-x-auto rounded-xl border border-gray-100">` (scroll wrapper)
|
||||||
|
- Inner: `<table>` with `style={{ minWidth: Math.max(400, candidates.length * 180) + 'px' }}` and `className="border-collapse text-sm w-full"`
|
||||||
|
- `<thead>`: One `<tr>` with sticky corner `<th>` (empty, for label column) + one `<th>` per candidate showing name. If `candidate.id === resolvedCandidateId`, apply `bg-amber-50 text-amber-800` and prepend a trophy icon: `<LucideIcon name="trophy" size={12} className="text-amber-600" />`.
|
||||||
|
- `<tbody>`: Render rows using a declarative ATTRIBUTE_ROWS array (see below).
|
||||||
|
|
||||||
|
**Sticky left column CSS (CRITICAL):**
|
||||||
|
Every `<td>` and `<th>` in the first (label) column MUST have: `sticky left-0 z-10 bg-white`. Without `bg-white`, scrolled content bleeds through. Use `z-10` (not higher — avoid conflicts with panels/modals).
|
||||||
|
|
||||||
|
**Attribute row order** (per locked decision): Image, Name, Rank, Weight (with delta), Price (with delta), Status, Product Link, Notes, Pros, Cons.
|
||||||
|
|
||||||
|
**Row rendering — use a declarative array pattern:**
|
||||||
|
Define `ATTRIBUTE_ROWS` as an array of `{ key, label, render(candidate) }`. This keeps the JSX clean and makes row reordering trivial. Build this array inside the component function body (after useMemo hooks) so it can close over `weightDeltas`, `priceDeltas`, `bestWeightId`, `bestPriceId`, `unit`, `currency`.
|
||||||
|
|
||||||
|
**Cell renderers:**
|
||||||
|
- **Image**: 48x48 rounded-lg container. If `imageFilename`, render `<img src="/uploads/${imageFilename}" />` with `object-cover`. Else render `<LucideIcon name={categoryIcon} size={20} className="text-gray-400" />` in a `bg-gray-50` placeholder. Use `w-12 h-12` sizing.
|
||||||
|
- **Name**: `<span className="text-sm font-medium text-gray-900">{name}</span>`
|
||||||
|
- **Rank**: Reuse `<RankBadge rank={index + 1} />` imported from CandidateListItem. Rank is derived from array position (candidates are already sorted by sort_order from the API).
|
||||||
|
- **Weight**: Show `formatWeight(weightGrams, unit)` as primary value in `font-medium text-gray-900`. If this is the best (`isBest`), apply `bg-blue-50` to the `<td>`. If delta string exists (not null, not best), show delta below in `text-xs text-gray-400`. If `weightGrams` is null, show `<span className="text-gray-300">—</span>`.
|
||||||
|
- **Price**: Same pattern as weight but with `formatPrice(priceCents, currency)` and `bg-green-50` for the best cell.
|
||||||
|
- **Status**: Render as static text `<span className="text-xs text-gray-600">{STATUS_LABELS[status]}</span>`. Define STATUS_LABELS map: `{ researching: "Researching", ordered: "Ordered", arrived: "Arrived" }`. No click-to-cycle in compare view — comparison is for reading, not mutation.
|
||||||
|
- **Product Link**: If `productUrl` exists, render a clickable link that calls `openExternalLink(productUrl)` from uiStore: `<button onClick={() => openExternalLink(productUrl)} className="text-xs text-blue-500 hover:underline">View</button>`. If null, render `<span className="text-gray-300">—</span>`. Links remain clickable even in resolved threads (read-only means no mutations, but navigation is fine).
|
||||||
|
- **Notes**: If `notes` exists, render `<p className="text-xs text-gray-700 whitespace-pre-line">{notes}</p>` (whitespace-pre-line preserves newlines). If null, render em dash placeholder.
|
||||||
|
- **Pros**: If `pros` exists, split on `"\n"`, filter empty strings, render as `<ul className="list-disc list-inside space-y-0.5">` with `<li className="text-xs text-gray-700">` items. If null, render em dash placeholder.
|
||||||
|
- **Cons**: Same as Pros rendering.
|
||||||
|
|
||||||
|
**Winner column highlight (resolved threads):**
|
||||||
|
When `resolvedCandidateId` is set, the winner's `<th>` in the header gets `bg-amber-50 text-amber-800` + trophy icon. Each body `<td>` for the winner column gets a subtle `bg-amber-50/50` tint (half-opacity amber). This must not conflict with the best-weight/best-price blue/green highlights — when both apply (winner IS also lightest), use the weight/price highlight color (it's more informative).
|
||||||
|
|
||||||
|
**Row styling:**
|
||||||
|
- Each `<tr>` gets `border-b border-gray-50` for subtle row separation.
|
||||||
|
- Label `<td>` cells: `text-xs font-medium text-gray-500 uppercase tracking-wide w-28`.
|
||||||
|
- Data `<td>` cells: `px-4 py-3 min-w-[160px]`.
|
||||||
|
- Header `<th>` cells: `px-4 py-3 text-left text-xs font-medium text-gray-700 min-w-[160px]`.
|
||||||
|
|
||||||
|
**Table border + rounding:**
|
||||||
|
The outer wrapper has `rounded-xl border border-gray-100`. Add `overflow-hidden` to the wrapper alongside `overflow-x-auto` to clip the table's corners to the rounded border: `className="overflow-x-auto overflow-hidden rounded-xl border border-gray-100"`. Actually, use `overflow-x-auto` on an outer div, and put the border/rounding there. The table itself does not need border-radius.
|
||||||
|
</action>
|
||||||
|
<verify>
|
||||||
|
<automated>cd /home/jlmak/Projects/jlmak/GearBox && bun run lint 2>&1 | head -20</automated>
|
||||||
|
</verify>
|
||||||
|
<done>ComparisonTable.tsx exists with all 10 attribute rows, delta computation via useMemo, sticky left column with bg-white, horizontal scroll wrapper, blue/green best-cell highlights, gray delta text for non-best, amber winner column for resolved threads, em dash for missing data (never zero).</done>
|
||||||
|
</task>
|
||||||
|
|
||||||
|
<task type="auto">
|
||||||
|
<name>Task 2: Wire compare toggle and ComparisonTable into thread detail</name>
|
||||||
|
<files>src/client/stores/uiStore.ts, src/client/routes/threads/$threadId.tsx</files>
|
||||||
|
<action>
|
||||||
|
**Step 1: Extend uiStore candidateViewMode (src/client/stores/uiStore.ts)**
|
||||||
|
|
||||||
|
Change the type union on lines 53-54 from:
|
||||||
|
```typescript
|
||||||
|
candidateViewMode: "list" | "grid";
|
||||||
|
setCandidateViewMode: (mode: "list" | "grid") => void;
|
||||||
|
```
|
||||||
|
to:
|
||||||
|
```typescript
|
||||||
|
candidateViewMode: "list" | "grid" | "compare";
|
||||||
|
setCandidateViewMode: (mode: "list" | "grid" | "compare") => void;
|
||||||
|
```
|
||||||
|
|
||||||
|
No other changes needed in uiStore — the implementation lines 112-113 are generic and already work with the wider type.
|
||||||
|
|
||||||
|
**Step 2: Add compare toggle button (src/client/routes/threads/$threadId.tsx)**
|
||||||
|
|
||||||
|
In the toolbar toggle bar (the `<div className="flex items-center gap-1 bg-gray-100 rounded-lg p-0.5">` block around line 146), add a third button for compare mode. The compare button should only render when `thread.candidates.length >= 2` (per locked decision).
|
||||||
|
|
||||||
|
Add the compare button after the grid button but inside the toggle container:
|
||||||
|
```tsx
|
||||||
|
{thread.candidates.length >= 2 && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setCandidateViewMode("compare")}
|
||||||
|
className={`p-1.5 rounded-md transition-colors ${
|
||||||
|
candidateViewMode === "compare"
|
||||||
|
? "bg-gray-200 text-gray-900"
|
||||||
|
: "text-gray-400 hover:text-gray-600"
|
||||||
|
}`}
|
||||||
|
title="Compare view"
|
||||||
|
>
|
||||||
|
<LucideIcon name="columns-3" size={16} />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
```
|
||||||
|
|
||||||
|
Also: Hide the "Add Candidate" button when in compare view. Change the existing `{isActive && (` guard (around line 123) to `{isActive && candidateViewMode !== "compare" && (`. This keeps the toolbar uncluttered — users switch to list/grid to add candidates.
|
||||||
|
|
||||||
|
**Step 3: Add ComparisonTable rendering branch ($threadId.tsx)**
|
||||||
|
|
||||||
|
Import ComparisonTable at the top of the file:
|
||||||
|
```typescript
|
||||||
|
import { ComparisonTable } from "../../components/ComparisonTable";
|
||||||
|
```
|
||||||
|
|
||||||
|
In the candidates rendering section (starting around line 192), add a compare branch BEFORE the existing list check:
|
||||||
|
```tsx
|
||||||
|
) : candidateViewMode === "compare" ? (
|
||||||
|
<ComparisonTable
|
||||||
|
candidates={displayItems}
|
||||||
|
resolvedCandidateId={thread.resolvedCandidateId}
|
||||||
|
/>
|
||||||
|
) : candidateViewMode === "list" ? (
|
||||||
|
// ... existing list rendering (unchanged)
|
||||||
|
) : (
|
||||||
|
// ... existing grid rendering (unchanged)
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
Pass `displayItems` (not `thread.candidates`) so the order reflects any pending drag reorder state, though in compare mode drag is not active — `displayItems` will equal `thread.candidates` when `tempItems` is null.
|
||||||
|
</action>
|
||||||
|
<verify>
|
||||||
|
<automated>cd /home/jlmak/Projects/jlmak/GearBox && bun run lint 2>&1 | head -20 && bun test 2>&1 | tail -5</automated>
|
||||||
|
</verify>
|
||||||
|
<done>uiStore accepts "compare" as a candidateViewMode value. Thread detail page shows a third toggle icon (columns-3) when 2+ candidates exist. Clicking it renders ComparisonTable. "Add Candidate" button is hidden in compare mode. Existing list/grid views still work unchanged. All existing tests pass.</done>
|
||||||
|
</task>
|
||||||
|
|
||||||
|
</tasks>
|
||||||
|
|
||||||
|
<verification>
|
||||||
|
1. `bun run lint` passes with no errors
|
||||||
|
2. `bun test` full suite passes (no backend changes, existing tests unaffected)
|
||||||
|
3. Manual browser verification:
|
||||||
|
- Navigate to a thread with 2+ candidates
|
||||||
|
- Verify the compare icon (columns-3) appears in the toggle bar
|
||||||
|
- Click compare icon -> tabular comparison renders with candidates as columns
|
||||||
|
- Verify attribute row order: Image, Name, Rank, Weight, Price, Status, Link, Notes, Pros, Cons
|
||||||
|
- Verify lightest weight cell has blue-50 tint, cheapest price cell has green-50 tint
|
||||||
|
- Verify non-best cells show gray +delta text
|
||||||
|
- Verify missing weight/price shows em dash (not zero)
|
||||||
|
- Resize viewport narrow -> table scrolls horizontally, label column stays fixed
|
||||||
|
- Navigate to a resolved thread -> winner column has amber tint + trophy, no mutation controls
|
||||||
|
- Toggle back to list/grid views -> they still work correctly
|
||||||
|
- Thread with 0 or 1 candidate -> compare icon does not appear
|
||||||
|
</verification>
|
||||||
|
|
||||||
|
<success_criteria>
|
||||||
|
- ComparisonTable.tsx renders all 10 attribute rows with correct data
|
||||||
|
- Delta highlighting: blue-50 on lightest weight, green-50 on cheapest price, gray delta text on non-best
|
||||||
|
- Sticky label column with solid bg-white stays visible during horizontal scroll
|
||||||
|
- Resolved threads show winner column with amber-50 tint and trophy icon
|
||||||
|
- Missing data renders as em dash, never as zero (COMP-04)
|
||||||
|
- Compare toggle icon appears only when >= 2 candidates
|
||||||
|
- All existing tests continue to pass
|
||||||
|
</success_criteria>
|
||||||
|
|
||||||
|
<output>
|
||||||
|
After completion, create `.planning/phases/12-comparison-view/12-01-SUMMARY.md`
|
||||||
|
</output>
|
||||||
Reference in New Issue
Block a user