fix(planner): correct delete navigation, recurring projection, and load robustness

Return to the originating view (not the project list) when deleting a task
from the detail overlay, and drop deleted tasks from the planner. Fix recurring
projection for long-past start dates, multi-task create dropping tasks, and add
loading/error state, load sequencing, drag-listener cleanup, and input clamping.
This commit is contained in:
Marlon May 2026-06-26 08:16:52 +02:00
parent 96451e20d9
commit cc7c596d19
No known key found for this signature in database
12 changed files with 260 additions and 69 deletions

View File

@ -763,6 +763,7 @@
"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",

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

@ -78,6 +78,7 @@ 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[]
@ -121,11 +122,7 @@ function onDrop(event: DragEvent) {
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}`
return plannerTaskColor(task.hexColor, projectStore.projects[task.projectId]?.hexColor)
}
function projectName(task: ITask): string {

View File

@ -62,9 +62,22 @@
</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"
@ -76,7 +89,7 @@
<CalendarGrid
:days="days"
:tasks="visibleGridTasks"
:slot-minutes="settings.slotMinutes"
:slot-minutes="slotMinutes"
:day-start-hour="dayStartHour"
:day-end-hour="dayEndHour"
:px-per-hour="pxPerHour"
@ -102,7 +115,7 @@
</template>
<script setup lang="ts">
import {computed, onMounted, ref, watchEffect} from 'vue'
import {computed, nextTick, onMounted, ref, watchEffect} from 'vue'
import {useRouter} from 'vue-router'
import {useI18n} from 'vue-i18n'
import {useStorage} from '@vueuse/core'
@ -110,6 +123,8 @@ 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'
@ -187,12 +202,17 @@ const rangeLabel = computed(() => {
return `${first.format('ll')} ${last.format('ll')}`
})
const {sidebarTasks, gridTasks, updateTask, scheduleTask} = usePlannerTasks(range, sidebarFilter, sidebarSort)
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()
@ -226,7 +246,7 @@ function zoomOut() {
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')
const end = start.add(defaultDurationMinutes.value, 'minute')
updateTask({id: taskId, startDate: start.toDate(), endDate: end.toDate()})
}
@ -255,7 +275,7 @@ function onCreateTask({day, startMinutes, endMinutes}: {day: Date, startMinutes:
const start = base.add(startMinutes, 'minute')
const end = endMinutes !== null
? base.add(endMinutes, 'minute')
: start.add(settings.value.defaultDurationMinutes, 'minute')
: start.add(defaultDurationMinutes.value, 'minute')
createCtx.value = {
startDate: start.toDate(),
endDate: end.toDate(),
@ -272,12 +292,16 @@ function onCreateAllDay({day}: {day: Date}) {
}
}
// 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) {
if (!createCtx.value) {
const ctx = createCtx.value
if (!ctx) {
return
}
scheduleTask(task, {startDate: createCtx.value.startDate, endDate: createCtx.value.endDate})
createCtx.value = null
scheduleTask(task, {startDate: ctx.startDate, endDate: ctx.endDate})
nextTick(() => createCtx.value = null)
}
function openTask(taskId: number) {
@ -334,6 +358,19 @@ watchEffect(() => setTitle(t('planner.title')))
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;

View File

@ -53,7 +53,7 @@
</template>
<script setup lang="ts">
import {computed, ref} from 'vue'
import {computed, onBeforeUnmount, ref} from 'vue'
import dayjs from 'dayjs'
import {useProjectStore} from '@/stores/projects'
@ -63,6 +63,7 @@ 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
@ -93,14 +94,10 @@ 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 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))
@ -154,6 +151,22 @@ function snap(deltaPx: number): number {
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.
@ -179,8 +192,7 @@ function onMovePointerDown(event: PointerEvent) {
}
const onUp = (e: PointerEvent) => {
document.removeEventListener('pointermove', onMove)
document.removeEventListener('pointerup', onUp)
detachInteraction()
const taskId = props.occurrence.task.id
// Hit-test from the preview block's top-centre (what the user visually
@ -224,6 +236,8 @@ function onMovePointerDown(event: PointerEvent) {
isMoving.value = false
}
activeMove = onMove
activeUp = onUp
document.addEventListener('pointermove', onMove)
document.addEventListener('pointerup', onUp)
}
@ -237,8 +251,7 @@ function onResizePointerDown(event: PointerEvent) {
}
const onUp = () => {
document.removeEventListener('pointermove', onMove)
document.removeEventListener('pointerup', onUp)
detachInteraction()
const newDuration = Math.max(props.durationMinutes + resizeDeltaMinutes.value, props.slotMinutes)
if (newDuration !== props.durationMinutes) {
@ -252,6 +265,8 @@ function onResizePointerDown(event: PointerEvent) {
isInteracting.value = false
}
activeMove = onMove
activeUp = onUp
document.addEventListener('pointermove', onMove)
document.addEventListener('pointerup', onUp)
}

View File

@ -91,7 +91,27 @@ onMounted(() => {
timer = setInterval(() => now.value = new Date(), 60_000)
}
})
onBeforeUnmount(() => clearInterval(timer))
// 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
@ -125,7 +145,6 @@ function onDblClick(event: MouseEvent) {
emit('createTask', {startMinutes: minutesAt(event.clientY), endMinutes: null})
}
let longPressTimer: ReturnType<typeof setTimeout> | undefined
function onCreatePointerDown(event: PointerEvent) {
if (!onEmptyArea(event.target) || (event.pointerType === 'mouse' && event.button !== 0)) {
return
@ -147,8 +166,7 @@ function onCreatePointerDown(event: PointerEvent) {
}
}
const onUp = () => {
document.removeEventListener('pointermove', onMove)
document.removeEventListener('pointerup', 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})
@ -156,6 +174,8 @@ function onCreatePointerDown(event: PointerEvent) {
selStart.value = null
selEnd.value = null
}
activeMove = onMove
activeEnd = onUp
document.addEventListener('pointermove', onMove)
document.addEventListener('pointerup', onUp)
return
@ -167,18 +187,15 @@ function onCreatePointerDown(event: PointerEvent) {
const onMove = (e: PointerEvent) => {
if (Math.abs(e.clientY - startY) > 10) {
moved = true
cleanup()
detachCreate()
}
}
const cleanup = () => {
clearTimeout(longPressTimer)
document.removeEventListener('pointermove', onMove)
document.removeEventListener('pointerup', cleanup)
}
activeMove = onMove
activeEnd = detachCreate
document.addEventListener('pointermove', onMove)
document.addEventListener('pointerup', cleanup)
document.addEventListener('pointerup', detachCreate)
longPressTimer = setTimeout(() => {
cleanup()
detachCreate()
if (!moved) {
emit('createTask', {startMinutes, endMinutes: null})
}

View File

@ -41,7 +41,7 @@
@pointerdown="onAllDayPointerDown($event, day)"
>
<button
v-for="item in allDayTasksForDay(tasks, day)"
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}"
@ -103,7 +103,8 @@ import {useTimeFormat} from '@/composables/useTimeFormat'
import {TIME_FORMAT} from '@/constants/timeFormat'
import {getTextColor} from '@/helpers/color/getTextColor'
import CalendarDayColumn from './CalendarDayColumn.vue'
import {allDayTasksForDay} from '../helpers/dayLayout'
import {allDayTasksForDay, type AllDayItem} from '../helpers/dayLayout'
import {plannerTaskColor} from '../helpers/taskColor'
const props = defineProps<{
days: Date[]
@ -160,6 +161,19 @@ function onAllDayDblClick(event: MouseEvent, day: Date) {
// 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
@ -170,18 +184,15 @@ function onAllDayPointerDown(event: PointerEvent, day: Date) {
const onMove = (e: PointerEvent) => {
if (Math.abs(e.clientX - startX) > 10 || Math.abs(e.clientY - startY) > 10) {
moved = true
cleanup()
detachAllDay()
}
}
const cleanup = () => {
clearTimeout(allDayTimer)
document.removeEventListener('pointermove', onMove)
document.removeEventListener('pointerup', cleanup)
}
allDayMove = onMove
allDayEnd = detachAllDay
document.addEventListener('pointermove', onMove)
document.addEventListener('pointerup', cleanup)
document.addEventListener('pointerup', detachAllDay)
allDayTimer = setTimeout(() => {
cleanup()
detachAllDay()
if (!moved) {
emit('createAllDay', {day})
}
@ -227,6 +238,16 @@ function onTouchEnd(event: TouchEvent) {
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`}))
@ -238,11 +259,7 @@ function measureScrollbar() {
}
function taskColor(task: ITask): string {
const hex = projectStore.projects[task.projectId]?.hexColor || task.hexColor
if (!hex) {
return 'var(--primary)'
}
return hex.startsWith('#') ? hex : `#${hex}`
return plannerTaskColor(task.hexColor, projectStore.projects[task.projectId]?.hexColor)
}
// All-day chips are single-line; surface the project name via the tooltip.
@ -279,7 +296,10 @@ onMounted(() => {
window.addEventListener('resize', measureScrollbar)
})
onBeforeUnmount(() => window.removeEventListener('resize', measureScrollbar))
onBeforeUnmount(() => {
window.removeEventListener('resize', measureScrollbar)
detachAllDay()
})
watch(() => [props.dayStartHour, props.dayEndHour, props.days, props.autoFit], () => {
fitToWorkingHours()

View File

@ -62,6 +62,19 @@ describe('expandOccurrences', () => {
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'),

View File

@ -36,6 +36,26 @@ function getRepeatStep(task: ITask): {amount: number, unit: ManipulateType} | nu
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].
*
@ -77,13 +97,14 @@ export function expandOccurrences(task: ITask, from: Date, to: Date): PlannedOcc
return occurrences
}
let cursor = realStart
for (let i = 1; i <= MAX_OCCURRENCES; i++) {
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, i)
pushIfVisible(cursor, true, index)
}
return occurrences
@ -116,8 +137,10 @@ export function allDayOccurrenceForDay(task: ITask, day: Date): {covered: boolea
return {covered: false, isGhost: false}
}
let cursor = realStart
for (let i = 1; i <= MAX_OCCURRENCES; i++) {
// 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

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

@ -60,6 +60,12 @@ export function usePlannerTasks(range: Ref<PlannerRange>, sidebarFilter: Ref<Tas
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[]
@ -97,10 +103,22 @@ export function usePlannerTasks(range: Ref<PlannerRange>, sidebarFilter: Ref<Tas
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
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() {
@ -126,15 +144,28 @@ export function usePlannerTasks(range: Ref<PlannerRange>, sidebarFilter: Ref<Tas
// 'none'/'random' send no sort_by, so the server returns its own order.
if (sort !== 'none' && !random) {
const [field, order] = sort.split(':')
params.sort_by = (field === 'id' ? ['id'] : [field, 'id']) as TaskFilterParams['sort_by']
params.order_by = (field === 'id' ? ['desc'] : [order, 'desc']) as TaskFilterParams['order_by']
// 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 loaded = await fetchAll(sidebarService, params)
const unscheduled = loaded.filter(task => !task.startDate && !task.endDate && !task.dueDate)
sidebarTasks.value = random ? shuffle(unscheduled) : unscheduled
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() {
@ -161,6 +192,22 @@ export function usePlannerTasks(range: Ref<PlannerRange>, sidebarFilter: Ref<Tas
},
)
// 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) {
@ -215,6 +262,7 @@ export function usePlannerTasks(range: Ref<PlannerRange>, sidebarFilter: Ref<Tas
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}})
}