How to generate sections and content from csv?

For an event schedule I’d like to take date from a csv table and generate sections by location and within those sections list all events ordered by date.

Suppose I have the following .csv:

title,date,city
Event1,2025-10-11T19:00,"Amsterdam"
Event2,2025-10-01T15:00,"Amsterdam"
Event3,2025-10-17T19:00,"New York"
Event4,2025-10-01T19:30,"New York"
...

I’d like to get a document like this

= Amsterdam
== 01/10/2025
- Event2
- ...
- 
== 11/10/2025
- Event 1
- ...

= New York
...

I’ve already imported the csv and parsed the times into datetime objects so I have an array of dicts like this:

events

(
  (
    title: "Event2",
    date: datetime(
      year: 2025,
      month: 10,
      day: 1,
      hour: 15,
      minute: 0,
      second: 0,
    ),
    city: "Amsterdam",
  ),
  (
  title: "Event4",
...
)

From this also was able to generate an array of unique cities

#let cities = ()
#for event in events{
  cities.push(event.at("city"))
}
#let unique_cities = cities.dedup().sorted()

and use it to generate the city sections

#for unique_city in unique_cities{

  set page(...
  )

  // Section Heading for each city
  align(center, text(heading(unique_city), size: 1.5em))

// Get events for unique city and show them using the event template

for (title, date, city) in events {
  if city == unique_city{
  show: event.with(title: title, date: date, city: city)}
}

}

Now I am stuck at filtering events by city and then generating subsections for unique dates within a city. Also I suspect I’m doing this all in a very roundabout way. If someone could point me in the right direction I’d be grateful.

I would create a nested dictionary where the first key is each city and it’s value is another dict with key the date and value an array of cities:

#let cities-and-events = (:)

#for (event, date, city) in csv("data.csv").slice(1) {
  let city-map = cities-and-events.at(city, default: (:))
  let date-events = city-map.at(date, default: ())
  city-map.insert(date, date-events + (event,))
  cities-and-events.insert(city, city-map)
}
Summary
(
  Amsterdam: (
    "2025-10-11T19:00": ("Event1",),
    "2025-10-01T15:00": ("Event2",),
  ),
  "New York": (
    "2025-10-17T19:00": ("Event3",),
    "2025-10-01T19:30": ("Event4",),
  ),
)

The output would look like this

Then you can iterate through the dictionaries and style/format them as you wish:

#for (city, event-dict) in cities-and-events.pairs() [
  #heading(level: 1, city)  // or `= #city`
  
  #for (date, event) in event-dict.pairs() [
    #heading(level: 2, date)
    #list(..event)
  ]
  
]
Summary


Output

I’ll leave the parsing of the dates up to you since it seems you already managed to do that. Note that my solution does not sort the inputs

Hello. This is an interesting problem. The only problem is sorting, though here it’s not that hard, since you can use default lexicographic sort for any data. But if you have sorted rows, then you can omit it. The only missing part is group-by(), which isn’t that hard to implement:

#let group-by(data, key) = {
  let grouped-data = (:)
  for row in data {
    let value = row.at(key)
    _ = row.remove(key)
    grouped-data += ((value): grouped-data.at(value, default: ()) + (row,))
  }
  grouped-data
}


#let file = ```csv
title,date,city
Event1,2025-10-11T19:00,"Amsterdam"
Event2,2025-10-01T15:00,"Amsterdam"
Event3,2025-10-17T19:00,"New York"
Event4,2025-10-01T19:30,"New York"
Event5,2025-10-01T19:30,"New York"
```.text

#let data = csv(bytes(file), row-type: dictionary)
#let per-city-data = group-by(data.sorted(key: it => it.city), "city")
#let per-city-per-date-data = for (city, data) in per-city-data {
  ((city): group-by(data.sorted(key: it => (it.date, it.title)), "date"))
}

#for (city, per-date-events) in per-city-per-date-data {
  heading(city)
  for (date, events) in per-date-events [
    == #date
    #list(..events.map(x => x.values()).flatten())
  ]
}

At == #date you can format the date how you want. See Add support for parsing datetimes from strings · Issue #4107 · typst/typst · GitHub.

Hi @Michael, don’t forget to tick :ballot_box_with_check: one of the responses if you got a satisfying answer. The answer you choose should usually be the response that you found most correct/helpful/comprehensive for the question you asked. If something is missing, please let us know what so we can resolve your issue. Thanks!