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",
|
"allDay": "All day",
|
||||||
"saved": "Saved",
|
"saved": "Saved",
|
||||||
"saveError": "Something went wrong saving the task",
|
"saveError": "Something went wrong saving the task",
|
||||||
|
"loadError": "Something went wrong loading your tasks",
|
||||||
"createTitle": "New task",
|
"createTitle": "New task",
|
||||||
"createAllDay": "All day · {date}",
|
"createAllDay": "All day · {date}",
|
||||||
"sortDefault": "Default order",
|
"sortDefault": "Default order",
|
||||||
|
|
|
||||||
|
|
@ -154,6 +154,7 @@ export const useTaskStore = defineStore('task', () => {
|
||||||
const isLoading = ref(false)
|
const isLoading = ref(false)
|
||||||
const draggedTask = ref<ITask | null>(null)
|
const draggedTask = ref<ITask | null>(null)
|
||||||
const lastUpdatedTask = 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)
|
const hasTasks = computed(() => Object.keys(tasks.value).length > 0)
|
||||||
|
|
||||||
|
|
@ -214,6 +215,7 @@ export const useTaskStore = defineStore('task', () => {
|
||||||
const taskService = new TaskService()
|
const taskService = new TaskService()
|
||||||
const response = await taskService.delete(task)
|
const response = await taskService.delete(task)
|
||||||
kanbanStore.removeTaskInBucket(task)
|
kanbanStore.removeTaskInBucket(task)
|
||||||
|
lastDeletedTask.value = task
|
||||||
return response
|
return response
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -594,6 +596,7 @@ export const useTaskStore = defineStore('task', () => {
|
||||||
isLoading,
|
isLoading,
|
||||||
draggedTask,
|
draggedTask,
|
||||||
lastUpdatedTask,
|
lastUpdatedTask,
|
||||||
|
lastDeletedTask,
|
||||||
|
|
||||||
hasTasks,
|
hasTasks,
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -78,6 +78,7 @@ import type {PlannerSidebarSort} from './helpers/usePlannerTasks'
|
||||||
import FilterPopup from '@/components/project/partials/FilterPopup.vue'
|
import FilterPopup from '@/components/project/partials/FilterPopup.vue'
|
||||||
import PriorityLabel from '@/components/tasks/partials/PriorityLabel.vue'
|
import PriorityLabel from '@/components/tasks/partials/PriorityLabel.vue'
|
||||||
import {useProjectStore} from '@/stores/projects'
|
import {useProjectStore} from '@/stores/projects'
|
||||||
|
import {plannerTaskColor} from './helpers/taskColor'
|
||||||
|
|
||||||
defineProps<{
|
defineProps<{
|
||||||
tasks: ITask[]
|
tasks: ITask[]
|
||||||
|
|
@ -121,11 +122,7 @@ function onDrop(event: DragEvent) {
|
||||||
const projectStore = useProjectStore()
|
const projectStore = useProjectStore()
|
||||||
|
|
||||||
function taskColor(task: ITask): string {
|
function taskColor(task: ITask): string {
|
||||||
const hex = projectStore.projects[task.projectId]?.hexColor || task.hexColor
|
return plannerTaskColor(task.hexColor, projectStore.projects[task.projectId]?.hexColor)
|
||||||
if (!hex) {
|
|
||||||
return 'var(--primary)'
|
|
||||||
}
|
|
||||||
return hex.startsWith('#') ? hex : `#${hex}`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function projectName(task: ITask): string {
|
function projectName(task: ITask): string {
|
||||||
|
|
|
||||||
|
|
@ -62,9 +62,22 @@
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<PlannerSettings />
|
<PlannerSettings />
|
||||||
|
|
||||||
|
<Loading
|
||||||
|
v-if="isLoading"
|
||||||
|
class="planner-loading is-loading-small"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
|
<Message
|
||||||
|
v-if="loadError"
|
||||||
|
variant="danger"
|
||||||
|
class="planner-error"
|
||||||
|
>
|
||||||
|
{{ $t('planner.loadError') }}
|
||||||
|
</Message>
|
||||||
|
|
||||||
<div class="planner-body">
|
<div class="planner-body">
|
||||||
<PlannerSidebar
|
<PlannerSidebar
|
||||||
v-model:filter="sidebarFilter"
|
v-model:filter="sidebarFilter"
|
||||||
|
|
@ -76,7 +89,7 @@
|
||||||
<CalendarGrid
|
<CalendarGrid
|
||||||
:days="days"
|
:days="days"
|
||||||
:tasks="visibleGridTasks"
|
:tasks="visibleGridTasks"
|
||||||
:slot-minutes="settings.slotMinutes"
|
:slot-minutes="slotMinutes"
|
||||||
:day-start-hour="dayStartHour"
|
:day-start-hour="dayStartHour"
|
||||||
:day-end-hour="dayEndHour"
|
:day-end-hour="dayEndHour"
|
||||||
:px-per-hour="pxPerHour"
|
:px-per-hour="pxPerHour"
|
||||||
|
|
@ -102,7 +115,7 @@
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<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 {useRouter} from 'vue-router'
|
||||||
import {useI18n} from 'vue-i18n'
|
import {useI18n} from 'vue-i18n'
|
||||||
import {useStorage} from '@vueuse/core'
|
import {useStorage} from '@vueuse/core'
|
||||||
|
|
@ -110,6 +123,8 @@ import dayjs from 'dayjs'
|
||||||
|
|
||||||
import type {ITask} from '@/modelTypes/ITask'
|
import type {ITask} from '@/modelTypes/ITask'
|
||||||
import BaseButton from '@/components/base/BaseButton.vue'
|
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 PlannerSidebar from './PlannerSidebar.vue'
|
||||||
import PlannerSettings from './PlannerSettings.vue'
|
import PlannerSettings from './PlannerSettings.vue'
|
||||||
import PlannerCreateTaskModal from './PlannerCreateTaskModal.vue'
|
import PlannerCreateTaskModal from './PlannerCreateTaskModal.vue'
|
||||||
|
|
@ -187,12 +202,17 @@ const rangeLabel = computed(() => {
|
||||||
return `${first.format('ll')} – ${last.format('ll')}`
|
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(() =>
|
const visibleGridTasks = computed(() =>
|
||||||
[...gridTasks.value.values()].filter(task => settings.value.showDone || !task.done),
|
[...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).
|
// Page by the visible window (day=1, full week=7, rolling=daysToShow).
|
||||||
function goPrev() {
|
function goPrev() {
|
||||||
anchor.value = dayjs(anchor.value).subtract(days.value.length, 'day').toDate()
|
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}) {
|
function onDropTask({taskId, minutes, day}: {taskId: number, minutes: number, day: Date}) {
|
||||||
const start = dayjs(day).startOf('day').add(minutes, 'minute')
|
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()})
|
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 start = base.add(startMinutes, 'minute')
|
||||||
const end = endMinutes !== null
|
const end = endMinutes !== null
|
||||||
? base.add(endMinutes, 'minute')
|
? base.add(endMinutes, 'minute')
|
||||||
: start.add(settings.value.defaultDurationMinutes, 'minute')
|
: start.add(defaultDurationMinutes.value, 'minute')
|
||||||
createCtx.value = {
|
createCtx.value = {
|
||||||
startDate: start.toDate(),
|
startDate: start.toDate(),
|
||||||
endDate: end.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) {
|
function onCreated(task: ITask) {
|
||||||
if (!createCtx.value) {
|
const ctx = createCtx.value
|
||||||
|
if (!ctx) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
scheduleTask(task, {startDate: createCtx.value.startDate, endDate: createCtx.value.endDate})
|
scheduleTask(task, {startDate: ctx.startDate, endDate: ctx.endDate})
|
||||||
createCtx.value = null
|
nextTick(() => createCtx.value = null)
|
||||||
}
|
}
|
||||||
|
|
||||||
function openTask(taskId: number) {
|
function openTask(taskId: number) {
|
||||||
|
|
@ -334,6 +358,19 @@ watchEffect(() => setTitle(t('planner.title')))
|
||||||
text-align: center;
|
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 {
|
.mode-toggle {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: .25rem;
|
gap: .25rem;
|
||||||
|
|
|
||||||
|
|
@ -53,7 +53,7 @@
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import {computed, ref} from 'vue'
|
import {computed, onBeforeUnmount, ref} from 'vue'
|
||||||
import dayjs from 'dayjs'
|
import dayjs from 'dayjs'
|
||||||
|
|
||||||
import {useProjectStore} from '@/stores/projects'
|
import {useProjectStore} from '@/stores/projects'
|
||||||
|
|
@ -63,6 +63,7 @@ import {getTextColor} from '@/helpers/color/getTextColor'
|
||||||
import {isEditorContentEmpty} from '@/helpers/editorContentEmpty'
|
import {isEditorContentEmpty} from '@/helpers/editorContentEmpty'
|
||||||
import PriorityLabel from '@/components/tasks/partials/PriorityLabel.vue'
|
import PriorityLabel from '@/components/tasks/partials/PriorityLabel.vue'
|
||||||
import type {PlannedOccurrence} from '../helpers/types'
|
import type {PlannedOccurrence} from '../helpers/types'
|
||||||
|
import {plannerTaskColor} from '../helpers/taskColor'
|
||||||
|
|
||||||
const props = withDefaults(defineProps<{
|
const props = withDefaults(defineProps<{
|
||||||
occurrence: PlannedOccurrence
|
occurrence: PlannedOccurrence
|
||||||
|
|
@ -93,14 +94,10 @@ const grabOffset = ref({x: 0, y: 0})
|
||||||
const previewPos = ref({x: 0, y: 0})
|
const previewPos = ref({x: 0, y: 0})
|
||||||
const previewSize = ref({w: 0, h: 0})
|
const previewSize = ref({w: 0, h: 0})
|
||||||
|
|
||||||
const color = computed(() => {
|
const color = computed(() => plannerTaskColor(
|
||||||
const project = projectStore.projects[props.occurrence.task.projectId]
|
props.occurrence.task.hexColor,
|
||||||
const hex = project?.hexColor || props.occurrence.task.hexColor
|
projectStore.projects[props.occurrence.task.projectId]?.hexColor,
|
||||||
if (!hex) {
|
))
|
||||||
return 'var(--primary)'
|
|
||||||
}
|
|
||||||
return hex.startsWith('#') ? hex : `#${hex}`
|
|
||||||
})
|
|
||||||
|
|
||||||
const projectName = computed(() => projectStore.projects[props.occurrence.task.projectId]?.title ?? '')
|
const projectName = computed(() => projectStore.projects[props.occurrence.task.projectId]?.title ?? '')
|
||||||
const textColor = computed(() => getTextColor(color.value))
|
const textColor = computed(() => getTextColor(color.value))
|
||||||
|
|
@ -154,6 +151,22 @@ function snap(deltaPx: number): number {
|
||||||
return Math.round(minutes / props.slotMinutes) * props.slotMinutes
|
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) {
|
function onMovePointerDown(event: PointerEvent) {
|
||||||
if (props.occurrence.isGhost) {
|
if (props.occurrence.isGhost) {
|
||||||
// Ghosts are read-only, but still let the user open the underlying task.
|
// 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) => {
|
const onUp = (e: PointerEvent) => {
|
||||||
document.removeEventListener('pointermove', onMove)
|
detachInteraction()
|
||||||
document.removeEventListener('pointerup', onUp)
|
|
||||||
|
|
||||||
const taskId = props.occurrence.task.id
|
const taskId = props.occurrence.task.id
|
||||||
// Hit-test from the preview block's top-centre (what the user visually
|
// Hit-test from the preview block's top-centre (what the user visually
|
||||||
|
|
@ -224,6 +236,8 @@ function onMovePointerDown(event: PointerEvent) {
|
||||||
isMoving.value = false
|
isMoving.value = false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
activeMove = onMove
|
||||||
|
activeUp = onUp
|
||||||
document.addEventListener('pointermove', onMove)
|
document.addEventListener('pointermove', onMove)
|
||||||
document.addEventListener('pointerup', onUp)
|
document.addEventListener('pointerup', onUp)
|
||||||
}
|
}
|
||||||
|
|
@ -237,8 +251,7 @@ function onResizePointerDown(event: PointerEvent) {
|
||||||
}
|
}
|
||||||
|
|
||||||
const onUp = () => {
|
const onUp = () => {
|
||||||
document.removeEventListener('pointermove', onMove)
|
detachInteraction()
|
||||||
document.removeEventListener('pointerup', onUp)
|
|
||||||
|
|
||||||
const newDuration = Math.max(props.durationMinutes + resizeDeltaMinutes.value, props.slotMinutes)
|
const newDuration = Math.max(props.durationMinutes + resizeDeltaMinutes.value, props.slotMinutes)
|
||||||
if (newDuration !== props.durationMinutes) {
|
if (newDuration !== props.durationMinutes) {
|
||||||
|
|
@ -252,6 +265,8 @@ function onResizePointerDown(event: PointerEvent) {
|
||||||
isInteracting.value = false
|
isInteracting.value = false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
activeMove = onMove
|
||||||
|
activeUp = onUp
|
||||||
document.addEventListener('pointermove', onMove)
|
document.addEventListener('pointermove', onMove)
|
||||||
document.addEventListener('pointerup', onUp)
|
document.addEventListener('pointerup', onUp)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -91,7 +91,27 @@ onMounted(() => {
|
||||||
timer = setInterval(() => now.value = new Date(), 60_000)
|
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) {
|
function onDrop(event: DragEvent) {
|
||||||
isDropTarget.value = false
|
isDropTarget.value = false
|
||||||
|
|
@ -125,7 +145,6 @@ function onDblClick(event: MouseEvent) {
|
||||||
emit('createTask', {startMinutes: minutesAt(event.clientY), endMinutes: null})
|
emit('createTask', {startMinutes: minutesAt(event.clientY), endMinutes: null})
|
||||||
}
|
}
|
||||||
|
|
||||||
let longPressTimer: ReturnType<typeof setTimeout> | undefined
|
|
||||||
function onCreatePointerDown(event: PointerEvent) {
|
function onCreatePointerDown(event: PointerEvent) {
|
||||||
if (!onEmptyArea(event.target) || (event.pointerType === 'mouse' && event.button !== 0)) {
|
if (!onEmptyArea(event.target) || (event.pointerType === 'mouse' && event.button !== 0)) {
|
||||||
return
|
return
|
||||||
|
|
@ -147,8 +166,7 @@ function onCreatePointerDown(event: PointerEvent) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const onUp = () => {
|
const onUp = () => {
|
||||||
document.removeEventListener('pointermove', onMove)
|
detachCreate()
|
||||||
document.removeEventListener('pointerup', onUp)
|
|
||||||
if (painting && selStart.value !== null && selEnd.value !== null) {
|
if (painting && selStart.value !== null && selEnd.value !== null) {
|
||||||
const end = Math.max(selEnd.value, selStart.value + props.slotMinutes)
|
const end = Math.max(selEnd.value, selStart.value + props.slotMinutes)
|
||||||
emit('createTask', {startMinutes: selStart.value, endMinutes: end})
|
emit('createTask', {startMinutes: selStart.value, endMinutes: end})
|
||||||
|
|
@ -156,6 +174,8 @@ function onCreatePointerDown(event: PointerEvent) {
|
||||||
selStart.value = null
|
selStart.value = null
|
||||||
selEnd.value = null
|
selEnd.value = null
|
||||||
}
|
}
|
||||||
|
activeMove = onMove
|
||||||
|
activeEnd = onUp
|
||||||
document.addEventListener('pointermove', onMove)
|
document.addEventListener('pointermove', onMove)
|
||||||
document.addEventListener('pointerup', onUp)
|
document.addEventListener('pointerup', onUp)
|
||||||
return
|
return
|
||||||
|
|
@ -167,18 +187,15 @@ function onCreatePointerDown(event: PointerEvent) {
|
||||||
const onMove = (e: PointerEvent) => {
|
const onMove = (e: PointerEvent) => {
|
||||||
if (Math.abs(e.clientY - startY) > 10) {
|
if (Math.abs(e.clientY - startY) > 10) {
|
||||||
moved = true
|
moved = true
|
||||||
cleanup()
|
detachCreate()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const cleanup = () => {
|
activeMove = onMove
|
||||||
clearTimeout(longPressTimer)
|
activeEnd = detachCreate
|
||||||
document.removeEventListener('pointermove', onMove)
|
|
||||||
document.removeEventListener('pointerup', cleanup)
|
|
||||||
}
|
|
||||||
document.addEventListener('pointermove', onMove)
|
document.addEventListener('pointermove', onMove)
|
||||||
document.addEventListener('pointerup', cleanup)
|
document.addEventListener('pointerup', detachCreate)
|
||||||
longPressTimer = setTimeout(() => {
|
longPressTimer = setTimeout(() => {
|
||||||
cleanup()
|
detachCreate()
|
||||||
if (!moved) {
|
if (!moved) {
|
||||||
emit('createTask', {startMinutes, endMinutes: null})
|
emit('createTask', {startMinutes, endMinutes: null})
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -41,7 +41,7 @@
|
||||||
@pointerdown="onAllDayPointerDown($event, day)"
|
@pointerdown="onAllDayPointerDown($event, day)"
|
||||||
>
|
>
|
||||||
<button
|
<button
|
||||||
v-for="item in allDayTasksForDay(tasks, day)"
|
v-for="item in allDayItemsByDay.get(formatDayKey(day)) ?? []"
|
||||||
:key="item.task.id"
|
:key="item.task.id"
|
||||||
class="all-day-chip"
|
class="all-day-chip"
|
||||||
:class="{'is-done': item.task.done, 'is-ghost': item.isGhost}"
|
: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 {TIME_FORMAT} from '@/constants/timeFormat'
|
||||||
import {getTextColor} from '@/helpers/color/getTextColor'
|
import {getTextColor} from '@/helpers/color/getTextColor'
|
||||||
import CalendarDayColumn from './CalendarDayColumn.vue'
|
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<{
|
const props = defineProps<{
|
||||||
days: Date[]
|
days: Date[]
|
||||||
|
|
@ -160,6 +161,19 @@ function onAllDayDblClick(event: MouseEvent, day: Date) {
|
||||||
|
|
||||||
// Touch/pen: long-press an empty all-day cell to create.
|
// Touch/pen: long-press an empty all-day cell to create.
|
||||||
let allDayTimer: ReturnType<typeof setTimeout> | undefined
|
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) {
|
function onAllDayPointerDown(event: PointerEvent, day: Date) {
|
||||||
if (event.pointerType === 'mouse' || !onAllDayCell(event.target)) {
|
if (event.pointerType === 'mouse' || !onAllDayCell(event.target)) {
|
||||||
return
|
return
|
||||||
|
|
@ -170,18 +184,15 @@ function onAllDayPointerDown(event: PointerEvent, day: Date) {
|
||||||
const onMove = (e: PointerEvent) => {
|
const onMove = (e: PointerEvent) => {
|
||||||
if (Math.abs(e.clientX - startX) > 10 || Math.abs(e.clientY - startY) > 10) {
|
if (Math.abs(e.clientX - startX) > 10 || Math.abs(e.clientY - startY) > 10) {
|
||||||
moved = true
|
moved = true
|
||||||
cleanup()
|
detachAllDay()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const cleanup = () => {
|
allDayMove = onMove
|
||||||
clearTimeout(allDayTimer)
|
allDayEnd = detachAllDay
|
||||||
document.removeEventListener('pointermove', onMove)
|
|
||||||
document.removeEventListener('pointerup', cleanup)
|
|
||||||
}
|
|
||||||
document.addEventListener('pointermove', onMove)
|
document.addEventListener('pointermove', onMove)
|
||||||
document.addEventListener('pointerup', cleanup)
|
document.addEventListener('pointerup', detachAllDay)
|
||||||
allDayTimer = setTimeout(() => {
|
allDayTimer = setTimeout(() => {
|
||||||
cleanup()
|
detachAllDay()
|
||||||
if (!moved) {
|
if (!moved) {
|
||||||
emit('createAllDay', {day})
|
emit('createAllDay', {day})
|
||||||
}
|
}
|
||||||
|
|
@ -227,6 +238,16 @@ function onTouchEnd(event: TouchEvent) {
|
||||||
|
|
||||||
const pxPerMinute = computed(() => props.pxPerHour / 60)
|
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 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.
|
// the same width on them so the day-column verticals line up.
|
||||||
const headerStyle = computed(() => ({paddingInlineEnd: `${scrollbarWidth.value}px`}))
|
const headerStyle = computed(() => ({paddingInlineEnd: `${scrollbarWidth.value}px`}))
|
||||||
|
|
@ -238,11 +259,7 @@ function measureScrollbar() {
|
||||||
}
|
}
|
||||||
|
|
||||||
function taskColor(task: ITask): string {
|
function taskColor(task: ITask): string {
|
||||||
const hex = projectStore.projects[task.projectId]?.hexColor || task.hexColor
|
return plannerTaskColor(task.hexColor, projectStore.projects[task.projectId]?.hexColor)
|
||||||
if (!hex) {
|
|
||||||
return 'var(--primary)'
|
|
||||||
}
|
|
||||||
return hex.startsWith('#') ? hex : `#${hex}`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// All-day chips are single-line; surface the project name via the tooltip.
|
// All-day chips are single-line; surface the project name via the tooltip.
|
||||||
|
|
@ -279,7 +296,10 @@ onMounted(() => {
|
||||||
window.addEventListener('resize', measureScrollbar)
|
window.addEventListener('resize', measureScrollbar)
|
||||||
})
|
})
|
||||||
|
|
||||||
onBeforeUnmount(() => window.removeEventListener('resize', measureScrollbar))
|
onBeforeUnmount(() => {
|
||||||
|
window.removeEventListener('resize', measureScrollbar)
|
||||||
|
detachAllDay()
|
||||||
|
})
|
||||||
|
|
||||||
watch(() => [props.dayStartHour, props.dayEndHour, props.days, props.autoFit], () => {
|
watch(() => [props.dayStartHour, props.dayEndHour, props.days, props.autoFit], () => {
|
||||||
fitToWorkingHours()
|
fitToWorkingHours()
|
||||||
|
|
|
||||||
|
|
@ -62,6 +62,19 @@ describe('expandOccurrences', () => {
|
||||||
out.forEach(o => expect(o.end.getTime() - o.start.getTime()).toBe(90 * 60 * 1000))
|
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', () => {
|
it('honours monthly repeat mode regardless of repeatAfter', () => {
|
||||||
const task = makeTask({
|
const task = makeTask({
|
||||||
startDate: new Date('2026-01-15T09:00:00'),
|
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]}
|
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].
|
* 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
|
return occurrences
|
||||||
}
|
}
|
||||||
|
|
||||||
let cursor = realStart
|
let {cursor, index} = coarseJump(realStart, step, rangeStart, durationMs)
|
||||||
for (let i = 1; i <= MAX_OCCURRENCES; i++) {
|
for (let i = 0; i < MAX_OCCURRENCES; i++) {
|
||||||
cursor = cursor.add(step.amount, step.unit)
|
cursor = cursor.add(step.amount, step.unit)
|
||||||
|
index++
|
||||||
if (cursor.isAfter(rangeEnd)) {
|
if (cursor.isAfter(rangeEnd)) {
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
pushIfVisible(cursor, true, i)
|
pushIfVisible(cursor, true, index)
|
||||||
}
|
}
|
||||||
|
|
||||||
return occurrences
|
return occurrences
|
||||||
|
|
@ -116,8 +137,10 @@ export function allDayOccurrenceForDay(task: ITask, day: Date): {covered: boolea
|
||||||
return {covered: false, isGhost: false}
|
return {covered: false, isGhost: false}
|
||||||
}
|
}
|
||||||
|
|
||||||
let cursor = realStart
|
// Back off by the task's span so a long all-day occurrence starting before
|
||||||
for (let i = 1; i <= MAX_OCCURRENCES; i++) {
|
// 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)
|
cursor = cursor.add(step.amount, step.unit)
|
||||||
if (cursor.isAfter(target)) {
|
if (cursor.isAfter(target)) {
|
||||||
break
|
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 sidebarTasks = ref<ITask[]>([])
|
||||||
|
|
||||||
const isLoading = computed(() => gridService.loading || sidebarService.loading)
|
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[]> {
|
async function fetchAll(service: TaskService, params: TaskFilterParams, page = 1): Promise<ITask[]> {
|
||||||
const tasks = await service.getAll({} as ITask, params, page) as 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',
|
expand: 'subtasks',
|
||||||
}
|
}
|
||||||
|
|
||||||
const loaded = await fetchAll(gridService, params)
|
const id = ++gridLoadId
|
||||||
const map = new Map<ITask['id'], ITask>()
|
try {
|
||||||
loaded.forEach(t => map.set(t.id, t))
|
const loaded = await fetchAll(gridService, params)
|
||||||
gridTasks.value = map
|
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() {
|
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.
|
// 'none'/'random' send no sort_by, so the server returns its own order.
|
||||||
if (sort !== 'none' && !random) {
|
if (sort !== 'none' && !random) {
|
||||||
const [field, order] = sort.split(':')
|
const [field, order] = sort.split(':')
|
||||||
params.sort_by = (field === 'id' ? ['id'] : [field, 'id']) as TaskFilterParams['sort_by']
|
// Keep id as the final tiebreaker so the chosen column drives the order.
|
||||||
params.order_by = (field === 'id' ? ['desc'] : [order, 'desc']) as TaskFilterParams['order_by']
|
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
|
// 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.
|
// render in the all-day row, so keep them out of the sidebar.
|
||||||
const loaded = await fetchAll(sidebarService, params)
|
const id = ++sidebarLoadId
|
||||||
const unscheduled = loaded.filter(task => !task.startDate && !task.endDate && !task.dueDate)
|
try {
|
||||||
sidebarTasks.value = random ? shuffle(unscheduled) : unscheduled
|
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() {
|
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
|
// 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.
|
// date (timed, all-day or due), otherwise the unscheduled sidebar.
|
||||||
function placeTask(task: ITask) {
|
function placeTask(task: ITask) {
|
||||||
|
|
@ -215,6 +262,7 @@ export function usePlannerTasks(range: Ref<PlannerRange>, sidebarFilter: Ref<Tas
|
||||||
gridTasks,
|
gridTasks,
|
||||||
sidebarTasks,
|
sidebarTasks,
|
||||||
isLoading,
|
isLoading,
|
||||||
|
loadError,
|
||||||
load,
|
load,
|
||||||
updateTask,
|
updateTask,
|
||||||
scheduleTask,
|
scheduleTask,
|
||||||
|
|
|
||||||
|
|
@ -716,7 +716,7 @@ const props = defineProps<{
|
||||||
backdropView?: RouteLocation['fullPath'],
|
backdropView?: RouteLocation['fullPath'],
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
defineEmits<{
|
const emit = defineEmits<{
|
||||||
'close': [],
|
'close': [],
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
|
|
@ -1131,6 +1131,13 @@ const showDeleteModal = ref(false)
|
||||||
async function deleteTask() {
|
async function deleteTask() {
|
||||||
await taskStore.delete(task.value)
|
await taskStore.delete(task.value)
|
||||||
success({message: t('task.detail.deleteSuccess')})
|
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}})
|
router.push({name: 'project.index', params: {projectId: task.value.projectId}})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue