export type BoardRowOrderByDay = Record; const STORAGE_PREFIX = "shoc:wo-board-row-order:v1:"; export function boardRowOrderStorageKey(weekMonday: string, ownerId = ""): string { const owner = ownerId.trim() || "local"; return `${STORAGE_PREFIX}${owner}:${weekMonday}`; } export function legacyBoardRowOrderStorageKey(weekMonday: string): string { return `${STORAGE_PREFIX}${weekMonday}`; } function isStringArray(value: unknown): value is string[] { return Array.isArray(value) && value.every((item) => typeof item === "string"); } function isBoardRowOrderByDay(value: unknown): value is BoardRowOrderByDay { if (value === null || typeof value !== "object" || Array.isArray(value)) { return false; } return Object.values(value).every(isStringArray); } function readStoredOrder(key: string): BoardRowOrderByDay { try { const raw = localStorage.getItem(key); if (!raw) { return {}; } const parsed: unknown = JSON.parse(raw); return isBoardRowOrderByDay(parsed) ? parsed : {}; } catch { return {}; } } export function loadBoardRowOrder(weekMonday: string, ownerId = ""): BoardRowOrderByDay { if (typeof window === "undefined" || !weekMonday) { return {}; } const scoped = readStoredOrder(boardRowOrderStorageKey(weekMonday, ownerId)); if (Object.keys(scoped).length) { return scoped; } return readStoredOrder(legacyBoardRowOrderStorageKey(weekMonday)); } export function saveBoardRowOrder( weekMonday: string, orderByDay: BoardRowOrderByDay, ownerId = "", ): void { if (typeof window === "undefined" || !weekMonday) { return; } try { localStorage.setItem(boardRowOrderStorageKey(weekMonday, ownerId), JSON.stringify(orderByDay)); } catch { // Quota exceeded or storage blocked — keep in-memory order only. } } /** Reorder `items` so known ids follow `orderedIds`; unknown ids stay at the end (stable). */ export function applyDayRowOrder( items: T[], orderedIds: string[] | undefined, ): T[] { if (!orderedIds?.length || items.length <= 1) { return items; } const orderMap = new Map(orderedIds.map((id, idx) => [id, idx])); return [...items].sort((a, b) => { const ai = orderMap.get(String(a.id)); const bi = orderMap.get(String(b.id)); if (ai === undefined && bi === undefined) { return 0; } if (ai === undefined) { return 1; } if (bi === undefined) { return -1; } return ai - bi; }); } /** Apply saved per-day orders across a flat row list (preserves relative order across days). */ export function applyRowOrderByDay( items: T[], orderByDay: BoardRowOrderByDay, ): T[] { if (!items.length || !Object.keys(orderByDay).length) { return items; } const byDay = new Map(); for (const item of items) { const group = byDay.get(item.dayGroup); if (group) { group.push(item); } else { byDay.set(item.dayGroup, [item]); } } const orderedByDay = new Map(); for (const [dayGroup, dayItems] of byDay) { orderedByDay.set(dayGroup, applyDayRowOrder(dayItems, orderByDay[dayGroup])); } const nextIndex = new Map(); return items.map((item) => { const dayItems = orderedByDay.get(item.dayGroup)!; const idx = nextIndex.get(item.dayGroup) ?? 0; nextIndex.set(item.dayGroup, idx + 1); return dayItems[idx]!; }); } /** Same-day neighbor for keyboard reorder; null at list ends or if the row is missing. */ export function neighborIdForReorder( rows: T[], row: T, delta: -1 | 1, isReorderTarget: (item: T) => boolean = () => true, ): string | number | null { const dayRows = rows.filter((item) => item.dayGroup === row.dayGroup); const index = dayRows.findIndex((item) => String(item.id) === String(row.id)); if (index < 0) { return null; } for (let cursor = index + delta; cursor >= 0 && cursor < dayRows.length; cursor += delta) { const candidate = dayRows[cursor]; if (candidate && isReorderTarget(candidate)) { return candidate.id; } } return null; } export function reorderIdsInList(ids: string[], dragId: string, overId: string): string[] | null { const dragIdx = ids.indexOf(dragId); const overIdx = ids.indexOf(overId); if (dragIdx < 0 || overIdx < 0 || dragIdx === overIdx) { return null; } const next = [...ids]; next.splice(dragIdx, 1); next.splice(overIdx, 0, dragId); return next; } /** Remove an id from every day bucket (used when a row changes dayGroup). */ export function removeIdFromRowOrder( orderByDay: BoardRowOrderByDay, id: string, ): BoardRowOrderByDay { let changed = false; const next: BoardRowOrderByDay = {}; for (const [dayGroup, ids] of Object.entries(orderByDay)) { if (!ids.includes(id)) { next[dayGroup] = ids; continue; } changed = true; const filtered = ids.filter((item) => item !== id); if (filtered.length) { next[dayGroup] = filtered; } } return changed ? next : orderByDay; } /** Drop the id from every day, then append it to the destination day. */ export type TransferIdToDestinationWeekOrderInput = { sourceWeekMonday: string; destinationWeekMonday: string; ownerId: string; id: string; nextDayGroup: string; }; /** Persist a moved row under the destination week when schedule crosses week boundaries. */ export function transferIdToDestinationWeekOrder({ sourceWeekMonday, destinationWeekMonday, ownerId, id, nextDayGroup, }: TransferIdToDestinationWeekOrderInput): void { if (!sourceWeekMonday || !destinationWeekMonday || !nextDayGroup) { return; } if (sourceWeekMonday === destinationWeekMonday) { return; } const sourceOrder = loadBoardRowOrder(sourceWeekMonday, ownerId); const sourceWithoutId = removeIdFromRowOrder(sourceOrder, id); if (sourceWithoutId !== sourceOrder) { saveBoardRowOrder(sourceWeekMonday, sourceWithoutId, ownerId); } const destinationOrder = loadBoardRowOrder(destinationWeekMonday, ownerId); saveBoardRowOrder( destinationWeekMonday, moveIdToDayOrder(destinationOrder, id, nextDayGroup), ownerId, ); } export function moveIdToDayOrder( orderByDay: BoardRowOrderByDay, id: string, nextDayGroup: string, ): BoardRowOrderByDay { if (!nextDayGroup) { return removeIdFromRowOrder(orderByDay, id); } const without = removeIdFromRowOrder(orderByDay, id); const dest = without[nextDayGroup] ?? []; if (dest.includes(id)) { return without; } return { ...without, [nextDayGroup]: [...dest, id] }; } /** * Merge a visible day's new order into stored order: visible ids take the new sequence, * ids that were stored but not currently visible stay after the visible block (preserving * their prior relative order). */ export function mergeDayOrder( previousDayIds: string[] | undefined, visibleOrderedIds: string[], ): string[] { if (!previousDayIds?.length) { return visibleOrderedIds; } const visibleSet = new Set(visibleOrderedIds); const hidden = previousDayIds.filter((id) => !visibleSet.has(id)); return [...visibleOrderedIds, ...hidden]; }