diff --git a/.gitea/workflows/deploy.yml b/.gitea/workflows/deploy.yml new file mode 100644 index 0000000..8f67da7 --- /dev/null +++ b/.gitea/workflows/deploy.yml @@ -0,0 +1,88 @@ +name: Nightly build & deploy + +# Rebuilds the site (refreshing the Discord embed's "today" snapshot), uploads +# the built dist/ to the CMU AFS space over SFTP, then triggers CMU's publish +# CGI so the files go live. +on: + # Midnight run. NOTE: cron here is UTC. 00:00 UTC is 8pm/7pm Eastern. + # For midnight *Eastern*, use "0 5 * * *" (EST) or "0 4 * * *" (EDT). + schedule: + - cron: "0 5 * * *" + # Allow manual runs from the Gitea Actions UI. + workflow_dispatch: {} + # Also deploy on pushes to the main branch. + push: + branches: + - main + +jobs: + build-and-deploy: + runs-on: ubuntu-latest + + env: + # Absolute base URL where the site is served. Used to build the absolute + # icon URL embedded for Discord. Set SITE_BASE_URL as a secret/variable, + # e.g. https://www.andrew.cmu.edu/user/ + SITE_BASE_URL: ${{ secrets.SITE_BASE_URL }} + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up Node + uses: actions/setup-node@v4 + with: + node-version: "20" + cache: "npm" + + - name: Install dependencies + run: npm ci + + - name: Build (injects today's Red-plan embed) + env: + # Discord fetches this absolute URL for the embed thumbnail. + SITE_ICON_URL: "${{ secrets.SITE_BASE_URL }}/apple-touch-icon.png" + run: npm run build + + - name: Install lftp + run: | + sudo apt-get update + sudo apt-get install -y lftp + + - name: Upload dist/ via SFTP + env: + SFTP_HOST: ${{ secrets.SFTP_HOST }} + SFTP_PORT: ${{ secrets.SFTP_PORT }} + SFTP_USERNAME: ${{ secrets.SFTP_USERNAME }} + SFTP_PASSWORD: ${{ secrets.SFTP_PASSWORD }} + SFTP_REMOTE_PATH: ${{ secrets.SFTP_REMOTE_PATH }} + run: | + set -euo pipefail + PORT="${SFTP_PORT:-22}" + + # Mirror the local dist/ into the remote path. + # -R : reverse mirror (local -> remote) + # --delete : remove remote files no longer present locally + # --verbose : log transferred files + # Host key checking is disabled because CMU's key is not pre-seeded on + # the runner; the connection is still encrypted. + lftp -c " + set sftp:auto-confirm yes; + set net:max-retries 3; + set net:timeout 20; + open -u '${SFTP_USERNAME}','${SFTP_PASSWORD}' sftp://${SFTP_HOST}:${PORT}; + mirror -R --delete --verbose ./dist/ '${SFTP_REMOTE_PATH}'; + bye + " + + - name: Trigger CMU publish + env: + SFTP_USERNAME: ${{ secrets.SFTP_USERNAME }} + run: | + set -euo pipefail + # FLAG=0 publishes; NAME is the AFS username (same as the SFTP user). + # --fail makes curl exit non-zero on HTTP errors so the job fails + # loudly if publishing is rejected. + curl --fail --show-error --silent \ + "https://www.andrew.cmu.edu/cgi-bin/publish?FLAG=0&NAME=${SFTP_USERNAME}" + echo "Publish request sent for ${SFTP_USERNAME}." diff --git a/README.md b/README.md index be2ccce..7c97130 100644 --- a/README.md +++ b/README.md @@ -38,7 +38,8 @@ npm run dev # start the dev server at http://localhost:5173 ### Other scripts ```bash -npm run build # type-check and produce a production build in dist/ +npm run build # type-check, build to dist/, then inject the Discord embed +npm run embed # (re)inject the Discord embed into an existing dist/ build npm run preview # serve the production build locally npm test # run the estimation-engine tests ``` @@ -136,6 +137,71 @@ Because `config.json` sits alongside `index.html` in the deployed files and is f runtime, you can update plan numbers, dates, or breaks by editing the deployed `config.json` directly. No rebuild or redeploy of the JS is required — just reload the page. +## Discord link embed + +The page's `` includes a Discord [Components V2](https://discord.com/developers/docs/components/reference) +link embed (` + 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