Sample School
🚀Quick Start
Activities
Prize Store
Recent scans
Leaderboard
Preview mode — scores won't be saved here. You're viewing this inside a browser that blocks local saving. To use it for real, download this file and open it in Chrome on your own computer; everything will then save automatically.
Want your class backed up automatically? Sign in with Google — it takes a second.

    Pick an activity, then scan each student — a USB scanner works anywhere on this page. Scan a point-value QR to switch what you're scoring.

    Scan to award points

    Scan or tap a student's seat to give points — the seating chart fills the screen so it's easy to read from across the room.

    Tap a name to give points

    Scan history

    The full record of every scan — not just the last few shown on the Dashboard's Recent scans.

    Leader Board

    Live point totals. Great for the projector — filter by class, group, activity, or time period.

    🏆 No contest running right now

    Raffle

    Spin the wheel to pick a winner. Each slice is sized to that student's tickets — the more tickets, the bigger the slice and the better the odds.

    Wheel options
    Who's in this draw

    Untick a boy to keep him out of the next draw. Nothing is deducted and no points change — he simply isn't on the wheel. ↺ Un-remove everyone above puts them all back.

    Each student's current points are their tickets. Points are not spent — this is just a draw, so the wheel always reflects the live standings.
    Tickets count:
    Tickets from:
    Time window:

    Recent winners

    Chinese Auction

    Set up your prizes, then let each student place tickets on the prizes he wants. Each prize is drawn from its own pile — the more tickets a student puts in, the better his odds. With Each student's points, each prize has its own cost per ticket (deducted right away); removing a ticket refunds it — until that prize is drawn, after which it locks.

    Prizes


    Tickets each student has:
    When dividing evenly, leftover tickets:
    Prize:

    Review

    Pick a sub-tab to teach, test, or review your text.

    Text:
    Parsha & range
    Now marking —
    Pick a number above, then scan each student who knew it.

    Shorashim

    Practice the roots from the parsha. Pick a mode, then play.

    Word bank — tap + to add

    Common shorashim from the parsha. Tap one to add it to your list (or add them all).

    The checkbox marks a shoresh as "learned" — only checked ones show up in the games.

    Students

    Add your students once. Each gets a QR code you can print.

    Add many at once

    One name per line. To set a group, write Name, Group.

    Share a roster with another rebbi's Menchmark

    For two rebbeim who teach the same boys and want the same printed QR card to work in both classes — a morning and afternoon rebbi, for example. This shares only who the students are: name, class, and the exact ID printed on their card. Nothing about points, activities, rewards, or history ever leaves your Menchmark.

    Export

    A small file with just names, classes, and card IDs.

    Import

    From the other rebbi's Export, above. Different from Add many at once — that always makes new cards; this one keeps the cards already printed.

    Activities & point values

    These are the buttons on the Scan screen. Change a point value right here — tap the +/– or type a number. Use a negative value for a deduction.

    pts

    Seating chart & print

    This is the one page you need to start scanning. Arrange your room here, then print it. Every student's code sitting in his own seat, with the codes for your activities underneath. Print it, keep it on your desk or clipboard, and scan an activity code then a student — that's a whole scoring action in two scans.

    Rows Cols Code size 100%

    Preview — this is what will print, and you edit it here. Drag a boy into a different desk, or ✕ to empty his seat. Drag a code to re-order it or move it to another section, ✕ to take it off, and click a section's name to rename it. Anything you take off waits in the tray below the sheet.

    Print: Students

    Each student's own QR code, and their own full page of one-scan-does-it-all codes for every activity.

    Generating the QR codes needs an internet connection this one time. Daily scanning works offline afterward.

    Student labels

    Load Avery 5160 (or 8160 / 5136 / 8250, or 18660 clear) address-label sheets. In the print dialog choose Letter paper, set Margins: None and Scale: 100% (turn off "Fit to page"), and print. Do a plain-paper test first and hold it over a label sheet to check alignment.

    Personal scoring pages (one per student)

    Each student gets their own page with a QR code for every activity, already linked to them — scan one code and it awards that activity to that student in a single scan, no separate student scan needed. The Custom card uses whatever amount is in the Custom box on the Scan tab at the moment it's scanned, not the amount at print time.

    Print: Activities

    Print: Points

    Prints one card per student with their current points — uses whatever class/group/activity/time filter is currently set on the Leader Board tab.

    Format
    Groups to print:

    Backup & Restore

    Your scores save automatically on this computer. Back up regularly so nothing is ever lost.

    Moving to a new computer? 2 steps

    Take your class with you

    No sign-in, no new Menchmark account — just Google Drive, the same way you'd email yourself a photo. Two steps, one on each computer.

    Do this last — right before you switch, not a few days ahead. Once you've made the move, stop scanning on the old computer: anything recorded there afterward won't come along.

    1 On this computer

    1. Press Download backup file below. It saves everything — students, activities, scores, history, and your seating chart — to this computer's Downloads folder.
    2. Go to drive.google.com and sign in with your Google account — the one you already use for email works fine.
    3. Click + New in the top-left corner, then File upload, and choose the file you just downloaded. Its name starts with points-backup- and today's date.

    2 On the new computer

    1. Go to drive.google.com and sign in with the same Google account.
    2. Find the file — right-click it and choose Download.
    3. Open Menchmark. First time on this computer? Choose Restore / Import, then drop the file in. Already started using Menchmark here? Go to Backup & Restore → Restore from a backup instead, and choose Overwrite what's here when asked.

    Already switched, and remembered something was left behind on the old computer? Go back, download a fresh backup, and repeat step 1 — then choose Combine with what's here instead of Overwrite when you import on the new computer, so nothing you've already entered there gets lost.

    Automatic — set up once, runs by itself

    Install Menchmark as an app Best way to work offline

    Install Menchmark from your browser (look for Install in the menu, or the install icon — a small monitor with a download arrow — in the address bar) to run it like a real app — its own window, an icon on your device, and persistent storage that resists the browser clearing your data. Works fully offline once installed, and it stays the same Menchmark — the same students, the same scores, whether you are online or not.

    Installing helps a lot, but on locked-down or shared school Chromebooks an administrator can still clear data on sign-out — so keep folder backup connected above as your ultimate safety net.

    Manual backup & restore

    Back up everything

    Save a file with your full roster, activities, and scores. Keep it somewhere safe.

    Restore from a backup

    Load a backup file from another computer. Preview what's inside — including a way to combine it with what's already here — before anything changes.

    Download a separate copy — for a computer with no internet at all

    Saves a single HTML file with your current students, activities, and scores already loaded in — plus the QR scanner built right in. For a classroom computer that never goes online, or a spare machine you want a self-contained copy on. Needs internet once, to prepare the file; after that, just open it in any browser like any other file.

    The downloaded copy keeps its own separate data. Points you enter in it stay in it — they will not appear here, and points entered here will not appear there.

    If you just want Menchmark to work without internet, install it as an app above instead — that keeps one set of data. Use this download only when you need a copy that lives entirely on its own.

    Check for updates

    Menchmark updates itself automatically when you're online. Use this if you want to confirm right now that you're on the latest version.

    Other exports

    Export standings

    A spreadsheet (CSV) of every student and their current score. Not a backup — just a snapshot to view or share.

    Emergency recovery — restore from a Log CSV

    No backup file? If you synced to Google Sheets, download the Log tab as CSV (File → Download → .csv) and load it here. Rebuilds your students, current scores, and full points history from the scan log — activities, students, and points are all reconstructed.

    Google Sheets sync Legacy
    Being retired. Google Sheets sync still works today, but it's no longer where Menchmark is headed — use Download backup file or Export standings (CSV) above instead. Keep this connected if you already rely on it; there's no need to set it up fresh.

    ☁ Save everything to your Google Sheet

    Pushes a full snapshot of your data — students, activities, scores, settings, passes, pesukim, everything — to a "Snapshot" tab on your connected Google Sheet. This also happens automatically in the background every 30 seconds.

    ☁ Restore everything from your Google Sheet

    Lost your data? Switched devices? Pull back the latest snapshot saved on your Google Sheet. This replaces everything currently here.

    Sync to Google Sheets

    Send every scan and the live standings to your own Google Sheet, so you can see totals anywhere and keep a permanent record.

    First-time setup — do this once (about 5 minutes)
    1. Open sheets.google.com and make a new blank sheet. Name it (e.g. "Sample Class 1 Points").
      ⚠️ Every teacher needs their own sheet. Do not paste another teacher's link here — your scans, scores, and backups would mix together and overwrite each other. Each teacher creates one sheet and gets their own link.
    2. In that sheet's menu: Extensions → Apps Script.
    3. At the top-left, click Untitled project and rename it (e.g. "Sample Class 1 Points") so the permission screen later looks familiar, not anonymous.
    4. Delete any code in the editor and paste in Code 1 below.
    5. Click the gear (Project Settings) on the left and tick "Show appsscript.json manifest file in editor."
    6. Back in the editor (<> Editor), open the file named appsscript.json, delete what's there, and paste in Code 2. This limits the app to this one sheet only.
    7. Click Deploy → New deployment. Type: Web app. Set Execute as: Me and Who has access: Anyone. Click Deploy.
    8. Google will warn the app "isn't verified" — that's normal for your own script. Click Advanced → Go to … (unsafe) → Allow. (It runs as you, on your sheet only.)
    9. Copy the Web app URL it gives you (ends in /exec), paste it in the box above, and click Save link.

    Tip: to test by hand, pick testSetup in the function dropdown and click Run — that authorizes the script and drops one test row in your Log. Don't click Run on doPost; it only works from a real web request and will show a "postData" error otherwise.

    Code 1 — paste into Code.gs

    Already set up? Two steps, not one:

    1. Re-copy this updated Code 1 and paste it over your existing Code.gs (replacing it entirely), then save (Ctrl/Cmd+S).
    2. Redeploy it — pasting new code alone does not update your live sync link. In Apps Script: Deploy → Manage deployments, click the ✏️ pencil icon next to your existing deployment, set Version to New version, then click Deploy. (Do not click "New deployment" — that would give you a different link and break your existing sync URL in this app.)
    This is what actually turns on Attendance, Tracker, and Homework syncing — they write to new "Attendance Log", "Tracker Log", and "Homework Log" tabs, which only get created after the redeploy step above.

    function doGet(e) {
      if (e && e.parameter && e.parameter.action === "roster") {
        var ss = SpreadsheetApp.getActiveSpreadsheet();
        var sh = ss.getSheetByName("Roster");
        if (!sh) {
          var all = ss.getSheets();
          for (var i = 0; i < all.length; i++) {
            var nm = all[i].getName().toLowerCase().replace(/\s+/g, "");
            if (nm === "roster" || nm === "roaster" || nm === "classlist" ||
                nm === "classes" || nm === "students" || nm === "kids") { sh = all[i]; break; }
          }
        }
        var out = [];
        if (sh && sh.getLastRow() > 1) {
          var rows = sh.getRange(2, 1, sh.getLastRow() - 1, 3).getValues();
          rows.forEach(function (r) {
            var name = (r[0] || "").toString().trim();
            var cls = (r[1] || "").toString().trim();
            var photo = (r[2] || "").toString().trim();
            if (name) out.push({ name: name, cls: cls, photo: photo });
          });
        }
        return ContentService.createTextOutput(JSON.stringify(out))
          .setMimeType(ContentService.MimeType.JSON);
      }
    
      // --- Snapshot pull (Phase 1) ---
      if (e && e.parameter && e.parameter.action === "snapshot") {
        var ss = SpreadsheetApp.getActiveSpreadsheet();
        var sh = ss.getSheetByName("Snapshot");
        if (!sh || sh.getLastRow() < 2) {
          return ContentService.createTextOutput(JSON.stringify({ ok: false, error: "No snapshots found." }))
            .setMimeType(ContentService.MimeType.JSON);
        }
        // Optional: pull snapshot for a specific device
        var deviceId = (e.parameter.deviceId || "").trim();
        var lastRow = sh.getLastRow();
        var lastCol = sh.getLastColumn();
        // Walk backward from the bottom to find the newest matching row
        for (var r = lastRow; r >= 2; r--) {
          var row = sh.getRange(r, 1, 1, lastCol).getValues()[0];
          var rowDevice = (row[1] || "").toString().trim();
          if (deviceId && rowDevice !== deviceId) continue;
          // Reassemble JSON from chunks (columns C onward)
          var chunks = [];
          for (var c = 2; c < row.length; c++) {
            var chunk = (row[c] || "").toString();
            if (chunk) chunks.push(chunk);
          }
          if (!chunks.length) continue;
          var json = chunks.join("");
          return ContentService.createTextOutput(JSON.stringify({ ok: true, deviceId: rowDevice, snapshot: json }))
            .setMimeType(ContentService.MimeType.JSON);
        }
        return ContentService.createTextOutput(JSON.stringify({ ok: false, error: "No snapshot found" + (deviceId ? " for device " + deviceId : "") + "." }))
          .setMimeType(ContentService.MimeType.JSON);
      }
    
      return ContentService.createTextOutput("Points sync is live.");
    }
    
    // Log columns are: Time, Student, Class/Group, Activity, Points, New total, ID,
    // Undo Of, Note. Note is appended at the very end deliberately — sheets that
    // were created before this column existed keep their original 8-column
    // header untouched; new rows simply carry a 9th value. Add a "Note" label to
    // I1 by hand on an older sheet if you'd like the column header to show it.
    // The ID column is how duplicate scans get caught — if the app sends the same
    // scan twice (a flaky connection, a manual resync), the second one is skipped.
    function logHasId(log, id) {
      if (!id) return false;
      var last = log.getLastRow();
      if (last < 2) return false;
      var ids = log.getRange(2, 7, last - 1, 1).getValues();
      for (var i = 0; i < ids.length; i++) { if (ids[i][0] === id) return true; }
      return false;
    }
    
    // Sort helper for rebuildLeaderBoard — "First Last" becomes "Last, First"
    // so ties on points sort alphabetically by last name, same as the app.
    function lastFirstKey(name) {
      var parts = (name || "").toString().trim().split(/\s+/);
      if (parts.length < 2) return name || "";
      return parts[parts.length - 1] + ", " + parts.slice(0, -1).join(" ");
    }
    
    // Recomputes a "Leader Board" tab straight from the Log, so standings are
    // always current without anyone having to click "Send standings now."
    function rebuildLeaderBoard() {
      var ss = SpreadsheetApp.getActiveSpreadsheet();
      var log = ss.getSheetByName("Log");
      if (!log || log.getLastRow() < 2) return;
      var rows = log.getRange(2, 1, log.getLastRow() - 1, 6).getValues();
      var totals = {};
      rows.forEach(function (r) {
        var name = r[1];
        if (!name) return;
        totals[name] = { group: r[2], total: r[5] }; // last row per student wins — rows are in time order
      });
      var list = Object.keys(totals).map(function (name) {
        return { name: name, group: totals[name].group, total: totals[name].total };
      });
      list.sort(function (a, b) { return b.total - a.total || lastFirstKey(a.name).localeCompare(lastFirstKey(b.name)); });
      var lb = ss.getSheetByName("Leader Board") || ss.insertSheet("Leader Board");
      lb.clear();
      lb.appendRow(["Rank", "Student", "Class/Group", "Points"]);
      list.forEach(function (it, i) { lb.appendRow([i + 1, it.name, it.group, it.total]); });
      lb.setFrozenRows(1);
    }
    
    // ---- Snapshot helpers (Phase 1) ----
    
    // Google Sheets cells have a ~50,000 character limit. We chunk the JSON
    // across columns C, D, E… so even large data objects (with big logs or
    // photo data-URLs) are stored safely.
    var SNAPSHOT_CHUNK_SIZE = 45000;
    var SNAPSHOT_MAX_PER_DEVICE = 5;
    
    function saveSnapshot(ss, deviceId, jsonString) {
      var sh = ss.getSheetByName("Snapshot") || ss.insertSheet("Snapshot");
      if (sh.getLastRow() === 0) {
        sh.appendRow(["Time", "Device ID", "Data →"]);
        sh.setFrozenRows(1);
      }
      // Build row: [timestamp, deviceId, chunk1, chunk2, …]
      var row = [new Date(), deviceId];
      for (var i = 0; i < jsonString.length; i += SNAPSHOT_CHUNK_SIZE) {
        row.push(jsonString.substring(i, i + SNAPSHOT_CHUNK_SIZE));
      }
      sh.appendRow(row);
    
      // Trim old snapshots for this device — keep only the newest N
      trimSnapshots(sh, deviceId);
    }
    
    function trimSnapshots(sh, deviceId) {
      var lastRow = sh.getLastRow();
      if (lastRow < 2) return;
      var deviceCol = sh.getRange(2, 2, lastRow - 1, 1).getValues();
      var matchingRows = [];
      for (var i = 0; i < deviceCol.length; i++) {
        if ((deviceCol[i][0] || "").toString().trim() === deviceId) {
          matchingRows.push(i + 2); // 1-indexed sheet row
        }
      }
      // If this device has more than SNAPSHOT_MAX_PER_DEVICE snapshots, delete oldest
      if (matchingRows.length > SNAPSHOT_MAX_PER_DEVICE) {
        var toDelete = matchingRows.slice(0, matchingRows.length - SNAPSHOT_MAX_PER_DEVICE);
        // Delete from bottom up so row numbers don't shift
        for (var j = toDelete.length - 1; j >= 0; j--) {
          sh.deleteRow(toDelete[j]);
        }
      }
    }
    
    // Returns a log sheet, creating it and writing its header EXACTLY ONCE.
    //
    // The header used to be written with a bare check-then-act:
    //   if (sh.getLastRow() === 0) sh.appendRow(header);
    // One "Send Attendance" fires one request per student — twenty-odd at once —
    // and Apps Script runs those concurrently. Two executions that both arrive
    // before either has appended BOTH see an empty sheet, so both write a header.
    // That is the doubled header. It is the ONLY thing this race produced: the
    // data rows are one per request either way, which is why the header was the
    // one symptom that survived a clean-sheet test.
    //
    // The lock closes the window. flush() is not optional — it forces the header
    // to be visible to the next execution before the lock is released; without it
    // the append can still be pending and the next execution reads 0 again.
    function logSheet_(ss, name, header) {
      var lock = LockService.getScriptLock();
      var held = false;
      try { held = lock.tryLock(30000); } catch (err) {}
      try {
        var sh = ss.getSheetByName(name) || ss.insertSheet(name);
        if (sh.getLastRow() === 0) {
          sh.appendRow(header);
          SpreadsheetApp.flush();
        }
        return sh;
      } finally {
        if (held) { try { lock.releaseLock(); } catch (err) {} }
      }
    }
    
    function doPost(e) {
      if (!e || !e.postData) {
        return ContentService.createTextOutput(
          "No data received. This runs from the Points app. To test by hand, run testSetup() instead."
        );
      }
    
      var ss = SpreadsheetApp.getActiveSpreadsheet();
      var body = JSON.parse(e.postData.contents);
    
      // --- Snapshot save (Phase 1) ---
      if (body.type === "snapshot") {
        var deviceId = (body.deviceId || "unknown").toString().trim();
        var snapshot = body.snapshot || "{}";
        // snapshot is the full JSON string of the app's data object
        saveSnapshot(ss, deviceId, snapshot);
        return ContentService.createTextOutput("ok");
      }
    
      if (body.type === "scan" || body.type === "correction") {
        var log = logSheet_(ss, "Log", ["Time", "Student", "Class/Group", "Activity", "Points", "New total", "ID", "Undo Of", "Note"]);
        if (!logHasId(log, body.id)) {
          log.appendRow([new Date(body.ts), body.name, body.group, body.label, body.delta, body.total, body.id || "", body.undoOf || "", body.note || ""]);
          rebuildLeaderBoard();
        }
      }
    
      // A rebbi tagged an existing entry with a note (e.g. "talked to Dovid")
      // after the fact, from the app's History tab. Finds that scan's row by its
      // ID (column G) and fills in column I (Note) — added at the END of the row
      // so older, already-populated sheets are never shifted or reflowed.
      if (body.type === "note") {
        var nlog = ss.getSheetByName("Log");
        if (nlog && body.id) {
          var nlast = nlog.getLastRow();
          if (nlast >= 2) {
            var nids = nlog.getRange(2, 7, nlast - 1, 1).getValues(); // column G = ID
            for (var ni = 0; ni < nids.length; ni++) {
              if (nids[ni][0] === body.id) {
                nlog.getRange(ni + 2, 9).setValue(body.note || ""); // column I = Note
                break;
              }
            }
          }
        }
      }
    
      if (body.type === "standings") {
        var st = ss.getSheetByName("Standings") || ss.insertSheet("Standings");
        st.clear();
        st.appendRow(["Rank", "Student", "Class/Group", "Points"]);
        (body.rows || []).forEach(function (r) { st.appendRow(r); });
      }
    
      if (body.type === "pasuk") {
        var pl = logSheet_(ss, "Pesukim Log", ["Time", "Student", "Class/Group", "Pasuk"]);
        pl.appendRow([new Date(body.ts), body.name, body.group, body.ref + ":" + body.pasuk]);
      }
    
      if (body.type === "pasukgrid") {
        var pc = ss.getSheetByName("Pesukim Chart") || ss.insertSheet("Pesukim Chart");
        pc.clear();
        var header = ["Student", "Class/Group"];
        for (var v = body.from; v <= body.to; v++) { header.push(body.ref + ":" + v); }
        header.push("Done");
        pc.appendRow(header);
        (body.rows || []).forEach(function (r) { pc.appendRow(r); });
      }
    
      if (body.type === "totals") {
        var st = ss.getSheetByName("Student Totals") || ss.insertSheet("Student Totals");
        st.clear();
        st.appendRow(["Updated", new Date()]);
        st.appendRow(body.header || []);
        (body.rows || []).forEach(function (r) { st.appendRow(r); });
        st.setFrozenRows(2);
      }
    
      // Attendance. Takes a BATCH — {rows:[{ts,date,name,group,status}, ...]} — and
      // writes the whole class in ONE locked operation.
      //
      // Why: the app used to send one request per student. Apps Script runs those
      // concurrently, each execution called getLastRow() to decide where to append,
      // and several read the same row number before any had written — so they
      // appended over each other. A class of 20 landed 7 rows with wrong names.
      // logSheet_'s lock only ever guarded the HEADER write; the appends were
      // unprotected. Taking the lock around the whole append is the fix, and
      // batching is what makes one lock enough.
      //
      // BOTH SHAPES ARE ACCEPTED, and this is not only for the transition. Any
      // rebbi who has not redeployed is still sending one-at-a-time, and a script
      // that understood only the new shape would silently drop his attendance
      // entirely. The single-student auto-push that fires when a status changes on
      // the Attendance tab also still uses the old shape by design — it is one row
      // by nature. Do not remove this branch.
      if (body.type === "attendance") {
        var attRows = Array.isArray(body.rows)
          ? body.rows
          : [{ ts: body.ts, date: body.date, name: body.name, group: body.group, status: body.status }];
        var attLock = LockService.getScriptLock();
        var attHeld = false;
        try { attHeld = attLock.tryLock(30000); } catch (err) {}
        try {
          var att = logSheet_(ss, "Attendance Log", ["Time", "Date", "Student", "Class/Group", "Status"]);
          // setValues() in one call rather than appendRow() per row: one write, one
          // getLastRow() read, and nothing else can interleave between them.
          var attOut = [];
          for (var ai = 0; ai < attRows.length; ai++) {
            var ar = attRows[ai] || {};
            // SKIP EMPTY ROWS. A send with nothing to send used to POST anyway with
            // no body.rows at all, so the single-row fallback above built
            // [new Date(undefined), undefined, undefined, undefined, undefined] and
            // wrote a garbage row dated 1969 (new Date(undefined) is Invalid Date,
            // which lands at the epoch once the Sheet coerces it).
            // The app no longer POSTs an empty diff, but the guard belongs here too:
            // this script has to keep serving older app versions that still do, and
            // an empty row is never something anyone meant to record.
            if (!ar.name && !ar.status) continue;
            attOut.push([new Date(ar.ts), ar.date, ar.name, ar.group, ar.status]);
          }
          if (attOut.length) {
            att.getRange(att.getLastRow() + 1, 1, attOut.length, 5).setValues(attOut);
            SpreadsheetApp.flush();
          }
        } finally {
          if (attHeld) { try { attLock.releaseLock(); } catch (err) {} }
        }
      }
    
      if (body.type === "tracker") {
        var trk = logSheet_(ss, "Tracker Log", ["Time", "Student", "Class/Group", "Activity"]);
        trk.appendRow([new Date(body.ts), body.name, body.group, body.activity]);
      }
    
      if (body.type === "hw") {
        var hwl = logSheet_(ss, "Homework Log", ["Time", "Date", "Student", "Class/Group", "Checked"]);
        hwl.appendRow([new Date(body.ts), body.date, body.name, body.group, body.checked ? "Yes" : "No"]);
      }
    
      return ContentService.createTextOutput("ok");
    }
    
    // Run THIS one by hand (pick it in the function dropdown, click Run) to
    // authorize the script and confirm it works. It writes one test row you
    // can delete afterward. Do not click Run on doPost — it needs a web request.
    function testSetup() {
      var ss = SpreadsheetApp.getActiveSpreadsheet();
      var log = ss.getSheetByName("Log") || ss.insertSheet("Log");
      if (log.getLastRow() === 0) {
        log.appendRow(["Time", "Student", "Class/Group", "Activity", "Points", "New total", "ID", "Undo Of", "Note"]);
      }
      log.appendRow([new Date(), "Test student", "Gimel 1", "Setup test", 0, 0, "test-" + Date.now(), "", ""]);
      rebuildLeaderBoard();
    }
    
    // Run THIS once to create the "Roster" tab with Name / Class headers and a
    // few example rows. Then fill it in with your students and their classes, and
    // tap "Import roster from sheet" on the Students tab in the app.
    function setupRoster() {
      var ss = SpreadsheetApp.getActiveSpreadsheet();
      var sh = ss.getSheetByName("Roster") || ss.insertSheet("Roster");
      if (sh.getLastRow() === 0) {
        sh.appendRow(["Name", "Class"]);
        sh.appendRow(["Yaakov Cohen", "Math 5A"]);
        sh.appendRow(["Dovid Levi", "Math 5A"]);
        sh.appendRow(["Moshe Stern", "Math 6B"]);
      }
    }
    Code 2 — paste into appsscript.json
    {
      "timeZone": "America/New_York",
      "dependencies": {},
      "exceptionLogging": "STACKDRIVER",
      "runtimeVersion": "V8",
      "oauthScopes": [
        "https://www.googleapis.com/auth/spreadsheets.currentonly"
      ]
    }

    With Code 2 in place, the permission screen will say it only wants the spreadsheet "this application is installed in" — meaning your points sheet, nothing else in your Drive.

    If you ever change the code, you must re-deploy for it to take effect: Deploy → Manage deployments → ✏️ Edit → Version: New version → Deploy. The URL stays the same.

    Already set up from before? The Pesukim chart needs the newest Code 1 above. Re-paste it, then re-deploy: Deploy → Manage deployments → ✏️ Edit → Version: New version → Deploy. Your link stays the same.

    Updating from an older setup: the newest Code 1 adds an ID and Undo Of column at the end of the Log tab (used to stop the same scan being recorded twice) and a new Leader Board tab that recalculates itself from the Log automatically. Existing rows in Log are untouched; new columns just start filling in going forward. Re-paste and re-deploy as above.

    Heads up: the page sends data without waiting for a reply, so a wrong link won't show an error here. After saving, do one scan and check that a row appears in your sheet's Log tab — if it does, you're set.

    Danger zone

    Reset spendable points

    Zeroes current point balances (e.g. to start a fresh week) for the current class or one group. Lifetime Trends history is kept.

    Start a new period

    Reset all scores to zero but keep your students and activities.

    Clear ALL data

    Wipes everything — institution & class name, students, activities, scores, scan log, raffle history, and the saved Google Sheet link. Use this to start completely fresh (a new class, a new year). Download a backup first if you're not sure.

    Erase from this computer

    Backs your class up, then removes it from this computer and returns it to the setup screen — for a computer that isn't yours. Your backup is not touched, and this is only offered once your class is safely backed up somewhere.

    After-Snack Brachos

    Quick access after snack — Al Hamichya for the 5 grains, with Borei Nefashos right below.

    Al Hamichya · 5 grains

    בָּרוּךְ אַתָּה יְיָ, אֱלֹהֵינוּ מֶלֶךְ הָעוֹלָם, עַל הַמִּחְיָה וְעַל הַכַּלְכָּלָה, וְעַל תְּנוּבַת הַשָּׂדֶה, וְעַל אֶרֶץ חֶמְדָּה טוֹבָה וּרְחָבָה שֶׁרָצִיתָ וְהִנְחַלְתָּ לַאֲבוֹתֵינוּ לֶאֱכוֹל מִפִּרְיָהּ וְלִשְׂבּוֹעַ מִטּוּבָהּ. רַחֵם (נָא) יְיָ אֱלֹהֵינוּ עַל יִשְׂרָאֵל עַמֶּךָ, וְעַל יְרוּשָׁלַיִם עִירֶךָ, וְעַל צִיּוֹן מִשְׁכַּן כְּבוֹדֶךָ, וְעַל מִזְבְּחֶךָ, וְעַל הֵיכָלֶךָ. וּבְנֵה יְרוּשָׁלַיִם עִיר הַקֹּדֶשׁ בִּמְהֵרָה בְיָמֵינוּ, וְהַעֲלֵנוּ לְתוֹכָהּ, וְשַׂמְּחֵנוּ בְּבִנְיָנָהּ, וְנֹאכַל מִפִּרְיָהּ, וְנִשְׂבַּע מִטּוּבָהּ, וּנְבָרֶכְךָ עָלֶיהָ בִּקְדֻשָּׁה וּבְטָהֳרָה.

    כִּי אַתָּה יְיָ טוֹב וּמֵטִיב לַכֹּל, וְנוֹדֶה לְּךָ עַל הָאָרֶץ וְעַל הַמִּחְיָה. בָּרוּךְ אַתָּה יְיָ, עַל הָאָרֶץ וְעַל הַמִּחְיָה.

    Borei Nefashos

    בָּרוּךְ אַתָּה יְיָ אֱלֹהֵינוּ מֶלֶךְ הָעוֹלָם, בּוֹרֵא נְפָשׁוֹת רַבּוֹת וְחֶסְרוֹנָן, עַל כָּל מַה שֶּׁבָּרָאתָ לְהַחֲיוֹת בָּהֶם נֶפֶשׁ כָּל חָי. בָּרוּךְ חֵי הָעוֹלָמִים.

    Contest

    Run a contest for as long as you like. While it runs, the points you scan are held on the contest instead of going into each boy’s score. When you finish, you choose — in two separate steps — whether to add them to everyone’s real score or let them go.

    🏆 No contest running right now.

    Scanning works exactly as it always does until you start one.

    Prize Store

    Students spend their points to buy prizes. Set up prizes and their point cost, then pick a student (or scan) and tap what they want. Every prize also gets its own scan code — scan a prize, then scan or tap students, and the cost comes off each one's balance. Prizes are armed from the seating chart the same way activities are.

    Prizes for sale

    Student:

    Recent purchases

    Print: Prizes

    Sheet

    One card per prize in your Store, with its cost and its own scan code. Scanning one arms that prize — the next student you scan or tap buys it, and the cost comes off his balance. Tape them to the prize box, or keep the sheet with the prizes themselves.

    Coin Deposit

    Turn a student's physical Shulchani coins into a safe digital balance, or hand physical coins back. This is loss protection, not a separate savings account — deposited coins go straight onto the student's normal spendable balance, the same balance Standings and the Store already use. Scan a student's code to jump straight to his balance.

    Student:

    Coins to deposit

    Recent coin deposits & withdrawals

    Bathroom Passes

    Each student gets 1 pass per week. Scan a student's QR code when they use one. Reset the passes at the start of each week.

    Scan or type a student. Once a student has used all their passes, further scans are refused.

    Chavrusa Mode

    Build learning pairs or small groups for this sitting — tap students to group them manually, or generate groups automatically. A session ends every sitting; past groups are saved below to reuse.

    Tap students to select them, then Save as group.

    This sitting's groups

    Past Chavrusas

    Attendance

    I mark

    Tracker

    Track non-point activities — like how many times a student was called on. Each tap or scan logs one more, with a timestamp.

    Gradebook

    One grid for everything you keep track of outside of points: students down the side, things like Attendance, Homework, Tracker and Passes across the top. Set one up and it shows up here on its own. Pick which ones to show and over what dates, then print exactly what you see.

    Class Book

    Read-only. Shows exactly the items this account is allowed to see — server-enforced by firestore.rules, nothing hidden or filtered here on top of that.

    No class browser yet — paste a class ID directly. A school picker is a known, deliberately deferred follow-on, not an oversight.

    Spinner

    Spin your current class roster, or switch to a custom list for anything else (activities, prizes, "who goes first"). Separate from the student Picker in the top corner.

    Pulls live from whichever class is selected in the top-right corner. Check or uncheck names to include/exclude them from the spin.

    Settings

    Set-and-forget options that apply across the app. They save automatically on this computer.

    Account

    Moving to a new computer?

    Take your class with you — no sign-in, no new account, just Google Drive. The steps live in Backup & Restore.

    Class name

    The name of the class this app was set up for. It heads your printed Gradebook, and stands in on printed attendance and seating sheets when no particular class is picked in the dropdown at the top right. Your school name is edited by tapping it in the top left.

    If you teach several classes, this is not the one you are working with right now — that is the dropdown at the top right of every screen. This is the one name behind it all, used wherever no single class has been chosen.

    Subjects

    Same roster, same point total — subjects split which activities and history you're looking at for the class picked at the top right (e.g. Chumash and Davening, taught to the same boys). Leave this empty for a class you teach only one way; nothing changes for it.

    Barcode/QR Scanner — connect directly (Serial/COM)

    Most cheap USB scanners need no setup — they act like a keyboard, and just work on the Scan tab already. Use this only if your scanner connects as an actual serial/COM port device instead (common with some industrial, handheld, or Bluetooth-SPP scanners) — connecting it here reads scans straight from the port, everywhere in the app, instead of through the keyboard.

    Baud rate:
    Not connected.

    Tabs shown

    Hide sections you don't use to keep the top of the app simpler. Setup and Settings always stay reachable so you can turn things back on.

    Sound

    🎤 Voice notes

    Say a note onto the scan you just made instead of typing it. Scan a student, then press 🎤 Note next to Undo — or scan the printed 🎤 Voice Note card from wherever you're standing — and say what he did. Nothing is shown on screen while you speak, so nothing reaches the class; you read and fix your notes afterwards on the History tab.

    Off to begin with — tick it to switch it on. While it is off the 🎤 buttons are hidden, the printed card does nothing, and the microphone is never opened at all. No note you have already saved is changed or removed, and typing a note on the History tab keeps working either way. Needs Chrome or Edge and a connection — it uses your browser's own dictation, so there is nothing to install and no account to set up.

    🪙 Shulchani Mode

    Replace numeric points with Talmudic coin denominations. Balances are stored as a plain whole number behind the scenes and shown as the biggest coins that fit. It starts as seven coins — Darkon, Sela, Dinar, Maah, Pundion, Isar and Prutah — but the list is yours: use Customize coins below to rename them, change what they are worth, or add and remove coins so it matches what your yeshiva actually uses.

    Scan Tab — Tap Panel

    Off by default. When on, tapping a name in the list or seating chart on the Scan tab pops up that student's points breakdown (same as the "▾ Breakdown" panel on the Students tab) instead of scoring them — handy if you want to use the Tap Panel to check on a student mid-class without accidentally giving or taking points. Scanning a QR code still always scores normally, even while this is on.

    On by default. List / Seating chart is one setting shared by all your classes, but each class has its own chart. So if you arrange one class and leave the panel on Seating chart, switching to a class you haven't arranged yet would otherwise show a grid of empty seats with none of your students in it. With this on, that class shows your class list — alphabetically by last name — with a button to set its chart up when you're ready. The class you did arrange still opens straight to its chart, and nothing about any chart is changed or deleted either way.

    Warning Flash

    Off by default. When on, a ⚠️ Warning button sits next to Custom (add)/(subtract) on the Record tab, and next to Give/Take in a boy's own info popup — arm it, then tap or scan a student (or press it right from his popup) to flash a heads-up on his tile, same look as a demerit. Turning on Traffic Light Warnings below shows this button too, even if this stays off — the two work independently.

    Point value

    Traffic Light Warnings

    Off by default, and turns the ⚠️ Warning button on by itself — no need to also flip Warning Flash above. When on, every boy's seat tile (and his own info popup) changes color to show where he stands, starting green. Each ⚠️ Warning tap cycles it forward one light; cycling past the last one asks whether to refund the day's points and start him fresh, since that's almost always a rebbi correcting an overshoot rather than a genuine extra warning. Every light resets to green on its own at the start of each day. Visual by default — see Warning Flash's own Point value above if you want a warning to also take off points.

    You can hide the dots on any one chart, or reset every light back to green right away, without waiting for tomorrow — see ⚙ Display on the Seating chart.

    Lights

    Switching resets the colors below to their defaults for that count, rather than trying to guess how to remap ones you've already picked.

    Colors
    Scan Confirmation Banner

    The floating banner that briefly confirms a scan or tap — "+1 Davening for Dovid", a demerit, a freeze, an attendance mark. All three are on by default; the beep still sounds either way, so turning a kind off only hides the banner, not the confirmation sound.

    Banner size

    Normal is what every banner has always used. Large and Extra Large keep the same layout — name, the +/− points, and the running total underneath — just bigger, and the total-points line grows and darkens with it instead of staying small and grey, so it reads from across the room, not just up close.

    Stays up for

    1.4s (default) is how long a banner has always stayed up before fading. Pick a longer setting if a class doesn't have time to read it before it's gone — it still clears itself instantly the moment another scan comes in.

    See exactly how it will look and how long it stays up.

    Global Scan Bar

    Off by default. The scrolling strip of names next to the scan box, on every tab, normally shows who was just scanned and for what. Turn this on to hide it — the scan box, armed activity, Undo, Leaderboard and History buttons all stay; only the strip of recent names is gone. Handy if the screen is visible to the class or projected and you'd rather not show who scored what in real time.

    Header Shortcuts

    On by default. A single + sits in the bottom-right corner of every screen — tap it to reveal the floating points scanner, timer, picker wheel, and Board, then tap one to open it. Turn this off to hide it if you don't use any of the four — the class dropdown and sound button stay either way, and you can always turn this back on here.

    How Record Opens

    Open Record on:

    Remember last by default: if you were scanning from the full-screen seating chart when you left, the app opens back into it; close it with ✕ and the app goes back to opening on the Dashboard. Dashboard means always start there, even if you were full screen yesterday. Full-screen chart means always start in the full-screen seating chart — the room, the scan bar, nothing else — however you left things. Whatever you pick, the ⛶ and ✕ buttons keep working exactly as before within the day.

    How the Picker Opens

    Open the Picker on:

    Wheel by default. The Picker chooses a boy at random two ways, and this is the one it starts on every time you open it. Wheel spins a colored wheel of names and lands on one. Seating chart shows the class in their seats and flashes from desk to desk before landing on one — the same "Flash & land" every boy can watch from where he's sitting. Both skip boys who already had a turn, and both use the same Instant setting, so this only changes which one is in front of you when the panel opens. You can still switch between them inside the Picker whenever you want — that switch lasts for the rest of the session and doesn't change what you set here.

    Matching Games

    Match by:

    Drag by default. This sets how both matching games in Review start out — the Pesukim/Mishnayos matching game and the Shorashim match game. Drag means picking a piece up and dropping it on the line it belongs to. Tap means tapping the piece and then tapping its line — usually easier on a touchscreen or a Smart Board, and much easier for a boy who finds dragging fiddly. Both play identically otherwise and the timer and best scores are the same either way. As with the Picker, the Drag / Tap buttons on the games themselves still switch it for the rest of the session without changing what you set here.

    Hebrew Date

    On by default. Computed by the browser itself — no internet connection needed. If a browser doesn't support it, the Hebrew date is simply left off and nothing else is affected.

    Page Width

    Off by default. The app normally keeps its content in a centered column, which reads comfortably on a laptop but leaves a lot of empty space on either side of a large desktop monitor. Turn this on to stretch every tab to fill the screen instead — most noticeable on the Scan tab's Customize layout, where it gives the movable sections more room to work with.

    Class Schedule

    Off by default — this is opt-in. Check the box above and add your class's daily periods (a label and a start/end time each) and the Dashboard shows what's now and how much time is left, read-only during class. One schedule, used every school day for now — it doesn't yet know about a shorter Friday or a day with no school.

    Homework

    Points for each mark

    Checked is the same number as Homework Checked on the Activities tab. Below zero takes points off — for example −1 for Incomplete.

    In the homework grade

    50% unless you change it. Used for the homework percentage on a boy's ⓘ card and at the end of the Gradebook's spreadsheet. Incomplete and Not checked count zero there. It doesn't change any points.

    Changing a number doesn't rewrite days already marked: their points stay as given, and the new value applies the next time a day is marked or corrected.

    Class Points

    Off by default. When on, each class gets its own pool of points that works like an extra "student" — arm an activity and scan the class's own QR card (print it from Run → QR Labels → Activity Menu), or tap 🏆 Class on the seating chart, which carries the running total right on the button. The class pool never appears in the Leader Board, the raffle, the picker, attendance or the Gradebook — it is a separate count, not a boy.

    Class points are spent in the Prize Store, where a prize can be marked for the whole class (a pizza party, a trip) instead of for one student. A class scan can be undone and shows in History exactly like any other scan.

    Turning this off later hides the button and the class prizes — it never deletes a pool's points. Turn it back on and the totals are exactly where they were.

    Class Strikes

    Off by default. When on, a counter sits on the seating chart's toolbar, next to 🏆 Class. Arm Strike and scan a boy — the class's count goes up by one. ⤢ on the counter pops it out so you can drag it anywhere, even over the full-screen chart, and shrink it with –. The count is the class's running tally, and yours to reset whenever the round is over.

    0 counts the strike and takes nothing. Anything taken off is a real, undo-able scan, the same as any other deduction. A strike with no boy named never takes a point from anyone.

    Past rounds

    6-7 Celebration

    Off by default. When on, a scan that brings a student's point total to exactly 67 pops up a short, silent full-screen moment — two hands weighing, a little confetti — then fades away on its own (or tap anywhere to dismiss it early). It fires every time a total lands on exactly 67, so a student who spends points and earns their way back can hit it again. No sound, ever.

    Can't open that class

    You don't have access to this class, or the link is out of date.

    🪙 Shulchani Coin Guide

    How the coins add up