diff --git a/app.js b/app.js index 83096dc..9147a54 100644 --- a/app.js +++ b/app.js @@ -1,354 +1,796 @@ (function () { -"use strict"; - -const $ = id => document.getElementById(id); - -/* ───────────────── ELEMENTS ───────────────── */ -const els = { - prevBtn: $("prevBtn"), - nextBtn: $("nextBtn"), - todayBtn: $("todayBtn"), - monthSelect: $("monthSelect"), - yearSelect: $("yearSelect"), - - grid: $("grid"), - searchInput: $("searchInput"), - dayLabel: $("dayLabel"), - selectedEvents: $("selectedEvents"), - upcomingEvents: $("upcomingEvents"), - - addBtn: $("addBtn"), - editBtn: $("editBtn"), - deleteSideBtn: $("deleteSideBtn"), - exportBtn: $("exportBtn"), - clearAllBtn: $("clearAllBtn"), - - modal: $("eventModal"), - backdrop: $("backdrop"), - eventForm: $("eventForm"), - closeBtn: $("closeBtn"), - cancelBtn: $("cancelBtn"), - deleteBtn: $("deleteBtn"), - - modalTitle: $("modalTitle"), - modalSub: $("modalSub"), - - idInput: $("idInput"), - titleInput: $("titleInput"), - dateInput: $("dateInput"), - endDateInput: $("endDateInput"), - startInput: $("startInput"), - endInput: $("endInput"), - descInput: $("descInput"), - remindInput: $("remindInput"), - colorInput: $("colorInput"), - conflictBox: $("conflictBox"), - - notifBanner: $("notifBanner"), - notifAllowBtn: $("notifAllowBtn"), - notifDismissBtn: $("notifDismissBtn") -}; - -/* ───────────────── STORAGE ───────────────── */ -const STORAGE_KEY = "calendra_lite_events_v2"; -const POPUP_SEEN_KEY = "calendra_lite_popup_seen_v1"; -const NOTIF_SENT_KEY = "calendra_notif_sent_v1"; -const BANNER_DISMISSED_KEY = "calendra_notif_banner_dismissed"; - -/* ───────────────── STATE ───────────────── */ -let events = loadEvents(); -let viewDate = new Date(); -let selectedDate = toDateKey(new Date()); -let editingId = null; -let selectedEventId = null; - -/* ───────────────── INIT ───────────────── */ -init(); - -function init() { - bind(); - initTheme(); - initYearDropdown(); - initMonthDropdown(); - render(); - renderDayPanel(); - registerServiceWorker(); - initNotificationBanner(); - checkPopupReminders(); - checkPassiveReminders(); - setInterval(checkPassiveReminders, 60000); -} + const $ = (id) => document.getElementById(id); + + const els = { + prevBtn: $("prevBtn"), + nextBtn: $("nextBtn"), + todayBtn: $("todayBtn"), + monthSelect: $("monthSelect"), + yearSelect: $("yearSelect"), + + grid: $("grid"), + searchInput: $("searchInput"), + dayLabel: $("dayLabel"), + selectedEvents: $("selectedEvents"), + upcomingEvents: $("upcomingEvents"), + + addBtn: $("addBtn"), + editBtn: $("editBtn"), + deleteSideBtn: $("deleteSideBtn"), + exportBtn: $("exportBtn"), + clearAllBtn: $("clearAllBtn"), + + modal: $("eventModal"), + backdrop: $("backdrop"), + eventForm: $("eventForm"), + closeBtn: $("closeBtn"), + cancelBtn: $("cancelBtn"), + deleteBtn: $("deleteBtn"), + + modalTitle: $("modalTitle"), + modalSub: $("modalSub"), + + idInput: $("idInput"), + titleInput: $("titleInput"), + dateInput: $("dateInput"), + endDateInput: $("endDateInput"), + startInput: $("startInput"), + endInput: $("endInput"), + descInput: $("descInput"), + remindInput: $("remindInput"), + colorInput: $("colorInput"), + + conflictBox: $("conflictBox"), + }; -/* ───────────────── BINDINGS ───────────────── */ -function bind() { - els.prevBtn.onclick = () => { viewDate = addMonths(viewDate,-1); render(); }; - els.nextBtn.onclick = () => { viewDate = addMonths(viewDate,1); render(); }; - - els.todayBtn.onclick = () => { - viewDate = new Date(); - selectedDate = toDateKey(new Date()); - selectedEventId = null; - render(); - renderDayPanel(); - }; - - els.searchInput.oninput = () => { render(); renderDayPanel(); }; - els.addBtn.onclick = () => openModalForDate(selectedDate); - - els.editBtn.onclick = () => { - if(!selectedEventId) return toast("Select an event first"); - openModalForEdit(selectedEventId); - }; - - els.deleteSideBtn.onclick = () => { - if(!selectedEventId) return toast("Select an event first"); - editingId = selectedEventId; - onDelete(); - }; - - els.exportBtn.onclick = exportEvents; - - els.clearAllBtn.onclick = () => { - if(!confirm("Delete ALL events?")) return; - events = []; - saveEvents(events); - selectedEventId = null; - render(); - renderDayPanel(); - }; - - els.closeBtn.onclick = closeModal; - els.cancelBtn.onclick = closeModal; - els.backdrop.onclick = closeModal; - - els.eventForm.onsubmit = e => { e.preventDefault(); onSave(); }; - els.deleteBtn.onclick = onDelete; -} + const STORAGE_KEY = "calendra_lite_events_v2"; + const POPUP_SEEN_KEY = "calendra_lite_popup_seen_v1"; + + let events = loadEvents(); + let viewDate = new Date(); + let selectedDate = toDateKey(new Date()); -/* ───────────────── RENDER CALENDAR ───────────────── */ -function render() { - const y = viewDate.getFullYear(); - const m = viewDate.getMonth(); - els.yearSelect.value = y; - els.monthSelect.value = m; + let editingId = null; + let selectedEventId = null; - const first = new Date(y,m,1); - const startDay = first.getDay(); - const daysInMonth = new Date(y,m+1,0).getDate(); + init(); - const cells = []; - for(let i=0;i{ - const cell = document.createElement("div"); + months.forEach((month, index) => { + const option = document.createElement("option"); + option.value = index; + option.textContent = month; + els.monthSelect.appendChild(option); + }); - if(!date){ - cell.className="cell empty"; - els.grid.appendChild(cell); - return; + els.monthSelect.value = viewDate.getMonth(); + + els.monthSelect.addEventListener("change", function () { + const selectedMonth = parseInt(this.value); + viewDate = new Date(viewDate.getFullYear(), selectedMonth, 1); + render(); + }); + } + + function bind() { + els.prevBtn.addEventListener("click", () => { + viewDate = addMonths(viewDate, -1); + render(); + }); + els.nextBtn.addEventListener("click", () => { + viewDate = addMonths(viewDate, 1); + render(); + }); + els.todayBtn.addEventListener("click", () => { + viewDate = new Date(); + selectedDate = toDateKey(new Date()); + selectedEventId = null; + render(); + renderDayPanel(); + }); + + els.searchInput.addEventListener("input", () => { + render(); + renderDayPanel(); + }); + + els.addBtn.addEventListener("click", () => openModalForDate(selectedDate)); + + els.editBtn.addEventListener("click", () => { + if (!selectedEventId) return toast("Select an event first"); + openModalForEdit(selectedEventId); + }); + + els.deleteSideBtn.addEventListener("click", () => { + if (!selectedEventId) return toast("Select an event first"); + editingId = selectedEventId; + onDelete(); + }); + + els.exportBtn.addEventListener("click", exportEvents); + + els.clearAllBtn.addEventListener("click", () => { + if (!confirm("Are you sure you want to delete ALL events?")) return; + events = []; + saveEvents(events); + selectedEventId = null; + render(); + renderDayPanel(); + toast("All events cleared"); + }); + + els.closeBtn.addEventListener("click", closeModal); + els.cancelBtn.addEventListener("click", closeModal); + els.backdrop.addEventListener("click", closeModal); + + // Auto-suggest titles while typing + els.titleInput.addEventListener("input", () => { + const { titleFrequency } = analyzeEventPatterns(); + const input = els.titleInput.value.toLowerCase(); + + const suggestions = Object.keys(titleFrequency) + .filter(title => title.toLowerCase().startsWith(input)) + .sort((a, b) => titleFrequency[b] - titleFrequency[a]); + + if (suggestions.length > 0 && input.length > 0) { + els.titleInput.setAttribute("placeholder", `Suggested: ${suggestions[0]}`); + } + }); + + els.eventForm.addEventListener("submit", (e) => { + e.preventDefault(); + onSave(); + }); + + els.deleteBtn.addEventListener("click", onDelete); + + ["dateInput", "endDateInput", "startInput", "endInput"].forEach(id => + $(id).addEventListener("input", () => updateConflictWarning(editingId)) + ); + + // Smart time suggestion based on weekday + els.dateInput.addEventListener("change", () => { + const { weekdayTimePatterns } = analyzeEventPatterns(); + const selectedDateVal = new Date(els.dateInput.value + "T00:00:00"); + const weekday = selectedDateVal.getDay(); + + if (weekdayTimePatterns[weekday]) { + const sortedTimes = Object.entries(weekdayTimePatterns[weekday]) + .sort((a, b) => b[1] - a[1]); + + if (sortedTimes.length > 0) { + const [timeRange] = sortedTimes[0]; + const [start, end] = timeRange.split("-"); + + // Only autofill if empty + if (!els.startInput.value && !els.endInput.value) { + els.startInput.value = start; + els.endInput.value = end; + } + } + } + }); } - const key = toDateKey(date); - const dayEvents = getEventsOnDate(key) - .filter(ev => !q || (ev.title + ev.description).toLowerCase().includes(q)); + // ---------- RENDER CALENDAR ---------- + function render() { + let anyMatch = false; + const y = viewDate.getFullYear(); + els.yearSelect.value = y; + const m = viewDate.getMonth(); + els.monthSelect.value = m; + + const first = new Date(y, m, 1); + const startDay = first.getDay(); + const daysInMonth = new Date(y, m + 1, 0).getDate(); + + const cells = []; + for (let i = 0; i < startDay; i++) cells.push({ empty: true }); + + for (let d = 1; d <= daysInMonth; d++) { + cells.push({ empty: false, date: new Date(y, m, d) }); + } + + while (cells.length % 7 !== 0) cells.push({ empty: true }); + while (cells.length < 42) cells.push({ empty: true }); + + const q = (els.searchInput.value || "").trim().toLowerCase(); + + els.grid.innerHTML = ""; + cells.forEach(cellData => { + const cell = document.createElement("div"); + + if (cellData.empty) { + cell.className = "cell empty"; + cell.innerHTML = `
`; + els.grid.appendChild(cell); + return; + } + + const date = cellData.date; + const key = toDateKey(date); + + const dayEvents = getEventsOnDate(key) + .filter(ev => !q || formatSearch(ev).includes(q)) + .sort((a, b) => (a.start || "").localeCompare(b.start || "")); + + if (dayEvents.length > 0) { + anyMatch = true; + } + + if (q && dayEvents.length === 0) { + cell.style.display = "none"; + } + + cell.className = "cell"; + if (key === toDateKey(new Date())) cell.classList.add("today"); + if (key === selectedDate) cell.classList.add("selected"); + + cell.addEventListener("click", () => { + selectedDate = key; + selectedEventId = null; + render(); + renderDayPanel(); + }); + + const head = document.createElement("div"); + head.className = "date"; + + const left = document.createElement("span"); + left.textContent = String(date.getDate()); + head.appendChild(left); + + const right = document.createElement("span"); + if (dayEvents.length) { + const pill = document.createElement("span"); + pill.className = "pill"; + pill.textContent = String(dayEvents.length); + right.appendChild(pill); + } + head.appendChild(right); + + const list = document.createElement("div"); + list.className = "events"; + + dayEvents.slice(0, 3).forEach(ev => { + const item = document.createElement("div"); + item.className = "event-chip"; + if (ev.color && ev.color !== "default") { + item.dataset.color = ev.color; + } + + const timeText = (ev.start && ev.end) ? ` ${ev.start}` : ""; + const bell = (ev.remindMode === "popup") ? " ⏰" : ""; + + item.innerHTML = `
${escapeHtml(ev.title)}${timeText}${bell}
`; + + item.addEventListener("click", (e) => { + e.stopPropagation(); + selectedEventId = ev.id; + openModalForEdit(ev.id); + }); + + list.appendChild(item); + }); + + cell.appendChild(head); + cell.appendChild(list); + els.grid.appendChild(cell); + }); + + // "No results" message for search + let noResultEl = document.getElementById("noResults"); + if (!noResultEl) { + noResultEl = document.createElement("div"); + noResultEl.id = "noResults"; + noResultEl.style.textAlign = "center"; + noResultEl.style.padding = "10px"; + noResultEl.style.fontWeight = "bold"; + noResultEl.style.color = "red"; + els.grid.parentNode.appendChild(noResultEl); + } + if (q && !anyMatch) { + noResultEl.textContent = "No events found"; + noResultEl.style.display = "block"; + } else { + noResultEl.style.display = "none"; + } + } - cell.className="cell"; - if(key===toDateKey(new Date())) cell.classList.add("today"); - if(key===selectedDate) cell.classList.add("selected"); + function renderDayPanel() { - cell.onclick=()=>{ - selectedDate=key; - selectedEventId=null; - render(); - renderDayPanel(); - }; + const selectedContainer = els.selectedEvents; + const upcomingContainer = els.upcomingEvents; + + selectedContainer.innerHTML = ""; + upcomingContainer.innerHTML = ""; - cell.innerHTML=`
${date.getDate()}
`; + const selected = new Date(selectedDate + "T00:00:00"); - if(dayEvents.length){ - const pill=document.createElement("span"); - pill.className="pill"; - pill.textContent=dayEvents.length; - cell.querySelector(".date").appendChild(pill); + els.dayLabel.textContent = selected.toLocaleDateString(undefined, { + weekday: "long", + year: "numeric", + month: "long", + day: "numeric" + }); + + const q = (els.searchInput.value || "").trim().toLowerCase(); + + // ===== SELECTED DAY EVENTS ===== + const dayEvents = getEventsOnDate(selectedDate) + .filter(ev => !q || formatSearch(ev).includes(q)) + .sort((a, b) => (a.start || "").localeCompare(b.start || "")); + + if (!dayEvents.length) { + selectedContainer.innerHTML = `
No events for this day.
`; + } else { + dayEvents.forEach(ev => { + const item = createEventCard(ev); + selectedContainer.appendChild(item); + }); } - els.grid.appendChild(cell); - }); + // ===== UPCOMING EVENTS ===== + const upcoming = events + .filter(ev => ev.date > selectedDate) + .sort((a, b) => a.date.localeCompare(b.date)) + .slice(0, 5); + + if (!upcoming.length) { + upcomingContainer.innerHTML = `
No upcoming events.
`; + } else { + upcoming.forEach(ev => { + const item = createEventCard(ev, true); + upcomingContainer.appendChild(item); + }); + } } +function createEventCard(ev, showDate = false) { + + const item = document.createElement("div"); + item.className = "day-item"; -/* ───────────────── DAY PANEL ───────────────── */ -function renderDayPanel(){ - els.selectedEvents.innerHTML=""; - els.upcomingEvents.innerHTML=""; - - const selected = new Date(selectedDate+"T00:00:00"); - els.dayLabel.textContent = selected.toLocaleDateString(undefined,{ - weekday:"long",year:"numeric",month:"long",day:"numeric" - }); - - const q = (els.searchInput.value || "").toLowerCase(); - - const dayEvents = getEventsOnDate(selectedDate) - .filter(ev => !q || (ev.title + ev.description).toLowerCase().includes(q)) - .sort((a,b)=>(a.start||"").localeCompare(b.start||"")); - - if(!dayEvents.length){ - els.selectedEvents.innerHTML=`
No events for this day.
`; - } else { - dayEvents.forEach(ev=>{ - const item = createEventCard(ev,false); - if(ev.id===selectedEventId) item.classList.add("selected"); - item.onclick=()=>{selectedEventId=ev.id; renderDayPanel();}; - els.selectedEvents.appendChild(item); + if (ev.color && ev.color !== "default") { + item.dataset.color = ev.color; + } + + let tag = "All day"; + if (ev.start && ev.end) { + tag = `${ev.start} – ${ev.end}`; + } + + const dateText = showDate ? `
${ev.date}
` : ""; + + item.innerHTML = ` +
+
+
${escapeHtml(ev.title)}
+ ${dateText} +
${escapeHtml(tag)}
+
+ +
+ + +
+
+ `; + + item.querySelector(".edit-btn").addEventListener("click", (e) => { + e.stopPropagation(); + openModalForEdit(ev.id); }); - } - - const upcoming = events - .filter(ev=>ev.date>selectedDate) - .sort((a,b)=>a.date.localeCompare(b.date)) - .slice(0,5); - - if(!upcoming.length){ - els.upcomingEvents.innerHTML=`
No upcoming events.
`; - } else { - upcoming.forEach(ev=>{ - els.upcomingEvents.appendChild(createEventCard(ev,true)); + + item.querySelector(".delete-btn").addEventListener("click", (e) => { + e.stopPropagation(); + editingId = ev.id; + onDelete(); }); - } -} -function createEventCard(ev,showDate){ - const item=document.createElement("div"); - item.className="day-item"; - - let tag="All day"; - if(ev.start && ev.end) tag=`${ev.start} – ${ev.end}`; - - item.innerHTML=` -
-
-
${escapeHtml(ev.title)}
- ${showDate?`
${ev.date}
`:""} -
${tag}
-
-
`; - return item; + return item; } + // ---------- MODAL ---------- + function openModalForDate(dateKey) { + editingId = null; + els.deleteBtn.hidden = true; -/* ───────────────── SAVE / DELETE ───────────────── */ -function onSave(){ - const ev={ - id: editingId || safeUUID(), - title: els.titleInput.value.trim(), - date: els.dateInput.value, - endDate: els.endDateInput.value, - start: els.startInput.value || null, - end: els.endInput.value || null, - description: els.descInput.value.trim(), - remindMode: els.remindInput.value, - color: els.colorInput.value - }; - - if(!ev.title||!ev.date) return toast("Fill required fields"); - - const idx=events.findIndex(e=>e.id===ev.id); - if(idx>=0) events[idx]=ev; - else events.push(ev); - - saveEvents(events); - - selectedDate=ev.date; - selectedEventId=ev.id; - viewDate=new Date(ev.date); - - render(); - renderDayPanel(); - closeModal(); -} + els.modalTitle.textContent = "New event"; + els.modalSub.textContent = "Fill details and click Save."; -function onDelete(){ - if(!editingId) return; - if(!confirm("Delete this event?")) return; + els.idInput.value = ""; + els.titleInput.value = ""; + els.dateInput.value = dateKey; + els.endDateInput.value = dateKey; - events=events.filter(e=>e.id!==editingId); - saveEvents(events); - selectedEventId=null; + els.startInput.value = ""; + els.endInput.value = ""; - render(); - renderDayPanel(); - closeModal(); -} + els.descInput.value = ""; + els.remindInput.value = "off"; + els.colorInput.value = "default"; -/* ───────────────── HELPERS ───────────────── */ -function loadEvents(){ - try{ return JSON.parse(localStorage.getItem(STORAGE_KEY)||"[]"); } - catch{ return []; } -} + els.conflictBox.hidden = true; + showModal(); + } -function saveEvents(list){ - localStorage.setItem(STORAGE_KEY,JSON.stringify(list)); - syncEventsToSW(); -} + function openModalForEdit(id) { + const ev = events.find(e => e.id === id); + if (!ev) return; -function getEventsOnDate(key){ - return events.filter(ev=>key>=ev.date && key<=(ev.endDate||ev.date)); -} + editingId = id; + els.deleteBtn.hidden = false; -function toDateKey(d){ - return d.getFullYear()+"-"+String(d.getMonth()+1).padStart(2,"0")+"-"+String(d.getDate()).padStart(2,"0"); -} + els.modalTitle.textContent = "Edit event"; + els.modalSub.textContent = "Update or delete this event."; -function addMonths(d,n){ - return new Date(d.getFullYear(),d.getMonth()+n,1); -} + els.idInput.value = id; + els.titleInput.value = ev.title || ""; + els.dateInput.value = ev.date; + els.endDateInput.value = ev.endDate || ev.date; -function safeUUID(){ - return crypto.randomUUID?crypto.randomUUID():Date.now()+"_"+Math.random(); -} + els.startInput.value = ev.start || ""; + els.endInput.value = ev.end || ""; -function escapeHtml(s=""){ - return s.replaceAll("&","&").replaceAll("<","<").replaceAll(">",">"); -} + els.descInput.value = ev.description || ""; + els.remindInput.value = ev.remindMode || "off"; + els.colorInput.value = ev.color || "default"; -function closeModal(){ - els.modal.close(); - els.backdrop.hidden=true; -} + updateConflictWarning(editingId); + showModal(); + } -/* ───────────────── NOTIFICATIONS ───────────────── */ -function registerServiceWorker(){ - if("serviceWorker" in navigator){ - navigator.serviceWorker.register("sw.js"); - } -} + function draftFromForm() { + const id = els.idInput.value || editingId || safeUUID(); + return { + id, + title: els.titleInput.value.trim(), + date: els.dateInput.value, + endDate: els.endDateInput.value, + start: els.startInput.value || null, + end: els.endInput.value || null, + description: els.descInput.value.trim(), + remindMode: els.remindInput.value, + color: els.colorInput.value + }; + } -function initNotificationBanner(){ - if(!("Notification" in window)) return; - if(Notification.permission==="default"){ - els.notifBanner.hidden=false; - els.notifAllowBtn.onclick=()=>Notification.requestPermission(); - els.notifDismissBtn.onclick=()=>els.notifBanner.hidden=true; - } -} + function onSave() { + const ev = draftFromForm(); -function syncEventsToSW(){} -function checkPassiveReminders(){} -function checkPopupReminders(){} + if (!ev.title || !ev.date) { + toast("Please fill required fields"); + return; + } -/* ───────────────── THEME ───────────────── */ -function initTheme(){ - const btn=document.getElementById("themeToggle"); - const saved=localStorage.getItem("calendar_theme"); - if(saved==="dark"){ document.body.classList.add("dark"); } - btn.onclick=()=>document.body.classList.toggle("dark"); -} + if (ev.endDate && ev.endDate < ev.date) { + toast("End date cannot be before start date"); + return; + } + + if ((ev.start && !ev.end) || (!ev.start && ev.end)) { + toast("If you set time, set both Start and End"); + return; + } + + if (ev.date === ev.endDate && ev.start && ev.end && ev.end <= ev.start) { + toast("End time must be after start time"); + return; + } + + const conflicts = detectConflicts(ev, editingId); + els.conflictBox.hidden = conflicts.length === 0; + + if (conflicts.length) { + const sample = conflicts.slice(0, 2).map(e => `• ${e.title} (${e.date} ${e.start}-${e.end})`).join("\n"); + if (!confirm(`Conflict detected with:\n${sample}\n\nSave anyway?`)) return; + } + + const idx = events.findIndex(e => e.id === ev.id); + if (idx >= 0) events[idx] = ev; + else events.push(ev); + + saveEvents(events); + + selectedDate = ev.date; + selectedEventId = ev.id; + viewDate = new Date(ev.date + "T00:00:00"); + + render(); + renderDayPanel(); + closeModal(); + toast("Saved"); + + checkPopupReminders(); + } -function toast(msg){ alert(msg); } + function onDelete() { + if (!editingId) return; + if (!confirm("Delete this event?")) return; -})(); \ No newline at end of file + events = events.filter(e => e.id !== editingId); + saveEvents(events); + + if (selectedEventId === editingId) selectedEventId = null; + + render(); + renderDayPanel(); + closeModal(); + toast("Deleted"); + } + + function showModal() { + els.backdrop.hidden = false; + els.modal.showModal(); + } + + function closeModal() { + els.modal.close(); + els.backdrop.hidden = true; + } + + // ---------- EXPORT EVENTS ---------- + function exportEvents() { + if (events.length === 0) { + toast("No events to export!"); + return; + } + + const sorted = [...events].sort((a, b) => a.date.localeCompare(b.date)); + + const content = sorted.map((ev, i) => { + let time = "All day"; + if (ev.start && ev.end) { + if (ev.endDate && ev.endDate !== ev.date) { + time = `${ev.date} ${ev.start} – ${ev.endDate} ${ev.end}`; + } else { + time = `${ev.start} – ${ev.end}`; + } + } + const desc = ev.description ? `\n Description: ${ev.description}` : ""; + const remind = ev.remindMode === "popup" ? "\n 🔔 Reminder enabled" : ""; + const color = (ev.color && ev.color !== "default") ? `\n Color: ${ev.color}` : ""; + return `Event ${i + 1}:\n Title: ${ev.title}\n Date: ${ev.date}\n Time: ${time}${desc}${remind}${color}`; + }).join("\n\n---\n\n"); + + const blob = new Blob([content], { type: "text/plain" }); + const url = URL.createObjectURL(blob); + const a = document.createElement("a"); + a.href = url; + a.download = "my-calendar-events.txt"; + a.click(); + URL.revokeObjectURL(url); + toast("Events exported!"); + } + + // ---------- SEARCH / CONFLICTS ---------- + function formatSearch(ev) { + return (ev.title + " " + (ev.description || "")).toLowerCase(); + } + + function getEventsOnDate(dateKey) { + const d = new Date(dateKey + "T00:00:00"); + return events.filter(ev => { + const eventStart = new Date(ev.date + "T00:00:00"); + const eventEnd = ev.endDate ? new Date(ev.endDate + "T00:00:00") : eventStart; + return d >= eventStart && d <= eventEnd; + }); + } + + function detectConflicts(candidate, excludeId = null) { + if (!candidate.start || !candidate.end) return []; + const candidateStart = new Date(`${candidate.date}T${candidate.start}`); + const candidateEnd = new Date(`${candidate.endDate || candidate.date}T${candidate.end}`); + + return events + .filter(e => e.id !== excludeId && e.start && e.end) + .filter(e => { + const existingStart = new Date(`${e.date}T${e.start}`); + const existingEnd = new Date(`${e.endDate || e.date}T${e.end}`); + return candidateStart < existingEnd && existingStart < candidateEnd; + }); + } + + function updateConflictWarning(excludeId = null) { + const d = draftFromForm(); + if (!d.date || !d.start || !d.end) { + els.conflictBox.hidden = true; + return; + } + els.conflictBox.hidden = detectConflicts(d, excludeId).length === 0; + } + + // ---------- SMART PATTERN ANALYSIS ---------- + function analyzeEventPatterns() { + const titleFrequency = {}; + const weekdayTimePatterns = {}; + + events.forEach(ev => { + // Count title usage + if (ev.title) { + titleFrequency[ev.title] = (titleFrequency[ev.title] || 0) + 1; + } + + // Track weekday + time pattern + if (ev.start && ev.end) { + const weekday = new Date(ev.date + "T00:00:00").getDay(); + + if (!weekdayTimePatterns[weekday]) { + weekdayTimePatterns[weekday] = {}; + } + + const timeKey = `${ev.start}-${ev.end}`; + weekdayTimePatterns[weekday][timeKey] = + (weekdayTimePatterns[weekday][timeKey] || 0) + 1; + } + }); + + return { titleFrequency, weekdayTimePatterns }; + } + + // ---------- LOCAL STORAGE ---------- + function loadEvents() { + try { + const raw = localStorage.getItem(STORAGE_KEY); + const items = raw ? JSON.parse(raw) : []; + return Array.isArray(items) ? items : []; + } catch { + return []; + } + } + + function saveEvents(list) { + localStorage.setItem(STORAGE_KEY, JSON.stringify(list)); + } + + // ---------- DATE HELPERS ---------- + function toDateKey(d) { + const y = d.getFullYear(); + const m = String(d.getMonth() + 1).padStart(2, "0"); + const day = String(d.getDate()).padStart(2, "0"); + return `${y}-${m}-${day}`; + } + + function addMonths(d, n) { + return new Date(d.getFullYear(), d.getMonth() + n, 1); + } + + // ---------- POPUP REMINDERS ---------- + function checkPopupReminders() { + const todayKey = toDateKey(new Date()); + + let seen = {}; + try { + seen = JSON.parse(localStorage.getItem(POPUP_SEEN_KEY) || "{}"); + } catch { + seen = {}; + } + + if (seen[todayKey]) return; + + const today = new Date(todayKey + "T00:00:00"); + const tomorrowKey = toDateKey(new Date(today.getTime() + 24 * 60 * 60 * 1000)); + + const todayEvents = getEventsOnDate(todayKey).filter(e => e.remindMode === "popup"); + const tomorrowEvents = getEventsOnDate(tomorrowKey).filter(e => e.remindMode === "popup"); + + const list = [ + ...todayEvents.map(e => ({ e, when: "Today" })), + ...tomorrowEvents.map(e => ({ e, when: "Tomorrow" })) + ]; + + if (!list.length) return; + + const lines = list.slice(0, 6).map(x => `• ${x.e.title} (${x.when})`); + const msg = + "🔔 Reminder\n\n" + + lines.join("\n") + + (list.length > 6 ? `\n+${list.length - 6} more` : ""); + + alert(msg); + + seen[todayKey] = true; + localStorage.setItem(POPUP_SEEN_KEY, JSON.stringify(seen)); + } + + // ---------- UTILS ---------- + function safeUUID() { + return (crypto && crypto.randomUUID) + ? crypto.randomUUID() + : String(Date.now()) + "_" + Math.random().toString(16).slice(2); + } + + function escapeHtml(s = "") { + return String(s) + .replaceAll("&", "&") + .replaceAll("<", "<") + .replaceAll(">", ">") + .replaceAll('"', """) + .replaceAll("'", "'"); + } + + let toastTimer = null; + function toast(msg) { + let el = document.getElementById("toast"); + if (!el) { + el = document.createElement("div"); + el.id = "toast"; + Object.assign(el.style, { + position: "fixed", + left: "50%", + bottom: "18px", + transform: "translateX(-50%)", + background: "rgba(0,0,0,.78)", + color: "#fff", + padding: "10px 12px", + borderRadius: "999px", + fontWeight: "900", + fontSize: "12px", + zIndex: "9999", + maxWidth: "calc(100% - 24px)", + textAlign: "center" + }); + document.body.appendChild(el); + } + el.textContent = msg; + el.style.opacity = "1"; + clearTimeout(toastTimer); + toastTimer = setTimeout(() => { + el.style.opacity = "0"; + }, 1600); + } + + function initTheme() { + const btn = document.getElementById("themeToggle"); + + const saved = localStorage.getItem("calendar_theme"); + if (saved === "dark") { + document.body.classList.add("dark"); + btn.textContent = "☀️ Light"; + } + + btn.addEventListener("click", () => { + document.body.classList.toggle("dark"); + const isDark = document.body.classList.contains("dark"); + btn.textContent = isDark ? "☀️ Light" : "🌙 Dark"; + localStorage.setItem("calendar_theme", isDark ? "dark" : "light"); + }); + } +})(); diff --git a/index.html b/index.html index 382b901..23974d0 100644 --- a/index.html +++ b/index.html @@ -5,62 +5,68 @@ My-Calendar - + - - -
-
- -

My-Calendar

-
-
- - - -
- -
-
-
-

Calendar

- -
- - -
- - -
- - - +
+
+ +
+

My-Calendar

+
+ +
+
+
+
+

Calendar

+

Click a date to view events. Select an event to Edit/Delete from the side.

+ +
+ + +
+ + + +
+ + + +
+
-
- - -
-
+
+ + +
+
-
-
Sun
Mon
Tue
-
Wed
Thu
Fri
Sat
-
+
+
Sun
Mon
Tue
Wed
Thu
Fri
Sat
+
-
-
+
+ -
+
+ + + + + +
+ + - + - -
- + + + -
- - - - - - - - -
+
+ + +
+ + +
- +
+ + +
+ + + +
+ + + +
+ + +
+ + - - -
+ + +
- + \ No newline at end of file diff --git a/style.css b/style.css index 4fc47c8..d2a1f01 100644 --- a/style.css +++ b/style.css @@ -939,80 +939,6 @@ body.dark .event-chip[data-color="green"] b, body.dark .event-chip[data-color="green"] .t { color: #86efac; } - -/* ── Notification Permission Banner ──────────────────────────────────────── */ -.notif-banner { - display: flex; - align-items: center; - justify-content: space-between; - flex-wrap: wrap; - gap: 12px; - padding: 14px 20px; - background: linear-gradient(135deg, #ede9fe, #fce7f3); - border-bottom: 1px solid rgba(185,164,255,0.4); - font-size: 14px; - font-weight: 600; - color: #4b3f6b; -} - -/* hidden attribute must always win over display:flex */ -.notif-banner[hidden] { - display: none !important; -} - -.notif-banner-actions { - display: flex; - gap: 10px; - flex-shrink: 0; -} - -.notif-banner .btn.primary { - padding: 8px 18px; - font-size: 13px; -} - -.notif-banner .btn.ghost { - padding: 8px 14px; - font-size: 13px; -} - -body.dark .notif-banner { - background: linear-gradient(135deg, #1e1b33, #2d1e2f); - border-bottom-color: #475569; - color: #e2e8f0; -} - -/* ── Selected day-item highlight (issue #32) ─────────────────────────────── */ -.day-item.selected { - border: 2px solid var(--primary); - background: rgba(185, 164, 255, 0.18); - box-shadow: 0 0 0 3px rgba(185, 164, 255, 0.25), 0 8px 20px rgba(185, 164, 255, 0.2); - transform: translateY(-2px) scale(1.01); -} - -.day-item.selected .title { - background: linear-gradient(145deg, #6c3fc5, #b9a4ff); - -webkit-background-clip: text; - -webkit-text-fill-color: transparent; -} - -.day-item.selected .tag { - background: rgba(185, 164, 255, 0.4); - border-color: rgba(185, 164, 255, 0.6); - color: #6c3fc5; -} - -body.dark .day-item.selected { - border-color: #7c9cff; - background: rgba(124, 156, 255, 0.15); - box-shadow: 0 0 0 3px rgba(124, 156, 255, 0.2), 0 8px 20px rgba(124, 156, 255, 0.15); -} - -body.dark .day-item.selected .tag { - background: rgba(124, 156, 255, 0.25); - border-color: rgba(124, 156, 255, 0.5); - color: #93c5fd; -} /* ---------- RECURRING EVENT STYLING ---------- */ /* Container for the checkbox and label */ diff --git a/sw.js b/sw.js deleted file mode 100644 index 1e8f587..0000000 --- a/sw.js +++ /dev/null @@ -1,217 +0,0 @@ -/** - * sw.js — My-Calendar Service Worker - * Handles background passive reminder notifications. - * - * How it works: - * 1. When the page is OPEN → the page's own setInterval fires checkPassiveReminders() - * and posts SHOW_NOTIFICATION messages here. We just call showNotification(). - * - * 2. When the page is CLOSED → the SW reads events from Cache Storage and fires - * notifications on its own via the "periodicsync" event (Chrome) or a - * self-triggered alarm approach as a fallback. - * - * Console logs from the SW appear in DevTools → Application → Service Workers - * → "Inspect" link, OR in the SW's own DevTools console. - * We also forward logs to any open page client via postMessage. - */ - -const CACHE_NAME = "calendar-data-v1"; -const SW_VERSION = "1.0.0"; - -// ── Helper: log to SW console AND forward to page ──────────────────────────── -function log(...args) { - const msg = args.join(" "); - console.log("[SW " + SW_VERSION + "]", msg); - // Forward to any open page clients so devs can see SW logs in the page console - self.clients.matchAll({ includeUncontrolled: true, type: "window" }).then(clients => { - clients.forEach(c => c.postMessage({ type: "SW_LOG", msg: "[SW] " + msg })); - }); -} - -function warn(...args) { - const msg = args.join(" "); - console.warn("[SW " + SW_VERSION + "]", msg); - self.clients.matchAll({ includeUncontrolled: true, type: "window" }).then(clients => { - clients.forEach(c => c.postMessage({ type: "SW_LOG", msg: "[SW WARN] " + msg })); - }); -} - -// ── Install ─────────────────────────────────────────────────────────────────── -self.addEventListener("install", event => { - log("Installing SW version", SW_VERSION); - self.skipWaiting(); // activate immediately without waiting for old SW to die -}); - -// ── Activate ────────────────────────────────────────────────────────────────── -self.addEventListener("activate", event => { - log("Activating — claiming all clients"); - event.waitUntil(self.clients.claim()); -}); - -// ── Message from the main page thread ───────────────────────────────────────── -// The page sends SHOW_NOTIFICATION when it wants to fire a notification through us. -// This works even when the tab is backgrounded, as long as the page is still open. -self.addEventListener("message", event => { - const data = event.data; - if (!data || !data.type) return; - - if (data.type === "SHOW_NOTIFICATION") { - log("Received SHOW_NOTIFICATION from page — title:", data.title, "| tag:", data.tag); - event.waitUntil( - self.registration.showNotification(data.title, { - body: data.body || "", - icon: data.icon || "images/android-chrome-512x512.png", - badge: data.icon || "images/android-chrome-512x512.png", - tag: data.tag || "cal-notif", - requireInteraction: false, - silent: false, - }).then(() => { - log("✅ Notification shown successfully:", data.title); - }).catch(err => { - warn("❌ showNotification() failed:", err.message); - }) - ); - } - - if (data.type === "CHECK_REMINDERS") { - log("Received CHECK_REMINDERS from page — running background check"); - event.waitUntil(checkRemindersInBackground()); - } -}); - -// ── Notification click: focus or open the app ───────────────────────────────── -self.addEventListener("notificationclick", event => { - log("Notification clicked:", event.notification.title); - event.notification.close(); - event.waitUntil( - self.clients.matchAll({ type: "window", includeUncontrolled: true }).then(clients => { - const existing = clients.find(c => c.url.includes(self.location.origin)); - if (existing) { - log("Focusing existing page client"); - return existing.focus(); - } - log("No existing client — opening new window"); - return self.clients.openWindow(self.location.origin + "/"); - }) - ); -}); - -// ── Background Periodic Sync ────────────────────────────────────────────────── -// Chrome on Android (and some desktop) fires this when the browser decides it is -// a good time to run background tasks. minInterval = 60 000 ms (1 minute). -self.addEventListener("periodicsync", event => { - log("periodicsync fired — tag:", event.tag); - if (event.tag === "calendar-reminders") { - event.waitUntil(checkRemindersInBackground()); - } -}); - -// ── Core background check ───────────────────────────────────────────────────── -// Called when NO page client is open. Reads events from Cache Storage. -async function checkRemindersInBackground() { - log("checkRemindersInBackground() running at", new Date().toLocaleTimeString()); - - // If a page is open, let it handle the check (it has fresh localStorage state) - const clients = await self.clients.matchAll({ type: "window", includeUncontrolled: true }); - if (clients.length > 0) { - log("Page is open — delegating check to page (", clients.length, "client(s))"); - return; - } - - log("No page open — reading events from Cache Storage"); - - let events, sent; - try { - const cache = await caches.open(CACHE_NAME); - const evRes = await cache.match("events"); - const sentRes = await cache.match("notif-sent"); - - if (!evRes) { - warn("No events found in Cache Storage — has the app been opened at least once?"); - return; - } - - events = await evRes.json(); - sent = sentRes ? await sentRes.json() : {}; - log("Loaded", events.length, "event(s) from cache"); - } catch (err) { - warn("Failed to read from Cache Storage:", err.message); - return; - } - - const now = new Date(); - const todayKey = toDateKey(now); - const nowMs = now.getTime(); - let dirty = false; - - // Prune stale sent-keys - Object.keys(sent).forEach(k => { - if (!k.startsWith(todayKey)) { delete sent[k]; dirty = true; } - }); - - // Filter events that fall on today - const todayEvents = events.filter(ev => { - const s = new Date(ev.date + "T00:00:00"); - const e = ev.endDate ? new Date(ev.endDate + "T00:00:00") : s; - const t = new Date(todayKey + "T00:00:00"); - return t >= s && t <= e; - }); - - log("Events on today (" + todayKey + "):", todayEvents.length); - - for (const ev of todayEvents) { - const mins = parseInt(ev.remindMode, 10); - if (!ev.start || isNaN(mins)) { - log("Skipping '" + ev.title + "' — remindMode='" + ev.remindMode + "' (not passive)"); - continue; - } - - const eventMs = new Date(ev.date + "T" + ev.start).getTime(); - const diffMins = (eventMs - nowMs) / 60_000; - const sentKey = todayKey + "_" + ev.id + "_" + mins; - - log("'" + ev.title + "' diff=" + diffMins.toFixed(1) + " min | threshold=" + mins + " | sent=" + !!sent[sentKey]); - - if (diffMins <= 0 || diffMins > mins || sent[sentKey]) continue; - - const roundedMins = Math.round(diffMins); - const timeLabel = roundedMins >= 60 ? "1 hour" : roundedMins + " minute" + (roundedMins !== 1 ? "s" : ""); - - log("🔔 FIRING background notification for '" + ev.title + "' — in " + timeLabel); - try { - await self.registration.showNotification("⏰ " + ev.title, { - body: "Starting in about " + timeLabel, - icon: "images/android-chrome-512x512.png", - badge: "images/android-chrome-512x512.png", - tag: sentKey, - requireInteraction: false, - }); - log("✅ Background notification shown for '" + ev.title + "'"); - } catch (err) { - warn("❌ showNotification() failed for '" + ev.title + "':", err.message); - } - - sent[sentKey] = true; - dirty = true; - } - - if (dirty) { - try { - const cache = await caches.open(CACHE_NAME); - await cache.put("notif-sent", new Response(JSON.stringify(sent), { - headers: { "Content-Type": "application/json" } - })); - log("Updated notif-sent in cache"); - } catch (err) { - warn("Failed to update notif-sent in cache:", err.message); - } - } -} - -// ── Date helper (mirrors the one in app.js) ─────────────────────────────────── -function toDateKey(d) { - const y = d.getFullYear(); - const mo = String(d.getMonth() + 1).padStart(2, "0"); - const day = String(d.getDate()).padStart(2, "0"); - return y + "-" + mo + "-" + day; -}