diff --git a/frontend/src/i18n/lang/en.json b/frontend/src/i18n/lang/en.json index 3d5b53141..ccaa684b6 100644 --- a/frontend/src/i18n/lang/en.json +++ b/frontend/src/i18n/lang/en.json @@ -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" } }, diff --git a/frontend/src/router/index.ts b/frontend/src/router/index.ts index fed0382b9..cbc88de9e 100644 --- a/frontend/src/router/index.ts +++ b/frontend/src/router/index.ts @@ -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 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 @@ + + + + + {{ context }} + + $emit('created', task)" + /> + + + + + + + diff --git a/frontend/src/views/planner/PlannerSettings.vue b/frontend/src/views/planner/PlannerSettings.vue index 6c6b43801..989b6b156 100644 --- a/frontend/src/views/planner/PlannerSettings.vue +++ b/frontend/src/views/planner/PlannerSettings.vue @@ -8,14 +8,20 @@ {{ $t('planner.settings.dayStart') }} {{ $t('planner.settings.dayEnd') }} @@ -39,6 +45,19 @@ {{ $t('planner.settings.fullWeek') }} + + {{ $t('planner.settings.daysToShow') }} + + {{ $t('planner.settings.showDone') }} @@ -47,12 +66,27 @@ diff --git a/frontend/src/views/planner/PlannerView.vue b/frontend/src/views/planner/PlannerView.vue index e16013068..a4b845e4d 100644 --- a/frontend/src/views/planner/PlannerView.vue +++ b/frontend/src/views/planner/PlannerView.vue @@ -68,6 +68,7 @@ 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" /> + + diff --git a/frontend/src/views/planner/grid/CalendarBlock.vue b/frontend/src/views/planner/grid/CalendarBlock.vue index 14fb6e4b1..49e48b2d2 100644 --- a/frontend/src/views/planner/grid/CalendarBlock.vue +++ b/frontend/src/views/planner/grid/CalendarBlock.vue @@ -9,10 +9,26 @@ 'is-moving': isMoving, }" :style="blockStyle" + :title="tooltip" @pointerdown="onMovePointerDown" > {{ timeLabel }} {{ occurrence.task.title }} + + {{ projectName }} + + {{ Math.round(occurrence.task.percentDone * 100) }}% + { 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 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; } diff --git a/frontend/src/views/planner/grid/CalendarDayColumn.vue b/frontend/src/views/planner/grid/CalendarDayColumn.vue index 376609933..26c8af9b2 100644 --- a/frontend/src/views/planner/grid/CalendarDayColumn.vue +++ b/frontend/src/views/planner/grid/CalendarDayColumn.vue @@ -7,6 +7,8 @@ @dragover.prevent="isDropTarget = true" @dragleave="isDropTarget = false" @drop="onDrop" + @dblclick="onDblClick" + @pointerdown="onCreatePointerDown" > + + () const columnEl = ref(null) const isDropTarget = ref(false) const now = ref(new Date()) +const selStart = ref(null) +const selEnd = ref(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 | 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) } @@ -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; diff --git a/frontend/src/views/planner/grid/CalendarGrid.vue b/frontend/src/views/planner/grid/CalendarGrid.vue index 966be06a0..e3d0d2d05 100644 --- a/frontend/src/views/planner/grid/CalendarGrid.vue +++ b/frontend/src/views/planner/grid/CalendarGrid.vue @@ -1,8 +1,13 @@ - + - {{ task.title }} + {{ item.task.title }} @@ -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})" /> @@ -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 | 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 { diff --git a/frontend/src/views/planner/helpers/dayLayout.ts b/frontend/src/views/planner/helpers/dayLayout.ts index 208037325..544455c8b 100644 --- a/frontend/src/views/planner/helpers/dayLayout.ts +++ b/frontend/src/views/planner/helpers/dayLayout.ts @@ -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 } diff --git a/frontend/src/views/planner/helpers/expandOccurrences.ts b/frontend/src/views/planner/helpers/expandOccurrences.ts index 9cabdbaa6..1f17c05ff 100644 --- a/frontend/src/views/planner/helpers/expandOccurrences.ts +++ b/frontend/src/views/planner/helpers/expandOccurrences.ts @@ -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} +} diff --git a/frontend/src/views/planner/helpers/useCalendarSettings.ts b/frontend/src/views/planner/helpers/useCalendarSettings.ts index ac341d1a7..d580ce97f 100644 --- a/frontend/src/views/planner/helpers/useCalendarSettings.ts +++ b/frontend/src/views/planner/helpers/useCalendarSettings.ts @@ -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. diff --git a/frontend/src/views/planner/helpers/usePlannerTasks.ts b/frontend/src/views/planner/helpers/usePlannerTasks.ts index ee33121b0..a6bae8d63 100644 --- a/frontend/src/views/planner/helpers/usePlannerTasks.ts +++ b/frontend/src/views/planner/helpers/usePlannerTasks.ts @@ -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, sidebarFilter: Ref) { +// 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() @@ -38,16 +71,25 @@ export function usePlannerTasks(range: Ref, sidebarFilter: Ref= "${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, sidebarFilter: Ref !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, sidebarFilter: Ref 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, sidebarFilter: Ref { 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() + }) })
+ {{ context }} +