Helper Functions
Game Tag Finder
Search a game, copy its internal tag, paste it into a post's tag field to link the article to that game's page.
Poll Cookbook
Day-to-day recipes for creating polls. Everything runs in the Supabase SQL Editor.
Before anything — install the helper (once)
Every recipe below uses create_poll(). If you haven't installed it yet, run this once and never again.
create or replace function public.create_poll(
p_question text,
p_slot text,
p_options text[],
p_xp integer default 100,
p_opens_at timestamptz default now(),
p_closes_at timestamptz default null
)
returns uuid
language plpgsql
set search_path = public
as $function$
declare
v_id uuid;
begin
if array_length(p_options, 1) is null or array_length(p_options, 1) < 2 then
raise exception 'A poll needs at least 2 options';
end if;
insert into public.polls (question, slot, xp_award, opens_at, closes_at)
values (p_question, nullif(trim(p_slot), ''), p_xp, p_opens_at, p_closes_at)
returning id into v_id;
insert into public.poll_options (poll_id, label, sort)
select v_id, o, i
from unnest(p_options) with ordinality as t(o, i);
return v_id;
end;
$function$;
revoke execute on function public.create_poll(text,text,text[],integer,timestamptz,timestamptz)
from anon, authenticated;
Apostrophes. Inside SQL strings, a single quote must be doubled.'You don''t care'— correct.'You don't care'— syntax error.
1 · Weekly poll
Slot: weekly · XP: 50 · Window: Monday → Monday
select public.create_poll(
'Which 2026 release has held up best?',
'weekly',
array[
'Clair Obscur: Expedition 33',
'Death Stranding 2',
'Hollow Knight: Silksong',
'Ghost of Yotei'
],
50,
date_trunc('week', now()),
date_trunc('week', now()) + interval '7 days'
);
Appears immediately in the homepage hub (WEEKLY tab) and anywhere you've mounted data-poll-slot="weekly". Retires itself when the window closes.
2 · Monthly poll
Slot: monthly · XP: 100 · Window: 1st → 1st
select public.create_poll(
'How do you feel about Sony abandoning physical media?',
'monthly',
array[
'You care, and collect physical',
'You care, but lean towards digital',
'You don''t care, digital is better anyway'
],
100,
date_trunc('month', now()),
date_trunc('month', now()) + interval '1 month'
);
3 · Article poll
Slot: post-<post-slug> · XP: 100 · Window: never closes
Step 1 — lock the Post URL first
In the Ghost editor, open Settings → Post URL and confirm the slug is final.
Ghost rewrites the slug while a draft is untitled. If you create the poll against post-untitled and then title the post, the mount looks for a slot that no longer exists and hides itself — leaving the poll orphaned.Step 2 — create the poll
Slot must equal the Post URL exactly, prefixed with post-.
select public.create_poll(
'How does Silksong hold up against Hollow Knight?',
'post-silksong-review',
array['Surpasses it','Matches it','Falls short','Too early to say']
);
Step 3 — nothing
post.hbs already carries an auto-mount on every post. The poll appears between the article body and the comments on its own. Posts without a poll render nothing.
To override placement — put it mid-article instead of after it — drop an HTML card where you want it:
<div class="tarrmu-poll" data-poll-slot="post-silksong-review"></div>
A hand-placed card automatically suppresses the auto-mount, so you never get two.
Two articles about the same game
Not a problem. The slot is the post slug, not the game, and Ghost guarantees post slugs are unique. post-silksong-review and post-silksong-two-years-on are separate polls with separate votes.
Scheduling ahead
Polls in the same slot with different windows coexist. The live one shows, future ones wait, rotation happens on its own. No deleting, no flag flipping.
Next month's monthly, written today:
select public.create_poll(
'Next month''s question?',
'monthly',
array['Option A','Option B','Option C'],
100,
date_trunc('month', now()) + interval '1 month',
date_trunc('month', now()) + interval '2 months'
);
A quarter of weeklies in one statement:
select public.create_poll(
'Weekly poll — week ' || to_char(wk, 'IW'),
'weekly',
array['Option A','Option B','Option C'],
50, wk, wk + interval '7 days'
)
from generate_series(
date_trunc('week', now()) + interval '1 week',
date_trunc('week', now()) + interval '12 weeks',
interval '1 week'
) as wk;
Checking your work
select * from public.poll_status;
| Column | Meaning |
|---|---|
live |
Currently being served for that slot |
votes |
Votes cast |
xp_paid |
Total XP awarded by this poll |
You want exactly one live = true row per slot.
Fixing things
Kill a poll early — falls through to the previous one in that slot:
select public.close_poll('monthly');
Extend a window:
update public.polls set closes_at = closes_at + interval '7 days'
where slot = 'weekly' and is_open;
Post slug changed after you made the poll — re-point, don't recreate. Votes live on poll_id, so nothing is lost:
update public.polls set slot = 'post-new-slug' where slot = 'post-old-slug';
Find orphans — article polls whose slug no longer matches a post:
select slot, question, created_at from public.polls
where slot like 'post-%' order by created_at desc;
Two polls fighting over one slot — only happens if you gave them identical windows. Close one:
select public.close_poll('monthly'); -- then create the replacement
Never delete a poll that has votes.poll_options,poll_votesandpoll_xp_awardsall cascade onpolls.id. Deleting takes the XP ledger with it, and the ledger is the only thing stopping everyone who voted from earning the XP a second time. Close instead — old polls cost nothing and are your archive.
Cheat sheet
| Slot | XP | Window | Where it shows | |
|---|---|---|---|---|
| Weekly | weekly |
50 | 7 days | Homepage hub (WEEKLY tab), Community Hub page |
| Monthly | monthly |
100 | 1 month | Homepage hub (MONTHLY tab), Community Hub page |
| Article | post-<slug> |
100 | never closes | Auto-mounted on that post |
| Game page | game-<slug> |
100 | never closes | pending route info |
| Cross-article | your own name | 100 | your call | Wherever you mount it |
XP on top of the award: milestone quests fire at 1, 5, 10, 25 and 50 polls voted (+50 / +100 / +150 / +300 / +500), for 1,100 lifetime. Retract never claws back XP.
Widget states
| What you see | What it means |
|---|---|
| Nothing at all | No live poll in that slot — check poll_status |
// PICK ONE |
Signed in, hasn't voted |
// SIGN IN TO VOTE |
Logged out |
// VOTE RECORDED |
Already voted, can change or retract |
// CLOSED |
Window expired or is_open = false |
// UNAVAILABLE |
RPC threw — check the browser console |
| Two polls stacked | Hand-placed card plus auto-mount without the dedup guard |