founders-break/ops/founders-break-reminder.js

173 lines
8.8 KiB
JavaScript

// The Founder's Break - weekly reminder.
//
// Runs on a plain interval (the cron runner has no day-of-week syntax), works
// out the Eastern weekday itself, and only sends on Wednesday. Deterministic on
// purpose: no LLM anywhere in the send path. A kv marker keyed to the event date
// makes a double-fire a no-op, which is what lets the job run more than once a
// day without ever mailing the list twice.
//
// env: CORBEN_KEY, LIST_ID, FROM, NOTIFY
// event: { force: true } sends regardless of the day, for testing.
var event = ctx.event, env = ctx.env, fetch = ctx.fetch, log = ctx.log, kv = ctx.kv;
var API = "https://api.corben.world/mcp";
var TZ = "America/New_York";
var VENUE = "Silver Q Billiards & Sports Bar";
var STREET = "225 W University Ave";
var CITY = "Gainesville, FL 32601";
var SITE = "https://foundersbreak.onetouch.studio";
var MAP = "https://www.google.com/maps/search/?api=1&query=Silver+Q+Billiards+225+W+University+Ave+Gainesville+FL+32601";
var WEEKDAY = 4; // Thursday
var SEND_DAY = 3; // Wednesday
var CANCELLED = []; // ISO dates to skip; keep in step with site.config.js
async function tool(name, args) {
var res = await fetch(API, {
method: "POST",
headers: { "Content-Type": "application/json", Authorization: "Bearer " + env.CORBEN_KEY },
body: JSON.stringify({ tool: name, arguments: args }),
});
if (!res.ok) throw new Error(name + " failed: " + res.status + " " + String(res.text).slice(0, 200));
return res.json;
}
function etNow() {
var now = new Date();
var iso = new Intl.DateTimeFormat("en-CA", {
timeZone: TZ, year: "numeric", month: "2-digit", day: "2-digit",
}).format(now);
var wd = new Intl.DateTimeFormat("en-US", { timeZone: TZ, weekday: "short" }).format(now);
var hour = Number(new Intl.DateTimeFormat("en-US", { timeZone: TZ, hour: "2-digit", hourCycle: "h23" }).format(now)) % 24;
var order = { Sun: 0, Mon: 1, Tue: 2, Wed: 3, Thu: 4, Fri: 5, Sat: 6 };
return { iso: iso, day: order[wd], hour: hour };
}
function addDays(iso, n) {
var p = iso.split("-").map(Number);
var d = new Date(Date.UTC(p[0], p[1] - 1, p[2]));
d.setUTCDate(d.getUTCDate() + n);
return d.toISOString().slice(0, 10);
}
function pretty(iso) {
var p = iso.split("-").map(Number);
return new Intl.DateTimeFormat("en-US", {
timeZone: "UTC", weekday: "long", month: "long", day: "numeric",
}).format(new Date(Date.UTC(p[0], p[1] - 1, p[2])));
}
function esc(s) {
return String(s == null ? "" : s)
.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;")
.replace(/"/g, "&quot;").replace(/'/g, "&#39;");
}
var HERO = "https://nyc3.digitaloceanspaces.com/corben-sites/images/c23ea202-5442-41b4-9a1b-1586e81c50eb/e7119448-83ba-4da4-95d8-b64045c21160/email-header-trim.png";
var SANS = "-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Helvetica,Arial,sans-serif";
// One shell for every outbound email so they all look like the site. `image`
// is optional: the internal notification does not need a photograph.
function shell(opts) {
var image = opts.image
? '<tr><td style="padding:0;"><img src="' + opts.image + '" width="560" alt="' + esc(opts.imageAlt || "") + '" ' +
'style="display:block;width:100%;max-width:560px;height:auto;border-radius:14px 14px 0 0;border:0;"></td></tr>'
: "";
return '<!DOCTYPE html><html><head><meta charset="utf-8">' +
'<meta name="viewport" content="width=device-width,initial-scale=1">' +
'<meta name="color-scheme" content="dark light">' +
'<meta name="supported-color-schemes" content="dark light"></head>' +
'<body style="margin:0;padding:0;background:#06060b;">' +
'<div style="display:none;max-height:0;overflow:hidden;opacity:0;">' + esc(opts.preview || "") + '</div>' +
'<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="background:#06060b;padding:32px 16px;">' +
'<tr><td align="center">' +
'<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="max-width:560px;background:#0c0c14;border:1px solid rgba(16,185,129,0.18);border-radius:14px;overflow:hidden;">' +
image +
'<tr><td style="padding:34px 34px 0;">' +
'<p style="margin:0 0 8px;font:700 11px/1.4 ' + SANS + ';letter-spacing:.16em;text-transform:uppercase;color:#34d399;">The Founder\'s Break</p>' +
'<h1 style="margin:0;font:700 27px/1.18 ' + SANS + ';letter-spacing:-.02em;color:#f1f0ff;">' + opts.headline + '</h1>' +
'</td></tr>' +
'<tr><td style="padding:16px 34px 34px;font:400 16px/1.65 ' + SANS + ';color:#c2c0d4;">' + opts.body + '</td></tr>' +
'<tr><td style="padding:0 34px 30px;">' +
'<p style="margin:0;font:400 12px/1.6 ' + SANS + ';color:#6f6d88;border-top:1px solid rgba(255,255,255,.08);padding-top:18px;">' +
'Hosted by <a href="https://onetouch.studio" style="color:#34d399;text-decoration:none;">OneTouch Studio</a>. ' +
'You are getting this because you RSVPed at <a href="' + SITE + '" style="color:#34d399;text-decoration:none;">foundersbreak.onetouch.studio</a>. ' +
'Reply and we will take you off the list.' +
'</p></td></tr>' +
'</table></td></tr></table></body></html>';
}
// The date and place, sized like an invitation rather than a spec sheet.
function marquee(when) {
return '<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="margin:24px 0 6px;background:rgba(16,185,129,.07);border:1px solid rgba(16,185,129,.22);border-radius:12px;">' +
'<tr><td style="padding:22px 24px;">' +
'<p style="margin:0;font:700 21px/1.3 ' + SANS + ';color:#f1f0ff;letter-spacing:-.01em;">' + esc(when) + '</p>' +
'<p style="margin:6px 0 0;font:600 16px/1.5 ' + SANS + ';color:#34d399;">7:00 to 9:00 PM</p>' +
'<p style="margin:14px 0 0;font:400 15px/1.55 ' + SANS + ';color:#c2c0d4;">' +
'<a href="' + MAP + '" style="color:#c2c0d4;text-decoration:none;border-bottom:1px solid rgba(16,185,129,.45);">' +
VENUE + '<br>' + STREET + ', ' + CITY + '</a></p>' +
'<p style="margin:12px 0 0;font:400 14px/1.5 ' + SANS + ';color:#8b89a6;">Free to get in. 18 and over, so bring an ID.</p>' +
'</td></tr></table>';
}
function button(label, href) {
return '<table role="presentation" cellpadding="0" cellspacing="0" style="margin:26px 0 4px;"><tr><td style="border-radius:10px;background:#10b981;">' +
'<a href="' + href + '" style="display:inline-block;padding:13px 28px;font:700 15px/1 ' + SANS + ';color:#04140f;text-decoration:none;border-radius:10px;">' + label + '</a>' +
'</td></tr></table>';
}
// ── main ────────────────────────────────────────────────────────────────
var force = !!(event && event.force);
var now = etNow();
if (!force) {
if (now.day !== SEND_DAY) return { skipped: "not wednesday (et day " + now.day + ")" };
if (now.hour < 9 || now.hour > 20) return { skipped: "outside sending hours (et hour " + now.hour + ")" };
}
// Tomorrow is the Thursday we are reminding people about.
var target = addDays(now.iso, (WEEKDAY - now.day + 7) % 7 || 7);
if (CANCELLED.indexOf(target) !== -1) return { skipped: "next Break is cancelled: " + target };
var marker = "founders-break:reminder:" + target;
if (!force && (await kv.get(marker))) return { skipped: "already sent for " + target };
var when = pretty(target);
var body =
'<p style="margin:0;">Tomorrow night. Same tables, same time. Come build something with people who are also building something.</p>' +
marquee(when) +
'<p style="margin:20px 0 0;">Still zero pool skills required. Bring a valid ID and whatever you are working on. If you know someone who should be in that room, forward this to them.</p>' +
button("See what to expect", SITE);
var campaign = await tool("create_campaign", {
name: "Founder's Break reminder " + target,
subject: "Tomorrow: The Founder's Break, " + when,
list_id: env.LIST_ID,
from_address_id: env.FROM_ADDRESS_ID,
reply_to: env.NOTIFY,
html_body: shell({
headline: "See you tomorrow.",
preview: when + ", 7:00 PM at Silver Q. Free, 18+.",
image: HERO,
imageAlt: "A blueprint drawing of a pool table and the break shot.",
body: body,
}),
text_body: "The Break is tomorrow, " + when + ".\n\n" + VENUE + ", " + STREET + ", " + CITY +
"\n7:00 to 9:00 PM. Free, 18+.\n\nZero pool skills required. Bring a valid ID.\n\n" + SITE,
tags: ["founders-break", "reminder"],
});
var campaignId = campaign && (campaign.id || (campaign.result && campaign.result.id));
if (!campaignId) throw new Error("create_campaign returned no id: " + JSON.stringify(campaign).slice(0, 200));
// Mark before sending. A crash after this leaves the week unsent, which is a
// far better failure than mailing the whole list twice.
await kv.set(marker, new Date().toISOString());
await tool("send_campaign", { id: campaignId });
log("sent reminder for " + target);
return { ok: true, event_date: target, campaign_id: campaignId };