Skip to content
Quilium Docs
Back to site
How-to guides

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.

Live example: Le Cinquième. Every snippet on this page comes from Le Cinquième, the demo site of a 120-seat neighbourhood venue. Its 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.

Label auto-filled queries as such. Le Cinquième prefixes them (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

  • limit in the query is not the editor's cap. The query's limit bounds how much data is fetched (100 here); the block's maxItems field is an editorial choice applied while rendering. Keep both.
  • Filtering on now in 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 date field cannot be split this way. If you started with date and now need an upcoming/past split, change the field type to calendar before 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.

The feed is per-locale, like every other page. On Le Cinquième, /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.