The public menu board: a full-screen React page that runs on the TV facing the room during SkullSpace socials and events. Live at https://skullmenu.shotty.tech.
It is read-only. It never writes to Appwrite, takes no payment, and has no admin screen — prices, categories and the member discount are all edited elsewhere (the admin app) and appear here within seconds. The one thing it decides for itself is whether alcohol may be shown, and that logic is the part of this README worth reading before an event.
Clicking anywhere on the board calls requestFullscreen(), which is how you get the TV out of
browser chrome. The dark theme is forced on at mount; there is no light mode.
Appwrite, endpoint https://api.cloud.shotty.tech/v1, project 68f2ac7b00002e7563a8. These are
hardcoded in src/API/api.js — there is no .env, and no process.env reads anywhere in src/.
| What | Database | Collection | Read by the board as |
|---|---|---|---|
| Categories | 67c9ffd9003d68236514 |
67c9ffdd0039c4e09c9a |
anonymous (read("any")) |
| Items | 67c9ffd9003d68236514 |
pos_items |
anonymous (read("any")) |
| Bar config | barData |
config |
anonymous (read("any")) |
| Active event | — | — | ticketing-active-event function, not a collection read |
Those three collections are fetched once at load and then kept current by an Appwrite Realtime
subscription on all three. The active event has no usable realtime channel here (Realtime only
delivers rows the session can read, and this session cannot read Events at all), so it is polled
every 60 seconds and re-fetched on any realtime event.
The board also creates an anonymous session at startup. It needs one only because
ticketing-active-event is execute: ["users"]. The three collection reads deliberately do not
wait on the session — a hung account.get() must not keep prices off the screen.
The old items collection 67c9ffe6001c17071bb7 (Items_old) is retired and still holds
cost-of-goods columns. Nothing here reads it; do not point it back.
One column per category, left to right, each column a list of items.
A category is shown when it has at least one visible item. Empty categories are dropped entirely (the remaining columns grow to fill the space) — so "alcohol outside bar hours" reads as a board with fewer columns, not as an empty box. With one or two columns on screen, each spreads its items into two balanced sub-columns.
An item is shown when all of these hold:
- its category is shown (see the alcohol gate below),
enabled_menuis true,enabled_posis true — the board will not advertise something the bartender cannot ring up,- its name does not end in
DBL(those rows exist for the register; see below).
Per item: name_menu if set, otherwise name; a maple leaf if canadian is true; the image
URL if set, otherwise a vector CategoryIcon; size rendered as mL except in the Food category,
where a mL figure is meaningless.
Prices are cents, from sale_price — the same number the register charges, and the only one.
self_pricing is never displayed (it used to be shown in place of the real price whenever alcohol
was off, which quietly advertised a price the till would not honour).
Two categories get a two-line price stack instead of a single price:
- Mixed Drinks →
Single/Double - Food →
1 Slice/2 Slices, when a double price exists
The double price comes from the register's own <name> DBL item row (sale_price on that row,
only if it is enabled_pos), falling back to the single row's dbl_price field when no DBL twin
exists. This matters: dbl_price is free text nobody rings up, and four of the six spirits had it
a dollar under the DBL row's real price. If the board and the till disagree on a double, fix the
DBL item.
Footer: "SkullSpace members receive N% discount", from barData/config key member_discount
(live value: 25). It is shown only while alcohol is on the board.
Layout decisions (utils/categoryLayout.js) match on a normalised name: leading emoji and their
modifiers stripped, whitespace collapsed, case folded. 🥃 Mixed Drinks/Shots, 🥃 Mixed Drinks
and Mixed all keep their Single/Double stack; Food is matched exactly; Non-Alcoholic matches
with or without the hyphen. Renaming a category in the admin app will not silently switch a price
stack off.
Two things are still name-sensitive, and are worth knowing before you rename anything:
components/CategoryIcon.jsmatches the raw name forNon-Alcoholic,MixedandAlcohol. A rename that drops those words falls back to the generic glyph. Cosmetic only.- While alcohol is off, the Non-Alcoholic column's heading is softened to "Beverages" — there is nothing alcoholic on screen for it to be the "non" of.
Three checks, in this order. Every one of them fails closed: anything unknown hides alcohol.
-
Kill switch.
barData/configrowalcohol_override_disabled(live value:false). The string"true"hides all alcohol immediately. If the config fetch has not resolved or failed, this reads as engaged — no Appwrite function enforces this switch, the clients are the only place it is honoured, so an unknown answer must not leave a board selling. -
Active event. Fetched through the
ticketing-active-eventfunction (below). No active event → no alcohol. -
Bar hours. The event's
sellsAlcoholmust be true, and the current time must fall insidebarOpensAt–barClosesAt, a pair of full ISO-8601 instants. There is no wall clock to parse and no timezone to guess — the gate compares two numbers — and a window that crosses midnight needs no special case, because each instant carries its own date. A missing instant, an unparseable one, or a pair that closes at or before it opens all fail closed.The retired
barOpenTime/barCloseTimestrings have no reader left on this board. While they are still present on a row they are ignored, never consulted as a fallback. See "The divergence that used to live here" below.
The gate is re-evaluated once a minute, so the window opens and closes on its own mid-shift without a reload.
The board and the staff POS read the same data — the same barData/config row and the same
active event. On bar hours they now also run the same comparison against the same two instants,
so the TV and the till can no longer disagree about the window. On the kill switch they still
differ on which stored values count as engaged; see below before concluding that a disagreement
means one of them is on stale code.
The gate keys on the category's alcohol boolean, not on the item's contains_alcohol. The
board never reads contains_alcohol. An alcoholic item filed under a non-alcohol category will sit
on the board at every hour of the day.
For most of this project's life the board and the register could read the same stored row and reach opposite conclusions about whether the bar was open. It is worth knowing what that was, because it is the reason the bar window is a pair of instants today.
The admin app's bar-time field had no validation at entry, so an admin could type "1800" where
the label asked for "18:00". This board's parseTimeToMinutes stripped the colon and accepted
3–4 digits, so "1800" parsed; the POS's copy was strict (/^(\d{1,2}):(\d{2})$/) and returned
null, which its gate failed closed on. One event, saved once, advertised a drink on the TV that
the till then refused to ring up — and bartender PINs kept working throughout, because Verify-Pin's
parser was lenient too, so nothing on any screen indicated a contradiction.
That class of bug is now closed by construction, not by agreement. The fix was never a third
parser the two sides could finally share: it is that neither surface has a wall clock left to
parse. parseTimeToMinutes is gone from this repo, barOpenTime/barCloseTime have no reader
here, and both gates compare the same two instants. Two numbers cannot be read two ways.
One real divergence remains, and it is unrelated to bar hours:
- The kill switch. This board's
isAlcoholOverrideDisabledengages only on the exact string"true". The POS'sreadAlcoholOverride— which lives inPOS/src/components/pos/pos.js, not in itsbarHours.js— inverts the test: any value that is present and not in{"", "false", "0", "no", "off"}engages the switch.alcohol_override_disabledset to"TRUE","1"or"yes"therefore stops the register selling while this board keeps advertising. Only the exact lowercase"true"stops both. The POS's switch is also four-state (on/off/unknown/pending, distinguishing "we could not read the config" from "the admin never set it"); this board's is two-state, with any unresolved or failed config read treated as engaged. Both fail closed on an unknown answer; they differ on which known values count as engaged.
| On screen | Means |
|---|---|
Bar Open · Until 2:00 AM |
gate open; the time is the event's barClosesAt instant, rendered in the TV's own clock (which at the venue is the venue's). An unreadable or absent instant prints just Bar Open rather than a guessed time |
Bar Closed |
we asked, and alcohol is genuinely not being served right now |
Bar Status Unavailable · Ask Staff + red banner |
we could not ask. Alcohol is hidden anyway |
The third state exists because the board is the only place either claim is said out loud. A failed lookup used to render as a confident "Bar Closed", which is a statement about the bar made on no information.
- Function id
ticketing-active-event(nameTicketing-ActiveEvent), execute["users"], scopesdocuments.read, timeout 15s. - Source:
AppwriteFunctions/functions/Ticketing-ActiveEvent/. That repo is the only source of Appwrite Functions — the fork that used to live under ShottyTicketing is gone. - It returns
{ "event": {...} }or{ "event": null }; among the door-facing fields it projectssellsAlcohol,barOpensAtandbarClosesAt, which is the whole reason this board calls it. It normalizes each instant to a real value or tonull— never an empty string, never something unparseable — so the gate's "unknown" case is genuinely unknown rather than malformed. - The response is stored whole. Neither
utils/activeEvent.jsnorAPI/api.jspicks fields out of it; the allowlist lives in the function, and a second one on this side would just be another place an instant could go missing. A dropped instant does not raise an error — it reads as "no bar window" and hides alcohol on a night the bar was open.
It exists because the board's anonymous session cannot read the Events collection
(68e400210008d19bb5c9) at all — that collection is admin-team-only, and widening it is not an
option, because the same rows carry revenue, cogs, profit, tips_earned, cash_sales and
card_sales. Appwrite permissions are per-collection, never per-field; the function is the field
filter. Do not "fix" a blank bar status by granting the board read on Events.
barData/config still contains bar_start and bar_end (both live at 2200). Nothing reads
them. They are left over from the static schedule the board used before the gate moved to the
active event. Editing them does nothing; bar hours are set per-event in the admin app.
| Symptom | Check |
|---|---|
| Alcohol columns missing, bar should be open | In this order: alcohol_override_disabled is not "true"; an event has isActive true; that event has sellsAlcohol true; its barOpensAt/barClosesAt bracket right now and barClosesAt is genuinely after barOpensAt. A backwards pair (a 2am close that never got the next day attached) fails closed — re-save the event in the admin app and the board picks it up within 60s. Setting barOpenTime/barCloseTime does nothing; they are not read. |
Bar Status Unavailable · Ask Staff |
The function call failed. appwrite functions get --function-id ticketing-active-event — is it enabled, is the latest deployment ready, is execute still users? Then: is anonymous auth still on for the project? The board's browser console logs the underlying error. |
| One item missing | enabled_menu and enabled_pos both true; the item is assigned to a category; its name does not end in DBL. |
| Board's double price ≠ what the till charges | The board quotes the <name> DBL row's sale_price. Fix that row, not dbl_price. |
| An alcoholic item shows after close | It is filed under a category whose alcohol flag is false. |
| Whole board blank / stale | Nothing here caches beyond the page: reload the TV. Prices and categories otherwise update live over Realtime. |
npm install
npm start # dev server on :3000
CI=true npx react-scripts test --watchAll=false # 7 suites, 69 tests green at dba5aec
npm run build # static output in build/, gitignored
Create React App (react-scripts 5.0.1), React 18, appwrite web SDK 17. Prettier config in
.prettierrc (4 spaces, single quotes, no trailing commas).
Deployment is Cloudflare Pages, built from a push to master on
https://github.com/skullspace/skullmenu. There is no CI config, no wrangler.toml and no deploy
script in this repo — the build settings live in the Cloudflare dashboard. Verified as of
2026-09-13: https://skullmenu.shotty.tech is served by Cloudflare and the deployed bundle
(/static/js/main.51a91217.js) is byte-identical to a local npm run build of master at
dba5aec.
src/API/api.js Appwrite client, the three collection reads, realtime + event poll
src/App.js column/item filtering, alcohol gate wiring
src/utils/activeEvent.js calls ticketing-active-event; ok / unavailable / pending states
src/utils/barHours.js the alcohol gate itself (kill switch + bar-hours window)
src/utils/doublePrice.js DBL-row lookup for the posted double price
src/utils/categoryLayout.js emoji-insensitive category name matching, column headings
src/components/ Header (bar status), Footer (member discount), BarItem, CategoryIcon, Grain
src/dark.css is not imported by anything; src/theme.css is the live stylesheet.
There is a second checkout of this repository at ../MEnu. It is stale and not authoritative — see
its README before touching it.
License: AGPL-3.0+ (see COPYING). Originally by Zorian Medwid.