import calendarUtil from "./calendarUtil.js" import "./Week/WeekView.js" import "./Month/MonthView.js" import "./Day/DayView.js" import "./Events/EventForm.js" import "./Events/EventDetails.js" import "./Events/FilePreview.js" import "./Toolbar/CalendarToolbar.js" import "./Toolbar/CalendarOptions.js" import "./Toolbar/BottomBar.js" import "./CalendarForm.js" import "../components/BottomSheet.js" import "/_/code/components/LoadingCircle.js" css(` calendar- { font-family: 'Arial'; scrollbar-width: none; -ms-overflow-style: none; } calendar- h1 { font-family: 'Bona'; } `) class Calendar extends Shadow { swipeTranslate = 0; // current drag offset in px isSwiping = false; isCommitting = false; swipeDragStartX = null; swipeDragStartY = null; swipeDragStartTime = null; swipeAxisLocked = false; swipeIsHorizontal = false; get basePath() { return window.location.pathname.replace(/\/day\/[^/]+$/, '').replace(/\/$/, '') } get urlDayDate() { const match = window.location.pathname.match(/\/day\/(\d{4}-\d{2}-\d{2})$/) return match ? new Date(match[1] + 'T00:00:00') : null } SWIPE_COMMIT_DISTANCE = window.outerWidth * 0.35; // 35% of screen SWIPE_VELOCITY_THRESHOLD = 0.4; // px/ms calendars = []; events = []; constructor() { super() this.currentDate = new Date(); this.viewMode = localStorage.getItem(`calendarViewMode_${global.profile.id}`) || "month"; this.weekStartsOn = 0; this.showPopout = false; this.calendars = [...global.currentNetwork.data.calendars]; // Restore previously-selected calendars from localStorage; fall back to all const storedCalIds = JSON.parse(localStorage.getItem(`calendarSelection_${global.profile.id}_${global.currentNetwork.id}`) || 'null') if (storedCalIds) { console.log(storedCalIds) const restored = this.calendars.filter(c => storedCalIds.includes(c.id)) this.selectedCalendars = restored.length > 0 ? restored : [...this.calendars] } else { console.log("nope") this.selectedCalendars = [...this.calendars] } this.events = global.currentNetwork.data.events.map(event => ({ ...event, time_start: new Date(event.time_start), time_end: new Date(event.time_end) })); } render() { const dayDate = this.urlDayDate ZStack(() => { VStack(() => { if (dayDate) { CalendarToolbar( dayDate, this.weekStartsOn, { goToPrevious: () => this.subpathNavigateToDate(calendarUtil.addDays(dayDate, -1)), goToCurrent: () => this.subpathNavigateToDate(new Date()), goToNext: () => this.subpathNavigateToDate(calendarUtil.addDays(dayDate, 1)), goToDate: (date) => this.subpathNavigateToDate(date), }, this.selectedCalendars, this.events, this.showPopout, { onBack: () => navigateTo(this.basePath), viewModeOverride: "day" } ) } else { CalendarToolbar(this.currentDate, this.weekStartsOn, { goToPrevious: () => this.goToPrevious(), goToCurrent: () => this.goToCurrent(), goToNext: () => this.goToNext(), goToDate: (date) => this.goToDate(date) }, this.selectedCalendars, this.events, this.showPopout) } if (global.appRefreshing) { LoadingCircle() } else { ZStack(() => { // Three panels (previous/current/next) for swipe transitions [-1, 0, 1].forEach(offset => { let viewDate; if (dayDate) { viewDate = calendarUtil.addDays(dayDate, offset); } else if (this.viewMode === "week") { viewDate = calendarUtil.addDays(this.currentDate, offset * 7); } else if (this.viewMode === "month") { viewDate = calendarUtil.addMonths(this.currentDate, offset); } else if (this.viewMode === "day") { viewDate = calendarUtil.addDays(this.currentDate, offset); } ZStack(() => { const isCenter = offset === 0; if (dayDate) { DayView(this.selectedCalendars, this.events, viewDate, (dateTime) => this.openNewEventForm(dateTime), isCenter) } else if (this.viewMode === "week") { WeekView(this.selectedCalendars, this.events, viewDate, this.weekStartsOn, (dateTime) => this.openNewEventForm(dateTime), (day) => window.navigateTo(`${this.basePath}/day/${calendarUtil.toDateInput(day)}`), isCenter) } else if (this.viewMode === "month") { MonthView(this.selectedCalendars, this.events, viewDate, this.weekStartsOn, (day) => { if (!calendarUtil.isSameMonth(day, viewDate)) { this.commitSwipe(day > viewDate ? "next" : "previous") } else { window.navigateTo(`${this.basePath}/day/${calendarUtil.toDateInput(day)}`) } }) } else if (this.viewMode === "day") { DayView(this.selectedCalendars, this.events, viewDate, (dateTime) => this.openNewEventForm(dateTime), isCenter) } }) .position("absolute") .width(100, pct) .height(100, pct) .transform(`translateX(${offset * 100}%)`) .attr({ "data-swipe-panel": offset }) }) }) .position("relative") .overflow("hidden") .width(100, pct) .flex(1) .onTouch((start, e) => this.handleSwipeTouch(start, e)) } }) .height(100, pct) ActionSheetPopup() FilePreview() const sheet = BottomSheet(); // Exposed so child views (WeekView, DayView, etc.) can open event details sheet.showEvent = (event) => { let dirty = false; sheet.show( () => EventDetails( this.calendars, event, (updateResult) => { if (updateResult?.scope) { this.handleEditResult(updateResult); } else { const updatedEvent = updateResult; this.events = this.events.map(e => { if (e.id !== updatedEvent.id) return e; if (updatedEvent._isOccurrence) return { ...e, calendars: updatedEvent.calendars }; return { ...updatedEvent, time_start: new Date(updatedEvent.time_start), time_end: new Date(updatedEvent.time_end) }; }); global.currentNetwork.data.events = global.currentNetwork.data.events.map(e => { if (e.id !== updatedEvent.id) return e; if (updatedEvent._isOccurrence) return { ...e, calendars: updatedEvent.calendars }; return updatedEvent; }); } dirty = true; }, (deleteResult) => { this.handleDeleteResult(deleteResult); dirty = false; this.rerender(); } ), () => { if (dirty) { dirty = false; this.rerender(); } } ); }; BottomBar({ onAddEvent: () => this.openNewEventForm(dayDate ?? null), hideViewSelect: !!dayDate, onCalendarOptions: () => $("bottomsheet-").show(() => CalendarOptions(this.calendars, this.selectedCalendars, { onSelection: (newSelectedCalendars) => { this.selectedCalendars = newSelectedCalendars; localStorage.setItem(`calendarSelection_${global.profile.id}_${global.currentNetwork.id}`, JSON.stringify(newSelectedCalendars.map(c => c.id))) this.rerender(); }, onCalendarAdded: (newCalendar) => { this.calendars = [...this.calendars, newCalendar]; global.currentNetwork.data.calendars = [...global.currentNetwork.data.calendars, newCalendar]; }, onCalendarUpdated: (updatedCalendar) => { this.calendars = this.calendars.map(c => c.id === updatedCalendar.id ? updatedCalendar : c); this.selectedCalendars = this.selectedCalendars.map(c => c.id === updatedCalendar.id ? updatedCalendar : c); global.currentNetwork.data.calendars = global.currentNetwork.data.calendars.map(c => c.id === updatedCalendar.id ? updatedCalendar : c); }, onCalendarDeleted: (deletedId) => { this.calendars = this.calendars.filter(c => c.id !== deletedId); this.selectedCalendars = this.selectedCalendars.filter(c => c.id !== deletedId); global.currentNetwork.data.calendars = global.currentNetwork.data.calendars.filter(c => c.id !== deletedId); } })), viewMode: this.viewMode, onChangeView: (mode) => { this.viewMode = mode; localStorage.setItem(`calendarViewMode_${global.profile.id}`, mode); this.rerender(); } }) }) .position("relative") .overflowY("hidden") .boxSizing("border-box") .height(100, pct) .width(100, pct) .onNavigate(() => this.rerender()) } subpathNavigateToDate(date) { this.currentDate = date window.history.replaceState({}, '', `${this.basePath}/day/${calendarUtil.toDateInput(date)}`) this.rerender() } openNewEventForm(initialDate = null) { let formEl const sheet = $("bottomsheet-") const onSaveError = () => { sheet._closeOverride = () => sheet.forceClose() } sheet.show(() => { formEl = EventForm(this.calendars, (event) => this.updateEvents(event), null, null, null, initialDate, onSaveError) }) sheet._closeOverride = () => { sheet.setSheet(true) formEl?.handleBack() } } handleEditResult({ scope, event: resultEvent, templateId, occurrenceDate }) { const event = { ...resultEvent, time_start: new Date(resultEvent.time_start), time_end: new Date(resultEvent.time_end) }; if (scope === 'all') { // Preserve end_date from old template — it may have been set by a 'this and future' split. const oldTemplate = this.events.find(e => e.id === templateId); const oldEndDate = oldTemplate?.recurrence?.end_date ?? null; const recurrence = event.recurrence ? { ...event.recurrence, end_date: event.recurrence.end_date ?? oldEndDate } : null; const mergedEvent = { ...event, recurrence }; this.events = this.events.map(e => e.id === templateId ? mergedEvent : e); global.currentNetwork.data.events = global.currentNetwork.data.events.map(e => e.id === templateId ? { ...resultEvent, recurrence } : e ); } else if (scope === 'single') { const alreadyExists = this.events.some(e => e.id === resultEvent.id); if (alreadyExists) { this.events = this.events.map(e => e.id === resultEvent.id ? event : e); global.currentNetwork.data.events = global.currentNetwork.data.events.map(e => e.id === resultEvent.id ? resultEvent : e); } else { this.events = [...this.events, event]; global.currentNetwork.data.events = [...global.currentNetwork.data.events, resultEvent]; } } else if (scope === 'future') { const capDate = occurrenceDate ? new Date(occurrenceDate) : null; if (capDate) { const oldTemplate = this.events.find(e => e.id === templateId); const oldEndDate = oldTemplate?.recurrence?.end_date ? new Date(oldTemplate.recurrence.end_date) : null; // Inherit recurrence from form (or old template's rule). A's old end_date caps the new series to avoid overlap with independent later splits. const baseRecurrence = event.recurrence ?? oldTemplate?.recurrence; const inheritedRecurrence = baseRecurrence ? { ...baseRecurrence, end_date: oldEndDate ? oldEndDate.toISOString() : null } : null; const newTemplateEvent = { ...event, recurrence: inheritedRecurrence }; const newTemplateRaw = { ...resultEvent, recurrence: inheritedRecurrence }; // Collect descendants in [capDate, oldEndDate) only; independent splits at/beyond oldEndDate are preserved const descendantIds = new Set( this.events .filter(e => { if (!(e.parent_template_id === templateId && e.recurrence_id)) return false; const t = new Date(e.time_start); return t >= capDate && (!oldEndDate || t < oldEndDate); }) .map(e => e.id) ); const newId = resultEvent.id; const updateAndFilter = (arr) => arr.map(e => { if (e.id === templateId && e.recurrence) { return { ...e, recurrence: { ...e.recurrence, end_date: capDate.toISOString() } }; } // Migrate overrides in [capDate, oldEndDate) to the new template (mirrors server migration) if (e.recurrence_parent_id === templateId && e.recurrence_exception_date) { const exDate = new Date(e.recurrence_exception_date); if (exDate >= capDate && (!oldEndDate || exDate < oldEndDate)) { return { ...e, recurrence_parent_id: newId }; } } return e; }).filter(e => { // Overrides of the old template stay if they're before capDate. // Migrated overrides now have recurrence_parent_id = newId so they pass through. if (e.recurrence_parent_id === templateId && e.recurrence_exception_date) { return new Date(e.recurrence_exception_date) < capDate; } if (descendantIds.has(e.id) || descendantIds.has(e.recurrence_parent_id)) { return false; } return true; }); this.events = [...updateAndFilter(this.events), newTemplateEvent]; global.currentNetwork.data.events = [...updateAndFilter(global.currentNetwork.data.events), newTemplateRaw]; } } } handleDeleteResult({ scope, templateId, occurrenceDate, overrideId }) { if (scope === 'all') { // Promote non-cancelled overrides (single-event edits) to standalone; remove cancelled placeholders and template const promoteOverrides = (arr) => arr .filter(e => e.id !== templateId && !(e.recurrence_parent_id === templateId && e.is_cancelled)) .map(e => e.recurrence_parent_id === templateId ? { ...e, recurrence_parent_id: null, recurrence_exception_date: null } : e ); this.events = promoteOverrides(this.events); global.currentNetwork.data.events = promoteOverrides(global.currentNetwork.data.events); } else if (scope === 'single') { if (overrideId) { this.events = this.events.map(e => e.id === overrideId ? { ...e, is_cancelled: true } : e); global.currentNetwork.data.events = global.currentNetwork.data.events.map(e => e.id === overrideId ? { ...e, is_cancelled: true } : e); } else if (occurrenceDate) { const occDate = new Date(occurrenceDate); const syntheticOverride = { id: `cancelled_${templateId}_${occurrenceDate}`, recurrence_parent_id: templateId, recurrence_exception_date: occDate, is_cancelled: true, time_start: occDate, time_end: occDate, calendars: [], all_day: false, }; this.events = [...this.events, syntheticOverride]; global.currentNetwork.data.events = [...global.currentNetwork.data.events, syntheticOverride]; } } else if (scope === 'future') { const capDate = occurrenceDate ? new Date(occurrenceDate) : null; if (capDate) { const oldTemplate = this.events.find(e => e.id === templateId); // Server does a full delete when capDate <= time_start (no occurrences would remain) if (oldTemplate && capDate <= new Date(oldTemplate.time_start)) { const promoteOverrides = (arr) => arr .filter(e => e.id !== templateId && !(e.recurrence_parent_id === templateId && e.is_cancelled)) .map(e => e.recurrence_parent_id === templateId ? { ...e, recurrence_parent_id: null, recurrence_exception_date: null } : e ); this.events = promoteOverrides(this.events); global.currentNetwork.data.events = promoteOverrides(global.currentNetwork.data.events); return; } const oldEndDate = oldTemplate?.recurrence?.end_date ? new Date(oldTemplate.recurrence.end_date) : null; const descendantIds = new Set( this.events .filter(e => { if (!(e.parent_template_id === templateId && e.recurrence_id)) return false; const t = new Date(e.time_start); return t >= capDate && (!oldEndDate || t < oldEndDate); }) .map(e => e.id) ); const updateAndFilter = (arr) => arr.map(e => { if (e.id === templateId && e.recurrence) { return { ...e, recurrence: { ...e.recurrence, end_date: capDate.toISOString() } }; } // Promote future non-cancelled overrides to standalone events if (e.recurrence_parent_id === templateId && e.recurrence_exception_date && new Date(e.recurrence_exception_date) >= capDate && !e.is_cancelled) { return { ...e, recurrence_parent_id: null, recurrence_exception_date: null }; } return e; }).filter(e => { // Remove future cancelled placeholders and past-promoted overrides that are still linked if (e.recurrence_parent_id === templateId && e.recurrence_exception_date) { return new Date(e.recurrence_exception_date) < capDate; } if (descendantIds.has(e.id) || descendantIds.has(e.recurrence_parent_id)) { return false; } return true; }); this.events = updateAndFilter(this.events); global.currentNetwork.data.events = updateAndFilter(global.currentNetwork.data.events); } } } updateEvents(event) { this.events = [...this.events, { ...event, time_start: new Date(event.time_start), time_end: new Date(event.time_end) }]; global.currentNetwork.data.events = [...global.currentNetwork.data.events, event]; this.rerender(); } changeView(type) { if (this.viewMode === type || this.viewMode === "day") return false; this.viewMode = type; localStorage.setItem('calendarViewMode', type) this.rerender(); return true; } goToPrevious() { this.navigate("previous"); } goToCurrent() { this.currentDate = new Date(); this.rerender(); } goToNext() { this.navigate("next"); } goToDate(date) { const prev = this.currentDate; this.currentDate = date; if (this.viewMode === "week") { if (calendarUtil.isSameWeek(prev, date)) return false; } else if (this.viewMode === "month") { if (calendarUtil.isSameMonth(prev, date)) return false; } this.rerender(); return true; } navigate(direction) { const sign = direction === "next" ? 1 : -1; if (this.viewMode === "week") { this.currentDate = calendarUtil.addDays(this.currentDate, sign * 7) } else if (this.viewMode === "day") { this.currentDate = calendarUtil.addDays(this.currentDate, sign) } else if (this.viewMode === "month") { this.currentDate = calendarUtil.addMonths(this.currentDate, sign) } this.rerender(); } handleSwipeTouch(start, e) { if (start) { // Block new swipes during active animations if (this.isCommitting) return; if ($("home-").sidebarOpen) return; const startX = e.touches[0].clientX; const sidebarOpenZone = window.outerWidth / 10; const sidebarCloseZone = window.outerWidth * 5 / 6; if (startX < sidebarOpenZone || startX > sidebarCloseZone) return; this.swipeDragStartX = e.touches[0].clientX; this.swipeDragStartY = e.touches[0].clientY; this.swipeDragStartTime = Date.now(); this.isSwiping = true; this.swipeAxisLocked = false; this.swipeIsHorizontal = false; document.addEventListener("touchmove", this.onSwipeMove, { passive: true }); } else { if (!this.isSwiping) return; document.removeEventListener("touchmove", this.onSwipeMove); if (!this.swipeIsHorizontal) { this.isSwiping = false; this.swipeDragStartX = null; this.swipeDragStartY = null; return; } const endX = e.changedTouches[0].clientX; const delta = endX - this.swipeDragStartX; const elapsed = Date.now() - this.swipeDragStartTime; const velocity = Math.abs(delta) / elapsed; const shouldCommit = Math.abs(delta) > this.SWIPE_COMMIT_DISTANCE || velocity > this.SWIPE_VELOCITY_THRESHOLD; if (shouldCommit && delta < 0) { this.commitSwipe("next"); } else if (shouldCommit && delta > 0) { this.commitSwipe("previous"); } else { this.snapBack(); } this.isSwiping = false; this.swipeDragStartX = null; this.swipeDragStartY = null; } } onSwipeMove = (e) => { if (!this.isSwiping) return; const dx = e.touches[0].clientX - this.swipeDragStartX; const dy = e.touches[0].clientY - this.swipeDragStartY; if (!this.swipeAxisLocked) { if (Math.abs(dx) < 5 && Math.abs(dy) < 5) return; this.swipeAxisLocked = true; this.swipeIsHorizontal = Math.abs(dx) > Math.abs(dy); } if (!this.swipeIsHorizontal) return; const delta = e.touches[0].clientX - this.swipeDragStartX; this.swipeTranslate = delta; this.applySwipeTransform(delta, false); } applySwipeTransform(delta, animated) { const panels = this.$$("[data-swipe-panel]", this); panels.forEach(panel => { const offset = parseInt(panel.getAttribute("data-swipe-panel")); panel.style.transition = animated ? "transform 300ms ease" : ""; panel.style.transform = `translateX(calc(${offset * 100}% + ${delta}px))`; }); } commitSwipe(direction) { const dayDate = this.urlDayDate const sign = direction === "next" ? 1 : -1; let nextDate; if (dayDate) { nextDate = calendarUtil.addDays(dayDate, sign); } else if (this.viewMode === "week") { nextDate = calendarUtil.addDays(this.currentDate, sign * 7); } else if (this.viewMode === "day") { nextDate = calendarUtil.addDays(this.currentDate, sign); } else { nextDate = calendarUtil.addMonths(this.currentDate, sign); } this.isCommitting = true; const screenWidth = window.innerWidth; const currentDelta = this.swipeTranslate; const panels = this.$$("[data-swipe-panel]", this); panels.forEach(panel => { const offset = parseInt(panel.getAttribute("data-swipe-panel")); panel.style.transition = ""; panel.style.transform = `translateX(calc(${offset * 100}% + ${currentDelta}px))`; panel.getBoundingClientRect(); // force reflow so transition fires from current position panel.style.transition = "transform 300ms ease"; panel.style.transform = `translateX(calc(${offset * 100}% + ${sign * -screenWidth}px))`; }); setTimeout(() => { this.swipeTranslate = 0; this.isCommitting = false; if (dayDate) { this.subpathNavigateToDate(nextDate) } else { this.currentDate = nextDate; this.rerender(); } }, 300); } snapBack() { const panels = this.$$("[data-swipe-panel]", this); panels.forEach(panel => { const offset = parseInt(panel.getAttribute("data-swipe-panel")); panel.style.transition = "transform 300ms ease"; panel.style.transform = `translateX(${offset * 100}%)`; }); this.swipeTranslate = 0; } } register(Calendar)