- Run record
- loading
Loading this Run…
Loading the Revisions of this Run…
A published source file of Revision 0 of Boardly — Website Relaunch 1.1.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 { INITIAL_BOARD, type BoardState, type Card, type Column } from "./model";
export type Filters = { assignee: string; label: string; text: string };
export const NO_FILTERS: Filters = { assignee: "all", label: "all", text: "" };
export function filtersActive(f: Filters): boolean {
return f.assignee !== "all" || f.label !== "all" || f.text.trim() !== "";
}
export function cardMatches(card: Card, f: Filters): boolean {
if (f.assignee === "unassigned") {
if (card.assignee !== null) return false;
} else if (f.assignee !== "all" && card.assignee !== f.assignee) return false;
if (f.label !== "all" && !card.labels.includes(f.label)) return false;
const q = f.text.trim().toLowerCase();
if (q !== "" && !card.title.toLowerCase().includes(q) && !card.description.toLowerCase().includes(q)) return false;
return true;
}
export function findColumn(columns: Column[], cardId: string): Column | undefined {
return columns.find(c => c.card_ids.includes(cardId));
}
export function wouldExceedLimit(board: BoardState, colId: string, incomingCardId: string | null): boolean {
const col = board.columns.find(c => c.id === colId);
if (!col || col.wip_limit == null) return false;
const count = col.card_ids.filter(id => id !== incomingCardId).length;
return count + 1 > col.wip_limit;
}
export function limitMessage(col: Column): string {
return `"${col.title}" is at its WIP limit of ${col.wip_limit} cards. Finish or move a card out first.`;
}
export function moveCard(board: BoardState, cardId: string, toColId: string, anchor: string | "end"): BoardState {
const from = findColumn(board.columns, cardId);
const to = board.columns.find(c => c.id === toColId);
if (!from || !to) return board;
const stripped = board.columns.map(c =>
c.id === from.id ? { ...c, card_ids: c.card_ids.filter(id => id !== cardId) } : c,
);
const target = stripped.find(c => c.id === toColId)!;
let idx = anchor === "end" ? target.card_ids.length : target.card_ids.indexOf(anchor);
if (idx === -1) idx = target.card_ids.length;
const nextIds = [...target.card_ids.slice(0, idx), cardId, ...target.card_ids.slice(idx)];
const columns = stripped.map(c => (c.id === toColId ? { ...c, card_ids: nextIds } : c));
const changed = columns.some((c, i) => c.card_ids.join("\u0000") !== board.columns[i].card_ids.join("\u0000"));
if (!changed) return board;
return { columns, cards: board.cards };
}
export function appendCard(board: BoardState, colId: string, card: Card): BoardState {
return {
columns: board.columns.map(c => (c.id === colId ? { ...c, card_ids: [...c.card_ids, card.id] } : c)),
cards: { ...board.cards, [card.id]: card },
};
}
export function removeCard(board: BoardState, cardId: string): BoardState {
const cards = { ...board.cards };
delete cards[cardId];
return {
columns: board.columns.map(c =>
c.card_ids.includes(cardId) ? { ...c, card_ids: c.card_ids.filter(id => id !== cardId) } : c,
),
cards,
};
}
export function patchCard(board: BoardState, cardId: string, patch: Partial<Card>): BoardState {
const card = board.cards[cardId];
if (!card) return board;
return { ...board, cards: { ...board.cards, [cardId]: { ...card, ...patch } } };
}
export function newCardId(): string {
return `c-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 7)}`;
}
export type HistoryKind = "move" | "create" | "delete";
export type HistoryEntry = { kind: HistoryKind; cardTitle: string; board: BoardState };
export type AppState = { board: BoardState; history: HistoryEntry[] };
export type Action =
| { type: "move"; cardId: string; toColId: string; anchor: string | "end"; title: string }
| { type: "create"; colId: string; card: Card }
| { type: "delete"; cardId: string; title: string }
| { type: "patch"; cardId: string; patch: Partial<Card> }
| { type: "undo" }
| { type: "reset" };
const MAX_HISTORY = 50;
function pushHistory(state: AppState, entry: HistoryEntry): HistoryEntry[] {
return [...state.history.slice(-(MAX_HISTORY - 1)), entry];
}
export function reducer(state: AppState, action: Action): AppState {
switch (action.type) {
case "move": {
const board = moveCard(state.board, action.cardId, action.toColId, action.anchor);
if (board === state.board) return state;
return { board, history: pushHistory(state, { kind: "move", cardTitle: action.title, board: state.board }) };
}
case "create": {
const board = appendCard(state.board, action.colId, action.card);
return { board, history: pushHistory(state, { kind: "create", cardTitle: action.card.title, board: state.board }) };
}
case "delete": {
const board = removeCard(state.board, action.cardId);
return { board, history: pushHistory(state, { kind: "delete", cardTitle: action.title, board: state.board }) };
}
case "patch":
return { ...state, board: patchCard(state.board, action.cardId, action.patch) };
case "undo": {
if (state.history.length === 0) return state;
const last = state.history[state.history.length - 1];
return { board: last.board, history: state.history.slice(0, -1) };
}
case "reset":
return { board: INITIAL_BOARD, history: [] };
}
}
const STORAGE_KEY = "boardly.board.v1";
function isStoredBoardValid(b: unknown): b is BoardState {
const board = b as BoardState;
if (!board || typeof board !== "object" || Array.isArray(board)) return false;
if (!Array.isArray(board.columns) || board.columns.length !== INITIAL_BOARD.columns.length) return false;
if (!board.cards || typeof board.cards !== "object") return false;
const initialIds = INITIAL_BOARD.columns.map(c => c.id).join("|");
if (board.columns.map(c => c?.id).join("|") !== initialIds) return false;
let total = 0;
for (const col of board.columns) {
if (!col || !Array.isArray(col.card_ids)) return false;
for (const id of col.card_ids) {
const card = board.cards[id];
if (!card || typeof card.title !== "string" || !Array.isArray(card.checklist)) return false;
total++;
}
}
return total === Object.keys(board.cards).length;
}
export function loadBoard(): BoardState {
try {
const raw = localStorage.getItem(STORAGE_KEY);
if (raw) {
const parsed = JSON.parse(raw) as unknown;
if (isStoredBoardValid(parsed)) return parsed;
}
} catch {}
return INITIAL_BOARD;
}
export function saveBoard(board: BoardState): void {
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(board));
} catch {}
}