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