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 (
+
+ );
+ },
}}
+ 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(