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'), + }, { // 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/views/planner/PlannerSettings.vue b/frontend/src/views/planner/PlannerSettings.vue new file mode 100644 index 000000000..6c6b43801 --- /dev/null +++ b/frontend/src/views/planner/PlannerSettings.vue @@ -0,0 +1,73 @@ + + + + + diff --git a/frontend/src/views/planner/PlannerSidebar.vue b/frontend/src/views/planner/PlannerSidebar.vue new file mode 100644 index 000000000..af933bdf8 --- /dev/null +++ b/frontend/src/views/planner/PlannerSidebar.vue @@ -0,0 +1,169 @@ + + + + + diff --git a/frontend/src/views/planner/PlannerView.vue b/frontend/src/views/planner/PlannerView.vue new file mode 100644 index 000000000..e16013068 --- /dev/null +++ b/frontend/src/views/planner/PlannerView.vue @@ -0,0 +1,270 @@ + + + + + diff --git a/frontend/src/views/planner/grid/CalendarBlock.vue b/frontend/src/views/planner/grid/CalendarBlock.vue new file mode 100644 index 000000000..14fb6e4b1 --- /dev/null +++ b/frontend/src/views/planner/grid/CalendarBlock.vue @@ -0,0 +1,306 @@ + + + + + diff --git a/frontend/src/views/planner/grid/CalendarDayColumn.vue b/frontend/src/views/planner/grid/CalendarDayColumn.vue new file mode 100644 index 000000000..376609933 --- /dev/null +++ b/frontend/src/views/planner/grid/CalendarDayColumn.vue @@ -0,0 +1,140 @@ + + + + + diff --git a/frontend/src/views/planner/grid/CalendarGrid.vue b/frontend/src/views/planner/grid/CalendarGrid.vue new file mode 100644 index 000000000..966be06a0 --- /dev/null +++ b/frontend/src/views/planner/grid/CalendarGrid.vue @@ -0,0 +1,345 @@ + + + + + diff --git a/frontend/src/views/planner/helpers/dayLayout.ts b/frontend/src/views/planner/helpers/dayLayout.ts new file mode 100644 index 000000000..208037325 --- /dev/null +++ b/frontend/src/views/planner/helpers/dayLayout.ts @@ -0,0 +1,67 @@ +import dayjs from 'dayjs' + +import type {ITask} from '@/modelTypes/ITask' +import {expandOccurrences} from './expandOccurrences' +import {packColumns} from './packColumns' +import type {PlannedOccurrence} from './types' + +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): ITask[] { + const target = dayjs(day) + return tasks.filter(task => { + if (isAllDayTask(task)) { + return !target.isBefore(dayjs(task.startDate), 'day') && !target.isAfter(dayjs(task.endDate), 'day') + } + // due-only tasks (no time block) show on their due day + return !task.startDate && !task.endDate && task.dueDate && target.isSame(dayjs(task.dueDate), 'day') + }) +} 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..7290ef428 --- /dev/null +++ b/frontend/src/views/planner/helpers/expandOccurrences.test.ts @@ -0,0 +1,75 @@ +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('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..9cabdbaa6 --- /dev/null +++ b/frontend/src/views/planner/helpers/expandOccurrences.ts @@ -0,0 +1,90 @@ +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]} +} + +/** + * 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 = realStart + for (let i = 1; i <= MAX_OCCURRENCES; i++) { + cursor = cursor.add(step.amount, step.unit) + if (cursor.isAfter(rangeEnd)) { + break + } + pushIfVisible(cursor, true, i) + } + + return occurrences +} 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/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..ac341d1a7 --- /dev/null +++ b/frontend/src/views/planner/helpers/useCalendarSettings.ts @@ -0,0 +1,29 @@ +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: 7 days from today. + fullWeek: boolean +} + +const DEFAULTS: CalendarSettings = { + dayStart: '08:00', + dayEnd: '18:00', + defaultDurationMinutes: 60, + slotMinutes: 30, + showDone: false, + fullWeek: true, +} + +// 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..ee33121b0 --- /dev/null +++ b/frontend/src/views/planner/helpers/usePlannerTasks.ts @@ -0,0 +1,153 @@ +import {computed, ref, shallowReactive, watch, type Ref} from 'vue' +import {klona} from 'klona/lite' + +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 +} + +export function usePlannerTasks(range: Ref, sidebarFilter: 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) + + 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()) + const to = isoToKebabDate(range.value.to.toISOString()) + + const params: TaskFilterParams = { + sort_by: ['start_date', 'id'], + order_by: ['asc', 'desc'], + 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}")` + + ')', + filter_include_nulls: false, + filter_timezone: authStore.settings.timezone, + s: '', + expand: 'subtasks', + } + + const loaded = await fetchAll(gridService, params) + const map = new Map() + loaded.forEach(t => map.set(t.id, t)) + gridTasks.value = map + } + + 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' + + 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', + } + + // 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) + } + + function load() { + return Promise.all([loadGrid(), loadSidebar()]) + } + + watch(range, () => loadGrid(), {immediate: true, deep: true}) + watch(sidebarFilter, () => 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) + } + }, + ) + + // 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) + } + } + + return { + gridTasks, + sidebarTasks, + isLoading, + load, + updateTask, + } +} diff --git a/frontend/tests/e2e/planner/planner.spec.ts b/frontend/tests/e2e/planner/planner.spec.ts new file mode 100644 index 000000000..5b60e2a1e --- /dev/null +++ b/frontend/tests/e2e/planner/planner.spec.ts @@ -0,0 +1,78 @@ +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) + }) +})