add workflow and embed
Nightly build & deploy / build-and-deploy (push) Failing after 8m23s

This commit is contained in:
2026-09-19 00:59:55 -04:00
parent 31d5322b38
commit db75d5bfe7
5 changed files with 383 additions and 3 deletions
+199
View File
@@ -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();