feat(calendar): linked start/end times, 1h default duration, smart rounding, past event hint
This commit is contained in:
@@ -64,36 +64,114 @@ function toIso(date: string, time: string): string {
|
|||||||
return `${date}T${time}:00${sign}${h}:${min}`;
|
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() {
|
function resetForm() {
|
||||||
if (props.event) {
|
if (props.event) {
|
||||||
title.value = props.event.title;
|
title.value = props.event.title;
|
||||||
allDay.value = props.event.all_day;
|
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);
|
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);
|
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)) : "";
|
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) : "";
|
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 || "";
|
description.value = props.event.description || "";
|
||||||
location.value = props.event.location || "";
|
location.value = props.event.location || "";
|
||||||
color.value = props.event.color || "";
|
color.value = props.event.color || "";
|
||||||
projectId.value = props.event.project_id;
|
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 {
|
} else {
|
||||||
title.value = "";
|
title.value = "";
|
||||||
allDay.value = false;
|
allDay.value = false;
|
||||||
const base = props.initialDate ? dateFromIso(props.initialDate) : new Date().toISOString().slice(0, 10);
|
const base = props.initialDate ? dateFromIso(props.initialDate) : new Date().toISOString().slice(0, 10);
|
||||||
|
const roundedStart = nextRoundedTime();
|
||||||
startDate.value = base;
|
startDate.value = base;
|
||||||
startTime.value = "09:00";
|
startTime.value = roundedStart;
|
||||||
endDate.value = base;
|
endDate.value = base;
|
||||||
endTime.value = "10:00";
|
endTime.value = addHours(roundedStart, 1);
|
||||||
description.value = "";
|
description.value = "";
|
||||||
location.value = "";
|
location.value = "";
|
||||||
color.value = "";
|
color.value = "";
|
||||||
projectId.value = null;
|
projectId.value = null;
|
||||||
|
_lastDurationMin = 60;
|
||||||
}
|
}
|
||||||
deleteConfirm.value = false;
|
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.event, resetForm, { immediate: true });
|
||||||
watch(() => props.initialDate, resetForm);
|
watch(() => props.initialDate, resetForm);
|
||||||
|
|
||||||
@@ -113,6 +191,10 @@ async function save() {
|
|||||||
toast.show("Start date is required", "error");
|
toast.show("Start date is required", "error");
|
||||||
return;
|
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 start_dt = allDay.value ? `${startDate.value}T00:00:00` : toIso(startDate.value, startTime.value);
|
||||||
const end_dt = endDate.value
|
const end_dt = endDate.value
|
||||||
@@ -199,18 +281,19 @@ async function doDelete() {
|
|||||||
|
|
||||||
<!-- Start -->
|
<!-- Start -->
|
||||||
<div class="so-field">
|
<div class="so-field">
|
||||||
<label class="so-label">Start</label>
|
<label class="so-label">Start <span class="required">*</span></label>
|
||||||
<div class="dt-row">
|
<div class="dt-row">
|
||||||
<input v-model="startDate" type="date" class="so-input dt-date" />
|
<input v-model="startDate" type="date" class="so-input dt-date" required />
|
||||||
<input v-if="!allDay" v-model="startTime" type="time" class="so-input dt-time" />
|
<input v-if="!allDay" v-model="startTime" type="time" class="so-input dt-time" required />
|
||||||
</div>
|
</div>
|
||||||
|
<p v-if="isPastEvent" class="so-past-hint">This event is in the past</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- End -->
|
<!-- End -->
|
||||||
<div class="so-field">
|
<div class="so-field">
|
||||||
<label class="so-label">End <span class="so-hint">(optional)</span></label>
|
<label class="so-label">End</label>
|
||||||
<div class="dt-row">
|
<div class="dt-row">
|
||||||
<input v-model="endDate" type="date" class="so-input dt-date" />
|
<input v-model="endDate" type="date" class="so-input dt-date" :min="startDate" />
|
||||||
<input v-if="!allDay" v-model="endTime" type="time" class="so-input dt-time" />
|
<input v-if="!allDay" v-model="endTime" type="time" class="so-input dt-time" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -364,6 +447,12 @@ async function doDelete() {
|
|||||||
.dt-row { display: flex; gap: 0.5rem; }
|
.dt-row { display: flex; gap: 0.5rem; }
|
||||||
.dt-date { flex: 1; }
|
.dt-date { flex: 1; }
|
||||||
.dt-time { width: 7.5rem; flex-shrink: 0; }
|
.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 {
|
.toggle-btn {
|
||||||
background: var(--color-input-bg, #111113);
|
background: var(--color-input-bg, #111113);
|
||||||
|
|||||||
Reference in New Issue
Block a user