200 lines
6.2 KiB
JavaScript
200 lines
6.2 KiB
JavaScript
/**
|
|
* 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 <script type="application/json"> tags.
|
|
const json = JSON.stringify(payload, null, 2);
|
|
|
|
writeFileSync(htmlPath, html.replace(PLACEHOLDER, json), "utf-8");
|
|
|
|
console.log(
|
|
`Injected Discord embed (${meta.plan} plan, as of ${meta.date}) into dist/index.html`
|
|
);
|
|
}
|
|
|
|
main();
|