diff --git a/package-lock.json b/package-lock.json index 18507e7c..7a45f6a7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -33,7 +33,7 @@ "class-variance-authority": "0.7.0", "clsx": "2.1.1", "cmdk": "1.0.0", - "date-fns": "3.6.0", + "date-fns": "4.1.0", "file-saver": "2.0.5", "html-react-parser": "^5.1.12", "i18next": "23.15.1", @@ -46,7 +46,7 @@ "lucide-react": "0.441.0", "marked": "14.1.2", "react": "18.3.1", - "react-day-picker": "8.10.1", + "react-day-picker": "9.1.1", "react-dom": "18.3.1", "react-i18next": "15.0.2", "react-transition-group": "4.4.5", @@ -2069,6 +2069,12 @@ "dev": true, "license": "MIT" }, + "node_modules/@date-fns/tz": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@date-fns/tz/-/tz-1.0.2.tgz", + "integrity": "sha512-iKxj0kXMy7Qe6vjK+flz33cpy2j0dnTKT5i54p3fFlB411J47aSs6HBg7LOO5X9LjDi2iNlctD9rFn738ySOGQ==", + "license": "MIT" + }, "node_modules/@dnd-kit/accessibility": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/@dnd-kit/accessibility/-/accessibility-3.1.0.tgz", @@ -6915,9 +6921,10 @@ } }, "node_modules/date-fns": { - "version": "3.6.0", - "resolved": "https://registry.npmjs.org/date-fns/-/date-fns-3.6.0.tgz", - "integrity": "sha512-fRHTG8g/Gif+kSh50gaGEdToemgfj74aRX3swtiouboip5JDLAyDE9F11nHMIcvOaXeOC6D7SpNhi7uFyB7Uww==", + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/date-fns/-/date-fns-4.1.0.tgz", + "integrity": "sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg==", + "license": "MIT", "funding": { "type": "github", "url": "https://github.com/sponsors/kossnocorp" @@ -12505,16 +12512,20 @@ } }, "node_modules/react-day-picker": { - "version": "8.10.1", - "resolved": "https://registry.npmjs.org/react-day-picker/-/react-day-picker-8.10.1.tgz", - "integrity": "sha512-TMx7fNbhLk15eqcMt+7Z7S2KF7mfTId/XJDjKE8f+IUcFn0l08/kI4FiYTL/0yuOLmEcbR4Fwe3GJf/NiiMnPA==", + "version": "9.1.1", + "resolved": "https://registry.npmjs.org/react-day-picker/-/react-day-picker-9.1.1.tgz", + "integrity": "sha512-z+xPpd5N42kIqt27XEIyawJgKEya2l5GJMTpZ9FIclqdu40BFfKR4rjQlnXz05h2Gxh0s2F72gL/JCYWuteCIg==", + "license": "MIT", + "dependencies": { + "@date-fns/tz": "^1.0.2", + "date-fns": "^4.1.0" + }, "funding": { "type": "individual", "url": "https://github.com/sponsors/gpbl" }, "peerDependencies": { - "date-fns": "^2.28.0 || ^3.0.0", - "react": "^16.8.0 || ^17.0.0 || ^18.0.0" + "react": ">=16.8.0" } }, "node_modules/react-dom": { diff --git a/package.json b/package.json index 4950b22a..83893d38 100644 --- a/package.json +++ b/package.json @@ -69,7 +69,7 @@ "class-variance-authority": "0.7.0", "clsx": "2.1.1", "cmdk": "1.0.0", - "date-fns": "3.6.0", + "date-fns": "4.1.0", "file-saver": "2.0.5", "html-react-parser": "^5.1.12", "i18next": "23.15.1", @@ -82,7 +82,7 @@ "lucide-react": "0.441.0", "marked": "14.1.2", "react": "18.3.1", - "react-day-picker": "8.10.1", + "react-day-picker": "9.1.1", "react-dom": "18.3.1", "react-i18next": "15.0.2", "react-transition-group": "4.4.5", diff --git a/public/locales/de/translation.json b/public/locales/de/translation.json index f7526e64..8650dc95 100644 --- a/public/locales/de/translation.json +++ b/public/locales/de/translation.json @@ -73,6 +73,7 @@ "Language": "Sprache", "Light": "Hell", "List": "Liste", + "Lists": "Listen", "M": "Menü öffnen (Filter | Einstellungen)", "Months": "Monate", "N": "Neue Aufgabe erstellen", diff --git a/public/locales/en/translation.json b/public/locales/en/translation.json index a90e2519..afd05a15 100644 --- a/public/locales/en/translation.json +++ b/public/locales/en/translation.json @@ -72,6 +72,7 @@ "Language": "Language", "Light": "Light", "List": "List", + "Lists": "Lists", "M": "Open menu (Filter | Settings)", "Months": "Months", "N": "Create new task", diff --git a/src/components/TodoFileList.tsx b/src/components/TodoFileList.tsx index 0d052834..44e05a22 100644 --- a/src/components/TodoFileList.tsx +++ b/src/components/TodoFileList.tsx @@ -142,7 +142,7 @@ export const TodoFileList = memo(() => { return (
- + @@ -164,7 +164,7 @@ export const TodoFileList = memo(() => { variant="ghost" size="icon" onClick={handleCreateFile} - className="absolute bottom-0 right-0 top-0 m-auto h-7 w-7 opacity-0 transition-opacity duration-300 ease-in-out group-hover:opacity-100" + className="absolute bottom-0 right-0 top-0 m-auto h-7 w-7 opacity-0 transition-opacity duration-300 ease-in-out group-hover:opacity-100 touch:hidden" > diff --git a/src/components/ui/calendar.tsx b/src/components/ui/calendar.tsx index fb9cc8f8..24849a9b 100644 --- a/src/components/ui/calendar.tsx +++ b/src/components/ui/calendar.tsx @@ -1,66 +1,311 @@ -import { DayPicker } from "react-day-picker"; - -import { buttonVariants } from "@/components/ui/button"; +import { Button, buttonVariants } from "@/components/ui/button"; import { cn } from "@/utils/tw-utils"; +import { differenceInCalendarDays } from "date-fns"; import { ChevronLeftIcon, ChevronRightIcon } from "lucide-react"; -import { ComponentProps } from "react"; +import * as React from "react"; +import { + DayPicker, + labelNext, + labelPrevious, + useDayPicker, +} from "react-day-picker"; -type CalendarProps = ComponentProps; +export type CalendarProps = React.ComponentProps & { + /** + * In the year view, the number of years to display at once. + * @default 12 + */ + yearRange?: number; + /** + * Wether to let user switch between months and years view. + * @default false + */ + showYearSwitcher?: boolean; +}; function Calendar({ className, classNames, showOutsideDays = true, + yearRange = 12, + showYearSwitcher = false, + numberOfMonths, ...props }: CalendarProps) { + const [navView, setNavView] = React.useState<"days" | "years">("days"); + const [displayYears, setDisplayYears] = React.useState<{ + from: number; + to: number; + }>( + React.useMemo(() => { + const currentYear = new Date().getFullYear(); + return { + from: currentYear - Math.floor(yearRange / 2 - 1), + to: currentYear + Math.ceil(yearRange / 2), + }; + }, [yearRange]), + ); + + const { onNextClick, onPrevClick, startMonth, endMonth } = props; + + const columnsDisplayed = navView === "years" ? 1 : numberOfMonths; + return ( .day-range-end)]:rounded-r-md [&:has(>.day-range-start)]:rounded-l-md first:[&:has([aria-selected])]:rounded-l-md last:[&:has([aria-selected])]:rounded-r-md" - : "[&:has([aria-selected])]:rounded-md", + button_previous: cn( + buttonVariants({ + variant: "outline", + className: + "absolute left-0 h-7 w-7 bg-transparent p-0 opacity-50 hover:opacity-100", + }), ), - day: cn( + nav: "flex items-start", + month_grid: "mt-4", + week: "mt-2 flex w-full", + day: "flex size-8 flex-1 items-center justify-center rounded-md p-0 text-sm [&:has(button)]:hover:!bg-accent [&:has(button)]:hover:text-accent-foreground [&:has(button)]:hover:aria-selected:!bg-primary [&:has(button)]:hover:aria-selected:text-primary-foreground", + day_button: cn( buttonVariants({ variant: "ghost" }), - "h-8 w-8 p-0 font-normal aria-selected:opacity-100", + "h-8 w-8 p-0 font-normal transition-none hover:bg-transparent hover:text-inherit aria-selected:opacity-100", ), - day_range_start: "day-range-start", - day_range_end: "day-range-end", - day_selected: - "bg-primary text-primary-foreground hover:bg-primary hover:text-primary-foreground focus:bg-primary focus:text-primary-foreground", - day_today: "bg-accent text-accent-foreground", - day_outside: - "day-outside text-muted-foreground opacity-50 aria-selected:bg-accent/50 aria-selected:text-muted-foreground aria-selected:opacity-30", - day_disabled: "text-muted-foreground opacity-50", - day_range_middle: - "aria-selected:bg-accent aria-selected:text-accent-foreground", - day_hidden: "invisible", + range_start: "day-range-start rounded-s-md", + range_end: "day-range-end rounded-e-md", + selected: + "bg-primary text-primary-foreground hover:!bg-primary hover:text-primary-foreground focus:bg-primary focus:text-primary-foreground", + today: "bg-accent text-accent-foreground", + outside: + "day-outside text-muted-foreground opacity-50 aria-selected:bg-accent/50 aria-selected:text-muted-foreground aria-selected:opacity-30", + disabled: "text-muted-foreground opacity-50", + range_middle: + "rounded-none aria-selected:bg-accent aria-selected:text-accent-foreground hover:aria-selected:!bg-accent hover:aria-selected:text-accent-foreground", + hidden: "invisible hidden", ...classNames, }} components={{ - IconLeft: () => , - IconRight: () => , + Chevron: ({ orientation }) => { + const Icon = + orientation === "left" ? ChevronLeftIcon : ChevronRightIcon; + return ; + }, + Nav: ({ className, children, ...props }) => { + const { nextMonth, previousMonth, goToMonth } = useDayPicker(); + + const isPreviousDisabled = (() => { + if (navView === "years") { + return ( + (startMonth && + differenceInCalendarDays( + new Date(displayYears.from - 1, 0, 1), + startMonth, + ) < 0) || + (endMonth && + differenceInCalendarDays( + new Date(displayYears.from - 1, 0, 1), + endMonth, + ) > 0) + ); + } + return !previousMonth; + })(); + + const isNextDisabled = (() => { + if (navView === "years") { + return ( + (startMonth && + differenceInCalendarDays( + new Date(displayYears.to + 1, 0, 1), + startMonth, + ) < 0) || + (endMonth && + differenceInCalendarDays( + new Date(displayYears.to + 1, 0, 1), + endMonth, + ) > 0) + ); + } + return !nextMonth; + })(); + + const handlePreviousClick = React.useCallback(() => { + if (!previousMonth) return; + if (navView === "years") { + setDisplayYears((prev) => ({ + from: prev.from - (prev.to - prev.from + 1), + to: prev.to - (prev.to - prev.from + 1), + })); + onPrevClick?.( + new Date( + displayYears.from - (displayYears.to - displayYears.from), + 0, + 1, + ), + ); + return; + } + goToMonth(previousMonth); + onPrevClick?.(previousMonth); + }, [previousMonth, goToMonth]); + + const handleNextClick = React.useCallback(() => { + if (!nextMonth) return; + if (navView === "years") { + setDisplayYears((prev) => ({ + from: prev.from + (prev.to - prev.from + 1), + to: prev.to + (prev.to - prev.from + 1), + })); + onNextClick?.( + new Date( + displayYears.from + (displayYears.to - displayYears.from), + 0, + 1, + ), + ); + return; + } + goToMonth(nextMonth); + onNextClick?.(nextMonth); + }, [goToMonth, nextMonth]); + return ( + + ); + }, + CaptionLabel: ({ children, ...props }) => { + if (!showYearSwitcher) return {children}; + + return ( + + ); + }, + MonthGrid: ({ className, children, ...props }) => { + const { goToMonth } = useDayPicker(); + if (navView === "years") { + return ( +
+ {Array.from( + { length: displayYears.to - displayYears.from + 1 }, + (_, i) => { + const isBefore = + differenceInCalendarDays( + new Date(displayYears.from + i, 11, 31), + startMonth!, + ) < 0; + + const isAfter = + differenceInCalendarDays( + new Date(displayYears.from + i, 0, 0), + endMonth!, + ) > 0; + + const isDisabled = isBefore || isAfter; + return ( + + ); + }, + )} +
+ ); + } + return ( + + {children} +
+ ); + }, }} + numberOfMonths={ + // we need to override the number of months if we are in years view to 1 + columnsDisplayed + } {...props} /> ); diff --git a/src/components/ui/date-picker.tsx b/src/components/ui/date-picker.tsx index 8a9d7a3e..8e25296a 100644 --- a/src/components/ui/date-picker.tsx +++ b/src/components/ui/date-picker.tsx @@ -97,7 +97,8 @@ export function DatePicker(props: DatePickerProps) { month={date} selected={date} onSelect={handleSelect} - initialFocus + autoFocus + showYearSwitcher /> diff --git a/src/stores/filter-store.ts b/src/stores/filter-store.ts index 43cc54d2..7f8055a8 100644 --- a/src/stores/filter-store.ts +++ b/src/stores/filter-store.ts @@ -1,4 +1,5 @@ import { getPreferencesItem, setPreferencesItem } from "@/utils/preferences"; +import { getTodoFileIds } from "@/utils/todo-files"; import { createContext, useContext } from "react"; import { useStore as useZustandStore } from "zustand"; import { createStore } from "zustand/vanilla"; @@ -50,7 +51,7 @@ export const FilterStoreProvider = zustandContext.Provider; export async function filterLoader(): Promise { const searchParams = new URLSearchParams(window.location.search); const [ - selectedTaskListIds, + selectedTaskListIdsStr, selectedPriorities, selectedProjects, selectedContexts, @@ -68,11 +69,25 @@ export async function filterLoader(): Promise { getPreferencesItem("filter-type"), getPreferencesItem("hide-completed-tasks"), ]); + + // removes any selected task list ids that no longer exist + const todoListIds = await getTodoFileIds(); + const selectedTaskListIds = selectedTaskListIdsStr + ? selectedTaskListIdsStr.split(",").map((i) => parseInt(i)) + : []; + const filteredSelectedTaskListIds = todoListIds + .map(({ todoFileId }) => todoFileId) + .filter((id) => selectedTaskListIds.includes(id)); + if (todoListIds.length !== filteredSelectedTaskListIds.length) { + await setPreferencesItem( + "selected-task-list-ids", + filteredSelectedTaskListIds.join(","), + ); + } + return { searchTerm: searchParams.get("term") || "", - selectedTaskListIds: selectedTaskListIds - ? selectedTaskListIds.split(",").map((i) => parseInt(i)) - : [], + selectedTaskListIds: filteredSelectedTaskListIds, selectedPriorities: selectedPriorities ? selectedPriorities.split(",") : [], selectedProjects: selectedProjects ? selectedProjects.split(",") : [], selectedContexts: selectedContexts ? selectedContexts.split(",") : [], diff --git a/src/utils/private-filesystem.ts b/src/utils/private-filesystem.ts index ea259b1e..1f9842c8 100644 --- a/src/utils/private-filesystem.ts +++ b/src/utils/private-filesystem.ts @@ -93,9 +93,10 @@ type Result = T extends CreateOptions ? ErrorResult : never; -const worker = new Worker( - new URL("./private-filesystem-sw.ts", import.meta.url), -); +let worker: Worker; +if (typeof Worker !== "undefined") { + worker = new Worker(new URL("./private-filesystem-sw.ts", import.meta.url)); +} function postMessage(options: T): Promise> { return new Promise>((resolve, reject) => { diff --git a/src/utils/useTask.tsx b/src/utils/useTask.tsx index 7fd29f28..83770e10 100644 --- a/src/utils/useTask.tsx +++ b/src/utils/useTask.tsx @@ -436,10 +436,8 @@ export function useTask() { ]); } - if (selectedTaskLists.some((list) => list.id === id)) { - const taskListIds = selectedTaskLists - .filter((t) => t.id !== id) - .map((list) => list.id); + if (selectedTaskListIds.includes(id)) { + const taskListIds = selectedTaskListIds.filter((val) => val !== id); setSelectedTaskListIds(taskListIds); } @@ -448,7 +446,7 @@ export function useTask() { [ taskLists, deleteTodoFile, - selectedTaskLists, + selectedTaskListIds, removeTaskList, cancelNotifications, setSelectedTaskListIds, diff --git a/tests/task-dialog.spec.ts b/tests/task-dialog.spec.ts index 23351394..d3c6c27c 100644 --- a/tests/task-dialog.spec.ts +++ b/tests/task-dialog.spec.ts @@ -64,7 +64,10 @@ test.describe("Task dialog", () => { ).toHaveCount(1); }); - test("should display due date as text in task list", async ({ page }) => { + test("should display due date as text in task list", async ({ + page, + isMobile, + }) => { await openTaskDialog(page); await getEditor(page).pressSequentially("This is a test", delay); @@ -73,11 +76,9 @@ test.describe("Task dialog", () => { await page.getByLabel("Due date").click(); // choose date const date = new Date(); - date.setDate(15); - const currentDateSelector = date.getDate().toString(); await page - .getByRole("gridcell", { name: currentDateSelector, exact: true }) - .click(); + .locator(`[role="gridcell"] button[aria-label^="Today"]`) + .click({ clickCount: isMobile ? 2 : 1 }); await page.getByRole("button", { name: "Save task" }).click(); @@ -99,11 +100,9 @@ test.describe("Task dialog", () => { await page.getByLabel("Due date").click(); // choose date const date = new Date(); - date.setDate(15); - const currentDateSelector = date.getDate().toString(); await page - .getByRole("gridcell", { name: currentDateSelector, exact: true }) - .click(); + .locator(`[role="gridcell"] button[aria-label^="Today"]`) + .click({ clickCount: isMobile ? 2 : 1 }); // make sure the date picker contains a value await expect(page.getByLabel("Due date")).toHaveText( @@ -130,8 +129,8 @@ test.describe("Task dialog", () => { // choose date and confirm await page - .getByRole("gridcell", { name: currentDateSelector, exact: true }) - .click(); + .locator(`[role="gridcell"] button[aria-label^="Today"]`) + .click({ clickCount: isMobile ? 2 : 1 }); // make sure the date picker contains a value await expect(page.getByLabel("Due date")).toHaveText( @@ -146,8 +145,8 @@ test.describe("Task dialog", () => { // clear date selection await page - .getByRole("gridcell", { name: currentDateSelector, exact: true }) - .click(); + .locator(`[role="gridcell"] button[aria-label^="Today"]`) + .click({ clickCount: isMobile ? 2 : 1 }); // make sure the text field doesn't contain the due date await expect(getEditor(page)).not.toHaveText(dueDateTag); @@ -301,12 +300,7 @@ test.describe("Task dialog", () => { await page.keyboard.press("Tab"); await page.keyboard.press("Enter"); const date = new Date(); - date.setDate(15); - const currentDateSelector = date.getDate().toString(); - await page - .getByRole("gridcell", { name: currentDateSelector, exact: true }) - .first() - .click(); + await page.locator(`[role="gridcell"] button[aria-label^="Today"]`).click(); // make sure due date was selected await expect(page.getByLabel("Due date")).toHaveText(