refactor: code clean up

Signed-off-by: Sebastian Krupinski <krupinski01@gmail.com>
This commit is contained in:
2026-07-06 21:09:07 -04:00
parent dea5a61811
commit df531908f3
19 changed files with 594 additions and 643 deletions
+53 -145
View File
@@ -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">
import { ref, computed, watch } from 'vue';
import { AGENDA_VIEW_SPANS, spanToDays, type AgendaViewSpan } from '@/types/spans';
import type { EntityObject } from '@ChronoManager/models/entity';
import type { CollectionObject } from '@ChronoManager/models/collection';
import { daysVisibleRange, shiftLocalDays } from '@/utils/date';
import { daysVisibleRange } from '@/utils/date';
import { useChronoInstancesStore } from '@/stores/chronoInstancesStore';
import type { CalendarInstance } from '@/types/instance';
type CalendarEntity = EntityObject;
type CalendarCollection = CollectionObject;
import ViewControls from './ViewControls.vue';
import { formatEpochTime, formatFullDate } from '@/utils/format';
const props = defineProps<{
calendars: CalendarCollection[];
calendars: CollectionObject[];
currentDate: Date;
initialSpan?: AgendaViewSpan;
}>();
const emit = defineEmits<{
'event-click': [event: EntityObject];
'update:span': [span: AgendaViewSpan];
'update:current-date': [date: Date];
}>();
const instancesStore = useChronoInstancesStore();
const selectedSpan = ref<AgendaViewSpan>(props.initialSpan ?? '1w');
const emit = defineEmits<{
'event-click': [event: CalendarEntity];
'update:span': [span: AgendaViewSpan];
'update:current-date': [date: Date];
}>();
const selectedSpanDays = computed(() => spanToDays(selectedSpan.value));
watch(() => props.initialSpan, (newSpan) => {
if (newSpan && newSpan !== selectedSpan.value) {
@@ -86,58 +37,25 @@ function setSpan(span: AgendaViewSpan) {
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 range = daysVisibleRange(props.currentDate, getSpanDays());
const range = daysVisibleRange(props.currentDate, selectedSpanDays.value);
return {
start: new Date(range.startMs),
end: new Date(range.endMs),
start: new Date(range.start),
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 grouped: Record<string, CalendarInstance[]> = {};
const { start, end } = dateRange.value;
const instances = instancesStore.instancesForRange({
startMs: start.getTime(),
endMs: end.getTime(),
start: start.getTime(),
end: end.getTime(),
});
instances.forEach(instance => {
const dateKey = new Date(instance.startMs).toDateString();
const dateKey = new Date(instance.start).toDateString();
if (!grouped[dateKey]) {
grouped[dateKey] = [];
}
@@ -153,26 +71,44 @@ function getEventColor(instance: CalendarInstance): string {
const calendar = props.calendars.find(cal => cal.identifier === entity.collection);
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>
<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>
.agenda-view {
display: flex;
@@ -180,34 +116,6 @@ function formatAgendaDate(dateString: string): string {
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 {
flex: 1;
overflow-y: auto;
+56 -72
View File
@@ -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>
<div class="calendar-view-container">
<EventViewerPopup
@@ -40,78 +96,6 @@
</div>
</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>
.calendar-view-container {
height: 100%;
+142 -233
View File
@@ -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>
<div class="days-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 DAYS_VIEW_SPANS"
:key="span"
size="x-small"
variant="text"
@click="setSpan(span)"
:color="selectedSpan === span ? 'primary' : undefined"
>
{{ span.toUpperCase() }}
</v-btn>
</div>
</div>
<ViewControls
mode="days"
:current-date="currentDate"
:spans="DAYS_VIEW_SPANS"
:selected-span="selectedSpan"
@update:current-date="emit('update:current-date', $event)"
@update:span="setSpan"
/>
<div class="days-grid">
<!-- Fixed Headers Row -->
<div class="day-headers">
@@ -83,7 +203,7 @@
>
<template v-if="daysCount <= 3">
<div class="event-time">
{{ formatInstanceTime(instance.startMs) }} - {{ formatInstanceTime(instance.endMs) }}
{{ formatEpochTime(instance.start) }} - {{ formatEpochTime(instance.end) }}
</div>
<div class="event-title">{{ instance.label || 'Untitled' }}</div>
</template>
@@ -99,189 +219,6 @@
</div>
</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>
.days-view {
display: flex;
@@ -290,34 +227,6 @@ function handleDayClick(event: MouseEvent, day: Date) {
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 {
display: flex;
flex-direction: column;
+2 -6
View File
@@ -1,6 +1,7 @@
<script setup lang="ts">
import { computed } from 'vue'
import { parseCalendarDate } from '@/utils/date'
import { formatTime as formatClockTime } from '@/utils/format'
const props = defineProps<{
event: any | null
@@ -13,12 +14,7 @@ const emit = defineEmits<{
}>()
const formatTime = (isoString: string | null | undefined): string => {
if (!isoString) return 'Not set'
const date = new Date(isoString)
return date.toLocaleTimeString('en-US', {
hour: 'numeric',
minute: '2-digit'
})
return isoString ? formatClockTime(new Date(isoString)) : 'Not set'
}
const formatAllDayDate = (isoString: string | null | undefined): string => {
+11 -57
View File
@@ -1,9 +1,11 @@
<script setup lang="ts">
import { computed, ref, onMounted, onUnmounted } from 'vue';
import { layoutMultiDayLanes, type MultiDaySegment } from '@/utils/multiDayLanes';
import { shiftLocalMonths, startOfLocalDay } from '@/utils/date';
import { startOfLocalDay } from '@/utils/date';
import { useChronoInstancesStore } from '@/stores/chronoInstancesStore';
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 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'];
// 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
const bodyRef = ref<HTMLElement | null>(null);
const bodyHeight = ref(600);
@@ -123,8 +108,8 @@ const weeks = computed<WeekData[]>(() => {
// Get multiday segments for this week
const instances = instancesStore.instancesForRange({
startMs: weekStart.getTime(),
endMs: new Date(
start: weekStart.getTime(),
end: new Date(
weekEnd.getFullYear(),
weekEnd.getMonth(),
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 {
const today = new Date();
@@ -211,14 +190,11 @@ function getEventColor(instance: CalendarInstance): string {
<template>
<div class="month-view">
<!-- Navigation Controls -->
<div class="view-controls">
<div class="view-navigation">
<v-btn size="x-small" variant="text" icon="mdi-chevron-left" @click="previousMonth" />
<v-btn size="x-small" variant="tonal" @click="goToToday">Today</v-btn>
<v-btn size="x-small" variant="text" icon="mdi-chevron-right" @click="nextMonth" />
<span class="current-period">{{ monthLabel }}</span>
</div>
</div>
<ViewControls
mode="month"
:current-date="currentDate"
@update:current-date="emit('update:current-date', $event)"
/>
<!-- Header row -->
<div class="day-headers">
@@ -292,7 +268,7 @@ function getEventColor(instance: CalendarInstance): string {
</template>
<v-list-item-title>
<span v-if="!instance.timeless" class="more-popover-time">
{{ formatStartTime(instance) }}
{{ formatEpochTime(instance.start) }}
</span>
{{ instance.label || 'Untitled' }}
</v-list-item-title>
@@ -345,28 +321,6 @@ function getEventColor(instance: CalendarInstance): string {
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 {
display: grid;
grid-template-columns: repeat(7, 1fr);
+110
View File
@@ -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>
+11 -39
View File
@@ -1,6 +1,7 @@
<script setup lang="ts">
import { ref, computed, watch } from 'vue'
import { parseCalendarDate } from '@/utils/date'
import { formatDisplayDate, formatDisplayTime, formatLocalIsoDate, formatLocalIsoTime } from '@/utils/format'
interface Props {
mode: 'edit' | 'view'
@@ -29,18 +30,6 @@ const timeZoneValue = computed({
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 => {
if (!isoString) return 'Not set'
if (timelessValue.value) {
@@ -64,24 +53,7 @@ const serializeDateTime = (date: string, time: string): string => {
}
const inputDate = (value: string, timeless: boolean): string => {
return timeless ? value.slice(0, 10) : formatDate(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' })
return timeless ? value.slice(0, 10) : formatLocalIsoDate(new Date(value))
}
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 selected = Array.isArray(value) ? value[0] : value
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
@@ -119,11 +91,11 @@ watch([() => props.startsOn, () => props.timeless], ([newValue, timeless]) => {
if (newValue) {
const start = new Date(newValue)
startDate.value = inputDate(newValue, timeless === true)
startTime.value = formatTime(start)
startTime.value = formatLocalIsoTime(start)
} else {
const now = new Date()
startDate.value = formatDate(now)
startTime.value = formatTime(now)
startDate.value = formatLocalIsoDate(now)
startTime.value = formatLocalIsoTime(now)
}
}, { immediate: true })
@@ -131,11 +103,11 @@ watch([() => props.endsOn, () => props.timeless], ([newValue, timeless]) => {
if (newValue) {
const end = new Date(newValue)
endDate.value = inputDate(newValue, timeless === true)
endTime.value = formatTime(end)
endTime.value = formatLocalIsoTime(end)
} else {
const later = new Date(Date.now() + 60 * 60 * 1000)
endDate.value = formatDate(later)
endTime.value = formatTime(later)
endDate.value = formatLocalIsoDate(later)
endTime.value = formatLocalIsoTime(later)
}
}, { immediate: true })
@@ -151,8 +123,8 @@ const ensureValidRange = (): boolean => {
const correctedEnd = new Date(start)
if (timelessValue.value) correctedEnd.setDate(correctedEnd.getDate() + 1)
else correctedEnd.setHours(correctedEnd.getHours() + 1)
endDate.value = formatDate(correctedEnd)
endTime.value = formatTime(correctedEnd)
endDate.value = formatLocalIsoDate(correctedEnd)
endTime.value = formatLocalIsoTime(correctedEnd)
return true
}
@@ -1,6 +1,7 @@
<script setup lang="ts">
import { computed, ref } from 'vue'
import type { EventOccurrence } from '@ChronoManager/types/event'
import { formatDisplayDate, formatLocalIsoDate } from '@/utils/format'
type Frequency = 'daily' | 'weekly' | 'monthly' | 'yearly'
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
}
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 selected = Array.isArray(value) ? value[0] : value
const date = selected instanceof Date ? selected : new Date(String(selected))
if (!Number.isNaN(date.getTime())) {
const year = date.getFullYear()
const month = String(date.getMonth() + 1).padStart(2, '0')
const day = String(date.getDate()).padStart(2, '0')
updatePattern({ concludes: `${year}-${month}-${day}`, iterations: null })
updatePattern({ concludes: formatLocalIsoDate(date), iterations: null })
}
endDateMenu.value = false
}
+30 -8
View File
@@ -5,12 +5,12 @@ import { storeToRefs } from 'pinia';
import { useRoute } from 'vue-router';
import { useDisplay } from 'vuetify';
import { useModuleStore } from '@KTXC/stores/moduleStore';
import { useChronoInstancesStore } from '@/stores/chronoInstancesStore';
import { useChronoOperationsStore } from '@/stores/chronoOperationsStore';
import { useChronoSettingsStore } from '@/stores/chronoSettingsStore';
import { useChronoUiStore } from '@/stores/chronoUiStore';
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 CollectionEditor from '@/components/CollectionEditor.vue';
import CalendarView from '@/components/CalendarView.vue';
@@ -19,6 +19,7 @@ import EventEditor from '@/components/EventEditor.vue';
import TaskEditor from '@/components/TaskEditor.vue';
import MiniCalendar from '@/components/MiniCalendar.vue';
import ImportDialog from '@/components/ImportDialog.vue';
import { EpochSpan } from '@/types/date';
// Vuetify display
const display = useDisplay();
@@ -29,7 +30,6 @@ const isChronoManagerAvailable = computed(() => {
return moduleStore.has('chrono_manager') || moduleStore.has('ChronoManager')
});
const chronoInstancesStore = useChronoInstancesStore();
const chronoOperationsStore = useChronoOperationsStore();
const chronoSettingsStore = useChronoSettingsStore();
const chronoUiStore = useChronoUiStore();
@@ -49,8 +49,6 @@ const {
collections,
} = storeToRefs(chronoOperationsStore);
const { setVisibleRange } = chronoInstancesStore;
const {
currentDate,
sidebarVisible,
@@ -67,6 +65,8 @@ const {
isTaskView,
} = storeToRefs(chronoUiStore);
const { loadVisibleRange: loadVisibleRangeOperations } = chronoOperationsStore;
const {
initialize,
setViewMode,
@@ -96,6 +96,7 @@ const {
const {
toggleCalendarVisibility,
toggleTaskComplete,
ensureTasksLoaded,
} = chronoOperationsStore;
function setCalendarView(view: CalendarViewType) {
@@ -129,18 +130,40 @@ function handleCalendarViewChange(view: CalendarViewType) {
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 () => {
try {
syncViewModeFromRoute();
await initialize();
await initialize(isTaskView.value ? 'tasks' : 'calendar');
} catch (error) {
console.error('[Chrono] - Failed to load data from ChronoManager:', error);
}
});
watch(() => route.fullPath, () => {
watch(() => route.fullPath, async () => {
syncViewModeFromRoute();
if (isTaskView.value) {
await ensureTasksLoaded();
}
});
</script>
<template>
@@ -285,7 +308,6 @@ watch(() => route.fullPath, () => {
@event-click="editEvent"
@date-click="createEventFromDate"
@update:current-date="setCurrentDate"
@update:visible-range="setVisibleRange"
@update:days-span="setDaysViewSpan"
@update:agenda-span="setAgendaViewSpan"
/>
+14 -26
View File
@@ -1,11 +1,11 @@
import { computed, shallowRef } from 'vue'
import { defineStore } from 'pinia'
import type { EntityObject } from '@ChronoManager/models/entity'
import type { VisibleDateRange } from '@/types'
import type { CalendarInstance } from '@/types/instance'
import { useChronoOperationsStore } from '@/stores/chronoOperationsStore'
import { expandEntityInstances } from '@/utils/recurrence'
import { shiftLocalDays, startOfLocalDay } from '@/utils/date'
import { EpochSpan } from '@/types/date'
type InstancesByDay = Map<number, CalendarInstance[]>
@@ -15,8 +15,8 @@ interface ExpansionCacheEntry {
}
function appendToDayBuckets(byDay: InstancesByDay, instance: CalendarInstance) {
const lastOccupiedDayMs = startOfLocalDay(new Date(instance.endMs - 1)).getTime()
let day = new Date(instance.dayStartMs)
const lastOccupiedDayMs = startOfLocalDay(new Date(instance.end - 1)).getTime()
let day = new Date(instance.dayStart)
while (day.getTime() <= lastOccupiedDayMs) {
const dayKey = day.getTime()
@@ -29,8 +29,8 @@ function appendToDayBuckets(byDay: InstancesByDay, instance: CalendarInstance) {
function sortInstances(instances: CalendarInstance[]) {
instances.sort((left, right) => (
left.startMs - right.startMs
|| right.durationMs - left.durationMs
left.start - right.start
|| right.duration - left.duration
|| left.key.localeCompare(right.key)
))
}
@@ -38,20 +38,20 @@ function sortInstances(instances: CalendarInstance[]) {
export const useChronoInstancesStore = defineStore('chronoInstancesStore', () => {
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
// to one entity only re-expands that entity. Entries are validated by
// object identity: the entities store replaces the EntityObject on every
// create/update, so in-place mutation of a stored entity is not observed.
const expansionCache = new Map<string, ExpansionCacheEntry>()
let expansionCacheRange: VisibleDateRange | null = null
let expansionCacheRange: EpochSpan | null = null
const instancesByDay = computed<InstancesByDay>(() => {
const range = visibleRange.value
if (
expansionCacheRange?.startMs !== range?.startMs
|| expansionCacheRange?.endMs !== range?.endMs
expansionCacheRange?.start !== range?.start
|| expansionCacheRange?.end !== range?.end
) {
expansionCache.clear()
expansionCacheRange = range
@@ -86,15 +86,15 @@ export const useChronoInstancesStore = defineStore('chronoInstancesStore', () =>
return instancesByDay.value.get(dayKey) ?? []
}
function instancesForRange(range: VisibleDateRange): CalendarInstance[] {
if (range.endMs <= range.startMs) return []
function instancesForRange(range: EpochSpan): CalendarInstance[] {
if (range.end <= range.start) return []
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()) ?? []) {
if (instance.startMs < range.endMs && instance.endMs > range.startMs) {
if (instance.start < range.end && instance.end > range.start) {
matches.set(instance.key, instance)
}
}
@@ -106,20 +106,8 @@ export const useChronoInstancesStore = defineStore('chronoInstancesStore', () =>
return instances
}
function setVisibleRange(range: VisibleDateRange) {
if (
visibleRange.value?.startMs === range.startMs
&& visibleRange.value.endMs === range.endMs
) {
return
}
visibleRange.value = { ...range }
}
return {
visibleRange,
setVisibleRange,
instancesForDay,
instancesForRange,
}
+61 -6
View File
@@ -5,10 +5,12 @@ import { EntityObject } from '@ChronoManager/models/entity'
import { EventObject } from '@ChronoManager/models/event'
import { TaskObject } from '@ChronoManager/models/task'
import type { ServiceObject } from '@ChronoManager/models/service'
import type { CollectionIdentifier, ListRangeDate } from '@ChronoManager/types/common'
import { useCollectionsStore } from '@ChronoManager/stores/collectionsStore'
import { useEntitiesStore } from '@ChronoManager/stores/entitiesStore'
import { useServicesStore } from '@ChronoManager/stores/servicesStore'
import { useImportStore } from '@ChronoManager/stores/importStore'
import { EpochSpan } from '@/types/date'
type ChronoEntityProperties = EventObject | TaskObject
@@ -19,6 +21,10 @@ export const useChronoOperationsStore = defineStore('chronoOperationsStore', ()
const importStore = useImportStore()
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 entities = computed(() => entitiesStore.entities)
@@ -43,6 +49,14 @@ export const useChronoOperationsStore = defineStore('chronoOperationsStore', ()
})
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 {
const properties = entity.properties as ChronoEntityProperties
const now = new Date().toISOString()
@@ -132,26 +146,65 @@ export const useChronoOperationsStore = defineStore('chronoOperationsStore', ()
const target = importStore.sessions[id]?.targetIdentifier
if (target) targets.add(target)
}
for (const target of targets) await entitiesStore.list([target])
await loadVisibleRange(requestedEventRange.value, Array.from(targets))
}
importStore.removeAllFiles()
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
try {
await servicesStore.list()
await collectionsStore.list()
if (!collectionsLoaded.value) {
await servicesStore.list()
await collectionsStore.list()
collectionsLoaded.value = true
}
const sources = collections.value.map(collection => collection.identifier)
if (sources.length > 0) await entitiesStore.list(sources)
if (viewMode === 'tasks') {
await ensureTasksLoaded()
}
if (requestedEventRange.value) {
await loadVisibleRange(requestedEventRange.value)
}
} finally {
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 {
loading,
collections,
@@ -171,5 +224,7 @@ export const useChronoOperationsStore = defineStore('chronoOperationsStore', ()
prepareImport,
finishImport,
initialize,
ensureTasksLoaded,
loadVisibleRange,
}
})
+2 -2
View File
@@ -193,8 +193,8 @@ export const useChronoUiStore = defineStore('chronoUiStore', () => {
await operationsStore.finishImport(refresh)
}
async function initialize() {
await operationsStore.initialize()
async function initialize(viewMode: 'calendar' | 'tasks' = 'calendar') {
await operationsStore.initialize(viewMode)
if (selectedCollection.value && !operationsStore.collections.some(
collection => collection.identifier === selectedCollection.value?.identifier,
)) {
+4
View File
@@ -0,0 +1,4 @@
export interface EpochSpan {
start: number;
end: number;
}
-6
View File
@@ -7,12 +7,6 @@
// Local UI-specific types
export type CalendarView = 'days' | 'month' | 'agenda';
/** Half-open local calendar range: [startMs, endMs). */
export interface VisibleDateRange {
startMs: number;
endMs: number;
}
export interface ViewState {
currentView: CalendarView;
currentDate: Date;
+4 -4
View File
@@ -4,10 +4,10 @@ export interface CalendarInstance {
key: string
entity: EntityObject
instanceId: string | null
startMs: number
endMs: number
dayStartMs: number
durationMs: number
start: number
end: number
dayStart: number
duration: number
timeless: boolean
multiDay: boolean
label: string | null
+7 -7
View File
@@ -1,4 +1,4 @@
import type { VisibleDateRange } from '../types'
import { EpochSpan } from '@/types/date'
export function parseCalendarDate(value: string | null | undefined): Date | null {
if (!value) return null
@@ -45,17 +45,17 @@ export function shiftLocalMonths(date: Date, months: number): Date {
return shifted
}
export function daysVisibleRange(date: Date, days: number): VisibleDateRange {
export function daysVisibleRange(date: Date, days: number): EpochSpan {
const start = startOfLocalDay(date)
const end = shiftLocalDays(start, Math.max(1, days))
return {
startMs: start.getTime(),
endMs: end.getTime(),
start: start.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 gridStart = new Date(firstDay)
gridStart.setDate(gridStart.getDate() - gridStart.getDay())
@@ -65,7 +65,7 @@ export function monthGridVisibleRange(date: Date): VisibleDateRange {
const gridEnd = shiftLocalDays(gridStart, weeks * 7)
return {
startMs: gridStart.getTime(),
endMs: gridEnd.getTime(),
start: gridStart.getTime(),
end: gridEnd.getTime(),
}
}
+61
View File
@@ -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' })
}
+6 -6
View File
@@ -37,22 +37,22 @@ export function layoutMultiDayLanes(
const multiDayEvents = instances.filter(instance => {
return instance.timeless
&& instance.startMs < rangeEndExclusive.getTime()
&& instance.endMs > rangeStartMs;
&& instance.start < rangeEndExclusive.getTime()
&& instance.end > rangeStartMs;
});
// Sort: longer events first, then by start date
multiDayEvents.sort((a, b) => {
if (b.durationMs !== a.durationMs) return b.durationMs - a.durationMs;
return a.startMs - b.startMs;
if (b.duration !== a.duration) return b.duration - a.duration;
return a.start - b.start;
});
// Lane assignment - track which days are occupied in each lane
const lanes: boolean[][] = [];
for (const instance of multiDayEvents) {
const eventStart = startOfLocalDay(new Date(instance.startMs));
const eventEnd = startOfLocalDay(new Date(instance.endMs - 1));
const eventStart = startOfLocalDay(new Date(instance.start));
const eventEnd = startOfLocalDay(new Date(instance.end - 1));
// Clamp to visible range
const segStart = eventStart < rangeStart ? rangeStart : eventStart;
+18 -17
View File
@@ -4,6 +4,7 @@ import type { EventMutationObject } from '@ChronoManager/models/event-mutation'
import type { CalendarInstance } from '../types/instance'
import type { VisibleDateRange } from '../types'
import { parseCalendarDate, shiftLocalDays, startOfLocalDay } from './date'
import { EpochSpan } from '@/types/date'
const MINIMUM_DURATION_MS = 1
@@ -40,8 +41,8 @@ function conclusionTimestamp(value: string | null | undefined): number | null {
return timestamp(value)
}
function overlapsRange(startMs: number, endMs: number, range?: VisibleDateRange): boolean {
return !range || (startMs < range.endMs && endMs > range.startMs)
function overlapsRange(start: number, end: number, range?: EpochSpan): boolean {
return !range || (start < range.end && end > range.start)
}
function createInstance(
@@ -65,10 +66,10 @@ function createInstance(
key: `${String(entity.identifier)}:${instanceId ?? 'master'}`,
entity,
instanceId,
startMs: effectiveStartMs,
endMs,
dayStartMs,
durationMs: endMs - effectiveStartMs,
start: effectiveStartMs,
end: effectiveEndMs,
dayStart: dayStartMs,
duration: endMs - effectiveStartMs,
timeless: timeless || lastOccupiedDayMs !== dayStartMs,
multiDay: lastOccupiedDayMs !== dayStartMs,
label: mutation?.label ?? event.label,
@@ -127,9 +128,9 @@ function dailyStartIndex(
start: Date,
interval: number,
durationMs: number,
range: VisibleDateRange,
range: EpochSpan,
): number {
const earliestRelevant = new Date(range.startMs - durationMs)
const earliestRelevant = new Date(range.start - durationMs)
const elapsedDays = localDayNumber(earliestRelevant) - localDayNumber(start)
return Math.max(0, Math.floor(elapsedDays / interval) - 1)
}
@@ -138,9 +139,9 @@ function weeklyStartIndex(
weekStart: Date,
interval: number,
durationMs: number,
range: VisibleDateRange,
range: EpochSpan,
): number {
const earliestRelevant = new Date(range.startMs - durationMs)
const earliestRelevant = new Date(range.start - durationMs)
const elapsedDays = localDayNumber(earliestRelevant) - localDayNumber(weekStart)
return Math.max(0, Math.floor(elapsedDays / (interval * 7)) - 1)
}
@@ -154,9 +155,9 @@ function normalizedWeekdays(values: number[] | null, fallback: number): number[]
function expandDaily(
entity: EntityObject,
start: Date,
durationMs: number,
duration: number,
durationDays: number | null,
range: VisibleDateRange,
range: EpochSpan,
mutations: Map<number, EventMutationObject>,
): CalendarInstance[] {
const pattern = (entity.properties as EventObject).pattern!
@@ -169,7 +170,7 @@ function expandDaily(
? new Set(normalizedWeekdays(pattern.onDayOfWeek, start.getDay() || 7))
: null
let step = maximumOccurrences === null || pattern.onDayOfWeek?.length === 0
? dailyStartIndex(start, interval, durationMs, range)
? dailyStartIndex(start, interval, duration, range)
: 0
let occurrenceCount = weekdays ? 0 : step
const instances: CalendarInstance[] = []
@@ -177,7 +178,7 @@ function expandDaily(
while (true) {
const candidate = shiftLocalDays(start, step * interval)
const candidateMs = candidate.getTime()
if (candidateMs >= range.endMs) break
if (candidateMs >= range.end) break
if (concludesMs !== null && candidateMs > concludesMs) break
if (!weekdays || weekdays.has(candidate.getDay() || 7)) {
@@ -187,7 +188,7 @@ function expandDaily(
const instance = materializeInstance(
entity,
candidateMs,
durationMs,
duration,
durationDays,
true,
mutations.get(candidateMs),
@@ -285,7 +286,7 @@ function appendMovedMutations(
export function expandEntityInstances(
entity: EntityObject,
range?: VisibleDateRange,
range?: EpochSpan,
): CalendarInstance[] {
const event = entity.properties as EventObject
const timeless = event.timeless === true
@@ -320,6 +321,6 @@ export function expandEntityInstances(
: expandWeekly(entity, new Date(startMs), 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
}