640 lines
26 KiB
HTML
640 lines
26 KiB
HTML
<!-- Copyright 2026 Anthropic PBC · SPDX-License-Identifier: Apache-2.0 -->
|
||
<!DOCTYPE html>
|
||
<html lang="en">
|
||
<head>
|
||
<meta charset="UTF-8">
|
||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||
<title>PR #247 — Review Summary</title>
|
||
<style>
|
||
:root {
|
||
--ivory: #FAF9F5;
|
||
--slate: #141413;
|
||
--clay: #D97757;
|
||
--oat: #E3DACC;
|
||
--olive: #788C5D;
|
||
--rust: #B04A3F;
|
||
--gray-150: #F0EEE6;
|
||
--gray-300: #D1CFC5;
|
||
--gray-500: #87867F;
|
||
--gray-700: #3D3D3A;
|
||
--serif: ui-serif, Georgia, 'Times New Roman', serif;
|
||
--sans: system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif;
|
||
--mono: ui-monospace, 'SF Mono', Menlo, Monaco, monospace;
|
||
}
|
||
|
||
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||
|
||
html { scroll-behavior: smooth; }
|
||
|
||
body {
|
||
background: var(--ivory);
|
||
color: var(--gray-700);
|
||
font-family: var(--sans);
|
||
font-size: 15px;
|
||
line-height: 1.6;
|
||
padding: 48px 24px 80px;
|
||
}
|
||
|
||
.page {
|
||
max-width: 920px;
|
||
margin: 0 auto;
|
||
}
|
||
|
||
/* ---------- Header ---------- */
|
||
header.pr-head {
|
||
border: 1.5px solid var(--gray-300);
|
||
border-radius: 12px;
|
||
padding: 28px 32px;
|
||
background: #fff;
|
||
margin-bottom: 36px;
|
||
}
|
||
|
||
.repo-line {
|
||
font-family: var(--mono);
|
||
font-size: 12.5px;
|
||
color: var(--gray-500);
|
||
letter-spacing: 0.01em;
|
||
margin-bottom: 10px;
|
||
}
|
||
|
||
h1 {
|
||
font-family: var(--serif);
|
||
font-weight: 500;
|
||
font-size: 30px;
|
||
line-height: 1.25;
|
||
color: var(--slate);
|
||
margin-bottom: 18px;
|
||
}
|
||
|
||
.meta-row {
|
||
display: flex;
|
||
align-items: center;
|
||
flex-wrap: wrap;
|
||
gap: 20px;
|
||
}
|
||
|
||
.author {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 10px;
|
||
}
|
||
|
||
.avatar {
|
||
width: 36px;
|
||
height: 36px;
|
||
border-radius: 50%;
|
||
background: var(--oat);
|
||
color: var(--slate);
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
font-weight: 600;
|
||
font-size: 13px;
|
||
letter-spacing: 0.02em;
|
||
border: 1.5px solid var(--gray-300);
|
||
}
|
||
|
||
.author-name { font-weight: 500; color: var(--slate); }
|
||
.author-sub { font-size: 12px; color: var(--gray-500); }
|
||
|
||
.branch {
|
||
font-family: var(--mono);
|
||
font-size: 12.5px;
|
||
color: var(--gray-700);
|
||
background: var(--gray-150);
|
||
border: 1.5px solid var(--gray-300);
|
||
border-radius: 8px;
|
||
padding: 6px 10px;
|
||
}
|
||
.branch .arrow { color: var(--gray-500); margin: 0 6px; }
|
||
|
||
.stat {
|
||
font-family: var(--mono);
|
||
font-size: 13px;
|
||
}
|
||
.stat .add { color: var(--olive); font-weight: 600; }
|
||
.stat .del { color: var(--rust); font-weight: 600; }
|
||
.stat .files { color: var(--gray-500); margin-left: 10px; }
|
||
|
||
/* ---------- Section blocks ---------- */
|
||
section { margin-bottom: 40px; }
|
||
|
||
h2 {
|
||
font-family: var(--serif);
|
||
font-weight: 500;
|
||
font-size: 21px;
|
||
color: var(--slate);
|
||
margin-bottom: 14px;
|
||
}
|
||
|
||
.prose ul {
|
||
list-style: none;
|
||
padding: 0;
|
||
}
|
||
.prose li {
|
||
position: relative;
|
||
padding-left: 22px;
|
||
margin-bottom: 10px;
|
||
}
|
||
.prose li::before {
|
||
content: "";
|
||
position: absolute;
|
||
left: 4px;
|
||
top: 9px;
|
||
width: 6px;
|
||
height: 6px;
|
||
background: var(--gray-500);
|
||
border-radius: 2px;
|
||
}
|
||
|
||
/* ---------- Risk map ---------- */
|
||
.risk-map {
|
||
display: flex;
|
||
flex-wrap: wrap;
|
||
gap: 10px;
|
||
}
|
||
|
||
.chip {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
gap: 8px;
|
||
padding: 8px 12px;
|
||
border-radius: 8px;
|
||
border: 1.5px solid var(--gray-300);
|
||
font-family: var(--mono);
|
||
font-size: 12.5px;
|
||
color: var(--slate);
|
||
text-decoration: none;
|
||
background: #fff;
|
||
transition: transform 0.12s ease;
|
||
}
|
||
.chip:hover { transform: translateY(-1px); }
|
||
|
||
.chip .dot {
|
||
width: 9px;
|
||
height: 9px;
|
||
border-radius: 50%;
|
||
flex-shrink: 0;
|
||
}
|
||
.chip.safe { background: rgba(120,140,93,0.10); border-color: rgba(120,140,93,0.45); }
|
||
.chip.safe .dot { background: var(--olive); }
|
||
.chip.medium { background: var(--oat); }
|
||
.chip.medium .dot { background: #B89B6E; }
|
||
.chip.attention { background: rgba(217,119,87,0.12); border-color: rgba(217,119,87,0.55); }
|
||
.chip.attention .dot { background: var(--clay); }
|
||
|
||
.legend {
|
||
margin-top: 12px;
|
||
font-size: 12px;
|
||
color: var(--gray-500);
|
||
display: flex;
|
||
gap: 18px;
|
||
}
|
||
.legend span { display: inline-flex; align-items: center; gap: 6px; }
|
||
.legend .dot { width: 8px; height: 8px; border-radius: 50%; }
|
||
|
||
/* ---------- File sections ---------- */
|
||
.file-card {
|
||
border: 1.5px solid var(--gray-300);
|
||
border-radius: 12px;
|
||
background: #fff;
|
||
margin-bottom: 24px;
|
||
overflow: hidden;
|
||
scroll-margin-top: 20px;
|
||
}
|
||
|
||
.file-head {
|
||
padding: 16px 20px;
|
||
border-bottom: 1.5px solid var(--gray-150);
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
gap: 12px;
|
||
}
|
||
.file-path {
|
||
font-family: var(--mono);
|
||
font-size: 13.5px;
|
||
color: var(--slate);
|
||
}
|
||
.file-delta {
|
||
font-family: var(--mono);
|
||
font-size: 12px;
|
||
color: var(--gray-500);
|
||
}
|
||
.file-delta .add { color: var(--olive); }
|
||
.file-delta .del { color: var(--rust); }
|
||
|
||
.risk-tag {
|
||
font-size: 11px;
|
||
text-transform: uppercase;
|
||
letter-spacing: 0.06em;
|
||
padding: 3px 8px;
|
||
border-radius: 6px;
|
||
font-weight: 600;
|
||
}
|
||
.risk-tag.safe { background: rgba(120,140,93,0.15); color: var(--olive); }
|
||
.risk-tag.medium { background: var(--oat); color: #7A6A4F; }
|
||
.risk-tag.attention { background: rgba(217,119,87,0.15); color: var(--clay); }
|
||
|
||
/* ---------- Diff block ---------- */
|
||
.diff {
|
||
background: var(--slate);
|
||
font-family: var(--mono);
|
||
font-size: 12.5px;
|
||
line-height: 1.7;
|
||
overflow-x: auto;
|
||
}
|
||
.diff-row {
|
||
display: grid;
|
||
grid-template-columns: 48px 18px 1fr;
|
||
align-items: baseline;
|
||
padding: 0 14px 0 0;
|
||
white-space: pre;
|
||
}
|
||
.diff-row .ln {
|
||
text-align: right;
|
||
padding-right: 14px;
|
||
color: var(--gray-500);
|
||
user-select: none;
|
||
}
|
||
.diff-row .mark {
|
||
text-align: center;
|
||
color: var(--gray-500);
|
||
}
|
||
.diff-row .code { color: #E8E6DC; }
|
||
.diff-row.ctx .code { color: #B8B6AC; }
|
||
.diff-row.add { background: rgba(120,140,93,0.15); }
|
||
.diff-row.add .mark { color: var(--olive); }
|
||
.diff-row.del { background: rgba(176,74,63,0.15); }
|
||
.diff-row.del .mark { color: var(--rust); }
|
||
.diff-row.hunk {
|
||
background: rgba(255,255,255,0.04);
|
||
color: var(--gray-500);
|
||
}
|
||
.diff-row.hunk .code { color: var(--gray-500); }
|
||
|
||
/* ---------- Review comments ---------- */
|
||
.comments {
|
||
padding: 18px 20px 20px;
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 14px;
|
||
background: var(--gray-150);
|
||
}
|
||
|
||
.bubble {
|
||
position: relative;
|
||
background: #fff;
|
||
border: 1.5px solid var(--gray-300);
|
||
border-left-width: 4px;
|
||
border-radius: 8px;
|
||
padding: 12px 14px 12px 16px;
|
||
max-width: 680px;
|
||
}
|
||
.bubble.blocking { border-left-color: var(--clay); }
|
||
.bubble.nit { border-left-color: var(--gray-300); }
|
||
|
||
.bubble::before {
|
||
content: "";
|
||
position: absolute;
|
||
left: -9px;
|
||
top: 16px;
|
||
width: 12px;
|
||
height: 12px;
|
||
background: #fff;
|
||
border-left: 1.5px solid var(--gray-300);
|
||
border-bottom: 1.5px solid var(--gray-300);
|
||
transform: rotate(45deg);
|
||
}
|
||
.bubble.blocking::before { border-left-color: var(--clay); border-bottom-color: var(--clay); }
|
||
|
||
.bubble .anchor {
|
||
font-family: var(--mono);
|
||
font-size: 11.5px;
|
||
color: var(--gray-500);
|
||
margin-bottom: 4px;
|
||
}
|
||
.bubble .label {
|
||
display: inline-block;
|
||
font-size: 10.5px;
|
||
text-transform: uppercase;
|
||
letter-spacing: 0.08em;
|
||
font-weight: 700;
|
||
margin-right: 8px;
|
||
}
|
||
.bubble.blocking .label { color: var(--clay); }
|
||
.bubble.nit .label { color: var(--gray-500); }
|
||
.bubble p { font-size: 13.5px; color: var(--gray-700); }
|
||
.bubble code {
|
||
font-family: var(--mono);
|
||
font-size: 12.5px;
|
||
background: var(--gray-150);
|
||
padding: 1px 5px;
|
||
border-radius: 4px;
|
||
}
|
||
|
||
/* ---------- Collapsed files ---------- */
|
||
details.file-collapsed {
|
||
border: 1.5px solid var(--gray-300);
|
||
border-radius: 12px;
|
||
background: #fff;
|
||
margin-bottom: 14px;
|
||
scroll-margin-top: 20px;
|
||
}
|
||
details.file-collapsed summary {
|
||
list-style: none;
|
||
cursor: pointer;
|
||
padding: 14px 20px;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
gap: 12px;
|
||
}
|
||
details.file-collapsed summary::-webkit-details-marker { display: none; }
|
||
details.file-collapsed summary::after {
|
||
content: "+";
|
||
font-family: var(--mono);
|
||
color: var(--gray-500);
|
||
font-size: 16px;
|
||
}
|
||
details.file-collapsed[open] summary::after { content: "−"; }
|
||
details.file-collapsed .body {
|
||
padding: 0 20px 16px;
|
||
font-size: 13.5px;
|
||
color: var(--gray-700);
|
||
}
|
||
|
||
/* ---------- Footer ---------- */
|
||
footer.next-steps {
|
||
border: 1.5px solid var(--gray-300);
|
||
border-radius: 12px;
|
||
background: #fff;
|
||
padding: 24px 28px;
|
||
}
|
||
.checklist { list-style: none; padding: 0; }
|
||
.checklist li {
|
||
display: flex;
|
||
align-items: flex-start;
|
||
gap: 12px;
|
||
padding: 8px 0;
|
||
}
|
||
.checklist input[type="checkbox"] {
|
||
width: 17px;
|
||
height: 17px;
|
||
margin-top: 2px;
|
||
accent-color: var(--olive);
|
||
cursor: pointer;
|
||
}
|
||
.checklist label { cursor: pointer; flex: 1; }
|
||
.checklist code {
|
||
font-family: var(--mono);
|
||
font-size: 12.5px;
|
||
background: var(--gray-150);
|
||
padding: 1px 5px;
|
||
border-radius: 4px;
|
||
}
|
||
</style>
|
||
</head>
|
||
<body>
|
||
<div class="page">
|
||
|
||
<header class="pr-head">
|
||
<div class="repo-line">acme/web · Pull Request #247</div>
|
||
<h1>Add optimistic updates to task list mutations</h1>
|
||
<div class="meta-row">
|
||
<div class="author">
|
||
<div class="avatar">MO</div>
|
||
<div>
|
||
<div class="author-name">Mira Okafor</div>
|
||
<div class="author-sub">opened 2 days ago</div>
|
||
</div>
|
||
</div>
|
||
<div class="branch">
|
||
mo/optimistic-tasks <span class="arrow">→</span> main
|
||
</div>
|
||
<div class="stat">
|
||
<span class="add">+142</span> / <span class="del">−38</span>
|
||
<span class="files">6 files changed</span>
|
||
</div>
|
||
</div>
|
||
</header>
|
||
|
||
<section class="prose">
|
||
<h2>What this PR does</h2>
|
||
<ul>
|
||
<li>Replaces the await-then-refetch pattern in <code style="font-family:var(--mono);font-size:12.5px;background:var(--gray-150);padding:1px 5px;border-radius:4px">TaskList</code> with optimistic cache writes, so toggling or reordering a task feels instant instead of waiting ~300ms for the round-trip.</li>
|
||
<li>Introduces a small <code style="font-family:var(--mono);font-size:12.5px;background:var(--gray-150);padding:1px 5px;border-radius:4px">useOptimisticTasks</code> hook that wraps the mutation, snapshots the previous list, and rolls back on error.</li>
|
||
<li>Extends the API client to accept an idempotency key per mutation and adds a toast when a rollback fires.</li>
|
||
</ul>
|
||
</section>
|
||
|
||
<section>
|
||
<h2>Risk map</h2>
|
||
<div class="risk-map">
|
||
<a class="chip attention" href="#file-hook"><span class="dot"></span>useOptimisticTasks.ts</a>
|
||
<a class="chip medium" href="#file-tasklist"><span class="dot"></span>TaskList.tsx</a>
|
||
<a class="chip medium" href="#file-api"><span class="dot"></span>api/tasks.ts</a>
|
||
<a class="chip safe" href="#file-toast"><span class="dot"></span>Toast.tsx</a>
|
||
<a class="chip safe" href="#file-types"><span class="dot"></span>types/task.ts</a>
|
||
<a class="chip safe" href="#file-test"><span class="dot"></span>TaskList.test.tsx</a>
|
||
</div>
|
||
<div class="legend">
|
||
<span><span class="dot" style="background:var(--olive)"></span> safe</span>
|
||
<span><span class="dot" style="background:#B89B6E"></span> worth a look</span>
|
||
<span><span class="dot" style="background:var(--clay)"></span> needs attention</span>
|
||
</div>
|
||
</section>
|
||
|
||
<section>
|
||
<h2>Files</h2>
|
||
|
||
<!-- File 1: hook (attention) -->
|
||
<article class="file-card" id="file-hook">
|
||
<div class="file-head">
|
||
<div>
|
||
<div class="file-path">src/hooks/useOptimisticTasks.ts</div>
|
||
</div>
|
||
<div style="display:flex;align-items:center;gap:12px">
|
||
<span class="risk-tag attention">needs attention</span>
|
||
<span class="file-delta"><span class="add">+58</span> <span class="del">−0</span></span>
|
||
</div>
|
||
</div>
|
||
<div class="diff">
|
||
<div class="diff-row hunk"><span class="ln"></span><span class="mark"></span><span class="code">@@ -0,0 +1,58 @@</span></div>
|
||
<div class="diff-row add"><span class="ln">1</span><span class="mark">+</span><span class="code">import { useMutation, useQueryClient } from '@tanstack/react-query';</span></div>
|
||
<div class="diff-row add"><span class="ln">2</span><span class="mark">+</span><span class="code">import { updateTask, TaskPatch } from '../api/tasks';</span></div>
|
||
<div class="diff-row add"><span class="ln">3</span><span class="mark">+</span><span class="code">import type { Task } from '../types/task';</span></div>
|
||
<div class="diff-row add"><span class="ln">4</span><span class="mark">+</span><span class="code"> </span></div>
|
||
<div class="diff-row add"><span class="ln">5</span><span class="mark">+</span><span class="code">export function useOptimisticTasks(boardId: string) {</span></div>
|
||
<div class="diff-row add"><span class="ln">6</span><span class="mark">+</span><span class="code"> const qc = useQueryClient();</span></div>
|
||
<div class="diff-row add"><span class="ln">7</span><span class="mark">+</span><span class="code"> const key = ['tasks', boardId];</span></div>
|
||
<div class="diff-row add"><span class="ln">8</span><span class="mark">+</span><span class="code"> </span></div>
|
||
<div class="diff-row add"><span class="ln">9</span><span class="mark">+</span><span class="code"> return useMutation({</span></div>
|
||
<div class="diff-row add"><span class="ln">10</span><span class="mark">+</span><span class="code"> mutationFn: (patch: TaskPatch) => updateTask(patch),</span></div>
|
||
<div class="diff-row add"><span class="ln">11</span><span class="mark">+</span><span class="code"> onMutate: async (patch) => {</span></div>
|
||
<div class="diff-row add"><span class="ln">12</span><span class="mark">+</span><span class="code"> const prev = qc.getQueryData<Task[]>(key);</span></div>
|
||
<div class="diff-row add"><span class="ln">13</span><span class="mark">+</span><span class="code"> qc.setQueryData<Task[]>(key, (old = []) =></span></div>
|
||
<div class="diff-row add"><span class="ln">14</span><span class="mark">+</span><span class="code"> old.map(t => t.id === patch.id ? { ...t, ...patch } : t)</span></div>
|
||
<div class="diff-row add"><span class="ln">15</span><span class="mark">+</span><span class="code"> );</span></div>
|
||
<div class="diff-row add"><span class="ln">16</span><span class="mark">+</span><span class="code"> return { prev };</span></div>
|
||
<div class="diff-row add"><span class="ln">17</span><span class="mark">+</span><span class="code"> },</span></div>
|
||
<div class="diff-row add"><span class="ln">18</span><span class="mark">+</span><span class="code"> onError: (_e, _p, ctx) => qc.setQueryData(key, ctx?.prev),</span></div>
|
||
<div class="diff-row add"><span class="ln">19</span><span class="mark">+</span><span class="code"> });</span></div>
|
||
<div class="diff-row add"><span class="ln">20</span><span class="mark">+</span><span class="code">}</span></div>
|
||
</div>
|
||
<div class="comments">
|
||
<div class="bubble blocking">
|
||
<div class="anchor">line 11</div>
|
||
<p><span class="label">Blocking</span><code>onMutate</code> doesn't call <code>qc.cancelQueries(key)</code> first. If a background refetch lands between the optimistic write and the server response, it will clobber the optimistic state and the UI will flicker back to the old value.</p>
|
||
</div>
|
||
<div class="bubble nit">
|
||
<div class="anchor">line 18</div>
|
||
<p><span class="label">Nit</span>Rollback restores the list but never surfaces the error. Consider wiring the existing <code>pushToast</code> here so users know the toggle didn't stick.</p>
|
||
</div>
|
||
</div>
|
||
</article>
|
||
|
||
<!-- File 2: TaskList (medium) -->
|
||
<article class="file-card" id="file-tasklist">
|
||
<div class="file-head">
|
||
<div>
|
||
<div class="file-path">src/components/TaskList.tsx</div>
|
||
</div>
|
||
<div style="display:flex;align-items:center;gap:12px">
|
||
<span class="risk-tag medium">worth a look</span>
|
||
<span class="file-delta"><span class="add">+31</span> <span class="del">−24</span></span>
|
||
</div>
|
||
</div>
|
||
<div class="diff">
|
||
<div class="diff-row hunk"><span class="ln"></span><span class="mark"></span><span class="code">@@ -42,14 +42,17 @@ export function TaskList({ boardId }: Props) {</span></div>
|
||
<div class="diff-row ctx"><span class="ln">42</span><span class="mark"> </span><span class="code"> const { data: tasks } = useTasks(boardId);</span></div>
|
||
<div class="diff-row del"><span class="ln">43</span><span class="mark">-</span><span class="code"> const [pending, setPending] = useState<string | null>(null);</span></div>
|
||
<div class="diff-row del"><span class="ln">44</span><span class="mark">-</span><span class="code"> </span></div>
|
||
<div class="diff-row del"><span class="ln">45</span><span class="mark">-</span><span class="code"> async function toggle(task: Task) {</span></div>
|
||
<div class="diff-row del"><span class="ln">46</span><span class="mark">-</span><span class="code"> setPending(task.id);</span></div>
|
||
<div class="diff-row del"><span class="ln">47</span><span class="mark">-</span><span class="code"> await updateTask({ id: task.id, done: !task.done });</span></div>
|
||
<div class="diff-row del"><span class="ln">48</span><span class="mark">-</span><span class="code"> await refetch();</span></div>
|
||
<div class="diff-row del"><span class="ln">49</span><span class="mark">-</span><span class="code"> setPending(null);</span></div>
|
||
<div class="diff-row del"><span class="ln">50</span><span class="mark">-</span><span class="code"> }</span></div>
|
||
<div class="diff-row add"><span class="ln">43</span><span class="mark">+</span><span class="code"> const { mutate, isPending } = useOptimisticTasks(boardId);</span></div>
|
||
<div class="diff-row add"><span class="ln">44</span><span class="mark">+</span><span class="code"> </span></div>
|
||
<div class="diff-row add"><span class="ln">45</span><span class="mark">+</span><span class="code"> const toggle = (task: Task) =></span></div>
|
||
<div class="diff-row add"><span class="ln">46</span><span class="mark">+</span><span class="code"> mutate({ id: task.id, done: !task.done });</span></div>
|
||
<div class="diff-row ctx"><span class="ln">47</span><span class="mark"> </span><span class="code"> </span></div>
|
||
<div class="diff-row ctx"><span class="ln">48</span><span class="mark"> </span><span class="code"> return (</span></div>
|
||
<div class="diff-row ctx"><span class="ln">49</span><span class="mark"> </span><span class="code"> <ul className="tasks"></span></div>
|
||
<div class="diff-row del"><span class="ln">50</span><span class="mark">-</span><span class="code"> {tasks?.map(t => <TaskRow key={t.id} task={t} busy={pending === t.id} />)}</span></div>
|
||
<div class="diff-row add"><span class="ln">50</span><span class="mark">+</span><span class="code"> {tasks?.map(t => <TaskRow key={t.id} task={t} onToggle={toggle} />)}</span></div>
|
||
<div class="diff-row ctx"><span class="ln">51</span><span class="mark"> </span><span class="code"> </ul></span></div>
|
||
</div>
|
||
<div class="comments">
|
||
<div class="bubble nit">
|
||
<div class="anchor">line 43</div>
|
||
<p><span class="label">Nit</span><code>isPending</code> is destructured but never read. Either drop it or pass it to <code>TaskRow</code> so the checkbox can dim while the request is in flight.</p>
|
||
</div>
|
||
</div>
|
||
</article>
|
||
|
||
<!-- File 3: api/tasks (medium) -->
|
||
<article class="file-card" id="file-api">
|
||
<div class="file-head">
|
||
<div>
|
||
<div class="file-path">src/api/tasks.ts</div>
|
||
</div>
|
||
<div style="display:flex;align-items:center;gap:12px">
|
||
<span class="risk-tag medium">worth a look</span>
|
||
<span class="file-delta"><span class="add">+19</span> <span class="del">−6</span></span>
|
||
</div>
|
||
</div>
|
||
<div class="diff">
|
||
<div class="diff-row hunk"><span class="ln"></span><span class="mark"></span><span class="code">@@ -12,10 +12,15 @@ export type TaskPatch = Partial<Task> & { id: string };</span></div>
|
||
<div class="diff-row ctx"><span class="ln">12</span><span class="mark"> </span><span class="code"> </span></div>
|
||
<div class="diff-row del"><span class="ln">13</span><span class="mark">-</span><span class="code">export async function updateTask(patch: TaskPatch) {</span></div>
|
||
<div class="diff-row del"><span class="ln">14</span><span class="mark">-</span><span class="code"> return http.patch(`/tasks/${patch.id}`, patch);</span></div>
|
||
<div class="diff-row add"><span class="ln">13</span><span class="mark">+</span><span class="code">export async function updateTask(</span></div>
|
||
<div class="diff-row add"><span class="ln">14</span><span class="mark">+</span><span class="code"> patch: TaskPatch,</span></div>
|
||
<div class="diff-row add"><span class="ln">15</span><span class="mark">+</span><span class="code"> key = crypto.randomUUID(),</span></div>
|
||
<div class="diff-row add"><span class="ln">16</span><span class="mark">+</span><span class="code">) {</span></div>
|
||
<div class="diff-row add"><span class="ln">17</span><span class="mark">+</span><span class="code"> return http.patch(`/tasks/${patch.id}`, patch, {</span></div>
|
||
<div class="diff-row add"><span class="ln">18</span><span class="mark">+</span><span class="code"> headers: { 'Idempotency-Key': key },</span></div>
|
||
<div class="diff-row add"><span class="ln">19</span><span class="mark">+</span><span class="code"> });</span></div>
|
||
<div class="diff-row ctx"><span class="ln">20</span><span class="mark"> </span><span class="code">}</span></div>
|
||
</div>
|
||
<div class="comments">
|
||
<div class="bubble blocking">
|
||
<div class="anchor">line 15</div>
|
||
<p><span class="label">Blocking</span>Generating the idempotency key as a default parameter means retries from the mutation layer get a <em>new</em> key each time, which defeats the purpose. The key should be minted once in <code>onMutate</code> and threaded through.</p>
|
||
</div>
|
||
</div>
|
||
</article>
|
||
|
||
<!-- Collapsed files -->
|
||
<details class="file-collapsed" id="file-toast">
|
||
<summary>
|
||
<span class="file-path">src/components/Toast.tsx</span>
|
||
<span style="display:flex;align-items:center;gap:12px">
|
||
<span class="risk-tag safe">safe</span>
|
||
<span class="file-delta"><span class="add">+14</span> <span class="del">−2</span></span>
|
||
</span>
|
||
</summary>
|
||
<div class="body">Adds a <code style="font-family:var(--mono)">variant="warning"</code> style and exports <code style="font-family:var(--mono)">pushToast</code>. Purely additive, no behaviour change for existing call sites.</div>
|
||
</details>
|
||
|
||
<details class="file-collapsed" id="file-types">
|
||
<summary>
|
||
<span class="file-path">src/types/task.ts</span>
|
||
<span style="display:flex;align-items:center;gap:12px">
|
||
<span class="risk-tag safe">safe</span>
|
||
<span class="file-delta"><span class="add">+6</span> <span class="del">−2</span></span>
|
||
</span>
|
||
</summary>
|
||
<div class="body">Widens <code style="font-family:var(--mono)">Task.status</code> to include <code style="font-family:var(--mono)">"archived"</code> and adds an optional <code style="font-family:var(--mono)">updatedAt</code> timestamp. Type-only change.</div>
|
||
</details>
|
||
|
||
<details class="file-collapsed" id="file-test">
|
||
<summary>
|
||
<span class="file-path">src/components/__tests__/TaskList.test.tsx</span>
|
||
<span style="display:flex;align-items:center;gap:12px">
|
||
<span class="risk-tag safe">safe</span>
|
||
<span class="file-delta"><span class="add">+14</span> <span class="del">−4</span></span>
|
||
</span>
|
||
</summary>
|
||
<div class="body">Adds a test asserting the row updates synchronously after click, and one asserting rollback when the mocked request rejects. Both pass locally.</div>
|
||
</details>
|
||
</section>
|
||
|
||
<footer class="next-steps">
|
||
<h2>Suggested next steps</h2>
|
||
<ul class="checklist">
|
||
<li>
|
||
<input type="checkbox" id="step1">
|
||
<label for="step1">Add <code>await qc.cancelQueries(key)</code> at the top of <code>onMutate</code> in <code>useOptimisticTasks.ts</code>.</label>
|
||
</li>
|
||
<li>
|
||
<input type="checkbox" id="step2">
|
||
<label for="step2">Move idempotency-key generation into the mutation context so retries reuse the same key.</label>
|
||
</li>
|
||
<li>
|
||
<input type="checkbox" id="step3">
|
||
<label for="step3">Either consume <code>isPending</code> in <code>TaskRow</code> or remove it from the destructure to keep lint clean.</label>
|
||
</li>
|
||
</ul>
|
||
</footer>
|
||
|
||
</div>
|
||
|
||
<script>
|
||
// Briefly highlight a file card when reached via the risk-map anchors.
|
||
document.querySelectorAll('.risk-map a').forEach(function (a) {
|
||
a.addEventListener('click', function () {
|
||
var target = document.querySelector(a.getAttribute('href'));
|
||
if (!target) return;
|
||
target.style.transition = 'box-shadow 180ms ease';
|
||
target.style.boxShadow = '0 0 0 3px rgba(217,119,87,0.35)';
|
||
setTimeout(function () { target.style.boxShadow = 'none'; }, 1400);
|
||
});
|
||
});
|
||
</script>
|
||
</body>
|
||
</html>
|