Build an agenda
Store dated events in a collection and split them into upcoming and past, in chronological order, without writing a line of date logic in the template.
An agenda is a collection whose entries carry a date, queried twice: once for what is coming, once for what is done. The split happens in the query, against server time.
evenement collection drives a single agenda block reused on the home page and the programme page.1. Use calendar, not date
The two are not interchangeable, and the choice decides what you can query.
| Field type | Stores | Use for |
|---|---|---|
date |
a plain day | a publication date, a release date, anything you only sort on |
calendar |
a date and a time, exposed as occurrences | anything you compare against "now": events, sessions, opening slots |
A calendar field exposes its occurrences to queries as _occurrence.start, which is what makes the upcoming/past split possible. A date field cannot do that.
evenement:
name: Events
objectName: Event
icon: ticket
tabsets:
main:
name: Show
fields:
title:
type: text
label: Title
searchable: true
slug:
type: slug
label: Slug
config:
linkedField: title
date:
type: calendar
label: Date & time
required: true
discipline:
type: select
label: Discipline
config:
options:
theatre: Theatre
concert: Concert
lecture: Reading
featured:
type: checkbox
label: Featured
config:
checkedValue: oui
uncheckedValue: non
displays:
- type: datatable
config:
cols: [poster, title, discipline, date]
order:
column: date
sort: asc
filters:
- discipline
- featured
Mark the date required: true. An event with no date falls out of both queries and vanishes from the site with no visible error.
2. Split upcoming from past in the query
Two queries, same collection, opposite comparison against {{now.datetime}}, opposite sort. Upcoming reads soonest-first, past reads most-recent-first.
upcoming:
type: query
label: (auto) Upcoming
config:
itemSet: evenement
limit: 100
where:
and:
- '>=':
'_occurrence.start': '{{now.datetime}}'
sort:
- '_occurrence.start': asc
past:
type: query
label: (auto) Past
config:
itemSet: evenement
limit: 100
where:
and:
- '<':
'_occurrence.start': '{{now.datetime}}'
sort:
- '_occurrence.start': desc
{{now.datetime}} is resolved server-side at render time, so the agenda rolls over on its own. Nothing expires by hand, and there is no scheduled job to run.
(auto) so an editor opening the block sees at a glance which fields they fill and which the engine fills.3. Give the editor one block, not three
Rather than three content-types, declare one with a mode select and let the template pick which query to read. The same block then serves "what's on" on the home page and the full programme elsewhere.
agenda:
name: Agenda (events)
icon: calendar
template: elements/agenda.liquid
tabsets:
main:
name: Content
fields:
title:
type: text
label: Title
mode:
type: select
label: Which events?
default: avenir
config:
options:
avenir: Upcoming
passes: Past
alaune: Featured
maxItems:
type: number
label: "Max count (0 = all)"
Add the three query fields from the previous step to the same tabset. All three resolve on every render; the template consumes one.
4. Render it
{% assign c = content.custom %}
{% assign mode = c.mode %}{% if mode == blank %}{% assign mode = 'avenir' %}{% endif %}
{% if mode == 'avenir' %}{% assign list = c.upcoming %}
{% elsif mode == 'passes' %}{% assign list = c.past %}
{% else %}{% assign list = c.featured %}{% endif %}
{% assign lim = c.maxItems | times: 1 %}
{% if list != blank %}
<div class="grid">
{% for ev in list %}
{% if lim == 0 or forloop.index <= lim %}
{% include '_eventCard', ev: ev, past: isPast %}
{% endif %}
{% endfor %}
</div>
{% else %}
<p>{{ "Nothing coming up right now. Check back soon." | _ }}</p>
{% endif %}
Two details worth copying. c.maxItems | times: 1 coerces the number field to an integer so the comparison is numeric. And an empty agenda gets a real sentence: for a venue between seasons, that is the normal state of the page, not an error.
5. Show the date in the reader's language
<time datetime="{{ ev.date }}">{{ ev.date | date: 'D MMMM YYYY' }}</time>
The date filter formats according to the current locale, so the same template prints 12 mars 2027 and 12 March 2027 with no branching.
Pitfalls
limitin the query is not the editor's cap. The query'slimitbounds how much data is fetched (100 here); the block'smaxItemsfield is an editorial choice applied while rendering. Keep both.- Filtering on
nowin the template does not work. You would have to fetch everything and discard most of it, and pagination would be meaningless. The comparison belongs in the query. - A
datefield cannot be split this way. If you started withdateand now need an upcoming/past split, change the field type tocalendarbefore writing template logic around it.
Serve the agenda as a JSON feed
A template can answer with something other than HTML, and it takes one key. A page-type declares its response headers, and the template writes the body:
agendaFeed:
name: Feed agenda (JSON)
template: 'agendaFeed.liquid'
headers:
content-type: 'application/json; charset=utf-8'
sections:
main:
tabsets:
config:
name: Source
fields:
upcoming:
type: query
label: (auto) Upcoming
config:
itemSet: evenement
limit: 100
where:
and:
- '>=':
'_occurrence.start': '{{now.datetime}}'
sort:
- '_occurrence.start': asc
Then one hidden page on that type — state: active, visible: false — and a template with no {% layout %}: a single line of chrome would break the document.
{%- assign origine = 'https://' | append: request.host -%}
{
"site": {{ globals.siteName | default: '' | json }},
"generated": {{ now.datetime | default: '' | json }},
"count": {{ page.custom.upcoming.size | default: 0 }},
"events": [
{%- for ev in page.custom.upcoming %}
{
"title": {{ ev.title | default: '' | json }},
"url": {{ 'evenement' | url: evenement: ev | prepend: origine | json }},
"start": {{ ev.date.start | default: '' | json }},
"end": {{ ev.date.end | default: '' | json }},
"summary": {{ ev.excerpt | default: '' | json }}
}{%- unless forloop.last -%},{%- endunless -%}
{%- endfor %}
]
}
Two rules hold the whole file together.
Every value goes through | json. The filter writes the quotes and escapes the content, so never type "{{ ev.title }}" by hand — one apostrophe in a title and the document is broken. Verified on Le Cinquième: a summary containing s'invite round-trips correctly.
Every value that might be absent goes through | default: first. This is the sharp edge. A missing value renders as nothing at all, so "key": {{ absent | json }} emits "key": , — invalid JSON, served with a 200 and an application/json content type. Nothing warns you; the consumer's parser is what finally complains.
Three constraints worth knowing before you start
sections is required, even with no HTML. A page-type without it fails validation. Declare one and let the template ignore it.
A slug cannot contain a dot, so the feed cannot live at /agenda.json — that request is rejected at page creation with a slug validation error. Ours is at /agenda-feed. The content-type header is what identifies the response, not an extension, and every consumer that matters reads the header. It does mean a URL that looks less like a feed than people expect.
There is no site-origin variable. site.url is empty, so an absolute URL is built from request.host, which is what the snippet above does. Feeds need absolute URLs — a relative one is meaningless the moment the document leaves the site.
/fr/agenda-feed and /en/agenda-feed return the same events with their translated titles — "Piano sous la lune" and "Piano Under the Moon". Two feeds, one template, nothing to declare. Publish the one that matches the audience, or both.Everything here generalises: the same headers key serves an RSS feed, a sitemap, a .ics calendar or a CSV export. Only the body changes.
Related
Last updated September 6, 2026.