shoc-frontend-new/src/domain/work-orders/utils/board-row-order-storage.ts
Arthur Bassi dc178340de feat(work-orders): persist board row order locally and support keyboard reorder
Keep same-day WO order across refresh and sign-out in the same browser.
Add keyboard reorder on the row grip.
2026-09-02 11:07:27 -03:00

204 lines
5.9 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,
): 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;
}
return dayRows[index + delta]?.id ?? 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 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];
}