Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ Notable user-visible changes to Food Help are recorded here. Community publicati

### Changed

- Category controls now show only primary types represented in the active browsing view, and omit “All types” when only one type is available.
- 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.
2 changes: 1 addition & 1 deletion docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ Branding files must live in the selected folder's `assets/` and are copied with

Jurisdiction configuration contains `coverage_through` and `warning_dates` entries with date, note, source URL and checked date. There is no automatic national holiday library. A local warning suppresses an inferred weekly status unless a provider has a confirmed exception for that date. Expired coverage yields uncertainty. Do not assume that a public holiday means a provider is closed.

The directory title derives from `community.name`. `presentation.introduction` overrides the generic introductory sentence. Optional `presentation.category_groups` entries have a unique `id`, a short `label`, and one or more existing taxonomy `categories`. For example, a “Take-home food” button can include both `groceries` and `food_banks_pantries`. Groups without published resources are hidden. Without configured groups, buttons are generated from the categories present. The built-in “All food help” option always includes all published resources, even categories outside the configured groups; do not define an `all` group yourself. Grouping changes discovery, never resource classification or evidence.
The directory title derives from `community.name`. `presentation.introduction` overrides the generic introductory sentence. Optional `presentation.category_groups` entries have a unique `id`, a short `label`, and one or more existing taxonomy `categories`. For example, a “Take-home food” button can include both `groceries` and `food_banks_pantries`. The first category on a resource is its primary configured filter type; later categories remain descriptive tags and search terms. Groups without a resource of that primary type are hidden. Without configured groups, buttons are generated from all categories present. “All types” appears when two or more groups are available, or when it is needed to keep a resource whose primary category is outside the configured groups reachable. A single represented group is shown by itself. Do not define an `all` group. Grouping changes discovery, never resource classification or evidence.

The compact header and help panel use `help_contact.label` and `help_contact.description`; keep the label brief. `operator.statement` supports plain-text line breaks in the footer. These are reviewed public deployment copy, not HTML templates.

Expand Down
10 changes: 5 additions & 5 deletions scripts/lib/web.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
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 { browseViewPaths, categoryGroups, inBrowseView, type BrowseView } from '../../src/domain.ts';
import { browseViewPaths, categoryFilter, 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; directory?: boolean; review?: boolean; jsonld?: unknown };
Expand All @@ -15,7 +15,7 @@ export function pages(site: Site, data: PublicDataset, usage?: Usage): Page[] {
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 { groups, includeAll } = categoryFilter(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 = `<nav class="browse-view-control" aria-label="${c.browseBy}">${(['emergency', 'affordable'] as BrowseView[]).map(item => `<a href="${browseViewPaths[item]}" data-view="${item}"${item === view ? ' aria-current="page"' : ''}>${c.browseViews[item]}</a>`).join('')}</nav>`;
return {
Expand All @@ -25,9 +25,9 @@ export function pages(site: Site, data: PublicDataset, usage?: Usage): Page[] {
<section class="directory-shell" data-browse-view="${view}" aria-labelledby="directory-heading">
<div class="section-heading"><h2 id="directory-heading">${c.directory}</h2><p class="dataset-status" id="data-status">${data.data_updated_on ? `${c.listingsUpdated} · ${formatDate(data.data_updated_on, site)}` : ''}</p></div>
${viewNavigation}<p class="browse-view-description" id="browse-view-description">${e(description)}</p>
<div id="filters" hidden><fieldset class="category-control"><legend class="sr-only">${c.category}</legend><div class="segmented-control">
<button type="button" data-category="all" aria-pressed="true">${c.all}</button>
${groups.map(group => `<button type="button" data-category="${e(group.id)}" aria-pressed="false">${e(site.presentation?.category_groups ? group.label : c.categories[group.id as keyof typeof c.categories])}</button>`).join('')}
<div id="filters" hidden><fieldset class="category-control"${resources.length ? '' : ' hidden'}><legend class="sr-only">${c.category}</legend><div class="segmented-control">
${includeAll ? `<button type="button" data-category="all" aria-pressed="true">${c.all}</button>` : ''}
${groups.map(group => `<button type="button" data-category="${e(group.id)}" aria-pressed="${includeAll ? 'false' : 'true'}">${e(site.presentation?.category_groups ? group.label : c.categories[group.id as keyof typeof c.categories])}</button>`).join('')}
</div></fieldset><div class="filters">
<label class="search-control"><span>${c.search}</span><input id="search" type="search" autocomplete="off" placeholder="${c.searchPlaceholder}"></label>
<label class="check-control"><input id="scheduled" type="checkbox"><span>${c.scheduledFilter}</span></label>
Expand Down
9 changes: 5 additions & 4 deletions scripts/verify-release.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { createHash } from 'node:crypto';
import { chromium, expect } from '@playwright/test';
import axe from 'axe-core';
import { argumentsFor, selection } from './lib/selection.ts';
import { categoryFilter, resourceInCategoryGroup } from '../src/domain.ts';
const hash = bytes => createHash('sha256').update(bytes).digest('hex');
const args = argumentsFor();
if (!args.siteDir) throw new Error('Explicit --site required for production verification');
Expand Down Expand Up @@ -128,14 +129,14 @@ try {
await page.goto(origin + view.path); await expect(page.locator('.brand')).toHaveText(site.site_name); await expect(page.locator('#filters')).toBeVisible();
await expect(page.locator('.directory-shell')).toHaveAttribute('data-browse-view', view.id);
await expect(page.locator('#resource-list article')).toHaveCount(scoped.length);
const configured = site.presentation?.category_groups ?? [...new Set(scoped.flatMap(resource => resource.categories))].map(category => ({ id: category, categories: [category] }));
const groups = configured.filter(group => group.categories.some(category => scoped.some(resource => resource.categories.includes(category))));
const { groups, includeAll } = categoryFilter(site, scoped);
await expect(page.locator('[data-category]:not([data-category="all"])')).toHaveCount(groups.length);
await expect(page.locator('[data-category="all"]')).toHaveCount(includeAll ? 1 : 0);
for (const group of groups) {
await page.locator(`[data-category="${group.id}"]`).click();
await expect(page.locator('#resource-list article')).toHaveCount(scoped.filter(resource => resource.categories.some(category => group.categories.includes(category))).length);
await expect(page.locator('#resource-list article')).toHaveCount(scoped.filter(resource => resourceInCategoryGroup(site, resource, group)).length);
}
await page.locator('[data-category="all"]').click();
if (includeAll) await page.locator('[data-category="all"]').click();
if (scoped[0]) {
await page.locator('#search').fill(scoped[0].name);
await expect(page.locator(`[data-resource-id="${scoped[0].id}"]`)).toBeVisible();
Expand Down
2 changes: 1 addition & 1 deletion src/copy/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ export const copy = {
locationOptional: 'Location is optional. Browse all listings without it.',
scheduleDisclaimer: 'Times are based on published schedules,', notLive: 'not live availability.', callBefore: 'Call before travelling when you can.',
locationNotice: 'Location stays in memory on this device. Distance is straight-line, not a travel route.', locationFailed: 'Location was unavailable. You can still browse every resource.',
nearest: 'Approximate distance', resourceCount: (n: number, view: 'emergency' | 'affordable', review = false) => review ? `${n} ${n === 1 ? 'option' : 'options'} shown for review in ${view === 'emergency' ? 'Emergency food' : 'Affordable food'}.` : `${n} reviewed ${n === 1 ? 'option' : 'options'} shown in ${view === 'emergency' ? 'Emergency food' : 'Affordable food'}.`, noResults: (view: 'emergency' | 'affordable') => `No ${view === 'emergency' ? 'Emergency food' : 'Affordable food'} options match. Clear the search, choose All types, or turn off the schedule filter.`, noListings: (view: 'emergency' | 'affordable') => view === 'emergency' ? 'No Emergency food listings have completed review for publication yet. Affordable food listings may still be available.' : 'No Affordable food listings have completed review for publication yet. Emergency food listings are still available.',
nearest: 'Approximate distance', resourceCount: (n: number, view: 'emergency' | 'affordable', review = false) => review ? `${n} ${n === 1 ? 'option' : 'options'} shown for review in ${view === 'emergency' ? 'Emergency food' : 'Affordable food'}.` : `${n} reviewed ${n === 1 ? 'option' : 'options'} shown in ${view === 'emergency' ? 'Emergency food' : 'Affordable food'}.`, noResults: (view: 'emergency' | 'affordable', includeAll = true) => `No ${view === 'emergency' ? 'Emergency food' : 'Affordable food'} options match. Clear the search${includeAll ? ', choose All types, or' : ' or'} turn off the schedule filter.`, noListings: (view: 'emergency' | 'affordable') => view === 'emergency' ? 'No Emergency food listings have completed review for publication yet. Affordable food listings may still be available.' : 'No Affordable food listings have completed review for publication yet. Emergency food listings are still available.',
provider: 'Operated by', address: 'Where', today: 'Today', quickAccess: 'Access', area: 'Service area', phone: 'Phone', eligibility: 'Who can use this', limitations: 'Limitations', before: 'Before you go',
access: 'Access requirements', details: 'Before you go & weekly hours', schedule: 'Published schedule', timezone: 'Local timezone', cost: 'Cost', languages: 'Service languages',
foodPurpose: 'Food support provided', staticHours: 'See published hours below; confirm before travelling.', accessUnknown: 'Check access arrangements before travelling.', dropIn: 'Drop-in access', appointmentNeeded: 'Appointment required', registrationNeeded: 'Registration required',
Expand Down
14 changes: 12 additions & 2 deletions src/domain.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,12 +64,22 @@ export function scheduledStatus(resource: Resource, site: Site, now: Date): 'sch
export function scheduledToday(resource: Resource, site: Site, now: Date): boolean {
return ['now', 'later'].includes(publishedSchedule(resource, site, now).kind);
}
export function categoryGroups(site: Site, resources: Resource[]): { id: string; label: string; categories: string[] }[] {
export type CategoryGroup = { id: string; label: string; categories: string[] };
export function resourceInCategoryGroup(site: Site, resource: Resource, group: CategoryGroup): boolean {
if (!site.presentation?.category_groups) return resource.categories.some(category => group.categories.includes(category));
return group.categories.includes(resource.categories[0]!);
}
export function categoryGroups(site: Site, resources: Resource[]): CategoryGroup[] {
const present = new Set(resources.flatMap(resource => resource.categories));
const configured = site.presentation?.category_groups;
if (configured) return configured.filter(group => group.categories.some(category => present.has(category as Resource['categories'][number])));
if (configured) return configured.filter(group => resources.some(resource => resourceInCategoryGroup(site, resource, group)));
return [...present].map(category => ({ id: category, label: category, categories: [category] }));
}
export function categoryFilter(site: Site, resources: Resource[]): { groups: CategoryGroup[]; includeAll: boolean } {
const groups = categoryGroups(site, resources);
const allCovered = resources.every(resource => groups.some(group => resourceInCategoryGroup(site, resource, group)));
return { groups, includeAll: resources.length > 0 && (groups.length > 1 || !allCovered) };
}
export function distanceKm(a: { latitude: number; longitude: number }, b: { latitude: number; longitude: number }): number {
const rad = (n: number) => n * Math.PI / 180, dlat = rad(b.latitude - a.latitude), dlon = rad(b.longitude - a.longitude);
const h = Math.sin(dlat / 2) ** 2 + Math.cos(rad(a.latitude)) * Math.cos(rad(b.latitude)) * Math.sin(dlon / 2) ** 2;
Expand Down
18 changes: 10 additions & 8 deletions src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import configuration from 'virtual:food-help-config';
import type { PublicDataset, RuntimeConfig } from './types.ts';
import { copy as c } from './copy/en.ts';
import { card, formatDate } from './presentation.ts';
import { browseViewFromPath, browseViewPaths, distanceKm, inBrowseView, scheduledToday, searchable, categoryGroups, type BrowseView } from './domain.ts';
import { browseViewFromPath, browseViewPaths, distanceKm, inBrowseView, scheduledToday, searchable, categoryFilter, resourceInCategoryGroup, type BrowseView } from './domain.ts';
import { loadData } from './data-loader.ts';
import { analytics, type EventKind } from './analytics.ts';
type ReviewRuntimeConfig = RuntimeConfig & { review_drafts?: boolean };
Expand Down Expand Up @@ -38,25 +38,27 @@ function render(): void {
if (!dataset || !results) return;
const now = new Date(), query = (search?.value ?? '').normalize('NFKD').replace(/\p{M}/gu, '').toLocaleLowerCase().trim();
const viewResources = dataset.resources.filter(resource => inBrowseView(resource, selectedView));
const groups = categoryGroups(site, viewResources);
const { groups, includeAll } = categoryFilter(site, viewResources);
// Retained or refreshed data may add/remove categories; keep every resource reachable.
const buttons = document.querySelector('.segmented-control');
if (buttons) {
const ids = ['all', ...groups.map(group => group.id)];
if (!ids.includes(selectedCategory)) selectedCategory = 'all';
const options = [...(includeAll ? [{ id: 'all', label: c.all }] : []), ...groups.map(group => ({ id: group.id, label: site.presentation?.category_groups ? group.label : c.categories[group.id as keyof typeof c.categories] }))];
const ids = options.map(option => option.id);
if (!ids.includes(selectedCategory)) selectedCategory = groups.length === 1 && !includeAll ? groups[0]!.id : 'all';
buttons.closest('fieldset')?.toggleAttribute('hidden', options.length === 0);
if (Array.from(buttons.querySelectorAll<HTMLElement>('[data-category]')).map(button => button.dataset.category).join('|') !== ids.join('|')) {
buttons.replaceChildren(...[{ id: 'all', label: c.all }, ...groups.map(group => ({ id: group.id, label: site.presentation?.category_groups ? group.label : c.categories[group.id as keyof typeof c.categories] }))].map(group => {
buttons.replaceChildren(...options.map(group => {
const button = document.createElement('button'); button.type = 'button'; button.dataset.category = group.id; button.textContent = group.label; return button;
}));
}
buttons.querySelectorAll<HTMLElement>('[data-category]').forEach(button => button.setAttribute('aria-pressed', String(button.dataset.category === selectedCategory)));
}
const categories = groups.find(group => group.id === selectedCategory)?.categories;
let resources = viewResources.filter(r => (!query || searchable(r, dataset!.organizations.find(o => o.id === r.organization_id)!.name).includes(query)) && (!categories || r.categories.some(category => categories.includes(category))) && (!scheduled?.checked || scheduledToday(r, site, now)));
const group = groups.find(group => group.id === selectedCategory);
let resources = viewResources.filter(r => (!query || searchable(r, dataset!.organizations.find(o => o.id === r.organization_id)!.name).includes(query)) && (!group || resourceInCategoryGroup(site, r, group)) && (!scheduled?.checked || scheduledToday(r, site, now)));
if (position) resources = [...resources].sort((a, b) => (a.location?.coordinates ? distanceKm(position!, a.location.coordinates) : Infinity) - (b.location?.coordinates ? distanceKm(position!, b.location.coordinates) : Infinity));
const opened = new Set(Array.from(results.querySelectorAll('article:has(details[open])')).map(el => el.getAttribute('data-resource-id')));
const focused = document.activeElement instanceof HTMLElement && results.contains(document.activeElement) ? { id: document.activeElement.closest('[data-resource-id]')?.getAttribute('data-resource-id'), index: Array.from(document.activeElement.closest('article')!.querySelectorAll('a,button,summary')).indexOf(document.activeElement) } : null;
results.innerHTML = resources.map(r => card(r, dataset!, site, false, 3, now)).join('') || `<p class="message-card">${viewResources.length ? c.noResults(selectedView) : c.noListings(selectedView)}</p>`;
results.innerHTML = resources.map(r => card(r, dataset!, site, false, 3, now)).join('') || `<p class="message-card">${viewResources.length ? c.noResults(selectedView, includeAll) : c.noListings(selectedView)}</p>`;
for (const resource of resources) {
const element = results.querySelector(`[data-resource-id="${resource.id}"]`)!;
if (position && resource.location?.coordinates) {
Expand Down
Loading
Loading