From 5882d0c138ac32af551e915324626b97c59f8ed4 Mon Sep 17 00:00:00 2001 From: Jamie Date: Tue, 15 Sep 2026 13:45:50 -0400 Subject: [PATCH] Add emergency and affordable food browsing --- CHANGELOG.md | 17 + README.md | 6 +- deployments/kingston/REVIEW.md | 41 +- deployments/kingston/resources.json | 1421 ++++++++++++++++++- deployments/kingston/site.json | 5 +- docs/architecture.md | 8 +- docs/data.md | 10 +- docs/maintenance.md | 6 +- examples/exampleville/resources.json | 12 +- scripts/build.ts | 48 +- scripts/check.ts | 5 + scripts/lib/selection.ts | 7 +- scripts/lib/web.ts | 70 +- scripts/serve.ts | 8 +- scripts/verify-release.mjs | 77 +- src/analytics.ts | 2 +- src/copy/en.ts | 16 +- src/data-loader.ts | 10 +- src/domain.ts | 17 + src/main.ts | 83 +- src/presentation.ts | 18 +- src/simple.css | 2 +- src/styles.css | 57 +- src/types.ts | 2 +- src/validation.ts | 10 +- tests/contracts/output.test.ts | 25 +- tests/e2e/browsing.spec.ts | 31 +- tests/e2e/capabilities.spec.ts | 18 +- tests/e2e/layout.spec.ts | 26 +- tests/e2e/offline.spec.ts | 28 +- tests/e2e/review-preview.spec.ts | 47 + tests/e2e/selected-offline.spec.ts | 26 +- tests/fixtures/kingston-like/resources.json | 12 +- tests/unit/domain.test.ts | 26 +- tests/unit/kingston-lionhearts.test.ts | 78 + tests/unit/presentation.test.ts | 22 + tests/unit/selection.test.ts | 2 + tests/unit/validation.test.ts | 51 + 38 files changed, 2186 insertions(+), 164 deletions(-) create mode 100644 CHANGELOG.md create mode 100644 tests/e2e/review-preview.spec.ts create mode 100644 tests/unit/kingston-lionhearts.test.ts create mode 100644 tests/unit/validation.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..61f3f41 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,17 @@ +# Changelog + +Notable user-visible changes to Food Help are recorded here. Community publication still requires an explicit reviewed release of that community and source revision. + +## Unreleased + +### Added + +- Top-level Emergency food and Affordable food browsing, with Emergency as the default and a stable `/affordable-food/` route. +- Location-specific resource cards that can share one provider identity without merging venue schedules. +- Isolated local review builds for visibly labelled draft records. + +### Changed + +- Published resources require reviewed, evidence-backed cost information. Free services appear in Emergency food, low-cost and subsidized services appear in Affordable food, and mixed-cost services appear in both without duplicate records. +- Static, printable, downloaded, offline and public-data surfaces continue to preserve the complete published directory. +- Kingston’s eight Lionhearts Fresh Food Market location records were approved by Jamie Whelan for publication on 2026-09-15, with their recorded uncertainties and accessibility qualifications unchanged. diff --git a/README.md b/README.md index 76f40e8..e9e6b45 100644 --- a/README.md +++ b/README.md @@ -38,11 +38,13 @@ npm run build -- --site deployments/kingston --production --source-revision ` means **structurally safe and internally consistent enough to be reviewed for publication**. It builds the selected deployment and independent fixtures, validates output relationships, and exercises supported browsers. `npm run check -- --all` discovers community folders for validation only. Neither command certifies provider facts, rights or live availability. Missing required test engines are failures, not silent substitutions. ## Generated surfaces -- A static directory with local search, category buttons and optional in-memory distance sorting. +- Emergency food at `/` and directly linkable Affordable food at `/affordable-food/`, with local search, category buttons and optional in-memory distance sorting within either view. - Useful `/resources/{stable-id}/` HTML with source evidence and review dates. - `/directory/`, a printable no-JavaScript directory, and a self-contained HTML download. - `/data/v1/resources.json`, filtered from the selected authoritative dataset. @@ -65,7 +67,7 @@ Published schedules are never a live availability feed. Location is optional and | `.github/workflows/` | Repeatable validation, separate from explicit publication | | `.generated/`, `artifacts/`, `dist//` | Disposable generated output; never hand-maintained authority | -Read [maintenance](docs/maintenance.md) for adding listings, communities and shared upgrades; [architecture](docs/architecture.md) for generation/offline behaviour; [interoperability](docs/interoperability.md) for the preserved v1/HSDS mapping; and [verification](docs/verification.md) for automated versus physical-device evidence. +Read [maintenance](docs/maintenance.md) for adding listings, communities and shared upgrades; [architecture](docs/architecture.md) for generation/offline behaviour; [interoperability](docs/interoperability.md) for the preserved v1/HSDS mapping; [verification](docs/verification.md) for automated versus physical-device evidence; and the [changelog](CHANGELOG.md) for unreleased user-visible changes. Software and supporting documentation use [MPL-2.0](LICENSE). The font retains [OFL-1.1](src/assets/fonts/OFL.txt), fictional datasets retain CC0-1.0, and real Kingston data has no asserted blanket open-data licence. [NOTICE](NOTICE.md) and [licensing](docs/licensing.md) explain coverage, attribution and source availability. Self-hosting and commercial software use do not require buying TGC services. diff --git a/deployments/kingston/REVIEW.md b/deployments/kingston/REVIEW.md index 1e70aff..9cd2564 100644 --- a/deployments/kingston/REVIEW.md +++ b/deployments/kingston/REVIEW.md @@ -4,19 +4,56 @@ This is an operator checklist, not a replacement dataset. Authoritative provider **Review owner:** Jamie Whelan, for True Good Craft. The public correction address is the address in `site.json`. Review sources sooner when a correction, closure or changed schedule is reported. Automation can flag a due review; it cannot approve a provider fact. -All eight current listings retain their existing **2026-09-04** verification date. The configured review interval is **30 days**, so the next review target is **2026-10-04**. This calculation is a maintenance deadline, not a claim that the listings were rechecked during software consolidation. After an actual review, update the relevant evidence and resource dates and this checklist's derived deadline together; do not advance verification dates for formatting, schema migration or deployment alone. +Seven existing listings retain their **2026-09-04** verification date. Their next 30-day review target is **2026-10-04**. Brit Smith Social Market was newly checked against the current City food-support page on **2026-09-15** for the explicit no-fee classification only, so its next target is **2026-10-15**. These dates are maintenance deadlines, not claims of live availability. After an actual review, update the relevant evidence and resource dates and this checklist's derived deadline together; do not advance verification dates for formatting, schema migration or deployment alone. + +The browsing classification is cost-derived: `free` appears under **Emergency food**; `low_cost` or `subsidized` appears under **Affordable food**; `mixed` appears in both; missing or `unknown` cost remains unresolved. All eight previously published Kingston resources now have a source-supported `free` cost. No record was duplicated to create a browsing view. | Resource ID | Current review state | Next review by | | --- | --- | --- | | `marthas-table` | Confirmed | 2026-10-04 | | `loretta-meal-program` | Confirmed | 2026-10-04 | -| `brit-smith-social-market` | Confirmed | 2026-10-04 | +| `brit-smith-social-market` | Confirmed; cost source newly checked 2026-09-15 | 2026-10-15 | | `partners-in-mission` | Confirmed | 2026-10-04 | | `st-marys-drop-in` | Confirmed | 2026-10-04 | | `lunch-by-george` | Partially confirmed | 2026-10-04 | | `salvation-army-pantry` | Partially confirmed | 2026-10-04 | | `storehouse-of-hope` | Confirmed | 2026-10-04 | +## Lionhearts publication review + +One shared `lionhearts` organization and eight distinct physical market records were prepared on **2026-09-15**. Jamie Whelan reviewed and approved the browsing structure, classification, listings, wording, evidence and existing qualifications for publication on **2026-09-15**. The records are `published` with verification state `partially_confirmed`: publication approval does not convert the documented unknown or conflicting details into confirmed facts. The affordable-food wording is deliberately consistent across the cards: + +- Eligibility: “Open to everyone — no income requirement.” +- Cost: “Affordable food, with prices kept as low as possible.” +- Payment: “All payment methods accepted, including debit, credit and mobile payments.” + +| Published resource ID | Venue and current published schedule | Ongoing review focus | +| --- | --- | --- | +| `lionhearts-market-seniors-association` | Seniors Association Kingston Region, 56 Francis Street — Tuesdays 10 a.m.–1 p.m. | Venue, address, time, accessibility wording and public links | +| `lionhearts-market-ymca-wright-crescent` | YMCA Wright Crescent, 100 Wright Crescent — Fridays 10 a.m.–1 p.m.; Tuesdays 4:30–6:30 p.m. starting September 22 | Confirm transition wording; Monday September 14 was the final published Monday session | +| `lionhearts-market-kingston-east` | Kingston East Community Centre, 779 Highway 15 — Wednesdays 10–11:30 a.m. | Venue, address, time and facility accessibility wording | +| `lionhearts-market-rideau-heights` | Rideau Heights Community Centre, 85 MacCauley Street — Wednesdays 1:30–4:30 p.m. | Venue spelling, address, time and facility accessibility wording | +| `lionhearts-market-st-lawrence-college` | Main lobby, 100 Portsmouth Avenue — September 10/24, October 8/22, November 5/19 and December 3, 10 a.m.–noon | All seven dates, year, main-lobby directions and public eligibility | +| `lionhearts-market-isabel-turner` | Isabel Turner Branch, 935 Gardiners Road — Thursdays 2:30–5:30 p.m. | Venue, address, time and library accessibility wording | +| `lionhearts-market-wj-henderson` | Outside W.J. Henderson Community Centre’s main entrance, 322 Amherst Drive — September 4/18, October 2/16/30, November 13/27 and December 11, 4–6 p.m. | All eight dates, year, outdoor position and access needs | +| `lionhearts-market-artillery-park` | Artillery Park Aquatic Centre, 382 Bagot Street — Saturdays 10 a.m.–1 p.m.; September 19 cancelled, returning September 26 | Cancellation/return, dated notice expiry and conflicting washroom information | + +Jamie’s September 15 provider meeting is recorded only as a public-safe direct-confirmation summary: information was relayed by Jamie and was not an interview conducted by the implementation agent. It supports public availability, affordability, payment methods, food range, and Lionhearts’ qualified accessibility/dignity approach. Private correspondence, meeting notes and personal contact details remain outside the repository. + +Online evidence was checked on **2026-09-15**. The current Lionhearts schedule page is undated; dated official market posts establish 2026 for the YMCA change and the St. Lawrence College and W.J. Henderson date lists. The market’s official Instagram account is used as the update/contact link, and a published market post supplies `info@freshfoodmkt.ca`. No general cancellation policy was found. + +Source qualifications and conflicts to keep visible during review: + +- The City food-support page has older Lionhearts venues and hours, including different Rideau Heights, YMCA and Artillery times. It tells visitors that times vary and links to Lionhearts, so the current provider page and dated market posts control. +- The 211 Lionhearts agency roster contains older locations such as CANEX, Queen’s, BGC and Amherstview Community Hall. They were not carried into the current published set. +- Artillery Park accessibility sources conflict: the City lists accessible change-room features; 211 lists accessible parking and entrance but a non-accessible washroom. The listing reports the conflict and asks visitors to confirm the specific feature needed. +- W.J. Henderson was advertised outside the main entrance. The outdoor market setup’s specific accessibility features are not published. The exact within-venue market position is also unpublished for the other sites except St. Lawrence College. +- Market-specific walk-in, appointment, registration and identification details were not found for YMCA Wright Crescent, Kingston East, Rideau Heights, Isabel Turner or W.J. Henderson. Those access flags remain explicitly unknown; this does not alter the meeting-confirmed eligibility wording that the markets are open to everyone with no income requirement. +- An August 24 official update named Ontario peaches, corn, strawberries and blackberries for that week only. The cards do not claim that all products are Canadian, local or Ontario-grown. +- A past August 23 Portsmouth District Community BBQ pop-up at 56 Francis Street was found, but it was not turned into a current recurring location or future date. +- The additional program names referenced after the meeting were not available in a review packet or other recorded source, so no names or records were invented. +- Reviewed coordinates were not found in the selected official sources and are intentionally absent rather than guessed. + Outstanding source questions remain open: - **Lunch by George:** changed lunch hours conflict across published sources; the complete dated change notice was not inspected in the recorded review. Preserve the warning and unknown schedule until serving times are confirmed. diff --git a/deployments/kingston/resources.json b/deployments/kingston/resources.json index 07a2e4c..d6a3e0c 100644 --- a/deployments/kingston/resources.json +++ b/deployments/kingston/resources.json @@ -32,6 +32,12 @@ { "id": "kingston-alliance-church", "name": "Kingston Alliance Church" + }, + { + "id": "lionhearts", + "name": "Lionhearts", + "description": "Operator of Fresh Food Market Pop-Ups in Kingston and area.", + "url": "https://www.lionhearts.ca/kingston/our-programs/fresh-food-market-pop-ups/" } ], "resources": [ @@ -81,6 +87,10 @@ "Takeout is at the rear of the building.", "For meal delivery, call 343-580-1519 to ask about arrangements." ], + "cost": { + "state": "free", + "description": "Free meals." + }, "schedule": { "kind": "weekly", "confirmed": true, @@ -295,6 +305,7 @@ "name", "location", "phone", + "cost", "access.walk_in", "access.appointment_required", "access.registration_required", @@ -358,6 +369,10 @@ "Use the new location at 595 Bagot Street.", "Call to check holiday service." ], + "cost": { + "state": "free", + "description": "Free meal." + }, "schedule": { "kind": "weekly", "confirmed": true, @@ -444,6 +459,7 @@ "eligibility", "limitations", "before_you_go", + "cost", "schedule", "access.walk_in", "access.appointment_required", @@ -521,6 +537,10 @@ "Ask about registration before making a trip.", "Call to check holiday service." ], + "cost": { + "state": "free", + "description": "Free groceries." + }, "schedule": { "kind": "weekly", "confirmed": true, @@ -618,23 +638,24 @@ "id": "source-2", "source_kind": "local_authority", "url": "https://www.cityofkingston.ca/community-supports/community-supports-and-well-being/food-supports/", - "checked_on": "2026-09-04", - "note": "Confirms current Bagot Street location and public telephone.", + "checked_on": "2026-09-15", + "note": "Current City food-support page confirms the Bagot Street location and public telephone and places St. Vincent de Paul under free food assistance programs.", "supports": [ "name", "location", - "phone" + "phone", + "cost" ] } ], "verification": { - "reviewed_on": "2026-09-04", + "reviewed_on": "2026-09-15", "method": "multiple_sources", "state": "confirmed" }, "publication_status": "published", "service_condition": "active", - "updated_on": "2026-09-04" + "updated_on": "2026-09-15" }, { "id": "partners-in-mission", @@ -685,6 +706,10 @@ "Book by phone, Monday–Friday 8:30–noon or 1–4:30.", "The displayed food hours are shopping appointments, not walk-in or phone hours." ], + "cost": { + "state": "free", + "description": "Free groceries." + }, "schedule": { "kind": "weekly", "confirmed": true, @@ -819,6 +844,7 @@ "eligibility", "limitations", "before_you_go", + "cost", "schedule", "access.walk_in", "access.appointment_required", @@ -895,6 +921,10 @@ "Use the Parish Centre at 260 Brock Street.", "Call extension 3 for the drop-in centre and holiday arrangements." ], + "cost": { + "state": "free", + "description": "Free light meals and snacks." + }, "schedule": { "kind": "weekly", "confirmed": true, @@ -994,6 +1024,7 @@ "name", "location", "phone", + "cost", "access.walk_in", "access.appointment_required", "access.registration_required", @@ -1057,6 +1088,10 @@ "Use the Wellington Street entrance.", "Provider lists weekday breakfast 9–10:30 a.m. and lunch noon–1 p.m.; older listings show lunch 11:30–12:30." ], + "cost": { + "state": "free", + "description": "Free breakfast and lunch." + }, "schedule": { "kind": "unknown", "confirmed": false, @@ -1082,6 +1117,7 @@ "eligibility", "limitations", "before_you_go", + "cost", "schedule" ] }, @@ -1177,6 +1213,10 @@ "before_you_go": [ "Call before going; office opening hours do not establish food availability." ], + "cost": { + "state": "free", + "description": "Free emergency food assistance." + }, "schedule": { "kind": "unknown", "confirmed": false, @@ -1213,11 +1253,12 @@ "source_kind": "local_authority", "url": "https://www.cityofkingston.ca/community-supports/community-supports-and-well-being/food-supports/", "checked_on": "2026-09-04", - "note": "Corroborates food assistance at 342 Patrick Street and public phone.", + "note": "Corroborates free food assistance at 342 Patrick Street and the public phone.", "supports": [ "name", "location", - "phone" + "phone", + "cost" ] } ], @@ -1287,6 +1328,10 @@ "First fall session: Monday, September 14, 2026.", "Coffee and snacks 10:30–11:30 a.m.; lunch 11:30 a.m.–1 p.m." ], + "cost": { + "state": "free", + "description": "Free Monday lunch, coffee and snacks." + }, "schedule": { "kind": "weekly", "confirmed": true, @@ -1386,7 +1431,8 @@ "checked_on": "2026-09-04", "note": "Free food outreach; holiday Mondays excluded; summer service changes.", "supports": [ - "name" + "name", + "cost" ] }, { @@ -1424,6 +1470,1365 @@ "publication_status": "published", "service_condition": "active", "updated_on": "2026-09-04" + }, + { + "id": "lionhearts-market-seniors-association", + "organization_id": "lionhearts", + "name": "Lionhearts Fresh Food Market — Seniors Association Kingston Region", + "summary": "Affordable market open to everyone, with fruit and vegetables plus some prepared and frozen foods.", + "categories": [ + "community_markets", + "groceries", + "prepared_meals" + ], + "food_access_purpose": "Affordable food purchases at a neighbourhood market open to everyone.", + "location": { + "description": "Seniors Association Kingston Region, 56 Francis Street, Kingston, ON", + "address": { + "street": "56 Francis Street", + "locality": "Kingston", + "region": "ON", + "country": "CA" + } + }, + "phone": "+16137660664", + "official_url": "https://www.instagram.com/freshfoodmktygk/", + "time_zone": "America/Toronto", + "access": { + "walk_in": "yes", + "appointment_required": "no", + "registration_required": "no", + "identification_required": "no", + "details": [ + "211 lists this market location as fully accessible.", + "Lionhearts aims to choose locations accessible to wheelchair users and people with other access needs, and includes dignity and respect in its approach." + ] + }, + "eligibility": [ + "Open to everyone — no income requirement." + ], + "limitations": [ + "Fruit and vegetables, some prepared foods, and frozen soups, stews, or meals may be offered; selection varies by market session.", + "An August 24, 2026 market update advertised Ontario peaches, corn, strawberries and blackberries for that week; not every product is confirmed Canadian or local." + ], + "before_you_go": [ + "All payment methods accepted, including debit, credit and mobile payments.", + "For schedule changes, use the Fresh Food Market account on Instagram. Public market email: info@freshfoodmkt.ca." + ], + "cost": { + "state": "low_cost", + "description": "Affordable food, with prices kept as low as possible." + }, + "schedule": { + "kind": "weekly", + "confirmed": true, + "note": "Tuesdays, 10 a.m.–1 p.m. This is a published schedule, not live availability.", + "weekly": [ + { "day": 1, "state": "closed", "intervals": [] }, + { "day": 2, "state": "published", "intervals": [{ "opens": "10:00", "closes": "13:00" }] }, + { "day": 3, "state": "closed", "intervals": [] }, + { "day": 4, "state": "closed", "intervals": [] }, + { "day": 5, "state": "closed", "intervals": [] }, + { "day": 6, "state": "closed", "intervals": [] }, + { "day": 7, "state": "closed", "intervals": [] } + ], + "exceptions": [], + "evidence_ids": [ + "lionhearts-schedule" + ] + }, + "evidence": [ + { + "id": "lionhearts-schedule", + "source_kind": "provider", + "url": "https://www.lionhearts.ca/kingston/our-programs/fresh-food-market-pop-ups/", + "checked_on": "2026-09-15", + "note": "Undated live Lionhearts page checked September 15, 2026 lists the venue, address and Tuesday 10 a.m.–1 p.m. schedule; it also describes seasonal produce, prepared options and low prices. The page does not publish a general cancellation policy.", + "supports": [ + "name", + "summary", + "categories", + "food_access_purpose", + "location", + "phone", + "limitations", + "cost", + "schedule" + ] + }, + { + "id": "jamie-provider-relay", + "source_kind": "direct_confirmation", + "checked_on": "2026-09-15", + "note": "Provider information relayed by Jamie Whelan from his September 15, 2026 meeting and subsequent clarification: markets are publicly available and open to everyone with no income requirement; prices are kept as low as possible; debit, credit and mobile payments are accepted; offerings include fruit and vegetables, some prepared foods and frozen soups, stews or meals; Lionhearts aims to choose accessible locations and treats dignity and respect as part of its approach.", + "supports": [ + "summary", + "food_access_purpose", + "eligibility", + "limitations", + "before_you_go", + "cost", + "access.details" + ] + }, + { + "id": "market-account", + "source_kind": "provider", + "url": "https://www.instagram.com/freshfoodmktygk/", + "checked_on": "2026-09-15", + "note": "The official Fresh Food Market account identifies the market as a Lionhearts program and links to lionhearts.ca/popup; this is the primary public stream for schedule changes.", + "supports": [ + "official_url", + "before_you_go" + ] + }, + { + "id": "market-contact-post", + "source_kind": "provider", + "url": "https://www.instagram.com/freshfoodmktygk/p/DcUG0avjtGk/", + "checked_on": "2026-09-15", + "note": "Official market post published August 21, 2026 publishes info@freshfoodmkt.ca as the market email.", + "supports": [ + "before_you_go" + ] + }, + { + "id": "ontario-produce-post", + "source_kind": "provider", + "url": "https://www.instagram.com/freshfoodmktygk/p/DcbbyP5FspW/", + "checked_on": "2026-09-15", + "note": "Official market post published August 24, 2026 advertises Ontario peaches, corn, strawberries and blackberries for that week; it does not establish that all food is Canadian, local or Ontario-grown.", + "supports": [ + "limitations" + ] + }, + { + "id": "seniors-211", + "source_kind": "referral_directory", + "url": "https://211ontario.ca/service/96539444/lionhearts-fresh-food-market-pop-up/", + "checked_on": "2026-09-15", + "note": "Listing updated January 19, 2026 corroborates the address, Tuesday time, drop-in access, no documents, public contact and fully accessible designation.", + "supports": [ + "location", + "before_you_go", + "access.walk_in", + "access.appointment_required", + "access.registration_required", + "access.identification_required", + "access.details" + ] + } + ], + "verification": { + "reviewed_on": "2026-09-15", + "method": "multiple_sources", + "state": "partially_confirmed" + }, + "publication_status": "published", + "service_condition": "active", + "updated_on": "2026-09-15" + }, + { + "id": "lionhearts-market-ymca-wright-crescent", + "organization_id": "lionhearts", + "name": "Lionhearts Fresh Food Market — YMCA Wright Crescent", + "summary": "Affordable market open to everyone, with fruit and vegetables plus some prepared and frozen foods.", + "categories": [ + "community_markets", + "groceries", + "prepared_meals" + ], + "food_access_purpose": "Affordable food purchases at a neighbourhood market open to everyone.", + "location": { + "description": "YMCA Wright Crescent, 100 Wright Crescent, Kingston, ON", + "address": { + "street": "100 Wright Crescent", + "locality": "Kingston", + "region": "ON", + "country": "CA" + } + }, + "phone": "+16137660664", + "official_url": "https://www.instagram.com/freshfoodmktygk/", + "time_zone": "America/Toronto", + "access": { + "walk_in": "unknown", + "appointment_required": "unknown", + "registration_required": "unknown", + "identification_required": "unknown", + "details": [ + "The YMCA lists elevator access to second-floor programming areas and free parking; the market’s exact position inside the building is not published.", + "Lionhearts aims to choose locations accessible to wheelchair users and people with other access needs, and includes dignity and respect in its approach." + ] + }, + "eligibility": [ + "Open to everyone — no income requirement." + ], + "limitations": [ + "Fruit and vegetables, some prepared foods, and frozen soups, stews, or meals may be offered; selection varies by market session.", + "An August 24, 2026 market update advertised Ontario peaches, corn, strawberries and blackberries for that week; not every product is confirmed Canadian or local." + ], + "before_you_go": [ + "All payment methods accepted, including debit, credit and mobile payments.", + "For schedule changes, use the Fresh Food Market account on Instagram. Public market email: info@freshfoodmkt.ca." + ], + "cost": { + "state": "low_cost", + "description": "Affordable food, with prices kept as low as possible." + }, + "schedule": { + "kind": "weekly", + "confirmed": true, + "note": "Fridays, 10 a.m.–1 p.m. The evening market moved from Monday to Tuesday and starts Tuesday, September 22, at 4:30–6:30 p.m.", + "valid_from": "2026-09-15", + "weekly": [ + { "day": 1, "state": "closed", "intervals": [] }, + { "day": 2, "state": "published", "intervals": [{ "opens": "16:30", "closes": "18:30" }] }, + { "day": 3, "state": "closed", "intervals": [] }, + { "day": 4, "state": "closed", "intervals": [] }, + { "day": 5, "state": "published", "intervals": [{ "opens": "10:00", "closes": "13:00" }] }, + { "day": 6, "state": "closed", "intervals": [] }, + { "day": 7, "state": "closed", "intervals": [] } + ], + "exceptions": [ + { + "date": "2026-09-15", + "confirmed": true, + "intervals": [], + "note": "No Tuesday market; Tuesday evening sessions start September 22.", + "evidence_ids": [ + "ymca-change-post" + ] + } + ], + "evidence_ids": [ + "lionhearts-schedule", + "ymca-change-post" + ] + }, + "evidence": [ + { + "id": "lionhearts-schedule", + "source_kind": "provider", + "url": "https://www.lionhearts.ca/kingston/our-programs/fresh-food-market-pop-ups/", + "checked_on": "2026-09-15", + "note": "Undated live Lionhearts page checked September 15, 2026 lists 100 Wright Crescent, Friday 10 a.m.–1 p.m., Monday dates September 7 and 14, and Tuesday evenings starting September 22; it also describes seasonal produce, prepared options and low prices.", + "supports": [ + "name", + "summary", + "categories", + "food_access_purpose", + "location", + "phone", + "limitations", + "cost", + "schedule" + ] + }, + { + "id": "jamie-provider-relay", + "source_kind": "direct_confirmation", + "checked_on": "2026-09-15", + "note": "Provider information relayed by Jamie Whelan from his September 15, 2026 meeting and subsequent clarification: markets are publicly available and open to everyone with no income requirement; prices are kept as low as possible; debit, credit and mobile payments are accepted; offerings include fruit and vegetables, some prepared foods and frozen soups, stews or meals; Lionhearts aims to choose accessible locations and treats dignity and respect as part of its approach.", + "supports": [ + "summary", + "food_access_purpose", + "eligibility", + "limitations", + "before_you_go", + "cost", + "access.details" + ] + }, + { + "id": "market-account", + "source_kind": "provider", + "url": "https://www.instagram.com/freshfoodmktygk/", + "checked_on": "2026-09-15", + "note": "The official Fresh Food Market account identifies the market as a Lionhearts program and links to lionhearts.ca/popup; this is the primary public stream for schedule changes.", + "supports": [ + "official_url", + "before_you_go" + ] + }, + { + "id": "market-contact-post", + "source_kind": "provider", + "url": "https://www.instagram.com/freshfoodmktygk/p/DcUG0avjtGk/", + "checked_on": "2026-09-15", + "note": "Official market post published August 21, 2026 publishes info@freshfoodmkt.ca as the market email.", + "supports": [ + "before_you_go" + ] + }, + { + "id": "ontario-produce-post", + "source_kind": "provider", + "url": "https://www.instagram.com/freshfoodmktygk/p/DcbbyP5FspW/", + "checked_on": "2026-09-15", + "note": "Official market post published August 24, 2026 advertises Ontario peaches, corn, strawberries and blackberries for that week; it does not establish that all food is Canadian, local or Ontario-grown.", + "supports": [ + "limitations" + ] + }, + { + "id": "ymca-change-post", + "source_kind": "provider", + "url": "https://www.instagram.com/freshfoodmktygk/p/DdRxVVbo-wc/", + "checked_on": "2026-09-15", + "note": "Official update published September 14, 2026 says the September 14 Monday market will run and the evening market moves to Tuesdays, 4:30–6:30 p.m., starting September 22.", + "supports": [ + "schedule", + "before_you_go", + "notices" + ] + }, + { + "id": "ymca-venue", + "source_kind": "other_reviewed", + "url": "https://eo.ymca.ca/who-we-are", + "checked_on": "2026-09-15", + "note": "Undated live YMCA page checked September 15, 2026 confirms the 100 Wright Crescent address, elevator access to second-floor programming areas and free parking; it does not identify the market’s position inside the building.", + "supports": [ + "location", + "access.details" + ] + } + ], + "verification": { + "reviewed_on": "2026-09-15", + "method": "multiple_sources", + "state": "partially_confirmed" + }, + "publication_status": "published", + "service_condition": "active", + "updated_on": "2026-09-15", + "notices": [ + { + "id": "evening-market-moves-to-tuesday", + "text": "The YMCA evening market moved from Monday to Tuesday, 4:30–6:30 p.m., starting September 22, 2026. Friday market hours remain 10 a.m.–1 p.m.", + "starts_on": "2026-09-14", + "evidence_ids": [ + "ymca-change-post" + ] + } + ] + }, + { + "id": "lionhearts-market-kingston-east", + "organization_id": "lionhearts", + "name": "Lionhearts Fresh Food Market — Kingston East Community Centre", + "summary": "Affordable market open to everyone, with fruit and vegetables plus some prepared and frozen foods.", + "categories": [ + "community_markets", + "groceries", + "prepared_meals" + ], + "food_access_purpose": "Affordable food purchases at a neighbourhood market open to everyone.", + "location": { + "description": "Kingston East Community Centre, 779 Highway 15, Kingston, ON", + "address": { + "street": "779 Highway 15", + "locality": "Kingston", + "region": "ON", + "country": "CA" + } + }, + "phone": "+16137660664", + "official_url": "https://www.instagram.com/freshfoodmktygk/", + "time_zone": "America/Toronto", + "access": { + "walk_in": "unknown", + "appointment_required": "unknown", + "registration_required": "unknown", + "identification_required": "unknown", + "details": [ + "The City lists Kingston East Community Centre as a fully accessible facility with Rick Hansen Foundation Certified Gold status.", + "Lionhearts aims to choose locations accessible to wheelchair users and people with other access needs, and includes dignity and respect in its approach." + ] + }, + "eligibility": [ + "Open to everyone — no income requirement." + ], + "limitations": [ + "Fruit and vegetables, some prepared foods, and frozen soups, stews, or meals may be offered; selection varies by market session.", + "An August 24, 2026 market update advertised Ontario peaches, corn, strawberries and blackberries for that week; not every product is confirmed Canadian or local." + ], + "before_you_go": [ + "All payment methods accepted, including debit, credit and mobile payments.", + "For schedule changes, use the Fresh Food Market account on Instagram. Public market email: info@freshfoodmkt.ca." + ], + "cost": { + "state": "low_cost", + "description": "Affordable food, with prices kept as low as possible." + }, + "schedule": { + "kind": "weekly", + "confirmed": true, + "note": "Wednesdays, 10–11:30 a.m. This is a published schedule, not live availability.", + "weekly": [ + { "day": 1, "state": "closed", "intervals": [] }, + { "day": 2, "state": "closed", "intervals": [] }, + { "day": 3, "state": "published", "intervals": [{ "opens": "10:00", "closes": "11:30" }] }, + { "day": 4, "state": "closed", "intervals": [] }, + { "day": 5, "state": "closed", "intervals": [] }, + { "day": 6, "state": "closed", "intervals": [] }, + { "day": 7, "state": "closed", "intervals": [] } + ], + "exceptions": [], + "evidence_ids": [ + "lionhearts-schedule" + ] + }, + "evidence": [ + { + "id": "lionhearts-schedule", + "source_kind": "provider", + "url": "https://www.lionhearts.ca/kingston/our-programs/fresh-food-market-pop-ups/", + "checked_on": "2026-09-15", + "note": "Undated live Lionhearts page checked September 15, 2026 lists the venue, address and Wednesday 10–11:30 a.m. schedule; it also describes seasonal produce, prepared options and low prices.", + "supports": [ + "name", + "summary", + "categories", + "food_access_purpose", + "location", + "phone", + "limitations", + "cost", + "schedule" + ] + }, + { + "id": "jamie-provider-relay", + "source_kind": "direct_confirmation", + "checked_on": "2026-09-15", + "note": "Provider information relayed by Jamie Whelan from his September 15, 2026 meeting and subsequent clarification: markets are publicly available and open to everyone with no income requirement; prices are kept as low as possible; debit, credit and mobile payments are accepted; offerings include fruit and vegetables, some prepared foods and frozen soups, stews or meals; Lionhearts aims to choose accessible locations and treats dignity and respect as part of its approach.", + "supports": [ + "summary", + "food_access_purpose", + "eligibility", + "limitations", + "before_you_go", + "cost", + "access.details" + ] + }, + { + "id": "market-account", + "source_kind": "provider", + "url": "https://www.instagram.com/freshfoodmktygk/", + "checked_on": "2026-09-15", + "note": "The official Fresh Food Market account identifies the market as a Lionhearts program and links to lionhearts.ca/popup; this is the primary public stream for schedule changes.", + "supports": [ + "official_url", + "before_you_go" + ] + }, + { + "id": "market-contact-post", + "source_kind": "provider", + "url": "https://www.instagram.com/freshfoodmktygk/p/DcUG0avjtGk/", + "checked_on": "2026-09-15", + "note": "Official market post published August 21, 2026 publishes info@freshfoodmkt.ca as the market email.", + "supports": [ + "before_you_go" + ] + }, + { + "id": "ontario-produce-post", + "source_kind": "provider", + "url": "https://www.instagram.com/freshfoodmktygk/p/DcbbyP5FspW/", + "checked_on": "2026-09-15", + "note": "Official market post published August 24, 2026 advertises Ontario peaches, corn, strawberries and blackberries for that week; it does not establish that all food is Canadian, local or Ontario-grown.", + "supports": [ + "limitations" + ] + }, + { + "id": "city-community-centres", + "source_kind": "local_authority", + "url": "https://www.cityofkingston.ca/activities-and-recreation/facilities/community-centres/", + "checked_on": "2026-09-15", + "note": "Undated live City page checked September 15, 2026 confirms 779 Highway 15 and describes Kingston East Community Centre as fully accessible and Rick Hansen Foundation Certified Gold. General facility hours were not used as market hours.", + "supports": [ + "location", + "access.details" + ] + } + ], + "verification": { + "reviewed_on": "2026-09-15", + "method": "multiple_sources", + "state": "partially_confirmed" + }, + "publication_status": "published", + "service_condition": "active", + "updated_on": "2026-09-15" + }, + { + "id": "lionhearts-market-rideau-heights", + "organization_id": "lionhearts", + "name": "Lionhearts Fresh Food Market — Rideau Heights Community Centre", + "summary": "Affordable market open to everyone, with fruit and vegetables plus some prepared and frozen foods.", + "categories": [ + "community_markets", + "groceries", + "prepared_meals" + ], + "food_access_purpose": "Affordable food purchases at a neighbourhood market open to everyone.", + "location": { + "description": "Rideau Heights Community Centre, 85 MacCauley Street, Kingston, ON", + "address": { + "street": "85 MacCauley Street", + "locality": "Kingston", + "region": "ON", + "country": "CA" + } + }, + "phone": "+16137660664", + "official_url": "https://www.instagram.com/freshfoodmktygk/", + "time_zone": "America/Toronto", + "access": { + "walk_in": "unknown", + "appointment_required": "unknown", + "registration_required": "unknown", + "identification_required": "unknown", + "details": [ + "The City lists Rideau Heights Community Centre as a fully accessible facility with Rick Hansen Foundation Certified Gold status.", + "Lionhearts aims to choose locations accessible to wheelchair users and people with other access needs, and includes dignity and respect in its approach." + ] + }, + "eligibility": [ + "Open to everyone — no income requirement." + ], + "limitations": [ + "Fruit and vegetables, some prepared foods, and frozen soups, stews, or meals may be offered; selection varies by market session.", + "An August 24, 2026 market update advertised Ontario peaches, corn, strawberries and blackberries for that week; not every product is confirmed Canadian or local." + ], + "before_you_go": [ + "All payment methods accepted, including debit, credit and mobile payments.", + "For schedule changes, use the Fresh Food Market account on Instagram. Public market email: info@freshfoodmkt.ca." + ], + "cost": { + "state": "low_cost", + "description": "Affordable food, with prices kept as low as possible." + }, + "schedule": { + "kind": "weekly", + "confirmed": true, + "note": "Wednesdays, 1:30–4:30 p.m. This is a published schedule, not live availability.", + "weekly": [ + { "day": 1, "state": "closed", "intervals": [] }, + { "day": 2, "state": "closed", "intervals": [] }, + { "day": 3, "state": "published", "intervals": [{ "opens": "13:30", "closes": "16:30" }] }, + { "day": 4, "state": "closed", "intervals": [] }, + { "day": 5, "state": "closed", "intervals": [] }, + { "day": 6, "state": "closed", "intervals": [] }, + { "day": 7, "state": "closed", "intervals": [] } + ], + "exceptions": [], + "evidence_ids": [ + "lionhearts-schedule" + ] + }, + "evidence": [ + { + "id": "lionhearts-schedule", + "source_kind": "provider", + "url": "https://www.lionhearts.ca/kingston/our-programs/fresh-food-market-pop-ups/", + "checked_on": "2026-09-15", + "note": "Undated live Lionhearts page checked September 15, 2026 lists 85 Maccauley Street and Wednesday 1:30–4:30 p.m.; the venue-owned page uses the spelling MacCauley. Lionhearts also describes seasonal produce, prepared options and low prices.", + "supports": [ + "name", + "summary", + "categories", + "food_access_purpose", + "location", + "phone", + "limitations", + "cost", + "schedule" + ] + }, + { + "id": "jamie-provider-relay", + "source_kind": "direct_confirmation", + "checked_on": "2026-09-15", + "note": "Provider information relayed by Jamie Whelan from his September 15, 2026 meeting and subsequent clarification: markets are publicly available and open to everyone with no income requirement; prices are kept as low as possible; debit, credit and mobile payments are accepted; offerings include fruit and vegetables, some prepared foods and frozen soups, stews or meals; Lionhearts aims to choose accessible locations and treats dignity and respect as part of its approach.", + "supports": [ + "summary", + "food_access_purpose", + "eligibility", + "limitations", + "before_you_go", + "cost", + "access.details" + ] + }, + { + "id": "market-account", + "source_kind": "provider", + "url": "https://www.instagram.com/freshfoodmktygk/", + "checked_on": "2026-09-15", + "note": "The official Fresh Food Market account identifies the market as a Lionhearts program and links to lionhearts.ca/popup; this is the primary public stream for schedule changes.", + "supports": [ + "official_url", + "before_you_go" + ] + }, + { + "id": "market-contact-post", + "source_kind": "provider", + "url": "https://www.instagram.com/freshfoodmktygk/p/DcUG0avjtGk/", + "checked_on": "2026-09-15", + "note": "Official market post published August 21, 2026 publishes info@freshfoodmkt.ca as the market email.", + "supports": [ + "before_you_go" + ] + }, + { + "id": "ontario-produce-post", + "source_kind": "provider", + "url": "https://www.instagram.com/freshfoodmktygk/p/DcbbyP5FspW/", + "checked_on": "2026-09-15", + "note": "Official market post published August 24, 2026 advertises Ontario peaches, corn, strawberries and blackberries for that week; it does not establish that all food is Canadian, local or Ontario-grown.", + "supports": [ + "limitations" + ] + }, + { + "id": "city-community-centres", + "source_kind": "local_authority", + "url": "https://www.cityofkingston.ca/activities-and-recreation/facilities/community-centres/", + "checked_on": "2026-09-15", + "note": "Undated live City page checked September 15, 2026 confirms 85 MacCauley Street and describes Rideau Heights Community Centre as fully accessible and Rick Hansen Foundation Certified Gold. Its facility hours were not used as market hours.", + "supports": [ + "location", + "access.details" + ] + } + ], + "verification": { + "reviewed_on": "2026-09-15", + "method": "multiple_sources", + "state": "partially_confirmed" + }, + "publication_status": "published", + "service_condition": "active", + "updated_on": "2026-09-15" + }, + { + "id": "lionhearts-market-st-lawrence-college", + "organization_id": "lionhearts", + "name": "Lionhearts Fresh Food Market — St. Lawrence College", + "summary": "Affordable market open to everyone, with fruit and vegetables plus some prepared and frozen foods.", + "categories": [ + "community_markets", + "groceries", + "prepared_meals" + ], + "food_access_purpose": "Affordable food purchases at a neighbourhood market open to everyone.", + "location": { + "description": "St. Lawrence College Kingston campus, main lobby beside the information desk, 100 Portsmouth Avenue, Kingston, ON", + "address": { + "street": "100 Portsmouth Avenue", + "locality": "Kingston", + "region": "ON", + "country": "CA" + } + }, + "phone": "+16137660664", + "official_url": "https://www.instagram.com/freshfoodmktygk/", + "time_zone": "America/Toronto", + "access": { + "walk_in": "yes", + "appointment_required": "no", + "registration_required": "no", + "identification_required": "no", + "details": [ + "211 lists the market’s main-entrance lobby location as fully accessible.", + "Lionhearts aims to choose locations accessible to wheelchair users and people with other access needs, and includes dignity and respect in its approach." + ] + }, + "eligibility": [ + "Open to everyone — no income requirement." + ], + "limitations": [ + "Fruit and vegetables, some prepared foods, and frozen soups, stews, or meals may be offered; selection varies by market session.", + "An August 24, 2026 market update advertised Ontario peaches, corn, strawberries and blackberries for that week; not every product is confirmed Canadian or local." + ], + "before_you_go": [ + "All payment methods accepted, including debit, credit and mobile payments.", + "The market is in the main lobby beside the information desk.", + "For schedule changes, use the Fresh Food Market account on Instagram. Public market email: info@freshfoodmkt.ca." + ], + "cost": { + "state": "low_cost", + "description": "Affordable food, with prices kept as low as possible." + }, + "schedule": { + "kind": "dated", + "confirmed": true, + "note": "Published fall 2026 market dates, 10 a.m.–noon. This is not live availability.", + "valid_from": "2026-09-10", + "valid_to": "2026-12-03", + "weekly": [], + "exceptions": [ + { "date": "2026-09-10", "confirmed": true, "intervals": [{ "opens": "10:00", "closes": "12:00" }], "note": "Published market date.", "evidence_ids": ["lionhearts-schedule", "slc-launch-post"] }, + { "date": "2026-09-24", "confirmed": true, "intervals": [{ "opens": "10:00", "closes": "12:00" }], "note": "Published market date.", "evidence_ids": ["lionhearts-schedule"] }, + { "date": "2026-10-08", "confirmed": true, "intervals": [{ "opens": "10:00", "closes": "12:00" }], "note": "Published market date.", "evidence_ids": ["lionhearts-schedule"] }, + { "date": "2026-10-22", "confirmed": true, "intervals": [{ "opens": "10:00", "closes": "12:00" }], "note": "Published market date.", "evidence_ids": ["lionhearts-schedule"] }, + { "date": "2026-11-05", "confirmed": true, "intervals": [{ "opens": "10:00", "closes": "12:00" }], "note": "Published market date.", "evidence_ids": ["lionhearts-schedule"] }, + { "date": "2026-11-19", "confirmed": true, "intervals": [{ "opens": "10:00", "closes": "12:00" }], "note": "Published market date.", "evidence_ids": ["lionhearts-schedule"] }, + { "date": "2026-12-03", "confirmed": true, "intervals": [{ "opens": "10:00", "closes": "12:00" }], "note": "Published market date.", "evidence_ids": ["lionhearts-schedule"] } + ], + "evidence_ids": [ + "lionhearts-schedule", + "slc-launch-post" + ] + }, + "evidence": [ + { + "id": "lionhearts-schedule", + "source_kind": "provider", + "url": "https://www.lionhearts.ca/kingston/our-programs/fresh-food-market-pop-ups/", + "checked_on": "2026-09-15", + "note": "Undated live Lionhearts page checked September 15, 2026 lists the main lobby, 100 Portsmouth Avenue and seven Thursday dates from September 10 through December 3 at 10 a.m.–noon. The official launch post establishes the year as 2026.", + "supports": [ + "name", + "summary", + "categories", + "food_access_purpose", + "location", + "phone", + "limitations", + "cost", + "schedule" + ] + }, + { + "id": "jamie-provider-relay", + "source_kind": "direct_confirmation", + "checked_on": "2026-09-15", + "note": "Provider information relayed by Jamie Whelan from his September 15, 2026 meeting and subsequent clarification: markets are publicly available and open to everyone with no income requirement; prices are kept as low as possible; debit, credit and mobile payments are accepted; offerings include fruit and vegetables, some prepared foods and frozen soups, stews or meals; Lionhearts aims to choose accessible locations and treats dignity and respect as part of its approach.", + "supports": [ + "summary", + "food_access_purpose", + "eligibility", + "limitations", + "before_you_go", + "cost", + "access.details" + ] + }, + { + "id": "market-account", + "source_kind": "provider", + "url": "https://www.instagram.com/freshfoodmktygk/", + "checked_on": "2026-09-15", + "note": "The official Fresh Food Market account identifies the market as a Lionhearts program and links to lionhearts.ca/popup; this is the primary public stream for schedule changes.", + "supports": [ + "official_url", + "before_you_go" + ] + }, + { + "id": "market-contact-post", + "source_kind": "provider", + "url": "https://www.instagram.com/freshfoodmktygk/p/DcUG0avjtGk/", + "checked_on": "2026-09-15", + "note": "Official market post published August 21, 2026 publishes info@freshfoodmkt.ca as the market email.", + "supports": [ + "before_you_go" + ] + }, + { + "id": "ontario-produce-post", + "source_kind": "provider", + "url": "https://www.instagram.com/freshfoodmktygk/p/DcbbyP5FspW/", + "checked_on": "2026-09-15", + "note": "Official market post published August 24, 2026 advertises Ontario peaches, corn, strawberries and blackberries for that week; it does not establish that all food is Canadian, local or Ontario-grown.", + "supports": [ + "limitations" + ] + }, + { + "id": "slc-launch-post", + "source_kind": "provider", + "url": "https://www.instagram.com/freshfoodmktygk/p/Dc_fZqijNcD/", + "checked_on": "2026-09-15", + "note": "Official post published September 7, 2026 announces the first St. Lawrence College market for September 10, 10 a.m.–noon, in the main lobby beside the information desk.", + "supports": [ + "location", + "before_you_go", + "schedule" + ] + }, + { + "id": "slc-211", + "source_kind": "referral_directory", + "url": "https://211ontario.ca/service/96539438/lionhearts-fresh-food-market-pop-up/", + "checked_on": "2026-09-15", + "note": "Listing updated January 19, 2026 corroborates the address, main-entrance lobby by the information kiosk, drop-in access, no documents, public contact and fully accessible designation. Its older Wednesday hours are superseded by Lionhearts’ current schedule.", + "supports": [ + "location", + "before_you_go", + "access.walk_in", + "access.appointment_required", + "access.registration_required", + "access.identification_required", + "access.details" + ] + } + ], + "verification": { + "reviewed_on": "2026-09-15", + "method": "multiple_sources", + "state": "partially_confirmed" + }, + "publication_status": "published", + "service_condition": "active", + "updated_on": "2026-09-15" + }, + { + "id": "lionhearts-market-isabel-turner", + "organization_id": "lionhearts", + "name": "Lionhearts Fresh Food Market — Isabel Turner Library", + "summary": "Affordable market open to everyone, with fruit and vegetables plus some prepared and frozen foods.", + "categories": [ + "community_markets", + "groceries", + "prepared_meals" + ], + "food_access_purpose": "Affordable food purchases at a neighbourhood market open to everyone.", + "location": { + "description": "Isabel Turner Branch, 935 Gardiners Road, Kingston, ON", + "address": { + "street": "935 Gardiners Road", + "locality": "Kingston", + "region": "ON", + "country": "CA" + } + }, + "phone": "+16137660664", + "official_url": "https://www.instagram.com/freshfoodmktygk/", + "time_zone": "America/Toronto", + "access": { + "walk_in": "unknown", + "appointment_required": "unknown", + "registration_required": "unknown", + "identification_required": "unknown", + "details": [ + "The library lists the Isabel Turner Branch as wheelchair accessible, with an accessible washroom and accessible parking.", + "Lionhearts aims to choose locations accessible to wheelchair users and people with other access needs, and includes dignity and respect in its approach." + ] + }, + "eligibility": [ + "Open to everyone — no income requirement." + ], + "limitations": [ + "Fruit and vegetables, some prepared foods, and frozen soups, stews, or meals may be offered; selection varies by market session.", + "An August 24, 2026 market update advertised Ontario peaches, corn, strawberries and blackberries for that week; not every product is confirmed Canadian or local." + ], + "before_you_go": [ + "All payment methods accepted, including debit, credit and mobile payments.", + "For schedule changes, use the Fresh Food Market account on Instagram. Public market email: info@freshfoodmkt.ca." + ], + "cost": { + "state": "low_cost", + "description": "Affordable food, with prices kept as low as possible." + }, + "schedule": { + "kind": "weekly", + "confirmed": true, + "note": "Thursdays, 2:30–5:30 p.m. This is a published schedule, not live availability.", + "weekly": [ + { "day": 1, "state": "closed", "intervals": [] }, + { "day": 2, "state": "closed", "intervals": [] }, + { "day": 3, "state": "closed", "intervals": [] }, + { "day": 4, "state": "published", "intervals": [{ "opens": "14:30", "closes": "17:30" }] }, + { "day": 5, "state": "closed", "intervals": [] }, + { "day": 6, "state": "closed", "intervals": [] }, + { "day": 7, "state": "closed", "intervals": [] } + ], + "exceptions": [], + "evidence_ids": [ + "lionhearts-schedule" + ] + }, + "evidence": [ + { + "id": "lionhearts-schedule", + "source_kind": "provider", + "url": "https://www.lionhearts.ca/kingston/our-programs/fresh-food-market-pop-ups/", + "checked_on": "2026-09-15", + "note": "Undated live Lionhearts page checked September 15, 2026 lists Isabel Turner Library, 935 Gardiners Road and Thursday 2:30–5:30 p.m.; it also describes seasonal produce, prepared options and low prices.", + "supports": [ + "name", + "summary", + "categories", + "food_access_purpose", + "location", + "phone", + "limitations", + "cost", + "schedule" + ] + }, + { + "id": "jamie-provider-relay", + "source_kind": "direct_confirmation", + "checked_on": "2026-09-15", + "note": "Provider information relayed by Jamie Whelan from his September 15, 2026 meeting and subsequent clarification: markets are publicly available and open to everyone with no income requirement; prices are kept as low as possible; debit, credit and mobile payments are accepted; offerings include fruit and vegetables, some prepared foods and frozen soups, stews or meals; Lionhearts aims to choose accessible locations and treats dignity and respect as part of its approach.", + "supports": [ + "summary", + "food_access_purpose", + "eligibility", + "limitations", + "before_you_go", + "cost", + "access.details" + ] + }, + { + "id": "market-account", + "source_kind": "provider", + "url": "https://www.instagram.com/freshfoodmktygk/", + "checked_on": "2026-09-15", + "note": "The official Fresh Food Market account identifies the market as a Lionhearts program and links to lionhearts.ca/popup; this is the primary public stream for schedule changes.", + "supports": [ + "official_url", + "before_you_go" + ] + }, + { + "id": "market-contact-post", + "source_kind": "provider", + "url": "https://www.instagram.com/freshfoodmktygk/p/DcUG0avjtGk/", + "checked_on": "2026-09-15", + "note": "Official market post published August 21, 2026 publishes info@freshfoodmkt.ca as the market email.", + "supports": [ + "before_you_go" + ] + }, + { + "id": "ontario-produce-post", + "source_kind": "provider", + "url": "https://www.instagram.com/freshfoodmktygk/p/DcbbyP5FspW/", + "checked_on": "2026-09-15", + "note": "Official market post published August 24, 2026 advertises Ontario peaches, corn, strawberries and blackberries for that week; it does not establish that all food is Canadian, local or Ontario-grown.", + "supports": [ + "limitations" + ] + }, + { + "id": "isabel-turner-venue", + "source_kind": "other_reviewed", + "url": "https://www.kfpl.ca/locations/all-locations/isabel-turner-branch", + "checked_on": "2026-09-15", + "note": "Undated live library page checked September 15, 2026 confirms 935 Gardiners Road and lists wheelchair access, an accessible washroom and accessible parking. Library opening hours were not used as market hours.", + "supports": [ + "location", + "access.details" + ] + } + ], + "verification": { + "reviewed_on": "2026-09-15", + "method": "multiple_sources", + "state": "partially_confirmed" + }, + "publication_status": "published", + "service_condition": "active", + "updated_on": "2026-09-15" + }, + { + "id": "lionhearts-market-wj-henderson", + "organization_id": "lionhearts", + "name": "Lionhearts Fresh Food Market — W.J. Henderson Community Centre", + "summary": "Affordable market open to everyone, with fruit and vegetables plus some prepared and frozen foods.", + "categories": [ + "community_markets", + "groceries", + "prepared_meals" + ], + "food_access_purpose": "Affordable food purchases at a neighbourhood market open to everyone.", + "location": { + "description": "Outside the main entrance of W.J. Henderson Community Centre, 322 Amherst Drive, Amherstview, ON", + "address": { + "street": "322 Amherst Drive", + "locality": "Amherstview", + "region": "ON", + "country": "CA" + } + }, + "phone": "+16137660664", + "official_url": "https://www.instagram.com/freshfoodmktygk/", + "time_zone": "America/Toronto", + "access": { + "walk_in": "unknown", + "appointment_required": "unknown", + "registration_required": "unknown", + "identification_required": "unknown", + "details": [ + "The market was announced outside the main entrance; specific accessibility features for the outdoor setup were not published.", + "Lionhearts aims to choose locations accessible to wheelchair users and people with other access needs, and includes dignity and respect in its approach." + ] + }, + "eligibility": [ + "Open to everyone — no income requirement." + ], + "limitations": [ + "Fruit and vegetables, some prepared foods, and frozen soups, stews, or meals may be offered; selection varies by market session.", + "An August 24, 2026 market update advertised Ontario peaches, corn, strawberries and blackberries for that week; not every product is confirmed Canadian or local." + ], + "before_you_go": [ + "All payment methods accepted, including debit, credit and mobile payments.", + "The market was announced outside the community centre’s main entrance.", + "For schedule changes, use the Fresh Food Market account on Instagram. Public market email: info@freshfoodmkt.ca." + ], + "cost": { + "state": "low_cost", + "description": "Affordable food, with prices kept as low as possible." + }, + "schedule": { + "kind": "dated", + "confirmed": true, + "note": "Published fall 2026 market dates, 4–6 p.m. This is not live availability.", + "valid_from": "2026-09-04", + "valid_to": "2026-12-11", + "weekly": [], + "exceptions": [ + { "date": "2026-09-04", "confirmed": true, "intervals": [{ "opens": "16:00", "closes": "18:00" }], "note": "Published market date.", "evidence_ids": ["lionhearts-schedule", "wj-launch-post"] }, + { "date": "2026-09-18", "confirmed": true, "intervals": [{ "opens": "16:00", "closes": "18:00" }], "note": "Published market date.", "evidence_ids": ["lionhearts-schedule"] }, + { "date": "2026-10-02", "confirmed": true, "intervals": [{ "opens": "16:00", "closes": "18:00" }], "note": "Published market date.", "evidence_ids": ["lionhearts-schedule"] }, + { "date": "2026-10-16", "confirmed": true, "intervals": [{ "opens": "16:00", "closes": "18:00" }], "note": "Published market date.", "evidence_ids": ["lionhearts-schedule"] }, + { "date": "2026-10-30", "confirmed": true, "intervals": [{ "opens": "16:00", "closes": "18:00" }], "note": "Published market date.", "evidence_ids": ["lionhearts-schedule"] }, + { "date": "2026-11-13", "confirmed": true, "intervals": [{ "opens": "16:00", "closes": "18:00" }], "note": "Published market date.", "evidence_ids": ["lionhearts-schedule"] }, + { "date": "2026-11-27", "confirmed": true, "intervals": [{ "opens": "16:00", "closes": "18:00" }], "note": "Published market date.", "evidence_ids": ["lionhearts-schedule"] }, + { "date": "2026-12-11", "confirmed": true, "intervals": [{ "opens": "16:00", "closes": "18:00" }], "note": "Published market date.", "evidence_ids": ["lionhearts-schedule"] } + ], + "evidence_ids": [ + "lionhearts-schedule", + "wj-launch-post" + ] + }, + "evidence": [ + { + "id": "lionhearts-schedule", + "source_kind": "provider", + "url": "https://www.lionhearts.ca/kingston/our-programs/fresh-food-market-pop-ups/", + "checked_on": "2026-09-15", + "note": "Undated live Lionhearts page checked September 15, 2026 lists 322 Amherst Drive and eight Friday dates from September 4 through December 11 at 4–6 p.m. The official launch post establishes the year as 2026.", + "supports": [ + "name", + "summary", + "categories", + "food_access_purpose", + "location", + "phone", + "limitations", + "cost", + "schedule" + ] + }, + { + "id": "jamie-provider-relay", + "source_kind": "direct_confirmation", + "checked_on": "2026-09-15", + "note": "Provider information relayed by Jamie Whelan from his September 15, 2026 meeting and subsequent clarification: markets are publicly available and open to everyone with no income requirement; prices are kept as low as possible; debit, credit and mobile payments are accepted; offerings include fruit and vegetables, some prepared foods and frozen soups, stews or meals; Lionhearts aims to choose accessible locations and treats dignity and respect as part of its approach.", + "supports": [ + "summary", + "food_access_purpose", + "eligibility", + "limitations", + "before_you_go", + "cost", + "access.details" + ] + }, + { + "id": "market-account", + "source_kind": "provider", + "url": "https://www.instagram.com/freshfoodmktygk/", + "checked_on": "2026-09-15", + "note": "The official Fresh Food Market account identifies the market as a Lionhearts program and links to lionhearts.ca/popup; this is the primary public stream for schedule changes.", + "supports": [ + "official_url", + "before_you_go" + ] + }, + { + "id": "market-contact-post", + "source_kind": "provider", + "url": "https://www.instagram.com/freshfoodmktygk/p/DcUG0avjtGk/", + "checked_on": "2026-09-15", + "note": "Official market post published August 21, 2026 publishes info@freshfoodmkt.ca as the market email.", + "supports": [ + "before_you_go" + ] + }, + { + "id": "ontario-produce-post", + "source_kind": "provider", + "url": "https://www.instagram.com/freshfoodmktygk/p/DcbbyP5FspW/", + "checked_on": "2026-09-15", + "note": "Official market post published August 24, 2026 advertises Ontario peaches, corn, strawberries and blackberries for that week; it does not establish that all food is Canadian, local or Ontario-grown.", + "supports": [ + "limitations" + ] + }, + { + "id": "wj-launch-post", + "source_kind": "provider", + "url": "https://www.instagram.com/freshfoodmktygk/p/DclhtxEgecD/", + "checked_on": "2026-09-15", + "note": "Official post published August 28, 2026 and edited August 31 announces the September 4 launch, biweekly Fridays from 4–6 p.m., outside the main entrance.", + "supports": [ + "location", + "before_you_go", + "schedule", + "access.details" + ] + }, + { + "id": "loyalist-food-programs", + "source_kind": "local_authority", + "url": "https://www.loyalist.ca/living-in-loyalist/community-organizations/local-food-programs-and-services/", + "checked_on": "2026-09-15", + "note": "Undated live Loyalist Township page checked September 15, 2026 corroborates Lionhearts at W.J. Henderson Community Centre, 322 Amherst Drive, September 4–December 11, biweekly Fridays 4–6 p.m.", + "supports": [ + "name", + "location", + "schedule" + ] + } + ], + "verification": { + "reviewed_on": "2026-09-15", + "method": "multiple_sources", + "state": "partially_confirmed" + }, + "publication_status": "published", + "service_condition": "active", + "updated_on": "2026-09-15" + }, + { + "id": "lionhearts-market-artillery-park", + "organization_id": "lionhearts", + "name": "Lionhearts Fresh Food Market — Artillery Park Aquatic Centre", + "summary": "Affordable market open to everyone, with fruit and vegetables plus some prepared and frozen foods.", + "categories": [ + "community_markets", + "groceries", + "prepared_meals" + ], + "food_access_purpose": "Affordable food purchases at a neighbourhood market open to everyone.", + "location": { + "description": "Artillery Park Aquatic Centre, 382 Bagot Street, Kingston, ON", + "address": { + "street": "382 Bagot Street", + "locality": "Kingston", + "region": "ON", + "country": "CA" + } + }, + "phone": "+16137660664", + "official_url": "https://www.instagram.com/freshfoodmktygk/", + "time_zone": "America/Toronto", + "access": { + "walk_in": "yes", + "appointment_required": "no", + "registration_required": "no", + "identification_required": "no", + "details": [ + "The City lists ramped pools and fully accessible change rooms, while 211 lists accessible parking and entrance but says the washroom is not accessible. Confirm the specific feature you need with the facility.", + "Lionhearts aims to choose locations accessible to wheelchair users and people with other access needs, and includes dignity and respect in its approach." + ] + }, + "eligibility": [ + "Open to everyone — no income requirement." + ], + "limitations": [ + "Fruit and vegetables, some prepared foods, and frozen soups, stews, or meals may be offered; selection varies by market session.", + "An August 24, 2026 market update advertised Ontario peaches, corn, strawberries and blackberries for that week; not every product is confirmed Canadian or local." + ], + "before_you_go": [ + "All payment methods accepted, including debit, credit and mobile payments.", + "The September 19 market is cancelled; Lionhearts says the Saturday market returns September 26.", + "For schedule changes, use the Fresh Food Market account on Instagram. Public market email: info@freshfoodmkt.ca." + ], + "cost": { + "state": "low_cost", + "description": "Affordable food, with prices kept as low as possible." + }, + "schedule": { + "kind": "weekly", + "confirmed": true, + "note": "Saturdays, 10 a.m.–1 p.m.; September 19 is cancelled and the market returns September 26. This is not live availability.", + "weekly": [ + { "day": 1, "state": "closed", "intervals": [] }, + { "day": 2, "state": "closed", "intervals": [] }, + { "day": 3, "state": "closed", "intervals": [] }, + { "day": 4, "state": "closed", "intervals": [] }, + { "day": 5, "state": "closed", "intervals": [] }, + { "day": 6, "state": "published", "intervals": [{ "opens": "10:00", "closes": "13:00" }] }, + { "day": 7, "state": "closed", "intervals": [] } + ], + "exceptions": [ + { + "date": "2026-09-19", + "confirmed": true, + "intervals": [], + "note": "Cancelled during the Artillery Park maintenance closure; market returns September 26.", + "evidence_ids": [ + "artillery-change-post" + ] + } + ], + "evidence_ids": [ + "lionhearts-schedule", + "artillery-change-post" + ] + }, + "evidence": [ + { + "id": "lionhearts-schedule", + "source_kind": "provider", + "url": "https://www.lionhearts.ca/kingston/our-programs/fresh-food-market-pop-ups/", + "checked_on": "2026-09-15", + "note": "Undated live Lionhearts page checked September 15, 2026 lists Artillery Park Aquatic Centre, 382 Bagot Street and Saturday 10 a.m.–1 p.m.; it also describes seasonal produce, prepared options and low prices.", + "supports": [ + "name", + "summary", + "categories", + "food_access_purpose", + "location", + "phone", + "limitations", + "cost", + "schedule" + ] + }, + { + "id": "jamie-provider-relay", + "source_kind": "direct_confirmation", + "checked_on": "2026-09-15", + "note": "Provider information relayed by Jamie Whelan from his September 15, 2026 meeting and subsequent clarification: markets are publicly available and open to everyone with no income requirement; prices are kept as low as possible; debit, credit and mobile payments are accepted; offerings include fruit and vegetables, some prepared foods and frozen soups, stews or meals; Lionhearts aims to choose accessible locations and treats dignity and respect as part of its approach.", + "supports": [ + "summary", + "food_access_purpose", + "eligibility", + "limitations", + "before_you_go", + "cost", + "access.details" + ] + }, + { + "id": "market-account", + "source_kind": "provider", + "url": "https://www.instagram.com/freshfoodmktygk/", + "checked_on": "2026-09-15", + "note": "The official Fresh Food Market account identifies the market as a Lionhearts program and links to lionhearts.ca/popup; this is the primary public stream for schedule changes.", + "supports": [ + "official_url", + "before_you_go" + ] + }, + { + "id": "market-contact-post", + "source_kind": "provider", + "url": "https://www.instagram.com/freshfoodmktygk/p/DcUG0avjtGk/", + "checked_on": "2026-09-15", + "note": "Official market post published August 21, 2026 publishes info@freshfoodmkt.ca as the market email.", + "supports": [ + "before_you_go" + ] + }, + { + "id": "ontario-produce-post", + "source_kind": "provider", + "url": "https://www.instagram.com/freshfoodmktygk/p/DcbbyP5FspW/", + "checked_on": "2026-09-15", + "note": "Official market post published August 24, 2026 advertises Ontario peaches, corn, strawberries and blackberries for that week; it does not establish that all food is Canadian, local or Ontario-grown.", + "supports": [ + "limitations" + ] + }, + { + "id": "artillery-change-post", + "source_kind": "provider", + "url": "https://www.instagram.com/freshfoodmktygk/p/DdRxVVbo-wc/", + "checked_on": "2026-09-15", + "note": "Official update published September 14, 2026 says Artillery Park is closed September 8–20, the September 19 market is cancelled and Saturday service returns September 26.", + "supports": [ + "before_you_go", + "schedule", + "notices" + ] + }, + { + "id": "artillery-city-facility", + "source_kind": "local_authority", + "url": "https://www.cityofkingston.ca/activities-and-recreation/aquatics-and-swimming/artillery-park-aquatic-centre/", + "checked_on": "2026-09-15", + "note": "Undated live City page checked September 15, 2026 confirms 382 Bagot Street and lists ramped pool access, fully accessible pool change rooms and fully accessible universal change rooms. Facility hours were not used as market hours.", + "supports": [ + "location", + "access.details" + ] + }, + { + "id": "artillery-211", + "source_kind": "referral_directory", + "url": "https://211ontario.ca/service/96539427/lionhearts-fresh-food-market-pop-up/", + "checked_on": "2026-09-15", + "note": "Listing updated January 19, 2026 corroborates the address, Saturday time, drop-in access, no documents and public contact. It lists accessible parking and entrance but says the washroom is not accessible, which conflicts with the City’s accessible change-room information.", + "supports": [ + "location", + "before_you_go", + "access.walk_in", + "access.appointment_required", + "access.registration_required", + "access.identification_required", + "access.details" + ] + }, + { + "id": "artillery-city-closure", + "source_kind": "local_authority", + "url": "https://www.cityofkingston.ca/news/posts/artillery-park-aquatic-centre-closed-sept-8-20-inclusive-for-annual-maintenance-work/", + "checked_on": "2026-09-15", + "note": "City notice published August 24, 2026 independently confirms the facility closure from September 8 through September 20 inclusive. The notice also calls September 20 Friday even though that date is a Sunday; only the numeric date range is used.", + "supports": [ + "before_you_go", + "schedule", + "notices" + ] + } + ], + "verification": { + "reviewed_on": "2026-09-15", + "method": "multiple_sources", + "state": "partially_confirmed" + }, + "publication_status": "published", + "service_condition": "active", + "updated_on": "2026-09-15", + "notices": [ + { + "id": "artillery-maintenance-cancellation", + "text": "No market Saturday, September 19, during the Artillery Park closure. Lionhearts says the market returns September 26.", + "starts_on": "2026-09-08", + "ends_on": "2026-09-20", + "evidence_ids": [ + "artillery-change-post", + "artillery-city-closure" + ] + } + ] } ] } diff --git a/deployments/kingston/site.json b/deployments/kingston/site.json index a902473..fef4154 100644 --- a/deployments/kingston/site.json +++ b/deployments/kingston/site.json @@ -39,10 +39,11 @@ "interval_days": 30 }, "presentation": { - "introduction": "Find a meal or groceries. See where to go, when to visit, and what to bring.", + "introduction": "Find Emergency food or Affordable food in Kingston. Use meal, grocery and market filters to see what is offered, and check details before travelling.", "category_groups": [ { "id": "meals", "label": "Meals", "categories": ["prepared_meals"] }, - { "id": "groceries", "label": "Groceries", "categories": ["groceries", "food_banks_pantries"] } + { "id": "groceries", "label": "Groceries", "categories": ["groceries", "food_banks_pantries"] }, + { "id": "markets", "label": "Markets", "categories": ["community_markets"] } ] }, "location": { diff --git a/docs/architecture.md b/docs/architecture.md index 47edd88..cd48548 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -18,11 +18,15 @@ Setup requires an explicit `--site` folder. Development, build and preview selec The report is for this local build, not a historical public dataset snapshot. Keep a whole approved artifact privately for deployment rollback. Default output is `dist/{deployment_id}/`; an explicit `--out` must be an approved output location. Different communities must use unique deployment IDs and separate output locations. `dist/`, `.generated/`, `artifacts/`, browser profiles and reports are ignored by Git. The pipeline never empties source community folders. Shared schema declarations are generic; checks serialize shared generation rather than letting concurrent checks rewrite it unpredictably. +An explicit `--review-drafts` build is separate from that public pipeline. It is confined to `artifacts/reviews/`, cannot be a production build, reads review data from `/data/review/resources.json`, labels drafts, and applies noindex/no-store policy. Review pages do not register a service worker or retain review data in Cache Storage. The public `/data/v1/resources.json` remains a published-only projection. + Source text copied or embedded in output is normalized to LF before hashing, including worker source, SVG branding, standalone CSS and licence/notice text. Font and icon binary bytes remain unchanged. A contract test builds isolated fictional source trees with LF and CRLF endings and compares every output file hash, so Windows checkout settings cannot silently change the deployable artifact. ## Offline lifecycle -The first connected page view shows complete static HTML immediately. JavaScript fetches the current public JSON with a bounded 3.5-second deadline. It validates all fields and semantic relationships, checks deployment/configuration compatibility and retains only a valid public dataset. Cache operations have bounded waits; failed or blocked storage cannot block browsing. +The first connected page view shows complete static HTML immediately. `/` is the default **Emergency food** view; `/affordable-food/` is the ordinary, directly linkable **Affordable food** view. Free resources appear in Emergency, low-cost and subsidized resources appear in Affordable, and mixed-cost resources appear in both while keeping one resource identity. Search, category, schedule and distance controls operate within the selected view. The simple directory, printable/downloaded directory and public dataset retain every published resource, so disabling JavaScript does not erase access to the complete directory. + +JavaScript fetches the current public JSON with a bounded 3.5-second deadline. It validates all fields and semantic relationships, checks deployment/configuration compatibility and retains only a valid public dataset. Cache operations have bounded waits; failed or blocked storage cannot block browsing. On network failure or an invalid response, the loader validates the retained last-good data again. If none is usable, the useful static page remains visible and dynamic filters remain hidden. No corrupt response overwrites last-good data. Online data may refresh independently of the app shell; this is why it must maintain its own compatibility discriminator. Configuration changes affecting timezone, jurisdiction, review rules or dataset identity change that discriminator. @@ -30,7 +34,7 @@ The native worker fetches and verifies the digest of every application asset and First installation activates normally. Later workers wait and display an update button; activation reloads each controlled tab once. The new worker deletes only this deployment’s superseded app caches and incompatible data caches, leaving unrelated applications alone. All pages belong to one complete app release. Unknown routes go to the network and receive an actual host 404, rather than an SPA catch-all. -Offline navigation uses the cached root, resource pages, methodology and simple directory. Search/filtering uses retained validated data; visiting any enhanced page initializes data retention. Reconnection requests current data. Neither the worker nor analytics queues external events. Cache names begin `food-help-{deployment_id}-` and never contain a reference locality by convention. +Offline navigation uses the cached Emergency and Affordable routes, resource pages, methodology and simple directory. Search/filtering uses retained validated data; visiting any enhanced page initializes data retention. Reconnection requests current data. Neither the worker nor analytics queues external events. Cache names begin `food-help-{deployment_id}-` and never contain a reference locality by convention. Rollback means serving the previous complete artifact for one selected community, including its worker and JSON. Its worker becomes a waiting update and follows the same explicit activation path. A compatible last-good dataset remains usable; incompatible data falls back to the artifact's static content until connected validation succeeds. A domain transition separately accounts for workers already registered on the legacy origin; absence of home-screen installs is not evidence that no worker exists. diff --git a/docs/data.md b/docs/data.md index 415f866..891da1a 100644 --- a/docs/data.md +++ b/docs/data.md @@ -4,12 +4,16 @@ The selected deployment's `resources.json` is authoritative (for Kingston, `depl The dataset has a stable UUID `dataset_id`, `content_language`, `taxonomy_version: 1`, organizations and resources. Organizations have stable slug IDs, names and optional descriptions, official URLs and content language. A resource references `organization_id`; locations are inline to keep small-directory authoring manageable. Preserve IDs through name changes. Do not recycle withdrawn IDs for unrelated services. V1 URLs use the stable resource ID directly. +One organization can operate several resources. Give each distinct physical venue its own resource ID, inline location and applicable schedule while reusing the organization ID. Keep repeated sessions at one venue in that resource's schedule unless they are genuinely different services. This preserves one provider identity without merging addresses or hours. + Use the example as a shape guide. Replace it with reviewed facts and a new dataset UUID. Empty arrays are allowed for an initially empty directory. Never publish synthetic contacts, coordinates or fictional records under a real community’s identity. ## Taxonomy and scope The fixed v1 categories are `prepared_meals`, `food_banks_pantries`, `groceries`, `community_fridges`, `community_markets`, `community_gardens`, `food_delivery`, `food_access_support`, and `other_food_access`. A service can have several categories. `cost` independently describes free, low-cost, subsidized, mixed or unknown pricing, with useful public wording. +Top-level browsing derives from reviewed cost instead of changing category meanings: `free` appears under **Emergency food**; `low_cost` and `subsidized` appear under **Affordable food**; and `mixed` appears in both views without duplicating the resource or its stable URL. Missing or `unknown` cost is unresolved. A published, non-closed resource must have a resolved cost and evidence supporting `cost`; keep an unresolved candidate in draft. Category filters continue to describe what the service offers within either view. This uses existing v1 cost values, so it adds neither a grouping field nor a category-taxonomy version. + Every resource needs an evidence-supported `food_access_purpose`. Ordinary commercial retail does not qualify merely because it sells food. A retailer’s independently reviewed subsidized programme may qualify as a service. This is a human editorial decision; a validator cannot prove public benefit. ## Facts and uncertainty @@ -28,9 +32,9 @@ Only published, non-closed records enter public JSON, HTML or discovery output. ## Schedules and evidence -Schedule kind is weekly, dated, by-arrangement or unknown. Weekly weekdays use ISO 1=Monday through 7=Sunday. Each day is published, closed or unknown; a missing day is unknown. Intervals use local `HH:MM`, an exclusive closing time and explicit `closes_next_day` for overnight service. Optional valid date ranges, explicit date exceptions, confirmation and evidence references are preserved. A confirmed empty exception means closed for that whole date and overrides overnight carry-in. Unconfirmed exceptions remain unknown. Never infer live availability, stock or guaranteed access. +Schedule kind is weekly, dated, by-arrangement or unknown. Weekly weekdays use ISO 1=Monday through 7=Sunday. Each day is published, closed or unknown; a missing day is unknown. Intervals use local `HH:MM`, an exclusive closing time and explicit `closes_next_day` for overnight service. Optional valid date ranges, explicit date exceptions, confirmation and evidence references are preserved. Represent a dated event with its actual date and intervals in `exceptions`; a validity range alone does not establish that the service is scheduled on a particular day. A confirmed empty exception means closed for that whole date and overrides overnight carry-in. Unconfirmed exceptions remain unknown. Never infer live availability, stock or guaranteed access. -Evidence has a stable ID, source kind, checked date, public note, supported field paths and a source URL (direct confirmation may omit a URL). Keep private names, correspondence and personal details out of public evidence notes. Verification records give the review date, method and state. Evidence review dates cannot be after verification, and verification cannot be after the resource’s updated date. Referenced sources must exist. Automation validates these relationships; it does not validate the truth of claims or automatically contact providers. +Evidence has a stable ID, source kind, checked date, public note, supported field paths and a source URL (direct confirmation may omit a URL). Describe direct-confirmation provenance accurately—for example, provider information relayed by the review owner is not an interview performed by an automated agent. Keep private names, correspondence and personal details out of public evidence notes. Verification records give the review date, method and state. Evidence review dates cannot be after verification, and verification cannot be after the resource’s updated date. Referenced sources must exist. Automation validates these relationships; it does not validate the truth of claims or automatically contact providers. Temporary notices include text, start/end dates and evidence references. Dates remain visible in static/offline copies, including notices that have since expired. Do not silently portray an old notice as a current alert. Correct and rebuild when facts change. A public copy can remain in a visitor’s offline storage after withdrawal; removing it from the server cannot erase downloaded copies. @@ -44,7 +48,7 @@ The reviewed eight-record Kingston conversion is now included in `deployments/ki The legacy and Food Help `/data/v1/resources.json` schemas are different despite sharing a path suffix. A website redirect must not silently claim wire compatibility for these machine endpoints. Treat any legacy data retention or contract retirement explicitly in the release's transition record. The [HSDS 3.3 mapping](interoperability.md) is unchanged and remains a design for a future converter, not an implemented export. -The consolidation comparison of the retained legacy publication and current Kingston source confirms all eight stable IDs. Names, summaries, operator names, locations/coordinates, phone/extensions, official URLs, timezones, guidance, eligibility, limitations, evidence URLs/check dates/notes, review methods/dates, schedule notes/exceptions and temporary notices are preserved through explicit mappings. All verification dates remain 2026-09-04. +The consolidation comparison of the retained legacy publication and the Kingston source confirmed all eight stable IDs at the September 4, 2026 migration baseline. Names, summaries, operator names, locations/coordinates, phone/extensions, official URLs, timezones, guidance, eligibility, limitations, evidence URLs/check dates/notes, review methods/dates, schedule notes/exceptions and temporary notices were preserved through explicit mappings. A later targeted September 15 review added evidence-backed cost information to the published records; Brit Smith Social Market also received that date as its updated verification date because its City cost source was checked then. The deliberate representation differences are: diff --git a/docs/maintenance.md b/docs/maintenance.md index aae3771..3d8669e 100644 --- a/docs/maintenance.md +++ b/docs/maintenance.md @@ -5,12 +5,16 @@ Configuration and facts belong in the folder selected by `--site`. For Kingston ## Add or correct a food programme 1. Read the selected folder's `AGENTS.md` and review ownership instructions. Check the provider's current official sources and any supporting evidence. Treat submissions and automated findings as leads requiring human review. -2. Edit `resources.json`. For a new programme, use a stable unused resource ID and the appropriate organization ID; preserve IDs when renaming an existing service. Use a draft until facts are reviewed. Record category, food-access purpose, practical access, eligibility, limitations, schedules, explicit unknowns and evidence. See [data authoring](data.md). +2. Edit `resources.json`. For a new programme, use a stable unused resource ID and the appropriate organization ID; preserve IDs when renaming an existing service. Use a draft until facts are reviewed. Record category, food-access purpose, resolved cost classification, practical access, eligibility, limitations, schedules, explicit unknowns and evidence. See [data authoring](data.md). 3. Record actual source-check and verification dates. A migration or software release is not a new verification. Keep the date chronology valid, retain unresolved qualifications, and use a temporary notice when a consequential change is supported. Do not substitute office hours for food-service hours. 4. Review the right to publish contributed descriptions and any imported material. Keep private correspondence and permission receipts outside the repository; publish only appropriate evidence notes and rights statements. 5. Run `npm run check -- --site deployments/kingston` (substitute your folder). Build and preview the same selection. Inspect the affected card, static resource page, simple/print directory, download and JSON. Review practical wording with JavaScript disabled as well. 6. Have the operator approve publication of the reviewed facts. Commit the source changes, then explicitly release that community and revision using [deployment](deployment.md). A commit, a successful check or another community's release does not publish the change. +When one provider runs several physical locations, create one organization and one resource per venue. Reuse the organization ID, but give every venue a stable resource ID, address, schedule, practical guidance and evidence. Put repeated sessions at the same venue in one weekly or dated schedule; do not merge different venues into one card. + +To inspect draft records without putting them in the public projection, run `npm run dev -- --site deployments/kingston --review-drafts` (substitute the selected folder). A review build defaults below `artifacts/reviews//`, visibly labels drafts, and cannot be combined with production. It uses a review-only data route, disables persistent offline installation, and sends noindex/no-store policy. This is a local editorial aid, not publication approval. Normal builds continue to exclude drafts. + Use `withdrawn` or `closed` according to the documented distinction when a service leaves the public directory. Generated pages are removed and unknown routes return a genuine 404. Offline/downloaded copies cannot be reliably recalled; issue a corrected release promptly and keep uncertainty visible. For Kingston, [the current review checklist](../deployments/kingston/REVIEW.md) identifies the owner, carried-forward dates and unresolved questions. The review interval in configuration drives overdue warnings; it is not an automatic re-verification or publication schedule. diff --git a/examples/exampleville/resources.json b/examples/exampleville/resources.json index 1833fd9..0ad5948 100644 --- a/examples/exampleville/resources.json +++ b/examples/exampleville/resources.json @@ -431,8 +431,8 @@ "Do not use this fictional listing to seek food." ], "cost": { - "state": "free", - "description": "Fictional example of a free service." + "state": "low_cost", + "description": "Fictional example of a low-cost service." }, "schedule": { "kind": "by_arrangement", @@ -502,8 +502,8 @@ "Do not use this fictional listing to seek food." ], "cost": { - "state": "free", - "description": "Fictional example of a free service." + "state": "mixed", + "description": "Fictional example with both free and low-cost support." }, "schedule": { "kind": "by_arrangement", @@ -588,8 +588,8 @@ "Do not use this fictional listing to seek food." ], "cost": { - "state": "free", - "description": "Fictional example of a free service." + "state": "unknown", + "description": "Cost is intentionally unresolved in this fictional draft." }, "schedule": { "kind": "weekly", diff --git a/scripts/build.ts b/scripts/build.ts index 417e711..e7b0247 100644 --- a/scripts/build.ts +++ b/scripts/build.ts @@ -12,7 +12,7 @@ import { argumentsFor, selection } from './lib/selection.ts'; import { renderHeaders } from './lib/headers.ts'; import { readText } from './lib/text.ts'; -export type BuildOptions = { siteDir?: string; outDir?: string; production?: boolean; sourceRevision?: string; sourceUrl?: string }; +export type BuildOptions = { siteDir?: string; outDir?: string; production?: boolean; reviewDrafts?: boolean; sourceRevision?: string; sourceUrl?: string }; export async function files(directory: string): Promise { return (await Promise.all((await readdir(directory, { withFileTypes: true })).map(async entry => entry.isDirectory() ? (await files(path.join(directory, entry.name))).map(file => `${entry.name}/${file}`) : [entry.name]))).flat().sort(); } @@ -23,9 +23,11 @@ export async function build(options: BuildOptions = {}) { try { return await buildSelected(options); } finally { await rm(lock, { recursive: true, force: true }); } } async function buildSelected(options: BuildOptions): Promise<{ release: string; outDir: string; reportPath: string; data: PublicDataset }> { - const root = process.cwd(), { siteDir, outDir } = await selection(options), production = options.production ?? false; + const root = process.cwd(), { siteDir, outDir } = await selection(options), production = options.production ?? false, reviewDrafts = options.reviewDrafts ?? false; // Destructive work is restricted to known generated directories inside this workspace. if (!['dist', 'artifacts', '.generated'].some(directory => outDir.startsWith(path.join(root, directory) + path.sep)) || outDir === siteDir || siteDir.startsWith(outDir + path.sep) || outDir === path.join(root, '.generated/build.lock')) throw new Error('Output must be a child of dist/, artifacts/ or .generated/ and must not contain source configuration'); + if (production && reviewDrafts) throw new Error('Draft review previews cannot be production builds'); + if (reviewDrafts && !outDir.startsWith(path.join(root, 'artifacts', 'reviews') + path.sep)) throw new Error('Draft review previews must be written below artifacts/reviews/'); await generateContracts(); const validation: typeof import('../src/validation.ts') = await import('../src/validation.ts'); const site: unknown = JSON.parse(await readFile(path.join(siteDir, 'site.json'), 'utf8')); @@ -41,14 +43,23 @@ async function buildSelected(options: BuildOptions): Promise<{ release: string; } if (production && !sourceUrl) throw new Error('Production requires --source-url for the corresponding covered source, or --source-revision with a configured GitHub repository URL'); if (sourceUrl) { if (new URL(sourceUrl).protocol !== 'https:') throw new Error('Covered source URL must use HTTPS'); site.software_source_url = sourceUrl; } - const resources = source.resources.filter(validation.isPublished); - const compatibility = digest(JSON.stringify({ deployment_id: site.deployment_id, dataset_id: source.dataset_id, contract: 1, taxonomy: 1, language: site.language, time_zone: site.time_zone, review: site.review, jurisdiction: site.jurisdiction ?? null })); - const data: PublicDataset = { ...source, organizations: source.organizations.filter(org => resources.some(r => r.organization_id === org.id)), resources, deployment_id: site.deployment_id, compatibility_id: compatibility, dataset_version: '', data_updated_on: resources.map(r => r.updated_on).sort().at(-1) ?? null }; - data.dataset_version = digest(JSON.stringify(data)); validation.assertDataset(data, 'public'); - if (JSON.stringify(data).length > 5_000_000) throw new Error('v1 supports at most 5 MB of public resource JSON'); + const publicResources = source.resources.filter(validation.isPublished); + const compatibilityFor = (review: boolean) => digest(JSON.stringify({ deployment_id: site.deployment_id, dataset_id: source.dataset_id, contract: 1, taxonomy: 1, browse_views: 1, resolved_costs: 1, review_drafts: review, language: site.language, time_zone: site.time_zone, review: site.review, jurisdiction: site.jurisdiction ?? null })); + const datasetFor = (resources: Dataset['resources'], compatibility: string): PublicDataset => { + const value: PublicDataset = { ...source, organizations: source.organizations.filter(org => resources.some(r => r.organization_id === org.id)), resources, deployment_id: site.deployment_id, compatibility_id: compatibility, dataset_version: '', data_updated_on: resources.map(r => r.updated_on).sort().at(-1) ?? null }; + value.dataset_version = digest(JSON.stringify(value)); + return value; + }; + const publicCompatibility = compatibilityFor(false); + const publicData = datasetFor(publicResources, publicCompatibility); validation.assertDataset(publicData, 'public'); + const reviewResources = source.resources.filter(resource => resource.publication_status !== 'withdrawn' && resource.service_condition !== 'closed'); + const compatibility = reviewDrafts ? compatibilityFor(true) : publicCompatibility; + const data = reviewDrafts ? datasetFor(reviewResources, compatibility) : publicData; + if (reviewDrafts) validation.assertDataset(data, 'review'); + if (JSON.stringify(publicData).length > 5_000_000 || JSON.stringify(data).length > 5_000_000) throw new Error('v1 supports at most 5 MB of resource JSON'); let usage: Usage | undefined; if (site.public_usage?.enabled) { const value: unknown = JSON.parse(await readFile(path.join(siteDir, 'usage.json'), 'utf8')); validation.assertUsage(value, site); usage = value; } - const runtimeConfig = JSON.stringify({ site, compatibility_id: compatibility, data_url: '/data/v1/resources.json', production }); + const runtimeConfig = JSON.stringify({ site, compatibility_id: compatibility, data_url: reviewDrafts ? '/data/review/resources.json' : '/data/v1/resources.json', production, review_drafts: reviewDrafts }); await rm(outDir, { recursive: true, force: true }); await mkdir(outDir, { recursive: true }); await viteBuild({ configFile: false, publicDir: false, logLevel: 'warn', plugins: [{ name: 'food-help-selected-config', resolveId(id) { if (id === 'virtual:food-help-config') return '\0food-help-config'; }, load(id) { if (id === '\0food-help-config') return `export default ${runtimeConfig};`; } }], build: { outDir, emptyOutDir: false, manifest: true, sourcemap: false, rollupOptions: { input: path.join(root, 'src/main.ts'), output: { entryFileNames: 'assets/app-[hash].js', assetFileNames: 'assets/[name]-[hash][extname]' } } } }); const manifest = JSON.parse(await readFile(path.join(outDir, '.vite/manifest.json'), 'utf8')) as Record; @@ -83,13 +94,17 @@ async function buildSelected(options: BuildOptions): Promise<{ release: string; const download = document(simple, site, assets, false, standaloneCSS).replace('', `${licenseAppendix}`).replace(/href="\/(?!\/)/g, `href="${site.canonical_origin}/`).replace(/]*>/g, ''); await write('/directory/download.html', download); headers['/directory/download.html'] = { ...securityHeaders(download, site, production), 'X-Robots-Tag': 'noindex, follow' }; routes['/directory/download.html'] = '/directory/download.html'; headers['/directory/download'] = headers['/directory/download.html']!; routes['/directory/download'] = '/directory/download.html'; - await write('/data/v1/resources.json', JSON.stringify(data, null, 2) + '\n'); + await write('/data/v1/resources.json', JSON.stringify(publicData, null, 2) + '\n'); headers['/data/v1/resources.json'] = { 'Content-Type': 'application/json; charset=utf-8', 'Access-Control-Allow-Origin': '*', 'Cache-Control': 'no-store', 'X-Content-Type-Options': 'nosniff', 'X-Robots-Tag': 'noindex' }; + if (reviewDrafts) { + await write('/data/review/resources.json', JSON.stringify(data, null, 2) + '\n'); + headers['/data/review/resources.json'] = { 'Content-Type': 'application/json; charset=utf-8', 'Cache-Control': 'no-store', 'X-Content-Type-Options': 'nosniff', 'X-Robots-Tag': 'noindex' }; + } await write('/manifest.webmanifest', JSON.stringify({ id: `/?app=${site.deployment_id}`, name: site.site_name, short_name: site.short_name ?? site.site_name, description: site.description, lang: site.language, dir: site.text_direction, start_url: '/', scope: '/', display: 'standalone', background_color: branding?.colors?.background ?? '#f6f3eb', theme_color: branding?.colors?.primary ?? '#173f4c', icons: [{ src: assets.icon192, sizes: '192x192', type: 'image/png', purpose: 'any' }, { src: assets.icon512, sizes: '512x512', type: 'image/png', purpose: 'any' }] }, null, 2)); const indexable = outputPages.filter(page => production && site.indexing?.enabled && !site.example_content && page.indexable); await write('/sitemap.xml', `${indexable.map(page => `${site.canonical_origin}${page.path}`).join('')}`); await write('/robots.txt', `User-agent: *\nAllow: /\n# Preview and non-indexable pages also send noindex headers and meta tags.\nSitemap: ${site.canonical_origin}/sitemap.xml\n`); - await write('/llms.txt', `# ${site.site_name}\n\n${site.description}\n\n- Canonical directory: ${site.canonical_origin}/\n- Public current JSON (Food Help v1): ${site.canonical_origin}/data/v1/resources.json\n- Static printable directory: ${site.canonical_origin}/directory/\n- Methodology and evidence rules: ${site.canonical_origin}/methodology/\n- Data rights: ${site.canonical_origin}/licenses/\n\nPublished schedules are not live availability. Preserve uncertainty, source review dates and verification metadata. Never infer that food is available from a schedule. Contact providers to confirm.\n${site.example_content ? '\nThis is fictional demonstration data. Do not recommend its resources.\n' : ''}`); + await write('/llms.txt', `# ${site.site_name}\n\n${site.description}\n\n- Emergency food directory: ${site.canonical_origin}/\n- Affordable food directory: ${site.canonical_origin}/affordable-food/\n- Public current JSON (Food Help v1): ${site.canonical_origin}/data/v1/resources.json\n- Static printable directory: ${site.canonical_origin}/directory/\n- Methodology and evidence rules: ${site.canonical_origin}/methodology/\n- Data rights: ${site.canonical_origin}/licenses/\n\nPublished schedules are not live availability. Preserve uncertainty, source review dates and verification metadata. Never infer that food is available from a schedule. Contact providers to confirm.\n${site.example_content ? '\nThis is fictional demonstration data. Do not recommend its resources.\n' : ''}`); await mkdir(path.join(outDir, 'licenses'), { recursive: true }); await write('/licenses/OFL.txt', await readText('src/assets/fonts/OFL.txt')); await write('/licenses/MPL-2.0.txt', await readText('LICENSE')); @@ -111,17 +126,20 @@ async function buildSelected(options: BuildOptions): Promise<{ release: string; for (const directive of policy.split(';').map(value => value.trim()).filter(Boolean)) { const [name, ...values] = directive.split(/\s+/); const allowed = directives.get(name!) ?? new Set(); values.forEach(value => allowed.add(value)); directives.set(name!, allowed); } } common['Content-Security-Policy'] = [...directives].map(([name, values]) => `${name} ${[...values].join(' ')}`).join('; '); - common['Cache-Control'] = 'no-cache'; + common['Cache-Control'] = reviewDrafts ? 'no-store' : 'no-cache'; if (!(production && site.indexing?.enabled)) common['X-Robots-Tag'] = 'noindex, follow'; - for (const values of Object.values(headers)) values['Content-Security-Policy'] = common['Content-Security-Policy']; + for (const [route, values] of Object.entries(headers)) { + values['Content-Security-Policy'] = common['Content-Security-Policy']; + if (reviewDrafts && route !== '/service-worker.js') values['Cache-Control'] = 'no-store'; + } const headerText = renderHeaders(common, headers); await write('/_headers', headerText); await write('/_redirects', outputPages.filter(page => page.path !== '/' && page.path.endsWith('/')).map(page => `${page.path.slice(0, -1)} ${page.path} 301`).join('\n') + '\n'); const reportDir = path.resolve('artifacts/reports', site.deployment_id); await mkdir(reportDir, { recursive: true }); const reportPath = path.join(reportDir, `${digest(outDir).slice(0, 16)}.json`); - const report = { deployment_id: site.deployment_id, site_directory: siteDir, source_revision: sourceRevision ?? null, source_url: site.software_source_url ?? null, release, dataset_version: data.dataset_version, compatibility_id: compatibility, production, resources: data.resources.length, routes, indexable: indexable.map(page => page.path), headers, common_headers: common, output: outDir, file_hashes: Object.fromEntries(await Promise.all((await files(outDir)).map(async file => [file, digest(await readFile(path.join(outDir, file)))]))) }; + const report = { deployment_id: site.deployment_id, site_directory: siteDir, source_revision: sourceRevision ?? null, source_url: site.software_source_url ?? null, release, dataset_version: data.dataset_version, compatibility_id: compatibility, production, review_drafts: reviewDrafts, resources: data.resources.length, public_resources: publicData.resources.length, draft_resources: data.resources.filter(resource => resource.publication_status === 'draft').length, routes, indexable: indexable.map(page => page.path), headers, common_headers: common, output: outDir, file_hashes: Object.fromEntries(await Promise.all((await files(outDir)).map(async file => [file, digest(await readFile(path.join(outDir, file)))]))) }; await writeFile(reportPath, JSON.stringify(report, null, 2)); - console.log(`Built ${site.deployment_id}: ${resources.length} resources, release ${release.slice(0, 16)} → ${outDir}`); + console.log(`Built ${site.deployment_id}: ${data.resources.length} resources${reviewDrafts ? ` (${publicData.resources.length} public, draft review enabled)` : ''}, release ${release.slice(0, 16)} → ${outDir}`); console.log(`Build report: ${reportPath}`); return { release, outDir, reportPath, data }; } @@ -129,5 +147,5 @@ if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) const options = argumentsFor(); if (process.argv.includes('--dev') && options.production) throw new Error('Development rebuilds are previews. Use build --production for a release artifact.'); const result = await build(options); - if (process.argv.includes('--dev')) { const { serve } = await import('./serve.ts'); await serve({ root: result.outDir, siteDir: options.siteDir, watch: true, port: options.port ? Number(options.port) : undefined }); } + if (process.argv.includes('--dev')) { const { serve } = await import('./serve.ts'); await serve({ root: result.outDir, siteDir: options.siteDir, reviewDrafts: options.reviewDrafts, watch: true, port: options.port ? Number(options.port) : options.reviewDrafts ? 4174 : undefined }); } } diff --git a/scripts/check.ts b/scripts/check.ts index 8d958f1..312bf65 100644 --- a/scripts/check.ts +++ b/scripts/check.ts @@ -20,6 +20,11 @@ await mkdir('.generated', { recursive: true }); try { await mkdir('.generated/check.lock'); } catch (error) { if ((error as NodeJS.ErrnoException).code === 'EEXIST') throw new Error('Another Food Help check is active. Checks sharing test fixtures are explicitly serialized. If a process crashed, confirm it has stopped before removing .generated/check.lock.'); throw error; } try { const independent = await build({ siteDir: 'examples/exampleville', outDir: 'artifacts/exampleville' }); +const requestedReviewSource = options.siteDir ?? starterSite; +const requestedReviewData = JSON.parse(await readFile(`${requestedReviewSource}/resources.json`, 'utf8')) as { resources?: Array<{ publication_status?: string }> }; +const reviewSource = requestedReviewData.resources?.some(resource => resource.publication_status === 'draft') ? requestedReviewSource : starterSite; +const review = await build({ siteDir: reviewSource, reviewDrafts: true }); +process.env.FOOD_HELP_TEST_REVIEW = JSON.stringify({ directory: review.outDir, sourceDir: reviewSource }); const untouchedFiles = JSON.parse(await readFile(independent.reportPath, 'utf8')).file_hashes as Record; await build({ siteDir: 'tests/fixtures/kingston-like', outDir: 'artifacts/kingston-like' }); await variant('artifacts/production-site', { example_content: false, canonical_origin: 'https://directory.example.test', indexing: { enabled: true } }); diff --git a/scripts/lib/selection.ts b/scripts/lib/selection.ts index 0d3e93e..3b164b1 100644 --- a/scripts/lib/selection.ts +++ b/scripts/lib/selection.ts @@ -11,12 +11,13 @@ export function argumentsFor(args = process.argv.slice(2)) { if (!result || result.startsWith('--')) throw new Error(`${name} requires a value`); return result; }; - return { siteDir: value('--site'), outDir: value('--out'), production: args.includes('--production'), sourceRevision: value('--source-revision'), sourceUrl: value('--source-url'), port: value('--port') }; + return { siteDir: value('--site'), outDir: value('--out'), production: args.includes('--production'), reviewDrafts: args.includes('--review-drafts'), sourceRevision: value('--source-revision'), sourceUrl: value('--source-url'), port: value('--port') }; } -export async function selection(options: { siteDir?: string; outDir?: string } = {}) { +export async function selection(options: { siteDir?: string; outDir?: string; reviewDrafts?: boolean } = {}) { const siteDir = path.resolve(options.siteDir ?? starterSite); const site = JSON.parse(await readFile(path.join(siteDir, 'site.json'), 'utf8')) as { deployment_id?: unknown }; if (typeof site.deployment_id !== 'string' || !/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(site.deployment_id)) throw new Error('Select a site with a valid deployment_id'); if (path.dirname(siteDir) === path.resolve('deployments') && path.basename(siteDir) !== site.deployment_id) throw new Error('deployment_id must match its deployments/ folder name so default outputs remain independent'); - return { siteDir, outDir: path.resolve(options.outDir ?? path.join('dist', site.deployment_id)), deploymentId: site.deployment_id }; + const defaultOutput = options.reviewDrafts ? path.join('artifacts', 'reviews', site.deployment_id) : path.join('dist', site.deployment_id); + return { siteDir, outDir: path.resolve(options.outDir ?? defaultOutput), deploymentId: site.deployment_id }; } diff --git a/scripts/lib/web.ts b/scripts/lib/web.ts index b26700d..a861207 100644 --- a/scripts/lib/web.ts +++ b/scripts/lib/web.ts @@ -2,43 +2,51 @@ import { createHash } from 'node:crypto'; import { copy as c } from '../../src/copy/en.ts'; import { card, escape as e, resourcePath, formatDate } from '../../src/presentation.ts'; -import { categoryGroups } from '../../src/domain.ts'; +import { browseViewPaths, categoryGroups, inBrowseView, type BrowseView } from '../../src/domain.ts'; import type { PublicDataset, Site, Usage } from '../../src/types.ts'; export const digest = (input: string | Uint8Array): string => createHash('sha256').update(input).digest('hex'); -export type Page = { path: string; title: string; description: string; body: string; indexable: boolean; jsonld?: unknown }; +export type Page = { path: string; title: string; description: string; body: string; indexable: boolean; directory?: boolean; review?: boolean; jsonld?: unknown }; export type Assets = { js: string; css: string[]; logo: string; icon192: string; icon512: string; font: string; brand: string }; export function pages(site: Site, data: PublicDataset, usage?: Usage): Page[] { - const cta = `

${c.simple} · ${c.download}

`; + const reviewDrafts = data.resources.some(resource => resource.publication_status === 'draft'); + const browseLinks = `${c.browseViews.emergency} · ${c.browseViews.affordable}`; + const cta = `

${browseLinks} · ${c.simple} · ${c.download}

`; const warning = `

${c.warning}

`; - const resources = data.resources.map(r => card(r, data, site)).join(''); - const listData = { '@context': 'https://schema.org', '@type': 'CollectionPage', name: site.site_name, url: `${site.canonical_origin}/`, description: site.description, mainEntity: { '@type': 'ItemList', itemListElement: data.resources.map((r, index) => ({ '@type': 'ListItem', position: index + 1, url: site.canonical_origin + resourcePath(r.id), name: r.name })) } }; - const groups = categoryGroups(site, data.resources); - const output: Page[] = [{ - path: '/', title: site.site_name, description: site.description, indexable: true, jsonld: listData, - body: `

${e(c.headline(site.community.name))}

${e(site.presentation?.introduction ?? c.introduction)}

- ${site.help_contact ? `` : ''}
-
-

${c.directory}

${data.data_updated_on ? `${c.listingsUpdated} · ${formatDate(data.data_updated_on, site)}` : ''}

- -

${c.scheduleDisclaimer} ${c.notLive} ${c.callBefore}

-

${c.resourceCount(data.resources.length)}

-
${resources}
- ${aboutSection(site)}` - }]; + const directoryPage = (view: BrowseView): Page => { + const path = browseViewPaths[view], label = c.browseViews[view], description = c.browseDescriptions[view]; + const resources = data.resources.filter(resource => inBrowseView(resource, view)); + const groups = categoryGroups(site, resources); + const listData = { '@context': 'https://schema.org', '@type': 'CollectionPage', name: view === 'emergency' ? site.site_name : `${label} | ${site.site_name}`, url: site.canonical_origin + path, description, mainEntity: { '@type': 'ItemList', itemListElement: resources.map((r, index) => ({ '@type': 'ListItem', position: index + 1, url: site.canonical_origin + resourcePath(r.id), name: r.name })) } }; + const viewNavigation = ``; + return { + path, title: view === 'emergency' ? site.site_name : `${label} | ${site.site_name}`, description, indexable: true, directory: true, jsonld: listData, + body: `

${e(c.headline(site.community.name))}

${e(site.presentation?.introduction ?? c.introduction)}

+ ${site.help_contact ? `` : ''}
+
+

${c.directory}

${data.data_updated_on ? `${c.listingsUpdated} · ${formatDate(data.data_updated_on, site)}` : ''}

+ ${viewNavigation}

${e(description)}

+ +

${c.scheduleDisclaimer} ${c.notLive} ${c.callBefore}

+

${c.resourceCount(resources.length, view, reviewDrafts)}

+
${resources.map(r => card(r, data, site)).join('') || `

${c.noListings(view)}

`}
+ ${aboutSection(site)}`, + }; + }; + const output: Page[] = [directoryPage('emergency'), directoryPage('affordable')]; for (const r of data.resources) { const organization = data.organizations.find(o => o.id === r.organization_id)!; const url = site.canonical_origin + resourcePath(r.id); const service = { '@type': 'Service', '@id': `${url}#service`, name: r.name, description: r.summary, url, serviceType: r.categories.map(cat => c.categories[cat]), provider: { '@type': 'Organization', name: organization.name, ...(organization.url ? { url: organization.url } : {}) }, ...(r.service_area ? { areaServed: r.service_area } : {}), ...(r.location?.address ? { availableChannel: { '@type': 'ServiceChannel', serviceLocation: { '@type': 'Place', name: r.location.description, address: { '@type': 'PostalAddress', streetAddress: r.location.address.street, addressLocality: r.location.address.locality, addressRegion: r.location.address.region, addressCountry: r.location.address.country, ...(r.location.address.postal_code ? { postalCode: r.location.address.postal_code } : {}) } } } } : {}) }; output.push({ path: resourcePath(r.id), title: `${r.name} | ${site.site_name}`, description: r.summary, indexable: true, body: warning + card(r, data, site, true) + cta, jsonld: { '@context': 'https://schema.org', '@type': 'WebPage', name: r.name, description: r.summary, url, inLanguage: r.content_language ?? data.content_language, dateModified: r.updated_on, mainEntity: service, citation: r.evidence.flatMap(item => item.url ? [item.url] : []) } }); } - output.push({ path: '/directory/', title: `${c.simple} | ${site.site_name}`, description: site.description, indexable: false, body: `

${c.simple}

${warning}${cta}

${c.downloadNote}

${data.resources.map(r => card(r, data, site, false, 2).replace(/])/, '
${c.simple}${warning}${cta}

${c.simpleViews}

${c.downloadNote}

${data.resources.map(r => card(r, data, site, false, 2).replace(/])/, '
output.push({ path, title: `${title} | ${site.site_name}`, description: `${title}. ${site.description}`, body: `

${title}

${body}`, indexable }); page('/methodology/', c.methodology, `

${c.methodologyText}

${c.methodologyScope}

${c.reviewInterval(site.review.interval_days)}

${warning}

${c.holidayText}

${site.jurisdiction ? `

${c.holidayCoverage}: ${e(site.jurisdiction.coverage_through)}

    ${site.jurisdiction.warning_dates.map(item => `
  • ${e(item.date)}: ${e(item.note)} ${c.source} (${c.checked}: ${e(item.checked_on)})
  • `).join('')}
` : ''}

${c.json}

`); page('/privacy/', c.privacy, `

${c.privacyText}

${c.privacyOffline}

${site.analytics?.enabled ? `

${c.analyticsOn}

${e(site.analytics.disclosure)}

${c.analyticsExplanation}

${e(new URL(site.analytics.endpoint!).origin)}

${analyticsChoice()}` : `

${c.analyticsOff}

`}`); @@ -46,7 +54,7 @@ export function pages(site: Site, data: PublicDataset, usage?: Usage): Page[] { page('/licenses/', c.licenses, `

${c.softwareLicense} MPL-2.0

${sourceLink(site)}

${c.attribution}

${c.fontLicense} OFL

${c.dataLicense}

${e(site.data_rights.statement)}

${site.data_rights.url ? `${e(site.data_rights.license ?? site.data_rights.url)}` : ''}`); if (usage) page('/usage/', c.usage, `

${c.usageIntro}

${c.usagePeriod}: ${e(usage.period_start)} – ${e(usage.period_end)}

${c.usageGenerated}: ${e(usage.generated_at)}

${Object.entries(usage.counts).map(([key, value]) => `
${c.usageCounts[key as keyof typeof c.usageCounts]}
${value === null ? c.usageMissing : new Intl.NumberFormat(site.locale).format(value)}
`).join('')}
`, false); page('/404.html', c.notFound, `

${c.notFoundText}

`, false); - return output; + return output.map(page => ({ ...page, review: reviewDrafts })); } function analyticsChoice(): string { @@ -65,12 +73,12 @@ function aboutSection(site: Site): string { export function document(page: Page, site: Site, assets: Assets, production: boolean, standaloneCSS?: string): string { const index = production && !site.example_content && site.indexing?.enabled === true && page.indexable; const json = page.jsonld ? JSON.stringify(page.jsonld).replace(/${e(page.title)}${standalone ? `` : `${[...assets.css, assets.brand].map(css => ``).join('')}`}${json ? `` : ''} + const standalone = standaloneCSS !== undefined, home = page.directory === true; + const links = [['/', c.browseViews.emergency], ['/affordable-food/', c.browseViews.affordable], ['/directory/', c.simple], ['/methodology/', c.methodology], ['/privacy/', c.privacy], ['/about/', c.about], ['/licenses/', c.licenses], ['/data/v1/resources.json', c.json], ...(site.public_usage?.enabled ? [['/usage/', c.usage]] : [])]; + return `${e(page.title)}${standalone ? `` : `${page.review ? '' : ''}${[...assets.css, assets.brand].map(css => ``).join('')}`}${json ? `` : ''} -
${!home ? `` : ''}${site.example_content ? `

${c.example}

` : ''}${page.body}${standalone ? `

${c.licenses}

${e(site.data_rights.statement)}

${c.softwareLicense} MPL-2.0 · ${c.attribution}

${sourceLink(site)}
` : ''} +
${page.review ? `

${c.draftPreview}

` : ''}${!home ? `` : ''}${site.example_content ? `

${c.example}

` : ''}${page.body}${standalone ? `

${c.licenses}

${e(site.data_rights.statement)}

${c.softwareLicense} MPL-2.0 · ${c.attribution}

${sourceLink(site)}
` : ''} ${standalone ? '' : `${!home ? `

` : ''}
`}
${!home ? `` : ''}
${standalone ? '' : ``}`; diff --git a/scripts/serve.ts b/scripts/serve.ts index d8e1e64..1189dd3 100644 --- a/scripts/serve.ts +++ b/scripts/serve.ts @@ -6,9 +6,9 @@ import path from 'node:path'; import { pathToFileURL } from 'node:url'; import { argumentsFor, selection, starterSite } from './lib/selection.ts'; import { responseHeaders } from './lib/headers.ts'; -export async function serve(options: { root?: string | (() => string); siteDir?: string; port?: number; watch?: boolean; fail?: (route: string) => boolean } = {}) { +export async function serve(options: { root?: string | (() => string); siteDir?: string; port?: number; watch?: boolean; reviewDrafts?: boolean; fail?: (route: string) => boolean } = {}) { const port = options.port ?? 4173; - const fallbackRoot = options.root ? undefined : (await selection({ siteDir: options.siteDir })).outDir; + const fallbackRoot = options.root ? undefined : (await selection({ siteDir: options.siteDir, reviewDrafts: options.reviewDrafts })).outDir; const mime: Record = { '.html': 'text/html; charset=utf-8', '.js': 'text/javascript', '.css': 'text/css', '.json': 'application/json', '.xml': 'application/xml', '.webmanifest': 'application/manifest+json', '.svg': 'image/svg+xml', '.png': 'image/png', '.woff2': 'font/woff2', '.txt': 'text/plain; charset=utf-8' }; const server = createServer(async (request, response) => { try { @@ -29,8 +29,8 @@ export async function serve(options: { root?: string | (() => string); siteDir?: await new Promise(resolve => server.listen(port, '127.0.0.1', resolve)); console.log(`Food Help static preview: http://127.0.0.1:${port}`); if (options.watch) { let timer: ReturnType, rebuilding = false; - for (const directory of ['src', options.siteDir ?? starterSite, 'schemas', 'scripts']) watch(directory, { recursive: true }, () => { clearTimeout(timer); timer = setTimeout(async () => { if (rebuilding) return; rebuilding = true; try { const { build } = await import('./build.ts'); await build({ siteDir: options.siteDir, outDir: typeof options.root === 'string' ? options.root : fallbackRoot }); } catch (error) { console.error(error); } finally { rebuilding = false; } }, 300); }); + for (const directory of ['src', options.siteDir ?? starterSite, 'schemas', 'scripts']) watch(directory, { recursive: true }, () => { clearTimeout(timer); timer = setTimeout(async () => { if (rebuilding) return; rebuilding = true; try { const { build } = await import('./build.ts'); await build({ siteDir: options.siteDir, outDir: typeof options.root === 'string' ? options.root : fallbackRoot, reviewDrafts: options.reviewDrafts }); } catch (error) { console.error(error); } finally { rebuilding = false; } }, 300); }); } return server; } -if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { const options = argumentsFor(); const selected = options.siteDir || options.outDir || !process.env.FOOD_HELP_PREVIEW_ROOT ? await selection(options) : undefined; await serve({ root: selected?.outDir ?? process.env.FOOD_HELP_PREVIEW_ROOT, siteDir: options.siteDir, port: Number(options.port ?? process.env.FOOD_HELP_PORT ?? 4173) }); } +if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { const options = argumentsFor(); const selected = options.siteDir || options.outDir || !process.env.FOOD_HELP_PREVIEW_ROOT ? await selection(options) : undefined; await serve({ root: selected?.outDir ?? process.env.FOOD_HELP_PREVIEW_ROOT, siteDir: options.siteDir, reviewDrafts: options.reviewDrafts, port: Number(options.port ?? process.env.FOOD_HELP_PORT ?? (options.reviewDrafts ? 4174 : 4173)) }); } diff --git a/scripts/verify-release.mjs b/scripts/verify-release.mjs index 884df3f..c64ed16 100644 --- a/scripts/verify-release.mjs +++ b/scripts/verify-release.mjs @@ -17,6 +17,16 @@ const built = JSON.parse(await readFile(reportPath, 'utf8')); assert.equal(built.production, true, 'Use the exact production build report'); assert.equal(built.deployment_id, site.deployment_id); const origin = site.canonical_origin; +const browseViews = [ + { id: 'emergency', path: '/', costs: ['free', 'mixed'] }, + { id: 'affordable', path: '/affordable-food/', costs: ['low_cost', 'subsidized', 'mixed'] }, +]; +const inBrowseView = (resources, view) => resources.filter(resource => view.costs.includes(resource.cost?.state)); +const jsonld = html => { + const match = html.match(/