feat(planner): add cross-project calendar planner view
A standalone /planner page to schedule tasks on a week/day time grid. Frontend-only; no API or DB changes (reuses start/end/due dates and the v2 tasks endpoint). - Week + day views with zoomable hour grid and current-time marker - Drag unscheduled tasks from a sidebar onto time slots; drag to move, resize, or send back to unschedule - All-day row for due-only/all-day tasks; drag in and out of it - Cross-project: reuses the standard task filter in the sidebar - View settings (working hours, slot size, default duration, full-week vs next-7-days, show done) persisted per device - Respects user week-start and 12h/24h time format
This commit is contained in:
parent
0f3a8a7e39
commit
5809b510ac
|
|
@ -38,6 +38,17 @@
|
||||||
{{ $t('navigation.upcoming') }}
|
{{ $t('navigation.upcoming') }}
|
||||||
</RouterLink>
|
</RouterLink>
|
||||||
</li>
|
</li>
|
||||||
|
<li>
|
||||||
|
<RouterLink
|
||||||
|
v-shortcut="'KeyG KeyL'"
|
||||||
|
:to="{ name: 'planner.index'}"
|
||||||
|
>
|
||||||
|
<span class="menu-item-icon icon">
|
||||||
|
<Icon icon="table-columns" />
|
||||||
|
</span>
|
||||||
|
{{ $t('navigation.planner') }}
|
||||||
|
</RouterLink>
|
||||||
|
</li>
|
||||||
<li>
|
<li>
|
||||||
<RouterLink
|
<RouterLink
|
||||||
v-shortcut="'KeyG KeyP'"
|
v-shortcut="'KeyG KeyP'"
|
||||||
|
|
|
||||||
|
|
@ -55,6 +55,8 @@ import {
|
||||||
faPaste,
|
faPaste,
|
||||||
faPen,
|
faPen,
|
||||||
faPencilAlt,
|
faPencilAlt,
|
||||||
|
faMinus,
|
||||||
|
faTableColumns,
|
||||||
faPercent,
|
faPercent,
|
||||||
faPlay,
|
faPlay,
|
||||||
faPlus,
|
faPlus,
|
||||||
|
|
@ -131,6 +133,7 @@ library.add(faBell)
|
||||||
library.add(faBellSlash)
|
library.add(faBellSlash)
|
||||||
library.add(faCalendar)
|
library.add(faCalendar)
|
||||||
library.add(faCalendarAlt)
|
library.add(faCalendarAlt)
|
||||||
|
library.add(faTableColumns)
|
||||||
library.add(faCheck)
|
library.add(faCheck)
|
||||||
library.add(faCheckDouble)
|
library.add(faCheckDouble)
|
||||||
library.add(faChessKnight)
|
library.add(faChessKnight)
|
||||||
|
|
@ -166,6 +169,7 @@ library.add(faLock)
|
||||||
library.add(faPaperclip)
|
library.add(faPaperclip)
|
||||||
library.add(faPaste)
|
library.add(faPaste)
|
||||||
library.add(faPen)
|
library.add(faPen)
|
||||||
|
library.add(faMinus)
|
||||||
library.add(faPencilAlt)
|
library.add(faPencilAlt)
|
||||||
library.add(faPercent)
|
library.add(faPercent)
|
||||||
library.add(faPlay)
|
library.add(faPlay)
|
||||||
|
|
|
||||||
|
|
@ -742,12 +742,37 @@
|
||||||
"navigation": {
|
"navigation": {
|
||||||
"overview": "Overview",
|
"overview": "Overview",
|
||||||
"upcoming": "Upcoming",
|
"upcoming": "Upcoming",
|
||||||
|
"planner": "Planner",
|
||||||
"settings": "Settings",
|
"settings": "Settings",
|
||||||
"imprint": "Imprint",
|
"imprint": "Imprint",
|
||||||
"privacy": "Privacy Policy",
|
"privacy": "Privacy Policy",
|
||||||
"closeSidebar": "Close sidebar",
|
"closeSidebar": "Close sidebar",
|
||||||
"home": "Vikunja home"
|
"home": "Vikunja home"
|
||||||
},
|
},
|
||||||
|
"planner": {
|
||||||
|
"title": "Planner",
|
||||||
|
"today": "Today",
|
||||||
|
"week": "Week",
|
||||||
|
"day": "Day",
|
||||||
|
"previous": "Previous",
|
||||||
|
"next": "Next",
|
||||||
|
"zoomIn": "Increase resolution",
|
||||||
|
"zoomOut": "Decrease resolution",
|
||||||
|
"unscheduled": "Unscheduled tasks",
|
||||||
|
"noUnscheduled": "No unscheduled tasks. Everything has a time slot!",
|
||||||
|
"allDay": "All day",
|
||||||
|
"saved": "Saved",
|
||||||
|
"saveError": "Something went wrong saving the task",
|
||||||
|
"settings": {
|
||||||
|
"title": "View settings",
|
||||||
|
"dayStart": "Day starts at",
|
||||||
|
"dayEnd": "Day ends at",
|
||||||
|
"defaultDuration": "Default duration (minutes)",
|
||||||
|
"slotDuration": "Slot size (minutes)",
|
||||||
|
"fullWeek": "Show full week (off: next 7 days)",
|
||||||
|
"showDone": "Show done tasks"
|
||||||
|
}
|
||||||
|
},
|
||||||
"misc": {
|
"misc": {
|
||||||
"loading": "Loading…",
|
"loading": "Loading…",
|
||||||
"save": "Save",
|
"save": "Save",
|
||||||
|
|
|
||||||
|
|
@ -225,6 +225,11 @@ const router = createRouter({
|
||||||
showOverdue: route.query.showOverdue === 'true',
|
showOverdue: route.query.showOverdue === 'true',
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: '/planner',
|
||||||
|
name: 'planner.index',
|
||||||
|
component: () => import('@/views/planner/PlannerView.vue'),
|
||||||
|
},
|
||||||
{
|
{
|
||||||
// Redirect old list routes to the respective project routes
|
// Redirect old list routes to the respective project routes
|
||||||
// see: https://router.vuejs.org/guide/essentials/dynamic-matching.html#catch-all-404-not-found-route
|
// see: https://router.vuejs.org/guide/essentials/dynamic-matching.html#catch-all-404-not-found-route
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,73 @@
|
||||||
|
<template>
|
||||||
|
<Dropdown
|
||||||
|
trigger-icon="cog"
|
||||||
|
:trigger-label="$t('planner.settings.title')"
|
||||||
|
>
|
||||||
|
<div class="planner-settings">
|
||||||
|
<label class="setting">
|
||||||
|
<span>{{ $t('planner.settings.dayStart') }}</span>
|
||||||
|
<FormInput
|
||||||
|
v-model="settings.dayStart"
|
||||||
|
type="time"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label class="setting">
|
||||||
|
<span>{{ $t('planner.settings.dayEnd') }}</span>
|
||||||
|
<FormInput
|
||||||
|
v-model="settings.dayEnd"
|
||||||
|
type="time"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label class="setting">
|
||||||
|
<span>{{ $t('planner.settings.defaultDuration') }}</span>
|
||||||
|
<FormInput
|
||||||
|
v-model.number="settings.defaultDurationMinutes"
|
||||||
|
type="number"
|
||||||
|
min="5"
|
||||||
|
step="5"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label class="setting">
|
||||||
|
<span>{{ $t('planner.settings.slotDuration') }}</span>
|
||||||
|
<FormInput
|
||||||
|
v-model.number="settings.slotMinutes"
|
||||||
|
type="number"
|
||||||
|
min="5"
|
||||||
|
step="5"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<FancyCheckbox v-model="settings.fullWeek">
|
||||||
|
{{ $t('planner.settings.fullWeek') }}
|
||||||
|
</FancyCheckbox>
|
||||||
|
<FancyCheckbox v-model="settings.showDone">
|
||||||
|
{{ $t('planner.settings.showDone') }}
|
||||||
|
</FancyCheckbox>
|
||||||
|
</div>
|
||||||
|
</Dropdown>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import Dropdown from '@/components/misc/Dropdown.vue'
|
||||||
|
import FormInput from '@/components/input/FormInput.vue'
|
||||||
|
import FancyCheckbox from '@/components/input/FancyCheckbox.vue'
|
||||||
|
import {useCalendarSettings} from './helpers/useCalendarSettings'
|
||||||
|
|
||||||
|
const {settings} = useCalendarSettings()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
.planner-settings {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: .6rem;
|
||||||
|
min-inline-size: 14rem;
|
||||||
|
padding: .25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.setting {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: .2rem;
|
||||||
|
font-size: .8rem;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
@ -0,0 +1,169 @@
|
||||||
|
<template>
|
||||||
|
<aside
|
||||||
|
class="planner-sidebar"
|
||||||
|
:class="{'is-drop-target': isDropTarget}"
|
||||||
|
@dragover.prevent="isDropTarget = true"
|
||||||
|
@dragleave="isDropTarget = false"
|
||||||
|
@drop="onDrop"
|
||||||
|
>
|
||||||
|
<h3 class="sidebar-title">
|
||||||
|
{{ $t('planner.unscheduled') }}
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
<FilterPopup
|
||||||
|
v-model="filter"
|
||||||
|
class="sidebar-filter"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<p
|
||||||
|
v-if="!tasks.length"
|
||||||
|
class="no-tasks"
|
||||||
|
>
|
||||||
|
{{ $t('planner.noUnscheduled') }}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<ul class="task-list">
|
||||||
|
<li
|
||||||
|
v-for="task in tasks"
|
||||||
|
:key="task.id"
|
||||||
|
class="sidebar-task"
|
||||||
|
:style="{'--task-color': taskColor(task)}"
|
||||||
|
draggable="true"
|
||||||
|
@dragstart="onDragStart($event, task)"
|
||||||
|
@click="emit('openTask', task.id)"
|
||||||
|
>
|
||||||
|
<span class="task-title">{{ task.title }}</span>
|
||||||
|
<span
|
||||||
|
v-if="task.dueDate"
|
||||||
|
class="task-due"
|
||||||
|
>
|
||||||
|
<Icon :icon="['far', 'calendar-alt']" />
|
||||||
|
{{ formatDueDate(task.dueDate) }}
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</aside>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import {ref} from 'vue'
|
||||||
|
import dayjs from 'dayjs'
|
||||||
|
|
||||||
|
import type {ITask} from '@/modelTypes/ITask'
|
||||||
|
import type {TaskFilterParams} from '@/services/taskCollection'
|
||||||
|
import FilterPopup from '@/components/project/partials/FilterPopup.vue'
|
||||||
|
import {useProjectStore} from '@/stores/projects'
|
||||||
|
|
||||||
|
defineProps<{
|
||||||
|
tasks: ITask[]
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
openTask: [taskId: number]
|
||||||
|
unschedule: [taskId: number]
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const filter = defineModel<TaskFilterParams>('filter', {required: true})
|
||||||
|
|
||||||
|
const isDropTarget = ref(false)
|
||||||
|
|
||||||
|
function onDrop(event: DragEvent) {
|
||||||
|
isDropTarget.value = false
|
||||||
|
const taskId = Number(event.dataTransfer?.getData('text/plain'))
|
||||||
|
if (taskId) {
|
||||||
|
emit('unschedule', taskId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const projectStore = useProjectStore()
|
||||||
|
|
||||||
|
function taskColor(task: ITask): string {
|
||||||
|
const hex = projectStore.projects[task.projectId]?.hexColor || task.hexColor
|
||||||
|
if (!hex) {
|
||||||
|
return 'var(--primary)'
|
||||||
|
}
|
||||||
|
return hex.startsWith('#') ? hex : `#${hex}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function onDragStart(event: DragEvent, task: ITask) {
|
||||||
|
event.dataTransfer?.setData('text/plain', String(task.id))
|
||||||
|
if (event.dataTransfer) {
|
||||||
|
event.dataTransfer.effectAllowed = 'move'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDueDate(date: Date): string {
|
||||||
|
return dayjs(date).format('ll')
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
.planner-sidebar {
|
||||||
|
flex: 0 0 16rem;
|
||||||
|
inline-size: 16rem;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
min-block-size: 0;
|
||||||
|
border: 1px solid var(--grey-200);
|
||||||
|
border-radius: $radius;
|
||||||
|
background: var(--white);
|
||||||
|
padding: .75rem;
|
||||||
|
overflow-y: auto;
|
||||||
|
|
||||||
|
&.is-drop-target {
|
||||||
|
border-color: var(--primary);
|
||||||
|
background: var(--grey-100);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-title {
|
||||||
|
font-size: .9rem;
|
||||||
|
margin-block-end: .5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-filter {
|
||||||
|
margin-block-end: .75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.no-tasks {
|
||||||
|
font-size: .8rem;
|
||||||
|
color: var(--grey-500);
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-list {
|
||||||
|
list-style: none;
|
||||||
|
margin: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: .35rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-task {
|
||||||
|
cursor: grab;
|
||||||
|
border: 1px solid var(--grey-200);
|
||||||
|
border-inline-start: 3px solid var(--task-color);
|
||||||
|
border-radius: 4px;
|
||||||
|
padding: .4rem .5rem;
|
||||||
|
background: var(--white);
|
||||||
|
font-size: .8rem;
|
||||||
|
|
||||||
|
&:active {
|
||||||
|
cursor: grabbing;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: var(--grey-100);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-title {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-due {
|
||||||
|
display: block;
|
||||||
|
margin-block-start: .15rem;
|
||||||
|
font-size: .7rem;
|
||||||
|
color: var(--grey-500);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
@ -0,0 +1,270 @@
|
||||||
|
<template>
|
||||||
|
<div class="planner-view">
|
||||||
|
<header class="planner-toolbar">
|
||||||
|
<h1 class="planner-heading">
|
||||||
|
{{ $t('planner.title') }}
|
||||||
|
</h1>
|
||||||
|
|
||||||
|
<div class="toolbar-controls">
|
||||||
|
<XButton
|
||||||
|
variant="secondary"
|
||||||
|
:shadow="false"
|
||||||
|
@click="goToday"
|
||||||
|
>
|
||||||
|
{{ $t('planner.today') }}
|
||||||
|
</XButton>
|
||||||
|
<div class="nav-arrows">
|
||||||
|
<BaseButton
|
||||||
|
:aria-label="$t('planner.previous')"
|
||||||
|
@click="goPrev"
|
||||||
|
>
|
||||||
|
<Icon icon="angle-left" />
|
||||||
|
</BaseButton>
|
||||||
|
<BaseButton
|
||||||
|
:aria-label="$t('planner.next')"
|
||||||
|
@click="goNext"
|
||||||
|
>
|
||||||
|
<Icon icon="angle-right" />
|
||||||
|
</BaseButton>
|
||||||
|
</div>
|
||||||
|
<span class="range-label">{{ rangeLabel }}</span>
|
||||||
|
|
||||||
|
<div class="mode-toggle">
|
||||||
|
<XButton
|
||||||
|
:variant="viewMode === 'week' ? 'primary' : 'secondary'"
|
||||||
|
:shadow="false"
|
||||||
|
@click="viewMode = 'week'"
|
||||||
|
>
|
||||||
|
{{ $t('planner.week') }}
|
||||||
|
</XButton>
|
||||||
|
<XButton
|
||||||
|
:variant="viewMode === 'day' ? 'primary' : 'secondary'"
|
||||||
|
:shadow="false"
|
||||||
|
@click="viewMode = 'day'"
|
||||||
|
>
|
||||||
|
{{ $t('planner.day') }}
|
||||||
|
</XButton>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="zoom-controls">
|
||||||
|
<BaseButton
|
||||||
|
:aria-label="$t('planner.zoomOut')"
|
||||||
|
@click="zoomOut"
|
||||||
|
>
|
||||||
|
<Icon icon="minus" />
|
||||||
|
</BaseButton>
|
||||||
|
<BaseButton
|
||||||
|
:aria-label="$t('planner.zoomIn')"
|
||||||
|
@click="zoomIn"
|
||||||
|
>
|
||||||
|
<Icon icon="plus" />
|
||||||
|
</BaseButton>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<PlannerSettings />
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="planner-body">
|
||||||
|
<PlannerSidebar
|
||||||
|
v-model:filter="sidebarFilter"
|
||||||
|
:tasks="sidebarTasks"
|
||||||
|
@openTask="openTask"
|
||||||
|
@unschedule="taskId => updateTask({id: taskId, startDate: null, endDate: null})"
|
||||||
|
/>
|
||||||
|
<CalendarGrid
|
||||||
|
:days="days"
|
||||||
|
:tasks="visibleGridTasks"
|
||||||
|
:slot-minutes="settings.slotMinutes"
|
||||||
|
:day-start-hour="dayStartHour"
|
||||||
|
:day-end-hour="dayEndHour"
|
||||||
|
:px-per-hour="pxPerHour"
|
||||||
|
:auto-fit="!userZoomed"
|
||||||
|
@openTask="openTask"
|
||||||
|
@updateBlock="onUpdateBlock"
|
||||||
|
@dropTask="onDropTask"
|
||||||
|
@dropAllDay="onDropAllDay"
|
||||||
|
@update:pxPerHour="value => pxPerHour = value"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import {computed, ref, watchEffect} from 'vue'
|
||||||
|
import {useRouter} from 'vue-router'
|
||||||
|
import {useI18n} from 'vue-i18n'
|
||||||
|
import dayjs from 'dayjs'
|
||||||
|
|
||||||
|
import BaseButton from '@/components/base/BaseButton.vue'
|
||||||
|
import PlannerSidebar from './PlannerSidebar.vue'
|
||||||
|
import PlannerSettings from './PlannerSettings.vue'
|
||||||
|
import CalendarGrid from './grid/CalendarGrid.vue'
|
||||||
|
|
||||||
|
import {setTitle} from '@/helpers/setTitle'
|
||||||
|
import {useAuthStore} from '@/stores/auth'
|
||||||
|
import type {TaskFilterParams} from '@/services/taskCollection'
|
||||||
|
import {useCalendarSettings} from './helpers/useCalendarSettings'
|
||||||
|
import {usePlannerTasks, type PlannerRange} from './helpers/usePlannerTasks'
|
||||||
|
|
||||||
|
const router = useRouter()
|
||||||
|
const {t} = useI18n({useScope: 'global'})
|
||||||
|
const {settings} = useCalendarSettings()
|
||||||
|
const authStore = useAuthStore()
|
||||||
|
|
||||||
|
const viewMode = ref<'week' | 'day'>('week')
|
||||||
|
const anchor = ref(new Date())
|
||||||
|
const sidebarFilter = ref<TaskFilterParams>({filter: '', s: ''} as TaskFilterParams)
|
||||||
|
|
||||||
|
const pxPerHour = ref(48)
|
||||||
|
const userZoomed = ref(false)
|
||||||
|
|
||||||
|
// "HH:MM" working-hour strings → fractional hours for the grid's zoom/scroll.
|
||||||
|
function hoursFromTime(time: string): number {
|
||||||
|
const [h, m] = (time || '0:0').split(':').map(Number)
|
||||||
|
return (h || 0) + (m || 0) / 60
|
||||||
|
}
|
||||||
|
const dayStartHour = computed(() => hoursFromTime(settings.value.dayStart))
|
||||||
|
const dayEndHour = computed(() => hoursFromTime(settings.value.dayEnd))
|
||||||
|
|
||||||
|
// Respect the user's configured first day of the week (0 = Sunday … 6 = Saturday).
|
||||||
|
function startOfWeek(date: Date): dayjs.Dayjs {
|
||||||
|
const weekStart = authStore.settings.weekStart ?? 0
|
||||||
|
const day = dayjs(date).startOf('day')
|
||||||
|
const diff = (day.day() - weekStart + 7) % 7
|
||||||
|
return day.subtract(diff, 'day')
|
||||||
|
}
|
||||||
|
|
||||||
|
const days = computed<Date[]>(() => {
|
||||||
|
if (viewMode.value === 'day') {
|
||||||
|
return [dayjs(anchor.value).startOf('day').toDate()]
|
||||||
|
}
|
||||||
|
const start = settings.value.fullWeek ? startOfWeek(anchor.value) : dayjs(anchor.value).startOf('day')
|
||||||
|
return Array.from({length: 7}, (_, i) => start.add(i, 'day').toDate())
|
||||||
|
})
|
||||||
|
|
||||||
|
const range = computed<PlannerRange>(() => ({
|
||||||
|
from: days.value[0],
|
||||||
|
to: dayjs(days.value[days.value.length - 1]).endOf('day').toDate(),
|
||||||
|
}))
|
||||||
|
|
||||||
|
const rangeLabel = computed(() => {
|
||||||
|
if (viewMode.value === 'day') {
|
||||||
|
return dayjs(anchor.value).format('LL')
|
||||||
|
}
|
||||||
|
const first = dayjs(days.value[0])
|
||||||
|
const last = dayjs(days.value[days.value.length - 1])
|
||||||
|
return `${first.format('ll')} – ${last.format('ll')}`
|
||||||
|
})
|
||||||
|
|
||||||
|
const {sidebarTasks, gridTasks, updateTask} = usePlannerTasks(range, sidebarFilter)
|
||||||
|
|
||||||
|
const visibleGridTasks = computed(() =>
|
||||||
|
[...gridTasks.value.values()].filter(task => settings.value.showDone || !task.done),
|
||||||
|
)
|
||||||
|
|
||||||
|
function goPrev() {
|
||||||
|
anchor.value = dayjs(anchor.value).subtract(1, viewMode.value).toDate()
|
||||||
|
}
|
||||||
|
|
||||||
|
function goNext() {
|
||||||
|
anchor.value = dayjs(anchor.value).add(1, viewMode.value).toDate()
|
||||||
|
}
|
||||||
|
|
||||||
|
function goToday() {
|
||||||
|
anchor.value = new Date()
|
||||||
|
}
|
||||||
|
|
||||||
|
function zoomIn() {
|
||||||
|
userZoomed.value = true
|
||||||
|
pxPerHour.value = Math.min(pxPerHour.value + 12, 200)
|
||||||
|
}
|
||||||
|
|
||||||
|
function zoomOut() {
|
||||||
|
userZoomed.value = true
|
||||||
|
pxPerHour.value = Math.max(pxPerHour.value - 12, 16)
|
||||||
|
}
|
||||||
|
|
||||||
|
function onDropTask({taskId, minutes, day}: {taskId: number, minutes: number, day: Date}) {
|
||||||
|
const start = dayjs(day).startOf('day').add(minutes, 'minute')
|
||||||
|
const end = start.add(settings.value.defaultDurationMinutes, 'minute')
|
||||||
|
updateTask({id: taskId, startDate: start.toDate(), endDate: end.toDate()})
|
||||||
|
}
|
||||||
|
|
||||||
|
function onUpdateBlock({taskId, start, end}: {taskId: number, start: Date | null, end: Date | null}) {
|
||||||
|
updateTask({id: taskId, startDate: start, endDate: end})
|
||||||
|
}
|
||||||
|
|
||||||
|
function onDropAllDay({taskId, day}: {taskId: number, day: Date}) {
|
||||||
|
// All-day = start and end pinned to midnight of that day.
|
||||||
|
const midnight = dayjs(day).startOf('day').toDate()
|
||||||
|
updateTask({id: taskId, startDate: midnight, endDate: midnight})
|
||||||
|
}
|
||||||
|
|
||||||
|
function openTask(taskId: number) {
|
||||||
|
router.push({
|
||||||
|
name: 'task.detail',
|
||||||
|
params: {id: taskId},
|
||||||
|
state: {backdropView: router.currentRoute.value.fullPath},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
watchEffect(() => setTitle(t('planner.title')))
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
.planner-view {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
block-size: calc(100vh - #{$navbar-height} - 1.5rem);
|
||||||
|
}
|
||||||
|
|
||||||
|
.planner-toolbar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: .5rem;
|
||||||
|
margin-block-end: .75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.planner-heading {
|
||||||
|
font-size: 1.4rem;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toolbar-controls {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: .5rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-arrows {
|
||||||
|
display: flex;
|
||||||
|
gap: .25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.range-label {
|
||||||
|
font-weight: 600;
|
||||||
|
min-inline-size: 11rem;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mode-toggle {
|
||||||
|
display: flex;
|
||||||
|
gap: .25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.zoom-controls {
|
||||||
|
display: flex;
|
||||||
|
gap: .25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.planner-body {
|
||||||
|
display: flex;
|
||||||
|
gap: .75rem;
|
||||||
|
flex: 1 1 auto;
|
||||||
|
min-block-size: 0;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
@ -0,0 +1,306 @@
|
||||||
|
<template>
|
||||||
|
<div
|
||||||
|
ref="blockEl"
|
||||||
|
class="calendar-block"
|
||||||
|
:class="{
|
||||||
|
'is-ghost': occurrence.isGhost,
|
||||||
|
'is-done': occurrence.task.done,
|
||||||
|
'is-dragging': isInteracting,
|
||||||
|
'is-moving': isMoving,
|
||||||
|
}"
|
||||||
|
:style="blockStyle"
|
||||||
|
@pointerdown="onMovePointerDown"
|
||||||
|
>
|
||||||
|
<span class="block-time">{{ timeLabel }}</span>
|
||||||
|
<span class="block-title">{{ occurrence.task.title }}</span>
|
||||||
|
<div
|
||||||
|
v-if="!occurrence.isGhost"
|
||||||
|
class="resize-handle"
|
||||||
|
@pointerdown.stop="onResizePointerDown"
|
||||||
|
>
|
||||||
|
<span class="grip-dot" />
|
||||||
|
<span class="grip-dot" />
|
||||||
|
<span class="grip-dot" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Teleport to="body">
|
||||||
|
<div
|
||||||
|
v-if="isMoving"
|
||||||
|
class="calendar-block drag-preview"
|
||||||
|
:style="previewStyle"
|
||||||
|
>
|
||||||
|
<span class="block-time">{{ timeLabel }}</span>
|
||||||
|
<span class="block-title">{{ occurrence.task.title }}</span>
|
||||||
|
</div>
|
||||||
|
</Teleport>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import {computed, ref} from 'vue'
|
||||||
|
import dayjs from 'dayjs'
|
||||||
|
|
||||||
|
import {useProjectStore} from '@/stores/projects'
|
||||||
|
import {useTimeFormat} from '@/composables/useTimeFormat'
|
||||||
|
import {TIME_FORMAT} from '@/constants/timeFormat'
|
||||||
|
import type {PlannedOccurrence} from '../helpers/types'
|
||||||
|
|
||||||
|
const props = withDefaults(defineProps<{
|
||||||
|
occurrence: PlannedOccurrence
|
||||||
|
col: number
|
||||||
|
cols: number
|
||||||
|
topMinutes: number
|
||||||
|
durationMinutes: number
|
||||||
|
pxPerMinute: number
|
||||||
|
slotMinutes: number
|
||||||
|
originMinutes?: number
|
||||||
|
}>(), {
|
||||||
|
originMinutes: 0,
|
||||||
|
})
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
open: [taskId: number]
|
||||||
|
update: [payload: {taskId: number, start: Date | null, end: Date | null}]
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const projectStore = useProjectStore()
|
||||||
|
const {store: timeFormat} = useTimeFormat()
|
||||||
|
const blockEl = ref<HTMLElement | null>(null)
|
||||||
|
|
||||||
|
const resizeDeltaMinutes = ref(0)
|
||||||
|
const isInteracting = ref(false)
|
||||||
|
const isMoving = ref(false)
|
||||||
|
const grabOffset = ref({x: 0, y: 0})
|
||||||
|
const previewPos = ref({x: 0, y: 0})
|
||||||
|
const previewSize = ref({w: 0, h: 0})
|
||||||
|
|
||||||
|
const color = computed(() => {
|
||||||
|
const project = projectStore.projects[props.occurrence.task.projectId]
|
||||||
|
const hex = project?.hexColor || props.occurrence.task.hexColor
|
||||||
|
if (!hex) {
|
||||||
|
return 'var(--primary)'
|
||||||
|
}
|
||||||
|
return hex.startsWith('#') ? hex : `#${hex}`
|
||||||
|
})
|
||||||
|
|
||||||
|
const effectiveTop = computed(() => (props.topMinutes - props.originMinutes) * props.pxPerMinute)
|
||||||
|
const effectiveHeight = computed(() => Math.max(
|
||||||
|
(props.durationMinutes + resizeDeltaMinutes.value) * props.pxPerMinute,
|
||||||
|
props.slotMinutes * props.pxPerMinute,
|
||||||
|
))
|
||||||
|
|
||||||
|
const blockStyle = computed(() => ({
|
||||||
|
top: `${effectiveTop.value}px`,
|
||||||
|
height: `${effectiveHeight.value}px`,
|
||||||
|
insetInlineStart: `${(props.col / props.cols) * 100}%`,
|
||||||
|
inlineSize: `${(1 / props.cols) * 100}%`,
|
||||||
|
'--block-color': color.value,
|
||||||
|
}))
|
||||||
|
|
||||||
|
// A floating clone teleported to <body> follows the cursor, so it isn't clipped
|
||||||
|
// by the grid's scroll container when dragged over the sidebar/all-day row.
|
||||||
|
const previewStyle = computed(() => ({
|
||||||
|
left: `${previewPos.value.x}px`,
|
||||||
|
top: `${previewPos.value.y}px`,
|
||||||
|
inlineSize: `${previewSize.value.w}px`,
|
||||||
|
blockSize: `${previewSize.value.h}px`,
|
||||||
|
'--block-color': color.value,
|
||||||
|
}))
|
||||||
|
|
||||||
|
const timeLabel = computed(() => dayjs(props.occurrence.start)
|
||||||
|
.format(timeFormat.value === TIME_FORMAT.HOURS_24 ? 'HH:mm' : 'h:mm A'))
|
||||||
|
|
||||||
|
function snap(deltaPx: number): number {
|
||||||
|
const minutes = deltaPx / props.pxPerMinute
|
||||||
|
return Math.round(minutes / props.slotMinutes) * props.slotMinutes
|
||||||
|
}
|
||||||
|
|
||||||
|
function onMovePointerDown(event: PointerEvent) {
|
||||||
|
if (props.occurrence.isGhost) {
|
||||||
|
// Ghosts are read-only, but still let the user open the underlying task.
|
||||||
|
emit('open', props.occurrence.task.id)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const startY = event.clientY
|
||||||
|
const startX = event.clientX
|
||||||
|
const rect = blockEl.value?.getBoundingClientRect()
|
||||||
|
grabOffset.value = {x: rect ? startX - rect.left : 0, y: rect ? startY - rect.top : 0}
|
||||||
|
previewSize.value = {w: rect?.width ?? 0, h: rect?.height ?? 0}
|
||||||
|
previewPos.value = {x: rect?.left ?? startX, y: rect?.top ?? startY}
|
||||||
|
let moved = false
|
||||||
|
|
||||||
|
const onMove = (e: PointerEvent) => {
|
||||||
|
previewPos.value = {x: e.clientX - grabOffset.value.x, y: e.clientY - grabOffset.value.y}
|
||||||
|
if (Math.abs(e.clientX - startX) > 3 || Math.abs(e.clientY - startY) > 3) {
|
||||||
|
moved = true
|
||||||
|
isInteracting.value = true
|
||||||
|
isMoving.value = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const onUp = (e: PointerEvent) => {
|
||||||
|
document.removeEventListener('pointermove', onMove)
|
||||||
|
document.removeEventListener('pointerup', onUp)
|
||||||
|
|
||||||
|
const taskId = props.occurrence.task.id
|
||||||
|
// Hit-test from the preview block's top-centre (what the user visually
|
||||||
|
// aligns), not the cursor, which sits at the grab offset further down.
|
||||||
|
const hitX = previewPos.value.x + previewSize.value.w / 2
|
||||||
|
const hitY = previewPos.value.y
|
||||||
|
const dropEl = document.elementFromPoint(hitX, hitY)
|
||||||
|
const overSidebar = dropEl?.closest('.planner-sidebar')
|
||||||
|
const allDayCell = dropEl?.closest<HTMLElement>('.all-day-cell')
|
||||||
|
const dayColumn = dropEl?.closest<HTMLElement>('.day-column')
|
||||||
|
const targetDay = dayColumn?.dataset.day ?? null
|
||||||
|
|
||||||
|
if (overSidebar) {
|
||||||
|
// Drop on the sidebar → unschedule (back to the unscheduled list).
|
||||||
|
emit('update', {taskId, start: null, end: null})
|
||||||
|
} else if (allDayCell?.dataset.day) {
|
||||||
|
// Drop on the all-day row → make it an all-day task on that day.
|
||||||
|
const day = dayjs(allDayCell.dataset.day).startOf('day').toDate()
|
||||||
|
emit('update', {taskId, start: day, end: day})
|
||||||
|
} else {
|
||||||
|
const dayChanged = targetDay !== null && !dayjs(targetDay).isSame(props.occurrence.start, 'day')
|
||||||
|
if (!moved && !dayChanged) {
|
||||||
|
emit('open', taskId)
|
||||||
|
} else {
|
||||||
|
const origStart = dayjs(props.occurrence.start)
|
||||||
|
const minutesFromMidnight = origStart.diff(origStart.startOf('day'), 'minute')
|
||||||
|
const newMinutes = Math.min(
|
||||||
|
Math.max(minutesFromMidnight + snap(e.clientY - startY), 0),
|
||||||
|
24 * 60 - props.durationMinutes,
|
||||||
|
)
|
||||||
|
const baseDay = targetDay ? dayjs(targetDay).startOf('day') : origStart.startOf('day')
|
||||||
|
const newStart = baseDay.add(newMinutes, 'minute')
|
||||||
|
emit('update', {
|
||||||
|
taskId,
|
||||||
|
start: newStart.toDate(),
|
||||||
|
end: newStart.add(props.durationMinutes, 'minute').toDate(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
isInteracting.value = false
|
||||||
|
isMoving.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener('pointermove', onMove)
|
||||||
|
document.addEventListener('pointerup', onUp)
|
||||||
|
}
|
||||||
|
|
||||||
|
function onResizePointerDown(event: PointerEvent) {
|
||||||
|
const startY = event.clientY
|
||||||
|
|
||||||
|
const onMove = (e: PointerEvent) => {
|
||||||
|
isInteracting.value = true
|
||||||
|
resizeDeltaMinutes.value = snap(e.clientY - startY)
|
||||||
|
}
|
||||||
|
|
||||||
|
const onUp = () => {
|
||||||
|
document.removeEventListener('pointermove', onMove)
|
||||||
|
document.removeEventListener('pointerup', onUp)
|
||||||
|
|
||||||
|
const newDuration = Math.max(props.durationMinutes + resizeDeltaMinutes.value, props.slotMinutes)
|
||||||
|
if (newDuration !== props.durationMinutes) {
|
||||||
|
emit('update', {
|
||||||
|
taskId: props.occurrence.task.id,
|
||||||
|
start: new Date(props.occurrence.start),
|
||||||
|
end: dayjs(props.occurrence.start).add(newDuration, 'minute').toDate(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
resizeDeltaMinutes.value = 0
|
||||||
|
isInteracting.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener('pointermove', onMove)
|
||||||
|
document.addEventListener('pointerup', onUp)
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
.calendar-block {
|
||||||
|
position: absolute;
|
||||||
|
overflow: hidden;
|
||||||
|
padding: 2px 6px;
|
||||||
|
border-radius: 4px;
|
||||||
|
border-inline-start: 3px solid var(--block-color);
|
||||||
|
background-color: var(--block-color);
|
||||||
|
color: var(--white);
|
||||||
|
cursor: grab;
|
||||||
|
user-select: none;
|
||||||
|
font-size: .75rem;
|
||||||
|
line-height: 1.1;
|
||||||
|
box-shadow: 0 1px 2px hsla(0, 0%, 0%, .15);
|
||||||
|
|
||||||
|
&.is-dragging {
|
||||||
|
cursor: grabbing;
|
||||||
|
}
|
||||||
|
|
||||||
|
// While moving, dim the original in place and let elementFromPoint see what's
|
||||||
|
// underneath (the floating preview is what the user actually follows).
|
||||||
|
&.is-moving {
|
||||||
|
opacity: .3;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.is-ghost {
|
||||||
|
opacity: .45;
|
||||||
|
cursor: pointer;
|
||||||
|
background-image: repeating-linear-gradient(
|
||||||
|
45deg,
|
||||||
|
hsla(0, 0%, 100%, .15),
|
||||||
|
hsla(0, 0%, 100%, .15) 4px,
|
||||||
|
transparent 4px,
|
||||||
|
transparent 8px
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
&.is-done {
|
||||||
|
opacity: .6;
|
||||||
|
|
||||||
|
.block-title {
|
||||||
|
text-decoration: line-through;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.drag-preview {
|
||||||
|
position: fixed;
|
||||||
|
z-index: 100;
|
||||||
|
pointer-events: none;
|
||||||
|
opacity: .95;
|
||||||
|
box-shadow: 0 4px 12px hsla(0, 0%, 0%, .3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.block-time {
|
||||||
|
display: block;
|
||||||
|
font-weight: 700;
|
||||||
|
opacity: .85;
|
||||||
|
}
|
||||||
|
|
||||||
|
.block-title {
|
||||||
|
display: block;
|
||||||
|
white-space: normal;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
|
||||||
|
.resize-handle {
|
||||||
|
position: absolute;
|
||||||
|
inset-block-end: 0;
|
||||||
|
inset-inline: 0;
|
||||||
|
block-size: 9px;
|
||||||
|
cursor: ns-resize;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.grip-dot {
|
||||||
|
inline-size: 3px;
|
||||||
|
block-size: 3px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background-color: hsla(0, 0%, 100%, .8);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
@ -0,0 +1,140 @@
|
||||||
|
<template>
|
||||||
|
<div
|
||||||
|
ref="columnEl"
|
||||||
|
class="day-column"
|
||||||
|
:class="{'is-drop-target': isDropTarget}"
|
||||||
|
:data-day="dayKey"
|
||||||
|
@dragover.prevent="isDropTarget = true"
|
||||||
|
@dragleave="isDropTarget = false"
|
||||||
|
@drop="onDrop"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
v-for="hour in 24"
|
||||||
|
:key="hour"
|
||||||
|
class="hour-slot"
|
||||||
|
:style="{height: `${pxPerMinute * 60}px`}"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-if="isToday"
|
||||||
|
class="now-line"
|
||||||
|
:style="{top: `${nowMinutes * pxPerMinute}px`}"
|
||||||
|
>
|
||||||
|
<span class="now-dot" />
|
||||||
|
<span class="now-bar" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<CalendarBlock
|
||||||
|
v-for="block in blocks"
|
||||||
|
:key="block.occurrence.key"
|
||||||
|
:occurrence="block.occurrence"
|
||||||
|
:col="block.col"
|
||||||
|
:cols="block.cols"
|
||||||
|
:top-minutes="block.topMinutes"
|
||||||
|
:duration-minutes="block.durationMinutes"
|
||||||
|
:px-per-minute="pxPerMinute"
|
||||||
|
:slot-minutes="slotMinutes"
|
||||||
|
@open="taskId => emit('openTask', taskId)"
|
||||||
|
@update="payload => emit('updateBlock', payload)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import {computed, onBeforeUnmount, onMounted, ref} from 'vue'
|
||||||
|
import dayjs from 'dayjs'
|
||||||
|
|
||||||
|
import type {ITask} from '@/modelTypes/ITask'
|
||||||
|
import CalendarBlock from './CalendarBlock.vue'
|
||||||
|
import {timedBlocksForDay} from '../helpers/dayLayout'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
day: Date
|
||||||
|
tasks: ITask[]
|
||||||
|
pxPerMinute: number
|
||||||
|
slotMinutes: number
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
openTask: [taskId: number]
|
||||||
|
updateBlock: [payload: {taskId: number, start: Date | null, end: Date | null}]
|
||||||
|
dropTask: [payload: {taskId: number, minutes: number}]
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const columnEl = ref<HTMLElement | null>(null)
|
||||||
|
const isDropTarget = ref(false)
|
||||||
|
const now = ref(new Date())
|
||||||
|
|
||||||
|
const dayKey = computed(() => dayjs(props.day).format('YYYY-MM-DD'))
|
||||||
|
const blocks = computed(() => timedBlocksForDay(props.tasks, props.day))
|
||||||
|
const isToday = computed(() => dayjs(props.day).isSame(now.value, 'day'))
|
||||||
|
const nowMinutes = computed(() => dayjs(now.value).diff(dayjs(now.value).startOf('day'), 'minute'))
|
||||||
|
|
||||||
|
// Keep the current-time marker fresh. Only today's column needs the ticker.
|
||||||
|
let timer: ReturnType<typeof setInterval> | undefined
|
||||||
|
onMounted(() => {
|
||||||
|
if (dayjs(props.day).isSame(new Date(), 'day')) {
|
||||||
|
timer = setInterval(() => now.value = new Date(), 60_000)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
onBeforeUnmount(() => clearInterval(timer))
|
||||||
|
|
||||||
|
function onDrop(event: DragEvent) {
|
||||||
|
isDropTarget.value = false
|
||||||
|
const taskId = Number(event.dataTransfer?.getData('text/plain'))
|
||||||
|
if (!taskId || !columnEl.value) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const rect = columnEl.value.getBoundingClientRect()
|
||||||
|
const offsetY = event.clientY - rect.top
|
||||||
|
const rawMinutes = offsetY / props.pxPerMinute
|
||||||
|
const snapped = Math.round(rawMinutes / props.slotMinutes) * props.slotMinutes
|
||||||
|
const minutes = Math.min(Math.max(snapped, 0), 24 * 60 - props.slotMinutes)
|
||||||
|
|
||||||
|
emit('dropTask', {taskId, minutes})
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
.day-column {
|
||||||
|
position: relative;
|
||||||
|
flex: 1 1 0;
|
||||||
|
min-inline-size: 0;
|
||||||
|
border-inline-start: 1px solid var(--grey-200);
|
||||||
|
|
||||||
|
&.is-drop-target {
|
||||||
|
background-color: var(--grey-100);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.hour-slot {
|
||||||
|
border-block-end: 1px solid var(--grey-200);
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
.now-line {
|
||||||
|
position: absolute;
|
||||||
|
inset-inline: 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
transform: translateY(-50%);
|
||||||
|
z-index: 15;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.now-dot {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
inline-size: 9px;
|
||||||
|
block-size: 9px;
|
||||||
|
margin-inline-start: -4px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background-color: var(--danger);
|
||||||
|
}
|
||||||
|
|
||||||
|
.now-bar {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
block-size: 2px;
|
||||||
|
background-color: var(--danger);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
@ -0,0 +1,345 @@
|
||||||
|
<template>
|
||||||
|
<div class="calendar-grid">
|
||||||
|
<div
|
||||||
|
class="grid-head"
|
||||||
|
:style="headerStyle"
|
||||||
|
>
|
||||||
|
<div class="axis-gutter" />
|
||||||
|
<div
|
||||||
|
v-for="day in days"
|
||||||
|
:key="day.toISOString()"
|
||||||
|
class="day-head"
|
||||||
|
:class="{'is-today': isToday(day)}"
|
||||||
|
>
|
||||||
|
<span class="day-name">{{ formatWeekday(day) }}</span>
|
||||||
|
<span class="day-number">{{ day.getDate() }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
class="all-day-row"
|
||||||
|
:style="headerStyle"
|
||||||
|
>
|
||||||
|
<div class="axis-gutter all-day-label">
|
||||||
|
{{ $t('planner.allDay') }}
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
v-for="day in days"
|
||||||
|
:key="day.toISOString()"
|
||||||
|
class="all-day-cell"
|
||||||
|
:class="{'is-drop-target': allDayDropDay === formatDayKey(day)}"
|
||||||
|
:data-day="formatDayKey(day)"
|
||||||
|
@dragover.prevent="allDayDropDay = formatDayKey(day)"
|
||||||
|
@dragleave="allDayDropDay = null"
|
||||||
|
@drop="onAllDayDrop($event, day)"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
v-for="task in allDayTasksForDay(tasks, day)"
|
||||||
|
:key="task.id"
|
||||||
|
class="all-day-chip"
|
||||||
|
:class="{'is-done': task.done}"
|
||||||
|
:style="{'--chip-color': taskColor(task)}"
|
||||||
|
draggable="true"
|
||||||
|
@dragstart="onChipDragStart($event, task)"
|
||||||
|
@click="emit('openTask', task.id)"
|
||||||
|
>
|
||||||
|
{{ task.title }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
ref="bodyEl"
|
||||||
|
class="grid-body"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="grid-content"
|
||||||
|
:style="{blockSize: `${pxPerHour * 24}px`}"
|
||||||
|
>
|
||||||
|
<div class="time-axis">
|
||||||
|
<div
|
||||||
|
v-for="hour in 24"
|
||||||
|
:key="hour"
|
||||||
|
class="axis-hour"
|
||||||
|
:style="{height: `${pxPerMinute * 60}px`}"
|
||||||
|
>
|
||||||
|
<span>{{ formatHour(hour - 1) }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="day-columns">
|
||||||
|
<CalendarDayColumn
|
||||||
|
v-for="day in days"
|
||||||
|
:key="day.toISOString()"
|
||||||
|
:day="day"
|
||||||
|
:tasks="tasks"
|
||||||
|
:px-per-minute="pxPerMinute"
|
||||||
|
:slot-minutes="slotMinutes"
|
||||||
|
@openTask="taskId => emit('openTask', taskId)"
|
||||||
|
@updateBlock="payload => emit('updateBlock', payload)"
|
||||||
|
@dropTask="payload => emit('dropTask', {...payload, day})"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import {computed, nextTick, onBeforeUnmount, onMounted, ref, watch} from 'vue'
|
||||||
|
import dayjs from 'dayjs'
|
||||||
|
|
||||||
|
import type {ITask} from '@/modelTypes/ITask'
|
||||||
|
import {useProjectStore} from '@/stores/projects'
|
||||||
|
import {useTimeFormat} from '@/composables/useTimeFormat'
|
||||||
|
import {TIME_FORMAT} from '@/constants/timeFormat'
|
||||||
|
import CalendarDayColumn from './CalendarDayColumn.vue'
|
||||||
|
import {allDayTasksForDay} from '../helpers/dayLayout'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
days: Date[]
|
||||||
|
tasks: ITask[]
|
||||||
|
slotMinutes: number
|
||||||
|
dayStartHour: number
|
||||||
|
dayEndHour: number
|
||||||
|
pxPerHour: number
|
||||||
|
autoFit: boolean
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
openTask: [taskId: number]
|
||||||
|
updateBlock: [payload: {taskId: number, start: Date | null, end: Date | null}]
|
||||||
|
dropTask: [payload: {taskId: number, minutes: number, day: Date}]
|
||||||
|
dropAllDay: [payload: {taskId: number, day: Date}]
|
||||||
|
'update:pxPerHour': [value: number]
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const projectStore = useProjectStore()
|
||||||
|
const {store: timeFormat} = useTimeFormat()
|
||||||
|
const bodyEl = ref<HTMLElement | null>(null)
|
||||||
|
const scrollbarWidth = ref(0)
|
||||||
|
const allDayDropDay = ref<string | null>(null)
|
||||||
|
|
||||||
|
function onAllDayDrop(event: DragEvent, day: Date) {
|
||||||
|
allDayDropDay.value = null
|
||||||
|
const taskId = Number(event.dataTransfer?.getData('text/plain'))
|
||||||
|
if (taskId) {
|
||||||
|
emit('dropAllDay', {taskId, day})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function onChipDragStart(event: DragEvent, task: ITask) {
|
||||||
|
event.dataTransfer?.setData('text/plain', String(task.id))
|
||||||
|
if (event.dataTransfer) {
|
||||||
|
event.dataTransfer.effectAllowed = 'move'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const pxPerMinute = computed(() => props.pxPerHour / 60)
|
||||||
|
|
||||||
|
// The body has a vertical scrollbar but the header/all-day rows don't; reserve
|
||||||
|
// the same width on them so the day-column verticals line up.
|
||||||
|
const headerStyle = computed(() => ({paddingInlineEnd: `${scrollbarWidth.value}px`}))
|
||||||
|
|
||||||
|
function measureScrollbar() {
|
||||||
|
if (bodyEl.value) {
|
||||||
|
scrollbarWidth.value = bodyEl.value.offsetWidth - bodyEl.value.clientWidth
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function taskColor(task: ITask): string {
|
||||||
|
const hex = projectStore.projects[task.projectId]?.hexColor || task.hexColor
|
||||||
|
if (!hex) {
|
||||||
|
return 'var(--primary)'
|
||||||
|
}
|
||||||
|
return hex.startsWith('#') ? hex : `#${hex}`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Choose a zoom level so the working-hours window fills the visible grid, then
|
||||||
|
// scroll to the day start. Off-hours stay reachable by scrolling.
|
||||||
|
function fitToWorkingHours() {
|
||||||
|
if (!props.autoFit || !bodyEl.value) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const workingHours = Math.max(props.dayEndHour - props.dayStartHour, 1)
|
||||||
|
const height = bodyEl.value.clientHeight
|
||||||
|
if (height > 0) {
|
||||||
|
emit('update:pxPerHour', Math.min(Math.max(Math.round(height / workingHours), 16), 200))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function scrollToDayStart() {
|
||||||
|
if (bodyEl.value) {
|
||||||
|
bodyEl.value.scrollTop = props.dayStartHour * props.pxPerHour
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
fitToWorkingHours()
|
||||||
|
nextTick(() => {
|
||||||
|
scrollToDayStart()
|
||||||
|
measureScrollbar()
|
||||||
|
})
|
||||||
|
window.addEventListener('resize', measureScrollbar)
|
||||||
|
})
|
||||||
|
|
||||||
|
onBeforeUnmount(() => window.removeEventListener('resize', measureScrollbar))
|
||||||
|
|
||||||
|
watch(() => [props.dayStartHour, props.dayEndHour, props.days, props.autoFit], () => {
|
||||||
|
fitToWorkingHours()
|
||||||
|
nextTick(() => {
|
||||||
|
scrollToDayStart()
|
||||||
|
measureScrollbar()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
watch(() => props.pxPerHour, () => nextTick(() => {
|
||||||
|
scrollToDayStart()
|
||||||
|
measureScrollbar()
|
||||||
|
}))
|
||||||
|
|
||||||
|
function isToday(day: Date): boolean {
|
||||||
|
return dayjs(day).isSame(dayjs(), 'day')
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatWeekday(day: Date): string {
|
||||||
|
return dayjs(day).format('ddd')
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDayKey(day: Date): string {
|
||||||
|
return dayjs(day).format('YYYY-MM-DD')
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatHour(hour: number): string {
|
||||||
|
return dayjs().hour(hour).minute(0)
|
||||||
|
.format(timeFormat.value === TIME_FORMAT.HOURS_24 ? 'HH:mm' : 'h A')
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
$gutter-width: 3.5rem;
|
||||||
|
|
||||||
|
.calendar-grid {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
flex: 1 1 auto;
|
||||||
|
min-block-size: 0;
|
||||||
|
border: 1px solid var(--grey-200);
|
||||||
|
border-radius: $radius;
|
||||||
|
overflow: hidden;
|
||||||
|
background: var(--white);
|
||||||
|
}
|
||||||
|
|
||||||
|
.grid-head,
|
||||||
|
.all-day-row {
|
||||||
|
display: flex;
|
||||||
|
border-block-end: 1px solid var(--grey-200);
|
||||||
|
}
|
||||||
|
|
||||||
|
.day-head {
|
||||||
|
flex: 1 1 0;
|
||||||
|
min-inline-size: 0;
|
||||||
|
text-align: center;
|
||||||
|
padding: .25rem 0;
|
||||||
|
border-inline-start: 1px solid var(--grey-200);
|
||||||
|
|
||||||
|
&.is-today {
|
||||||
|
color: var(--primary);
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.day-name {
|
||||||
|
display: block;
|
||||||
|
font-size: .75rem;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.day-number {
|
||||||
|
font-size: 1.1rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.axis-gutter {
|
||||||
|
flex: 0 0 $gutter-width;
|
||||||
|
inline-size: $gutter-width;
|
||||||
|
}
|
||||||
|
|
||||||
|
.all-day-label {
|
||||||
|
font-size: .7rem;
|
||||||
|
color: var(--grey-500);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: flex-end;
|
||||||
|
padding-inline-end: .35rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.all-day-cell {
|
||||||
|
flex: 1 1 0;
|
||||||
|
min-inline-size: 0;
|
||||||
|
min-block-size: 1.5rem;
|
||||||
|
border-inline-start: 1px solid var(--grey-200);
|
||||||
|
padding: 2px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 2px;
|
||||||
|
|
||||||
|
&.is-drop-target {
|
||||||
|
background-color: var(--grey-100);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.all-day-chip {
|
||||||
|
display: block;
|
||||||
|
inline-size: 100%;
|
||||||
|
text-align: start;
|
||||||
|
border: none;
|
||||||
|
cursor: grab;
|
||||||
|
border-radius: 3px;
|
||||||
|
padding: 1px 5px;
|
||||||
|
font-size: .72rem;
|
||||||
|
color: var(--white);
|
||||||
|
background-color: var(--chip-color);
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
|
||||||
|
&.is-done {
|
||||||
|
opacity: .6;
|
||||||
|
text-decoration: line-through;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.grid-body {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
min-block-size: 0;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.grid-content {
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
|
||||||
|
.time-axis {
|
||||||
|
flex: 0 0 $gutter-width;
|
||||||
|
inline-size: $gutter-width;
|
||||||
|
}
|
||||||
|
|
||||||
|
.axis-hour {
|
||||||
|
position: relative;
|
||||||
|
text-align: end;
|
||||||
|
padding-inline-end: .35rem;
|
||||||
|
box-sizing: border-box;
|
||||||
|
|
||||||
|
span {
|
||||||
|
position: relative;
|
||||||
|
inset-block-start: -.5em;
|
||||||
|
font-size: .7rem;
|
||||||
|
color: var(--grey-500);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.day-columns {
|
||||||
|
display: flex;
|
||||||
|
flex: 1 1 auto;
|
||||||
|
min-inline-size: 0;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
@ -0,0 +1,67 @@
|
||||||
|
import dayjs from 'dayjs'
|
||||||
|
|
||||||
|
import type {ITask} from '@/modelTypes/ITask'
|
||||||
|
import {expandOccurrences} from './expandOccurrences'
|
||||||
|
import {packColumns} from './packColumns'
|
||||||
|
import type {PlannedOccurrence} from './types'
|
||||||
|
|
||||||
|
export interface TimedBlock {
|
||||||
|
occurrence: PlannedOccurrence
|
||||||
|
col: number
|
||||||
|
cols: number
|
||||||
|
topMinutes: number
|
||||||
|
durationMinutes: number
|
||||||
|
}
|
||||||
|
|
||||||
|
const MIN_BLOCK_MINUTES = 15
|
||||||
|
|
||||||
|
// A task with a start and end both pinned to local midnight has no time-of-day
|
||||||
|
// and belongs in the all-day row, not as a (zero-height or full-column) block.
|
||||||
|
export function isAllDayTask(task: ITask): boolean {
|
||||||
|
if (!task.startDate || !task.endDate) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
const start = dayjs(task.startDate)
|
||||||
|
const end = dayjs(task.endDate)
|
||||||
|
return start.hour() === 0 && start.minute() === 0 && end.hour() === 0 && end.minute() === 0
|
||||||
|
}
|
||||||
|
|
||||||
|
export function timedBlocksForDay(tasks: ITask[], day: Date): TimedBlock[] {
|
||||||
|
const dayStart = dayjs(day).startOf('day')
|
||||||
|
const dayEnd = dayStart.add(1, 'day')
|
||||||
|
|
||||||
|
const occurrences: PlannedOccurrence[] = []
|
||||||
|
for (const task of tasks) {
|
||||||
|
if (!task.startDate || !task.endDate || isAllDayTask(task)) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
occurrences.push(...expandOccurrences(task, dayStart.toDate(), dayEnd.toDate()))
|
||||||
|
}
|
||||||
|
|
||||||
|
const sized = occurrences.map(occurrence => {
|
||||||
|
const start = dayjs(occurrence.start).isBefore(dayStart) ? dayStart : dayjs(occurrence.start)
|
||||||
|
const end = dayjs(occurrence.end).isAfter(dayEnd) ? dayEnd : dayjs(occurrence.end)
|
||||||
|
return {
|
||||||
|
occurrence,
|
||||||
|
topMinutes: start.diff(dayStart, 'minute'),
|
||||||
|
durationMinutes: Math.max(end.diff(start, 'minute'), MIN_BLOCK_MINUTES),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
return packColumns(
|
||||||
|
sized,
|
||||||
|
s => s.topMinutes,
|
||||||
|
s => s.topMinutes + s.durationMinutes,
|
||||||
|
).map(packed => ({...packed.item, col: packed.col, cols: packed.cols}))
|
||||||
|
}
|
||||||
|
|
||||||
|
export function allDayTasksForDay(tasks: ITask[], day: Date): ITask[] {
|
||||||
|
const target = dayjs(day)
|
||||||
|
return tasks.filter(task => {
|
||||||
|
if (isAllDayTask(task)) {
|
||||||
|
return !target.isBefore(dayjs(task.startDate), 'day') && !target.isAfter(dayjs(task.endDate), 'day')
|
||||||
|
}
|
||||||
|
// due-only tasks (no time block) show on their due day
|
||||||
|
return !task.startDate && !task.endDate && task.dueDate && target.isSame(dayjs(task.dueDate), 'day')
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,75 @@
|
||||||
|
import {describe, it, expect} from 'vitest'
|
||||||
|
import {expandOccurrences} from './expandOccurrences'
|
||||||
|
import {TASK_REPEAT_MODES} from '@/types/IRepeatMode'
|
||||||
|
import type {ITask} from '@/modelTypes/ITask'
|
||||||
|
|
||||||
|
function makeTask(overrides: Partial<ITask>): ITask {
|
||||||
|
return {
|
||||||
|
id: 1,
|
||||||
|
startDate: null,
|
||||||
|
endDate: null,
|
||||||
|
repeatAfter: 0,
|
||||||
|
repeatMode: TASK_REPEAT_MODES.REPEAT_MODE_DEFAULT,
|
||||||
|
...overrides,
|
||||||
|
} as ITask
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('expandOccurrences', () => {
|
||||||
|
it('returns nothing for a task without start or end', () => {
|
||||||
|
const task = makeTask({startDate: new Date('2026-06-22T10:00:00')})
|
||||||
|
expect(expandOccurrences(task, new Date('2026-06-22'), new Date('2026-06-29'))).toHaveLength(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns a single non-recurring instance, not a ghost', () => {
|
||||||
|
const task = makeTask({
|
||||||
|
startDate: new Date('2026-06-23T10:00:00'),
|
||||||
|
endDate: new Date('2026-06-23T11:00:00'),
|
||||||
|
})
|
||||||
|
const out = expandOccurrences(task, new Date('2026-06-22T00:00:00'), new Date('2026-06-29T00:00:00'))
|
||||||
|
expect(out).toHaveLength(1)
|
||||||
|
expect(out[0].isGhost).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('skips an instance entirely outside the range', () => {
|
||||||
|
const task = makeTask({
|
||||||
|
startDate: new Date('2026-01-01T10:00:00'),
|
||||||
|
endDate: new Date('2026-01-01T11:00:00'),
|
||||||
|
})
|
||||||
|
const out = expandOccurrences(task, new Date('2026-06-22T00:00:00'), new Date('2026-06-29T00:00:00'))
|
||||||
|
expect(out).toHaveLength(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('projects weekly ghosts across a month, only the first is real', () => {
|
||||||
|
const task = makeTask({
|
||||||
|
startDate: new Date('2026-06-01T09:00:00'),
|
||||||
|
endDate: new Date('2026-06-01T10:00:00'),
|
||||||
|
repeatAfter: {type: 'weeks', amount: 1},
|
||||||
|
})
|
||||||
|
const out = expandOccurrences(task, new Date('2026-06-01T00:00:00'), new Date('2026-06-29T00:00:00'))
|
||||||
|
// Jun 1, 8, 15, 22 (29 is excluded by the open upper bound)
|
||||||
|
expect(out.map(o => o.start.getDate())).toEqual([1, 8, 15, 22])
|
||||||
|
expect(out[0].isGhost).toBe(false)
|
||||||
|
expect(out.slice(1).every(o => o.isGhost)).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('preserves duration on ghost occurrences', () => {
|
||||||
|
const task = makeTask({
|
||||||
|
startDate: new Date('2026-06-01T09:00:00'),
|
||||||
|
endDate: new Date('2026-06-01T10:30:00'),
|
||||||
|
repeatAfter: {type: 'days', amount: 1},
|
||||||
|
})
|
||||||
|
const out = expandOccurrences(task, new Date('2026-06-01T00:00:00'), new Date('2026-06-04T00:00:00'))
|
||||||
|
out.forEach(o => expect(o.end.getTime() - o.start.getTime()).toBe(90 * 60 * 1000))
|
||||||
|
})
|
||||||
|
|
||||||
|
it('honours monthly repeat mode regardless of repeatAfter', () => {
|
||||||
|
const task = makeTask({
|
||||||
|
startDate: new Date('2026-01-15T09:00:00'),
|
||||||
|
endDate: new Date('2026-01-15T10:00:00'),
|
||||||
|
repeatMode: TASK_REPEAT_MODES.REPEAT_MODE_MONTH,
|
||||||
|
repeatAfter: 0,
|
||||||
|
})
|
||||||
|
const out = expandOccurrences(task, new Date('2026-01-01T00:00:00'), new Date('2026-04-01T00:00:00'))
|
||||||
|
expect(out.map(o => o.start.getMonth())).toEqual([0, 1, 2])
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
@ -0,0 +1,90 @@
|
||||||
|
import dayjs, {type ManipulateType} from 'dayjs'
|
||||||
|
|
||||||
|
import type {ITask} from '@/modelTypes/ITask'
|
||||||
|
import type {IRepeatAfter} from '@/types/IRepeatAfter'
|
||||||
|
import {TASK_REPEAT_MODES} from '@/types/IRepeatMode'
|
||||||
|
import {parseRepeatAfter} from '@/models/task'
|
||||||
|
import type {PlannedOccurrence} from './types'
|
||||||
|
|
||||||
|
// Guard against pathological repeat intervals projecting forever.
|
||||||
|
const MAX_OCCURRENCES = 366
|
||||||
|
|
||||||
|
const TYPE_TO_DAYJS: Record<IRepeatAfter['type'], ManipulateType> = {
|
||||||
|
seconds: 'second',
|
||||||
|
minutes: 'minute',
|
||||||
|
hours: 'hour',
|
||||||
|
days: 'day',
|
||||||
|
weeks: 'week',
|
||||||
|
months: 'month',
|
||||||
|
years: 'year',
|
||||||
|
}
|
||||||
|
|
||||||
|
function getRepeatStep(task: ITask): {amount: number, unit: ManipulateType} | null {
|
||||||
|
// Monthly mode repeats on the same day each month regardless of repeatAfter.
|
||||||
|
if (task.repeatMode === TASK_REPEAT_MODES.REPEAT_MODE_MONTH) {
|
||||||
|
return {amount: 1, unit: 'month'}
|
||||||
|
}
|
||||||
|
|
||||||
|
const repeat: IRepeatAfter = typeof task.repeatAfter === 'number'
|
||||||
|
? parseRepeatAfter(task.repeatAfter)
|
||||||
|
: task.repeatAfter
|
||||||
|
|
||||||
|
if (!repeat || repeat.amount <= 0) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
return {amount: repeat.amount, unit: TYPE_TO_DAYJS[repeat.type]}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Projects a timed task's occurrences across [from, to].
|
||||||
|
*
|
||||||
|
* The stored task itself (at its current start/end) is the only real,
|
||||||
|
* editable instance; every projected future occurrence is a read-only ghost.
|
||||||
|
* Projection is keyed off the task's current start so a just-completed
|
||||||
|
* recurring task (whose start the backend has already advanced) does not draw
|
||||||
|
* both the finished slot and its next occurrence.
|
||||||
|
*/
|
||||||
|
export function expandOccurrences(task: ITask, from: Date, to: Date): PlannedOccurrence[] {
|
||||||
|
if (!task.startDate || !task.endDate) {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
|
||||||
|
const realStart = dayjs(task.startDate)
|
||||||
|
const realEnd = dayjs(task.endDate)
|
||||||
|
const durationMs = realEnd.diff(realStart)
|
||||||
|
const rangeStart = dayjs(from)
|
||||||
|
const rangeEnd = dayjs(to)
|
||||||
|
|
||||||
|
const occurrences: PlannedOccurrence[] = []
|
||||||
|
const pushIfVisible = (start: dayjs.Dayjs, isGhost: boolean, index: number) => {
|
||||||
|
const end = start.add(durationMs, 'millisecond')
|
||||||
|
if (end.isAfter(rangeStart) && start.isBefore(rangeEnd)) {
|
||||||
|
occurrences.push({
|
||||||
|
key: `${task.id}-${index}`,
|
||||||
|
task,
|
||||||
|
start: start.toDate(),
|
||||||
|
end: end.toDate(),
|
||||||
|
isGhost,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pushIfVisible(realStart, false, 0)
|
||||||
|
|
||||||
|
const step = getRepeatStep(task)
|
||||||
|
if (step === null) {
|
||||||
|
return occurrences
|
||||||
|
}
|
||||||
|
|
||||||
|
let cursor = realStart
|
||||||
|
for (let i = 1; i <= MAX_OCCURRENCES; i++) {
|
||||||
|
cursor = cursor.add(step.amount, step.unit)
|
||||||
|
if (cursor.isAfter(rangeEnd)) {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
pushIfVisible(cursor, true, i)
|
||||||
|
}
|
||||||
|
|
||||||
|
return occurrences
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,66 @@
|
||||||
|
import {describe, it, expect} from 'vitest'
|
||||||
|
import {packColumns} from './packColumns'
|
||||||
|
|
||||||
|
interface Interval {
|
||||||
|
id: string
|
||||||
|
start: number
|
||||||
|
end: number
|
||||||
|
}
|
||||||
|
|
||||||
|
function pack(items: Interval[]) {
|
||||||
|
return packColumns(items, i => i.start, i => i.end)
|
||||||
|
.reduce((acc, p) => {
|
||||||
|
acc[p.item.id] = {col: p.col, cols: p.cols}
|
||||||
|
return acc
|
||||||
|
}, {} as Record<string, {col: number, cols: number}>)
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('packColumns', () => {
|
||||||
|
it('gives a single non-overlapping item one full column', () => {
|
||||||
|
const out = pack([{id: 'a', start: 0, end: 60}])
|
||||||
|
expect(out.a).toEqual({col: 0, cols: 1})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('treats touching intervals as non-overlapping', () => {
|
||||||
|
const out = pack([
|
||||||
|
{id: 'a', start: 0, end: 60},
|
||||||
|
{id: 'b', start: 60, end: 120},
|
||||||
|
])
|
||||||
|
expect(out.a).toEqual({col: 0, cols: 1})
|
||||||
|
expect(out.b).toEqual({col: 0, cols: 1})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('splits two overlapping intervals into two columns', () => {
|
||||||
|
const out = pack([
|
||||||
|
{id: 'a', start: 0, end: 60},
|
||||||
|
{id: 'b', start: 30, end: 90},
|
||||||
|
])
|
||||||
|
expect(out.a).toEqual({col: 0, cols: 2})
|
||||||
|
expect(out.b).toEqual({col: 1, cols: 2})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('reuses a freed column within the same cluster', () => {
|
||||||
|
// a+b overlap (2 cols); c starts after a ends but still overlaps b,
|
||||||
|
// so the whole run is one cluster of width 2 and c reuses column 0.
|
||||||
|
const out = pack([
|
||||||
|
{id: 'a', start: 0, end: 30},
|
||||||
|
{id: 'b', start: 10, end: 90},
|
||||||
|
{id: 'c', start: 40, end: 80},
|
||||||
|
])
|
||||||
|
expect(out.b.cols).toBe(2)
|
||||||
|
expect(out.a.col).toBe(0)
|
||||||
|
expect(out.b.col).toBe(1)
|
||||||
|
expect(out.c.col).toBe(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('keeps separate clusters independent', () => {
|
||||||
|
const out = pack([
|
||||||
|
{id: 'a', start: 0, end: 60},
|
||||||
|
{id: 'b', start: 10, end: 70},
|
||||||
|
{id: 'c', start: 200, end: 260},
|
||||||
|
])
|
||||||
|
expect(out.a.cols).toBe(2)
|
||||||
|
expect(out.b.cols).toBe(2)
|
||||||
|
expect(out.c).toEqual({col: 0, cols: 1})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
@ -0,0 +1,63 @@
|
||||||
|
export interface PackedItem<T> {
|
||||||
|
item: T
|
||||||
|
col: number
|
||||||
|
cols: number
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lays out overlapping intervals into side-by-side columns (Google-Calendar
|
||||||
|
* style). Items are grouped into clusters of transitively overlapping
|
||||||
|
* intervals; within a cluster each item gets the lowest free column index and
|
||||||
|
* every item in that cluster shares the same total column count.
|
||||||
|
*
|
||||||
|
* `getStart`/`getEnd` return comparable numbers (e.g. minutes from midnight).
|
||||||
|
* Intervals that merely touch (`a.end === b.start`) do not count as overlapping.
|
||||||
|
*/
|
||||||
|
export function packColumns<T>(
|
||||||
|
items: T[],
|
||||||
|
getStart: (item: T) => number,
|
||||||
|
getEnd: (item: T) => number,
|
||||||
|
): PackedItem<T>[] {
|
||||||
|
const sorted = [...items].sort((a, b) => getStart(a) - getStart(b) || getEnd(a) - getEnd(b))
|
||||||
|
|
||||||
|
const result: PackedItem<T>[] = []
|
||||||
|
let cluster: PackedItem<T>[] = []
|
||||||
|
let clusterEnd = -Infinity
|
||||||
|
let columnEnds: number[] = []
|
||||||
|
|
||||||
|
const flush = () => {
|
||||||
|
const cols = columnEnds.length
|
||||||
|
cluster.forEach(packed => packed.cols = cols)
|
||||||
|
result.push(...cluster)
|
||||||
|
cluster = []
|
||||||
|
columnEnds = []
|
||||||
|
clusterEnd = -Infinity
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const item of sorted) {
|
||||||
|
const start = getStart(item)
|
||||||
|
const end = getEnd(item)
|
||||||
|
|
||||||
|
// A gap to everything placed so far closes the current cluster.
|
||||||
|
if (start >= clusterEnd && cluster.length > 0) {
|
||||||
|
flush()
|
||||||
|
}
|
||||||
|
|
||||||
|
let col = columnEnds.findIndex(colEnd => colEnd <= start)
|
||||||
|
if (col === -1) {
|
||||||
|
col = columnEnds.length
|
||||||
|
columnEnds.push(end)
|
||||||
|
} else {
|
||||||
|
columnEnds[col] = end
|
||||||
|
}
|
||||||
|
|
||||||
|
cluster.push({item, col, cols: 1})
|
||||||
|
clusterEnd = Math.max(clusterEnd, end)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (cluster.length > 0) {
|
||||||
|
flush()
|
||||||
|
}
|
||||||
|
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,12 @@
|
||||||
|
import type {ITask} from '@/modelTypes/ITask'
|
||||||
|
|
||||||
|
// A single concrete placement of a task on the calendar grid. Recurring tasks
|
||||||
|
// expand into one real occurrence (the stored task) plus dimmed, read-only
|
||||||
|
// ghost occurrences projected forward across the visible range.
|
||||||
|
export interface PlannedOccurrence {
|
||||||
|
key: string
|
||||||
|
task: ITask
|
||||||
|
start: Date
|
||||||
|
end: Date
|
||||||
|
isGhost: boolean
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,29 @@
|
||||||
|
import {useStorage} from '@vueuse/core'
|
||||||
|
|
||||||
|
export interface CalendarSettings {
|
||||||
|
// Working hours ("HH:MM") define the initial zoom/scroll window — the grid
|
||||||
|
// still renders the full 0–24h so off-hours stay reachable by scrolling.
|
||||||
|
dayStart: string
|
||||||
|
dayEnd: string
|
||||||
|
defaultDurationMinutes: number
|
||||||
|
slotMinutes: number
|
||||||
|
showDone: boolean
|
||||||
|
// true: week aligned to the user's first weekday; false: 7 days from today.
|
||||||
|
fullWeek: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
const DEFAULTS: CalendarSettings = {
|
||||||
|
dayStart: '08:00',
|
||||||
|
dayEnd: '18:00',
|
||||||
|
defaultDurationMinutes: 60,
|
||||||
|
slotMinutes: 30,
|
||||||
|
showDone: false,
|
||||||
|
fullWeek: true,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Module-level so every caller shares the same reactive ref within the tab.
|
||||||
|
const settings = useStorage<CalendarSettings>('planner-settings', {...DEFAULTS}, localStorage, {mergeDefaults: true})
|
||||||
|
|
||||||
|
export function useCalendarSettings() {
|
||||||
|
return {settings}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,153 @@
|
||||||
|
import {computed, ref, shallowReactive, watch, type Ref} from 'vue'
|
||||||
|
import {klona} from 'klona/lite'
|
||||||
|
|
||||||
|
import TaskService from '@/services/task'
|
||||||
|
import type {TaskFilterParams} from '@/services/taskCollection'
|
||||||
|
import type {ITask, ITaskPartialWithId} from '@/modelTypes/ITask'
|
||||||
|
|
||||||
|
import {useAuthStore} from '@/stores/auth'
|
||||||
|
import {useTaskStore} from '@/stores/tasks'
|
||||||
|
import {isoToKebabDate} from '@/helpers/time/isoToKebabDate'
|
||||||
|
import {error, success} from '@/message'
|
||||||
|
import {i18n} from '@/i18n'
|
||||||
|
|
||||||
|
export interface PlannerRange {
|
||||||
|
from: Date
|
||||||
|
to: Date
|
||||||
|
}
|
||||||
|
|
||||||
|
export function usePlannerTasks(range: Ref<PlannerRange>, sidebarFilter: Ref<TaskFilterParams>) {
|
||||||
|
const authStore = useAuthStore()
|
||||||
|
const taskStore = useTaskStore()
|
||||||
|
|
||||||
|
const gridService = shallowReactive(new TaskService())
|
||||||
|
const sidebarService = shallowReactive(new TaskService())
|
||||||
|
|
||||||
|
const gridTasks = ref<Map<ITask['id'], ITask>>(new Map())
|
||||||
|
const sidebarTasks = ref<ITask[]>([])
|
||||||
|
|
||||||
|
const isLoading = computed(() => gridService.loading || sidebarService.loading)
|
||||||
|
|
||||||
|
async function fetchAll(service: TaskService, params: TaskFilterParams, page = 1): Promise<ITask[]> {
|
||||||
|
const tasks = await service.getAll({} as ITask, params, page) as ITask[]
|
||||||
|
if (page < service.totalPages) {
|
||||||
|
return tasks.concat(await fetchAll(service, params, page + 1))
|
||||||
|
}
|
||||||
|
return tasks
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadGrid() {
|
||||||
|
const from = isoToKebabDate(range.value.from.toISOString())
|
||||||
|
const to = isoToKebabDate(range.value.to.toISOString())
|
||||||
|
|
||||||
|
const params: TaskFilterParams = {
|
||||||
|
sort_by: ['start_date', 'id'],
|
||||||
|
order_by: ['asc', 'desc'],
|
||||||
|
filter: '(' +
|
||||||
|
`(start_date >= "${from}" && start_date <= "${to}") || ` +
|
||||||
|
`(end_date >= "${from}" && end_date <= "${to}") || ` +
|
||||||
|
`(due_date >= "${from}" && due_date <= "${to}") || ` +
|
||||||
|
`(start_date <= "${from}" && end_date >= "${to}")` +
|
||||||
|
')',
|
||||||
|
filter_include_nulls: false,
|
||||||
|
filter_timezone: authStore.settings.timezone,
|
||||||
|
s: '',
|
||||||
|
expand: 'subtasks',
|
||||||
|
}
|
||||||
|
|
||||||
|
const loaded = await fetchAll(gridService, params)
|
||||||
|
const map = new Map<ITask['id'], ITask>()
|
||||||
|
loaded.forEach(t => map.set(t.id, t))
|
||||||
|
gridTasks.value = map
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadSidebar() {
|
||||||
|
// Combine the user's filter (already API-form from the Filters component)
|
||||||
|
// with done=false. The v1 filter can't express "date is null", so we keep
|
||||||
|
// only tasks lacking a start/end client-side.
|
||||||
|
const userFilter = sidebarFilter.value.filter?.trim()
|
||||||
|
const filter = userFilter ? `(${userFilter}) && done = false` : 'done = false'
|
||||||
|
|
||||||
|
const params: TaskFilterParams = {
|
||||||
|
sort_by: ['due_date', 'id'],
|
||||||
|
order_by: ['asc', 'desc'],
|
||||||
|
filter,
|
||||||
|
filter_include_nulls: true,
|
||||||
|
filter_timezone: authStore.settings.timezone,
|
||||||
|
s: sidebarFilter.value.s ?? '',
|
||||||
|
expand: 'subtasks',
|
||||||
|
}
|
||||||
|
|
||||||
|
// Truly unscheduled = no start, end or due date. Due-only tasks already
|
||||||
|
// render in the all-day row, so keep them out of the sidebar.
|
||||||
|
const loaded = await fetchAll(sidebarService, params)
|
||||||
|
sidebarTasks.value = loaded.filter(task => !task.startDate && !task.endDate && !task.dueDate)
|
||||||
|
}
|
||||||
|
|
||||||
|
function load() {
|
||||||
|
return Promise.all([loadGrid(), loadSidebar()])
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(range, () => loadGrid(), {immediate: true, deep: true})
|
||||||
|
watch(sidebarFilter, () => loadSidebar(), {immediate: true, deep: true})
|
||||||
|
|
||||||
|
// Keep both lists in sync with edits made elsewhere (e.g. the task detail
|
||||||
|
// modal): re-file the task into the grid or sidebar, or drop it if it's now
|
||||||
|
// done. Only react to tasks the planner already tracks.
|
||||||
|
watch(
|
||||||
|
() => taskStore.lastUpdatedTask,
|
||||||
|
updatedTask => {
|
||||||
|
if (!updatedTask) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const known = gridTasks.value.has(updatedTask.id)
|
||||||
|
|| sidebarTasks.value.some(t => t.id === updatedTask.id)
|
||||||
|
if (known) {
|
||||||
|
placeTask(updatedTask)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
// Put a task into whichever list it now belongs to: the grid if it has any
|
||||||
|
// date (timed, all-day or due), otherwise the unscheduled sidebar.
|
||||||
|
function placeTask(task: ITask) {
|
||||||
|
gridTasks.value.delete(task.id)
|
||||||
|
const sidebarIndex = sidebarTasks.value.findIndex(t => t.id === task.id)
|
||||||
|
if (sidebarIndex >= 0) {
|
||||||
|
sidebarTasks.value.splice(sidebarIndex, 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (task.startDate || task.endDate || task.dueDate) {
|
||||||
|
gridTasks.value.set(task.id, task)
|
||||||
|
} else if (!task.done) {
|
||||||
|
sidebarTasks.value.unshift(task)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function updateTask(partial: ITaskPartialWithId) {
|
||||||
|
const base = gridTasks.value.get(partial.id) ?? sidebarTasks.value.find(t => t.id === partial.id)
|
||||||
|
if (!base) return
|
||||||
|
|
||||||
|
const oldTask = klona(base)
|
||||||
|
const newTask: ITask = {...oldTask, ...partial}
|
||||||
|
|
||||||
|
placeTask(newTask)
|
||||||
|
|
||||||
|
try {
|
||||||
|
const updated = await taskStore.update(newTask)
|
||||||
|
placeTask(updated)
|
||||||
|
success(i18n.global.t('planner.saved'))
|
||||||
|
} catch (_) {
|
||||||
|
error(i18n.global.t('planner.saveError'))
|
||||||
|
placeTask(oldTask)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
gridTasks,
|
||||||
|
sidebarTasks,
|
||||||
|
isLoading,
|
||||||
|
load,
|
||||||
|
updateTask,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,78 @@
|
||||||
|
import {test, expect} from '../../support/fixtures'
|
||||||
|
import dayjs from 'dayjs'
|
||||||
|
|
||||||
|
import {TaskFactory} from '../../factories/task'
|
||||||
|
import {ProjectFactory} from '../../factories/project'
|
||||||
|
|
||||||
|
interface Project {
|
||||||
|
id: number
|
||||||
|
title: string
|
||||||
|
}
|
||||||
|
|
||||||
|
// A time inside today that is comfortably away from midnight so the block is
|
||||||
|
// unambiguously a timed block (not all-day).
|
||||||
|
function todayAt(hour: number): string {
|
||||||
|
return dayjs().hour(hour).minute(0).second(0).millisecond(0).toISOString()
|
||||||
|
}
|
||||||
|
|
||||||
|
test.describe('Planner', () => {
|
||||||
|
let projects: Project[]
|
||||||
|
|
||||||
|
test.beforeEach(async () => {
|
||||||
|
projects = await ProjectFactory.create(1) as Project[]
|
||||||
|
})
|
||||||
|
|
||||||
|
test('renders the grid and an unscheduled task in the sidebar', async ({authenticatedPage: page}) => {
|
||||||
|
await TaskFactory.create(1, {
|
||||||
|
id: 901,
|
||||||
|
title: 'Unscheduled planner task',
|
||||||
|
project_id: projects[0].id,
|
||||||
|
start_date: null,
|
||||||
|
end_date: null,
|
||||||
|
due_date: null,
|
||||||
|
}, false)
|
||||||
|
|
||||||
|
await page.goto('/planner')
|
||||||
|
|
||||||
|
await expect(page.locator('.calendar-grid')).toBeVisible()
|
||||||
|
await expect(page.locator('.planner-sidebar')).toContainText('Unscheduled planner task')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('shows a scheduled task as a timed block', async ({authenticatedPage: page}) => {
|
||||||
|
await TaskFactory.create(1, {
|
||||||
|
id: 902,
|
||||||
|
title: 'Scheduled block task',
|
||||||
|
project_id: projects[0].id,
|
||||||
|
start_date: todayAt(10),
|
||||||
|
end_date: todayAt(11),
|
||||||
|
}, false)
|
||||||
|
|
||||||
|
await page.goto('/planner')
|
||||||
|
|
||||||
|
await expect(page.locator('.calendar-block')).toContainText('Scheduled block task')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('shows a due-only task in the all-day row', async ({authenticatedPage: page}) => {
|
||||||
|
await TaskFactory.create(1, {
|
||||||
|
id: 903,
|
||||||
|
title: 'Due only task',
|
||||||
|
project_id: projects[0].id,
|
||||||
|
start_date: null,
|
||||||
|
end_date: null,
|
||||||
|
due_date: todayAt(0),
|
||||||
|
}, false)
|
||||||
|
|
||||||
|
await page.goto('/planner')
|
||||||
|
|
||||||
|
await expect(page.locator('.all-day-chip')).toContainText('Due only task')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('toggles between week and day views', async ({authenticatedPage: page}) => {
|
||||||
|
await page.goto('/planner')
|
||||||
|
|
||||||
|
// Week view shows 7 day headers, day view shows 1.
|
||||||
|
await expect(page.locator('.day-head')).toHaveCount(7)
|
||||||
|
await page.getByRole('button', {name: 'Day', exact: true}).click()
|
||||||
|
await expect(page.locator('.day-head')).toHaveCount(1)
|
||||||
|
})
|
||||||
|
})
|
||||||
Loading…
Reference in New Issue