This commit is contained in:
@@ -0,0 +1,199 @@
|
||||
/**
|
||||
* 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();
|
||||
Reference in New Issue
Block a user