Add end-to-end tests for chore and project functionalities, enhance auth flow, and update package version

This commit is contained in:
Mo Tarbin
2026-08-11 00:55:17 -04:00
parent 6100738db6
commit 43ffb3da5a
12 changed files with 535 additions and 14 deletions

19
e2e/debug.mjs Normal file
View File

@@ -0,0 +1,19 @@
import { chromium } from '@playwright/test'
import { readFile } from 'fs/promises'
const state = JSON.parse(await readFile('.auth/state.json', 'utf8'))
const browser = await chromium.launch()
const context = await browser.newContext({ storageState: state })
const page = await context.newPage()
page.on('console', msg => console.log('[console]', msg.type(), msg.text()))
page.on('pageerror', err => console.log('[pageerror]', err.message, '\n', err.stack))
await page.goto('http://localhost:5173/chores/create')
await page.getByTestId('chore-name-input').fill('Debug Chore ' + Date.now())
await page.getByLabel('Repeat this task').click()
await page.getByLabel('Daily').click()
await page.getByRole('button', { name: 'Create' }).click()
await page.waitForURL('**/chores', { timeout: 15000 })
await page.waitForTimeout(2000)
await browser.close()

View File

@@ -6,7 +6,8 @@ const __dirname = path.dirname(fileURLToPath(import.meta.url))
/**
* Fill and submit the signup form through the UI.
* After successful signup the app auto-logs in and redirects to /chores.
* After successful signup the app auto-logs in and walks through the
* onboarding flow (/circle-setup, then /ready) before landing on /chores.
*/
export async function signUpViaUI(page, { username, email, password, displayName }) {
await page.goto('/signup')
@@ -14,7 +15,14 @@ export async function signUpViaUI(page, { username, email, password, displayName
await page.locator('#email').fill(email)
await page.locator('#password').fill(password)
await page.locator('#displayName').fill(displayName)
await page.getByRole('button', { name: 'Sign Up' }).click()
await page.getByRole('button', { name: 'Create account' }).click()
await page.waitForURL('**/circle-setup', { timeout: 10_000 })
await page.getByRole('button', { name: 'Continue' }).click()
await page.waitForURL('**/ready', { timeout: 10_000 })
await page.getByRole('button', { name: 'Continue' }).click()
await page.waitForURL('**/chores', { timeout: 10_000 })
}
@@ -24,8 +32,7 @@ export async function signUpViaUI(page, { username, email, password, displayName
*/
export async function loginViaUI(page, { username, password }) {
await page.goto('/login')
// The username input on the login page has id="email" (quirk of the form)
await page.locator('#email').fill(username)
await page.locator('#username').fill(username)
await page.locator('#password').fill(password)
await page.getByRole('button', { name: 'Sign In' }).click()
await page.waitForURL('**/chores', { timeout: 10_000 })

View File

@@ -4,6 +4,7 @@
# (2022/5180) so it never touches dev servers you may have running elsewhere
# (e.g. another worktree on the default 2021/5173).
set -uo pipefail
set -m # each backgrounded job gets its own process group, so we can kill it as a unit (portable alternative to setsid, which isn't available on macOS)
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
FRONTEND_DIR="$(cd "$SCRIPT_DIR/.." && pwd)"
@@ -70,7 +71,7 @@ kill_port "$FRONTEND_PORT"
echo "--- Starting backend on :$BACKEND_PORT (log: $LOG_DIR/backend.log) ---"
(
cd "$BACKEND_DIR"
exec setsid env \
exec env \
DT_NAME=e2e-frontend-repo \
DT_IS_DONE_TICK_DOT_COM=false \
DT_IS_USER_CREATION_DISABLED=false \
@@ -93,7 +94,7 @@ BACKEND_PID=$!
echo "--- Starting frontend on :$FRONTEND_PORT (log: $LOG_DIR/frontend.log) ---"
(
cd "$FRONTEND_DIR"
exec setsid env VITE_APP_API_URL="$BACKEND_URL" npx vite --port "$FRONTEND_PORT" --strictPort
exec env VITE_APP_API_URL="$BACKEND_URL" npx vite --port "$FRONTEND_PORT" --strictPort
) > "$LOG_DIR/frontend.log" 2>&1 &
FRONTEND_PID=$!

View File

@@ -0,0 +1,174 @@
import { expect, test } from '../fixtures/auth.js'
import { API_URL } from '../global-setup.js'
// Fetch the full chore list for the authenticated user via the API — used to
// verify a task's persisted fields without relying on list-page badge markup.
async function fetchChores(page) {
const token = await page.evaluate(() => localStorage.getItem('token'))
const apiRes = await fetch(`${API_URL}/api/v1/chores/`, {
headers: { Authorization: `Bearer ${token}` },
})
expect(apiRes.ok).toBe(true)
const { res: chores } = await apiRes.json()
return chores
}
test.describe('AddTaskModal (quick-add)', () => {
// All tests in this suite run as the pre-authenticated E2E user
test.use({
storageState: '.auth/state.json',
})
test('creates a task from plain text title only and it appears in the list', async ({
page,
}) => {
const taskName = `E2E Quick Task ${Date.now()}`
// ── Open the quick-add modal from the chores list ───────────────────────
await page.goto('/chores')
await page.getByTestId('open-add-task-modal').click()
const dialog = page.getByRole('dialog', { name: 'Create new task' })
await expect(dialog).toBeVisible()
// ── Type a plain title (no parseable tokens) and submit with Enter ──────
const input = dialog.locator('textarea')
await input.fill(taskName)
await input.press('Enter')
// ── Modal closes and the task shows up in the list ──────────────────────
await expect(dialog).toBeHidden()
await expect(page.getByText(taskName)).toBeVisible({ timeout: 10_000 })
// ── Verify it exists via the API too ─────────────────────────────────────
const chores = await fetchChores(page)
const created = chores.find(c => c.name === taskName)
expect(created).toBeDefined()
})
test('smart-parses a due date and priority from typed text', async ({
page,
}) => {
// Kept lowercase: parsePriority's cleanup step lowercases the whole
// sentence when a priority token matches, so an already-lowercase prefix
// sidesteps that quirk and round-trips unchanged.
const taskName = `e2e parse task ${Date.now()}`
await page.goto('/chores')
await page.getByTestId('open-add-task-modal').click()
const dialog = page.getByRole('dialog', { name: 'Create new task' })
await expect(dialog).toBeVisible()
const input = dialog.locator('textarea')
await input.fill(`${taskName} !p1 tomorrow`)
// ── Confirm the smart parse is reflected in the pickers before saving ───
await expect(
dialog.getByRole('button', { name: 'P1', exact: true }),
).toBeVisible()
// The due-date trigger only ever reads "Due" in its empty state, so once
// parsing lands a date the button relabels itself away from that text.
await expect(
dialog.getByRole('button', { name: 'Due', exact: true }),
).toHaveCount(0)
await input.press('Enter')
await expect(dialog).toBeHidden()
await expect(page.getByText(taskName)).toBeVisible({ timeout: 10_000 })
// ── Verify the parsed priority + due date persisted via the API ─────────
const chores = await fetchChores(page)
const created = chores.find(c => c.name === taskName)
expect(created).toBeDefined()
expect(created.priority).toBe(1)
expect(created.nextDueDate).toBeTruthy()
})
test('creates a task using the picker UI directly, skipping smart-parse tokens', async ({
page,
}) => {
const taskName = `E2E Picker Task ${Date.now()}`
await page.goto('/chores')
await page.getByTestId('open-add-task-modal').click()
const dialog = page.getByRole('dialog', { name: 'Create new task' })
await expect(dialog).toBeVisible()
// Plain title only — none of "due date"/"priority"/"repeat" tokens.
await dialog.locator('textarea').fill(taskName)
// ── Set the due date via the picker (Due → Tomorrow → Apply) ────────────
await dialog.getByRole('button', { name: 'Due', exact: true }).click()
const dueDateDialog = page.getByRole('dialog', { name: 'Due Date' })
await dueDateDialog
.getByRole('checkbox', { name: 'Tomorrow', exact: true })
.click()
await dueDateDialog
.getByRole('button', { name: 'Apply', exact: true })
.click()
// ── Set the priority via the picker (Priority → P2) ──────────────────────
await dialog.getByRole('button', { name: 'Priority', exact: true }).click()
await page.getByRole('button', { name: 'P2', exact: true }).click()
await expect(
dialog.getByRole('button', { name: 'P2', exact: true }),
).toBeVisible()
// ── Set the repeat schedule via the picker (Repeat → Daily → Apply) ─────
await dialog.getByRole('button', { name: 'Repeat', exact: true }).click()
const repeatDialog = page.getByRole('dialog', { name: 'Repeat Schedule' })
await repeatDialog
.getByRole('checkbox', { name: 'Daily', exact: true })
.click()
await repeatDialog
.getByRole('button', { name: 'Apply', exact: true })
.click()
await dialog.getByRole('button', { name: 'Create', exact: true }).click()
await expect(dialog).toBeHidden()
await expect(page.getByText(taskName)).toBeVisible({ timeout: 10_000 })
// ── Verify the picker-set values persisted via the API ───────────────────
const chores = await fetchChores(page)
const created = chores.find(c => c.name === taskName)
expect(created).toBeDefined()
expect(created.priority).toBe(2)
expect(created.frequencyType).toBe('daily')
expect(created.nextDueDate).toBeTruthy()
})
test('Create button is disabled until the title is non-empty', async ({
page,
}) => {
await page.goto('/chores')
await page.getByTestId('open-add-task-modal').click()
const dialog = page.getByRole('dialog', { name: 'Create new task' })
await expect(dialog).toBeVisible()
const createButton = dialog.getByRole('button', {
name: 'Create',
exact: true,
})
const input = dialog.locator('textarea')
// ── Empty title ───────────────────────────────────────────────────────
await expect(createButton).toBeDisabled()
// ── Whitespace-only title still counts as empty ─────────────────────────
await input.fill(' ')
await expect(createButton).toBeDisabled()
// ── Real text enables the button ─────────────────────────────────────────
await input.fill('E2E Enable Check')
await expect(createButton).toBeEnabled()
// No task should be created by this test — back out via Cancel.
await dialog.getByRole('button', { name: 'Cancel', exact: true }).click()
await expect(dialog).toBeHidden()
})
})

View File

@@ -31,7 +31,7 @@ test.describe('Auth Sign Up', () => {
await page.locator('#email').fill('short@donetick.test')
await page.locator('#password').fill('ValidPass123!')
await page.locator('#displayName').fill('Short User')
await page.getByRole('button', { name: 'Sign Up' }).click()
await page.getByRole('button', { name: 'Create account' }).click()
await expect(
page.getByText('Username must be at least 4 characters'),
@@ -52,7 +52,7 @@ test.describe('Auth Login', () => {
test('shows an error for wrong password', async ({ page }) => {
await page.goto('/login')
await page.locator('#email').fill('e2e.user')
await page.locator('#username').fill('e2e.user')
await page.locator('#password').fill('WrongPassword!')
await page.getByRole('button', { name: 'Sign In' }).click()

View File

@@ -0,0 +1,150 @@
import { expect, test } from '../fixtures/auth.js'
import { API_URL } from '../global-setup.js'
test.describe('ChoreEdit', () => {
// All tests in this suite run as the pre-authenticated E2E user
test.use({
storageState: '.auth/state.json',
})
test('blocks save and shows an error when the name is left blank', async ({
page,
}) => {
// ── Navigate to the create chore page, leave the form at defaults ──────
await page.goto('/chores/create')
// ── Attempt to save without a name ──────────────────────────────────────
await page.getByRole('button', { name: 'Create' }).click()
// ── The validation error is shown and we never leave the create page ───
await expect(page.getByText('Name is required').first()).toBeVisible({
timeout: 5_000,
})
await expect(page).toHaveURL(/\/chores\/create/)
})
test('days-of-the-week repeat: blocks save with no days selected, then succeeds once days are chosen', async ({
page,
}) => {
const choreName = `E2E Weekly Chore ${Date.now()}`
// ── Navigate to the create chore page ────────────────────────────────
await page.goto('/chores/create')
// ── Fill in the chore name ────────────────────────────────────────────
await page.locator('input').first().fill(choreName)
// ── Enable recurrence and switch to a custom "days of the week" schedule
await page.getByLabel('Repeat this task').click()
await page.getByLabel('Custom').click()
await page.getByLabel('Days of the Week').click()
// ── Attempt to save with zero days selected ─────────────────────────────
await page.getByRole('button', { name: 'Create' }).click()
await expect(
page.getByText('Please select at least one day of the week').first(),
).toBeVisible({ timeout: 5_000 })
await expect(page).toHaveURL(/\/chores\/create/)
// ── Select Monday and Wednesday, then save successfully ────────────────
await page.getByLabel('Monday').click()
await page.getByLabel('Wednesday').click()
await page.getByRole('button', { name: 'Create' }).click()
await page.waitForURL('**/chores', { timeout: 15_000 })
await expect(page.getByText(choreName)).toBeVisible({ timeout: 10_000 })
// ── Verify the chore exists in the API with the selected days ─────────
const token = await page.evaluate(() => localStorage.getItem('token'))
const apiRes = await fetch(`${API_URL}/api/v1/chores/`, {
headers: { Authorization: `Bearer ${token}` },
})
expect(apiRes.ok).toBe(true)
const { res: chores } = await apiRes.json()
const created = chores.find(c => c.name === choreName)
expect(created).toBeDefined()
expect(created.frequencyType).toBe('days_of_the_week')
expect([...(created.frequencyMetadata?.days || [])].sort()).toEqual([
'monday',
'wednesday',
])
})
test('creates a chore, edits it after reload, and toggles Anyone assignment', async ({
page,
}) => {
const originalName = `E2E Edit Chore ${Date.now()}`
const updatedName = `${originalName} Updated`
const updatedDescription = `Updated by e2e ${Date.now()}`
// One week out, formatted as YYYY-MM-DD for the native date input
const updatedDueDate = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000)
.toISOString()
.slice(0, 10)
// ── Create a simple one-off chore with a due date ───────────────────────
await page.goto('/chores/create')
await page.locator('input').first().fill(originalName)
// Toggle "Anyone" on then off again — confirm it snaps back to the
// default self-assignment before we submit.
await page.getByLabel('Anyone').click()
await expect(page.getByLabel('Anyone')).toBeChecked()
await page.getByLabel('Anyone').click()
await expect(page.getByLabel('Anyone')).not.toBeChecked()
// Give it a due date so it's a simple one-off chore
await page.getByLabel('Give this task a due date').click()
await page.getByRole('button', { name: 'Create' }).click()
await page.waitForURL('**/chores', { timeout: 15_000 })
await expect(page.getByText(originalName)).toBeVisible({ timeout: 10_000 })
// ── Look up the created chore via the API (id + self-assignment state) ─
const token = await page.evaluate(() => localStorage.getItem('token'))
const listRes = await fetch(`${API_URL}/api/v1/chores/`, {
headers: { Authorization: `Bearer ${token}` },
})
expect(listRes.ok).toBe(true)
const { res: chores } = await listRes.json()
const created = chores.find(c => c.name === originalName)
expect(created).toBeDefined()
// Default self-assignment: exactly one assignee, matching assignedTo.
expect(created.assignees?.length).toBe(1)
expect(created.assignedTo).toBe(created.assignees[0].userId)
// ── Edit: change name, description, due date, and switch to Anyone ─────
await page.goto(`/chores/${created.id}/edit`)
await page.locator('input').first().fill(updatedName)
await page.locator('.ql-editor').fill(updatedDescription)
await page.locator('input[type="date"]').fill(updatedDueDate)
await page.getByLabel('Anyone').click()
await page.getByRole('button', { name: 'Save' }).click()
await page.waitForURL('**/chores', { timeout: 15_000 })
// ── Reload the edit page and confirm the new values persisted in the UI
await page.goto(`/chores/${created.id}/edit`)
await expect(page.locator('input').first()).toHaveValue(updatedName)
await expect(page.locator('.ql-editor')).toContainText(updatedDescription)
await expect(page.locator('input[type="date"]')).toHaveValue(updatedDueDate)
await expect(page.getByLabel('Anyone')).toBeChecked()
// ── Confirm persisted via the API too ───────────────────────────────────
const detailRes = await fetch(`${API_URL}/api/v1/chores/${created.id}`, {
headers: { Authorization: `Bearer ${token}` },
})
expect(detailRes.ok).toBe(true)
const { res: updated } = await detailRes.json()
expect(updated.name).toBe(updatedName)
expect(updated.description).toContain(updatedDescription)
// Due-date persistence is already confirmed via the reloaded date input
// above; comparing the raw UTC `nextDueDate` string here would be
// timezone-fragile since the app stores it as an end-of-local-day UTC
// instant.
// "Anyone" assignment: no specific assignees, no fixed assignedTo.
expect(updated.assignees ?? []).toHaveLength(0)
expect(updated.assignedTo).toBeFalsy()
})
})

View File

@@ -0,0 +1,164 @@
import { expect, test } from '../fixtures/auth.js'
import { API_URL } from '../global-setup.js'
test.describe('Projects & Filters Create and navigate', () => {
// All tests in this suite run as the pre-authenticated E2E user
test.use({
storageState: '.auth/state.json',
})
test('creates a project via the modal, tracks its task count, and filters chores by it', async ({
page,
}) => {
const projectName = `E2E Project ${Date.now()}`
const choreName = `E2E Project Chore ${Date.now()}`
// ── Create the project via the modal ─────────────────────────────────
await page.goto('/projects')
await page.getByTestId('open-add-project-modal').click()
const projectDialog = page.getByRole('dialog')
await projectDialog.getByLabel('Project Name').fill(projectName)
await projectDialog.getByRole('button', { name: 'Create' }).click()
// ── Verify the project appears in the list with a 0 task count ───────
// Scope to the row: the name Typography's parent Box also holds the
// "N tasks" chip as a sibling, so one level up covers both.
const projectRow = page
.getByText(projectName, { exact: true })
.locator('xpath=..')
await expect(projectRow.getByText('0 tasks')).toBeVisible({
timeout: 10_000,
})
// ── Look up the created project via the API to get its id ────────────
const token = await page.evaluate(() => localStorage.getItem('token'))
const projectsRes = await fetch(`${API_URL}/api/v1/projects`, {
headers: { Authorization: `Bearer ${token}` },
})
expect(projectsRes.ok).toBe(true)
const projects = await projectsRes.json()
const project = projects.find(p => p.name === projectName)
expect(project).toBeDefined()
// ── Create a chore inside the project directly via the API ───────────
// Cheaper than driving the full chore-create UI, and this project is
// brand new so its task count is guaranteed to go from 0 to 1.
const choreRes = await fetch(`${API_URL}/api/v1/chores/`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`,
},
body: JSON.stringify({
name: choreName,
frequencyType: 'once',
assignStrategy: 'no_assignee',
projectId: project.id,
}),
})
expect(choreRes.ok).toBe(true)
// ── Reload so the project list reflects the new task count ───────────
await page.reload()
await expect(projectRow.getByText('1 tasks')).toBeVisible({
timeout: 10_000,
})
// ── Click the project card and confirm navigation + filtered results ─
await page.getByText(projectName, { exact: true }).click()
await page.waitForURL(new RegExp(`/chores\\?project=${project.id}(&|$)`), {
timeout: 10_000,
})
await expect(page.getByText(choreName)).toBeVisible({ timeout: 10_000 })
})
test('creates a filter via AdvancedFilterBuilder, tracks its task count, and filters chores by it', async ({
page,
}) => {
const filterProjectName = `E2E Filter Project ${Date.now()}`
const filterName = `E2E Filter ${Date.now()}`
const choreName = `E2E Filter Chore ${Date.now()}`
// ── Seed a throwaway project via the API to use as the filter's ──────
// condition target. A brand-new project has zero chores, so the
// filter's task count is guaranteed to start at 0 regardless of
// whatever else has accumulated in the shared test database.
await page.goto('/filters')
const token = await page.evaluate(() => localStorage.getItem('token'))
const createProjectRes = await fetch(`${API_URL}/api/v1/projects`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`,
},
body: JSON.stringify({ name: filterProjectName }),
})
expect(createProjectRes.ok).toBe(true)
const { res: filterProject } = await createProjectRes.json()
// Reload so the modal's project list (fetched on page load) includes it
await page.reload()
// ── Create the filter via AdvancedFilterBuilder ───────────────────────
await page.getByTestId('open-add-filter-modal').click()
const filterDialog = page.getByRole('dialog')
await filterDialog
.getByPlaceholder('e.g. Overdue tasks for Alice')
.fill(filterName)
// Condition: Projects is <the seeded project> — one condition is enough
await filterDialog
.getByRole('button', { name: filterProjectName, exact: true })
.click()
await filterDialog.getByRole('button', { name: 'Save Filter' }).click()
// ── Verify the filter appears in the list with a 0 task count ────────
// The name Typography sits inside a name-row Box, itself inside the
// content Box that also holds the "N tasks" chip — two levels up.
const filterRow = page
.getByText(filterName, { exact: true })
.locator('xpath=../..')
await expect(filterRow.getByText('0 tasks')).toBeVisible({
timeout: 10_000,
})
// ── Look up the created filter via the API to get its id ─────────────
const filtersRes = await fetch(`${API_URL}/api/v1/filters`, {
headers: { Authorization: `Bearer ${token}` },
})
expect(filtersRes.ok).toBe(true)
const filters = await filtersRes.json()
const filter = filters.find(f => f.name === filterName)
expect(filter).toBeDefined()
// ── Create a chore matching the filter's project condition ───────────
const choreRes = await fetch(`${API_URL}/api/v1/chores/`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`,
},
body: JSON.stringify({
name: choreName,
frequencyType: 'once',
assignStrategy: 'no_assignee',
projectId: filterProject.id,
}),
})
expect(choreRes.ok).toBe(true)
// ── Reload so the filter list reflects the new task count ────────────
await page.reload()
await expect(filterRow.getByText('1 tasks')).toBeVisible({
timeout: 10_000,
})
// ── Click the filter card and confirm navigation + filtered results ──
await page.getByText(filterName, { exact: true }).click()
await page.waitForURL(new RegExp(`/chores\\?filterId=${filter.id}(&|$)`), {
timeout: 10_000,
})
await expect(page.getByText(choreName)).toBeVisible({ timeout: 10_000 })
})
})

4
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{
"name": "donetick",
"version": "1.2.38",
"version": "1.2.45",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "donetick",
"version": "1.2.38",
"version": "1.2.45",
"hasInstallScript": true,
"dependencies": {
"@capacitor-community/in-app-review": "^8.0.0",

View File

@@ -34,7 +34,7 @@ export const AuthField = ({ label, error, helper, children, ...formProps }) => (
)
export const AuthTextField = ({ label, error, helper, sx, ...inputProps }) => (
<AuthField label={label} error={error} helper={helper}>
<AuthField label={label} error={error} helper={helper} id={inputProps.id}>
<Input size='lg' sx={{ ...authInputSx, ...sx }} {...inputProps} />
</AuthField>
)
@@ -49,7 +49,7 @@ export const AuthPasswordField = ({
const [visible, setVisible] = useState(false)
return (
<AuthField label={label} error={error} helper={helper}>
<AuthField label={label} error={error} helper={helper} id={inputProps.id}>
<Input
size='lg'
type={visible ? 'text' : 'password'}

View File

@@ -576,6 +576,7 @@ const FilterView = () => {
}}
>
<IconButton
data-testid='open-add-filter-modal'
color='primary'
variant='solid'
sx={{

View File

@@ -495,6 +495,7 @@ const ProjectView = () => {
}}
>
<IconButton
data-testid='open-add-project-modal'
color='primary'
variant='solid'
sx={{

View File

@@ -1047,8 +1047,12 @@ const TaskInput = ({ initialMode, isModalOpen, onChoreUpdate, onClose }) => {
chore.frequencyMetadata = frequency.frequencyMetadata
chore.frequency = frequency.frequency
}
if (!frequency && dueDate) {
// Use RFC3339/ISO-8601 format expected by backend.
if (dueDate) {
// Use RFC3339/ISO-8601 format expected by backend. The backend only
// derives NextDueDate from what's sent on create (handler.go never
// computes it from frequencyType), so this must be sent whether or
// not the task also repeats — otherwise a recurring task created with
// a due date lands with nextDueDate: null.
chore.nextDueDate = new Date(dueDate).toISOString()
}
if (hasReminders && (frequency || dueDate)) {