A single-file, interactive web calendar that turns a sprawling group-trip Slack channel into a four-week, at-a-glance planner. Built for a friend group travelling in Porto so nobody misses an activity and time collisions are obvious before they become double-bookings.
Deliverable: porto-chapter-calendar.html — one self-contained file, no build step, no
dependencies. Open it in any modern browser.
The brief was to analyse the channel message list and generate a calendar that differentiates activities by commitment level. The explicit requirements, and how each is met:
| Requirement from the brief | How it’s addressed |
|---|---|
| Differentiate confirmed / pending / placeholder | Three visual statuses — colour + fill + border style — plus filter toggles. |
| Main goal: see what’s available at a glance; avoid missing things | Whole trip fits on one screen as a 4-week grid; a “replies due today” banner surfaces time-critical RSVPs. |
| Prevent collisions, or see them clearly when they exist | Automatic clash detection by time-of-day slot, shown as per-day badges and per-tile markers, with a drill-down that lists exactly what conflicts. |
| Relevant for ~4 weeks, may span two months | Fixed range Mon 6 Jul → Sun 2 Aug 2026; renders month boundaries (Aug days are muted/out-of-range). No multi-month scaling needed. |
| Favour the overall picture but allow dynamic drill-down | Grid gives the overview; clicking any tile or day badge opens a detail drawer with the full context. |
| Tell apart long vs short activities (full-day vs a block vs a 1-hour/deadline item) | Duration is encoded independently of status: full-width bars, timed blocks, and slim pills. |
The Slack export gives message timestamps and relative language (“tonight”, “tomorrow”, “next week”) but no absolute dates on most events. Dates were reconstructed from internal evidence rather than guessed:
getyourguide link carrying date_from=2026-07-12 confirmed the kayak Sunday).Two interpretive calls are documented as assumptions in §7 because the thread doesn’t state them outright.
Every activity is classified on two independent axes, plus an optional third (deadlines).
| Status | Meaning | Visual |
|---|---|---|
| Confirmed | Booked or decided (tickets being purchased, slot fixed) | Solid green fill, green left bar |
| Pending | A binding post still inside its RSVP window | Solid ochre fill, ochre left bar |
| Interest | Interest post, poll, or floated idea — not committed | Dashed blue outline, no fill |
The fill weight itself signals commitment: interest reads as a “sketched, unglazed tile”, pending as warm, confirmed as fully glazed-in.
| Length | Examples | Visual |
|---|---|---|
| Full day | Douro tour, Chefs on Fire, day trips | Full-width bar labelled “all day” |
| Part day (morning / afternoon / evening) | Food tour, fado, cocktails | Standard block with a time and a period label |
| Short | Majestic coffee, Livraria slot | Slim pill with just a start time |
Time-of-day isn’t given its own colour to avoid overloading the eye; instead events sort chronologically within a cell (all-day → morning → afternoon → evening), so the day reads top-to-bottom.
Events carrying an RSVP cut-off (deadline) drive the red “replies due today” banner and a
highlighted callout in the detail drawer. Three land on Wed 8 Jul (Douro 10:00, Livraria 12:00,
Chefs on Fire menu 17:00).
Each event occupies time-of-day slots: full-day = morning + afternoon (evenings stay free, so a day tour followed by a night out is not flagged); a part/short event occupies only its own period; a poll with undecided timing occupies nothing.
Two events on the same date that share a slot produce a conflict, at one of two severities:
Conflicts surface at three levels of zoom: a day badge in the grid, an inset outline on each affected tile, and a cross-linked list inside the detail drawer.
The visual identity is drawn from Porto’s own material world: the blue-and-white glazed azulejo tilework that covers the city (São Bento station being the famous example). This grounds the design in the subject rather than a generic calendar look, and deliberately avoids the default AI-design palette (cream background + high-contrast serif + terracotta accent), which would read as templated.
#EAF0F6), azulejo indigo for structure (#122F58),
cobalt accent (#2E63B0). Status hues are the only other colours: glaze green (confirmed), kiln
ochre (pending), cobalt (interest). Port-wine garnet (#B23A2E) is reserved exclusively for
clash warnings, so red always means “conflict”.DOURO, FADO, CHEFS·FIRE) rather than emoji, to stay
consistent with the ceramic register and scan cleanly.Overview-first: a fixed weekday header over four stacked week-rows. Multi-day events (the villa weekend) render as a single spanning bar in a dedicated lane above the day cells, so a continuous stay reads as one thing rather than three separate chips. Today is ringed; past days are muted; the Aug tail is shown as out-of-range hatching so the calendar edges are honest about the range.
Interface language names things by what the user controls (“Show past days”, “Replies due today”, “Reply by 12 PM”), states clash meaning plainly, and keeps the footer honest about provenance and the need to verify times before spending money.
EVENTS, SPANS,
FLOATING). Rendering, filtering, and clash detection derive from that data, so updating the
calendar means editing objects, not markup (see §8).computeClashes) into a lookup map keyed by event id, then
reused by both views and the drawer — O(events² per day), trivial at this scale.Date.UTC, ISO YYYY-MM-DD keys) to avoid timezone drift shifting an
event onto the wrong day.<button>, visible keyboard focus rings,
aria-pressed on toggles, a labelled role="dialog" drawer, and prefers-reduced-motion honoured
for the drawer transition.These are judgement calls the thread doesn’t fully settle — worth confirming with the group:
Europe/Lisbon, DST-aware) via
Intl.DateTimeFormat("en-CA", …), so the highlighted day is correct for every viewer regardless of
their own timezone. The date range is fixed by design (Mon 6 Jul → Sun 2 Aug 2026): while the
real date falls inside that window the current day is ringed and earlier days are dimmed; once the
date is outside the window no day is ringed (nothing breaks — there’s simply no “today” marker).
To reuse this calendar for another trip, change the START date and the range accordingly.All content lives in the <script> block. To change the calendar, edit the data arrays — no other
code needs touching.
Event object shape (EVENTS):
{
id: "douro", // unique string, used for cross-links
title: "Douro Valley boat tour & wine tasting",
tag: "DOURO", // short mono category label shown on the tile
org: "Emeraude L", // who posted it
status: "pending", // "confirmed" | "pending" | "interest"
date: "2026-07-11", // ISO date (omit for floating items)
len: "fullday", // "fullday" | "part" | "short"
start: "08:30", // 24h "HH:MM" (drives morning/afternoon/evening)
end: "16:30", // optional
price: "€90", // optional
loc: "Meet: Largo da Lapa 1", // optional
link: { label:"getyourguide.com", url:"https://..." }, // optional
deadline:{ text:"Commit so the booking can go in",
date:"2026-07-08", time:"10:00" }, // optional → feeds today-banner
options: ["Mon 13 Jul", "Wed 15 Jul"], // optional, for polls
noSlot: true // optional: undecided time → excluded from clash detection
}
SPANS holds multi-day events (startDate / endDate) rendered as a spanning bar.FLOATING holds undated ideas (no date), shown in the shelf below the grid.START date and the 4 in the week-building loop.date, len, and start; no manual conflict list to
maintain.porto-chapter-calendar.html and find the const EVENTS = [ ... ] array in the <script>.id, title,
tag, org, status, and len are required — drop any optional field you don’t need.id (used for cross-links). Save and refresh the browser — that’s it. The
tile, filters, deadline banner, and clash detection all pick it up automatically.// Example: a confirmed morning pastel-de-nata run on Fri 17 Jul
{
id: "natas",
title: "Pastel de nata crawl",
tag: "FOOD",
org: "You",
status: "confirmed",
date: "2026-07-17",
len: "short",
start: "10:00",
loc: "Manteigaria + Fábrica da Nata"
}
SPANS instead, with startDate / endDate (no len/start).FLOATING (omit date); it lands in the “floating ideas” shelf.dresscode field): add it to the object, then add one line in
openDrawer() where the other rows.push([...]) calls are, so it renders in the drawer.tag:"WHATEVER" — tags are free text, nothing else to register.:root, a .chip.<status> rule in the CSS, a matching entry in the openDrawer
status label map, and a filter button in the controls. Doable, but it’s the one change that isn’t
data-only — most needs are met by the three existing statuses.porto-chapter-calendar.html The entire app: markup, CSS, data, and logic in one file
README.md This document
Reconstructed from the #porto Slack thread. Statuses reflect the thread at the inferred “today” (Wed 8 Jul 2026); verify times and prices against the channel before committing money.