diff --git a/src/components/AgendaView.vue b/src/components/AgendaView.vue index eb520ed..71d1a5b 100644 --- a/src/components/AgendaView.vue +++ b/src/components/AgendaView.vue @@ -1,79 +1,30 @@ - - - - - - Today - - {{ dateRangeLabel }} - - - - - {{ span.toUpperCase() }} - - - - - - - No events in this period - - - - {{ formatAgendaDate(date) }} - - - mdi-circle - - {{ instance.label || 'Untitled' }} - - {{ instance.timeless ? 'All day' : `${formatInstanceTime(instance.startMs)} - ${formatInstanceTime(instance.endMs)}` }} - - - - - - - + + + + + + + No events in this period + + + + {{ formatFullDate(date) }} + + + mdi-circle + + {{ instance.label || 'Untitled' }} + + {{ instance.timeless ? 'All day' : `${formatEpochTime(instance.start)} - ${formatEpochTime(instance.end)}` }} + + + + + + + diff --git a/src/components/editors/EventEditorDates.vue b/src/components/editors/EventEditorDates.vue index e04c4e4..c3ee4cd 100644 --- a/src/components/editors/EventEditorDates.vue +++ b/src/components/editors/EventEditorDates.vue @@ -1,6 +1,7 @@ @@ -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" /> diff --git a/src/stores/chronoInstancesStore.ts b/src/stores/chronoInstancesStore.ts index 444b91a..1cc89bf 100644 --- a/src/stores/chronoInstancesStore.ts +++ b/src/stores/chronoInstancesStore.ts @@ -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 @@ -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(null) + const visibleRange = shallowRef(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() - let expansionCacheRange: VisibleDateRange | null = null + let expansionCacheRange: EpochSpan | null = null const instancesByDay = computed(() => { 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() - 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, } diff --git a/src/stores/chronoOperationsStore.ts b/src/stores/chronoOperationsStore.ts index 5d164d6..dfca603 100644 --- a/src/stores/chronoOperationsStore.ts +++ b/src/stores/chronoOperationsStore.ts @@ -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(null) + const loadedEventRange = ref(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, } }) diff --git a/src/stores/chronoUiStore.ts b/src/stores/chronoUiStore.ts index 35b500e..b25390f 100644 --- a/src/stores/chronoUiStore.ts +++ b/src/stores/chronoUiStore.ts @@ -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, )) { diff --git a/src/types/date.ts b/src/types/date.ts new file mode 100644 index 0000000..1429e9a --- /dev/null +++ b/src/types/date.ts @@ -0,0 +1,4 @@ +export interface EpochSpan { + start: number; + end: number; +} \ No newline at end of file diff --git a/src/types/index.ts b/src/types/index.ts index a7ddde9..102da54 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -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; diff --git a/src/types/instance.ts b/src/types/instance.ts index 185e081..8b3e7a5 100644 --- a/src/types/instance.ts +++ b/src/types/instance.ts @@ -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 diff --git a/src/utils/date.ts b/src/utils/date.ts index 4e880cc..e38e840 100644 --- a/src/utils/date.ts +++ b/src/utils/date.ts @@ -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(), } } diff --git a/src/utils/format.ts b/src/utils/format.ts new file mode 100644 index 0000000..e14d6f3 --- /dev/null +++ b/src/utils/format.ts @@ -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' }) +} diff --git a/src/utils/multiDayLanes.ts b/src/utils/multiDayLanes.ts index c7c0e31..dfc1ede 100644 --- a/src/utils/multiDayLanes.ts +++ b/src/utils/multiDayLanes.ts @@ -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; diff --git a/src/utils/recurrence.ts b/src/utils/recurrence.ts index dedde3a..e6ff998 100644 --- a/src/utils/recurrence.ts +++ b/src/utils/recurrence.ts @@ -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, ): 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 }