Publish MealViewer menus as calendar feeds on Pages
Assisted-by: Claude Opus 5 via Claude Code
Claude-Session: https://claude.ai/code/session_01GLD6zqefK1dcdkMiaB7HUH
change qzpvyulnzrqxnrpkwytnyuykvtkvsusu
commit e5e900862cb07979b952aaa09c53fc41e9dfd503
author Alpha Chen <alpha@kejadlen.dev>
date
diff --git a/.github/workflows/pages.yml b/.github/workflows/pages.yml
new file mode 100644
index 0000000..5ed9fcb
--- /dev/null
+++ b/.github/workflows/pages.yml
@@ -0,0 +1,60 @@
+name: Pages
+
+on:
+  push:
+    branches: [main]
+  schedule:
+    - cron: "17 11 * * *"
+  workflow_dispatch:
+
+permissions: {}
+
+concurrency:
+  group: pages
+  cancel-in-progress: true
+
+jobs:
+  build:
+    runs-on: ubuntu-latest
+    permissions:
+      contents: read # checkout.
+    env:
+      RUBYOPT: --enable-frozen-string-literal
+    steps:
+      - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
+        with:
+          persist-credentials: false
+      - uses: ruby/setup-ruby@95ef2b042f9d7a56d8268cba8559e2842e2ad01b # v1.321.0
+        with:
+          bundler-cache: true
+      - run: bundle exec rake test
+      - run: bundle exec rake build
+      - uses: actions/upload-pages-artifact@fc324d3547104276b827a68afc52ff2a11cc49c9 # v5.0.0
+        with:
+          path: _site
+
+  deploy:
+    needs: [build, zizmor]
+    runs-on: ubuntu-latest
+    permissions:
+      pages: write    # publish the site.
+      id-token: write # prove the deployment came from this workflow.
+    environment:
+      name: github-pages
+      url: ${{ steps.deployment.outputs.page_url }}
+    steps:
+      - id: deployment
+        uses: actions/deploy-pages@368f82528645a54fb793d4d04e342629a3f51346 # v5.0.1
+
+  zizmor:
+    runs-on: ubuntu-latest
+    permissions:
+      contents: read # checkout.
+    steps:
+      - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
+        with:
+          persist-credentials: false
+      - uses: zizmorcore/zizmor-action@cc914d7f3750a2d13d75c7f184a1060aa0e9d482 # v0.6.4
+        with:
+          advanced-security: false
+          annotations: true
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..bd3f1fa
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1 @@
+/_site/
diff --git a/.quire/config.fnl b/.quire/config.fnl
new file mode 100644
index 0000000..dcbec4f
--- /dev/null
+++ b/.quire/config.fnl
@@ -0,0 +1 @@
+{:mirrors {"https://github.com/kejadlen/sps-menus.git" :github-mirror}}
diff --git a/.ruby-version b/.ruby-version
new file mode 100644
index 0000000..b8626c4
--- /dev/null
+++ b/.ruby-version
@@ -0,0 +1 @@
+4
diff --git a/Gemfile b/Gemfile
new file mode 100644
index 0000000..d5aafd9
--- /dev/null
+++ b/Gemfile
@@ -0,0 +1,8 @@
+source "https://rubygems.org"
+
+gem "icalendar"
+
+group :development do
+  gem "minitest"
+  gem "rake"
+end
diff --git a/Gemfile.lock b/Gemfile.lock
new file mode 100644
index 0000000..c186799
--- /dev/null
+++ b/Gemfile.lock
@@ -0,0 +1,43 @@
+GEM
+  remote: https://rubygems.org/
+  specs:
+    base64 (0.3.0)
+    drb (2.2.3)
+    icalendar (2.12.4)
+      base64
+      ice_cube (~> 0.16)
+      logger
+      ostruct
+    ice_cube (0.17.0)
+    logger (1.7.0)
+    minitest (6.0.6)
+      drb (~> 2.0)
+      prism (~> 1.5)
+    ostruct (0.6.3)
+    prism (1.9.0)
+    rake (13.4.2)
+
+PLATFORMS
+  arm64-darwin
+  arm64-darwin-25
+  ruby
+  x86_64-linux
+
+DEPENDENCIES
+  icalendar
+  minitest
+  rake
+
+CHECKSUMS
+  base64 (0.3.0) sha256=27337aeabad6ffae05c265c450490628ef3ebd4b67be58257393227588f5a97b
+  drb (2.2.3) sha256=0b00d6fdb50995fe4a45dea13663493c841112e4068656854646f418fda13373
+  icalendar (2.12.4) sha256=acf9b8b166dad4679b8be2cc3202260896c199ab7302f748c760c97e9d32e3a2
+  ice_cube (0.17.0) sha256=32deb45dda4b4acc53505c2f581f6d32b5afc04d29b9004769944a0df5a5fcbe
+  logger (1.7.0) sha256=196edec7cc44b66cfb40f9755ce11b392f21f7967696af15d274dde7edff0203
+  minitest (6.0.6) sha256=153ea36d1d987a62942382b61075745042a2b3123b1cd48f4c3675af9cc7d6f1
+  ostruct (0.6.3) sha256=95a2ed4a4bd1d190784e666b47b2d3f078e4a9efda2fccf18f84ddc6538ed912
+  prism (1.9.0) sha256=7b530c6a9f92c24300014919c9dcbc055bf4cdf51ec30aed099b06cd6674ef85
+  rake (13.4.2) sha256=cb825b2bd5f1f8e91ca37bddb4b9aaf345551b4731da62949be002fa89283701
+
+BUNDLED WITH
+  4.0.3
diff --git a/Rakefile b/Rakefile
new file mode 100644
index 0000000..daa346d
--- /dev/null
+++ b/Rakefile
@@ -0,0 +1,60 @@
+require "erb"
+require "minitest/test_task"
+require "pathname"
+
+Minitest::TestTask.create
+
+task default: :test
+
+# MealViewer school lookups, as they appear in https://schools.mealviewer.com/school/<lookup>.
+SCHOOLS = ["McDonald"]
+# Fixed rather than read from the menu so feeds keep existing over breaks.
+BLOCKS = ["Breakfast", "Lunch"]
+SITE = Pathname("_site")
+
+INDEX = ERB.new(<<~'ERB', trim_mode: "-")
+  <!doctype html>
+  <meta charset="utf-8">
+  <title>School menus</title>
+  <h1>School menus</h1>
+  <p>Click a meal to subscribe to its calendar.</p>
+  <ul>
+  <%- schools.each do |school, feeds| -%>
+    <li>
+      <%= ERB::Util.h(school) %>:
+  <%- feeds.each do |block, href| -%>
+      <a class="subscribe" href="<%= ERB::Util.h(href) %>"><%= ERB::Util.h(block) %></a>
+  <%- end -%>
+    </li>
+  <%- end -%>
+  </ul>
+  <script>
+    for (const link of document.querySelectorAll("a.subscribe")) {
+      link.href = link.href.replace(/^https?:/, "webcal:");
+    }
+  </script>
+ERB
+
+def slug(name) = name.downcase.gsub(/[^a-z0-9]+/, "-")
+
+desc "Write a calendar feed per school and meal, plus a landing page, to #{SITE}"
+task :build do
+  require_relative "lib/menu_feed"
+
+  today = Date.today
+  SITE.mkpath
+  schools = SCHOOLS.to_h do |school|
+    feed = MenuFeed.fetch(school, today - 7, today + 35)
+    feeds = BLOCKS.to_h do |block|
+      ics = "#{slug(school)}-#{slug(block)}.ics"
+      (SITE / ics).write(feed.to_ical(block))
+      puts "Wrote #{feed.meals.count { it.block_name == block }} meals to #{SITE / ics}"
+
+      [block, ics]
+    end
+
+    [feed.school, feeds]
+  end
+
+  (SITE / "index.html").write(INDEX.result_with_hash(schools:))
+end
diff --git a/lib/menu_feed.rb b/lib/menu_feed.rb
new file mode 100644
index 0000000..3d4efa2
--- /dev/null
+++ b/lib/menu_feed.rb
@@ -0,0 +1,77 @@
+require "date"
+require "icalendar"
+require "json"
+require "net/http"
+
+# Converts a MealViewer school menu into an iCalendar feed.
+class MenuFeed
+  API = URI("https://api.mealviewer.com/api/v4/school/")
+
+  # Item types that name the meal, in the order they should appear in a title.
+  PRIMARY_TYPES = ["Entree", "Grab & Go", "Breakfast"]
+  SKIPPED_TYPES = ["Condiment"]
+
+  Item = Data.define(:type, :name)
+
+  Meal = Data.define(:date, :block_id, :block_name, :items) do
+    def self.from_block(block)
+      new(
+        date: Date.strptime(block.fetch("scheduledDate"), "%Y-%m-%dT%H:%M:%S"),
+        block_id: block.fetch("id"),
+        block_name: block.fetch("blockName"),
+        items: block.fetch("cafeteriaLineList").fetch("data")
+          .flat_map { it.fetch("foodItemList").fetch("data") }
+          .map { Item.new(type: it.fetch("item_Type"), name: it.fetch("item_Name")) }
+          .reject { SKIPPED_TYPES.include?(it.type) },
+      )
+    end
+
+    def summary
+      primary = items
+        .select { PRIMARY_TYPES.include?(it.type) }
+        .sort_by.with_index { |item, i| [PRIMARY_TYPES.index(item.type), i] }
+      return block_name if primary.empty?
+
+      primary.map(&:name).join(" / ")
+    end
+
+    def description = items.map { "#{it.type}: #{it.name}" }.join("\n")
+  end
+
+  def self.fetch(school, from, to)
+    uri = API + [URI.encode_uri_component(school), from.strftime("%m-%d-%Y"), to.strftime("%m-%d-%Y"), "0"].join("/")
+    response = Net::HTTP.get_response(uri)
+    raise "GET #{uri} returned #{response.code}: #{response.body}" unless response.is_a?(Net::HTTPSuccess)
+
+    new(JSON.parse(response.body))
+  end
+
+  def initialize(menu)
+    location = menu.fetch("physicalLocation")
+    @school = location.fetch("name")
+    @lookup = location.fetch("physicalLocationLookup")
+    @meals = menu.fetch("menuSchedules")
+      .flat_map { it.fetch("menuBlocks") }
+      .reject { it.fetch("blackedOut") }
+      .map { Meal.from_block(it) }
+  end
+
+  attr_reader :school, :meals
+
+  def to_ical(block_name)
+    calendar = Icalendar::Calendar.new
+    calendar.x_wr_calname = "#{school} #{block_name.downcase}"
+    meals.select { it.block_name == block_name }.each do |meal|
+      calendar.event do |event|
+        event.uid = "#{meal.date.iso8601}-#{meal.block_id}@mealviewer.#{@lookup}"
+        event.dtstart = Icalendar::Values::Date.new(meal.date)
+        event.dtend = Icalendar::Values::Date.new(meal.date + 1)
+        event.summary = meal.summary
+        event.description = meal.description
+        event.transp = "TRANSPARENT"
+      end
+    end
+    calendar.publish
+    calendar.to_ical
+  end
+end
diff --git a/test/fixtures/menu.json b/test/fixtures/menu.json
new file mode 100644
index 0000000..436fadf
--- /dev/null
+++ b/test/fixtures/menu.json
@@ -0,0 +1,299 @@
+{
+  "physicalLocation": {
+    "name": "McDonald",
+    "physicalLocationLookup": "McDonald"
+  },
+  "menuSchedules": [
+    {
+      "dateInformation": {
+        "dateKey": 20260901,
+        "dateFull": "2026-09-01T00:00:00",
+        "weekNumber": 36,
+        "weekDayName": "Tuesday",
+        "monthDay": 1,
+        "monthName": "September",
+        "weekDay": 3,
+        "monthNumber": 9
+      },
+      "menuBlocks": []
+    },
+    {
+      "dateInformation": {
+        "dateKey": 20260902,
+        "dateFull": "2026-09-02T00:00:00",
+        "weekNumber": 36,
+        "weekDayName": "Wednesday",
+        "monthDay": 2,
+        "monthName": "September",
+        "weekDay": 4,
+        "monthNumber": 9
+      },
+      "menuBlocks": [
+        {
+          "id": 3577,
+          "object": "menuBlock",
+          "blockName": "Breakfast",
+          "scheduledDate": "2026-09-02T00:00:00",
+          "cafeteriaLineList": {
+            "object": "list",
+            "url": "",
+            "hasMore": false,
+            "data": [
+              {
+                "name": "",
+                "id": 0,
+                "object": "cafeteriaLine",
+                "foodItemList": {
+                  "object": "list",
+                  "url": "",
+                  "hasMore": false,
+                  "data": [
+                    {
+                      "item_Type": "Breakfast",
+                      "item_Name": "Croissant, Turkey Sausage",
+                      "block_Name": "Breakfast",
+                      "menu_Block_Date": "2026-09-02T00:00:00"
+                    },
+                    {
+                      "item_Type": "Breakfast",
+                      "item_Name": "Cereal, Cinnamon Chex",
+                      "block_Name": "Breakfast",
+                      "menu_Block_Date": "2026-09-02T00:00:00"
+                    },
+                    {
+                      "item_Type": "Breakfast",
+                      "item_Name": "Cereal, Cinnamon Toast Crunch",
+                      "block_Name": "Breakfast",
+                      "menu_Block_Date": "2026-09-02T00:00:00"
+                    },
+                    {
+                      "item_Type": "Breakfast",
+                      "item_Name": "Cereal, Honey Cheerios",
+                      "block_Name": "Breakfast",
+                      "menu_Block_Date": "2026-09-02T00:00:00"
+                    },
+                    {
+                      "item_Type": "Daily Fruit",
+                      "item_Name": "Pears",
+                      "block_Name": "Breakfast",
+                      "menu_Block_Date": "2026-09-02T00:00:00"
+                    }
+                  ]
+                }
+              }
+            ]
+          },
+          "blackedOut": false,
+          "noScheduleText": null,
+          "orderId": 1
+        },
+        {
+          "id": 3578,
+          "object": "menuBlock",
+          "blockName": "Lunch",
+          "scheduledDate": "2026-09-02T00:00:00",
+          "cafeteriaLineList": {
+            "object": "list",
+            "url": "",
+            "hasMore": false,
+            "data": [
+              {
+                "name": "",
+                "id": 0,
+                "object": "cafeteriaLine",
+                "foodItemList": {
+                  "object": "list",
+                  "url": "",
+                  "hasMore": false,
+                  "data": [
+                    {
+                      "item_Type": "Condiment",
+                      "item_Name": "Ketchup Packet",
+                      "block_Name": "Lunch",
+                      "menu_Block_Date": "2026-09-02T00:00:00"
+                    },
+                    {
+                      "item_Type": "Condiment",
+                      "item_Name": "Mustard Packet",
+                      "block_Name": "Lunch",
+                      "menu_Block_Date": "2026-09-02T00:00:00"
+                    },
+                    {
+                      "item_Type": "Condiment",
+                      "item_Name": "Mayo Packet",
+                      "block_Name": "Lunch",
+                      "menu_Block_Date": "2026-09-02T00:00:00"
+                    },
+                    {
+                      "item_Type": "Condiment",
+                      "item_Name": "Ranch, Packet",
+                      "block_Name": "Lunch",
+                      "menu_Block_Date": "2026-09-02T00:00:00"
+                    },
+                    {
+                      "item_Type": "Daily Vegetable",
+                      "item_Name": "Black Beans",
+                      "block_Name": "Lunch",
+                      "menu_Block_Date": "2026-09-02T00:00:00"
+                    },
+                    {
+                      "item_Type": "Entree",
+                      "item_Name": "Chicken Teriyaki w/ Steamed Rice",
+                      "block_Name": "Lunch",
+                      "menu_Block_Date": "2026-09-02T00:00:00"
+                    },
+                    {
+                      "item_Type": "Grab & Go",
+                      "item_Name": "Turkey and Cheese Wrap",
+                      "block_Name": "Lunch",
+                      "menu_Block_Date": "2026-09-02T00:00:00"
+                    },
+                    {
+                      "item_Type": "Sides",
+                      "item_Name": "Pears",
+                      "block_Name": "Lunch",
+                      "menu_Block_Date": "2026-09-02T00:00:00"
+                    }
+                  ]
+                }
+              }
+            ]
+          },
+          "blackedOut": false,
+          "noScheduleText": null,
+          "orderId": 2
+        }
+      ]
+    },
+    {
+      "dateInformation": {
+        "dateKey": 20260904,
+        "dateFull": "2026-09-04T00:00:00",
+        "weekNumber": 36,
+        "weekDayName": "Friday",
+        "monthDay": 4,
+        "monthName": "September",
+        "weekDay": 6,
+        "monthNumber": 9
+      },
+      "menuBlocks": [
+        {
+          "id": 3577,
+          "object": "menuBlock",
+          "blockName": "Breakfast",
+          "scheduledDate": "2026-09-04T00:00:00",
+          "cafeteriaLineList": {
+            "object": "list",
+            "url": "",
+            "hasMore": false,
+            "data": [
+              {
+                "name": "",
+                "id": 0,
+                "object": "cafeteriaLine",
+                "foodItemList": {
+                  "object": "list",
+                  "url": "",
+                  "hasMore": false,
+                  "data": [
+                    {
+                      "item_Type": "Breakfast",
+                      "item_Name": "Pancake",
+                      "block_Name": "Breakfast",
+                      "menu_Block_Date": "2026-09-04T00:00:00"
+                    },
+                    {
+                      "item_Type": "Breakfast",
+                      "item_Name": "Cereal, Cinnamon Chex",
+                      "block_Name": "Breakfast",
+                      "menu_Block_Date": "2026-09-04T00:00:00"
+                    },
+                    {
+                      "item_Type": "Breakfast",
+                      "item_Name": "Cereal, Cinnamon Toast Crunch",
+                      "block_Name": "Breakfast",
+                      "menu_Block_Date": "2026-09-04T00:00:00"
+                    },
+                    {
+                      "item_Type": "Breakfast",
+                      "item_Name": "Cereal, Honey Cheerios",
+                      "block_Name": "Breakfast",
+                      "menu_Block_Date": "2026-09-04T00:00:00"
+                    },
+                    {
+                      "item_Type": "Condiment",
+                      "item_Name": "Maple Syrup",
+                      "block_Name": "Breakfast",
+                      "menu_Block_Date": "2026-09-04T00:00:00"
+                    },
+                    {
+                      "item_Type": "Daily Fruit",
+                      "item_Name": "Fruit Cup, Fresh Fruit",
+                      "block_Name": "Breakfast",
+                      "menu_Block_Date": "2026-09-04T00:00:00"
+                    }
+                  ]
+                }
+              }
+            ]
+          },
+          "blackedOut": false,
+          "noScheduleText": null,
+          "orderId": 1
+        },
+        {
+          "id": 3578,
+          "object": "menuBlock",
+          "blockName": "Lunch",
+          "scheduledDate": "2026-09-04T00:00:00",
+          "cafeteriaLineList": {
+            "object": "list",
+            "url": "",
+            "hasMore": false,
+            "data": [
+              {
+                "name": "",
+                "id": 0,
+                "object": "cafeteriaLine",
+                "foodItemList": {
+                  "object": "list",
+                  "url": "",
+                  "hasMore": false,
+                  "data": [
+                    {
+                      "item_Type": "Daily Fruit",
+                      "item_Name": "Fruit Cup, Fresh Fruit",
+                      "block_Name": "Lunch",
+                      "menu_Block_Date": "2026-09-04T00:00:00"
+                    },
+                    {
+                      "item_Type": "Daily Vegetable",
+                      "item_Name": "Cucumbers",
+                      "block_Name": "Lunch",
+                      "menu_Block_Date": "2026-09-04T00:00:00"
+                    },
+                    {
+                      "item_Type": "Grab & Go",
+                      "item_Name": "Wowbutter Lunchbox",
+                      "block_Name": "Lunch",
+                      "menu_Block_Date": "2026-09-04T00:00:00"
+                    },
+                    {
+                      "item_Type": "Pre-Pack",
+                      "item_Name": "Honey BBQ Wings w/ Mac and Cheese",
+                      "block_Name": "Lunch",
+                      "menu_Block_Date": "2026-09-04T00:00:00"
+                    }
+                  ]
+                }
+              }
+            ]
+          },
+          "blackedOut": false,
+          "noScheduleText": null,
+          "orderId": 2
+        }
+      ]
+    }
+  ]
+}
diff --git a/test/menu_feed_test.rb b/test/menu_feed_test.rb
new file mode 100644
index 0000000..5e353be
--- /dev/null
+++ b/test/menu_feed_test.rb
@@ -0,0 +1,60 @@
+require "minitest/autorun"
+require_relative "../lib/menu_feed"
+
+class MenuFeedTest < Minitest::Test
+  def setup
+    @feed = MenuFeed.new(JSON.parse((Pathname(__dir__) / "fixtures/menu.json").read))
+  end
+
+  def test_skips_days_without_menus
+    assert_equal [Date.new(2026, 9, 2), Date.new(2026, 9, 4)], @feed.meals.map(&:date).uniq
+  end
+
+  def test_summary_lists_primary_items
+    lunch = meal(Date.new(2026, 9, 2), "Lunch")
+
+    assert_equal "Chicken Teriyaki w/ Steamed Rice / Turkey and Cheese Wrap", lunch.summary
+  end
+
+  def test_description_omits_condiments
+    lunch = meal(Date.new(2026, 9, 2), "Lunch")
+
+    refute_includes lunch.description, "Condiment"
+    assert_includes lunch.description, "Daily Vegetable: Black Beans"
+  end
+
+  def test_summary_falls_back_to_block_name
+    meal = MenuFeed::Meal.new(date: Date.new(2026, 9, 2), block_id: 1, block_name: "Lunch", items: [])
+
+    assert_equal "Lunch", meal.summary
+  end
+
+  def test_skips_blacked_out_blocks
+    menu = JSON.parse((Pathname(__dir__) / "fixtures/menu.json").read)
+    menu.fetch("menuSchedules").flat_map { it.fetch("menuBlocks") }.each { it["blackedOut"] = true }
+
+    assert_empty MenuFeed.new(menu).meals
+  end
+
+  def test_to_ical_emits_all_day_events_for_one_block
+    calendar = Icalendar::Calendar.parse(@feed.to_ical("Lunch")).fetch(0)
+    event = calendar.events.find { it.dtstart == Date.new(2026, 9, 2) }
+
+    assert_equal "McDonald lunch", calendar.x_wr_calname.first
+    assert_equal 2, calendar.events.size
+    assert_equal Date.new(2026, 9, 3), event.dtend.value
+    assert_equal "2026-09-02-3578@mealviewer.McDonald", event.uid
+  end
+
+  def test_to_ical_without_meals_is_an_empty_calendar
+    calendar = Icalendar::Calendar.parse(@feed.to_ical("Dinner")).fetch(0)
+
+    assert_empty calendar.events
+  end
+
+  private
+
+  def meal(date, block_name)
+    @feed.meals.find { it.date == date && it.block_name == block_name }
+  end
+end