Publish MealViewer menus as calendar feeds on Pages
Assisted-by: Claude Opus 5 via Claude Code
Claude-Session: https://claude.ai/code/session_01GLD6zqefK1dcdkMiaB7HUH
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