81 Commits

Author SHA1 Message Date
Sebastian f245de16d6 chore(deps): update dependency jsdom to v30
Build Test / test (pull_request) Successful in 39s
JS Unit Tests / test (pull_request) Successful in 40s
2026-07-28 03:08:12 +00:00
Sebastian ed534b0fb7 Merge pull request 'fix(deps): update dependency pinia to v4' (#46) from renovate/pinia-4.x into main
Reviewed-on: #46
2026-07-25 23:53:26 +00:00
Sebastian 8060153db2 fix(deps): update dependency pinia to v4
Build Test / test (pull_request) Successful in 54s
JS Unit Tests / test (pull_request) Successful in 55s
2026-07-25 03:03:33 +00:00
Sebastian 52c8247671 Merge pull request 'fix: checkout companion module in CI for cross-module type imports' (#44) from chore/js-test-suites into main
Reviewed-on: #44
2026-07-24 05:03:15 +00:00
Sebastian 398f770694 fix: regenerate package-lock.json to fix corrupted vue dependency tree
Build Test / test (pull_request) Successful in 36s
JS Unit Tests / test (pull_request) Successful in 36s
Signed-off-by: Sebastian Krupinski <krupinski01@gmail.com>
2026-07-24 01:01:27 -04:00
Sebastian 2d21833833 fix: checkout companion module in CI for cross-module type imports
JS Unit Tests / test (pull_request) Failing after 31s
Build Test / test (pull_request) Failing after 34s
Signed-off-by: Sebastian Krupinski <krupinski01@gmail.com>
2026-07-24 00:50:42 -04:00
Sebastian f3aa93dfa3 Merge pull request 'chore: implement js test suites' (#43) from chore/js-test-suites into main
Reviewed-on: #43
2026-07-24 04:25:12 +00:00
Sebastian 844bbd0ad0 chore: implement js test suites
Build Test / test (pull_request) Successful in 34s
JS Unit Tests / test (pull_request) Successful in 41s
Signed-off-by: Sebastian Krupinski <krupinski01@gmail.com>
2026-07-24 00:13:45 -04:00
Sebastian 6569767258 Merge pull request 'chore(deps): update dependency vue to v3.5.40' (#41) from renovate/vue-monorepo into main
Reviewed-on: #41
2026-07-24 03:17:46 +00:00
Sebastian 833584ae8d Merge pull request 'chore(deps): update dependency vuetify to v4.1.6' (#42) from renovate/vuetify-4.x-lockfile into main
Reviewed-on: #42
2026-07-24 03:17:38 +00:00
Sebastian 967d93658a chore(deps): update dependency vuetify to v4.1.6 2026-07-24 03:16:13 +00:00
Sebastian 598c1754c7 chore(deps): update dependency vue to v3.5.40 2026-07-24 03:16:07 +00:00
Sebastian 22775b1f32 Merge pull request 'chore: implement php test suites' (#40) from chore/php83-test-suites into main
Reviewed-on: #40
2026-07-24 02:25:29 +00:00
Sebastian af0350592d chore: implement php test suites
Signed-off-by: Sebastian Krupinski <krupinski01@gmail.com>
2026-07-23 22:20:36 -04:00
Sebastian bd6440a653 Merge pull request 'chore(deps): update dependency vite to v8.1.5' (#35) from renovate/vite-8.x-lockfile into main
Reviewed-on: #35
2026-07-23 20:02:32 +00:00
Sebastian cea1a71b77 Merge pull request 'chore(deps): update dependency vuetify to v4.1.5' (#37) from renovate/vuetify-4.x-lockfile into main
Reviewed-on: #37
2026-07-23 20:02:23 +00:00
Sebastian f62bb28d81 Merge pull request 'chore(deps): update dependency typescript to v7' (#38) from renovate/typescript-7.x into main
Reviewed-on: #38
2026-07-23 20:02:14 +00:00
Sebastian 7fab1c2d9d Merge pull request 'chore(deps): update dependency @vitejs/plugin-vue to v6.0.8' (#39) from renovate/vitejs-plugin-vue-6.x-lockfile into main
Reviewed-on: #39
2026-07-23 20:01:56 +00:00
Sebastian 63246931a7 Merge pull request 'chore(deps): update dependency vue-tsc to v3.3.8' (#36) from renovate/vue-language-tools-monorepo into main
Reviewed-on: #36
2026-07-23 20:01:49 +00:00
Sebastian 2ceba8c7e2 chore(deps): update dependency vue-tsc to v3.3.8 2026-07-23 03:07:52 +00:00
Sebastian 9e48e3823a chore(deps): update dependency vite to v8.1.5 2026-07-23 03:07:50 +00:00
Sebastian f8a51d4fd6 chore(deps): update dependency vuetify to v4.1.5 2026-07-18 03:05:44 +00:00
Sebastian f5a6cef6f8 chore(deps): update dependency @vitejs/plugin-vue to v6.0.8 2026-07-18 03:05:34 +00:00
Sebastian 822acf7612 refactor: rename main entry point
Signed-off-by: Sebastian Krupinski <krupinski01@gmail.com>
2026-07-14 17:40:15 -04:00
Sebastian a6af22859a refactor: add navigaton panel component
Signed-off-by: Sebastian Krupinski <krupinski01@gmail.com>
2026-07-14 17:18:08 -04:00
Sebastian 01afdf4860 refactor: standardize module tool bar
Signed-off-by: Sebastian Krupinski <krupinski01@gmail.com>
2026-07-13 22:03:49 -04:00
Sebastian a62c2f135e chore(deps): update dependency typescript to v7 2026-07-11 03:05:48 +00:00
Sebastian 327b23c238 Track Composer dependencies
Signed-off-by: Sebastian Krupinski <krupinski01@gmail.com>
2026-07-09 22:56:21 -04:00
Sebastian 4b782ff078 fix(chrono): fit workspace within private layout
Signed-off-by: Sebastian Krupinski <krupinski01@gmail.com>
2026-07-09 21:20:30 -04:00
Sebastian 38bd5a097a fix(renovate): install renovate in runner temp
Signed-off-by: Sebastian Krupinski <krupinski01@gmail.com>
2026-07-09 20:12:07 -04:00
Sebastian c38fad8b32 Merge pull request 'chore(deps): update dependency vite to v8.1.3' (#33) from renovate/vite-8.x-lockfile into main
Reviewed-on: #33
2026-07-09 23:50:39 +00:00
Sebastian 532a83c24e Merge pull request 'chore(deps): update dependency vue-tsc to v3.3.6' (#34) from renovate/vue-language-tools-monorepo into main
Reviewed-on: #34
2026-07-09 23:50:34 +00:00
Sebastian 992825c291 feat: create event with click and drag
Signed-off-by: Sebastian Krupinski <krupinski01@gmail.com>
2026-07-07 21:36:50 -04:00
Sebastian 019b0b6367 feat: show edit on hover only
Signed-off-by: Sebastian Krupinski <krupinski01@gmail.com>
2026-07-07 21:04:07 -04:00
Sebastian 8bbcc9e8b7 fix: remove duplicate colour indicator
Signed-off-by: Sebastian Krupinski <krupinski01@gmail.com>
2026-07-07 21:01:23 -04:00
Sebastian 8010d4805f feat: unify collection creation
Signed-off-by: Sebastian Krupinski <krupinski01@gmail.com>
2026-07-07 20:58:24 -04:00
Sebastian 41a2de2b3d refactor: code clean up
Signed-off-by: Sebastian Krupinski <krupinski01@gmail.com>
2026-07-07 20:48:20 -04:00
Sebastian 3c0ea698b1 refactor: code clean up
Signed-off-by: Sebastian Krupinski <krupinski01@gmail.com>
2026-07-06 22:42:31 -04:00
Sebastian df531908f3 refactor: code clean up
Signed-off-by: Sebastian Krupinski <krupinski01@gmail.com>
2026-07-06 21:09:07 -04:00
Sebastian 61626f50e5 chore(deps): update dependency vue-tsc to v3.3.6 2026-07-05 03:02:58 +00:00
Sebastian 421e763633 chore(deps): update dependency vite to v8.1.3 2026-07-05 03:02:55 +00:00
Sebastian dea5a61811 chore: fix tests
Signed-off-by: Sebastian Krupinski <krupinski01@gmail.com>
2026-07-03 21:08:51 -04:00
Sebastian bdac31e8e7 feat: make event over flow show on hover
Signed-off-by: Sebastian Krupinski <krupinski01@gmail.com>
2026-07-03 20:23:50 -04:00
Sebastian 1ffbfc0c3d fix: month view day events
Signed-off-by: Sebastian Krupinski <krupinski01@gmail.com>
2026-07-03 19:42:59 -04:00
Sebastian 47c9a9027b fix: month view events list
Signed-off-by: Sebastian Krupinski <krupinski01@gmail.com>
2026-07-03 19:37:47 -04:00
Sebastian 37fa7b911e refactor: event instances
Signed-off-by: Sebastian Krupinski <krupinski01@gmail.com>
2026-07-03 19:10:00 -04:00
Sebastian acbe0ad5ca feat: recurrence expansion.
Signed-off-by: Sebastian Krupinski <krupinski01@gmail.com>
2026-06-29 22:19:38 -04:00
Sebastian cd270e9e99 feat: build the occurrence index
Signed-off-by: Sebastian Krupinski <krupinski01@gmail.com>
2026-06-29 21:52:09 -04:00
Sebastian 51bf195664 feat: visible date range
Signed-off-by: Sebastian Krupinski <krupinski01@gmail.com>
2026-06-29 21:41:27 -04:00
Sebastian 18a96f5dab chore: add .ideas folder to ignore list
Signed-off-by: Sebastian Krupinski <krupinski01@gmail.com>
2026-06-29 21:05:24 -04:00
Sebastian 31d20448be refactor: split uni store to per responsiblity store
Signed-off-by: Sebastian Krupinski <krupinski01@gmail.com>
2026-06-29 20:41:01 -04:00
Sebastian 460c31e224 fix: implement recurrance configuration properly
Signed-off-by: Sebastian Krupinski <krupinski01@gmail.com>
2026-06-29 15:29:10 -04:00
Sebastian ee9495487e feat: import
Signed-off-by: Sebastian Krupinski <krupinski01@gmail.com>
2026-06-28 18:06:45 -04:00
Sebastian 6657b8a0db refactor: use unified manager design
Signed-off-by: Sebastian Krupinski <krupinski01@gmail.com>
2026-06-28 14:53:11 -04:00
Sebastian aa96aa4cb4 Merge pull request 'chore(deps): update dependency vue to v3.5.39' (#31) from renovate/vue-monorepo into main
Reviewed-on: #31
2026-06-28 14:41:46 +00:00
Sebastian 625159c2b2 Merge pull request 'chore(deps): update dependency vite to v8.1.0' (#32) from renovate/vite-8.x-lockfile into main
Reviewed-on: #32
2026-06-28 14:41:39 +00:00
Sebastian dee5fc4eea chore(deps): update dependency vite to v8.1.0 2026-06-26 03:05:30 +00:00
Sebastian ee651b123e chore(deps): update dependency vue to v3.5.39 2026-06-26 03:05:24 +00:00
Sebastian f7fac8a697 Merge pull request 'chore(deps): update vue-language-tools monorepo to v3.3.5' (#30) from renovate/vue-language-tools-monorepo into main
Reviewed-on: #30
2026-06-20 23:00:37 +00:00
Sebastian f181e7baa2 chore(deps): update vue-language-tools monorepo to v3.3.5 2026-06-19 03:02:54 +00:00
Sebastian 29ba107585 Merge pull request 'chore(deps): update dependency vuetify to v4.1.2' (#28) from renovate/vuetify-4.x-lockfile into main
Reviewed-on: #28
2026-06-18 23:09:32 +00:00
Sebastian 63a4f93077 Merge pull request 'chore(deps): update vue monorepo to v3.5.38' (#29) from renovate/vue-monorepo into main
Reviewed-on: #29
2026-06-18 23:09:26 +00:00
Sebastian b1022dd896 chore(deps): update vue monorepo to v3.5.38 2026-06-18 03:04:31 +00:00
Sebastian 01dac91ed9 chore(deps): update dependency vuetify to v4.1.2 2026-06-18 03:04:29 +00:00
Sebastian a8a21eaaea Merge pull request 'chore(deps): update dependency vite to v8.0.16' (#25) from renovate/vite-8.x-lockfile into main
Reviewed-on: #25
2026-06-16 17:47:47 +00:00
Sebastian 929cafb577 Merge pull request 'chore(deps): update dependency vuetify to v4.1.0' (#27) from renovate/vuetify-4.x-lockfile into main
Renovate / renovate (push) Failing after 2m3s
Reviewed-on: #27
2026-06-06 04:09:20 +00:00
Sebastian 9f3a916bc7 Merge pull request 'chore(deps): update vue-language-tools monorepo to v3.3.3' (#26) from renovate/vue-language-tools-monorepo into main
Reviewed-on: #26
2026-06-06 04:09:07 +00:00
Sebastian 8efc974826 chore(deps): update dependency vuetify to v4.1.0 2026-06-05 03:04:04 +00:00
Sebastian 93f905af3a chore(deps): update dependency vue-tsc to v3.3.3 2026-06-02 03:04:27 +00:00
Sebastian 5a91b488a6 chore(deps): update dependency vite to v8.0.16 2026-06-02 03:04:25 +00:00
Sebastian 43663bbad2 Merge pull request 'chore(deps): update dependency vite to v8.0.14' (#20) from renovate/vite-8.x-lockfile into main
Renovate / renovate (push) Failing after 1m27s
Reviewed-on: #20
2026-05-29 03:10:10 +00:00
Sebastian 23f7cb3efc Merge pull request 'chore(deps): update dependency vue to v3.5.35' (#22) from renovate/vue-monorepo into main
Reviewed-on: #22
2026-05-29 03:09:44 +00:00
Sebastian 38aac5cf52 chore(deps): update dependency vue to v3.5.35 2026-05-29 03:08:51 +00:00
Sebastian 4a4c8e46e4 chore(deps): update dependency vite to v8.0.14 2026-05-29 03:08:48 +00:00
Sebastian e1c17138e6 Merge pull request 'chore(deps): update dependency vuetify to v4.0.8' (#23) from renovate/vuetify-4.x-lockfile into main
Reviewed-on: #23
2026-05-29 03:07:09 +00:00
Sebastian 159d5d8db0 Merge pull request 'chore(deps): update dependency vue-router to v5.1.0' (#24) from renovate/vue-router-5.x-lockfile into main
Reviewed-on: #24
2026-05-29 03:06:59 +00:00
Sebastian d1719c2d42 chore(deps): update dependency vue-router to v5.1.0 2026-05-29 03:05:50 +00:00
Sebastian fdf57614cf chore(deps): update dependency vuetify to v4.0.8 2026-05-29 03:05:47 +00:00
Sebastian ea41cf3bd1 Merge pull request 'chore(deps): update dependency vue-tsc to v3.2.9' (#13) from renovate/vue-tsc-3.x-lockfile into main
Renovate / renovate (push) Successful in 1m27s
Reviewed-on: #13
2026-05-15 12:43:24 +00:00
Sebastian e28b4eb0cd Merge pull request 'chore(deps): update dependency vite to v8' (#15) from renovate/vite-8.x into main
Reviewed-on: #15
2026-05-15 12:43:16 +00:00
Sebastian 9fbda4499b chore(deps): update dependency vue-tsc to v3.2.9 2026-05-15 12:43:01 +00:00
42 changed files with 5738 additions and 2222 deletions
+50
View File
@@ -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
+50
View File
@@ -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
+8 -2
View File
@@ -25,11 +25,17 @@ jobs:
tools: composer:v2
- 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
env:
RENOVATE_TOKEN: ${{ secrets.RENOVATE_TOKEN }}
RENOVATE_PLATFORM: gitea
RENOVATE_ENDPOINT: https://git.ktrix.dev/api/v1
run: renovate ${{ gitea.repository }}
run: |
"${{ runner.temp }}/renovate-npm/bin/renovate" ${{ gitea.repository }}
+3 -4
View File
@@ -12,18 +12,17 @@ node_modules/
/static/
# Backend development
/vendor/
/lib/vendor/
coverage/
phpunit.xml.cache
.phpunit.result.cache
.php-cs-fixer.cache
.phpstan.cache
*.cache
.phpactor/
# Editors
.DS_Store
.vscode/
.idea/
.ideas/
# Logs
*.log
Generated
+18
View File
@@ -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"
}
+2123 -470
View File
File diff suppressed because it is too large Load Diff
+10 -2
View File
@@ -11,19 +11,27 @@
"dev": "vite build --mode development --config vite.config.ts",
"watch": "vite build --mode development --watch --config vite.config.ts",
"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": {
"pinia": "^3.0.0",
"pinia": "^4.0.0",
"vue": "^3.5.18",
"vue-router": "^5.0.0",
"vuetify": "^4.0.0"
},
"devDependencies": {
"@vitejs/plugin-vue": "^6.0.1",
"@vitest/coverage-v8": "^4.1.6",
"@vue/test-utils": "^2.4.10",
"@vue/tsconfig": "^0.9.0",
"jsdom": "^30.0.0",
"typescript": "~6.0.0",
"vite": "^8.0.0",
"vitest": "^4.1.6",
"vue-tsc": "^3.0.5"
}
}
+66 -177
View File
@@ -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">
import { ref, computed, watch } from 'vue';
import { AGENDA_VIEW_SPANS, spanToDays, type AgendaViewSpan } from '@/types/spans';
import type { EntityObject } from '@ChronoManager/models/entity';
import type { CollectionObject } from '@ChronoManager/models/collection';
import { EventObject } from '@ChronoManager/models/event';
type CalendarEntity = EntityObject;
type CalendarCollection = CollectionObject;
import { daysVisibleRange } from '@/utils/date';
import { useChronoInstancesStore } from '@/stores/chronoInstancesStore';
import type { CalendarInstance } from '@/types/instance';
import ViewControls from './ViewControls.vue';
import { formatEpochTime, formatFullDate } from '@/utils/format';
const props = defineProps<{
events: CalendarEntity[];
calendars: CalendarCollection[];
currentDate?: Date;
calendars: CollectionObject[];
currentDate: Date;
initialSpan?: AgendaViewSpan;
}>();
const selectedSpan = ref<AgendaViewSpan>(props.initialSpan ?? '1w');
const localDate = ref(new Date(props.currentDate ?? new Date()));
const emit = defineEmits<{
'event-click': [event: CalendarEntity];
'event-click': [event: EntityObject];
'update:span': [span: AgendaViewSpan];
'update:current-date': [date: Date];
}>();
// Watch for external date changes (e.g., from mini calendar)
watch(() => props.currentDate, (newDate) => {
if (newDate) {
localDate.value = new Date(newDate);
}
});
const instancesStore = useChronoInstancesStore();
const selectedSpan = ref<AgendaViewSpan>(props.initialSpan ?? '1w');
const selectedSpanDays = computed(() => spanToDays(selectedSpan.value));
watch(() => props.initialSpan, (newSpan) => {
if (newSpan && newSpan !== selectedSpan.value) {
@@ -90,108 +37,78 @@ function setSpan(span: AgendaViewSpan) {
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 start = new Date(localDate.value);
start.setHours(0, 0, 0, 0);
const end = new Date(start);
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 range = daysVisibleRange(props.currentDate, selectedSpanDays.value);
return {
start: new Date(range.start),
end: new Date(range.end),
};
});
const groupedEvents = computed(() => {
const grouped: Record<string, CalendarEntity[]> = {};
const grouped: Record<string, CalendarInstance[]> = {};
const { start, end } = dateRange.value;
const filtered = props.events.filter((e): e is CalendarEntity => {
const startsOn = getEventProperties(e).startsOn;
if (typeof startsOn !== 'string') return false;
const eventStart = new Date(startsOn);
return eventStart >= start && eventStart <= end;
const instances = instancesStore.instancesForRange({
start: start.getTime(),
end: end.getTime(),
});
const sorted = filtered.sort((a, b) =>
new Date(getEventProperties(a).startsOn || 0).getTime() - new Date(getEventProperties(b).startsOn || 0).getTime()
);
sorted.forEach(entity => {
const dateKey = new Date(getEventProperties(entity).startsOn || 0).toDateString();
instances.forEach(instance => {
const dateKey = new Date(instance.start).toDateString();
if (!grouped[dateKey]) {
grouped[dateKey] = [];
}
grouped[dateKey].push(entity);
grouped[dateKey].push(instance);
});
return grouped;
});
function getEventColor(entity: CalendarEntity): string {
const event = getEventProperties(entity);
if (event.color) return event.color;
function getEventColor(instance: CalendarInstance): string {
const entity = instance.entity;
if (instance.color) return instance.color;
const calendar = props.calendars.find(cal => cal.identifier === entity.collection);
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>
<template>
<div class="agenda-view">
<ViewControls
mode="days"
:current-date="currentDate"
:spans="AGENDA_VIEW_SPANS"
:selected-span="selectedSpan"
@update:current-date="emit('update:current-date', $event)"
@update:span="setSpan"
/>
<v-list class="agenda-list">
<template v-if="Object.keys(groupedEvents).length === 0">
<v-list-item>
<v-list-item-title class="text-center text-medium-emphasis">No events in this period</v-list-item-title>
</v-list-item>
</template>
<template v-for="(dayInstances, date) in groupedEvents" :key="date">
<v-list-subheader>{{ formatFullDate(date) }}</v-list-subheader>
<v-list-item
v-for="instance in dayInstances"
:key="instance.key"
@click="$emit('event-click', instance.entity)"
>
<template #prepend>
<v-icon :color="getEventColor(instance)">mdi-circle</v-icon>
</template>
<v-list-item-title>{{ instance.label || 'Untitled' }}</v-list-item-title>
<v-list-item-subtitle>
{{ instance.timeless ? 'All day' : `${formatEpochTime(instance.start)} - ${formatEpochTime(instance.end)}` }}
</v-list-item-subtitle>
</v-list-item>
</template>
</v-list>
</div>
</template>
<style scoped>
.agenda-view {
display: flex;
@@ -199,34 +116,6 @@ function formatAgendaDate(dateString: string): string {
height: 100%;
}
.view-controls {
display: flex;
align-items: center;
padding: 4px 8px;
border-bottom: 1px solid rgb(var(--v-border-color));
background-color: rgb(var(--v-theme-surface));
flex-shrink: 0;
}
.view-navigation {
display: flex;
align-items: center;
gap: 4px;
}
.current-period {
font-size: 14px;
font-weight: 500;
margin-left: 8px;
white-space: nowrap;
}
.view-selector {
display: flex;
align-items: center;
gap: 2px;
}
.agenda-list {
flex: 1;
overflow-y: auto;
+51 -46
View File
@@ -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">
import { ref } from 'vue';
import MonthView from './MonthView.vue';
import DaysView from './DaysView.vue';
import AgendaView from './AgendaView.vue';
import EventViewerPopup from './EventViewerPopup.vue';
import type { AgendaViewSpan, DaysViewSpan } from '@/types/spans';
import {
type AgendaViewSpan,
type DaysViewSpan
} from '@/types/spans';
import type { EntityObject } from '@ChronoManager/models/entity';
import type { CollectionObject } from '@ChronoManager/models/collection';
defineProps<{
const props = defineProps<{
view: 'days' | 'month' | 'agenda';
currentDate: Date;
events: EntityObject[];
calendars: CollectionObject[];
initialDaysSpan?: DaysViewSpan;
initialAgendaViewSpan?: AgendaViewSpan;
}>();
defineEmits<{
const emit = defineEmits<{
'event-click': [event: EntityObject];
'date-click': [date: Date];
'date-range-select': [range: { start: Date; end: Date }];
'update:current-date': [date: Date];
'update:days-span': [span: DaysViewSpan];
'update:agenda-span': [span: AgendaViewSpan];
}>();
@@ -93,6 +55,49 @@ function hidePopup() {
}
</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>
.calendar-view-container {
height: 100%;
+5 -5
View File
@@ -2,7 +2,7 @@
import { ref, computed, watch } from 'vue'
import { useServicesStore } from '@ChronoManager/stores/servicesStore'
import { CollectionObject } from '@ChronoManager/models/collection'
import { ServiceObject } from '@ChronoManager/models/service'
import type { ServiceObject } from '@ChronoManager/models/service'
// Store
const servicesStore = useServicesStore()
@@ -74,11 +74,11 @@ const onOpen = async () => {
if (props.mode === 'edit') {
// Edit mode - find the service
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
} else {
// 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-model="editingCollectionService"
label="Service"
:items="services.filter(s => s.capabilities?.CollectionCreate)"
:items="services.filter(s => s.capable('CollectionCreate'))"
item-title="label"
item-value="identifier"
required
@@ -254,7 +254,7 @@ watch(() => props.modelValue, async (newValue) => {
<v-card-actions class="justify-space-between align-center">
<div>
<v-btn
v-if="mode === 'edit' && editingCollectionService?.capabilities?.CollectionDestroy"
v-if="mode === 'edit' && editingCollectionService?.capable('CollectionDelete')"
color="error"
variant="text"
@click="onDelete"
+53 -18
View File
@@ -11,6 +11,7 @@ const props = defineProps<{
// Emits
const emit = defineEmits<{
'create': []
'select': [collection: CollectionObject]
'edit': [collection: CollectionObject]
'toggle-visibility': [collection: CollectionObject]
@@ -42,6 +43,12 @@ const displayIcon = computed(() => {
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
const onCollectionSelect = (collection: CollectionObject) => {
console.log('[Chrono] - Collection selected', collection)
@@ -60,13 +67,23 @@ const onToggleVisibility = (collection: CollectionObject) => {
<template>
<div class="collection-selector">
<div class="collection-selector-header">
<div class="d-flex align-center mb-3">
<v-icon :icon="displayIcon" size="small" class="mr-2" />
<span class="text-subtitle-2 font-weight-bold">{{ displayTitle }}</span>
<div class="d-flex align-center justify-space-between mb-1">
<div class="d-flex align-center">
<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>
<v-divider class="my-2" />
<v-divider class="mb-2" />
<div class="collection-selector-content">
<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>
<template #append>
<div class="d-flex align-center">
<v-icon
:color="collection.properties.color || 'primary'"
size="small"
class="mr-1"
>mdi-circle</v-icon>
<v-btn
icon="mdi-pencil"
size="x-small"
variant="text"
@click.stop="onCollectionEdit(collection)"
/>
</div>
<v-btn
icon="mdi-pencil"
size="x-small"
variant="text"
class="collection-edit-btn"
@click.stop="onCollectionEdit(collection)"
/>
</template>
</v-list-item>
</v-list>
@@ -135,4 +146,28 @@ const onToggleVisibility = (collection: CollectionObject) => {
.v-list-item {
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>
+301 -263
View File
@@ -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>
<div class="days-view">
<div class="view-controls">
<div class="view-navigation">
<v-btn size="x-small" variant="text" icon="mdi-chevron-left" @click="previousPeriod" />
<v-btn size="x-small" variant="tonal" @click="goToToday">Today</v-btn>
<v-btn size="x-small" variant="text" icon="mdi-chevron-right" @click="nextPeriod" />
<span class="current-period">{{ dateRangeLabel }}</span>
</div>
<v-spacer />
<div class="view-selector">
<v-btn
v-for="span in DAYS_VIEW_SPANS"
:key="span"
size="x-small"
variant="text"
@click="setSpan(span)"
:color="selectedSpan === span ? 'primary' : undefined"
>
{{ span.toUpperCase() }}
</v-btn>
</div>
</div>
<ViewControls
mode="days"
:current-date="currentDate"
:spans="DAYS_VIEW_SPANS"
:selected-span="selectedSpan"
@update:current-date="emit('update:current-date', $event)"
@update:span="setSpan"
/>
<div class="days-grid">
<!-- Fixed Headers Row -->
<div class="day-headers">
@@ -45,18 +245,18 @@
<div class="all-day-events-overlay">
<div
v-for="segment in allDaySegments.segments"
:key="segment.entity.identifier"
:key="segment.instance.key"
class="all-day-event"
:class="{
'is-start': segment.isStart,
'is-end': segment.isEnd,
}"
:style="getAllDayEventStyle(segment)"
@click="emit('event-click', segment.entity)"
@mouseenter="emit('event-hover', { event: $event, entity: segment.entity })"
@click="emit('event-click', segment.instance.entity)"
@mouseenter="emit('event-hover', { event: $event, entity: segment.instance.entity })"
@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>
@@ -71,24 +271,46 @@
</div>
<div class="days-columns">
<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
v-for="entity in getTimedEvents(day)"
:key="entity.identifier"
v-if="hoveredTime?.day === day.toISOString()"
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"
:style="getEventStyle(entity)"
@click.stop="emit('event-click', entity)"
@mouseenter="emit('event-hover', { event: $event, entity })"
:style="getEventStyle(instance)"
@pointerdown.stop
@click.stop="emit('event-click', instance.entity)"
@mouseenter="emit('event-hover', { event: $event, entity: instance.entity })"
@mouseleave="emit('event-hover-end')"
>
<template v-if="daysCount <= 3">
<div class="event-time">
{{ formatEventDateTime(getEventProperties(entity).startsOn) }} - {{ formatEventDateTime(getEventProperties(entity).endsOn) }}
{{ formatEpochTime(instance.start) }} - {{ formatEpochTime(instance.end) }}
</div>
<div class="event-title">{{ getEventProperties(entity).label || 'Untitled' }}</div>
<div class="event-title">{{ instance.label || 'Untitled' }}</div>
</template>
<template v-else>
<div class="event-title-compact">{{ getEventProperties(entity).label || 'Untitled' }}</div>
<div class="event-title-compact">{{ instance.label || 'Untitled' }}</div>
</template>
</div>
</div>
@@ -99,240 +321,12 @@
</div>
</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>
.days-view {
display: flex;
flex-direction: column;
height: 100%;
min-height: 600px;
}
.view-controls {
display: flex;
align-items: center;
padding: 4px 8px;
border-bottom: 1px solid rgb(var(--v-border-color));
background-color: rgb(var(--v-theme-surface));
flex-shrink: 0;
}
.view-navigation {
display: flex;
align-items: center;
gap: 4px;
}
.current-period {
font-size: 14px;
font-weight: 500;
margin-left: 8px;
white-space: nowrap;
}
.view-selector {
display: flex;
align-items: center;
gap: 2px;
min-height: 0;
}
.days-grid {
@@ -569,6 +563,50 @@ function handleDayClick(event: MouseEvent, day: Date) {
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 {
position: absolute;
left: 4px;
+17 -11
View File
@@ -49,6 +49,10 @@ const calendarOptions = computed(() =>
}))
)
const selectedCalendar = computed(() =>
(props.calendars || []).find(calendar => calendar.identifier === selectedCollectionId.value) ?? null
)
// Permissions
const isExternalEvent = computed(() => {
return false
@@ -77,13 +81,12 @@ const cancelEdit = () => {
}
const saveEntity = async () => {
if (!editableEntity.value) {
if (!editableEntity.value || !selectedCalendar.value) {
return
}
saving.value = true
const targetCollection = (props.calendars || []).find(cal => cal.identifier === selectedCollectionId.value)
emit('save', editableEntity.value as EntityObject, targetCollection || props.collection)
emit('save', editableEntity.value as EntityObject, selectedCalendar.value)
saving.value = false
}
@@ -127,7 +130,7 @@ watch(
(newEntity) => {
const entity = newEntity as EntityObject | 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 },
)
@@ -138,7 +141,7 @@ watch(
const entity = props.entity as EntityObject | null
if (newMode === 'edit' && entity?.clone) {
draftEntity.value = entity.clone()
selectedCollectionId.value = entity.collection || props.collection?.identifier || props.calendars?.[0]?.identifier || ''
selectedCollectionId.value = entity.collection || props.collection?.identifier || ''
}
},
)
@@ -194,6 +197,7 @@ watch(
v-model="selectedCollectionId"
:items="calendarOptions"
label="Calendar"
:rules="[value => (value !== '' && value != null) || 'Calendar is required']"
variant="outlined"
density="compact"
class="mb-4"
@@ -223,6 +227,13 @@ watch(
@update:time-zone="entityObject!.timeZone = $event"
/>
<EventEditorOccurrence
:mode="mode"
:pattern="entityObject!.pattern"
:starts-on="entityObject!.startsOn"
@update:pattern="entityObject!.pattern = $event"
/>
<EventEditorLocations
:mode="mode"
:locations-physical="entityObject!.locationsPhysical || {}"
@@ -249,12 +260,6 @@ watch(
@remove-notification="removeNotification"
/>
<EventEditorOccurrence
:mode="mode"
:pattern="entityObject!.pattern"
@update:pattern="entityObject!.pattern = $event"
/>
<EventEditorTags
:mode="mode"
:tags="entityObject!.tags"
@@ -292,6 +297,7 @@ watch(
color="primary"
variant="elevated"
@click="saveEntity"
:disabled="!selectedCalendar"
:loading="saving">
Save
</v-btn>
+7 -19
View File
@@ -1,5 +1,7 @@
<script setup lang="ts">
import { computed } from 'vue'
import { parseCalendarDate } from '@/utils/date'
import { formatTime as formatClockTime } from '@/utils/format'
const props = defineProps<{
event: any | null
@@ -11,26 +13,12 @@ const emit = defineEmits<{
'click': [event: any]
}>()
const formatDateTime = (isoString: string | null | undefined): string => {
if (!isoString) return '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 => {
return isoString ? formatClockTime(new Date(isoString)) : 'Not set'
}
const formatTime = (isoString: string | null | undefined): string => {
if (!isoString) return 'Not set'
const date = new Date(isoString)
return date.toLocaleTimeString('en-US', {
hour: 'numeric',
minute: '2-digit'
})
const formatAllDayDate = (isoString: string | null | undefined): string => {
return parseCalendarDate(isoString)?.toLocaleDateString() ?? 'Not set'
}
const popupStyle = computed(() => {
@@ -83,7 +71,7 @@ const participantCount = computed(() => {
<v-icon icon="mdi-clock-outline" size="small" class="mr-2 text-grey" />
<div class="text-caption">
<div v-if="event.properties?.timeless">
All Day - {{ new Date(event.properties.startsOn).toLocaleDateString() }}
All Day - {{ formatAllDayDate(event.properties.startsOn) }}
</div>
<div v-else>
{{ formatTime(event.properties?.startsOn) }} - {{ formatTime(event.properties?.endsOn) }}
+63
View File
@@ -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
View File
@@ -1,90 +1,68 @@
<script setup lang="ts">
import { computed, ref, watch, onMounted, onUnmounted } from 'vue';
import {
startOfDay,
daysDiff,
isMultiDay,
getSingleDayEventsForDate,
type MultiDaySegment,
} from '@/utils/calendarHelpers';
import { computed, ref, onMounted, onUnmounted } from 'vue';
import { layoutMultiDayLanes, type MultiDaySegment } from '@/utils/multiDayLanes';
import { startOfLocalDay } from '@/utils/date';
import { useChronoInstancesStore } from '@/stores/chronoInstancesStore';
import type { CalendarInstance } from '@/types/instance';
import ViewControls from './ViewControls.vue';
import { formatEpochTime } from '@/utils/format';
const EVENT_HEIGHT = 22; // Height of each event row in pixels
const 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 {
startDate: Date;
days: { date: Date; currentMonth: boolean }[];
days: DayCell[];
multiDaySegments: MultiDaySegment[];
laneCount: number;
}
const props = defineProps<{
currentDate: Date;
events: any[];
calendars: any[];
}>();
defineEmits<{
const instancesStore = useChronoInstancesStore();
const emit = defineEmits<{
'event-click': [event: any];
'date-click': [date: Date];
'event-hover': [data: { event: MouseEvent; entity: any }];
'event-hover-end': [];
'update:current-date': [date: Date];
}>();
const weekDays = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
// Local date for navigation
const localDate = ref(new Date(props.currentDate));
// Watch for external date changes (e.g., from mini calendar)
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;
}
}
// Track the calendar body size so each week row shows as many event rows as fit
const bodyRef = ref<HTMLElement | null>(null);
const bodyHeight = ref(600);
let bodyResizeObserver: ResizeObserver | null = null;
onMounted(() => {
updateContainerHeight();
window.addEventListener('resize', updateContainerHeight);
bodyResizeObserver = new ResizeObserver(entries => {
bodyHeight.value = entries[0].contentRect.height;
});
if (bodyRef.value) {
bodyResizeObserver.observe(bodyRef.value);
}
});
onUnmounted(() => {
window.removeEventListener('resize', updateContainerHeight);
bodyResizeObserver?.disconnect();
});
// Calculate how many weeks are needed for this month
const weeksNeeded = computed(() => {
const year = localDate.value.getFullYear();
const month = localDate.value.getMonth();
const year = props.currentDate.getFullYear();
const month = props.currentDate.getMonth();
const firstDay = new Date(year, month, 1);
const lastDay = new Date(year, month + 1, 0);
@@ -95,10 +73,16 @@ const weeksNeeded = computed(() => {
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
const weeks = computed<WeekData[]>(() => {
const year = localDate.value.getFullYear();
const month = localDate.value.getMonth();
const year = props.currentDate.getFullYear();
const month = props.currentDate.getMonth();
const firstDay = new Date(year, month, 1);
const gridStart = new Date(firstDay);
gridStart.setDate(gridStart.getDate() - gridStart.getDay());
@@ -109,140 +93,108 @@ const weeks = computed<WeekData[]>(() => {
const numWeeks = weeksNeeded.value;
for (let w = 0; w < numWeeks; w++) {
const weekStart = startOfDay(new Date(current));
const weekStart = startOfLocalDay(new Date(current));
const weekEnd = new Date(weekStart);
weekEnd.setDate(weekEnd.getDate() + 6);
const days: { date: Date; currentMonth: boolean }[] = [];
const baseDays: { date: Date; currentMonth: boolean }[] = [];
for (let d = 0; d < 7; d++) {
days.push({
baseDays.push({
date: new Date(current),
currentMonth: current.getMonth() === month,
});
current.setDate(current.getDate() + 1);
}
// 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({
startDate: weekStart,
days,
multiDaySegments: segments,
laneCount: Math.min(laneCount, MAX_VISIBLE_EVENTS),
multiDaySegments: visibleSegments,
laneCount: visibleLaneCount,
});
}
return weeksData;
});
function getMultiDaySegments(weekStart: Date, weekEnd: Date): { segments: MultiDaySegment[]; laneCount: number } {
const segments: MultiDaySegment[] = [];
// Filter multiday events that overlap this week
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 };
// Timed events for a specific date; timeless instances (all-day and
// multi-day) render as lane bars via layoutMultiDayLanes instead
function getSingleDayEvents(date: Date): CalendarInstance[] {
return instancesStore.instancesForDay(date).filter(instance => !instance.timeless);
}
// Single-day events for a specific date
function getSingleDayEvents(date: Date): any[] {
return getSingleDayEventsForDate(props.events, date);
// "+N more" popover listing all of a day's events
function morePopoverLabel(date: Date): string {
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 {
const today = new Date();
return date.toDateString() === today.toDateString();
}
function getEventColor(entity: any): string {
if (entity.properties?.color) return entity.properties.color;
const calendar = props.calendars.find(cal => cal.identifier === entity.collection);
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';
}
</script>
<template>
<div class="month-view" ref="containerRef">
<div class="month-view">
<!-- Navigation Controls -->
<div class="view-controls">
<div class="view-navigation">
<v-btn size="x-small" variant="text" icon="mdi-chevron-left" @click="previousMonth" />
<v-btn size="x-small" variant="tonal" @click="goToToday">Today</v-btn>
<v-btn size="x-small" variant="text" icon="mdi-chevron-right" @click="nextMonth" />
<span class="current-period">{{ monthLabel }}</span>
</div>
</div>
<ViewControls
mode="month"
:current-date="currentDate"
@update:current-date="emit('update:current-date', $event)"
/>
<!-- Header row -->
<div class="day-headers">
@@ -252,7 +204,7 @@ function getEventColor(entity: any): string {
</div>
<!-- Week rows -->
<div class="calendar-body">
<div class="calendar-body" ref="bodyRef">
<div v-for="(week, weekIndex) in weeks" :key="weekIndex" class="calendar-week">
<!-- Base layer: Day cells grid -->
<div class="days-grid">
@@ -275,22 +227,55 @@ function getEventColor(entity: any): string {
<!-- Single-day events -->
<div class="cell-events">
<div
v-for="entity in getSingleDayEvents(cell.date).slice(0, MAX_VISIBLE_EVENTS - week.laneCount)"
:key="entity.identifier"
v-for="instance in cell.visibleInstances"
:key="instance.key"
class="single-day-event"
:style="{ backgroundColor: getEventColor(entity) }"
@click.stop="$emit('event-click', entity)"
@mouseenter="$emit('event-hover', { event: $event, entity })"
:style="{ backgroundColor: getEventColor(instance) }"
@click.stop="$emit('event-click', instance.entity)"
@mouseenter="$emit('event-hover', { event: $event, entity: instance.entity })"
@mouseleave="$emit('event-hover-end')"
>
{{ entity.properties?.label || 'Untitled' }}
{{ instance.label || 'Untitled' }}
</div>
<div
v-if="getHiddenCount(cell, week.laneCount) > 0"
<div
v-if="cell.hiddenCount > 0"
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>
@@ -300,24 +285,24 @@ function getEventColor(entity: any): string {
<div class="multiday-overlay">
<div
v-for="segment in week.multiDaySegments"
:key="`${segment.entity.identifier}-${weekIndex}`"
:key="`${segment.instance.key}-${weekIndex}`"
class="multiday-event"
:class="{
'is-start': segment.isStart,
'is-end': segment.isEnd,
}"
:style="{
backgroundColor: getEventColor(segment.entity),
backgroundColor: getEventColor(segment.instance),
left: `calc(${segment.startCol} / 7 * 100% + 4px)`,
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)"
@mouseenter="$emit('event-hover', { event: $event, entity: segment.entity })"
@click.stop="$emit('event-click', segment.instance.entity)"
@mouseenter="$emit('event-hover', { event: $event, entity: segment.instance.entity })"
@mouseleave="$emit('event-hover-end')"
>
<span v-if="segment.isStart" class="event-label">
{{ segment.entity.properties?.label || 'Untitled' }}
{{ segment.instance.label || 'Untitled' }}
</span>
</div>
</div>
@@ -336,28 +321,6 @@ function getEventColor(entity: any): string {
border: 1px solid rgb(var(--v-border-color));
}
.view-controls {
display: flex;
align-items: center;
padding: 4px 8px;
border-bottom: 1px solid rgb(var(--v-border-color));
background-color: rgb(var(--v-theme-surface));
flex-shrink: 0;
}
.view-navigation {
display: flex;
align-items: center;
gap: 4px;
}
.current-period {
font-size: 14px;
font-weight: 500;
margin-left: 8px;
white-space: nowrap;
}
.day-headers {
display: grid;
grid-template-columns: repeat(7, 1fr);
@@ -548,4 +511,26 @@ function getEventColor(entity: any): string {
overflow: hidden;
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>
+181
View File
@@ -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>
+10 -4
View File
@@ -34,6 +34,7 @@
v-model="selectedCollectionId"
:items="listOptions"
label="Task List"
:rules="[value => (value !== '' && value != null) || 'Task list is required']"
:readonly="!isEditing || !canChangeCollection"
:variant="isEditing && canChangeCollection ? 'outlined' : 'plain'"
class="mb-4"
@@ -243,13 +244,13 @@ watch(() => props.entity, (newEntity) => {
startDate.value = '';
}
selectedCollectionId.value = draftEntity.value?.collection || props.collection?.identifier || props.lists[0]?.identifier || '';
selectedCollectionId.value = draftEntity.value?.collection || props.collection?.identifier || '';
}, { immediate: true });
watch(() => props.mode, (newMode) => {
if (newMode === 'edit' && 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 = [
{ title: 'Low', value: 3 },
{ title: 'Medium', value: 2 },
@@ -288,8 +293,9 @@ async function handleSave() {
const { valid } = await formRef.value.validate();
if (!valid) return;
const targetCollection = props.lists.find(list => list.identifier === selectedCollectionId.value);
emit('save', editableEntity.value as EntityObject, targetCollection || props.collection);
if (!selectedTaskList.value) return;
emit('save', editableEntity.value as EntityObject, selectedTaskList.value);
}
function handleCancel() {
+110
View File
@@ -0,0 +1,110 @@
<script setup lang="ts" generic="S extends TimeSpanLabel">
import { computed } from 'vue';
import { spanToDays, type TimeSpanLabel } from '@/types/spans';
import { daysVisibleRange, shiftLocalDays, shiftLocalMonths } from '@/utils/date';
const props = defineProps<{
mode: 'days' | 'month';
currentDate: Date;
spans?: readonly S[];
selectedSpan?: S;
}>();
const emit = defineEmits<{
'update:current-date': [date: Date];
'update:span': [span: S];
}>();
const spanDays = computed(() => props.selectedSpan ? spanToDays(props.selectedSpan) : 1);
function previousPeriod() {
emit('update:current-date', props.mode === 'month'
? shiftLocalMonths(props.currentDate, -1)
: shiftLocalDays(props.currentDate, -spanDays.value));
}
function nextPeriod() {
emit('update:current-date', props.mode === 'month'
? shiftLocalMonths(props.currentDate, 1)
: shiftLocalDays(props.currentDate, spanDays.value));
}
function goToToday() {
emit('update:current-date', new Date());
}
const periodLabel = computed(() => {
if (props.mode === 'month') {
return props.currentDate.toLocaleDateString('en-US', { month: 'long', year: 'numeric' });
}
const range = daysVisibleRange(props.currentDate, spanDays.value);
const start = new Date(range.start);
const end = shiftLocalDays(new Date(range.end), -1);
const formatOptions: Intl.DateTimeFormatOptions = { month: 'short', day: 'numeric' };
if (spanDays.value === 1) {
return start.toLocaleDateString('en-US', { month: 'long', day: 'numeric', year: 'numeric' });
}
if (start.getMonth() === end.getMonth()) {
return `${start.toLocaleDateString('en-US', formatOptions)} - ${end.getDate()}, ${start.getFullYear()}`;
}
return `${start.toLocaleDateString('en-US', formatOptions)} - ${end.toLocaleDateString('en-US', formatOptions)}, ${end.getFullYear()}`;
});
</script>
<template>
<div class="view-controls">
<div class="view-navigation">
<v-btn size="x-small" variant="text" icon="mdi-chevron-left" @click="previousPeriod" />
<v-btn size="x-small" variant="tonal" @click="goToToday">Today</v-btn>
<v-btn size="x-small" variant="text" icon="mdi-chevron-right" @click="nextPeriod" />
<span class="current-period">{{ periodLabel }}</span>
</div>
<v-spacer />
<div v-if="spans?.length" class="view-selector">
<v-btn
v-for="span in spans"
:key="span"
size="x-small"
variant="text"
:color="selectedSpan === span ? 'primary' : undefined"
@click="emit('update:span', span)"
>
{{ span.toUpperCase() }}
</v-btn>
</div>
</div>
</template>
<style scoped>
.view-controls {
display: flex;
align-items: center;
padding: 4px 8px;
border-bottom: 1px solid rgb(var(--v-border-color));
background-color: rgb(var(--v-theme-surface));
flex-shrink: 0;
}
.view-navigation {
display: flex;
align-items: center;
gap: 4px;
}
.current-period {
font-size: 14px;
font-weight: 500;
margin-left: 8px;
white-space: nowrap;
}
.view-selector {
display: flex;
align-items: center;
gap: 2px;
}
</style>
+147 -58
View File
@@ -1,5 +1,7 @@
<script setup lang="ts">
import { ref, computed, watch } from 'vue'
import { parseCalendarDate } from '@/utils/date'
import { formatDisplayDate, formatDisplayTime, formatLocalIsoDate, formatLocalIsoTime } from '@/utils/format'
interface Props {
mode: 'edit' | 'view'
@@ -28,70 +30,108 @@ const timeZoneValue = computed({
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 => {
if (!isoString) return 'Not set'
if (timelessValue.value) {
return parseCalendarDate(isoString)?.toLocaleDateString() ?? 'Not set'
}
const date = new Date(isoString)
return date.toLocaleString()
}
const parseDateTime = (date: string, time: string): Date => {
if (timelessValue.value) {
return new Date(date)
return new Date(`${date}T00:00:00`)
}
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
const startDate = ref('')
const startTime = ref('')
const endDate = 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
watch(() => props.startsOn, (newValue) => {
watch([() => props.startsOn, () => props.timeless], ([newValue, timeless]) => {
if (newValue) {
const start = new Date(newValue)
startDate.value = formatDate(start)
startTime.value = formatTime(start)
startDate.value = inputDate(newValue, timeless === true)
startTime.value = formatLocalIsoTime(start)
} else {
const now = new Date()
startDate.value = formatDate(now)
startTime.value = formatTime(now)
startDate.value = formatLocalIsoDate(now)
startTime.value = formatLocalIsoTime(now)
}
}, { immediate: true })
watch(() => props.endsOn, (newValue) => {
watch([() => props.endsOn, () => props.timeless], ([newValue, timeless]) => {
if (newValue) {
const end = new Date(newValue)
endDate.value = formatDate(end)
endTime.value = formatTime(end)
endDate.value = inputDate(newValue, timeless === true)
endTime.value = formatLocalIsoTime(end)
} else {
const later = new Date(Date.now() + 60 * 60 * 1000)
endDate.value = formatDate(later)
endTime.value = formatTime(later)
endDate.value = formatLocalIsoDate(later)
endTime.value = formatLocalIsoTime(later)
}
}, { immediate: true })
// Update parent when date/time changes
watch([startDate, startTime], () => {
if (props.mode === 'edit') {
emit('update:startsOn', parseDateTime(startDate.value, startTime.value).toISOString())
}
})
const ensureValidRange = (): boolean => {
if (!startDate.value || !endDate.value || !startTime.value || !endTime.value) return false
const start = parseDateTime(startDate.value, startTime.value)
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], () => {
if (props.mode === 'edit') {
emit('update:endsOn', parseDateTime(endDate.value, endTime.value).toISOString())
}
const correctedEnd = new Date(start)
if (timelessValue.value) correctedEnd.setDate(correctedEnd.getDate() + 1)
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>
@@ -127,22 +167,46 @@ watch([endDate, endTime], () => {
<div class="text-subtitle-2 mb-2">Start</div>
<v-row dense>
<v-col cols="12" md="6">
<v-text-field
v-model="startDate"
label="Start Date"
type="date"
variant="outlined"
density="compact"
/>
<v-menu v-model="startDateMenu" :close-on-content-click="false" location="bottom start">
<template #activator="{ props: activatorProps }">
<v-text-field
v-bind="activatorProps"
:model-value="formatDisplayDate(startDate)"
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-if="!timelessValue" cols="12" md="6">
<v-text-field
v-model="startTime"
label="Start Time"
type="time"
variant="outlined"
density="compact"
/>
<v-menu v-model="startTimeMenu" :close-on-content-click="false" location="bottom start">
<template #activator="{ props: activatorProps }">
<v-text-field
v-bind="activatorProps"
:model-value="formatDisplayTime(startTime)"
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-row>
</div>
@@ -151,22 +215,47 @@ watch([endDate, endTime], () => {
<div class="text-subtitle-2 mb-2">End</div>
<v-row dense>
<v-col cols="12" md="6">
<v-text-field
v-model="endDate"
label="End Date"
type="date"
variant="outlined"
density="compact"
/>
<v-menu v-model="endDateMenu" :close-on-content-click="false" location="bottom start">
<template #activator="{ props: activatorProps }">
<v-text-field
v-bind="activatorProps"
:model-value="formatDisplayDate(endDate)"
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-if="!timelessValue" cols="12" md="6">
<v-text-field
v-model="endTime"
label="End Time"
type="time"
variant="outlined"
density="compact"
/>
<v-menu v-model="endTimeMenu" :close-on-content-click="false" location="bottom start">
<template #activator="{ props: activatorProps }">
<v-text-field
v-bind="activatorProps"
:model-value="formatDisplayTime(endTime)"
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-row>
</div>
+557 -93
View File
@@ -1,43 +1,216 @@
<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 {
mode: 'edit' | 'view'
pattern: any | null
pattern: RecurrencePattern | null
startsOn?: string | null
}
const props = defineProps<Props>()
const emit = defineEmits<{
'update:pattern': [pattern: any | null]
'update:pattern': [pattern: EventOccurrence | null]
}>()
const precisionOptions = [
{ title: 'Yearly', value: 'yearly' },
{ title: 'Monthly', value: 'monthly' },
{ title: 'Weekly', value: 'weekly' },
const precisionOptions: { title: string, value: Frequency }[] = [
{ title: 'Daily', value: 'daily' },
{ title: 'Hourly', value: 'hourly' },
{ title: 'Minutely', value: 'minutely' },
{ title: 'Secondly', value: 'secondly' }
{ title: 'Weekly', value: 'weekly' },
{ title: 'Monthly', value: 'monthly' },
{ title: 'Yearly', value: 'yearly' },
]
const patternTypeOptions = [
{ title: 'Absolute', value: 'absolute' },
{ title: 'Relative', value: 'relative' }
const weekdayOptions = [
{ title: 'Monday', short: 'Mon', value: 1 },
{ 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) => {
if (!pattern) return 'No recurrence'
const parts = []
parts.push(`Every ${pattern.interval || 1}`)
parts.push(pattern.precision || 'day')
if (pattern.iterations) {
parts.push(`(${pattern.iterations} times)`)
} else if (pattern.concludes) {
parts.push(`until ${new Date(pattern.concludes).toLocaleDateString()}`)
const monthOptions = [
'January', 'February', 'March', 'April', 'May', 'June',
'July', 'August', 'September', 'October', 'November', 'December',
].map((title, index) => ({ title, value: index + 1 }))
const monthDayOptions = Array.from({ length: 31 }, (_, index) => index + 1)
const positionOptions = [
{ title: 'First', value: 1 },
{ title: 'Second', value: 2 },
{ 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(' ')
}
</script>
@@ -46,92 +219,265 @@ const formatRecurrence = (pattern: any) => {
<div v-if="pattern || mode === 'edit'" class="event-editor-section">
<div class="d-flex align-center justify-space-between mb-2">
<div class="text-subtitle-1">Recurrence</div>
<v-btn v-if="mode === 'edit' && !pattern"
<v-btn
v-if="mode === 'edit' && !pattern"
size="small"
variant="outlined"
@click="$emit('update:pattern', { pattern: 'absolute', precision: 'daily', interval: 1 })">
<v-icon left>mdi-plus</v-icon>
Add Recurrence
@click="addRecurrence"
>
<v-icon start icon="mdi-plus" />
Add
</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"
variant="text"
color="error"
@click="$emit('update:pattern', null)">
Remove
</v-btn>
variant="text"
@click="$emit('update:pattern', null)"
/>
</div>
<!-- Read-only view -->
<div v-if="mode === 'view' && pattern" class="mb-4">
<div class="pa-2 border rounded">
<div class="mb-1"><strong>{{ formatRecurrence(pattern) }}</strong></div>
<div v-if="pattern.onDayOfWeek && pattern.onDayOfWeek.length > 0" class="text-caption text-grey">
Days: {{ pattern.onDayOfWeek.join(', ') }}
</div>
<div class="recurrence-summary pa-3 rounded">
<v-icon icon="mdi-repeat" size="small" class="mr-2" />
<strong>{{ formatRecurrence(pattern) }}</strong>
</div>
</div>
<!-- Edit view -->
<div v-else-if="mode === 'edit' && pattern" class="pa-3 border rounded">
<v-row dense class="mb-2">
<v-col cols="12" md="6">
<v-select
v-model="pattern.pattern"
:items="patternTypeOptions"
label="Pattern Type"
variant="outlined"
density="compact"
/>
</v-col>
<v-col cols="12" md="6">
<v-select
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">
<div v-else-if="mode === 'edit' && pattern" class="recurrence-editor pa-3 rounded">
<div class="recurrence-controls mb-4">
<span>Repeat</span>
<v-select
:model-value="repeatValue"
:items="precisionOptions"
variant="outlined"
density="compact"
hide-details
class="frequency-field"
@update:model-value="changeFrequency"
/>
<template v-if="pattern">
<span>Every</span>
<v-text-field
v-model.number="pattern.interval"
label="Interval"
:model-value="pattern.interval"
aria-label="Repeat interval"
type="number"
min="1"
max="999"
variant="outlined"
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>
<v-col cols="12" md="4">
<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
<span>{{ intervalUnit }}</span>
</template>
</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>
</template>
@@ -140,4 +486,122 @@ const formatRecurrence = (pattern: any) => {
.event-editor-section {
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>
-96
View File
@@ -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,
}
}
+130 -139
View File
@@ -1,21 +1,22 @@
<script setup lang="ts">
import { computed, onMounted, watch } from 'vue';
import { storeToRefs } from 'pinia';
import { useRoute } from 'vue-router';
import { useDisplay } from 'vuetify';
import { useModuleStore } from '@KTXC/stores/moduleStore';
import { useChronoStore } from '@/stores/chronoStore';
import CollectionList from '@/components/CollectionList.vue';
import { useChronoOperationsStore } from '@/stores/chronoOperationsStore';
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 CalendarView from '@/components/CalendarView.vue';
import TaskView from '@/components/TaskView.vue';
import EventEditor from '@/components/EventEditor.vue';
import TaskEditor from '@/components/TaskEditor.vue';
import MiniCalendar from '@/components/MiniCalendar.vue';
// Vuetify display
const display = useDisplay();
import ImportDialog from '@/components/ImportDialog.vue';
import { EpochSpan } from '@/types/date';
// Check if chrono manager is available
const moduleStore = useModuleStore();
@@ -23,88 +24,124 @@ const isChronoManagerAvailable = computed(() => {
return moduleStore.has('chrono_manager') || moduleStore.has('ChronoManager')
});
const chronoStore = useChronoStore();
const chronoOperationsStore = useChronoOperationsStore();
const chronoSettingsStore = useChronoSettingsStore();
const chronoUiStore = useChronoUiStore();
const route = useRoute();
const {
calendarView,
daysViewSpan,
agendaViewSpan,
} = storeToRefs(chronoSettingsStore);
const {
loading,
calendars,
taskLists,
filteredTasks,
collections,
} = storeToRefs(chronoOperationsStore);
const {
currentDate,
sidebarVisible,
selectedCollection,
showEventEditor,
showTaskEditor,
showCollectionEditor,
showImportDialog,
selectedEntity,
entityEditorMode,
editingCollection,
collectionEditorMode,
collectionEditorType,
calendars,
taskLists,
isTaskView,
filteredEvents,
filteredTasks,
collections,
} = storeToRefs(chronoStore);
isTasksSection,
} = storeToRefs(chronoUiStore);
const { loadVisibleRange: loadVisibleRangeOperations } = chronoOperationsStore;
const {
initialize,
setViewMode,
setCalendarView,
setDaysViewSpan,
setAgendaViewSpan,
selectCalendar,
openEditCalendar,
toggleCalendarVisibility,
selectTaskList,
openEditTaskList,
selectSection,
selectDate,
createEventFromDate,
startEditingSelectedEntity,
cancelEditingSelectedEntity,
closeEntityEditor,
editEvent,
editTask,
toggleTaskComplete,
saveEvent,
deleteEvent,
saveTask,
deleteTask,
saveCollection,
deleteCollection,
openCreateCalendar,
openCreateTaskList,
} = chronoStore;
closeImport,
} = chronoUiStore;
function syncViewModeFromRoute() {
const routePath = route.path;
const mode = routePath.endsWith('/tasks') ? 'tasks' : 'calendar';
setViewMode(mode);
}
const {
toggleTaskComplete,
ensureTasksLoaded,
} = chronoOperationsStore;
function handleCalendarViewChange(view: 'days' | 'month' | 'agenda') {
setCalendarView(view);
}
onMounted(async () => {
try {
syncViewModeFromRoute();
await initialize();
} catch (error) {
console.error('[Chrono] - Failed to load data from ChronoManager:', error);
watch(() => route.fullPath, async () => {
selectSectionFromRoute();
if (isTasksSection.value) {
await ensureTasksLoaded();
}
});
watch(() => route.fullPath, () => {
syncViewModeFromRoute();
watch(
[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>
<template>
<div class="chrono-container">
<!-- Top Navigation Bar -->
<v-app-bar elevation="0" class="chrono-toolbar border-b">
<v-toolbar elevation="0" density="compact" class="chrono-toolbar">
<template #prepend>
<v-btn
icon="mdi-menu"
@@ -113,16 +150,16 @@ watch(() => route.fullPath, () => {
></v-btn>
</template>
<v-app-bar-title class="d-flex align-center">
<v-icon size="28" color="primary" class="mr-2">mdi-calendar-month</v-icon>
<span class="text-h6 font-weight-bold">Chrono</span>
</v-app-bar-title>
<v-toolbar-title class="d-flex align-center">
<v-icon size="24" color="primary" class="mr-2">mdi-calendar-month</v-icon>
<span class="text-subtitle-1 font-weight-bold">Chrono</span>
</v-toolbar-title>
<v-spacer></v-spacer>
<!-- View Switcher for Calendar -->
<v-btn-toggle
v-if="!isTaskView"
v-if="!isTasksSection"
:model-value="calendarView"
color="primary"
variant="outlined"
@@ -135,68 +172,12 @@ watch(() => route.fullPath, () => {
<v-btn value="month" size="small">Month</v-btn>
<v-btn value="agenda" size="small">Agenda</v-btn>
</v-btn-toggle>
</v-app-bar>
</v-toolbar>
<!-- Main Content Area -->
<div class="chrono-content">
<!-- Sidebar Navigation Drawer -->
<v-navigation-drawer
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>
<Sidebar />
<!-- Main Calendar/Task View -->
<div class="chrono-main">
@@ -223,27 +204,32 @@ watch(() => route.fullPath, () => {
</div>
</v-alert>
<CalendarView
v-if="!isTaskView"
: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"
/>
<div v-if="isChronoManagerAvailable" class="chrono-wrapper">
<v-progress-linear v-if="loading" indeterminate color="primary" />
<TaskView
v-else
:tasks="filteredTasks"
:lists="taskLists"
@task-click="editTask"
@toggle-complete="toggleTaskComplete"
/>
<CalendarView
v-if="!isTasksSection"
:view="calendarView"
:current-date="currentDate"
:calendars="calendars"
: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>
@@ -288,6 +274,8 @@ watch(() => route.fullPath, () => {
@save="saveCollection"
@delete="deleteCollection"
/>
<ImportDialog v-model="showImportDialog" :collections="collections" @close="closeImport" />
</div>
</template>
@@ -295,35 +283,38 @@ watch(() => route.fullPath, () => {
.chrono-container {
display: flex;
flex-direction: column;
height: 100vh;
height: 100%;
min-height: 0;
isolation: isolate; /* Create stacking context to prevent style leakage */
}
.chrono-toolbar {
flex-shrink: 0;
border-bottom: 1px solid rgb(var(--v-border-color)) !important;
}
.chrono-content {
display: flex;
flex: 1;
min-height: 0;
overflow: hidden;
position: relative;
}
.chrono-sidebar {
border-right: 1px solid rgb(var(--v-border-color)) !important;
overflow-y: auto;
}
.chrono-main {
flex: 1;
overflow-y: auto;
min-height: 0;
min-width: 0;
overflow: hidden;
display: flex;
flex-direction: column;
}
.border-b {
border-bottom: 1px solid rgb(var(--v-border-color));
.chrono-wrapper {
display: flex;
flex: 1;
min-height: 0;
flex-direction: column;
}
/* Responsive adjustments */
+4 -2
View File
@@ -7,12 +7,14 @@ const routes = [
{
name: 'calendar',
path: '/calendar',
component: () => import('@/pages/ChronoPage.vue'),
component: () => import('@/pages/Main.vue'),
meta: { layout: 'fixed' },
},
{
name: 'tasks',
path: '/tasks',
component: () => import('@/pages/ChronoPage.vue'),
component: () => import('@/pages/Main.vue'),
meta: { layout: 'fixed' },
},
]
+114
View File
@@ -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,
}
})
+231
View File
@@ -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,
}
})
+55
View File
@@ -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,
}
})
-424
View File
@@ -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,
}
})
+210
View File
@@ -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,
}
})
+4
View File
@@ -0,0 +1,4 @@
export interface EpochSpan {
start: number;
end: number;
}
+1
View File
@@ -5,6 +5,7 @@
*/
// Local UI-specific types
export type ChronoSection = 'calendar' | 'tasks';
export type CalendarView = 'days' | 'month' | 'agenda';
export interface ViewState {
+15
View File
@@ -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
}
-193
View File
@@ -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();
});
}
+71
View File
@@ -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(),
}
}
+61
View File
@@ -0,0 +1,61 @@
// Human-readable date/time formatting shared across chrono components
export function formatTime(date: Date): string {
return date.toLocaleTimeString('en-US', { hour: 'numeric', minute: '2-digit' })
}
export function formatEpochTime(timestamp: number): string {
return formatTime(new Date(timestamp))
}
export function formatHour(hour: number): string {
const ampm = hour >= 12 ? 'PM' : 'AM'
const displayHour = hour % 12 || 12
return `${displayHour} ${ampm}`
}
export function formatWeekDay(date: Date): string {
return date.toLocaleDateString('en-US', { weekday: 'short' })
}
export function formatFullDate(date: Date | string): string {
const value = date instanceof Date ? date : new Date(date)
return value.toLocaleDateString('en-US', {
weekday: 'long',
year: 'numeric',
month: 'long',
day: 'numeric',
})
}
// Local-time YYYY-MM-DD, for date input values
export function formatLocalIsoDate(date: Date): string {
const year = date.getFullYear()
const month = String(date.getMonth() + 1).padStart(2, '0')
const day = String(date.getDate()).padStart(2, '0')
return `${year}-${month}-${day}`
}
// Local-time HH:MM, for time input values
export function formatLocalIsoTime(date: Date): string {
return date.toTimeString().slice(0, 5)
}
// "Jul 6, 2026" from a YYYY-MM-DD (or ISO) string
export function formatDisplayDate(value: string | null | undefined): string {
if (!value) return ''
return new Date(`${value.slice(0, 10)}T00:00:00`).toLocaleDateString(undefined, {
year: 'numeric',
month: 'short',
day: 'numeric',
})
}
// "1:05 PM" from an HH:MM string
export function formatDisplayTime(value: string | null | undefined): string {
if (!value) return ''
const [hour, minute] = value.split(':').map(Number)
const date = new Date()
date.setHours(hour, minute, 0, 0)
return date.toLocaleTimeString(undefined, { hour: 'numeric', minute: '2-digit' })
}
+104
View File
@@ -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 };
}
+325
View File
@@ -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
}
+30
View File
@@ -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)
})
})
+64
View File
@@ -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])
})
})
+89
View File
@@ -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([])
})
})
+186
View File
@@ -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')
})
})
+33
View File
@@ -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/**',
],
},
},
})