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 */} + + + {/* Secondary actions */} + - + + {/* 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 ( <> - { - e.stopPropagation() - onCompleteWithNote?.() - handleMenuClose() - }} - > - - Complete with note - - { - e.stopPropagation() - onCompleteWithPastDate?.() - handleMenuClose() - }} - > - - Complete in past - - { - e.stopPropagation() - 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') + setShowProjectPicker(false) }} + sx={{ gap: 1 }} > - - - - - + + Move to project + + + + { 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 + + + )} )