@handled-ai/design-system 0.9.24 → 0.9.25

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,310 @@
1
+ /**
2
+ * Tests for ScoreBreakdown's `initialFeedback` prop and related behaviors.
3
+ *
4
+ * Covers:
5
+ * - deriveInitialState helper (via observable rendered state)
6
+ * - Pre-populated feedback state (thumb shown as active)
7
+ * - Pre-populated feedbackText / savedText
8
+ * - useEffect sync on prop change (with editingKey guard)
9
+ * - submitFeedbackText calls onFactorFeedback with text ?? "" (not undefined)
10
+ */
11
+
12
+ import { describe, it, expect, vi } from "vitest";
13
+ import React from "react";
14
+ import { render, screen, fireEvent, act } from "@testing-library/react";
15
+ import { ScoreBreakdown } from "../score-breakdown";
16
+ import {
17
+ makeScoreFactor,
18
+ makeHighRiskFactor,
19
+ makeInitialFactorFeedback,
20
+ makeSingleFactorFeedback,
21
+ EMPTY_FACTOR_FEEDBACK,
22
+ } from "../../__test-helpers__/fixtures";
23
+
24
+ // ─── Helpers ─────────────────────────────────────────────────────────────────
25
+
26
+ function makeFactors() {
27
+ return [
28
+ makeScoreFactor({ key: "revenue_change", label: "Revenue Change" }),
29
+ makeHighRiskFactor({ key: "cash_runway", label: "Cash Runway" }),
30
+ ];
31
+ }
32
+
33
+ // ─── Tests ────────────────────────────────────────────────────────────────────
34
+
35
+ describe("ScoreBreakdown — initialFeedback prop (deriveInitialState)", () => {
36
+ it("renders factors without initialFeedback (no pre-populated state)", () => {
37
+ render(
38
+ <ScoreBreakdown
39
+ factors={makeFactors()}
40
+ initialFeedback={undefined}
41
+ />,
42
+ );
43
+ expect(screen.getByText("Revenue Change")).toBeDefined();
44
+ expect(screen.getByText("Cash Runway")).toBeDefined();
45
+ });
46
+
47
+ it("with EMPTY_FACTOR_FEEDBACK, renders like undefined (no active thumbs)", () => {
48
+ // Neither factor should show a saved text or different thumb style
49
+ const { container } = render(
50
+ <ScoreBreakdown
51
+ factors={makeFactors()}
52
+ initialFeedback={EMPTY_FACTOR_FEEDBACK}
53
+ />,
54
+ );
55
+ // No saved text paragraphs — just label and score bar
56
+ expect(screen.queryByText("Revenue data looks accurate.")).toBeNull();
57
+ expect(container).toBeDefined();
58
+ });
59
+
60
+ it("pre-populates thumb state for revenue_change as 'up'", () => {
61
+ // When feedback[key] = 'up', the thumb-up button gets emerald styling
62
+ // We can observe this by checking that the saved text from detail is rendered
63
+ render(
64
+ <ScoreBreakdown
65
+ factors={makeFactors()}
66
+ initialFeedback={makeSingleFactorFeedback("revenue_change", "up", "Revenue data looks accurate.")}
67
+ />,
68
+ );
69
+ // savedText["revenue_change"] = "Revenue data looks accurate." is rendered as an edit button
70
+ expect(screen.getByText("Revenue data looks accurate.")).toBeDefined();
71
+ });
72
+
73
+ it("pre-populates thumb state for cash_runway as 'down' with detail", () => {
74
+ render(
75
+ <ScoreBreakdown
76
+ factors={makeFactors()}
77
+ initialFeedback={makeSingleFactorFeedback("cash_runway", "down", "Cash figures seem too optimistic.")}
78
+ />,
79
+ );
80
+ expect(screen.getByText("Cash figures seem too optimistic.")).toBeDefined();
81
+ });
82
+
83
+ it("pre-populates multiple factors from initialFeedback", () => {
84
+ render(
85
+ <ScoreBreakdown
86
+ factors={makeFactors()}
87
+ initialFeedback={makeInitialFactorFeedback()}
88
+ />,
89
+ );
90
+ expect(screen.getByText("Revenue data looks accurate.")).toBeDefined();
91
+ expect(screen.getByText("Cash figures seem too optimistic.")).toBeDefined();
92
+ });
93
+
94
+ it("factors with no matching key in initialFeedback are unaffected", () => {
95
+ // Only revenue_change is in the initial feedback; cash_runway should have no saved text
96
+ render(
97
+ <ScoreBreakdown
98
+ factors={makeFactors()}
99
+ initialFeedback={makeSingleFactorFeedback("revenue_change", "up", "Revenue data looks accurate.")}
100
+ />,
101
+ );
102
+ expect(screen.getByText("Revenue data looks accurate.")).toBeDefined();
103
+ expect(screen.queryByText("Cash figures seem too optimistic.")).toBeNull();
104
+ });
105
+
106
+ it("detail = '' does not produce a savedText entry (filterFn filters it)", () => {
107
+ // makeMinimalInitialScoreFeedback analogue for factor: detail = ""
108
+ render(
109
+ <ScoreBreakdown
110
+ factors={makeFactors()}
111
+ initialFeedback={makeSingleFactorFeedback("revenue_change", "up", "")}
112
+ />,
113
+ );
114
+ // No saved text button rendered (detail is empty, filterFn(v => !!v.detail) excludes it)
115
+ // But the thumb IS pre-populated — factor shows "up" state
116
+ // We can't easily assert CSS classes, but we can assert no saved text renders
117
+ // (the "revenue data looks accurate" text should NOT be here since detail = "")
118
+ expect(screen.queryByText("Revenue data looks accurate.")).toBeNull();
119
+ });
120
+ });
121
+
122
+ describe("ScoreBreakdown — useEffect sync on prop change", () => {
123
+ it("syncs state when initialFeedback changes from undefined to a value", async () => {
124
+ const { rerender } = render(
125
+ <ScoreBreakdown
126
+ factors={makeFactors()}
127
+ initialFeedback={undefined}
128
+ />,
129
+ );
130
+
131
+ // No saved text initially
132
+ expect(screen.queryByText("Revenue data looks accurate.")).toBeNull();
133
+
134
+ // Now provide initialFeedback
135
+ await act(async () => {
136
+ rerender(
137
+ <ScoreBreakdown
138
+ factors={makeFactors()}
139
+ initialFeedback={makeSingleFactorFeedback(
140
+ "revenue_change",
141
+ "up",
142
+ "Revenue data looks accurate.",
143
+ )}
144
+ />,
145
+ );
146
+ });
147
+
148
+ expect(screen.getByText("Revenue data looks accurate.")).toBeDefined();
149
+ });
150
+
151
+ it("syncs state when initialFeedback changes from one value to another", async () => {
152
+ const { rerender } = render(
153
+ <ScoreBreakdown
154
+ factors={makeFactors()}
155
+ initialFeedback={makeSingleFactorFeedback("revenue_change", "up", "Old detail.")}
156
+ />,
157
+ );
158
+
159
+ expect(screen.getByText("Old detail.")).toBeDefined();
160
+
161
+ await act(async () => {
162
+ rerender(
163
+ <ScoreBreakdown
164
+ factors={makeFactors()}
165
+ initialFeedback={makeSingleFactorFeedback("revenue_change", "up", "New detail.")}
166
+ />,
167
+ );
168
+ });
169
+
170
+ expect(screen.queryByText("Old detail.")).toBeNull();
171
+ expect(screen.getByText("New detail.")).toBeDefined();
172
+ });
173
+
174
+ it("guard: preserves actively-edited key when initialFeedback changes", async () => {
175
+ // Start with one value
176
+ const { rerender, container } = render(
177
+ <ScoreBreakdown
178
+ factors={makeFactors()}
179
+ initialFeedback={makeSingleFactorFeedback("revenue_change", "up", "Old detail.")}
180
+ />,
181
+ );
182
+
183
+ // Click the saved text to enter edit mode (sets editingKey = "revenue_change")
184
+ const savedTextButton = screen.getByText("Old detail.");
185
+ await act(async () => {
186
+ fireEvent.click(savedTextButton);
187
+ });
188
+
189
+ // Now an input should be visible
190
+ const input = container.querySelector("input");
191
+ expect(input).not.toBeNull();
192
+
193
+ // Change the input text to something different
194
+ await act(async () => {
195
+ fireEvent.change(input!, { target: { value: "My in-progress edit" } });
196
+ });
197
+
198
+ // Now the prop changes — sync should skip the editing key
199
+ await act(async () => {
200
+ rerender(
201
+ <ScoreBreakdown
202
+ factors={makeFactors()}
203
+ initialFeedback={makeSingleFactorFeedback("revenue_change", "up", "Async update.")}
204
+ />,
205
+ );
206
+ });
207
+
208
+ // The input should still have the user's in-progress value
209
+ const inputAfter = container.querySelector("input") as HTMLInputElement;
210
+ expect(inputAfter?.value).toBe("My in-progress edit");
211
+ });
212
+ });
213
+
214
+ describe("ScoreBreakdown — submitFeedbackText: text ?? '' fix", () => {
215
+ it("calls onFactorFeedback with empty string when no text entered (not undefined)", async () => {
216
+ const onFactorFeedback = vi.fn();
217
+ const { container } = render(
218
+ <ScoreBreakdown
219
+ factors={[makeScoreFactor({ key: "revenue_change", label: "Revenue Change" })]}
220
+ onFactorFeedback={onFactorFeedback}
221
+ initialFeedback={undefined}
222
+ />,
223
+ );
224
+
225
+ // Click thumbs-up to set feedback[revenue_change] = "up" and enter edit mode
226
+ const thumbUpButton = container.querySelectorAll("button")[0];
227
+ await act(async () => {
228
+ fireEvent.click(thumbUpButton);
229
+ });
230
+
231
+ // An input should appear (editingKey = "revenue_change")
232
+ const input = container.querySelector("input");
233
+ expect(input).not.toBeNull();
234
+
235
+ // Blur the input without typing (feedbackText["revenue_change"] = "")
236
+ await act(async () => {
237
+ fireEvent.blur(input!);
238
+ });
239
+
240
+ // onFactorFeedback should have been called twice:
241
+ // 1. from handleFeedback when thumbs clicked: (key, "up")
242
+ // 2. from submitFeedbackText on blur: (key, "up", "" ) ← the fix
243
+ const calls = onFactorFeedback.mock.calls;
244
+ const submitCall = calls.find(
245
+ (c: unknown[]) => c.length === 3,
246
+ );
247
+ expect(submitCall).toBeDefined();
248
+ expect(submitCall![2]).toBe(""); // "" not undefined
249
+ });
250
+
251
+ it("calls onFactorFeedback with trimmed text when text is entered", async () => {
252
+ const onFactorFeedback = vi.fn();
253
+ const { container } = render(
254
+ <ScoreBreakdown
255
+ factors={[makeScoreFactor({ key: "revenue_change", label: "Revenue Change" })]}
256
+ onFactorFeedback={onFactorFeedback}
257
+ initialFeedback={undefined}
258
+ />,
259
+ );
260
+
261
+ const thumbUpButton = container.querySelectorAll("button")[0];
262
+ await act(async () => {
263
+ fireEvent.click(thumbUpButton);
264
+ });
265
+
266
+ const input = container.querySelector("input");
267
+ await act(async () => {
268
+ fireEvent.change(input!, { target: { value: " Revenue is accurate " } });
269
+ });
270
+
271
+ await act(async () => {
272
+ fireEvent.blur(input!);
273
+ });
274
+
275
+ const calls = onFactorFeedback.mock.calls;
276
+ const submitCall = calls.find(
277
+ (c: unknown[]) => c.length === 3,
278
+ );
279
+ expect(submitCall).toBeDefined();
280
+ expect(submitCall![2]).toBe("Revenue is accurate"); // trimmed
281
+ });
282
+
283
+ it("pressing Enter submits with text ?? '' (empty string for blank input)", async () => {
284
+ const onFactorFeedback = vi.fn();
285
+ const { container } = render(
286
+ <ScoreBreakdown
287
+ factors={[makeScoreFactor({ key: "revenue_change", label: "Revenue Change" })]}
288
+ onFactorFeedback={onFactorFeedback}
289
+ initialFeedback={undefined}
290
+ />,
291
+ );
292
+
293
+ const thumbUpButton = container.querySelectorAll("button")[0];
294
+ await act(async () => {
295
+ fireEvent.click(thumbUpButton);
296
+ });
297
+
298
+ const input = container.querySelector("input");
299
+ await act(async () => {
300
+ fireEvent.keyDown(input!, { key: "Enter" });
301
+ });
302
+
303
+ const calls = onFactorFeedback.mock.calls;
304
+ const submitCall = calls.find(
305
+ (c: unknown[]) => c.length === 3,
306
+ );
307
+ expect(submitCall).toBeDefined();
308
+ expect(submitCall![2]).toBe(""); // empty string, not undefined
309
+ });
310
+ });
@@ -0,0 +1,253 @@
1
+ /**
2
+ * Tests for ScoreFeedback.Root's `initialFeedback` prop and related behaviors.
3
+ *
4
+ * Covers:
5
+ * - Initial `submittedFeedback` pre-population via prop
6
+ * - "Noted" / "Recorded" shown instead of thumbs when pre-populated
7
+ * - null initialFeedback → blank state (thumbs shown)
8
+ * - useEffect sync: prop changes from null → value updates submittedFeedback
9
+ * - useEffect guard: in-progress edit (thumbState ≠ null) skips sync
10
+ * - editSubmitted restores prior feedback into editing state
11
+ * - handleSubmit calls onSubmitFeedback
12
+ * - factor callback fix: text ?? "" (empty string, not undefined)
13
+ */
14
+
15
+ import { describe, it, expect, vi } from "vitest";
16
+ import React from "react";
17
+ import { render, screen, fireEvent, act } from "@testing-library/react";
18
+ import { ScoreFeedback } from "../score-feedback";
19
+ import {
20
+ makeInitialScoreFeedback,
21
+ makeNegativeInitialScoreFeedback,
22
+ makeMinimalInitialScoreFeedback,
23
+ } from "../../__test-helpers__/fixtures";
24
+
25
+ // ─── Minimal compound-component wrapper ──────────────────────────────────────
26
+
27
+ function Wrapper({
28
+ initialFeedback,
29
+ onSubmitFeedback,
30
+ }: {
31
+ initialFeedback?: { type: "up" | "down"; pills: string[]; detail: string } | null;
32
+ onSubmitFeedback?: (type: "up" | "down", pills: string[], detail: string) => void;
33
+ }) {
34
+ return (
35
+ <ScoreFeedback.Root
36
+ initialFeedback={initialFeedback}
37
+ onSubmitFeedback={onSubmitFeedback}
38
+ >
39
+ <ScoreFeedback.Trigger />
40
+ <ScoreFeedback.Panel />
41
+ </ScoreFeedback.Root>
42
+ );
43
+ }
44
+
45
+ // ─── Tests ────────────────────────────────────────────────────────────────────
46
+
47
+ describe("ScoreFeedback.Root — initialFeedback prop", () => {
48
+ it("shows 'Noted' when initialFeedback.type is 'up'", () => {
49
+ render(<Wrapper initialFeedback={makeInitialScoreFeedback()} />);
50
+ expect(screen.getByText("Noted")).toBeDefined();
51
+ });
52
+
53
+ it("shows 'Recorded' when initialFeedback.type is 'down'", () => {
54
+ render(<Wrapper initialFeedback={makeNegativeInitialScoreFeedback()} />);
55
+ expect(screen.getByText("Recorded")).toBeDefined();
56
+ });
57
+
58
+ it("does NOT show thumbs when initialFeedback is set (submitted state)", () => {
59
+ render(
60
+ <Wrapper initialFeedback={makeInitialScoreFeedback()} />,
61
+ );
62
+ // Thumbs-up and thumbs-down buttons should not be rendered
63
+ // The only button visible should be the "edit" button (with pill/detail), not bare thumbs
64
+ // We check by absence of the thumb click pattern — no button with onClick=handleThumbClick("up")
65
+ // More concretely: no thumb buttons means the Trigger is in "submitted" mode
66
+ expect(screen.queryByTitle?.("")).toBeNull?.(); // optional, just no crash
67
+ expect(screen.getByText("Noted")).toBeDefined();
68
+ });
69
+
70
+ it("shows thumbs when initialFeedback is null", () => {
71
+ const { container } = render(<Wrapper initialFeedback={null} />);
72
+ // Panel is hidden (no thumbState), Trigger shows thumb buttons
73
+ const buttons = container.querySelectorAll("button");
74
+ // Two thumb buttons are present
75
+ expect(buttons.length).toBeGreaterThanOrEqual(2);
76
+ // No "Noted" or "Recorded" text
77
+ expect(screen.queryByText("Noted")).toBeNull();
78
+ expect(screen.queryByText("Recorded")).toBeNull();
79
+ });
80
+
81
+ it("shows thumbs when initialFeedback is undefined", () => {
82
+ const { container } = render(<Wrapper />);
83
+ const buttons = container.querySelectorAll("button");
84
+ expect(buttons.length).toBeGreaterThanOrEqual(2);
85
+ expect(screen.queryByText("Noted")).toBeNull();
86
+ });
87
+
88
+ it("renders submitted pills from initialFeedback", () => {
89
+ render(
90
+ <Wrapper
91
+ initialFeedback={makeInitialScoreFeedback({
92
+ pills: ["Right timing", "Accurate data"],
93
+ })}
94
+ />,
95
+ );
96
+ expect(screen.getByText("Right timing")).toBeDefined();
97
+ expect(screen.getByText("Accurate data")).toBeDefined();
98
+ });
99
+
100
+ it("renders submitted detail text from initialFeedback", () => {
101
+ render(
102
+ <Wrapper
103
+ initialFeedback={makeInitialScoreFeedback({ detail: "Score looks correct." })}
104
+ />,
105
+ );
106
+ expect(screen.getByText("Score looks correct.")).toBeDefined();
107
+ });
108
+
109
+ it("minimal initialFeedback (no pills, no detail) still shows Noted without edit button", () => {
110
+ const { container } = render(
111
+ <Wrapper initialFeedback={makeMinimalInitialScoreFeedback("up")} />,
112
+ );
113
+ expect(screen.getByText("Noted")).toBeDefined();
114
+ // No pill text to find — just check no crash
115
+ const buttons = container.querySelectorAll("button");
116
+ // The edit button requires pills.length > 0 || detail, so no edit button here
117
+ expect(buttons.length).toBe(0);
118
+ });
119
+ });
120
+
121
+ describe("ScoreFeedback.Root — useEffect sync", () => {
122
+ it("updates submittedFeedback when initialFeedback prop changes from null to value", async () => {
123
+ const { rerender } = render(<Wrapper initialFeedback={null} />);
124
+
125
+ // Initially null → thumbs shown
126
+ expect(screen.queryByText("Noted")).toBeNull();
127
+
128
+ // Prop changes to a value
129
+ await act(async () => {
130
+ rerender(<Wrapper initialFeedback={makeInitialScoreFeedback()} />);
131
+ });
132
+
133
+ // Now should show "Noted"
134
+ expect(screen.getByText("Noted")).toBeDefined();
135
+ });
136
+
137
+ it("updates from 'up' to 'down' when prop changes", async () => {
138
+ const { rerender } = render(
139
+ <Wrapper initialFeedback={makeInitialScoreFeedback()} />,
140
+ );
141
+ expect(screen.getByText("Noted")).toBeDefined();
142
+
143
+ await act(async () => {
144
+ rerender(<Wrapper initialFeedback={makeNegativeInitialScoreFeedback()} />);
145
+ });
146
+
147
+ expect(screen.getByText("Recorded")).toBeDefined();
148
+ });
149
+
150
+ it("guard: does NOT sync when user has thumbState set (in-progress edit)", async () => {
151
+ // Start with null initialFeedback
152
+ const { rerender, container } = render(<Wrapper initialFeedback={null} />);
153
+
154
+ // User clicks thumbs-up to start an edit
155
+ const buttons = container.querySelectorAll("button");
156
+ await act(async () => {
157
+ fireEvent.click(buttons[0]); // thumbs-up button
158
+ });
159
+
160
+ // Now prop changes — should NOT overwrite in-progress edit
161
+ await act(async () => {
162
+ rerender(<Wrapper initialFeedback={makeNegativeInitialScoreFeedback()} />);
163
+ });
164
+
165
+ // Should NOT show "Recorded" — user's thumb state takes precedence
166
+ expect(screen.queryByText("Recorded")).toBeNull();
167
+ // Panel should be visible (thumbState is set)
168
+ // The Panel shows "How's this score?" section
169
+ expect(screen.getByText(/How['']s this score\?/)).toBeDefined();
170
+ });
171
+ });
172
+
173
+ describe("ScoreFeedback.Root — onSubmitFeedback callback", () => {
174
+ it("calls onSubmitFeedback with (type, pills, detail) on submit", async () => {
175
+ const onSubmit = vi.fn();
176
+ const { container } = render(
177
+ <Wrapper initialFeedback={null} onSubmitFeedback={onSubmit} />,
178
+ );
179
+
180
+ // Click thumbs-up
181
+ const thumbButtons = container.querySelectorAll("button");
182
+ await act(async () => {
183
+ fireEvent.click(thumbButtons[0]); // thumbs-up
184
+ });
185
+
186
+ // Select a pill
187
+ const pillButton = screen.getByText("Right timing");
188
+ await act(async () => {
189
+ fireEvent.click(pillButton);
190
+ });
191
+
192
+ // Click Submit
193
+ const submitButton = screen.getByText("Submit");
194
+ await act(async () => {
195
+ fireEvent.click(submitButton);
196
+ });
197
+
198
+ expect(onSubmit).toHaveBeenCalledTimes(1);
199
+ expect(onSubmit).toHaveBeenCalledWith("up", ["Right timing"], "");
200
+ });
201
+
202
+ it("calls onSubmitFeedback with empty string detail when no detail entered", async () => {
203
+ const onSubmit = vi.fn();
204
+ const { container } = render(
205
+ <Wrapper initialFeedback={null} onSubmitFeedback={onSubmit} />,
206
+ );
207
+
208
+ const thumbButtons = container.querySelectorAll("button");
209
+ await act(async () => {
210
+ fireEvent.click(thumbButtons[0]); // thumbs-up
211
+ });
212
+
213
+ const pillButton = screen.getByText("Right timing");
214
+ await act(async () => {
215
+ fireEvent.click(pillButton);
216
+ });
217
+
218
+ const submitButton = screen.getByText("Submit");
219
+ await act(async () => {
220
+ fireEvent.click(submitButton);
221
+ });
222
+
223
+ const [, , detail] = onSubmit.mock.calls[0] as [string, string[], string];
224
+ expect(detail).toBe("");
225
+ });
226
+ });
227
+
228
+ describe("ScoreFeedback.Root — editSubmitted", () => {
229
+ it("clicking the pills/detail edit area restores feedback into editing state", async () => {
230
+ const { container } = render(
231
+ <Wrapper
232
+ initialFeedback={makeInitialScoreFeedback({
233
+ pills: ["Right timing"],
234
+ detail: "Score looks correct.",
235
+ })}
236
+ />,
237
+ );
238
+
239
+ // The edit button (wraps pills + detail) should be present
240
+ const editButton = container.querySelector("button");
241
+ expect(editButton).not.toBeNull();
242
+
243
+ await act(async () => {
244
+ fireEvent.click(editButton!);
245
+ });
246
+
247
+ // After clicking, the Panel should be visible with "How's this score?"
248
+ expect(screen.getByText(/How['']s this score\?/)).toBeDefined();
249
+ // And the previously selected pill should be pre-selected
250
+ // (visible in the panel's pill list as selected)
251
+ expect(screen.getAllByText("Right timing").length).toBeGreaterThanOrEqual(1);
252
+ });
253
+ });
@@ -36,28 +36,56 @@ interface ScoreBreakdownProps {
36
36
  initialFeedback?: Record<string, { type: "up" | "down"; detail: string }>
37
37
  }
38
38
 
39
+ function deriveInitialState<T>(
40
+ init: Record<string, { type: "up" | "down"; detail: string }> | undefined,
41
+ mapFn: (v: { type: "up" | "down"; detail: string }) => T,
42
+ filterFn?: (v: { type: "up" | "down"; detail: string }) => boolean,
43
+ ): Record<string, T> {
44
+ if (!init) return {}
45
+ const entries = Object.entries(init)
46
+ const filtered = filterFn ? entries.filter(([, v]) => filterFn(v)) : entries
47
+ return Object.fromEntries(filtered.map(([k, v]) => [k, mapFn(v)]))
48
+ }
49
+
39
50
  function ScoreBreakdown({ factors, onFactorFeedback, className, initialFeedback }: ScoreBreakdownProps) {
40
51
  const [feedback, setFeedback] = React.useState<Record<string, "up" | "down" | null>>(
41
- initialFeedback
42
- ? Object.fromEntries(Object.entries(initialFeedback).map(([k, v]) => [k, v.type]))
43
- : {}
52
+ () => deriveInitialState(initialFeedback, (v) => v.type)
44
53
  )
45
54
  const [feedbackText, setFeedbackText] = React.useState<Record<string, string>>(
46
- initialFeedback
47
- ? Object.fromEntries(Object.entries(initialFeedback).map(([k, v]) => [k, v.detail]))
48
- : {}
55
+ () => deriveInitialState(initialFeedback, (v) => v.detail)
49
56
  )
50
57
  const [savedText, setSavedText] = React.useState<Record<string, string>>(
51
- initialFeedback
52
- ? Object.fromEntries(
53
- Object.entries(initialFeedback)
54
- .filter(([, v]) => v.detail)
55
- .map(([k, v]) => [k, v.detail])
56
- )
57
- : {}
58
+ () => deriveInitialState(initialFeedback, (v) => v.detail, (v) => !!v.detail)
58
59
  )
59
60
  const [editingKey, setEditingKey] = React.useState<string | null>(null)
60
61
 
62
+ // Sync state when initialFeedback prop changes (e.g. async hydration).
63
+ // Skip keys the user is actively editing to avoid clobbering in-progress input.
64
+ React.useEffect(() => {
65
+ setFeedback((prev) => {
66
+ const next: Record<string, "up" | "down" | null> = deriveInitialState(initialFeedback, (v) => v.type)
67
+ // Preserve any key the user is currently editing
68
+ if (editingKey && prev[editingKey] !== undefined) {
69
+ next[editingKey] = prev[editingKey]
70
+ }
71
+ return next
72
+ })
73
+ setFeedbackText((prev) => {
74
+ const next = deriveInitialState(initialFeedback, (v) => v.detail)
75
+ if (editingKey && prev[editingKey] !== undefined) {
76
+ next[editingKey] = prev[editingKey]
77
+ }
78
+ return next
79
+ })
80
+ setSavedText((prev) => {
81
+ const next = deriveInitialState(initialFeedback, (v) => v.detail, (v) => !!v.detail)
82
+ if (editingKey && prev[editingKey] !== undefined) {
83
+ next[editingKey] = prev[editingKey]
84
+ }
85
+ return next
86
+ })
87
+ }, [initialFeedback]) // eslint-disable-line react-hooks/exhaustive-deps -- reads editingKey as guard, not trigger
88
+
61
89
  const handleFeedback = (factorKey: string, type: "up" | "down") => {
62
90
  const newState = feedback[factorKey] === type ? null : type
63
91
  setFeedback((prev) => ({ ...prev, [factorKey]: newState }))
@@ -61,9 +61,16 @@ function Root({ children, onSubmitFeedback, initialFeedback }: RootProps) {
61
61
  const [detailText, setDetailTextState] = React.useState("")
62
62
  const [notedType, setNotedType] = React.useState<"up" | "down" | null>(null)
63
63
  const [submittedFeedback, setSubmittedFeedback] = React.useState<SubmittedScoreFeedback | null>(
64
- initialFeedback ? { type: initialFeedback.type, pills: initialFeedback.pills, detail: initialFeedback.detail } : null
64
+ initialFeedback ?? null
65
65
  )
66
66
 
67
+ // Sync submitted feedback when initialFeedback prop changes (e.g. async
68
+ // detail load). Skip when the user has an in-progress edit (thumbState set).
69
+ React.useEffect(() => {
70
+ if (thumbState !== null) return
71
+ setSubmittedFeedback(initialFeedback ?? null)
72
+ }, [initialFeedback]) // eslint-disable-line react-hooks/exhaustive-deps -- intentionally omits thumbState to read it as a guard, not a trigger
73
+
67
74
  const otherSelected = selectedPills.includes("Other")
68
75
 
69
76
  const hasRequiredInput =