shoc-frontend-new/src/domain/work-orders/utils/board-row-order-storage.ts

248 lines
7.1 KiB
TypeScript

export type BoardRowOrderByDay = Record<string, string[]>;
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<T extends { id: string | number }>(
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<T extends { id: string | number; dayGroup: string }>(
items: T[],
orderByDay: BoardRowOrderByDay,
): T[] {
if (!items.length || !Object.keys(orderByDay).length) {
return items;
}
const byDay = new Map<string, T[]>();
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<string, T[]>();
for (const [dayGroup, dayItems] of byDay) {
orderedByDay.set(dayGroup, applyDayRowOrder(dayItems, orderByDay[dayGroup]));
}
const nextIndex = new Map<string, number>();
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<T extends { id: string | number; dayGroup: string }>(
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];
}