Files
apps/calendar/calendar.js
metacryst 0d6c7683ff init
2026-04-28 20:05:00 -05:00

582 lines
27 KiB
JavaScript

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)