返回 JoyAI-Echo
reducer.ts
1 /**
2 * Overlay + field lease merge for workplace text fields.
3 *
4 * Poll, WebSocket, and mutation responses all funnel through
5 * `applyServerSnapshot`. Held/dirty paths keep local overlay values so
6 * textarea editing is not clobbered by background refresh.
7 */
8 import type { StoryProfileBeat, WorkplaceData } from "@/lib/types";
9
10 import {
11 beatPath,
12 type EditorSegment,
13 type FieldPath,
14 type LeaseMode,
15 segmentIdForShot,
16 type WorkplaceEditorState,
17 } from "./types";
18
19 export const initialEditorState: WorkplaceEditorState = {
20 server: null,
21 overlay: {},
22 lease: {},
23 dirty: {},
24 committing: false,
25 };
26
27 function beatsFromServer(server: WorkplaceData): StoryProfileBeat[] {
28 const beats = server.story_profile?.beats;
29 if (!Array.isArray(beats)) return [];
30 return beats
31 .map((beat, index) => {
32 const shotId = Number(beat?.shot_id ?? index + 1);
33 const summary = String(beat?.summary ?? "").trim();
34 if (!Number.isFinite(shotId) || !summary) return null;
35 return { shot_id: shotId, summary };
36 })
37 .filter((beat): beat is StoryProfileBeat => beat !== null);
38 }
39
40 export function serverValueForPath(
41 server: WorkplaceData,
42 path: FieldPath,
43 ): string {
44 if (path === "story") return server.story_md ?? "";
45 const shotId = Number(path.slice("beat:".length));
46 const beat = beatsFromServer(server).find((item) => item.shot_id === shotId);
47 return beat?.summary ?? "";
48 }
49
50 export function buildSegmentsFromServer(
51 server: WorkplaceData,
52 overlay: Partial<Record<FieldPath, string>>,
53 lease: Partial<Record<FieldPath, LeaseMode>>,
54 dirty: Partial<Record<FieldPath, boolean>>,
55 ): EditorSegment[] {
56 return beatsFromServer(server).map((beat) => {
57 const path = beatPath(beat.shot_id);
58 const held = lease[path] === "held" || dirty[path];
59 const text =
60 held && overlay[path] !== undefined ? overlay[path]! : beat.summary;
61 return {
62 id: segmentIdForShot(beat.shot_id),
63 shotId: beat.shot_id,
64 text,
65 };
66 });
67 }
68
69 function shouldPreservePath(
70 state: WorkplaceEditorState,
71 path: FieldPath,
72 ): boolean {
73 if (state.committing) return true;
74 return state.lease[path] === "held" || Boolean(state.dirty[path]);
75 }
76
77 export function applyServerSnapshot(
78 state: WorkplaceEditorState,
79 next: WorkplaceData,
80 ): WorkplaceEditorState {
81 const stageChanged = state.server?.stage !== next.stage;
82 const workChanged = state.server?.work_id !== next.work_id;
83
84 if (stageChanged || workChanged) {
85 return {
86 server: next,
87 overlay: {},
88 lease: {},
89 dirty: {},
90 committing: false,
91 };
92 }
93
94 const overlay = { ...state.overlay };
95 const lease = { ...state.lease };
96 const dirty = { ...state.dirty };
97
98 if (!shouldPreservePath(state, "story")) {
99 delete overlay.story;
100 delete lease.story;
101 delete dirty.story;
102 }
103
104 const nextBeatIds = new Set(
105 beatsFromServer(next).map((beat) => beat.shot_id),
106 );
107 for (const path of Object.keys(overlay) as FieldPath[]) {
108 if (path === "story") continue;
109 const shotId = Number(path.slice("beat:".length));
110 if (!nextBeatIds.has(shotId)) {
111 delete overlay[path];
112 delete lease[path];
113 delete dirty[path];
114 }
115 }
116
117 for (const beat of beatsFromServer(next)) {
118 const path = beatPath(beat.shot_id);
119 if (!shouldPreservePath(state, path)) {
120 delete overlay[path];
121 delete lease[path];
122 delete dirty[path];
123 }
124 }
125
126 return {
127 ...state,
128 server: next,
129 overlay,
130 lease,
131 dirty,
132 committing: state.committing,
133 };
134 }
135
136 export function editorGet(
137 state: WorkplaceEditorState,
138 path: FieldPath,
139 ): string {
140 if (path in state.overlay) return state.overlay[path] ?? "";
141 if (state.server) return serverValueForPath(state.server, path);
142 return "";
143 }
144
145 export function editorHold(
146 state: WorkplaceEditorState,
147 path: FieldPath,
148 ): WorkplaceEditorState {
149 const value = editorGet(state, path);
150 return {
151 ...state,
152 overlay: { ...state.overlay, [path]: value },
153 lease: { ...state.lease, [path]: "held" },
154 };
155 }
156
157 function fieldValueDiffersFromServer(
158 server: WorkplaceData,
159 path: FieldPath,
160 value: string,
161 ): boolean {
162 return value.trim() !== serverValueForPath(server, path).trim();
163 }
164
165 export function editorEdit(
166 state: WorkplaceEditorState,
167 path: FieldPath,
168 value: string,
169 ): WorkplaceEditorState {
170 const overlay = { ...state.overlay };
171 const dirty = { ...state.dirty };
172 const lease = { ...state.lease, [path]: "held" as LeaseMode };
173
174 if (state.server && !fieldValueDiffersFromServer(state.server, path, value)) {
175 delete overlay[path];
176 delete dirty[path];
177 } else {
178 overlay[path] = value;
179 dirty[path] = true;
180 }
181
182 return { ...state, overlay, lease, dirty };
183 }
184
185 export function editorRelease(
186 state: WorkplaceEditorState,
187 path: FieldPath,
188 ): WorkplaceEditorState {
189 const lease = { ...state.lease };
190 delete lease[path];
191 return { ...state, lease };
192 }
193
194 export function editorClearOverlays(
195 state: WorkplaceEditorState,
196 paths?: FieldPath[],
197 ): WorkplaceEditorState {
198 if (!paths) {
199 return {
200 ...state,
201 overlay: {},
202 lease: {},
203 dirty: {},
204 };
205 }
206 const overlay = { ...state.overlay };
207 const lease = { ...state.lease };
208 const dirty = { ...state.dirty };
209 for (const path of paths) {
210 delete overlay[path];
211 delete lease[path];
212 delete dirty[path];
213 }
214 return { ...state, overlay, lease, dirty };
215 }
216
217 export function editorSetCommitting(
218 state: WorkplaceEditorState,
219 committing: boolean,
220 ): WorkplaceEditorState {
221 return { ...state, committing };
222 }
223
224 export function mergeStoryProfileWithOverlay(
225 server: WorkplaceData,
226 overlay: Partial<Record<FieldPath, string>>,
227 dirty: Partial<Record<FieldPath, boolean>>,
228 ): Record<string, unknown> | null {
229 const base = server.story_profile;
230 if (!base || typeof base !== "object") return null;
231 const beats = beatsFromServer(server).map((beat) => {
232 const path = beatPath(beat.shot_id);
233 const text =
234 dirty[path] && overlay[path] !== undefined
235 ? overlay[path]!
236 : beat.summary;
237 return { shot_id: beat.shot_id, summary: text.trim() };
238 });
239 if (beats.some((beat) => !beat.summary)) return null;
240 return {
241 ...base,
242 beats,
243 };
244 }
245
246 export function hasDirtyBeats(
247 dirty: Partial<Record<FieldPath, boolean>>,
248 ): boolean {
249 return Object.entries(dirty).some(
250 ([path, isDirty]) => isDirty && path.startsWith("beat:"),
251 );
252 }
253
253 lines TYPESCRIPT