Own it · example handover · app/

src/ui/app.tsx

← All files · the demo this builds

/**
 * The Spreadsheet Rescue demo: the broken workbook and the rebuilt app in
 * one frame, a wipe handle between them, five tour stops underneath, and
 * the downloadable .xlsx generated in-browser from the same description
 * that renders the grid.
 */

import { useEffect, useMemo, useRef, useState } from "preact/hooks";
import { DEMO_SEED, makeGsmDemo, type WbCell } from "@portfolio/fabricator";
import { Grid, displayValue } from "./grid.js";
import { Rebuilt, type AppFocus, type AppView } from "./rebuilt.js";
import { workbookToXlsx } from "../xlsx/xlsx.js";

interface TourStop {
  key: string;
  label: string;
  sheet: number;
  cell: string;
  view: AppView;
  focus: AppFocus;
  caption: string;
  revealsHidden?: boolean;
}

const reduceMotion = () =>
  typeof matchMedia !== "undefined" && matchMedia("(prefers-reduced-motion: reduce)").matches;

export function App() {
  const demo = useMemo(() => makeGsmDemo(DEMO_SEED, new Date()), []);
  const wb = demo.workbook;
  const lm = demo.landmarks;

  const [wipe, setWipe] = useState(reduceMotion() ? 50 : 2);
  const [sheetIdx, setSheetIdx] = useState(wb.activeSheet);
  const [selected, setSelected] = useState<string | null>(null);
  const [hiddenRevealed, setHiddenRevealed] = useState(false);
  const [stopIdx, setStopIdx] = useState<number | null>(null);
  const [appView, setAppView] = useState<AppView>("jobs");
  const [appFocus, setAppFocus] = useState<AppFocus>(null);
  const [dl, setDl] = useState<"idle" | "busy" | "done">("idle");
  const frameRef = useRef<HTMLDivElement>(null);
  const beforeScrollRef = useRef<HTMLDivElement>(null);
  const userTouched = useRef(false);

  const sheet = wb.sheets[sheetIdx]!;
  const selectedCell: WbCell | undefined = selected ? sheet.cells[selected] : undefined;

  const stops: TourStop[] = useMemo(
    () => [
      {
        key: "ref",
        label: "#REF!",
        sheet: lm.jobsSheet,
        cell: lm.refCell,
        view: "jobs",
        focus: "total",
        caption:
          "Somebody deleted the Deposit column; every Balance formula written before that week now reads =H–I–#REF!. Tap the cell — the wreck is in the formula bar, not painted on. On the right, balances are computed from the data, so there's nothing left to dangle.",
      },
      {
        key: "hidden",
        label: "the hidden tab",
        sheet: lm.hiddenSheet,
        cell: "A1",
        view: "jobs",
        focus: "recovered",
        caption:
          "“Mike's copy” — hidden, forked, in his own column layout, holding six service contracts that exist nowhere else. Mike left two years ago. The rebuild surfaces his six jobs with a badge instead of leaving them buried in a tab nobody knows is there.",
        revealsHidden: true,
      },
      {
        key: "merge",
        label: "merged cells",
        sheet: lm.scheduleSheet,
        cell: lm.mergeCell,
        view: "schedule",
        focus: null,
        caption:
          "The crew names are merged across rows because it looked tidy — and now sorting the schedule shreds it, so nobody dares. The rebuild keeps schedule and structure separate: real rows, a real grid, sort all you like.",
      },
      {
        key: "dates",
        label: "date soup",
        sheet: lm.jobsSheet,
        cell: lm.dateSoupCell,
        view: "jobs",
        focus: "dates",
        caption:
          "One Start column, four date formats — real dates, “6/3”, “June 3rd”, one with a trailing space — typed by whoever was fastest. Half of them won't sort and won't add up. The rebuild stores one format and prints one format.",
      },
      {
        key: "po",
        label: "the mid-year column",
        sheet: lm.jobsSheet,
        cell: lm.poHeaderCell,
        view: "jobs",
        focus: "po",
        caption:
          "PO # was wedged in this spring, so every older row is blank and every older formula quietly ignores the column — including the Total at the bottom, which somebody typed in by hand and hasn't matched its own column since. In the rebuild, PO is a field on every job and the total is computed, every time.",
      },
    ],
    [lm],
  );

  // one slow auto-wipe on mount, then park mid-screen for the thumb
  useEffect(() => {
    if (reduceMotion()) return;
    let raf = 0;
    const t0 = performance.now();
    const tick = (t: number) => {
      if (userTouched.current) return;
      const el = (t - t0) / 1000;
      let v: number;
      if (el < 1.6) v = 2 + (el / 1.6) * 94; // sweep to 96
      else if (el < 2.5) v = 96 - ((el - 1.6) / 0.9) * 46; // back to 50
      else {
        setWipe(50);
        return;
      }
      setWipe(v);
      raf = requestAnimationFrame(tick);
    };
    raf = requestAnimationFrame(tick);
    return () => cancelAnimationFrame(raf);
  }, []);

  const dragTo = (clientX: number) => {
    const frame = frameRef.current;
    if (!frame) return;
    const rect = frame.getBoundingClientRect();
    const pct = ((clientX - rect.left) / rect.width) * 100;
    setWipe(Math.max(0, Math.min(100, pct)));
  };

  const onPointerDown = (e: PointerEvent) => {
    userTouched.current = true;
    (e.currentTarget as HTMLElement).setPointerCapture(e.pointerId);
    dragTo(e.clientX);
  };
  const onPointerMove = (e: PointerEvent) => {
    if ((e.currentTarget as HTMLElement).hasPointerCapture(e.pointerId)) dragTo(e.clientX);
  };
  const onPointerUp = () => {
    // gentle snap at the meaningful positions
    setWipe((w) => (Math.abs(w - 50) < 7 ? 50 : w < 7 ? 0 : w > 93 ? 100 : w));
  };

  const goToStop = (i: number) => {
    const s = stops[i]!;
    userTouched.current = true;
    setStopIdx(i);
    if (s.revealsHidden) setHiddenRevealed(true);
    setSheetIdx(s.sheet);
    setSelected(s.cell);
    setWipe(50);
    setAppView(s.view);
    setAppFocus(s.focus);
    // scroll the grid to the cell once it's rendered
    requestAnimationFrame(() => {
      const box = beforeScrollRef.current;
      const cell = box?.querySelector<HTMLElement>(`[data-addr="${s.cell}"]`);
      if (box && cell) {
        box.scrollTo({
          left: Math.max(0, cell.offsetLeft - box.clientWidth / 3),
          top: Math.max(0, cell.offsetTop - box.clientHeight / 3),
          behavior: reduceMotion() ? "auto" : "smooth",
        });
      }
    });
  };

  const download = async () => {
    setDl("busy");
    try {
      const bytes = await workbookToXlsx(wb, new Date());
      const blob = new Blob([bytes as unknown as BlobPart], {
        type: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
      });
      const url = URL.createObjectURL(blob);
      const a = document.createElement("a");
      a.href = url;
      a.download = "granite-state-mechanical-job-board.xlsx";
      a.click();
      setTimeout(() => URL.revokeObjectURL(url), 4000);
      setDl("done");
    } catch {
      setDl("idle");
    }
  };

  const visibleSheets = wb.sheets
    .map((s, i) => ({ s, i }))
    .filter(({ s }) => !s.hidden || hiddenRevealed);

  return (
    <div class="ssr">
      <p class="ssr-kicker">
        Granite State Mechanical is fictional; this workbook breaks the way real ones do. Drag the
        handle — same jobs on both sides.
      </p>

      <div class="ssr-frame" ref={frameRef}>
        <div class="ssr-before" aria-label="The workbook as it is today">
          <div class="ssr-fxbar">
            <span class="ssr-namebox">{selected ?? ""}</span>
            <span class="ssr-fx">fx</span>
            <span class="ssr-formula">
              {selectedCell?.f
                ? `=${selectedCell.f}`
                : displayValue(selectedCell?.v, selectedCell?.t, selectedCell?.s ? wb.styles[selectedCell.s] : undefined)}
            </span>
          </div>
          <div class="ssr-sheetwrap" ref={beforeScrollRef}>
            <Grid sheet={sheet} styles={wb.styles} selected={selected} onSelect={setSelected} />
          </div>
          <div class="ssr-tabs" role="tablist" aria-label="Workbook tabs">
            {visibleSheets.map(({ s, i }) => (
              <button
                type="button"
                role="tab"
                aria-selected={i === sheetIdx}
                class={`ssr-tab ${i === sheetIdx ? "is-active" : ""} ${s.hidden ? "was-hidden" : ""}`}
                onClick={() => {
                  setSheetIdx(i);
                  setSelected(null);
                }}
              >
                {s.name}
              </button>
            ))}
            {!hiddenRevealed && (
              <button type="button" class="ssr-tab ssr-unhide" onClick={() => setHiddenRevealed(true)} title="Unhide…">
                1 hidden…
              </button>
            )}
          </div>
        </div>

        <div class="ssr-after" style={{ clipPath: `inset(0 0 0 ${wipe}%)` }} aria-label="The rebuilt app over the same jobs">
          <Rebuilt data={demo.data} view={appView} onView={setAppView} focus={appFocus} />
        </div>

        <div
          class="ssr-handle"
          style={{ left: `${wipe}%` }}
          role="slider"
          aria-label="Reveal the rebuilt app"
          aria-valuemin={0}
          aria-valuemax={100}
          aria-valuenow={Math.round(wipe)}
          tabIndex={0}
          onPointerDown={onPointerDown}
          onPointerMove={onPointerMove}
          onPointerUp={onPointerUp}
          onKeyDown={(e) => {
            userTouched.current = true;
            if (e.key === "ArrowLeft") setWipe((w) => Math.max(0, w - 5));
            if (e.key === "ArrowRight") setWipe((w) => Math.min(100, w + 5));
            if (e.key === "Home") setWipe(0);
            if (e.key === "End") setWipe(100);
          }}
        >
          <span class="ssr-grip" aria-hidden="true">⇿</span>
        </div>

        <span class="ssr-chip ssr-chip-left" aria-hidden="true">their workbook</span>
        <span class="ssr-chip ssr-chip-right" aria-hidden="true">the rebuild</span>
      </div>

      <div class="ssr-tour" role="group" aria-label="Tour the damage">
        {stops.map((s, i) => (
          <button type="button" class={`ssr-stop ${stopIdx === i ? "is-on" : ""}`} onClick={() => goToStop(i)}>
            {s.label}
          </button>
        ))}
      </div>
      <p class="ssr-caption" aria-live="polite">
        {stopIdx === null
          ? "Five of this workbook's wounds are marked above. Tap one — both sides jump to it, wound on the left, fix on the right."
          : stops[stopIdx]!.caption}
      </p>

      <div class="ssr-foot">
        <button type="button" class="ssr-dl" data-ctx="download" onClick={download} disabled={dl === "busy"}>
          {dl === "busy" ? "Building the file in your browser…" : "Download the broken workbook — open it yourself."}
        </button>
        <p class="ssr-dl-note" data-ctx="download">
          It's a realistic sample; no client's real numbers on this site. Generated in your browser
          just now from the same data as the demo — hidden tab actually hidden, broken formulas
          actually broken.{dl === "done" ? " Sent to your downloads." : ""}
        </p>
      </div>
    </div>
  );
}