Skip to content

Instantly share code, notes, and snippets.

@simonw

simonw/s.html Secret

Created July 17, 2025 17:25
Show Gist options
  • Save simonw/06a5d1f3bf0af81d55a411f32b2f37c7 to your computer and use it in GitHub Desktop.
Save simonw/06a5d1f3bf0af81d55a411f32b2f37c7 to your computer and use it in GitHub Desktop.
<!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>
@simonw
Copy link
Author

simonw commented Jul 17, 2025

Notes on how I made this to follow

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment