- Run record
- loading
Loading this Run…
Loading the Revisions of this Run…
A published source file of Revision 0 of Boardly — Website Relaunch 1.0.0, shown as text in the browser.
Source is browser-viewable only. There is no download or archive endpoint, and no additional license is granted beyond inspection and research.
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { initialState } from "./data";
import type { BoardState, Card, Column, UndoEntry } from "./types";
export const STORAGE_KEY = "boardly.harbor-studio.website-relaunch.v1";
/* ---------- pure helpers ---------- */
export function findColumnOf(state: BoardState, cardId: string): Column | undefined {
return state.columns.find((c) => c.card_ids.includes(cardId));
}
export function columnById(state: BoardState, id: string): Column | undefined {
return state.columns.find((c) => c.id === id);
}
export interface Blocked {
ok: false;
reason: string;
}
export interface Ok {
ok: true;
}
export type Result = Ok | Blocked;
/**
* Explains why a card cannot enter `toColumnId`, or null when it can.
* A card already in the column can always be reordered within it.
*/
export function wipBlockReason(
state: BoardState,
toColumnId: string,
fromColumnId: string | null,
): string | null {
const col = columnById(state, toColumnId);
if (!col || col.wip_limit === null) return null;
if (fromColumnId === toColumnId) return null;
if (col.card_ids.length < col.wip_limit) return null;
return `${col.title} is at its limit of ${col.wip_limit} card${col.wip_limit === 1 ? "" : "s"}. Move one out before adding another.`;
}
function removeCard(columns: Column[], cardId: string): Column[] {
return columns.map((c) =>
c.card_ids.includes(cardId) ? { ...c, card_ids: c.card_ids.filter((id) => id !== cardId) } : c,
);
}
function insertAt(columns: Column[], columnId: string, cardId: string, index: number): Column[] {
return columns.map((c) => {
if (c.id !== columnId) return c;
const ids = [...c.card_ids];
const i = Math.max(0, Math.min(index, ids.length));
ids.splice(i, 0, cardId);
return { ...c, card_ids: ids };
});
}
/** Move `cardId` into `toColumnId`, placing it before `beforeCardId` (or at the end when null). */
export function moveBefore(
state: BoardState,
cardId: string,
toColumnId: string,
beforeCardId: string | null,
): BoardState {
const without = removeCard(state.columns, cardId);
const target = without.find((c) => c.id === toColumnId);
if (!target) return state;
const index = beforeCardId ? target.card_ids.indexOf(beforeCardId) : -1;
return { ...state, columns: insertAt(without, toColumnId, cardId, index === -1 ? target.card_ids.length : index) };
}
export function moveToIndex(state: BoardState, cardId: string, toColumnId: string, index: number): BoardState {
const without = removeCard(state.columns, cardId);
return { ...state, columns: insertAt(without, toColumnId, cardId, index) };
}
export function positionOf(state: BoardState, cardId: string): { columnId: string; index: number } | null {
for (const c of state.columns) {
const i = c.card_ids.indexOf(cardId);
if (i !== -1) return { columnId: c.id, index: i };
}
return null;
}
/* ---------- persistence ---------- */
function isValidState(v: unknown): v is BoardState {
if (!v || typeof v !== "object") return false;
const s = v as BoardState;
return Array.isArray(s.columns) && typeof s.cards === "object" && s.cards !== null;
}
function loadState(): BoardState {
try {
const raw = localStorage.getItem(STORAGE_KEY);
if (!raw) return initialState();
const parsed: unknown = JSON.parse(raw);
if (!isValidState(parsed)) return initialState();
// Guard against stale shapes: every referenced card must exist.
for (const col of parsed.columns) {
for (const id of col.card_ids) if (!parsed.cards[id]) return initialState();
}
return parsed;
} catch {
return initialState();
}
}
function saveState(state: BoardState) {
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(state));
} catch {
/* storage may be unavailable; the board still works in memory */
}
}
/* ---------- hook ---------- */
let cardSeq = 0;
export function newCardId(): string {
cardSeq += 1;
return `c-${Date.now().toString(36)}-${cardSeq}`;
}
export function useBoard() {
const [state, setState] = useState<BoardState>(loadState);
const [undoStack, setUndoStack] = useState<UndoEntry[]>([]);
const stateRef = useRef(state);
stateRef.current = state;
useEffect(() => {
saveState(state);
}, [state]);
const push = useCallback((entry: UndoEntry) => {
setUndoStack((s) => [...s.slice(-19), entry]);
}, []);
/** Move by drop position. Records an undo entry unless `record` is false. */
const move = useCallback(
(cardId: string, toColumnId: string, beforeCardId: string | null, record = true): Result => {
const s = stateRef.current;
const from = positionOf(s, cardId);
if (!from) return { ok: false, reason: "That card no longer exists." };
const reason = wipBlockReason(s, toColumnId, from.columnId);
if (reason) return { ok: false, reason };
const next = moveBefore(s, cardId, toColumnId, beforeCardId);
stateRef.current = next;
setState(next);
if (record) {
push({ kind: "move", label: "move", cardId, columnId: from.columnId, index: from.index });
}
return { ok: true };
},
[push],
);
const moveToPosition = useCallback(
(cardId: string, toColumnId: string, index: number, record = true): Result => {
const s = stateRef.current;
const from = positionOf(s, cardId);
if (!from) return { ok: false, reason: "That card no longer exists." };
const reason = wipBlockReason(s, toColumnId, from.columnId);
if (reason) return { ok: false, reason };
const next = moveToIndex(s, cardId, toColumnId, index);
stateRef.current = next;
setState(next);
if (record) {
push({ kind: "move", label: "move", cardId, columnId: from.columnId, index: from.index });
}
return { ok: true };
},
[push],
);
/** Records one undo entry for a keyboard session that started at `origin`. */
const recordMove = useCallback(
(cardId: string, origin: { columnId: string; index: number }) => {
push({ kind: "move", label: "move", cardId, columnId: origin.columnId, index: origin.index });
},
[push],
);
const create = useCallback(
(columnId: string, title: string): Result & { cardId?: string } => {
const s = stateRef.current;
const trimmed = title.trim();
if (!trimmed) return { ok: false, reason: "Add a title to create the card." };
const reason = wipBlockReason(s, columnId, null);
if (reason) return { ok: false, reason };
const card: Card = {
id: newCardId(),
title: trimmed,
description: "",
assignee: null,
labels: [],
priority: "medium",
due: null,
checklist: [],
};
const next: BoardState = {
cards: { ...s.cards, [card.id]: card },
columns: s.columns.map((c) => (c.id === columnId ? { ...c, card_ids: [...c.card_ids, card.id] } : c)),
};
stateRef.current = next;
setState(next);
push({ kind: "create", label: "new card", cardId: card.id });
return { ok: true, cardId: card.id };
},
[push],
);
const remove = useCallback(
(cardId: string): Result => {
const s = stateRef.current;
const pos = positionOf(s, cardId);
const card = s.cards[cardId];
if (!pos || !card) return { ok: false, reason: "That card no longer exists." };
const cards = { ...s.cards };
delete cards[cardId];
const next: BoardState = { cards, columns: removeCard(s.columns, cardId) };
stateRef.current = next;
setState(next);
push({ kind: "delete", label: "delete", card, columnId: pos.columnId, index: pos.index });
return { ok: true };
},
[push],
);
const update = useCallback((cardId: string, patch: Partial<Card>) => {
const s = stateRef.current;
const card = s.cards[cardId];
if (!card) return;
const next: BoardState = { ...s, cards: { ...s.cards, [cardId]: { ...card, ...patch } } };
stateRef.current = next;
setState(next);
}, []);
const undo = useCallback((): Result & { entry?: UndoEntry } => {
const entry = undoStack[undoStack.length - 1];
if (!entry) return { ok: false, reason: "Nothing to undo." };
const s = stateRef.current;
let next: BoardState;
if (entry.kind === "move") {
const from = positionOf(s, entry.cardId);
if (!from) return { ok: false, reason: "The moved card no longer exists." };
const reason = wipBlockReason(s, entry.columnId, from.columnId);
if (reason) return { ok: false, reason: `Can't undo: ${reason}` };
next = moveToIndex(s, entry.cardId, entry.columnId, entry.index);
} else if (entry.kind === "create") {
const cards = { ...s.cards };
delete cards[entry.cardId];
next = { cards, columns: removeCard(s.columns, entry.cardId) };
} else {
const reason = wipBlockReason(s, entry.columnId, null);
if (reason) return { ok: false, reason: `Can't undo: ${reason}` };
next = {
cards: { ...s.cards, [entry.card.id]: entry.card },
columns: insertAt(s.columns, entry.columnId, entry.card.id, entry.index),
};
}
stateRef.current = next;
setState(next);
setUndoStack((st) => st.slice(0, -1));
return { ok: true, entry };
}, [undoStack]);
const reset = useCallback(() => {
const next = initialState();
stateRef.current = next;
setState(next);
setUndoStack([]);
try {
localStorage.removeItem(STORAGE_KEY);
} catch {
/* ignore */
}
}, []);
const lastUndo = undoStack[undoStack.length - 1] ?? null;
return useMemo(
() => ({ state, move, moveToPosition, recordMove, create, remove, update, undo, reset, lastUndo }),
[state, move, moveToPosition, recordMove, create, remove, update, undo, reset, lastUndo],
);
}
export type BoardApi = ReturnType<typeof useBoard>;