diff --git a/src/utils/Chores.jsx b/src/utils/Chores.jsx
index cde9fe7..bd225ab 100644
--- a/src/utils/Chores.jsx
+++ b/src/utils/Chores.jsx
@@ -25,6 +25,67 @@ export const ChoreStatus = Object.freeze({
PAUSED: 2,
PENDING_APPROVAL: 3,
})
+
+const getDateGroupKey = dueDate =>
+ moment(dueDate).startOf('day').format('YYYY-MM-DD')
+
+const getDateGroupName = dateKey =>
+ moment(dateKey, 'YYYY-MM-DD').format('dddd, MMM D')
+
+const getDateGroupColor = dateKey => {
+ const today = moment().startOf('day')
+ const tomorrow = moment().add(1, 'day').startOf('day')
+ const groupDate = moment(dateKey, 'YYYY-MM-DD')
+
+ if (groupDate.isBefore(today)) {
+ return TASK_COLOR.OVERDUE
+ }
+ if (groupDate.isSame(today)) {
+ return TASK_COLOR.TODAY
+ }
+ if (groupDate.isSame(tomorrow)) {
+ return TASK_COLOR.TOMORROW
+ }
+ if (groupDate.isBefore(moment(today).add(8, 'days'))) {
+ return TASK_COLOR.NEXT_7_DAYS
+ }
+ if (groupDate.isSame(today, 'month')) {
+ return TASK_COLOR.LATER_THIS_MONTH
+ }
+ return TASK_COLOR.FUTURE
+}
+
+const buildActualDateGroups = chores => {
+ const groupedByDate = {}
+ const anytime = []
+
+ chores.forEach(chore => {
+ if (!chore.nextDueDate) {
+ anytime.push(chore)
+ return
+ }
+
+ const dateKey = getDateGroupKey(chore.nextDueDate)
+ if (!groupedByDate[dateKey]) {
+ groupedByDate[dateKey] = []
+ }
+ groupedByDate[dateKey].push(chore)
+ })
+
+ const dateGroups = Object.keys(groupedByDate)
+ .sort(
+ (a, b) =>
+ moment(a, 'YYYY-MM-DD').valueOf() - moment(b, 'YYYY-MM-DD').valueOf(),
+ )
+ .map(dateKey => ({
+ name: getDateGroupName(dateKey),
+ content: groupedByDate[dateKey],
+ color: getDateGroupColor(dateKey),
+ }))
+
+ return { dateGroups, anytime }
+}
+
export const ChoresGrouper = (groupBy, chores, filter) => {
if (filter) {
chores = chores.filter(chore => filter(chore))
@@ -34,7 +95,7 @@ export const ChoresGrouper = (groupBy, chores, filter) => {
chores.sort(ChoreSorter)
var groups = []
switch (groupBy) {
- case 'default':
+ case 'default': {
// same as due_date but hide empty groups: and if status is 1 or 2 have seperated catigory as Started:
var groupRaw = {
PendingApproval: [],
@@ -147,82 +208,21 @@ export const ChoresGrouper = (groupBy, chores, filter) => {
})
}
break
+ }
- case 'due_date':
- var groupRaw = {
- Today: [],
- Tomorrow: [],
- 'Next 7 Days': [],
- 'Later This Month': [],
- Future: [],
- Overdue: [],
- Anytime: [],
- }
- chores.forEach(chore => {
- if (chore.nextDueDate === null) {
- groupRaw['Anytime'].push(chore)
- } else if (new Date(chore.nextDueDate) < new Date()) {
- groupRaw['Overdue'].push(chore)
- } else if (
- new Date(chore.nextDueDate).toDateString() ===
- new Date().toDateString()
- ) {
- groupRaw['Today'].push(chore)
- } else if (
- new Date(chore.nextDueDate).toDateString() ===
- new Date(Date.now() + 24 * 60 * 60 * 1000).toDateString()
- ) {
- groupRaw['Tomorrow'].push(chore)
- } else if (
- new Date(chore.nextDueDate) <
- new Date(Date.now() + 8 * 24 * 60 * 60 * 1000) &&
- new Date(chore.nextDueDate) >
- new Date(Date.now() + 24 * 60 * 60 * 1000)
- ) {
- groupRaw['Next 7 Days'].push(chore)
- } else if (
- new Date(chore.nextDueDate).getMonth() === new Date().getMonth() &&
- new Date(chore.nextDueDate).getFullYear() === new Date().getFullYear()
- ) {
- groupRaw['Later This Month'].push(chore)
- } else {
- groupRaw['Future'].push(chore)
- }
- })
- groups = [
- {
- name: 'Overdue',
- content: groupRaw['Overdue'],
- color: TASK_COLOR.OVERDUE,
- },
- { name: 'Today', content: groupRaw['Today'], color: TASK_COLOR.TODAY },
- {
- name: 'Tomorrow',
- content: groupRaw['Tomorrow'],
- color: TASK_COLOR.TOMORROW,
- },
- {
- name: 'Next 7 Days',
- content: groupRaw['Next 7 Days'],
- color: TASK_COLOR.NEXT_7_DAYS,
- },
- {
- name: 'Later This Month',
- content: groupRaw['Later This Month'],
- color: TASK_COLOR.LATER_THIS_MONTH,
- },
- {
- name: 'Future',
- content: groupRaw['Future'],
- color: TASK_COLOR.FUTURE,
- },
- {
+ case 'due_date': {
+ var { dateGroups: dueDateGroups, anytime: dueAnytime } =
+ buildActualDateGroups(chores)
+ groups = [...dueDateGroups]
+ if (dueAnytime.length > 0) {
+ groups.push({
name: 'Anytime',
- content: groupRaw['Anytime'],
+ content: dueAnytime,
color: TASK_COLOR.ANYTIME,
- },
- ]
+ })
+ }
break
+ }
case 'priority':
groupRaw = {
p1: [],
diff --git a/src/views/Chores/components/CustomFilterChips.jsx b/src/views/Chores/components/CustomFilterChips.jsx
index 563bb93..672d218 100644
--- a/src/views/Chores/components/CustomFilterChips.jsx
+++ b/src/views/Chores/components/CustomFilterChips.jsx
@@ -9,11 +9,11 @@ import {
import {
Box,
Chip,
+ IconButton,
Menu,
MenuItem,
Tooltip,
Typography,
- IconButton,
} from '@mui/joy'
import { useState } from 'react'
import { useNavigate } from 'react-router-dom'
@@ -116,6 +116,8 @@ const CustomFilterChips = ({
px: 1.0,
py: 0.5,
height: 32,
+ display: 'flex',
+ alignItems: 'center',
opacity: hasWarning ? 0.7 : isActive ? 1 : 0.85,
...(hasCustomColor && {
@@ -185,6 +187,10 @@ const CustomFilterChips = ({
maxWidth: 100,
overflow: 'hidden',
textOverflow: 'ellipsis',
+ display: 'flex',
+ alignItems: 'center',
+ height: '100%',
+ lineHeight: 1,
...(hasCustomColor && {
color: textColor,
}),
diff --git a/src/views/Chores/components/MyChoreHeader.jsx b/src/views/Chores/components/MyChoreHeader.jsx
index ab9ae4a..feb40f6 100644
--- a/src/views/Chores/components/MyChoreHeader.jsx
+++ b/src/views/Chores/components/MyChoreHeader.jsx
@@ -9,12 +9,10 @@ const MyChoreHeader = ({
tempFilter,
tempFilterMeta,
}) => {
- if (
- !activeFilterId &&
- !tempFilter &&
- (!selectedProject || selectedProject.id === 'default')
- )
- return null
+ const isVisible =
+ !!activeFilterId ||
+ !!tempFilter ||
+ (!!selectedProject && selectedProject.id !== 'default')
const renderIcon = () => {
if (tempFilter) {
@@ -53,18 +51,41 @@ const MyChoreHeader = ({
: activeFilter?.description || selectedProject?.description
return (
-
- {renderIcon()}
-
-
- {name}
-
- {description && (
-
- {description}
+
+
+ {renderIcon()}
+
+
+ {name}
- )}
-
+
+
+ {description}
+
+
+
+
)
}
diff --git a/src/views/Chores/hooks/useChoreActions.js b/src/views/Chores/hooks/useChoreActions.js
index 25b5820..adca5a3 100644
--- a/src/views/Chores/hooks/useChoreActions.js
+++ b/src/views/Chores/hooks/useChoreActions.js
@@ -12,6 +12,7 @@ import {
MarkChoreComplete,
NudgeChore,
RejectChore,
+ SaveChore,
SkipChore,
UndoChoreAction,
UpdateChoreAssignee,
@@ -664,6 +665,28 @@ export const useChoreActions = ({
}
break
+ case 'moveToProject': {
+ const project = extraData?.project
+ const projectId = project?.id === null ? null : project?.id
+ const updatedChore = { ...chore, projectId }
+ try {
+ const response = await SaveChore(updatedChore)
+ if (response.ok) {
+ updateChoreInState(updatedChore, 'moved-to-project')
+ showSuccess({
+ title: 'Task Moved',
+ message: `Task moved to ${project?.name || 'Default Project'}.`,
+ })
+ }
+ } catch (error) {
+ showError({
+ title: 'Failed to move task',
+ message: error?.message || 'Unable to move task to project',
+ })
+ }
+ break
+ }
+
case 'completeWithNote':
case 'completeWithPastDate':
case 'changeAssignee':
diff --git a/src/views/Error.jsx b/src/views/Error.jsx
index b140ed2..10a06e1 100644
--- a/src/views/Error.jsx
+++ b/src/views/Error.jsx
@@ -1,52 +1,226 @@
-import { HomeRounded, Login } from '@mui/icons-material'
-import { Box, Button, CircularProgress, Container, Typography } from '@mui/joy'
-import { Link } from 'react-router-dom'
-import Logo from '../Logo' // Adjust the import path as necessary
+import {
+ BugReportRounded,
+ CloudOffRounded,
+ ContentCopyRounded,
+ ErrorRounded,
+ ExpandMoreRounded,
+ HomeRounded,
+ LockRounded,
+ RefreshRounded,
+ SearchOffRounded,
+} from '@mui/icons-material'
+import { Box, Button, IconButton, Snackbar, Typography } from '@mui/joy'
+import { useState } from 'react'
+import { Link, useRouteError } from 'react-router-dom'
+
+const getErrorKind = error => {
+ if (!error)
+ return { label: 'Unknown Error', color: 'danger', Icon: ErrorRounded }
+ const status = error?.status ?? error?.response?.status
+ if (status === 404)
+ return {
+ label: '404 · Not Found',
+ color: 'warning',
+ Icon: SearchOffRounded,
+ }
+ if (status === 401 || status === 403)
+ return {
+ label: `${status} · Unauthorized`,
+ color: 'warning',
+ Icon: LockRounded,
+ }
+ if (status >= 500)
+ return {
+ label: `${status} · Server Error`,
+ color: 'danger',
+ Icon: CloudOffRounded,
+ }
+ if (error?.name === 'TypeError')
+ return { label: 'Runtime Error', color: 'danger', Icon: BugReportRounded }
+ if (error?.name === 'SyntaxError')
+ return { label: 'Syntax Error', color: 'danger', Icon: BugReportRounded }
+ return { label: 'Unexpected Error', color: 'danger', Icon: ErrorRounded }
+}
+
+const safeMessage = error => {
+ const msg = error?.message ?? error?.statusText
+ if (!msg || msg === '[object Object]') return null
+ return msg
+}
+
+const buildErrorText = (error, url) => {
+ const lines = [
+ `URL: ${url}`,
+ `Time: ${new Date().toISOString()}`,
+ `Error: ${safeMessage(error) ?? String(error)}`,
+ ]
+ if (error?.stack) lines.push(`\nStack:\n${error.stack}`)
+ return lines.join('\n')
+}
const Error = () => {
+ const error = useRouteError()
+ const [showDetails, setShowDetails] = useState(false)
+ const [copied, setCopied] = useState(false)
+
+ const { color, Icon } = getErrorKind(error)
+ const message = safeMessage(error)
+ const url = window.location.href
+
+ const handleCopy = () => {
+ navigator.clipboard.writeText(buildErrorText(error, url)).then(() => {
+ setCopied(true)
+ })
+ }
+
return (
-
+
+ {/* Decorative dots */}
+
+
+
+
+ {/* Icon with concentric rings */}
+
-
-
-
- Ops, something went wrong
-
-
- if you think this is a mistake, please contact us or{' '}
-
- open issue here
- {' '}
-
+
+
+
+
+
+
+ {/* Title */}
+
+ Something went wrong
+
+
+ {/* Error message */}
+
+ {message ??
+ 'An unexpected error occurred. Try reloading — it usually fixes it.'}
+
+
+ {/* Primary CTA */}
+ }
+ onClick={() => window.location.reload()}
+ sx={{ width: '100%', mb: 2 }}
+ >
+ Try again
+
+
+ {/* Secondary actions */}
+
}
>
@@ -55,16 +229,108 @@ const Error = () => {
}
>
Login
-
+
+ {/* Report hint + collapsible details */}
+
+
+ If this keeps happening,{' '}
+
+ open an issue
+ {' '}
+ and include the error details below.
+
+
+ {(error?.stack || message) && (
+ <>
+
+
+ {showDetails && (
+
+
+
+
+
+ {buildErrorText(error, url)}
+
+
+ )}
+ >
+ )}
+
+
+ setCopied(false)}
+ anchorOrigin={{ vertical: 'bottom', horizontal: 'center' }}
+ size='sm'
+ >
+ Error details copied to clipboard
+
+
)
}
diff --git a/src/views/components/ChoreActionMenu.jsx b/src/views/components/ChoreActionMenu.jsx
index 57c58e8..88d8c42 100644
--- a/src/views/components/ChoreActionMenu.jsx
+++ b/src/views/components/ChoreActionMenu.jsx
@@ -1,8 +1,10 @@
import {
Archive,
+ ArrowBack,
Cancel,
CopyAll,
Delete,
+ DriveFileMove,
Edit,
ManageSearch,
MoreTime,
@@ -20,10 +22,25 @@ import {
WbSunny,
Weekend,
} from '@mui/icons-material'
-import { Divider, IconButton, Menu, MenuItem, Tooltip } from '@mui/joy'
+import {
+ Avatar,
+ Divider,
+ IconButton,
+ ListItemContent,
+ ListItemDecorator,
+ Menu,
+ MenuItem,
+ Tooltip,
+ Typography,
+} from '@mui/joy'
import React, { useEffect, useState } from 'react'
import { useNavigate } from 'react-router-dom'
+import LABEL_COLORS, {
+ getTextColorFromBackgroundColor,
+} from '../../utils/Colors'
import { isOfficialDonetickInstanceSync } from '../../utils/FeatureToggle'
+import { getIconComponent } from '../../utils/ProjectIcons'
+import { useProjects } from '../Projects/ProjectQueries'
const ChoreActionMenu = ({
chore,
@@ -43,10 +60,11 @@ const ChoreActionMenu = ({
}) => {
const [anchorEl, setAnchorEl] = React.useState(null)
const [isOfficialInstance, setIsOfficialInstance] = useState(false)
+ const [showProjectPicker, setShowProjectPicker] = useState(false)
const menuRef = React.useRef(null)
const navigate = useNavigate()
+ const { data: projects = [] } = useProjects()
- // Check if this is the official donetick.com instance
useEffect(() => {
try {
setIsOfficialInstance(isOfficialDonetickInstanceSync())
@@ -83,6 +101,12 @@ const ChoreActionMenu = ({
const handleMenuClose = () => {
setAnchorEl(null)
+ setShowProjectPicker(false)
+ }
+
+ const handleMoveToProject = project => {
+ onAction?.('moveToProject', chore, { project })
+ handleMenuClose()
}
const handleEdit = () => {
@@ -134,7 +158,6 @@ const ChoreActionMenu = ({
switch (option) {
case 'today': {
- // Schedule for today at the next available slot: 9am, 12pm, 5pm, or now if after 5pm
const nowHour = now.getHours()
const scheduled = new Date(today)
if (nowHour < 9) {
@@ -144,7 +167,6 @@ const ChoreActionMenu = ({
} else if (nowHour < 17) {
scheduled.setHours(17, 0, 0, 0)
} else {
- // After 5pm, use current time
scheduled.setHours(
now.getHours(),
now.getMinutes(),
@@ -163,7 +185,7 @@ const ChoreActionMenu = ({
case 'tomorrow': {
const tomorrow = new Date(today)
tomorrow.setDate(today.getDate() + 1)
- tomorrow.setHours(12, 0, 0, 0) // Set to noon
+ tomorrow.setHours(12, 0, 0, 0)
return tomorrow
}
case 'tomorrow-afternoon': {
@@ -195,6 +217,18 @@ const ChoreActionMenu = ({
handleMenuClose()
}
+ const renderProjectAvatar = (color, icon) => {
+ const bg = color || LABEL_COLORS[0].value
+ const IconComponent = getIconComponent(icon || 'FolderOpen')
+ return (
+
+
+
+ )
+ }
+
return (
<>
-
-
-
-
- {isOfficialInstance && (
-
- )}
-
-
-
-
+
+ {
e.stopPropagation()
- handleQuickSchedule('tomorrow')
+ handleMoveToProject({ id: null, name: 'Default Project' })
}}
>
-
-
-
- {/*
-
+ {renderProjectAvatar(LABEL_COLORS[0].value, 'FolderOpen')}
+
+
+ Default Project
+
+
+ {projects.map(project => (
+ {
+ e.stopPropagation()
+ handleMoveToProject(project)
+ }}
+ >
+
+ {renderProjectAvatar(project.color, project.icon)}
+
+
+ {project.name}
+
+
+ ))}
+ >
+ ) : (
+ <>
+ {
e.stopPropagation()
- handleQuickSchedule('tomorrow-afternoon')
+ onCompleteWithNote?.()
+ handleMenuClose()
}}
>
-
-
- */}
-
-
+ Complete with note
+
+ {
e.stopPropagation()
- handleQuickSchedule('weekend')
+ onCompleteWithPastDate?.()
+ handleMenuClose()
}}
>
-
-
-
-
-
+ Complete in past
+
+ {
e.stopPropagation()
- handleQuickSchedule('next-week')
+ handleSkip()
}}
>
-
-
-
-
-
+ Skip to next due date
+
+ {
+ e.stopPropagation()
+ onChangeAssignee?.()
+ handleMenuClose()
+ }}
+ >
+
+ Delegate to someone else
+
+ {isOfficialInstance && (
+ {
+ e.stopPropagation()
+ onNudge?.()
+ handleMenuClose()
+ }}
+ >
+
+ Send nudge
+
+ )}
+
+ {
+ e.stopPropagation()
+ handleHistory()
+ }}
+ >
+
+ History
+
+
+ e.stopPropagation()}
+ >
+
+ {
+ e.stopPropagation()
+ handleQuickSchedule('today')
+ }}
+ >
+
+
+
+
+ {
+ e.stopPropagation()
+ handleQuickSchedule('tomorrow')
+ }}
+ >
+
+
+
+
+ {
+ e.stopPropagation()
+ handleQuickSchedule('weekend')
+ }}
+ >
+
+
+
+
+ {
+ e.stopPropagation()
+ handleQuickSchedule('next-week')
+ }}
+ >
+
+
+
+
+ {
+ e.stopPropagation()
+ handleQuickSchedule('remove')
+ }}
+ >
+
+
+
+
+
+ {
+ e.stopPropagation()
+ onChangeDueDate?.()
+ handleMenuClose()
+ }}
+ >
+
+ Change due date
+
+ {
+ e.stopPropagation()
+ onWriteNFC?.()
+ handleMenuClose()
+ }}
+ >
+
+ Write to NFC
+
+ {
+ e.stopPropagation()
+ handleEdit()
+ }}
+ >
+
+ Edit
+
+ {
+ e.stopPropagation()
+ handleClone()
+ }}
+ >
+
+ Clone
+
+ {
+ e.stopPropagation()
+ handleView()
+ }}
+ >
+
+ View
+
+ {
+ e.stopPropagation()
+ handleArchive()
+ }}
color='neutral'
+ >
+ {chore.isActive ? : }
+ {chore.isActive ? 'Archive' : 'Unarchive'}
+
+ {projects.length > 0 && (
+ {
+ e.stopPropagation()
+ setShowProjectPicker(true)
+ }}
+ >
+
+ Move to project
+
+ )}
+
+ {
e.stopPropagation()
- handleQuickSchedule('remove')
+ handleDelete()
}}
+ color='danger'
>
-
-
-
-
-
- {
- e.stopPropagation()
- onChangeDueDate?.()
- handleMenuClose()
- }}
- >
-
- Change due date
-
- {
- e.stopPropagation()
- onWriteNFC?.()
- handleMenuClose()
- }}
- >
-
- Write to NFC
-
- {
- e.stopPropagation()
- handleEdit()
- }}
- >
-
- Edit
-
- {
- e.stopPropagation()
- handleClone()
- }}
- >
-
- Clone
-
- {
- e.stopPropagation()
- handleView()
- }}
- >
-
- View
-
- {
- e.stopPropagation()
- handleArchive()
- }}
- color='neutral'
- >
- {chore.isActive ? : }
- {chore.isActive ? 'Archive' : 'Unarchive'}
-
-
- {
- e.stopPropagation()
- handleDelete()
- }}
- color='danger'
- >
-
- Delete
-
+
+ Delete
+
+ >
+ )}
>
)