From f9c68029395947e57aac76ab2d8a20025dd6315b Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Wed, 8 Apr 2026 13:47:15 -0400 Subject: [PATCH] feat(calendar): linked start/end times, 1h default duration, smart rounding, past event hint --- frontend/src/components/EventSlideOver.vue | 111 +++++++++++++++++++-- 1 file changed, 100 insertions(+), 11 deletions(-) diff --git a/frontend/src/components/EventSlideOver.vue b/frontend/src/components/EventSlideOver.vue index 50bf92b..9a190c0 100644 --- a/frontend/src/components/EventSlideOver.vue +++ b/frontend/src/components/EventSlideOver.vue @@ -64,36 +64,114 @@ function toIso(date: string, time: string): string { return `${date}T${time}:00${sign}${h}:${min}`; } +// ── Time helpers ────────────────────────────────────────────────────────────── + +/** Round up to next 30-minute boundary */ +function nextRoundedTime(): string { + const now = new Date(); + let h = now.getHours(); + let m = now.getMinutes(); + if (m <= 30) { m = 30; } + else { m = 0; h = (h + 1) % 24; } + return `${String(h).padStart(2, "0")}:${String(m).padStart(2, "0")}`; +} + +/** Add hours to a time string (HH:MM), returns HH:MM */ +function addHours(time: string, hours: number): string { + const [h, m] = time.split(":").map(Number); + const totalMin = h * 60 + m + hours * 60; + const nh = Math.floor(totalMin / 60) % 24; + const nm = totalMin % 60; + return `${String(nh).padStart(2, "0")}:${String(nm).padStart(2, "0")}`; +} + +/** Duration in minutes between two time strings */ +function durationMin(start: string, end: string): number { + const [sh, sm] = start.split(":").map(Number); + const [eh, em] = end.split(":").map(Number); + return (eh * 60 + em) - (sh * 60 + sm); +} + +/** True if a date+time is in the past */ +const isPastEvent = computed(() => { + if (allDay.value || !startDate.value || !startTime.value) return false; + const dt = new Date(`${startDate.value}T${startTime.value}:00`); + return dt.getTime() < Date.now(); +}); + +// Track duration so end moves with start +let _lastDurationMin = 60; + function resetForm() { if (props.event) { title.value = props.event.title; allDay.value = props.event.all_day; - // All-day events: use UTC date string directly to avoid timezone shifting - // (UTC midnight parsed through new Date() becomes the previous day in UTC-X zones) startDate.value = props.event.all_day ? props.event.start_dt.slice(0, 10) : dateFromIso(props.event.start_dt); startTime.value = props.event.all_day ? "" : timeFromIso(props.event.start_dt); - endDate.value = props.event.end_dt ? (props.event.all_day ? props.event.end_dt.slice(0, 10) : dateFromIso(props.event.end_dt)) : ""; - endTime.value = props.event.end_dt && !props.event.all_day ? timeFromIso(props.event.end_dt) : ""; + endDate.value = props.event.end_dt ? (props.event.all_day ? props.event.end_dt.slice(0, 10) : dateFromIso(props.event.end_dt)) : startDate.value; + endTime.value = props.event.end_dt && !props.event.all_day ? timeFromIso(props.event.end_dt) : addHours(startTime.value || "09:00", 1); description.value = props.event.description || ""; location.value = props.event.location || ""; color.value = props.event.color || ""; projectId.value = props.event.project_id; + _lastDurationMin = !allDay.value && startTime.value && endTime.value ? durationMin(startTime.value, endTime.value) : 60; + if (_lastDurationMin <= 0) _lastDurationMin = 60; } else { title.value = ""; allDay.value = false; const base = props.initialDate ? dateFromIso(props.initialDate) : new Date().toISOString().slice(0, 10); + const roundedStart = nextRoundedTime(); startDate.value = base; - startTime.value = "09:00"; + startTime.value = roundedStart; endDate.value = base; - endTime.value = "10:00"; + endTime.value = addHours(roundedStart, 1); description.value = ""; location.value = ""; color.value = ""; projectId.value = null; + _lastDurationMin = 60; } deleteConfirm.value = false; } +// When start time changes, move end time to preserve duration +watch(startTime, (newStart) => { + if (allDay.value || !newStart) return; + endTime.value = addHours(newStart, _lastDurationMin / 60); +}); + +// When start date changes, move end date to match (preserve same-day or multi-day gap) +watch(startDate, (newDate) => { + if (!newDate) return; + endDate.value = newDate; +}); + +// When end time changes manually, update the tracked duration (but guard against end < start) +watch(endTime, (newEnd) => { + if (allDay.value || !newEnd || !startTime.value) return; + const dur = durationMin(startTime.value, newEnd); + if (dur <= 0) { + // Snap back to start + 1 hour + endTime.value = addHours(startTime.value, 1); + _lastDurationMin = 60; + } else { + _lastDurationMin = dur; + } +}); + +// All-day toggle: clear/restore times +watch(allDay, (isAllDay) => { + if (isAllDay) { + startTime.value = ""; + endTime.value = ""; + } else { + const rounded = nextRoundedTime(); + startTime.value = rounded; + endTime.value = addHours(rounded, 1); + _lastDurationMin = 60; + } +}); + watch(() => props.event, resetForm, { immediate: true }); watch(() => props.initialDate, resetForm); @@ -113,6 +191,10 @@ async function save() { toast.show("Start date is required", "error"); return; } + if (!allDay.value && !startTime.value) { + toast.show("Start time is required", "error"); + return; + } const start_dt = allDay.value ? `${startDate.value}T00:00:00` : toIso(startDate.value, startTime.value); const end_dt = endDate.value @@ -199,18 +281,19 @@ async function doDelete() {
- +
- - + +
+

This event is in the past

- +
- +
@@ -364,6 +447,12 @@ async function doDelete() { .dt-row { display: flex; gap: 0.5rem; } .dt-date { flex: 1; } .dt-time { width: 7.5rem; flex-shrink: 0; } +.so-past-hint { + margin: 4px 0 0; + font-size: 0.75rem; + color: var(--color-accent-warm, #d4a017); + font-style: italic; +} .toggle-btn { background: var(--color-input-bg, #111113);