/** * Build-time injector for the Discord Components V2 link embed. * * Computes the Red plan, no-breaks, "as of today" estimate using the SAME * engine the app uses, formats a rich embed, and replaces the * __DISCORD_EMBED_JSON__ placeholder in dist/index.html with the final, * properly-escaped JSON. * * Run AFTER `vite build` (see the "build" script in package.json). It is * executed with vite-node so it can import the TypeScript engine/config * directly, guaranteeing the embed and the app agree. * * The snapshot reflects the moment of the build. The nightly Gitea workflow * rebuilds + redeploys so it stays current. */ import { readFileSync, writeFileSync } from "node:fs"; import { fileURLToPath } from "node:url"; import { dirname, resolve } from "node:path"; import { loadConfig } from "../src/configLoader.ts"; import { estimate } from "../src/engine.ts"; import { atMidnight, toISODate } from "../src/dates.ts"; const __dirname = dirname(fileURLToPath(import.meta.url)); const root = resolve(__dirname, ".."); const PLACEHOLDER = "__DISCORD_EMBED_JSON__"; const PLAN_ID = "red"; const ACCENT_COLOR = 0xc8102e; // CMU Tartan red, as a decimal int for Discord. const SITE_NAME = "CMU Meal Estimator"; const SITE_BLURB = "See how many meal blocks and FLEX dollars you should have used by any date."; /** * Absolute URL to the site icon shown in the embed. Discord fetches this over * the network, so it must be an absolute, publicly reachable URL. Override it * per deployment with the SITE_ICON_URL env var (the Gitea workflow sets this * from the deploy path). Falls back to a sensible default. */ const SITE_ICON_URL = process.env.SITE_ICON_URL ?? "https://www.andrew.cmu.edu/user/REPLACE_ME/apple-touch-icon.png"; /* ---------- formatting helpers (mirror the app) ---------- */ const fmtBlocksWhole = (n) => Math.round(n).toLocaleString("en-US"); const fmtBlocks = (n) => (Math.round(n * 10) / 10).toLocaleString("en-US", { maximumFractionDigits: 1, }); const fmtMoney = (n) => `$${n.toLocaleString("en-US", { minimumFractionDigits: 2, maximumFractionDigits: 2, })}`; function niceDate(d) { return d.toLocaleDateString("en-US", { weekday: "long", month: "long", day: "numeric", year: "numeric", }); } /* ---------- compute today's Red-plan snapshot ---------- */ function computeEmbed() { const raw = JSON.parse( readFileSync(resolve(root, "public/config.json"), "utf-8") ); const result = loadConfig(raw); if (!result.ok) { throw new Error( `config.json failed validation:\n - ${result.errors.join("\n - ")}` ); } const config = result.config; const plan = config.plans.find((p) => p.id === PLAN_ID); if (!plan) { throw new Error( `Plan "${PLAN_ID}" not found in config. Available: ${config.plans .map((p) => p.id) .join(", ")}` ); } const today = atMidnight(new Date()); const r = estimate(config, { selectedPlanId: PLAN_ID, targetDate: today, activeMissingPeriodIds: new Set(), extraExcludedDays: 0, actualMealBlocks: null, actualFlexDollars: null, }); // Where "today" falls relative to the meal window, for friendlier copy. const beforeStart = today.getTime() < config.mealStartDate.getTime(); const afterEnd = today.getTime() > config.mealEndDate.getTime(); const header = `# ${SITE_NAME}\n${SITE_BLURB}`; let body; if (beforeStart) { body = `\n\n**${plan.displayName} plan โ€” not started yet**\n` + `The meal period begins ${niceDate(config.mealStartDate)}. ` + `You start with **${fmtBlocksWhole(plan.totalMealBlocks)} blocks** and ` + `**${fmtMoney(plan.totalFlexDollars)} FLEX**.`; } else if (afterEnd) { body = `\n\n**${plan.displayName} plan โ€” meal period ended**\n` + `The period ended ${niceDate(config.mealEndDate)}.`; } else { body = `\n\n**${plan.displayName} plan ยท as of ${niceDate(today)}**\n` + `> ๐Ÿด **Blocks left:** ${fmtBlocksWhole(r.remainingMealBlocks)} of ${fmtBlocksWhole( plan.totalMealBlocks )}\n` + `> ๐Ÿ’ณ **FLEX left:** ${fmtMoney(r.remainingFlexDollars)} of ${fmtMoney( plan.totalFlexDollars )}\n` + `> ๐Ÿ“† **Suggested pace:** ${fmtBlocks(r.perWeekMealBlocks)} blocks + ${fmtMoney( r.perWeekFlexDollars )} / week\n` + `-# On-pace estimate, no breaks excluded. Adjust your own plan & dates on the site.`; } const content = header + body; // Discord Components V2 payload: // type 17 = Container (carries the accent color) // type 9 = Section (text with a right-aligned accessory) // type 10 = Text Display (the markdown content) // accessory type 11 = Thumbnail (the site icon) // type 14 = Separator // type 10 = Text Display (footer link line) const payload = { component: { type: 17, accent_color: ACCENT_COLOR, components: [ { type: 9, components: [{ type: 10, content }], accessory: { type: 11, media: { url: SITE_ICON_URL }, description: `${SITE_NAME} icon`, }, }, { type: 14 }, { type: 10, content: `-# ๐Ÿ”— Open the estimator to pick your own plan, date, and breaks.`, }, ], }, }; return { payload, meta: { plan: plan.displayName, date: toISODate(today) } }; } /* ---------- inject into dist/index.html ---------- */ function main() { const htmlPath = resolve(root, "dist/index.html"); let html; try { html = readFileSync(htmlPath, "utf-8"); } catch { throw new Error( `dist/index.html not found. Run "vite build" before this script.` ); } if (!html.includes(PLACEHOLDER)) { throw new Error( `Placeholder ${PLACEHOLDER} not found in dist/index.html. ` + `Was index.html changed?` ); } const { payload, meta } = computeEmbed(); // JSON.stringify produces valid, fully-escaped JSON โ€” safe to drop between // the