diff --git a/src/CapacitorListener.js b/src/CapacitorListener.js index c1facf2..32bd343 100644 --- a/src/CapacitorListener.js +++ b/src/CapacitorListener.js @@ -5,6 +5,7 @@ import { Device } from '@capacitor/device' import { LocalNotifications } from '@capacitor/local-notifications' import { Preferences } from '@capacitor/preferences' import { PushNotifications } from '@capacitor/push-notifications' +import { focusManager } from '@tanstack/react-query' import { RegisterDeviceToken } from './utils/Fetcher' // OAuth callback handler for deep links @@ -225,6 +226,10 @@ const registerCapacitorListeners = () => { } }) + mobileApp.addListener('appStateChange', ({ isActive }) => { + focusManager.setFocused(isActive) + }) + mobileApp.addListener('backButton', ({ canGoBack }) => { if (canGoBack) { window.history.back() diff --git a/src/components/NotificationTemplate.jsx b/src/components/NotificationTemplate.jsx index faad23d..0556e39 100644 --- a/src/components/NotificationTemplate.jsx +++ b/src/components/NotificationTemplate.jsx @@ -14,12 +14,9 @@ import Select from '@mui/joy/Select' import Typography from '@mui/joy/Typography' import { useCallback, useEffect, useState } from 'react' import { NOTIFICATION_TYPE, TASK_COLOR } from '../utils/Colors' +import { TIME_UNITS } from '../utils/DurationUtils' -const timeUnits = [ - { label: 'Mins', value: 'm' }, - { label: 'Hours', value: 'h' }, - { label: 'Days', value: 'd' }, -] +const timeUnits = TIME_UNITS const timingOptions = [ { label: 'Before', value: 'before' }, diff --git a/src/components/common/DurationInput.jsx b/src/components/common/DurationInput.jsx new file mode 100644 index 0000000..a1bac5f --- /dev/null +++ b/src/components/common/DurationInput.jsx @@ -0,0 +1,104 @@ +import { Add, Remove } from '@mui/icons-material' +import { Box, IconButton, Input, Option, Select } from '@mui/joy' +import { useEffect, useState } from 'react' +import { + secondsToValueAndUnit, + TIME_UNITS, + valueAndUnitToSeconds, +} from '../../utils/DurationUtils' + +/** + * A reusable duration picker: [−] number [+] unit-select + * + * Props: + * value – duration in seconds (positive integer) + * onChange – called with new duration in seconds + * size – Joy UI size ('sm' | 'md') + * minValue – minimum numeric value (default 1) + */ +const DurationInput = ({ value, onChange, size = 'md', minValue = 1 }) => { + const derived = + value != null && value >= 0 + ? secondsToValueAndUnit(value) + : { value: 1, unit: 'h' } + const [displayValue, setDisplayValue] = useState(derived.value) + const [unit, setUnit] = useState(derived.unit) + + useEffect(() => { + if (value != null && value >= 0) { + const { value: v, unit: u } = secondsToValueAndUnit(value) + setDisplayValue(v) + setUnit(u) + } + }, [value]) + + const emit = (v, u) => { + onChange(valueAndUnitToSeconds(v, u)) + } + + const handleDecrement = () => { + const next = Math.max(minValue, displayValue - 1) + setDisplayValue(next) + emit(next, unit) + } + + const handleIncrement = () => { + const next = displayValue + 1 + setDisplayValue(next) + emit(next, unit) + } + + return ( + + + + + + { + const v = Math.max(minValue, parseInt(e.target.value) || minValue) + setDisplayValue(v) + emit(v, unit) + }} + /> + + + + + + + + ) +} + +export default DurationInput diff --git a/src/queries/ChoreQueries.jsx b/src/queries/ChoreQueries.jsx index d8869d3..c17bf8b 100644 --- a/src/queries/ChoreQueries.jsx +++ b/src/queries/ChoreQueries.jsx @@ -25,6 +25,7 @@ import { localStore } from '../utils/LocalStore' export const useChores = includeArchive => { return useQuery({ queryKey: ['chores', includeArchive], + refetchOnWindowFocus: true, queryFn: async () => { const onlineChores = await GetChoresNew(includeArchive) @@ -272,6 +273,7 @@ export const useChoresHistory = (initialLimit, includeMembers) => { export const useChoreDetails = choreId => { return useQuery({ queryKey: ['choreDetails', choreId], + refetchOnWindowFocus: true, queryFn: async () => { var onlineChore = null diff --git a/src/utils/Chores.jsx b/src/utils/Chores.jsx index 74e5226..6813603 100644 --- a/src/utils/Chores.jsx +++ b/src/utils/Chores.jsx @@ -324,7 +324,7 @@ export const notInCompletionWindow = chore => { chore.completionWindow && chore.completionWindow > -1 && chore.nextDueDate && - moment().add(chore.completionWindow, 'hours') < moment(chore.nextDueDate) + moment() < moment(chore.nextDueDate).add(-chore.completionWindow, 'seconds') ) } export const ChoreFilters = userId => ({ diff --git a/src/utils/DurationUtils.js b/src/utils/DurationUtils.js new file mode 100644 index 0000000..1a4849f --- /dev/null +++ b/src/utils/DurationUtils.js @@ -0,0 +1,18 @@ +export const TIME_UNITS = [ + { label: 'Mins', value: 'm', seconds: 60 }, + { label: 'Hours', value: 'h', seconds: 3600 }, + { label: 'Days', value: 'd', seconds: 86400 }, +] + +export function secondsToValueAndUnit(totalSeconds) { + if (totalSeconds % 86400 === 0) + return { value: totalSeconds / 86400, unit: 'd' } + if (totalSeconds % 3600 === 0) + return { value: totalSeconds / 3600, unit: 'h' } + return { value: Math.round(totalSeconds / 60), unit: 'm' } +} + +export function valueAndUnitToSeconds(value, unit) { + const unitInfo = TIME_UNITS.find(u => u.value === unit) + return value * (unitInfo?.seconds ?? 1) +} diff --git a/src/utils/SidepanelConfig.js b/src/utils/SidepanelConfig.js index ed065d3..26ffb8e 100644 --- a/src/utils/SidepanelConfig.js +++ b/src/utils/SidepanelConfig.js @@ -7,13 +7,21 @@ export const DEFAULT_SIDEPANEL_CONFIG = [ enabled: true, order: 0, }, + { + id: 'smartInsights', + name: 'Smart Insights', + description: 'Quick actions based on your tasks', + iconName: 'TrendingUp', + enabled: false, + order: 1, + }, { id: 'assignees', name: 'Tasks by Assignee', description: 'Groups tasks by who they are assigned to', iconName: 'Person', enabled: true, - order: 1, + order: 2, }, { id: 'calendar', @@ -21,7 +29,7 @@ export const DEFAULT_SIDEPANEL_CONFIG = [ description: 'Shows tasks in a calendar format', iconName: 'CalendarMonth', enabled: true, - order: 2, + order: 3, }, { id: 'activities', @@ -29,7 +37,7 @@ export const DEFAULT_SIDEPANEL_CONFIG = [ description: 'Shows recent task completions and activities', iconName: 'History', enabled: true, - order: 3, + order: 4, }, { id: 'weeklyGoals', @@ -37,21 +45,37 @@ export const DEFAULT_SIDEPANEL_CONFIG = [ description: 'Shows weekly progress and family completion stats', iconName: 'EmojiEvents', enabled: true, - order: 4, + order: 5, }, ] export const getSidepanelConfig = () => { const saved = localStorage.getItem('sidepanelConfig') + let savedConfig = [] + if (saved) { try { - return JSON.parse(saved) + savedConfig = JSON.parse(saved) } catch (error) { console.error('Error parsing sidepanel config:', error) return DEFAULT_SIDEPANEL_CONFIG } } - return DEFAULT_SIDEPANEL_CONFIG + + // Merge saved config with default config + // This ensures new items in DEFAULT_SIDEPANEL_CONFIG are added to existing configs + const mergedConfig = DEFAULT_SIDEPANEL_CONFIG.map(defaultItem => { + const savedItem = savedConfig.find(item => item.id === defaultItem.id) + return savedItem || defaultItem + }) + + // Add any saved items that are no longer in default (for backwards compatibility) + const newSavedItems = savedConfig.filter( + savedItem => + !DEFAULT_SIDEPANEL_CONFIG.find(item => item.id === savedItem.id), + ) + + return [...mergedConfig, ...newSavedItems] } export const saveSidepanelConfig = config => { diff --git a/src/views/Authorization/Signup.jsx b/src/views/Authorization/Signup.jsx index 021a389..4dc6988 100644 --- a/src/views/Authorization/Signup.jsx +++ b/src/views/Authorization/Signup.jsx @@ -70,7 +70,12 @@ const SignupView = () => { } if (password.length < 8) { - setPasswordError('Password must be at least 8 characters') + setPasswordError('Password must be between 8 and 64 characters') + isValid = false + } + + if (password.length > 64) { + setPasswordError('Password must be between 8 and 64 characters') isValid = false } @@ -218,6 +223,7 @@ const SignupView = () => { label='Password' type='password' id='password' + placeholder='Enter password (8-64 characters)' value={password} onChange={e => { setPasswordError(null) @@ -237,6 +243,7 @@ const SignupView = () => { name='displayName' label='Display Name' id='displayName' + placeholder='Confirm password' value={displayName} onChange={e => { setDisplayNameError(null) diff --git a/src/views/Authorization/UpdatePasswordView.jsx b/src/views/Authorization/UpdatePasswordView.jsx index d891706..dbce331 100644 --- a/src/views/Authorization/UpdatePasswordView.jsx +++ b/src/views/Authorization/UpdatePasswordView.jsx @@ -31,8 +31,8 @@ const UpdatePasswordView = () => { const handlePasswordChange = e => { const password = e.target.value setPassword(password) - if (password.length < 8) { - setPasswordError('Password must be at least 8 characters') + if (password.length < 8 || password.length > 64) { + setPasswordError('Password must be between 8 and 64 characters') } else { setPasswordError(null) } diff --git a/src/views/ChoreEdit/ChoreEdit.jsx b/src/views/ChoreEdit/ChoreEdit.jsx index 98c413e..c1b4af3 100644 --- a/src/views/ChoreEdit/ChoreEdit.jsx +++ b/src/views/ChoreEdit/ChoreEdit.jsx @@ -19,12 +19,12 @@ import { RadioGroup, Select, Sheet, - Switch, Typography, } from '@mui/joy' import moment from 'moment' import { useEffect, useState } from 'react' import { useNavigate, useParams, useSearchParams } from 'react-router-dom' +import DurationInput from '../../components/common/DurationInput' import KeyboardShortcutHint from '../../components/common/KeyboardShortcutHint' import NotificationTemplate from '../../components/NotificationTemplate.jsx' import { @@ -97,9 +97,7 @@ const ChoreEdit = () => { const [isPrivate, setIsPrivate] = useState(false) const [subTasks, setSubTasks] = useState(null) const [completionWindow, setCompletionWindow] = useState(-1) - const [deadline, setDeadline] = useState(null) const [deadlineOffset, setDeadlineOffset] = useState(-1) - const [deadlineUnit, setDeadlineUnit] = useState('hours') const [allUserThings, setAllUserThings] = useState([]) const [thingTrigger, setThingTrigger] = useState(null) const [isThingValid, setIsThingValid] = useState(false) @@ -352,6 +350,7 @@ const ChoreEdit = () => { completionWindow: // if completionWindow is -1 then set it to null or dueDate is null completionWindow < 0 || dueDate === null ? null : completionWindow, + deadlineOffset: deadlineOffset < 0 ? null : deadlineOffset, priority: priority, projectId: projectId === 'default' ? null : projectId, } @@ -477,6 +476,11 @@ const ChoreEdit = () => { ? data.res.completionWindow : -1, ) + setDeadlineOffset( + data.res.deadlineOffset && data.res.deadlineOffset > -1 + ? data.res.deadlineOffset + : -1, + ) setLabelsV2(data.res.labelsV2) @@ -1174,181 +1178,88 @@ const ChoreEdit = () => { {dueDate && ( - Completion Window - - { - event.preventDefault() - if (completionWindow != -1) { - setCompletionWindow(-1) - } else { - setCompletionWindow(1) - } - }} - color={completionWindow !== -1 ? 'success' : 'neutral'} - variant={completionWindow !== -1 ? 'solid' : 'outlined'} - sx={{ - mr: 2, - }} - /> -
- - Completion window (hours) - - - {"Set a time window that task can't be completed before"} - -
-
- {completionWindow != -1 && ( - - - Hours: - { - setCompletionWindow(parseInt(e.target.value)) - }} - /> - - - )} -
- )} - - {dueDate && ( - - Deadline + Task Window - When should this task be considered expired? + Define when this task can be completed and when it expires - {/* One-time tasks: Date picker */} - {['once', 'no_repeat'].includes(frequencyType) ? ( - - { - if (e.target.checked) { - // Set deadline to 24 hours after due date by default - const deadlineDate = moment(dueDate) - .add(1, 'day') - .format('YYYY-MM-DDTHH:mm:00') - setDeadline(deadlineDate) - } else { - setDeadline(null) - } - }} - checked={deadline !== null} - overlay - label='Set a deadline for this task' + {/* Available From (Completion Window) */} + + { + if (e.target.checked) { + setCompletionWindow(3600) // default 1 hour in seconds + } else { + setCompletionWindow(-1) + } + }} + overlay + label='Set earliest completion time' + /> + + Task becomes available to complete X hours before the due date + + + + {completionWindow !== -1 && ( + + - - Task will be considered expired after this date - - - ) : ( - /* Recurring tasks: Offset input */ - - { - if (e.target.checked) { - setDeadlineOffset(24) // Default to 24 hours - } else { - setDeadlineOffset(-1) - } - }} - checked={deadlineOffset !== -1} - overlay - label='Set a deadline for this task' - /> - - Task will be considered expired after the specified time from - due date - - + before due date + )} - {/* Date picker for one-time tasks */} - {deadline && ['once', 'no_repeat'].includes(frequencyType) && ( - - - - Deadline Date: - - setDeadline(e.target.value)} - slotProps={{ - input: { - min: dueDate, // Deadline cannot be before due date - }, - }} - /> - - - )} + {/* Expires After (Deadline) */} + + { + if (e.target.checked) { + setDeadlineOffset(86400) // default 1 day in seconds + } else { + setDeadlineOffset(-1) + } + }} + overlay + label='Set a deadline' + /> + + Task will be considered expired after the due date + + - {/* Offset input for recurring tasks */} - {deadlineOffset !== -1 && - !['once', 'no_repeat'].includes(frequencyType) && ( - - - - - Time after due date: - - { - setDeadlineOffset(parseInt(e.target.value) || 1) - }} - /> - - - - Unit: - - - - - - )} + {deadlineOffset !== -1 && ( + + + after due date + + )} )} diff --git a/src/views/ChoreEdit/ChoreView.jsx b/src/views/ChoreEdit/ChoreView.jsx index fdbc12e..9f1d843 100644 --- a/src/views/ChoreEdit/ChoreView.jsx +++ b/src/views/ChoreEdit/ChoreView.jsx @@ -156,7 +156,7 @@ const ChoreView = () => { chore.lastCompletedDate ? performers.find(p => p.userId === chore.lastCompletedBy) ?.displayName - : '--' + : 'N/A' }`, }, { @@ -171,6 +171,11 @@ const ChoreView = () => { ? moment(chore.lastCompletedDate).fromNow() : t('choreView.na') }`, + + subtext2: + chore.deadlineOffset > 0 && chore.nextDueDate + ? `Deadline: ${moment(chore.nextDueDate).add(chore.deadlineOffset, 'seconds').fromNow()}` + : null, }, { size: 6, @@ -520,7 +525,10 @@ const ChoreView = () => { handleAction={action => { if (action === 'pause') { handleChorePause() - } else if (action === 'resume') { + } else if ( + action === 'resume' && + !notInCompletionWindow(chore) + ) { handleChoreStart() } }} @@ -578,6 +586,14 @@ const ChoreView = () => { > {card.subtext} + {card.subtext2 && ( + + {card.subtext2} + + )} @@ -1034,8 +1050,9 @@ const ChoreView = () => { }) }} disabled={ - chore.lastCompletedDate !== null && - chore.frequencyType === 'once' + notInCompletionWindow(chore) || + (chore.lastCompletedDate !== null && + chore.frequencyType === 'once') } startDecorator={} sx={{ @@ -1047,12 +1064,25 @@ const ChoreView = () => { )} + {notInCompletionWindow(chore) && ( + + Available to complete starting{' '} + {moment(chore.nextDueDate) + .subtract(chore.completionWindow, 'seconds') + .format('MM/DD/YYYY hh:mm A')} + + )} {/* Timer Button - Show split button when timer is active, regular button otherwise */} {[ChoreStatus.ACTIVE, ChoreStatus.PAUSED].includes(chore.status) ? ( { @@ -1078,8 +1108,9 @@ const ChoreView = () => { variant='soft' color='success' disabled={ - chore.lastCompletedDate !== null && - chore.frequencyType === 'once' + notInCompletionWindow(chore) || + (chore.lastCompletedDate !== null && + chore.frequencyType === 'once') } startDecorator={} sx={{ diff --git a/src/views/Chores/ActivitesCard.jsx b/src/views/Chores/ActivitesCard.jsx index 55c7fcd..6f0f0e9 100644 --- a/src/views/Chores/ActivitesCard.jsx +++ b/src/views/Chores/ActivitesCard.jsx @@ -17,6 +17,7 @@ import { Chip, Divider, IconButton, + Link, List, ListItem, ListItemContent, @@ -25,16 +26,31 @@ import { Typography, } from '@mui/joy' import moment from 'moment' +import { useState } from 'react' import { useChores, useChoresHistory } from '../../queries/ChoreQueries' import { useCircleMembers } from '../../queries/UserQueries' import { resolvePhotoURL } from '../../utils/Helpers' +import NoteViewerModal from '../Modals/Inputs/NoteViewerModal' -const ActivityItem = ({ activity, members }) => { +const ActivityItem = ({ activity, members, onViewNote }) => { // Find the member who completed the activity const completedByMember = members?.find( member => member.userId === activity.completedBy, ) + // Strip HTML tags from notes for plain text display + const stripHtmlTags = html => { + if (!html) return '' + const div = document.createElement('div') + div.innerHTML = html + return div.textContent || div.innerText || '' + } + + const plainTextNotes = activity.notes ? stripHtmlTags(activity.notes) : '' + + // Calculate if notes should be truncated (more than 2 lines in the UI, which is roughly 100 characters) + const shouldTruncate = plainTextNotes && plainTextNotes.length > 80 + const getTimeDisplay = dateToDisplay => { const now = moment() const completed = moment(dateToDisplay) @@ -177,7 +193,7 @@ const ActivityItem = ({ activity, members }) => { > {/* Notes */} - {activity.notes && ( + {plainTextNotes && ( { color: 'text.secondary', }} > - - {activity.notes} + + + + {plainTextNotes} + + {shouldTruncate && ( + onViewNote(activity.notes)} + sx={{ + cursor: 'pointer', + mt: 0.25, + display: 'inline-block', + }} + > + Show more + + )} + )} @@ -217,6 +261,8 @@ const groupActivitiesByDate = activities => { } const ActivitiesCard = ({ title = 'Recent Activities' }) => { + const [noteViewerConfig, setNoteViewerConfig] = useState({ isOpen: false }) + // Use hooks to fetch data const { data: choresData, @@ -430,6 +476,14 @@ const ActivitiesCard = ({ title = 'Recent Activities' }) => { key={activity.id} activity={activity} members={members} + onViewNote={notes => { + setNoteViewerConfig({ + isOpen: true, + title: `Note - ${activity.choreName}`, + content: notes, + onClose: () => setNoteViewerConfig({ isOpen: false }), + }) + }} /> ))} @@ -437,6 +491,8 @@ const ActivitiesCard = ({ title = 'Recent Activities' }) => { ) })} + + ) } diff --git a/src/views/Chores/ChoreCard.jsx b/src/views/Chores/ChoreCard.jsx index 12592f5..8d85c52 100644 --- a/src/views/Chores/ChoreCard.jsx +++ b/src/views/Chores/ChoreCard.jsx @@ -213,7 +213,7 @@ const ChoreCard = ({ {getName(chore.name)} - {chore.assignedTo && chore.assignedTo !== userProfile?.id && ( + {chore.assignedTo && ( p.userId === chore.assignedTo, )?.displayName diff --git a/src/views/Chores/MyChores.jsx b/src/views/Chores/MyChores.jsx index d800c93..2f219a5 100644 --- a/src/views/Chores/MyChores.jsx +++ b/src/views/Chores/MyChores.jsx @@ -62,6 +62,7 @@ import ChoreListView from './ChoreListView.jsx' import ChoreModals from './components/ChoreModals' import FilterSection from './components/FilterSection' import MultiSelectToolbar from './components/MultiSelectToolbar' +import MyChoreHeader from './components/MyChoreHeader' import SearchBar from './components/SearchBar' import { useChoreActions } from './hooks/useChoreActions' import { useChoreFilters } from './hooks/useChoreFilters' @@ -76,6 +77,7 @@ import { } from './LocalNotificationScheduler' import NotificationAccessSnackbar from './NotificationAccessSnackbar' import Sidepanel from './Sidepanel' +import { INSIGHT_FILTER_DEFS } from './SmartInsightsCard' import SortAndGrouping from './SortAndGrouping' const MyChores = () => { @@ -167,6 +169,7 @@ const MyChores = () => { activeFilter, activeFilterId, tempFilter, + tempFilterMeta, filteredChores: customFilteredChores, applyCustomFilter, clearActiveFilter, @@ -376,6 +379,18 @@ const MyChores = () => { const oldFilter = searchParams.get('filter') + // Restore smart insight temp filter from URL (e.g. on page reload) + // Insight IDs are strings (e.g. 'overdue'), saved filter IDs are numeric + if ( + filterId && + INSIGHT_FILTER_DEFS[filterId] && + tempFilterMeta?.id !== filterId + ) { + const def = INSIGHT_FILTER_DEFS[filterId] + applyTempFilter(def.filter, { id: filterId, name: def.name }) + return + } + // If filterId is no longer in URL but filter is still active in state, clear it if (!filterId && !oldFilter && activeFilterId) { clearActiveFilter() @@ -422,6 +437,7 @@ const MyChores = () => { activeFilterId, savedFilters, applyCustomFilter, + applyTempFilter, clearActiveFilter, selectedProject, projectFilteredChores, @@ -431,6 +447,35 @@ const MyChores = () => { setSelectedCalendarDate, ]) + // Sync tempFilterMeta (smart insight) → URL using filterId param + // Insight IDs are strings (e.g. 'overdue') so they don't conflict with numeric saved filter IDs + useEffect(() => { + const insightId = tempFilterMeta?.id + const params = new URLSearchParams(searchParams) + const currentFilterId = params.get('filterId') + + if (insightId && currentFilterId !== insightId) { + params.delete('filter_id') + params.delete('filter') + params.set('filterId', insightId) + Navigate( + { pathname: '/chores', search: params.toString() }, + { replace: true }, + ) + } else if ( + !insightId && + currentFilterId && + INSIGHT_FILTER_DEFS[currentFilterId] + ) { + // Only clear filterId if it was set by an insight (not a numeric saved filter) + params.delete('filterId') + Navigate( + { pathname: '/chores', search: params.toString() }, + { replace: true }, + ) + } + }, [tempFilterMeta?.id, searchParams]) + const { handleChoreAction, handleChangeDueDate, @@ -830,6 +875,13 @@ const MyChores = () => { }} > + { +const Sidepanel = ({ + chores, + allChores, + applyTempFilter, + clearTempFilter, + tempFilter, +}) => { const isLargeScreen = useMediaQuery(theme => theme.breakpoints.up('lg')) const [dueDatePieChartData, setDueDatePieChartData] = useState([]) const [sidepanelConfig, setSidepanelConfig] = useState([]) @@ -55,6 +62,16 @@ const Sidepanel = ({ chores }) => { switch (cardConfig.id) { case 'welcome': return + case 'smartInsights': + return ( + + ) case 'assignees': return case 'calendar': diff --git a/src/views/Chores/SmartInsightsCard.jsx b/src/views/Chores/SmartInsightsCard.jsx new file mode 100644 index 0000000..0e1b666 --- /dev/null +++ b/src/views/Chores/SmartInsightsCard.jsx @@ -0,0 +1,379 @@ +import { + EventBusy, + EventNote, + HourglassEmpty, + PriorityHigh, + TrendingUp, + WatchLater, +} from '@mui/icons-material' +import { Box, Button, Chip, Sheet, Typography } from '@mui/joy' +import { useMemo } from 'react' +import { TASK_COLOR } from '../../utils/Colors' + +// Static insight filter definitions – used for URL restoration +export const INSIGHT_FILTER_DEFS = { + overdue: { + name: 'Overdue', + filter: { + conditions: [{ type: 'dueDate', operator: 'isOverdue', value: null }], + operator: 'AND', + }, + }, + 'due-today': { + name: 'Due Today', + filter: { + conditions: [{ type: 'dueDate', operator: 'isDueToday', value: null }], + operator: 'AND', + }, + }, + 'pending-approval': { + name: 'Pending Approval', + filter: { + conditions: [{ type: 'status', operator: 'is', value: 3 }], + operator: 'AND', + }, + }, + 'due-this-week': { + name: 'Due This Week', + filter: { + conditions: [{ type: 'dueDate', operator: 'isDueThisWeek', value: null }], + operator: 'AND', + }, + }, + 'high-priority': { + name: 'High Priority', + filter: { + conditions: [{ type: 'priority', operator: 'is', value: [1, 2] }], + operator: 'AND', + }, + }, + 'no-due-date': { + name: 'No Due Date', + filter: { + conditions: [{ type: 'dueDate', operator: 'hasNoDueDate', value: null }], + operator: 'AND', + }, + }, +} + +const SmartInsightsCard = ({ + chores, + applyTempFilter, + clearTempFilter, + tempFilter, +}) => { + // Detect all possible insights from chores + const insights = useMemo(() => { + if (!chores || chores.length === 0) return [] + + const now = new Date() + const today = new Date(now.getFullYear(), now.getMonth(), now.getDate()) + const tomorrow = new Date(today) + tomorrow.setDate(tomorrow.getDate() + 1) + const nextWeek = new Date(today) + nextWeek.setDate(nextWeek.getDate() + 7) + + const detectedInsights = [] + + // 1. Overdue tasks (Highest Priority) + const overdueTasks = chores.filter( + chore => chore.nextDueDate && new Date(chore.nextDueDate) < now, + ) + if (overdueTasks.length > 0) { + detectedInsights.push({ + id: 'overdue', + priority: 1, + count: overdueTasks.length, + title: 'Overdue', + description: `${overdueTasks.length} ${overdueTasks.length === 1 ? 'task is' : 'tasks are'} overdue`, + color: 'danger', + bgColor: TASK_COLOR.OVERDUE, + icon: , + filter: { + conditions: [ + { + type: 'dueDate', + operator: 'isOverdue', + value: null, + }, + ], + operator: 'AND', + }, + }) + } + + // 2. Due today (High Priority) + const dueTodayTasks = chores.filter( + chore => + chore.nextDueDate && + new Date(chore.nextDueDate).toDateString() === today.toDateString(), + ) + if (dueTodayTasks.length > 0) { + detectedInsights.push({ + id: 'due-today', + priority: 2, + count: dueTodayTasks.length, + title: 'Due Today', + description: `${dueTodayTasks.length} ${dueTodayTasks.length === 1 ? 'task' : 'tasks'} due by end of day`, + color: 'warning', + bgColor: '#FFA500', + icon: , + filter: { + conditions: [ + { + type: 'dueDate', + operator: 'isDueToday', + value: null, + }, + ], + operator: 'AND', + }, + }) + } + + // 3. Pending approval (High Priority) + const pendingApprovalTasks = chores.filter(chore => chore.status === 3) + if (pendingApprovalTasks.length > 0) { + detectedInsights.push({ + id: 'pending-approval', + priority: 3, + count: pendingApprovalTasks.length, + title: 'Pending Approval', + description: `${pendingApprovalTasks.length} ${pendingApprovalTasks.length === 1 ? 'task awaits' : 'tasks await'} approval`, + color: 'neutral', + bgColor: TASK_COLOR.PENDING_REVIEW, + icon: , + filter: { + conditions: [ + { + type: 'status', + operator: 'is', + value: 3, + }, + ], + operator: 'AND', + }, + }) + } + + // 4. Due this week (excluding today) (Medium Priority) + const dueThisWeekTasks = chores.filter(chore => { + if (!chore.nextDueDate) return false + const dueDate = new Date(chore.nextDueDate) + return dueDate >= tomorrow && dueDate < nextWeek + }) + if (dueThisWeekTasks.length > 0) { + detectedInsights.push({ + id: 'due-this-week', + priority: 4, + count: dueThisWeekTasks.length, + title: 'Due This Week', + description: `${dueThisWeekTasks.length} ${dueThisWeekTasks.length === 1 ? 'task' : 'tasks'} due in the next 7 days`, + color: 'primary', + bgColor: TASK_COLOR.IN_PROGRESS, + icon: , + filter: { + conditions: [ + { + type: 'dueDate', + operator: 'isDueThisWeek', + value: null, + }, + ], + operator: 'AND', + }, + }) + } + + // 5. High priority tasks (Medium Priority) + const highPriorityTasks = chores.filter( + chore => chore.priority === 1 || chore.priority === 2, + ) + if (highPriorityTasks.length > 0) { + detectedInsights.push({ + id: 'high-priority', + priority: 5, + count: highPriorityTasks.length, + title: 'High Priority', + description: `${highPriorityTasks.length} ${highPriorityTasks.length === 1 ? 'task requires' : 'tasks require'} immediate attention`, + color: 'warning', + bgColor: '#FF6B6B', + icon: , + filter: { + conditions: [ + { + type: 'priority', + operator: 'is', + value: [1, 2], + }, + ], + operator: 'AND', + }, + }) + } + + // 6. No due date (Lower Priority) + const noDueDateTasks = chores.filter( + chore => !chore.nextDueDate || chore.nextDueDate === null, + ) + if (noDueDateTasks.length > 0) { + detectedInsights.push({ + id: 'no-due-date', + priority: 6, + count: noDueDateTasks.length, + title: 'No Due Date', + description: `${noDueDateTasks.length} ${noDueDateTasks.length === 1 ? 'task needs' : 'tasks need'} a deadline`, + color: 'neutral', + bgColor: '#9E9E9E', + icon: , + filter: { + conditions: [ + { + type: 'dueDate', + operator: 'hasNoDueDate', + value: null, + }, + ], + operator: 'AND', + }, + }) + } + + // Sort by priority and return top 3 + return detectedInsights.sort((a, b) => a.priority - b.priority).slice(0, 3) + }, [chores]) + + const handleInsightClick = insight => { + // Toggle: if already active, clear it; otherwise apply it + if (isInsightActive(insight)) { + clearTempFilter() + } else { + applyTempFilter(insight.filter, { + id: insight.id, + name: insight.title, + description: insight.description, + icon: insight.icon, + color: insight.color, + }) + } + } + + const isInsightActive = insight => { + if (!tempFilter || !tempFilter.conditions) return false + return ( + JSON.stringify(tempFilter.conditions) === + JSON.stringify(insight.filter.conditions) + ) + } + + if (insights.length === 0) { + return null + } + + return ( + + {/* Header */} + + + + + Smart Insights + + {tempFilter && ( + + Active + + )} + + + {tempFilter + ? 'Click active filter to clear' + : 'Quick actions based on your tasks'} + + + + {/* Insight Cards */} + + {insights.map(insight => { + const isActive = isInsightActive(insight) + return ( + + ) + })} + + + ) +} + +export default SmartInsightsCard diff --git a/src/views/Chores/components/MyChoreHeader.jsx b/src/views/Chores/components/MyChoreHeader.jsx new file mode 100644 index 0000000..ab9ae4a --- /dev/null +++ b/src/views/Chores/components/MyChoreHeader.jsx @@ -0,0 +1,72 @@ +import { FilterAlt } from '@mui/icons-material' +import { Box, Stack, Typography } from '@mui/joy' +import { getIconComponent } from '../../../utils/ProjectIcons.jsx' + +const MyChoreHeader = ({ + activeFilterId, + activeFilter, + selectedProject, + tempFilter, + tempFilterMeta, +}) => { + if ( + !activeFilterId && + !tempFilter && + (!selectedProject || selectedProject.id === 'default') + ) + return null + + const renderIcon = () => { + if (tempFilter) { + return tempFilterMeta?.icon ? ( + + {tempFilterMeta.icon} + + ) : ( + + ) + } + if (activeFilterId) { + return + } + if (selectedProject) { + const iconValue = selectedProject.icon || 'FolderOpen' + const IconComponent = getIconComponent(iconValue) + return ( + + ) + } + return null + } + + const name = tempFilter + ? tempFilterMeta?.name || 'Smart Filter' + : activeFilter?.name || selectedProject?.name + + const description = tempFilter + ? tempFilterMeta?.description + : activeFilter?.description || selectedProject?.description + + return ( + + {renderIcon()} + + + {name} + + {description && ( + + {description} + + )} + + + ) +} + +export default MyChoreHeader diff --git a/src/views/Chores/hooks/useCustomFilters.js b/src/views/Chores/hooks/useCustomFilters.js index c773f26..c8c6c32 100644 --- a/src/views/Chores/hooks/useCustomFilters.js +++ b/src/views/Chores/hooks/useCustomFilters.js @@ -26,6 +26,7 @@ export const useCustomFilters = (chores, membersData, labels, projects) => { const [activeFilterId, setActiveFilterId] = useState(null) const [tempFilter, setTempFilter] = useState(null) + const [tempFilterMeta, setTempFilterMeta] = useState(null) const context = useMemo( () => ({ @@ -95,15 +96,18 @@ export const useCustomFilters = (chores, membersData, labels, projects) => { const clearActiveFilter = useCallback(() => { setActiveFilterId(null) setTempFilter(null) + setTempFilterMeta(null) }, []) - const applyTempFilter = useCallback(filter => { + const applyTempFilter = useCallback((filter, meta = null) => { setTempFilter(filter) + setTempFilterMeta(meta) setActiveFilterId(null) }, []) const clearTempFilter = useCallback(() => { setTempFilter(null) + setTempFilterMeta(null) }, []) const saveFilter = useCallback( @@ -256,6 +260,7 @@ export const useCustomFilters = (chores, membersData, labels, projects) => { activeFilter, activeFilterId, tempFilter, + tempFilterMeta, filteredChores, applyCustomFilter, clearActiveFilter, diff --git a/src/views/History/HistoryCard.jsx b/src/views/History/HistoryCard.jsx index b2a7308..bfc3f6c 100644 --- a/src/views/History/HistoryCard.jsx +++ b/src/views/History/HistoryCard.jsx @@ -7,6 +7,7 @@ import { MoreVert, Person, Redo, + RunningWithErrors, ThumbDown, Timelapse, Toll, @@ -17,9 +18,10 @@ import { useLocalization } from '../../contexts/LocalizationContext' import { TASK_COLOR } from '../../utils/Colors.jsx' const getCompletedChip = historyEntry => { - if (historyEntry.status === 0) { + if (historyEntry.status === 0 || historyEntry.status === 5) { return null } + if (!historyEntry.dueDate) { return null // , color: 'warning' }, // Skipped 3: { icon: , color: 'neutral' }, // Pending Approval 4: { icon: , color: 'danger' }, // Rejected + 5: { icon: , color: 'danger' }, // Missed } const config = statusMap[historyEntry.status] || statusMap[1] @@ -189,7 +192,9 @@ const HistoryCard = ({ ? 'Pending Approval' : historyEntry.status === 4 ? 'Rejected' - : 'Completed'} + : historyEntry.status === 5 + ? 'Missed' + : 'Completed'} }> @@ -233,16 +238,21 @@ const HistoryCard = ({ mt: 0.5, }} > - - } - > - {performer?.displayName || 'Unknown'} - + {performer && ( + + } + > + {performer?.displayName || 'Unknown'} + + )} {historyEntry.completedBy !== historyEntry.assignedTo && assignedTo && ( @@ -262,7 +272,11 @@ const HistoryCard = ({ variant='plain' color='neutral' startDecorator={} - sx={{ maxWidth: '120px', overflow: 'hidden', cursor: 'pointer' }} + sx={{ + maxWidth: '120px', + overflow: 'hidden', + cursor: 'pointer', + }} onClick={e => { e.stopPropagation() onViewNote?.(historyEntry.notes) diff --git a/src/views/Modals/Inputs/CreateChildUserModal.jsx b/src/views/Modals/Inputs/CreateChildUserModal.jsx index d33c943..173d618 100644 --- a/src/views/Modals/Inputs/CreateChildUserModal.jsx +++ b/src/views/Modals/Inputs/CreateChildUserModal.jsx @@ -40,9 +40,9 @@ function CreateChildUserModal({ isOpen, onClose, onSuccess }) { if (!password) { newErrors.password = 'Password is required' } else if (password.length < 8) { - newErrors.password = 'Password must be at least 8 characters' - } else if (password.length > 45) { - newErrors.password = 'Password must be less than 45 characters' + newErrors.password = 'Password must be between 8 and 64 characters' + } else if (password.length > 64) { + newErrors.password = 'Password must be between 8 and 64 characters' } } @@ -165,7 +165,7 @@ function CreateChildUserModal({ isOpen, onClose, onSuccess }) { name='password' type='password' id='password' - placeholder='Enter password (8-45 characters)' + placeholder='Enter password (8-64 characters)' value={password} onChange={e => { setPassword(e.target.value) diff --git a/src/views/Modals/Inputs/PasswordChangeModal.jsx b/src/views/Modals/Inputs/PasswordChangeModal.jsx index f75c7ab..9d3cad7 100644 --- a/src/views/Modals/Inputs/PasswordChangeModal.jsx +++ b/src/views/Modals/Inputs/PasswordChangeModal.jsx @@ -25,8 +25,8 @@ function PassowrdChangeModal({ isOpen, onClose }) { setPasswordError('Passwords do not match') } else if (password.length < 8) { setPasswordError('Password must be at least 8 characters') - } else if (password.length > 50) { - setPasswordError('Password must be less than 50 characters') + } else if (password.length > 64) { + setPasswordError('Password must be less than 64 characters') } else { setPasswordError(null) } @@ -63,6 +63,7 @@ function PassowrdChangeModal({ isOpen, onClose }) { label='Password' type='password' id='password' + placeholder='Enter password (8-64 characters)' value={password} onChange={e => { setPasswordTouched(true) diff --git a/src/views/Settings/DeveloperSettings.jsx b/src/views/Settings/DeveloperSettings.jsx index 7664d62..35ef183 100644 --- a/src/views/Settings/DeveloperSettings.jsx +++ b/src/views/Settings/DeveloperSettings.jsx @@ -1,6 +1,7 @@ import { Refresh, Token } from '@mui/icons-material' import { Box, Button, Card, Chip, Divider, Typography } from '@mui/joy' import { useEffect, useState } from 'react' +import { LocalNotifications } from '@capacitor/local-notifications' import { useSSEContext } from '../../hooks/useSSEContext' import { useNotification } from '../../service/NotificationProvider' import { apiClient } from '../../utils/ApiClient' @@ -28,6 +29,8 @@ const DeveloperSettings = () => { const [timeSinceLastHeartbeat, setTimeSinceLastHeartbeat] = useState(null) const [isRefreshing, setIsRefreshing] = useState(false) const [isRefreshingDirect, setIsRefreshingDirect] = useState(false) + const [scheduledNotifications, setScheduledNotifications] = useState([]) + const [isLoadingNotifications, setIsLoadingNotifications] = useState(false) const { showNotification } = useNotification() @@ -44,7 +47,32 @@ const DeveloperSettings = () => { } } + const loadScheduledNotifications = async () => { + if (isNative()) { + setIsLoadingNotifications(true) + try { + const pending = await LocalNotifications.getPending() + // Sort by schedule time (earliest first) + const sorted = pending.notifications.sort((a, b) => { + const timeA = a.schedule?.at + ? new Date(a.schedule.at).getTime() + : 0 + const timeB = b.schedule?.at + ? new Date(b.schedule.at).getTime() + : 0 + return timeA - timeB + }) + setScheduledNotifications(sorted) + } catch (error) { + console.error('Error loading scheduled notifications:', error) + } finally { + setIsLoadingNotifications(false) + } + } + } + loadTokenData() + loadScheduledNotifications() }, []) useEffect(() => { @@ -183,6 +211,47 @@ const DeveloperSettings = () => { } } + const handleRefreshNotifications = async () => { + if (!isNativePlatform) return + + setIsLoadingNotifications(true) + try { + const pending = await LocalNotifications.getPending() + // Sort by schedule time (earliest first) + const sorted = pending.notifications.sort((a, b) => { + const timeA = a.schedule?.at ? new Date(a.schedule.at).getTime() : 0 + const timeB = b.schedule?.at ? new Date(b.schedule.at).getTime() : 0 + return timeA - timeB + }) + setScheduledNotifications(sorted) + showNotification({ + type: 'success', + message: `Loaded ${sorted.length} scheduled notifications`, + }) + } catch (error) { + console.error('Error loading scheduled notifications:', error) + showNotification({ + type: 'error', + message: `Error loading notifications: ${error.message}`, + }) + } finally { + setIsLoadingNotifications(false) + } + } + + const getNotificationStatusColor = scheduleTime => { + if (!scheduleTime) return 'neutral' + + const now = new Date() + const scheduledDate = new Date(scheduleTime) + const diffMs = scheduledDate - now + + if (diffMs < 0) return 'danger' // Past due + if (diffMs < 5 * 60 * 1000) return 'warning' // Less than 5 minutes + if (diffMs < 60 * 60 * 1000) return 'primary' // Less than 1 hour + return 'success' // More than 1 hour + } + return (
Developer Settings @@ -308,6 +377,133 @@ const DeveloperSettings = () => { + {isNativePlatform && ( + + + + + Scheduled Local Notifications + + + + + + + {scheduledNotifications.length === 0 ? ( + + No scheduled notifications + + ) : ( + + + Total scheduled:{' '} + + {scheduledNotifications.length} + + + + + + + {scheduledNotifications.map((notification, index) => { + const scheduleTime = notification.schedule?.at + const scheduledDate = scheduleTime + ? new Date(scheduleTime) + : null + const now = new Date() + const timeUntil = scheduledDate + ? scheduledDate - now + : null + + return ( + + + + {notification.title || 'No title'} + + + {notification.body || 'No body'} + + + + {scheduledDate && ( + <> + + {timeUntil && timeUntil > 0 + ? formatTimeLeft(timeUntil) + : 'Past due'} + + + {scheduledDate.toLocaleString()} + + + )} + {notification.extra?.choreId && ( + + Chore ID: {notification.extra.choreId} + + )} + + + + ) + })} + + + )} + + + )} + Server-Sent Events (SSE) diff --git a/src/views/Settings/SidepanelSettings.jsx b/src/views/Settings/SidepanelSettings.jsx index 0734da7..6467858 100644 --- a/src/views/Settings/SidepanelSettings.jsx +++ b/src/views/Settings/SidepanelSettings.jsx @@ -2,8 +2,11 @@ import { DragDropContext, Draggable, Droppable } from '@hello-pangea/dnd' import { CalendarMonth, DragIndicator, + EmojiEvents, History, Person, + SupervisorAccount, + TrendingUp, Visibility, VisibilityOff, WavingHand, @@ -23,48 +26,22 @@ import { Typography, } from '@mui/joy' import { useEffect, useState } from 'react' +import { + DEFAULT_SIDEPANEL_CONFIG, + getSidepanelConfig, + saveSidepanelConfig, +} from '../../utils/SidepanelConfig' import SettingsLayout from './SettingsLayout' -const DEFAULT_SIDEPANEL_CONFIG = [ - { - id: 'welcome', - name: 'Welcome Card', - description: 'Shows greeting and quick stats', - iconName: 'WavingHand', - enabled: true, - order: 0, - }, - { - id: 'assignees', - name: 'Tasks by Assignee', - description: 'Groups tasks by who they are assigned to', - iconName: 'Person', - enabled: true, - order: 1, - }, - { - id: 'calendar', - name: 'Calendar View', - description: 'Shows tasks in a calendar format', - iconName: 'CalendarMonth', - enabled: true, - order: 2, - }, - { - id: 'activities', - name: 'Recent Activities', - description: 'Shows recent task completions and activities', - iconName: 'History', - enabled: true, - order: 3, - }, -] - const SidepanelSettings = () => { - const [config, setConfig] = useState(DEFAULT_SIDEPANEL_CONFIG) + const [config, setConfig] = useState(getSidepanelConfig()) const getIcon = iconName => { switch (iconName) { + case 'SupervisorAccount': + return + case 'TrendingUp': + return case 'WavingHand': return case 'Person': @@ -73,27 +50,20 @@ const SidepanelSettings = () => { return case 'History': return + case 'EmojiEvents': + return default: return } } useEffect(() => { - const saved = localStorage.getItem('sidepanelConfig') - if (saved) { - try { - const parsed = JSON.parse(saved) - setConfig(parsed) - } catch (error) { - console.error('Error parsing sidepanel config:', error) - } - } + setConfig(getSidepanelConfig()) }, []) const saveConfig = newConfig => { setConfig(newConfig) - localStorage.setItem('sidepanelConfig', JSON.stringify(newConfig)) - window.dispatchEvent(new Event('sidepanelConfigChanged')) + saveSidepanelConfig(newConfig) } const handleToggleEnabled = (id, enabled) => { diff --git a/src/views/components/AddTaskModal.jsx b/src/views/components/AddTaskModal.jsx index 2442d39..4e83499 100644 --- a/src/views/components/AddTaskModal.jsx +++ b/src/views/components/AddTaskModal.jsx @@ -20,6 +20,7 @@ import { } from './CustomParsers' import SmartTaskTitleInput from './SmartTaskTitleInput' +import DurationInput from '../../components/common/DurationInput' import KeyboardShortcutHint from '../../components/common/KeyboardShortcutHint' import NotificationTemplate from '../../components/NotificationTemplate' import LearnMoreButton from './LearnMore' @@ -90,6 +91,8 @@ const TaskInput = ({ autoFocus, onChoreUpdate, isModalOpen, onClose }) => { const [hasDescription, setHasDescription] = useState(false) const [hasSubTasks, setHasSubTasks] = useState(false) const [hasNotifications, setHasNotifications] = useState(false) + const [hasDeadline, setHasDeadline] = useState(false) + const [deadlineOffset, setDeadlineOffset] = useState(-1) const [showKeyboardShortcuts, setShowKeyboardShortcuts] = useState(false) const [projectId, setProjectId] = useState(getInitialProject()) @@ -491,6 +494,8 @@ const TaskInput = ({ autoFocus, onChoreUpdate, isModalOpen, onClose }) => { setLabelsV2([]) setAssignees([]) setProjectId(getInitialProject()) + setHasDeadline(false) + setDeadlineOffset(-1) } const createChore = () => { @@ -529,6 +534,7 @@ const TaskInput = ({ autoFocus, onChoreUpdate, isModalOpen, onClose }) => { labelsV2: labelsV2, priority: priority ? Number(priority) : 0, points: points > -1 ? points : null, + deadlineOffset: deadlineOffset < 0 ? null : deadlineOffset, status: 0, frequencyType: 'once', frequencyMetadata: {}, @@ -798,6 +804,19 @@ const TaskInput = ({ autoFocus, onChoreUpdate, isModalOpen, onClose }) => { Edit Notifications )} + {!hasDeadline && dueDate && ( + + )} {hasDescription && ( @@ -959,6 +978,27 @@ const TaskInput = ({ autoFocus, onChoreUpdate, isModalOpen, onClose }) => { )} */} + {hasDeadline && dueDate && ( + + Deadline + + + after due date + + + )} {hasNotifications && dueDate && ( { } if ( ['/chores', '/'].includes(location.pathname) && - !searchParams.get('filter') + !searchParams.get('filterId') ) { return menuRounded }