diff --git a/frontend/src/components/home/Navigation.vue b/frontend/src/components/home/Navigation.vue index b657f9eaa..8d33f2d5f 100644 --- a/frontend/src/components/home/Navigation.vue +++ b/frontend/src/components/home/Navigation.vue @@ -38,6 +38,17 @@ {{ $t('navigation.upcoming') }} +
  • + + + + + {{ $t('navigation.planner') }} + +
  • import('@/views/planner/PlannerView.vue'), + meta: { + title: 'planner.title', + }, + }, { // Redirect old list routes to the respective project routes // see: https://router.vuejs.org/guide/essentials/dynamic-matching.html#catch-all-404-not-found-route diff --git a/frontend/src/stores/tasks.ts b/frontend/src/stores/tasks.ts index ac27ff0d2..da4eaaad2 100644 --- a/frontend/src/stores/tasks.ts +++ b/frontend/src/stores/tasks.ts @@ -154,6 +154,7 @@ export const useTaskStore = defineStore('task', () => { const isLoading = ref(false) const draggedTask = ref(null) const lastUpdatedTask = ref(null) + const lastDeletedTask = ref(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, diff --git a/frontend/src/views/planner/PlannerCreateTaskModal.vue b/frontend/src/views/planner/PlannerCreateTaskModal.vue new file mode 100644 index 000000000..b1e6e9fb4 --- /dev/null +++ b/frontend/src/views/planner/PlannerCreateTaskModal.vue @@ -0,0 +1,53 @@ + + + + + diff --git a/frontend/src/views/planner/PlannerSettings.vue b/frontend/src/views/planner/PlannerSettings.vue new file mode 100644 index 000000000..989b6b156 --- /dev/null +++ b/frontend/src/views/planner/PlannerSettings.vue @@ -0,0 +1,107 @@ + + + + + diff --git a/frontend/src/views/planner/PlannerSidebar.vue b/frontend/src/views/planner/PlannerSidebar.vue new file mode 100644 index 000000000..8e03ed607 --- /dev/null +++ b/frontend/src/views/planner/PlannerSidebar.vue @@ -0,0 +1,256 @@ + + + + + diff --git a/frontend/src/views/planner/PlannerView.vue b/frontend/src/views/planner/PlannerView.vue new file mode 100644 index 000000000..c37bad5b2 --- /dev/null +++ b/frontend/src/views/planner/PlannerView.vue @@ -0,0 +1,390 @@ + + + + + diff --git a/frontend/src/views/planner/grid/CalendarBlock.vue b/frontend/src/views/planner/grid/CalendarBlock.vue new file mode 100644 index 000000000..649fd341e --- /dev/null +++ b/frontend/src/views/planner/grid/CalendarBlock.vue @@ -0,0 +1,407 @@ + + + + + diff --git a/frontend/src/views/planner/grid/CalendarDayColumn.vue b/frontend/src/views/planner/grid/CalendarDayColumn.vue new file mode 100644 index 000000000..82ecb451c --- /dev/null +++ b/frontend/src/views/planner/grid/CalendarDayColumn.vue @@ -0,0 +1,257 @@ + + + + + diff --git a/frontend/src/views/planner/grid/CalendarGrid.vue b/frontend/src/views/planner/grid/CalendarGrid.vue new file mode 100644 index 000000000..d12955b2d --- /dev/null +++ b/frontend/src/views/planner/grid/CalendarGrid.vue @@ -0,0 +1,475 @@ + + + + + diff --git a/frontend/src/views/planner/helpers/dayLayout.ts b/frontend/src/views/planner/helpers/dayLayout.ts new file mode 100644 index 000000000..544455c8b --- /dev/null +++ b/frontend/src/views/planner/helpers/dayLayout.ts @@ -0,0 +1,78 @@ +import dayjs from 'dayjs' + +import type {ITask} from '@/modelTypes/ITask' +import {expandOccurrences, allDayOccurrenceForDay} from './expandOccurrences' +import {packColumns} from './packColumns' +import type {PlannedOccurrence} from './types' + +export interface AllDayItem { + task: ITask + isGhost: boolean +} + +export interface TimedBlock { + occurrence: PlannedOccurrence + col: number + cols: number + topMinutes: number + durationMinutes: number +} + +const MIN_BLOCK_MINUTES = 15 + +// A task with a start and end both pinned to local midnight has no time-of-day +// and belongs in the all-day row, not as a (zero-height or full-column) block. +export function isAllDayTask(task: ITask): boolean { + if (!task.startDate || !task.endDate) { + return false + } + const start = dayjs(task.startDate) + const end = dayjs(task.endDate) + return start.hour() === 0 && start.minute() === 0 && end.hour() === 0 && end.minute() === 0 +} + +export function timedBlocksForDay(tasks: ITask[], day: Date): TimedBlock[] { + const dayStart = dayjs(day).startOf('day') + const dayEnd = dayStart.add(1, 'day') + + const occurrences: PlannedOccurrence[] = [] + for (const task of tasks) { + if (!task.startDate || !task.endDate || isAllDayTask(task)) { + continue + } + occurrences.push(...expandOccurrences(task, dayStart.toDate(), dayEnd.toDate())) + } + + const sized = occurrences.map(occurrence => { + const start = dayjs(occurrence.start).isBefore(dayStart) ? dayStart : dayjs(occurrence.start) + const end = dayjs(occurrence.end).isAfter(dayEnd) ? dayEnd : dayjs(occurrence.end) + return { + occurrence, + topMinutes: start.diff(dayStart, 'minute'), + durationMinutes: Math.max(end.diff(start, 'minute'), MIN_BLOCK_MINUTES), + } + }) + + return packColumns( + sized, + s => s.topMinutes, + s => s.topMinutes + s.durationMinutes, + ).map(packed => ({...packed.item, col: packed.col, cols: packed.cols})) +} + +export function allDayTasksForDay(tasks: ITask[], day: Date): AllDayItem[] { + const target = dayjs(day) + const items: AllDayItem[] = [] + for (const task of tasks) { + if (isAllDayTask(task)) { + const {covered, isGhost} = allDayOccurrenceForDay(task, day) + if (covered) { + items.push({task, isGhost}) + } + } else if (!task.startDate && !task.endDate && task.dueDate && target.isSame(dayjs(task.dueDate), 'day')) { + // due-only tasks (no time block) show on their due day + items.push({task, isGhost: false}) + } + } + return items +} diff --git a/frontend/src/views/planner/helpers/expandOccurrences.test.ts b/frontend/src/views/planner/helpers/expandOccurrences.test.ts new file mode 100644 index 000000000..0eb437a7e --- /dev/null +++ b/frontend/src/views/planner/helpers/expandOccurrences.test.ts @@ -0,0 +1,88 @@ +import {describe, it, expect} from 'vitest' +import {expandOccurrences} from './expandOccurrences' +import {TASK_REPEAT_MODES} from '@/types/IRepeatMode' +import type {ITask} from '@/modelTypes/ITask' + +function makeTask(overrides: Partial): ITask { + return { + id: 1, + startDate: null, + endDate: null, + repeatAfter: 0, + repeatMode: TASK_REPEAT_MODES.REPEAT_MODE_DEFAULT, + ...overrides, + } as ITask +} + +describe('expandOccurrences', () => { + it('returns nothing for a task without start or end', () => { + const task = makeTask({startDate: new Date('2026-06-22T10:00:00')}) + expect(expandOccurrences(task, new Date('2026-06-22'), new Date('2026-06-29'))).toHaveLength(0) + }) + + it('returns a single non-recurring instance, not a ghost', () => { + const task = makeTask({ + startDate: new Date('2026-06-23T10:00:00'), + endDate: new Date('2026-06-23T11:00:00'), + }) + const out = expandOccurrences(task, new Date('2026-06-22T00:00:00'), new Date('2026-06-29T00:00:00')) + expect(out).toHaveLength(1) + expect(out[0].isGhost).toBe(false) + }) + + it('skips an instance entirely outside the range', () => { + const task = makeTask({ + startDate: new Date('2026-01-01T10:00:00'), + endDate: new Date('2026-01-01T11:00:00'), + }) + const out = expandOccurrences(task, new Date('2026-06-22T00:00:00'), new Date('2026-06-29T00:00:00')) + expect(out).toHaveLength(0) + }) + + it('projects weekly ghosts across a month, only the first is real', () => { + const task = makeTask({ + startDate: new Date('2026-06-01T09:00:00'), + endDate: new Date('2026-06-01T10:00:00'), + repeatAfter: {type: 'weeks', amount: 1}, + }) + const out = expandOccurrences(task, new Date('2026-06-01T00:00:00'), new Date('2026-06-29T00:00:00')) + // Jun 1, 8, 15, 22 (29 is excluded by the open upper bound) + expect(out.map(o => o.start.getDate())).toEqual([1, 8, 15, 22]) + expect(out[0].isGhost).toBe(false) + expect(out.slice(1).every(o => o.isGhost)).toBe(true) + }) + + it('preserves duration on ghost occurrences', () => { + const task = makeTask({ + startDate: new Date('2026-06-01T09:00:00'), + endDate: new Date('2026-06-01T10:30:00'), + repeatAfter: {type: 'days', amount: 1}, + }) + const out = expandOccurrences(task, new Date('2026-06-01T00:00:00'), new Date('2026-06-04T00:00:00')) + out.forEach(o => expect(o.end.getTime() - o.start.getTime()).toBe(90 * 60 * 1000)) + }) + + it('projects into a window far past the cap from a long-untouched daily task', () => { + // Stored start is well over a year before the window; stepping naively from + // the start would exhaust the iteration cap before reaching it. + const task = makeTask({ + startDate: new Date('2024-01-01T09:00:00'), + endDate: new Date('2024-01-01T10:00:00'), + repeatAfter: {type: 'days', amount: 1}, + }) + const out = expandOccurrences(task, new Date('2026-06-22T00:00:00'), new Date('2026-06-24T00:00:00')) + expect(out.map(o => o.start.getDate())).toEqual([22, 23]) + expect(out.every(o => o.isGhost)).toBe(true) + }) + + it('honours monthly repeat mode regardless of repeatAfter', () => { + const task = makeTask({ + startDate: new Date('2026-01-15T09:00:00'), + endDate: new Date('2026-01-15T10:00:00'), + repeatMode: TASK_REPEAT_MODES.REPEAT_MODE_MONTH, + repeatAfter: 0, + }) + const out = expandOccurrences(task, new Date('2026-01-01T00:00:00'), new Date('2026-04-01T00:00:00')) + expect(out.map(o => o.start.getMonth())).toEqual([0, 1, 2]) + }) +}) diff --git a/frontend/src/views/planner/helpers/expandOccurrences.ts b/frontend/src/views/planner/helpers/expandOccurrences.ts new file mode 100644 index 000000000..e3d9edbe4 --- /dev/null +++ b/frontend/src/views/planner/helpers/expandOccurrences.ts @@ -0,0 +1,154 @@ +import dayjs, {type ManipulateType} from 'dayjs' + +import type {ITask} from '@/modelTypes/ITask' +import type {IRepeatAfter} from '@/types/IRepeatAfter' +import {TASK_REPEAT_MODES} from '@/types/IRepeatMode' +import {parseRepeatAfter} from '@/models/task' +import type {PlannedOccurrence} from './types' + +// Guard against pathological repeat intervals projecting forever. +const MAX_OCCURRENCES = 366 + +const TYPE_TO_DAYJS: Record = { + seconds: 'second', + minutes: 'minute', + hours: 'hour', + days: 'day', + weeks: 'week', + months: 'month', + years: 'year', +} + +function getRepeatStep(task: ITask): {amount: number, unit: ManipulateType} | null { + // Monthly mode repeats on the same day each month regardless of repeatAfter. + if (task.repeatMode === TASK_REPEAT_MODES.REPEAT_MODE_MONTH) { + return {amount: 1, unit: 'month'} + } + + const repeat: IRepeatAfter = typeof task.repeatAfter === 'number' + ? parseRepeatAfter(task.repeatAfter) + : task.repeatAfter + + if (!repeat || repeat.amount <= 0) { + return null + } + + return {amount: repeat.amount, unit: TYPE_TO_DAYJS[repeat.type]} +} + +// Skip ahead to shortly before `towards` so a task whose stored start is far in +// the past (e.g. a daily repeater untouched for years) doesn't exhaust the +// iteration cap before reaching the visible range. `minBackoffMs` keeps enough +// margin that an occurrence starting before the window but still overlapping it +// isn't skipped. The caller's fine-stepping loop covers the small remainder. +function coarseJump( + realStart: dayjs.Dayjs, + step: {amount: number, unit: ManipulateType}, + towards: dayjs.Dayjs, + minBackoffMs: number, +): {cursor: dayjs.Dayjs, index: number} { + const stepMs = realStart.add(step.amount, step.unit).diff(realStart) + if (stepMs <= 0 || !realStart.isBefore(towards)) { + return {cursor: realStart, index: 0} + } + const backoffSteps = Math.ceil(minBackoffMs / stepMs) + 1 + const jumps = Math.max(Math.floor(towards.diff(realStart) / stepMs) - backoffSteps, 0) + return {cursor: realStart.add(step.amount * jumps, step.unit), index: jumps} +} + +/** + * Projects a timed task's occurrences across [from, to]. + * + * The stored task itself (at its current start/end) is the only real, + * editable instance; every projected future occurrence is a read-only ghost. + * Projection is keyed off the task's current start so a just-completed + * recurring task (whose start the backend has already advanced) does not draw + * both the finished slot and its next occurrence. + */ +export function expandOccurrences(task: ITask, from: Date, to: Date): PlannedOccurrence[] { + if (!task.startDate || !task.endDate) { + return [] + } + + const realStart = dayjs(task.startDate) + const realEnd = dayjs(task.endDate) + const durationMs = realEnd.diff(realStart) + const rangeStart = dayjs(from) + const rangeEnd = dayjs(to) + + const occurrences: PlannedOccurrence[] = [] + const pushIfVisible = (start: dayjs.Dayjs, isGhost: boolean, index: number) => { + const end = start.add(durationMs, 'millisecond') + if (end.isAfter(rangeStart) && start.isBefore(rangeEnd)) { + occurrences.push({ + key: `${task.id}-${index}`, + task, + start: start.toDate(), + end: end.toDate(), + isGhost, + }) + } + } + + pushIfVisible(realStart, false, 0) + + const step = getRepeatStep(task) + if (step === null) { + return occurrences + } + + let {cursor, index} = coarseJump(realStart, step, rangeStart, durationMs) + for (let i = 0; i < MAX_OCCURRENCES; i++) { + cursor = cursor.add(step.amount, step.unit) + index++ + if (cursor.isAfter(rangeEnd)) { + break + } + pushIfVisible(cursor, true, index) + } + + return occurrences +} + +/** + * Whether an all-day task covers `day`, following its recurrence. All-day + * occurrences sit at midnight with zero duration, which expandOccurrences' + * range test excludes, so they get their own day-granular check here. + * Returns isGhost = true when only a projected occurrence (not the stored + * span) lands on the day, so the caller can render it read-only. + */ +export function allDayOccurrenceForDay(task: ITask, day: Date): {covered: boolean, isGhost: boolean} { + if (!task.startDate || !task.endDate) { + return {covered: false, isGhost: false} + } + + const target = dayjs(day).startOf('day') + const realStart = dayjs(task.startDate).startOf('day') + const realEnd = dayjs(task.endDate).startOf('day') + const spanDays = Math.max(realEnd.diff(realStart, 'day'), 0) + const covers = (start: dayjs.Dayjs) => !target.isBefore(start) && !target.isAfter(start.add(spanDays, 'day')) + + if (covers(realStart)) { + return {covered: true, isGhost: false} + } + + const step = getRepeatStep(task) + if (step === null) { + return {covered: false, isGhost: false} + } + + // Back off by the task's span so a long all-day occurrence starting before + // the target day but still covering it isn't jumped over. + let {cursor} = coarseJump(realStart, step, target, spanDays * 24 * 60 * 60 * 1000) + for (let i = 0; i < MAX_OCCURRENCES; i++) { + cursor = cursor.add(step.amount, step.unit) + if (cursor.isAfter(target)) { + break + } + if (covers(cursor)) { + return {covered: true, isGhost: true} + } + } + + return {covered: false, isGhost: false} +} diff --git a/frontend/src/views/planner/helpers/packColumns.test.ts b/frontend/src/views/planner/helpers/packColumns.test.ts new file mode 100644 index 000000000..de45a61a1 --- /dev/null +++ b/frontend/src/views/planner/helpers/packColumns.test.ts @@ -0,0 +1,66 @@ +import {describe, it, expect} from 'vitest' +import {packColumns} from './packColumns' + +interface Interval { + id: string + start: number + end: number +} + +function pack(items: Interval[]) { + return packColumns(items, i => i.start, i => i.end) + .reduce((acc, p) => { + acc[p.item.id] = {col: p.col, cols: p.cols} + return acc + }, {} as Record) +} + +describe('packColumns', () => { + it('gives a single non-overlapping item one full column', () => { + const out = pack([{id: 'a', start: 0, end: 60}]) + expect(out.a).toEqual({col: 0, cols: 1}) + }) + + it('treats touching intervals as non-overlapping', () => { + const out = pack([ + {id: 'a', start: 0, end: 60}, + {id: 'b', start: 60, end: 120}, + ]) + expect(out.a).toEqual({col: 0, cols: 1}) + expect(out.b).toEqual({col: 0, cols: 1}) + }) + + it('splits two overlapping intervals into two columns', () => { + const out = pack([ + {id: 'a', start: 0, end: 60}, + {id: 'b', start: 30, end: 90}, + ]) + expect(out.a).toEqual({col: 0, cols: 2}) + expect(out.b).toEqual({col: 1, cols: 2}) + }) + + it('reuses a freed column within the same cluster', () => { + // a+b overlap (2 cols); c starts after a ends but still overlaps b, + // so the whole run is one cluster of width 2 and c reuses column 0. + const out = pack([ + {id: 'a', start: 0, end: 30}, + {id: 'b', start: 10, end: 90}, + {id: 'c', start: 40, end: 80}, + ]) + expect(out.b.cols).toBe(2) + expect(out.a.col).toBe(0) + expect(out.b.col).toBe(1) + expect(out.c.col).toBe(0) + }) + + it('keeps separate clusters independent', () => { + const out = pack([ + {id: 'a', start: 0, end: 60}, + {id: 'b', start: 10, end: 70}, + {id: 'c', start: 200, end: 260}, + ]) + expect(out.a.cols).toBe(2) + expect(out.b.cols).toBe(2) + expect(out.c).toEqual({col: 0, cols: 1}) + }) +}) diff --git a/frontend/src/views/planner/helpers/packColumns.ts b/frontend/src/views/planner/helpers/packColumns.ts new file mode 100644 index 000000000..f1917c2e9 --- /dev/null +++ b/frontend/src/views/planner/helpers/packColumns.ts @@ -0,0 +1,63 @@ +export interface PackedItem { + item: T + col: number + cols: number +} + +/** + * Lays out overlapping intervals into side-by-side columns (Google-Calendar + * style). Items are grouped into clusters of transitively overlapping + * intervals; within a cluster each item gets the lowest free column index and + * every item in that cluster shares the same total column count. + * + * `getStart`/`getEnd` return comparable numbers (e.g. minutes from midnight). + * Intervals that merely touch (`a.end === b.start`) do not count as overlapping. + */ +export function packColumns( + items: T[], + getStart: (item: T) => number, + getEnd: (item: T) => number, +): PackedItem[] { + const sorted = [...items].sort((a, b) => getStart(a) - getStart(b) || getEnd(a) - getEnd(b)) + + const result: PackedItem[] = [] + let cluster: PackedItem[] = [] + let clusterEnd = -Infinity + let columnEnds: number[] = [] + + const flush = () => { + const cols = columnEnds.length + cluster.forEach(packed => packed.cols = cols) + result.push(...cluster) + cluster = [] + columnEnds = [] + clusterEnd = -Infinity + } + + for (const item of sorted) { + const start = getStart(item) + const end = getEnd(item) + + // A gap to everything placed so far closes the current cluster. + if (start >= clusterEnd && cluster.length > 0) { + flush() + } + + let col = columnEnds.findIndex(colEnd => colEnd <= start) + if (col === -1) { + col = columnEnds.length + columnEnds.push(end) + } else { + columnEnds[col] = end + } + + cluster.push({item, col, cols: 1}) + clusterEnd = Math.max(clusterEnd, end) + } + + if (cluster.length > 0) { + flush() + } + + return result +} diff --git a/frontend/src/views/planner/helpers/taskColor.ts b/frontend/src/views/planner/helpers/taskColor.ts new file mode 100644 index 000000000..7c0b693d8 --- /dev/null +++ b/frontend/src/views/planner/helpers/taskColor.ts @@ -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}` +} diff --git a/frontend/src/views/planner/helpers/types.ts b/frontend/src/views/planner/helpers/types.ts new file mode 100644 index 000000000..d821c19e0 --- /dev/null +++ b/frontend/src/views/planner/helpers/types.ts @@ -0,0 +1,12 @@ +import type {ITask} from '@/modelTypes/ITask' + +// A single concrete placement of a task on the calendar grid. Recurring tasks +// expand into one real occurrence (the stored task) plus dimmed, read-only +// ghost occurrences projected forward across the visible range. +export interface PlannedOccurrence { + key: string + task: ITask + start: Date + end: Date + isGhost: boolean +} diff --git a/frontend/src/views/planner/helpers/useCalendarSettings.ts b/frontend/src/views/planner/helpers/useCalendarSettings.ts new file mode 100644 index 000000000..d580ce97f --- /dev/null +++ b/frontend/src/views/planner/helpers/useCalendarSettings.ts @@ -0,0 +1,32 @@ +import {useStorage} from '@vueuse/core' + +export interface CalendarSettings { + // Working hours ("HH:MM") define the initial zoom/scroll window — the grid + // still renders the full 0–24h so off-hours stay reachable by scrolling. + dayStart: string + dayEnd: string + defaultDurationMinutes: number + slotMinutes: number + showDone: boolean + // true: week aligned to the user's first weekday; false: `daysToShow` days from the anchor. + fullWeek: boolean + // Number of days shown when fullWeek is off (rolling window, 1–31). + daysToShow: number +} + +const DEFAULTS: CalendarSettings = { + dayStart: '08:00', + dayEnd: '18:00', + defaultDurationMinutes: 60, + slotMinutes: 30, + showDone: false, + fullWeek: true, + daysToShow: 7, +} + +// Module-level so every caller shares the same reactive ref within the tab. +const settings = useStorage('planner-settings', {...DEFAULTS}, localStorage, {mergeDefaults: true}) + +export function useCalendarSettings() { + return {settings} +} diff --git a/frontend/src/views/planner/helpers/usePlannerTasks.ts b/frontend/src/views/planner/helpers/usePlannerTasks.ts new file mode 100644 index 000000000..d2dcc2cc3 --- /dev/null +++ b/frontend/src/views/planner/helpers/usePlannerTasks.ts @@ -0,0 +1,270 @@ +import {computed, ref, shallowReactive, watch, type Ref} from 'vue' +import {klona} from 'klona/lite' +import dayjs from 'dayjs' + +import TaskService from '@/services/task' +import type {TaskFilterParams} from '@/services/taskCollection' +import type {ITask, ITaskPartialWithId} from '@/modelTypes/ITask' + +import {useAuthStore} from '@/stores/auth' +import {useTaskStore} from '@/stores/tasks' +import {isoToKebabDate} from '@/helpers/time/isoToKebabDate' +import {error, success} from '@/message' +import {i18n} from '@/i18n' + +export interface PlannerRange { + from: Date + to: Date +} + +// Sidebar sort is a ":" 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(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, sidebarFilter: Ref, sidebarSort: Ref) { + const authStore = useAuthStore() + const taskStore = useTaskStore() + + const gridService = shallowReactive(new TaskService()) + const sidebarService = shallowReactive(new TaskService()) + + const gridTasks = ref>(new Map()) + const sidebarTasks = ref([]) + + 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 { + const tasks = await service.getAll({} as ITask, params, page) as ITask[] + if (page < service.totalPages) { + return tasks.concat(await fetchAll(service, params, page + 1)) + } + return tasks + } + + async function loadGrid() { + const from = isoToKebabDate(range.value.from.toISOString()) + // The backend parses a date-only filter value as start-of-day, so a `<= to` + // bound on the last day's date would drop tasks later that same day. Use the + // day after the last visible day to keep the whole last day inclusive. + const to = isoToKebabDate(dayjs(range.value.to).add(1, 'day').toISOString()) + + const params: TaskFilterParams = { + sort_by: ['start_date', 'id'], + order_by: ['asc', 'desc'], + // Last clause: recurring tasks whose stored start is before the window + // still project occurrences into it (expandOccurrences walks forward), + // so fetch any repeater that started on/before the range end. Only + // repeat_after is filterable (repeat_mode is not), so month-mode tasks + // with repeat_after = 0 aren't caught here. + filter: '(' + + `(start_date >= "${from}" && start_date <= "${to}") || ` + + `(end_date >= "${from}" && end_date <= "${to}") || ` + + `(due_date >= "${from}" && due_date <= "${to}") || ` + + `(start_date <= "${from}" && end_date >= "${to}") || ` + + `(start_date <= "${to}" && repeat_after > 0)` + + ')', + filter_include_nulls: false, + filter_timezone: authStore.settings.timezone, + s: '', + expand: 'subtasks', + } + + const id = ++gridLoadId + try { + const loaded = await fetchAll(gridService, params) + if (id !== gridLoadId) { + return + } + const map = new Map() + loaded.forEach(t => map.set(t.id, t)) + gridTasks.value = map + loadError.value = false + } catch (_) { + if (id === gridLoadId) { + loadError.value = true + error(i18n.global.t('planner.loadError')) + } + } + } + + async function loadSidebar() { + // Combine the user's filter (already API-form from the Filters component) + // with done=false. The v1 filter can't express "date is null", so we keep + // only tasks lacking a start/end client-side. + const userFilter = sidebarFilter.value.filter?.trim() + const filter = userFilter ? `(${userFilter}) && done = false` : 'done = false' + + // Guard against a stale/garbage stored value reaching the API as a bad sort. + const sort = PLANNER_SIDEBAR_SORTS.includes(sidebarSort.value) ? sidebarSort.value : DEFAULT_PLANNER_SIDEBAR_SORT + // 'random' has no backend sort, so fetch in server order and shuffle below. + const random = sort === 'random' + + const params: TaskFilterParams = { + filter, + filter_include_nulls: true, + filter_timezone: authStore.settings.timezone, + s: sidebarFilter.value.s ?? '', + expand: 'subtasks', + } as TaskFilterParams + + // 'none'/'random' send no sort_by, so the server returns its own order. + if (sort !== 'none' && !random) { + const [field, order] = sort.split(':') + // Keep id as the final tiebreaker so the chosen column drives the order. + params.sort_by = [field, 'id'] as TaskFilterParams['sort_by'] + params.order_by = [order, 'desc'] as TaskFilterParams['order_by'] + } + + // Truly unscheduled = no start, end or due date. Due-only tasks already + // render in the all-day row, so keep them out of the sidebar. + const id = ++sidebarLoadId + try { + const loaded = await fetchAll(sidebarService, params) + if (id !== sidebarLoadId) { + return + } + const unscheduled = loaded.filter(task => !task.startDate && !task.endDate && !task.dueDate) + sidebarTasks.value = random ? shuffle(unscheduled) : unscheduled + loadError.value = false + } catch (_) { + if (id === sidebarLoadId) { + loadError.value = true + error(i18n.global.t('planner.loadError')) + } + } + } + + function load() { + return Promise.all([loadGrid(), loadSidebar()]) + } + + watch(range, () => loadGrid(), {immediate: true, deep: true}) + watch([sidebarFilter, sidebarSort], () => loadSidebar(), {immediate: true, deep: true}) + + // Keep both lists in sync with edits made elsewhere (e.g. the task detail + // modal): re-file the task into the grid or sidebar, or drop it if it's now + // done. Only react to tasks the planner already tracks. + watch( + () => taskStore.lastUpdatedTask, + updatedTask => { + if (!updatedTask) { + return + } + const known = gridTasks.value.has(updatedTask.id) + || sidebarTasks.value.some(t => t.id === updatedTask.id) + if (known) { + placeTask(updatedTask) + } + }, + ) + + // Drop a task deleted elsewhere (e.g. the task detail modal opened over the + // planner) from both lists, since the planner stays mounted underneath. + watch( + () => taskStore.lastDeletedTask, + deletedTask => { + if (!deletedTask) { + return + } + gridTasks.value.delete(deletedTask.id) + const index = sidebarTasks.value.findIndex(t => t.id === deletedTask.id) + if (index >= 0) { + sidebarTasks.value.splice(index, 1) + } + }, + ) + + // Put a task into whichever list it now belongs to: the grid if it has any + // date (timed, all-day or due), otherwise the unscheduled sidebar. + function placeTask(task: ITask) { + gridTasks.value.delete(task.id) + const sidebarIndex = sidebarTasks.value.findIndex(t => t.id === task.id) + if (sidebarIndex >= 0) { + sidebarTasks.value.splice(sidebarIndex, 1) + } + + if (task.startDate || task.endDate || task.dueDate) { + gridTasks.value.set(task.id, task) + } else if (!task.done) { + sidebarTasks.value.unshift(task) + } + } + + async function updateTask(partial: ITaskPartialWithId) { + const base = gridTasks.value.get(partial.id) ?? sidebarTasks.value.find(t => t.id === partial.id) + if (!base) return + + const oldTask = klona(base) + const newTask: ITask = {...oldTask, ...partial} + + placeTask(newTask) + + try { + const updated = await taskStore.update(newTask) + placeTask(updated) + success(i18n.global.t('planner.saved')) + } catch (_) { + error(i18n.global.t('planner.saveError')) + placeTask(oldTask) + } + } + + // Place a freshly created task (not yet tracked) onto the grid with the given + // dates and persist them. Used by the create-by-gesture flow, where AddTask + // creates a dateless task that we then schedule into the painted slot. + async function scheduleTask(task: ITask, dates: {startDate: Date | null, endDate: Date | null}) { + const newTask: ITask = {...task, ...dates} + placeTask(newTask) + try { + const updated = await taskStore.update(newTask) + placeTask(updated) + success(i18n.global.t('planner.saved')) + } catch (_) { + error(i18n.global.t('planner.saveError')) + } + } + + return { + gridTasks, + sidebarTasks, + isLoading, + loadError, + load, + updateTask, + scheduleTask, + } +} diff --git a/frontend/src/views/tasks/TaskDetailView.vue b/frontend/src/views/tasks/TaskDetailView.vue index 5396bc797..67b7d3f5c 100644 --- a/frontend/src/views/tasks/TaskDetailView.vue +++ b/frontend/src/views/tasks/TaskDetailView.vue @@ -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}}) } diff --git a/frontend/tests/e2e/planner/planner.spec.ts b/frontend/tests/e2e/planner/planner.spec.ts new file mode 100644 index 000000000..19023030e --- /dev/null +++ b/frontend/tests/e2e/planner/planner.spec.ts @@ -0,0 +1,135 @@ +import {test, expect} from '../../support/fixtures' +import dayjs from 'dayjs' + +import {TaskFactory} from '../../factories/task' +import {ProjectFactory} from '../../factories/project' + +interface Project { + id: number + title: string +} + +// A time inside today that is comfortably away from midnight so the block is +// unambiguously a timed block (not all-day). +function todayAt(hour: number): string { + return dayjs().hour(hour).minute(0).second(0).millisecond(0).toISOString() +} + +test.describe('Planner', () => { + let projects: Project[] + + test.beforeEach(async () => { + projects = await ProjectFactory.create(1) as Project[] + }) + + test('renders the grid and an unscheduled task in the sidebar', async ({authenticatedPage: page}) => { + await TaskFactory.create(1, { + id: 901, + title: 'Unscheduled planner task', + project_id: projects[0].id, + start_date: null, + end_date: null, + due_date: null, + }, false) + + await page.goto('/planner') + + await expect(page.locator('.calendar-grid')).toBeVisible() + await expect(page.locator('.planner-sidebar')).toContainText('Unscheduled planner task') + }) + + test('shows a scheduled task as a timed block', async ({authenticatedPage: page}) => { + await TaskFactory.create(1, { + id: 902, + title: 'Scheduled block task', + project_id: projects[0].id, + start_date: todayAt(10), + end_date: todayAt(11), + }, false) + + await page.goto('/planner') + + await expect(page.locator('.calendar-block')).toContainText('Scheduled block task') + }) + + test('shows a due-only task in the all-day row', async ({authenticatedPage: page}) => { + await TaskFactory.create(1, { + id: 903, + title: 'Due only task', + project_id: projects[0].id, + start_date: null, + end_date: null, + due_date: todayAt(0), + }, false) + + await page.goto('/planner') + + await expect(page.locator('.all-day-chip')).toContainText('Due only task') + }) + + test('toggles between week and day views', async ({authenticatedPage: page}) => { + await page.goto('/planner') + + // Week view shows 7 day headers, day view shows 1. + await expect(page.locator('.day-head')).toHaveCount(7) + await page.getByRole('button', {name: 'Day', exact: true}).click() + await expect(page.locator('.day-head')).toHaveCount(1) + }) + + test('offers a sort control for the unscheduled sidebar', async ({authenticatedPage: page}) => { + await page.goto('/planner') + + const sortSelect = page.locator('.planner-sidebar .sort-select select') + await expect(sortSelect).toBeVisible() + // Includes the client-side random shuffle and excludes nonsensical date sorts. + await expect(sortSelect.locator('option', {hasText: 'Random'})).toHaveCount(1) + }) + + test('double-clicking an empty slot opens the create-task modal', async ({authenticatedPage: page}) => { + await page.goto('/planner') + + await page.locator('.day-column').first().dblclick({position: {x: 20, y: 200}}) + + const dialog = page.locator('.modal-dialog') + await expect(dialog).toContainText('New task') + await expect(dialog.locator('textarea')).toBeVisible() + }) + + test('double-clicking the all-day row opens the create-task modal', async ({authenticatedPage: page}) => { + await page.goto('/planner') + + await page.locator('.all-day-cell').first().dblclick() + + const dialog = page.locator('.modal-dialog') + await expect(dialog).toContainText('All day') + await expect(dialog.locator('textarea')).toBeVisible() + }) + + test('renders the configured number of days in rolling mode', async ({authenticatedPage: page}) => { + // Seed planner settings before the app reads them (mergeDefaults fills the rest). + await page.addInitScript(() => { + localStorage.setItem('planner-settings', JSON.stringify({fullWeek: false, daysToShow: 10})) + }) + + await page.goto('/planner') + + await expect(page.locator('.day-head')).toHaveCount(10) + }) + + test('projects a daily recurring task into the next week', async ({authenticatedPage: page}) => { + await TaskFactory.create(1, { + id: 904, + title: 'Daily standup', + project_id: projects[0].id, + start_date: todayAt(10), + end_date: todayAt(11), + repeat_after: 86400, // one day, in seconds + }, false) + + await page.goto('/planner') + // Next week contains no stored instance — only projected occurrences. + await page.getByRole('button', {name: 'Next', exact: true}).click() + + await expect(page.locator('.calendar-block', {hasText: 'Daily standup'}).first()).toBeVisible() + }) +})