From 19e5ca5077b81e6b66ed24b5ab83ccf25478d612 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Wed, 4 Nov 2026 08:02:27 +0100 Subject: [PATCH 01/32] fix: list the tasks of task-only lists and turn tasks on by default MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The tasks view filtered tasks by the grid's calendar selection, which never holds a task-only list such as "To-do". Those tasks never showed while the sidebar counted them, and a task moved into To-do vanished on save. The tasks view now has its own task lists in the sidebar, ticked by default, and the grid's selection is left alone. - tasks are on by default; an "off" the user never chose turns on, one they chose stays - new tasks go to the first task-only list - "Add a task…" reads a date at the end of the title (tomorrow, Friday, 12.11.) into the due date and shows it as a chip first - the task status says "Not started" and "In progress" filterTasksForList now takes the unticked task lists instead of the selected calendars, so task-plugin-surfaces passes the unticked list and task-list-dates no longer passes selectedCalendarIds. --- .../__tests__/quick-task-due-ux-pim.test.tsx | 49 ++++++ .../__tests__/task-list-dates.test.tsx | 2 +- .../__tests__/task-lists-ux-pim.test.tsx | 129 ++++++++++++++ .../__tests__/task-plugin-surfaces.test.tsx | 5 +- components/calendar/calendar-app.tsx | 18 +- components/calendar/calendar-print.tsx | 6 +- .../calendar/calendar-sidebar-panel.tsx | 60 +++++-- components/calendar/task-list-view.tsx | 162 ++++++++++++------ components/calendar/task-modal.tsx | 10 +- lib/__tests__/task-due-parser.test.ts | 57 ++++++ lib/task-due-parser.ts | 160 +++++++++++++++++ locales/ar/common.json | 20 +++ locales/ca/common.json | 20 +++ locales/cs/common.json | 20 +++ locales/da/common.json | 20 +++ locales/de/common.json | 20 +++ locales/en/common.json | 20 +++ locales/es/common.json | 20 +++ locales/et/common.json | 20 +++ locales/fa/common.json | 20 +++ locales/fr/common.json | 20 +++ locales/he/common.json | 20 +++ locales/hu/common.json | 20 +++ locales/it/common.json | 20 +++ locales/ja/common.json | 20 +++ locales/ko/common.json | 20 +++ locales/lv/common.json | 20 +++ locales/mn/common.json | 20 +++ locales/nb/common.json | 20 +++ locales/nl/common.json | 20 +++ locales/pl/common.json | 20 +++ locales/pt/common.json | 20 +++ locales/ro/common.json | 20 +++ locales/ru/common.json | 20 +++ locales/sk/common.json | 20 +++ locales/th/common.json | 20 +++ locales/tr/common.json | 20 +++ locales/uk/common.json | 20 +++ locales/vi/common.json | 20 +++ locales/zh-TW/common.json | 20 +++ locales/zh/common.json | 20 +++ .../settings-calendar-tasks-default.test.ts | 49 ++++++ stores/calendar-store.ts | 20 ++- stores/settings-store.ts | 21 ++- 44 files changed, 1264 insertions(+), 84 deletions(-) create mode 100644 components/calendar/__tests__/quick-task-due-ux-pim.test.tsx create mode 100644 components/calendar/__tests__/task-lists-ux-pim.test.tsx create mode 100644 lib/__tests__/task-due-parser.test.ts create mode 100644 lib/task-due-parser.ts create mode 100644 stores/__tests__/settings-calendar-tasks-default.test.ts diff --git a/components/calendar/__tests__/quick-task-due-ux-pim.test.tsx b/components/calendar/__tests__/quick-task-due-ux-pim.test.tsx new file mode 100644 index 000000000..75cca0be4 --- /dev/null +++ b/components/calendar/__tests__/quick-task-due-ux-pim.test.tsx @@ -0,0 +1,49 @@ +import { render, screen, fireEvent } from '@testing-library/react'; +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { TaskListView } from '../task-list-view'; +import { useCalendarStore } from '@/stores/calendar-store'; + +// pim-24: "Call the plumber tomorrow" in "Add a task…" made a task with that +// title and no due date. The date at the end is now the due date, shown as +// a chip before the task is made. + +beforeEach(() => { + vi.useFakeTimers({ toFake: ['Date'] }); + vi.setSystemTime(new Date(2026, 10, 4, 9, 0)); + useCalendarStore.setState({ hiddenTaskListIds: [] } as never); +}); +afterEach(() => vi.useRealTimers()); + +function setup() { + const onQuickCreate = vi.fn(); + render( {}} onToggleComplete={() => {}} onQuickCreate={onQuickCreate} />); + const input = screen.getByLabelText('tasks.quick_add_placeholder'); + return { input, onQuickCreate }; +} + +describe('quick task due date (pim-24)', () => { + it('shows the date as a chip and makes the task due then, without the word', () => { + const { input, onQuickCreate } = setup(); + fireEvent.change(input, { target: { value: 'Call the plumber tomorrow' } }); + expect(screen.getByTestId('quick-task-due')).toHaveTextContent('quick_task_due'); + fireEvent.keyDown(input, { key: 'Enter' }); + expect(onQuickCreate).toHaveBeenCalledWith('Call the plumber', '2026-11-05'); + }); + + it('keeps the words in the title when the chip is dismissed', () => { + const { input, onQuickCreate } = setup(); + fireEvent.change(input, { target: { value: 'Call the plumber tomorrow' } }); + fireEvent.click(screen.getByRole('button', { name: 'quick_task_due_remove' })); + expect(screen.queryByTestId('quick-task-due')).toBeNull(); + fireEvent.keyDown(input, { key: 'Enter' }); + expect(onQuickCreate).toHaveBeenCalledWith('Call the plumber tomorrow', null); + }); + + it('makes a task without a date as before', () => { + const { input, onQuickCreate } = setup(); + fireEvent.change(input, { target: { value: 'Water the plants' } }); + expect(screen.queryByTestId('quick-task-due')).toBeNull(); + fireEvent.keyDown(input, { key: 'Enter' }); + expect(onQuickCreate).toHaveBeenCalledWith('Water the plants', null); + }); +}); diff --git a/components/calendar/__tests__/task-list-dates.test.tsx b/components/calendar/__tests__/task-list-dates.test.tsx index ef2738e39..f87924f08 100644 --- a/components/calendar/__tests__/task-list-dates.test.tsx +++ b/components/calendar/__tests__/task-list-dates.test.tsx @@ -19,7 +19,7 @@ const calendars = [{ id: 'cal', name: 'Privat', color: '#123456' }] as unknown a describe('TaskListView due dates', () => { it('writes the due date in the app language', () => { const task = { id: 't', title: 'Steuer', calendarIds: { cal: true }, progress: 'needs-action', priority: 0, due: '2031-10-30', showWithoutTime: true } as unknown as CalendarTask; - render( {}} onToggleComplete={() => {}} />); + render( {}} onToggleComplete={() => {}} />); expect(screen.getByText('30. Okt.')).toBeInTheDocument(); expect(screen.queryByText('Oct 30')).toBeNull(); }); diff --git a/components/calendar/__tests__/task-lists-ux-pim.test.tsx b/components/calendar/__tests__/task-lists-ux-pim.test.tsx new file mode 100644 index 000000000..31f20d643 --- /dev/null +++ b/components/calendar/__tests__/task-lists-ux-pim.test.tsx @@ -0,0 +1,129 @@ +import { render, screen, fireEvent, within } from '@testing-library/react'; +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { TaskListView, defaultTaskList, taskListsOf } from '../task-list-view'; +import { CalendarSidebarPanel } from '../calendar-sidebar-panel'; +import { TaskModal } from '../task-modal'; +import { useCalendarStore } from '@/stores/calendar-store'; +import { useSettingsStore } from '@/stores/settings-store'; +import { useTaskStore } from '@/stores/task-store'; +import type { Calendar, CalendarTask } from '@/lib/jmap/types'; +import type { IJMAPClient } from '@/lib/jmap/client-interface'; + +// pim-1: tasks in a task-only list ("To-do", as Thunderbird, Apple Reminders +// and DAVx⁵ make them) never showed, while the sidebar counted them: the +// list filtered by the grid's calendar selection, which leaves task-only +// lists out. A task moved into To-do vanished on save. + +function cal(id: string, name: string, extra: Partial = {}): Calendar { + return { + id, name, color: '#3b82f6', sortOrder: 0, isSubscribed: true, isVisible: true, isDefault: false, + includeInAvailability: 'all', timeZone: null, shareWith: null, + myRights: { mayReadItems: true, mayWriteAll: true, mayWriteOwn: true, mayUpdatePrivate: true, mayRSVP: true, mayShare: true, mayDelete: true }, + ...extra, + } as unknown as Calendar; +} + +function task(id: string, calendarId: string, extra: Partial = {}): CalendarTask { + return { + id, calendarIds: { [calendarId]: true }, '@type': 'Task', uid: id, title: id, description: '', + due: null, start: null, duration: null, timeZone: null, showWithoutTime: true, progress: 'needs-action', + priority: 0, privacy: 'public', keywords: null, categories: null, color: null, created: null, + updated: '2026-11-01T00:00:00Z', recurrenceRules: null, alerts: null, relatedTo: null, + ...extra, + } as CalendarTask; +} + +const home = cal('home', 'Home', { isDefault: true }); +const todo = cal('todo', 'To-do', { isTasksOnly: true }); +const work = cal('work', 'Work'); + +beforeEach(() => { + // The grid shows Home and Work, as fetchCalendars leaves it: never To-do. + useCalendarStore.setState({ calendars: [home, todo, work], selectedCalendarIds: ['home', 'work'], hiddenTaskListIds: [], viewMode: 'tasks' } as never); + useSettingsStore.setState({ enableCalendarTasks: true } as never); + useTaskStore.setState({ tasks: [] } as never); +}); + +const listed = () => Array.from(document.querySelectorAll('span[title^="t-"]')).map((el) => el.getAttribute('title')); + +describe('the task list (pim-1)', () => { + const tasks = [ + task('t-open', 'todo', { due: '2020-01-01T10:00:00' }), + task('t-done', 'todo', { progress: 'completed' }), + task('t-work', 'work'), + ]; + + it('lists tasks of a task-only list in every filter', () => { + const { rerender } = render( {}} onToggleComplete={() => {}} />); + expect(listed()).toEqual(expect.arrayContaining(['t-open', 't-done', 't-work'])); + rerender( {}} onToggleComplete={() => {}} />); + expect(listed()).toEqual(expect.arrayContaining(['t-open', 't-work'])); + rerender( {}} onToggleComplete={() => {}} />); + expect(listed()).toEqual(['t-open']); + rerender( {}} onToggleComplete={() => {}} />); + expect(listed()).toEqual(['t-done']); + }); + + it('keeps a task moved into To-do in the list', () => { + const { rerender } = render( {}} onToggleComplete={() => {}} />); + expect(listed()).toEqual(['t-moved']); + rerender( {}} onToggleComplete={() => {}} />); + expect(listed()).toEqual(['t-moved']); + }); + + it('leaves out the lists the user unticked, not the calendars the grid hides', () => { + useCalendarStore.setState({ hiddenTaskListIds: ['todo'], selectedCalendarIds: [] } as never); + render( {}} onToggleComplete={() => {}} />); + expect(listed()).toEqual(['t-work']); + }); + + it('offers task-only lists and calendars holding tasks as task lists', () => { + expect(taskListsOf([home, todo, work], tasks).map((c) => c.id)).toEqual(['todo', 'work']); + }); +}); + +describe('the sidebar in the tasks view (pim-1)', () => { + const client = { getAccountId: () => 'a' } as unknown as IJMAPClient; + + it('lists the task lists, ticked, and unticking one hides its tasks', () => { + useTaskStore.setState({ tasks: [task('t-open', 'todo')] } as never); + render( {}} client={client} />); + const section = screen.getByTestId('task-lists-section'); + const item = within(section).getByRole('checkbox', { name: /To-do/ }); + expect(item).toHaveAttribute('aria-checked', 'true'); + // The event calendars aren't offered here: they only steer the grid. + expect(screen.queryByTestId('calendar-item')).toBeNull(); + fireEvent.click(item); + expect(useCalendarStore.getState().hiddenTaskListIds).toEqual(['todo']); + expect(useCalendarStore.getState().selectedCalendarIds).toEqual(['home', 'work']); + }); + + it('shows the event calendars again outside the tasks view', () => { + useCalendarStore.setState({ viewMode: 'month' } as never); + render( {}} client={client} />); + expect(screen.queryByTestId('task-lists-section')).toBeNull(); + expect(screen.getAllByTestId('calendar-item').map((el) => el.dataset.calendarName)).toEqual(['Home', 'Work']); + }); +}); + +describe('where a new task goes (pim-8)', () => { + it('picks the first task-only list, else the default calendar', () => { + expect(defaultTaskList([home, work, todo])?.id).toBe('todo'); + expect(defaultTaskList([work, home])?.id).toBe('home'); + expect(defaultTaskList([work])?.id).toBe('work'); + }); + + it('opens the task form on the task-only list', () => { + render( {}} />); + expect((screen.getByLabelText('tasks.calendar') as HTMLSelectElement).value).toBe('todo'); + }); +}); + +describe('task status words (pim-19)', () => { + it('says "Not started" and "In progress" and keeps the stored values', () => { + render( {}} />); + const select = screen.getByLabelText('tasks.progress') as HTMLSelectElement; + const options = Array.from(select.options).map((o) => [o.value, o.textContent]); + expect(options.slice(0, 2)).toEqual([['needs-action', 'task_status_not_started'], ['in-process', 'task_status_in_progress']]); + }); +}); diff --git a/components/calendar/__tests__/task-plugin-surfaces.test.tsx b/components/calendar/__tests__/task-plugin-surfaces.test.tsx index eabfe086f..3352fc27d 100644 --- a/components/calendar/__tests__/task-plugin-surfaces.test.tsx +++ b/components/calendar/__tests__/task-plugin-surfaces.test.tsx @@ -103,7 +103,8 @@ describe('task-list-actions', () => { makeTask('soon', { due: '2030-01-01' }), ]; // All is all of them: completed ones too, at the end. - expect(filterTasksForList(tasks, ['cal-1'], 'all').map((t) => t.id)).toEqual(['soon', 'later', 'done']); - expect(filterTasksForList(tasks, ['cal-1'], 'pending').map((t) => t.id)).toEqual(['soon', 'later']); + // The second argument names the task lists the user unticked (pim-1). + expect(filterTasksForList(tasks, ['cal-2'], 'all').map((t) => t.id)).toEqual(['soon', 'later', 'done']); + expect(filterTasksForList(tasks, ['cal-2'], 'pending').map((t) => t.id)).toEqual(['soon', 'later']); }); }); diff --git a/components/calendar/calendar-app.tsx b/components/calendar/calendar-app.tsx index 291c715b6..7930c9ac7 100644 --- a/components/calendar/calendar-app.tsx +++ b/components/calendar/calendar-app.tsx @@ -29,7 +29,7 @@ import { CalendarMonthView } from "@/components/calendar/calendar-month-view"; import { CalendarWeekView } from "@/components/calendar/calendar-week-view"; import { CalendarDayView } from "@/components/calendar/calendar-day-view"; import { CalendarAgendaView } from "@/components/calendar/calendar-agenda-view"; -import { TaskListView, filterTasksForList } from "@/components/calendar/task-list-view"; +import { TaskListView, filterTasksForList, defaultTaskList } from "@/components/calendar/task-list-view"; import { CalendarLoadErrorNotice } from "@/components/calendar/calendar-load-error-notice"; import { TaskToolbar } from "@/components/calendar/task-toolbar"; import { PluginTaskView, usePluginTaskView } from "@/components/plugins/plugin-task-view"; @@ -1794,10 +1794,11 @@ export function CalendarApp({ linkSegments: routeSegments }: CalendarAppProps = // The plugin drawing the task view's list, if one offers it. const pluginTaskView = usePluginTaskView(); + const hiddenTaskListIds = useCalendarStore((s) => s.hiddenTaskListIds); // The tasks the task list shows; plugin buttons in its toolbar act on them. const listedTaskIds = useMemo( - () => filterTasksForList(taskStore.tasks, selectedCalendarIds, taskStore.filter).map((task) => task.id), - [taskStore.tasks, selectedCalendarIds, taskStore.filter], + () => filterTasksForList(taskStore.tasks, hiddenTaskListIds, taskStore.filter).map((task) => task.id), + [taskStore.tasks, hiddenTaskListIds, taskStore.filter], ); // Tasks on the month, week and day grids follow the calendar filter like @@ -2017,20 +2018,19 @@ export function CalendarApp({ linkSegments: routeSegments }: CalendarAppProps = { - // Into a calendar that takes it: the default one, else the - // first the user may write to. - const target = writableTaskCalendars.find((c) => c.isDefault) ?? writableTaskCalendars[0]; + onQuickCreate={(title, due) => { + // Into the first task-only list, else the default calendar, + // else the first the user may write to (pim-8). + const target = defaultTaskList(writableTaskCalendars); if (!client || !target) { toast.error(t("notifications.task_save_error")); return; } - taskStore.createTask(client, { "@type": "Task", title, progress: "needs-action", calendarIds: { [target.id]: true } }) + taskStore.createTask(client, { "@type": "Task", title, progress: "needs-action", calendarIds: { [target.id]: true }, ...(due ? { due, showWithoutTime: true } : {}) }) .then((created) => { if (created) toast.success(t("notifications.task_created")); }) .catch((error: unknown) => toast.error(describeError(error, t("notifications.task_save_error")))); }} diff --git a/components/calendar/calendar-print.tsx b/components/calendar/calendar-print.tsx index e47299a47..6d9892263 100644 --- a/components/calendar/calendar-print.tsx +++ b/components/calendar/calendar-print.tsx @@ -4,7 +4,7 @@ import { useMemo, type CSSProperties } from "react"; import { useLocale, useTranslations } from "next-intl"; import { addDays, format, isSameDay, parseISO } from "date-fns"; import type { Calendar, CalendarEvent, CalendarTask } from "@/lib/jmap/types"; -import type { CalendarViewMode } from "@/stores/calendar-store"; +import { useCalendarStore, type CalendarViewMode } from "@/stores/calendar-store"; import { useSettingsStore } from "@/stores/settings-store"; import { useCalendarLocale } from "@/hooks/use-calendar-locale"; import { useDisplayDateFormatter } from "@/hooks/use-display-date-formatter"; @@ -98,6 +98,8 @@ export function CalendarPrint(props: CalendarPrintProps) { const locale = useLocale(); const { formatMonthYear, formatWeekRange, formatFullDate } = useCalendarLocale(); const { content, viewMode } = props; + // The tasks view's own lists, as the list on screen (pim-1). + const hiddenTaskListIds = useCalendarStore((s) => s.hiddenTaskListIds); const calendarMap = useMemo(() => new Map(props.calendars.map((c) => [c.id, c])), [props.calendars]); @@ -211,7 +213,7 @@ export function CalendarPrint(props: CalendarPrintProps) { {header(title)} diff --git a/components/calendar/calendar-sidebar-panel.tsx b/components/calendar/calendar-sidebar-panel.tsx index 8b14360de..6fd2bbc9e 100644 --- a/components/calendar/calendar-sidebar-panel.tsx +++ b/components/calendar/calendar-sidebar-panel.tsx @@ -19,6 +19,7 @@ import { ContextMenu, ContextMenuItem, ContextMenuSeparator, ContextMenuSubMenu import { useContextMenu } from "@/hooks/use-context-menu"; import { ConfirmDialog } from "@/components/ui/confirm-dialog"; import { useConfirmDialog } from "@/hooks/use-confirm-dialog"; +import { taskListsOf } from "@/components/calendar/task-list-view"; import type { IJMAPClient } from '@/lib/jmap/client-interface'; import { displayNow } from "@/lib/timezone"; import { readableTextOn } from "@/lib/color-transform"; @@ -93,6 +94,7 @@ export function CalendarSidebarPanel({ multiAccountMode, }: CalendarSidebarPanelProps) { const t = useTranslations("calendar"); + const tUx = useTranslations("ux_pim_calendar"); const paint = useCvdColor(); const tSub = useTranslations("calendar.subscription"); const tMgmt = useTranslations("calendar.management"); @@ -107,6 +109,9 @@ export function CalendarSidebarPanel({ const refreshICalSubscription = useCalendarStore((s) => s.refreshICalSubscription); const removeICalSubscription = useCalendarStore((s) => s.removeICalSubscription); const setDefaultCalendar = useCalendarStore((s) => s.setDefaultCalendar); + const viewMode = useCalendarStore((s) => s.viewMode); + const hiddenTaskListIds = useCalendarStore((s) => s.hiddenTaskListIds); + const toggleTaskListVisibility = useCalendarStore((s) => s.toggleTaskListVisibility); const timeFormat = useSettingsStore((s) => s.timeFormat); const sharedCalendarColors = useSettingsStore((s) => s.sharedCalendarColors); const enableCalendarTasks = useSettingsStore((s) => s.enableCalendarTasks); @@ -268,6 +273,25 @@ export function CalendarSidebarPanel({ } }; + // The tasks view lists task lists, each with its own tick (pim-1): a + // task-only list never shows in the grid, so the grid's selection can't + // say which tasks to list. + const inTasksView = enableCalendarTasks && viewMode === 'tasks'; + const taskLists = useMemo(() => (inTasksView ? taskListsOf(calendars, tasks) : []), [inTasksView, calendars, tasks]); + const accountLabel = (localAccountId: string) => { + const acct = localAccounts.find(a => a.id === localAccountId); + return acct?.label || acct?.email || acct?.username || localAccountId; + }; + const taskListGroups = useMemo(() => { + if (!multiAccountMode) return [{ key: '__all__', label: '', lists: taskLists }]; + const groups = new Map(); + for (const cal of taskLists) { + const key = cal.localAccountId || '__other__'; + groups.set(key, [...(groups.get(key) ?? []), cal]); + } + return Array.from(groups.entries()).map(([key, lists]) => ({ key, label: key === '__other__' ? '' : key, lists })); + }, [multiAccountMode, taskLists]); + const handleSetDefault = async (calendarId: string) => { if (!client) return; try { @@ -280,8 +304,9 @@ export function CalendarSidebarPanel({ if (calendars.length === 0 && !onSubscribe) return null; - const renderCalendarItem = (cal: Calendar) => { - const isVisible = selectedCalendarIds.includes(cal.id); + const renderCalendarItem = (cal: Calendar, asTaskList = false) => { + const isVisible = asTaskList ? !hiddenTaskListIds.includes(cal.id) : selectedCalendarIds.includes(cal.id); + const onToggle = asTaskList ? () => toggleTaskListVisibility(cal.id) : () => onToggleVisibility(cal.id); const color = paint(cal.color || "#3b82f6"); // A company calendar can only be shown or hidden: no menu. const hasMenu = cal.isCompany ? false : isSubscriptionCalendar(cal.id) ? !!client : true; @@ -289,7 +314,7 @@ export function CalendarSidebarPanel({ return (
@@ -533,7 +558,22 @@ export function CalendarSidebarPanel({ )} )} - {multiAccountMode && localAccountGroups.length > 0 ? ( + {inTasksView ? ( +
+ {renderSectionHeader('__task_lists__', tUx('task_lists'))} + {!collapsedAccountGroups.has('__task_lists__') && taskListGroups.map((group) => ( +
+ {group.label && ( +
+
+ )} + {group.lists.map((cal) => renderCalendarItem(cal, true))} +
+ ))} +
+ ) : multiAccountMode && localAccountGroups.length > 0 ? ( <> {localAccountGroups.map((group, idx) => { const expanded = !collapsedAccountGroups.has(group.key); @@ -581,7 +621,7 @@ export function CalendarSidebarPanel({ {t('my_calendars')}
- {owned.map(renderCalendarItem)} + {owned.map((cal) => renderCalendarItem(cal))}
)} @@ -592,7 +632,7 @@ export function CalendarSidebarPanel({ {sg.label}
- {sg.calendars.map(renderCalendarItem)} + {sg.calendars.map((cal) => renderCalendarItem(cal))}
))} @@ -627,7 +667,7 @@ export function CalendarSidebarPanel({ {group.accountName}
- {group.calendars.map(renderCalendarItem)} + {group.calendars.map((cal) => renderCalendarItem(cal))}
))} diff --git a/components/calendar/task-list-view.tsx b/components/calendar/task-list-view.tsx index e1c766d78..5bfdbff66 100644 --- a/components/calendar/task-list-view.tsx +++ b/components/calendar/task-list-view.tsx @@ -1,26 +1,113 @@ "use client"; -import { useMemo, useCallback, useState } from "react"; -import { useTranslations } from "next-intl"; +import { useMemo, useCallback, useId, useState } from "react"; +import { useLocale, useTranslations } from "next-intl"; import { parseISO, isBefore, isTomorrow } from "date-fns"; import { displayNow, isDisplayToday } from "@/lib/timezone"; -import { Check, Flag, CalendarDays, ListTodo, Plus } from "@/components/icons"; +import { Check, Flag, CalendarDays, ListTodo, Plus, X } from "@/components/icons"; import { cn } from "@/lib/utils"; import type { CalendarTask, Calendar } from "@/lib/jmap/types"; import type { TaskViewFilter } from "@/stores/task-store"; import { useSettingsStore } from "@/stores/settings-store"; +import { useCalendarStore } from "@/stores/calendar-store"; import { useDisplayDateFormatter } from "@/hooks/use-display-date-formatter"; import { useCvdColor } from "@/hooks/use-cvd-color"; +import { parseTaskDue } from "@/lib/task-due-parser"; interface TaskListViewProps { tasks: CalendarTask[]; calendars: Calendar[]; - selectedCalendarIds: string[]; filter: TaskViewFilter; onSelectTask: (task: CalendarTask) => void; onToggleComplete: (task: CalendarTask) => void; selectedTaskId?: string | null; - onQuickCreate?: (title: string) => void; + /** `due`: the date read from the end of the title (yyyy-MM-dd), or null. */ + onQuickCreate?: (title: string, due: string | null) => void; +} + +/** + * Where a new task goes (pim-8): the first task-only list, else the default + * calendar, else the first one. `calendars` are the ones the user may write. + */ +export function defaultTaskList(calendars: Calendar[]): Calendar | undefined { + return calendars.find((c) => c.isTasksOnly) ?? calendars.find((c) => c.isDefault) ?? calendars[0]; +} + +/** + * The task lists the tasks view offers (pim-1): every task-only calendar and + * every calendar that holds a task, in the calendars' order. + */ +export function taskListsOf(calendars: Calendar[], tasks: CalendarTask[]): Calendar[] { + const holding = new Set(tasks.flatMap((task) => Object.keys(task.calendarIds ?? {}))); + return calendars.filter((c) => c.isTasksOnly || holding.has(c.id)); +} + +/** + * "Add a task…": reads a date at the end of the title (pim-24) and shows it + * as a chip before the task is made; the chip's × keeps the words in the + * title instead. + */ +function QuickAddTask({ onQuickCreate }: { onQuickCreate: (title: string, due: string | null) => void }) { + const t = useTranslations("calendar"); + const tUx = useTranslations("ux_pim_calendar"); + const locale = useLocale(); + const { dateTime: formatDate } = useDisplayDateFormatter(); + const dueId = useId(); + const [title, setTitle] = useState(""); + // The text whose date the user turned down with the chip's ×. + const [declined, setDeclined] = useState(null); + const parsed = useMemo( + () => (title.trim() && title !== declined ? parseTaskDue(title, locale, displayNow()) : null), + [title, declined, locale], + ); + + const submit = () => { + const trimmed = title.trim(); + if (!trimmed) return; + if (parsed) onQuickCreate(parsed.title, parsed.due); + else onQuickCreate(trimmed, null); + setTitle(""); + setDeclined(null); + }; + + return ( +
+
+ + setTitle(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter" && title.trim()) submit(); + }} + placeholder={t("tasks.quick_add_placeholder")} + aria-label={t("tasks.quick_add_placeholder")} + aria-describedby={parsed ? dueId : undefined} + className="flex-1 bg-transparent text-sm outline-none placeholder:text-muted-foreground" + /> + {parsed && ( + + + )} +
+
+ ); } function getTaskPriorityIcon(priority: number) { @@ -66,15 +153,21 @@ function getDueDateLabel(due: string, showWithoutTime: boolean, t: ReturnType