Initial commit

This commit is contained in:
root
2025-12-21 09:59:39 -05:00
committed by Sebastian Krupinski
commit cc4e467cef
36 changed files with 7133 additions and 0 deletions

29
.gitignore vendored Normal file
View File

@@ -0,0 +1,29 @@
# Frontend development
node_modules/
*.local
.env.local
.env.*.local
.cache/
.vite/
.temp/
.tmp/
# Frontend build
/static/
# Backend development
/lib/vendor/
coverage/
phpunit.xml.cache
.phpunit.result.cache
.php-cs-fixer.cache
.phpstan.cache
.phpactor/
# Editors
.DS_Store
.vscode/
.idea/
# Logs
*.log

9
composer.json Normal file
View File

@@ -0,0 +1,9 @@
{
"name": "ktxm/chrono",
"type": "project",
"autoload": {
"psr-4": {
"KTXM\\Chrono\\": "lib/"
}
}
}

66
lib/Module.php Normal file
View File

@@ -0,0 +1,66 @@
<?php
namespace KTXM\Chrono;
use KTXF\Module\ModuleBrowserInterface;
use KTXF\Module\ModuleInstanceAbstract;
/**
* Chrono Module - Calendaring and Tasks
*/
class Module extends ModuleInstanceAbstract implements ModuleBrowserInterface
{
public function __construct()
{ }
public function handle(): string
{
return 'chrono';
}
public function label(): string
{
return 'Chrono';
}
public function author(): string
{
return 'Ktrix';
}
public function description(): string
{
return 'Calendar and task management interface - provides event scheduling, task tracking, and time management capabilities';
}
public function version(): string
{
return '0.0.1';
}
public function permissions(): array
{
return [
'chrono' => [
'label' => 'Access Chrono',
'description' => 'View and access the calendar and task management module',
'group' => 'Calendar & Tasks'
],
];
}
public function registerBI(): array
{
return [
'handle' => $this->handle(),
'namespace' => 'Chrono',
'version' => $this->version(),
'label' => $this->label(),
'author' => $this->author(),
'description' => $this->description(),
'boot' => 'static/module.mjs',
];
}
}

1527
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

29
package.json Normal file
View File

@@ -0,0 +1,29 @@
{
"name": "chrono",
"description": "Ktrix Chrono Module - Calendaring and Tasks",
"version": "0.0.1",
"private": true,
"license": "AGPL-3.0-or-later",
"author": "Ktrix",
"type": "module",
"scripts": {
"build": "vite build --mode production --config vite.config.ts",
"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"
},
"dependencies": {
"pinia": "^2.3.1",
"vue": "^3.5.18",
"vue-router": "^4.5.1",
"vuetify": "^3.10.2"
},
"devDependencies": {
"@vitejs/plugin-vue": "^6.0.1",
"@vue/tsconfig": "^0.7.0",
"typescript": "~5.8.3",
"vite": "^7.1.2",
"vue-tsc": "^3.0.5"
}
}

View File

@@ -0,0 +1,226 @@
<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 size="x-small" variant="text" @click="setSpan('1d')" :color="selectedSpan === '1d' ? 'primary' : undefined">1D</v-btn>
<v-btn size="x-small" variant="text" @click="setSpan('3d')" :color="selectedSpan === '3d' ? 'primary' : undefined">3D</v-btn>
<v-btn size="x-small" variant="text" @click="setSpan('1w')" :color="selectedSpan === '1w' ? 'primary' : undefined">1W</v-btn>
<v-btn size="x-small" variant="text" @click="setSpan('2w')" :color="selectedSpan === '2w' ? 'primary' : undefined">2W</v-btn>
<v-btn size="x-small" variant="text" @click="setSpan('3w')" :color="selectedSpan === '3w' ? 'primary' : undefined">3W</v-btn>
<v-btn size="x-small" variant="text" @click="setSpan('1m')" :color="selectedSpan === '1m' ? 'primary' : undefined">1M</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.id"
@click="$emit('event-click', entity)"
>
<template #prepend>
<v-icon :color="getEventColor(entity)">mdi-circle</v-icon>
</template>
<v-list-item-title>{{ entity.data?.label || 'Untitled' }}</v-list-item-title>
<v-list-item-subtitle>
{{ entity.data?.allDay ? 'All day' : `${entity.data?.startsOn ? formatTime(new Date(entity.data.startsOn)) : ''} - ${entity.data?.endsOn ? formatTime(new Date(entity.data.endsOn)) : ''}` }}
</v-list-item-subtitle>
</v-list-item>
</template>
</v-list>
</div>
</template>
<script setup lang="ts">
import { ref, computed, watch } from 'vue';
type SpanType = '1d' | '3d' | '1w' | '2w' | '3w' | '1m';
const props = defineProps<{
events: any[];
calendars: any[];
currentDate?: Date;
initialSpan?: SpanType;
}>();
defineEmits<{
'event-click': [event: any];
}>();
const selectedSpan = ref<SpanType>(props.initialSpan ?? '1w');
const localDate = ref(new Date(props.currentDate ?? new Date()));
// Watch for external date changes (e.g., from mini calendar)
watch(() => props.currentDate, (newDate) => {
if (newDate) {
localDate.value = new Date(newDate);
}
});
function setSpan(span: SpanType) {
selectedSpan.value = span;
}
function getSpanDays(): number {
switch (selectedSpan.value) {
case '1d': return 1;
case '3d': return 3;
case '1w': return 7;
case '2w': return 14;
case '3w': return 21;
case '1m': return 30;
default: return 7;
}
}
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 groupedEvents = computed(() => {
const grouped: Record<string, any[]> = {};
const { start, end } = dateRange.value;
const filtered = props.events.filter(e => {
if (!e.data?.startsOn) return false;
const eventStart = new Date(e.data.startsOn);
return eventStart >= start && eventStart <= end;
});
const sorted = filtered.sort((a, b) =>
new Date(a.data.startsOn).getTime() - new Date(b.data.startsOn).getTime()
);
sorted.forEach(entity => {
const dateKey = new Date(entity.data.startsOn).toDateString();
if (!grouped[dateKey]) {
grouped[dateKey] = [];
}
grouped[dateKey].push(entity);
});
return grouped;
});
function getEventColor(entity: any): string {
if (entity.data?.color) return entity.data.color;
const calendar = props.calendars.find(cal => cal.id === entity.in);
return calendar?.color || '#1976D2';
}
function formatTime(date: Date): string {
return date.toLocaleTimeString('en-US', { hour: 'numeric', minute: '2-digit' });
}
function formatAgendaDate(dateString: string): string {
const date = new Date(dateString);
return date.toLocaleDateString('en-US', {
weekday: 'long',
year: 'numeric',
month: 'long',
day: 'numeric'
});
}
</script>
<style scoped>
.agenda-view {
display: flex;
flex-direction: column;
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;
background-color: transparent;
}
.agenda-list :deep(.v-list-item) {
margin: 4px 8px;
border-radius: 4px;
background-color: transparent;
}
.agenda-list :deep(.v-list-item:hover) {
background-color: rgba(var(--v-theme-primary), 0.04);
}
</style>

View File

@@ -0,0 +1,96 @@
<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"
@event-click="$emit('event-click', $event)"
@date-click="$emit('date-click', $event)"
@event-hover="handleEventHover"
@event-hover-end="hidePopup"
/>
<AgendaView
v-else-if="view === 'agenda'"
:events="events"
:calendars="calendars"
@event-click="$emit('event-click', $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';
defineProps<{
view: 'days' | 'month' | 'agenda';
currentDate: Date;
events: any[];
calendars: any[];
}>();
defineEmits<{
'event-click': [event: any];
'date-click': [date: Date];
}>();
// Popup state
const hoveredEvent = ref<any>(null);
const popupPosition = ref({ x: 0, y: 0 });
const showPopup = ref(false);
let hideTimeout: NodeJS.Timeout | null = null;
function handleEventHover(data: { event: MouseEvent; entity: any }) {
if (hideTimeout) {
clearTimeout(hideTimeout);
hideTimeout = null;
}
hoveredEvent.value = data.entity;
popupPosition.value = {
x: data.event.clientX,
y: data.event.clientY
};
showPopup.value = true;
}
function hidePopup() {
hideTimeout = setTimeout(() => {
showPopup.value = false;
hoveredEvent.value = null;
}, 200);
}
</script>
<style scoped>
.calendar-view-container {
height: 100%;
width: 100%;
display: flex;
flex-direction: column;
}
.calendar-view-container > * {
flex: 1;
min-height: 0;
}
</style>

View File

@@ -0,0 +1,329 @@
<script setup lang="ts">
import { ref, computed, watch } from 'vue'
import { useServicesStore } from '@ChronoManager/stores/servicesStore'
import { CollectionObject } from '@ChronoManager/models/collection'
import { ServiceObject } from '@ChronoManager/models/service'
// Store
const servicesStore = useServicesStore()
// Props
const props = defineProps<{
modelValue: boolean
collection: CollectionObject | null
mode: 'create' | 'edit'
type?: 'calendar' | 'tasklist'
}>()
// Emits
const emit = defineEmits<{
'update:modelValue': [value: boolean]
'save': [collection: CollectionObject, service: ServiceObject]
'delete': [collection: CollectionObject]
}>()
// State
const services = ref<ServiceObject[]>([])
const editingCollection = ref<CollectionObject | null>(null)
const editingCollectionService = ref<ServiceObject | null>(null)
const editingCollectionValidated = ref(false)
const colorMenuOpen = ref(false)
const colorOptions = [
'#FF6B6B', '#FF8E4D', '#FFB347', '#FFD166', '#E5FF7F', '#B5E86D',
'#7CD992', '#46CBB7', '#2ECCCB', '#27B1E6', '#2196F3', '#2E6FE3',
'#3A4CC9', '#5A3FC0', '#7F3FBF', '#B339AE', '#DE3A9B', '#FF6FB7'
]
const COLORS_PER_ROW = 6
const colorRows = computed(() => {
const rows: string[][] = []
for (let i = 0; i < colorOptions.length; i += COLORS_PER_ROW) {
rows.push(colorOptions.slice(i, i + COLORS_PER_ROW))
}
return rows
})
// Computed
const dialogOpen = computed({
get: () => props.modelValue,
set: (value: boolean) => emit('update:modelValue', value)
})
const dialogTitle = computed(() => {
const itemType = props.type === 'tasklist' ? 'Task List' : 'Calendar'
return props.mode === 'create' ? `Create New ${itemType}` : `Edit ${itemType}`
})
const dialogIcon = computed(() => {
return props.type === 'tasklist' ? 'mdi-checkbox-marked-outline' : 'mdi-calendar'
})
// Functions
const onOpen = async () => {
if (services.value.length === 0) {
services.value = await servicesStore.list()
}
if (!props.collection) {
return
}
// Clone the collection to avoid mutating the original
editingCollection.value = props.collection.clone()
if (props.collection.id !== null) {
// Edit mode - find the service
editingCollectionService.value = services.value.find(s =>
s.provider === props.collection!.provider && s.id === props.collection!.service
) || null
} else {
// Create mode - use first service that can create
editingCollectionService.value = services.value.filter(s => s.capabilities?.CollectionCreate)[0] || null
}
}
const onSave = () => {
if (!editingCollection.value || !editingCollectionService.value) {
return
}
emit('save', editingCollection.value, editingCollectionService.value)
dialogOpen.value = false
}
const onDelete = () => {
if (!editingCollection.value) {
return
}
emit('delete', editingCollection.value)
dialogOpen.value = false
}
const onColorSelect = (color: string | null, closeMenu = true) => {
if (!editingCollection.value) {
return
}
editingCollection.value.color = color
if (closeMenu) {
colorMenuOpen.value = false
}
}
const onCustomColorChange = (color: string) => {
if (!color) {
return
}
onColorSelect(color, false)
}
// Watch for dialog opening
watch(() => props.modelValue, async (newValue) => {
if (newValue) {
await onOpen()
}
})
</script>
<template>
<v-dialog
v-model="dialogOpen"
max-width="500"
persistent
>
<v-card>
<v-card-title>
<v-icon :icon="dialogIcon" class="mr-2" />
{{ dialogTitle }}
</v-card-title>
<v-divider />
<v-card-text v-if="editingCollection" class="pt-4">
<v-form v-model="editingCollectionValidated" ref="collectionEditor">
<v-combobox v-show="mode === 'create' && services.length > 1"
v-model="editingCollectionService"
label="Service"
:items="services.filter(s => s.capabilities?.CollectionCreate)"
item-title="label"
item-value="id"
required
:rules="[(v: ServiceObject) => !!v || 'Service is required']"
/>
<div v-if="mode === 'edit'" class="mb-4"><strong>Service:</strong> {{ editingCollection.service }}</div>
<v-text-field
v-model="editingCollection.label"
label="Label"
required
:rules="[(v: string) => !!v || 'Label is required']"
>
<template #append-inner>
<v-menu
v-model="colorMenuOpen"
:close-on-content-click="false"
location="bottom end"
offset="8"
>
<template #activator="{ props: menuProps }">
<v-btn
v-bind="menuProps"
icon
variant="text"
size="small"
:style="{ color: editingCollection.color || 'var(--v-theme-on-surface)' }"
aria-label="Select color"
title="Select color"
>
<v-icon icon="mdi-palette" />
</v-btn>
</template>
<v-card class="pa-2 color-menu" elevation="4">
<div class="color-menu-header">
<v-btn
variant="text"
size="small"
class="color-menu-header__close"
aria-label="Close"
title="Close"
@click="colorMenuOpen = false"
>
<v-icon icon="mdi-close" />
</v-btn>
</div>
<div class="color-menu-body__presets">
<div
v-for="(rowColors, rowIndex) in colorRows"
:key="`color-row-${rowIndex}`"
class="color-menu-body__row"
>
<v-btn
v-for="color in rowColors"
:key="color"
variant="flat"
size="small"
class="color-menu-body__presets--swatch"
:class="{ 'color-menu-body__presets--swatch--active': editingCollection.color === color }"
:style="{ backgroundColor: color }"
@click="onColorSelect(color)">
<v-icon
v-if="editingCollection.color === color"
icon="mdi-check"
size="x-small"
color="white"
/>
</v-btn>
</div>
</div>
<div class="color-menu-body__picker">
<v-color-picker
v-model="editingCollection.color"
mode="hex"
hide-canvas
width="100%"
elevation="0"
@update:model-value="onCustomColorChange"
/>
</div>
</v-card>
</v-menu>
</template>
</v-text-field>
<v-textarea
v-model="editingCollection.description"
label="Description"
rows="2"
/>
<v-row>
<v-col v-if="mode === 'edit'" cols="6">
<v-switch
v-model="editingCollection.enabled"
label="Enabled"
color="primary"
/>
</v-col>
</v-row>
</v-form>
</v-card-text>
<v-divider />
<v-card-actions class="justify-space-between align-center">
<div>
<v-btn
v-if="mode === 'edit' && editingCollectionService?.capabilities?.CollectionDestroy"
color="error"
variant="text"
@click="onDelete"
>
Delete
</v-btn>
</div>
<div class="d-flex align-center">
<v-btn
variant="text"
@click="dialogOpen = false"
>
Cancel
</v-btn>
<v-btn
color="primary"
variant="flat"
:disabled="!editingCollectionValidated"
@click="onSave"
>
{{ mode === 'create' ? 'Create' : 'Save' }}
</v-btn>
</div>
</v-card-actions>
</v-card>
</v-dialog>
</template>
<style scoped>
.color-menu {
max-width: 320px;
}
.color-menu-header {
display: flex;
justify-content: flex-end;
width: 100%;
margin-bottom: 16px;
}
.color-menu-header__close {
margin-left: auto;
}
.color-menu-body__presets {
width: 100%;
margin-bottom: 20px;
}
.color-menu-body__row {
display: flex;
gap: 8px;
}
.color-menu-body__picker {
margin-top: 20px;
}
.color-menu-body__presets--swatch {
min-width: 32px !important;
height: 32px !important;
padding: 0 !important;
border-radius: 4px !important;
position: relative;
}
.color-menu-body__presets--swatch--active {
outline: 2px solid rgba(var(--v-theme-on-surface), 0.5);
outline-offset: 2px;
}
</style>

View File

@@ -0,0 +1,172 @@
<script setup lang="ts">
import { ref, onMounted, computed } from 'vue'
import { useCollectionsStore } from '@ChronoManager/stores/collectionsStore'
import { CollectionObject } from '@ChronoManager/models/collection';
// Store
const collectionsStore = useCollectionsStore()
// Props
const props = defineProps<{
selectedCollection?: CollectionObject | null
type?: 'calendar' | 'tasklist'
}>()
// Emits
const emit = defineEmits<{
'select': [collection: CollectionObject]
'edit': [collection: CollectionObject]
'toggle-visibility': [collection: CollectionObject]
}>()
// State
const loading = ref(false)
const collections = ref<CollectionObject[]>([])
// Computed
const filteredCollections = computed(() => {
if (!props.type) return collections.value
return collections.value.filter(collection => {
if (props.type === 'calendar') {
return collection.contents?.event
} else if (props.type === 'tasklist') {
return collection.contents?.task
}
return true
})
})
const displayTitle = computed(() => {
if (props.type === 'calendar') return 'Calendars'
if (props.type === 'tasklist') return 'Task Lists'
return 'Collections'
})
const displayIcon = computed(() => {
if (props.type === 'calendar') return 'mdi-calendar'
if (props.type === 'tasklist') return 'mdi-checkbox-marked-outline'
return 'mdi-folder'
})
// Lifecycle
onMounted(async () => {
loading.value = true
try {
collections.value = await collectionsStore.list()
} catch (error) {
console.error('[Chrono] - Failed to load collections:', error)
}
loading.value = false
})
// Functions
const onCollectionSelect = (collection: CollectionObject) => {
console.log('[Chrono] - Collection selected', collection)
emit('select', collection)
}
const onCollectionEdit = (collection: CollectionObject) => {
emit('edit', collection)
}
const onToggleVisibility = (collection: CollectionObject) => {
collection.enabled = !collection.enabled
emit('toggle-visibility', collection)
}
// Expose refresh method
defineExpose({
async refresh() {
loading.value = true
try {
collections.value = await collectionsStore.list()
} catch (error) {
console.error('[Chrono] - Failed to load collections:', error)
}
loading.value = false
}
})
</script>
<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>
</div>
<v-divider class="my-2" />
<div class="collection-selector-content">
<v-progress-linear v-if="loading" indeterminate color="primary" />
<v-list v-else density="compact" nav class="pa-0">
<v-list-item
v-for="collection in filteredCollections"
:key="collection.id"
:value="collection.id"
:active="selectedCollection?.id === collection.id"
@click="onCollectionSelect(collection)"
rounded="lg"
class="mb-1"
>
<template #prepend>
<v-checkbox-btn
:model-value="collection.enabled !== false"
:color="collection.color || 'primary'"
hide-details
@click.stop="onToggleVisibility(collection)"
/>
</template>
<v-list-item-title>{{ collection.label || 'Unnamed Collection' }}</v-list-item-title>
<template #append>
<div class="d-flex align-center">
<v-icon
:color="collection.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>
</template>
</v-list-item>
</v-list>
<v-alert v-if="!loading && filteredCollections.length === 0" type="info" variant="tonal" density="compact" class="mt-2">
No {{ displayTitle.toLowerCase() }} found
</v-alert>
</div>
</div>
</template>
<style scoped>
.collection-selector {
display: flex;
flex-direction: column;
height: 100%;
}
.collection-selector-header {
flex-shrink: 0;
}
.collection-selector-content {
flex: 1;
overflow-y: auto;
min-height: 0;
}
.v-list-item {
cursor: pointer;
}
</style>

580
src/components/DaysView.vue Normal file
View File

@@ -0,0 +1,580 @@
<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 size="x-small" variant="text" @click="setDays(1)" :color="daysCount === 1 ? 'primary' : undefined">1D</v-btn>
<v-btn size="x-small" variant="text" @click="setDays(3)" :color="daysCount === 3 ? 'primary' : undefined">3D</v-btn>
<v-btn size="x-small" variant="text" @click="setDays(5)" :color="daysCount === 5 ? 'primary' : undefined">5D</v-btn>
<v-btn size="x-small" variant="text" @click="setDays(7)" :color="daysCount === 7 ? 'primary' : undefined">7D</v-btn>
<v-btn size="x-small" variant="text" @click="setDays(9)" :color="daysCount === 9 ? 'primary' : undefined">9D</v-btn>
<v-btn size="x-small" variant="text" @click="setDays(11)" :color="daysCount === 11 ? 'primary' : undefined">11D</v-btn>
<v-btn size="x-small" variant="text" @click="setDays(14)" :color="daysCount === 14 ? 'primary' : undefined">14D</v-btn>
</div>
</div>
<div class="days-grid">
<!-- Fixed Headers Row -->
<div class="day-headers">
<div class="time-header-spacer"></div>
<div class="day-headers-row">
<div v-for="day in visibleDates" :key="day.toISOString() + '-header'" class="day-header-cell" :class="{ 'single-day': daysCount === 1 }">
<span class="day-name">{{ formatWeekDay(day) }}</span>
<span class="day-number" :class="{ 'today': isToday(day) }">{{ day.getDate() }}</span>
</div>
</div>
</div>
<!-- All-Day Events Section -->
<div v-if="allDaySegments.laneCount > 0" class="all-day-section">
<div class="all-day-label">
<span>All day</span>
</div>
<div class="all-day-events-container" :style="{ height: `${allDaySegments.laneCount * 24 + 4}px` }">
<div class="all-day-columns">
<div v-for="day in visibleDates" :key="day.toISOString() + '-allday'" class="all-day-column" :class="{ 'single-day': daysCount === 1 }"></div>
</div>
<div class="all-day-events-overlay">
<div
v-for="segment in allDaySegments.segments"
:key="segment.entity.id"
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 })"
@mouseleave="emit('event-hover-end')"
>
<span v-if="segment.isStart" class="event-label">{{ segment.entity.data?.label || 'Untitled' }}</span>
</div>
</div>
</div>
</div>
<!-- Scrollable Content -->
<div class="days-content">
<div class="time-column">
<div v-for="hour in 24" :key="hour" class="time-slot">
{{ formatHour(hour - 1) }}
</div>
</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
v-for="entity in getTimedEvents(day)"
:key="entity.id"
class="day-event"
:style="getEventStyle(entity)"
@click.stop="emit('event-click', entity)"
@mouseenter="emit('event-hover', { event: $event, entity })"
@mouseleave="emit('event-hover-end')"
>
<template v-if="daysCount <= 3">
<div class="event-time">
{{ entity.data?.startsOn ? formatTime(new Date(entity.data.startsOn)) : '' }} - {{ entity.data?.endsOn ? formatTime(new Date(entity.data.endsOn)) : '' }}
</div>
<div class="event-title">{{ entity.data?.label || 'Untitled' }}</div>
</template>
<template v-else>
<div class="event-title-compact">{{ entity.data?.label || 'Untitled' }}</div>
</template>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed, watch } from 'vue';
import {
startOfDay,
getMultiDaySegments,
getTimedEventsForDate,
type MultiDaySegment,
} from '@/utils/calendarHelpers';
const ALL_DAY_EVENT_HEIGHT = 24;
const props = defineProps<{
currentDate: Date;
events: any[];
calendars: any[];
initialDays?: number;
}>();
const daysCount = ref(props.initialDays ?? 7);
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);
});
function setDays(count: number) {
daysCount.value = count;
}
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): any[] {
return getTimedEventsForDate(props.events, date);
}
function getEventColor(entity: any): string {
if (entity.data?.color) return entity.data.color;
const calendar = props.calendars.find(cal => cal.id === entity.in);
return calendar?.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: any) {
if (!entity.data?.startsOn || !entity.data?.endsOn) {
return { display: 'none' };
}
const startTime = new Date(entity.data.startsOn);
const endTime = new Date(entity.data.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 formatWeekDay(date: Date): string {
return date.toLocaleDateString('en-US', { weekday: 'short' });
}
const emit = defineEmits<{
'event-click': [event: any];
'event-hover': [data: { event: MouseEvent; entity: any }];
'event-hover-end': [];
'date-click': [date: Date];
}>();
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;
}
.days-grid {
display: flex;
flex-direction: column;
flex: 1;
overflow: hidden;
border: 1px solid rgb(var(--v-border-color));
border-top: none;
}
/* Fixed Headers Row */
.day-headers {
display: flex;
flex-shrink: 0;
border-bottom: 2px solid rgb(var(--v-border-color));
background-color: rgb(var(--v-theme-surface-variant));
}
.time-header-spacer {
width: 60px;
min-width: 60px;
flex-shrink: 0;
border-right: 1px solid rgb(var(--v-border-color));
}
.day-headers-row {
flex: 1;
display: flex;
}
.day-header-cell {
flex: 1;
min-width: 80px;
padding: 4px;
text-align: center;
border-right: 1px solid rgb(var(--v-border-color));
display: flex;
align-items: center;
justify-content: center;
gap: 4px;
}
.day-header-cell:last-child {
border-right: none;
}
.day-header-cell.single-day {
min-width: 100%;
}
.day-name {
font-size: 12px;
text-transform: uppercase;
opacity: 0.7;
font-weight: 500;
}
.day-number {
font-size: 12px;
font-weight: 600;
}
.day-number.today {
background-color: rgb(var(--v-theme-primary));
color: rgb(var(--v-theme-on-primary));
border-radius: 50%;
width: 20px;
height: 20px;
display: inline-flex;
align-items: center;
justify-content: center;
font-size: 11px;
}
/* All-Day Events Section */
.all-day-section {
display: flex;
flex-shrink: 0;
border-bottom: 1px solid rgb(var(--v-border-color));
background-color: rgb(var(--v-theme-surface));
min-height: 28px;
}
.all-day-label {
width: 60px;
min-width: 60px;
flex-shrink: 0;
border-right: 1px solid rgb(var(--v-border-color));
background-color: rgb(var(--v-theme-surface-variant));
display: flex;
align-items: flex-start;
justify-content: flex-end;
padding: 4px 8px;
font-size: 10px;
opacity: 0.6;
text-transform: uppercase;
}
.all-day-events-container {
flex: 1;
position: relative;
min-height: 28px;
}
.all-day-columns {
display: flex;
height: 100%;
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
}
.all-day-column {
flex: 1;
min-width: 80px;
border-right: 1px solid rgb(var(--v-border-color), 0.3);
}
.all-day-column.single-day {
min-width: 100%;
}
.all-day-column:last-child {
border-right: none;
}
.all-day-events-overlay {
position: absolute;
top: 2px;
left: 0;
right: 0;
bottom: 2px;
pointer-events: none;
z-index: 1;
}
.all-day-event {
position: absolute;
height: 20px;
padding: 0 6px;
font-size: 12px;
color: white;
cursor: pointer;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
font-weight: 500;
display: flex;
align-items: center;
pointer-events: auto;
box-sizing: border-box;
border-radius: 4px;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.1);
}
.all-day-event:not(.is-start) {
border-top-left-radius: 0;
border-bottom-left-radius: 0;
padding-left: 4px;
}
.all-day-event:not(.is-end) {
border-top-right-radius: 0;
border-bottom-right-radius: 0;
}
.all-day-event:hover {
filter: brightness(1.1);
z-index: 2;
}
.all-day-event .event-label {
overflow: hidden;
text-overflow: ellipsis;
}
/* Scrollable Content Area */
.days-content {
display: flex;
flex: 1;
overflow-y: auto;
overflow-x: hidden;
min-height: 0; /* Allow flex child to shrink for scrolling */
}
.time-column {
width: 60px;
min-width: 60px;
height: 1440px; /* 24 hours * 60px per hour */
border-right: 1px solid rgb(var(--v-border-color));
background-color: rgb(var(--v-theme-surface-variant));
flex-shrink: 0;
}
.time-slot {
height: 60px;
padding: 4px 8px;
font-size: 11px;
opacity: 0.6;
border-bottom: 1px solid rgb(var(--v-border-color), 0.5);
text-align: right;
}
.days-columns {
flex: 1;
display: flex;
min-height: 1440px; /* 24 hours * 60px per hour */
}
.day-column {
flex: 1;
min-width: 80px;
border-right: 1px solid rgb(var(--v-border-color));
}
.day-column.single-day {
min-width: 100%;
}
.day-column:last-child {
border-right: none;
}
.day-events {
position: relative;
height: 1440px; /* 24 hours * 60px per hour */
cursor: pointer;
}
.day-events:hover {
background-color: rgba(var(--v-theme-primary), 0.02);
}
.day-event {
position: absolute;
left: 4px;
right: 4px;
padding: 6px 8px;
border-radius: 4px;
color: white;
font-size: 12px;
cursor: pointer;
overflow: hidden;
font-weight: 500;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.12);
transition: box-shadow 0.2s;
}
.day-event:hover {
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.2);
filter: brightness(1.1);
}
.event-time {
font-weight: 600;
margin-bottom: 2px;
font-size: 11px;
}
.event-title {
font-size: 13px;
font-weight: 500;
}
.event-title-compact {
font-size: 11px;
font-weight: 500;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
</style>

View File

@@ -0,0 +1,277 @@
<script setup lang="ts">
import { ref, computed } from 'vue'
import type { CollectionObject } from '@ChronoManager/models/collection'
import type { EntityObject } from '@ChronoManager/models/entity'
import type { EventObject } from '@ChronoManager/models/event'
import EventEditorLabel from './editors/EventEditorLabel.vue'
import EventEditorDescription from './editors/EventEditorDescription.vue'
import EventEditorDates from './editors/EventEditorDates.vue'
import EventEditorLocations from './editors/EventEditorLocations.vue'
import EventEditorParticipants from './editors/EventEditorParticipants.vue'
import EventEditorNotifications from './editors/EventEditorNotifications.vue'
import EventEditorOccurrence from './editors/EventEditorOccurrence.vue'
import EventEditorTags from './editors/EventEditorTags.vue'
// Props - wrapped entity with id, data properties
const props = defineProps<{
mode: 'edit' | 'view'
entity: EntityObject | null
collection?: CollectionObject | null
calendars?: CollectionObject[]
}>()
// Emits
const emit = defineEmits<{
'edit': []
'cancel': []
'close': []
'save': [entity: any, collection?: any]
'delete': [entity: any, collection?: any]
}>()
// State
const loading = ref(false)
const saving = ref(false)
const selectedCollectionId = ref(props.entity?.in || props.collection?.id || props.calendars?.[0]?.id || '')
// Computed
const mode = computed(() => props.mode)
const entity = computed(() => props.entity || null)
const entityObject = computed(() => entity.value?.data as EventObject ?? null)
const entityFresh = computed(() => entity.value?.id === null || entity.value?.id === undefined)
const calendarOptions = computed(() =>
(props.calendars || []).map(cal => ({
title: cal.label || 'Unnamed Calendar',
value: cal.id,
}))
)
// Permissions
const isExternalEvent = computed(() => {
return false
})
const canEdit = computed(() => {
return true
})
const canDelete = computed(() => {
return !isExternalEvent.value
})
// UI helpers
const entityIcon = computed(() => 'mdi-calendar')
// Functions
const startEdit = () => {
emit('edit')
}
const cancelEdit = () => {
emit('cancel')
}
const saveEntity = async () => {
saving.value = true
const targetCollection = (props.calendars || []).find(cal => cal.id === selectedCollectionId.value)
emit('save', entity.value!, targetCollection || props.collection)
saving.value = false
}
// Location management
const addLocationPhysical = () => {
(entityObject.value as any)?.addLocationPhysical()
}
const removeLocationPhysical = (key: string) => {
(entityObject.value as any)?.removeLocationPhysical(key)
}
const addLocationVirtual = () => {
(entityObject.value as any)?.addLocationVirtual()
}
const removeLocationVirtual = (key: string) => {
(entityObject.value as any)?.removeLocationVirtual(key)
}
// Participant management
const addParticipant = () => {
(entityObject.value as any)?.addParticipant()
}
const removeParticipant = (key: string) => {
(entityObject.value as any)?.removeParticipant(key)
}
// Notification management
const addNotification = () => {
(entityObject.value as any)?.addNotification()
}
const removeNotification = (key: string) => {
(entityObject.value as any)?.removeNotification(key)
}
</script>
<template>
<v-sheet class="pa-2 text-start overflow-auto" min-height="84vh" max-height="84vh">
<div class="event-editor-header d-flex justify-space-between pa-4">
<div>
<v-icon :icon="entityIcon" class="mr-2" />
{{ entityObject?.label || 'Nothing Selected' }}
</div>
<div v-if="!loading && entity">
<v-btn
v-if="mode === 'view' && canEdit"
icon="mdi-pencil"
size="small"
variant="text"
@click="startEdit"
/>
<v-btn
v-if="!entityFresh && canDelete"
icon="mdi-delete"
size="small"
variant="text"
color="error"
@click="emit('delete', entity!, collection)"
/>
<v-btn
icon="mdi-close"
size="small"
variant="text"
@click="$emit('close')"
/>
</div>
</div>
<div class="event-editor-fields">
<v-divider />
<v-progress-linear v-if="loading" indeterminate color="primary" />
<div v-if="!entity" class="text-center pa-8">
<v-icon icon="mdi-calendar-blank" size="64" color="grey" class="mb-4" />
<p class="text-h6 text-grey">Select an event to view details</p>
</div>
<div v-else class="pa-4">
<v-select
v-if="mode === 'edit' && !entity.id"
v-model="selectedCollectionId"
:items="calendarOptions"
label="Calendar"
variant="outlined"
density="compact"
class="mb-4"
/>
<EventEditorLabel
:mode="mode"
:label="entityObject!.label"
@update:label="entityObject!.label = $event"
/>
<EventEditorDescription
:mode="mode"
:description="entityObject!.description"
@update:description="entityObject!.description = $event"
/>
<EventEditorDates
:mode="mode"
:starts-on="entityObject!.startsOn"
:ends-on="entityObject!.endsOn"
:timeless="entityObject!.timeless"
:time-zone="entityObject!.timeZone"
@update:starts-on="entityObject!.startsOn = $event"
@update:ends-on="entityObject!.endsOn = $event"
@update:timeless="entityObject!.timeless = $event"
@update:time-zone="entityObject!.timeZone = $event"
/>
<EventEditorLocations
:mode="mode"
:locations-physical="entityObject!.locationsPhysical || {}"
:locations-virtual="entityObject!.locationsVirtual || {}"
@add-location-physical="addLocationPhysical"
@remove-location-physical="removeLocationPhysical"
@add-location-virtual="addLocationVirtual"
@remove-location-virtual="removeLocationVirtual"
/>
<EventEditorParticipants
:mode="mode"
:organizer="entityObject!.organizer"
:participants="entityObject!.participants || {}"
@update:organizer="entityObject!.organizer = $event"
@add-participant="addParticipant"
@remove-participant="removeParticipant"
/>
<EventEditorNotifications
:mode="mode"
:notifications="entityObject!.notifications || {}"
@add-notification="addNotification"
@remove-notification="removeNotification"
/>
<EventEditorOccurrence
:mode="mode"
:pattern="entityObject!.pattern"
@update:pattern="entityObject!.pattern = $event"
/>
<EventEditorTags
:mode="mode"
:tags="entityObject!.tags"
@update:tags="entityObject!.tags = $event"
/>
<!-- Metadata for view mode -->
<div v-if="mode === 'view' && entityObject!.created" class="event-editor-section">
<v-divider class="mb-4"></v-divider>
<div class="text-caption text-medium-emphasis">
<div>Created: {{ new Date(entityObject!.created).toLocaleString() }}</div>
<div v-if="entityObject!.modified">Modified: {{ new Date(entityObject!.modified).toLocaleString() }}</div>
<div v-if="isExternalEvent" class="mt-2">
<v-chip size="small" color="info" variant="tonal">
<v-icon start size="small">mdi-account-arrow-right</v-icon>
External Event
</v-chip>
</div>
</div>
</div>
</div>
<v-divider/>
</div>
<div class="event-editor-footer d-flex text-end justify-space-between pa-4">
<v-btn v-if="mode === 'edit'"
variant="text"
@click="cancelEdit"
:disabled="saving">
Cancel
</v-btn>
<v-btn v-if="mode === 'edit'"
color="primary"
variant="elevated"
@click="saveEntity"
:loading="saving">
Save
</v-btn>
</div>
</v-sheet>
</template>
<style scoped>
.event-editor-section {
margin-bottom: 1.5rem;
}
</style>

View File

@@ -0,0 +1,181 @@
<script setup lang="ts">
import { computed } from 'vue'
const props = defineProps<{
event: any | null
position: { x: number; y: number }
visible: boolean
}>()
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 => {
if (!isoString) return 'Not set'
const date = new Date(isoString)
return date.toLocaleTimeString('en-US', {
hour: 'numeric',
minute: '2-digit'
})
}
const popupStyle = computed(() => {
return {
left: `${props.position.x}px`,
top: `${props.position.y}px`
}
})
const hasLocation = computed(() => {
return props.event?.data?.locationsPhysical && Object.keys(props.event.data.locationsPhysical).length > 0 ||
props.event?.data?.locationsVirtual && Object.keys(props.event.data.locationsVirtual).length > 0
})
const firstLocation = computed(() => {
if (props.event?.data?.locationsPhysical && Object.keys(props.event.data.locationsPhysical).length > 0) {
const key = Object.keys(props.event.data.locationsPhysical)[0]
return props.event.data.locationsPhysical[key]
}
if (props.event?.data?.locationsVirtual && Object.keys(props.event.data.locationsVirtual).length > 0) {
const key = Object.keys(props.event.data.locationsVirtual)[0]
return props.event.data.locationsVirtual[key]
}
return null
})
const participantCount = computed(() => {
if (!props.event?.data?.participants) return 0
return Object.keys(props.event.data.participants).length
})
</script>
<template>
<Transition name="fade">
<div
v-if="visible && event"
class="event-popup"
:style="popupStyle"
@click.stop="$emit('click', event)"
>
<v-card elevation="8" class="event-popup-card">
<v-card-text class="pa-3">
<div class="d-flex align-center mb-2">
<v-icon icon="mdi-calendar" size="small" class="mr-2" />
<div class="text-subtitle-1 font-weight-bold">{{ event.data?.label || 'Untitled Event' }}</div>
</div>
<div class="event-popup-details">
<div class="d-flex align-center mb-1">
<v-icon icon="mdi-clock-outline" size="small" class="mr-2 text-grey" />
<div class="text-caption">
<div v-if="event.data?.timeless">
All Day - {{ new Date(event.data.startsOn).toLocaleDateString() }}
</div>
<div v-else>
{{ formatTime(event.data?.startsOn) }} - {{ formatTime(event.data?.endsOn) }}
</div>
</div>
</div>
<div v-if="hasLocation" class="d-flex align-center mb-1">
<v-icon icon="mdi-map-marker" size="small" class="mr-2 text-grey" />
<div class="text-caption text-truncate">
{{ firstLocation?.label || firstLocation?.location || 'Location' }}
</div>
</div>
<div v-if="event.data?.organizer" class="d-flex align-center mb-1">
<v-icon icon="mdi-account" size="small" class="mr-2 text-grey" />
<div class="text-caption">
{{ event.data.organizer.name || event.data.organizer.address }}
</div>
</div>
<div v-if="participantCount > 0" class="d-flex align-center mb-1">
<v-icon icon="mdi-account-group" size="small" class="mr-2 text-grey" />
<div class="text-caption">
{{ participantCount }} participant{{ participantCount !== 1 ? 's' : '' }}
</div>
</div>
<div v-if="event.data?.description" class="mt-2 pt-2 border-t">
<div class="text-caption text-grey">{{ event.data.description }}</div>
</div>
<div v-if="event.data?.tags && event.data.tags.length > 0" class="mt-2">
<v-chip
v-for="(tag, index) in event.data.tags.slice(0, 3)"
:key="index"
size="x-small"
class="mr-1"
variant="tonal"
>
{{ tag }}
</v-chip>
<span v-if="event.data.tags.length > 3" class="text-caption text-grey">
+{{ event.data.tags.length - 3 }} more
</span>
</div>
</div>
<div class="text-caption text-grey-lighten-1 mt-2 text-center">
Click to view details
</div>
</v-card-text>
</v-card>
</div>
</Transition>
</template>
<style scoped>
.event-popup {
position: fixed;
z-index: 9999;
pointer-events: auto;
transform: translate(10px, 10px);
}
.event-popup-card {
min-width: 280px;
max-width: 350px;
cursor: pointer;
transition: box-shadow 0.2s;
}
.event-popup-card:hover {
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.2) !important;
}
.event-popup-details {
font-size: 13px;
}
.border-t {
border-top: 1px solid rgb(var(--v-border-color));
}
/* Fade transition */
.fade-enter-active,
.fade-leave-active {
transition: opacity 0.15s ease;
}
.fade-enter-from,
.fade-leave-to {
opacity: 0;
}
</style>

View File

@@ -0,0 +1,262 @@
<template>
<div class="mini-calendar">
<!-- Navigation Header -->
<div class="mini-calendar-header">
<v-btn size="x-small" variant="text" icon="mdi-chevron-left" density="compact" @click="previousMonth" />
<div class="month-year-selector">
<v-menu>
<template #activator="{ props: menuProps }">
<v-btn
size="x-small"
variant="text"
v-bind="menuProps"
class="month-btn"
>
{{ monthLabel }}
</v-btn>
</template>
<v-list density="compact" class="month-list">
<v-list-item
v-for="(month, index) in months"
:key="month"
:active="index === displayDate.getMonth()"
@click="setMonth(index)"
>
<v-list-item-title>{{ month }}</v-list-item-title>
</v-list-item>
</v-list>
</v-menu>
<v-menu>
<template #activator="{ props: menuProps }">
<v-btn
size="x-small"
variant="text"
v-bind="menuProps"
class="year-btn"
>
{{ displayDate.getFullYear() }}
</v-btn>
</template>
<v-list density="compact" class="year-list">
<v-list-item
v-for="year in yearRange"
:key="year"
:active="year === displayDate.getFullYear()"
@click="setYear(year)"
>
<v-list-item-title>{{ year }}</v-list-item-title>
</v-list-item>
</v-list>
</v-menu>
</div>
<v-btn size="x-small" variant="text" icon="mdi-chevron-right" density="compact" @click="nextMonth" />
</div>
<!-- Day Headers -->
<div class="mini-calendar-grid">
<div v-for="day in dayHeaders" :key="day" class="mini-day-header">
{{ day }}
</div>
<!-- Calendar Days -->
<div
v-for="(date, index) in calendarDates"
:key="index"
class="mini-day"
:class="{
'today': isToday(date),
'selected': isSelected(date),
'other-month': !isSameMonth(date, displayDate)
}"
@click="selectDate(date)"
>
{{ date.getDate() }}
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed, watch } from 'vue';
const props = defineProps<{
modelValue: Date;
}>();
const emit = defineEmits<{
'update:modelValue': [date: Date];
}>();
const months = [
'January', 'February', 'March', 'April', 'May', 'June',
'July', 'August', 'September', 'October', 'November', 'December'
];
const dayHeaders = ['S', 'M', 'T', 'W', 'T', 'F', 'S'];
// The display date controls which month/year is shown
const displayDate = ref(new Date(props.modelValue));
// Watch for external changes to modelValue
watch(() => props.modelValue, (newDate) => {
displayDate.value = new Date(newDate);
});
const monthLabel = computed(() => {
return months[displayDate.value.getMonth()];
});
const yearRange = computed(() => {
const currentYear = new Date().getFullYear();
const years: number[] = [];
for (let y = currentYear - 10; y <= currentYear + 10; y++) {
years.push(y);
}
return years;
});
const calendarDates = computed(() => {
const year = displayDate.value.getFullYear();
const month = displayDate.value.getMonth();
const firstDay = new Date(year, month, 1);
const startDate = new Date(firstDay);
startDate.setDate(startDate.getDate() - startDate.getDay());
const dates: Date[] = [];
const current = new Date(startDate);
for (let i = 0; i < 42; i++) {
dates.push(new Date(current));
current.setDate(current.getDate() + 1);
}
return dates;
});
function previousMonth() {
const newDate = new Date(displayDate.value);
newDate.setMonth(newDate.getMonth() - 1);
displayDate.value = newDate;
}
function nextMonth() {
const newDate = new Date(displayDate.value);
newDate.setMonth(newDate.getMonth() + 1);
displayDate.value = newDate;
}
function setMonth(monthIndex: number) {
const newDate = new Date(displayDate.value);
newDate.setMonth(monthIndex);
displayDate.value = newDate;
}
function setYear(year: number) {
const newDate = new Date(displayDate.value);
newDate.setFullYear(year);
displayDate.value = newDate;
}
function selectDate(date: Date) {
emit('update:modelValue', date);
}
function isToday(date: Date): boolean {
const today = new Date();
return date.toDateString() === today.toDateString();
}
function isSelected(date: Date): boolean {
return date.toDateString() === props.modelValue.toDateString();
}
function isSameMonth(date1: Date, date2: Date): boolean {
return date1.getMonth() === date2.getMonth() && date1.getFullYear() === date2.getFullYear();
}
</script>
<style scoped>
.mini-calendar {
user-select: none;
}
.mini-calendar-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 8px;
}
.month-year-selector {
display: flex;
align-items: center;
gap: 2px;
}
.month-btn, .year-btn {
font-size: 12px !important;
font-weight: 500;
text-transform: none !important;
letter-spacing: normal !important;
min-width: 0 !important;
padding: 0 4px !important;
}
.month-list, .year-list {
max-height: 250px;
overflow-y: auto;
}
.mini-calendar-grid {
display: grid;
grid-template-columns: repeat(7, 1fr);
gap: 2px;
}
.mini-day-header {
text-align: center;
font-weight: 600;
padding: 4px 0;
opacity: 0.6;
font-size: 10px;
line-height: 1;
}
.mini-day {
text-align: center;
padding: 6px 4px;
cursor: pointer;
border-radius: 4px;
transition: background-color 0.2s;
font-size: 11px;
line-height: 1;
display: flex;
align-items: center;
justify-content: center;
min-height: 24px;
}
.mini-day:hover {
background-color: rgba(var(--v-theme-primary), 0.08);
}
.mini-day.today {
background-color: rgb(var(--v-theme-primary));
color: rgb(var(--v-theme-on-primary));
font-weight: 600;
}
.mini-day.today:hover {
background-color: rgb(var(--v-theme-primary));
filter: brightness(1.1);
}
.mini-day.selected:not(.today) {
outline: 2px solid rgb(var(--v-theme-primary));
font-weight: 600;
}
.mini-day.other-month {
opacity: 0.3;
}
</style>

View File

@@ -0,0 +1,551 @@
<script setup lang="ts">
import { computed, ref, watch, onMounted, onUnmounted } from 'vue';
import {
startOfDay,
daysDiff,
isMultiDay,
getSingleDayEventsForDate,
type MultiDaySegment,
} from '@/utils/calendarHelpers';
const EVENT_HEIGHT = 22; // Height of each event row in pixels
const MAX_VISIBLE_EVENTS = 3; // Maximum event rows to show before "+N more"
interface WeekData {
startDate: Date;
days: { date: Date; currentMonth: boolean }[];
multiDaySegments: MultiDaySegment[];
laneCount: number;
}
const props = defineProps<{
currentDate: Date;
events: any[];
calendars: any[];
}>();
defineEmits<{
'event-click': [event: any];
'date-click': [date: Date];
'event-hover': [data: { event: MouseEvent; entity: any }];
'event-hover-end': [];
}>();
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;
}
}
onMounted(() => {
updateContainerHeight();
window.addEventListener('resize', updateContainerHeight);
});
onUnmounted(() => {
window.removeEventListener('resize', updateContainerHeight);
});
// Calculate how many weeks are needed for this month
const weeksNeeded = computed(() => {
const year = localDate.value.getFullYear();
const month = localDate.value.getMonth();
const firstDay = new Date(year, month, 1);
const lastDay = new Date(year, month + 1, 0);
// Days before the 1st in the first week + days in month
const daysBeforeFirst = firstDay.getDay();
const daysInMonth = lastDay.getDate();
return Math.ceil((daysBeforeFirst + daysInMonth) / 7);
});
// Build weeks with multiday event segments
const weeks = computed<WeekData[]>(() => {
const year = localDate.value.getFullYear();
const month = localDate.value.getMonth();
const firstDay = new Date(year, month, 1);
const gridStart = new Date(firstDay);
gridStart.setDate(gridStart.getDate() - gridStart.getDay());
const weeksData: WeekData[] = [];
const current = new Date(gridStart);
const numWeeks = weeksNeeded.value;
for (let w = 0; w < numWeeks; w++) {
const weekStart = startOfDay(new Date(current));
const weekEnd = new Date(weekStart);
weekEnd.setDate(weekEnd.getDate() + 6);
const days: { date: Date; currentMonth: boolean }[] = [];
for (let d = 0; d < 7; d++) {
days.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);
weeksData.push({
startDate: weekStart,
days,
multiDaySegments: segments,
laneCount: Math.min(laneCount, MAX_VISIBLE_EVENTS),
});
}
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.data?.startsOn || !isMultiDay(entity)) return false;
const eventStart = startOfDay(new Date(entity.data.startsOn));
const eventEnd = startOfDay(new Date(entity.data.endsOn));
return eventStart <= weekEnd && eventEnd >= weekStart;
});
// Sort: longer events first, then by start date
multiDayEvents.sort((a, b) => {
const aStart = new Date(a.data.startsOn);
const aEnd = new Date(a.data.endsOn);
const bStart = new Date(b.data.startsOn);
const bEnd = new Date(b.data.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.data.startsOn));
const eventEnd = startOfDay(new Date(entity.data.endsOn));
// Clamp to this week
const segStart = eventStart < weekStart ? weekStart : eventStart;
const segEnd = eventEnd > weekEnd ? weekEnd : eventEnd;
const startCol = segStart.getDay();
const endCol = segEnd.getDay();
const span = endCol - startCol + 1;
// Find available lane
let lane = 0;
while (true) {
if (!lanes[lane]) lanes[lane] = [false, false, false, false, false, false, false];
let isFree = true;
for (let d = startCol; d <= endCol; d++) {
if (lanes[lane][d]) { isFree = false; break; }
}
if (isFree) {
for (let d = startCol; d <= endCol; d++) lanes[lane][d] = true;
break;
}
lane++;
}
segments.push({
entity,
startCol,
span,
lane,
isStart: eventStart >= weekStart,
isEnd: eventEnd <= weekEnd,
});
}
return { segments, laneCount: lanes.length };
}
// Single-day events for a specific date
function getSingleDayEvents(date: Date): any[] {
return getSingleDayEventsForDate(props.events, date);
}
// 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.data?.color) return entity.data.color;
const calendar = props.calendars.find(cal => cal.id === entity.in);
return calendar?.color || '#1976D2';
}
</script>
<template>
<div class="month-view" ref="containerRef">
<!-- 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>
<!-- Header row -->
<div class="day-headers">
<div v-for="day in weekDays" :key="day" class="day-header-cell">
{{ day }}
</div>
</div>
<!-- Week rows -->
<div class="calendar-body">
<div v-for="(week, weekIndex) in weeks" :key="weekIndex" class="calendar-week">
<!-- Base layer: Day cells grid -->
<div class="days-grid">
<div
v-for="(cell, dayIndex) in week.days"
:key="dayIndex"
class="calendar-cell"
:class="{
'today': isToday(cell.date),
'other-month': !cell.currentMonth
}"
@click="$emit('date-click', cell.date)"
>
<div class="cell-date">{{ cell.date.getDate() }}</div>
<!-- Spacer for multiday events -->
<div
class="multiday-spacer"
:style="{ height: `${week.laneCount * EVENT_HEIGHT}px` }"
></div>
<!-- Single-day events -->
<div class="cell-events">
<div
v-for="entity in getSingleDayEvents(cell.date).slice(0, MAX_VISIBLE_EVENTS - week.laneCount)"
:key="entity.id"
class="single-day-event"
:style="{ backgroundColor: getEventColor(entity) }"
@click.stop="$emit('event-click', entity)"
@mouseenter="$emit('event-hover', { event: $event, entity })"
@mouseleave="$emit('event-hover-end')"
>
{{ entity.data?.label || 'Untitled' }}
</div>
<div
v-if="getHiddenCount(cell, week.laneCount) > 0"
class="more-events"
@click.stop="$emit('date-click', cell.date)"
>
+{{ getHiddenCount(cell, week.laneCount) }} more
</div>
</div>
</div>
</div>
<!-- Overlay layer: Multiday events (positioned absolutely over the grid) -->
<div class="multiday-overlay">
<div
v-for="segment in week.multiDaySegments"
:key="`${segment.entity.id}-${weekIndex}`"
class="multiday-event"
:class="{
'is-start': segment.isStart,
'is-end': segment.isEnd,
}"
:style="{
backgroundColor: getEventColor(segment.entity),
left: `calc(${segment.startCol} / 7 * 100% + 4px)`,
width: `calc(${segment.span} / 7 * 100% - 8px)`,
top: `${34 + segment.lane * EVENT_HEIGHT}px`,
}"
@click.stop="$emit('event-click', segment.entity)"
@mouseenter="$emit('event-hover', { event: $event, entity: segment.entity })"
@mouseleave="$emit('event-hover-end')"
>
<span v-if="segment.isStart" class="event-label">
{{ segment.entity.data?.label || 'Untitled' }}
</span>
</div>
</div>
</div>
</div>
</div>
</template>
<style scoped>
.month-view {
height: 100%;
width: 100%;
display: flex;
flex-direction: column;
flex: 1;
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);
flex-shrink: 0;
border-bottom: 2px solid rgb(var(--v-border-color));
background-color: rgb(var(--v-theme-surface-variant));
}
.day-header-cell {
padding: 4px;
text-align: center;
font-weight: 500;
font-size: 12px;
text-transform: uppercase;
opacity: 0.7;
border-right: 1px solid rgb(var(--v-border-color));
}
.day-header-cell:last-child {
border-right: none;
}
.calendar-body {
display: flex;
flex-direction: column;
flex: 1;
min-height: 0;
}
.calendar-week {
position: relative;
flex: 1;
min-height: 0;
}
/* Base layer: Day cells */
.days-grid {
display: grid;
grid-template-columns: repeat(7, 1fr);
height: 100%;
}
.calendar-cell {
background-color: transparent;
padding: 4px;
cursor: pointer;
overflow: hidden;
border-right: 1px solid rgb(var(--v-border-color));
border-bottom: 1px solid rgb(var(--v-border-color));
display: flex;
flex-direction: column;
transition: background-color 0.2s;
}
.calendar-cell:last-child {
border-right: none;
}
.calendar-cell.other-month {
opacity: 0.4;
}
.calendar-cell:hover {
background-color: rgba(var(--v-theme-primary), 0.04);
}
.calendar-cell.today {
background-color: rgba(var(--v-theme-primary), 0.06);
}
.calendar-cell.today:hover {
background-color: rgba(var(--v-theme-primary), 0.1);
}
.cell-date {
font-weight: 500;
font-size: 14px;
padding: 0 4px;
flex-shrink: 0;
}
.calendar-cell.today .cell-date {
background-color: rgb(var(--v-theme-primary));
color: rgb(var(--v-theme-on-primary));
width: 28px;
height: 28px;
border-radius: 50%;
display: inline-flex;
align-items: center;
justify-content: center;
font-weight: 600;
}
/* Spacer to reserve room for multiday events overlay */
.multiday-spacer {
flex-shrink: 0;
}
.cell-events {
display: flex;
flex-direction: column;
gap: 2px;
flex: 1;
overflow: hidden;
margin-top: 2px;
}
.single-day-event {
height: 20px;
padding: 2px 6px;
border-radius: 4px;
font-size: 12px;
color: white;
cursor: pointer;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
font-weight: 500;
display: flex;
align-items: center;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.1);
flex-shrink: 0;
}
.single-day-event:hover {
filter: brightness(1.1);
}
.more-events {
font-size: 11px;
color: rgb(var(--v-theme-primary));
padding: 2px 6px;
cursor: pointer;
font-weight: 500;
flex-shrink: 0;
}
.more-events:hover {
text-decoration: underline;
}
/* Overlay layer: Multiday events */
.multiday-overlay {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
pointer-events: none;
z-index: 1;
}
.multiday-event {
position: absolute;
height: 20px;
padding: 0 6px;
font-size: 12px;
color: white;
cursor: pointer;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
font-weight: 500;
display: flex;
align-items: center;
pointer-events: auto;
box-sizing: border-box;
border-radius: 4px;
}
.multiday-event:not(.is-start) {
border-top-left-radius: 0;
border-bottom-left-radius: 0;
padding-left: 4px;
}
.multiday-event:not(.is-end) {
border-top-right-radius: 0;
border-bottom-right-radius: 0;
}
.multiday-event:hover {
filter: brightness(1.1);
z-index: 2;
}
.event-label {
overflow: hidden;
text-overflow: ellipsis;
}
</style>

View File

@@ -0,0 +1,324 @@
<template>
<v-card v-if="entity" class="task-editor">
<v-card-title class="d-flex justify-space-between align-center">
<span>{{ entity.id ? entity.data?.label || 'Untitled Task' : 'New Task' }}</span>
<div class="d-flex gap-2">
<v-btn
v-if="mode === 'view' && canEdit"
icon="mdi-pencil"
variant="text"
size="small"
@click="$emit('edit')"
></v-btn>
<v-btn
icon="mdi-close"
variant="text"
size="small"
@click="$emit('close')"
></v-btn>
</div>
</v-card-title>
<v-divider></v-divider>
<v-card-text class="task-editor-content">
<v-form ref="formRef">
<v-text-field
v-model="entity.data.label"
label="Title"
:rules="[v => !!v || 'Title is required']"
:readonly="!isEditing"
:variant="isEditing ? 'outlined' : 'plain'"
class="mb-4"
></v-text-field>
<v-select
v-model="selectedCollectionId"
:items="listOptions"
label="Task List"
:readonly="!isEditing || !canChangeCollection"
:variant="isEditing && canChangeCollection ? 'outlined' : 'plain'"
class="mb-4"
></v-select>
<v-textarea
v-model="entity.data.description"
label="Description"
:readonly="!isEditing"
:variant="isEditing ? 'outlined' : 'plain'"
rows="3"
class="mb-4"
></v-textarea>
<v-select
v-model="entity.data.priority"
:items="priorityOptions"
label="Priority"
:readonly="!isEditing"
:variant="isEditing ? 'outlined' : 'plain'"
class="mb-4"
></v-select>
<v-text-field
v-model="dueDate"
label="Due Date"
type="date"
:readonly="!isEditing || !canEditDates"
:variant="isEditing && canEditDates ? 'outlined' : 'plain'"
clearable
class="mb-4"
></v-text-field>
<v-text-field
v-model="startDate"
label="Start Date"
type="date"
:readonly="!isEditing || !canEditDates"
:variant="isEditing && canEditDates ? 'outlined' : 'plain'"
clearable
class="mb-4"
></v-text-field>
<v-combobox
v-model="entity.data.categories"
label="Tags"
multiple
chips
closable-chips
:readonly="!isEditing"
:variant="isEditing ? 'outlined' : 'plain'"
class="mb-4"
></v-combobox>
<div class="d-flex gap-4">
<v-text-field
v-model.number="entity.data.estimatedTime"
label="Estimated Time (minutes)"
type="number"
:readonly="!isEditing"
:variant="isEditing ? 'outlined' : 'plain'"
class="flex-1-1"
></v-text-field>
<v-text-field
v-model.number="entity.data.actualTime"
label="Actual Time (minutes)"
type="number"
:readonly="!isEditing"
:variant="isEditing ? 'outlined' : 'plain'"
class="flex-1-1"
></v-text-field>
</div>
<!-- Metadata for view mode -->
<div v-if="mode === 'view' && entity.data.created" class="mt-4">
<v-divider class="mb-4"></v-divider>
<div class="text-caption text-medium-emphasis">
<div>Created: {{ new Date(entity.data.created).toLocaleString() }}</div>
<div v-if="entity.data.modified">Modified: {{ new Date(entity.data.modified).toLocaleString() }}</div>
<div v-if="entity.data.status === 'completed' && entity.data.completedOn">
Completed: {{ new Date(entity.data.completedOn).toLocaleString() }}
</div>
<div v-if="isExternalTask" class="mt-2">
<v-chip size="small" color="info" variant="tonal">
<v-icon start size="small">mdi-account-arrow-right</v-icon>
Assigned Task
</v-chip>
</div>
</div>
</div>
</v-form>
</v-card-text>
<v-divider></v-divider>
<v-card-actions>
<v-btn
v-if="mode === 'edit' && entity.id && canDelete"
color="error"
variant="text"
@click="handleDelete"
>
Delete
</v-btn>
<v-spacer></v-spacer>
<v-btn
v-if="mode === 'edit'"
variant="text"
@click="handleCancel"
>
Cancel
</v-btn>
<v-btn
v-if="mode === 'edit'"
color="primary"
variant="flat"
@click="handleSave"
>
Save
</v-btn>
</v-card-actions>
</v-card>
<v-card v-else class="task-editor">
<v-card-text class="text-center pa-8">
<v-icon size="64" color="grey-lighten-1">mdi-checkbox-marked-outline</v-icon>
<div class="text-h6 mt-4 text-medium-emphasis">No task selected</div>
<div class="text-body-2 text-medium-emphasis mt-2">Select a task to view or edit</div>
</v-card-text>
</v-card>
</template>
<script setup lang="ts">
import { ref, computed, watch } from 'vue';
const props = defineProps<{
mode: 'view' | 'edit';
entity: any | null;
collection: any | null;
lists: any[];
}>();
const emit = defineEmits<{
save: [entity: any, collection?: any];
delete: [entity: any, collection?: any];
edit: [];
cancel: [];
close: [];
}>();
const formRef = ref();
const selectedCollectionId = ref(props.entity?.in || props.collection?.id || props.lists[0]?.id || '');
// Determine permissions based on entity metadata
const isExternalTask = computed(() => {
// Check if task was assigned by another user
return props.entity?.data?.external === true || props.entity?.data?.assignedBy;
});
const canEdit = computed(() => {
if (!props.entity) return false;
if (props.entity.data?.readonly === true) return false;
return true;
});
const canEditDates = computed(() => {
// External/assigned tasks can't change dates
return !isExternalTask.value;
});
const canChangeCollection = computed(() => {
// Can't move external tasks to different lists
return !isExternalTask.value && !props.entity?.id;
});
const canDelete = computed(() => {
// Can't delete external/readonly tasks
return !isExternalTask.value && props.entity?.data?.readonly !== true;
});
const isEditing = computed(() => props.mode === 'edit');
const dueDate = ref('');
const startDate = ref('');
// Watch for entity changes to update date fields
watch(() => props.entity, (newEntity) => {
if (newEntity?.data?.dueOn) {
dueDate.value = formatDate(new Date(newEntity.data.dueOn));
} else {
dueDate.value = '';
}
if (newEntity?.data?.startsOn) {
startDate.value = formatDate(new Date(newEntity.data.startsOn));
} else {
startDate.value = '';
}
selectedCollectionId.value = newEntity?.in || props.collection?.id || props.lists[0]?.id || '';
}, { immediate: true });
const listOptions = computed(() =>
props.lists.map(list => ({
title: list.label || 'Unnamed List',
value: list.id,
}))
);
const priorityOptions = [
{ title: 'Low', value: 3 },
{ title: 'Medium', value: 2 },
{ title: 'High', value: 1 },
];
watch(dueDate, (newVal) => {
if (props.entity && isEditing.value) {
props.entity.data.dueOn = newVal ? new Date(newVal).toISOString() : null;
}
});
watch(startDate, (newVal) => {
if (props.entity && isEditing.value) {
props.entity.data.startsOn = newVal ? new Date(newVal).toISOString() : null;
}
});
function formatDate(date: Date): string {
return date.toISOString().split('T')[0];
}
async function handleSave() {
const { valid } = await formRef.value.validate();
if (!valid) return;
const targetCollection = props.lists.find(list => list.id === selectedCollectionId.value);
emit('save', props.entity, targetCollection || props.collection);
}
function handleCancel() {
emit('cancel');
}
function handleDelete() {
if (props.entity) {
const targetCollection = props.lists.find(list => list.id === selectedCollectionId.value);
emit('delete', props.entity, targetCollection || props.collection);
}
}
</script>
<style scoped>
.gap-2 {
gap: 8px;
}
.gap-4 {
gap: 16px;
}
.flex-1-1 {
flex: 1 1 0;
}
.task-editor {
display: flex;
flex-direction: column;
height: 100%;
}
.v-card-title {
position: sticky;
top: 0;
z-index: 2;
background: rgb(var(--v-theme-surface));
border-bottom: 1px solid rgb(var(--v-border-color));
}
.task-editor-content {
overflow-y: auto;
flex: 1 1 auto;
}
.v-card-actions {
position: sticky;
bottom: 0;
z-index: 2;
background: rgb(var(--v-theme-surface));
border-top: 1px solid rgb(var(--v-border-color));
}
</style>

219
src/components/TaskView.vue Normal file
View File

@@ -0,0 +1,219 @@
<template>
<div class="task-view">
<v-tabs v-model="activeTab" class="mb-4">
<v-tab value="all">All Tasks</v-tab>
<v-tab value="active">Active</v-tab>
<v-tab value="completed">Completed</v-tab>
</v-tabs>
<div class="task-filters mb-4">
<v-select
v-model="filterPriority"
:items="priorityOptions"
label="Priority"
variant="outlined"
density="compact"
clearable
style="max-width: 200px"
></v-select>
</div>
<v-list>
<v-list-item
v-for="entity in filteredTasks"
:key="entity.id"
class="task-item"
:class="{ 'completed': entity.data?.status === 'completed' }"
@click="$emit('task-click', entity)"
>
<template #prepend>
<v-checkbox-btn
:model-value="entity.data?.status === 'completed'"
hide-details
@click.stop="$emit('toggle-complete', entity.id)"
></v-checkbox-btn>
</template>
<div class="task-content">
<div class="task-title" :class="{ 'completed': entity.data?.status === 'completed' }">
{{ entity.data?.label || 'Untitled Task' }}
</div>
<div v-if="entity.data?.description" class="task-description text-caption">
{{ entity.data.description }}
</div>
<div class="task-meta">
<span v-if="entity.data?.dueOn" class="due-date">
<v-icon size="small">mdi-calendar</v-icon>
{{ formatDueDate(new Date(entity.data.dueOn)) }}
</span>
<v-chip
v-if="entity.data?.priority"
size="x-small"
:class="`priority-${entity.data.priority === 1 ? 'high' : entity.data.priority === 2 ? 'medium' : 'low'}`"
class="priority-badge"
>
{{ entity.data.priority === 1 ? 'high' : entity.data.priority === 2 ? 'medium' : 'low' }}
</v-chip>
<v-chip
v-for="tag in (entity.data?.categories || [])"
:key="tag"
size="x-small"
variant="outlined"
>
{{ tag }}
</v-chip>
</div>
</div>
</v-list-item>
</v-list>
<div v-if="filteredTasks.length === 0" class="text-center pa-8 text-disabled">
No tasks found
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed } from 'vue';
const props = defineProps<{
tasks: any[];
lists: any[];
}>();
defineEmits<{
'task-click': [task: any];
'toggle-complete': [taskId: string];
}>();
const activeTab = ref('all');
const filterPriority = ref<string | null>(null);
const priorityOptions = [
{ title: 'High', value: 'high' },
{ title: 'Medium', value: 'medium' },
{ title: 'Low', value: 'low' },
];
const filteredTasks = computed(() => {
let filtered = [...props.tasks];
// Filter by tab
if (activeTab.value === 'active') {
filtered = filtered.filter(entity => entity.data?.status !== 'completed');
} else if (activeTab.value === 'completed') {
filtered = filtered.filter(entity => entity.data?.status === 'completed');
}
// Filter by priority
if (filterPriority.value) {
const priorityMap = { high: 1, medium: 2, low: 3 };
const targetPriority = priorityMap[filterPriority.value];
filtered = filtered.filter(entity => entity.data?.priority === targetPriority);
}
// Sort: incomplete first, then by due date, then by priority
return filtered.sort((a, b) => {
const aCompleted = a.data?.status === 'completed';
const bCompleted = b.data?.status === 'completed';
if (aCompleted !== bCompleted) {
return aCompleted ? 1 : -1;
}
if (a.data?.dueOn && b.data?.dueOn) {
return new Date(a.data.dueOn).getTime() - new Date(b.data.dueOn).getTime();
}
if (a.data?.dueOn) return -1;
if (b.data?.dueOn) return 1;
return (a.data?.priority || 4) - (b.data?.priority || 4);
});
});
function formatDueDate(date: Date): string {
const today = new Date();
const tomorrow = new Date(today);
tomorrow.setDate(tomorrow.getDate() + 1);
if (date.toDateString() === today.toDateString()) {
return 'Today';
} else if (date.toDateString() === tomorrow.toDateString()) {
return 'Tomorrow';
} else if (date < today) {
return `Overdue (${date.toLocaleDateString()})`;
} else {
return date.toLocaleDateString();
}
}
</script>
<style scoped>
.task-view {
height: 100%;
}
.task-filters {
display: flex;
gap: 12px;
}
.task-item {
border-bottom: 1px solid rgba(0, 0, 0, 0.12);
padding: 12px;
}
.task-item.completed {
opacity: 0.6;
}
.task-content {
flex: 1;
}
.task-title {
font-weight: 500;
margin-bottom: 4px;
}
.task-title.completed {
text-decoration: line-through;
}
.task-description {
color: rgba(0, 0, 0, 0.6);
margin-bottom: 8px;
}
.task-meta {
display: flex;
align-items: center;
gap: 8px;
font-size: 12px;
}
.due-date {
display: flex;
align-items: center;
gap: 4px;
}
.priority-badge {
font-weight: 500;
}
.priority-high {
background-color: #ffebee !important;
color: #c62828 !important;
}
.priority-medium {
background-color: #fff3e0 !important;
color: #ef6c00 !important;
}
.priority-low {
background-color: #e8f5e9 !important;
color: #2e7d32 !important;
}
</style>

View File

@@ -0,0 +1,190 @@
<script setup lang="ts">
import { ref, computed, watch } from 'vue'
interface Props {
mode: 'edit' | 'view'
startsOn?: string | null
endsOn?: string | null
timeless?: boolean | null
timeZone?: string | null
}
const props = defineProps<Props>()
const emit = defineEmits<{
'update:startsOn': [date: string | null]
'update:endsOn': [date: string | null]
'update:timeless': [timeless: boolean | null]
'update:timeZone': [timeZone: string | null]
}>()
const timelessValue = computed({
get: () => props.timeless ?? false,
set: (value) => emit('update:timeless', value)
})
const timeZoneValue = computed({
get: () => props.timeZone ?? '',
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'
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}T${time}`)
}
// Date/time fields
const startDate = ref('')
const startTime = ref('')
const endDate = ref('')
const endTime = ref('')
// Initialize date/time from props
watch(() => props.startsOn, (newValue) => {
if (newValue) {
const start = new Date(newValue)
startDate.value = formatDate(start)
startTime.value = formatTime(start)
} else {
const now = new Date()
startDate.value = formatDate(now)
startTime.value = formatTime(now)
}
}, { immediate: true })
watch(() => props.endsOn, (newValue) => {
if (newValue) {
const end = new Date(newValue)
endDate.value = formatDate(end)
endTime.value = formatTime(end)
} else {
const later = new Date(Date.now() + 60 * 60 * 1000)
endDate.value = formatDate(later)
endTime.value = formatTime(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())
}
})
watch([endDate, endTime], () => {
if (props.mode === 'edit') {
emit('update:endsOn', parseDateTime(endDate.value, endTime.value).toISOString())
}
})
</script>
<template>
<div class="event-editor-section">
<div class="text-subtitle-1 mb-2">Date & Time</div>
<!-- Read-only view -->
<div v-if="mode === 'view'">
<div class="mb-2 pa-2">
<div class="mb-1"><strong>Starts:</strong> {{ formatDateTime(startsOn) }}</div>
<div class="mb-1"><strong>Ends:</strong> {{ formatDateTime(endsOn) }}</div>
<div v-if="timelessValue" class="mb-1">
<v-chip size="small" color="info" variant="tonal">
<v-icon start size="small">mdi-calendar-blank</v-icon>
All Day Event
</v-chip>
</div>
<div v-if="timeZoneValue" class="text-caption text-grey">Time Zone: {{ timeZoneValue }}</div>
</div>
</div>
<!-- Edit view -->
<div v-else>
<v-checkbox
v-model="timelessValue"
label="All day event"
density="compact"
class="mb-2"
/>
<div class="mb-3">
<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-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-col>
</v-row>
</div>
<div class="mb-3">
<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-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-col>
</v-row>
</div>
<v-text-field
v-model="timeZoneValue"
label="Time Zone"
variant="outlined"
density="compact"
hint="e.g., America/New_York"
persistent-hint
/>
</div>
</div>
</template>
<style scoped>
.event-editor-section {
margin-bottom: 1.5rem;
}
</style>

View File

@@ -0,0 +1,51 @@
<script setup lang="ts">
import { computed } from 'vue'
interface Props {
mode: 'edit' | 'view'
description?: string | null
}
const props = defineProps<Props>()
const emit = defineEmits<{
'update:description': [description: string | null]
}>()
const descriptionValue = computed({
get: () => props.description ?? '',
set: (value) => emit('update:description', value || null)
})
</script>
<template>
<div v-if="descriptionValue || mode === 'edit'" class="event-editor-section">
<div class="text-subtitle-1 mb-2">Description</div>
<!-- Read-only view -->
<div v-if="mode === 'view'" class="mb-4">
<div class="mb-2 pa-2 white-space-pre-wrap">{{ descriptionValue || 'No description' }}</div>
</div>
<!-- Edit view -->
<div v-else>
<v-textarea
v-model="descriptionValue"
label="Description"
variant="outlined"
density="compact"
rows="3"
auto-grow
/>
</div>
</div>
</template>
<style scoped>
.event-editor-section {
margin-bottom: 1.5rem;
}
.white-space-pre-wrap {
white-space: pre-wrap;
}
</style>

View File

@@ -0,0 +1,46 @@
<script setup lang="ts">
import { computed } from 'vue'
interface Props {
mode: 'edit' | 'view'
label?: string | null
}
const props = defineProps<Props>()
const emit = defineEmits<{
'update:label': [label: string | null]
}>()
const labelValue = computed({
get: () => props.label ?? '',
set: (value) => emit('update:label', value || null)
})
</script>
<template>
<div class="event-editor-section">
<div class="text-subtitle-1 mb-2">Title</div>
<!-- Read-only view -->
<div v-if="mode === 'view'" class="mb-4">
<div class="mb-2 pa-2">{{ labelValue || 'Untitled Event' }}</div>
</div>
<!-- Edit view -->
<div v-else>
<v-text-field
v-model="labelValue"
label="Title"
variant="outlined"
density="compact"
:rules="[v => !!v || 'Title is required']"
/>
</div>
</div>
</template>
<style scoped>
.event-editor-section {
margin-bottom: 1.5rem;
}
</style>

View File

@@ -0,0 +1,174 @@
<script setup lang="ts">
interface Props {
mode: 'edit' | 'view'
locationsPhysical: Record<string, any>
locationsVirtual: Record<string, any>
}
const props = defineProps<Props>()
const emit = defineEmits<{
'add-location-physical': []
'remove-location-physical': [key: string]
'add-location-virtual': []
'remove-location-virtual': [key: string]
}>()
const hasLocations = () => {
return Object.keys(props.locationsPhysical || {}).length > 0 ||
Object.keys(props.locationsVirtual || {}).length > 0
}
</script>
<template>
<div v-if="hasLocations() || mode === 'edit'" class="event-editor-section">
<div class="text-subtitle-1 mb-2">Locations</div>
<!-- Physical Locations -->
<div v-if="Object.keys(locationsPhysical || {}).length > 0 || mode === 'edit'" class="mb-4">
<div class="d-flex align-center justify-space-between mb-2">
<div class="text-subtitle-2">Physical Locations</div>
<v-btn v-if="mode === 'edit'"
size="small"
variant="outlined"
@click="$emit('add-location-physical')">
<v-icon left>mdi-plus</v-icon>
Add
</v-btn>
</div>
<!-- Read-only view -->
<div v-if="mode === 'view'">
<div v-for="(location, key) in locationsPhysical" :key="key" class="mb-2 pa-2 border rounded">
<div class="mb-1"><strong>{{ location.label || 'Location' }}</strong></div>
<div v-if="location.timeZone" class="text-caption text-grey">Time Zone: {{ location.timeZone }}</div>
<div v-if="location.description" class="text-caption">{{ location.description }}</div>
</div>
</div>
<!-- Edit view -->
<div v-else>
<div v-for="(location, key) in locationsPhysical" :key="key" class="mb-3 pa-3 border rounded">
<div class="d-flex align-center justify-space-between mb-2">
<strong>{{ location.label || 'Physical Location' }}</strong>
<v-btn
icon="mdi-delete"
size="small"
color="error"
variant="text"
@click="$emit('remove-location-physical', key)">
</v-btn>
</div>
<v-row dense>
<v-col cols="12" md="6">
<v-text-field
v-model="location.label"
label="Label"
variant="outlined"
density="compact"
/>
</v-col>
<v-col cols="12" md="6">
<v-text-field
v-model="location.timeZone"
label="Time Zone"
variant="outlined"
density="compact"
/>
</v-col>
</v-row>
<v-textarea
v-model="location.description"
label="Description"
variant="outlined"
density="compact"
rows="2"
/>
<v-select
v-model="location.relation"
:items="['start', 'end']"
label="Relation"
variant="outlined"
density="compact"
clearable
/>
</div>
</div>
</div>
<!-- Virtual Locations -->
<div v-if="Object.keys(locationsVirtual || {}).length > 0 || mode === 'edit'">
<div class="d-flex align-center justify-space-between mb-2">
<div class="text-subtitle-2">Virtual Locations</div>
<v-btn v-if="mode === 'edit'"
size="small"
variant="outlined"
@click="$emit('add-location-virtual')">
<v-icon left>mdi-plus</v-icon>
Add
</v-btn>
</div>
<!-- Read-only view -->
<div v-if="mode === 'view'">
<div v-for="(location, key) in locationsVirtual" :key="key" class="mb-2 pa-2 border rounded">
<div class="mb-1"><strong>{{ location.label || 'Virtual Location' }}</strong></div>
<div class="text-caption">
<a :href="location.location" target="_blank" class="text-primary">{{ location.location }}</a>
</div>
<div v-if="location.description" class="text-caption text-grey mt-1">{{ location.description }}</div>
</div>
</div>
<!-- Edit view -->
<div v-else>
<div v-for="(location, key) in locationsVirtual" :key="key" class="mb-3 pa-3 border rounded">
<div class="d-flex align-center justify-space-between mb-2">
<strong>{{ location.label || 'Virtual Location' }}</strong>
<v-btn
icon="mdi-delete"
size="small"
color="error"
variant="text"
@click="$emit('remove-location-virtual', key)">
</v-btn>
</div>
<v-text-field
v-model="location.label"
label="Label"
variant="outlined"
density="compact"
class="mb-2"
/>
<v-text-field
v-model="location.location"
label="URL"
variant="outlined"
density="compact"
class="mb-2"
:rules="[v => !!v || 'URL is required']"
/>
<v-textarea
v-model="location.description"
label="Description"
variant="outlined"
density="compact"
rows="2"
/>
</div>
</div>
</div>
</div>
</template>
<style scoped>
.event-editor-section {
margin-bottom: 1.5rem;
}
</style>

View File

@@ -0,0 +1,149 @@
<script setup lang="ts">
interface Props {
mode: 'edit' | 'view'
notifications: Record<string, any>
}
const props = defineProps<Props>()
const emit = defineEmits<{
'add-notification': []
'remove-notification': [key: string]
}>()
const typeOptions = [
{ title: 'Email', value: 'email' },
{ title: 'Visual (Display)', value: 'visual' },
{ title: 'Audible (Sound)', value: 'audible' }
]
const patternOptions = [
{ title: 'Absolute (Specific Time)', value: 'absolute' },
{ title: 'Relative (Before Event)', value: 'relative' },
{ title: 'Unknown', value: 'unknown' }
]
const anchorOptions = [
{ title: 'Start Time', value: 'start' },
{ title: 'End Time', value: 'end' }
]
const formatNotification = (notification: any) => {
if (notification.pattern === 'absolute' && notification.when) {
return `At ${new Date(notification.when).toLocaleString()}`
} else if (notification.pattern === 'relative' && notification.offset) {
const anchor = notification.anchor === 'end' ? 'end' : 'start'
return `${notification.offset} before ${anchor}`
}
return 'Custom notification'
}
</script>
<template>
<div v-if="Object.keys(notifications || {}).length > 0 || mode === 'edit'" class="event-editor-section">
<div class="d-flex align-center justify-space-between mb-2">
<div class="text-subtitle-1">Notifications</div>
<v-btn v-if="mode === 'edit'"
size="small"
variant="outlined"
@click="$emit('add-notification')">
<v-icon left>mdi-plus</v-icon>
Add Notification
</v-btn>
</div>
<!-- Read-only view -->
<div v-if="mode === 'view'">
<div v-for="(notification, key) in notifications" :key="key" class="mb-2 pa-2 border rounded">
<div class="d-flex align-center justify-space-between">
<div>
<div class="mb-1">
<v-icon :icon="notification.type === 'email' ? 'mdi-email' : notification.type === 'audible' ? 'mdi-bell-ring' : 'mdi-bell'" size="small" class="mr-2" />
<strong>{{ notification.type || 'Notification' }}</strong>
</div>
<div class="text-caption text-grey">{{ formatNotification(notification) }}</div>
</div>
</div>
</div>
</div>
<!-- Edit view -->
<div v-else>
<div v-for="(notification, key) in notifications" :key="key" class="mb-3 pa-3 border rounded">
<div class="d-flex align-center justify-space-between mb-2">
<strong>Notification</strong>
<v-btn
icon="mdi-delete"
size="small"
color="error"
variant="text"
@click="$emit('remove-notification', key)">
</v-btn>
</div>
<v-row dense class="mb-2">
<v-col cols="12" md="6">
<v-select
v-model="notification.type"
:items="typeOptions"
label="Type"
variant="outlined"
density="compact"
:rules="[v => !!v || 'Type is required']"
/>
</v-col>
<v-col cols="12" md="6">
<v-select
v-model="notification.pattern"
:items="patternOptions"
label="Pattern"
variant="outlined"
density="compact"
:rules="[v => !!v || 'Pattern is required']"
/>
</v-col>
</v-row>
<div v-if="notification.pattern === 'absolute'">
<v-text-field
v-model="notification.when"
label="When (ISO Date)"
type="datetime-local"
variant="outlined"
density="compact"
hint="Specific date and time for notification"
/>
</div>
<div v-else-if="notification.pattern === 'relative'">
<v-row dense>
<v-col cols="12" md="6">
<v-text-field
v-model="notification.offset"
label="Offset (e.g., PT15M)"
variant="outlined"
density="compact"
hint="ISO 8601 duration before event"
/>
</v-col>
<v-col cols="12" md="6">
<v-select
v-model="notification.anchor"
:items="anchorOptions"
label="Anchor"
variant="outlined"
density="compact"
/>
</v-col>
</v-row>
</div>
</div>
</div>
</div>
</template>
<style scoped>
.event-editor-section {
margin-bottom: 1.5rem;
}
</style>

View File

@@ -0,0 +1,143 @@
<script setup lang="ts">
interface Props {
mode: 'edit' | 'view'
pattern: any | null
}
const props = defineProps<Props>()
const emit = defineEmits<{
'update:pattern': [pattern: any | null]
}>()
const precisionOptions = [
{ title: 'Yearly', value: 'yearly' },
{ title: 'Monthly', value: 'monthly' },
{ title: 'Weekly', value: 'weekly' },
{ title: 'Daily', value: 'daily' },
{ title: 'Hourly', value: 'hourly' },
{ title: 'Minutely', value: 'minutely' },
{ title: 'Secondly', value: 'secondly' }
]
const patternTypeOptions = [
{ title: 'Absolute', value: 'absolute' },
{ title: 'Relative', value: 'relative' }
]
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()}`)
}
return parts.join(' ')
}
</script>
<template>
<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"
size="small"
variant="outlined"
@click="$emit('update:pattern', { pattern: 'absolute', precision: 'daily', interval: 1 })">
<v-icon left>mdi-plus</v-icon>
Add Recurrence
</v-btn>
<v-btn v-else-if="mode === 'edit' && pattern"
size="small"
variant="text"
color="error"
@click="$emit('update:pattern', null)">
Remove
</v-btn>
</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>
</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">
<v-text-field
v-model.number="pattern.interval"
label="Interval"
type="number"
variant="outlined"
density="compact"
:rules="[v => v > 0 || 'Must be greater than 0']"
/>
</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
</div>
</div>
</div>
</template>
<style scoped>
.event-editor-section {
margin-bottom: 1.5rem;
}
</style>

View File

@@ -0,0 +1,206 @@
<script setup lang="ts">
interface Props {
mode: 'edit' | 'view'
organizer: any | null
participants: Record<string, any>
}
const props = defineProps<Props>()
const emit = defineEmits<{
'update:organizer': [organizer: any | null]
'add-participant': []
'remove-participant': [key: string]
}>()
const roleOptions = [
{ title: 'Owner', value: 'owner' },
{ title: 'Chair', value: 'chair' },
{ title: 'Attendee', value: 'attendee' },
{ title: 'Optional', value: 'optional' },
{ title: 'Informational', value: 'informational' },
{ title: 'Contact', value: 'contact' }
]
const statusOptions = [
{ title: 'None', value: 'none' },
{ title: 'Accepted', value: 'accepted' },
{ title: 'Declined', value: 'declined' },
{ title: 'Tentative', value: 'tentative' },
{ title: 'Delegated', value: 'delegated' }
]
const typeOptions = [
{ title: 'Unknown', value: 'unknown' },
{ title: 'Individual', value: 'individual' },
{ title: 'Group', value: 'group' },
{ title: 'Resource', value: 'resource' },
{ title: 'Location', value: 'location' }
]
const hasParticipants = () => {
return props.organizer || Object.keys(props.participants || {}).length > 0
}
</script>
<template>
<div v-if="hasParticipants() || mode === 'edit'" class="event-editor-section">
<div class="text-subtitle-1 mb-2">Participants</div>
<!-- Organizer -->
<div v-if="organizer || mode === 'edit'" class="mb-4">
<div class="text-subtitle-2 mb-2">Organizer</div>
<!-- Read-only view -->
<div v-if="mode === 'view' && organizer" class="pa-2 border rounded">
<div class="mb-1"><strong>{{ organizer.name || organizer.address }}</strong></div>
<div class="text-caption text-grey">{{ organizer.address }}</div>
</div>
<!-- Edit view -->
<div v-else-if="mode === 'edit'" class="pa-3 border rounded">
<v-row dense>
<v-col cols="12" md="6">
<v-text-field
v-model="organizer.name"
label="Name"
variant="outlined"
density="compact"
/>
</v-col>
<v-col cols="12" md="6">
<v-text-field
v-model="organizer.address"
label="Email/Address"
variant="outlined"
density="compact"
:rules="[v => !!v || 'Address is required']"
/>
</v-col>
</v-row>
</div>
</div>
<!-- Participants List -->
<div v-if="Object.keys(participants || {}).length > 0 || mode === 'edit'">
<div class="d-flex align-center justify-space-between mb-2">
<div class="text-subtitle-2">Attendees</div>
<v-btn v-if="mode === 'edit'"
size="small"
variant="outlined"
@click="$emit('add-participant')">
<v-icon left>mdi-plus</v-icon>
Add Participant
</v-btn>
</div>
<!-- Read-only view -->
<div v-if="mode === 'view'">
<div v-for="(participant, key) in participants" :key="key" class="mb-2 pa-2 border rounded">
<div class="d-flex align-center justify-space-between">
<div>
<div class="mb-1"><strong>{{ participant.name || participant.address }}</strong></div>
<div class="text-caption text-grey">
{{ participant.address }}
<span v-if="participant.type"> | {{ participant.type }}</span>
</div>
<div v-if="participant.roles && participant.roles.length > 0" class="mt-1">
<v-chip
v-for="role in participant.roles"
:key="role"
size="x-small"
class="mr-1"
variant="tonal"
>
{{ role }}
</v-chip>
</div>
</div>
<v-chip
v-if="participant.status && participant.status !== 'none'"
:color="participant.status === 'accepted' ? 'success' : participant.status === 'declined' ? 'error' : 'warning'"
size="small"
variant="tonal"
>
{{ participant.status }}
</v-chip>
</div>
</div>
</div>
<!-- Edit view -->
<div v-else>
<div v-for="(participant, key) in participants" :key="key" class="mb-3 pa-3 border rounded">
<div class="d-flex align-center justify-space-between mb-2">
<strong>{{ participant.name || 'Participant' }}</strong>
<v-btn
icon="mdi-delete"
size="small"
color="error"
variant="text"
@click="$emit('remove-participant', key)">
</v-btn>
</div>
<v-row dense class="mb-2">
<v-col cols="12" md="6">
<v-text-field
v-model="participant.name"
label="Name"
variant="outlined"
density="compact"
/>
</v-col>
<v-col cols="12" md="6">
<v-text-field
v-model="participant.address"
label="Email/Address"
variant="outlined"
density="compact"
:rules="[v => !!v || 'Address is required']"
/>
</v-col>
</v-row>
<v-row dense class="mb-2">
<v-col cols="12" md="6">
<v-select
v-model="participant.type"
:items="typeOptions"
label="Type"
variant="outlined"
density="compact"
/>
</v-col>
<v-col cols="12" md="6">
<v-select
v-model="participant.status"
:items="statusOptions"
label="Status"
variant="outlined"
density="compact"
/>
</v-col>
</v-row>
<v-select
v-model="participant.roles"
:items="roleOptions"
label="Roles"
variant="outlined"
density="compact"
multiple
chips
closable-chips
/>
</div>
</div>
</div>
</div>
</template>
<style scoped>
.event-editor-section {
margin-bottom: 1.5rem;
}
</style>

View File

@@ -0,0 +1,61 @@
<script setup lang="ts">
import { computed } from 'vue'
interface Props {
mode: 'edit' | 'view'
tags?: string[]
}
const props = defineProps<Props>()
const emit = defineEmits<{
'update:tags': [tags: string[]]
}>()
const tagsValue = computed({
get: () => props.tags ?? [],
set: (value) => emit('update:tags', value)
})
</script>
<template>
<div v-if="tagsValue.length > 0 || mode === 'edit'" class="event-editor-section">
<div class="text-subtitle-1 mb-2">Tags</div>
<!-- Read-only view -->
<div v-if="mode === 'view'" class="mb-4">
<div v-if="tagsValue.length > 0" class="pa-2">
<v-chip
v-for="(tag, index) in tagsValue"
:key="index"
size="small"
class="mr-1 mb-1"
variant="tonal"
>
{{ tag }}
</v-chip>
</div>
<div v-else class="pa-2 text-grey">No tags</div>
</div>
<!-- Edit view -->
<div v-else>
<v-combobox
v-model="tagsValue"
label="Tags"
variant="outlined"
density="compact"
multiple
chips
closable-chips
hint="Press Enter to add tags"
/>
</div>
</div>
</template>
<style scoped>
.event-editor-section {
margin-bottom: 1.5rem;
}
</style>

34
src/integrations.ts Normal file
View File

@@ -0,0 +1,34 @@
import type { ModuleIntegrations } from "@KTXC/types/moduleTypes";
const integrations: ModuleIntegrations = {
// Main application menu items
app_menu: [
{
id: 'chrono-calendar',
label: 'Calendar',
path: '/calendar',
icon: 'mdi-calendar',
priority: 10,
},
{
id: 'chrono-tasks',
label: 'Tasks',
path: '/tasks',
icon: 'mdi-checkbox-marked-outline',
priority: 20,
},
],
// Settings tab for chrono configuration
// settings_tabs: [
// {
// id: 'chrono-settings',
// label: 'Chrono Settings',
// icon: 'mdi-clock-outline',
// priority: 50,
// component: () => import('@/views/ChronoSettings.vue'),
// },
// ],
};
export default integrations;

8
src/main.ts Normal file
View File

@@ -0,0 +1,8 @@
import routes from '@/routes'
import integrations from '@/integrations'
// CSS filename is injected by the vite plugin at build time
export const css = ['__CSS_FILENAME_PLACEHOLDER__']
export { routes, integrations }

641
src/pages/ChronoPage.vue Normal file
View File

@@ -0,0 +1,641 @@
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue';
import { useDisplay } from 'vuetify';
import { useModuleStore } from '@KTXC/stores/moduleStore';
import { useCollectionsStore } from '@ChronoManager/stores/collectionsStore';
import { useEntitiesStore } from '@ChronoManager/stores/entitiesStore';
import { useServicesStore } from '@ChronoManager/stores/servicesStore';
import { CollectionObject } from '@ChronoManager/models/collection';
import { EntityObject } from '@ChronoManager/models/entity';
import { ServiceObject } from '@ChronoManager/models/service';
import type { CalendarView as CalendarViewType } from '@/types';
import CollectionList from '@/components/CollectionList.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();
// Check if chrono manager is available
const moduleStore = useModuleStore();
const isChronoManagerAvailable = computed(() => {
return moduleStore.has('chrono_manager') || moduleStore.has('ChronoManager')
});
// Stores
const collectionsStore = useCollectionsStore();
const entitiesStore = useEntitiesStore();
const servicesStore = useServicesStore();
// View state
const viewMode = ref<'calendar' | 'tasks'>('calendar');
const calendarView = ref<CalendarViewType>('days');
const currentDate = ref(new Date());
const sidebarVisible = ref(true);
// Data - using manager objects directly
const collections = ref<CollectionObject[]>([]);
const entities = ref<EntityObject[]>([]);
const selectedCollection = ref<CollectionObject | null>(null);
// Computed - filter collections and entities
const calendars = computed(() => {
return collections.value.filter(col => col.contents?.event);
});
const taskLists = computed(() => {
return collections.value.filter(col => col.contents?.task);
});
const events = computed(() => {
return entities.value.filter(entity => entity.data && (entity.data as any).type === 'event');
});
const tasks = computed(() => {
return entities.value.filter(entity => entity.data && (entity.data as any).type === 'task');
});
// Dialog state
const showEventEditor = ref(false);
const showTaskEditor = ref(false);
const showCollectionEditor = ref(false);
const selectedEntity = ref<EntityObject | null>(null);
const entityEditorMode = ref<'edit' | 'view'>('view');
const editingCollection = ref<CollectionObject | null>(null);
const collectionEditorMode = ref<'create' | 'edit'>('create');
const collectionEditorType = ref<'calendar' | 'tasklist'>('calendar');
// Computed
const isTaskView = computed(() => viewMode.value === 'tasks');
const filteredEvents = computed(() => {
const visibleCalendarIds = calendars.value
.filter(cal => cal.enabled !== false)
.map(cal => cal.id);
return events.value.filter(event =>
visibleCalendarIds.includes(event.in)
);
});
const filteredTasks = computed(() => {
return tasks.value;
});
// Methods
function selectCalendar(calendar: CollectionObject) {
selectedCollection.value = calendar;
console.log('[Chrono] - Selected calendar:', calendar);
}
function createCalendar() {
editingCollection.value = collectionsStore.fresh();
editingCollection.value.contents = { event: true };
collectionEditorMode.value = 'create';
collectionEditorType.value = 'calendar';
showCollectionEditor.value = true;
}
function editCalendar(collection: CollectionObject) {
editingCollection.value = collection;
collectionEditorMode.value = 'edit';
collectionEditorType.value = 'calendar';
showCollectionEditor.value = true;
}
async function toggleCalendarVisibility(collection: CollectionObject) {
try {
await collectionsStore.modify(collection);
console.log('[Chrono] - Toggled calendar visibility:', collection);
} catch (error) {
console.error('[Chrono] - Failed to toggle calendar visibility:', error);
}
}
function selectTaskList(list: CollectionObject) {
selectedCollection.value = list;
console.log('[Chrono] - Selected task list:', list);
}
function createTaskList() {
editingCollection.value = collectionsStore.fresh();
editingCollection.value.contents = { task: true };
collectionEditorMode.value = 'create';
collectionEditorType.value = 'tasklist';
showCollectionEditor.value = true;
}
function editTaskList(collection: CollectionObject) {
editingCollection.value = collection;
collectionEditorMode.value = 'edit';
collectionEditorType.value = 'tasklist';
showCollectionEditor.value = true;
}
function createEvent() {
// Select first calendar collection or use selected
if (!selectedCollection.value && calendars.value.length > 0) {
selectedCollection.value = calendars.value[0];
}
if (!selectedCollection.value) {
console.warn('[Chrono] - No calendar collection available');
return;
}
// Create fresh event entity
selectedEntity.value = entitiesStore.fresh('event');
selectedEntity.value.in = selectedCollection.value.id;
entityEditorMode.value = 'edit';
showEventEditor.value = true;
}
function editEvent(entity: EntityObject) {
selectedEntity.value = entity;
entityEditorMode.value = 'view';
showEventEditor.value = true;
}
async function saveEvent(entity: EntityObject, collection?: CollectionObject | null) {
try {
if (!(collection instanceof CollectionObject)) {
collection = selectedCollection.value;
}
if (!collection) {
console.error('[Chrono] - No collection selected');
return;
}
if (entity.data) {
entity.data.modified = new Date();
}
if (entity.id === null) {
entity.data.created = new Date();
selectedEntity.value = await entitiesStore.create(collection, entity);
} else {
selectedEntity.value = await entitiesStore.modify(collection, entity);
}
entityEditorMode.value = 'view';
} catch (error) {
console.error('[Chrono] - Failed to save event:', error);
}
}
async function deleteEvent(entity: EntityObject, collection?: CollectionObject | null) {
try {
if (!(collection instanceof CollectionObject)) {
collection = collections.value.find(c => c.id === entity.in);
}
if (!collection) {
console.error('[Chrono] - No collection found');
return;
}
await entitiesStore.destroy(collection, entity);
selectedEntity.value = null;
entityEditorMode.value = 'view';
showEventEditor.value = false;
} catch (error) {
console.error('[Chrono] - Failed to delete event:', error);
}
}
function handleDateClick(date: Date) {
// Select first calendar collection or use selected
if (!selectedCollection.value && calendars.value.length > 0) {
selectedCollection.value = calendars.value[0];
}
if (!selectedCollection.value) {
console.warn('[Chrono] - No calendar collection available');
return;
}
selectedEntity.value = entitiesStore.fresh('event');
selectedEntity.value.in = selectedCollection.value.id;
if (selectedEntity.value.data) {
selectedEntity.value.data.startsOn = date;
selectedEntity.value.data.endsOn = new Date(date.getTime() + 60 * 60 * 1000);
}
entityEditorMode.value = 'edit';
showEventEditor.value = true;
}
function handleEditorEdit() {
console.log('[Chrono] - Editor editing started');
entityEditorMode.value = 'edit';
}
function handleEditorCancel() {
console.log('[Chrono] - Editor editing cancelled');
entityEditorMode.value = 'view';
}
function handleEditorClose() {
console.log('[Chrono] - Editor closed');
selectedEntity.value = null;
entityEditorMode.value = 'view';
showEventEditor.value = false;
showTaskEditor.value = false;
}
function createTask() {
// Select first task list collection or use selected
if (!selectedCollection.value && taskLists.value.length > 0) {
selectedCollection.value = taskLists.value[0];
}
if (!selectedCollection.value) {
console.warn('[Chrono] - No task list available');
return;
}
// Create fresh task entity
selectedEntity.value = entitiesStore.fresh('task');
selectedEntity.value.in = selectedCollection.value.id;
entityEditorMode.value = 'edit';
showTaskEditor.value = true;
}
function editTask(entity: EntityObject) {
selectedEntity.value = entity;
entityEditorMode.value = 'view';
showTaskEditor.value = true;
}
async function saveTask(entity: EntityObject, collection?: CollectionObject | null) {
try {
if (!(collection instanceof CollectionObject)) {
collection = selectedCollection.value;
}
if (!collection) {
console.error('[Chrono] - No collection selected');
return;
}
if (entity.data) {
entity.data.modified = new Date();
}
if (entity.id === null) {
entity.data.created = new Date();
selectedEntity.value = await entitiesStore.create(collection, entity);
} else {
selectedEntity.value = await entitiesStore.modify(collection, entity);
}
entityEditorMode.value = 'view';
} catch (error) {
console.error('[Chrono] - Failed to save task:', error);
}
}
async function deleteTask(entity: EntityObject, collection?: CollectionObject | null) {
try {
if (!(collection instanceof CollectionObject)) {
collection = collections.value.find(c => c.id === entity.in);
}
if (!collection) {
console.error('[Chrono] - No collection found');
return;
}
await entitiesStore.destroy(collection, entity);
selectedEntity.value = null;
entityEditorMode.value = 'view';
showTaskEditor.value = false;
} catch (error) {
console.error('[Chrono] - Failed to delete task:', error);
}
}
async function toggleTaskComplete(taskId: string | number) {
try {
const entity = entities.value.find(e => e.id === taskId);
if (!entity || !entity.data) return;
const collection = collections.value.find(c => c.id === entity.in);
if (!collection) return;
const taskData = entity.data as any;
const isCompleted = taskData.status === 'completed';
taskData.status = isCompleted ? 'needs-action' : 'completed';
taskData.completedOn = isCompleted ? null : new Date();
taskData.progress = isCompleted ? null : 100;
taskData.modified = new Date();
await entitiesStore.modify(collection, entity);
} catch (error) {
console.error('[Chrono] - Failed to toggle task completion:', error);
}
}
async function saveCollection(collection: CollectionObject, service: ServiceObject) {
try {
if (collectionEditorMode.value === 'create') {
await collectionsStore.create(service, collection);
console.log('[Chrono] - Created collection:', collection);
} else {
await collectionsStore.modify(collection);
console.log('[Chrono] - Modified collection:', collection);
}
// Reload collections
collections.value = await collectionsStore.list();
} catch (error) {
console.error('[Chrono] - Failed to save collection:', error);
}
}
async function deleteCollection(collection: CollectionObject) {
try {
await collectionsStore.destroy(collection);
console.log('[Chrono] - Deleted collection:', collection);
// Reload collections
collections.value = await collectionsStore.list();
if (selectedCollection.value?.id === collection.id) {
selectedCollection.value = null;
}
} catch (error) {
console.error('[Chrono] - Failed to delete collection:', error);
}
}
// Initialize data from stores
onMounted(async () => {
try {
// Load collections (calendars and task lists)
collections.value = await collectionsStore.list();
// Load entities (events and tasks)
entities.value = await entitiesStore.list(null, null, null);
console.log('[Chrono] - Loaded data from ChronoManager:', {
collections: collections.value.length,
calendars: calendars.value.length,
events: events.value.length,
tasks: tasks.value.length,
taskLists: taskLists.value.length,
});
} catch (error) {
console.error('[Chrono] - Failed to load data from ChronoManager:', error);
}
});
</script>
<template>
<div class="chrono-container">
<!-- Top Navigation Bar -->
<v-app-bar elevation="0" class="chrono-toolbar border-b">
<template #prepend>
<v-btn
icon="mdi-menu"
variant="text"
@click="sidebarVisible = !sidebarVisible"
></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-spacer></v-spacer>
<!-- View Toggle -->
<v-btn-toggle
v-model="viewMode"
color="primary"
variant="outlined"
density="compact"
class="mr-2"
>
<v-btn value="calendar" size="small">
<v-icon>mdi-calendar</v-icon>
<span class="ml-1 d-none d-sm-inline">Calendar</span>
</v-btn>
<v-btn value="tasks" size="small">
<v-icon>mdi-checkbox-marked-outline</v-icon>
<span class="ml-1 d-none d-sm-inline">Tasks</span>
</v-btn>
</v-btn-toggle>
<!-- View Switcher for Calendar -->
<v-btn-toggle
v-if="!isTaskView"
v-model="calendarView"
color="primary"
variant="outlined"
density="compact"
mandatory
class="mr-2"
>
<v-btn value="days" size="small">Days</v-btn>
<v-btn value="month" size="small">Month</v-btn>
<v-btn value="agenda" size="small">Agenda</v-btn>
</v-btn-toggle>
</v-app-bar>
<!-- 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"
:selected-collection="selectedCollection"
type="calendar"
@select="selectCalendar"
@edit="editCalendar"
@toggle-visibility="toggleCalendarVisibility"
/>
<CollectionList
v-else
:selected-collection="selectedCollection"
type="tasklist"
@select="selectTaskList"
@edit="editTaskList"
/>
<v-btn
v-if="!isTaskView"
variant="tonal"
color="primary"
block
class="mt-3"
@click="createCalendar"
>
<v-icon start>mdi-plus</v-icon>
New Calendar
</v-btn>
<v-btn
v-else
variant="tonal"
color="primary"
block
class="mt-3"
@click="createTaskList"
>
<v-icon start>mdi-plus</v-icon>
New Task List
</v-btn>
<!-- Mini Calendar Widget -->
<v-card v-if="!isTaskView" class="mt-4" variant="outlined">
<v-card-text class="pa-2">
<MiniCalendar v-model="currentDate" />
</v-card-text>
</v-card>
</div>
</v-navigation-drawer>
<!-- Main Calendar/Task View -->
<div class="chrono-main">
<v-alert
v-if="!isChronoManagerAvailable"
type="warning"
variant="tonal"
closable
class="mb-4"
>
<v-alert-title class="d-flex align-center">
<v-icon icon="mdi-alert-circle" class="mr-2" />
Chrono Manager Not Available
</v-alert-title>
<div class="mt-2">
<p>
The Chrono Manager module is not installed or enabled.
This module requires the <strong>chrono_manager</strong> module to function properly.
</p>
<p class="mt-2 mb-0">
Please contact your system administrator to install and enable the
<code>chrono_manager</code> module.
</p>
</div>
</v-alert>
<CalendarView
v-if="!isTaskView"
:view="calendarView"
:current-date="currentDate"
:events="filteredEvents"
:calendars="calendars"
@event-click="editEvent"
@date-click="handleDateClick"
/>
<TaskView
v-else
:tasks="filteredTasks"
:lists="taskLists"
@task-click="editTask"
@toggle-complete="toggleTaskComplete"
/>
</div>
</div>
<!-- Event Editor Dialog -->
<v-dialog v-model="showEventEditor" max-width="800px" scrollable>
<EventEditor
v-if="showEventEditor"
:mode="entityEditorMode"
:entity="selectedEntity"
:collection="selectedCollection"
:calendars="calendars"
@save="saveEvent"
@delete="deleteEvent"
@edit="handleEditorEdit"
@cancel="handleEditorCancel"
@close="handleEditorClose"
/>
</v-dialog>
<!-- Task Editor Dialog -->
<v-dialog v-model="showTaskEditor" max-width="700px" scrollable>
<TaskEditor
v-if="showTaskEditor"
:mode="entityEditorMode"
:entity="selectedEntity"
:collection="selectedCollection"
:lists="taskLists"
@save="saveTask"
@delete="deleteTask"
@edit="handleEditorEdit"
@cancel="handleEditorCancel"
@close="handleEditorClose"
/>
</v-dialog>
<!-- Collection Editor Dialog -->
<CollectionEditor
v-model="showCollectionEditor"
:collection="editingCollection"
:mode="collectionEditorMode"
:type="collectionEditorType"
@save="saveCollection"
@delete="deleteCollection"
/>
</div>
</template>
<style scoped>
.chrono-container {
display: flex;
flex-direction: column;
height: 100vh;
isolation: isolate; /* Create stacking context to prevent style leakage */
}
.chrono-toolbar {
border-bottom: 1px solid rgb(var(--v-border-color)) !important;
}
.chrono-content {
display: flex;
flex: 1;
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;
display: flex;
flex-direction: column;
}
.border-b {
border-bottom: 1px solid rgb(var(--v-border-color));
}
/* Responsive adjustments */
@media (max-width: 960px) {
.chrono-main {
padding: 16px;
}
}
@media (max-width: 600px) {
.chrono-main {
padding: 12px;
}
}
</style>

19
src/routes.ts Normal file
View File

@@ -0,0 +1,19 @@
const routes = [
{
name: 'index',
path: '/',
redirect: { name: 'calendar' }
},
{
name: 'calendar',
path: '/calendar',
component: () => import('@/pages/ChronoPage.vue'),
},
{
name: 'tasks',
path: '/tasks',
component: () => import('@/pages/ChronoPage.vue'),
},
]
export default routes

201
src/style.css Normal file
View File

@@ -0,0 +1,201 @@
/* Chrono Module Styles - All scoped to .chrono-container and using theme colors */
/* Ensure grids display properly within Chrono module only */
.chrono-container .calendar-grid,
.chrono-container .mini-calendar {
display: grid !important;
}
.chrono-container .calendar-grid {
grid-template-columns: repeat(7, 1fr) !important;
grid-template-rows: auto repeat(6, 1fr) !important;
}
.chrono-container .mini-calendar {
grid-template-columns: repeat(7, 1fr) !important;
}
.chrono-container .week-days,
.chrono-container .week-view,
.chrono-container .day-view {
display: flex !important;
flex-direction: row !important;
}
.chrono-container {
display: flex;
flex-direction: column;
height: 100%;
width: 100%;
}
.chrono-container .chrono-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 16px;
border-bottom: 1px solid rgb(var(--v-border-color));
}
.chrono-container .chrono-content {
flex: 1;
display: flex;
overflow: hidden;
}
.chrono-container .chrono-sidebar {
width: 280px;
border-right: 1px solid rgb(var(--v-border-color));
overflow-y: auto;
}
.chrono-container .chrono-main {
flex: 1;
overflow-y: auto;
padding: 16px;
}
/* Calendar specific styles - scoped to chrono-container */
.chrono-container .calendar-grid {
display: grid;
grid-template-columns: repeat(7, 1fr);
gap: 1px;
background-color: rgb(var(--v-border-color));
border: 1px solid rgb(var(--v-border-color));
}
.chrono-container .calendar-cell {
background-color: rgb(var(--v-theme-surface));
min-height: 100px;
padding: 8px;
cursor: pointer;
transition: background-color 0.2s;
}
.chrono-container .calendar-cell:hover {
background-color: rgb(var(--v-theme-surface-variant));
}
.chrono-container .calendar-cell.today {
background-color: rgb(var(--v-theme-primary), 0.08);
}
.chrono-container .calendar-event {
padding: 2px 6px;
margin: 2px 0;
border-radius: 4px;
font-size: 12px;
cursor: pointer;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
/* Task specific styles - scoped to chrono-container */
.chrono-container .task-item {
display: flex;
align-items: flex-start;
padding: 12px;
border-bottom: 1px solid rgb(var(--v-border-color));
cursor: pointer;
transition: background-color 0.2s;
}
.chrono-container .task-item:hover {
background-color: rgb(var(--v-theme-surface-variant));
}
.chrono-container .task-item.completed {
opacity: 0.6;
}
.chrono-container .task-checkbox {
margin-right: 12px;
}
.chrono-container .task-content {
flex: 1;
}
.chrono-container .task-title {
font-weight: 500;
margin-bottom: 4px;
}
.chrono-container .task-title.completed {
text-decoration: line-through;
}
.chrono-container .task-meta {
display: flex;
gap: 12px;
font-size: 12px;
opacity: 0.7;
}
.chrono-container .priority-badge {
padding: 2px 8px;
border-radius: 12px;
font-size: 11px;
font-weight: 500;
}
.chrono-container .priority-high {
background-color: rgb(var(--v-theme-error), 0.1);
color: rgb(var(--v-theme-error));
}
.chrono-container .priority-medium {
background-color: rgb(var(--v-theme-warning), 0.1);
color: rgb(var(--v-theme-warning));
}
.chrono-container .priority-low {
background-color: rgb(var(--v-theme-success), 0.1);
color: rgb(var(--v-theme-success));
}
/* Event Viewer Popup - applied globally since popup is rendered at fixed position */
.event-popup {
position: fixed;
z-index: 9999;
pointer-events: auto;
transform: translate(-50%, -100%);
margin-top: -10px;
width: 320px;
min-height: 150px;
}
.event-popup-card {
cursor: pointer;
transition: transform 0.2s;
}
.event-popup-card:hover {
transform: scale(1.02);
}
.event-popup-details {
font-size: 13px;
}
.event-popup .text-truncate {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
max-width: 250px;
}
.fade-enter-active,
.fade-leave-active {
transition: opacity 0.2s ease;
}
.fade-enter-from,
.fade-leave-to {
opacity: 0;
}
.event-popup .border-t {
border-top: 1px solid rgba(var(--v-border-color), 0.5);
}

15
src/types/index.ts Normal file
View File

@@ -0,0 +1,15 @@
/**
* Chrono Module Types
*
* Local UI types for the Chrono module
*/
// Local UI-specific types
export type CalendarView = 'days' | 'month' | 'agenda';
export interface ViewState {
currentView: CalendarView;
currentDate: Date;
selectedCalendars: string[];
showTasks: boolean;
}

View File

@@ -0,0 +1,193 @@
/**
* 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.data?.startsOn) return false;
const start = startOfDay(new Date(entity.data.startsOn));
const end = entity.data?.endsOn ? startOfDay(new Date(entity.data.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.data?.startsOn) return false;
// Explicit all-day flag
if (entity.data?.allDay === 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.data?.startsOn) return false;
const eventStart = startOfDay(new Date(entity.data.startsOn));
const eventEnd = entity.data?.endsOn ? startOfDay(new Date(entity.data.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.data?.startsOn) return false;
const eventStart = startOfDay(new Date(entity.data.startsOn));
const eventEnd = entity.data?.endsOn ? startOfDay(new Date(entity.data.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.data?.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.data.startsOn);
const aEnd = new Date(a.data.endsOn || a.data.startsOn);
const bStart = new Date(b.data.startsOn);
const bEnd = new Date(b.data.endsOn || b.data.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.data.startsOn));
const eventEnd = entity.data?.endsOn ? startOfDay(new Date(entity.data.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.data?.startsOn) return false;
if (isAllDay(entity)) return false;
const eventDate = startOfDay(new Date(entity.data.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.data?.startsOn) return false;
if (isMultiDay(entity)) return false;
const eventDate = startOfDay(new Date(entity.data.startsOn));
return eventDate.toDateString() === date.toDateString();
});
}

1
src/vite-env.d.ts vendored Normal file
View File

@@ -0,0 +1 @@
/// <reference types="vite/client" />

32
tsconfig.app.json Normal file
View File

@@ -0,0 +1,32 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"target": "ES2020",
"useDefineForClassFields": true,
"module": "ESNext",
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"isolatedModules": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "preserve",
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true,
"paths": {
"@/*": ["./src/*"],
"@KTXC/*": ["../../core/src/*"],
"@ChronoManager/*": ["../chrono_manager/src/*"]
}
},
"include": ["src/**/*.ts", "src/**/*.tsx", "src/**/*.vue"]
}

7
tsconfig.json Normal file
View File

@@ -0,0 +1,7 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}

25
tsconfig.node.json Normal file
View File

@@ -0,0 +1,25 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
"target": "ES2023",
"lib": ["ES2023"],
"module": "ESNext",
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": ["vite.config.ts"]
}

60
vite.config.ts Normal file
View File

@@ -0,0 +1,60 @@
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import path from 'path'
// https://vite.dev/config/
export default defineConfig({
plugins: [
vue(),
{
name: 'inject-css-filename',
enforce: 'post',
generateBundle(_options, bundle) {
const cssFile = Object.keys(bundle).find(name => name.endsWith('.css'))
if (!cssFile) return
for (const fileName of Object.keys(bundle)) {
const chunk = bundle[fileName]
if (chunk.type === 'chunk' && chunk.code.includes('__CSS_FILENAME_PLACEHOLDER__')) {
chunk.code = chunk.code.replace(/__CSS_FILENAME_PLACEHOLDER__/g, `static/${cssFile}`)
console.log(`Injected CSS filename "static/${cssFile}" into ${fileName}`)
}
}
}
}
],
resolve: {
alias: {
'@': path.resolve(__dirname, './src'),
'@KTXC': path.resolve(__dirname, '../../core/src'),
'@ChronoManager': path.resolve(__dirname, '../chrono_manager/src'),
},
},
build: {
outDir: 'static',
emptyOutDir: true,
sourcemap: true,
cssCodeSplit: false,
lib: {
entry: path.resolve(__dirname, 'src/main.ts'),
formats: ['es'],
fileName: () => 'module.mjs',
},
rollupOptions: {
external: [
'vue',
'vue-router',
'pinia',
],
output: {
assetFileNames: (assetInfo) => {
if (assetInfo.name?.endsWith('.css')) {
return 'chrono-[hash].css'
}
return '[name]-[hash][extname]'
},
manualChunks: undefined,
},
},
},
})