Google Sheets Add-on — apps/addon-sheets

Architecture & audit documentation · ExcelRewind monorepo · generated from source read of apps/addon-sheets/src, findings_googlesheets.md, and root CLAUDE.md

1. Overview

apps/addon-sheets is the Google Sheets port of the ExcelRewind recorder: a sidebar add-on that watches a spreadsheet during a work session and produces a .rewind companion file — the same container format the Excel task pane writes, replayed by the same zero-backend web viewer. It ships as a Google Workspace Editor Add-on (a bound or standalone Apps Script project), not a web app with its own server.

The module has two runtime halves that live in one dist/ folder pushed by clasp:

Recording is poll-diff, not event-driven: Apps Script has no push channel into an open sidebar and no full/lossless edit-trigger, so the sidebar calls rewindPoll() every ~2s, diffs the returned grid against a client-held shadow map (src/engine/diff.ts), and turns differences into the same CellChangeEvent shape the Excel add-in's worksheets.onChanged handler produces. This is a deliberate, verified architectural choice — see findings_googlesheets.md for why onEdit triggers were rejected (2-event queue cap, no old formula, extra OAuth scope, 90 min/day quota).

Build/deploy is npm run build (bundles sidebar + copies Code.js/appsscript.json into dist/) then clasp push (uploads dist/ as the Apps Script project, per developer's own .clasp.json — not committed, each dev copies .clasp.json.example and fills in a Script ID). There is no CI/CD pipeline in-repo for this app yet; distribution is manual clasp push to an unlisted/private Marketplace deployment for beta testers. OAuth scope is declared in appsscript.json as spreadsheets.currentonly only — the lightest Sheets scope tier, chosen specifically to avoid Marketplace OAuth-verification review.

The module reuses @excelrewind/format (container writer, types, A1 utilities) and @excelrewind/branding (all display strings, BUILD_TAG) unchanged from the Excel add-in, and keeps an intentionally separate copy (not a shared package) of the Supabase auth/access/telemetry client code (auth.ts, access.ts, telemetry.ts) — documented in-code as a deliberate precedent, not an oversight.

2. Architecture diagram

flowchart TB
  subgraph GOOGLE["Google Sheets host (Apps Script project)"]
    direction TB
    subgraph SERVER["src/server/Code.js (plain Apps Script, V8, unbundled)"]
      onOpen["onOpen() / onInstall()
adds 'ExcelRewind > Record session' menu"] showSidebar["showSidebar()
HtmlService.createHtmlOutputFromFile('sidebar')"] rewindReadSheet["rewindReadSheet_(sheet)
getDataRange + cap at 100000 cells"] rewindBaseline["rewindBaseline()
every sheet's grid, once"] rewindPoll["rewindPoll()
active sheet grid + active range + sheet names"] onOpen --> showSidebar rewindBaseline --> rewindReadSheet rewindPoll --> rewindReadSheet end subgraph SIDEBAR["src/sidebar (React, built to one dist/sidebar.html)"] direction TB mainTsx["main.tsx
createRoot -> App"] App["App.tsx
record/stop/save state machine"] SignIn["SignIn.tsx
email OTP form"] gas["gas.ts
typed google.script.run bridge
+ DEV fake grid"] auth["auth.ts
Supabase client, OTP, checkAccess()"] access["access.ts
pure evaluateAccess() / offlineAccess()"] telemetry["telemetry.ts
track() -> usage_events"] download["download.ts
Blob + a.download"] diff["engine/diff.ts
seedShadow / diffPoll / selectEvent
(pure, platform-agnostic)"] mainTsx --> App App -->|"signedout"| SignIn App --> gas App --> auth App --> diff App --> download auth --> access App --> telemetry SignIn --> auth telemetry -->|"imports supabase client"| auth end end subgraph FORMAT["@excelrewind/format (shared package, imported unchanged)"] writeRewind["writeRewind(doc)
deflate + MessagePack + ZIP + SHA-256"] toA1["toA1(row,col)"] end subgraph BRANDING["@excelrewind/branding (shared package)"] strings["PRODUCT_NAME, RECORDER_NAME,
BUILD_TAG, FILE_EXTENSION, FILE_MIME"] end gas -.->|"google.script.run.rewindBaseline()
google.script.run.rewindPoll()"| rewindBaseline gas -.-> rewindPoll diff -->|"toA1"| toA1 App -->|"writeRewind(RewindDocument)"| writeRewind App --> strings

3. Data-flow diagrams

How a cell edit becomes a .rewind file on disk — nothing in this module ever leaves the user's browser + Google's own infrastructure; there is no ExcelRewind backend call for the core record path.

flowchart LR
  edit["User edits a cell
in Google Sheets grid"] poll["rewindPoll() server call
every ~2000ms
(google.script.run round trip)"] grid["ServerPoll JSON:
{activeSheet, activeRange,
grid: {values[][], formulas[][]},
sheetNames[]}"] shadow["Shadow (Map)
sheet -> A1 -> {v, f}
held in a React ref, in-memory only"] diffFn["diffPoll(shadow, frame, t)"] events["CellChangeEvent[]
{t, sheet, cell, from, to,
formula_from, formula_to, type}"] live["Live (React ref)
sessionId, title, tag, startedAt,
baseline, events[], annotations[]"] doc["RewindDocument
{meta, timeline, annotations, baseline}"] bytes["writeRewind(doc)
-> Uint8Array (ZIP: deflate+MessagePack, SHA-256)"] blob["Blob + ObjectURL + a.download
(HtmlService sidebar allows downloads)"] file[("*.rewind file
on the user's local disk")] viewer["ExcelRewind web viewer
(apps/viewer) — opened separately,
replays timeline.json"] edit --> poll --> grid --> diffFn shadow <-->|"read prev, mutate in place"| diffFn diffFn --> events --> live live -->|"Save Rewind button"| doc doc --> bytes --> blob --> file file -.->|"user opens file manually"| viewer
flowchart LR
  session["signInWithOtp(email)
verifyOtp(email, code)"] supa[("Supabase Auth
(shared project w/ addin-excel)")] profiles[("public.profiles table
status, tier, telemetry, email")] appcfg[("public.app_config table
id=1: enabled, message")] usage[("public.usage_events table
RLS: auth.uid() = user_id")] cache["localStorage
'rewind.access-check'
{at, email}"] session --> supa App2["auth.ts checkAccess()
(called from App.tsx)"] -->|"select .eq(id, session.user.id)"| profiles App2 -->|"select .eq(id,1)"| appcfg App2 -->|"write on success"| cache App2 -->|"read on network failure
(7-day offline grace)"| cache App2 -->|"rpc set_telemetry_level(level)"| profiles telemetryFn["telemetry.ts track()"] -->|"insert {user_id, event, props}
fire-and-forget"| usage

4. Process / sequence diagrams

4.1 Sidebar launch & access gate

sequenceDiagram
  participant User
  participant Menu as "Sheets menu"
  participant CodeJs as "Code.js"
  participant Sidebar as "App.tsx"
  participant Auth as "auth.ts"
  participant Supa as "Supabase"

  User->>Menu: "ExcelRewind -> Record session"
  Menu->>CodeJs: showSidebar()
  CodeJs-->>User: HtmlService iframe (dist/sidebar.html)
  Sidebar->>Sidebar: mount, useState(status: "loading")
  Sidebar->>Auth: refreshAuth() -> getSession()
  alt no VITE_SUPABASE_* env (local dev)
    Auth-->>Sidebar: authEnabled=false, status="ok" (ungated)
  else no session
    Auth-->>Sidebar: status="signedout"
    Sidebar-->>User: renders SignIn card
  else session exists
    Auth->>Supa: select app_config id=1 + profiles id=uid (parallel)
    Supa-->>Auth: {enabled,message} + {status,tier,email}
    Auth->>Auth: evaluateAccess(cfg, profile)
    alt allowed
      Auth-->>Sidebar: status="ok", cache last-good check
      Sidebar->>Sidebar: initTelemetry(level, uid), track('pane_open'), track('sign_in')
    else blocked (disabled tier / inactive profile / global kill)
      Auth-->>Sidebar: status="blocked", reason
      Sidebar-->>User: "Access unavailable" card + Re-check/Sign out
    end
  end

4.2 Record -> poll-diff loop -> save (core path)

sequenceDiagram
  participant User
  participant App as "App.tsx"
  participant Gas as "gas.ts"
  participant Server as "Code.js"
  participant Diff as "engine/diff.ts"
  participant Fmt as "@excelrewind/format"

  User->>App: click "Start recording"
  App->>App: checkAccess() re-verify (revoked-mid-session guard)
  App->>Gas: readBaseline()
  Gas->>Server: google.script.run.rewindBaseline()
  Server->>Server: every sheet: getDataRange().getValues()/getFormulas(), cap 100000 cells
  Server-->>Gas: {sheets: SheetGrid[]}
  Gas-->>App: BaselineFrame
  App->>Diff: seedShadow(baseline) -> Shadow map
  App->>App: liveRef = {sessionId, title, tag, baseline, events:[]}
  App->>App: track('record_start', {tag})

  loop every 2000ms while phase==="recording"
    App->>Gas: readPoll()
    Gas->>Server: google.script.run.rewindPoll()
    Server->>Server: active sheet only: getDataRange + getActiveRange()
    Server-->>Gas: {activeSheet, activeRange, grid, sheetNames}
    Gas-->>App: PollFrame
    App->>Diff: diffPoll(shadow, frame, t=(Date.now()-startMs)/1000)
    Diff->>Diff: walk grid vs shadow: emit value/formula events,
walk shadow-only cells: emit to:null (cleared) Diff-->>App: CellChangeEvent[] (mutates shadow in place) App->>Diff: selectEvent(lastRange, frame, t) App->>App: events.push(...batch), update UI counts end User->>App: click "Stop" App->>App: setPhase('stopped') (interval clears) App->>Gas: readBaseline() — FINAL full re-scan, all sheets App->>Diff: diffPoll per sheet grid (catches background-sheet edits + last <2s) App->>App: track('record_stop', {duration_s, event_count, note_count, audio:false}) User->>App: click "Save Rewind" App->>App: build RewindDocument {meta, timeline:events, annotations, baseline} App->>Fmt: writeRewind(doc) Fmt-->>App: Uint8Array (ZIP bytes, SHA-256 integrity) App->>App: downloadBytes(bytes, sessionFileName(...)) -> Blob + a.download App->>App: track('save_file', {size_kb}) App-->>User: "Saved <name>.rewind"

4.3 Failure paths handled in code

sequenceDiagram
  participant App as "App.tsx"
  participant Gas as "gas.ts"
  participant Server as "Code.js"
  participant Auth as "auth.ts"

  Note over App,Server: Poll failure (network blip, edit-mode lock, quota)
  App->>Gas: readPoll()
  Gas->>Server: google.script.run.rewindPoll()
  Server--xGas: withFailureHandler(reject)
  Gas--xApp: Promise rejects
  App->>App: catch -> setError('Polling failed: ...')
pollingRef cleared in finally (no overlap next tick) Note over App,Auth: checkAccess network failure at launch or record-start App->>Auth: checkAccess() Auth->>Auth: Promise.all(app_config, profiles) throws Auth->>Auth: read localStorage('rewind.access-check') Auth->>Auth: offlineAccess(cached, now): allowed if <7 days old Auth-->>App: {allowed:true} (grace) or {allowed:false, reason:'Could not verify...'} Note over App: stop() must never fail App->>App: try { final re-scan } catch {} — best-effort,
worst case keeps pre-flush event list

5. Interconnections

External systems this module talks to
CounterpartyProtocol / callDirectionPurpose
Google Sheets host (Apps Script runtime)google.script.run (async JSON-RPC over Apps Script's built-in bridge)Sidebar → ServerrewindBaseline() / rewindPoll() — the only reads; no writes ever issued
SpreadsheetApp API (Apps Script host API)In-process Apps Script callsServer → GooglegetDataRange/getValues/getFormulas/getActiveRange/getSheets — scope spreadsheets.currentonly
Supabase Auth (shared project with apps/addin-excel)HTTPS via @supabase/supabase-js; email OTP (signInWithOtp/verifyOtp)Sidebar → SupabaseSign-in — OTP-only because the sandboxed HtmlService iframe cannot do the Google system-browser popup handoff the Excel add-in uses
Supabase Postgres — public.profilesPostgREST (supabase-js .from('profiles').select/.rpc)Sidebar → SupabaseRead status,tier,email,telemetry for the licence gate; write telemetry level only via set_telemetry_level RPC (never a direct row update)
Supabase Postgres — public.app_config (id=1)PostgREST selectSidebar → SupabaseGlobal kill switch (enabled, message) — maintenance-mode / global disable floor
Supabase Postgres — public.usage_eventsPostgREST insert, fire-and-forgetSidebar → SupabaseMetadata-only telemetry (event, sanitized props); RLS requires auth.uid() = user_id
ExcelRewind viewer origin (apps/viewer, e.g. Cloudflare Pages deploy)Plain <a href> link, no API callSidebar → browser (out-of-band)VITE_EXCELREWIND_VIEWER_ORIGIN builds the Privacy Policy link shown in the OTP consent checkbox; the produced .rewind file is opened there manually by the user
Google Workspace Marketplaceclasp deployment / manifest (appsscript.json)Dev tooling → GoogleDistribution channel (private/unlisted for beta; public listing deferred, requires OAuth-scope review)
@excelrewind/format (in-repo package, not external but a hard boundary)Direct TS import, in-processSidebar → formatwriteRewind(), toA1(), shared types — the only writer of the .rewind container; identical to the Excel add-in's writer
@excelrewind/branding (in-repo package)Direct TS importSidebar → brandingAll display strings, BUILD_TAG staleness chip, FILE_EXTENSION/FILE_MIME
flowchart LR
  addon["apps/addon-sheets"]
  gsheets["Google Sheets host
(SpreadsheetApp API)"] gscript["google.script.run bridge"] supaAuth["Supabase Auth
(OTP)"] supaDb["Supabase Postgres
profiles / app_config / usage_events"] viewerOrigin["ExcelRewind viewer origin
(privacy.html link + manual file open)"] marketplace["Google Workspace Marketplace
(clasp deploy target)"] fmt["@excelrewind/format"] brand["@excelrewind/branding"] addon -->|"google.script.run"| gscript --> gsheets addon -->|"HTTPS / supabase-js"| supaAuth addon -->|"HTTPS / PostgREST"| supaDb addon -.->|"link only, no API"| viewerOrigin addon -->|"in-process import"| fmt addon -->|"in-process import"| brand addon -.->|"clasp push"| marketplace

6. Key functions & files

Load-bearing exports, by file
FileFunction / exportWhat it doesWhy it matters
src/engine/diff.tsdiffPoll(shadow, frame, tSeconds)Diffs one poll frame against the shadow map; emits one CellChangeEvent per changed/cleared cell and mutates the shadow in place.The entire recording engine's core logic; pure and unit-tested (diff.test.ts). A bug here silently drops or fabricates timeline events with no other safety net.
src/engine/diff.tsseedShadow(b: BaselineFrame) / shadowToBaseline(s)Converts the start-of-session full read into the shadow Map, and back into the sparse Baseline the format package's replay seeds from.Baseline seeding never emits events (first sight of a sheet = silent seed) — a caller mistake here would flood the timeline with false "changes" at session start.
src/engine/diff.tsselectEvent(lastRange, frame, tSeconds)Emits a type:'select' event when the active range moved; caller (App.tsx) owns debouncing.Cursor-tracking events pass a range address (not a single cell) through cell — the viewer must render it as a highlight, never a write.
src/engine/diff.tsnormVal(v)Normalizes Sheets' ''-for-empty and Date objects (converted to Excel serial-date numbers via the 1899-12-30 epoch) into CellValue.Cross-platform date encoding — without this, dates recorded in Sheets would replay differently than dates recorded in Excel in the same viewer.
src/engine/diff.tsMAX_CELLS = 100_000Client-side guard: a poll frame whose rows×cols exceeds this is skipped (warning logged, no diff, no shadow mutation) rather than diffed cell-by-cell.Second independent cap alongside the server's ExcelRewind_CELL_CAP — protects the browser tab from a pathological huge-sheet poll.
src/server/Code.jsrewindReadSheet_(sheet)Reads one sheet's getDataRange(), truncating by whole rows if rows×cols > 100000 so values/formulas stay rectangular.The only place cell data is ever read; truncation preserves A1 offset alignment (critical — a partial-column truncation would silently corrupt every subsequent event's address).
src/server/Code.jsrewindPoll()Returns the active sheet only (full grid + active range + all sheet names) — background sheets are not re-read every tick.Deliberate cost trade-off documented inline: keeps the ~2s round trip cheap; edits on a backgrounded sheet are only captured when it becomes active or at the final stop-time re-scan.
src/server/Code.jsonOpen() / onInstall(e) / showSidebar()Menu registration and sidebar launch via HtmlService.createHtmlOutputFromFile('sidebar').The entire server registers zero triggers (no onEdit, no installable trigger) — auditable by reading this one file; nothing here can fire without the user opening the sidebar.
src/sidebar/App.tsxApp() component — start/stop/saveFile/addNote/reset callbacksThe record state machine (idle → recording → stopped); owns liveRef/shadowRef in useRef so the poll interval doesn't re-subscribe on every render.stop() does a final full-sheet re-scan across every sheet (not just the active one) specifically to catch background-sheet edits and the last sub-2s window before Stop was clicked — see inline comment; without it, edits are silently lost.
src/sidebar/App.tsxsaveFile()Assembles the RewindDocument (meta.platform: 'google-sheets', tier: 'free' hardcoded) and calls writeRewind(), then downloadBytes().This is the only place a .rewind file is produced; tier: 'free' is currently a literal, not read from the profile — a paid-tier component gate would need this wired to the real profile tier.
src/sidebar/gas.tsrun<T>(fn), baseline(), poll(), inSheets()Promisifies google.script.run.withSuccessHandler/withFailureHandler; falls back to an in-memory devGrid() fake when google is undefined.The DEV fake (devTick-incrementing numbers) is what lets npm run dev run in a plain browser — auditors should not mistake this for a Sheets connection during local review; it self-selects via typeof google !== 'undefined'.
src/sidebar/auth.tscheckAccess()Full launch-time gate: session → parallel app_config + profiles read → evaluateAccess(); on network failure falls back to offlineAccess() against a 7-day localStorage cache.Re-called at record start (not just pane load) so a licence revoked mid-session is caught before a new recording begins — see the comment in App.tsx start().
src/sidebar/auth.tssetTelemetryLevel(level)Persists via the set_telemetry_level RPC, explicitly never a direct table update.Comment flags why: a direct update-own-row RLS policy would also let a user edit their own tier/status columns — the RPC is the security boundary.
src/sidebar/access.tsevaluateAccess(config, profile), offlineAccess(cached, now)Pure decision functions, no network — global-disabled floor, per-profile status check, 7-day offline grace window (OFFLINE_GRACE_MS).Deliberately separated from auth.ts so licence-gate logic is unit-testable without Supabase; mirrors the same pattern in apps/addin-excel.
src/sidebar/telemetry.tstrack(event, props, opts), sanitize(props), shouldSend(level, full)Fire-and-forget insert into usage_events; trims props to ≤10 primitive keys; gates opts.full events behind the user's 'full' opt-in.Hard product/privacy invariant enforced in code: never sends cell contents, note text, or filenames — sanitize() only accepts number|boolean|string primitives, structurally excluding payloads.
src/sidebar/download.tsdownloadBytes(bytes, filename), sessionFileName(title, startedAt)Blob + ObjectURL + synthetic <a download> click; revokes the object URL after 30s.Relies on the HtmlService sandbox's allow-downloads permission — a platform fact that doesn't hold in every embedded-iframe context, called out explicitly in the comment.
src/sidebar/SignIn.tsxSignIn() componentEmail OTP form with an explicit, unchecked-by-default "full analytics" consent checkbox (GDPR-valid affirmative opt-in).The only auth UI this app has — no Google-account sign-in button, because the sandboxed iframe cannot run the system-browser popup handoff apps/addin-excel uses.
vite.config.tscopyAppsScript() pluginAfter the Vite bundle closes, copies src/server/Code.js and appsscript.json straight into dist/ alongside the bundled sidebar.html.This is what makes dist/ a complete, clasp-pushable Apps Script project in one build step; Code.js itself is never bundled/transpiled — it ships as authored, plain ES5-ish Apps Script.
appsscript.jsonoauthScopesDeclares exactly ["https://www.googleapis.com/auth/spreadsheets.currentonly"].Single source of truth for the app's permission footprint; any future feature (snapshot export, charts) that needs drive.file or broader must change this file and re-triggers Marketplace review — audit checkpoint.

7. Invariants & gotchas

Never modifies the original spreadsheet. Every server call in Code.js is a read (getValues/getFormulas/getActiveRange/getSheets); there is no setValue/setValues call anywhere in this module, and no triggers are registered that could fire independently of the sidebar being open. This is the CLAUDE.md product-defining constraint and is structurally enforced here, not just documented.
Session events (and here, the whole file) never leave the user's machine automatically. Unlike the Excel add-in's eventual API sync (Milestone 2, not yet built anywhere), the Sheets add-on's .rewind is built and downloaded entirely client-side in this phase — the sheet's cell contents are read only into the sidebar's own memory and never sent to Supabase or any ExcelRewind server. Only OTP email, licence-gate reads, and metadata-only telemetry go to Supabase.

Platform quirks this code works around (see findings_googlesheets.md for the full "do not relearn" list):