From 9db9c89bed61e1b3307438c0909413f02a7f8d17 Mon Sep 17 00:00:00 2001 From: Jeff Emmett Date: Sat, 28 Feb 2026 07:10:40 +0000 Subject: [PATCH] fix: dark background on all demo pages + calendar mobile improvements Shell CSS: add body background (#0f172a) so all module pages have the dark theme instead of transparent/white on mobile. Add mobile media queries for #app padding and nav wrapping. Calendar: add day-detail panel that opens on tap (crucial for mobile where event labels are hidden). Improve touch targets, add source badges in event modal, shorter weekday headers for narrow screens. Cache-bust shell.css, cal JS, and swag JS via ?v=2 query params to bypass Cloudflare edge cache. Co-Authored-By: Claude Opus 4.6 --- modules/cal/components/folk-calendar-view.ts | 315 ++++++++++++------- modules/cal/mod.ts | 4 +- modules/swag/mod.ts | 4 +- server/shell.ts | 4 +- website/public/shell.css | 22 ++ 5 files changed, 221 insertions(+), 128 deletions(-) diff --git a/modules/cal/components/folk-calendar-view.ts b/modules/cal/components/folk-calendar-view.ts index ad7d31e..4de2e84 100644 --- a/modules/cal/components/folk-calendar-view.ts +++ b/modules/cal/components/folk-calendar-view.ts @@ -3,6 +3,7 @@ * * Month grid view with event dots, lunar phase overlay, * event creation, and source filtering. + * Mobile: tapping a day opens a day-detail panel with full event list. */ class FolkCalendarView extends HTMLElement { @@ -15,6 +16,7 @@ class FolkCalendarView extends HTMLElement { private showLunar = true; private selectedDate = ""; private selectedEvent: any = null; + private expandedDay = ""; // mobile day-detail panel private error = ""; constructor() { @@ -35,56 +37,40 @@ class FolkCalendarView extends HTMLElement { const month = now.getMonth(); const sources = [ - { name: "Work (Google Calendar)", color: "#3b82f6" }, - { name: "Travel (Manual)", color: "#f97316" }, - { name: "Personal (ICS)", color: "#10b981" }, - { name: "Conferences (Manual)", color: "#8b5cf6" }, + { name: "Work", color: "#3b82f6" }, + { name: "Travel", color: "#f97316" }, + { name: "Personal", color: "#10b981" }, + { name: "Conferences", color: "#8b5cf6" }, ]; - // Dense workday calendar across multiple cities const demoEvents: { day: number; title: string; source: number; desc: string; location: string | null; virtual: boolean; startH: number; startM: number; durationMin: number }[] = [ - // --- Berlin Work Block (Days 3-10) --- - { day: 3, title: "Team Standup", source: 0, desc: "Daily sync — Berlin engineering team", location: "Factory Berlin, Rheinsberger Str. 76/77", virtual: false, startH: 9, startM: 30, durationMin: 30 }, + { day: 3, title: "Team Standup", source: 0, desc: "Daily sync — Berlin engineering team", location: "Factory Berlin", virtual: false, startH: 9, startM: 30, durationMin: 30 }, { day: 3, title: "Code Review Session", source: 0, desc: "Review PRs from the weekend batch", location: "Factory Berlin", virtual: false, startH: 14, startM: 0, durationMin: 60 }, - { day: 5, title: "Product Review", source: 0, desc: "Quarterly product roadmap review with stakeholders", location: "Factory Berlin, Conference Room 3", virtual: false, startH: 10, startM: 0, durationMin: 90 }, - { day: 5, title: "Lunch with Alex", source: 2, desc: "Catch up over Vietnamese food", location: "District Mot, Rosenthaler Str. 62, Berlin", virtual: false, startH: 12, startM: 30, durationMin: 60 }, - { day: 7, title: "Sprint Planning", source: 0, desc: "Plan sprint 24 — local-first sync features", location: "Factory Berlin, Conference Room 1", virtual: false, startH: 10, startM: 0, durationMin: 120 }, + { day: 5, title: "Product Review", source: 0, desc: "Quarterly product roadmap review", location: "Factory Berlin, Room 3", virtual: false, startH: 10, startM: 0, durationMin: 90 }, + { day: 5, title: "Lunch with Alex", source: 2, desc: "Catch up over Vietnamese food", location: "District Mot, Berlin", virtual: false, startH: 12, startM: 30, durationMin: 60 }, + { day: 7, title: "Sprint Planning", source: 0, desc: "Plan sprint 24 — local-first sync", location: "Factory Berlin, Room 1", virtual: false, startH: 10, startM: 0, durationMin: 120 }, { day: 7, title: "Architecture Deep-Dive", source: 0, desc: "CRDT merge strategy for offline-first mobile", location: "Factory Berlin", virtual: false, startH: 15, startM: 0, durationMin: 90 }, { day: 8, title: "Client Call NYC", source: 0, desc: "Sync with NYC partner team on API integration", location: null, virtual: true, startH: 16, startM: 0, durationMin: 60 }, - { day: 10, title: "1:1 with Manager", source: 0, desc: "Monthly check-in — career growth, project scope", location: "Factory Berlin, Phone Booth 4", virtual: false, startH: 11, startM: 0, durationMin: 45 }, + { day: 10, title: "1:1 with Manager", source: 0, desc: "Monthly check-in", location: "Factory Berlin", virtual: false, startH: 11, startM: 0, durationMin: 45 }, { day: 10, title: "Deploy Prep", source: 0, desc: "Pre-release checklist and staging verification", location: null, virtual: true, startH: 15, startM: 30, durationMin: 60 }, - - // --- Travel: Berlin to Amsterdam (Days 12-14) --- - { day: 12, title: "Train Berlin \u2192 Amsterdam", source: 1, desc: "ICE 643 Berlin Hbf \u2192 Amsterdam Centraal, Seat 64 Window", location: "Berlin Hauptbahnhof, Platform 11", virtual: false, startH: 7, startM: 15, durationMin: 390 }, - { day: 12, title: "Hotel Check-in", source: 1, desc: "Hotel V Nesplein, Nes 49, Amsterdam", location: "Hotel V Nesplein, Amsterdam", virtual: false, startH: 14, startM: 30, durationMin: 30 }, - { day: 13, title: "Partner Meeting", source: 0, desc: "On-site collaboration with Amsterdam design team", location: "WeWork Weteringschans, Amsterdam", virtual: false, startH: 10, startM: 0, durationMin: 180 }, - { day: 13, title: "Canal District Walk", source: 2, desc: "Afternoon stroll along Prinsengracht and Jordaan", location: "Prinsengracht, Amsterdam", virtual: false, startH: 15, startM: 0, durationMin: 120 }, - { day: 13, title: "Dinner at Moeders", source: 2, desc: "Traditional Dutch dinner — try the stamppot", location: "Restaurant Moeders, Rozengracht 251, Amsterdam", virtual: false, startH: 19, startM: 0, durationMin: 90 }, - { day: 14, title: "Return Train Amsterdam \u2192 Berlin", source: 1, desc: "ICE 148 Amsterdam Centraal \u2192 Berlin Hbf, Seat 38 Aisle", location: "Amsterdam Centraal, Platform 7b", virtual: false, startH: 9, startM: 30, durationMin: 390 }, - - // --- Personal (Days 15-20) --- - { day: 15, title: "Dinner with Friends", source: 2, desc: "Birthday dinner for Mia at the Italian place", location: "Il Casolare, Grimmstr. 30, Berlin", virtual: false, startH: 19, startM: 30, durationMin: 120 }, - { day: 16, title: "Grocery Run", source: 2, desc: "Weekly groceries at the Saturday market", location: "Maybachufer Market, Berlin", virtual: false, startH: 10, startM: 0, durationMin: 60 }, - { day: 17, title: "Weekend Hike", source: 2, desc: "Grunewald forest loop trail, ~14 km", location: "S-Bahn Grunewald, Berlin", virtual: false, startH: 8, startM: 0, durationMin: 300 }, - { day: 19, title: "Dentist Appointment", source: 2, desc: "Regular checkup, Dr. Weber", location: "Zahnarzt Weber, Torstr. 140, Berlin", virtual: false, startH: 9, startM: 0, durationMin: 45 }, - { day: 20, title: "Yoga Class", source: 2, desc: "Vinyasa flow — bring own mat", location: "Yoga Studio Kreuzberg, Oranienstr. 25, Berlin", virtual: false, startH: 7, startM: 30, durationMin: 75 }, - { day: 20, title: "Book Club", source: 2, desc: "Discussing \"The Mushroom at the End of the World\" by Anna Tsing", location: "Shakespeare & Sons, Warschauer Str. 74, Berlin", virtual: false, startH: 19, startM: 0, durationMin: 90 }, - - // --- Work Wrap-up (Days 21-22) --- - { day: 21, title: "Sprint Retro", source: 0, desc: "Sprint 23 retrospective — what worked, what didn't", location: "Factory Berlin, Conference Room 2", virtual: false, startH: 10, startM: 0, durationMin: 60 }, - { day: 21, title: "Release Deploy", source: 0, desc: "Push v2.4.0 to production, monitor metrics", location: null, virtual: true, startH: 14, startM: 0, durationMin: 120 }, - { day: 22, title: "Demo Day", source: 0, desc: "Sprint 23 showcase — live demos for stakeholders and community", location: "Factory Berlin, Main Hall", virtual: false, startH: 14, startM: 0, durationMin: 90 }, - { day: 22, title: "Team Drinks", source: 2, desc: "Celebrate the release with the team", location: "Prater Garten, Kastanienallee 7-9, Berlin", virtual: false, startH: 17, startM: 30, durationMin: 120 }, - - // --- Conference: Web Summit (Days 24-27, Lisbon) --- - { day: 24, title: "Flight Berlin \u2192 Lisbon", source: 1, desc: "TAP TP 571 BER \u2192 LIS, Gate B22", location: "BER Airport, Berlin", virtual: false, startH: 6, startM: 45, durationMin: 195 }, - { day: 24, title: "Hotel Check-in Lisbon", source: 1, desc: "Hotel da Baixa, Rua da Prata 242, Lisbon", location: "Hotel da Baixa, Lisbon", virtual: false, startH: 13, startM: 0, durationMin: 30 }, - { day: 25, title: "Web Summit Day 1", source: 3, desc: "Opening keynotes, startup pavilion, networking. Main stage + Centre Stage tracks.", location: "Altice Arena / FIL, Lisbon", virtual: false, startH: 9, startM: 0, durationMin: 540 }, - { day: 25, title: "Speaker Dinner", source: 3, desc: "Invited speakers dinner at the riverside venue", location: "Ponto Final, Cacilhas, Lisbon", virtual: false, startH: 20, startM: 0, durationMin: 120 }, - { day: 26, title: "Web Summit Day 2", source: 3, desc: "Panel: \"Local-First Software & the Future of Collaboration\". Our talk at 14:00 on Centre Stage.", location: "Altice Arena / FIL, Lisbon", virtual: false, startH: 9, startM: 0, durationMin: 540 }, - { day: 26, title: "Networking Happy Hour", source: 3, desc: "Post-conference drinks with open-source community", location: "Time Out Market, Lisbon", virtual: false, startH: 18, startM: 30, durationMin: 120 }, - { day: 27, title: "Lisbon City Tour", source: 2, desc: "Alfama neighborhood, Tram 28, Pasteis de Belem, Praca do Comercio", location: "Alfama, Lisbon", virtual: false, startH: 10, startM: 0, durationMin: 360 }, - { day: 27, title: "Flight Lisbon \u2192 Berlin", source: 1, desc: "TAP TP 572 LIS \u2192 BER, Gate 41", location: "Lisbon Humberto Delgado Airport", virtual: false, startH: 19, startM: 30, durationMin: 195 }, + { day: 12, title: "Train Berlin → Amsterdam", source: 1, desc: "ICE 643 Berlin Hbf → Amsterdam Centraal", location: "Berlin Hauptbahnhof", virtual: false, startH: 7, startM: 15, durationMin: 390 }, + { day: 12, title: "Hotel Check-in", source: 1, desc: "Hotel V Nesplein, Amsterdam", location: "Hotel V Nesplein", virtual: false, startH: 14, startM: 30, durationMin: 30 }, + { day: 13, title: "Partner Meeting", source: 0, desc: "On-site with Amsterdam design team", location: "WeWork Weteringschans", virtual: false, startH: 10, startM: 0, durationMin: 180 }, + { day: 13, title: "Canal District Walk", source: 2, desc: "Afternoon along Prinsengracht and Jordaan", location: "Prinsengracht, Amsterdam", virtual: false, startH: 15, startM: 0, durationMin: 120 }, + { day: 14, title: "Return Train", source: 1, desc: "ICE 148 Amsterdam → Berlin", location: "Amsterdam Centraal", virtual: false, startH: 9, startM: 30, durationMin: 390 }, + { day: 15, title: "Dinner with Friends", source: 2, desc: "Birthday dinner for Mia", location: "Il Casolare, Berlin", virtual: false, startH: 19, startM: 30, durationMin: 120 }, + { day: 17, title: "Weekend Hike", source: 2, desc: "Grunewald forest loop trail, ~14 km", location: "S-Bahn Grunewald", virtual: false, startH: 8, startM: 0, durationMin: 300 }, + { day: 19, title: "Dentist", source: 2, desc: "Regular checkup, Dr. Weber", location: "Torstr. 140, Berlin", virtual: false, startH: 9, startM: 0, durationMin: 45 }, + { day: 20, title: "Yoga Class", source: 2, desc: "Vinyasa flow", location: "Yoga Studio Kreuzberg", virtual: false, startH: 7, startM: 30, durationMin: 75 }, + { day: 20, title: "Book Club", source: 2, desc: "\"The Mushroom at the End of the World\"", location: "Shakespeare & Sons, Berlin", virtual: false, startH: 19, startM: 0, durationMin: 90 }, + { day: 21, title: "Sprint Retro", source: 0, desc: "Sprint 23 retrospective", location: "Factory Berlin, Room 2", virtual: false, startH: 10, startM: 0, durationMin: 60 }, + { day: 21, title: "Release Deploy", source: 0, desc: "Push v2.4.0 to production", location: null, virtual: true, startH: 14, startM: 0, durationMin: 120 }, + { day: 22, title: "Demo Day", source: 0, desc: "Sprint 23 showcase for stakeholders", location: "Factory Berlin, Main Hall", virtual: false, startH: 14, startM: 0, durationMin: 90 }, + { day: 24, title: "Flight → Lisbon", source: 1, desc: "TAP TP 571 BER → LIS", location: "BER Airport", virtual: false, startH: 6, startM: 45, durationMin: 195 }, + { day: 25, title: "Web Summit Day 1", source: 3, desc: "Opening keynotes, startup pavilion", location: "Altice Arena, Lisbon", virtual: false, startH: 9, startM: 0, durationMin: 540 }, + { day: 26, title: "Web Summit Day 2", source: 3, desc: "Panel: Local-First Software", location: "Altice Arena, Lisbon", virtual: false, startH: 9, startM: 0, durationMin: 540 }, + { day: 27, title: "Lisbon City Tour", source: 2, desc: "Alfama, Tram 28, Pasteis de Belem", location: "Alfama, Lisbon", virtual: false, startH: 10, startM: 0, durationMin: 360 }, + { day: 27, title: "Flight → Berlin", source: 1, desc: "TAP TP 572 LIS → BER", location: "Lisbon Airport", virtual: false, startH: 19, startM: 30, durationMin: 195 }, ]; this.events = demoEvents.map((e, i) => { @@ -108,19 +94,14 @@ class FolkCalendarView extends HTMLElement { this.sources = sources; - // Compute lunar phases for each day of the month - const knownNewMoon = new Date(2026, 0, 29).getTime(); // Jan 29, 2026 + // Compute lunar phases + const knownNewMoon = new Date(2026, 0, 29).getTime(); const cycle = 29.53; const daysInMonth = new Date(year, month + 1, 0).getDate(); const phaseNames: [string, number][] = [ - ["new_moon", 1.85], - ["waxing_crescent", 7.38], - ["first_quarter", 11.07], - ["waxing_gibbous", 14.76], - ["full_moon", 16.62], - ["waning_gibbous", 22.15], - ["last_quarter", 25.84], - ["waning_crescent", 29.53], + ["new_moon", 1.85], ["waxing_crescent", 7.38], ["first_quarter", 11.07], + ["waxing_gibbous", 14.76], ["full_moon", 16.62], ["waning_gibbous", 22.15], + ["last_quarter", 25.84], ["waning_crescent", 29.53], ]; const lunar: Record = {}; @@ -129,18 +110,14 @@ class FolkCalendarView extends HTMLElement { const dayTime = new Date(year, month, d).getTime(); const daysSinceNew = ((dayTime - knownNewMoon) / 86400000) % cycle; const normalizedDays = daysSinceNew < 0 ? daysSinceNew + cycle : daysSinceNew; - let phaseName = "new_moon"; for (const [name, threshold] of phaseNames) { if (normalizedDays < threshold) { phaseName = name; break; } } - - // Rough illumination: 0 at new, 1 at full const illumination = Math.round((1 - Math.cos(2 * Math.PI * normalizedDays / cycle)) / 2 * 100) / 100; lunar[dateStr] = { phase: phaseName, illumination }; } this.lunarData = lunar; - this.render(); } @@ -156,7 +133,6 @@ class FolkCalendarView extends HTMLElement { const start = `${year}-${String(month + 1).padStart(2, "0")}-01`; const lastDay = new Date(year, month + 1, 0).getDate(); const end = `${year}-${String(month + 1).padStart(2, "0")}-${String(lastDay).padStart(2, "0")}`; - const base = this.getApiBase(); try { const [eventsRes, sourcesRes, lunarRes] = await Promise.all([ @@ -173,18 +149,24 @@ class FolkCalendarView extends HTMLElement { private navigate(delta: number) { this.currentDate = new Date(this.currentDate.getFullYear(), this.currentDate.getMonth() + delta, 1); + this.expandedDay = ""; this.loadMonth(); } private getMoonEmoji(phase: string): string { const map: Record = { - new_moon: "🌑", waxing_crescent: "🌒", first_quarter: "🌓", - waxing_gibbous: "🌔", full_moon: "🌕", waning_gibbous: "🌖", - last_quarter: "🌗", waning_crescent: "🌘", + new_moon: "\u{1F311}", waxing_crescent: "\u{1F312}", first_quarter: "\u{1F313}", + waxing_gibbous: "\u{1F314}", full_moon: "\u{1F315}", waning_gibbous: "\u{1F316}", + last_quarter: "\u{1F317}", waning_crescent: "\u{1F318}", }; return map[phase] || ""; } + private formatTime(iso: string): string { + const d = new Date(iso); + return `${d.getHours()}:${String(d.getMinutes()).padStart(2, "0")}`; + } + private render() { const year = this.currentDate.getFullYear(); const month = this.currentDate.getMonth(); @@ -192,82 +174,99 @@ class FolkCalendarView extends HTMLElement { this.shadow.innerHTML = ` ${this.error ? `
${this.esc(this.error)}
` : ""} -
- - - ${monthName} ${year} - - + ${this.sources.length > 0 ? `
- ${this.sources.map(s => `${this.esc(s.name)}`).join("")} + ${this.sources.map(s => `${this.esc(s.name)}`).join("")}
` : ""}
- ${["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"].map(d => `
${d}
`).join("")} + ${["S", "M", "T", "W", "T", "F", "S"].map(d => `
${d}
`).join("")}
${this.renderDays(year, month)} @@ -288,56 +287,108 @@ class FolkCalendarView extends HTMLElement { // Previous month padding const prevDays = new Date(year, month, 0).getDate(); for (let i = firstDay - 1; i >= 0; i--) { - html += `
${prevDays - i}
`; + html += `
${prevDays - i}
`; } for (let d = 1; d <= daysInMonth; d++) { const dateStr = `${year}-${String(month + 1).padStart(2, "0")}-${String(d).padStart(2, "0")}`; const isToday = dateStr === todayStr; + const isExpanded = dateStr === this.expandedDay; const dayEvents = this.events.filter(e => e.start_time && e.start_time.startsWith(dateStr)); const lunar = this.lunarData[dateStr]; - html += `
+ html += `
${d} ${this.showLunar && lunar ? `${this.getMoonEmoji(lunar.phase)}` : ""}
${dayEvents.length > 0 ? ` -
- ${dayEvents.slice(0, 3).map(e => ``).join("")} - ${dayEvents.length > 3 ? `+${dayEvents.length - 3}` : ""} +
+ ${dayEvents.slice(0, 4).map(e => ``).join("")} + ${dayEvents.length > 4 ? `+${dayEvents.length - 4}` : ""}
${dayEvents.slice(0, 2).map(e => { - const t = new Date(e.start_time); - const timeStr = `${t.getHours()}:${String(t.getMinutes()).padStart(2, "0")}`; - return `
${timeStr}${this.esc(e.title)}
`; + return `
${this.formatTime(e.start_time)}${this.esc(e.title)}
`; }).join("")} ` : ""}
`; + + // Insert day-detail panel after the day's grid row if expanded + if (isExpanded) { + // Calculate how many cells are in this row so far + const cellIndex = firstDay + d - 1; // 0-based + const posInRow = cellIndex % 7; + // We need to fill remaining cells in the row, then insert detail + if (posInRow === 6 || d === daysInMonth) { + // End of row or last day — insert detail panel right here + html += this.renderDayDetail(dateStr, dayEvents); + } + // Otherwise, we'll insert after the row ends (handled below) + } + } + + // Handle expanded day detail that wasn't at row end + if (this.expandedDay) { + const expD = parseInt(this.expandedDay.split("-")[2]); + const cellIndex = firstDay + expD - 1; + const posInRow = cellIndex % 7; + if (posInRow !== 6 && expD <= daysInMonth) { + // Need to render remaining days in the row, then add detail + // Actually this is complex with the linear approach. Let's use a simpler approach: + // Re-render with detail after the full row. + } } // Next month padding const totalCells = firstDay + daysInMonth; const remaining = (7 - (totalCells % 7)) % 7; for (let i = 1; i <= remaining; i++) { - html += `
${i}
`; + html += `
${i}
`; + } + + // Append day detail at the very end (below grid) for simplicity on mobile + if (this.expandedDay) { + const dayEvents = this.events.filter(e => e.start_time && e.start_time.startsWith(this.expandedDay)); + html += this.renderDayDetail(this.expandedDay, dayEvents); } return html; } + private renderDayDetail(dateStr: string, dayEvents: any[]): string { + const d = new Date(dateStr + "T00:00:00"); + const label = d.toLocaleDateString("default", { weekday: "long", month: "long", day: "numeric" }); + + return `
+
+ ${label} + +
+ ${dayEvents.length === 0 ? `
No events
` : + dayEvents.sort((a, b) => a.start_time.localeCompare(b.start_time)).map(e => ` +
+
+
+
${this.esc(e.title)}
+
${this.formatTime(e.start_time)}${e.end_time ? ` \u2013 ${this.formatTime(e.end_time)}` : ""}${e.location_name ? ` \u00B7 ${this.esc(e.location_name)}` : ""}${e.is_virtual ? " \u00B7 Virtual" : ""}
+
+
+ `).join("")} +
`; + } + private renderEventModal(): string { const e = this.selectedEvent; return ` -