All files / src state.ts

100% Statements 26/26
100% Branches 18/18
100% Functions 5/5
100% Lines 25/25

Press n or j to go to the next uncovered block, b, p or k for the previous block.

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98                                                        4x       3x   1x   1x   1x                                     8x 8x 8x   8x 8x 8x 8x 8x 6x 6x   8x 8x 5x     8x 8x 8x       6x   8x                       2x 2x    
/**
 * App-state reducer for the multi-sheet client.
 *
 * Chose plain `useReducer` over Zustand: our state is a single small record
 * (one active index + the Foldr's row list), and the reducer is trivial.
 * Adding Zustand would impose a runtime dependency + provider wiring for no
 * test-ergonomics win. See `FINDINGS.md` for the full rationale.
 */
 
import type { HackFoldr, FoldrRow } from './Foldr.ts';
 
export interface AppState {
  readonly foldr: HackFoldr;
  /** Index of the active tab; clamped at render time to foldr.lastIndex. */
  readonly activeIndex: number;
  /**
   * Monotonically incremented whenever `foldr` mutates in-place. React can't
   * see mutation, so components subscribe to this to force re-render.
   */
  readonly rev: number;
}
 
export type AppAction =
  | { type: 'setActive'; index: number }
  | { type: 'bumpRev' }
  | { type: 'bumpRev+setActive'; index: number };
 
export function createInitialState(foldr: HackFoldr): AppState {
  return { foldr, activeIndex: 0, rev: 0 };
}
 
export function reducer(state: AppState, action: AppAction): AppState {
  switch (action.type) {
    case 'setActive':
      return { ...state, activeIndex: action.index };
    case 'bumpRev':
      return { ...state, rev: state.rev + 1 };
    case 'bumpRev+setActive':
      return { ...state, rev: state.rev + 1, activeIndex: action.index };
  }
}
 
/**
 * Compute the next sheet's `{prefix, n, linkPrefix}` given the foldr's
 * current rows. Mirrors the legacy `on-add` logic.
 *
 *   prefix     — default "Sheet"; if the last row's title is `([_a-zA-Z]+)(\d+)`,
 *                the captured word becomes the prefix and the digit the
 *                starting number.
 *   linkPrefix — default `/{Index}.`; if the last row's link matches
 *                `^(\/[^=]+\.|\/sheet(?=\d))`, that match becomes the link
 *                prefix.
 *   n          — the first integer starting at `(next or 1)` that produces a
 *                `prefix+n` title and a `linkPrefix+n` link not already in
 *                the row list.
 */
export function computeNextRow(foldr: HackFoldr, index: string): FoldrRow {
  let prefix = 'Sheet';
  let nextSheet = foldr.size() + 1;
  let linkPrefix = `/${index}.`;
 
  const last = foldr.lastRow();
  const lastTitle = last.title ?? '';
  const lastLink = last.link ?? '';
  const titleMatch = /^([_a-zA-Z]+)(\d+)$/.exec(lastTitle);
  if (titleMatch && titleMatch[1] !== undefined && titleMatch[2] !== undefined) {
    prefix = titleMatch[1];
    nextSheet = parseInt(titleMatch[2], 10);
  }
  const linkMatch = /^(\/[^=]+\.|\/sheet(?=\d))/.exec(lastLink);
  if (linkMatch && linkMatch[1] !== undefined) {
    linkPrefix = linkMatch[1];
  }
 
  const titles = foldr.titles();
  const links = foldr.links();
  while (
    titles.includes(`${prefix}${nextSheet}`) ||
    links.includes(`${linkPrefix}${nextSheet}`)
  ) {
    nextSheet += 1;
  }
  return {
    link: `${linkPrefix}${nextSheet}`,
    title: `${prefix}${nextSheet}`,
    row: 0, // filled in by Foldr.push from the server response.
  };
}
 
/**
 * True when `candidate` (case-insensitive) is already among `titles`.
 * Mirrors the legacy duplicate-check in `on-rename`.
 */
export function titleTaken(titles: readonly string[], candidate: string): boolean {
  const lc = candidate.toLowerCase();
  return titles.some((t) => t.toLowerCase() === lc);
}