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:
parent
96451e20d9
commit
cc7c596d19
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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'),
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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}`
|
||||
}
|
||||
|
|
@ -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 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 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,
|
||||
|
|
|
|||
|
|
@ -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}})
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue