refactor: code clean up
Signed-off-by: Sebastian Krupinski <krupinski01@gmail.com>
This commit is contained in:
+53
-145
@@ -1,79 +1,30 @@
|
|||||||
<template>
|
|
||||||
<div class="agenda-view">
|
|
||||||
<div class="view-controls">
|
|
||||||
<div class="view-navigation">
|
|
||||||
<v-btn size="x-small" variant="text" icon="mdi-chevron-left" @click="previousPeriod" />
|
|
||||||
<v-btn size="x-small" variant="tonal" @click="goToToday">Today</v-btn>
|
|
||||||
<v-btn size="x-small" variant="text" icon="mdi-chevron-right" @click="nextPeriod" />
|
|
||||||
<span class="current-period">{{ dateRangeLabel }}</span>
|
|
||||||
</div>
|
|
||||||
<v-spacer />
|
|
||||||
<div class="view-selector">
|
|
||||||
<v-btn
|
|
||||||
v-for="span in AGENDA_VIEW_SPANS"
|
|
||||||
:key="span"
|
|
||||||
size="x-small"
|
|
||||||
variant="text"
|
|
||||||
@click="setSpan(span)"
|
|
||||||
:color="selectedSpan === span ? 'primary' : undefined"
|
|
||||||
>
|
|
||||||
{{ span.toUpperCase() }}
|
|
||||||
</v-btn>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<v-list class="agenda-list">
|
|
||||||
<template v-if="Object.keys(groupedEvents).length === 0">
|
|
||||||
<v-list-item>
|
|
||||||
<v-list-item-title class="text-center text-medium-emphasis">No events in this period</v-list-item-title>
|
|
||||||
</v-list-item>
|
|
||||||
</template>
|
|
||||||
<template v-for="(dayInstances, date) in groupedEvents" :key="date">
|
|
||||||
<v-list-subheader>{{ formatAgendaDate(date) }}</v-list-subheader>
|
|
||||||
<v-list-item
|
|
||||||
v-for="instance in dayInstances"
|
|
||||||
:key="instance.key"
|
|
||||||
@click="$emit('event-click', instance.entity)"
|
|
||||||
>
|
|
||||||
<template #prepend>
|
|
||||||
<v-icon :color="getEventColor(instance)">mdi-circle</v-icon>
|
|
||||||
</template>
|
|
||||||
<v-list-item-title>{{ instance.label || 'Untitled' }}</v-list-item-title>
|
|
||||||
<v-list-item-subtitle>
|
|
||||||
{{ instance.timeless ? 'All day' : `${formatInstanceTime(instance.startMs)} - ${formatInstanceTime(instance.endMs)}` }}
|
|
||||||
</v-list-item-subtitle>
|
|
||||||
</v-list-item>
|
|
||||||
</template>
|
|
||||||
</v-list>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, computed, watch } from 'vue';
|
import { ref, computed, watch } from 'vue';
|
||||||
import { AGENDA_VIEW_SPANS, spanToDays, type AgendaViewSpan } from '@/types/spans';
|
import { AGENDA_VIEW_SPANS, spanToDays, type AgendaViewSpan } from '@/types/spans';
|
||||||
import type { EntityObject } from '@ChronoManager/models/entity';
|
import type { EntityObject } from '@ChronoManager/models/entity';
|
||||||
import type { CollectionObject } from '@ChronoManager/models/collection';
|
import type { CollectionObject } from '@ChronoManager/models/collection';
|
||||||
import { daysVisibleRange, shiftLocalDays } from '@/utils/date';
|
import { daysVisibleRange } from '@/utils/date';
|
||||||
import { useChronoInstancesStore } from '@/stores/chronoInstancesStore';
|
import { useChronoInstancesStore } from '@/stores/chronoInstancesStore';
|
||||||
import type { CalendarInstance } from '@/types/instance';
|
import type { CalendarInstance } from '@/types/instance';
|
||||||
|
import ViewControls from './ViewControls.vue';
|
||||||
type CalendarEntity = EntityObject;
|
import { formatEpochTime, formatFullDate } from '@/utils/format';
|
||||||
type CalendarCollection = CollectionObject;
|
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
calendars: CalendarCollection[];
|
calendars: CollectionObject[];
|
||||||
currentDate: Date;
|
currentDate: Date;
|
||||||
initialSpan?: AgendaViewSpan;
|
initialSpan?: AgendaViewSpan;
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
'event-click': [event: EntityObject];
|
||||||
|
'update:span': [span: AgendaViewSpan];
|
||||||
|
'update:current-date': [date: Date];
|
||||||
|
}>();
|
||||||
|
|
||||||
const instancesStore = useChronoInstancesStore();
|
const instancesStore = useChronoInstancesStore();
|
||||||
|
|
||||||
const selectedSpan = ref<AgendaViewSpan>(props.initialSpan ?? '1w');
|
const selectedSpan = ref<AgendaViewSpan>(props.initialSpan ?? '1w');
|
||||||
|
const selectedSpanDays = computed(() => spanToDays(selectedSpan.value));
|
||||||
const emit = defineEmits<{
|
|
||||||
'event-click': [event: CalendarEntity];
|
|
||||||
'update:span': [span: AgendaViewSpan];
|
|
||||||
'update:current-date': [date: Date];
|
|
||||||
}>();
|
|
||||||
|
|
||||||
watch(() => props.initialSpan, (newSpan) => {
|
watch(() => props.initialSpan, (newSpan) => {
|
||||||
if (newSpan && newSpan !== selectedSpan.value) {
|
if (newSpan && newSpan !== selectedSpan.value) {
|
||||||
@@ -86,58 +37,25 @@ function setSpan(span: AgendaViewSpan) {
|
|||||||
emit('update:span', span);
|
emit('update:span', span);
|
||||||
}
|
}
|
||||||
|
|
||||||
function getSpanDays(): number {
|
|
||||||
return spanToDays(selectedSpan.value);
|
|
||||||
}
|
|
||||||
|
|
||||||
function previousPeriod() {
|
|
||||||
emit('update:current-date', shiftLocalDays(props.currentDate, -getSpanDays()));
|
|
||||||
}
|
|
||||||
|
|
||||||
function nextPeriod() {
|
|
||||||
emit('update:current-date', shiftLocalDays(props.currentDate, getSpanDays()));
|
|
||||||
}
|
|
||||||
|
|
||||||
function goToToday() {
|
|
||||||
emit('update:current-date', new Date());
|
|
||||||
}
|
|
||||||
|
|
||||||
const dateRange = computed(() => {
|
const dateRange = computed(() => {
|
||||||
const range = daysVisibleRange(props.currentDate, getSpanDays());
|
const range = daysVisibleRange(props.currentDate, selectedSpanDays.value);
|
||||||
return {
|
return {
|
||||||
start: new Date(range.startMs),
|
start: new Date(range.start),
|
||||||
end: new Date(range.endMs),
|
end: new Date(range.end),
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
const dateRangeLabel = computed(() => {
|
|
||||||
const { start, end: exclusiveEnd } = dateRange.value;
|
|
||||||
const end = new Date(exclusiveEnd);
|
|
||||||
end.setDate(end.getDate() - 1);
|
|
||||||
const formatOptions: Intl.DateTimeFormatOptions = { month: 'short', day: 'numeric' };
|
|
||||||
|
|
||||||
if (getSpanDays() === 1) {
|
|
||||||
return start.toLocaleDateString('en-US', { month: 'long', day: 'numeric', year: 'numeric' });
|
|
||||||
}
|
|
||||||
|
|
||||||
if (start.getMonth() === end.getMonth()) {
|
|
||||||
return `${start.toLocaleDateString('en-US', { month: 'short', day: 'numeric' })} - ${end.getDate()}, ${start.getFullYear()}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
return `${start.toLocaleDateString('en-US', formatOptions)} - ${end.toLocaleDateString('en-US', formatOptions)}, ${end.getFullYear()}`;
|
|
||||||
});
|
|
||||||
|
|
||||||
const groupedEvents = computed(() => {
|
const groupedEvents = computed(() => {
|
||||||
const grouped: Record<string, CalendarInstance[]> = {};
|
const grouped: Record<string, CalendarInstance[]> = {};
|
||||||
const { start, end } = dateRange.value;
|
const { start, end } = dateRange.value;
|
||||||
|
|
||||||
const instances = instancesStore.instancesForRange({
|
const instances = instancesStore.instancesForRange({
|
||||||
startMs: start.getTime(),
|
start: start.getTime(),
|
||||||
endMs: end.getTime(),
|
end: end.getTime(),
|
||||||
});
|
});
|
||||||
|
|
||||||
instances.forEach(instance => {
|
instances.forEach(instance => {
|
||||||
const dateKey = new Date(instance.startMs).toDateString();
|
const dateKey = new Date(instance.start).toDateString();
|
||||||
if (!grouped[dateKey]) {
|
if (!grouped[dateKey]) {
|
||||||
grouped[dateKey] = [];
|
grouped[dateKey] = [];
|
||||||
}
|
}
|
||||||
@@ -153,26 +71,44 @@ function getEventColor(instance: CalendarInstance): string {
|
|||||||
const calendar = props.calendars.find(cal => cal.identifier === entity.collection);
|
const calendar = props.calendars.find(cal => cal.identifier === entity.collection);
|
||||||
return calendar?.properties?.color || '#1976D2';
|
return calendar?.properties?.color || '#1976D2';
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatTime(date: Date): string {
|
|
||||||
return date.toLocaleTimeString('en-US', { hour: 'numeric', minute: '2-digit' });
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatInstanceTime(timestamp: number): string {
|
|
||||||
return formatTime(new Date(timestamp));
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatAgendaDate(dateString: string): string {
|
|
||||||
const date = new Date(dateString);
|
|
||||||
return date.toLocaleDateString('en-US', {
|
|
||||||
weekday: 'long',
|
|
||||||
year: 'numeric',
|
|
||||||
month: 'long',
|
|
||||||
day: 'numeric'
|
|
||||||
});
|
|
||||||
}
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="agenda-view">
|
||||||
|
<ViewControls
|
||||||
|
mode="days"
|
||||||
|
:current-date="currentDate"
|
||||||
|
:spans="AGENDA_VIEW_SPANS"
|
||||||
|
:selected-span="selectedSpan"
|
||||||
|
@update:current-date="emit('update:current-date', $event)"
|
||||||
|
@update:span="setSpan"
|
||||||
|
/>
|
||||||
|
<v-list class="agenda-list">
|
||||||
|
<template v-if="Object.keys(groupedEvents).length === 0">
|
||||||
|
<v-list-item>
|
||||||
|
<v-list-item-title class="text-center text-medium-emphasis">No events in this period</v-list-item-title>
|
||||||
|
</v-list-item>
|
||||||
|
</template>
|
||||||
|
<template v-for="(dayInstances, date) in groupedEvents" :key="date">
|
||||||
|
<v-list-subheader>{{ formatFullDate(date) }}</v-list-subheader>
|
||||||
|
<v-list-item
|
||||||
|
v-for="instance in dayInstances"
|
||||||
|
:key="instance.key"
|
||||||
|
@click="$emit('event-click', instance.entity)"
|
||||||
|
>
|
||||||
|
<template #prepend>
|
||||||
|
<v-icon :color="getEventColor(instance)">mdi-circle</v-icon>
|
||||||
|
</template>
|
||||||
|
<v-list-item-title>{{ instance.label || 'Untitled' }}</v-list-item-title>
|
||||||
|
<v-list-item-subtitle>
|
||||||
|
{{ instance.timeless ? 'All day' : `${formatEpochTime(instance.start)} - ${formatEpochTime(instance.end)}` }}
|
||||||
|
</v-list-item-subtitle>
|
||||||
|
</v-list-item>
|
||||||
|
</template>
|
||||||
|
</v-list>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.agenda-view {
|
.agenda-view {
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -180,34 +116,6 @@ function formatAgendaDate(dateString: string): string {
|
|||||||
height: 100%;
|
height: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
.view-controls {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
padding: 4px 8px;
|
|
||||||
border-bottom: 1px solid rgb(var(--v-border-color));
|
|
||||||
background-color: rgb(var(--v-theme-surface));
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.view-navigation {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.current-period {
|
|
||||||
font-size: 14px;
|
|
||||||
font-weight: 500;
|
|
||||||
margin-left: 8px;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.view-selector {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 2px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.agenda-list {
|
.agenda-list {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
|
|||||||
@@ -1,3 +1,59 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ref } from 'vue';
|
||||||
|
import MonthView from './MonthView.vue';
|
||||||
|
import DaysView from './DaysView.vue';
|
||||||
|
import AgendaView from './AgendaView.vue';
|
||||||
|
import EventViewerPopup from './EventViewerPopup.vue';
|
||||||
|
import {
|
||||||
|
type AgendaViewSpan,
|
||||||
|
type DaysViewSpan
|
||||||
|
} from '@/types/spans';
|
||||||
|
import type { EntityObject } from '@ChronoManager/models/entity';
|
||||||
|
import type { CollectionObject } from '@ChronoManager/models/collection';
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
view: 'days' | 'month' | 'agenda';
|
||||||
|
currentDate: Date;
|
||||||
|
calendars: CollectionObject[];
|
||||||
|
initialDaysSpan?: DaysViewSpan;
|
||||||
|
initialAgendaViewSpan?: AgendaViewSpan;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
'event-click': [event: EntityObject];
|
||||||
|
'date-click': [date: Date];
|
||||||
|
'update:current-date': [date: Date];
|
||||||
|
'update:days-span': [span: DaysViewSpan];
|
||||||
|
'update:agenda-span': [span: AgendaViewSpan];
|
||||||
|
}>();
|
||||||
|
|
||||||
|
// Popup state
|
||||||
|
const hoveredEvent = ref<EntityObject | null>(null);
|
||||||
|
const popupPosition = ref({ x: 0, y: 0 });
|
||||||
|
const showPopup = ref(false);
|
||||||
|
let hideTimeout: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
|
||||||
|
function handleEventHover(data: { event: MouseEvent; entity: EntityObject }) {
|
||||||
|
if (hideTimeout) {
|
||||||
|
clearTimeout(hideTimeout);
|
||||||
|
hideTimeout = null;
|
||||||
|
}
|
||||||
|
hoveredEvent.value = data.entity;
|
||||||
|
popupPosition.value = {
|
||||||
|
x: data.event.clientX,
|
||||||
|
y: data.event.clientY
|
||||||
|
};
|
||||||
|
showPopup.value = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function hidePopup() {
|
||||||
|
hideTimeout = setTimeout(() => {
|
||||||
|
showPopup.value = false;
|
||||||
|
hoveredEvent.value = null;
|
||||||
|
}, 200);
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="calendar-view-container">
|
<div class="calendar-view-container">
|
||||||
<EventViewerPopup
|
<EventViewerPopup
|
||||||
@@ -40,78 +96,6 @@
|
|||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
|
||||||
import { computed, ref, watch } from 'vue';
|
|
||||||
import MonthView from './MonthView.vue';
|
|
||||||
import DaysView from './DaysView.vue';
|
|
||||||
import AgendaView from './AgendaView.vue';
|
|
||||||
import EventViewerPopup from './EventViewerPopup.vue';
|
|
||||||
import { spanToDays, type AgendaViewSpan, type DaysViewSpan } from '@/types/spans';
|
|
||||||
import type { EntityObject } from '@ChronoManager/models/entity';
|
|
||||||
import type { CollectionObject } from '@ChronoManager/models/collection';
|
|
||||||
import type { VisibleDateRange } from '@/types';
|
|
||||||
import { daysVisibleRange, monthGridVisibleRange } from '@/utils/date.ts';
|
|
||||||
|
|
||||||
const props = defineProps<{
|
|
||||||
view: 'days' | 'month' | 'agenda';
|
|
||||||
currentDate: Date;
|
|
||||||
calendars: CollectionObject[];
|
|
||||||
initialDaysSpan?: DaysViewSpan;
|
|
||||||
initialAgendaViewSpan?: AgendaViewSpan;
|
|
||||||
}>();
|
|
||||||
|
|
||||||
const emit = defineEmits<{
|
|
||||||
'event-click': [event: EntityObject];
|
|
||||||
'date-click': [date: Date];
|
|
||||||
'update:days-span': [span: DaysViewSpan];
|
|
||||||
'update:agenda-span': [span: AgendaViewSpan];
|
|
||||||
'update:current-date': [date: Date];
|
|
||||||
'update:visible-range': [range: VisibleDateRange];
|
|
||||||
}>();
|
|
||||||
|
|
||||||
const visibleRange = computed(() => {
|
|
||||||
if (props.view === 'month') {
|
|
||||||
return monthGridVisibleRange(props.currentDate);
|
|
||||||
}
|
|
||||||
|
|
||||||
const span = props.view === 'agenda'
|
|
||||||
? props.initialAgendaViewSpan ?? '1w'
|
|
||||||
: props.initialDaysSpan ?? '7d';
|
|
||||||
|
|
||||||
return daysVisibleRange(props.currentDate, spanToDays(span));
|
|
||||||
});
|
|
||||||
|
|
||||||
watch(visibleRange, range => {
|
|
||||||
emit('update:visible-range', range);
|
|
||||||
}, { immediate: true });
|
|
||||||
|
|
||||||
// Popup state
|
|
||||||
const hoveredEvent = ref<EntityObject | null>(null);
|
|
||||||
const popupPosition = ref({ x: 0, y: 0 });
|
|
||||||
const showPopup = ref(false);
|
|
||||||
let hideTimeout: ReturnType<typeof setTimeout> | null = null;
|
|
||||||
|
|
||||||
function handleEventHover(data: { event: MouseEvent; entity: EntityObject }) {
|
|
||||||
if (hideTimeout) {
|
|
||||||
clearTimeout(hideTimeout);
|
|
||||||
hideTimeout = null;
|
|
||||||
}
|
|
||||||
hoveredEvent.value = data.entity;
|
|
||||||
popupPosition.value = {
|
|
||||||
x: data.event.clientX,
|
|
||||||
y: data.event.clientY
|
|
||||||
};
|
|
||||||
showPopup.value = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
function hidePopup() {
|
|
||||||
hideTimeout = setTimeout(() => {
|
|
||||||
showPopup.value = false;
|
|
||||||
hoveredEvent.value = null;
|
|
||||||
}, 200);
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.calendar-view-container {
|
.calendar-view-container {
|
||||||
height: 100%;
|
height: 100%;
|
||||||
|
|||||||
+142
-233
@@ -1,26 +1,146 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, computed, watch } from 'vue';
|
||||||
|
import { layoutMultiDayLanes, type MultiDaySegment } from '@/utils/multiDayLanes';
|
||||||
|
import { shiftLocalDays, startOfLocalDay } from '@/utils/date';
|
||||||
|
import { DAYS_VIEW_SPANS, spanToDays, type DaysViewSpan } from '@/types/spans';
|
||||||
|
import type { EntityObject } from '@ChronoManager/models/entity';
|
||||||
|
import type { CollectionObject } from '@ChronoManager/models/collection';
|
||||||
|
import { useChronoInstancesStore } from '@/stores/chronoInstancesStore';
|
||||||
|
import type { CalendarInstance } from '@/types/instance';
|
||||||
|
import ViewControls from './ViewControls.vue';
|
||||||
|
import { formatEpochTime, formatHour, formatWeekDay } from '@/utils/format';
|
||||||
|
|
||||||
|
const ALL_DAY_EVENT_HEIGHT = 24;
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
currentDate: Date;
|
||||||
|
calendars: CollectionObject[];
|
||||||
|
initialSpan?: DaysViewSpan;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const instancesStore = useChronoInstancesStore();
|
||||||
|
|
||||||
|
const selectedSpan = ref<DaysViewSpan>(props.initialSpan ?? '7d');
|
||||||
|
const daysCount = computed(() => spanToDays(selectedSpan.value));
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
'event-click': [event: EntityObject];
|
||||||
|
'event-hover': [data: { event: MouseEvent; entity: EntityObject }];
|
||||||
|
'event-hover-end': [];
|
||||||
|
'date-click': [date: Date];
|
||||||
|
'update:span': [span: DaysViewSpan];
|
||||||
|
'update:current-date': [date: Date];
|
||||||
|
}>();
|
||||||
|
|
||||||
|
watch(() => props.initialSpan, (newSpan) => {
|
||||||
|
if (newSpan && newSpan !== selectedSpan.value) {
|
||||||
|
selectedSpan.value = newSpan;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
function setSpan(span: DaysViewSpan) {
|
||||||
|
selectedSpan.value = span;
|
||||||
|
emit('update:span', span);
|
||||||
|
}
|
||||||
|
|
||||||
|
const visibleDates = computed(() => {
|
||||||
|
const dates: Date[] = [];
|
||||||
|
const start = new Date(props.currentDate);
|
||||||
|
|
||||||
|
for (let i = 0; i < daysCount.value; i++) {
|
||||||
|
dates.push(new Date(start));
|
||||||
|
start.setDate(start.getDate() + 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
return dates;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Compute all-day/multi-day event segments
|
||||||
|
const allDaySegments = computed(() => {
|
||||||
|
if (visibleDates.value.length === 0) {
|
||||||
|
return { segments: [], laneCount: 0 };
|
||||||
|
}
|
||||||
|
|
||||||
|
const rangeStart = startOfLocalDay(visibleDates.value[0]);
|
||||||
|
const rangeEnd = startOfLocalDay(visibleDates.value[visibleDates.value.length - 1]);
|
||||||
|
|
||||||
|
const getColumnIndex = (date: Date): number => {
|
||||||
|
const targetDay = startOfLocalDay(date);
|
||||||
|
return visibleDates.value.findIndex(d => startOfLocalDay(d).getTime() === targetDay.getTime());
|
||||||
|
};
|
||||||
|
|
||||||
|
const instances = instancesStore.instancesForRange({
|
||||||
|
start: rangeStart.getTime(),
|
||||||
|
end: shiftLocalDays(rangeEnd, 1).getTime(),
|
||||||
|
});
|
||||||
|
|
||||||
|
return layoutMultiDayLanes(instances, rangeStart, rangeEnd, daysCount.value, getColumnIndex);
|
||||||
|
});
|
||||||
|
|
||||||
|
function isToday(date: Date): boolean {
|
||||||
|
const today = new Date();
|
||||||
|
return date.toDateString() === today.toDateString();
|
||||||
|
}
|
||||||
|
|
||||||
|
function getTimedEvents(date: Date): CalendarInstance[] {
|
||||||
|
return instancesStore.instancesForDay(date).filter(instance => !instance.timeless);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getEventColor(instance: CalendarInstance): string {
|
||||||
|
if (instance.color) return instance.color;
|
||||||
|
const calendar = props.calendars.find(cal => cal.identifier === instance.entity.collection);
|
||||||
|
return calendar?.properties?.color || '#1976D2';
|
||||||
|
}
|
||||||
|
|
||||||
|
function getAllDayEventStyle(segment: MultiDaySegment) {
|
||||||
|
return {
|
||||||
|
backgroundColor: getEventColor(segment.instance),
|
||||||
|
left: `calc(${segment.startCol} / ${daysCount.value} * 100% + 4px)`,
|
||||||
|
width: `calc(${segment.span} / ${daysCount.value} * 100% - 8px)`,
|
||||||
|
top: `${segment.lane * ALL_DAY_EVENT_HEIGHT}px`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function getEventStyle(instance: CalendarInstance) {
|
||||||
|
const startTime = new Date(instance.start);
|
||||||
|
const start = startTime.getHours() * 60 + startTime.getMinutes();
|
||||||
|
const duration = instance.duration / (1000 * 60);
|
||||||
|
|
||||||
|
return {
|
||||||
|
top: `${(start / 1440) * 100}%`,
|
||||||
|
height: `${(duration / 1440) * 100}%`,
|
||||||
|
backgroundColor: getEventColor(instance),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleDayClick(event: MouseEvent, day: Date) {
|
||||||
|
const target = event.currentTarget as HTMLElement;
|
||||||
|
const rect = target.getBoundingClientRect();
|
||||||
|
const clickY = event.clientY - rect.top;
|
||||||
|
|
||||||
|
// Calculate the hour based on click position (60px per hour)
|
||||||
|
const totalMinutes = Math.floor((clickY / 60) * 60);
|
||||||
|
const hours = Math.floor(totalMinutes / 60);
|
||||||
|
const minutes = Math.round((totalMinutes % 60) / 15) * 15; // Round to nearest 15 min
|
||||||
|
|
||||||
|
// Create a new date with the clicked time
|
||||||
|
const clickedDate = new Date(day);
|
||||||
|
clickedDate.setHours(hours, minutes, 0, 0);
|
||||||
|
|
||||||
|
emit('date-click', clickedDate);
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="days-view">
|
<div class="days-view">
|
||||||
<div class="view-controls">
|
<ViewControls
|
||||||
<div class="view-navigation">
|
mode="days"
|
||||||
<v-btn size="x-small" variant="text" icon="mdi-chevron-left" @click="previousPeriod" />
|
:current-date="currentDate"
|
||||||
<v-btn size="x-small" variant="tonal" @click="goToToday">Today</v-btn>
|
:spans="DAYS_VIEW_SPANS"
|
||||||
<v-btn size="x-small" variant="text" icon="mdi-chevron-right" @click="nextPeriod" />
|
:selected-span="selectedSpan"
|
||||||
<span class="current-period">{{ dateRangeLabel }}</span>
|
@update:current-date="emit('update:current-date', $event)"
|
||||||
</div>
|
@update:span="setSpan"
|
||||||
<v-spacer />
|
/>
|
||||||
<div class="view-selector">
|
|
||||||
<v-btn
|
|
||||||
v-for="span in DAYS_VIEW_SPANS"
|
|
||||||
:key="span"
|
|
||||||
size="x-small"
|
|
||||||
variant="text"
|
|
||||||
@click="setSpan(span)"
|
|
||||||
:color="selectedSpan === span ? 'primary' : undefined"
|
|
||||||
>
|
|
||||||
{{ span.toUpperCase() }}
|
|
||||||
</v-btn>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="days-grid">
|
<div class="days-grid">
|
||||||
<!-- Fixed Headers Row -->
|
<!-- Fixed Headers Row -->
|
||||||
<div class="day-headers">
|
<div class="day-headers">
|
||||||
@@ -83,7 +203,7 @@
|
|||||||
>
|
>
|
||||||
<template v-if="daysCount <= 3">
|
<template v-if="daysCount <= 3">
|
||||||
<div class="event-time">
|
<div class="event-time">
|
||||||
{{ formatInstanceTime(instance.startMs) }} - {{ formatInstanceTime(instance.endMs) }}
|
{{ formatEpochTime(instance.start) }} - {{ formatEpochTime(instance.end) }}
|
||||||
</div>
|
</div>
|
||||||
<div class="event-title">{{ instance.label || 'Untitled' }}</div>
|
<div class="event-title">{{ instance.label || 'Untitled' }}</div>
|
||||||
</template>
|
</template>
|
||||||
@@ -99,189 +219,6 @@
|
|||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
|
||||||
import { ref, computed, watch } from 'vue';
|
|
||||||
import { layoutMultiDayLanes, type MultiDaySegment } from '@/utils/multiDayLanes';
|
|
||||||
import { shiftLocalDays, startOfLocalDay } from '@/utils/date';
|
|
||||||
import { DAYS_VIEW_SPANS, spanToDays, type DaysViewSpan } from '@/types/spans';
|
|
||||||
import type { EntityObject } from '@ChronoManager/models/entity';
|
|
||||||
import type { CollectionObject } from '@ChronoManager/models/collection';
|
|
||||||
import { useChronoInstancesStore } from '@/stores/chronoInstancesStore';
|
|
||||||
import type { CalendarInstance } from '@/types/instance';
|
|
||||||
|
|
||||||
const ALL_DAY_EVENT_HEIGHT = 24;
|
|
||||||
|
|
||||||
type CalendarEntity = EntityObject;
|
|
||||||
type CalendarCollection = CollectionObject;
|
|
||||||
|
|
||||||
const props = defineProps<{
|
|
||||||
currentDate: Date;
|
|
||||||
calendars: CalendarCollection[];
|
|
||||||
initialSpan?: DaysViewSpan;
|
|
||||||
}>();
|
|
||||||
|
|
||||||
const instancesStore = useChronoInstancesStore();
|
|
||||||
|
|
||||||
const selectedSpan = ref<DaysViewSpan>(props.initialSpan ?? '7d');
|
|
||||||
const daysCount = computed(() => spanToDays(selectedSpan.value));
|
|
||||||
|
|
||||||
const emit = defineEmits<{
|
|
||||||
'event-click': [event: CalendarEntity];
|
|
||||||
'event-hover': [data: { event: MouseEvent; entity: CalendarEntity }];
|
|
||||||
'event-hover-end': [];
|
|
||||||
'date-click': [date: Date];
|
|
||||||
'update:span': [span: DaysViewSpan];
|
|
||||||
'update:current-date': [date: Date];
|
|
||||||
}>();
|
|
||||||
|
|
||||||
watch(() => props.initialSpan, (newSpan) => {
|
|
||||||
if (newSpan && newSpan !== selectedSpan.value) {
|
|
||||||
selectedSpan.value = newSpan;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
function setSpan(span: DaysViewSpan) {
|
|
||||||
selectedSpan.value = span;
|
|
||||||
emit('update:span', span);
|
|
||||||
}
|
|
||||||
|
|
||||||
function previousPeriod() {
|
|
||||||
emit('update:current-date', shiftLocalDays(props.currentDate, -daysCount.value));
|
|
||||||
}
|
|
||||||
|
|
||||||
function nextPeriod() {
|
|
||||||
emit('update:current-date', shiftLocalDays(props.currentDate, daysCount.value));
|
|
||||||
}
|
|
||||||
|
|
||||||
function goToToday() {
|
|
||||||
emit('update:current-date', new Date());
|
|
||||||
}
|
|
||||||
|
|
||||||
const dateRangeLabel = computed(() => {
|
|
||||||
if (visibleDates.value.length === 0) return '';
|
|
||||||
|
|
||||||
const first = visibleDates.value[0];
|
|
||||||
const last = visibleDates.value[visibleDates.value.length - 1];
|
|
||||||
|
|
||||||
const formatOptions: Intl.DateTimeFormatOptions = { month: 'short', day: 'numeric' };
|
|
||||||
|
|
||||||
if (daysCount.value === 1) {
|
|
||||||
return first.toLocaleDateString('en-US', { month: 'long', day: 'numeric', year: 'numeric' });
|
|
||||||
}
|
|
||||||
|
|
||||||
if (first.getMonth() === last.getMonth()) {
|
|
||||||
return `${first.toLocaleDateString('en-US', { month: 'short', day: 'numeric' })} - ${last.getDate()}, ${first.getFullYear()}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
return `${first.toLocaleDateString('en-US', formatOptions)} - ${last.toLocaleDateString('en-US', formatOptions)}, ${last.getFullYear()}`;
|
|
||||||
});
|
|
||||||
|
|
||||||
const visibleDates = computed(() => {
|
|
||||||
const dates: Date[] = [];
|
|
||||||
const start = new Date(props.currentDate);
|
|
||||||
|
|
||||||
for (let i = 0; i < daysCount.value; i++) {
|
|
||||||
dates.push(new Date(start));
|
|
||||||
start.setDate(start.getDate() + 1);
|
|
||||||
}
|
|
||||||
|
|
||||||
return dates;
|
|
||||||
});
|
|
||||||
|
|
||||||
// Compute all-day/multi-day event segments
|
|
||||||
const allDaySegments = computed(() => {
|
|
||||||
if (visibleDates.value.length === 0) {
|
|
||||||
return { segments: [], laneCount: 0 };
|
|
||||||
}
|
|
||||||
|
|
||||||
const rangeStart = startOfLocalDay(visibleDates.value[0]);
|
|
||||||
const rangeEnd = startOfLocalDay(visibleDates.value[visibleDates.value.length - 1]);
|
|
||||||
|
|
||||||
const getColumnIndex = (date: Date): number => {
|
|
||||||
const targetDay = startOfLocalDay(date);
|
|
||||||
return visibleDates.value.findIndex(d => startOfLocalDay(d).getTime() === targetDay.getTime());
|
|
||||||
};
|
|
||||||
|
|
||||||
const instances = instancesStore.instancesForRange({
|
|
||||||
startMs: rangeStart.getTime(),
|
|
||||||
endMs: shiftLocalDays(rangeEnd, 1).getTime(),
|
|
||||||
});
|
|
||||||
|
|
||||||
return layoutMultiDayLanes(instances, rangeStart, rangeEnd, daysCount.value, getColumnIndex);
|
|
||||||
});
|
|
||||||
|
|
||||||
function isToday(date: Date): boolean {
|
|
||||||
const today = new Date();
|
|
||||||
return date.toDateString() === today.toDateString();
|
|
||||||
}
|
|
||||||
|
|
||||||
function getTimedEvents(date: Date): CalendarInstance[] {
|
|
||||||
return instancesStore.instancesForDay(date).filter(instance => !instance.timeless);
|
|
||||||
}
|
|
||||||
|
|
||||||
function getEventColor(instance: CalendarInstance): string {
|
|
||||||
if (instance.color) return instance.color;
|
|
||||||
const calendar = props.calendars.find(cal => cal.identifier === instance.entity.collection);
|
|
||||||
return calendar?.properties?.color || '#1976D2';
|
|
||||||
}
|
|
||||||
|
|
||||||
function getAllDayEventStyle(segment: MultiDaySegment) {
|
|
||||||
return {
|
|
||||||
backgroundColor: getEventColor(segment.instance),
|
|
||||||
left: `calc(${segment.startCol} / ${daysCount.value} * 100% + 4px)`,
|
|
||||||
width: `calc(${segment.span} / ${daysCount.value} * 100% - 8px)`,
|
|
||||||
top: `${segment.lane * ALL_DAY_EVENT_HEIGHT}px`,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function getEventStyle(instance: CalendarInstance) {
|
|
||||||
const startTime = new Date(instance.startMs);
|
|
||||||
const start = startTime.getHours() * 60 + startTime.getMinutes();
|
|
||||||
const duration = instance.durationMs / (1000 * 60);
|
|
||||||
|
|
||||||
return {
|
|
||||||
top: `${(start / 1440) * 100}%`,
|
|
||||||
height: `${(duration / 1440) * 100}%`,
|
|
||||||
backgroundColor: getEventColor(instance),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatHour(hour: number): string {
|
|
||||||
const ampm = hour >= 12 ? 'PM' : 'AM';
|
|
||||||
const displayHour = hour % 12 || 12;
|
|
||||||
return `${displayHour} ${ampm}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatTime(date: Date): string {
|
|
||||||
return date.toLocaleTimeString('en-US', { hour: 'numeric', minute: '2-digit' });
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatInstanceTime(timestamp: number): string {
|
|
||||||
return formatTime(new Date(timestamp));
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatWeekDay(date: Date): string {
|
|
||||||
return date.toLocaleDateString('en-US', { weekday: 'short' });
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleDayClick(event: MouseEvent, day: Date) {
|
|
||||||
const target = event.currentTarget as HTMLElement;
|
|
||||||
const rect = target.getBoundingClientRect();
|
|
||||||
const clickY = event.clientY - rect.top;
|
|
||||||
|
|
||||||
// Calculate the hour based on click position (60px per hour)
|
|
||||||
const totalMinutes = Math.floor((clickY / 60) * 60);
|
|
||||||
const hours = Math.floor(totalMinutes / 60);
|
|
||||||
const minutes = Math.round((totalMinutes % 60) / 15) * 15; // Round to nearest 15 min
|
|
||||||
|
|
||||||
// Create a new date with the clicked time
|
|
||||||
const clickedDate = new Date(day);
|
|
||||||
clickedDate.setHours(hours, minutes, 0, 0);
|
|
||||||
|
|
||||||
emit('date-click', clickedDate);
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.days-view {
|
.days-view {
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -290,34 +227,6 @@ function handleDayClick(event: MouseEvent, day: Date) {
|
|||||||
min-height: 600px;
|
min-height: 600px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.view-controls {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
padding: 4px 8px;
|
|
||||||
border-bottom: 1px solid rgb(var(--v-border-color));
|
|
||||||
background-color: rgb(var(--v-theme-surface));
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.view-navigation {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.current-period {
|
|
||||||
font-size: 14px;
|
|
||||||
font-weight: 500;
|
|
||||||
margin-left: 8px;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.view-selector {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 2px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.days-grid {
|
.days-grid {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed } from 'vue'
|
import { computed } from 'vue'
|
||||||
import { parseCalendarDate } from '@/utils/date'
|
import { parseCalendarDate } from '@/utils/date'
|
||||||
|
import { formatTime as formatClockTime } from '@/utils/format'
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
event: any | null
|
event: any | null
|
||||||
@@ -13,12 +14,7 @@ const emit = defineEmits<{
|
|||||||
}>()
|
}>()
|
||||||
|
|
||||||
const formatTime = (isoString: string | null | undefined): string => {
|
const formatTime = (isoString: string | null | undefined): string => {
|
||||||
if (!isoString) return 'Not set'
|
return isoString ? formatClockTime(new Date(isoString)) : 'Not set'
|
||||||
const date = new Date(isoString)
|
|
||||||
return date.toLocaleTimeString('en-US', {
|
|
||||||
hour: 'numeric',
|
|
||||||
minute: '2-digit'
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const formatAllDayDate = (isoString: string | null | undefined): string => {
|
const formatAllDayDate = (isoString: string | null | undefined): string => {
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, ref, onMounted, onUnmounted } from 'vue';
|
import { computed, ref, onMounted, onUnmounted } from 'vue';
|
||||||
import { layoutMultiDayLanes, type MultiDaySegment } from '@/utils/multiDayLanes';
|
import { layoutMultiDayLanes, type MultiDaySegment } from '@/utils/multiDayLanes';
|
||||||
import { shiftLocalMonths, startOfLocalDay } from '@/utils/date';
|
import { startOfLocalDay } from '@/utils/date';
|
||||||
import { useChronoInstancesStore } from '@/stores/chronoInstancesStore';
|
import { useChronoInstancesStore } from '@/stores/chronoInstancesStore';
|
||||||
import type { CalendarInstance } from '@/types/instance';
|
import type { CalendarInstance } from '@/types/instance';
|
||||||
|
import ViewControls from './ViewControls.vue';
|
||||||
|
import { formatEpochTime } from '@/utils/format';
|
||||||
|
|
||||||
const EVENT_HEIGHT = 22; // Height of each event row in pixels
|
const EVENT_HEIGHT = 22; // Height of each event row in pixels
|
||||||
const DATE_ROW_HEIGHT = 34; // Space reserved at the top of each cell for the date label
|
const DATE_ROW_HEIGHT = 34; // Space reserved at the top of each cell for the date label
|
||||||
@@ -39,23 +41,6 @@ const emit = defineEmits<{
|
|||||||
|
|
||||||
const weekDays = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
|
const weekDays = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
|
||||||
|
|
||||||
// Navigation functions
|
|
||||||
function previousMonth() {
|
|
||||||
emit('update:current-date', shiftLocalMonths(props.currentDate, -1));
|
|
||||||
}
|
|
||||||
|
|
||||||
function nextMonth() {
|
|
||||||
emit('update:current-date', shiftLocalMonths(props.currentDate, 1));
|
|
||||||
}
|
|
||||||
|
|
||||||
function goToToday() {
|
|
||||||
emit('update:current-date', new Date());
|
|
||||||
}
|
|
||||||
|
|
||||||
const monthLabel = computed(() => {
|
|
||||||
return props.currentDate.toLocaleDateString('en-US', { month: 'long', year: 'numeric' });
|
|
||||||
});
|
|
||||||
|
|
||||||
// Track the calendar body size so each week row shows as many event rows as fit
|
// Track the calendar body size so each week row shows as many event rows as fit
|
||||||
const bodyRef = ref<HTMLElement | null>(null);
|
const bodyRef = ref<HTMLElement | null>(null);
|
||||||
const bodyHeight = ref(600);
|
const bodyHeight = ref(600);
|
||||||
@@ -123,8 +108,8 @@ const weeks = computed<WeekData[]>(() => {
|
|||||||
|
|
||||||
// Get multiday segments for this week
|
// Get multiday segments for this week
|
||||||
const instances = instancesStore.instancesForRange({
|
const instances = instancesStore.instancesForRange({
|
||||||
startMs: weekStart.getTime(),
|
start: weekStart.getTime(),
|
||||||
endMs: new Date(
|
end: new Date(
|
||||||
weekEnd.getFullYear(),
|
weekEnd.getFullYear(),
|
||||||
weekEnd.getMonth(),
|
weekEnd.getMonth(),
|
||||||
weekEnd.getDate() + 1,
|
weekEnd.getDate() + 1,
|
||||||
@@ -189,12 +174,6 @@ function morePopoverLabel(date: Date): string {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatStartTime(instance: CalendarInstance): string {
|
|
||||||
return new Date(instance.startMs).toLocaleTimeString('en-US', {
|
|
||||||
hour: 'numeric',
|
|
||||||
minute: '2-digit',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function isToday(date: Date): boolean {
|
function isToday(date: Date): boolean {
|
||||||
const today = new Date();
|
const today = new Date();
|
||||||
@@ -211,14 +190,11 @@ function getEventColor(instance: CalendarInstance): string {
|
|||||||
<template>
|
<template>
|
||||||
<div class="month-view">
|
<div class="month-view">
|
||||||
<!-- Navigation Controls -->
|
<!-- Navigation Controls -->
|
||||||
<div class="view-controls">
|
<ViewControls
|
||||||
<div class="view-navigation">
|
mode="month"
|
||||||
<v-btn size="x-small" variant="text" icon="mdi-chevron-left" @click="previousMonth" />
|
:current-date="currentDate"
|
||||||
<v-btn size="x-small" variant="tonal" @click="goToToday">Today</v-btn>
|
@update:current-date="emit('update:current-date', $event)"
|
||||||
<v-btn size="x-small" variant="text" icon="mdi-chevron-right" @click="nextMonth" />
|
/>
|
||||||
<span class="current-period">{{ monthLabel }}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Header row -->
|
<!-- Header row -->
|
||||||
<div class="day-headers">
|
<div class="day-headers">
|
||||||
@@ -292,7 +268,7 @@ function getEventColor(instance: CalendarInstance): string {
|
|||||||
</template>
|
</template>
|
||||||
<v-list-item-title>
|
<v-list-item-title>
|
||||||
<span v-if="!instance.timeless" class="more-popover-time">
|
<span v-if="!instance.timeless" class="more-popover-time">
|
||||||
{{ formatStartTime(instance) }}
|
{{ formatEpochTime(instance.start) }}
|
||||||
</span>
|
</span>
|
||||||
{{ instance.label || 'Untitled' }}
|
{{ instance.label || 'Untitled' }}
|
||||||
</v-list-item-title>
|
</v-list-item-title>
|
||||||
@@ -345,28 +321,6 @@ function getEventColor(instance: CalendarInstance): string {
|
|||||||
border: 1px solid rgb(var(--v-border-color));
|
border: 1px solid rgb(var(--v-border-color));
|
||||||
}
|
}
|
||||||
|
|
||||||
.view-controls {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
padding: 4px 8px;
|
|
||||||
border-bottom: 1px solid rgb(var(--v-border-color));
|
|
||||||
background-color: rgb(var(--v-theme-surface));
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.view-navigation {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.current-period {
|
|
||||||
font-size: 14px;
|
|
||||||
font-weight: 500;
|
|
||||||
margin-left: 8px;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.day-headers {
|
.day-headers {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(7, 1fr);
|
grid-template-columns: repeat(7, 1fr);
|
||||||
|
|||||||
@@ -0,0 +1,110 @@
|
|||||||
|
<script setup lang="ts" generic="S extends TimeSpanLabel">
|
||||||
|
import { computed } from 'vue';
|
||||||
|
import { spanToDays, type TimeSpanLabel } from '@/types/spans';
|
||||||
|
import { daysVisibleRange, shiftLocalDays, shiftLocalMonths } from '@/utils/date';
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
mode: 'days' | 'month';
|
||||||
|
currentDate: Date;
|
||||||
|
spans?: readonly S[];
|
||||||
|
selectedSpan?: S;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
'update:current-date': [date: Date];
|
||||||
|
'update:span': [span: S];
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const spanDays = computed(() => props.selectedSpan ? spanToDays(props.selectedSpan) : 1);
|
||||||
|
|
||||||
|
function previousPeriod() {
|
||||||
|
emit('update:current-date', props.mode === 'month'
|
||||||
|
? shiftLocalMonths(props.currentDate, -1)
|
||||||
|
: shiftLocalDays(props.currentDate, -spanDays.value));
|
||||||
|
}
|
||||||
|
|
||||||
|
function nextPeriod() {
|
||||||
|
emit('update:current-date', props.mode === 'month'
|
||||||
|
? shiftLocalMonths(props.currentDate, 1)
|
||||||
|
: shiftLocalDays(props.currentDate, spanDays.value));
|
||||||
|
}
|
||||||
|
|
||||||
|
function goToToday() {
|
||||||
|
emit('update:current-date', new Date());
|
||||||
|
}
|
||||||
|
|
||||||
|
const periodLabel = computed(() => {
|
||||||
|
if (props.mode === 'month') {
|
||||||
|
return props.currentDate.toLocaleDateString('en-US', { month: 'long', year: 'numeric' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const range = daysVisibleRange(props.currentDate, spanDays.value);
|
||||||
|
const start = new Date(range.start);
|
||||||
|
const end = shiftLocalDays(new Date(range.end), -1);
|
||||||
|
const formatOptions: Intl.DateTimeFormatOptions = { month: 'short', day: 'numeric' };
|
||||||
|
|
||||||
|
if (spanDays.value === 1) {
|
||||||
|
return start.toLocaleDateString('en-US', { month: 'long', day: 'numeric', year: 'numeric' });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (start.getMonth() === end.getMonth()) {
|
||||||
|
return `${start.toLocaleDateString('en-US', formatOptions)} - ${end.getDate()}, ${start.getFullYear()}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return `${start.toLocaleDateString('en-US', formatOptions)} - ${end.toLocaleDateString('en-US', formatOptions)}, ${end.getFullYear()}`;
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="view-controls">
|
||||||
|
<div class="view-navigation">
|
||||||
|
<v-btn size="x-small" variant="text" icon="mdi-chevron-left" @click="previousPeriod" />
|
||||||
|
<v-btn size="x-small" variant="tonal" @click="goToToday">Today</v-btn>
|
||||||
|
<v-btn size="x-small" variant="text" icon="mdi-chevron-right" @click="nextPeriod" />
|
||||||
|
<span class="current-period">{{ periodLabel }}</span>
|
||||||
|
</div>
|
||||||
|
<v-spacer />
|
||||||
|
<div v-if="spans?.length" class="view-selector">
|
||||||
|
<v-btn
|
||||||
|
v-for="span in spans"
|
||||||
|
:key="span"
|
||||||
|
size="x-small"
|
||||||
|
variant="text"
|
||||||
|
:color="selectedSpan === span ? 'primary' : undefined"
|
||||||
|
@click="emit('update:span', span)"
|
||||||
|
>
|
||||||
|
{{ span.toUpperCase() }}
|
||||||
|
</v-btn>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.view-controls {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 4px 8px;
|
||||||
|
border-bottom: 1px solid rgb(var(--v-border-color));
|
||||||
|
background-color: rgb(var(--v-theme-surface));
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.view-navigation {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.current-period {
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 500;
|
||||||
|
margin-left: 8px;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.view-selector {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 2px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, computed, watch } from 'vue'
|
import { ref, computed, watch } from 'vue'
|
||||||
import { parseCalendarDate } from '@/utils/date'
|
import { parseCalendarDate } from '@/utils/date'
|
||||||
|
import { formatDisplayDate, formatDisplayTime, formatLocalIsoDate, formatLocalIsoTime } from '@/utils/format'
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
mode: 'edit' | 'view'
|
mode: 'edit' | 'view'
|
||||||
@@ -29,18 +30,6 @@ const timeZoneValue = computed({
|
|||||||
set: (value) => emit('update:timeZone', value || null)
|
set: (value) => emit('update:timeZone', value || null)
|
||||||
})
|
})
|
||||||
|
|
||||||
// Format helpers
|
|
||||||
const formatDate = (date: Date): string => {
|
|
||||||
const year = date.getFullYear()
|
|
||||||
const month = String(date.getMonth() + 1).padStart(2, '0')
|
|
||||||
const day = String(date.getDate()).padStart(2, '0')
|
|
||||||
return `${year}-${month}-${day}`
|
|
||||||
}
|
|
||||||
|
|
||||||
const formatTime = (date: Date): string => {
|
|
||||||
return date.toTimeString().slice(0, 5)
|
|
||||||
}
|
|
||||||
|
|
||||||
const formatDateTime = (isoString: string | null | undefined): string => {
|
const formatDateTime = (isoString: string | null | undefined): string => {
|
||||||
if (!isoString) return 'Not set'
|
if (!isoString) return 'Not set'
|
||||||
if (timelessValue.value) {
|
if (timelessValue.value) {
|
||||||
@@ -64,24 +53,7 @@ const serializeDateTime = (date: string, time: string): string => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const inputDate = (value: string, timeless: boolean): string => {
|
const inputDate = (value: string, timeless: boolean): string => {
|
||||||
return timeless ? value.slice(0, 10) : formatDate(new Date(value))
|
return timeless ? value.slice(0, 10) : formatLocalIsoDate(new Date(value))
|
||||||
}
|
|
||||||
|
|
||||||
const formatDisplayDate = (value: string): string => {
|
|
||||||
if (!value) return ''
|
|
||||||
return new Date(`${value}T00:00:00`).toLocaleDateString(undefined, {
|
|
||||||
year: 'numeric',
|
|
||||||
month: 'short',
|
|
||||||
day: 'numeric',
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
const formatDisplayTime = (value: string): string => {
|
|
||||||
if (!value) return ''
|
|
||||||
const [hour, minute] = value.split(':').map(Number)
|
|
||||||
const date = new Date()
|
|
||||||
date.setHours(hour, minute, 0, 0)
|
|
||||||
return date.toLocaleTimeString(undefined, { hour: 'numeric', minute: '2-digit' })
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const pickerDate = (value: string): Date | null => value ? new Date(`${value}T00:00:00`) : null
|
const pickerDate = (value: string): Date | null => value ? new Date(`${value}T00:00:00`) : null
|
||||||
@@ -89,7 +61,7 @@ const pickerDate = (value: string): Date | null => value ? new Date(`${value}T00
|
|||||||
const normalizePickerDate = (value: unknown): string => {
|
const normalizePickerDate = (value: unknown): string => {
|
||||||
const selected = Array.isArray(value) ? value[0] : value
|
const selected = Array.isArray(value) ? value[0] : value
|
||||||
const date = selected instanceof Date ? selected : new Date(String(selected))
|
const date = selected instanceof Date ? selected : new Date(String(selected))
|
||||||
return Number.isNaN(date.getTime()) ? '' : formatDate(date)
|
return Number.isNaN(date.getTime()) ? '' : formatLocalIsoDate(date)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Date/time fields
|
// Date/time fields
|
||||||
@@ -119,11 +91,11 @@ watch([() => props.startsOn, () => props.timeless], ([newValue, timeless]) => {
|
|||||||
if (newValue) {
|
if (newValue) {
|
||||||
const start = new Date(newValue)
|
const start = new Date(newValue)
|
||||||
startDate.value = inputDate(newValue, timeless === true)
|
startDate.value = inputDate(newValue, timeless === true)
|
||||||
startTime.value = formatTime(start)
|
startTime.value = formatLocalIsoTime(start)
|
||||||
} else {
|
} else {
|
||||||
const now = new Date()
|
const now = new Date()
|
||||||
startDate.value = formatDate(now)
|
startDate.value = formatLocalIsoDate(now)
|
||||||
startTime.value = formatTime(now)
|
startTime.value = formatLocalIsoTime(now)
|
||||||
}
|
}
|
||||||
}, { immediate: true })
|
}, { immediate: true })
|
||||||
|
|
||||||
@@ -131,11 +103,11 @@ watch([() => props.endsOn, () => props.timeless], ([newValue, timeless]) => {
|
|||||||
if (newValue) {
|
if (newValue) {
|
||||||
const end = new Date(newValue)
|
const end = new Date(newValue)
|
||||||
endDate.value = inputDate(newValue, timeless === true)
|
endDate.value = inputDate(newValue, timeless === true)
|
||||||
endTime.value = formatTime(end)
|
endTime.value = formatLocalIsoTime(end)
|
||||||
} else {
|
} else {
|
||||||
const later = new Date(Date.now() + 60 * 60 * 1000)
|
const later = new Date(Date.now() + 60 * 60 * 1000)
|
||||||
endDate.value = formatDate(later)
|
endDate.value = formatLocalIsoDate(later)
|
||||||
endTime.value = formatTime(later)
|
endTime.value = formatLocalIsoTime(later)
|
||||||
}
|
}
|
||||||
}, { immediate: true })
|
}, { immediate: true })
|
||||||
|
|
||||||
@@ -151,8 +123,8 @@ const ensureValidRange = (): boolean => {
|
|||||||
const correctedEnd = new Date(start)
|
const correctedEnd = new Date(start)
|
||||||
if (timelessValue.value) correctedEnd.setDate(correctedEnd.getDate() + 1)
|
if (timelessValue.value) correctedEnd.setDate(correctedEnd.getDate() + 1)
|
||||||
else correctedEnd.setHours(correctedEnd.getHours() + 1)
|
else correctedEnd.setHours(correctedEnd.getHours() + 1)
|
||||||
endDate.value = formatDate(correctedEnd)
|
endDate.value = formatLocalIsoDate(correctedEnd)
|
||||||
endTime.value = formatTime(correctedEnd)
|
endTime.value = formatLocalIsoTime(correctedEnd)
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, ref } from 'vue'
|
import { computed, ref } from 'vue'
|
||||||
import type { EventOccurrence } from '@ChronoManager/types/event'
|
import type { EventOccurrence } from '@ChronoManager/types/event'
|
||||||
|
import { formatDisplayDate, formatLocalIsoDate } from '@/utils/format'
|
||||||
|
|
||||||
type Frequency = 'daily' | 'weekly' | 'monthly' | 'yearly'
|
type Frequency = 'daily' | 'weekly' | 'monthly' | 'yearly'
|
||||||
type EndMode = 'never' | 'date' | 'count'
|
type EndMode = 'never' | 'date' | 'count'
|
||||||
@@ -170,19 +171,11 @@ const pickerDate = (value: string | null | undefined): Date | null => {
|
|||||||
return value ? new Date(`${value.slice(0, 10)}T00:00:00`) : null
|
return value ? new Date(`${value.slice(0, 10)}T00:00:00`) : null
|
||||||
}
|
}
|
||||||
|
|
||||||
const formatDisplayDate = (value: string | null | undefined): string => {
|
|
||||||
const date = pickerDate(value)
|
|
||||||
return date ? date.toLocaleDateString(undefined, { year: 'numeric', month: 'short', day: 'numeric' }) : ''
|
|
||||||
}
|
|
||||||
|
|
||||||
const selectConclusionDate = (value: unknown) => {
|
const selectConclusionDate = (value: unknown) => {
|
||||||
const selected = Array.isArray(value) ? value[0] : value
|
const selected = Array.isArray(value) ? value[0] : value
|
||||||
const date = selected instanceof Date ? selected : new Date(String(selected))
|
const date = selected instanceof Date ? selected : new Date(String(selected))
|
||||||
if (!Number.isNaN(date.getTime())) {
|
if (!Number.isNaN(date.getTime())) {
|
||||||
const year = date.getFullYear()
|
updatePattern({ concludes: formatLocalIsoDate(date), iterations: null })
|
||||||
const month = String(date.getMonth() + 1).padStart(2, '0')
|
|
||||||
const day = String(date.getDate()).padStart(2, '0')
|
|
||||||
updatePattern({ concludes: `${year}-${month}-${day}`, iterations: null })
|
|
||||||
}
|
}
|
||||||
endDateMenu.value = false
|
endDateMenu.value = false
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,12 +5,12 @@ import { storeToRefs } from 'pinia';
|
|||||||
import { useRoute } from 'vue-router';
|
import { useRoute } from 'vue-router';
|
||||||
import { useDisplay } from 'vuetify';
|
import { useDisplay } from 'vuetify';
|
||||||
import { useModuleStore } from '@KTXC/stores/moduleStore';
|
import { useModuleStore } from '@KTXC/stores/moduleStore';
|
||||||
import { useChronoInstancesStore } from '@/stores/chronoInstancesStore';
|
|
||||||
import { useChronoOperationsStore } from '@/stores/chronoOperationsStore';
|
import { useChronoOperationsStore } from '@/stores/chronoOperationsStore';
|
||||||
import { useChronoSettingsStore } from '@/stores/chronoSettingsStore';
|
import { useChronoSettingsStore } from '@/stores/chronoSettingsStore';
|
||||||
import { useChronoUiStore } from '@/stores/chronoUiStore';
|
import { useChronoUiStore } from '@/stores/chronoUiStore';
|
||||||
import type { CalendarView as CalendarViewType } from '@/types';
|
import type { CalendarView as CalendarViewType } from '@/types';
|
||||||
import type { AgendaViewSpan, DaysViewSpan } from '@/types/spans';
|
import { spanToDays, type AgendaViewSpan, type DaysViewSpan } from '@/types/spans';
|
||||||
|
import { daysVisibleRange, monthGridVisibleRange } from '@/utils/date';
|
||||||
import CollectionList from '@/components/CollectionList.vue';
|
import CollectionList from '@/components/CollectionList.vue';
|
||||||
import CollectionEditor from '@/components/CollectionEditor.vue';
|
import CollectionEditor from '@/components/CollectionEditor.vue';
|
||||||
import CalendarView from '@/components/CalendarView.vue';
|
import CalendarView from '@/components/CalendarView.vue';
|
||||||
@@ -19,6 +19,7 @@ import EventEditor from '@/components/EventEditor.vue';
|
|||||||
import TaskEditor from '@/components/TaskEditor.vue';
|
import TaskEditor from '@/components/TaskEditor.vue';
|
||||||
import MiniCalendar from '@/components/MiniCalendar.vue';
|
import MiniCalendar from '@/components/MiniCalendar.vue';
|
||||||
import ImportDialog from '@/components/ImportDialog.vue';
|
import ImportDialog from '@/components/ImportDialog.vue';
|
||||||
|
import { EpochSpan } from '@/types/date';
|
||||||
|
|
||||||
// Vuetify display
|
// Vuetify display
|
||||||
const display = useDisplay();
|
const display = useDisplay();
|
||||||
@@ -29,7 +30,6 @@ const isChronoManagerAvailable = computed(() => {
|
|||||||
return moduleStore.has('chrono_manager') || moduleStore.has('ChronoManager')
|
return moduleStore.has('chrono_manager') || moduleStore.has('ChronoManager')
|
||||||
});
|
});
|
||||||
|
|
||||||
const chronoInstancesStore = useChronoInstancesStore();
|
|
||||||
const chronoOperationsStore = useChronoOperationsStore();
|
const chronoOperationsStore = useChronoOperationsStore();
|
||||||
const chronoSettingsStore = useChronoSettingsStore();
|
const chronoSettingsStore = useChronoSettingsStore();
|
||||||
const chronoUiStore = useChronoUiStore();
|
const chronoUiStore = useChronoUiStore();
|
||||||
@@ -49,8 +49,6 @@ const {
|
|||||||
collections,
|
collections,
|
||||||
} = storeToRefs(chronoOperationsStore);
|
} = storeToRefs(chronoOperationsStore);
|
||||||
|
|
||||||
const { setVisibleRange } = chronoInstancesStore;
|
|
||||||
|
|
||||||
const {
|
const {
|
||||||
currentDate,
|
currentDate,
|
||||||
sidebarVisible,
|
sidebarVisible,
|
||||||
@@ -67,6 +65,8 @@ const {
|
|||||||
isTaskView,
|
isTaskView,
|
||||||
} = storeToRefs(chronoUiStore);
|
} = storeToRefs(chronoUiStore);
|
||||||
|
|
||||||
|
const { loadVisibleRange: loadVisibleRangeOperations } = chronoOperationsStore;
|
||||||
|
|
||||||
const {
|
const {
|
||||||
initialize,
|
initialize,
|
||||||
setViewMode,
|
setViewMode,
|
||||||
@@ -96,6 +96,7 @@ const {
|
|||||||
const {
|
const {
|
||||||
toggleCalendarVisibility,
|
toggleCalendarVisibility,
|
||||||
toggleTaskComplete,
|
toggleTaskComplete,
|
||||||
|
ensureTasksLoaded,
|
||||||
} = chronoOperationsStore;
|
} = chronoOperationsStore;
|
||||||
|
|
||||||
function setCalendarView(view: CalendarViewType) {
|
function setCalendarView(view: CalendarViewType) {
|
||||||
@@ -129,18 +130,40 @@ function handleCalendarViewChange(view: CalendarViewType) {
|
|||||||
setCalendarView(view);
|
setCalendarView(view);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function computeVisibleRange(): EpochSpan {
|
||||||
|
if (calendarView.value === 'month') {
|
||||||
|
return monthGridVisibleRange(currentDate.value);
|
||||||
|
}
|
||||||
|
|
||||||
|
const span = calendarView.value === 'agenda'
|
||||||
|
? agendaViewSpan.value
|
||||||
|
: daysViewSpan.value;
|
||||||
|
|
||||||
|
return daysVisibleRange(currentDate.value, spanToDays(span));
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(
|
||||||
|
[calendarView, currentDate, daysViewSpan, agendaViewSpan],
|
||||||
|
() => { loadVisibleRangeOperations(computeVisibleRange()); },
|
||||||
|
{ immediate: true },
|
||||||
|
);
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
try {
|
try {
|
||||||
syncViewModeFromRoute();
|
syncViewModeFromRoute();
|
||||||
await initialize();
|
await initialize(isTaskView.value ? 'tasks' : 'calendar');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('[Chrono] - Failed to load data from ChronoManager:', error);
|
console.error('[Chrono] - Failed to load data from ChronoManager:', error);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
watch(() => route.fullPath, () => {
|
watch(() => route.fullPath, async () => {
|
||||||
syncViewModeFromRoute();
|
syncViewModeFromRoute();
|
||||||
|
if (isTaskView.value) {
|
||||||
|
await ensureTasksLoaded();
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -285,7 +308,6 @@ watch(() => route.fullPath, () => {
|
|||||||
@event-click="editEvent"
|
@event-click="editEvent"
|
||||||
@date-click="createEventFromDate"
|
@date-click="createEventFromDate"
|
||||||
@update:current-date="setCurrentDate"
|
@update:current-date="setCurrentDate"
|
||||||
@update:visible-range="setVisibleRange"
|
|
||||||
@update:days-span="setDaysViewSpan"
|
@update:days-span="setDaysViewSpan"
|
||||||
@update:agenda-span="setAgendaViewSpan"
|
@update:agenda-span="setAgendaViewSpan"
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
import { computed, shallowRef } from 'vue'
|
import { computed, shallowRef } from 'vue'
|
||||||
import { defineStore } from 'pinia'
|
import { defineStore } from 'pinia'
|
||||||
import type { EntityObject } from '@ChronoManager/models/entity'
|
import type { EntityObject } from '@ChronoManager/models/entity'
|
||||||
import type { VisibleDateRange } from '@/types'
|
|
||||||
import type { CalendarInstance } from '@/types/instance'
|
import type { CalendarInstance } from '@/types/instance'
|
||||||
import { useChronoOperationsStore } from '@/stores/chronoOperationsStore'
|
import { useChronoOperationsStore } from '@/stores/chronoOperationsStore'
|
||||||
import { expandEntityInstances } from '@/utils/recurrence'
|
import { expandEntityInstances } from '@/utils/recurrence'
|
||||||
import { shiftLocalDays, startOfLocalDay } from '@/utils/date'
|
import { shiftLocalDays, startOfLocalDay } from '@/utils/date'
|
||||||
|
import { EpochSpan } from '@/types/date'
|
||||||
|
|
||||||
type InstancesByDay = Map<number, CalendarInstance[]>
|
type InstancesByDay = Map<number, CalendarInstance[]>
|
||||||
|
|
||||||
@@ -15,8 +15,8 @@ interface ExpansionCacheEntry {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function appendToDayBuckets(byDay: InstancesByDay, instance: CalendarInstance) {
|
function appendToDayBuckets(byDay: InstancesByDay, instance: CalendarInstance) {
|
||||||
const lastOccupiedDayMs = startOfLocalDay(new Date(instance.endMs - 1)).getTime()
|
const lastOccupiedDayMs = startOfLocalDay(new Date(instance.end - 1)).getTime()
|
||||||
let day = new Date(instance.dayStartMs)
|
let day = new Date(instance.dayStart)
|
||||||
|
|
||||||
while (day.getTime() <= lastOccupiedDayMs) {
|
while (day.getTime() <= lastOccupiedDayMs) {
|
||||||
const dayKey = day.getTime()
|
const dayKey = day.getTime()
|
||||||
@@ -29,8 +29,8 @@ function appendToDayBuckets(byDay: InstancesByDay, instance: CalendarInstance) {
|
|||||||
|
|
||||||
function sortInstances(instances: CalendarInstance[]) {
|
function sortInstances(instances: CalendarInstance[]) {
|
||||||
instances.sort((left, right) => (
|
instances.sort((left, right) => (
|
||||||
left.startMs - right.startMs
|
left.start - right.start
|
||||||
|| right.durationMs - left.durationMs
|
|| right.duration - left.duration
|
||||||
|| left.key.localeCompare(right.key)
|
|| left.key.localeCompare(right.key)
|
||||||
))
|
))
|
||||||
}
|
}
|
||||||
@@ -38,20 +38,20 @@ function sortInstances(instances: CalendarInstance[]) {
|
|||||||
export const useChronoInstancesStore = defineStore('chronoInstancesStore', () => {
|
export const useChronoInstancesStore = defineStore('chronoInstancesStore', () => {
|
||||||
const operationsStore = useChronoOperationsStore()
|
const operationsStore = useChronoOperationsStore()
|
||||||
|
|
||||||
const visibleRange = shallowRef<VisibleDateRange | null>(null)
|
const visibleRange = shallowRef<EpochSpan | null>(null)
|
||||||
|
|
||||||
// Expanded instances per entity, reused across index rebuilds so a change
|
// Expanded instances per entity, reused across index rebuilds so a change
|
||||||
// to one entity only re-expands that entity. Entries are validated by
|
// to one entity only re-expands that entity. Entries are validated by
|
||||||
// object identity: the entities store replaces the EntityObject on every
|
// object identity: the entities store replaces the EntityObject on every
|
||||||
// create/update, so in-place mutation of a stored entity is not observed.
|
// create/update, so in-place mutation of a stored entity is not observed.
|
||||||
const expansionCache = new Map<string, ExpansionCacheEntry>()
|
const expansionCache = new Map<string, ExpansionCacheEntry>()
|
||||||
let expansionCacheRange: VisibleDateRange | null = null
|
let expansionCacheRange: EpochSpan | null = null
|
||||||
|
|
||||||
const instancesByDay = computed<InstancesByDay>(() => {
|
const instancesByDay = computed<InstancesByDay>(() => {
|
||||||
const range = visibleRange.value
|
const range = visibleRange.value
|
||||||
if (
|
if (
|
||||||
expansionCacheRange?.startMs !== range?.startMs
|
expansionCacheRange?.start !== range?.start
|
||||||
|| expansionCacheRange?.endMs !== range?.endMs
|
|| expansionCacheRange?.end !== range?.end
|
||||||
) {
|
) {
|
||||||
expansionCache.clear()
|
expansionCache.clear()
|
||||||
expansionCacheRange = range
|
expansionCacheRange = range
|
||||||
@@ -86,15 +86,15 @@ export const useChronoInstancesStore = defineStore('chronoInstancesStore', () =>
|
|||||||
return instancesByDay.value.get(dayKey) ?? []
|
return instancesByDay.value.get(dayKey) ?? []
|
||||||
}
|
}
|
||||||
|
|
||||||
function instancesForRange(range: VisibleDateRange): CalendarInstance[] {
|
function instancesForRange(range: EpochSpan): CalendarInstance[] {
|
||||||
if (range.endMs <= range.startMs) return []
|
if (range.end <= range.start) return []
|
||||||
|
|
||||||
const matches = new Map<string, CalendarInstance>()
|
const matches = new Map<string, CalendarInstance>()
|
||||||
let day = startOfLocalDay(new Date(range.startMs))
|
let day = startOfLocalDay(new Date(range.start))
|
||||||
|
|
||||||
while (day.getTime() < range.endMs) {
|
while (day.getTime() < range.end) {
|
||||||
for (const instance of instancesByDay.value.get(day.getTime()) ?? []) {
|
for (const instance of instancesByDay.value.get(day.getTime()) ?? []) {
|
||||||
if (instance.startMs < range.endMs && instance.endMs > range.startMs) {
|
if (instance.start < range.end && instance.end > range.start) {
|
||||||
matches.set(instance.key, instance)
|
matches.set(instance.key, instance)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -106,20 +106,8 @@ export const useChronoInstancesStore = defineStore('chronoInstancesStore', () =>
|
|||||||
return instances
|
return instances
|
||||||
}
|
}
|
||||||
|
|
||||||
function setVisibleRange(range: VisibleDateRange) {
|
|
||||||
if (
|
|
||||||
visibleRange.value?.startMs === range.startMs
|
|
||||||
&& visibleRange.value.endMs === range.endMs
|
|
||||||
) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
visibleRange.value = { ...range }
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
visibleRange,
|
visibleRange,
|
||||||
setVisibleRange,
|
|
||||||
instancesForDay,
|
instancesForDay,
|
||||||
instancesForRange,
|
instancesForRange,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,10 +5,12 @@ import { EntityObject } from '@ChronoManager/models/entity'
|
|||||||
import { EventObject } from '@ChronoManager/models/event'
|
import { EventObject } from '@ChronoManager/models/event'
|
||||||
import { TaskObject } from '@ChronoManager/models/task'
|
import { TaskObject } from '@ChronoManager/models/task'
|
||||||
import type { ServiceObject } from '@ChronoManager/models/service'
|
import type { ServiceObject } from '@ChronoManager/models/service'
|
||||||
|
import type { CollectionIdentifier, ListRangeDate } from '@ChronoManager/types/common'
|
||||||
import { useCollectionsStore } from '@ChronoManager/stores/collectionsStore'
|
import { useCollectionsStore } from '@ChronoManager/stores/collectionsStore'
|
||||||
import { useEntitiesStore } from '@ChronoManager/stores/entitiesStore'
|
import { useEntitiesStore } from '@ChronoManager/stores/entitiesStore'
|
||||||
import { useServicesStore } from '@ChronoManager/stores/servicesStore'
|
import { useServicesStore } from '@ChronoManager/stores/servicesStore'
|
||||||
import { useImportStore } from '@ChronoManager/stores/importStore'
|
import { useImportStore } from '@ChronoManager/stores/importStore'
|
||||||
|
import { EpochSpan } from '@/types/date'
|
||||||
|
|
||||||
type ChronoEntityProperties = EventObject | TaskObject
|
type ChronoEntityProperties = EventObject | TaskObject
|
||||||
|
|
||||||
@@ -19,6 +21,10 @@ export const useChronoOperationsStore = defineStore('chronoOperationsStore', ()
|
|||||||
const importStore = useImportStore()
|
const importStore = useImportStore()
|
||||||
|
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
|
const collectionsLoaded = ref(false)
|
||||||
|
const tasksLoaded = ref(false)
|
||||||
|
const requestedEventRange = ref<EpochSpan | null>(null)
|
||||||
|
const loadedEventRange = ref<EpochSpan | null>(null)
|
||||||
|
|
||||||
const collections = computed(() => collectionsStore.collections)
|
const collections = computed(() => collectionsStore.collections)
|
||||||
const entities = computed(() => entitiesStore.entities)
|
const entities = computed(() => entitiesStore.entities)
|
||||||
@@ -43,6 +49,14 @@ export const useChronoOperationsStore = defineStore('chronoOperationsStore', ()
|
|||||||
})
|
})
|
||||||
const filteredTasks = computed(() => tasks.value)
|
const filteredTasks = computed(() => tasks.value)
|
||||||
|
|
||||||
|
function listRangeForVisibleRange(range: EpochSpan): ListRangeDate {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const enabledCalendarIds = computed(() => calendars.value
|
||||||
|
.filter(calendar => calendar.properties.visibility !== false)
|
||||||
|
.map(calendar => calendar.identifier))
|
||||||
|
|
||||||
function prepareEntityProperties(entity: EntityObject): ChronoEntityProperties {
|
function prepareEntityProperties(entity: EntityObject): ChronoEntityProperties {
|
||||||
const properties = entity.properties as ChronoEntityProperties
|
const properties = entity.properties as ChronoEntityProperties
|
||||||
const now = new Date().toISOString()
|
const now = new Date().toISOString()
|
||||||
@@ -132,26 +146,65 @@ export const useChronoOperationsStore = defineStore('chronoOperationsStore', ()
|
|||||||
const target = importStore.sessions[id]?.targetIdentifier
|
const target = importStore.sessions[id]?.targetIdentifier
|
||||||
if (target) targets.add(target)
|
if (target) targets.add(target)
|
||||||
}
|
}
|
||||||
for (const target of targets) await entitiesStore.list([target])
|
|
||||||
|
await loadVisibleRange(requestedEventRange.value, Array.from(targets))
|
||||||
}
|
}
|
||||||
|
|
||||||
importStore.removeAllFiles()
|
importStore.removeAllFiles()
|
||||||
importStore.reset()
|
importStore.reset()
|
||||||
}
|
}
|
||||||
|
|
||||||
async function initialize() {
|
async function ensureTasksLoaded() {
|
||||||
|
if (tasksLoaded.value) return
|
||||||
|
|
||||||
|
const taskSources = taskLists.value.map(list => list.identifier)
|
||||||
|
if (taskSources.length > 0) await entitiesStore.list(taskSources)
|
||||||
|
tasksLoaded.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
async function initialize(viewMode: 'calendar' | 'tasks' = 'calendar') {
|
||||||
loading.value = true
|
loading.value = true
|
||||||
try {
|
try {
|
||||||
await servicesStore.list()
|
if (!collectionsLoaded.value) {
|
||||||
await collectionsStore.list()
|
await servicesStore.list()
|
||||||
|
await collectionsStore.list()
|
||||||
|
collectionsLoaded.value = true
|
||||||
|
}
|
||||||
|
|
||||||
const sources = collections.value.map(collection => collection.identifier)
|
if (viewMode === 'tasks') {
|
||||||
if (sources.length > 0) await entitiesStore.list(sources)
|
await ensureTasksLoaded()
|
||||||
|
}
|
||||||
|
|
||||||
|
if (requestedEventRange.value) {
|
||||||
|
await loadVisibleRange(requestedEventRange.value)
|
||||||
|
}
|
||||||
} finally {
|
} finally {
|
||||||
loading.value = false
|
loading.value = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function loadVisibleRange(range: EpochSpan | null, targets: CollectionIdentifier[] | null = null) {
|
||||||
|
if (!range) return
|
||||||
|
|
||||||
|
requestedEventRange.value = { ...range }
|
||||||
|
|
||||||
|
if (loadedEventRange.value?.start === range.start && loadedEventRange.value?.end === range.end) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
let nativeRange: ListRangeDate = {
|
||||||
|
type: 'date',
|
||||||
|
start: range.start,
|
||||||
|
end: range.end,
|
||||||
|
}
|
||||||
|
|
||||||
|
let targetList: CollectionIdentifier[] = targets || enabledCalendarIds.value
|
||||||
|
|
||||||
|
await entitiesStore.list(targetList, undefined, undefined, nativeRange)
|
||||||
|
|
||||||
|
loadedEventRange.value = { ...range }
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
loading,
|
loading,
|
||||||
collections,
|
collections,
|
||||||
@@ -171,5 +224,7 @@ export const useChronoOperationsStore = defineStore('chronoOperationsStore', ()
|
|||||||
prepareImport,
|
prepareImport,
|
||||||
finishImport,
|
finishImport,
|
||||||
initialize,
|
initialize,
|
||||||
|
ensureTasksLoaded,
|
||||||
|
loadVisibleRange,
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -193,8 +193,8 @@ export const useChronoUiStore = defineStore('chronoUiStore', () => {
|
|||||||
await operationsStore.finishImport(refresh)
|
await operationsStore.finishImport(refresh)
|
||||||
}
|
}
|
||||||
|
|
||||||
async function initialize() {
|
async function initialize(viewMode: 'calendar' | 'tasks' = 'calendar') {
|
||||||
await operationsStore.initialize()
|
await operationsStore.initialize(viewMode)
|
||||||
if (selectedCollection.value && !operationsStore.collections.some(
|
if (selectedCollection.value && !operationsStore.collections.some(
|
||||||
collection => collection.identifier === selectedCollection.value?.identifier,
|
collection => collection.identifier === selectedCollection.value?.identifier,
|
||||||
)) {
|
)) {
|
||||||
|
|||||||
@@ -0,0 +1,4 @@
|
|||||||
|
export interface EpochSpan {
|
||||||
|
start: number;
|
||||||
|
end: number;
|
||||||
|
}
|
||||||
@@ -7,12 +7,6 @@
|
|||||||
// Local UI-specific types
|
// Local UI-specific types
|
||||||
export type CalendarView = 'days' | 'month' | 'agenda';
|
export type CalendarView = 'days' | 'month' | 'agenda';
|
||||||
|
|
||||||
/** Half-open local calendar range: [startMs, endMs). */
|
|
||||||
export interface VisibleDateRange {
|
|
||||||
startMs: number;
|
|
||||||
endMs: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ViewState {
|
export interface ViewState {
|
||||||
currentView: CalendarView;
|
currentView: CalendarView;
|
||||||
currentDate: Date;
|
currentDate: Date;
|
||||||
|
|||||||
@@ -4,10 +4,10 @@ export interface CalendarInstance {
|
|||||||
key: string
|
key: string
|
||||||
entity: EntityObject
|
entity: EntityObject
|
||||||
instanceId: string | null
|
instanceId: string | null
|
||||||
startMs: number
|
start: number
|
||||||
endMs: number
|
end: number
|
||||||
dayStartMs: number
|
dayStart: number
|
||||||
durationMs: number
|
duration: number
|
||||||
timeless: boolean
|
timeless: boolean
|
||||||
multiDay: boolean
|
multiDay: boolean
|
||||||
label: string | null
|
label: string | null
|
||||||
|
|||||||
+7
-7
@@ -1,4 +1,4 @@
|
|||||||
import type { VisibleDateRange } from '../types'
|
import { EpochSpan } from '@/types/date'
|
||||||
|
|
||||||
export function parseCalendarDate(value: string | null | undefined): Date | null {
|
export function parseCalendarDate(value: string | null | undefined): Date | null {
|
||||||
if (!value) return null
|
if (!value) return null
|
||||||
@@ -45,17 +45,17 @@ export function shiftLocalMonths(date: Date, months: number): Date {
|
|||||||
return shifted
|
return shifted
|
||||||
}
|
}
|
||||||
|
|
||||||
export function daysVisibleRange(date: Date, days: number): VisibleDateRange {
|
export function daysVisibleRange(date: Date, days: number): EpochSpan {
|
||||||
const start = startOfLocalDay(date)
|
const start = startOfLocalDay(date)
|
||||||
const end = shiftLocalDays(start, Math.max(1, days))
|
const end = shiftLocalDays(start, Math.max(1, days))
|
||||||
|
|
||||||
return {
|
return {
|
||||||
startMs: start.getTime(),
|
start: start.getTime(),
|
||||||
endMs: end.getTime(),
|
end: end.getTime(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function monthGridVisibleRange(date: Date): VisibleDateRange {
|
export function monthGridVisibleRange(date: Date): EpochSpan {
|
||||||
const firstDay = new Date(date.getFullYear(), date.getMonth(), 1)
|
const firstDay = new Date(date.getFullYear(), date.getMonth(), 1)
|
||||||
const gridStart = new Date(firstDay)
|
const gridStart = new Date(firstDay)
|
||||||
gridStart.setDate(gridStart.getDate() - gridStart.getDay())
|
gridStart.setDate(gridStart.getDate() - gridStart.getDay())
|
||||||
@@ -65,7 +65,7 @@ export function monthGridVisibleRange(date: Date): VisibleDateRange {
|
|||||||
const gridEnd = shiftLocalDays(gridStart, weeks * 7)
|
const gridEnd = shiftLocalDays(gridStart, weeks * 7)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
startMs: gridStart.getTime(),
|
start: gridStart.getTime(),
|
||||||
endMs: gridEnd.getTime(),
|
end: gridEnd.getTime(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,61 @@
|
|||||||
|
// Human-readable date/time formatting shared across chrono components
|
||||||
|
|
||||||
|
export function formatTime(date: Date): string {
|
||||||
|
return date.toLocaleTimeString('en-US', { hour: 'numeric', minute: '2-digit' })
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatEpochTime(timestamp: number): string {
|
||||||
|
return formatTime(new Date(timestamp))
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatHour(hour: number): string {
|
||||||
|
const ampm = hour >= 12 ? 'PM' : 'AM'
|
||||||
|
const displayHour = hour % 12 || 12
|
||||||
|
return `${displayHour} ${ampm}`
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatWeekDay(date: Date): string {
|
||||||
|
return date.toLocaleDateString('en-US', { weekday: 'short' })
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatFullDate(date: Date | string): string {
|
||||||
|
const value = date instanceof Date ? date : new Date(date)
|
||||||
|
return value.toLocaleDateString('en-US', {
|
||||||
|
weekday: 'long',
|
||||||
|
year: 'numeric',
|
||||||
|
month: 'long',
|
||||||
|
day: 'numeric',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Local-time YYYY-MM-DD, for date input values
|
||||||
|
export function formatLocalIsoDate(date: Date): string {
|
||||||
|
const year = date.getFullYear()
|
||||||
|
const month = String(date.getMonth() + 1).padStart(2, '0')
|
||||||
|
const day = String(date.getDate()).padStart(2, '0')
|
||||||
|
return `${year}-${month}-${day}`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Local-time HH:MM, for time input values
|
||||||
|
export function formatLocalIsoTime(date: Date): string {
|
||||||
|
return date.toTimeString().slice(0, 5)
|
||||||
|
}
|
||||||
|
|
||||||
|
// "Jul 6, 2026" from a YYYY-MM-DD (or ISO) string
|
||||||
|
export function formatDisplayDate(value: string | null | undefined): string {
|
||||||
|
if (!value) return ''
|
||||||
|
return new Date(`${value.slice(0, 10)}T00:00:00`).toLocaleDateString(undefined, {
|
||||||
|
year: 'numeric',
|
||||||
|
month: 'short',
|
||||||
|
day: 'numeric',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// "1:05 PM" from an HH:MM string
|
||||||
|
export function formatDisplayTime(value: string | null | undefined): string {
|
||||||
|
if (!value) return ''
|
||||||
|
const [hour, minute] = value.split(':').map(Number)
|
||||||
|
const date = new Date()
|
||||||
|
date.setHours(hour, minute, 0, 0)
|
||||||
|
return date.toLocaleTimeString(undefined, { hour: 'numeric', minute: '2-digit' })
|
||||||
|
}
|
||||||
@@ -37,22 +37,22 @@ export function layoutMultiDayLanes(
|
|||||||
|
|
||||||
const multiDayEvents = instances.filter(instance => {
|
const multiDayEvents = instances.filter(instance => {
|
||||||
return instance.timeless
|
return instance.timeless
|
||||||
&& instance.startMs < rangeEndExclusive.getTime()
|
&& instance.start < rangeEndExclusive.getTime()
|
||||||
&& instance.endMs > rangeStartMs;
|
&& instance.end > rangeStartMs;
|
||||||
});
|
});
|
||||||
|
|
||||||
// Sort: longer events first, then by start date
|
// Sort: longer events first, then by start date
|
||||||
multiDayEvents.sort((a, b) => {
|
multiDayEvents.sort((a, b) => {
|
||||||
if (b.durationMs !== a.durationMs) return b.durationMs - a.durationMs;
|
if (b.duration !== a.duration) return b.duration - a.duration;
|
||||||
return a.startMs - b.startMs;
|
return a.start - b.start;
|
||||||
});
|
});
|
||||||
|
|
||||||
// Lane assignment - track which days are occupied in each lane
|
// Lane assignment - track which days are occupied in each lane
|
||||||
const lanes: boolean[][] = [];
|
const lanes: boolean[][] = [];
|
||||||
|
|
||||||
for (const instance of multiDayEvents) {
|
for (const instance of multiDayEvents) {
|
||||||
const eventStart = startOfLocalDay(new Date(instance.startMs));
|
const eventStart = startOfLocalDay(new Date(instance.start));
|
||||||
const eventEnd = startOfLocalDay(new Date(instance.endMs - 1));
|
const eventEnd = startOfLocalDay(new Date(instance.end - 1));
|
||||||
|
|
||||||
// Clamp to visible range
|
// Clamp to visible range
|
||||||
const segStart = eventStart < rangeStart ? rangeStart : eventStart;
|
const segStart = eventStart < rangeStart ? rangeStart : eventStart;
|
||||||
|
|||||||
+18
-17
@@ -4,6 +4,7 @@ import type { EventMutationObject } from '@ChronoManager/models/event-mutation'
|
|||||||
import type { CalendarInstance } from '../types/instance'
|
import type { CalendarInstance } from '../types/instance'
|
||||||
import type { VisibleDateRange } from '../types'
|
import type { VisibleDateRange } from '../types'
|
||||||
import { parseCalendarDate, shiftLocalDays, startOfLocalDay } from './date'
|
import { parseCalendarDate, shiftLocalDays, startOfLocalDay } from './date'
|
||||||
|
import { EpochSpan } from '@/types/date'
|
||||||
|
|
||||||
const MINIMUM_DURATION_MS = 1
|
const MINIMUM_DURATION_MS = 1
|
||||||
|
|
||||||
@@ -40,8 +41,8 @@ function conclusionTimestamp(value: string | null | undefined): number | null {
|
|||||||
return timestamp(value)
|
return timestamp(value)
|
||||||
}
|
}
|
||||||
|
|
||||||
function overlapsRange(startMs: number, endMs: number, range?: VisibleDateRange): boolean {
|
function overlapsRange(start: number, end: number, range?: EpochSpan): boolean {
|
||||||
return !range || (startMs < range.endMs && endMs > range.startMs)
|
return !range || (start < range.end && end > range.start)
|
||||||
}
|
}
|
||||||
|
|
||||||
function createInstance(
|
function createInstance(
|
||||||
@@ -65,10 +66,10 @@ function createInstance(
|
|||||||
key: `${String(entity.identifier)}:${instanceId ?? 'master'}`,
|
key: `${String(entity.identifier)}:${instanceId ?? 'master'}`,
|
||||||
entity,
|
entity,
|
||||||
instanceId,
|
instanceId,
|
||||||
startMs: effectiveStartMs,
|
start: effectiveStartMs,
|
||||||
endMs,
|
end: effectiveEndMs,
|
||||||
dayStartMs,
|
dayStart: dayStartMs,
|
||||||
durationMs: endMs - effectiveStartMs,
|
duration: endMs - effectiveStartMs,
|
||||||
timeless: timeless || lastOccupiedDayMs !== dayStartMs,
|
timeless: timeless || lastOccupiedDayMs !== dayStartMs,
|
||||||
multiDay: lastOccupiedDayMs !== dayStartMs,
|
multiDay: lastOccupiedDayMs !== dayStartMs,
|
||||||
label: mutation?.label ?? event.label,
|
label: mutation?.label ?? event.label,
|
||||||
@@ -127,9 +128,9 @@ function dailyStartIndex(
|
|||||||
start: Date,
|
start: Date,
|
||||||
interval: number,
|
interval: number,
|
||||||
durationMs: number,
|
durationMs: number,
|
||||||
range: VisibleDateRange,
|
range: EpochSpan,
|
||||||
): number {
|
): number {
|
||||||
const earliestRelevant = new Date(range.startMs - durationMs)
|
const earliestRelevant = new Date(range.start - durationMs)
|
||||||
const elapsedDays = localDayNumber(earliestRelevant) - localDayNumber(start)
|
const elapsedDays = localDayNumber(earliestRelevant) - localDayNumber(start)
|
||||||
return Math.max(0, Math.floor(elapsedDays / interval) - 1)
|
return Math.max(0, Math.floor(elapsedDays / interval) - 1)
|
||||||
}
|
}
|
||||||
@@ -138,9 +139,9 @@ function weeklyStartIndex(
|
|||||||
weekStart: Date,
|
weekStart: Date,
|
||||||
interval: number,
|
interval: number,
|
||||||
durationMs: number,
|
durationMs: number,
|
||||||
range: VisibleDateRange,
|
range: EpochSpan,
|
||||||
): number {
|
): number {
|
||||||
const earliestRelevant = new Date(range.startMs - durationMs)
|
const earliestRelevant = new Date(range.start - durationMs)
|
||||||
const elapsedDays = localDayNumber(earliestRelevant) - localDayNumber(weekStart)
|
const elapsedDays = localDayNumber(earliestRelevant) - localDayNumber(weekStart)
|
||||||
return Math.max(0, Math.floor(elapsedDays / (interval * 7)) - 1)
|
return Math.max(0, Math.floor(elapsedDays / (interval * 7)) - 1)
|
||||||
}
|
}
|
||||||
@@ -154,9 +155,9 @@ function normalizedWeekdays(values: number[] | null, fallback: number): number[]
|
|||||||
function expandDaily(
|
function expandDaily(
|
||||||
entity: EntityObject,
|
entity: EntityObject,
|
||||||
start: Date,
|
start: Date,
|
||||||
durationMs: number,
|
duration: number,
|
||||||
durationDays: number | null,
|
durationDays: number | null,
|
||||||
range: VisibleDateRange,
|
range: EpochSpan,
|
||||||
mutations: Map<number, EventMutationObject>,
|
mutations: Map<number, EventMutationObject>,
|
||||||
): CalendarInstance[] {
|
): CalendarInstance[] {
|
||||||
const pattern = (entity.properties as EventObject).pattern!
|
const pattern = (entity.properties as EventObject).pattern!
|
||||||
@@ -169,7 +170,7 @@ function expandDaily(
|
|||||||
? new Set(normalizedWeekdays(pattern.onDayOfWeek, start.getDay() || 7))
|
? new Set(normalizedWeekdays(pattern.onDayOfWeek, start.getDay() || 7))
|
||||||
: null
|
: null
|
||||||
let step = maximumOccurrences === null || pattern.onDayOfWeek?.length === 0
|
let step = maximumOccurrences === null || pattern.onDayOfWeek?.length === 0
|
||||||
? dailyStartIndex(start, interval, durationMs, range)
|
? dailyStartIndex(start, interval, duration, range)
|
||||||
: 0
|
: 0
|
||||||
let occurrenceCount = weekdays ? 0 : step
|
let occurrenceCount = weekdays ? 0 : step
|
||||||
const instances: CalendarInstance[] = []
|
const instances: CalendarInstance[] = []
|
||||||
@@ -177,7 +178,7 @@ function expandDaily(
|
|||||||
while (true) {
|
while (true) {
|
||||||
const candidate = shiftLocalDays(start, step * interval)
|
const candidate = shiftLocalDays(start, step * interval)
|
||||||
const candidateMs = candidate.getTime()
|
const candidateMs = candidate.getTime()
|
||||||
if (candidateMs >= range.endMs) break
|
if (candidateMs >= range.end) break
|
||||||
if (concludesMs !== null && candidateMs > concludesMs) break
|
if (concludesMs !== null && candidateMs > concludesMs) break
|
||||||
|
|
||||||
if (!weekdays || weekdays.has(candidate.getDay() || 7)) {
|
if (!weekdays || weekdays.has(candidate.getDay() || 7)) {
|
||||||
@@ -187,7 +188,7 @@ function expandDaily(
|
|||||||
const instance = materializeInstance(
|
const instance = materializeInstance(
|
||||||
entity,
|
entity,
|
||||||
candidateMs,
|
candidateMs,
|
||||||
durationMs,
|
duration,
|
||||||
durationDays,
|
durationDays,
|
||||||
true,
|
true,
|
||||||
mutations.get(candidateMs),
|
mutations.get(candidateMs),
|
||||||
@@ -285,7 +286,7 @@ function appendMovedMutations(
|
|||||||
|
|
||||||
export function expandEntityInstances(
|
export function expandEntityInstances(
|
||||||
entity: EntityObject,
|
entity: EntityObject,
|
||||||
range?: VisibleDateRange,
|
range?: EpochSpan,
|
||||||
): CalendarInstance[] {
|
): CalendarInstance[] {
|
||||||
const event = entity.properties as EventObject
|
const event = entity.properties as EventObject
|
||||||
const timeless = event.timeless === true
|
const timeless = event.timeless === true
|
||||||
@@ -320,6 +321,6 @@ export function expandEntityInstances(
|
|||||||
: expandWeekly(entity, new Date(startMs), durationMs, durationDays, range, mutations)
|
: expandWeekly(entity, new Date(startMs), durationMs, durationDays, range, mutations)
|
||||||
|
|
||||||
appendMovedMutations(instances, entity, durationMs, durationDays, range, mutations)
|
appendMovedMutations(instances, entity, durationMs, durationDays, range, mutations)
|
||||||
instances.sort((left, right) => left.startMs - right.startMs || left.key.localeCompare(right.key))
|
instances.sort((left, right) => left.start - right.start || left.key.localeCompare(right.key))
|
||||||
return instances
|
return instances
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user