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 with live counts. |
| Main goal: see what’s available at a glance; avoid missing things | Whole trip fits on one screen as a 4-week grid; a collapsible “Ideas TBD” section surfaces undated activities. |
| Prevent collisions, or see them clearly when they exist | Automatic overlap 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.
Note: The “pending” status is rarely used in practice. Most events are either confirmed (green) or interest (blue). The filter buttons show live counts for each status.
| 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.
Each event title is prefixed with a category emoji for quick visual scanning. These represent the kind of activity (duration and context), not the specific content:
| Emoji | Category | Examples |
|---|---|---|
| ⚽ | Sports / Match | World Cup matches |
| 🏄 | Sports / Water | Kayak |
| 🏖️ | Sports / Leisure | Beach day |
| 🧗 | Sports / Climbing | Via Ferrata |
| 🏛️ | City / Tour | Food tour, bookstore, café |
| 🚶 | City / Walk | Walking tour |
| 🚌 | Full-day trip | Day tours, Chefs on Fire |
| 🍽️ | Food / Meal | Brunch, francesinha lunch |
| 🍸 | Food / Drinks | Cocktails, bar crawl |
| 🎵 | Entertainment / Music | Fado concerts |
| 🎭 | Entertainment / Theatre | Stand-up comedy |
| 🎬 | Entertainment / Cinema | IMAX movie |
| 💼 | Professional / Skill share | B2B sales, autism talk, SEO |
| 🧳 | Travel / Logistics | Checkout |
| 🏡 | Accommodation | Villa weekend |
| 🎨 | Workshop | Tile-painting |
Events carrying an RSVP cut-off (deadline) drive a highlighted callout in the detail drawer.
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:
All overlaps are treated as “competing options” rather than “hard clashes” since these are group activities where the group simply needs to choose. 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: each 7-day week splits into two grid rows, Mon–Thu (4 cells) then Fri–Sun (3 cells, centered at 85% width), so no row ever needs to shrink cells below a readable size. Weekday names live inside each day cell (no separate header row) since the two row shapes don’t share one column layout. 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; a fully-past row disappears entirely once “Show past rows” is off (default), rather than leaving a blank gap. The Aug tail is shown as out-of-range hatching so the calendar edges are honest about the range. Below 640px, both row shapes collapse to 2 columns; below 820px the page opens straight into Agenda view.
Weekend styling: Saturday and Sunday day numbers are displayed in firebrick red to visually distinguish weekends from weekdays.
Collapsible sections: The “Ideas TBD” section at the top is collapsed by default with a counter showing the number of undated activities. Click to expand and see the floating ideas.
Legend icon: A small blue ⓘ icon in the bottom-right of the hero section reveals a popover with clash indicator meanings on hover.
Status filters: The filter buttons (Confirmed / Pending / Interest) are consolidated into a single row with live counts, replacing the separate tally pills that were previously in the hero.
Interface language names things by what the user controls (“Show past rows”, “Reply by 12 PM”), states overlap meaning plainly, and keeps the footer honest about provenance and the need to verify times before spending money.
EVENTS, SPANS,
FLOATING). Rendering, filtering, and overlap 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 all transitions.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", // prefix with category emoji
tag: "DOURO", // short mono category label shown on the tile
org: "Emeraude L", // who posted it
status: "confirmed", // "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
post: "https://wifitribe.slack.com/archives/C0.../p123...", // optional, permalink to the source Slack post
deadline:{ text:"Commit so the booking can go in",
date:"2026-07-08", time:"10:00" }, // optional → feeds drawer callout
options: ["Mon 13 Jul", "Wed 15 Jul"], // optional, for polls
noSlot: true // optional: undecided time → excluded from overlap detection
}
SPANS holds multi-day events (startDate / endDate) rendered as a spanning bar.FLOATING holds undated ideas (no date), shown in the “Ideas TBD” section.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, and overlap 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 “Ideas TBD” section.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
Notes and ideas _jul26.md Working scratchpad with decisions and troubleshooting
development_runbook_jul26.md Detailed chronological log of changes
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.