1import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell";
2import {
3 generateSpreadPinOrderKeys,
4 pinOrderKeyBetween,
5 planPinnedReorder,
6} from "@t3tools/client-runtime/state/thread-sort";
7import { effectiveSnoozed } from "@t3tools/client-runtime/state/thread-settled";
8import type { EnvironmentId } from "@t3tools/contracts";
9
10export type ThreadMoveDestination =
11 | "up"
12 | "down"
13 | {
14 readonly targetId: string | null;
15 readonly section?: "pinned" | "active" | "settled";
16 readonly placement: "before" | "after";
17 };
18
19export function threadOrderAfterMove(
20 orderedIds: readonly string[],
21 movedId: string,
22 destination: ThreadMoveDestination,
23): string[] | null {
24 if (typeof destination === "object" && destination.section === "settled") return null;
25 const from = orderedIds.indexOf(movedId);
26 if (from < 0 && (typeof destination === "string" || destination.section === undefined))
27 return null;
28 const result = orderedIds.filter((id) => id !== movedId);
29 let to: number;
30 if (typeof destination === "string") {
31 to = from + (destination === "up" ? -1 : 1);
32 if (to < 0 || to >= orderedIds.length) return null;
33 } else {
34 if (destination.targetId === null) {
35 if (destination.section === undefined) return null;
36 to = destination.placement === "before" ? 0 : result.length;
37 } else {
38 const target = result.indexOf(destination.targetId);
39 if (target < 0) return null;
40 to = target + (destination.placement === "after" ? 1 : 0);
41 }
42 }
43 if (to === from) return null;
44 result.splice(to, 0, movedId);
45 return result;
46}
47
48export type OrderRow = Pick<
49 EnvironmentThreadShell,
50 | "id"
51 | "environmentId"
52 | "pinOrderKey"
53 | "activeOrderKey"
54 | "createdAt"
55 | "unsettledAt"
56 | "pinnedAt"
57>;
58
59export interface PendingThreadOrder {
60 readonly section: "pinned" | "active";
61 readonly orderedIds: readonly string[];
62 readonly before: ReadonlyMap<string, { readonly key: string | null; readonly anchor: string }>;
63 readonly assignments: ReadonlyMap<string, string>;
64 readonly confirmed: ReadonlySet<string>;
65 readonly commandsComplete: boolean;
66}
67
68function rowId(row: OrderRow): string {
69 return `${row.environmentId}:${row.id}`;
70}
71
72function rowOrder(row: OrderRow, section: PendingThreadOrder["section"]) {
73 return {
74 key: (section === "pinned" ? row.pinOrderKey : row.activeOrderKey) ?? null,
75 anchor: section === "pinned" ? (row.pinnedAt ?? "") : (row.unsettledAt ?? row.createdAt),
76 };
77}
78
79export function createThreadMovePlanner(input: {
80 readonly ordered: readonly OrderRow[];
81 readonly allThreads?: readonly OrderRow[];
82 readonly section: PendingThreadOrder["section"];
83 readonly reorderableEnvironmentIds: ReadonlySet<EnvironmentId>;
84}) {
85 const orderedIds = input.ordered.map(rowId);
86 const keysById = new Map(
87 (input.allThreads ?? input.ordered).map((row) => [
88 rowId(row),
89 rowOrder(row, input.section).key,
90 ]),
91 );
92 const writableIds = new Set(
93 (input.allThreads ?? input.ordered)
94 .filter((row) => input.reorderableEnvironmentIds.has(row.environmentId))
95 .map(rowId),
96 );
97 return (movedId: string, direction: ThreadMoveDestination) => {
98 if (!writableIds.has(movedId)) return null;
99 const nextIds = threadOrderAfterMove(orderedIds, movedId, direction);
100 if (nextIds === null) return null;
101 const assignments = planPinnedReorder({ orderedIds: nextIds, keysById, movedId });
102 return assignments === null ||
103 assignments.length === 0 ||
104 assignments.some((assignment) => !writableIds.has(assignment.id))
105 ? null
106 : assignments;
107 };
108}
109
110export interface ThreadMoveAvailability {
111 readonly canMoveUp: boolean;
112 readonly canMoveDown: boolean;
113}
114
115/** Compute per section; per-card planner probes make minute ticks quadratic. */
116export function computeThreadMoveAvailability(input: {
117 readonly ordered: readonly OrderRow[];
118 readonly allThreads?: readonly OrderRow[];
119 readonly section: PendingThreadOrder["section"];
120 readonly reorderableEnvironmentIds: ReadonlySet<EnvironmentId>;
121 readonly pendingOrder?: PendingThreadOrder | null;
122}): Map<string, ThreadMoveAvailability> {
123 const result = new Map<string, ThreadMoveAvailability>();
124 if (input.pendingOrder != null) return result;
125 const rows = input.ordered;
126 if (rows.length < 2) {
127 for (const row of rows) result.set(rowId(row), { canMoveUp: false, canMoveDown: false });
128 return result;
129 }
130 const orderedIds: string[] = [];
131 const indexById = new Map<string, number>();
132 for (const row of rows) {
133 const id = rowId(row);
134 indexById.set(id, orderedIds.length);
135 orderedIds.push(id);
136 }
137 const keysById = new Map<string, string | null>();
138 const writableIds = new Set<string>();
139 for (const row of input.allThreads ?? input.ordered) {
140 const id = rowId(row);
141 keysById.set(id, rowOrder(row, input.section).key);
142 if (input.reorderableEnvironmentIds.has(row.environmentId)) writableIds.add(id);
143 }
144 const reservedKeys = new Set<string>();
145 keysById.forEach((key, id) => {
146 if (key != null && !indexById.has(id)) reservedKeys.add(key);
147 });
148 // Swaps affect at most two positions; precomputed totals allow constant-size availability checks.
149 const midpoints = new Map<string, string | null>();
150 const fastPathKey = (beforeKey: string | null, afterKey: string | null): string | null => {
151 const memoKey = `${beforeKey ?? ""}\u0000${afterKey ?? ""}`;
152 const cached = midpoints.get(memoKey);
153 if (cached !== undefined) return cached;
154 let key = pinOrderKeyBetween(beforeKey, afterKey);
155 while (key !== null && reservedKeys.has(key)) key = pinOrderKeyBetween(key, afterKey);
156 midpoints.set(memoKey, key);
157 return key;
158 };
159 const spreadKeys = generateSpreadPinOrderKeys(orderedIds.length + reservedKeys.size)
160 .filter((key) => !reservedKeys.has(key))
161 .slice(0, orderedIds.length);
162 const currentKeys = orderedIds.map((id) => keysById.get(id) ?? null);
163 const writableRow = orderedIds.map((id) => writableIds.has(id));
164 let baselineWrites = 0;
165 let baselineUnwritableWrites = 0;
166 for (let position = 0; position < orderedIds.length; position += 1) {
167 if (currentKeys[position] === spreadKeys[position]) continue;
168 baselineWrites += 1;
169 if (!writableRow[position]) baselineUnwritableWrites += 1;
170 }
171 const rewriteViable = (index: number): boolean => {
172 let writes = baselineWrites;
173 let unwritableWrites = baselineUnwritableWrites;
174 for (const position of [index, index + 1]) {
175 const other = position === index ? index + 1 : index;
176 if (currentKeys[position] !== spreadKeys[position]) {
177 writes -= 1;
178 if (!writableRow[position]) unwritableWrites -= 1;
179 }
180 if (currentKeys[other] !== spreadKeys[position]) {
181 writes += 1;
182 if (!writableRow[other]) unwritableWrites += 1;
183 }
184 }
185 return writes > 0 && unwritableWrites === 0;
186 };
187 for (const row of rows) {
188 const movedId = rowId(row);
189 const denied = { canMoveUp: false, canMoveDown: false };
190 if (!writableIds.has(movedId)) {
191 result.set(movedId, denied);
192 continue;
193 }
194 const index = indexById.get(movedId);
195 if (index === undefined) {
196 result.set(movedId, denied);
197 continue;
198 }
199 const adjacentAvailable = (towardUp: boolean): boolean => {
200 const shifted = index + (towardUp ? -1 : 1);
201 if (shifted < 0 || shifted >= orderedIds.length) return false;
202 const beforeIndex = towardUp ? index - 2 : index + 1;
203 const afterIndex = towardUp ? index - 1 : index + 2;
204 const beforeId = beforeIndex < 0 ? null : (orderedIds[beforeIndex] ?? null);
205 const afterId = afterIndex >= orderedIds.length ? null : (orderedIds[afterIndex] ?? null);
206 const beforeKey = beforeId === null ? null : (keysById.get(beforeId) ?? null);
207 const afterKey = afterId === null ? null : (keysById.get(afterId) ?? null);
208 if ((beforeId === null || beforeKey != null) && (afterId === null || afterKey != null)) {
209 const key = fastPathKey(beforeKey, afterKey);
210 if (key !== null) return true;
211 }
212 return rewriteViable(Math.min(index, shifted));
213 };
214 result.set(movedId, {
215 canMoveUp: adjacentAvailable(true),
216 canMoveDown: adjacentAvailable(false),
217 });
218 }
219 return result;
220}
221
222export function createPendingThreadOrder(input: {
223 readonly section: PendingThreadOrder["section"];
224 readonly ordered: readonly OrderRow[];
225 readonly movedId: string;
226 readonly direction: ThreadMoveDestination;
227 readonly assignments: readonly { readonly id: string; readonly orderKey: string }[];
228}): PendingThreadOrder {
229 const orderedIds = threadOrderAfterMove(input.ordered.map(rowId), input.movedId, input.direction);
230 if (orderedIds === null) throw new Error("Cannot begin an invalid thread move");
231 return {
232 section: input.section,
233 orderedIds,
234 before: new Map(input.ordered.map((row) => [rowId(row), rowOrder(row, input.section)])),
235 assignments: new Map(input.assignments.map(({ id, orderKey }) => [id, orderKey])),
236 confirmed: new Set(),
237 commandsComplete: false,
238 };
239}
240
241/** Receipts and shell updates arrive independently. Only our own key writes
242 * may pass through the hold; membership and other arrangement changes win. */
243export function reconcilePendingThreadOrder(
244 pending: PendingThreadOrder,
245 ordered: readonly OrderRow[],
246): PendingThreadOrder | null {
247 if (ordered.length !== pending.before.size) return null;
248 const confirmed = new Set(pending.confirmed);
249 for (const row of ordered) {
250 const id = rowId(row);
251 const before = pending.before.get(id);
252 const current = rowOrder(row, pending.section);
253 if (before === undefined || current.anchor !== before.anchor) return null;
254 const assigned = pending.assignments.get(id);
255 if (assigned !== undefined && current.key === assigned) confirmed.add(id);
256 else if (current.key !== before.key || confirmed.has(id)) return null;
257 }
258 if (pending.commandsComplete && confirmed.size === pending.assignments.size) return null;
259 return confirmed.size === pending.confirmed.size ? pending : { ...pending, confirmed };
260}
261
262export function applyPendingThreadOrder<T extends OrderRow>(
263 rows: readonly T[],
264 section: PendingThreadOrder["section"],
265 pending: PendingThreadOrder | null | undefined,
266): T[] {
267 if (pending == null || pending.section !== section) return [...rows];
268 const rank = new Map(pending.orderedIds.map((id, index) => [id, index]));
269 return [...rows].sort(
270 (left, right) => (rank.get(rowId(left)) ?? Infinity) - (rank.get(rowId(right)) ?? Infinity),
271 );
272}
273
274export function threadDropLifecycle(
275 thread: EnvironmentThreadShell,
276 section: "pinned" | "active",
277 now: string,
278) {
279 if (section === "pinned") return { pin: true, unpin: false, unsettle: false, unsnooze: false };
280 return {
281 pin: false,
282 unpin: thread.pinnedAt != null,
283 unsettle: thread.settledOverride === "settled",
284 unsnooze: effectiveSnoozed(thread, { now }),
285 };
286}
287
288export type ThreadDragSection = "pinned" | "active" | "snoozed" | "settled";
289
290export function threadDragAction(source: ThreadDragSection, destination: ThreadDragSection) {
291 if (destination === "snoozed") return null;
292 if (destination === "settled") return source === "settled" ? null : "Settle";
293 if (source === destination) return "Reorder";
294 if (destination === "pinned") return "Pin";
295 if (source === "pinned") return "Unpin";
296 if (source === "settled") return "Unsettle";
297 return "Unsnooze";
298}