feat(planner): task creation, navigation, sorting, and display improvements
This commit is contained in:
parent
5809b510ac
commit
96451e20d9
|
|
@ -763,13 +763,18 @@
|
|||
"allDay": "All day",
|
||||
"saved": "Saved",
|
||||
"saveError": "Something went wrong saving the task",
|
||||
"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: next 7 days)",
|
||||
"fullWeek": "Show full week (off: rolling days)",
|
||||
"daysToShow": "Days to show",
|
||||
"showDone": "Show done tasks"
|
||||
}
|
||||
},
|
||||
|
|
|
|||
|
|
@ -229,6 +229,9 @@ const router = createRouter({
|
|||
path: '/planner',
|
||||
name: 'planner.index',
|
||||
component: () => import('@/views/planner/PlannerView.vue'),
|
||||
meta: {
|
||||
title: 'planner.title',
|
||||
},
|
||||
},
|
||||
{
|
||||
// Redirect old list routes to the respective project routes
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
@ -8,14 +8,20 @@
|
|||
<span>{{ $t('planner.settings.dayStart') }}</span>
|
||||
<FormInput
|
||||
v-model="settings.dayStart"
|
||||
type="time"
|
||||
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="time"
|
||||
type="text"
|
||||
placeholder="HH:MM"
|
||||
pattern="[0-2][0-9]:[0-5][0-9]"
|
||||
inputmode="numeric"
|
||||
/>
|
||||
</label>
|
||||
<label class="setting">
|
||||
|
|
@ -39,6 +45,19 @@
|
|||
<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>
|
||||
|
|
@ -47,12 +66,27 @@
|
|||
</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>
|
||||
|
|
|
|||
|
|
@ -10,10 +10,23 @@
|
|||
{{ $t('planner.unscheduled') }}
|
||||
</h3>
|
||||
|
||||
<FilterPopup
|
||||
v-model="filter"
|
||||
class="sidebar-filter"
|
||||
/>
|
||||
<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"
|
||||
|
|
@ -33,12 +46,21 @@
|
|||
@click="emit('openTask', task.id)"
|
||||
>
|
||||
<span class="task-title">{{ task.title }}</span>
|
||||
<span
|
||||
v-if="task.dueDate"
|
||||
class="task-due"
|
||||
>
|
||||
<Icon :icon="['far', 'calendar-alt']" />
|
||||
{{ formatDueDate(task.dueDate) }}
|
||||
<span 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>
|
||||
|
|
@ -46,12 +68,15 @@
|
|||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import {ref} from 'vue'
|
||||
import dayjs from 'dayjs'
|
||||
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'
|
||||
|
||||
defineProps<{
|
||||
|
|
@ -64,6 +89,24 @@ const emit = defineEmits<{
|
|||
}>()
|
||||
|
||||
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)
|
||||
|
||||
|
|
@ -85,16 +128,16 @@ function taskColor(task: ITask): string {
|
|||
return hex.startsWith('#') ? hex : `#${hex}`
|
||||
}
|
||||
|
||||
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'
|
||||
}
|
||||
}
|
||||
|
||||
function formatDueDate(date: Date): string {
|
||||
return dayjs(date).format('ll')
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
|
|
@ -121,10 +164,22 @@ function formatDueDate(date: Date): string {
|
|||
margin-block-end: .5rem;
|
||||
}
|
||||
|
||||
.sidebar-filter {
|
||||
.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);
|
||||
|
|
@ -145,7 +200,7 @@ function formatDueDate(date: Date): string {
|
|||
border-radius: 4px;
|
||||
padding: .4rem .5rem;
|
||||
background: var(--white);
|
||||
font-size: .8rem;
|
||||
font-size: .95rem;
|
||||
|
||||
&:active {
|
||||
cursor: grabbing;
|
||||
|
|
@ -160,10 +215,45 @@ function formatDueDate(date: Date): string {
|
|||
display: block;
|
||||
}
|
||||
|
||||
.task-due {
|
||||
display: block;
|
||||
.task-meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: .4rem;
|
||||
margin-block-start: .15rem;
|
||||
font-size: .7rem;
|
||||
|
||||
// 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>
|
||||
|
|
|
|||
|
|
@ -68,6 +68,7 @@
|
|||
<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})"
|
||||
|
|
@ -84,40 +85,64 @@
|
|||
@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, ref, watchEffect} from 'vue'
|
||||
import {computed, 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 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} from './helpers/usePlannerTasks'
|
||||
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 = ref<'week' | 'day'>('week')
|
||||
const viewMode = useStorage<'week' | 'day'>('planner-view-mode', 'week')
|
||||
const anchor = ref(new Date())
|
||||
const sidebarFilter = ref<TaskFilterParams>({filter: '', s: ''} as TaskFilterParams)
|
||||
// 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 = ref(48)
|
||||
const userZoomed = ref(false)
|
||||
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 {
|
||||
|
|
@ -139,8 +164,13 @@ const days = computed<Date[]>(() => {
|
|||
if (viewMode.value === 'day') {
|
||||
return [dayjs(anchor.value).startOf('day').toDate()]
|
||||
}
|
||||
const start = settings.value.fullWeek ? startOfWeek(anchor.value) : dayjs(anchor.value).startOf('day')
|
||||
return Array.from({length: 7}, (_, i) => start.add(i, 'day').toDate())
|
||||
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>(() => ({
|
||||
|
|
@ -157,24 +187,33 @@ const rangeLabel = computed(() => {
|
|||
return `${first.format('ll')} – ${last.format('ll')}`
|
||||
})
|
||||
|
||||
const {sidebarTasks, gridTasks, updateTask} = usePlannerTasks(range, sidebarFilter)
|
||||
const {sidebarTasks, gridTasks, updateTask, scheduleTask} = usePlannerTasks(range, sidebarFilter, sidebarSort)
|
||||
|
||||
const visibleGridTasks = computed(() =>
|
||||
[...gridTasks.value.values()].filter(task => settings.value.showDone || !task.done),
|
||||
)
|
||||
|
||||
// Page by the visible window (day=1, full week=7, rolling=daysToShow).
|
||||
function goPrev() {
|
||||
anchor.value = dayjs(anchor.value).subtract(1, viewMode.value).toDate()
|
||||
anchor.value = dayjs(anchor.value).subtract(days.value.length, 'day').toDate()
|
||||
}
|
||||
|
||||
function goNext() {
|
||||
anchor.value = dayjs(anchor.value).add(1, viewMode.value).toDate()
|
||||
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)
|
||||
|
|
@ -201,6 +240,46 @@ function onDropAllDay({taskId, day}: {taskId: number, day: Date}) {
|
|||
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(settings.value.defaultDurationMinutes, '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')}),
|
||||
}
|
||||
}
|
||||
|
||||
function onCreated(task: ITask) {
|
||||
if (!createCtx.value) {
|
||||
return
|
||||
}
|
||||
scheduleTask(task, {startDate: createCtx.value.startDate, endDate: createCtx.value.endDate})
|
||||
createCtx.value = null
|
||||
}
|
||||
|
||||
function openTask(taskId: number) {
|
||||
router.push({
|
||||
name: 'task.detail',
|
||||
|
|
@ -209,6 +288,10 @@ function openTask(taskId: number) {
|
|||
})
|
||||
}
|
||||
|
||||
// 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>
|
||||
|
||||
|
|
|
|||
|
|
@ -9,10 +9,26 @@
|
|||
'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"
|
||||
|
|
@ -43,6 +59,9 @@ 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'
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
|
|
@ -83,6 +102,24 @@ const color = computed(() => {
|
|||
return hex.startsWith('#') ? hex : `#${hex}`
|
||||
})
|
||||
|
||||
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,
|
||||
|
|
@ -95,6 +132,7 @@ const blockStyle = computed(() => ({
|
|||
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
|
||||
|
|
@ -105,6 +143,7 @@ const previewStyle = computed(() => ({
|
|||
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)
|
||||
|
|
@ -226,11 +265,11 @@ function onResizePointerDown(event: PointerEvent) {
|
|||
border-radius: 4px;
|
||||
border-inline-start: 3px solid var(--block-color);
|
||||
background-color: var(--block-color);
|
||||
color: var(--white);
|
||||
color: var(--block-text);
|
||||
cursor: grab;
|
||||
user-select: none;
|
||||
font-size: .75rem;
|
||||
line-height: 1.1;
|
||||
font-size: .85rem;
|
||||
line-height: 1.15;
|
||||
box-shadow: 0 1px 2px hsla(0, 0%, 0%, .15);
|
||||
|
||||
&.is-dragging {
|
||||
|
|
@ -285,6 +324,52 @@ function onResizePointerDown(event: PointerEvent) {
|
|||
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;
|
||||
|
|
@ -301,6 +386,7 @@ function onResizePointerDown(event: PointerEvent) {
|
|||
inline-size: 3px;
|
||||
block-size: 3px;
|
||||
border-radius: 50%;
|
||||
background-color: hsla(0, 0%, 100%, .8);
|
||||
background-color: var(--block-text);
|
||||
opacity: .8;
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -7,6 +7,8 @@
|
|||
@dragover.prevent="isDropTarget = true"
|
||||
@dragleave="isDropTarget = false"
|
||||
@drop="onDrop"
|
||||
@dblclick="onDblClick"
|
||||
@pointerdown="onCreatePointerDown"
|
||||
>
|
||||
<div
|
||||
v-for="hour in 24"
|
||||
|
|
@ -15,6 +17,15 @@
|
|||
: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"
|
||||
|
|
@ -59,11 +70,14 @@ 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))
|
||||
|
|
@ -86,13 +100,89 @@ function onDrop(event: DragEvent) {
|
|||
return
|
||||
}
|
||||
|
||||
const rect = columnEl.value.getBoundingClientRect()
|
||||
const offsetY = event.clientY - rect.top
|
||||
const rawMinutes = offsetY / props.pxPerMinute
|
||||
const snapped = Math.round(rawMinutes / props.slotMinutes) * props.slotMinutes
|
||||
const minutes = Math.min(Math.max(snapped, 0), 24 * 60 - props.slotMinutes)
|
||||
emit('dropTask', {taskId, minutes: minutesAt(event.clientY)})
|
||||
}
|
||||
|
||||
emit('dropTask', {taskId, minutes})
|
||||
// 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})
|
||||
}
|
||||
|
||||
let longPressTimer: ReturnType<typeof setTimeout> | undefined
|
||||
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 = () => {
|
||||
document.removeEventListener('pointermove', onMove)
|
||||
document.removeEventListener('pointerup', onUp)
|
||||
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
|
||||
}
|
||||
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
|
||||
cleanup()
|
||||
}
|
||||
}
|
||||
const cleanup = () => {
|
||||
clearTimeout(longPressTimer)
|
||||
document.removeEventListener('pointermove', onMove)
|
||||
document.removeEventListener('pointerup', cleanup)
|
||||
}
|
||||
document.addEventListener('pointermove', onMove)
|
||||
document.addEventListener('pointerup', cleanup)
|
||||
longPressTimer = setTimeout(() => {
|
||||
cleanup()
|
||||
if (!moved) {
|
||||
emit('createTask', {startMinutes, endMinutes: null})
|
||||
}
|
||||
}, 500)
|
||||
}
|
||||
</script>
|
||||
|
||||
|
|
@ -113,6 +203,16 @@ function onDrop(event: DragEvent) {
|
|||
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;
|
||||
|
|
|
|||
|
|
@ -1,8 +1,13 @@
|
|||
<template>
|
||||
<div class="calendar-grid">
|
||||
<div
|
||||
class="calendar-grid"
|
||||
@wheel="onWheel"
|
||||
>
|
||||
<div
|
||||
class="grid-head"
|
||||
:style="headerStyle"
|
||||
@touchstart.passive="onTouchStart"
|
||||
@touchend.passive="onTouchEnd"
|
||||
>
|
||||
<div class="axis-gutter" />
|
||||
<div
|
||||
|
|
@ -32,18 +37,21 @@
|
|||
@dragover.prevent="allDayDropDay = formatDayKey(day)"
|
||||
@dragleave="allDayDropDay = null"
|
||||
@drop="onAllDayDrop($event, day)"
|
||||
@dblclick="onAllDayDblClick($event, day)"
|
||||
@pointerdown="onAllDayPointerDown($event, day)"
|
||||
>
|
||||
<button
|
||||
v-for="task in allDayTasksForDay(tasks, day)"
|
||||
:key="task.id"
|
||||
v-for="item in allDayTasksForDay(tasks, day)"
|
||||
:key="item.task.id"
|
||||
class="all-day-chip"
|
||||
:class="{'is-done': task.done}"
|
||||
:style="{'--chip-color': taskColor(task)}"
|
||||
draggable="true"
|
||||
@dragstart="onChipDragStart($event, task)"
|
||||
@click="emit('openTask', task.id)"
|
||||
: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)"
|
||||
>
|
||||
{{ task.title }}
|
||||
{{ item.task.title }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -77,6 +85,7 @@
|
|||
@openTask="taskId => emit('openTask', taskId)"
|
||||
@updateBlock="payload => emit('updateBlock', payload)"
|
||||
@dropTask="payload => emit('dropTask', {...payload, day})"
|
||||
@createTask="payload => emit('createTask', {...payload, day})"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -92,6 +101,7 @@ 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} from '../helpers/dayLayout'
|
||||
|
||||
|
|
@ -110,6 +120,9 @@ const emit = defineEmits<{
|
|||
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]
|
||||
}>()
|
||||
|
||||
|
|
@ -134,6 +147,84 @@ function onChipDragStart(event: DragEvent, task: ITask) {
|
|||
}
|
||||
}
|
||||
|
||||
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
|
||||
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
|
||||
cleanup()
|
||||
}
|
||||
}
|
||||
const cleanup = () => {
|
||||
clearTimeout(allDayTimer)
|
||||
document.removeEventListener('pointermove', onMove)
|
||||
document.removeEventListener('pointerup', cleanup)
|
||||
}
|
||||
document.addEventListener('pointermove', onMove)
|
||||
document.addEventListener('pointerup', cleanup)
|
||||
allDayTimer = setTimeout(() => {
|
||||
cleanup()
|
||||
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)
|
||||
|
||||
// The body has a vertical scrollbar but the header/all-day rows don't; reserve
|
||||
|
|
@ -154,6 +245,12 @@ function taskColor(task: ITask): string {
|
|||
return hex.startsWith('#') ? hex : `#${hex}`
|
||||
}
|
||||
|
||||
// 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() {
|
||||
|
|
@ -295,8 +392,8 @@ $gutter-width: 3.5rem;
|
|||
cursor: grab;
|
||||
border-radius: 3px;
|
||||
padding: 1px 5px;
|
||||
font-size: .72rem;
|
||||
color: var(--white);
|
||||
font-size: .82rem;
|
||||
color: var(--chip-text);
|
||||
background-color: var(--chip-color);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
|
|
@ -306,6 +403,19 @@ $gutter-width: 3.5rem;
|
|||
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 {
|
||||
|
|
|
|||
|
|
@ -1,10 +1,15 @@
|
|||
import dayjs from 'dayjs'
|
||||
|
||||
import type {ITask} from '@/modelTypes/ITask'
|
||||
import {expandOccurrences} from './expandOccurrences'
|
||||
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
|
||||
|
|
@ -55,13 +60,19 @@ export function timedBlocksForDay(tasks: ITask[], day: Date): TimedBlock[] {
|
|||
).map(packed => ({...packed.item, col: packed.col, cols: packed.cols}))
|
||||
}
|
||||
|
||||
export function allDayTasksForDay(tasks: ITask[], day: Date): ITask[] {
|
||||
export function allDayTasksForDay(tasks: ITask[], day: Date): AllDayItem[] {
|
||||
const target = dayjs(day)
|
||||
return tasks.filter(task => {
|
||||
const items: AllDayItem[] = []
|
||||
for (const task of tasks) {
|
||||
if (isAllDayTask(task)) {
|
||||
return !target.isBefore(dayjs(task.startDate), 'day') && !target.isAfter(dayjs(task.endDate), 'day')
|
||||
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})
|
||||
}
|
||||
// due-only tasks (no time block) show on their due day
|
||||
return !task.startDate && !task.endDate && task.dueDate && target.isSame(dayjs(task.dueDate), 'day')
|
||||
})
|
||||
}
|
||||
return items
|
||||
}
|
||||
|
|
|
|||
|
|
@ -88,3 +88,44 @@ export function expandOccurrences(task: ITask, from: Date, to: Date): PlannedOcc
|
|||
|
||||
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}
|
||||
}
|
||||
|
||||
let cursor = realStart
|
||||
for (let i = 1; 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}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,8 +8,10 @@ export interface CalendarSettings {
|
|||
defaultDurationMinutes: number
|
||||
slotMinutes: number
|
||||
showDone: boolean
|
||||
// true: week aligned to the user's first weekday; false: 7 days from today.
|
||||
// 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, 1–31).
|
||||
daysToShow: number
|
||||
}
|
||||
|
||||
const DEFAULTS: CalendarSettings = {
|
||||
|
|
@ -19,6 +21,7 @@ const DEFAULTS: CalendarSettings = {
|
|||
slotMinutes: 30,
|
||||
showDone: false,
|
||||
fullWeek: true,
|
||||
daysToShow: 7,
|
||||
}
|
||||
|
||||
// Module-level so every caller shares the same reactive ref within the tab.
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
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'
|
||||
|
|
@ -16,7 +17,39 @@ export interface PlannerRange {
|
|||
to: Date
|
||||
}
|
||||
|
||||
export function usePlannerTasks(range: Ref<PlannerRange>, sidebarFilter: Ref<TaskFilterParams>) {
|
||||
// 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()
|
||||
|
||||
|
|
@ -38,16 +71,25 @@ export function usePlannerTasks(range: Ref<PlannerRange>, sidebarFilter: Ref<Tas
|
|||
|
||||
async function loadGrid() {
|
||||
const from = isoToKebabDate(range.value.from.toISOString())
|
||||
const to = isoToKebabDate(range.value.to.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 <= "${from}" && end_date >= "${to}") || ` +
|
||||
`(start_date <= "${to}" && repeat_after > 0)` +
|
||||
')',
|
||||
filter_include_nulls: false,
|
||||
filter_timezone: authStore.settings.timezone,
|
||||
|
|
@ -68,20 +110,31 @@ export function usePlannerTasks(range: Ref<PlannerRange>, sidebarFilter: Ref<Tas
|
|||
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 = {
|
||||
sort_by: ['due_date', 'id'],
|
||||
order_by: ['asc', 'desc'],
|
||||
filter,
|
||||
filter_include_nulls: true,
|
||||
filter_timezone: authStore.settings.timezone,
|
||||
s: sidebarFilter.value.s ?? '',
|
||||
expand: 'subtasks',
|
||||
} as TaskFilterParams
|
||||
|
||||
// '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']
|
||||
}
|
||||
|
||||
// Truly unscheduled = no start, end or due date. Due-only tasks already
|
||||
// render in the all-day row, so keep them out of the sidebar.
|
||||
const loaded = await fetchAll(sidebarService, params)
|
||||
sidebarTasks.value = loaded.filter(task => !task.startDate && !task.endDate && !task.dueDate)
|
||||
const unscheduled = loaded.filter(task => !task.startDate && !task.endDate && !task.dueDate)
|
||||
sidebarTasks.value = random ? shuffle(unscheduled) : unscheduled
|
||||
}
|
||||
|
||||
function load() {
|
||||
|
|
@ -89,7 +142,7 @@ export function usePlannerTasks(range: Ref<PlannerRange>, sidebarFilter: Ref<Tas
|
|||
}
|
||||
|
||||
watch(range, () => loadGrid(), {immediate: true, deep: true})
|
||||
watch(sidebarFilter, () => loadSidebar(), {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
|
||||
|
|
@ -143,11 +196,27 @@ export function usePlannerTasks(range: Ref<PlannerRange>, sidebarFilter: Ref<Tas
|
|||
}
|
||||
}
|
||||
|
||||
// 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,
|
||||
load,
|
||||
updateTask,
|
||||
scheduleTask,
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -75,4 +75,61 @@ test.describe('Planner', () => {
|
|||
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()
|
||||
})
|
||||
})
|
||||
|
|
|
|||
Loading…
Reference in New Issue