Compare commits
75 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 622c910b8d | |||
| e5ec5bb6a0 | |||
| 1ea9d18b5d | |||
| ed534b0fb7 | |||
| 8060153db2 | |||
| 52c8247671 | |||
| 398f770694 | |||
| 2d21833833 | |||
| f3aa93dfa3 | |||
| 844bbd0ad0 | |||
| 6569767258 | |||
| 833584ae8d | |||
| 967d93658a | |||
| 598c1754c7 | |||
| 22775b1f32 | |||
| af0350592d | |||
| bd6440a653 | |||
| cea1a71b77 | |||
| f62bb28d81 | |||
| 7fab1c2d9d | |||
| 63246931a7 | |||
| 2ceba8c7e2 | |||
| 9e48e3823a | |||
| f8a51d4fd6 | |||
| f5a6cef6f8 | |||
| 822acf7612 | |||
| a6af22859a | |||
| 01afdf4860 | |||
| a62c2f135e | |||
| 327b23c238 | |||
| 4b782ff078 | |||
| 38bd5a097a | |||
| c38fad8b32 | |||
| 532a83c24e | |||
| 992825c291 | |||
| 019b0b6367 | |||
| 8bbcc9e8b7 | |||
| 8010d4805f | |||
| 41a2de2b3d | |||
| 3c0ea698b1 | |||
| df531908f3 | |||
| 61626f50e5 | |||
| 421e763633 | |||
| dea5a61811 | |||
| bdac31e8e7 | |||
| 1ffbfc0c3d | |||
| 47c9a9027b | |||
| 37fa7b911e | |||
| acbe0ad5ca | |||
| cd270e9e99 | |||
| 51bf195664 | |||
| 18a96f5dab | |||
| 31d20448be | |||
| 460c31e224 | |||
| ee9495487e | |||
| 6657b8a0db | |||
| aa96aa4cb4 | |||
| 625159c2b2 | |||
| dee5fc4eea | |||
| ee651b123e | |||
| f7fac8a697 | |||
| f181e7baa2 | |||
| 29ba107585 | |||
| 63a4f93077 | |||
| b1022dd896 | |||
| 01dac91ed9 | |||
| a8a21eaaea | |||
| 929cafb577 | |||
| 9f3a916bc7 | |||
| 8efc974826 | |||
| 93f905af3a | |||
| 5a91b488a6 | |||
| 43663bbad2 | |||
| 23f7cb3efc | |||
| 4a4c8e46e4 |
@@ -0,0 +1,50 @@
|
|||||||
|
name: Build Test
|
||||||
|
|
||||||
|
on:
|
||||||
|
pull_request:
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
test:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Retrieve Server Install Action
|
||||||
|
uses: actions/checkout@v6.0.2
|
||||||
|
with:
|
||||||
|
repository: Nodarx/action-server-install
|
||||||
|
ref: main
|
||||||
|
path: action-server-install
|
||||||
|
github-server-url: https://git.ktrix.dev
|
||||||
|
|
||||||
|
- name: Install Server
|
||||||
|
uses: ./action-server-install
|
||||||
|
with:
|
||||||
|
install-php: 'false'
|
||||||
|
install-node: 'true'
|
||||||
|
php-version: '8.5'
|
||||||
|
node-version: '24'
|
||||||
|
server-path: './server'
|
||||||
|
|
||||||
|
- name: Install Chrono Manager
|
||||||
|
uses: actions/checkout@v6.0.2
|
||||||
|
with:
|
||||||
|
repository: Nodarx/chrono_manager
|
||||||
|
ref: main
|
||||||
|
path: server/modules/chrono_manager
|
||||||
|
github-server-url: https://git.ktrix.dev
|
||||||
|
|
||||||
|
- name: Checkout Pull Request
|
||||||
|
uses: actions/checkout@v6.0.2
|
||||||
|
with:
|
||||||
|
repository: ${{ github.repository }}
|
||||||
|
ref: ${{ github.event.pull_request.head.sha }}
|
||||||
|
path: server/modules/chrono
|
||||||
|
github-server-url: https://git.ktrix.dev
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: npm ci
|
||||||
|
working-directory: server/modules/chrono
|
||||||
|
|
||||||
|
- name: Build
|
||||||
|
run: npm run build
|
||||||
|
working-directory: server/modules/chrono
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
name: JS Unit Tests
|
||||||
|
|
||||||
|
on:
|
||||||
|
pull_request:
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
test:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Retrieve Server Install Action
|
||||||
|
uses: actions/checkout@v6.0.2
|
||||||
|
with:
|
||||||
|
repository: Nodarx/action-server-install
|
||||||
|
ref: main
|
||||||
|
path: action-server-install
|
||||||
|
github-server-url: https://git.ktrix.dev
|
||||||
|
|
||||||
|
- name: Install Server
|
||||||
|
uses: ./action-server-install
|
||||||
|
with:
|
||||||
|
install-php: 'false'
|
||||||
|
install-node: 'true'
|
||||||
|
php-version: '8.5'
|
||||||
|
node-version: '24'
|
||||||
|
server-path: './server'
|
||||||
|
|
||||||
|
- name: Install Chrono Manager
|
||||||
|
uses: actions/checkout@v6.0.2
|
||||||
|
with:
|
||||||
|
repository: Nodarx/chrono_manager
|
||||||
|
ref: main
|
||||||
|
path: server/modules/chrono_manager
|
||||||
|
github-server-url: https://git.ktrix.dev
|
||||||
|
|
||||||
|
- name: Checkout Pull Request
|
||||||
|
uses: actions/checkout@v6.0.2
|
||||||
|
with:
|
||||||
|
repository: ${{ github.repository }}
|
||||||
|
ref: ${{ github.event.pull_request.head.sha }}
|
||||||
|
path: server/modules/chrono
|
||||||
|
github-server-url: https://git.ktrix.dev
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: npm ci
|
||||||
|
working-directory: server/modules/chrono
|
||||||
|
|
||||||
|
- name: Run tests
|
||||||
|
run: npm run test:unit
|
||||||
|
working-directory: server/modules/chrono
|
||||||
@@ -25,11 +25,17 @@ jobs:
|
|||||||
tools: composer:v2
|
tools: composer:v2
|
||||||
|
|
||||||
- name: Install Renovate
|
- name: Install Renovate
|
||||||
run: npm install -g renovate
|
run: |
|
||||||
|
npm install --global --no-audit --fund=false \
|
||||||
|
--prefix "${{ runner.temp }}/renovate-npm" \
|
||||||
|
--cache "${{ runner.temp }}/renovate-npm-cache" \
|
||||||
|
renovate
|
||||||
|
"${{ runner.temp }}/renovate-npm/bin/renovate" --version
|
||||||
|
|
||||||
- name: Run Renovate
|
- name: Run Renovate
|
||||||
env:
|
env:
|
||||||
RENOVATE_TOKEN: ${{ secrets.RENOVATE_TOKEN }}
|
RENOVATE_TOKEN: ${{ secrets.RENOVATE_TOKEN }}
|
||||||
RENOVATE_PLATFORM: gitea
|
RENOVATE_PLATFORM: gitea
|
||||||
RENOVATE_ENDPOINT: https://git.ktrix.dev/api/v1
|
RENOVATE_ENDPOINT: https://git.ktrix.dev/api/v1
|
||||||
run: renovate ${{ gitea.repository }}
|
run: |
|
||||||
|
"${{ runner.temp }}/renovate-npm/bin/renovate" ${{ gitea.repository }}
|
||||||
+3
-4
@@ -12,18 +12,17 @@ node_modules/
|
|||||||
/static/
|
/static/
|
||||||
|
|
||||||
# Backend development
|
# Backend development
|
||||||
|
/vendor/
|
||||||
/lib/vendor/
|
/lib/vendor/
|
||||||
coverage/
|
coverage/
|
||||||
phpunit.xml.cache
|
*.cache
|
||||||
.phpunit.result.cache
|
|
||||||
.php-cs-fixer.cache
|
|
||||||
.phpstan.cache
|
|
||||||
.phpactor/
|
.phpactor/
|
||||||
|
|
||||||
# Editors
|
# Editors
|
||||||
.DS_Store
|
.DS_Store
|
||||||
.vscode/
|
.vscode/
|
||||||
.idea/
|
.idea/
|
||||||
|
.ideas/
|
||||||
|
|
||||||
# Logs
|
# Logs
|
||||||
*.log
|
*.log
|
||||||
|
|||||||
Generated
+18
@@ -0,0 +1,18 @@
|
|||||||
|
{
|
||||||
|
"_readme": [
|
||||||
|
"This file locks the dependencies of your project to a known state",
|
||||||
|
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
|
||||||
|
"This file is @generated automatically"
|
||||||
|
],
|
||||||
|
"content-hash": "be9a7c5c98bd4e0177db635c08bcd565",
|
||||||
|
"packages": [],
|
||||||
|
"packages-dev": [],
|
||||||
|
"aliases": [],
|
||||||
|
"minimum-stability": "stable",
|
||||||
|
"stability-flags": {},
|
||||||
|
"prefer-stable": false,
|
||||||
|
"prefer-lowest": false,
|
||||||
|
"platform": {},
|
||||||
|
"platform-dev": {},
|
||||||
|
"plugin-api-version": "2.6.0"
|
||||||
|
}
|
||||||
Generated
+2089
-397
File diff suppressed because it is too large
Load Diff
+10
-2
@@ -11,19 +11,27 @@
|
|||||||
"dev": "vite build --mode development --config vite.config.ts",
|
"dev": "vite build --mode development --config vite.config.ts",
|
||||||
"watch": "vite build --mode development --watch --config vite.config.ts",
|
"watch": "vite build --mode development --watch --config vite.config.ts",
|
||||||
"typecheck": "vue-tsc --noEmit",
|
"typecheck": "vue-tsc --noEmit",
|
||||||
"lint": "eslint . --ext .vue,.js,.jsx,.cjs,.mjs,.ts,.tsx,.cts,.mts --fix --ignore-path .gitignore"
|
"lint": "eslint . --ext .vue,.js,.jsx,.cjs,.mjs,.ts,.tsx,.cts,.mts --fix --ignore-path .gitignore",
|
||||||
|
"test": "vitest run --config tests/js/vitest.config.ts",
|
||||||
|
"test:unit": "vitest run --config tests/js/vitest.config.ts",
|
||||||
|
"test:watch": "vitest watch --config tests/js/vitest.config.ts",
|
||||||
|
"test:coverage": "vitest run --coverage --config tests/js/vitest.config.ts"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"pinia": "^3.0.0",
|
"pinia": "^4.0.0",
|
||||||
"vue": "^3.5.18",
|
"vue": "^3.5.18",
|
||||||
"vue-router": "^5.0.0",
|
"vue-router": "^5.0.0",
|
||||||
"vuetify": "^4.0.0"
|
"vuetify": "^4.0.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@vitejs/plugin-vue": "^6.0.1",
|
"@vitejs/plugin-vue": "^6.0.1",
|
||||||
|
"@vitest/coverage-v8": "^4.1.6",
|
||||||
|
"@vue/test-utils": "^2.4.10",
|
||||||
"@vue/tsconfig": "^0.9.0",
|
"@vue/tsconfig": "^0.9.0",
|
||||||
|
"jsdom": "^29.1.1",
|
||||||
"typescript": "~6.0.0",
|
"typescript": "~6.0.0",
|
||||||
"vite": "^8.0.0",
|
"vite": "^8.0.0",
|
||||||
|
"vitest": "^4.1.6",
|
||||||
"vue-tsc": "^3.0.5"
|
"vue-tsc": "^3.0.5"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+66
-177
@@ -1,83 +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="(dayEvents, date) in groupedEvents" :key="date">
|
|
||||||
<v-list-subheader>{{ formatAgendaDate(date) }}</v-list-subheader>
|
|
||||||
<v-list-item
|
|
||||||
v-for="entity in dayEvents"
|
|
||||||
:key="entity.identifier"
|
|
||||||
@click="$emit('event-click', entity)"
|
|
||||||
>
|
|
||||||
<template #prepend>
|
|
||||||
<v-icon :color="getEventColor(entity)">mdi-circle</v-icon>
|
|
||||||
</template>
|
|
||||||
<v-list-item-title>{{ entity.properties?.label || 'Untitled' }}</v-list-item-title>
|
|
||||||
<v-list-item-subtitle>
|
|
||||||
{{ getEventProperties(entity).timeless ? 'All day' : `${formatEventDateTime(getEventProperties(entity).startsOn)} - ${formatEventDateTime(getEventProperties(entity).endsOn)}` }}
|
|
||||||
</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 { EventObject } from '@ChronoManager/models/event';
|
import { daysVisibleRange } from '@/utils/date';
|
||||||
|
import { useChronoInstancesStore } from '@/stores/chronoInstancesStore';
|
||||||
type CalendarEntity = EntityObject;
|
import type { CalendarInstance } from '@/types/instance';
|
||||||
type CalendarCollection = CollectionObject;
|
import ViewControls from './ViewControls.vue';
|
||||||
|
import { formatEpochTime, formatFullDate } from '@/utils/format';
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
events: CalendarEntity[];
|
calendars: CollectionObject[];
|
||||||
calendars: CalendarCollection[];
|
currentDate: Date;
|
||||||
currentDate?: Date;
|
|
||||||
initialSpan?: AgendaViewSpan;
|
initialSpan?: AgendaViewSpan;
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
const selectedSpan = ref<AgendaViewSpan>(props.initialSpan ?? '1w');
|
|
||||||
const localDate = ref(new Date(props.currentDate ?? new Date()));
|
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
'event-click': [event: CalendarEntity];
|
'event-click': [event: EntityObject];
|
||||||
'update:span': [span: AgendaViewSpan];
|
'update:span': [span: AgendaViewSpan];
|
||||||
|
'update:current-date': [date: Date];
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
// Watch for external date changes (e.g., from mini calendar)
|
const instancesStore = useChronoInstancesStore();
|
||||||
watch(() => props.currentDate, (newDate) => {
|
|
||||||
if (newDate) {
|
const selectedSpan = ref<AgendaViewSpan>(props.initialSpan ?? '1w');
|
||||||
localDate.value = new Date(newDate);
|
const selectedSpanDays = computed(() => spanToDays(selectedSpan.value));
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
watch(() => props.initialSpan, (newSpan) => {
|
watch(() => props.initialSpan, (newSpan) => {
|
||||||
if (newSpan && newSpan !== selectedSpan.value) {
|
if (newSpan && newSpan !== selectedSpan.value) {
|
||||||
@@ -90,108 +37,78 @@ function setSpan(span: AgendaViewSpan) {
|
|||||||
emit('update:span', span);
|
emit('update:span', span);
|
||||||
}
|
}
|
||||||
|
|
||||||
function getSpanDays(): number {
|
|
||||||
return spanToDays(selectedSpan.value);
|
|
||||||
}
|
|
||||||
|
|
||||||
function previousPeriod() {
|
|
||||||
const date = new Date(localDate.value);
|
|
||||||
date.setDate(date.getDate() - getSpanDays());
|
|
||||||
localDate.value = date;
|
|
||||||
}
|
|
||||||
|
|
||||||
function nextPeriod() {
|
|
||||||
const date = new Date(localDate.value);
|
|
||||||
date.setDate(date.getDate() + getSpanDays());
|
|
||||||
localDate.value = date;
|
|
||||||
}
|
|
||||||
|
|
||||||
function goToToday() {
|
|
||||||
localDate.value = new Date();
|
|
||||||
}
|
|
||||||
|
|
||||||
const dateRange = computed(() => {
|
const dateRange = computed(() => {
|
||||||
const start = new Date(localDate.value);
|
const range = daysVisibleRange(props.currentDate, selectedSpanDays.value);
|
||||||
start.setHours(0, 0, 0, 0);
|
return {
|
||||||
|
start: new Date(range.start),
|
||||||
const end = new Date(start);
|
end: new Date(range.end),
|
||||||
end.setDate(end.getDate() + getSpanDays() - 1);
|
};
|
||||||
end.setHours(23, 59, 59, 999);
|
|
||||||
|
|
||||||
return { start, end };
|
|
||||||
});
|
|
||||||
|
|
||||||
const dateRangeLabel = computed(() => {
|
|
||||||
const { start, end } = dateRange.value;
|
|
||||||
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, CalendarEntity[]> = {};
|
const grouped: Record<string, CalendarInstance[]> = {};
|
||||||
const { start, end } = dateRange.value;
|
const { start, end } = dateRange.value;
|
||||||
|
|
||||||
const filtered = props.events.filter((e): e is CalendarEntity => {
|
const instances = instancesStore.instancesForRange({
|
||||||
const startsOn = getEventProperties(e).startsOn;
|
start: start.getTime(),
|
||||||
if (typeof startsOn !== 'string') return false;
|
end: end.getTime(),
|
||||||
const eventStart = new Date(startsOn);
|
|
||||||
return eventStart >= start && eventStart <= end;
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const sorted = filtered.sort((a, b) =>
|
instances.forEach(instance => {
|
||||||
new Date(getEventProperties(a).startsOn || 0).getTime() - new Date(getEventProperties(b).startsOn || 0).getTime()
|
const dateKey = new Date(instance.start).toDateString();
|
||||||
);
|
|
||||||
|
|
||||||
sorted.forEach(entity => {
|
|
||||||
const dateKey = new Date(getEventProperties(entity).startsOn || 0).toDateString();
|
|
||||||
if (!grouped[dateKey]) {
|
if (!grouped[dateKey]) {
|
||||||
grouped[dateKey] = [];
|
grouped[dateKey] = [];
|
||||||
}
|
}
|
||||||
grouped[dateKey].push(entity);
|
grouped[dateKey].push(instance);
|
||||||
});
|
});
|
||||||
|
|
||||||
return grouped;
|
return grouped;
|
||||||
});
|
});
|
||||||
|
|
||||||
function getEventColor(entity: CalendarEntity): string {
|
function getEventColor(instance: CalendarInstance): string {
|
||||||
const event = getEventProperties(entity);
|
const entity = instance.entity;
|
||||||
if (event.color) return event.color;
|
if (instance.color) return instance.color;
|
||||||
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 getEventProperties(entity: CalendarEntity): EventObject {
|
|
||||||
return entity.properties as EventObject;
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatTime(date: Date): string {
|
|
||||||
return date.toLocaleTimeString('en-US', { hour: 'numeric', minute: '2-digit' });
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatEventDateTime(value: string | null): string {
|
|
||||||
return value ? formatTime(new Date(value)) : '';
|
|
||||||
}
|
|
||||||
|
|
||||||
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;
|
||||||
@@ -199,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,67 +1,29 @@
|
|||||||
<template>
|
|
||||||
<div class="calendar-view-container">
|
|
||||||
<EventViewerPopup
|
|
||||||
:event="hoveredEvent"
|
|
||||||
:position="popupPosition"
|
|
||||||
:visible="showPopup"
|
|
||||||
@click="$emit('event-click', $event)"
|
|
||||||
/>
|
|
||||||
<MonthView
|
|
||||||
v-if="view === 'month'"
|
|
||||||
:current-date="currentDate"
|
|
||||||
:events="events"
|
|
||||||
:calendars="calendars"
|
|
||||||
@event-click="$emit('event-click', $event)"
|
|
||||||
@date-click="$emit('date-click', $event)"
|
|
||||||
@event-hover="handleEventHover"
|
|
||||||
@event-hover-end="hidePopup"
|
|
||||||
/>
|
|
||||||
<DaysView
|
|
||||||
v-else-if="view === 'days'"
|
|
||||||
:current-date="currentDate"
|
|
||||||
:events="events"
|
|
||||||
:calendars="calendars"
|
|
||||||
:initial-span="initialDaysSpan"
|
|
||||||
@event-click="$emit('event-click', $event)"
|
|
||||||
@date-click="$emit('date-click', $event)"
|
|
||||||
@event-hover="handleEventHover"
|
|
||||||
@event-hover-end="hidePopup"
|
|
||||||
@update:span="$emit('update:days-span', $event)"
|
|
||||||
/>
|
|
||||||
<AgendaView
|
|
||||||
v-else-if="view === 'agenda'"
|
|
||||||
:events="events"
|
|
||||||
:calendars="calendars"
|
|
||||||
:current-date="currentDate"
|
|
||||||
:initial-span="initialAgendaViewSpan"
|
|
||||||
@event-click="$emit('event-click', $event)"
|
|
||||||
@update:span="$emit('update:agenda-span', $event)"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref } from 'vue';
|
import { ref } from 'vue';
|
||||||
import MonthView from './MonthView.vue';
|
import MonthView from './MonthView.vue';
|
||||||
import DaysView from './DaysView.vue';
|
import DaysView from './DaysView.vue';
|
||||||
import AgendaView from './AgendaView.vue';
|
import AgendaView from './AgendaView.vue';
|
||||||
import EventViewerPopup from './EventViewerPopup.vue';
|
import EventViewerPopup from './EventViewerPopup.vue';
|
||||||
import type { AgendaViewSpan, DaysViewSpan } from '@/types/spans';
|
import {
|
||||||
|
type AgendaViewSpan,
|
||||||
|
type DaysViewSpan
|
||||||
|
} 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';
|
||||||
|
|
||||||
defineProps<{
|
const props = defineProps<{
|
||||||
view: 'days' | 'month' | 'agenda';
|
view: 'days' | 'month' | 'agenda';
|
||||||
currentDate: Date;
|
currentDate: Date;
|
||||||
events: EntityObject[];
|
|
||||||
calendars: CollectionObject[];
|
calendars: CollectionObject[];
|
||||||
initialDaysSpan?: DaysViewSpan;
|
initialDaysSpan?: DaysViewSpan;
|
||||||
initialAgendaViewSpan?: AgendaViewSpan;
|
initialAgendaViewSpan?: AgendaViewSpan;
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
defineEmits<{
|
const emit = defineEmits<{
|
||||||
'event-click': [event: EntityObject];
|
'event-click': [event: EntityObject];
|
||||||
'date-click': [date: Date];
|
'date-click': [date: Date];
|
||||||
|
'date-range-select': [range: { start: Date; end: Date }];
|
||||||
|
'update:current-date': [date: Date];
|
||||||
'update:days-span': [span: DaysViewSpan];
|
'update:days-span': [span: DaysViewSpan];
|
||||||
'update:agenda-span': [span: AgendaViewSpan];
|
'update:agenda-span': [span: AgendaViewSpan];
|
||||||
}>();
|
}>();
|
||||||
@@ -93,6 +55,49 @@ function hidePopup() {
|
|||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="calendar-view-container">
|
||||||
|
<EventViewerPopup
|
||||||
|
:event="hoveredEvent"
|
||||||
|
:position="popupPosition"
|
||||||
|
:visible="showPopup"
|
||||||
|
@click="$emit('event-click', $event)"
|
||||||
|
/>
|
||||||
|
<MonthView
|
||||||
|
v-if="view === 'month'"
|
||||||
|
:current-date="currentDate"
|
||||||
|
:calendars="calendars"
|
||||||
|
@event-click="$emit('event-click', $event)"
|
||||||
|
@date-click="$emit('date-click', $event)"
|
||||||
|
@event-hover="handleEventHover"
|
||||||
|
@event-hover-end="hidePopup"
|
||||||
|
@update:current-date="$emit('update:current-date', $event)"
|
||||||
|
/>
|
||||||
|
<DaysView
|
||||||
|
v-else-if="view === 'days'"
|
||||||
|
:current-date="currentDate"
|
||||||
|
:calendars="calendars"
|
||||||
|
:initial-span="initialDaysSpan"
|
||||||
|
@event-click="$emit('event-click', $event)"
|
||||||
|
@date-click="$emit('date-click', $event)"
|
||||||
|
@date-range-select="$emit('date-range-select', $event)"
|
||||||
|
@event-hover="handleEventHover"
|
||||||
|
@event-hover-end="hidePopup"
|
||||||
|
@update:span="$emit('update:days-span', $event)"
|
||||||
|
@update:current-date="$emit('update:current-date', $event)"
|
||||||
|
/>
|
||||||
|
<AgendaView
|
||||||
|
v-else-if="view === 'agenda'"
|
||||||
|
:calendars="calendars"
|
||||||
|
:current-date="currentDate"
|
||||||
|
:initial-span="initialAgendaViewSpan"
|
||||||
|
@event-click="$emit('event-click', $event)"
|
||||||
|
@update:span="$emit('update:agenda-span', $event)"
|
||||||
|
@update:current-date="$emit('update:current-date', $event)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.calendar-view-container {
|
.calendar-view-container {
|
||||||
height: 100%;
|
height: 100%;
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
import { ref, computed, watch } from 'vue'
|
import { ref, computed, watch } from 'vue'
|
||||||
import { useServicesStore } from '@ChronoManager/stores/servicesStore'
|
import { useServicesStore } from '@ChronoManager/stores/servicesStore'
|
||||||
import { CollectionObject } from '@ChronoManager/models/collection'
|
import { CollectionObject } from '@ChronoManager/models/collection'
|
||||||
import { ServiceObject } from '@ChronoManager/models/service'
|
import type { ServiceObject } from '@ChronoManager/models/service'
|
||||||
|
|
||||||
// Store
|
// Store
|
||||||
const servicesStore = useServicesStore()
|
const servicesStore = useServicesStore()
|
||||||
@@ -74,11 +74,11 @@ const onOpen = async () => {
|
|||||||
if (props.mode === 'edit') {
|
if (props.mode === 'edit') {
|
||||||
// Edit mode - find the service
|
// Edit mode - find the service
|
||||||
editingCollectionService.value = services.value.find(s =>
|
editingCollectionService.value = services.value.find(s =>
|
||||||
s.provider === props.collection!.provider && s.identifier === props.collection!.service
|
`${s.provider}:${s.identifier}` === String(props.collection!.service)
|
||||||
) || null
|
) || null
|
||||||
} else {
|
} else {
|
||||||
// Create mode - use first service that can create
|
// Create mode - use first service that can create
|
||||||
editingCollectionService.value = services.value.filter(s => s.capabilities?.CollectionCreate)[0] || null
|
editingCollectionService.value = services.value.find(s => s.capable('CollectionCreate')) || null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -142,7 +142,7 @@ watch(() => props.modelValue, async (newValue) => {
|
|||||||
<v-combobox v-show="mode === 'create' && services.length > 1"
|
<v-combobox v-show="mode === 'create' && services.length > 1"
|
||||||
v-model="editingCollectionService"
|
v-model="editingCollectionService"
|
||||||
label="Service"
|
label="Service"
|
||||||
:items="services.filter(s => s.capabilities?.CollectionCreate)"
|
:items="services.filter(s => s.capable('CollectionCreate'))"
|
||||||
item-title="label"
|
item-title="label"
|
||||||
item-value="identifier"
|
item-value="identifier"
|
||||||
required
|
required
|
||||||
@@ -254,7 +254,7 @@ watch(() => props.modelValue, async (newValue) => {
|
|||||||
<v-card-actions class="justify-space-between align-center">
|
<v-card-actions class="justify-space-between align-center">
|
||||||
<div>
|
<div>
|
||||||
<v-btn
|
<v-btn
|
||||||
v-if="mode === 'edit' && editingCollectionService?.capabilities?.CollectionDestroy"
|
v-if="mode === 'edit' && editingCollectionService?.capable('CollectionDelete')"
|
||||||
color="error"
|
color="error"
|
||||||
variant="text"
|
variant="text"
|
||||||
@click="onDelete"
|
@click="onDelete"
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ const props = defineProps<{
|
|||||||
|
|
||||||
// Emits
|
// Emits
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
|
'create': []
|
||||||
'select': [collection: CollectionObject]
|
'select': [collection: CollectionObject]
|
||||||
'edit': [collection: CollectionObject]
|
'edit': [collection: CollectionObject]
|
||||||
'toggle-visibility': [collection: CollectionObject]
|
'toggle-visibility': [collection: CollectionObject]
|
||||||
@@ -42,6 +43,12 @@ const displayIcon = computed(() => {
|
|||||||
return 'mdi-folder'
|
return 'mdi-folder'
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const createLabel = computed(() => {
|
||||||
|
if (props.type === 'calendar') return 'New Calendar'
|
||||||
|
if (props.type === 'tasklist') return 'New Task List'
|
||||||
|
return 'New Collection'
|
||||||
|
})
|
||||||
|
|
||||||
// Functions
|
// Functions
|
||||||
const onCollectionSelect = (collection: CollectionObject) => {
|
const onCollectionSelect = (collection: CollectionObject) => {
|
||||||
console.log('[Chrono] - Collection selected', collection)
|
console.log('[Chrono] - Collection selected', collection)
|
||||||
@@ -60,13 +67,23 @@ const onToggleVisibility = (collection: CollectionObject) => {
|
|||||||
<template>
|
<template>
|
||||||
<div class="collection-selector">
|
<div class="collection-selector">
|
||||||
<div class="collection-selector-header">
|
<div class="collection-selector-header">
|
||||||
<div class="d-flex align-center mb-3">
|
<div class="d-flex align-center justify-space-between mb-1">
|
||||||
<v-icon :icon="displayIcon" size="small" class="mr-2" />
|
<div class="d-flex align-center">
|
||||||
<span class="text-subtitle-2 font-weight-bold">{{ displayTitle }}</span>
|
<v-icon :icon="displayIcon" size="14" class="mr-2 text-medium-emphasis" />
|
||||||
|
<span class="sidebar-section-label">{{ displayTitle }}</span>
|
||||||
|
</div>
|
||||||
|
<v-btn
|
||||||
|
icon="mdi-plus"
|
||||||
|
size="x-small"
|
||||||
|
variant="text"
|
||||||
|
:aria-label="createLabel"
|
||||||
|
:title="createLabel"
|
||||||
|
@click="emit('create')"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<v-divider class="my-2" />
|
<v-divider class="mb-2" />
|
||||||
|
|
||||||
<div class="collection-selector-content">
|
<div class="collection-selector-content">
|
||||||
<v-list density="compact" nav class="pa-0">
|
<v-list density="compact" nav class="pa-0">
|
||||||
@@ -91,19 +108,13 @@ const onToggleVisibility = (collection: CollectionObject) => {
|
|||||||
<v-list-item-title>{{ collection.properties.label || 'Unnamed Collection' }}</v-list-item-title>
|
<v-list-item-title>{{ collection.properties.label || 'Unnamed Collection' }}</v-list-item-title>
|
||||||
|
|
||||||
<template #append>
|
<template #append>
|
||||||
<div class="d-flex align-center">
|
<v-btn
|
||||||
<v-icon
|
icon="mdi-pencil"
|
||||||
:color="collection.properties.color || 'primary'"
|
size="x-small"
|
||||||
size="small"
|
variant="text"
|
||||||
class="mr-1"
|
class="collection-edit-btn"
|
||||||
>mdi-circle</v-icon>
|
@click.stop="onCollectionEdit(collection)"
|
||||||
<v-btn
|
/>
|
||||||
icon="mdi-pencil"
|
|
||||||
size="x-small"
|
|
||||||
variant="text"
|
|
||||||
@click.stop="onCollectionEdit(collection)"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</template>
|
</template>
|
||||||
</v-list-item>
|
</v-list-item>
|
||||||
</v-list>
|
</v-list>
|
||||||
@@ -135,4 +146,28 @@ const onToggleVisibility = (collection: CollectionObject) => {
|
|||||||
.v-list-item {
|
.v-list-item {
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.sidebar-section-label {
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: 0.09em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: rgba(var(--v-theme-on-surface), 0.6);
|
||||||
|
}
|
||||||
|
|
||||||
|
.collection-edit-btn {
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity 0.15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.v-list-item:hover .collection-edit-btn,
|
||||||
|
.v-list-item:focus-within .collection-edit-btn {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (hover: none) {
|
||||||
|
.collection-edit-btn {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
+301
-263
@@ -1,26 +1,226 @@
|
|||||||
|
<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, formatTime, 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 hoveredTime = ref<{ day: string; minutes: number } | null>(null);
|
||||||
|
const timeSelection = ref<{ day: string; anchor: number; current: number } | null>(null);
|
||||||
|
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];
|
||||||
|
'date-range-select': [range: { start: Date; end: 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 pointerMinutes(event: MouseEvent, allowEndOfDay = false): number {
|
||||||
|
const target = event.currentTarget as HTMLElement;
|
||||||
|
const rect = target.getBoundingClientRect();
|
||||||
|
const pointerY = Math.min(Math.max(event.clientY - rect.top, 0), rect.height);
|
||||||
|
const minutes = Math.round(((pointerY / rect.height) * 1440) / 15) * 15;
|
||||||
|
|
||||||
|
return Math.min(minutes, allowEndOfDay ? 1440 : 1425);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleHover(event: MouseEvent, day: Date) {
|
||||||
|
hoveredTime.value = {
|
||||||
|
day: day.toISOString(),
|
||||||
|
minutes: pointerMinutes(event),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatHoverTime(totalMinutes: number): string {
|
||||||
|
const hours = Math.floor(totalMinutes / 60);
|
||||||
|
const minutes = totalMinutes % 60;
|
||||||
|
const date = new Date(2000, 0, 1, hours, minutes);
|
||||||
|
return formatTime(date);
|
||||||
|
}
|
||||||
|
|
||||||
|
function dateAtMinutes(day: Date, totalMinutes: number): Date {
|
||||||
|
const date = new Date(day);
|
||||||
|
date.setHours(0, totalMinutes, 0, 0);
|
||||||
|
return date;
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectionBounds(selection: NonNullable<typeof timeSelection.value>) {
|
||||||
|
const start = Math.min(selection.anchor, selection.current);
|
||||||
|
const end = selection.anchor === selection.current
|
||||||
|
? Math.min(start + 15, 1440)
|
||||||
|
: Math.max(selection.anchor, selection.current);
|
||||||
|
|
||||||
|
return { start, end };
|
||||||
|
}
|
||||||
|
|
||||||
|
function handlePointerDown(event: PointerEvent, day: Date) {
|
||||||
|
if (event.pointerType !== 'mouse' || event.button !== 0) return;
|
||||||
|
|
||||||
|
event.preventDefault();
|
||||||
|
const minutes = pointerMinutes(event);
|
||||||
|
hoveredTime.value = null;
|
||||||
|
timeSelection.value = {
|
||||||
|
day: day.toISOString(),
|
||||||
|
anchor: minutes,
|
||||||
|
current: minutes,
|
||||||
|
};
|
||||||
|
(event.currentTarget as HTMLElement).setPointerCapture(event.pointerId);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handlePointerUp(event: PointerEvent, day: Date) {
|
||||||
|
const selection = timeSelection.value;
|
||||||
|
if (!selection || selection.day !== day.toISOString()) return;
|
||||||
|
|
||||||
|
selection.current = pointerMinutes(event, true);
|
||||||
|
const { start, end } = selectionBounds(selection);
|
||||||
|
timeSelection.value = null;
|
||||||
|
|
||||||
|
if (selection.anchor === selection.current) {
|
||||||
|
emit('date-click', dateAtMinutes(day, start));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
emit('date-range-select', {
|
||||||
|
start: dateAtMinutes(day, start),
|
||||||
|
end: dateAtMinutes(day, end),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function handlePointerMove(event: PointerEvent, day: Date) {
|
||||||
|
if (timeSelection.value?.day === day.toISOString()) {
|
||||||
|
timeSelection.value.current = pointerMinutes(event, true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
handleHover(event, day);
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectionStyle(selection: NonNullable<typeof timeSelection.value>) {
|
||||||
|
const { start, end } = selectionBounds(selection);
|
||||||
|
return {
|
||||||
|
top: `${start / 1440 * 100}%`,
|
||||||
|
height: `${(end - start) / 1440 * 100}%`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectionLabel(selection: NonNullable<typeof timeSelection.value>): string {
|
||||||
|
const { start, end } = selectionBounds(selection);
|
||||||
|
return `${formatHoverTime(start)} – ${formatHoverTime(end)} · ${end - start} min`;
|
||||||
|
}
|
||||||
|
|
||||||
|
</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">
|
||||||
@@ -45,18 +245,18 @@
|
|||||||
<div class="all-day-events-overlay">
|
<div class="all-day-events-overlay">
|
||||||
<div
|
<div
|
||||||
v-for="segment in allDaySegments.segments"
|
v-for="segment in allDaySegments.segments"
|
||||||
:key="segment.entity.identifier"
|
:key="segment.instance.key"
|
||||||
class="all-day-event"
|
class="all-day-event"
|
||||||
:class="{
|
:class="{
|
||||||
'is-start': segment.isStart,
|
'is-start': segment.isStart,
|
||||||
'is-end': segment.isEnd,
|
'is-end': segment.isEnd,
|
||||||
}"
|
}"
|
||||||
:style="getAllDayEventStyle(segment)"
|
:style="getAllDayEventStyle(segment)"
|
||||||
@click="emit('event-click', segment.entity)"
|
@click="emit('event-click', segment.instance.entity)"
|
||||||
@mouseenter="emit('event-hover', { event: $event, entity: segment.entity })"
|
@mouseenter="emit('event-hover', { event: $event, entity: segment.instance.entity })"
|
||||||
@mouseleave="emit('event-hover-end')"
|
@mouseleave="emit('event-hover-end')"
|
||||||
>
|
>
|
||||||
<span v-if="segment.isStart" class="event-label">{{ segment.entity.properties?.label || 'Untitled' }}</span>
|
<span v-if="segment.isStart" class="event-label">{{ segment.instance.label || 'Untitled' }}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -71,24 +271,46 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="days-columns">
|
<div class="days-columns">
|
||||||
<div v-for="day in visibleDates" :key="day.toISOString()" class="day-column" :class="{ 'single-day': daysCount === 1 }">
|
<div v-for="day in visibleDates" :key="day.toISOString()" class="day-column" :class="{ 'single-day': daysCount === 1 }">
|
||||||
<div class="day-events" @click="handleDayClick($event, day)">
|
<div
|
||||||
|
class="day-events"
|
||||||
|
@pointerdown="handlePointerDown($event, day)"
|
||||||
|
@pointermove="handlePointerMove($event, day)"
|
||||||
|
@pointerup="handlePointerUp($event, day)"
|
||||||
|
@pointercancel="timeSelection = null"
|
||||||
|
@mouseleave="hoveredTime = null"
|
||||||
|
>
|
||||||
<div
|
<div
|
||||||
v-for="entity in getTimedEvents(day)"
|
v-if="hoveredTime?.day === day.toISOString()"
|
||||||
:key="entity.identifier"
|
class="hover-time-indicator"
|
||||||
|
:style="{ top: `${hoveredTime.minutes / 1440 * 100}%` }"
|
||||||
|
>
|
||||||
|
<span>{{ formatHoverTime(hoveredTime.minutes) }}</span>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
v-if="timeSelection?.day === day.toISOString()"
|
||||||
|
class="time-selection-preview"
|
||||||
|
:style="selectionStyle(timeSelection)"
|
||||||
|
>
|
||||||
|
<span>{{ selectionLabel(timeSelection) }}</span>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
v-for="instance in getTimedEvents(day)"
|
||||||
|
:key="instance.key"
|
||||||
class="day-event"
|
class="day-event"
|
||||||
:style="getEventStyle(entity)"
|
:style="getEventStyle(instance)"
|
||||||
@click.stop="emit('event-click', entity)"
|
@pointerdown.stop
|
||||||
@mouseenter="emit('event-hover', { event: $event, entity })"
|
@click.stop="emit('event-click', instance.entity)"
|
||||||
|
@mouseenter="emit('event-hover', { event: $event, entity: instance.entity })"
|
||||||
@mouseleave="emit('event-hover-end')"
|
@mouseleave="emit('event-hover-end')"
|
||||||
>
|
>
|
||||||
<template v-if="daysCount <= 3">
|
<template v-if="daysCount <= 3">
|
||||||
<div class="event-time">
|
<div class="event-time">
|
||||||
{{ formatEventDateTime(getEventProperties(entity).startsOn) }} - {{ formatEventDateTime(getEventProperties(entity).endsOn) }}
|
{{ formatEpochTime(instance.start) }} - {{ formatEpochTime(instance.end) }}
|
||||||
</div>
|
</div>
|
||||||
<div class="event-title">{{ getEventProperties(entity).label || 'Untitled' }}</div>
|
<div class="event-title">{{ instance.label || 'Untitled' }}</div>
|
||||||
</template>
|
</template>
|
||||||
<template v-else>
|
<template v-else>
|
||||||
<div class="event-title-compact">{{ getEventProperties(entity).label || 'Untitled' }}</div>
|
<div class="event-title-compact">{{ instance.label || 'Untitled' }}</div>
|
||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -99,240 +321,12 @@
|
|||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
|
||||||
import { ref, computed, watch } from 'vue';
|
|
||||||
import {
|
|
||||||
startOfDay,
|
|
||||||
getMultiDaySegments,
|
|
||||||
getTimedEventsForDate,
|
|
||||||
type MultiDaySegment,
|
|
||||||
} from '@/utils/calendarHelpers';
|
|
||||||
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 { EventObject } from '@ChronoManager/models/event';
|
|
||||||
|
|
||||||
const ALL_DAY_EVENT_HEIGHT = 24;
|
|
||||||
|
|
||||||
type CalendarEntity = EntityObject;
|
|
||||||
type CalendarCollection = CollectionObject;
|
|
||||||
|
|
||||||
const props = defineProps<{
|
|
||||||
currentDate: Date;
|
|
||||||
events: CalendarEntity[];
|
|
||||||
calendars: CalendarCollection[];
|
|
||||||
initialSpan?: DaysViewSpan;
|
|
||||||
}>();
|
|
||||||
|
|
||||||
const selectedSpan = ref<DaysViewSpan>(props.initialSpan ?? '7d');
|
|
||||||
const daysCount = computed(() => spanToDays(selectedSpan.value));
|
|
||||||
const localDate = ref(new Date(props.currentDate));
|
|
||||||
|
|
||||||
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];
|
|
||||||
}>();
|
|
||||||
|
|
||||||
// Watch for external date changes (e.g., from mini calendar)
|
|
||||||
watch(() => props.currentDate, (newDate) => {
|
|
||||||
localDate.value = new Date(newDate);
|
|
||||||
});
|
|
||||||
|
|
||||||
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() {
|
|
||||||
const date = new Date(localDate.value);
|
|
||||||
date.setDate(date.getDate() - daysCount.value);
|
|
||||||
localDate.value = date;
|
|
||||||
}
|
|
||||||
|
|
||||||
function nextPeriod() {
|
|
||||||
const date = new Date(localDate.value);
|
|
||||||
date.setDate(date.getDate() + daysCount.value);
|
|
||||||
localDate.value = date;
|
|
||||||
}
|
|
||||||
|
|
||||||
function goToToday() {
|
|
||||||
localDate.value = 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(localDate.value);
|
|
||||||
|
|
||||||
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 = startOfDay(visibleDates.value[0]);
|
|
||||||
const rangeEnd = startOfDay(visibleDates.value[visibleDates.value.length - 1]);
|
|
||||||
|
|
||||||
const getColumnIndex = (date: Date): number => {
|
|
||||||
const targetDay = startOfDay(date);
|
|
||||||
return visibleDates.value.findIndex(d => startOfDay(d).getTime() === targetDay.getTime());
|
|
||||||
};
|
|
||||||
|
|
||||||
return getMultiDaySegments(props.events, rangeStart, rangeEnd, daysCount.value, getColumnIndex);
|
|
||||||
});
|
|
||||||
|
|
||||||
function isToday(date: Date): boolean {
|
|
||||||
const today = new Date();
|
|
||||||
return date.toDateString() === today.toDateString();
|
|
||||||
}
|
|
||||||
|
|
||||||
function getTimedEvents(date: Date): CalendarEntity[] {
|
|
||||||
return getTimedEventsForDate(props.events, date) as CalendarEntity[];
|
|
||||||
}
|
|
||||||
|
|
||||||
function getEventProperties(entity: CalendarEntity): EventObject {
|
|
||||||
return entity.properties as EventObject;
|
|
||||||
}
|
|
||||||
|
|
||||||
function getEventColor(entity: CalendarEntity): string {
|
|
||||||
const event = getEventProperties(entity);
|
|
||||||
if (event.color) return event.color;
|
|
||||||
const calendar = props.calendars.find(cal => cal.identifier === entity.collection);
|
|
||||||
return calendar?.properties?.color || '#1976D2';
|
|
||||||
}
|
|
||||||
|
|
||||||
function getAllDayEventStyle(segment: MultiDaySegment) {
|
|
||||||
return {
|
|
||||||
backgroundColor: getEventColor(segment.entity),
|
|
||||||
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(entity: CalendarEntity) {
|
|
||||||
const event = getEventProperties(entity);
|
|
||||||
|
|
||||||
if (!event.startsOn || !event.endsOn) {
|
|
||||||
return { display: 'none' };
|
|
||||||
}
|
|
||||||
const startTime = new Date(event.startsOn);
|
|
||||||
const endTime = new Date(event.endsOn);
|
|
||||||
const start = startTime.getHours() * 60 + startTime.getMinutes();
|
|
||||||
const duration = (endTime.getTime() - startTime.getTime()) / (1000 * 60);
|
|
||||||
|
|
||||||
return {
|
|
||||||
top: `${(start / 1440) * 100}%`,
|
|
||||||
height: `${(duration / 1440) * 100}%`,
|
|
||||||
backgroundColor: getEventColor(entity),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
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 formatEventDateTime(value: string | null): string {
|
|
||||||
return value ? formatTime(new Date(value)) : '';
|
|
||||||
}
|
|
||||||
|
|
||||||
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;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
min-height: 600px;
|
min-height: 0;
|
||||||
}
|
|
||||||
|
|
||||||
.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 {
|
||||||
@@ -569,6 +563,50 @@ function handleDayClick(event: MouseEvent, day: Date) {
|
|||||||
background-color: rgba(var(--v-theme-primary), 0.02);
|
background-color: rgba(var(--v-theme-primary), 0.02);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.hover-time-indicator {
|
||||||
|
position: absolute;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
z-index: 3;
|
||||||
|
height: 1px;
|
||||||
|
background-color: rgb(var(--v-theme-primary));
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hover-time-indicator span {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 4px;
|
||||||
|
padding: 1px 4px;
|
||||||
|
border-radius: 3px;
|
||||||
|
background-color: rgb(var(--v-theme-primary));
|
||||||
|
color: rgb(var(--v-theme-on-primary));
|
||||||
|
font-size: 10px;
|
||||||
|
line-height: 14px;
|
||||||
|
transform: translateY(-50%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.time-selection-preview {
|
||||||
|
position: absolute;
|
||||||
|
left: 3px;
|
||||||
|
right: 3px;
|
||||||
|
z-index: 4;
|
||||||
|
min-height: 15px;
|
||||||
|
padding: 3px 5px;
|
||||||
|
border: 1px solid rgb(var(--v-theme-primary));
|
||||||
|
border-radius: 4px;
|
||||||
|
background-color: rgba(var(--v-theme-primary), 0.2);
|
||||||
|
color: rgb(var(--v-theme-primary));
|
||||||
|
pointer-events: none;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.time-selection-preview span {
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 600;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
.day-event {
|
.day-event {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
left: 4px;
|
left: 4px;
|
||||||
|
|||||||
@@ -49,6 +49,10 @@ const calendarOptions = computed(() =>
|
|||||||
}))
|
}))
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const selectedCalendar = computed(() =>
|
||||||
|
(props.calendars || []).find(calendar => calendar.identifier === selectedCollectionId.value) ?? null
|
||||||
|
)
|
||||||
|
|
||||||
// Permissions
|
// Permissions
|
||||||
const isExternalEvent = computed(() => {
|
const isExternalEvent = computed(() => {
|
||||||
return false
|
return false
|
||||||
@@ -77,13 +81,12 @@ const cancelEdit = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const saveEntity = async () => {
|
const saveEntity = async () => {
|
||||||
if (!editableEntity.value) {
|
if (!editableEntity.value || !selectedCalendar.value) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
saving.value = true
|
saving.value = true
|
||||||
const targetCollection = (props.calendars || []).find(cal => cal.identifier === selectedCollectionId.value)
|
emit('save', editableEntity.value as EntityObject, selectedCalendar.value)
|
||||||
emit('save', editableEntity.value as EntityObject, targetCollection || props.collection)
|
|
||||||
saving.value = false
|
saving.value = false
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -127,7 +130,7 @@ watch(
|
|||||||
(newEntity) => {
|
(newEntity) => {
|
||||||
const entity = newEntity as EntityObject | null
|
const entity = newEntity as EntityObject | null
|
||||||
draftEntity.value = entity?.clone ? entity.clone() : null
|
draftEntity.value = entity?.clone ? entity.clone() : null
|
||||||
selectedCollectionId.value = entity?.collection || props.collection?.identifier || props.calendars?.[0]?.identifier || ''
|
selectedCollectionId.value = entity?.collection || props.collection?.identifier || ''
|
||||||
},
|
},
|
||||||
{ immediate: true },
|
{ immediate: true },
|
||||||
)
|
)
|
||||||
@@ -138,15 +141,15 @@ watch(
|
|||||||
const entity = props.entity as EntityObject | null
|
const entity = props.entity as EntityObject | null
|
||||||
if (newMode === 'edit' && entity?.clone) {
|
if (newMode === 'edit' && entity?.clone) {
|
||||||
draftEntity.value = entity.clone()
|
draftEntity.value = entity.clone()
|
||||||
selectedCollectionId.value = entity.collection || props.collection?.identifier || props.calendars?.[0]?.identifier || ''
|
selectedCollectionId.value = entity.collection || props.collection?.identifier || ''
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<v-sheet class="pa-2 text-start overflow-auto" min-height="84vh" max-height="84vh">
|
<v-sheet class="text-start overflow-auto" color="surface" min-height="84vh" max-height="84vh">
|
||||||
<div class="event-editor-header d-flex justify-space-between pa-4">
|
<v-sheet class="event-editor-header d-flex justify-space-between pa-4" color="surface">
|
||||||
<div>
|
<div>
|
||||||
<v-icon :icon="entityIcon" class="mr-2" />
|
<v-icon :icon="entityIcon" class="mr-2" />
|
||||||
{{ entityObject?.label || 'Nothing Selected' }}
|
{{ entityObject?.label || 'Nothing Selected' }}
|
||||||
@@ -177,7 +180,7 @@ watch(
|
|||||||
@click="$emit('close')"
|
@click="$emit('close')"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</v-sheet>
|
||||||
|
|
||||||
<div class="event-editor-fields">
|
<div class="event-editor-fields">
|
||||||
<v-divider />
|
<v-divider />
|
||||||
@@ -194,6 +197,7 @@ watch(
|
|||||||
v-model="selectedCollectionId"
|
v-model="selectedCollectionId"
|
||||||
:items="calendarOptions"
|
:items="calendarOptions"
|
||||||
label="Calendar"
|
label="Calendar"
|
||||||
|
:rules="[value => (value !== '' && value != null) || 'Calendar is required']"
|
||||||
variant="outlined"
|
variant="outlined"
|
||||||
density="compact"
|
density="compact"
|
||||||
class="mb-4"
|
class="mb-4"
|
||||||
@@ -211,6 +215,12 @@ watch(
|
|||||||
@update:description="entityObject!.description = $event"
|
@update:description="entityObject!.description = $event"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<EventEditorTags
|
||||||
|
:mode="mode"
|
||||||
|
:tags="entityObject!.tags"
|
||||||
|
@update:tags="entityObject!.tags = $event"
|
||||||
|
/>
|
||||||
|
|
||||||
<EventEditorDates
|
<EventEditorDates
|
||||||
:mode="mode"
|
:mode="mode"
|
||||||
:starts-on="entityObject!.startsOn"
|
:starts-on="entityObject!.startsOn"
|
||||||
@@ -223,6 +233,13 @@ watch(
|
|||||||
@update:time-zone="entityObject!.timeZone = $event"
|
@update:time-zone="entityObject!.timeZone = $event"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<EventEditorOccurrence
|
||||||
|
:mode="mode"
|
||||||
|
:pattern="entityObject!.pattern"
|
||||||
|
:starts-on="entityObject!.startsOn"
|
||||||
|
@update:pattern="entityObject!.pattern = $event"
|
||||||
|
/>
|
||||||
|
|
||||||
<EventEditorLocations
|
<EventEditorLocations
|
||||||
:mode="mode"
|
:mode="mode"
|
||||||
:locations-physical="entityObject!.locationsPhysical || {}"
|
:locations-physical="entityObject!.locationsPhysical || {}"
|
||||||
@@ -249,18 +266,6 @@ watch(
|
|||||||
@remove-notification="removeNotification"
|
@remove-notification="removeNotification"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<EventEditorOccurrence
|
|
||||||
:mode="mode"
|
|
||||||
:pattern="entityObject!.pattern"
|
|
||||||
@update:pattern="entityObject!.pattern = $event"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<EventEditorTags
|
|
||||||
:mode="mode"
|
|
||||||
:tags="entityObject!.tags"
|
|
||||||
@update:tags="entityObject!.tags = $event"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<!-- Metadata for view mode -->
|
<!-- Metadata for view mode -->
|
||||||
<div v-if="mode === 'view' && entityObject!.created" class="event-editor-section">
|
<div v-if="mode === 'view' && entityObject!.created" class="event-editor-section">
|
||||||
<v-divider class="mb-4"></v-divider>
|
<v-divider class="mb-4"></v-divider>
|
||||||
@@ -280,7 +285,7 @@ watch(
|
|||||||
<v-divider/>
|
<v-divider/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="event-editor-footer d-flex text-end justify-space-between pa-4">
|
<v-sheet class="event-editor-footer d-flex text-end justify-space-between pa-4" color="surface">
|
||||||
<v-btn v-if="mode === 'edit'"
|
<v-btn v-if="mode === 'edit'"
|
||||||
variant="text"
|
variant="text"
|
||||||
@click="cancelEdit"
|
@click="cancelEdit"
|
||||||
@@ -292,10 +297,11 @@ watch(
|
|||||||
color="primary"
|
color="primary"
|
||||||
variant="elevated"
|
variant="elevated"
|
||||||
@click="saveEntity"
|
@click="saveEntity"
|
||||||
|
:disabled="!selectedCalendar"
|
||||||
:loading="saving">
|
:loading="saving">
|
||||||
Save
|
Save
|
||||||
</v-btn>
|
</v-btn>
|
||||||
</div>
|
</v-sheet>
|
||||||
</v-sheet>
|
</v-sheet>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -303,4 +309,18 @@ watch(
|
|||||||
.event-editor-section {
|
.event-editor-section {
|
||||||
margin-bottom: 1.5rem;
|
margin-bottom: 1.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.event-editor-header,
|
||||||
|
.event-editor-footer {
|
||||||
|
position: sticky;
|
||||||
|
z-index: 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.event-editor-header {
|
||||||
|
top: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.event-editor-footer {
|
||||||
|
bottom: 0;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed } from 'vue'
|
import { computed } from 'vue'
|
||||||
|
import { parseCalendarDate } from '@/utils/date'
|
||||||
|
import { formatTime as formatClockTime } from '@/utils/format'
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
event: any | null
|
event: any | null
|
||||||
@@ -11,26 +13,12 @@ const emit = defineEmits<{
|
|||||||
'click': [event: any]
|
'click': [event: any]
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const formatDateTime = (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.toLocaleString('en-US', {
|
|
||||||
weekday: 'short',
|
|
||||||
month: 'short',
|
|
||||||
day: 'numeric',
|
|
||||||
year: 'numeric',
|
|
||||||
hour: 'numeric',
|
|
||||||
minute: '2-digit'
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const formatTime = (isoString: string | null | undefined): string => {
|
const formatAllDayDate = (isoString: string | null | undefined): string => {
|
||||||
if (!isoString) return 'Not set'
|
return parseCalendarDate(isoString)?.toLocaleDateString() ?? 'Not set'
|
||||||
const date = new Date(isoString)
|
|
||||||
return date.toLocaleTimeString('en-US', {
|
|
||||||
hour: 'numeric',
|
|
||||||
minute: '2-digit'
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const popupStyle = computed(() => {
|
const popupStyle = computed(() => {
|
||||||
@@ -83,7 +71,7 @@ const participantCount = computed(() => {
|
|||||||
<v-icon icon="mdi-clock-outline" size="small" class="mr-2 text-grey" />
|
<v-icon icon="mdi-clock-outline" size="small" class="mr-2 text-grey" />
|
||||||
<div class="text-caption">
|
<div class="text-caption">
|
||||||
<div v-if="event.properties?.timeless">
|
<div v-if="event.properties?.timeless">
|
||||||
All Day - {{ new Date(event.properties.startsOn).toLocaleDateString() }}
|
All Day - {{ formatAllDayDate(event.properties.startsOn) }}
|
||||||
</div>
|
</div>
|
||||||
<div v-else>
|
<div v-else>
|
||||||
{{ formatTime(event.properties?.startsOn) }} - {{ formatTime(event.properties?.endsOn) }}
|
{{ formatTime(event.properties?.startsOn) }} - {{ formatTime(event.properties?.endsOn) }}
|
||||||
|
|||||||
@@ -0,0 +1,63 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed } from 'vue'
|
||||||
|
import { storeToRefs } from 'pinia'
|
||||||
|
import { useImportStore } from '@ChronoManager/stores/importStore'
|
||||||
|
import { CollectionObject } from '@ChronoManager/models/collection'
|
||||||
|
|
||||||
|
const props = defineProps<{ modelValue: boolean; collections: CollectionObject[] }>()
|
||||||
|
const emit = defineEmits<{ 'update:modelValue': [value: boolean]; close: [refresh: boolean] }>()
|
||||||
|
const importStore = useImportStore()
|
||||||
|
const { files, sessions, order, stage, running, totals } = storeToRefs(importStore)
|
||||||
|
|
||||||
|
const collectionOptions = computed(() => props.collections.map(collection => ({ title: collection.properties.label || 'Unnamed', value: collection.identifier })))
|
||||||
|
const selecting = computed(() => stage.value === 'selecting')
|
||||||
|
const complete = computed(() => stage.value === 'completed' || stage.value === 'error')
|
||||||
|
const canImport = computed(() => files.value.length > 0 && files.value.every(entry => entry.collectionId !== null))
|
||||||
|
const progress = computed(() => totals.value.discovered ? Math.min(100, Math.round(totals.value.processed / totals.value.discovered * 100)) : 0)
|
||||||
|
const counters = computed(() => [
|
||||||
|
{ key: 'discovered', label: 'Discovered', value: totals.value.discovered, color: 'info' },
|
||||||
|
{ key: 'created', label: 'Created', value: totals.value.created, color: 'success' },
|
||||||
|
{ key: 'updated', label: 'Updated', value: totals.value.updated, color: 'info' },
|
||||||
|
{ key: 'exists', label: 'Skipped', value: totals.value.exists, color: 'warning' },
|
||||||
|
{ key: 'error', label: 'Errors', value: totals.value.error, color: 'error' },
|
||||||
|
])
|
||||||
|
|
||||||
|
async function startImport() { try { await importStore.startImport() } catch (error) { console.error('[Chrono] - Import failed:', error) } }
|
||||||
|
function cancel() { emit('update:modelValue', false); emit('close', false) }
|
||||||
|
function done() { emit('update:modelValue', false); emit('close', totals.value.created > 0 || totals.value.updated > 0) }
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<v-dialog :model-value="modelValue" max-width="640" persistent scrollable>
|
||||||
|
<v-card>
|
||||||
|
<v-card-title class="d-flex align-center"><v-icon icon="mdi-calendar-import" class="mr-2" />Import iCalendar Data</v-card-title>
|
||||||
|
<v-divider />
|
||||||
|
<v-card-text v-if="selecting" class="pt-4">
|
||||||
|
<p class="text-body-2 text-medium-emphasis mb-4">Choose a destination collection for each iCalendar file. Events, tasks, and journals are supported.</p>
|
||||||
|
<v-card v-for="entry in files" :key="entry.file.id" variant="tonal" class="mb-3 pa-3">
|
||||||
|
<div class="d-flex align-center mb-2"><v-icon icon="mdi-calendar-blank-outline" size="small" class="mr-2" /><span class="text-subtitle-2 text-truncate">{{ entry.file.name }}</span></div>
|
||||||
|
<v-select
|
||||||
|
:model-value="entry.collectionId" :items="collectionOptions" label="Calendar or task list"
|
||||||
|
density="compact" variant="outlined" hide-details
|
||||||
|
@update:model-value="importStore.setCollectionForFile(entry.file.id, $event)"
|
||||||
|
/>
|
||||||
|
</v-card>
|
||||||
|
</v-card-text>
|
||||||
|
<v-card-text v-else class="pt-4">
|
||||||
|
<div class="d-flex justify-space-between mb-1"><span>{{ complete ? 'Import complete' : 'Importing…' }}</span><span class="text-medium-emphasis">{{ totals.processed }} / {{ totals.discovered }}</span></div>
|
||||||
|
<v-progress-linear :model-value="progress" :indeterminate="running && totals.discovered === 0" color="primary" height="8" rounded class="mb-4" />
|
||||||
|
<div class="d-flex flex-wrap ga-2"><v-chip v-for="counter in counters" :key="counter.key" :color="counter.color" size="small" variant="tonal">{{ counter.label }}: {{ counter.value }}</v-chip></div>
|
||||||
|
<v-list v-if="order.length > 1" density="compact" class="mt-3 bg-transparent">
|
||||||
|
<v-list-item v-for="id in order" :key="id" :title="sessions[id]?.fileName" :subtitle="sessions[id]?.lastError ?? undefined">
|
||||||
|
<template #append><span class="text-caption">{{ sessions[id]?.counters.processed }} / {{ sessions[id]?.counters.discovered }}</span></template>
|
||||||
|
</v-list-item>
|
||||||
|
</v-list>
|
||||||
|
</v-card-text>
|
||||||
|
<v-divider />
|
||||||
|
<v-card-actions><v-spacer />
|
||||||
|
<template v-if="selecting"><v-btn variant="text" @click="cancel">Cancel</v-btn><v-btn color="primary" variant="flat" :disabled="!canImport" @click="startImport">Import</v-btn></template>
|
||||||
|
<v-btn v-else color="primary" variant="flat" :disabled="running" @click="done">Close</v-btn>
|
||||||
|
</v-card-actions>
|
||||||
|
</v-card>
|
||||||
|
</v-dialog>
|
||||||
|
</template>
|
||||||
+181
-196
@@ -1,90 +1,68 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, ref, watch, onMounted, onUnmounted } from 'vue';
|
import { computed, ref, onMounted, onUnmounted } from 'vue';
|
||||||
import {
|
import { layoutMultiDayLanes, type MultiDaySegment } from '@/utils/multiDayLanes';
|
||||||
startOfDay,
|
import { startOfLocalDay } from '@/utils/date';
|
||||||
daysDiff,
|
import { useChronoInstancesStore } from '@/stores/chronoInstancesStore';
|
||||||
isMultiDay,
|
import type { CalendarInstance } from '@/types/instance';
|
||||||
getSingleDayEventsForDate,
|
import ViewControls from './ViewControls.vue';
|
||||||
type MultiDaySegment,
|
import { formatEpochTime } from '@/utils/format';
|
||||||
} from '@/utils/calendarHelpers';
|
|
||||||
|
|
||||||
const EVENT_HEIGHT = 22; // Height of each event row in pixels
|
const EVENT_HEIGHT = 22; // Height of each event row in pixels
|
||||||
const MAX_VISIBLE_EVENTS = 3; // Maximum event rows to show before "+N more"
|
const DATE_ROW_HEIGHT = 34; // Space reserved at the top of each cell for the date label
|
||||||
|
|
||||||
|
interface DayCell {
|
||||||
|
date: Date;
|
||||||
|
currentMonth: boolean;
|
||||||
|
visibleInstances: CalendarInstance[];
|
||||||
|
hiddenCount: number;
|
||||||
|
}
|
||||||
|
|
||||||
interface WeekData {
|
interface WeekData {
|
||||||
startDate: Date;
|
startDate: Date;
|
||||||
days: { date: Date; currentMonth: boolean }[];
|
days: DayCell[];
|
||||||
multiDaySegments: MultiDaySegment[];
|
multiDaySegments: MultiDaySegment[];
|
||||||
laneCount: number;
|
laneCount: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
currentDate: Date;
|
currentDate: Date;
|
||||||
events: any[];
|
|
||||||
calendars: any[];
|
calendars: any[];
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
defineEmits<{
|
const instancesStore = useChronoInstancesStore();
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
'event-click': [event: any];
|
'event-click': [event: any];
|
||||||
'date-click': [date: Date];
|
'date-click': [date: Date];
|
||||||
'event-hover': [data: { event: MouseEvent; entity: any }];
|
'event-hover': [data: { event: MouseEvent; entity: any }];
|
||||||
'event-hover-end': [];
|
'event-hover-end': [];
|
||||||
|
'update:current-date': [date: Date];
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
const weekDays = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
|
const weekDays = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
|
||||||
|
|
||||||
// Local date for navigation
|
// Track the calendar body size so each week row shows as many event rows as fit
|
||||||
const localDate = ref(new Date(props.currentDate));
|
const bodyRef = ref<HTMLElement | null>(null);
|
||||||
|
const bodyHeight = ref(600);
|
||||||
// Watch for external date changes (e.g., from mini calendar)
|
let bodyResizeObserver: ResizeObserver | null = null;
|
||||||
watch(() => props.currentDate, (newDate) => {
|
|
||||||
localDate.value = new Date(newDate);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Navigation functions
|
|
||||||
function previousMonth() {
|
|
||||||
const date = new Date(localDate.value);
|
|
||||||
date.setMonth(date.getMonth() - 1);
|
|
||||||
localDate.value = date;
|
|
||||||
}
|
|
||||||
|
|
||||||
function nextMonth() {
|
|
||||||
const date = new Date(localDate.value);
|
|
||||||
date.setMonth(date.getMonth() + 1);
|
|
||||||
localDate.value = date;
|
|
||||||
}
|
|
||||||
|
|
||||||
function goToToday() {
|
|
||||||
localDate.value = new Date();
|
|
||||||
}
|
|
||||||
|
|
||||||
const monthLabel = computed(() => {
|
|
||||||
return localDate.value.toLocaleDateString('en-US', { month: 'long', year: 'numeric' });
|
|
||||||
});
|
|
||||||
|
|
||||||
// Track container size for dynamic calculations
|
|
||||||
const containerRef = ref<HTMLElement | null>(null);
|
|
||||||
const containerHeight = ref(600);
|
|
||||||
|
|
||||||
function updateContainerHeight() {
|
|
||||||
if (containerRef.value) {
|
|
||||||
containerHeight.value = containerRef.value.clientHeight;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
updateContainerHeight();
|
bodyResizeObserver = new ResizeObserver(entries => {
|
||||||
window.addEventListener('resize', updateContainerHeight);
|
bodyHeight.value = entries[0].contentRect.height;
|
||||||
|
});
|
||||||
|
if (bodyRef.value) {
|
||||||
|
bodyResizeObserver.observe(bodyRef.value);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
onUnmounted(() => {
|
onUnmounted(() => {
|
||||||
window.removeEventListener('resize', updateContainerHeight);
|
bodyResizeObserver?.disconnect();
|
||||||
});
|
});
|
||||||
|
|
||||||
// Calculate how many weeks are needed for this month
|
// Calculate how many weeks are needed for this month
|
||||||
const weeksNeeded = computed(() => {
|
const weeksNeeded = computed(() => {
|
||||||
const year = localDate.value.getFullYear();
|
const year = props.currentDate.getFullYear();
|
||||||
const month = localDate.value.getMonth();
|
const month = props.currentDate.getMonth();
|
||||||
const firstDay = new Date(year, month, 1);
|
const firstDay = new Date(year, month, 1);
|
||||||
const lastDay = new Date(year, month + 1, 0);
|
const lastDay = new Date(year, month + 1, 0);
|
||||||
|
|
||||||
@@ -95,10 +73,16 @@ const weeksNeeded = computed(() => {
|
|||||||
return Math.ceil((daysBeforeFirst + daysInMonth) / 7);
|
return Math.ceil((daysBeforeFirst + daysInMonth) / 7);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Event rows that fit in one week row, given the available height
|
||||||
|
const maxEventRows = computed(() => {
|
||||||
|
const weekHeight = bodyHeight.value / weeksNeeded.value;
|
||||||
|
return Math.max(1, Math.floor((weekHeight - DATE_ROW_HEIGHT) / EVENT_HEIGHT));
|
||||||
|
});
|
||||||
|
|
||||||
// Build weeks with multiday event segments
|
// Build weeks with multiday event segments
|
||||||
const weeks = computed<WeekData[]>(() => {
|
const weeks = computed<WeekData[]>(() => {
|
||||||
const year = localDate.value.getFullYear();
|
const year = props.currentDate.getFullYear();
|
||||||
const month = localDate.value.getMonth();
|
const month = props.currentDate.getMonth();
|
||||||
const firstDay = new Date(year, month, 1);
|
const firstDay = new Date(year, month, 1);
|
||||||
const gridStart = new Date(firstDay);
|
const gridStart = new Date(firstDay);
|
||||||
gridStart.setDate(gridStart.getDate() - gridStart.getDay());
|
gridStart.setDate(gridStart.getDate() - gridStart.getDay());
|
||||||
@@ -109,140 +93,108 @@ const weeks = computed<WeekData[]>(() => {
|
|||||||
const numWeeks = weeksNeeded.value;
|
const numWeeks = weeksNeeded.value;
|
||||||
|
|
||||||
for (let w = 0; w < numWeeks; w++) {
|
for (let w = 0; w < numWeeks; w++) {
|
||||||
const weekStart = startOfDay(new Date(current));
|
const weekStart = startOfLocalDay(new Date(current));
|
||||||
const weekEnd = new Date(weekStart);
|
const weekEnd = new Date(weekStart);
|
||||||
weekEnd.setDate(weekEnd.getDate() + 6);
|
weekEnd.setDate(weekEnd.getDate() + 6);
|
||||||
|
|
||||||
const days: { date: Date; currentMonth: boolean }[] = [];
|
const baseDays: { date: Date; currentMonth: boolean }[] = [];
|
||||||
for (let d = 0; d < 7; d++) {
|
for (let d = 0; d < 7; d++) {
|
||||||
days.push({
|
baseDays.push({
|
||||||
date: new Date(current),
|
date: new Date(current),
|
||||||
currentMonth: current.getMonth() === month,
|
currentMonth: current.getMonth() === month,
|
||||||
});
|
});
|
||||||
current.setDate(current.getDate() + 1);
|
current.setDate(current.getDate() + 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get multiday segments for this week
|
// Get multiday segments for this week
|
||||||
const { segments, laneCount } = getMultiDaySegments(weekStart, weekEnd);
|
const instances = instancesStore.instancesForRange({
|
||||||
|
start: weekStart.getTime(),
|
||||||
|
end: new Date(
|
||||||
|
weekEnd.getFullYear(),
|
||||||
|
weekEnd.getMonth(),
|
||||||
|
weekEnd.getDate() + 1,
|
||||||
|
).getTime(),
|
||||||
|
});
|
||||||
|
const { segments, laneCount } = layoutMultiDayLanes(
|
||||||
|
instances,
|
||||||
|
weekStart,
|
||||||
|
weekEnd,
|
||||||
|
7,
|
||||||
|
date => date.getDay(),
|
||||||
|
);
|
||||||
|
|
||||||
|
// When lanes overflow, drop the last one(s) so a "+N more" row fits below
|
||||||
|
const maxRows = maxEventRows.value;
|
||||||
|
const visibleLaneCount = laneCount > maxRows ? Math.max(0, maxRows - 1) : laneCount;
|
||||||
|
const visibleSegments = segments.filter(segment => segment.lane < visibleLaneCount);
|
||||||
|
const hiddenSegments = segments.filter(segment => segment.lane >= visibleLaneCount);
|
||||||
|
|
||||||
|
const availableRows = Math.max(0, maxRows - visibleLaneCount);
|
||||||
|
const days = baseDays.map((day, dayIndex) => {
|
||||||
|
const singleDayEvents = getSingleDayEvents(day.date);
|
||||||
|
const hiddenMultiDay = hiddenSegments.filter(
|
||||||
|
segment => dayIndex >= segment.startCol && dayIndex < segment.startCol + segment.span,
|
||||||
|
).length;
|
||||||
|
|
||||||
|
if (hiddenMultiDay === 0 && singleDayEvents.length <= availableRows) {
|
||||||
|
return { ...day, visibleInstances: singleDayEvents, hiddenCount: 0 };
|
||||||
|
}
|
||||||
|
|
||||||
|
const visibleInstances = singleDayEvents.slice(0, Math.max(0, availableRows - 1));
|
||||||
|
return {
|
||||||
|
...day,
|
||||||
|
visibleInstances,
|
||||||
|
hiddenCount: hiddenMultiDay + singleDayEvents.length - visibleInstances.length,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
weeksData.push({
|
weeksData.push({
|
||||||
startDate: weekStart,
|
startDate: weekStart,
|
||||||
days,
|
days,
|
||||||
multiDaySegments: segments,
|
multiDaySegments: visibleSegments,
|
||||||
laneCount: Math.min(laneCount, MAX_VISIBLE_EVENTS),
|
laneCount: visibleLaneCount,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
return weeksData;
|
return weeksData;
|
||||||
});
|
});
|
||||||
|
|
||||||
function getMultiDaySegments(weekStart: Date, weekEnd: Date): { segments: MultiDaySegment[]; laneCount: number } {
|
// Timed events for a specific date; timeless instances (all-day and
|
||||||
const segments: MultiDaySegment[] = [];
|
// multi-day) render as lane bars via layoutMultiDayLanes instead
|
||||||
|
function getSingleDayEvents(date: Date): CalendarInstance[] {
|
||||||
// Filter multiday events that overlap this week
|
return instancesStore.instancesForDay(date).filter(instance => !instance.timeless);
|
||||||
const multiDayEvents = props.events.filter(entity => {
|
|
||||||
if (!entity.properties?.startsOn || !isMultiDay(entity)) return false;
|
|
||||||
const eventStart = startOfDay(new Date(entity.properties.startsOn));
|
|
||||||
const eventEnd = startOfDay(new Date(entity.properties.endsOn));
|
|
||||||
return eventStart <= weekEnd && eventEnd >= weekStart;
|
|
||||||
});
|
|
||||||
|
|
||||||
// Sort: longer events first, then by start date
|
|
||||||
multiDayEvents.sort((a, b) => {
|
|
||||||
const aStart = new Date(a.properties.startsOn);
|
|
||||||
const aEnd = new Date(a.properties.endsOn);
|
|
||||||
const bStart = new Date(b.properties.startsOn);
|
|
||||||
const bEnd = new Date(b.properties.endsOn);
|
|
||||||
const aDuration = daysDiff(aStart, aEnd);
|
|
||||||
const bDuration = daysDiff(bStart, bEnd);
|
|
||||||
if (bDuration !== aDuration) return bDuration - aDuration;
|
|
||||||
return aStart.getTime() - bStart.getTime();
|
|
||||||
});
|
|
||||||
|
|
||||||
// Lane assignment
|
|
||||||
const lanes: boolean[][] = []; // lanes[lane][dayOfWeek] = occupied
|
|
||||||
|
|
||||||
for (const entity of multiDayEvents) {
|
|
||||||
const eventStart = startOfDay(new Date(entity.properties.startsOn));
|
|
||||||
const eventEnd = startOfDay(new Date(entity.properties.endsOn));
|
|
||||||
|
|
||||||
// Clamp to this week
|
|
||||||
const segStart = eventStart < weekStart ? weekStart : eventStart;
|
|
||||||
const segEnd = eventEnd > weekEnd ? weekEnd : eventEnd;
|
|
||||||
|
|
||||||
const startCol = segStart.getDay();
|
|
||||||
const endCol = segEnd.getDay();
|
|
||||||
const span = endCol - startCol + 1;
|
|
||||||
|
|
||||||
// Find available lane
|
|
||||||
let lane = 0;
|
|
||||||
while (true) {
|
|
||||||
if (!lanes[lane]) lanes[lane] = [false, false, false, false, false, false, false];
|
|
||||||
|
|
||||||
let isFree = true;
|
|
||||||
for (let d = startCol; d <= endCol; d++) {
|
|
||||||
if (lanes[lane][d]) { isFree = false; break; }
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isFree) {
|
|
||||||
for (let d = startCol; d <= endCol; d++) lanes[lane][d] = true;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
lane++;
|
|
||||||
}
|
|
||||||
|
|
||||||
segments.push({
|
|
||||||
entity,
|
|
||||||
startCol,
|
|
||||||
span,
|
|
||||||
lane,
|
|
||||||
isStart: eventStart >= weekStart,
|
|
||||||
isEnd: eventEnd <= weekEnd,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return { segments, laneCount: lanes.length };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Single-day events for a specific date
|
// "+N more" popover listing all of a day's events
|
||||||
function getSingleDayEvents(date: Date): any[] {
|
function morePopoverLabel(date: Date): string {
|
||||||
return getSingleDayEventsForDate(props.events, date);
|
return date.toLocaleDateString('en-US', {
|
||||||
|
weekday: 'short',
|
||||||
|
month: 'short',
|
||||||
|
day: 'numeric',
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Count hidden events (multiday beyond MAX + single day beyond remaining space)
|
|
||||||
function getHiddenCount(cell: { date: Date }, weekLaneCount: number): number {
|
|
||||||
const singleDayEvents = getSingleDayEvents(cell.date);
|
|
||||||
const visibleSingleDay = Math.max(0, MAX_VISIBLE_EVENTS - weekLaneCount);
|
|
||||||
const hiddenSingleDay = Math.max(0, singleDayEvents.length - visibleSingleDay);
|
|
||||||
|
|
||||||
// Also count multiday events in lanes beyond MAX_VISIBLE_EVENTS for this day
|
|
||||||
// (This would need more complex tracking - simplified for now)
|
|
||||||
return hiddenSingleDay;
|
|
||||||
}
|
|
||||||
|
|
||||||
function isToday(date: Date): boolean {
|
function isToday(date: Date): boolean {
|
||||||
const today = new Date();
|
const today = new Date();
|
||||||
return date.toDateString() === today.toDateString();
|
return date.toDateString() === today.toDateString();
|
||||||
}
|
}
|
||||||
|
|
||||||
function getEventColor(entity: any): string {
|
function getEventColor(instance: CalendarInstance): string {
|
||||||
if (entity.properties?.color) return entity.properties.color;
|
if (instance.color) return instance.color;
|
||||||
const calendar = props.calendars.find(cal => cal.identifier === entity.collection);
|
const calendar = props.calendars.find(cal => cal.identifier === instance.entity.collection);
|
||||||
return calendar?.properties?.color || '#1976D2';
|
return calendar?.properties?.color || '#1976D2';
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="month-view" ref="containerRef">
|
<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">
|
||||||
@@ -252,7 +204,7 @@ function getEventColor(entity: any): string {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Week rows -->
|
<!-- Week rows -->
|
||||||
<div class="calendar-body">
|
<div class="calendar-body" ref="bodyRef">
|
||||||
<div v-for="(week, weekIndex) in weeks" :key="weekIndex" class="calendar-week">
|
<div v-for="(week, weekIndex) in weeks" :key="weekIndex" class="calendar-week">
|
||||||
<!-- Base layer: Day cells grid -->
|
<!-- Base layer: Day cells grid -->
|
||||||
<div class="days-grid">
|
<div class="days-grid">
|
||||||
@@ -275,22 +227,55 @@ function getEventColor(entity: any): string {
|
|||||||
<!-- Single-day events -->
|
<!-- Single-day events -->
|
||||||
<div class="cell-events">
|
<div class="cell-events">
|
||||||
<div
|
<div
|
||||||
v-for="entity in getSingleDayEvents(cell.date).slice(0, MAX_VISIBLE_EVENTS - week.laneCount)"
|
v-for="instance in cell.visibleInstances"
|
||||||
:key="entity.identifier"
|
:key="instance.key"
|
||||||
class="single-day-event"
|
class="single-day-event"
|
||||||
:style="{ backgroundColor: getEventColor(entity) }"
|
:style="{ backgroundColor: getEventColor(instance) }"
|
||||||
@click.stop="$emit('event-click', entity)"
|
@click.stop="$emit('event-click', instance.entity)"
|
||||||
@mouseenter="$emit('event-hover', { event: $event, entity })"
|
@mouseenter="$emit('event-hover', { event: $event, entity: instance.entity })"
|
||||||
@mouseleave="$emit('event-hover-end')"
|
@mouseleave="$emit('event-hover-end')"
|
||||||
>
|
>
|
||||||
{{ entity.properties?.label || 'Untitled' }}
|
{{ instance.label || 'Untitled' }}
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
v-if="getHiddenCount(cell, week.laneCount) > 0"
|
v-if="cell.hiddenCount > 0"
|
||||||
class="more-events"
|
class="more-events"
|
||||||
@click.stop="$emit('date-click', cell.date)"
|
@click.stop
|
||||||
>
|
>
|
||||||
+{{ getHiddenCount(cell, week.laneCount) }} more
|
+{{ cell.hiddenCount }} more
|
||||||
|
<v-menu
|
||||||
|
activator="parent"
|
||||||
|
open-on-hover
|
||||||
|
open-on-click
|
||||||
|
:open-delay="150"
|
||||||
|
:close-delay="200"
|
||||||
|
location="bottom"
|
||||||
|
:offset="4"
|
||||||
|
>
|
||||||
|
<v-card min-width="220" max-width="320">
|
||||||
|
<div class="more-popover-header">{{ morePopoverLabel(cell.date) }}</div>
|
||||||
|
<v-list density="compact">
|
||||||
|
<v-list-item
|
||||||
|
v-for="instance in instancesStore.instancesForDay(cell.date)"
|
||||||
|
:key="instance.key"
|
||||||
|
@click="$emit('event-click', instance.entity)"
|
||||||
|
>
|
||||||
|
<template #prepend>
|
||||||
|
<span
|
||||||
|
class="more-popover-dot"
|
||||||
|
:style="{ backgroundColor: getEventColor(instance) }"
|
||||||
|
></span>
|
||||||
|
</template>
|
||||||
|
<v-list-item-title>
|
||||||
|
<span v-if="!instance.timeless" class="more-popover-time">
|
||||||
|
{{ formatEpochTime(instance.start) }}
|
||||||
|
</span>
|
||||||
|
{{ instance.label || 'Untitled' }}
|
||||||
|
</v-list-item-title>
|
||||||
|
</v-list-item>
|
||||||
|
</v-list>
|
||||||
|
</v-card>
|
||||||
|
</v-menu>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -300,24 +285,24 @@ function getEventColor(entity: any): string {
|
|||||||
<div class="multiday-overlay">
|
<div class="multiday-overlay">
|
||||||
<div
|
<div
|
||||||
v-for="segment in week.multiDaySegments"
|
v-for="segment in week.multiDaySegments"
|
||||||
:key="`${segment.entity.identifier}-${weekIndex}`"
|
:key="`${segment.instance.key}-${weekIndex}`"
|
||||||
class="multiday-event"
|
class="multiday-event"
|
||||||
:class="{
|
:class="{
|
||||||
'is-start': segment.isStart,
|
'is-start': segment.isStart,
|
||||||
'is-end': segment.isEnd,
|
'is-end': segment.isEnd,
|
||||||
}"
|
}"
|
||||||
:style="{
|
:style="{
|
||||||
backgroundColor: getEventColor(segment.entity),
|
backgroundColor: getEventColor(segment.instance),
|
||||||
left: `calc(${segment.startCol} / 7 * 100% + 4px)`,
|
left: `calc(${segment.startCol} / 7 * 100% + 4px)`,
|
||||||
width: `calc(${segment.span} / 7 * 100% - 8px)`,
|
width: `calc(${segment.span} / 7 * 100% - 8px)`,
|
||||||
top: `${34 + segment.lane * EVENT_HEIGHT}px`,
|
top: `${DATE_ROW_HEIGHT + segment.lane * EVENT_HEIGHT}px`,
|
||||||
}"
|
}"
|
||||||
@click.stop="$emit('event-click', segment.entity)"
|
@click.stop="$emit('event-click', segment.instance.entity)"
|
||||||
@mouseenter="$emit('event-hover', { event: $event, entity: segment.entity })"
|
@mouseenter="$emit('event-hover', { event: $event, entity: segment.instance.entity })"
|
||||||
@mouseleave="$emit('event-hover-end')"
|
@mouseleave="$emit('event-hover-end')"
|
||||||
>
|
>
|
||||||
<span v-if="segment.isStart" class="event-label">
|
<span v-if="segment.isStart" class="event-label">
|
||||||
{{ segment.entity.properties?.label || 'Untitled' }}
|
{{ segment.instance.label || 'Untitled' }}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -336,28 +321,6 @@ function getEventColor(entity: any): 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);
|
||||||
@@ -548,4 +511,26 @@ function getEventColor(entity: any): string {
|
|||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* "+N more" popover */
|
||||||
|
.more-popover-header {
|
||||||
|
padding: 8px 16px 0;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.more-popover-time {
|
||||||
|
font-size: 11px;
|
||||||
|
opacity: 0.7;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
margin-right: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.more-popover-dot {
|
||||||
|
width: 10px;
|
||||||
|
height: 10px;
|
||||||
|
border-radius: 50%;
|
||||||
|
margin-right: 8px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -0,0 +1,181 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, ref } from 'vue';
|
||||||
|
import { storeToRefs } from 'pinia';
|
||||||
|
import { useDisplay } from 'vuetify';
|
||||||
|
import { useChronoOperationsStore } from '@/stores/chronoOperationsStore';
|
||||||
|
import { useChronoUiStore } from '@/stores/chronoUiStore';
|
||||||
|
import CollectionList from '@/components/CollectionList.vue';
|
||||||
|
import MiniCalendar from '@/components/MiniCalendar.vue';
|
||||||
|
|
||||||
|
const display = useDisplay();
|
||||||
|
const isMobile = computed(() => display.mdAndDown.value);
|
||||||
|
|
||||||
|
const chronoOperationsStore = useChronoOperationsStore();
|
||||||
|
const chronoUiStore = useChronoUiStore();
|
||||||
|
|
||||||
|
const { collections } = storeToRefs(chronoOperationsStore);
|
||||||
|
const {
|
||||||
|
sidebarVisible,
|
||||||
|
selectedCollection,
|
||||||
|
currentDate,
|
||||||
|
isTasksSection,
|
||||||
|
} = storeToRefs(chronoUiStore);
|
||||||
|
|
||||||
|
const {
|
||||||
|
selectCollection,
|
||||||
|
openCollectionEditor,
|
||||||
|
createEvent,
|
||||||
|
createTask,
|
||||||
|
openImport,
|
||||||
|
} = chronoUiStore;
|
||||||
|
|
||||||
|
const { toggleCalendarVisibility } = chronoOperationsStore;
|
||||||
|
|
||||||
|
// iCalendar import file picker
|
||||||
|
const fileInputRef = ref<HTMLInputElement | null>(null);
|
||||||
|
|
||||||
|
function handleImportStart() {
|
||||||
|
fileInputRef.value?.click();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleImportSelect(event: Event) {
|
||||||
|
const input = event.target as HTMLInputElement;
|
||||||
|
const files = Array.from(input.files ?? []);
|
||||||
|
input.value = '';
|
||||||
|
if (files.length) await openImport(files);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Settings placeholder (no settings dialog exists for this module yet)
|
||||||
|
const showSettingsDialog = ref(false);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<v-navigation-drawer
|
||||||
|
v-model="sidebarVisible"
|
||||||
|
contained
|
||||||
|
:permanent="sidebarVisible && !isMobile"
|
||||||
|
:temporary="isMobile"
|
||||||
|
width="280"
|
||||||
|
class="chrono-sidebar"
|
||||||
|
>
|
||||||
|
<div class="pa-3 d-flex flex-column h-100">
|
||||||
|
<!-- Primary action -->
|
||||||
|
<v-menu>
|
||||||
|
<template #activator="{ props: menuProps }">
|
||||||
|
<v-btn
|
||||||
|
block
|
||||||
|
color="primary"
|
||||||
|
variant="tonal"
|
||||||
|
prepend-icon="mdi-plus"
|
||||||
|
append-icon="mdi-chevron-down"
|
||||||
|
class="mb-4 justify-start"
|
||||||
|
v-bind="menuProps"
|
||||||
|
>
|
||||||
|
{{ isTasksSection ? 'New Task' : 'New Event' }}
|
||||||
|
</v-btn>
|
||||||
|
</template>
|
||||||
|
<v-list density="compact">
|
||||||
|
<v-list-item @click="createEvent()">
|
||||||
|
<template #prepend>
|
||||||
|
<v-icon icon="mdi-calendar-plus" />
|
||||||
|
</template>
|
||||||
|
<v-list-item-title>Event</v-list-item-title>
|
||||||
|
</v-list-item>
|
||||||
|
<v-list-item @click="createTask()">
|
||||||
|
<template #prepend>
|
||||||
|
<v-icon icon="mdi-checkbox-marked-circle-plus-outline" />
|
||||||
|
</template>
|
||||||
|
<v-list-item-title>Task</v-list-item-title>
|
||||||
|
</v-list-item>
|
||||||
|
</v-list>
|
||||||
|
</v-menu>
|
||||||
|
|
||||||
|
<!-- Collections -->
|
||||||
|
<CollectionList
|
||||||
|
v-if="!isTasksSection"
|
||||||
|
:collections="collections"
|
||||||
|
:selected-collection="selectedCollection"
|
||||||
|
type="calendar"
|
||||||
|
@create="openCollectionEditor('calendar')"
|
||||||
|
@select="selectCollection"
|
||||||
|
@edit="openCollectionEditor('calendar', $event)"
|
||||||
|
@toggle-visibility="toggleCalendarVisibility"
|
||||||
|
/>
|
||||||
|
<CollectionList
|
||||||
|
v-else
|
||||||
|
:collections="collections"
|
||||||
|
:selected-collection="selectedCollection"
|
||||||
|
type="tasklist"
|
||||||
|
@create="openCollectionEditor('tasklist')"
|
||||||
|
@select="selectCollection"
|
||||||
|
@edit="openCollectionEditor('tasklist', $event)"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- Mini Calendar Widget -->
|
||||||
|
<v-card v-if="!isTasksSection" class="mt-4 flex-shrink-0" variant="outlined">
|
||||||
|
<v-card-text class="pa-2">
|
||||||
|
<MiniCalendar v-model="currentDate" />
|
||||||
|
</v-card-text>
|
||||||
|
</v-card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Pinned utility footer -->
|
||||||
|
<template #append>
|
||||||
|
<v-divider />
|
||||||
|
<div class="pa-2">
|
||||||
|
<v-btn
|
||||||
|
block
|
||||||
|
variant="text"
|
||||||
|
size="small"
|
||||||
|
prepend-icon="mdi-calendar-import"
|
||||||
|
class="justify-start text-medium-emphasis"
|
||||||
|
@click="handleImportStart"
|
||||||
|
>
|
||||||
|
Import iCalendar
|
||||||
|
</v-btn>
|
||||||
|
<v-btn
|
||||||
|
block
|
||||||
|
variant="text"
|
||||||
|
size="small"
|
||||||
|
prepend-icon="mdi-cog"
|
||||||
|
class="justify-start text-medium-emphasis"
|
||||||
|
@click="showSettingsDialog = true"
|
||||||
|
>
|
||||||
|
Settings
|
||||||
|
</v-btn>
|
||||||
|
<input
|
||||||
|
ref="fileInputRef"
|
||||||
|
type="file"
|
||||||
|
accept=".ics,text/calendar"
|
||||||
|
multiple
|
||||||
|
class="d-none"
|
||||||
|
@change="handleImportSelect"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</v-navigation-drawer>
|
||||||
|
|
||||||
|
<!-- Settings placeholder dialog -->
|
||||||
|
<v-dialog v-model="showSettingsDialog" max-width="480">
|
||||||
|
<v-card>
|
||||||
|
<v-card-title class="d-flex align-center">
|
||||||
|
<v-icon icon="mdi-cog" size="small" class="mr-2" />
|
||||||
|
Chrono Settings
|
||||||
|
</v-card-title>
|
||||||
|
<v-card-text class="text-medium-emphasis">
|
||||||
|
No settings are available for this module yet.
|
||||||
|
</v-card-text>
|
||||||
|
<v-card-actions>
|
||||||
|
<v-spacer />
|
||||||
|
<v-btn variant="text" @click="showSettingsDialog = false">Close</v-btn>
|
||||||
|
</v-card-actions>
|
||||||
|
</v-card>
|
||||||
|
</v-dialog>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.chrono-sidebar {
|
||||||
|
border-right: 1px solid rgb(var(--v-border-color)) !important;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -34,6 +34,7 @@
|
|||||||
v-model="selectedCollectionId"
|
v-model="selectedCollectionId"
|
||||||
:items="listOptions"
|
:items="listOptions"
|
||||||
label="Task List"
|
label="Task List"
|
||||||
|
:rules="[value => (value !== '' && value != null) || 'Task list is required']"
|
||||||
:readonly="!isEditing || !canChangeCollection"
|
:readonly="!isEditing || !canChangeCollection"
|
||||||
:variant="isEditing && canChangeCollection ? 'outlined' : 'plain'"
|
:variant="isEditing && canChangeCollection ? 'outlined' : 'plain'"
|
||||||
class="mb-4"
|
class="mb-4"
|
||||||
@@ -243,13 +244,13 @@ watch(() => props.entity, (newEntity) => {
|
|||||||
startDate.value = '';
|
startDate.value = '';
|
||||||
}
|
}
|
||||||
|
|
||||||
selectedCollectionId.value = draftEntity.value?.collection || props.collection?.identifier || props.lists[0]?.identifier || '';
|
selectedCollectionId.value = draftEntity.value?.collection || props.collection?.identifier || '';
|
||||||
}, { immediate: true });
|
}, { immediate: true });
|
||||||
|
|
||||||
watch(() => props.mode, (newMode) => {
|
watch(() => props.mode, (newMode) => {
|
||||||
if (newMode === 'edit' && props.entity) {
|
if (newMode === 'edit' && props.entity) {
|
||||||
draftEntity.value = cloneEntity(props.entity);
|
draftEntity.value = cloneEntity(props.entity);
|
||||||
selectedCollectionId.value = draftEntity.value?.collection || props.collection?.identifier || props.lists[0]?.identifier || '';
|
selectedCollectionId.value = draftEntity.value?.collection || props.collection?.identifier || '';
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -260,6 +261,10 @@ const listOptions = computed(() =>
|
|||||||
}))
|
}))
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const selectedTaskList = computed(() =>
|
||||||
|
props.lists.find(list => list.identifier === selectedCollectionId.value) ?? null
|
||||||
|
);
|
||||||
|
|
||||||
const priorityOptions = [
|
const priorityOptions = [
|
||||||
{ title: 'Low', value: 3 },
|
{ title: 'Low', value: 3 },
|
||||||
{ title: 'Medium', value: 2 },
|
{ title: 'Medium', value: 2 },
|
||||||
@@ -288,8 +293,9 @@ async function handleSave() {
|
|||||||
const { valid } = await formRef.value.validate();
|
const { valid } = await formRef.value.validate();
|
||||||
if (!valid) return;
|
if (!valid) return;
|
||||||
|
|
||||||
const targetCollection = props.lists.find(list => list.identifier === selectedCollectionId.value);
|
if (!selectedTaskList.value) return;
|
||||||
emit('save', editableEntity.value as EntityObject, targetCollection || props.collection);
|
|
||||||
|
emit('save', editableEntity.value as EntityObject, selectedTaskList.value);
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleCancel() {
|
function handleCancel() {
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, watch } from 'vue'
|
||||||
|
import { formatDisplayDate, formatDisplayTime, formatLocalIsoDate, formatLocalIsoTime } from '@/utils/format'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
modelValue?: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = defineProps<Props>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
'update:modelValue': [value: string | null]
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const dateValue = ref('')
|
||||||
|
const timeValue = ref('')
|
||||||
|
const dateMenu = ref(false)
|
||||||
|
const timeMenu = ref(false)
|
||||||
|
|
||||||
|
watch(() => props.modelValue, (value) => {
|
||||||
|
const date = value ? new Date(value) : new Date()
|
||||||
|
dateValue.value = formatLocalIsoDate(date)
|
||||||
|
timeValue.value = formatLocalIsoTime(date)
|
||||||
|
}, { immediate: true })
|
||||||
|
|
||||||
|
const emitValue = () => {
|
||||||
|
if (!dateValue.value || !timeValue.value) return
|
||||||
|
emit('update:modelValue', new Date(`${dateValue.value}T${timeValue.value}`).toISOString())
|
||||||
|
}
|
||||||
|
|
||||||
|
const pickerDate = (value: string): Date | null => value ? new Date(`${value}T00:00:00`) : null
|
||||||
|
|
||||||
|
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()) ? '' : formatLocalIsoDate(date)
|
||||||
|
}
|
||||||
|
|
||||||
|
const selectDate = (value: unknown) => {
|
||||||
|
const date = normalizePickerDate(value)
|
||||||
|
if (date) dateValue.value = date
|
||||||
|
dateMenu.value = false
|
||||||
|
emitValue()
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(timeValue, () => emitValue())
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<v-row dense>
|
||||||
|
<v-col cols="6">
|
||||||
|
<v-menu v-model="dateMenu" :close-on-content-click="false" location="bottom start">
|
||||||
|
<template #activator="{ props: activatorProps }">
|
||||||
|
<v-text-field
|
||||||
|
v-bind="activatorProps"
|
||||||
|
:model-value="formatDisplayDate(dateValue)"
|
||||||
|
label="Date"
|
||||||
|
prepend-inner-icon="mdi-calendar"
|
||||||
|
variant="outlined"
|
||||||
|
density="compact"
|
||||||
|
hide-details
|
||||||
|
readonly
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
<v-date-picker
|
||||||
|
:model-value="pickerDate(dateValue)"
|
||||||
|
color="primary"
|
||||||
|
show-adjacent-months
|
||||||
|
@update:model-value="selectDate"
|
||||||
|
/>
|
||||||
|
</v-menu>
|
||||||
|
</v-col>
|
||||||
|
<v-col cols="6">
|
||||||
|
<v-menu v-model="timeMenu" :close-on-content-click="false" location="bottom start">
|
||||||
|
<template #activator="{ props: activatorProps }">
|
||||||
|
<v-text-field
|
||||||
|
v-bind="activatorProps"
|
||||||
|
:model-value="formatDisplayTime(timeValue)"
|
||||||
|
label="Time"
|
||||||
|
prepend-inner-icon="mdi-clock-outline"
|
||||||
|
variant="outlined"
|
||||||
|
density="compact"
|
||||||
|
hide-details
|
||||||
|
readonly
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
<v-card>
|
||||||
|
<v-time-picker v-model="timeValue" color="primary" format="ampm" />
|
||||||
|
<v-card-actions class="justify-end">
|
||||||
|
<v-btn variant="text" @click="timeMenu = false">Done</v-btn>
|
||||||
|
</v-card-actions>
|
||||||
|
</v-card>
|
||||||
|
</v-menu>
|
||||||
|
</v-col>
|
||||||
|
</v-row>
|
||||||
|
</template>
|
||||||
@@ -1,5 +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 { formatDisplayDate, formatDisplayTime, formatLocalIsoDate, formatLocalIsoTime } from '@/utils/format'
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
mode: 'edit' | 'view'
|
mode: 'edit' | 'view'
|
||||||
@@ -28,77 +30,113 @@ const timeZoneValue = computed({
|
|||||||
set: (value) => emit('update:timeZone', value || null)
|
set: (value) => emit('update:timeZone', value || null)
|
||||||
})
|
})
|
||||||
|
|
||||||
// Format helpers
|
|
||||||
const formatDate = (date: Date): string => {
|
|
||||||
return date.toISOString().split('T')[0]
|
|
||||||
}
|
|
||||||
|
|
||||||
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) {
|
||||||
|
return parseCalendarDate(isoString)?.toLocaleDateString() ?? 'Not set'
|
||||||
|
}
|
||||||
const date = new Date(isoString)
|
const date = new Date(isoString)
|
||||||
return date.toLocaleString()
|
return date.toLocaleString()
|
||||||
}
|
}
|
||||||
|
|
||||||
const parseDateTime = (date: string, time: string): Date => {
|
const parseDateTime = (date: string, time: string): Date => {
|
||||||
if (timelessValue.value) {
|
if (timelessValue.value) {
|
||||||
return new Date(date)
|
return new Date(`${date}T00:00:00`)
|
||||||
}
|
}
|
||||||
return new Date(`${date}T${time}`)
|
return new Date(`${date}T${time}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const serializeDateTime = (date: string, time: string): string => {
|
||||||
|
return timelessValue.value
|
||||||
|
? `${date}T00:00:00.000Z`
|
||||||
|
: new Date(`${date}T${time}`).toISOString()
|
||||||
|
}
|
||||||
|
|
||||||
|
const inputDate = (value: string, timeless: boolean): string => {
|
||||||
|
return timeless ? value.slice(0, 10) : formatLocalIsoDate(new Date(value))
|
||||||
|
}
|
||||||
|
|
||||||
|
const pickerDate = (value: string): Date | null => value ? new Date(`${value}T00:00:00`) : null
|
||||||
|
|
||||||
|
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()) ? '' : formatLocalIsoDate(date)
|
||||||
|
}
|
||||||
|
|
||||||
// Date/time fields
|
// Date/time fields
|
||||||
const startDate = ref('')
|
const startDate = ref('')
|
||||||
const startTime = ref('')
|
const startTime = ref('')
|
||||||
const endDate = ref('')
|
const endDate = ref('')
|
||||||
const endTime = ref('')
|
const endTime = ref('')
|
||||||
|
const startDateMenu = ref(false)
|
||||||
|
const startTimeMenu = ref(false)
|
||||||
|
const endDateMenu = ref(false)
|
||||||
|
const endTimeMenu = ref(false)
|
||||||
|
|
||||||
|
const selectStartDate = (value: unknown) => {
|
||||||
|
const date = normalizePickerDate(value)
|
||||||
|
if (date) startDate.value = date
|
||||||
|
startDateMenu.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
const selectEndDate = (value: unknown) => {
|
||||||
|
const date = normalizePickerDate(value)
|
||||||
|
if (date) endDate.value = date
|
||||||
|
endDateMenu.value = false
|
||||||
|
}
|
||||||
|
|
||||||
// Initialize date/time from props
|
// Initialize date/time from props
|
||||||
watch(() => props.startsOn, (newValue) => {
|
watch([() => props.startsOn, () => props.timeless], ([newValue, timeless]) => {
|
||||||
if (newValue) {
|
if (newValue) {
|
||||||
const start = new Date(newValue)
|
const start = new Date(newValue)
|
||||||
startDate.value = formatDate(start)
|
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 })
|
||||||
|
|
||||||
watch(() => props.endsOn, (newValue) => {
|
watch([() => props.endsOn, () => props.timeless], ([newValue, timeless]) => {
|
||||||
if (newValue) {
|
if (newValue) {
|
||||||
const end = new Date(newValue)
|
const end = new Date(newValue)
|
||||||
endDate.value = formatDate(end)
|
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 })
|
||||||
|
|
||||||
// Update parent when date/time changes
|
const ensureValidRange = (): boolean => {
|
||||||
watch([startDate, startTime], () => {
|
if (!startDate.value || !endDate.value || !startTime.value || !endTime.value) return false
|
||||||
if (props.mode === 'edit') {
|
const start = parseDateTime(startDate.value, startTime.value)
|
||||||
emit('update:startsOn', parseDateTime(startDate.value, startTime.value).toISOString())
|
const end = parseDateTime(endDate.value, endTime.value)
|
||||||
}
|
const valid = timelessValue.value
|
||||||
})
|
? end.getTime() > start.getTime()
|
||||||
|
: end.getTime() >= start.getTime()
|
||||||
|
if (valid) return false
|
||||||
|
|
||||||
watch([endDate, endTime], () => {
|
const correctedEnd = new Date(start)
|
||||||
if (props.mode === 'edit') {
|
if (timelessValue.value) correctedEnd.setDate(correctedEnd.getDate() + 1)
|
||||||
emit('update:endsOn', parseDateTime(endDate.value, endTime.value).toISOString())
|
else correctedEnd.setHours(correctedEnd.getHours() + 1)
|
||||||
}
|
endDate.value = formatLocalIsoDate(correctedEnd)
|
||||||
|
endTime.value = formatLocalIsoTime(correctedEnd)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
watch([startDate, startTime, endDate, endTime, timelessValue], () => {
|
||||||
|
if (props.mode !== 'edit' || ensureValidRange()) return
|
||||||
|
emit('update:startsOn', serializeDateTime(startDate.value, startTime.value))
|
||||||
|
emit('update:endsOn', serializeDateTime(endDate.value, endTime.value))
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="event-editor-section">
|
<div class="event-editor-section">
|
||||||
<div class="text-subtitle-1 mb-2">Date & Time</div>
|
|
||||||
|
|
||||||
<!-- Read-only view -->
|
<!-- Read-only view -->
|
||||||
<div v-if="mode === 'view'">
|
<div v-if="mode === 'view'">
|
||||||
<div class="mb-2 pa-2">
|
<div class="mb-2 pa-2">
|
||||||
@@ -124,49 +162,96 @@ watch([endDate, endTime], () => {
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
<div class="mb-3">
|
<div class="mb-3">
|
||||||
<div class="text-subtitle-2 mb-2">Start</div>
|
|
||||||
<v-row dense>
|
<v-row dense>
|
||||||
<v-col cols="12" md="6">
|
<v-col cols="12" md="6">
|
||||||
<v-text-field
|
<v-menu v-model="startDateMenu" :close-on-content-click="false" location="bottom start">
|
||||||
v-model="startDate"
|
<template #activator="{ props: activatorProps }">
|
||||||
label="Start Date"
|
<v-text-field
|
||||||
type="date"
|
v-bind="activatorProps"
|
||||||
variant="outlined"
|
:model-value="formatDisplayDate(startDate)"
|
||||||
density="compact"
|
label="Start date"
|
||||||
/>
|
prepend-inner-icon="mdi-calendar"
|
||||||
|
variant="outlined"
|
||||||
|
density="compact"
|
||||||
|
readonly
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
<v-date-picker
|
||||||
|
:model-value="pickerDate(startDate)"
|
||||||
|
color="primary"
|
||||||
|
show-adjacent-months
|
||||||
|
@update:model-value="selectStartDate"
|
||||||
|
/>
|
||||||
|
</v-menu>
|
||||||
</v-col>
|
</v-col>
|
||||||
<v-col v-if="!timelessValue" cols="12" md="6">
|
<v-col v-if="!timelessValue" cols="12" md="6">
|
||||||
<v-text-field
|
<v-menu v-model="startTimeMenu" :close-on-content-click="false" location="bottom start">
|
||||||
v-model="startTime"
|
<template #activator="{ props: activatorProps }">
|
||||||
label="Start Time"
|
<v-text-field
|
||||||
type="time"
|
v-bind="activatorProps"
|
||||||
variant="outlined"
|
:model-value="formatDisplayTime(startTime)"
|
||||||
density="compact"
|
label="Start time"
|
||||||
/>
|
prepend-inner-icon="mdi-clock-outline"
|
||||||
|
variant="outlined"
|
||||||
|
density="compact"
|
||||||
|
readonly
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
<v-card>
|
||||||
|
<v-time-picker v-model="startTime" color="primary" format="ampm" />
|
||||||
|
<v-card-actions class="justify-end">
|
||||||
|
<v-btn variant="text" @click="startTimeMenu = false">Done</v-btn>
|
||||||
|
</v-card-actions>
|
||||||
|
</v-card>
|
||||||
|
</v-menu>
|
||||||
</v-col>
|
</v-col>
|
||||||
</v-row>
|
</v-row>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="mb-3">
|
<div class="mb-3">
|
||||||
<div class="text-subtitle-2 mb-2">End</div>
|
|
||||||
<v-row dense>
|
<v-row dense>
|
||||||
<v-col cols="12" md="6">
|
<v-col cols="12" md="6">
|
||||||
<v-text-field
|
<v-menu v-model="endDateMenu" :close-on-content-click="false" location="bottom start">
|
||||||
v-model="endDate"
|
<template #activator="{ props: activatorProps }">
|
||||||
label="End Date"
|
<v-text-field
|
||||||
type="date"
|
v-bind="activatorProps"
|
||||||
variant="outlined"
|
:model-value="formatDisplayDate(endDate)"
|
||||||
density="compact"
|
label="End date"
|
||||||
/>
|
prepend-inner-icon="mdi-calendar"
|
||||||
|
variant="outlined"
|
||||||
|
density="compact"
|
||||||
|
readonly
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
<v-date-picker
|
||||||
|
:model-value="pickerDate(endDate)"
|
||||||
|
:min="pickerDate(startDate)"
|
||||||
|
color="primary"
|
||||||
|
show-adjacent-months
|
||||||
|
@update:model-value="selectEndDate"
|
||||||
|
/>
|
||||||
|
</v-menu>
|
||||||
</v-col>
|
</v-col>
|
||||||
<v-col v-if="!timelessValue" cols="12" md="6">
|
<v-col v-if="!timelessValue" cols="12" md="6">
|
||||||
<v-text-field
|
<v-menu v-model="endTimeMenu" :close-on-content-click="false" location="bottom start">
|
||||||
v-model="endTime"
|
<template #activator="{ props: activatorProps }">
|
||||||
label="End Time"
|
<v-text-field
|
||||||
type="time"
|
v-bind="activatorProps"
|
||||||
variant="outlined"
|
:model-value="formatDisplayTime(endTime)"
|
||||||
density="compact"
|
label="End time"
|
||||||
/>
|
prepend-inner-icon="mdi-clock-outline"
|
||||||
|
variant="outlined"
|
||||||
|
density="compact"
|
||||||
|
readonly
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
<v-card>
|
||||||
|
<v-time-picker v-model="endTime" color="primary" format="ampm" />
|
||||||
|
<v-card-actions class="justify-end">
|
||||||
|
<v-btn variant="text" @click="endTimeMenu = false">Done</v-btn>
|
||||||
|
</v-card-actions>
|
||||||
|
</v-card>
|
||||||
|
</v-menu>
|
||||||
</v-col>
|
</v-col>
|
||||||
</v-row>
|
</v-row>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -20,7 +20,6 @@ const descriptionValue = computed({
|
|||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div v-if="descriptionValue || mode === 'edit'" class="event-editor-section">
|
<div v-if="descriptionValue || mode === 'edit'" class="event-editor-section">
|
||||||
<div class="text-subtitle-1 mb-2">Description</div>
|
|
||||||
<!-- Read-only view -->
|
<!-- Read-only view -->
|
||||||
<div v-if="mode === 'view'" class="mb-4">
|
<div v-if="mode === 'view'" class="mb-4">
|
||||||
<div class="mb-2 pa-2 white-space-pre-wrap">{{ descriptionValue || 'No description' }}</div>
|
<div class="mb-2 pa-2 white-space-pre-wrap">{{ descriptionValue || 'No description' }}</div>
|
||||||
|
|||||||
@@ -20,7 +20,6 @@ const labelValue = computed({
|
|||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="event-editor-section">
|
<div class="event-editor-section">
|
||||||
<div class="text-subtitle-1 mb-2">Title</div>
|
|
||||||
<!-- Read-only view -->
|
<!-- Read-only view -->
|
||||||
<div v-if="mode === 'view'" class="mb-4">
|
<div v-if="mode === 'view'" class="mb-4">
|
||||||
<div class="mb-2 pa-2">{{ labelValue || 'Untitled Event' }}</div>
|
<div class="mb-2 pa-2">{{ labelValue || 'Untitled Event' }}</div>
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ const hasLocations = () => {
|
|||||||
<!-- Physical Locations -->
|
<!-- Physical Locations -->
|
||||||
<div v-if="Object.keys(locationsPhysical || {}).length > 0 || mode === 'edit'" class="mb-4">
|
<div v-if="Object.keys(locationsPhysical || {}).length > 0 || mode === 'edit'" class="mb-4">
|
||||||
<div class="d-flex align-center justify-space-between mb-2">
|
<div class="d-flex align-center justify-space-between mb-2">
|
||||||
<div class="text-subtitle-2">Physical Locations</div>
|
<div class="text-subtitle-2">Physical</div>
|
||||||
<v-btn v-if="mode === 'edit'"
|
<v-btn v-if="mode === 'edit'"
|
||||||
size="small"
|
size="small"
|
||||||
variant="outlined"
|
variant="outlined"
|
||||||
@@ -49,8 +49,7 @@ const hasLocations = () => {
|
|||||||
<!-- Edit view -->
|
<!-- Edit view -->
|
||||||
<div v-else>
|
<div v-else>
|
||||||
<div v-for="(location, key) in locationsPhysical" :key="key" class="mb-3 pa-3 border rounded">
|
<div v-for="(location, key) in locationsPhysical" :key="key" class="mb-3 pa-3 border rounded">
|
||||||
<div class="d-flex align-center justify-space-between mb-2">
|
<div class="d-flex justify-end mb-2">
|
||||||
<strong>{{ location.label || 'Physical Location' }}</strong>
|
|
||||||
<v-btn
|
<v-btn
|
||||||
icon="mdi-delete"
|
icon="mdi-delete"
|
||||||
size="small"
|
size="small"
|
||||||
@@ -102,7 +101,7 @@ const hasLocations = () => {
|
|||||||
<!-- Virtual Locations -->
|
<!-- Virtual Locations -->
|
||||||
<div v-if="Object.keys(locationsVirtual || {}).length > 0 || mode === 'edit'">
|
<div v-if="Object.keys(locationsVirtual || {}).length > 0 || mode === 'edit'">
|
||||||
<div class="d-flex align-center justify-space-between mb-2">
|
<div class="d-flex align-center justify-space-between mb-2">
|
||||||
<div class="text-subtitle-2">Virtual Locations</div>
|
<div class="text-subtitle-2">Virtual</div>
|
||||||
<v-btn v-if="mode === 'edit'"
|
<v-btn v-if="mode === 'edit'"
|
||||||
size="small"
|
size="small"
|
||||||
variant="outlined"
|
variant="outlined"
|
||||||
@@ -126,8 +125,7 @@ const hasLocations = () => {
|
|||||||
<!-- Edit view -->
|
<!-- Edit view -->
|
||||||
<div v-else>
|
<div v-else>
|
||||||
<div v-for="(location, key) in locationsVirtual" :key="key" class="mb-3 pa-3 border rounded">
|
<div v-for="(location, key) in locationsVirtual" :key="key" class="mb-3 pa-3 border rounded">
|
||||||
<div class="d-flex align-center justify-space-between mb-2">
|
<div class="d-flex justify-end mb-2">
|
||||||
<strong>{{ location.label || 'Virtual Location' }}</strong>
|
|
||||||
<v-btn
|
<v-btn
|
||||||
icon="mdi-delete"
|
icon="mdi-delete"
|
||||||
size="small"
|
size="small"
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
import EventEditorDateTimeField from './EventEditorDateTimeField.vue'
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
mode: 'edit' | 'view'
|
mode: 'edit' | 'view'
|
||||||
notifications: Record<string, any>
|
notifications: Record<string, any>
|
||||||
@@ -13,14 +15,12 @@ const emit = defineEmits<{
|
|||||||
|
|
||||||
const typeOptions = [
|
const typeOptions = [
|
||||||
{ title: 'Email', value: 'email' },
|
{ title: 'Email', value: 'email' },
|
||||||
{ title: 'Visual (Display)', value: 'visual' },
|
{ title: 'Display', value: 'visual' }
|
||||||
{ title: 'Audible (Sound)', value: 'audible' }
|
|
||||||
]
|
]
|
||||||
|
|
||||||
const patternOptions = [
|
const patternOptions = [
|
||||||
{ title: 'Absolute (Specific Time)', value: 'absolute' },
|
{ title: 'Relative', value: 'relative' },
|
||||||
{ title: 'Relative (Before Event)', value: 'relative' },
|
{ title: 'Absolute', value: 'absolute' },
|
||||||
{ title: 'Unknown', value: 'unknown' }
|
|
||||||
]
|
]
|
||||||
|
|
||||||
const anchorOptions = [
|
const anchorOptions = [
|
||||||
@@ -28,6 +28,18 @@ const anchorOptions = [
|
|||||||
{ title: 'End Time', value: 'end' }
|
{ title: 'End Time', value: 'end' }
|
||||||
]
|
]
|
||||||
|
|
||||||
|
const offsetPresets = [
|
||||||
|
{ title: '5 minutes before', value: 'PT5M' },
|
||||||
|
{ title: '10 minutes before', value: 'PT10M' },
|
||||||
|
{ title: '15 minutes before', value: 'PT15M' },
|
||||||
|
{ title: '30 minutes before', value: 'PT30M' },
|
||||||
|
{ title: '1 hour before', value: 'PT1H' },
|
||||||
|
{ title: '2 hours before', value: 'PT2H' },
|
||||||
|
{ title: '1 day before', value: 'P1D' },
|
||||||
|
{ title: '2 days before', value: 'P2D' },
|
||||||
|
{ title: '1 week before', value: 'P1W' },
|
||||||
|
]
|
||||||
|
|
||||||
const formatNotification = (notification: any) => {
|
const formatNotification = (notification: any) => {
|
||||||
if (notification.pattern === 'absolute' && notification.when) {
|
if (notification.pattern === 'absolute' && notification.when) {
|
||||||
return `At ${new Date(notification.when).toLocaleString()}`
|
return `At ${new Date(notification.when).toLocaleString()}`
|
||||||
@@ -48,7 +60,7 @@ const formatNotification = (notification: any) => {
|
|||||||
variant="outlined"
|
variant="outlined"
|
||||||
@click="$emit('add-notification')">
|
@click="$emit('add-notification')">
|
||||||
<v-icon left>mdi-plus</v-icon>
|
<v-icon left>mdi-plus</v-icon>
|
||||||
Add Notification
|
Add
|
||||||
</v-btn>
|
</v-btn>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -69,9 +81,65 @@ const formatNotification = (notification: any) => {
|
|||||||
|
|
||||||
<!-- Edit view -->
|
<!-- Edit view -->
|
||||||
<div v-else>
|
<div v-else>
|
||||||
<div v-for="(notification, key) in notifications" :key="key" class="mb-3 pa-3 border rounded">
|
<div v-for="(notification, key) in notifications" :key="key" class="mb-2 pa-2 border rounded">
|
||||||
<div class="d-flex align-center justify-space-between mb-2">
|
<div class="d-flex align-center ga-2">
|
||||||
<strong>Notification</strong>
|
<v-row dense class="flex-grow-1">
|
||||||
|
<v-col cols="6" sm="3">
|
||||||
|
<v-select
|
||||||
|
v-model="notification.type"
|
||||||
|
:items="typeOptions"
|
||||||
|
label="Type"
|
||||||
|
variant="outlined"
|
||||||
|
density="compact"
|
||||||
|
hide-details
|
||||||
|
:rules="[v => !!v || 'Type is required']"
|
||||||
|
/>
|
||||||
|
</v-col>
|
||||||
|
<v-col cols="6" sm="3">
|
||||||
|
<v-select
|
||||||
|
v-model="notification.pattern"
|
||||||
|
:items="patternOptions"
|
||||||
|
label="Pattern"
|
||||||
|
variant="outlined"
|
||||||
|
density="compact"
|
||||||
|
hide-details
|
||||||
|
:rules="[v => !!v || 'Pattern is required']"
|
||||||
|
/>
|
||||||
|
</v-col>
|
||||||
|
|
||||||
|
<v-col v-if="notification.pattern === 'absolute'" cols="12" sm="6">
|
||||||
|
<EventEditorDateTimeField
|
||||||
|
:model-value="notification.when"
|
||||||
|
@update:model-value="notification.when = $event"
|
||||||
|
/>
|
||||||
|
</v-col>
|
||||||
|
|
||||||
|
<template v-else-if="notification.pattern === 'relative'">
|
||||||
|
<v-col cols="6" sm="3">
|
||||||
|
<v-select
|
||||||
|
v-model="notification.anchor"
|
||||||
|
:items="anchorOptions"
|
||||||
|
label="Anchor"
|
||||||
|
variant="outlined"
|
||||||
|
density="compact"
|
||||||
|
hide-details
|
||||||
|
/>
|
||||||
|
</v-col>
|
||||||
|
<v-col cols="6" sm="3">
|
||||||
|
<v-combobox
|
||||||
|
v-model="notification.offset"
|
||||||
|
:items="offsetPresets"
|
||||||
|
item-title="title"
|
||||||
|
item-value="value"
|
||||||
|
label="Offset"
|
||||||
|
variant="outlined"
|
||||||
|
density="compact"
|
||||||
|
hide-details
|
||||||
|
/>
|
||||||
|
</v-col>
|
||||||
|
</template>
|
||||||
|
</v-row>
|
||||||
|
|
||||||
<v-btn
|
<v-btn
|
||||||
icon="mdi-delete"
|
icon="mdi-delete"
|
||||||
size="small"
|
size="small"
|
||||||
@@ -80,63 +148,6 @@ const formatNotification = (notification: any) => {
|
|||||||
@click="$emit('remove-notification', key)">
|
@click="$emit('remove-notification', key)">
|
||||||
</v-btn>
|
</v-btn>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<v-row dense class="mb-2">
|
|
||||||
<v-col cols="12" md="6">
|
|
||||||
<v-select
|
|
||||||
v-model="notification.type"
|
|
||||||
:items="typeOptions"
|
|
||||||
label="Type"
|
|
||||||
variant="outlined"
|
|
||||||
density="compact"
|
|
||||||
:rules="[v => !!v || 'Type is required']"
|
|
||||||
/>
|
|
||||||
</v-col>
|
|
||||||
<v-col cols="12" md="6">
|
|
||||||
<v-select
|
|
||||||
v-model="notification.pattern"
|
|
||||||
:items="patternOptions"
|
|
||||||
label="Pattern"
|
|
||||||
variant="outlined"
|
|
||||||
density="compact"
|
|
||||||
:rules="[v => !!v || 'Pattern is required']"
|
|
||||||
/>
|
|
||||||
</v-col>
|
|
||||||
</v-row>
|
|
||||||
|
|
||||||
<div v-if="notification.pattern === 'absolute'">
|
|
||||||
<v-text-field
|
|
||||||
v-model="notification.when"
|
|
||||||
label="When (ISO Date)"
|
|
||||||
type="datetime-local"
|
|
||||||
variant="outlined"
|
|
||||||
density="compact"
|
|
||||||
hint="Specific date and time for notification"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div v-else-if="notification.pattern === 'relative'">
|
|
||||||
<v-row dense>
|
|
||||||
<v-col cols="12" md="6">
|
|
||||||
<v-text-field
|
|
||||||
v-model="notification.offset"
|
|
||||||
label="Offset (e.g., PT15M)"
|
|
||||||
variant="outlined"
|
|
||||||
density="compact"
|
|
||||||
hint="ISO 8601 duration before event"
|
|
||||||
/>
|
|
||||||
</v-col>
|
|
||||||
<v-col cols="12" md="6">
|
|
||||||
<v-select
|
|
||||||
v-model="notification.anchor"
|
|
||||||
:items="anchorOptions"
|
|
||||||
label="Anchor"
|
|
||||||
variant="outlined"
|
|
||||||
density="compact"
|
|
||||||
/>
|
|
||||||
</v-col>
|
|
||||||
</v-row>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,43 +1,216 @@
|
|||||||
<script setup lang="ts">
|
<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'
|
||||||
|
type RecurrencePattern = EventOccurrence & { toJson?: () => EventOccurrence }
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
mode: 'edit' | 'view'
|
mode: 'edit' | 'view'
|
||||||
pattern: any | null
|
pattern: RecurrencePattern | null
|
||||||
|
startsOn?: string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
const props = defineProps<Props>()
|
const props = defineProps<Props>()
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
'update:pattern': [pattern: any | null]
|
'update:pattern': [pattern: EventOccurrence | null]
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const precisionOptions = [
|
const precisionOptions: { title: string, value: Frequency }[] = [
|
||||||
{ title: 'Yearly', value: 'yearly' },
|
|
||||||
{ title: 'Monthly', value: 'monthly' },
|
|
||||||
{ title: 'Weekly', value: 'weekly' },
|
|
||||||
{ title: 'Daily', value: 'daily' },
|
{ title: 'Daily', value: 'daily' },
|
||||||
{ title: 'Hourly', value: 'hourly' },
|
{ title: 'Weekly', value: 'weekly' },
|
||||||
{ title: 'Minutely', value: 'minutely' },
|
{ title: 'Monthly', value: 'monthly' },
|
||||||
{ title: 'Secondly', value: 'secondly' }
|
{ title: 'Yearly', value: 'yearly' },
|
||||||
]
|
]
|
||||||
|
|
||||||
const patternTypeOptions = [
|
const weekdayOptions = [
|
||||||
{ title: 'Absolute', value: 'absolute' },
|
{ title: 'Monday', short: 'Mon', value: 1 },
|
||||||
{ title: 'Relative', value: 'relative' }
|
{ title: 'Tuesday', short: 'Tue', value: 2 },
|
||||||
|
{ title: 'Wednesday', short: 'Wed', value: 3 },
|
||||||
|
{ title: 'Thursday', short: 'Thu', value: 4 },
|
||||||
|
{ title: 'Friday', short: 'Fri', value: 5 },
|
||||||
|
{ title: 'Saturday', short: 'Sat', value: 6 },
|
||||||
|
{ title: 'Sunday', short: 'Sun', value: 7 },
|
||||||
]
|
]
|
||||||
|
|
||||||
const formatRecurrence = (pattern: any) => {
|
const monthOptions = [
|
||||||
if (!pattern) return 'No recurrence'
|
'January', 'February', 'March', 'April', 'May', 'June',
|
||||||
|
'July', 'August', 'September', 'October', 'November', 'December',
|
||||||
const parts = []
|
].map((title, index) => ({ title, value: index + 1 }))
|
||||||
parts.push(`Every ${pattern.interval || 1}`)
|
|
||||||
parts.push(pattern.precision || 'day')
|
const monthDayOptions = Array.from({ length: 31 }, (_, index) => index + 1)
|
||||||
|
|
||||||
if (pattern.iterations) {
|
const positionOptions = [
|
||||||
parts.push(`(${pattern.iterations} times)`)
|
{ title: 'First', value: 1 },
|
||||||
} else if (pattern.concludes) {
|
{ title: 'Second', value: 2 },
|
||||||
parts.push(`until ${new Date(pattern.concludes).toLocaleDateString()}`)
|
{ title: 'Third', value: 3 },
|
||||||
|
{ title: 'Fourth', value: 4 },
|
||||||
|
{ title: 'Last', value: -1 },
|
||||||
|
]
|
||||||
|
|
||||||
|
const frequencyUnits: Record<Frequency, [string, string]> = {
|
||||||
|
daily: ['day', 'days'],
|
||||||
|
weekly: ['week', 'weeks'],
|
||||||
|
monthly: ['month', 'months'],
|
||||||
|
yearly: ['year', 'years'],
|
||||||
|
}
|
||||||
|
|
||||||
|
const endOptions: { title: string, value: EndMode }[] = [
|
||||||
|
{ title: 'Never', value: 'never' },
|
||||||
|
{ title: 'On', value: 'date' },
|
||||||
|
{ title: 'After', value: 'count' },
|
||||||
|
]
|
||||||
|
|
||||||
|
const endDateMenu = ref(false)
|
||||||
|
|
||||||
|
const recurrenceRuleFields: (keyof EventOccurrence)[] = [
|
||||||
|
'onDayOfWeek', 'onDayOfMonth', 'onDayOfYear', 'onWeekOfMonth',
|
||||||
|
'onWeekOfYear', 'onMonthOfYear', 'onHour', 'onMinute', 'onSecond', 'onPosition',
|
||||||
|
]
|
||||||
|
|
||||||
|
const toPlainPattern = (): EventOccurrence | null => {
|
||||||
|
if (!props.pattern) return null
|
||||||
|
return props.pattern.toJson ? props.pattern.toJson() : { ...props.pattern }
|
||||||
|
}
|
||||||
|
|
||||||
|
const updatePattern = (changes: Partial<EventOccurrence>) => {
|
||||||
|
const current = toPlainPattern() ?? { pattern: 'absolute', precision: 'daily', interval: 1 }
|
||||||
|
emit('update:pattern', { ...current, ...changes })
|
||||||
|
}
|
||||||
|
|
||||||
|
const getStartDate = () => {
|
||||||
|
const date = props.startsOn ? new Date(props.startsOn) : new Date()
|
||||||
|
return Number.isNaN(date.getTime()) ? new Date() : date
|
||||||
|
}
|
||||||
|
|
||||||
|
const getDateDefaults = () => {
|
||||||
|
const date = getStartDate()
|
||||||
|
const day = date.getDate()
|
||||||
|
const weekday = date.getDay() || 7
|
||||||
|
const daysInMonth = new Date(date.getFullYear(), date.getMonth() + 1, 0).getDate()
|
||||||
|
const position = day + 7 > daysInMonth ? -1 : Math.ceil(day / 7)
|
||||||
|
return { day, month: date.getMonth() + 1, weekday, position }
|
||||||
|
}
|
||||||
|
|
||||||
|
const repeatValue = computed<Frequency>(() => props.pattern?.precision as Frequency ?? 'daily')
|
||||||
|
const endMode = computed<EndMode>(() => {
|
||||||
|
if (props.pattern?.iterations) return 'count'
|
||||||
|
if (props.pattern?.concludes) return 'date'
|
||||||
|
return 'never'
|
||||||
|
})
|
||||||
|
|
||||||
|
const intervalUnit = computed(() => {
|
||||||
|
const frequency = props.pattern?.precision as Frequency ?? 'daily'
|
||||||
|
const interval = props.pattern?.interval || 1
|
||||||
|
const units = frequencyUnits[frequency] ?? ['period', 'periods']
|
||||||
|
return units[interval === 1 ? 0 : 1]
|
||||||
|
})
|
||||||
|
|
||||||
|
const changeFrequency = (frequency: Frequency) => {
|
||||||
|
const defaults = getDateDefaults()
|
||||||
|
const changes: Partial<EventOccurrence> = { precision: frequency, pattern: 'absolute' }
|
||||||
|
for (const field of recurrenceRuleFields) {
|
||||||
|
;(changes as Record<string, unknown>)[field] = null
|
||||||
|
}
|
||||||
|
|
||||||
|
if (frequency === 'weekly') changes.onDayOfWeek = [defaults.weekday]
|
||||||
|
if (frequency === 'monthly') changes.onDayOfMonth = [defaults.day]
|
||||||
|
if (frequency === 'yearly') {
|
||||||
|
changes.onMonthOfYear = [defaults.month]
|
||||||
|
changes.onDayOfMonth = [defaults.day]
|
||||||
|
}
|
||||||
|
updatePattern(changes)
|
||||||
|
}
|
||||||
|
|
||||||
|
const addRecurrence = () => {
|
||||||
|
emit('update:pattern', { pattern: 'absolute', precision: 'daily', interval: 1 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const changePatternType = (patternType: 'absolute' | 'relative') => {
|
||||||
|
const defaults = getDateDefaults()
|
||||||
|
const yearly = props.pattern?.precision === 'yearly'
|
||||||
|
updatePattern(patternType === 'absolute' ? {
|
||||||
|
pattern: 'absolute',
|
||||||
|
onDayOfWeek: null,
|
||||||
|
onDayOfMonth: props.pattern?.onDayOfMonth?.length ? props.pattern.onDayOfMonth : [defaults.day],
|
||||||
|
onMonthOfYear: yearly
|
||||||
|
? (props.pattern?.onMonthOfYear?.length ? props.pattern.onMonthOfYear : [defaults.month])
|
||||||
|
: null,
|
||||||
|
onPosition: null,
|
||||||
|
} : {
|
||||||
|
pattern: 'relative',
|
||||||
|
onDayOfWeek: props.pattern?.onDayOfWeek?.length ? props.pattern.onDayOfWeek : [defaults.weekday],
|
||||||
|
onDayOfMonth: null,
|
||||||
|
onMonthOfYear: yearly
|
||||||
|
? (props.pattern?.onMonthOfYear?.length ? props.pattern.onMonthOfYear : [defaults.month])
|
||||||
|
: null,
|
||||||
|
onPosition: props.pattern?.onPosition?.length ? props.pattern.onPosition : [defaults.position],
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const updatePosition = (position: number | null) => {
|
||||||
|
updatePattern({ onPosition: position === null ? [] : [position] })
|
||||||
|
}
|
||||||
|
|
||||||
|
const updateEndMode = (mode: EndMode) => {
|
||||||
|
if (mode === 'never') {
|
||||||
|
updatePattern({ iterations: null, concludes: null })
|
||||||
|
} else if (mode === 'count') {
|
||||||
|
updatePattern({ iterations: props.pattern?.iterations || 10, concludes: null })
|
||||||
|
} else {
|
||||||
|
const conclusion = new Date(getStartDate())
|
||||||
|
conclusion.setMonth(conclusion.getMonth() + 1)
|
||||||
|
updatePattern({ iterations: null, concludes: props.pattern?.concludes || conclusion.toISOString().slice(0, 10) })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const pickerDate = (value: string | null | undefined): Date | null => {
|
||||||
|
return value ? new Date(`${value.slice(0, 10)}T00:00:00`) : null
|
||||||
|
}
|
||||||
|
|
||||||
|
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())) {
|
||||||
|
updatePattern({ concludes: formatLocalIsoDate(date), iterations: null })
|
||||||
|
}
|
||||||
|
endDateMenu.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
const weekdayName = (day: number) => weekdayOptions.find(option => option.value === day)?.short ?? String(day)
|
||||||
|
const monthName = (month: number) => monthOptions.find(option => option.value === month)?.title ?? String(month)
|
||||||
|
|
||||||
|
const formatRecurrence = (pattern: RecurrencePattern) => {
|
||||||
|
const interval = pattern.interval || 1
|
||||||
|
const frequency = pattern.precision
|
||||||
|
const units = frequencyUnits[frequency as Frequency] ?? [frequency, frequency]
|
||||||
|
const unit = units[interval === 1 ? 0 : 1]
|
||||||
|
const parts = [`Every ${interval > 1 ? `${interval} ` : ''}${unit}`]
|
||||||
|
|
||||||
|
if (frequency === 'weekly' && pattern.onDayOfWeek?.length) {
|
||||||
|
parts.push(`on ${pattern.onDayOfWeek.map(weekdayName).join(', ')}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (frequency === 'monthly' || frequency === 'yearly') {
|
||||||
|
if (frequency === 'yearly' && pattern.onMonthOfYear?.length) {
|
||||||
|
parts.push(`in ${pattern.onMonthOfYear.map(monthName).join(', ')}`)
|
||||||
|
}
|
||||||
|
if (pattern.pattern === 'absolute' && pattern.onDayOfMonth?.length) {
|
||||||
|
parts.push(`on day ${pattern.onDayOfMonth.join(', ')}`)
|
||||||
|
} else if (pattern.onPosition?.length && pattern.onDayOfWeek?.length) {
|
||||||
|
const position = positionOptions.find(option => option.value === pattern.onPosition?.[0])?.title
|
||||||
|
parts.push(`on the ${position?.toLowerCase()} ${pattern.onDayOfWeek.map(weekdayName).join(', ')}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (pattern.iterations) {
|
||||||
|
parts.push(`for ${pattern.iterations} occurrences`)
|
||||||
|
} else if (pattern.concludes) {
|
||||||
|
parts.push(`until ${new Date(`${pattern.concludes.slice(0, 10)}T00:00:00`).toLocaleDateString()}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
return parts.join(' ')
|
return parts.join(' ')
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
@@ -46,92 +219,265 @@ const formatRecurrence = (pattern: any) => {
|
|||||||
<div v-if="pattern || mode === 'edit'" class="event-editor-section">
|
<div v-if="pattern || mode === 'edit'" class="event-editor-section">
|
||||||
<div class="d-flex align-center justify-space-between mb-2">
|
<div class="d-flex align-center justify-space-between mb-2">
|
||||||
<div class="text-subtitle-1">Recurrence</div>
|
<div class="text-subtitle-1">Recurrence</div>
|
||||||
<v-btn v-if="mode === 'edit' && !pattern"
|
<v-btn
|
||||||
|
v-if="mode === 'edit' && !pattern"
|
||||||
size="small"
|
size="small"
|
||||||
variant="outlined"
|
variant="outlined"
|
||||||
@click="$emit('update:pattern', { pattern: 'absolute', precision: 'daily', interval: 1 })">
|
@click="addRecurrence"
|
||||||
<v-icon left>mdi-plus</v-icon>
|
>
|
||||||
Add Recurrence
|
<v-icon start icon="mdi-plus" />
|
||||||
|
Add
|
||||||
</v-btn>
|
</v-btn>
|
||||||
<v-btn v-else-if="mode === 'edit' && pattern"
|
<v-btn
|
||||||
|
v-else-if="mode === 'edit'"
|
||||||
|
icon="mdi-delete"
|
||||||
|
aria-label="Remove recurrence"
|
||||||
size="small"
|
size="small"
|
||||||
variant="text"
|
|
||||||
color="error"
|
color="error"
|
||||||
@click="$emit('update:pattern', null)">
|
variant="text"
|
||||||
Remove
|
@click="$emit('update:pattern', null)"
|
||||||
</v-btn>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Read-only view -->
|
|
||||||
<div v-if="mode === 'view' && pattern" class="mb-4">
|
<div v-if="mode === 'view' && pattern" class="mb-4">
|
||||||
<div class="pa-2 border rounded">
|
<div class="recurrence-summary pa-3 rounded">
|
||||||
<div class="mb-1"><strong>{{ formatRecurrence(pattern) }}</strong></div>
|
<v-icon icon="mdi-repeat" size="small" class="mr-2" />
|
||||||
<div v-if="pattern.onDayOfWeek && pattern.onDayOfWeek.length > 0" class="text-caption text-grey">
|
<strong>{{ formatRecurrence(pattern) }}</strong>
|
||||||
Days: {{ pattern.onDayOfWeek.join(', ') }}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Edit view -->
|
<div v-else-if="mode === 'edit' && pattern" class="recurrence-editor pa-3 rounded">
|
||||||
<div v-else-if="mode === 'edit' && pattern" class="pa-3 border rounded">
|
<div class="recurrence-controls mb-4">
|
||||||
<v-row dense class="mb-2">
|
<span>Repeat</span>
|
||||||
<v-col cols="12" md="6">
|
<v-select
|
||||||
<v-select
|
:model-value="repeatValue"
|
||||||
v-model="pattern.pattern"
|
:items="precisionOptions"
|
||||||
:items="patternTypeOptions"
|
variant="outlined"
|
||||||
label="Pattern Type"
|
density="compact"
|
||||||
variant="outlined"
|
hide-details
|
||||||
density="compact"
|
class="frequency-field"
|
||||||
/>
|
@update:model-value="changeFrequency"
|
||||||
</v-col>
|
/>
|
||||||
<v-col cols="12" md="6">
|
<template v-if="pattern">
|
||||||
<v-select
|
<span>Every</span>
|
||||||
v-model="pattern.precision"
|
|
||||||
:items="precisionOptions"
|
|
||||||
label="Frequency"
|
|
||||||
variant="outlined"
|
|
||||||
density="compact"
|
|
||||||
/>
|
|
||||||
</v-col>
|
|
||||||
</v-row>
|
|
||||||
|
|
||||||
<v-row dense class="mb-2">
|
|
||||||
<v-col cols="12" md="4">
|
|
||||||
<v-text-field
|
<v-text-field
|
||||||
v-model.number="pattern.interval"
|
:model-value="pattern.interval"
|
||||||
label="Interval"
|
aria-label="Repeat interval"
|
||||||
type="number"
|
type="number"
|
||||||
|
min="1"
|
||||||
|
max="999"
|
||||||
variant="outlined"
|
variant="outlined"
|
||||||
density="compact"
|
density="compact"
|
||||||
:rules="[v => v > 0 || 'Must be greater than 0']"
|
hide-details
|
||||||
|
class="interval-field"
|
||||||
|
@update:model-value="updatePattern({ interval: Math.max(1, Number($event) || 1) })"
|
||||||
/>
|
/>
|
||||||
</v-col>
|
<span>{{ intervalUnit }}</span>
|
||||||
<v-col cols="12" md="4">
|
</template>
|
||||||
<v-text-field
|
|
||||||
v-model.number="pattern.iterations"
|
|
||||||
label="Iterations"
|
|
||||||
type="number"
|
|
||||||
variant="outlined"
|
|
||||||
density="compact"
|
|
||||||
hint="Leave empty for infinite"
|
|
||||||
clearable
|
|
||||||
/>
|
|
||||||
</v-col>
|
|
||||||
<v-col cols="12" md="4">
|
|
||||||
<v-text-field
|
|
||||||
v-model="pattern.concludes"
|
|
||||||
label="End Date"
|
|
||||||
type="date"
|
|
||||||
variant="outlined"
|
|
||||||
density="compact"
|
|
||||||
clearable
|
|
||||||
/>
|
|
||||||
</v-col>
|
|
||||||
</v-row>
|
|
||||||
|
|
||||||
<div class="text-caption text-grey mt-2">
|
|
||||||
Advanced recurrence rules (like specific days of week) can be added to the pattern object
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<v-expand-transition>
|
||||||
|
<div v-if="pattern" class="mt-4">
|
||||||
|
<div v-if="pattern.precision === 'weekly'" class="weekly-on-controls mb-4">
|
||||||
|
<span>On days</span>
|
||||||
|
<v-btn-toggle
|
||||||
|
:model-value="pattern.onDayOfWeek || []"
|
||||||
|
color="primary"
|
||||||
|
multiple
|
||||||
|
mandatory
|
||||||
|
divided
|
||||||
|
variant="outlined"
|
||||||
|
class="weekday-toggle"
|
||||||
|
@update:model-value="updatePattern({ onDayOfWeek: $event })"
|
||||||
|
>
|
||||||
|
<v-btn
|
||||||
|
v-for="day in weekdayOptions"
|
||||||
|
:key="day.value"
|
||||||
|
:value="day.value"
|
||||||
|
size="small"
|
||||||
|
variant="outlined"
|
||||||
|
>
|
||||||
|
{{ day.short.slice(0, 2) }}
|
||||||
|
<v-tooltip activator="parent" location="top">{{ day.title }}</v-tooltip>
|
||||||
|
</v-btn>
|
||||||
|
</v-btn-toggle>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="pattern.precision === 'monthly'" class="monthly-rule-controls mb-4">
|
||||||
|
<span>Using</span>
|
||||||
|
<v-btn-toggle
|
||||||
|
:model-value="pattern.pattern"
|
||||||
|
color="primary"
|
||||||
|
mandatory
|
||||||
|
divided
|
||||||
|
variant="outlined"
|
||||||
|
class="rule-toggle"
|
||||||
|
@update:model-value="changePatternType"
|
||||||
|
>
|
||||||
|
<v-btn value="absolute">Calendar date</v-btn>
|
||||||
|
<v-btn value="relative">Weekday pattern</v-btn>
|
||||||
|
</v-btn-toggle>
|
||||||
|
|
||||||
|
<v-select
|
||||||
|
v-if="pattern.pattern === 'absolute'"
|
||||||
|
:model-value="pattern.onDayOfMonth?.[0] ?? null"
|
||||||
|
:items="monthDayOptions"
|
||||||
|
label="Day"
|
||||||
|
variant="outlined"
|
||||||
|
density="compact"
|
||||||
|
hide-details
|
||||||
|
class="monthly-day-field"
|
||||||
|
@update:model-value="updatePattern({ onDayOfMonth: [$event] })"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<template v-else>
|
||||||
|
<v-select
|
||||||
|
:model-value="pattern.onPosition?.[0] ?? null"
|
||||||
|
:items="positionOptions"
|
||||||
|
label="Which"
|
||||||
|
variant="outlined"
|
||||||
|
density="compact"
|
||||||
|
hide-details
|
||||||
|
class="monthly-position-field"
|
||||||
|
@update:model-value="updatePosition"
|
||||||
|
/>
|
||||||
|
<v-select
|
||||||
|
:model-value="pattern.onDayOfWeek?.[0] ?? null"
|
||||||
|
:items="weekdayOptions"
|
||||||
|
label="Weekday"
|
||||||
|
variant="outlined"
|
||||||
|
density="compact"
|
||||||
|
hide-details
|
||||||
|
class="monthly-weekday-field"
|
||||||
|
@update:model-value="updatePattern({ onDayOfWeek: [$event] })"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else-if="pattern.precision === 'yearly'" class="yearly-rule-controls mb-4">
|
||||||
|
<span>Using</span>
|
||||||
|
<v-btn-toggle
|
||||||
|
:model-value="pattern.pattern"
|
||||||
|
color="primary"
|
||||||
|
mandatory
|
||||||
|
divided
|
||||||
|
variant="outlined"
|
||||||
|
class="rule-toggle"
|
||||||
|
@update:model-value="changePatternType"
|
||||||
|
>
|
||||||
|
<v-btn value="absolute">Calendar date</v-btn>
|
||||||
|
<v-btn value="relative">Weekday pattern</v-btn>
|
||||||
|
</v-btn-toggle>
|
||||||
|
|
||||||
|
<v-select
|
||||||
|
:model-value="pattern.onMonthOfYear?.[0] ?? null"
|
||||||
|
:items="monthOptions"
|
||||||
|
label="Month"
|
||||||
|
variant="outlined"
|
||||||
|
density="compact"
|
||||||
|
hide-details
|
||||||
|
class="yearly-month-field"
|
||||||
|
@update:model-value="updatePattern({ onMonthOfYear: [$event] })"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<v-select
|
||||||
|
v-if="pattern.pattern === 'absolute'"
|
||||||
|
:model-value="pattern.onDayOfMonth?.[0] ?? null"
|
||||||
|
:items="monthDayOptions"
|
||||||
|
label="Day"
|
||||||
|
variant="outlined"
|
||||||
|
density="compact"
|
||||||
|
hide-details
|
||||||
|
class="monthly-day-field"
|
||||||
|
@update:model-value="updatePattern({ onDayOfMonth: [$event] })"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<template v-else>
|
||||||
|
<v-select
|
||||||
|
:model-value="pattern.onPosition?.[0] ?? null"
|
||||||
|
:items="positionOptions"
|
||||||
|
label="Which"
|
||||||
|
variant="outlined"
|
||||||
|
density="compact"
|
||||||
|
hide-details
|
||||||
|
class="monthly-position-field"
|
||||||
|
@update:model-value="updatePosition"
|
||||||
|
/>
|
||||||
|
<v-select
|
||||||
|
:model-value="pattern.onDayOfWeek?.[0] ?? null"
|
||||||
|
:items="weekdayOptions"
|
||||||
|
label="Weekday"
|
||||||
|
variant="outlined"
|
||||||
|
density="compact"
|
||||||
|
hide-details
|
||||||
|
class="monthly-weekday-field"
|
||||||
|
@update:model-value="updatePattern({ onDayOfWeek: [$event] })"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="recurrence-end-controls mb-4">
|
||||||
|
<span>Ends</span>
|
||||||
|
<v-select
|
||||||
|
:model-value="endMode"
|
||||||
|
:items="endOptions"
|
||||||
|
aria-label="Recurrence end"
|
||||||
|
variant="outlined"
|
||||||
|
density="compact"
|
||||||
|
hide-details
|
||||||
|
class="end-mode-field"
|
||||||
|
@update:model-value="updateEndMode"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<v-menu
|
||||||
|
v-if="endMode === 'date'"
|
||||||
|
v-model="endDateMenu"
|
||||||
|
:close-on-content-click="false"
|
||||||
|
location="bottom start"
|
||||||
|
>
|
||||||
|
<template #activator="{ props: activatorProps }">
|
||||||
|
<v-text-field
|
||||||
|
v-bind="activatorProps"
|
||||||
|
:model-value="formatDisplayDate(pattern.concludes)"
|
||||||
|
aria-label="End date"
|
||||||
|
prepend-inner-icon="mdi-calendar"
|
||||||
|
variant="outlined"
|
||||||
|
density="compact"
|
||||||
|
hide-details
|
||||||
|
readonly
|
||||||
|
class="end-date-field"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
<v-date-picker
|
||||||
|
:model-value="pickerDate(pattern.concludes)"
|
||||||
|
:min="pickerDate(startsOn)"
|
||||||
|
color="primary"
|
||||||
|
show-adjacent-months
|
||||||
|
@update:model-value="selectConclusionDate"
|
||||||
|
/>
|
||||||
|
</v-menu>
|
||||||
|
|
||||||
|
<v-text-field
|
||||||
|
v-else-if="endMode === 'count'"
|
||||||
|
:model-value="pattern.iterations"
|
||||||
|
aria-label="Number of occurrences"
|
||||||
|
type="number"
|
||||||
|
min="1"
|
||||||
|
variant="outlined"
|
||||||
|
density="compact"
|
||||||
|
hide-details
|
||||||
|
class="interval-field"
|
||||||
|
@update:model-value="updatePattern({ iterations: Math.max(1, Number($event) || 1), concludes: null })"
|
||||||
|
/>
|
||||||
|
<span v-if="endMode === 'count'">occurrences</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="recurrence-summary pa-3 rounded">
|
||||||
|
<v-icon icon="mdi-repeat" size="small" class="mr-2" />
|
||||||
|
{{ formatRecurrence(pattern) }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</v-expand-transition>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@@ -140,4 +486,122 @@ const formatRecurrence = (pattern: any) => {
|
|||||||
.event-editor-section {
|
.event-editor-section {
|
||||||
margin-bottom: 1.5rem;
|
margin-bottom: 1.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.recurrence-editor {
|
||||||
|
border: 1px solid rgba(var(--v-border-color), var(--v-border-opacity));
|
||||||
|
}
|
||||||
|
|
||||||
|
.recurrence-summary {
|
||||||
|
background: rgba(var(--v-theme-primary), 0.08);
|
||||||
|
color: rgb(var(--v-theme-on-surface));
|
||||||
|
}
|
||||||
|
|
||||||
|
.recurrence-controls,
|
||||||
|
.recurrence-end-controls,
|
||||||
|
.weekly-on-controls,
|
||||||
|
.monthly-rule-controls,
|
||||||
|
.yearly-rule-controls,
|
||||||
|
.recurrence-sentence {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.recurrence-controls,
|
||||||
|
.recurrence-end-controls,
|
||||||
|
.weekly-on-controls,
|
||||||
|
.monthly-rule-controls,
|
||||||
|
.yearly-rule-controls {
|
||||||
|
flex-wrap: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.frequency-field {
|
||||||
|
flex: 0 1 9.5rem;
|
||||||
|
min-width: 0;
|
||||||
|
max-width: 9.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.interval-field {
|
||||||
|
flex: 0 0 5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.end-mode-field {
|
||||||
|
flex: 0 0 7rem;
|
||||||
|
max-width: 7rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.end-date-field {
|
||||||
|
flex: 0 1 12rem;
|
||||||
|
min-width: 0;
|
||||||
|
max-width: 12rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field-label {
|
||||||
|
color: rgba(var(--v-theme-on-surface), var(--v-medium-emphasis-opacity));
|
||||||
|
font-size: 0.875rem;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.weekday-toggle,
|
||||||
|
.rule-toggle {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
height: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.weekday-toggle :deep(.v-btn) {
|
||||||
|
flex: 1 1 2.5rem;
|
||||||
|
min-width: 2.5rem;
|
||||||
|
min-height: 40px;
|
||||||
|
border: 1px solid rgba(var(--v-theme-on-surface), 0.35) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.weekday-toggle :deep(.v-btn--active) {
|
||||||
|
border-color: rgb(var(--v-theme-primary)) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.weekly-on-controls .weekday-toggle {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
flex-wrap: nowrap;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.monthly-rule-controls .rule-toggle,
|
||||||
|
.yearly-rule-controls .rule-toggle {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
flex-wrap: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.monthly-rule-controls .rule-toggle :deep(.v-btn),
|
||||||
|
.yearly-rule-controls .rule-toggle :deep(.v-btn) {
|
||||||
|
min-height: 40px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.monthly-day-field {
|
||||||
|
flex: 0 0 6rem;
|
||||||
|
max-width: 6rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.monthly-position-field {
|
||||||
|
flex: 0 0 6.5rem;
|
||||||
|
max-width: 6.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.monthly-weekday-field {
|
||||||
|
flex: 0 1 8.5rem;
|
||||||
|
min-width: 0;
|
||||||
|
max-width: 8.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.yearly-month-field {
|
||||||
|
flex: 0 1 8rem;
|
||||||
|
min-width: 0;
|
||||||
|
max-width: 8rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 599px) {
|
||||||
|
.rule-toggle :deep(.v-btn) {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
}
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -90,7 +90,7 @@ const hasParticipants = () => {
|
|||||||
variant="outlined"
|
variant="outlined"
|
||||||
@click="$emit('add-participant')">
|
@click="$emit('add-participant')">
|
||||||
<v-icon left>mdi-plus</v-icon>
|
<v-icon left>mdi-plus</v-icon>
|
||||||
Add Participant
|
Add
|
||||||
</v-btn>
|
</v-btn>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -131,8 +131,7 @@ const hasParticipants = () => {
|
|||||||
<!-- Edit view -->
|
<!-- Edit view -->
|
||||||
<div v-else>
|
<div v-else>
|
||||||
<div v-for="(participant, key) in participants" :key="key" class="mb-3 pa-3 border rounded">
|
<div v-for="(participant, key) in participants" :key="key" class="mb-3 pa-3 border rounded">
|
||||||
<div class="d-flex align-center justify-space-between mb-2">
|
<div class="d-flex justify-end mb-2">
|
||||||
<strong>{{ participant.name || 'Participant' }}</strong>
|
|
||||||
<v-btn
|
<v-btn
|
||||||
icon="mdi-delete"
|
icon="mdi-delete"
|
||||||
size="small"
|
size="small"
|
||||||
|
|||||||
@@ -20,8 +20,6 @@ const tagsValue = computed({
|
|||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div v-if="tagsValue.length > 0 || mode === 'edit'" class="event-editor-section">
|
<div v-if="tagsValue.length > 0 || mode === 'edit'" class="event-editor-section">
|
||||||
<div class="text-subtitle-1 mb-2">Tags</div>
|
|
||||||
|
|
||||||
<!-- Read-only view -->
|
<!-- Read-only view -->
|
||||||
<div v-if="mode === 'view'" class="mb-4">
|
<div v-if="mode === 'view'" class="mb-4">
|
||||||
<div v-if="tagsValue.length > 0" class="pa-2">
|
<div v-if="tagsValue.length > 0" class="pa-2">
|
||||||
|
|||||||
@@ -1,96 +0,0 @@
|
|||||||
import { CollectionObject } from '@ChronoManager/models/collection'
|
|
||||||
import { EntityObject } from '@ChronoManager/models/entity'
|
|
||||||
import { EventObject } from '@ChronoManager/models/event'
|
|
||||||
import { TaskObject } from '@ChronoManager/models/task'
|
|
||||||
import { useCollectionsStore } from '@ChronoManager/stores/collectionsStore'
|
|
||||||
import { useEntitiesStore } from '@ChronoManager/stores/entitiesStore'
|
|
||||||
|
|
||||||
type ChronoEntityProperties = EventObject | TaskObject
|
|
||||||
|
|
||||||
export function useChronoEntityActions(
|
|
||||||
entitiesStore: ReturnType<typeof useEntitiesStore>,
|
|
||||||
collectionsStore: ReturnType<typeof useCollectionsStore>,
|
|
||||||
) {
|
|
||||||
function getEntityData(entity: EntityObject): Record<string, unknown> {
|
|
||||||
const properties = entity.properties as ChronoEntityProperties
|
|
||||||
const now = new Date().toISOString()
|
|
||||||
|
|
||||||
properties.modified = now as never
|
|
||||||
if (!entity.identifier) {
|
|
||||||
properties.created = now as never
|
|
||||||
}
|
|
||||||
|
|
||||||
return properties.toJson() as unknown as Record<string, unknown>
|
|
||||||
}
|
|
||||||
|
|
||||||
function resolveCollection(entity: EntityObject, collection?: CollectionObject | null): CollectionObject {
|
|
||||||
if (collection instanceof CollectionObject) {
|
|
||||||
return collection
|
|
||||||
}
|
|
||||||
|
|
||||||
const foundCollection = collectionsStore.collections.find((item) => item.identifier === entity.collection)
|
|
||||||
if (!foundCollection) {
|
|
||||||
throw new Error('Collection not found for entity operation')
|
|
||||||
}
|
|
||||||
|
|
||||||
return foundCollection
|
|
||||||
}
|
|
||||||
|
|
||||||
async function saveEntity(entity: EntityObject, collection: CollectionObject): Promise<EntityObject> {
|
|
||||||
const data = getEntityData(entity)
|
|
||||||
|
|
||||||
if (!entity.identifier) {
|
|
||||||
return entitiesStore.create(
|
|
||||||
collection.provider,
|
|
||||||
collection.service,
|
|
||||||
collection.identifier,
|
|
||||||
data,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
return entitiesStore.update(
|
|
||||||
collection.provider,
|
|
||||||
collection.service,
|
|
||||||
collection.identifier,
|
|
||||||
entity.identifier,
|
|
||||||
data,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
async function deleteEntity(entity: EntityObject, collection?: CollectionObject | null): Promise<void> {
|
|
||||||
const resolvedCollection = resolveCollection(entity, collection)
|
|
||||||
|
|
||||||
await entitiesStore.delete(
|
|
||||||
resolvedCollection.provider,
|
|
||||||
resolvedCollection.service,
|
|
||||||
resolvedCollection.identifier,
|
|
||||||
entity.identifier,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
async function toggleTaskCompletion(entity: EntityObject, collection?: CollectionObject | null): Promise<EntityObject> {
|
|
||||||
const resolvedCollection = resolveCollection(entity, collection)
|
|
||||||
const taskData = entity.properties as TaskObject
|
|
||||||
const isCompleted = taskData.status === 'completed'
|
|
||||||
|
|
||||||
taskData.status = isCompleted ? 'needs-action' : 'completed'
|
|
||||||
taskData.completedOn = isCompleted ? null : new Date().toISOString() as never
|
|
||||||
taskData.progress = isCompleted ? null : 100
|
|
||||||
taskData.modified = new Date().toISOString() as never
|
|
||||||
|
|
||||||
return entitiesStore.update(
|
|
||||||
resolvedCollection.provider,
|
|
||||||
resolvedCollection.service,
|
|
||||||
resolvedCollection.identifier,
|
|
||||||
entity.identifier,
|
|
||||||
taskData.toJson(),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
saveEntity,
|
|
||||||
deleteEntity,
|
|
||||||
toggleTaskCompletion,
|
|
||||||
resolveCollection,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,21 +1,22 @@
|
|||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, onMounted, watch } from 'vue';
|
import { computed, onMounted, watch } from 'vue';
|
||||||
import { storeToRefs } from 'pinia';
|
import { storeToRefs } from 'pinia';
|
||||||
import { useRoute } from 'vue-router';
|
import { useRoute } from 'vue-router';
|
||||||
import { useDisplay } from 'vuetify';
|
|
||||||
import { useModuleStore } from '@KTXC/stores/moduleStore';
|
import { useModuleStore } from '@KTXC/stores/moduleStore';
|
||||||
import { useChronoStore } from '@/stores/chronoStore';
|
import { useChronoOperationsStore } from '@/stores/chronoOperationsStore';
|
||||||
import CollectionList from '@/components/CollectionList.vue';
|
import { useChronoSettingsStore } from '@/stores/chronoSettingsStore';
|
||||||
|
import { useChronoUiStore } from '@/stores/chronoUiStore';
|
||||||
|
import type { CalendarView as CalendarViewType } from '@/types';
|
||||||
|
import { spanToDays, type AgendaViewSpan, type DaysViewSpan } from '@/types/spans';
|
||||||
|
import { daysVisibleRange, monthGridVisibleRange } from '@/utils/date';
|
||||||
|
import Sidebar from '@/components/Sidebar.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';
|
||||||
import TaskView from '@/components/TaskView.vue';
|
import TaskView from '@/components/TaskView.vue';
|
||||||
import EventEditor from '@/components/EventEditor.vue';
|
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 ImportDialog from '@/components/ImportDialog.vue';
|
||||||
|
import { EpochSpan } from '@/types/date';
|
||||||
// Vuetify display
|
|
||||||
const display = useDisplay();
|
|
||||||
|
|
||||||
// Check if chrono manager is available
|
// Check if chrono manager is available
|
||||||
const moduleStore = useModuleStore();
|
const moduleStore = useModuleStore();
|
||||||
@@ -23,88 +24,124 @@ const isChronoManagerAvailable = computed(() => {
|
|||||||
return moduleStore.has('chrono_manager') || moduleStore.has('ChronoManager')
|
return moduleStore.has('chrono_manager') || moduleStore.has('ChronoManager')
|
||||||
});
|
});
|
||||||
|
|
||||||
const chronoStore = useChronoStore();
|
const chronoOperationsStore = useChronoOperationsStore();
|
||||||
|
const chronoSettingsStore = useChronoSettingsStore();
|
||||||
|
const chronoUiStore = useChronoUiStore();
|
||||||
const route = useRoute();
|
const route = useRoute();
|
||||||
|
|
||||||
const {
|
const {
|
||||||
calendarView,
|
calendarView,
|
||||||
daysViewSpan,
|
daysViewSpan,
|
||||||
agendaViewSpan,
|
agendaViewSpan,
|
||||||
|
} = storeToRefs(chronoSettingsStore);
|
||||||
|
|
||||||
|
const {
|
||||||
|
loading,
|
||||||
|
calendars,
|
||||||
|
taskLists,
|
||||||
|
filteredTasks,
|
||||||
|
collections,
|
||||||
|
} = storeToRefs(chronoOperationsStore);
|
||||||
|
|
||||||
|
const {
|
||||||
currentDate,
|
currentDate,
|
||||||
sidebarVisible,
|
sidebarVisible,
|
||||||
selectedCollection,
|
selectedCollection,
|
||||||
showEventEditor,
|
showEventEditor,
|
||||||
showTaskEditor,
|
showTaskEditor,
|
||||||
showCollectionEditor,
|
showCollectionEditor,
|
||||||
|
showImportDialog,
|
||||||
selectedEntity,
|
selectedEntity,
|
||||||
entityEditorMode,
|
entityEditorMode,
|
||||||
editingCollection,
|
editingCollection,
|
||||||
collectionEditorMode,
|
collectionEditorMode,
|
||||||
collectionEditorType,
|
collectionEditorType,
|
||||||
calendars,
|
isTasksSection,
|
||||||
taskLists,
|
} = storeToRefs(chronoUiStore);
|
||||||
isTaskView,
|
|
||||||
filteredEvents,
|
const { loadVisibleRange: loadVisibleRangeOperations } = chronoOperationsStore;
|
||||||
filteredTasks,
|
|
||||||
collections,
|
|
||||||
} = storeToRefs(chronoStore);
|
|
||||||
|
|
||||||
const {
|
const {
|
||||||
initialize,
|
initialize,
|
||||||
setViewMode,
|
selectSection,
|
||||||
setCalendarView,
|
selectDate,
|
||||||
setDaysViewSpan,
|
|
||||||
setAgendaViewSpan,
|
|
||||||
selectCalendar,
|
|
||||||
openEditCalendar,
|
|
||||||
toggleCalendarVisibility,
|
|
||||||
selectTaskList,
|
|
||||||
openEditTaskList,
|
|
||||||
createEventFromDate,
|
createEventFromDate,
|
||||||
startEditingSelectedEntity,
|
startEditingSelectedEntity,
|
||||||
cancelEditingSelectedEntity,
|
cancelEditingSelectedEntity,
|
||||||
closeEntityEditor,
|
closeEntityEditor,
|
||||||
editEvent,
|
editEvent,
|
||||||
editTask,
|
editTask,
|
||||||
toggleTaskComplete,
|
|
||||||
saveEvent,
|
saveEvent,
|
||||||
deleteEvent,
|
deleteEvent,
|
||||||
saveTask,
|
saveTask,
|
||||||
deleteTask,
|
deleteTask,
|
||||||
saveCollection,
|
saveCollection,
|
||||||
deleteCollection,
|
deleteCollection,
|
||||||
openCreateCalendar,
|
closeImport,
|
||||||
openCreateTaskList,
|
} = chronoUiStore;
|
||||||
} = chronoStore;
|
|
||||||
|
|
||||||
function syncViewModeFromRoute() {
|
const {
|
||||||
const routePath = route.path;
|
toggleTaskComplete,
|
||||||
const mode = routePath.endsWith('/tasks') ? 'tasks' : 'calendar';
|
ensureTasksLoaded,
|
||||||
setViewMode(mode);
|
} = chronoOperationsStore;
|
||||||
}
|
|
||||||
|
|
||||||
function handleCalendarViewChange(view: 'days' | 'month' | 'agenda') {
|
watch(() => route.fullPath, async () => {
|
||||||
setCalendarView(view);
|
selectSectionFromRoute();
|
||||||
}
|
if (isTasksSection.value) {
|
||||||
|
await ensureTasksLoaded();
|
||||||
onMounted(async () => {
|
|
||||||
try {
|
|
||||||
syncViewModeFromRoute();
|
|
||||||
await initialize();
|
|
||||||
} catch (error) {
|
|
||||||
console.error('[Chrono] - Failed to load data from ChronoManager:', error);
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
watch(() => route.fullPath, () => {
|
watch(
|
||||||
syncViewModeFromRoute();
|
[calendarView, currentDate, daysViewSpan, agendaViewSpan],
|
||||||
|
() => { loadVisibleRangeOperations(computeVisibleRange()); },
|
||||||
|
{ immediate: true },
|
||||||
|
);
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
try {
|
||||||
|
selectSectionFromRoute();
|
||||||
|
await initialize(isTasksSection.value ? 'tasks' : 'calendar');
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[Chrono] - Failed to load data from Chrono Manager:', error);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
function handleCalendarViewChange(view: CalendarViewType) {
|
||||||
|
chronoSettingsStore.calendarView = view;
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleCalendarDaysViewSpanChange(span: DaysViewSpan) {
|
||||||
|
chronoSettingsStore.daysViewSpan = span;
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleCalendarAgendaViewSpanChange(span: AgendaViewSpan) {
|
||||||
|
chronoSettingsStore.agendaViewSpan = span;
|
||||||
|
}
|
||||||
|
|
||||||
|
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));
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectSectionFromRoute() {
|
||||||
|
const routePath = route.path;
|
||||||
|
const section = routePath.endsWith('/tasks') ? 'tasks' : 'calendar';
|
||||||
|
selectSection(section);
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="chrono-container">
|
<div class="chrono-container">
|
||||||
<!-- Top Navigation Bar -->
|
<!-- Top Navigation Bar -->
|
||||||
<v-app-bar elevation="0" class="chrono-toolbar border-b">
|
<v-toolbar elevation="0" density="compact" class="chrono-toolbar">
|
||||||
<template #prepend>
|
<template #prepend>
|
||||||
<v-btn
|
<v-btn
|
||||||
icon="mdi-menu"
|
icon="mdi-menu"
|
||||||
@@ -113,16 +150,16 @@ watch(() => route.fullPath, () => {
|
|||||||
></v-btn>
|
></v-btn>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<v-app-bar-title class="d-flex align-center">
|
<v-toolbar-title class="d-flex align-center">
|
||||||
<v-icon size="28" color="primary" class="mr-2">mdi-calendar-month</v-icon>
|
<v-icon size="24" color="primary" class="mr-2">mdi-calendar-month</v-icon>
|
||||||
<span class="text-h6 font-weight-bold">Chrono</span>
|
<span class="text-subtitle-1 font-weight-bold">Chrono</span>
|
||||||
</v-app-bar-title>
|
</v-toolbar-title>
|
||||||
|
|
||||||
<v-spacer></v-spacer>
|
<v-spacer></v-spacer>
|
||||||
|
|
||||||
<!-- View Switcher for Calendar -->
|
<!-- View Switcher for Calendar -->
|
||||||
<v-btn-toggle
|
<v-btn-toggle
|
||||||
v-if="!isTaskView"
|
v-if="!isTasksSection"
|
||||||
:model-value="calendarView"
|
:model-value="calendarView"
|
||||||
color="primary"
|
color="primary"
|
||||||
variant="outlined"
|
variant="outlined"
|
||||||
@@ -135,68 +172,12 @@ watch(() => route.fullPath, () => {
|
|||||||
<v-btn value="month" size="small">Month</v-btn>
|
<v-btn value="month" size="small">Month</v-btn>
|
||||||
<v-btn value="agenda" size="small">Agenda</v-btn>
|
<v-btn value="agenda" size="small">Agenda</v-btn>
|
||||||
</v-btn-toggle>
|
</v-btn-toggle>
|
||||||
</v-app-bar>
|
</v-toolbar>
|
||||||
|
|
||||||
<!-- Main Content Area -->
|
<!-- Main Content Area -->
|
||||||
<div class="chrono-content">
|
<div class="chrono-content">
|
||||||
<!-- Sidebar Navigation Drawer -->
|
<!-- Sidebar Navigation Drawer -->
|
||||||
<v-navigation-drawer
|
<Sidebar />
|
||||||
v-model="sidebarVisible"
|
|
||||||
:permanent="display.mdAndUp.value"
|
|
||||||
:temporary="display.smAndDown.value"
|
|
||||||
width="280"
|
|
||||||
class="chrono-sidebar"
|
|
||||||
>
|
|
||||||
<div class="pa-4">
|
|
||||||
<CollectionList
|
|
||||||
v-if="!isTaskView"
|
|
||||||
:collections="collections"
|
|
||||||
:selected-collection="selectedCollection"
|
|
||||||
type="calendar"
|
|
||||||
@select="selectCalendar"
|
|
||||||
@edit="openEditCalendar"
|
|
||||||
@toggle-visibility="toggleCalendarVisibility"
|
|
||||||
/>
|
|
||||||
<CollectionList
|
|
||||||
v-else
|
|
||||||
:collections="collections"
|
|
||||||
:selected-collection="selectedCollection"
|
|
||||||
type="tasklist"
|
|
||||||
@select="selectTaskList"
|
|
||||||
@edit="openEditTaskList"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<v-btn
|
|
||||||
v-if="!isTaskView"
|
|
||||||
variant="tonal"
|
|
||||||
color="primary"
|
|
||||||
block
|
|
||||||
class="mt-3"
|
|
||||||
@click="openCreateCalendar"
|
|
||||||
>
|
|
||||||
<v-icon start>mdi-plus</v-icon>
|
|
||||||
New Calendar
|
|
||||||
</v-btn>
|
|
||||||
<v-btn
|
|
||||||
v-else
|
|
||||||
variant="tonal"
|
|
||||||
color="primary"
|
|
||||||
block
|
|
||||||
class="mt-3"
|
|
||||||
@click="openCreateTaskList"
|
|
||||||
>
|
|
||||||
<v-icon start>mdi-plus</v-icon>
|
|
||||||
New Task List
|
|
||||||
</v-btn>
|
|
||||||
|
|
||||||
<!-- Mini Calendar Widget -->
|
|
||||||
<v-card v-if="!isTaskView" class="mt-4" variant="outlined">
|
|
||||||
<v-card-text class="pa-2">
|
|
||||||
<MiniCalendar v-model="currentDate" />
|
|
||||||
</v-card-text>
|
|
||||||
</v-card>
|
|
||||||
</div>
|
|
||||||
</v-navigation-drawer>
|
|
||||||
|
|
||||||
<!-- Main Calendar/Task View -->
|
<!-- Main Calendar/Task View -->
|
||||||
<div class="chrono-main">
|
<div class="chrono-main">
|
||||||
@@ -223,27 +204,32 @@ watch(() => route.fullPath, () => {
|
|||||||
</div>
|
</div>
|
||||||
</v-alert>
|
</v-alert>
|
||||||
|
|
||||||
<CalendarView
|
<div v-if="isChronoManagerAvailable" class="chrono-wrapper">
|
||||||
v-if="!isTaskView"
|
<v-progress-linear v-if="loading" indeterminate color="primary" />
|
||||||
:view="calendarView"
|
|
||||||
:current-date="currentDate"
|
|
||||||
:events="filteredEvents"
|
|
||||||
:calendars="calendars"
|
|
||||||
:initial-days-span="daysViewSpan"
|
|
||||||
:initial-agenda-view-span="agendaViewSpan"
|
|
||||||
@event-click="editEvent"
|
|
||||||
@date-click="createEventFromDate"
|
|
||||||
@update:days-span="setDaysViewSpan"
|
|
||||||
@update:agenda-span="setAgendaViewSpan"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<TaskView
|
<CalendarView
|
||||||
v-else
|
v-if="!isTasksSection"
|
||||||
:tasks="filteredTasks"
|
:view="calendarView"
|
||||||
:lists="taskLists"
|
:current-date="currentDate"
|
||||||
@task-click="editTask"
|
:calendars="calendars"
|
||||||
@toggle-complete="toggleTaskComplete"
|
:initial-days-span="daysViewSpan"
|
||||||
/>
|
:initial-agenda-view-span="agendaViewSpan"
|
||||||
|
@event-click="editEvent"
|
||||||
|
@date-click="createEventFromDate"
|
||||||
|
@date-range-select="createEventFromDate($event.start, $event.end)"
|
||||||
|
@update:current-date="selectDate"
|
||||||
|
@update:days-span="handleCalendarDaysViewSpanChange"
|
||||||
|
@update:agenda-span="handleCalendarAgendaViewSpanChange"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<TaskView
|
||||||
|
v-else
|
||||||
|
:tasks="filteredTasks"
|
||||||
|
:lists="taskLists"
|
||||||
|
@task-click="editTask"
|
||||||
|
@toggle-complete="toggleTaskComplete"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -288,6 +274,8 @@ watch(() => route.fullPath, () => {
|
|||||||
@save="saveCollection"
|
@save="saveCollection"
|
||||||
@delete="deleteCollection"
|
@delete="deleteCollection"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<ImportDialog v-model="showImportDialog" :collections="collections" @close="closeImport" />
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -295,35 +283,38 @@ watch(() => route.fullPath, () => {
|
|||||||
.chrono-container {
|
.chrono-container {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
height: 100vh;
|
height: 100%;
|
||||||
|
min-height: 0;
|
||||||
isolation: isolate; /* Create stacking context to prevent style leakage */
|
isolation: isolate; /* Create stacking context to prevent style leakage */
|
||||||
}
|
}
|
||||||
|
|
||||||
.chrono-toolbar {
|
.chrono-toolbar {
|
||||||
|
flex-shrink: 0;
|
||||||
border-bottom: 1px solid rgb(var(--v-border-color)) !important;
|
border-bottom: 1px solid rgb(var(--v-border-color)) !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.chrono-content {
|
.chrono-content {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex: 1;
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
position: relative;
|
position: relative;
|
||||||
}
|
}
|
||||||
|
|
||||||
.chrono-sidebar {
|
|
||||||
border-right: 1px solid rgb(var(--v-border-color)) !important;
|
|
||||||
overflow-y: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
.chrono-main {
|
.chrono-main {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
overflow-y: auto;
|
min-height: 0;
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
}
|
}
|
||||||
|
|
||||||
.border-b {
|
.chrono-wrapper {
|
||||||
border-bottom: 1px solid rgb(var(--v-border-color));
|
display: flex;
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
flex-direction: column;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Responsive adjustments */
|
/* Responsive adjustments */
|
||||||
+4
-2
@@ -7,12 +7,14 @@ const routes = [
|
|||||||
{
|
{
|
||||||
name: 'calendar',
|
name: 'calendar',
|
||||||
path: '/calendar',
|
path: '/calendar',
|
||||||
component: () => import('@/pages/ChronoPage.vue'),
|
component: () => import('@/pages/Main.vue'),
|
||||||
|
meta: { layout: 'fixed' },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'tasks',
|
name: 'tasks',
|
||||||
path: '/tasks',
|
path: '/tasks',
|
||||||
component: () => import('@/pages/ChronoPage.vue'),
|
component: () => import('@/pages/Main.vue'),
|
||||||
|
meta: { layout: 'fixed' },
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,114 @@
|
|||||||
|
import { computed, shallowRef } from 'vue'
|
||||||
|
import { defineStore } from 'pinia'
|
||||||
|
import type { EntityObject } from '@ChronoManager/models/entity'
|
||||||
|
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[]>
|
||||||
|
|
||||||
|
interface ExpansionCacheEntry {
|
||||||
|
entity: EntityObject
|
||||||
|
instances: CalendarInstance[]
|
||||||
|
}
|
||||||
|
|
||||||
|
function appendToDayBuckets(byDay: InstancesByDay, instance: CalendarInstance) {
|
||||||
|
const lastOccupiedDayMs = startOfLocalDay(new Date(instance.end - 1)).getTime()
|
||||||
|
let day = new Date(instance.dayStart)
|
||||||
|
|
||||||
|
while (day.getTime() <= lastOccupiedDayMs) {
|
||||||
|
const dayKey = day.getTime()
|
||||||
|
const bucket = byDay.get(dayKey)
|
||||||
|
if (bucket) bucket.push(instance)
|
||||||
|
else byDay.set(dayKey, [instance])
|
||||||
|
day = startOfLocalDay(shiftLocalDays(day, 1))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function sortInstances(instances: CalendarInstance[]) {
|
||||||
|
instances.sort((left, right) => (
|
||||||
|
left.start - right.start
|
||||||
|
|| right.duration - left.duration
|
||||||
|
|| left.key.localeCompare(right.key)
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useChronoInstancesStore = defineStore('chronoInstancesStore', () => {
|
||||||
|
const operationsStore = useChronoOperationsStore()
|
||||||
|
|
||||||
|
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: EpochSpan | null = null
|
||||||
|
|
||||||
|
const instancesByDay = computed<InstancesByDay>(() => {
|
||||||
|
const range = visibleRange.value
|
||||||
|
if (
|
||||||
|
expansionCacheRange?.start !== range?.start
|
||||||
|
|| expansionCacheRange?.end !== range?.end
|
||||||
|
) {
|
||||||
|
expansionCache.clear()
|
||||||
|
expansionCacheRange = range
|
||||||
|
}
|
||||||
|
|
||||||
|
const present = new Set<string>()
|
||||||
|
const byDay: InstancesByDay = new Map()
|
||||||
|
|
||||||
|
for (const entity of operationsStore.filteredEvents) {
|
||||||
|
const identifier = String(entity.identifier)
|
||||||
|
present.add(identifier)
|
||||||
|
|
||||||
|
let entry = expansionCache.get(identifier)
|
||||||
|
if (!entry || entry.entity !== entity) {
|
||||||
|
entry = { entity, instances: expandEntityInstances(entity, range ?? undefined) }
|
||||||
|
expansionCache.set(identifier, entry)
|
||||||
|
}
|
||||||
|
for (const instance of entry.instances) appendToDayBuckets(byDay, instance)
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const identifier of expansionCache.keys()) {
|
||||||
|
if (!present.has(identifier)) expansionCache.delete(identifier)
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const bucket of byDay.values()) sortInstances(bucket)
|
||||||
|
|
||||||
|
return byDay
|
||||||
|
})
|
||||||
|
|
||||||
|
function instancesForDay(date: Date | number): CalendarInstance[] {
|
||||||
|
const dayKey = startOfLocalDay(new Date(date)).getTime()
|
||||||
|
return instancesByDay.value.get(dayKey) ?? []
|
||||||
|
}
|
||||||
|
|
||||||
|
function instancesForRange(range: EpochSpan): CalendarInstance[] {
|
||||||
|
if (range.end <= range.start) return []
|
||||||
|
|
||||||
|
const matches = new Map<string, CalendarInstance>()
|
||||||
|
let day = startOfLocalDay(new Date(range.start))
|
||||||
|
|
||||||
|
while (day.getTime() < range.end) {
|
||||||
|
for (const instance of instancesByDay.value.get(day.getTime()) ?? []) {
|
||||||
|
if (instance.start < range.end && instance.end > range.start) {
|
||||||
|
matches.set(instance.key, instance)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
day = startOfLocalDay(shiftLocalDays(day, 1))
|
||||||
|
}
|
||||||
|
|
||||||
|
const instances = Array.from(matches.values())
|
||||||
|
sortInstances(instances)
|
||||||
|
return instances
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
visibleRange,
|
||||||
|
instancesForDay,
|
||||||
|
instancesForRange,
|
||||||
|
}
|
||||||
|
})
|
||||||
@@ -0,0 +1,231 @@
|
|||||||
|
import { computed, ref } from 'vue'
|
||||||
|
import { defineStore } from 'pinia'
|
||||||
|
import { CollectionObject } from '@ChronoManager/models/collection'
|
||||||
|
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'
|
||||||
|
import type { ChronoSection } from '@/types'
|
||||||
|
|
||||||
|
type ChronoEntityProperties = EventObject | TaskObject
|
||||||
|
|
||||||
|
export const useChronoOperationsStore = defineStore('chronoOperationsStore', () => {
|
||||||
|
const servicesStore = useServicesStore()
|
||||||
|
const collectionsStore = useCollectionsStore()
|
||||||
|
const entitiesStore = useEntitiesStore()
|
||||||
|
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)
|
||||||
|
const calendars = computed(() => collections.value.filter(
|
||||||
|
collection => collection.properties.content?.includes('event'),
|
||||||
|
))
|
||||||
|
const taskLists = computed(() => collections.value.filter(
|
||||||
|
collection => collection.properties.content?.includes('task'),
|
||||||
|
))
|
||||||
|
const events = computed(() => entities.value.filter(
|
||||||
|
entity => entity.properties instanceof EventObject,
|
||||||
|
))
|
||||||
|
const tasks = computed(() => entities.value.filter(
|
||||||
|
entity => entity.properties instanceof TaskObject,
|
||||||
|
))
|
||||||
|
const filteredEvents = computed(() => {
|
||||||
|
const visibleCalendarIds = calendars.value
|
||||||
|
.filter(calendar => calendar.properties.visibility !== false)
|
||||||
|
.map(calendar => calendar.identifier)
|
||||||
|
|
||||||
|
return events.value.filter(event => visibleCalendarIds.includes(event.collection))
|
||||||
|
})
|
||||||
|
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()
|
||||||
|
|
||||||
|
properties.modified = now as never
|
||||||
|
if (!entity.identifier) {
|
||||||
|
properties.created = now as never
|
||||||
|
}
|
||||||
|
|
||||||
|
return properties
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveEntity(entity: EntityObject, collection: CollectionObject): Promise<EntityObject> {
|
||||||
|
const properties = prepareEntityProperties(entity).toJson()
|
||||||
|
|
||||||
|
return entity.identifier
|
||||||
|
? entitiesStore.update(entity.identifier, properties)
|
||||||
|
: entitiesStore.create(collection.identifier, properties)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteEntity(entity: EntityObject) {
|
||||||
|
await entitiesStore.delete([entity.identifier])
|
||||||
|
}
|
||||||
|
|
||||||
|
async function toggleCalendarVisibility(collection: CollectionObject) {
|
||||||
|
const updated = collection.clone()
|
||||||
|
updated.properties.visibility = collection.properties.visibility === false
|
||||||
|
await collectionsStore.update(updated.identifier, updated.properties)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function toggleTaskComplete(taskId: string | number) {
|
||||||
|
const taskEntity = tasks.value.find(task => task.identifier === taskId)
|
||||||
|
if (!taskEntity) return
|
||||||
|
|
||||||
|
const taskData = taskEntity.properties as TaskObject
|
||||||
|
const isCompleted = taskData.status === 'completed'
|
||||||
|
taskData.status = isCompleted ? 'needs-action' : 'completed'
|
||||||
|
taskData.completedOn = isCompleted ? null : new Date().toISOString() as never
|
||||||
|
taskData.progress = isCompleted ? null : 100
|
||||||
|
taskData.modified = new Date().toISOString() as never
|
||||||
|
|
||||||
|
await entitiesStore.update(taskEntity.identifier, taskData.toJson())
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveCollection(
|
||||||
|
collection: CollectionObject,
|
||||||
|
service: ServiceObject,
|
||||||
|
mode: 'create' | 'edit',
|
||||||
|
) {
|
||||||
|
if (mode === 'create') {
|
||||||
|
await collectionsStore.create(service.provider, service.identifier || '', collection.properties)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
await collectionsStore.update(collection.identifier, collection.properties)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteCollection(collection: CollectionObject) {
|
||||||
|
await collectionsStore.delete(collection.identifier)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function prepareImport(files: File[], selectedCollection: CollectionObject | null) {
|
||||||
|
importStore.removeAllFiles()
|
||||||
|
importStore.reset()
|
||||||
|
|
||||||
|
for (const file of files) {
|
||||||
|
const id = importStore.addFile({
|
||||||
|
name: file.name,
|
||||||
|
contents: await file.text(),
|
||||||
|
size: file.size,
|
||||||
|
type: file.type,
|
||||||
|
})
|
||||||
|
if (selectedCollection && collections.value.some(
|
||||||
|
collection => collection.identifier === selectedCollection.identifier,
|
||||||
|
)) {
|
||||||
|
importStore.setCollectionForFile(id, selectedCollection.identifier)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
importStore.stage = 'selecting'
|
||||||
|
}
|
||||||
|
|
||||||
|
async function finishImport(refresh = false) {
|
||||||
|
if (refresh) {
|
||||||
|
const targets = new Set<CollectionObject['identifier']>()
|
||||||
|
for (const id of importStore.order) {
|
||||||
|
const target = importStore.sessions[id]?.targetIdentifier
|
||||||
|
if (target) targets.add(target)
|
||||||
|
}
|
||||||
|
|
||||||
|
await loadVisibleRange(requestedEventRange.value, Array.from(targets))
|
||||||
|
}
|
||||||
|
|
||||||
|
importStore.removeAllFiles()
|
||||||
|
importStore.reset()
|
||||||
|
}
|
||||||
|
|
||||||
|
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(section: ChronoSection = 'calendar') {
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
if (!collectionsLoaded.value) {
|
||||||
|
await servicesStore.list()
|
||||||
|
await collectionsStore.list()
|
||||||
|
collectionsLoaded.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
if (section === '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,
|
||||||
|
entities,
|
||||||
|
calendars,
|
||||||
|
taskLists,
|
||||||
|
events,
|
||||||
|
tasks,
|
||||||
|
filteredEvents,
|
||||||
|
filteredTasks,
|
||||||
|
saveEntity,
|
||||||
|
deleteEntity,
|
||||||
|
toggleCalendarVisibility,
|
||||||
|
toggleTaskComplete,
|
||||||
|
saveCollection,
|
||||||
|
deleteCollection,
|
||||||
|
prepareImport,
|
||||||
|
finishImport,
|
||||||
|
initialize,
|
||||||
|
ensureTasksLoaded,
|
||||||
|
loadVisibleRange,
|
||||||
|
}
|
||||||
|
})
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
import { computed } from 'vue'
|
||||||
|
import { defineStore } from 'pinia'
|
||||||
|
import { useUserStore } from '@KTXC/stores/userStore'
|
||||||
|
import type { CalendarView } from '@/types'
|
||||||
|
import {
|
||||||
|
AGENDA_VIEW_SPANS,
|
||||||
|
DAYS_VIEW_SPANS,
|
||||||
|
type AgendaViewSpan,
|
||||||
|
type DaysViewSpan,
|
||||||
|
} from '@/types/spans'
|
||||||
|
|
||||||
|
const CALENDAR_VIEW_KEY = 'chrono.calendarView'
|
||||||
|
const DAYS_VIEW_SPAN_KEY = 'chrono.daysViewSpan'
|
||||||
|
const AGENDA_VIEW_SPAN_KEY = 'chrono.agendaViewSpan'
|
||||||
|
|
||||||
|
function normalizeCalendarView(value: unknown): CalendarView {
|
||||||
|
return value === 'month' || value === 'agenda' || value === 'days' ? value : 'days'
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeDaysViewSpan(value: unknown): DaysViewSpan {
|
||||||
|
return typeof value === 'string' && DAYS_VIEW_SPANS.includes(value as DaysViewSpan)
|
||||||
|
? value as DaysViewSpan
|
||||||
|
: '7d'
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeAgendaViewSpan(value: unknown): AgendaViewSpan {
|
||||||
|
return typeof value === 'string' && AGENDA_VIEW_SPANS.includes(value as AgendaViewSpan)
|
||||||
|
? value as AgendaViewSpan
|
||||||
|
: '1w'
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useChronoSettingsStore = defineStore('chronoSettingsStore', () => {
|
||||||
|
const userStore = useUserStore()
|
||||||
|
|
||||||
|
const calendarView = computed({
|
||||||
|
get: () => normalizeCalendarView(userStore.getSetting(CALENDAR_VIEW_KEY)),
|
||||||
|
set: (value: CalendarView) => userStore.setSetting(CALENDAR_VIEW_KEY, normalizeCalendarView(value)),
|
||||||
|
})
|
||||||
|
|
||||||
|
const daysViewSpan = computed({
|
||||||
|
get: () => normalizeDaysViewSpan(userStore.getSetting(DAYS_VIEW_SPAN_KEY)),
|
||||||
|
set: (value: DaysViewSpan) => userStore.setSetting(DAYS_VIEW_SPAN_KEY, normalizeDaysViewSpan(value)),
|
||||||
|
})
|
||||||
|
|
||||||
|
const agendaViewSpan = computed({
|
||||||
|
get: () => normalizeAgendaViewSpan(userStore.getSetting(AGENDA_VIEW_SPAN_KEY)),
|
||||||
|
set: (value: AgendaViewSpan) => userStore.setSetting(AGENDA_VIEW_SPAN_KEY, normalizeAgendaViewSpan(value)),
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
calendarView,
|
||||||
|
daysViewSpan,
|
||||||
|
agendaViewSpan,
|
||||||
|
}
|
||||||
|
})
|
||||||
@@ -1,424 +0,0 @@
|
|||||||
import { computed, ref, shallowRef } from 'vue'
|
|
||||||
import { defineStore } from 'pinia'
|
|
||||||
import { useUserStore } from '@KTXC/stores/userStore'
|
|
||||||
import { CollectionObject } from '@ChronoManager/models/collection'
|
|
||||||
import { EntityObject } from '@ChronoManager/models/entity'
|
|
||||||
import { EventObject } from '@ChronoManager/models/event'
|
|
||||||
import { TaskObject } from '@ChronoManager/models/task'
|
|
||||||
import { ServiceObject } from '@ChronoManager/models/service'
|
|
||||||
import { useCollectionsStore } from '@ChronoManager/stores/collectionsStore'
|
|
||||||
import { useEntitiesStore } from '@ChronoManager/stores/entitiesStore'
|
|
||||||
import type { SourceSelector } from '@ChronoManager/types'
|
|
||||||
import type { CalendarView as CalendarViewType } from '@/types'
|
|
||||||
import {
|
|
||||||
AGENDA_VIEW_SPANS,
|
|
||||||
DAYS_VIEW_SPANS,
|
|
||||||
type AgendaViewSpan,
|
|
||||||
type DaysViewSpan,
|
|
||||||
} from '@/types/spans'
|
|
||||||
import { useChronoEntityActions } from '@/composables/useChronoEntityActions'
|
|
||||||
|
|
||||||
type EntitySources = Record<string, Record<string, Record<string | number, true>>>
|
|
||||||
|
|
||||||
export const useChronoStore = defineStore('chronoStore', () => {
|
|
||||||
const userStore = useUserStore()
|
|
||||||
const collectionsStore = useCollectionsStore()
|
|
||||||
const entitiesStore = useEntitiesStore()
|
|
||||||
const entityActions = useChronoEntityActions(entitiesStore, collectionsStore)
|
|
||||||
|
|
||||||
const savedCalendarView = userStore.getSetting('chrono.calendarView')
|
|
||||||
const savedDaysViewSpan = userStore.getSetting('chrono.daysViewSpan')
|
|
||||||
const savedAgendaViewSpan = userStore.getSetting('chrono.agendaViewSpan')
|
|
||||||
|
|
||||||
const viewMode = ref<'calendar' | 'tasks'>('calendar')
|
|
||||||
const calendarView = ref<CalendarViewType>(
|
|
||||||
savedCalendarView === 'month' || savedCalendarView === 'agenda' || savedCalendarView === 'days'
|
|
||||||
? savedCalendarView
|
|
||||||
: 'days',
|
|
||||||
)
|
|
||||||
const daysViewSpan = ref<DaysViewSpan>(
|
|
||||||
typeof savedDaysViewSpan === 'string' && DAYS_VIEW_SPANS.includes(savedDaysViewSpan as DaysViewSpan)
|
|
||||||
? (savedDaysViewSpan as DaysViewSpan)
|
|
||||||
: '7d',
|
|
||||||
)
|
|
||||||
|
|
||||||
const agendaViewSpan = ref<AgendaViewSpan>(
|
|
||||||
typeof savedAgendaViewSpan === 'string' && AGENDA_VIEW_SPANS.includes(savedAgendaViewSpan as AgendaViewSpan)
|
|
||||||
? (savedAgendaViewSpan as AgendaViewSpan)
|
|
||||||
: '1w',
|
|
||||||
)
|
|
||||||
const currentDate = ref(new Date())
|
|
||||||
const sidebarVisible = ref(true)
|
|
||||||
|
|
||||||
const selectedCollection = shallowRef<CollectionObject | null>(null)
|
|
||||||
const selectedEntity = shallowRef<EntityObject | null>(null)
|
|
||||||
|
|
||||||
const showEventEditor = ref(false)
|
|
||||||
const showTaskEditor = ref(false)
|
|
||||||
const showCollectionEditor = ref(false)
|
|
||||||
|
|
||||||
const entityEditorMode = ref<'edit' | 'view'>('view')
|
|
||||||
const editingCollection = shallowRef<CollectionObject | null>(null)
|
|
||||||
const collectionEditorMode = ref<'create' | 'edit'>('create')
|
|
||||||
const collectionEditorType = ref<'calendar' | 'tasklist'>('calendar')
|
|
||||||
|
|
||||||
const loading = ref(false)
|
|
||||||
|
|
||||||
const collections = computed(() => collectionsStore.collections)
|
|
||||||
const entities = computed(() => entitiesStore.entities)
|
|
||||||
|
|
||||||
const calendars = computed(() => {
|
|
||||||
return collectionsStore.collections.filter(col => col.properties.content?.includes('event'))
|
|
||||||
})
|
|
||||||
|
|
||||||
const taskLists = computed(() => {
|
|
||||||
return collectionsStore.collections.filter(col => col.properties.content?.includes('task'))
|
|
||||||
})
|
|
||||||
|
|
||||||
const events = computed(() => {
|
|
||||||
return entities.value.filter(entity => entity.properties instanceof EventObject)
|
|
||||||
})
|
|
||||||
|
|
||||||
const tasks = computed(() => {
|
|
||||||
return entities.value.filter(entity => entity.properties instanceof TaskObject)
|
|
||||||
})
|
|
||||||
|
|
||||||
const isTaskView = computed(() => viewMode.value === 'tasks')
|
|
||||||
|
|
||||||
const filteredEvents = computed(() => {
|
|
||||||
const visibleCalendarIds = calendars.value
|
|
||||||
.filter(cal => cal.properties.visibility !== false)
|
|
||||||
.map(cal => cal.identifier)
|
|
||||||
|
|
||||||
return events.value.filter(event => visibleCalendarIds.includes(event.collection))
|
|
||||||
})
|
|
||||||
|
|
||||||
const filteredTasks = computed(() => tasks.value)
|
|
||||||
|
|
||||||
function buildSources(collectionItems: CollectionObject[]): EntitySources {
|
|
||||||
return collectionItems.reduce<EntitySources>((acc, collection) => {
|
|
||||||
if (!acc[collection.provider]) {
|
|
||||||
acc[collection.provider] = {}
|
|
||||||
}
|
|
||||||
|
|
||||||
const serviceKey = String(collection.service)
|
|
||||||
if (!acc[collection.provider][serviceKey]) {
|
|
||||||
acc[collection.provider][serviceKey] = {}
|
|
||||||
}
|
|
||||||
|
|
||||||
acc[collection.provider][serviceKey][collection.identifier] = true
|
|
||||||
return acc
|
|
||||||
}, {})
|
|
||||||
}
|
|
||||||
|
|
||||||
function setViewMode(mode: 'calendar' | 'tasks') {
|
|
||||||
viewMode.value = mode
|
|
||||||
}
|
|
||||||
|
|
||||||
function setCalendarView(view: CalendarViewType) {
|
|
||||||
calendarView.value = view
|
|
||||||
userStore.setSetting('chrono.calendarView', view)
|
|
||||||
}
|
|
||||||
|
|
||||||
function setDaysViewSpan(span: DaysViewSpan) {
|
|
||||||
if (!DAYS_VIEW_SPANS.includes(span)) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
daysViewSpan.value = span
|
|
||||||
userStore.setSetting('chrono.daysViewSpan', span)
|
|
||||||
}
|
|
||||||
|
|
||||||
function setAgendaViewSpan(span: AgendaViewSpan) {
|
|
||||||
agendaViewSpan.value = span
|
|
||||||
userStore.setSetting('chrono.agendaViewSpan', span)
|
|
||||||
}
|
|
||||||
|
|
||||||
function selectCalendar(calendar: CollectionObject) {
|
|
||||||
selectedCollection.value = calendar
|
|
||||||
}
|
|
||||||
|
|
||||||
function selectTaskList(list: CollectionObject) {
|
|
||||||
selectedCollection.value = list
|
|
||||||
}
|
|
||||||
|
|
||||||
function openCreateCalendar() {
|
|
||||||
editingCollection.value = new CollectionObject()
|
|
||||||
editingCollection.value.properties.fromJson({
|
|
||||||
...editingCollection.value.properties.toJson(),
|
|
||||||
content: ['event'],
|
|
||||||
})
|
|
||||||
collectionEditorMode.value = 'create'
|
|
||||||
collectionEditorType.value = 'calendar'
|
|
||||||
showCollectionEditor.value = true
|
|
||||||
}
|
|
||||||
|
|
||||||
function openCreateTaskList() {
|
|
||||||
editingCollection.value = new CollectionObject()
|
|
||||||
editingCollection.value.properties.fromJson({
|
|
||||||
...editingCollection.value.properties.toJson(),
|
|
||||||
content: ['task'],
|
|
||||||
})
|
|
||||||
collectionEditorMode.value = 'create'
|
|
||||||
collectionEditorType.value = 'tasklist'
|
|
||||||
showCollectionEditor.value = true
|
|
||||||
}
|
|
||||||
|
|
||||||
function openEditCalendar(collection: CollectionObject) {
|
|
||||||
editingCollection.value = collection
|
|
||||||
collectionEditorMode.value = 'edit'
|
|
||||||
collectionEditorType.value = 'calendar'
|
|
||||||
showCollectionEditor.value = true
|
|
||||||
}
|
|
||||||
|
|
||||||
function openEditTaskList(collection: CollectionObject) {
|
|
||||||
editingCollection.value = collection
|
|
||||||
collectionEditorMode.value = 'edit'
|
|
||||||
collectionEditorType.value = 'tasklist'
|
|
||||||
showCollectionEditor.value = true
|
|
||||||
}
|
|
||||||
|
|
||||||
function ensureSelectedCollection(targetType: 'calendar' | 'tasklist'): CollectionObject | null {
|
|
||||||
const list = targetType === 'calendar' ? calendars.value : taskLists.value
|
|
||||||
|
|
||||||
if (!selectedCollection.value && list.length > 0) {
|
|
||||||
selectedCollection.value = list[0]
|
|
||||||
}
|
|
||||||
|
|
||||||
return selectedCollection.value
|
|
||||||
}
|
|
||||||
|
|
||||||
function createEvent() {
|
|
||||||
const collection = ensureSelectedCollection('calendar')
|
|
||||||
if (!collection) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const entity = new EntityObject()
|
|
||||||
entity.properties = new EventObject()
|
|
||||||
selectedEntity.value = entity
|
|
||||||
entityEditorMode.value = 'edit'
|
|
||||||
showEventEditor.value = true
|
|
||||||
}
|
|
||||||
|
|
||||||
function editEvent(entity: EntityObject) {
|
|
||||||
selectedEntity.value = entity
|
|
||||||
entityEditorMode.value = 'view'
|
|
||||||
showEventEditor.value = true
|
|
||||||
}
|
|
||||||
|
|
||||||
function createTask() {
|
|
||||||
const collection = ensureSelectedCollection('tasklist')
|
|
||||||
if (!collection) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const entity = new EntityObject()
|
|
||||||
entity.properties = new TaskObject()
|
|
||||||
selectedEntity.value = entity
|
|
||||||
entityEditorMode.value = 'edit'
|
|
||||||
showTaskEditor.value = true
|
|
||||||
}
|
|
||||||
|
|
||||||
function editTask(entity: EntityObject) {
|
|
||||||
selectedEntity.value = entity
|
|
||||||
entityEditorMode.value = 'view'
|
|
||||||
showTaskEditor.value = true
|
|
||||||
}
|
|
||||||
|
|
||||||
function createEventFromDate(date: Date) {
|
|
||||||
const collection = ensureSelectedCollection('calendar')
|
|
||||||
if (!collection) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const entity = new EntityObject()
|
|
||||||
entity.properties = new EventObject()
|
|
||||||
const eventData = entity.properties as EventObject
|
|
||||||
eventData.startsOn = date.toISOString()
|
|
||||||
eventData.endsOn = new Date(date.getTime() + 60 * 60 * 1000).toISOString()
|
|
||||||
|
|
||||||
selectedEntity.value = entity
|
|
||||||
entityEditorMode.value = 'edit'
|
|
||||||
showEventEditor.value = true
|
|
||||||
}
|
|
||||||
|
|
||||||
function startEditingSelectedEntity() {
|
|
||||||
entityEditorMode.value = 'edit'
|
|
||||||
}
|
|
||||||
|
|
||||||
function cancelEditingSelectedEntity() {
|
|
||||||
entityEditorMode.value = 'view'
|
|
||||||
}
|
|
||||||
|
|
||||||
function closeEntityEditor() {
|
|
||||||
selectedEntity.value = null
|
|
||||||
entityEditorMode.value = 'view'
|
|
||||||
showEventEditor.value = false
|
|
||||||
showTaskEditor.value = false
|
|
||||||
}
|
|
||||||
|
|
||||||
async function toggleCalendarVisibility(collection: CollectionObject) {
|
|
||||||
const nextVisibility = collection.properties.visibility === false
|
|
||||||
const updated = collection.clone()
|
|
||||||
updated.properties.visibility = nextVisibility
|
|
||||||
|
|
||||||
await collectionsStore.update(
|
|
||||||
updated.provider,
|
|
||||||
updated.service,
|
|
||||||
updated.identifier,
|
|
||||||
updated.properties,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
async function saveEvent(entity: EntityObject, collection?: CollectionObject | null) {
|
|
||||||
const targetCollection = collection instanceof CollectionObject
|
|
||||||
? collection
|
|
||||||
: selectedCollection.value
|
|
||||||
|
|
||||||
if (!targetCollection) {
|
|
||||||
throw new Error('No calendar collection selected')
|
|
||||||
}
|
|
||||||
|
|
||||||
selectedEntity.value = await entityActions.saveEntity(entity, targetCollection)
|
|
||||||
selectedCollection.value = targetCollection
|
|
||||||
entityEditorMode.value = 'view'
|
|
||||||
}
|
|
||||||
|
|
||||||
async function saveTask(entity: EntityObject, collection?: CollectionObject | null) {
|
|
||||||
const targetCollection = collection instanceof CollectionObject
|
|
||||||
? collection
|
|
||||||
: selectedCollection.value
|
|
||||||
|
|
||||||
if (!targetCollection) {
|
|
||||||
throw new Error('No task list selected')
|
|
||||||
}
|
|
||||||
|
|
||||||
selectedEntity.value = await entityActions.saveEntity(entity, targetCollection)
|
|
||||||
selectedCollection.value = targetCollection
|
|
||||||
entityEditorMode.value = 'view'
|
|
||||||
}
|
|
||||||
|
|
||||||
async function deleteEvent(entity: EntityObject, collection?: CollectionObject | null) {
|
|
||||||
await entityActions.deleteEntity(entity, collection)
|
|
||||||
closeEntityEditor()
|
|
||||||
}
|
|
||||||
|
|
||||||
async function deleteTask(entity: EntityObject, collection?: CollectionObject | null) {
|
|
||||||
await entityActions.deleteEntity(entity, collection)
|
|
||||||
closeEntityEditor()
|
|
||||||
}
|
|
||||||
|
|
||||||
async function toggleTaskComplete(taskId: string | number) {
|
|
||||||
const taskEntity = tasks.value.find(task => task.identifier === taskId)
|
|
||||||
if (!taskEntity) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
await entityActions.toggleTaskCompletion(taskEntity)
|
|
||||||
}
|
|
||||||
|
|
||||||
async function saveCollection(collection: CollectionObject, service: ServiceObject) {
|
|
||||||
if (collectionEditorMode.value === 'create') {
|
|
||||||
await collectionsStore.create(
|
|
||||||
service.provider,
|
|
||||||
service.identifier || '',
|
|
||||||
null,
|
|
||||||
collection.properties,
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
await collectionsStore.update(
|
|
||||||
collection.provider,
|
|
||||||
collection.service,
|
|
||||||
collection.identifier,
|
|
||||||
collection.properties,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function deleteCollection(collection: CollectionObject) {
|
|
||||||
await collectionsStore.delete(collection.provider, collection.service, collection.identifier)
|
|
||||||
|
|
||||||
if (selectedCollection.value?.identifier === collection.identifier) {
|
|
||||||
selectedCollection.value = null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function initialize() {
|
|
||||||
loading.value = true
|
|
||||||
try {
|
|
||||||
await collectionsStore.list()
|
|
||||||
|
|
||||||
const sources = buildSources(collections.value)
|
|
||||||
if (Object.keys(sources).length > 0) {
|
|
||||||
await entitiesStore.list(sources as SourceSelector)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (
|
|
||||||
selectedCollection.value
|
|
||||||
&& !collections.value.find(collection => collection.identifier === selectedCollection.value?.identifier)
|
|
||||||
) {
|
|
||||||
selectedCollection.value = null
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
loading.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
viewMode,
|
|
||||||
calendarView,
|
|
||||||
daysViewSpan,
|
|
||||||
agendaViewSpan,
|
|
||||||
currentDate,
|
|
||||||
sidebarVisible,
|
|
||||||
selectedCollection,
|
|
||||||
selectedEntity,
|
|
||||||
showEventEditor,
|
|
||||||
showTaskEditor,
|
|
||||||
showCollectionEditor,
|
|
||||||
entityEditorMode,
|
|
||||||
editingCollection,
|
|
||||||
collectionEditorMode,
|
|
||||||
collectionEditorType,
|
|
||||||
loading,
|
|
||||||
|
|
||||||
collections,
|
|
||||||
entities,
|
|
||||||
calendars,
|
|
||||||
taskLists,
|
|
||||||
events,
|
|
||||||
tasks,
|
|
||||||
isTaskView,
|
|
||||||
filteredEvents,
|
|
||||||
filteredTasks,
|
|
||||||
|
|
||||||
setViewMode,
|
|
||||||
setCalendarView,
|
|
||||||
setDaysViewSpan,
|
|
||||||
setAgendaViewSpan,
|
|
||||||
selectCalendar,
|
|
||||||
selectTaskList,
|
|
||||||
openCreateCalendar,
|
|
||||||
openCreateTaskList,
|
|
||||||
openEditCalendar,
|
|
||||||
openEditTaskList,
|
|
||||||
createEvent,
|
|
||||||
editEvent,
|
|
||||||
createTask,
|
|
||||||
editTask,
|
|
||||||
createEventFromDate,
|
|
||||||
startEditingSelectedEntity,
|
|
||||||
cancelEditingSelectedEntity,
|
|
||||||
closeEntityEditor,
|
|
||||||
toggleCalendarVisibility,
|
|
||||||
toggleTaskComplete,
|
|
||||||
saveEvent,
|
|
||||||
deleteEvent,
|
|
||||||
saveTask,
|
|
||||||
deleteTask,
|
|
||||||
saveCollection,
|
|
||||||
deleteCollection,
|
|
||||||
initialize,
|
|
||||||
}
|
|
||||||
})
|
|
||||||
@@ -0,0 +1,210 @@
|
|||||||
|
import { computed, ref, shallowRef } from 'vue'
|
||||||
|
import { defineStore } from 'pinia'
|
||||||
|
import { CollectionObject } from '@ChronoManager/models/collection'
|
||||||
|
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 { ChronoSection } from '@/types'
|
||||||
|
import { useChronoOperationsStore } from '@/stores/chronoOperationsStore'
|
||||||
|
|
||||||
|
export const useChronoUiStore = defineStore('chronoUiStore', () => {
|
||||||
|
const operationsStore = useChronoOperationsStore()
|
||||||
|
|
||||||
|
const section = ref<ChronoSection>('calendar')
|
||||||
|
const currentDate = ref(new Date())
|
||||||
|
const sidebarVisible = ref(true)
|
||||||
|
const selectedCollection = shallowRef<CollectionObject | null>(null)
|
||||||
|
const selectedEntity = shallowRef<EntityObject | null>(null)
|
||||||
|
const showEventEditor = ref(false)
|
||||||
|
const showTaskEditor = ref(false)
|
||||||
|
const showCollectionEditor = ref(false)
|
||||||
|
const showImportDialog = ref(false)
|
||||||
|
const entityEditorMode = ref<'edit' | 'view'>('view')
|
||||||
|
const editingCollection = shallowRef<CollectionObject | null>(null)
|
||||||
|
const collectionEditorMode = ref<'create' | 'edit'>('create')
|
||||||
|
const collectionEditorType = ref<'calendar' | 'tasklist'>('calendar')
|
||||||
|
|
||||||
|
const isTasksSection = computed(() => section.value === 'tasks')
|
||||||
|
|
||||||
|
function selectSection(target: ChronoSection) {
|
||||||
|
section.value = target
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectDate(date: Date) {
|
||||||
|
currentDate.value = new Date(date)
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectCollection(collection: CollectionObject) {
|
||||||
|
selectedCollection.value = collection
|
||||||
|
}
|
||||||
|
|
||||||
|
function openCollectionEditor(type: 'calendar' | 'tasklist', collection?: CollectionObject) {
|
||||||
|
const target = collection ?? new CollectionObject()
|
||||||
|
if (!collection) {
|
||||||
|
target.properties.fromJson({
|
||||||
|
...target.properties.toJson(),
|
||||||
|
content: [type === 'calendar' ? 'event' : 'task'],
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
editingCollection.value = target
|
||||||
|
collectionEditorMode.value = collection ? 'edit' : 'create'
|
||||||
|
collectionEditorType.value = type
|
||||||
|
showCollectionEditor.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
function openEntityEditor(entity: EntityObject, type: 'event' | 'task', mode: 'edit' | 'view') {
|
||||||
|
selectedEntity.value = entity
|
||||||
|
entityEditorMode.value = mode
|
||||||
|
showEventEditor.value = type === 'event'
|
||||||
|
showTaskEditor.value = type === 'task'
|
||||||
|
}
|
||||||
|
|
||||||
|
function createEventFromDate(start?: Date, end?: Date) {
|
||||||
|
const entity = new EntityObject()
|
||||||
|
entity.properties = new EventObject()
|
||||||
|
if (start) {
|
||||||
|
entity.properties.startsOn = start.toISOString()
|
||||||
|
entity.properties.endsOn = (end ?? new Date(start.getTime() + 60 * 60 * 1000)).toISOString()
|
||||||
|
}
|
||||||
|
openEntityEditor(entity, 'event', 'edit')
|
||||||
|
}
|
||||||
|
|
||||||
|
function createEvent() {
|
||||||
|
createEventFromDate()
|
||||||
|
}
|
||||||
|
|
||||||
|
function editEvent(entity: EntityObject) {
|
||||||
|
openEntityEditor(entity, 'event', 'view')
|
||||||
|
}
|
||||||
|
|
||||||
|
function createTask() {
|
||||||
|
const entity = new EntityObject()
|
||||||
|
entity.properties = new TaskObject()
|
||||||
|
openEntityEditor(entity, 'task', 'edit')
|
||||||
|
}
|
||||||
|
|
||||||
|
function editTask(entity: EntityObject) {
|
||||||
|
openEntityEditor(entity, 'task', 'view')
|
||||||
|
}
|
||||||
|
|
||||||
|
function startEditingSelectedEntity() {
|
||||||
|
entityEditorMode.value = 'edit'
|
||||||
|
}
|
||||||
|
|
||||||
|
function cancelEditingSelectedEntity() {
|
||||||
|
entityEditorMode.value = 'view'
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeEntityEditor() {
|
||||||
|
selectedEntity.value = null
|
||||||
|
entityEditorMode.value = 'view'
|
||||||
|
showEventEditor.value = false
|
||||||
|
showTaskEditor.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveSelectedEntity(entity: EntityObject, collection: CollectionObject | null | undefined, kind: string) {
|
||||||
|
const targetCollection = collection instanceof CollectionObject ? collection : selectedCollection.value
|
||||||
|
if (!targetCollection) throw new Error(`No ${kind} collection selected`)
|
||||||
|
|
||||||
|
selectedEntity.value = await operationsStore.saveEntity(entity, targetCollection)
|
||||||
|
selectedCollection.value = targetCollection
|
||||||
|
entityEditorMode.value = 'view'
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveEvent(entity: EntityObject, collection?: CollectionObject | null) {
|
||||||
|
await saveSelectedEntity(entity, collection, 'calendar')
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveTask(entity: EntityObject, collection?: CollectionObject | null) {
|
||||||
|
await saveSelectedEntity(entity, collection, 'task list')
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteSelectedEntity(entity: EntityObject, collection: CollectionObject | null | undefined, kind: string) {
|
||||||
|
if (!(collection instanceof CollectionObject) && !selectedCollection.value) {
|
||||||
|
throw new Error(`No ${kind} collection selected`)
|
||||||
|
}
|
||||||
|
await operationsStore.deleteEntity(entity)
|
||||||
|
closeEntityEditor()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteEvent(entity: EntityObject, collection?: CollectionObject | null) {
|
||||||
|
await deleteSelectedEntity(entity, collection, 'calendar')
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteTask(entity: EntityObject, collection?: CollectionObject | null) {
|
||||||
|
await deleteSelectedEntity(entity, collection, 'task list')
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveCollection(collection: CollectionObject, service: ServiceObject) {
|
||||||
|
await operationsStore.saveCollection(collection, service, collectionEditorMode.value)
|
||||||
|
showCollectionEditor.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteCollection(collection: CollectionObject) {
|
||||||
|
await operationsStore.deleteCollection(collection)
|
||||||
|
if (selectedCollection.value?.identifier === collection.identifier) {
|
||||||
|
selectedCollection.value = null
|
||||||
|
selectedEntity.value = null
|
||||||
|
}
|
||||||
|
showCollectionEditor.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
async function openImport(files: File[]) {
|
||||||
|
await operationsStore.prepareImport(files, selectedCollection.value)
|
||||||
|
showImportDialog.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
async function closeImport(refresh = false) {
|
||||||
|
showImportDialog.value = false
|
||||||
|
await operationsStore.finishImport(refresh)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function initialize(initialSection: ChronoSection = 'calendar') {
|
||||||
|
await operationsStore.initialize(initialSection)
|
||||||
|
if (selectedCollection.value && !operationsStore.collections.some(
|
||||||
|
collection => collection.identifier === selectedCollection.value?.identifier,
|
||||||
|
)) {
|
||||||
|
selectedCollection.value = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
section,
|
||||||
|
currentDate,
|
||||||
|
sidebarVisible,
|
||||||
|
selectedCollection,
|
||||||
|
selectedEntity,
|
||||||
|
showEventEditor,
|
||||||
|
showTaskEditor,
|
||||||
|
showCollectionEditor,
|
||||||
|
showImportDialog,
|
||||||
|
entityEditorMode,
|
||||||
|
editingCollection,
|
||||||
|
collectionEditorMode,
|
||||||
|
collectionEditorType,
|
||||||
|
isTasksSection,
|
||||||
|
selectSection,
|
||||||
|
selectDate,
|
||||||
|
selectCollection,
|
||||||
|
openCollectionEditor,
|
||||||
|
createEvent,
|
||||||
|
editEvent,
|
||||||
|
createTask,
|
||||||
|
editTask,
|
||||||
|
createEventFromDate,
|
||||||
|
startEditingSelectedEntity,
|
||||||
|
cancelEditingSelectedEntity,
|
||||||
|
closeEntityEditor,
|
||||||
|
saveEvent,
|
||||||
|
deleteEvent,
|
||||||
|
saveTask,
|
||||||
|
deleteTask,
|
||||||
|
saveCollection,
|
||||||
|
deleteCollection,
|
||||||
|
openImport,
|
||||||
|
closeImport,
|
||||||
|
initialize,
|
||||||
|
}
|
||||||
|
})
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
export interface EpochSpan {
|
||||||
|
start: number;
|
||||||
|
end: number;
|
||||||
|
}
|
||||||
@@ -5,6 +5,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
// Local UI-specific types
|
// Local UI-specific types
|
||||||
|
export type ChronoSection = 'calendar' | 'tasks';
|
||||||
export type CalendarView = 'days' | 'month' | 'agenda';
|
export type CalendarView = 'days' | 'month' | 'agenda';
|
||||||
|
|
||||||
export interface ViewState {
|
export interface ViewState {
|
||||||
|
|||||||
@@ -0,0 +1,15 @@
|
|||||||
|
import type { EntityObject } from '@ChronoManager/models/entity'
|
||||||
|
|
||||||
|
export interface CalendarInstance {
|
||||||
|
key: string
|
||||||
|
entity: EntityObject
|
||||||
|
instanceId: string | null
|
||||||
|
start: number
|
||||||
|
end: number
|
||||||
|
dayStart: number
|
||||||
|
duration: number
|
||||||
|
timeless: boolean
|
||||||
|
multiDay: boolean
|
||||||
|
label: string | null
|
||||||
|
color: string | null
|
||||||
|
}
|
||||||
@@ -1,193 +0,0 @@
|
|||||||
/**
|
|
||||||
* Calendar Helper Utilities
|
|
||||||
* Shared logic for multi-day event handling across calendar views
|
|
||||||
*/
|
|
||||||
|
|
||||||
export interface MultiDaySegment {
|
|
||||||
entity: any;
|
|
||||||
startCol: number;
|
|
||||||
span: number;
|
|
||||||
lane: number;
|
|
||||||
isStart: boolean;
|
|
||||||
isEnd: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface LaneAssignment {
|
|
||||||
segments: MultiDaySegment[];
|
|
||||||
laneCount: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get the start of day (midnight) for a given date
|
|
||||||
*/
|
|
||||||
export function startOfDay(date: Date): Date {
|
|
||||||
const d = new Date(date);
|
|
||||||
d.setHours(0, 0, 0, 0);
|
|
||||||
return d;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Calculate the difference in days between two dates
|
|
||||||
*/
|
|
||||||
export function daysDiff(a: Date, b: Date): number {
|
|
||||||
const msPerDay = 24 * 60 * 60 * 1000;
|
|
||||||
return Math.round((startOfDay(b).getTime() - startOfDay(a).getTime()) / msPerDay);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Check if an event spans multiple days
|
|
||||||
*/
|
|
||||||
export function isMultiDay(entity: any): boolean {
|
|
||||||
if (!entity.properties?.startsOn) return false;
|
|
||||||
const start = startOfDay(new Date(entity.properties.startsOn));
|
|
||||||
const end = entity.properties?.endsOn ? startOfDay(new Date(entity.properties.endsOn)) : start;
|
|
||||||
return daysDiff(start, end) > 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Check if an event is an all-day event (no specific time, or spans full day)
|
|
||||||
*/
|
|
||||||
export function isAllDay(entity: any): boolean {
|
|
||||||
if (!entity.properties?.startsOn) return false;
|
|
||||||
|
|
||||||
// Explicit all-day flag
|
|
||||||
if (entity.properties?.timeless === true) return true;
|
|
||||||
|
|
||||||
// Multi-day events are treated as all-day in the days view
|
|
||||||
if (isMultiDay(entity)) return true;
|
|
||||||
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Check if an event overlaps with a date range
|
|
||||||
*/
|
|
||||||
export function eventOverlapsRange(entity: any, rangeStart: Date, rangeEnd: Date): boolean {
|
|
||||||
if (!entity.properties?.startsOn) return false;
|
|
||||||
const eventStart = startOfDay(new Date(entity.properties.startsOn));
|
|
||||||
const eventEnd = entity.properties?.endsOn ? startOfDay(new Date(entity.properties.endsOn)) : eventStart;
|
|
||||||
return eventStart <= rangeEnd && eventEnd >= rangeStart;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Check if an event occurs on a specific date
|
|
||||||
*/
|
|
||||||
export function eventOnDate(entity: any, date: Date): boolean {
|
|
||||||
if (!entity.properties?.startsOn) return false;
|
|
||||||
const eventStart = startOfDay(new Date(entity.properties.startsOn));
|
|
||||||
const eventEnd = entity.properties?.endsOn ? startOfDay(new Date(entity.properties.endsOn)) : eventStart;
|
|
||||||
const targetDate = startOfDay(date);
|
|
||||||
return eventStart <= targetDate && eventEnd >= targetDate;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get multi-day event segments for a given date range with lane assignments
|
|
||||||
* Used for rendering spanning events across columns
|
|
||||||
*/
|
|
||||||
export function getMultiDaySegments(
|
|
||||||
events: any[],
|
|
||||||
rangeStart: Date,
|
|
||||||
rangeEnd: Date,
|
|
||||||
columnCount: number,
|
|
||||||
getColumnIndex: (date: Date) => number
|
|
||||||
): LaneAssignment {
|
|
||||||
const segments: MultiDaySegment[] = [];
|
|
||||||
|
|
||||||
// Filter multi-day/all-day events that overlap this range
|
|
||||||
const multiDayEvents = events.filter(entity => {
|
|
||||||
if (!entity.properties?.startsOn) return false;
|
|
||||||
if (!isAllDay(entity)) return false;
|
|
||||||
return eventOverlapsRange(entity, rangeStart, rangeEnd);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Sort: longer events first, then by start date
|
|
||||||
multiDayEvents.sort((a, b) => {
|
|
||||||
const aStart = new Date(a.properties.startsOn);
|
|
||||||
const aEnd = new Date(a.properties.endsOn || a.properties.startsOn);
|
|
||||||
const bStart = new Date(b.properties.startsOn);
|
|
||||||
const bEnd = new Date(b.properties.endsOn || b.properties.startsOn);
|
|
||||||
const aDuration = daysDiff(aStart, aEnd);
|
|
||||||
const bDuration = daysDiff(bStart, bEnd);
|
|
||||||
if (bDuration !== aDuration) return bDuration - aDuration;
|
|
||||||
return aStart.getTime() - bStart.getTime();
|
|
||||||
});
|
|
||||||
|
|
||||||
// Lane assignment - track which days are occupied in each lane
|
|
||||||
const lanes: boolean[][] = [];
|
|
||||||
|
|
||||||
for (const entity of multiDayEvents) {
|
|
||||||
const eventStart = startOfDay(new Date(entity.properties.startsOn));
|
|
||||||
const eventEnd = entity.properties?.endsOn ? startOfDay(new Date(entity.properties.endsOn)) : eventStart;
|
|
||||||
|
|
||||||
// Clamp to visible range
|
|
||||||
const segStart = eventStart < rangeStart ? rangeStart : eventStart;
|
|
||||||
const segEnd = eventEnd > rangeEnd ? rangeEnd : eventEnd;
|
|
||||||
|
|
||||||
const startCol = getColumnIndex(segStart);
|
|
||||||
const endCol = getColumnIndex(segEnd);
|
|
||||||
|
|
||||||
// Skip if outside visible columns
|
|
||||||
if (startCol < 0 || startCol >= columnCount) continue;
|
|
||||||
|
|
||||||
const span = Math.min(endCol, columnCount - 1) - startCol + 1;
|
|
||||||
|
|
||||||
// Find available lane
|
|
||||||
let lane = 0;
|
|
||||||
while (true) {
|
|
||||||
if (!lanes[lane]) {
|
|
||||||
lanes[lane] = new Array(columnCount).fill(false);
|
|
||||||
}
|
|
||||||
|
|
||||||
let isFree = true;
|
|
||||||
for (let d = startCol; d < startCol + span; d++) {
|
|
||||||
if (lanes[lane][d]) {
|
|
||||||
isFree = false;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isFree) {
|
|
||||||
for (let d = startCol; d < startCol + span; d++) {
|
|
||||||
lanes[lane][d] = true;
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
lane++;
|
|
||||||
}
|
|
||||||
|
|
||||||
segments.push({
|
|
||||||
entity,
|
|
||||||
startCol,
|
|
||||||
span,
|
|
||||||
lane,
|
|
||||||
isStart: eventStart >= rangeStart,
|
|
||||||
isEnd: eventEnd <= rangeEnd,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return { segments, laneCount: lanes.length };
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get timed (non-all-day) events for a specific date
|
|
||||||
*/
|
|
||||||
export function getTimedEventsForDate(events: any[], date: Date): any[] {
|
|
||||||
return events.filter(entity => {
|
|
||||||
if (!entity.properties?.startsOn) return false;
|
|
||||||
if (isAllDay(entity)) return false;
|
|
||||||
const eventDate = startOfDay(new Date(entity.properties.startsOn));
|
|
||||||
return eventDate.toDateString() === date.toDateString();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get single-day events for a specific date (used in month view)
|
|
||||||
*/
|
|
||||||
export function getSingleDayEventsForDate(events: any[], date: Date): any[] {
|
|
||||||
return events.filter(entity => {
|
|
||||||
if (!entity.properties?.startsOn) return false;
|
|
||||||
if (isMultiDay(entity)) return false;
|
|
||||||
const eventDate = startOfDay(new Date(entity.properties.startsOn));
|
|
||||||
return eventDate.toDateString() === date.toDateString();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
import { EpochSpan } from '@/types/date'
|
||||||
|
|
||||||
|
export function parseCalendarDate(value: string | null | undefined): Date | null {
|
||||||
|
if (!value) return null
|
||||||
|
|
||||||
|
const parts = /^(\d{4})-(\d{2})-(\d{2})/.exec(value)
|
||||||
|
if (!parts) return null
|
||||||
|
|
||||||
|
const year = Number(parts[1])
|
||||||
|
const month = Number(parts[2]) - 1
|
||||||
|
const day = Number(parts[3])
|
||||||
|
const date = new Date(year, month, day)
|
||||||
|
|
||||||
|
return date.getFullYear() === year && date.getMonth() === month && date.getDate() === day
|
||||||
|
? date
|
||||||
|
: null
|
||||||
|
}
|
||||||
|
|
||||||
|
export function startOfLocalDay(date: Date): Date {
|
||||||
|
const start = new Date(date)
|
||||||
|
start.setHours(0, 0, 0, 0)
|
||||||
|
return start
|
||||||
|
}
|
||||||
|
|
||||||
|
export function shiftLocalDays(date: Date, days: number): Date {
|
||||||
|
const shifted = new Date(date)
|
||||||
|
shifted.setDate(shifted.getDate() + days)
|
||||||
|
return shifted
|
||||||
|
}
|
||||||
|
|
||||||
|
export function shiftLocalMonths(date: Date, months: number): Date {
|
||||||
|
const shifted = new Date(date)
|
||||||
|
const targetDay = shifted.getDate()
|
||||||
|
|
||||||
|
shifted.setDate(1)
|
||||||
|
shifted.setMonth(shifted.getMonth() + months)
|
||||||
|
|
||||||
|
const lastDayOfTargetMonth = new Date(
|
||||||
|
shifted.getFullYear(),
|
||||||
|
shifted.getMonth() + 1,
|
||||||
|
0,
|
||||||
|
).getDate()
|
||||||
|
shifted.setDate(Math.min(targetDay, lastDayOfTargetMonth))
|
||||||
|
|
||||||
|
return shifted
|
||||||
|
}
|
||||||
|
|
||||||
|
export function daysVisibleRange(date: Date, days: number): EpochSpan {
|
||||||
|
const start = startOfLocalDay(date)
|
||||||
|
const end = shiftLocalDays(start, Math.max(1, days))
|
||||||
|
|
||||||
|
return {
|
||||||
|
start: start.getTime(),
|
||||||
|
end: end.getTime(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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())
|
||||||
|
|
||||||
|
const lastDay = new Date(date.getFullYear(), date.getMonth() + 1, 0)
|
||||||
|
const weeks = Math.ceil((firstDay.getDay() + lastDay.getDate()) / 7)
|
||||||
|
const gridEnd = shiftLocalDays(gridStart, weeks * 7)
|
||||||
|
|
||||||
|
return {
|
||||||
|
start: gridStart.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' })
|
||||||
|
}
|
||||||
@@ -0,0 +1,104 @@
|
|||||||
|
/**
|
||||||
|
* Multi-Day Lane Layout
|
||||||
|
* Assigns multi-day/all-day instances to horizontal lanes so they render
|
||||||
|
* as spanning bars across day columns without overlapping.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { CalendarInstance } from '@/types/instance'
|
||||||
|
import { startOfLocalDay } from './date'
|
||||||
|
|
||||||
|
export interface MultiDaySegment {
|
||||||
|
instance: CalendarInstance;
|
||||||
|
startCol: number;
|
||||||
|
span: number;
|
||||||
|
lane: number;
|
||||||
|
isStart: boolean;
|
||||||
|
isEnd: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LaneAssignment {
|
||||||
|
segments: MultiDaySegment[];
|
||||||
|
laneCount: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function layoutMultiDayLanes(
|
||||||
|
instances: CalendarInstance[],
|
||||||
|
rangeStart: Date,
|
||||||
|
rangeEnd: Date,
|
||||||
|
columnCount: number,
|
||||||
|
getColumnIndex: (date: Date) => number
|
||||||
|
): LaneAssignment {
|
||||||
|
const segments: MultiDaySegment[] = [];
|
||||||
|
|
||||||
|
// Filter multi-day/all-day events that overlap this range
|
||||||
|
const rangeStartMs = startOfLocalDay(rangeStart).getTime();
|
||||||
|
const rangeEndExclusive = startOfLocalDay(new Date(rangeEnd));
|
||||||
|
rangeEndExclusive.setDate(rangeEndExclusive.getDate() + 1);
|
||||||
|
|
||||||
|
const multiDayEvents = instances.filter(instance => {
|
||||||
|
return instance.timeless
|
||||||
|
&& instance.start < rangeEndExclusive.getTime()
|
||||||
|
&& instance.end > rangeStartMs;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Sort: longer events first, then by start date
|
||||||
|
multiDayEvents.sort((a, b) => {
|
||||||
|
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.start));
|
||||||
|
const eventEnd = startOfLocalDay(new Date(instance.end - 1));
|
||||||
|
|
||||||
|
// Clamp to visible range
|
||||||
|
const segStart = eventStart < rangeStart ? rangeStart : eventStart;
|
||||||
|
const segEnd = eventEnd > rangeEnd ? rangeEnd : eventEnd;
|
||||||
|
|
||||||
|
const startCol = getColumnIndex(segStart);
|
||||||
|
const endCol = getColumnIndex(segEnd);
|
||||||
|
|
||||||
|
// Skip if outside visible columns
|
||||||
|
if (startCol < 0 || startCol >= columnCount) continue;
|
||||||
|
|
||||||
|
const span = Math.min(endCol, columnCount - 1) - startCol + 1;
|
||||||
|
|
||||||
|
// Find available lane
|
||||||
|
let lane = 0;
|
||||||
|
while (true) {
|
||||||
|
if (!lanes[lane]) {
|
||||||
|
lanes[lane] = new Array(columnCount).fill(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
let isFree = true;
|
||||||
|
for (let d = startCol; d < startCol + span; d++) {
|
||||||
|
if (lanes[lane][d]) {
|
||||||
|
isFree = false;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isFree) {
|
||||||
|
for (let d = startCol; d < startCol + span; d++) {
|
||||||
|
lanes[lane][d] = true;
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
lane++;
|
||||||
|
}
|
||||||
|
|
||||||
|
segments.push({
|
||||||
|
instance,
|
||||||
|
startCol,
|
||||||
|
span,
|
||||||
|
lane,
|
||||||
|
isStart: eventStart >= rangeStart,
|
||||||
|
isEnd: eventEnd <= rangeEnd,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return { segments, laneCount: lanes.length };
|
||||||
|
}
|
||||||
@@ -0,0 +1,325 @@
|
|||||||
|
import type { EntityObject } from '@ChronoManager/models/entity'
|
||||||
|
import type { EventObject } from '@ChronoManager/models/event'
|
||||||
|
import type { EventMutationObject } from '@ChronoManager/models/event-mutation'
|
||||||
|
import type { CalendarInstance } from '../types/instance'
|
||||||
|
import { parseCalendarDate, shiftLocalDays, startOfLocalDay } from './date'
|
||||||
|
import { EpochSpan } from '@/types/date'
|
||||||
|
|
||||||
|
const MINIMUM_DURATION_MS = 1
|
||||||
|
|
||||||
|
function timestamp(value: string | null | undefined): number | null {
|
||||||
|
if (!value) return null
|
||||||
|
const result = Date.parse(value)
|
||||||
|
return Number.isFinite(result) ? result : null
|
||||||
|
}
|
||||||
|
|
||||||
|
function floatingDateTimestamp(value: string | null | undefined): number | null {
|
||||||
|
return parseCalendarDate(value)?.getTime() ?? null
|
||||||
|
}
|
||||||
|
|
||||||
|
function eventTimestamp(value: string | null | undefined, timeless: boolean): number | null {
|
||||||
|
return timeless ? floatingDateTimestamp(value) : timestamp(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
function conclusionTimestamp(value: string | null | undefined): number | null {
|
||||||
|
if (!value) return null
|
||||||
|
|
||||||
|
const dateOnly = /^(\d{4})-(\d{2})-(\d{2})$/.exec(value)
|
||||||
|
if (dateOnly) {
|
||||||
|
return new Date(
|
||||||
|
Number(dateOnly[1]),
|
||||||
|
Number(dateOnly[2]) - 1,
|
||||||
|
Number(dateOnly[3]),
|
||||||
|
23,
|
||||||
|
59,
|
||||||
|
59,
|
||||||
|
999,
|
||||||
|
).getTime()
|
||||||
|
}
|
||||||
|
|
||||||
|
return timestamp(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
function overlapsRange(start: number, end: number, range?: EpochSpan): boolean {
|
||||||
|
return !range || (start < range.end && end > range.start)
|
||||||
|
}
|
||||||
|
|
||||||
|
function createInstance(
|
||||||
|
entity: EntityObject,
|
||||||
|
sourceStartMs: number,
|
||||||
|
effectiveStartMs: number,
|
||||||
|
effectiveEndMs: number,
|
||||||
|
recurring: boolean,
|
||||||
|
timeless: boolean,
|
||||||
|
mutation?: EventMutationObject,
|
||||||
|
): CalendarInstance {
|
||||||
|
const event = entity.properties as EventObject
|
||||||
|
const endMs = effectiveEndMs > effectiveStartMs
|
||||||
|
? effectiveEndMs
|
||||||
|
: effectiveStartMs + MINIMUM_DURATION_MS
|
||||||
|
const dayStartMs = startOfLocalDay(new Date(effectiveStartMs)).getTime()
|
||||||
|
const lastOccupiedDayMs = startOfLocalDay(new Date(endMs - 1)).getTime()
|
||||||
|
const instanceId = recurring ? new Date(sourceStartMs).toISOString() : null
|
||||||
|
|
||||||
|
return {
|
||||||
|
key: `${String(entity.identifier)}:${instanceId ?? 'master'}`,
|
||||||
|
entity,
|
||||||
|
instanceId,
|
||||||
|
start: effectiveStartMs,
|
||||||
|
end: effectiveEndMs,
|
||||||
|
dayStart: dayStartMs,
|
||||||
|
duration: endMs - effectiveStartMs,
|
||||||
|
timeless: timeless || lastOccupiedDayMs !== dayStartMs,
|
||||||
|
multiDay: lastOccupiedDayMs !== dayStartMs,
|
||||||
|
label: mutation?.label ?? event.label,
|
||||||
|
color: mutation?.color ?? event.color,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function mutationMap(event: EventObject, timeless: boolean): Map<number, EventMutationObject> {
|
||||||
|
const mutations = new Map<number, EventMutationObject>()
|
||||||
|
|
||||||
|
for (const [key, mutation] of Object.entries(event.mutations ?? {})) {
|
||||||
|
const mutationId = eventTimestamp(mutation.mutationId, timeless)
|
||||||
|
?? eventTimestamp(key, timeless)
|
||||||
|
if (mutationId !== null) mutations.set(mutationId, mutation)
|
||||||
|
}
|
||||||
|
|
||||||
|
return mutations
|
||||||
|
}
|
||||||
|
|
||||||
|
function materializeInstance(
|
||||||
|
entity: EntityObject,
|
||||||
|
sourceStartMs: number,
|
||||||
|
durationMs: number,
|
||||||
|
durationDays: number | null,
|
||||||
|
recurring: boolean,
|
||||||
|
mutation: EventMutationObject | undefined,
|
||||||
|
range?: EpochSpan,
|
||||||
|
): CalendarInstance | null {
|
||||||
|
if (mutation?.mutationExclusion === true) return null
|
||||||
|
|
||||||
|
const event = entity.properties as EventObject
|
||||||
|
const timeless = mutation?.timeless ?? event.timeless === true
|
||||||
|
const effectiveStartMs = eventTimestamp(mutation?.startsOn, timeless) ?? sourceStartMs
|
||||||
|
const defaultEndMs = timeless && durationDays !== null
|
||||||
|
? shiftLocalDays(new Date(effectiveStartMs), durationDays).getTime()
|
||||||
|
: effectiveStartMs + durationMs
|
||||||
|
const effectiveEndMs = eventTimestamp(mutation?.endsOn, timeless) ?? defaultEndMs
|
||||||
|
if (!overlapsRange(effectiveStartMs, effectiveEndMs, range)) return null
|
||||||
|
|
||||||
|
return createInstance(
|
||||||
|
entity,
|
||||||
|
sourceStartMs,
|
||||||
|
effectiveStartMs,
|
||||||
|
effectiveEndMs,
|
||||||
|
recurring,
|
||||||
|
timeless,
|
||||||
|
mutation,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function localDayNumber(date: Date): number {
|
||||||
|
return Math.floor(Date.UTC(date.getFullYear(), date.getMonth(), date.getDate()) / 86_400_000)
|
||||||
|
}
|
||||||
|
|
||||||
|
function dailyStartIndex(
|
||||||
|
start: Date,
|
||||||
|
interval: number,
|
||||||
|
durationMs: number,
|
||||||
|
range: EpochSpan,
|
||||||
|
): number {
|
||||||
|
const earliestRelevant = new Date(range.start - durationMs)
|
||||||
|
const elapsedDays = localDayNumber(earliestRelevant) - localDayNumber(start)
|
||||||
|
return Math.max(0, Math.floor(elapsedDays / interval) - 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
function weeklyStartIndex(
|
||||||
|
weekStart: Date,
|
||||||
|
interval: number,
|
||||||
|
durationMs: number,
|
||||||
|
range: EpochSpan,
|
||||||
|
): number {
|
||||||
|
const earliestRelevant = new Date(range.start - durationMs)
|
||||||
|
const elapsedDays = localDayNumber(earliestRelevant) - localDayNumber(weekStart)
|
||||||
|
return Math.max(0, Math.floor(elapsedDays / (interval * 7)) - 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizedWeekdays(values: number[] | null, fallback: number): number[] {
|
||||||
|
const weekdays = (values?.length ? values : [fallback])
|
||||||
|
.filter(value => Number.isInteger(value) && value >= 1 && value <= 7)
|
||||||
|
return Array.from(new Set(weekdays)).sort((left, right) => left - right)
|
||||||
|
}
|
||||||
|
|
||||||
|
function expandDaily(
|
||||||
|
entity: EntityObject,
|
||||||
|
start: Date,
|
||||||
|
duration: number,
|
||||||
|
durationDays: number | null,
|
||||||
|
range: EpochSpan,
|
||||||
|
mutations: Map<number, EventMutationObject>,
|
||||||
|
): CalendarInstance[] {
|
||||||
|
const pattern = (entity.properties as EventObject).pattern!
|
||||||
|
const interval = Math.max(1, Math.trunc(pattern.interval || 1))
|
||||||
|
const maximumOccurrences = pattern.iterations && pattern.iterations > 0
|
||||||
|
? Math.trunc(pattern.iterations)
|
||||||
|
: null
|
||||||
|
const concludesMs = conclusionTimestamp(pattern.concludes)
|
||||||
|
const weekdays = pattern.onDayOfWeek?.length
|
||||||
|
? new Set(normalizedWeekdays(pattern.onDayOfWeek, start.getDay() || 7))
|
||||||
|
: null
|
||||||
|
let step = maximumOccurrences === null || pattern.onDayOfWeek?.length === 0
|
||||||
|
? dailyStartIndex(start, interval, duration, range)
|
||||||
|
: 0
|
||||||
|
let occurrenceCount = weekdays ? 0 : step
|
||||||
|
const instances: CalendarInstance[] = []
|
||||||
|
|
||||||
|
while (true) {
|
||||||
|
const candidate = shiftLocalDays(start, step * interval)
|
||||||
|
const candidateMs = candidate.getTime()
|
||||||
|
if (candidateMs >= range.end) break
|
||||||
|
if (concludesMs !== null && candidateMs > concludesMs) break
|
||||||
|
|
||||||
|
if (!weekdays || weekdays.has(candidate.getDay() || 7)) {
|
||||||
|
occurrenceCount += 1
|
||||||
|
if (maximumOccurrences !== null && occurrenceCount > maximumOccurrences) break
|
||||||
|
|
||||||
|
const instance = materializeInstance(
|
||||||
|
entity,
|
||||||
|
candidateMs,
|
||||||
|
duration,
|
||||||
|
durationDays,
|
||||||
|
true,
|
||||||
|
mutations.get(candidateMs),
|
||||||
|
range,
|
||||||
|
)
|
||||||
|
if (instance) instances.push(instance)
|
||||||
|
}
|
||||||
|
|
||||||
|
step += 1
|
||||||
|
}
|
||||||
|
|
||||||
|
return instances
|
||||||
|
}
|
||||||
|
|
||||||
|
function expandWeekly(
|
||||||
|
entity: EntityObject,
|
||||||
|
start: Date,
|
||||||
|
durationMs: number,
|
||||||
|
durationDays: number | null,
|
||||||
|
range: EpochSpan,
|
||||||
|
mutations: Map<number, EventMutationObject>,
|
||||||
|
): CalendarInstance[] {
|
||||||
|
const pattern = (entity.properties as EventObject).pattern!
|
||||||
|
const interval = Math.max(1, Math.trunc(pattern.interval || 1))
|
||||||
|
const maximumOccurrences = pattern.iterations && pattern.iterations > 0
|
||||||
|
? Math.trunc(pattern.iterations)
|
||||||
|
: null
|
||||||
|
const concludesMs = conclusionTimestamp(pattern.concludes)
|
||||||
|
const weekdays = normalizedWeekdays(pattern.onDayOfWeek, start.getDay() || 7)
|
||||||
|
const weekStart = shiftLocalDays(start, -((start.getDay() || 7) - 1))
|
||||||
|
let week = maximumOccurrences === null
|
||||||
|
? weeklyStartIndex(weekStart, interval, durationMs, range)
|
||||||
|
: 0
|
||||||
|
let occurrenceCount = maximumOccurrences === null ? week * weekdays.length : 0
|
||||||
|
const instances: CalendarInstance[] = []
|
||||||
|
|
||||||
|
while (true) {
|
||||||
|
const intervalWeekStart = shiftLocalDays(weekStart, week * interval * 7)
|
||||||
|
if (intervalWeekStart.getTime() >= range.end) break
|
||||||
|
|
||||||
|
for (const weekday of weekdays) {
|
||||||
|
const candidate = shiftLocalDays(intervalWeekStart, weekday - 1)
|
||||||
|
const candidateMs = candidate.getTime()
|
||||||
|
if (candidateMs < start.getTime()) continue
|
||||||
|
if (candidateMs >= range.end) return instances
|
||||||
|
if (concludesMs !== null && candidateMs > concludesMs) return instances
|
||||||
|
|
||||||
|
occurrenceCount += 1
|
||||||
|
if (maximumOccurrences !== null && occurrenceCount > maximumOccurrences) return instances
|
||||||
|
|
||||||
|
const instance = materializeInstance(
|
||||||
|
entity,
|
||||||
|
candidateMs,
|
||||||
|
durationMs,
|
||||||
|
durationDays,
|
||||||
|
true,
|
||||||
|
mutations.get(candidateMs),
|
||||||
|
range,
|
||||||
|
)
|
||||||
|
if (instance) instances.push(instance)
|
||||||
|
}
|
||||||
|
|
||||||
|
week += 1
|
||||||
|
}
|
||||||
|
|
||||||
|
return instances
|
||||||
|
}
|
||||||
|
|
||||||
|
function appendMovedMutations(
|
||||||
|
instances: CalendarInstance[],
|
||||||
|
entity: EntityObject,
|
||||||
|
durationMs: number,
|
||||||
|
durationDays: number | null,
|
||||||
|
range: VisibleDateRange,
|
||||||
|
mutations: Map<number, EventMutationObject>,
|
||||||
|
) {
|
||||||
|
const present = new Set(instances.map(instance => instance.key))
|
||||||
|
|
||||||
|
for (const [sourceStartMs, mutation] of mutations) {
|
||||||
|
const instance = materializeInstance(
|
||||||
|
entity,
|
||||||
|
sourceStartMs,
|
||||||
|
durationMs,
|
||||||
|
durationDays,
|
||||||
|
true,
|
||||||
|
mutation,
|
||||||
|
range,
|
||||||
|
)
|
||||||
|
if (instance && !present.has(instance.key)) {
|
||||||
|
instances.push(instance)
|
||||||
|
present.add(instance.key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function expandEntityInstances(
|
||||||
|
entity: EntityObject,
|
||||||
|
range?: EpochSpan,
|
||||||
|
): CalendarInstance[] {
|
||||||
|
const event = entity.properties as EventObject
|
||||||
|
const timeless = event.timeless === true
|
||||||
|
const startMs = eventTimestamp(event.startsOn, timeless)
|
||||||
|
if (startMs === null) return []
|
||||||
|
|
||||||
|
const parsedEndMs = eventTimestamp(event.endsOn, timeless)
|
||||||
|
const durationMs = parsedEndMs !== null && parsedEndMs > startMs
|
||||||
|
? parsedEndMs - startMs
|
||||||
|
: MINIMUM_DURATION_MS
|
||||||
|
const durationDays = timeless && parsedEndMs !== null && parsedEndMs > startMs
|
||||||
|
? Math.max(1, localDayNumber(new Date(parsedEndMs)) - localDayNumber(new Date(startMs)))
|
||||||
|
: null
|
||||||
|
const pattern = event.pattern
|
||||||
|
|
||||||
|
if (!pattern || !range || (pattern.precision !== 'daily' && pattern.precision !== 'weekly')) {
|
||||||
|
const instance = materializeInstance(
|
||||||
|
entity,
|
||||||
|
startMs,
|
||||||
|
durationMs,
|
||||||
|
durationDays,
|
||||||
|
false,
|
||||||
|
undefined,
|
||||||
|
range,
|
||||||
|
)
|
||||||
|
return instance ? [instance] : []
|
||||||
|
}
|
||||||
|
|
||||||
|
const mutations = mutationMap(event, timeless)
|
||||||
|
const instances = pattern.precision === 'daily'
|
||||||
|
? expandDaily(entity, new Date(startMs), durationMs, durationDays, range, mutations)
|
||||||
|
: expandWeekly(entity, new Date(startMs), durationMs, durationDays, range, mutations)
|
||||||
|
|
||||||
|
appendMovedMutations(instances, entity, durationMs, durationDays, range, mutations)
|
||||||
|
instances.sort((left, right) => left.start - right.start || left.key.localeCompare(right.key))
|
||||||
|
return instances
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
import { describe, it, expect } from 'vitest'
|
||||||
|
|
||||||
|
describe('Basic Tests', () => {
|
||||||
|
it('should perform basic assertion', () => {
|
||||||
|
expect(true).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should test array operations', () => {
|
||||||
|
const array = ['foo', 'bar', 'baz']
|
||||||
|
|
||||||
|
expect(array).toHaveLength(3)
|
||||||
|
expect(array).toContain('bar')
|
||||||
|
expect(array[0]).toBe('foo')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should test string operations', () => {
|
||||||
|
const string = 'Hello, World!'
|
||||||
|
|
||||||
|
expect(string).toContain('World')
|
||||||
|
expect(string.length).toBe(13)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should test object operations', () => {
|
||||||
|
const obj = { foo: 'bar', count: 42 }
|
||||||
|
|
||||||
|
expect(obj).toHaveProperty('foo')
|
||||||
|
expect(obj.foo).toBe('bar')
|
||||||
|
expect(obj.count).toBeGreaterThan(40)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
import {
|
||||||
|
daysVisibleRange,
|
||||||
|
monthGridVisibleRange,
|
||||||
|
shiftLocalDays,
|
||||||
|
shiftLocalMonths,
|
||||||
|
} from '../../../src/utils/date'
|
||||||
|
|
||||||
|
describe('calendar visible ranges', () => {
|
||||||
|
it('uses a half-open range for a day span', () => {
|
||||||
|
const range = daysVisibleRange(new Date(2026, 5, 15, 13, 45), 7)
|
||||||
|
const start = new Date(range.start)
|
||||||
|
const end = new Date(range.end)
|
||||||
|
|
||||||
|
expect([start.getFullYear(), start.getMonth(), start.getDate(), start.getHours()])
|
||||||
|
.toEqual([2026, 5, 15, 0])
|
||||||
|
expect([end.getFullYear(), end.getMonth(), end.getDate(), end.getHours()])
|
||||||
|
.toEqual([2026, 5, 22, 0])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('uses calendar-day arithmetic across a daylight-saving boundary', () => {
|
||||||
|
const range = daysVisibleRange(new Date(2026, 2, 8, 12), 1)
|
||||||
|
const start = new Date(range.start)
|
||||||
|
const end = new Date(range.end)
|
||||||
|
|
||||||
|
expect(start.getHours()).toBe(0)
|
||||||
|
expect(end.getHours()).toBe(0)
|
||||||
|
expect(end.getDate()).toBe(start.getDate() + 1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('includes every leading and trailing day rendered by a month grid', () => {
|
||||||
|
const range = monthGridVisibleRange(new Date(2026, 5, 15))
|
||||||
|
const start = new Date(range.start)
|
||||||
|
const end = new Date(range.end)
|
||||||
|
|
||||||
|
expect([start.getFullYear(), start.getMonth(), start.getDate()]).toEqual([2026, 4, 31])
|
||||||
|
expect([end.getFullYear(), end.getMonth(), end.getDate()]).toEqual([2026, 6, 5])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('calculates a leap-year February grid', () => {
|
||||||
|
const range = monthGridVisibleRange(new Date(2024, 1, 29))
|
||||||
|
const start = new Date(range.start)
|
||||||
|
const end = new Date(range.end)
|
||||||
|
|
||||||
|
expect([start.getFullYear(), start.getMonth(), start.getDate()]).toEqual([2024, 0, 28])
|
||||||
|
expect([end.getFullYear(), end.getMonth(), end.getDate()]).toEqual([2024, 2, 3])
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('calendar navigation', () => {
|
||||||
|
it('clamps month navigation instead of skipping a shorter month', () => {
|
||||||
|
const shifted = shiftLocalMonths(new Date(2026, 0, 31, 9), 1)
|
||||||
|
|
||||||
|
expect([shifted.getFullYear(), shifted.getMonth(), shifted.getDate(), shifted.getHours()])
|
||||||
|
.toEqual([2026, 1, 28, 9])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('uses calendar-day navigation', () => {
|
||||||
|
const shifted = shiftLocalDays(new Date(2026, 5, 30, 9), 1)
|
||||||
|
|
||||||
|
expect([shifted.getFullYear(), shifted.getMonth(), shifted.getDate(), shifted.getHours()])
|
||||||
|
.toEqual([2026, 6, 1, 9])
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
import type { EntityObject } from '../../../../chrono_manager/src/models/entity'
|
||||||
|
import { daysVisibleRange } from '../../../src/utils/date'
|
||||||
|
import { expandEntityInstances } from '../../../src/utils/recurrence'
|
||||||
|
|
||||||
|
function localIso(year: number, month: number, day: number, hour = 0): string {
|
||||||
|
return new Date(year, month, day, hour).toISOString()
|
||||||
|
}
|
||||||
|
|
||||||
|
function eventEntity(
|
||||||
|
identifier: string,
|
||||||
|
startsOn: string | null,
|
||||||
|
endsOn: string | null,
|
||||||
|
timeless = false,
|
||||||
|
): EntityObject {
|
||||||
|
return {
|
||||||
|
identifier,
|
||||||
|
properties: {
|
||||||
|
startsOn,
|
||||||
|
endsOn,
|
||||||
|
timeless,
|
||||||
|
},
|
||||||
|
} as unknown as EntityObject
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('entity instance expansion', () => {
|
||||||
|
it('normalizes timestamps and duration once', () => {
|
||||||
|
const [instance] = expandEntityInstances(
|
||||||
|
eventEntity('meeting', localIso(2026, 5, 15, 9), localIso(2026, 5, 15, 10)),
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(instance.duration).toBe(60 * 60 * 1000)
|
||||||
|
expect(new Date(instance.start).getHours()).toBe(9)
|
||||||
|
expect(new Date(instance.dayStart).getHours()).toBe(0)
|
||||||
|
expect(instance.instanceId).toBeNull()
|
||||||
|
expect(instance.multiDay).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('produces no instance for an invalid start timestamp', () => {
|
||||||
|
expect(expandEntityInstances(eventEntity('invalid', 'not-a-date', null))).toEqual([])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('marks an overnight event as multi-day', () => {
|
||||||
|
const [instance] = expandEntityInstances(
|
||||||
|
eventEntity('overnight', localIso(2026, 5, 15, 23), localIso(2026, 5, 16, 1)),
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(instance.multiDay).toBe(true)
|
||||||
|
expect(instance.timeless).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('honors an exclusive midnight end', () => {
|
||||||
|
const [instance] = expandEntityInstances(
|
||||||
|
eventEntity('all-day', localIso(2026, 5, 20), localIso(2026, 5, 21), true),
|
||||||
|
)
|
||||||
|
|
||||||
|
const end = new Date(instance.end)
|
||||||
|
expect([end.getDate(), end.getHours()]).toEqual([21, 0])
|
||||||
|
expect(new Date(instance.dayStart).getDate()).toBe(20)
|
||||||
|
expect(instance.multiDay).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('treats UTC timestamps on timeless events as floating calendar dates', () => {
|
||||||
|
const [instance] = expandEntityInstances(
|
||||||
|
eventEntity('utc-all-day', '2025-03-27T00:00:00+00:00', '2025-03-28T00:00:00+00:00', true),
|
||||||
|
)
|
||||||
|
|
||||||
|
const start = new Date(instance.start)
|
||||||
|
expect([start.getMonth(), start.getDate(), start.getHours()]).toEqual([2, 27, 0])
|
||||||
|
expect(new Date(instance.end).getDate()).toBe(28)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('gives a zero-duration event a queryable minimum duration', () => {
|
||||||
|
const [instance] = expandEntityInstances(
|
||||||
|
eventEntity('instant', localIso(2026, 5, 15, 9), null),
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(instance.duration).toBe(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('omits a non-recurring event outside the requested range', () => {
|
||||||
|
const instances = expandEntityInstances(
|
||||||
|
eventEntity('outside', localIso(2026, 6, 1, 9), localIso(2026, 6, 1, 10)),
|
||||||
|
daysVisibleRange(new Date(2026, 5, 15), 2),
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(instances).toEqual([])
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,186 @@
|
|||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
import type { EntityObject } from '../../../../chrono_manager/src/models/entity'
|
||||||
|
import type { EventOccurrence, EventMutation } from '../../../../chrono_manager/src/types/event'
|
||||||
|
import { daysVisibleRange } from '../../../src/utils/date'
|
||||||
|
import { expandEntityInstances } from '../../../src/utils/recurrence'
|
||||||
|
|
||||||
|
function localIso(year: number, month: number, day: number, hour = 9): string {
|
||||||
|
return new Date(year, month, day, hour).toISOString()
|
||||||
|
}
|
||||||
|
|
||||||
|
function recurringEntity(
|
||||||
|
pattern: EventOccurrence,
|
||||||
|
mutations: Record<string, Partial<EventMutation>> = {},
|
||||||
|
startsOn = localIso(2026, 5, 1),
|
||||||
|
): EntityObject {
|
||||||
|
return {
|
||||||
|
identifier: 'calendar:event-1',
|
||||||
|
properties: {
|
||||||
|
startsOn,
|
||||||
|
endsOn: new Date(new Date(startsOn).getTime() + 60 * 60 * 1000).toISOString(),
|
||||||
|
timeless: false,
|
||||||
|
label: 'Standup',
|
||||||
|
color: '#123456',
|
||||||
|
pattern,
|
||||||
|
mutations,
|
||||||
|
},
|
||||||
|
} as unknown as EntityObject
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('daily recurrence', () => {
|
||||||
|
it('expands interval rules only inside the requested range', () => {
|
||||||
|
const entity = recurringEntity({
|
||||||
|
pattern: 'absolute',
|
||||||
|
precision: 'daily',
|
||||||
|
interval: 2,
|
||||||
|
})
|
||||||
|
const instances = expandEntityInstances(
|
||||||
|
entity,
|
||||||
|
daysVisibleRange(new Date(2026, 5, 4), 6),
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(instances.map(instance => new Date(instance.start).getDate())).toEqual([5, 7, 9])
|
||||||
|
expect(instances.every(instance => instance.duration === 60 * 60 * 1000)).toBe(true)
|
||||||
|
expect(new Set(instances.map(instance => instance.key)).size).toBe(3)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('honors iteration limits before the requested range', () => {
|
||||||
|
const entity = recurringEntity({
|
||||||
|
pattern: 'absolute',
|
||||||
|
precision: 'daily',
|
||||||
|
interval: 1,
|
||||||
|
iterations: 3,
|
||||||
|
})
|
||||||
|
const instances = expandEntityInstances(
|
||||||
|
entity,
|
||||||
|
daysVisibleRange(new Date(2026, 5, 10), 3),
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(instances).toEqual([])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('treats a date-only conclusion as an inclusive local date', () => {
|
||||||
|
const entity = recurringEntity({
|
||||||
|
pattern: 'absolute',
|
||||||
|
precision: 'daily',
|
||||||
|
interval: 1,
|
||||||
|
concludes: '2026-06-03',
|
||||||
|
})
|
||||||
|
const instances = expandEntityInstances(
|
||||||
|
entity,
|
||||||
|
daysVisibleRange(new Date(2026, 5, 1), 5),
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(instances.map(instance => new Date(instance.start).getDate())).toEqual([1, 2, 3])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('preserves local wall-clock time across daylight-saving changes', () => {
|
||||||
|
const entity = recurringEntity({
|
||||||
|
pattern: 'absolute',
|
||||||
|
precision: 'daily',
|
||||||
|
interval: 1,
|
||||||
|
}, {}, localIso(2026, 2, 7, 9))
|
||||||
|
const instances = expandEntityInstances(
|
||||||
|
entity,
|
||||||
|
daysVisibleRange(new Date(2026, 2, 7), 3),
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(instances.map(instance => new Date(instance.start).getHours())).toEqual([9, 9, 9])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('preserves exclusive calendar-day duration for recurring timeless events', () => {
|
||||||
|
const entity = recurringEntity({
|
||||||
|
pattern: 'absolute',
|
||||||
|
precision: 'daily',
|
||||||
|
interval: 1,
|
||||||
|
}, {}, '2026-03-07T00:00:00+00:00')
|
||||||
|
const event = entity.properties as unknown as {
|
||||||
|
endsOn: string
|
||||||
|
timeless: boolean
|
||||||
|
}
|
||||||
|
event.endsOn = '2026-03-08T00:00:00+00:00'
|
||||||
|
event.timeless = true
|
||||||
|
|
||||||
|
const instances = expandEntityInstances(
|
||||||
|
entity,
|
||||||
|
daysVisibleRange(new Date(2026, 2, 7), 3),
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(instances).toHaveLength(3)
|
||||||
|
expect(instances.map(instance => new Date(instance.start).getHours())).toEqual([0, 0, 0])
|
||||||
|
expect(instances.map(instance => {
|
||||||
|
const end = new Date(instance.end)
|
||||||
|
return [end.getDate(), end.getHours()]
|
||||||
|
})).toEqual([[8, 0], [9, 0], [10, 0]])
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('weekly recurrence', () => {
|
||||||
|
it('expands selected ISO weekdays in chronological order', () => {
|
||||||
|
const entity = recurringEntity({
|
||||||
|
pattern: 'absolute',
|
||||||
|
precision: 'weekly',
|
||||||
|
interval: 1,
|
||||||
|
onDayOfWeek: [1, 3],
|
||||||
|
})
|
||||||
|
const instances = expandEntityInstances(
|
||||||
|
entity,
|
||||||
|
daysVisibleRange(new Date(2026, 5, 1), 10),
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(instances.map(instance => new Date(instance.start).getDate())).toEqual([1, 3, 8, 10])
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('recurrence mutations', () => {
|
||||||
|
it('removes excluded occurrences', () => {
|
||||||
|
const excludedId = localIso(2026, 5, 2)
|
||||||
|
const entity = recurringEntity({
|
||||||
|
pattern: 'absolute',
|
||||||
|
precision: 'daily',
|
||||||
|
interval: 1,
|
||||||
|
}, {
|
||||||
|
[excludedId]: {
|
||||||
|
mutationId: excludedId,
|
||||||
|
mutationExclusion: true,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
const instances = expandEntityInstances(
|
||||||
|
entity,
|
||||||
|
daysVisibleRange(new Date(2026, 5, 1), 3),
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(instances.map(instance => new Date(instance.start).getDate())).toEqual([1, 3])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('moves and restyles a modified occurrence without duplicating it', () => {
|
||||||
|
const sourceId = localIso(2026, 5, 2)
|
||||||
|
const movedStart = localIso(2026, 5, 4, 14)
|
||||||
|
const entity = recurringEntity({
|
||||||
|
pattern: 'absolute',
|
||||||
|
precision: 'daily',
|
||||||
|
interval: 1,
|
||||||
|
iterations: 2,
|
||||||
|
}, {
|
||||||
|
[sourceId]: {
|
||||||
|
mutationId: sourceId,
|
||||||
|
mutationExclusion: false,
|
||||||
|
startsOn: movedStart,
|
||||||
|
endsOn: new Date(new Date(movedStart).getTime() + 2 * 60 * 60 * 1000).toISOString(),
|
||||||
|
label: 'Moved standup',
|
||||||
|
color: '#abcdef',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
const instances = expandEntityInstances(
|
||||||
|
entity,
|
||||||
|
daysVisibleRange(new Date(2026, 5, 1), 5),
|
||||||
|
)
|
||||||
|
const moved = instances.find(instance => instance.instanceId === sourceId)
|
||||||
|
|
||||||
|
expect(instances).toHaveLength(2)
|
||||||
|
expect(new Date(moved!.start).getHours()).toBe(14)
|
||||||
|
expect(moved!.duration).toBe(2 * 60 * 60 * 1000)
|
||||||
|
expect(moved!.label).toBe('Moved standup')
|
||||||
|
expect(moved!.color).toBe('#abcdef')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
import { fileURLToPath } from 'node:url'
|
||||||
|
import { defineConfig, configDefaults } from 'vitest/config'
|
||||||
|
import vue from '@vitejs/plugin-vue'
|
||||||
|
import path from 'path'
|
||||||
|
|
||||||
|
const __filename = fileURLToPath(import.meta.url)
|
||||||
|
const __dirname = path.dirname(__filename)
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [vue()],
|
||||||
|
resolve: {
|
||||||
|
alias: {
|
||||||
|
'@': path.resolve(__dirname, '../../src'),
|
||||||
|
'@KTXC': path.resolve(__dirname, '../../../../core/src'),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
test: {
|
||||||
|
environment: 'jsdom',
|
||||||
|
exclude: [...configDefaults.exclude, 'e2e/**'],
|
||||||
|
root: fileURLToPath(new URL('../../', import.meta.url)),
|
||||||
|
coverage: {
|
||||||
|
provider: 'v8',
|
||||||
|
reporter: ['text', 'json', 'html'],
|
||||||
|
exclude: [
|
||||||
|
'node_modules/',
|
||||||
|
'tests/',
|
||||||
|
'**/*.d.ts',
|
||||||
|
'**/*.config.*',
|
||||||
|
'**/dist/**',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user