-
-
Save simonw/06a5d1f3bf0af81d55a411f32b2f37c7 to your computer and use it in GitHub Desktop.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| <!DOCTYPE html> | |
| <html lang="en"> | |
| <head> | |
| <meta charset="UTF-8"> | |
| <meta name="viewport" content="width=device-width, initial-scale=1.0"> | |
| <title>Open Sauce 2025 Schedule</title> | |
| <style> | |
| * { | |
| margin: 0; | |
| padding: 0; | |
| box-sizing: border-box; | |
| } | |
| body { | |
| font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; | |
| background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); | |
| min-height: 100vh; | |
| color: #333; | |
| } | |
| .container { | |
| max-width: 1200px; | |
| margin: 0 auto; | |
| padding: 20px; | |
| } | |
| .header { | |
| text-align: center; | |
| margin-bottom: 30px; | |
| color: white; | |
| } | |
| .header h1 { | |
| font-size: 2.5rem; | |
| margin-bottom: 10px; | |
| text-shadow: 2px 2px 4px rgba(0,0,0,0.3); | |
| } | |
| .header p { | |
| font-size: 1.2rem; | |
| opacity: 0.9; | |
| } | |
| .download-btn { | |
| background: #4CAF50; | |
| color: white; | |
| border: none; | |
| padding: 12px 24px; | |
| font-size: 1rem; | |
| border-radius: 8px; | |
| cursor: pointer; | |
| margin: 20px auto; | |
| display: block; | |
| box-shadow: 0 4px 8px rgba(0,0,0,0.2); | |
| transition: all 0.3s ease; | |
| } | |
| .download-btn:hover { | |
| background: #45a049; | |
| transform: translateY(-2px); | |
| box-shadow: 0 6px 12px rgba(0,0,0,0.3); | |
| } | |
| .day-tabs { | |
| display: flex; | |
| justify-content: center; | |
| margin-bottom: 30px; | |
| gap: 10px; | |
| flex-wrap: wrap; | |
| } | |
| .day-tab { | |
| background: rgba(255,255,255,0.2); | |
| color: white; | |
| border: none; | |
| padding: 12px 24px; | |
| border-radius: 25px; | |
| cursor: pointer; | |
| transition: all 0.3s ease; | |
| font-size: 1rem; | |
| backdrop-filter: blur(10px); | |
| } | |
| .day-tab.active { | |
| background: rgba(255,255,255,0.9); | |
| color: #667eea; | |
| } | |
| .day-tab:hover { | |
| background: rgba(255,255,255,0.3); | |
| } | |
| .day-content { | |
| display: none; | |
| } | |
| .day-content.active { | |
| display: block; | |
| } | |
| .session-card { | |
| background: white; | |
| border-radius: 12px; | |
| padding: 20px; | |
| margin-bottom: 20px; | |
| box-shadow: 0 4px 12px rgba(0,0,0,0.1); | |
| transition: transform 0.3s ease; | |
| } | |
| .session-card:hover { | |
| transform: translateY(-2px); | |
| } | |
| .session-header { | |
| display: flex; | |
| justify-content: space-between; | |
| align-items: flex-start; | |
| margin-bottom: 15px; | |
| flex-wrap: wrap; | |
| gap: 10px; | |
| } | |
| .session-time { | |
| background: #667eea; | |
| color: white; | |
| padding: 6px 12px; | |
| border-radius: 20px; | |
| font-size: 0.9rem; | |
| font-weight: 500; | |
| } | |
| .session-location { | |
| background: #f0f0f0; | |
| padding: 6px 12px; | |
| border-radius: 20px; | |
| font-size: 0.9rem; | |
| color: #666; | |
| } | |
| .session-title { | |
| font-size: 1.3rem; | |
| font-weight: 600; | |
| color: #333; | |
| margin-bottom: 10px; | |
| } | |
| .session-description { | |
| color: #666; | |
| line-height: 1.6; | |
| margin-bottom: 15px; | |
| } | |
| .speakers { | |
| display: flex; | |
| flex-wrap: wrap; | |
| gap: 12px; | |
| margin-top: 15px; | |
| } | |
| .speaker { | |
| display: flex; | |
| align-items: center; | |
| gap: 8px; | |
| background: #f8f9fa; | |
| padding: 8px 12px; | |
| border-radius: 20px; | |
| font-size: 0.9rem; | |
| } | |
| .speaker-avatar { | |
| width: 24px; | |
| height: 24px; | |
| border-radius: 50%; | |
| background: #667eea; | |
| } | |
| .length-badge { | |
| background: #ffc107; | |
| color: #333; | |
| padding: 4px 8px; | |
| border-radius: 12px; | |
| font-size: 0.8rem; | |
| font-weight: 500; | |
| } | |
| .loading { | |
| text-align: center; | |
| color: white; | |
| font-size: 1.2rem; | |
| margin-top: 50px; | |
| } | |
| @media (max-width: 768px) { | |
| .container { | |
| padding: 15px; | |
| } | |
| .header h1 { | |
| font-size: 2rem; | |
| } | |
| .session-header { | |
| flex-direction: column; | |
| align-items: flex-start; | |
| } | |
| .day-tabs { | |
| flex-direction: column; | |
| align-items: center; | |
| } | |
| .day-tab { | |
| width: 200px; | |
| text-align: center; | |
| } | |
| } | |
| </style> | |
| </head> | |
| <body> | |
| <div class="container"> | |
| <div class="header"> | |
| <h1>Open Sauce 2025</h1> | |
| <p>July 25-27, 2025</p> | |
| <button class="download-btn" onclick="downloadICS()">📅 Download Calendar (ICS)</button> | |
| </div> | |
| <div class="day-tabs"> | |
| <button class="day-tab active" onclick="showDay('friday')">Friday 25th</button> | |
| <button class="day-tab" onclick="showDay('saturday')">Saturday 26th</button> | |
| <button class="day-tab" onclick="showDay('sunday')">Sunday 27th</button> | |
| </div> | |
| <div class="loading" id="loading">Loading schedule...</div> | |
| <div id="friday" class="day-content active"></div> | |
| <div id="saturday" class="day-content"></div> | |
| <div id="sunday" class="day-content"></div> | |
| </div> | |
| <script> | |
| let scheduleData = {}; | |
| async function loadSchedule() { | |
| try { | |
| const response = await fetch('https://raw.githubusercontent.com/simonw/.github/f671bf57f7c20a4a7a5b0642837811e37c557499/schedule.json'); | |
| if (!response.ok) { | |
| throw new Error(`HTTP error! status: ${response.status}`); | |
| } | |
| scheduleData = await response.json(); | |
| renderSchedule(); | |
| document.getElementById('loading').style.display = 'none'; | |
| } catch (error) { | |
| console.error('Error loading schedule:', error); | |
| document.getElementById('loading').textContent = 'Error loading schedule'; | |
| } | |
| } | |
| function renderSchedule() { | |
| const days = ['friday', 'saturday', 'sunday']; | |
| days.forEach(day => { | |
| const dayContainer = document.getElementById(day); | |
| const sessions = scheduleData[day] || []; | |
| dayContainer.innerHTML = sessions.map(session => ` | |
| <div class="session-card"> | |
| <div class="session-header"> | |
| <div> | |
| <span class="session-time">${session.time}</span> | |
| <span class="length-badge">${session.length} min</span> | |
| </div> | |
| <div class="session-location">${session.where}</div> | |
| </div> | |
| <div class="session-title">${session.title}</div> | |
| <div class="session-description">${session.description}</div> | |
| ${session.speakers && session.speakers.length > 0 ? ` | |
| <div class="speakers"> | |
| ${session.speakers.map(speaker => ` | |
| <div class="speaker"> | |
| <img src="${speaker.media}" alt="${speaker.name}" class="speaker-avatar" onerror="this.style.display='none'"> | |
| <span>${speaker.name}</span> | |
| </div> | |
| `).join('')} | |
| </div> | |
| ` : ''} | |
| ${session.moderator ? ` | |
| <div class="speakers"> | |
| <div class="speaker" style="background: #e3f2fd;"> | |
| <img src="${session.moderator.media}" alt="${session.moderator.name}" class="speaker-avatar" onerror="this.style.display='none'"> | |
| <span><strong>Moderator:</strong> ${session.moderator.name}</span> | |
| </div> | |
| </div> | |
| ` : ''} | |
| </div> | |
| `).join(''); | |
| }); | |
| } | |
| function showDay(day) { | |
| // Hide all day contents | |
| document.querySelectorAll('.day-content').forEach(content => { | |
| content.classList.remove('active'); | |
| }); | |
| // Remove active class from all tabs | |
| document.querySelectorAll('.day-tab').forEach(tab => { | |
| tab.classList.remove('active'); | |
| }); | |
| // Show selected day content | |
| document.getElementById(day).classList.add('active'); | |
| // Add active class to clicked tab | |
| event.target.classList.add('active'); | |
| } | |
| function timeToMinutes(timeStr) { | |
| const [time, period] = timeStr.split(' '); | |
| const [hours, minutes] = time.split(':').map(Number); | |
| let totalMinutes = hours * 60 + minutes; | |
| if (period === 'PM' && hours !== 12) { | |
| totalMinutes += 12 * 60; | |
| } else if (period === 'AM' && hours === 12) { | |
| totalMinutes = minutes; | |
| } | |
| return totalMinutes; | |
| } | |
| function minutesToTime(minutes) { | |
| const hours = Math.floor(minutes / 60); | |
| const mins = minutes % 60; | |
| return `${hours.toString().padStart(2, '0')}${mins.toString().padStart(2, '0')}00`; | |
| } | |
| function downloadICS() { | |
| if (!scheduleData || Object.keys(scheduleData).length === 0) { | |
| alert('Schedule not loaded yet. Please wait a moment and try again.'); | |
| return; | |
| } | |
| const icsContent = generateICS(); | |
| const blob = new Blob([icsContent], { type: 'text/calendar' }); | |
| const url = URL.createObjectURL(blob); | |
| const a = document.createElement('a'); | |
| a.href = url; | |
| a.download = 'open-sauce-2025-schedule.ics'; | |
| document.body.appendChild(a); | |
| a.click(); | |
| document.body.removeChild(a); | |
| URL.revokeObjectURL(url); | |
| } | |
| function generateICS() { | |
| const dayDates = { | |
| friday: '20250725', | |
| saturday: '20250726', | |
| sunday: '20250727' | |
| }; | |
| let icsContent = `BEGIN:VCALENDAR | |
| VERSION:2.0 | |
| PRODID:-//Open Sauce//Open Sauce 2025//EN | |
| CALSCALE:GREGORIAN | |
| METHOD:PUBLISH | |
| X-WR-CALNAME:Open Sauce 2025 | |
| X-WR-CALDESC:Open Sauce 2025 Event Schedule | |
| X-WR-TIMEZONE:America/Los_Angeles | |
| BEGIN:VTIMEZONE | |
| TZID:America/Los_Angeles | |
| BEGIN:STANDARD | |
| DTSTART:20241103T020000 | |
| RRULE:FREQ=YEARLY;BYMONTH=11;BYDAY=1SU | |
| TZOFFSETFROM:-0700 | |
| TZOFFSETTO:-0800 | |
| TZNAME:PST | |
| END:STANDARD | |
| BEGIN:DAYLIGHT | |
| DTSTART:20250309T020000 | |
| RRULE:FREQ=YEARLY;BYMONTH=3;BYDAY=2SU | |
| TZOFFSETFROM:-0800 | |
| TZOFFSETTO:-0700 | |
| TZNAME:PDT | |
| END:DAYLIGHT | |
| END:VTIMEZONE | |
| `; | |
| Object.keys(scheduleData).forEach(day => { | |
| const sessions = scheduleData[day]; | |
| const dateStr = dayDates[day]; | |
| sessions.forEach((session, index) => { | |
| const startMinutes = timeToMinutes(session.time); | |
| const endMinutes = startMinutes + parseInt(session.length); | |
| const startTime = minutesToTime(startMinutes); | |
| const endTime = minutesToTime(endMinutes); | |
| const speakers = session.speakers ? session.speakers.map(s => s.name).join(', ') : ''; | |
| const moderator = session.moderator ? `Moderator: ${session.moderator.name}` : ''; | |
| const speakerInfo = [speakers, moderator].filter(s => s).join('\n'); | |
| const description = [ | |
| session.description, | |
| speakerInfo ? `\n${speakerInfo}` : '' | |
| ].filter(d => d).join(''); | |
| icsContent += `BEGIN:VEVENT | |
| DTSTART;TZID=America/Los_Angeles:${dateStr}T${startTime} | |
| DTEND;TZID=America/Los_Angeles:${dateStr}T${endTime} | |
| SUMMARY:${session.title} | |
| DESCRIPTION:${description.replace(/\n/g, '\\n')} | |
| LOCATION:${session.where} | |
| UID:opensauce2025-${day}-${index}@opensauce.com | |
| DTSTAMP:20250101T000000Z | |
| END:VEVENT | |
| `; | |
| }); | |
| }); | |
| icsContent += 'END:VCALENDAR'; | |
| return icsContent; | |
| } | |
| // Load schedule when page loads | |
| loadSchedule(); | |
| </script> | |
| </body> | |
| </html> |
Author
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Notes on how I made this to follow