This commit is contained in:
Marlon May 2026-06-30 08:13:28 +02:00 committed by GitHub
commit 1fd9a27319
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
23 changed files with 2918 additions and 1 deletions

View File

@ -38,6 +38,17 @@
{{ $t('navigation.upcoming') }}
</RouterLink>
</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>
<RouterLink
v-shortcut="'KeyG KeyP'"

View File

@ -55,6 +55,8 @@ import {
faPaste,
faPen,
faPencilAlt,
faMinus,
faTableColumns,
faPercent,
faPlay,
faPlus,
@ -131,6 +133,7 @@ library.add(faBell)
library.add(faBellSlash)
library.add(faCalendar)
library.add(faCalendarAlt)
library.add(faTableColumns)
library.add(faCheck)
library.add(faCheckDouble)
library.add(faChessKnight)
@ -166,6 +169,7 @@ library.add(faLock)
library.add(faPaperclip)
library.add(faPaste)
library.add(faPen)
library.add(faMinus)
library.add(faPencilAlt)
library.add(faPercent)
library.add(faPlay)

View File

@ -743,12 +743,43 @@
"navigation": {
"overview": "Overview",
"upcoming": "Upcoming",
"planner": "Planner",
"settings": "Settings",
"imprint": "Imprint",
"privacy": "Privacy Policy",
"closeSidebar": "Close sidebar",
"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",
"loadError": "Something went wrong loading your tasks",
"createTitle": "New task",
"createAllDay": "All day · {date}",
"sortDefault": "Default order",
"sortRandom": "Random",
"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: rolling days)",
"daysToShow": "Days to show",
"showDone": "Show done tasks"
}
},
"misc": {
"loading": "Loading…",
"save": "Save",

View File

@ -225,6 +225,14 @@ const router = createRouter({
showOverdue: route.query.showOverdue === 'true',
}),
},
{
path: '/planner',
name: 'planner.index',
component: () => import('@/views/planner/PlannerView.vue'),
meta: {
title: 'planner.title',
},
},
{
// 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

View File

@ -154,6 +154,7 @@ export const useTaskStore = defineStore('task', () => {
const isLoading = ref(false)
const draggedTask = ref<ITask | null>(null)
const lastUpdatedTask = ref<ITask | null>(null)
const lastDeletedTask = ref<ITask | null>(null)
const hasTasks = computed(() => Object.keys(tasks.value).length > 0)
@ -214,6 +215,7 @@ export const useTaskStore = defineStore('task', () => {
const taskService = new TaskService()
const response = await taskService.delete(task)
kanbanStore.removeTaskInBucket(task)
lastDeletedTask.value = task
return response
}
@ -594,6 +596,7 @@ export const useTaskStore = defineStore('task', () => {
isLoading,
draggedTask,
lastUpdatedTask,
lastDeletedTask,
hasTasks,

View File

@ -0,0 +1,53 @@
<template>
<Modal
:enabled="true"
@close="$emit('close')"
>
<Card
:title="$t('planner.createTitle')"
:show-close="true"
@close="$emit('close')"
>
<p class="create-context">
{{ context }}
</p>
<AddTask
ref="addTaskRef"
@taskAdded="task => $emit('created', task)"
/>
</Card>
</Modal>
</template>
<script setup lang="ts">
import {onMounted, ref} from 'vue'
import type {ITask} from '@/modelTypes/ITask'
import Modal from '@/components/misc/Modal.vue'
import Card from '@/components/misc/Card.vue'
import AddTask from '@/components/tasks/AddTask.vue'
defineProps<{
// Human-readable date/time the new task will be scheduled at.
context: string
}>()
defineEmits<{
created: [task: ITask]
close: []
}>()
// AddTask autofocuses on mount, but the modal's showModal() runs after that and
// pulls focus to the dialog. A rAF lands after that synchronous focus move, so
// the user can start typing immediately.
const addTaskRef = ref<InstanceType<typeof AddTask> | null>(null)
onMounted(() => requestAnimationFrame(() => addTaskRef.value?.focusTaskInput()))
</script>
<style lang="scss" scoped>
.create-context {
color: var(--grey-500);
font-size: .9rem;
margin-block-end: .75rem;
}
</style>

View File

@ -0,0 +1,107 @@
<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="text"
placeholder="HH:MM"
pattern="[0-2][0-9]:[0-5][0-9]"
inputmode="numeric"
/>
</label>
<label class="setting">
<span>{{ $t('planner.settings.dayEnd') }}</span>
<FormInput
v-model="settings.dayEnd"
type="text"
placeholder="HH:MM"
pattern="[0-2][0-9]:[0-5][0-9]"
inputmode="numeric"
/>
</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>
<label
v-if="!settings.fullWeek"
class="setting"
>
<span>{{ $t('planner.settings.daysToShow') }}</span>
<FormInput
v-model.number="settings.daysToShow"
type="number"
min="1"
max="31"
step="1"
/>
</label>
<FancyCheckbox v-model="settings.showDone">
{{ $t('planner.settings.showDone') }}
</FancyCheckbox>
</div>
</Dropdown>
</template>
<script setup lang="ts">
import {watch} from 'vue'
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()
// Plain 24h "HH:MM" text fields (no native time picker, so no OS-locale AM/PM).
// Keep the window ordered; "HH:MM" strings compare lexically.
watch(() => settings.value.dayStart, start => {
if (start > settings.value.dayEnd) {
settings.value.dayStart = settings.value.dayEnd
}
})
watch(() => settings.value.dayEnd, end => {
if (end < settings.value.dayStart) {
settings.value.dayEnd = settings.value.dayStart
}
})
</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>

View File

@ -0,0 +1,256 @@
<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>
<div class="sidebar-controls">
<FilterPopup v-model="filter" />
<div class="select is-small sort-select">
<select
v-model="sort"
:aria-label="$t('misc.sortBy')"
>
<option
v-for="o in sortOptions"
:key="o.value"
:value="o.value"
>
{{ o.label }}
</option>
</select>
</div>
</div>
<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 class="task-meta">
<span
v-if="projectName(task)"
class="task-project"
>
{{ projectName(task) }}
</span>
<PriorityLabel
:priority="task.priority"
:done="task.done"
/>
<span
v-if="task.percentDone > 0"
class="task-percent"
>{{ Math.round(task.percentDone * 100) }}%</span>
</span>
</li>
</ul>
</aside>
</template>
<script setup lang="ts">
import {computed, ref} from 'vue'
import {useI18n} from 'vue-i18n'
import type {ITask} from '@/modelTypes/ITask'
import type {TaskFilterParams} from '@/services/taskCollection'
import type {PlannerSidebarSort} from './helpers/usePlannerTasks'
import FilterPopup from '@/components/project/partials/FilterPopup.vue'
import PriorityLabel from '@/components/tasks/partials/PriorityLabel.vue'
import {useProjectStore} from '@/stores/projects'
import {plannerTaskColor} from './helpers/taskColor'
defineProps<{
tasks: ITask[]
}>()
const emit = defineEmits<{
openTask: [taskId: number]
unschedule: [taskId: number]
}>()
const filter = defineModel<TaskFilterParams>('filter', {required: true})
const sort = defineModel<PlannerSidebarSort>('sort', {required: true})
const {t} = useI18n({useScope: 'global'})
// Curated for unscheduled tasks: no date sorts (those tasks live in the grid),
// no manual/position (errors cross-project), plus a client-side random shuffle.
const sortOptions = computed<{value: PlannerSidebarSort, label: string}[]>(() => [
{value: 'none', label: t('planner.sortDefault')},
{value: 'priority:desc', label: t('sorting.options.priorityDesc')},
{value: 'priority:asc', label: t('sorting.options.priorityAsc')},
{value: 'title:asc', label: t('sorting.options.titleAsc')},
{value: 'title:desc', label: t('sorting.options.titleDesc')},
{value: 'created:desc', label: t('sorting.options.createdDesc')},
{value: 'created:asc', label: t('sorting.options.createdAsc')},
{value: 'percent_done:desc', label: t('sorting.options.percentDoneDesc')},
{value: 'percent_done:asc', label: t('sorting.options.percentDoneAsc')},
{value: 'random', label: t('planner.sortRandom')},
])
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 {
return plannerTaskColor(task.hexColor, projectStore.projects[task.projectId]?.hexColor)
}
function projectName(task: ITask): string {
return projectStore.projects[task.projectId]?.title ?? ''
}
function onDragStart(event: DragEvent, task: ITask) {
event.dataTransfer?.setData('text/plain', String(task.id))
if (event.dataTransfer) {
event.dataTransfer.effectAllowed = 'move'
}
}
</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-controls {
display: flex;
align-items: center;
gap: .35rem;
margin-block-end: .75rem;
}
.sort-select {
flex: 1 1 auto;
min-inline-size: 0;
select {
inline-size: 100%;
}
}
.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: .95rem;
&:active {
cursor: grabbing;
}
&:hover {
background: var(--grey-100);
}
}
.task-title {
display: block;
}
.task-meta {
display: flex;
align-items: center;
gap: .4rem;
margin-block-start: .15rem;
// Tame PriorityLabel's oversized Bulma .icon box and centre it inline.
:deep(.priority-label) {
display: inline-flex;
align-items: center;
font-size: .8rem;
line-height: 1.4;
.icon {
block-size: auto;
margin: 0;
padding: 0 .15rem 0 0;
}
svg {
block-size: .85em;
inline-size: auto;
vertical-align: middle;
}
}
}
.task-project {
font-size: .8rem;
color: var(--grey-500);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.task-percent {
flex: 0 0 auto;
font-size: .8rem;
color: var(--grey-500);
font-variant-numeric: tabular-nums;
}
</style>

View File

@ -0,0 +1,390 @@
<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 />
<Loading
v-if="isLoading"
class="planner-loading is-loading-small"
/>
</div>
</header>
<Message
v-if="loadError"
variant="danger"
class="planner-error"
>
{{ $t('planner.loadError') }}
</Message>
<div class="planner-body">
<PlannerSidebar
v-model:filter="sidebarFilter"
v-model:sort="sidebarSort"
:tasks="sidebarTasks"
@openTask="openTask"
@unschedule="taskId => updateTask({id: taskId, startDate: null, endDate: null})"
/>
<CalendarGrid
:days="days"
:tasks="visibleGridTasks"
:slot-minutes="slotMinutes"
:day-start-hour="dayStartHour"
:day-end-hour="dayEndHour"
:px-per-hour="pxPerHour"
:auto-fit="!userZoomed"
@openTask="openTask"
@updateBlock="onUpdateBlock"
@dropTask="onDropTask"
@dropAllDay="onDropAllDay"
@navigate="slideDays"
@createTask="onCreateTask"
@createAllDay="onCreateAllDay"
@update:pxPerHour="value => pxPerHour = value"
/>
</div>
<PlannerCreateTaskModal
v-if="createCtx"
:context="createCtx.label"
@created="onCreated"
@close="createCtx = null"
/>
</div>
</template>
<script setup lang="ts">
import {computed, nextTick, onMounted, ref, watchEffect} from 'vue'
import {useRouter} from 'vue-router'
import {useI18n} from 'vue-i18n'
import {useStorage} from '@vueuse/core'
import dayjs from 'dayjs'
import type {ITask} from '@/modelTypes/ITask'
import BaseButton from '@/components/base/BaseButton.vue'
import Loading from '@/components/misc/Loading.vue'
import Message from '@/components/misc/Message.vue'
import PlannerSidebar from './PlannerSidebar.vue'
import PlannerSettings from './PlannerSettings.vue'
import PlannerCreateTaskModal from './PlannerCreateTaskModal.vue'
import CalendarGrid from './grid/CalendarGrid.vue'
import {setTitle} from '@/helpers/setTitle'
import {useAuthStore} from '@/stores/auth'
import {useBaseStore} from '@/stores/base'
import {useTimeFormat} from '@/composables/useTimeFormat'
import {TIME_FORMAT} from '@/constants/timeFormat'
import type {TaskFilterParams} from '@/services/taskCollection'
import {useCalendarSettings} from './helpers/useCalendarSettings'
import {usePlannerTasks, type PlannerRange, type PlannerSidebarSort, PLANNER_SIDEBAR_SORTS, DEFAULT_PLANNER_SIDEBAR_SORT} from './helpers/usePlannerTasks'
const router = useRouter()
const {t} = useI18n({useScope: 'global'})
const {settings} = useCalendarSettings()
const authStore = useAuthStore()
const baseStore = useBaseStore()
const viewMode = useStorage<'week' | 'day'>('planner-view-mode', 'week')
const anchor = ref(new Date())
// filter_include_nulls must be defined: Filters.vue binds it to a Boolean
// FancyCheckbox, which warns on undefined.
const sidebarFilter = ref<TaskFilterParams>({filter: '', s: '', filter_include_nulls: false} as TaskFilterParams)
const sidebarSort = useStorage<PlannerSidebarSort>('planner-sidebar-sort', DEFAULT_PLANNER_SIDEBAR_SORT)
// An earlier build stored this as an object; reset any value that isn't a known option.
if (!PLANNER_SIDEBAR_SORTS.includes(sidebarSort.value)) {
sidebarSort.value = DEFAULT_PLANNER_SIDEBAR_SORT
}
const pxPerHour = useStorage('planner-px-per-hour', 48)
const userZoomed = useStorage('planner-user-zoomed', 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()]
}
if (settings.value.fullWeek) {
const start = startOfWeek(anchor.value)
return Array.from({length: 7}, (_, i) => start.add(i, 'day').toDate())
}
const count = Math.min(Math.max(settings.value.daysToShow || 7, 1), 31)
const start = dayjs(anchor.value).startOf('day')
return Array.from({length: count}, (_, 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, isLoading, loadError, updateTask, scheduleTask} = usePlannerTasks(range, sidebarFilter, sidebarSort)
const visibleGridTasks = computed(() =>
[...gridTasks.value.values()].filter(task => settings.value.showDone || !task.done),
)
// Guard the duration/slot inputs: a stray 0 or blank would yield NaN positions
// and invalid dates downstream.
const slotMinutes = computed(() => Math.max(Math.round(settings.value.slotMinutes) || 0, 5))
const defaultDurationMinutes = computed(() => Math.max(Math.round(settings.value.defaultDurationMinutes) || 0, 5))
// Page by the visible window (day=1, full week=7, rolling=daysToShow).
function goPrev() {
anchor.value = dayjs(anchor.value).subtract(days.value.length, 'day').toDate()
}
function goNext() {
anchor.value = dayjs(anchor.value).add(days.value.length, 'day').toDate()
}
function goToday() {
anchor.value = new Date()
}
// Horizontal swipe/scroll slides the window. In a date-aligned full week a
// one-day shift is invisible (the week snaps back), so page by a whole week
// there; rolling and day views slide a day at a time.
function slideDays(delta: number) {
const unit = viewMode.value === 'week' && settings.value.fullWeek ? 'week' : 'day'
anchor.value = dayjs(anchor.value).add(delta, unit).toDate()
}
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(defaultDurationMinutes.value, '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})
}
const {store: timeFormat} = useTimeFormat()
function formatTime(date: dayjs.Dayjs): string {
return date.format(timeFormat.value === TIME_FORMAT.HOURS_24 ? 'HH:mm' : 'h:mm A')
}
// The pending create gesture: target dates plus a label shown in the modal.
// null while no create is in flight (and drives whether the modal is mounted).
const createCtx = ref<{startDate: Date, endDate: Date, label: string} | null>(null)
function onCreateTask({day, startMinutes, endMinutes}: {day: Date, startMinutes: number, endMinutes: number | null}) {
const base = dayjs(day).startOf('day')
const start = base.add(startMinutes, 'minute')
const end = endMinutes !== null
? base.add(endMinutes, 'minute')
: start.add(defaultDurationMinutes.value, 'minute')
createCtx.value = {
startDate: start.toDate(),
endDate: end.toDate(),
label: `${start.format('ll')} · ${formatTime(start)} ${formatTime(end)}`,
}
}
function onCreateAllDay({day}: {day: Date}) {
const midnight = dayjs(day).startOf('day').toDate()
createCtx.value = {
startDate: midnight,
endDate: midnight,
label: t('planner.createAllDay', {date: dayjs(day).format('ll')}),
}
}
// AddTask emits one `taskAdded` per line synchronously, so schedule each into
// the same painted slot and close once after the batch (nulling createCtx here
// would drop every task after the first).
function onCreated(task: ITask) {
const ctx = createCtx.value
if (!ctx) {
return
}
scheduleTask(task, {startDate: ctx.startDate, endDate: ctx.endDate})
nextTick(() => createCtx.value = null)
}
function openTask(taskId: number) {
router.push({
name: 'task.detail',
params: {id: taskId},
state: {backdropView: router.currentRoute.value.fullPath},
})
}
// Standalone page: drop any stale project so the app header shows the planner
// title instead of the last visited project.
onMounted(() => baseStore.handleSetCurrentProject({project: null}))
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;
}
// A small inline refresh indicator in the toolbar; override the component's
// large default min sizes (meant for full-page use).
.planner-loading {
min-block-size: 0 !important;
min-inline-size: 0 !important;
inline-size: 1.75rem;
block-size: 1.75rem;
}
.planner-error {
margin-block-end: .75rem;
}
.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>

View File

@ -0,0 +1,407 @@
<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"
:title="tooltip"
@pointerdown="onMovePointerDown"
>
<span class="block-time">{{ timeLabel }}</span>
<span class="block-title">{{ occurrence.task.title }}</span>
<span class="block-meta">
<span
v-if="projectName"
class="block-project"
>{{ projectName }}</span>
<PriorityLabel
class="block-priority"
:priority="occurrence.task.priority"
:done="occurrence.task.done"
/>
<span
v-if="occurrence.task.percentDone > 0"
class="block-percent"
>{{ Math.round(occurrence.task.percentDone * 100) }}%</span>
</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, onBeforeUnmount, ref} from 'vue'
import dayjs from 'dayjs'
import {useProjectStore} from '@/stores/projects'
import {useTimeFormat} from '@/composables/useTimeFormat'
import {TIME_FORMAT} from '@/constants/timeFormat'
import {getTextColor} from '@/helpers/color/getTextColor'
import {isEditorContentEmpty} from '@/helpers/editorContentEmpty'
import PriorityLabel from '@/components/tasks/partials/PriorityLabel.vue'
import type {PlannedOccurrence} from '../helpers/types'
import {plannerTaskColor} from '../helpers/taskColor'
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(() => plannerTaskColor(
props.occurrence.task.hexColor,
projectStore.projects[props.occurrence.task.projectId]?.hexColor,
))
const projectName = computed(() => projectStore.projects[props.occurrence.task.projectId]?.title ?? '')
const textColor = computed(() => getTextColor(color.value))
// Hover tooltip: title plus a plain-text excerpt of the (rich-text) description,
// since blocks are too small to show the description inline.
const tooltip = computed(() => {
const task = props.occurrence.task
if (isEditorContentEmpty(task.description)) {
return task.title
}
const text = new DOMParser().parseFromString(task.description, 'text/html').body.textContent?.trim() ?? ''
if (!text) {
return task.title
}
const excerpt = text.length > 280 ? `${text.slice(0, 280)}` : text
return `${task.title}\n\n${excerpt}`
})
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,
'--block-text': textColor.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,
'--block-text': textColor.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
}
// Track the listeners for the active move/resize gesture so an unmount mid-drag
// (e.g. a data reload re-keys the columns) can't leave them attached to document.
let activeMove: ((e: PointerEvent) => void) | null = null
let activeUp: ((e: PointerEvent) => void) | null = null
function detachInteraction() {
if (activeMove) {
document.removeEventListener('pointermove', activeMove)
}
if (activeUp) {
document.removeEventListener('pointerup', activeUp)
}
activeMove = null
activeUp = null
}
onBeforeUnmount(detachInteraction)
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) => {
detachInteraction()
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
}
activeMove = onMove
activeUp = onUp
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 = () => {
detachInteraction()
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
}
activeMove = onMove
activeUp = onUp
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(--block-text);
cursor: grab;
user-select: none;
font-size: .85rem;
line-height: 1.15;
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;
}
.block-meta {
display: flex;
align-items: center;
gap: .35rem;
font-size: .75rem;
line-height: 1.4;
min-inline-size: 0;
}
.block-project {
opacity: .8;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
// block-priority is the PriorityLabel root itself (the class merges onto it), so
// style it directly a descendant `.priority-label` selector would match nothing.
.block-priority {
flex: 0 0 auto;
display: inline-flex;
align-items: center;
font-size: .72rem;
line-height: 1;
// Tame Bulma's ~1.5rem .icon box and shrink the glyph to the block text size.
:deep(.icon) {
block-size: auto;
inline-size: auto;
margin: 0;
padding: 0 .12rem 0 0;
}
:deep(svg) {
block-size: .8em;
inline-size: auto;
display: block;
}
}
.block-percent {
flex: 0 0 auto;
opacity: .8;
font-variant-numeric: tabular-nums;
}
.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: var(--block-text);
opacity: .8;
}
</style>

View File

@ -0,0 +1,257 @@
<template>
<div
ref="columnEl"
class="day-column"
:class="{'is-drop-target': isDropTarget}"
:data-day="dayKey"
@dragover.prevent="isDropTarget = true"
@dragleave="isDropTarget = false"
@drop="onDrop"
@dblclick="onDblClick"
@pointerdown="onCreatePointerDown"
>
<div
v-for="hour in 24"
:key="hour"
class="hour-slot"
:style="{height: `${pxPerMinute * 60}px`}"
/>
<div
v-if="selStart !== null && selEnd !== null"
class="paint-selection"
:style="{
top: `${selStart * pxPerMinute}px`,
height: `${Math.max(selEnd - selStart, slotMinutes) * pxPerMinute}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}]
createTask: [payload: {startMinutes: number, endMinutes: number | null}]
}>()
const columnEl = ref<HTMLElement | null>(null)
const isDropTarget = ref(false)
const now = ref(new Date())
const selStart = ref<number | null>(null)
const selEnd = ref<number | null>(null)
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)
}
})
// Listeners for an in-flight create gesture, torn down on unmount so a mid-drag
// re-render can't leak them onto document.
let longPressTimer: ReturnType<typeof setTimeout> | undefined
let activeMove: ((e: PointerEvent) => void) | null = null
let activeEnd: ((e: PointerEvent) => void) | null = null
function detachCreate() {
clearTimeout(longPressTimer)
if (activeMove) {
document.removeEventListener('pointermove', activeMove)
}
if (activeEnd) {
document.removeEventListener('pointerup', activeEnd)
}
activeMove = null
activeEnd = null
}
onBeforeUnmount(() => {
clearInterval(timer)
detachCreate()
})
function onDrop(event: DragEvent) {
isDropTarget.value = false
const taskId = Number(event.dataTransfer?.getData('text/plain'))
if (!taskId || !columnEl.value) {
return
}
emit('dropTask', {taskId, minutes: minutesAt(event.clientY)})
}
// Pixel position within the column minute-of-day, snapped to the slot grid.
function minutesAt(clientY: number): number {
if (!columnEl.value) {
return 0
}
const raw = (clientY - columnEl.value.getBoundingClientRect().top) / props.pxPerMinute
const snapped = Math.round(raw / props.slotMinutes) * props.slotMinutes
return Math.min(Math.max(snapped, 0), 24 * 60 - props.slotMinutes)
}
function onEmptyArea(target: EventTarget | null): boolean {
return !(target as HTMLElement)?.closest?.('.calendar-block')
}
// Desktop: double-click an empty slot to create with the default duration.
function onDblClick(event: MouseEvent) {
if (!onEmptyArea(event.target)) {
return
}
emit('createTask', {startMinutes: minutesAt(event.clientY), endMinutes: null})
}
function onCreatePointerDown(event: PointerEvent) {
if (!onEmptyArea(event.target) || (event.pointerType === 'mouse' && event.button !== 0)) {
return
}
const startY = event.clientY
const startMinutes = minutesAt(startY)
if (event.pointerType === 'mouse') {
// Click-drag paints a range; a plain click does nothing (dblclick handles it).
let painting = false
const onMove = (e: PointerEvent) => {
if (!painting && Math.abs(e.clientY - startY) > 4) {
painting = true
}
if (painting) {
const m = minutesAt(e.clientY)
selStart.value = Math.min(startMinutes, m)
selEnd.value = Math.max(startMinutes, m)
}
}
const onUp = () => {
detachCreate()
if (painting && selStart.value !== null && selEnd.value !== null) {
const end = Math.max(selEnd.value, selStart.value + props.slotMinutes)
emit('createTask', {startMinutes: selStart.value, endMinutes: end})
}
selStart.value = null
selEnd.value = null
}
activeMove = onMove
activeEnd = onUp
document.addEventListener('pointermove', onMove)
document.addEventListener('pointerup', onUp)
return
}
// Touch/pen: long-press creates at the slot. Bail out if the finger moves
// first, so the gesture doesn't hijack vertical scrolling of the grid.
let moved = false
const onMove = (e: PointerEvent) => {
if (Math.abs(e.clientY - startY) > 10) {
moved = true
detachCreate()
}
}
activeMove = onMove
activeEnd = detachCreate
document.addEventListener('pointermove', onMove)
document.addEventListener('pointerup', detachCreate)
longPressTimer = setTimeout(() => {
detachCreate()
if (!moved) {
emit('createTask', {startMinutes, endMinutes: null})
}
}, 500)
}
</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;
}
.paint-selection {
position: absolute;
inset-inline: 2px;
z-index: 14;
border-radius: 4px;
background-color: var(--primary);
opacity: .25;
pointer-events: none;
}
.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>

View File

@ -0,0 +1,475 @@
<template>
<div
class="calendar-grid"
@wheel="onWheel"
>
<div
class="grid-head"
:style="headerStyle"
@touchstart.passive="onTouchStart"
@touchend.passive="onTouchEnd"
>
<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)"
@dblclick="onAllDayDblClick($event, day)"
@pointerdown="onAllDayPointerDown($event, day)"
>
<button
v-for="item in allDayItemsByDay.get(formatDayKey(day)) ?? []"
:key="item.task.id"
class="all-day-chip"
:class="{'is-done': item.task.done, 'is-ghost': item.isGhost}"
:style="{'--chip-color': taskColor(item.task), '--chip-text': getTextColor(taskColor(item.task))}"
:title="chipTitle(item.task)"
:draggable="!item.isGhost"
@dragstart="onChipDragStart($event, item.task)"
@click="emit('openTask', item.task.id)"
>
{{ item.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})"
@createTask="payload => emit('createTask', {...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 {getTextColor} from '@/helpers/color/getTextColor'
import CalendarDayColumn from './CalendarDayColumn.vue'
import {allDayTasksForDay, type AllDayItem} from '../helpers/dayLayout'
import {plannerTaskColor} from '../helpers/taskColor'
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}]
createTask: [payload: {day: Date, startMinutes: number, endMinutes: number | null}]
createAllDay: [payload: {day: Date}]
navigate: [delta: number]
'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'
}
}
function onAllDayCell(target: EventTarget | null): boolean {
return !(target as HTMLElement)?.closest?.('.all-day-chip')
}
// Desktop: double-click an empty all-day cell to create an all-day task.
function onAllDayDblClick(event: MouseEvent, day: Date) {
if (onAllDayCell(event.target)) {
emit('createAllDay', {day})
}
}
// Touch/pen: long-press an empty all-day cell to create.
let allDayTimer: ReturnType<typeof setTimeout> | undefined
let allDayMove: ((e: PointerEvent) => void) | null = null
let allDayEnd: ((e: PointerEvent) => void) | null = null
function detachAllDay() {
clearTimeout(allDayTimer)
if (allDayMove) {
document.removeEventListener('pointermove', allDayMove)
}
if (allDayEnd) {
document.removeEventListener('pointerup', allDayEnd)
}
allDayMove = null
allDayEnd = null
}
function onAllDayPointerDown(event: PointerEvent, day: Date) {
if (event.pointerType === 'mouse' || !onAllDayCell(event.target)) {
return
}
const startX = event.clientX
const startY = event.clientY
let moved = false
const onMove = (e: PointerEvent) => {
if (Math.abs(e.clientX - startX) > 10 || Math.abs(e.clientY - startY) > 10) {
moved = true
detachAllDay()
}
}
allDayMove = onMove
allDayEnd = detachAllDay
document.addEventListener('pointermove', onMove)
document.addEventListener('pointerup', detachAllDay)
allDayTimer = setTimeout(() => {
detachAllDay()
if (!moved) {
emit('createAllDay', {day})
}
}, 500)
}
// Horizontal wheel/trackpad scroll slides the window a day at a time, with a
// short cooldown so one flick doesn't skip several days.
let navCooldown = false
function navigate(delta: number) {
if (navCooldown) {
return
}
emit('navigate', delta)
navCooldown = true
setTimeout(() => navCooldown = false, 250)
}
function onWheel(event: WheelEvent) {
if (Math.abs(event.deltaX) <= Math.abs(event.deltaY) || Math.abs(event.deltaX) < 30) {
return
}
event.preventDefault()
navigate(event.deltaX > 0 ? 1 : -1)
}
// Touch swipe on the day header navigates without colliding with the grid's
// create/paint gestures, which live in the columns below.
let touchStartX = 0
let touchStartY = 0
function onTouchStart(event: TouchEvent) {
touchStartX = event.touches[0].clientX
touchStartY = event.touches[0].clientY
}
function onTouchEnd(event: TouchEvent) {
const dx = event.changedTouches[0].clientX - touchStartX
const dy = event.changedTouches[0].clientY - touchStartY
if (Math.abs(dx) > 50 && Math.abs(dx) > Math.abs(dy)) {
emit('navigate', dx < 0 ? 1 : -1)
}
}
const pxPerMinute = computed(() => props.pxPerHour / 60)
// Resolve the all-day items per day once per render instead of re-filtering all
// tasks inside the template v-for (each lookup walks recurrences).
const allDayItemsByDay = computed(() => {
const map = new Map<string, AllDayItem[]>()
for (const day of props.days) {
map.set(formatDayKey(day), allDayTasksForDay(props.tasks, day))
}
return map
})
// 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 {
return plannerTaskColor(task.hexColor, projectStore.projects[task.projectId]?.hexColor)
}
// All-day chips are single-line; surface the project name via the tooltip.
function chipTitle(task: ITask): string {
const project = projectStore.projects[task.projectId]?.title
return project ? `${task.title} · ${project}` : task.title
}
// 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)
detachAllDay()
})
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: .82rem;
color: var(--chip-text);
background-color: var(--chip-color);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
&.is-done {
opacity: .6;
text-decoration: line-through;
}
// Projected recurrence: read-only, visually dimmed like timed ghosts.
&.is-ghost {
cursor: pointer;
opacity: .55;
background-image: repeating-linear-gradient(
45deg,
hsla(0, 0%, 100%, .15),
hsla(0, 0%, 100%, .15) 4px,
transparent 4px,
transparent 8px
);
}
}
.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>

View File

@ -0,0 +1,78 @@
import dayjs from 'dayjs'
import type {ITask} from '@/modelTypes/ITask'
import {expandOccurrences, allDayOccurrenceForDay} from './expandOccurrences'
import {packColumns} from './packColumns'
import type {PlannedOccurrence} from './types'
export interface AllDayItem {
task: ITask
isGhost: boolean
}
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): AllDayItem[] {
const target = dayjs(day)
const items: AllDayItem[] = []
for (const task of tasks) {
if (isAllDayTask(task)) {
const {covered, isGhost} = allDayOccurrenceForDay(task, day)
if (covered) {
items.push({task, isGhost})
}
} else if (!task.startDate && !task.endDate && task.dueDate && target.isSame(dayjs(task.dueDate), 'day')) {
// due-only tasks (no time block) show on their due day
items.push({task, isGhost: false})
}
}
return items
}

View File

@ -0,0 +1,88 @@
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('projects into a window far past the cap from a long-untouched daily task', () => {
// Stored start is well over a year before the window; stepping naively from
// the start would exhaust the iteration cap before reaching it.
const task = makeTask({
startDate: new Date('2024-01-01T09:00:00'),
endDate: new Date('2024-01-01T10:00:00'),
repeatAfter: {type: 'days', amount: 1},
})
const out = expandOccurrences(task, new Date('2026-06-22T00:00:00'), new Date('2026-06-24T00:00:00'))
expect(out.map(o => o.start.getDate())).toEqual([22, 23])
expect(out.every(o => o.isGhost)).toBe(true)
})
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])
})
})

View File

@ -0,0 +1,154 @@
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]}
}
// Skip ahead to shortly before `towards` so a task whose stored start is far in
// the past (e.g. a daily repeater untouched for years) doesn't exhaust the
// iteration cap before reaching the visible range. `minBackoffMs` keeps enough
// margin that an occurrence starting before the window but still overlapping it
// isn't skipped. The caller's fine-stepping loop covers the small remainder.
function coarseJump(
realStart: dayjs.Dayjs,
step: {amount: number, unit: ManipulateType},
towards: dayjs.Dayjs,
minBackoffMs: number,
): {cursor: dayjs.Dayjs, index: number} {
const stepMs = realStart.add(step.amount, step.unit).diff(realStart)
if (stepMs <= 0 || !realStart.isBefore(towards)) {
return {cursor: realStart, index: 0}
}
const backoffSteps = Math.ceil(minBackoffMs / stepMs) + 1
const jumps = Math.max(Math.floor(towards.diff(realStart) / stepMs) - backoffSteps, 0)
return {cursor: realStart.add(step.amount * jumps, step.unit), index: jumps}
}
/**
* 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, index} = coarseJump(realStart, step, rangeStart, durationMs)
for (let i = 0; i < MAX_OCCURRENCES; i++) {
cursor = cursor.add(step.amount, step.unit)
index++
if (cursor.isAfter(rangeEnd)) {
break
}
pushIfVisible(cursor, true, index)
}
return occurrences
}
/**
* Whether an all-day task covers `day`, following its recurrence. All-day
* occurrences sit at midnight with zero duration, which expandOccurrences'
* range test excludes, so they get their own day-granular check here.
* Returns isGhost = true when only a projected occurrence (not the stored
* span) lands on the day, so the caller can render it read-only.
*/
export function allDayOccurrenceForDay(task: ITask, day: Date): {covered: boolean, isGhost: boolean} {
if (!task.startDate || !task.endDate) {
return {covered: false, isGhost: false}
}
const target = dayjs(day).startOf('day')
const realStart = dayjs(task.startDate).startOf('day')
const realEnd = dayjs(task.endDate).startOf('day')
const spanDays = Math.max(realEnd.diff(realStart, 'day'), 0)
const covers = (start: dayjs.Dayjs) => !target.isBefore(start) && !target.isAfter(start.add(spanDays, 'day'))
if (covers(realStart)) {
return {covered: true, isGhost: false}
}
const step = getRepeatStep(task)
if (step === null) {
return {covered: false, isGhost: false}
}
// Back off by the task's span so a long all-day occurrence starting before
// the target day but still covering it isn't jumped over.
let {cursor} = coarseJump(realStart, step, target, spanDays * 24 * 60 * 60 * 1000)
for (let i = 0; i < MAX_OCCURRENCES; i++) {
cursor = cursor.add(step.amount, step.unit)
if (cursor.isAfter(target)) {
break
}
if (covers(cursor)) {
return {covered: true, isGhost: true}
}
}
return {covered: false, isGhost: false}
}

View File

@ -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})
})
})

View File

@ -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
}

View File

@ -0,0 +1,10 @@
// The bar/chip colour for a task: its project's colour, falling back to the
// task's own and then the theme primary. Model constructors already normalise
// hexColor to a leading '#', but guard anyway for un-modelled inputs.
export function plannerTaskColor(taskHexColor: string, projectHexColor?: string): string {
const hex = projectHexColor || taskHexColor
if (!hex) {
return 'var(--primary)'
}
return hex.startsWith('#') ? hex : `#${hex}`
}

View File

@ -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
}

View File

@ -0,0 +1,32 @@
import {useStorage} from '@vueuse/core'
export interface CalendarSettings {
// Working hours ("HH:MM") define the initial zoom/scroll window — the grid
// still renders the full 024h 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: `daysToShow` days from the anchor.
fullWeek: boolean
// Number of days shown when fullWeek is off (rolling window, 131).
daysToShow: number
}
const DEFAULTS: CalendarSettings = {
dayStart: '08:00',
dayEnd: '18:00',
defaultDurationMinutes: 60,
slotMinutes: 30,
showDone: false,
fullWeek: true,
daysToShow: 7,
}
// 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}
}

View File

@ -0,0 +1,270 @@
import {computed, ref, shallowReactive, watch, type Ref} from 'vue'
import {klona} from 'klona/lite'
import dayjs from 'dayjs'
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
}
// Sidebar sort is a "<field>:<order>" string, or 'random' (no backend equivalent,
// so we shuffle client-side). Date fields are intentionally excluded: dated tasks
// live in the grid, not the unscheduled sidebar.
export type PlannerSidebarSort =
| 'none'
| 'priority:desc' | 'priority:asc'
| 'title:asc' | 'title:desc'
| 'created:desc' | 'created:asc'
| 'percent_done:desc' | 'percent_done:asc'
| 'random'
export const PLANNER_SIDEBAR_SORTS: PlannerSidebarSort[] = [
'none',
'priority:desc', 'priority:asc',
'title:asc', 'title:desc',
'created:desc', 'created:asc',
'percent_done:desc', 'percent_done:asc',
'random',
]
// Default: no explicit sort — show the order the server returns.
export const DEFAULT_PLANNER_SIDEBAR_SORT: PlannerSidebarSort = 'none'
function shuffle<T>(input: T[]): T[] {
const arr = [...input]
for (let i = arr.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1))
;[arr[i], arr[j]] = [arr[j], arr[i]]
}
return arr
}
export function usePlannerTasks(range: Ref<PlannerRange>, sidebarFilter: Ref<TaskFilterParams>, sidebarSort: Ref<PlannerSidebarSort>) {
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)
const loadError = ref(false)
// Monotonic tokens so a slow earlier load can't overwrite a newer one when the
// user navigates faster than requests resolve.
let gridLoadId = 0
let sidebarLoadId = 0
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())
// The backend parses a date-only filter value as start-of-day, so a `<= to`
// bound on the last day's date would drop tasks later that same day. Use the
// day after the last visible day to keep the whole last day inclusive.
const to = isoToKebabDate(dayjs(range.value.to).add(1, 'day').toISOString())
const params: TaskFilterParams = {
sort_by: ['start_date', 'id'],
order_by: ['asc', 'desc'],
// Last clause: recurring tasks whose stored start is before the window
// still project occurrences into it (expandOccurrences walks forward),
// so fetch any repeater that started on/before the range end. Only
// repeat_after is filterable (repeat_mode is not), so month-mode tasks
// with repeat_after = 0 aren't caught here.
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}") || ` +
`(start_date <= "${to}" && repeat_after > 0)` +
')',
filter_include_nulls: false,
filter_timezone: authStore.settings.timezone,
s: '',
expand: 'subtasks',
}
const id = ++gridLoadId
try {
const loaded = await fetchAll(gridService, params)
if (id !== gridLoadId) {
return
}
const map = new Map<ITask['id'], ITask>()
loaded.forEach(t => map.set(t.id, t))
gridTasks.value = map
loadError.value = false
} catch (_) {
if (id === gridLoadId) {
loadError.value = true
error(i18n.global.t('planner.loadError'))
}
}
}
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'
// Guard against a stale/garbage stored value reaching the API as a bad sort.
const sort = PLANNER_SIDEBAR_SORTS.includes(sidebarSort.value) ? sidebarSort.value : DEFAULT_PLANNER_SIDEBAR_SORT
// 'random' has no backend sort, so fetch in server order and shuffle below.
const random = sort === 'random'
const params: TaskFilterParams = {
filter,
filter_include_nulls: true,
filter_timezone: authStore.settings.timezone,
s: sidebarFilter.value.s ?? '',
expand: 'subtasks',
} as TaskFilterParams
// 'none'/'random' send no sort_by, so the server returns its own order.
if (sort !== 'none' && !random) {
const [field, order] = sort.split(':')
// Keep id as the final tiebreaker so the chosen column drives the order.
params.sort_by = [field, 'id'] as TaskFilterParams['sort_by']
params.order_by = [order, 'desc'] as TaskFilterParams['order_by']
}
// 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 id = ++sidebarLoadId
try {
const loaded = await fetchAll(sidebarService, params)
if (id !== sidebarLoadId) {
return
}
const unscheduled = loaded.filter(task => !task.startDate && !task.endDate && !task.dueDate)
sidebarTasks.value = random ? shuffle(unscheduled) : unscheduled
loadError.value = false
} catch (_) {
if (id === sidebarLoadId) {
loadError.value = true
error(i18n.global.t('planner.loadError'))
}
}
}
function load() {
return Promise.all([loadGrid(), loadSidebar()])
}
watch(range, () => loadGrid(), {immediate: true, deep: true})
watch([sidebarFilter, sidebarSort], () => 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)
}
},
)
// Drop a task deleted elsewhere (e.g. the task detail modal opened over the
// planner) from both lists, since the planner stays mounted underneath.
watch(
() => taskStore.lastDeletedTask,
deletedTask => {
if (!deletedTask) {
return
}
gridTasks.value.delete(deletedTask.id)
const index = sidebarTasks.value.findIndex(t => t.id === deletedTask.id)
if (index >= 0) {
sidebarTasks.value.splice(index, 1)
}
},
)
// 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)
}
}
// Place a freshly created task (not yet tracked) onto the grid with the given
// dates and persist them. Used by the create-by-gesture flow, where AddTask
// creates a dateless task that we then schedule into the painted slot.
async function scheduleTask(task: ITask, dates: {startDate: Date | null, endDate: Date | null}) {
const newTask: ITask = {...task, ...dates}
placeTask(newTask)
try {
const updated = await taskStore.update(newTask)
placeTask(updated)
success(i18n.global.t('planner.saved'))
} catch (_) {
error(i18n.global.t('planner.saveError'))
}
}
return {
gridTasks,
sidebarTasks,
isLoading,
loadError,
load,
updateTask,
scheduleTask,
}
}

View File

@ -716,7 +716,7 @@ const props = defineProps<{
backdropView?: RouteLocation['fullPath'],
}>()
defineEmits<{
const emit = defineEmits<{
'close': [],
}>()
@ -1131,6 +1131,13 @@ const showDeleteModal = ref(false)
async function deleteTask() {
await taskStore.delete(task.value)
success({message: t('task.detail.deleteSuccess')})
// Opened as an overlay (kanban/gantt/planner/related tasks): defer to the
// modal's close handler so we return to the originating view instead of the
// task's project list which is wrong for the cross-project planner.
if (isModal.value) {
emit('close')
return
}
router.push({name: 'project.index', params: {projectId: task.value.projectId}})
}

View File

@ -0,0 +1,135 @@
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)
})
test('offers a sort control for the unscheduled sidebar', async ({authenticatedPage: page}) => {
await page.goto('/planner')
const sortSelect = page.locator('.planner-sidebar .sort-select select')
await expect(sortSelect).toBeVisible()
// Includes the client-side random shuffle and excludes nonsensical date sorts.
await expect(sortSelect.locator('option', {hasText: 'Random'})).toHaveCount(1)
})
test('double-clicking an empty slot opens the create-task modal', async ({authenticatedPage: page}) => {
await page.goto('/planner')
await page.locator('.day-column').first().dblclick({position: {x: 20, y: 200}})
const dialog = page.locator('.modal-dialog')
await expect(dialog).toContainText('New task')
await expect(dialog.locator('textarea')).toBeVisible()
})
test('double-clicking the all-day row opens the create-task modal', async ({authenticatedPage: page}) => {
await page.goto('/planner')
await page.locator('.all-day-cell').first().dblclick()
const dialog = page.locator('.modal-dialog')
await expect(dialog).toContainText('All day')
await expect(dialog.locator('textarea')).toBeVisible()
})
test('renders the configured number of days in rolling mode', async ({authenticatedPage: page}) => {
// Seed planner settings before the app reads them (mergeDefaults fills the rest).
await page.addInitScript(() => {
localStorage.setItem('planner-settings', JSON.stringify({fullWeek: false, daysToShow: 10}))
})
await page.goto('/planner')
await expect(page.locator('.day-head')).toHaveCount(10)
})
test('projects a daily recurring task into the next week', async ({authenticatedPage: page}) => {
await TaskFactory.create(1, {
id: 904,
title: 'Daily standup',
project_id: projects[0].id,
start_date: todayAt(10),
end_date: todayAt(11),
repeat_after: 86400, // one day, in seconds
}, false)
await page.goto('/planner')
// Next week contains no stored instance — only projected occurrences.
await page.getByRole('button', {name: 'Next', exact: true}).click()
await expect(page.locator('.calendar-block', {hasText: 'Daily standup'}).first()).toBeVisible()
})
})