Public
Edited
Apr 18
Importers
Insert cell
Insert cell
Insert cell
Insert cell
viewof planner = weeklyPlanner()
Insert cell
planner
Insert cell
Insert cell
function weeklyPlanner(initialEvents = []) {
const days = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'];
const categories = ['Work', 'Personal', 'Study', 'Other'];
let events = [...initialEvents];
let selectedEventIndex = null;
let currentSearch = "";
let currentCategoryFilter = "All";
let minuteInterval = 60;

const container = html`
<div style="font-family: sans-serif; position: relative;">
<div style="margin-bottom: 10px;">
<button id="addEvent">➕ Add</button>
<button id="editEvent">✏️ Edit</button>
<button id="deleteEvent">🗑️ Delete</button>
<input id="eventSearch" placeholder="🔍 Search event..." style="margin-left:10px;padding:2px 5px;"/>
<label style="margin-left:10px;">Category:
<select id="categoryFilter">
<option value="All">All</option>
${categories.map(cat => html`<option value="${cat}">${cat}</option>`)}
</select>
</label>
<label style="margin-left:10px;">Time Interval:
<input type="range" id="intervalSlider" min="15" max="120" step="15" value="60" />
<span id="intervalLabel">1 hour</span>
</label>
<div id="statusLog" style="margin-top:8px; font-size:12px; color:#333;"></div>
</div>

<div id="eventForm" style="display:none; margin-bottom:10px; padding:8px; border:1px solid #ccc; background:#f9f9f9; border-radius:6px;">
<label>Event: <input id="inputEvent"/></label><br/>
<label>Day:
<select id="selectDay">
${days.map((day, i) => {
const option = document.createElement("option");
option.value = i;
option.textContent = day;
return option;
})}
</select>
</label><br/>
<label>Start: <select id="selectStart"></select></label><br/>
<label>End: <select id="selectEnd"></select></label><br/>
<label>Category:
<select id="inputCategory">
${categories.map(cat => html`<option value="${cat}">${cat}</option>`)}
</select>
</label><br/>
<label>Description:<br/><textarea id="inputDesc" rows="2" style="width:100%;"></textarea></label><br/>
<label>Image: <input type="file" id="inputImage" accept="image/*" /></label><br/>
<button id="saveEvent">✅ Save</button>
<button id="cancelEvent">❌ Cancel</button>
</div>

<div id="eventDetails" style="margin-bottom: 16px; display: none; padding: 12px; border: 1px solid #ccc; border-radius: 8px; background: #f9f9f9;">
<h3>📌 Event Details</h3>
<div id="eventDetailsContent" style="font-size: 14px; line-height: 1.6;"></div>
</div>

<div id="calendarGrid" style="display: grid; grid-template-columns: 60px repeat(7, 1fr); gap: 1px; background:#ccc; position: relative;"></div>
</div>
`;

const statusLog = container.querySelector("#statusLog");
const calendarGrid = container.querySelector("#calendarGrid");
const addBtn = container.querySelector("#addEvent");
const editBtn = container.querySelector("#editEvent");
const deleteBtn = container.querySelector("#deleteEvent");
const searchInput = container.querySelector("#eventSearch");
const categoryFilter = container.querySelector("#categoryFilter");
const slider = container.querySelector("#intervalSlider");
const intervalLabel = container.querySelector("#intervalLabel");

const form = container.querySelector("#eventForm");
const eventInput = container.querySelector("#inputEvent");
const daySelect = container.querySelector("#selectDay");
const startSelect = container.querySelector("#selectStart");
const endSelect = container.querySelector("#selectEnd");
const descInput = container.querySelector("#inputDesc");
const imageInput = container.querySelector("#inputImage");
const inputCategory = container.querySelector("#inputCategory");
const saveBtn = container.querySelector("#saveEvent");
const cancelBtn = container.querySelector("#cancelEvent");

const formatIntervalLabel = mins => mins % 60 === 0 ? `${mins / 60} hour${mins / 60 > 1 ? 's' : ''}` : `${mins} min`;

const generateTimeSlots = step => {
let slots = [];
for (let t = 0; t < 24 * 60; t += step) {
const hour = Math.floor(t / 60);
const min = t % 60;
slots.push(`${hour.toString().padStart(2, '0')}:${min.toString().padStart(2, '0')}`);
}
return slots;
};

const timeStringToMins = timeStr => {
const [h, m] = timeStr.split(":").map(Number);
return h * 60 + m;
};

let timeSlots = generateTimeSlots(minuteInterval);

const populateStartOptions = (defaultStart = null, defaultEnd = null) => {
startSelect.innerHTML = "";
timeSlots.forEach(slot => {
const option = document.createElement("option");
option.value = slot;
option.textContent = slot;
startSelect.appendChild(option);
});
startSelect.value = defaultStart || timeSlots[0];
updateEndOptions(defaultEnd);
};

const updateEndOptions = (defaultEnd = null) => {
const startIndex = timeSlots.indexOf(startSelect.value);
endSelect.innerHTML = "";
for (let i = startIndex + 1; i < timeSlots.length; i++) {
const option = document.createElement("option");
option.value = timeSlots[i];
option.textContent = timeSlots[i];
endSelect.appendChild(option);
}
if (defaultEnd) endSelect.value = defaultEnd;
};

startSelect.onchange = () => updateEndOptions();
categoryFilter.onchange = () => {
currentCategoryFilter = categoryFilter.value;
updateCalendar();
};

const buildCalendarGrid = () => {
calendarGrid.innerHTML = "";
calendarGrid.appendChild(html`<div style="background:#eee"></div>`);
days.forEach(day => {
calendarGrid.appendChild(html`<div style="background:#eee;text-align:center;padding:4px;">${day}</div>`);
});
for (const slot of timeSlots) {
calendarGrid.appendChild(html`<div style="background:#f8f8f8;padding:4px;text-align:right;font-size:12px;">${slot}</div>`);
for (let d = 0; d < 7; d++) {
const cell = html`<div style="background:#fff;height:40px;position:relative;border:1px solid #ddd" data-day="${d}" data-time="${slot}"></div>`;
cell.ondragover = e => e.preventDefault();
cell.ondrop = e => {
const draggedIndex = Number(e.dataTransfer.getData("index"));
if (!isNaN(draggedIndex)) {
const draggedEvent = events[draggedIndex];
const dropStart = slot;
const eventDuration = timeStringToMins(draggedEvent.hour.end) - timeStringToMins(draggedEvent.hour.start);
const newStartMins = timeStringToMins(dropStart);
const newEndMins = newStartMins + eventDuration;
const newEnd = `${String(Math.floor(newEndMins / 60)).padStart(2, '0')}:${String(newEndMins % 60).padStart(2, '0')}`;
events[draggedIndex].day = d;
events[draggedIndex].hour = { start: dropStart, end: newEnd };
selectedEventIndex = null;
updateCalendar();
emitChange();
}
};
calendarGrid.appendChild(cell);
}
}
};

const updateCalendar = () => {
calendarGrid.querySelectorAll(".event-card").forEach(card => card.remove());
const cellHeight = 40;
const colWidth = (calendarGrid.clientWidth - 60 - 6) / 7;

const totalMinutes = 24 * 60;
const pixelsPerMinute = (timeSlots.length * cellHeight) / totalMinutes;

events.forEach((evt, index) => {
if (currentCategoryFilter !== "All" && evt.category !== currentCategoryFilter) return;
if (currentSearch && !evt.event.toLowerCase().includes(currentSearch.toLowerCase())) return;

const startMins = timeStringToMins(evt.hour.start);
const endMins = timeStringToMins(evt.hour.end);
const duration = endMins - startMins;
if (duration <= 0) return;

const topPos = startMins * pixelsPerMinute + cellHeight;
const eventHeight = duration * pixelsPerMinute;
const leftPos = 60 + evt.day * (colWidth + 1);

const cardColor = selectedEventIndex === index ? '#d32f2f' : '#1976d2';

const card = html`
<div class="event-card" draggable="true" style="
position:absolute;
left:${leftPos}px;
width:${colWidth}px;
top:${topPos}px;
height:${eventHeight}px;
background:${cardColor};
color:#fff;
padding:4px;
border-radius:4px;
font-size:12px;
box-sizing: border-box;
z-index:2;
overflow: hidden;">
<div><strong>${evt.event}</strong></div>
<div style="font-size:10px;">${evt.hour.start} ~ ${evt.hour.end}</div>
</div>
`;

card.onclick = e => {
e.stopPropagation();
if (selectedEventIndex === index) {
selectedEventIndex = null;
form.style.display = "none";
container.querySelector("#eventDetails").style.display = "none";
statusLog.textContent = "";
updateCalendar();
return;
}
selectedEventIndex = index;
eventInput.value = evt.event;
daySelect.value = evt.day;
descInput.value = evt.description || "";
inputCategory.value = evt.category || "Other";
populateStartOptions(evt.hour.start, evt.hour.end);
const detailBox = container.querySelector("#eventDetails");
const contentBox = container.querySelector("#eventDetailsContent");
contentBox.innerHTML = `
<strong>📌 ${evt.event}</strong><br/>
<strong>🗓️ Day:</strong> ${days[evt.day]}<br/>
<strong>🕒 Time:</strong> ${evt.hour.start} ~ ${evt.hour.end}<br/>
<strong>🏷️ Category:</strong> ${evt.category || "Other"}<br/>
${evt.description ? `<strong>📝 Description:</strong><br/>${evt.description}<br/>` : ""}
${evt.image ? `<strong>🖼️ Image:</strong><br/><img src="${evt.image}" style="max-width: 300px; margin-top: 8px; border-radius: 4px; border: 1px solid #ccc;"/>` : ""}
`;
detailBox.style.display = "block";
form.style.display = "block";
updateCalendar();
};


card.ondragstart = e => {
e.dataTransfer.setData("index", index);
};

calendarGrid.appendChild(card);
});
};

const emitChange = () => {
container.value = [...events];
container.dispatchEvent(new CustomEvent("input"));
};

addBtn.onclick = () => {
selectedEventIndex = null;
form.style.display = "block";
eventInput.value = "";
descInput.value = "";
imageInput.value = "";
inputCategory.value = "Work";
daySelect.value = "0";
populateStartOptions();
};

editBtn.onclick = () => {
if (selectedEventIndex === null) {
alert("Please select an event first.");
return;
}
const evt = events[selectedEventIndex];
form.style.display = "block";
eventInput.value = evt.event;
daySelect.value = evt.day;
descInput.value = evt.description || "";
inputCategory.value = evt.category || "Other";
populateStartOptions(evt.hour.start, evt.hour.end);
};

saveBtn.onclick = async () => {
const eventText = eventInput.value.trim();
const eventDay = Number(daySelect.value);
const startTime = startSelect.value;
const endTime = endSelect.value;
const description = descInput.value.trim();
const category = inputCategory.value;
const file = imageInput.files[0];
let imageUrl = null;

if (!eventText || !startTime || !endTime || timeStringToMins(endTime) <= timeStringToMins(startTime)) {
alert("Please enter a valid event and time range.");
return;
}

if (file) {
imageUrl = await new Promise(resolve => {
const reader = new FileReader();
reader.onload = () => resolve(reader.result);
reader.readAsDataURL(file);
});
}

const newEvent = {
event: eventText,
day: eventDay,
hour: { start: startTime, end: endTime },
description,
image: imageUrl,
category
};

if (selectedEventIndex === null) {
events.push(newEvent);
} else {
events[selectedEventIndex] = newEvent;
}

form.style.display = "none";
selectedEventIndex = null;
updateCalendar();
emitChange();
};

cancelBtn.onclick = () => {
form.style.display = "none";
};

deleteBtn.onclick = () => {
if (selectedEventIndex === null) {
alert("Please select an event first.");
return;
}
events.splice(selectedEventIndex, 1);
selectedEventIndex = null;
form.style.display = "none";
const detailBox = container.querySelector("#eventDetails");
detailBox.style.display = "none";
updateCalendar();
emitChange();
};

searchInput.oninput = e => {
currentSearch = e.target.value;
updateCalendar();
};

slider.oninput = () => {
minuteInterval = Number(slider.value);
intervalLabel.textContent = formatIntervalLabel(minuteInterval);
timeSlots = generateTimeSlots(minuteInterval);
buildCalendarGrid();
populateStartOptions();
updateCalendar();
};

populateStartOptions();
buildCalendarGrid();
updateCalendar();

container.value = events;
return container;
}
Insert cell
import {html} from "@observablehq/htl"
Insert cell

Purpose-built for displays of data

Observable is your go-to platform for exploring data and creating expressive data visualizations. Use reactive JavaScript notebooks for prototyping and a collaborative canvas for visual data exploration and dashboard creation.
Learn more