add calendar modal #123
Labels
No labels
Compat/Breaking
Kind/Bug
Kind/Documentation
Kind/Enhancement
Kind/Feature
Kind/Security
Kind/Testing
Priority
Critical
Priority
High
Priority
Low
Priority
Medium
Reviewed
Confirmed
Reviewed
Duplicate
Reviewed
Invalid
Reviewed
Won't Fix
Status
Abandoned
Status
Blocked
Status
Need More Info
No milestone
No project
No assignees
1 participant
Notifications
Due date
No due date set.
Dependencies
No dependencies set.
Reference
gtfs.zone/coloring-book#123
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Summary
Add a Calendar Modal that gives an at-a-glance overview of when service is scheduled across all services in the loaded feed. The modal has two tabs: a Month Grid (traditional calendar view with colored service chips per day) and a Timeline/Gantt (week-resolution horizontal bars per service with exception ticks). Clicking any service chip/row closes the modal and navigates to that service's edit view. This is read-only — no edits from within the modal. The modal is opened via a new navbar button (next to Fares).
The approach uses no external calendar libraries — all rendering is plain HTML strings via the existing
showModal()infrastructure. Active-service computation follows standard GTFS logic (exceptions override weekly pattern, date range enforced) as a pure function over the already-loaded data.Relevant Context
Files to create
src/modules/calendar-modal.ts— new module, all logic lives hereFiles to modify
src/index.html— add calendar navbar button (between Shapes and Fares)src/index.ts— wire up button click toshowCalendarModal()Types
Calendar,CalendarDates,GTFSTableMapfromsrc/types/gtfs-entities.tsqueryRowsinterface fromsrc/modules/gtfs-database.tsPatterns to follow
showModal()insrc/modules/modal-utils.ts— modal primitive;onMountcallback to attach interactive DOM event listeners after insertionshowFaresModal/showAboutModal— examples of standalone modal functions wired viaindex.tsid="calendar-btn",class="btn btn-ghost btn-sm btn-square hidden md:flex", SVG icononServiceClick(or equivalent) is wired inindex.tsnearservice-view-controllerusageActive-service computation (GTFS spec)
For a given
service_idanddate(YYYYMMDD string):calendar_datesforexception_type=2(removed) → inactivecalendar_datesforexception_type=1(added) → activecalendar:date < start_dateordate > end_date→ inactivecalendar.{sunday,monday,…}→ 1=active, 0=inactivecalendarrow and no exception → inactiveColor palette
Assign a stable color to each service by its index in the sorted list of service IDs. Use a fixed 10-color HSL palette cycling with enough contrast on both dark and light DaisyUI themes.
Navigation callback
showCalendarModaltakes anonServiceClick: (service_id: string) => voidcallback. When a chip or timeline row is clicked the modal closes and the callback fires.Phase 1 — Month Grid tab
Goal: Render a working month-grid view inside the modal. This is the primary view most users will interact with. Build the data loading, active-service computation, and month grid renderer. Add the navbar button and wire everything up.
Create
src/modules/calendar-modal.tswith:CalendarModalDepsinterface:{ gtfsDatabase: { queryRows }, onServiceClick: (service_id: string) => void }PALETTE: string[]— 10 hex or hsl colors for service chips (readable on both dark/light backgrounds; use DaisyUIbadgeinline style approach)getServiceColor(index: number): string— cycles through paletteparseGTFSDate(s: string): Date— parses YYYYMMDD to UTC midnight Date usingDate.UTC(year, month-1, day)formatGTFS(date: Date): string— Date → YYYYMMDDgetDayOfWeek(gtfsDate: string): number— 0=Sun…6=Sat, derived from YYYYMMDD without timezone issues (use UTC)isServiceActive(calendar: Record<string,unknown> | null, exceptions: Record<string,unknown>[], gtfsDate: string): boolean— pure function implementing the 4-step logic above; useNumber(calendar[dayKey]) === 1loadCalendarData(db)— queries allcalendarrows and allcalendar_datesrows, returnsMap<service_id, { calendar, exceptions, color, label }>wherelabelisservice_idtruncated to 8 charsrenderMonthGrid(data: ServiceDataMap, year: number, month: number): string— returns full HTML for the calendar grid:badge badge-xsstyle, inlinebackground-color) for each active servicecalendar_datesentry exists for that day, add a small+(add, green tint on chip) or−(remove, red tint) suffix inside the chip labeldata-service-idandclass="cal-chip cursor-pointer"for click handling+Noverflow badgerenderMonthNav(year: number, month: number): string—< [Month Year] >navigation bar HTML withdata-action="prev-month"/data-action="next-month"buttonsshowCalendarModal(deps: CalendarModalDeps): Promise<void>— main entry point:loadCalendarDatashowModal({ title: 'Service Calendar', body, actions: [{label:'Close', onClick:()=>{}}], escapeAction: 0, boxClassName: 'max-w-5xl w-full', onMount })onMount:[data-action="prev-month"]/[data-action="next-month"]to decrement/increment month state, re-render grid HTML in the grid container.cal-chipto readdata-service-id, callclose()thendeps.onServiceClick(service_id)Add
#calendar-btnbutton tosrc/index.htmlnavbar, between#shapes-btnand#fares-btn:calendar-daysoutline SVG (h-5 w-5)btn btn-ghost btn-sm btn-square hidden md:flex,title="Service Calendar"Wire up in
src/index.ts:showCalendarModalfrom./modules/calendar-modal#calendar-btnthat callsshowCalendarModal({ gtfsDatabase: this.gtfsParser.gtfsDatabase, onServiceClick: (service_id) => { /* navigate to service */ } })onServiceClickcallback: navigate to the service using the same mechanism as the browse panel (inspect how service_id navigation works nearpageStateManagerornavigationActionsinindex.ts)Gotchas / invariants:
new Date('YYYYMMDD')(browser-inconsistent timezone). Always useDate.UTC(year, month-1, day).Date.UTC. Keep variables named clearly (month0for 0-indexed,month1for 1-indexed).+Noverflow indicator.isServiceActiveweekday keys:['sunday','monday','tuesday','wednesday','thursday','friday','saturday']— index bygetDayOfWeek()result.Phase 2 — Timeline / Gantt tab
Goal: Implement the Timeline tab with week-level X-axis resolution. One row per service, horizontal bar from
start_datetoend_date, small ticks for exceptions.In
calendar-modal.ts, addrenderTimeline(data: ServiceDataMap): string:minDate/maxDateacross all services (use bothcalendardate ranges andcalendar_datesentries for services with nocalendarrow)minDateback to the nearest Sunday so columns are full weeksweeks: string[]array of YYYYMMDD week-start dates from snappedminDatetomaxDateservice_idcolspanper month)bg-base-100calendar_datesentry in this week, render a small▲(type=1, green) or▼(type=2, red) indicatordata-service-idandclass="timeline-row cursor-pointer"for click handling<div class="overflow-x-auto">for horizontal scrollw-5(20px). A 1-year feed = 52 × 20px = 1040px — fits inmax-w-5xlwith the service label column.Replace the "Coming soon" placeholder in the Timeline tab with
renderTimeline(data)In
onMount: attach click on.timeline-rowelements (sameonServiceClickclose-and-navigate pattern as chips)Handle services with only
calendar_dates(nocalendarrow): derive min/max dates from exceptions; show no bar but show the exception ticksGotchas / invariants:
maxDate - minDate > 3 years, warn with a note and truncate to 3 years fromminDate.calendar.start_date ≤ weekEndANDcalendar.end_date ≥ weekStartAND any weekday incalendaris 1. This avoids computing all 7 days per week per service.loadCalendarData's output anyway.Phase 3 — Month Grid polish
Goal: Three targeted improvements to the Month Grid: scrollable overflow instead of the +N cap, full service IDs with clear visual truncation, and feed-level start/end date markers pulled from
feed_info.txt.Scrollable day cells: Remove the 4-chip cap and overflow badge logic entirely. Change the chips container inside each day cell to
class="flex flex-col gap-0.5 max-h-24 overflow-y-auto". Keepmin-h-16on the outer cell. This lets every service show without an arbitrary cut-off, and the cell scrolls if tall.Full service IDs with clear truncation: In
loadCalendarData, changelabel: sid.length > 8 ? sid.slice(0, 8) : sidtolabel: sid. The chip already hastruncate max-w-fullCSS so it will show ellipsis when the text overflows, which signals truncation to the user. The existingtitle="${esc(sid)}"attribute shows the full ID on hover. No other changes needed.Feed start/end markers:
showCalendarModal, afterloadCalendarData, also calldeps.gtfsDatabase.getAllRows('feed_info')and extractfeed_start_date/feed_end_datefrom the first row (if any). Store asfeedStartDate: string | nullandfeedEndDate: string | null.CalendarModalDepsinterface'sgtfsDatabasetype to includegetAllRows(it currently only declares it; confirm or add if missing).{ feedStartDate, feedEndDate }torenderMonthGridand torerenderGrid(update its signature).renderMonthGrid, for each day cell, after the day number<span>, conditionally append small inline badges:gtfsDate === feedStartDate:<span class="badge badge-xs badge-success ml-1" title="Feed start date">▶</span>gtfsDate === feedEndDate:<span class="badge badge-xs badge-error ml-1" title="Feed end date">◀</span><div class="text-xs text-base-content/60 mb-0.5 flex items-center gap-0.5">to hold the number and any badges inline.feed_infotable is empty or missingfeed_start_date/feed_end_date, skip silently.Gotchas:
getAllRowsis already on thegtfsDatabaseinterface (check the existing usage inloadCalendarData— it usesgetAllRows, notqueryRows).rerenderGridinonMountcapturesyearandmonth1by closure; just pass the feed dates through the closure too.Phase 4 — Timeline polish
Goal: Three improvements to the Timeline tab: auto-fit the service label column to the longest ID so nothing is needlessly truncated; add a weekday-pattern dot column so you can see at a glance which days each service runs; replace browser
titletooltips on exception ticks with DaisyUI tooltips showing a human-friendly date.Fit label column to longest ID:
rowsHtml, computeconst maxIdLen = Math.max(0, ...[...data.keys()].map((k) => k.length)).const labelColPx = Math.max(80, maxIdLen * 7 + 32)(7px per char attext-xs+ 32px padding). Cap at a reasonable max like 300px.w-36 min-w-36 max-w-36(and thew-36 min-w-36in the<th>) withstyle="width:${labelColPx}px;min-width:${labelColPx}px;max-width:${labelColPx}px".truncateon the inner<span>still applies so very long IDs degrade gracefully.Weekday dot column:
renderWeekdayDots(calendar: Record<string, unknown> | null): stringthat returns 7 dot characters for Mon–Sun order (['monday','tuesday','wednesday','thursday','friday','saturday','sunday']). For each day:●(U+25CF) ifNumber(calendar[key]) === 1, else○(U+25CB). Ifcalendaris null, return 7○dots. Wrap in<span class="font-mono tracking-tight text-base-content/70">.<th>in the header row (after the label<th>, before the month spans): width ~56px (w-14), label<span class="text-xs">M T W T F S S</span>withtracking-tight font-mono text-base-content/50.<tr>, insert a<td>after the label<td>containing the dot string for that service's calendar.<td>is NOT sticky (doesn't need to scroll with the week columns). Give itclass="w-14 min-w-14 px-1 py-1 border-b border-base-300/30 text-xs".DaisyUI tooltips on exception ticks:
formatHumanDate(gtfsDate: string): stringthat parses the YYYYMMDD string and returns a string like"Mon, Jan 15 2026"usingDate.UTC+Date.prototype.toUTCStringor manual formatting withMONTH_ABBRand a day-of-week array.▼(type 2) tick.tooltipworks purely via CSS ([data-tip]pseudo-element), so no JS needed.Gotchas:
<th>header labels "M T W T F S S" are Mon–Sun, which matches the dot order. Include this as a comment in code if the order differs fromWEEKDAY_KEYS(which is Sun-first).tooltip-topensures the tooltip opens upward; the timeline table scrolls horizontally sotooltip-right/tooltip-leftcould get clipped.formatHumanDateshould use UTC methods consistently (no local-time leakage). A clean approach:const d = parseGTFSDate(gtfsDate); const dayName = ['Sun','Mon','Tue','Wed','Thu','Fri','Sat'][d.getUTCDay()]; return \${dayName}, ${MONTH_ABBR[d.getUTCMonth()]} ${d.getUTCDate()} ${d.getUTCFullYear()}`;`Original Issue
Make a calendar modal view to see how far ahead service has been scheduled and when there is holiday service and how they overlap.
One last bug: overlapping cards on the Month Grid