Merge pull request #71 from donetick/dev

Fix multiselect issues and enhance chore management features
This commit is contained in:
Mohamad Tarbin
2026-03-09 00:02:37 -04:00
committed by GitHub
23 changed files with 1173 additions and 272 deletions

View File

@@ -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()

View File

@@ -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' },

View File

@@ -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 (
<Box sx={{ display: 'flex', gap: 0.5, alignItems: 'center' }}>
<IconButton
size={size}
variant='outlined'
color='neutral'
onClick={handleDecrement}
disabled={displayValue <= minValue}
>
<Remove fontSize='small' />
</IconButton>
<Input
type='number'
value={displayValue}
size={size}
slotProps={{ input: { min: minValue } }}
sx={{ maxWidth: 70, textAlign: 'center' }}
onChange={e => {
const v = Math.max(minValue, parseInt(e.target.value) || minValue)
setDisplayValue(v)
emit(v, unit)
}}
/>
<IconButton
size={size}
variant='outlined'
color='neutral'
onClick={handleIncrement}
>
<Add fontSize='small' />
</IconButton>
<Select
value={unit}
size={size}
sx={{ minWidth: 90, ml: 0.5 }}
onChange={(_, newUnit) => {
setUnit(newUnit)
emit(displayValue, newUnit)
}}
>
{TIME_UNITS.map(u => (
<Option key={u.value} value={u.value}>
{u.label}
</Option>
))}
</Select>
</Box>
)
}
export default DurationInput

View File

@@ -30,11 +30,7 @@ html {
border-radius: 4px;
}
/* Improve button and interactive element performance */
button, a, [role="button"] {
transform: translateZ(0);
backface-visibility: hidden;
}
/* Ensure smooth transitions for dynamic content */
* {

View File

@@ -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

View File

@@ -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 => ({

View File

@@ -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)
}

View File

@@ -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 => {

View File

@@ -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 && (
<Box mb={3}>
<Typography level='h4'>Completion Window</Typography>
<FormControl orientation='horizontal'>
<Switch
checked={completionWindow != -1}
onClick={event => {
event.preventDefault()
if (completionWindow != -1) {
setCompletionWindow(-1)
} else {
setCompletionWindow(1)
}
}}
color={completionWindow !== -1 ? 'success' : 'neutral'}
variant={completionWindow !== -1 ? 'solid' : 'outlined'}
sx={{
mr: 2,
}}
/>
<div>
<Typography level='body-md'>
Completion window (hours)
</Typography>
<FormHelperText sx={{ mt: 0 }}>
{"Set a time window that task can't be completed before"}
</FormHelperText>
</div>
</FormControl>
{completionWindow != -1 && (
<Card variant='outlined'>
<Box
sx={{
mt: 0,
ml: 4,
}}
>
<Typography level='body-sm'>Hours:</Typography>
<Input
type='number'
value={completionWindow}
sx={{ maxWidth: 100 }}
slotProps={{
input: {
min: 0,
max: 24 * 7,
},
}}
placeholder='Hours'
onChange={e => {
setCompletionWindow(parseInt(e.target.value))
}}
/>
</Box>
</Card>
)}
</Box>
)}
{dueDate && (
<Box mb={3}>
<Typography level='h4'>Deadline</Typography>
<Typography level='h4'>Task Window</Typography>
<Typography level='body-md'>
When should this task be considered expired?
Define when this task can be completed and when it expires
</Typography>
{/* One-time tasks: Date picker */}
{['once', 'no_repeat'].includes(frequencyType) ? (
<FormControl sx={{ mt: 1 }}>
<Checkbox
onChange={e => {
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) */}
<FormControl sx={{ mt: 1 }}>
<Checkbox
checked={completionWindow !== -1}
onChange={e => {
if (e.target.checked) {
setCompletionWindow(3600) // default 1 hour in seconds
} else {
setCompletionWindow(-1)
}
}}
overlay
label='Set earliest completion time'
/>
<FormHelperText>
Task becomes available to complete X hours before the due date
</FormHelperText>
</FormControl>
{completionWindow !== -1 && (
<Box
sx={{
mt: 1,
ml: 4,
display: 'flex',
gap: 1,
alignItems: 'center',
}}
>
<DurationInput
value={completionWindow}
onChange={setCompletionWindow}
size='sm'
minValue={0}
/>
<FormHelperText>
Task will be considered expired after this date
</FormHelperText>
</FormControl>
) : (
/* Recurring tasks: Offset input */
<FormControl sx={{ mt: 1 }}>
<Checkbox
onChange={e => {
if (e.target.checked) {
setDeadlineOffset(24) // Default to 24 hours
} else {
setDeadlineOffset(-1)
}
}}
checked={deadlineOffset !== -1}
overlay
label='Set a deadline for this task'
/>
<FormHelperText>
Task will be considered expired after the specified time from
due date
</FormHelperText>
</FormControl>
<Typography level='body-sm'>before due date</Typography>
</Box>
)}
{/* Date picker for one-time tasks */}
{deadline && ['once', 'no_repeat'].includes(frequencyType) && (
<Card variant='outlined' sx={{ mt: 2 }}>
<Box sx={{ p: 2 }}>
<Typography level='body-sm' mb={1}>
Deadline Date:
</Typography>
<Input
type='datetime-local'
value={deadline}
onChange={e => setDeadline(e.target.value)}
slotProps={{
input: {
min: dueDate, // Deadline cannot be before due date
},
}}
/>
</Box>
</Card>
)}
{/* Expires After (Deadline) */}
<FormControl sx={{ mt: 2 }}>
<Checkbox
checked={deadlineOffset !== -1}
onChange={e => {
if (e.target.checked) {
setDeadlineOffset(86400) // default 1 day in seconds
} else {
setDeadlineOffset(-1)
}
}}
overlay
label='Set a deadline'
/>
<FormHelperText>
Task will be considered expired after the due date
</FormHelperText>
</FormControl>
{/* Offset input for recurring tasks */}
{deadlineOffset !== -1 &&
!['once', 'no_repeat'].includes(frequencyType) && (
<Card variant='outlined' sx={{ mt: 2 }}>
<Box
sx={{ p: 2, display: 'flex', gap: 2, alignItems: 'end' }}
>
<Box>
<Typography level='body-sm' mb={1}>
Time after due date:
</Typography>
<Input
type='number'
value={deadlineOffset}
sx={{ maxWidth: 100 }}
slotProps={{
input: {
min: 1,
max: 720, // Max 30 days in hours
},
}}
placeholder='Time'
onChange={e => {
setDeadlineOffset(parseInt(e.target.value) || 1)
}}
/>
</Box>
<Box>
<Typography level='body-sm' mb={1}>
Unit:
</Typography>
<Select
value={deadlineUnit}
onChange={(event, newValue) =>
setDeadlineUnit(newValue)
}
sx={{ minWidth: 100 }}
>
<Option value='hours'>Hours</Option>
<Option value='days'>Days</Option>
</Select>
</Box>
</Box>
</Card>
)}
{deadlineOffset !== -1 && (
<Box
sx={{
mt: 1,
ml: 4,
display: 'flex',
gap: 1,
alignItems: 'center',
}}
>
<DurationInput
value={deadlineOffset}
onChange={setDeadlineOffset}
size='sm'
minValue={0}
/>
<Typography level='body-sm'>after due date</Typography>
</Box>
)}
</Box>
)}

View File

@@ -149,10 +149,10 @@ const ChoreView = () => {
'N/A'
}`,
subtext: ` Last: ${
chore.lastCompletedDate
chore.lastCompletedDate && chore.lastCompletedBy
? performers.find(p => p.userId === chore.lastCompletedBy)
?.displayName
: '--'
: 'N/A'
}`,
},
{
@@ -167,6 +167,11 @@ const ChoreView = () => {
? moment(chore.lastCompletedDate).fromNow()
: 'N/A'
}`,
subtext2:
chore.deadlineOffset > 0 && chore.nextDueDate
? `Deadline: ${moment(chore.nextDueDate).add(chore.deadlineOffset, 'seconds').fromNow()}`
: null,
},
{
size: 6,
@@ -518,7 +523,10 @@ const ChoreView = () => {
handleAction={action => {
if (action === 'pause') {
handleChorePause()
} else if (action === 'resume') {
} else if (
action === 'resume' &&
!notInCompletionWindow(chore)
) {
handleChoreStart()
}
}}
@@ -576,6 +584,14 @@ const ChoreView = () => {
>
{card.subtext}
</Typography>
{card.subtext2 && (
<Typography
level='body-sm'
sx={{ color: 'danger.plainColor', lineHeight: 1.5 }}
>
{card.subtext2}
</Typography>
)}
</Box>
</CardContent>
</Card>
@@ -1034,8 +1050,9 @@ const ChoreView = () => {
})
}}
disabled={
chore.lastCompletedDate !== null &&
chore.frequencyType === 'once'
notInCompletionWindow(chore) ||
(chore.lastCompletedDate !== null &&
chore.frequencyType === 'once')
}
startDecorator={<SwitchAccessShortcut />}
sx={{
@@ -1047,12 +1064,25 @@ const ChoreView = () => {
</>
)}
</Box>
{notInCompletionWindow(chore) && (
<Typography
level='body-sm'
sx={{ color: 'warning.plainColor', textAlign: 'center', mb: 1 }}
>
Available to complete starting{' '}
{moment(chore.nextDueDate)
.subtract(chore.completionWindow, 'seconds')
.format('MM/DD/YYYY hh:mm A')}
</Typography>
)}
{/* Timer Button - Show split button when timer is active, regular button otherwise */}
{[ChoreStatus.ACTIVE, ChoreStatus.PAUSED].includes(chore.status) ? (
<TimerSplitButton
disabled={
chore.lastCompletedDate !== null &&
chore.frequencyType === 'once'
(chore.status === ChoreStatus.PAUSED &&
notInCompletionWindow(chore)) ||
(chore.lastCompletedDate !== null &&
chore.frequencyType === 'once')
}
chore={chore}
onAction={action => {
@@ -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={<PlayArrow />}
sx={{

View File

@@ -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 }) => {
></Box>
{/* Notes */}
{activity.notes && (
{plainTextNotes && (
<Box sx={{ mt: 0.5, ml: 2.5 }}>
<Typography
level='body-xs'
@@ -189,8 +205,36 @@ const ActivityItem = ({ activity, members }) => {
color: 'text.secondary',
}}
>
<Notes sx={{ fontSize: 14, mt: 0.1 }} />
{activity.notes}
<Notes sx={{ fontSize: 14, mt: 0.1, flexShrink: 0 }} />
<Box sx={{ flex: 1 }}>
<Typography
level='body-xs'
sx={{
fontStyle: 'italic',
color: 'text.secondary',
overflow: 'hidden',
display: '-webkit-box',
WebkitLineClamp: shouldTruncate ? 2 : 'unset',
WebkitBoxOrient: 'vertical',
wordBreak: 'break-word',
}}
>
{plainTextNotes}
</Typography>
{shouldTruncate && (
<Link
level='body-xs'
onClick={() => onViewNote(activity.notes)}
sx={{
cursor: 'pointer',
mt: 0.25,
display: 'inline-block',
}}
>
Show more
</Link>
)}
</Box>
</Typography>
</Box>
)}
@@ -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 }),
})
}}
/>
))}
</List>
@@ -437,6 +491,8 @@ const ActivitiesCard = ({ title = 'Recent Activities' }) => {
)
})}
</Box>
<NoteViewerModal config={noteViewerConfig} />
</Sheet>
)
}

View File

@@ -860,6 +860,7 @@ const ArchivedTasks = () => {
membersData={membersData}
isMultiSelectMode={isMultiSelectMode}
selectedChores={selectedChores}
toggleChoreSelection={toggleChoreSelection}
/>
</List>
</Box>

View File

@@ -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,
@@ -368,8 +371,6 @@ const MyChores = () => {
// Read and apply filters from URL parameters
useEffect(() => {
if (!chores.length || !savedFilters.length) return
// Check for filterId (camelCase) or filter_id (snake_case) for advanced filters
const rawFilterId =
searchParams.get('filterId') || searchParams.get('filter_id')
@@ -378,6 +379,26 @@ 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()
return
}
if (!chores.length || !savedFilters.length) return
// Handle advanced filter parameter
if (filterId && !activeFilterId) {
const filter = savedFilters.find(f => f.id === filterId)
@@ -416,6 +437,8 @@ const MyChores = () => {
activeFilterId,
savedFilters,
applyCustomFilter,
applyTempFilter,
clearActiveFilter,
selectedProject,
projectFilteredChores,
setSearchFilter,
@@ -424,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,
@@ -823,6 +875,13 @@ const MyChores = () => {
}}
>
<Container maxWidth='md'>
<MyChoreHeader
activeFilterId={activeFilterId}
activeFilter={activeFilter}
selectedProject={selectedProject}
tempFilter={tempFilter}
tempFilterMeta={tempFilterMeta}
/>
<Box
sx={{
display: 'flex',

View File

@@ -6,10 +6,17 @@ import { ChoresGrouper } from '../../utils/Chores'
import { getSidepanelConfig } from '../../utils/SidepanelConfig'
import CalendarCard from '../components/CalendarCard'
import ActivitiesCard from './ActivitesCard'
import SmartInsightsCard from './SmartInsightsCard'
import TasksByAssigneeCard from './TasksByAssigneeCard'
import UserSwitcher from './UserSwitcher'
const Sidepanel = ({ chores }) => {
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 <UserSwitcher key='welcome' chores={chores} />
case 'smartInsights':
return (
<SmartInsightsCard
key='smartInsights'
chores={allChores || chores}
applyTempFilter={applyTempFilter}
clearTempFilter={clearTempFilter}
tempFilter={tempFilter}
/>
)
case 'assignees':
return <TasksByAssigneeCard key='assignees' chores={chores} />
case 'calendar':

View File

@@ -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: <WatchLater />,
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: <EventNote />,
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: <HourglassEmpty />,
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: <TrendingUp />,
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: <PriorityHigh />,
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: <EventBusy />,
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 (
<Sheet
variant='plain'
sx={{
p: 2,
display: 'flex',
flexDirection: 'column',
boxShadow: 'sm',
borderRadius: 20,
width: '315px',
mb: 1,
}}
>
{/* Header */}
<Box sx={{ mb: 2 }}>
<Box
sx={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
gap: 1,
}}
>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<TrendingUp color='' />
<Typography level='title-md'>Smart Insights</Typography>
</Box>
{tempFilter && (
<Chip size='sm' variant='solid' color='primary'>
Active
</Chip>
)}
</Box>
<Typography level='body-xs' sx={{ mt: 0.5, color: 'text.secondary' }}>
{tempFilter
? 'Click active filter to clear'
: 'Quick actions based on your tasks'}
</Typography>
</Box>
{/* Insight Cards */}
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1.5 }}>
{insights.map(insight => {
const isActive = isInsightActive(insight)
return (
<Button
key={insight.id}
variant={isActive ? 'solid' : 'soft'}
color='neutral'
onClick={() => handleInsightClick(insight)}
sx={{
display: 'flex',
flexDirection: 'column',
alignItems: 'flex-start',
justifyContent: 'space-between',
p: 1.5,
height: 'auto',
borderRadius: 12,
transition: 'all 0.2s ease',
border: isActive ? '2px solid' : '2px solid transparent',
}}
>
<Box
sx={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
width: '100%',
mb: 0.5,
}}
>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
{insight.icon}
<Typography level='title-sm' sx={{ fontWeight: 600 }}>
{insight.title}
</Typography>
</Box>
<Chip
size='sm'
variant='solid'
color={insight.color}
sx={{
minWidth: 32,
fontWeight: 700,
}}
>
{insight.count}
</Chip>
</Box>
<Typography
level='body-xs'
sx={{
textAlign: 'left',
opacity: 0.9,
}}
>
{isActive ? `${insight.description}` : insight.description}
</Typography>
</Button>
)
})}
</Box>
</Sheet>
)
}
export default SmartInsightsCard

View File

@@ -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 ? (
<Box sx={{ fontSize: '2rem', display: 'flex', alignItems: 'center' }}>
{tempFilterMeta.icon}
</Box>
) : (
<FilterAlt sx={{ fontSize: '2rem', color: 'primary.main' }} />
)
}
if (activeFilterId) {
return <FilterAlt sx={{ fontSize: '2rem', color: 'primary.main' }} />
}
if (selectedProject) {
const iconValue = selectedProject.icon || 'FolderOpen'
const IconComponent = getIconComponent(iconValue)
return (
<IconComponent
sx={{
fontSize: 32,
color: selectedProject.color || 'primary.main',
}}
/>
)
}
return null
}
const name = tempFilter
? tempFilterMeta?.name || 'Smart Filter'
: activeFilter?.name || selectedProject?.name
const description = tempFilter
? tempFilterMeta?.description
: activeFilter?.description || selectedProject?.description
return (
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2, mb: 2 }}>
{renderIcon()}
<Stack sx={{ flex: 1 }}>
<Typography level='h3' sx={{ fontWeight: 'lg', color: 'text.primary' }}>
{name}
</Typography>
{description && (
<Typography level='body-sm' sx={{ color: 'text.secondary' }}>
{description}
</Typography>
)}
</Stack>
</Box>
)
}
export default MyChoreHeader

View File

@@ -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,

View File

@@ -7,6 +7,7 @@ import {
MoreVert,
Person,
Redo,
RunningWithErrors,
ThumbDown,
Timelapse,
Toll,
@@ -16,9 +17,10 @@ import moment from 'moment'
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
// <Chip
@@ -123,6 +125,7 @@ const HistoryCard = ({
2: { icon: <Redo />, color: 'warning' }, // Skipped
3: { icon: <HourglassEmpty />, color: 'neutral' }, // Pending Approval
4: { icon: <ThumbDown />, color: 'danger' }, // Rejected
5: { icon: <RunningWithErrors />, color: 'danger' }, // Missed
}
const config = statusMap[historyEntry.status] || statusMap[1]
@@ -187,14 +190,17 @@ const HistoryCard = ({
? 'Pending Approval'
: historyEntry.status === 4
? 'Rejected'
: 'Completed'}
: historyEntry.status === 5
? 'Missed'
: 'Completed'}
</Typography>
<Chip size='sm' startDecorator={<EventNote />}>
{moment(
historyEntry.performedAt || historyEntry.updatedAt,
).format('MMM DD, h:mm A')}
</Chip>
{historyEntry.performedAt && (
<Chip size='sm' startDecorator={<EventNote />}>
{moment(
historyEntry.performedAt || historyEntry.updatedAt,
).format('MMM DD, h:mm A')}
</Chip>
)}
<Box sx={{ display: 'flex', gap: 0.5 }}>
{getCompletedChip(historyEntry)}
@@ -231,16 +237,21 @@ const HistoryCard = ({
mt: 0.5,
}}
>
<Chip
size='sm'
variant='solid'
color='success'
startDecorator={
<Avatar src={performer?.image} alt={performer?.displayName} />
}
>
{performer?.displayName || 'Unknown'}
</Chip>
{performer && (
<Chip
size='sm'
variant='solid'
color='success'
startDecorator={
<Avatar
src={performer?.image}
alt={performer?.displayName}
/>
}
>
{performer?.displayName || 'Unknown'}
</Chip>
)}
{historyEntry.completedBy !== historyEntry.assignedTo &&
assignedTo && (
@@ -260,7 +271,11 @@ const HistoryCard = ({
variant='plain'
color='neutral'
startDecorator={<EventNote />}
sx={{ maxWidth: '120px', overflow: 'hidden', cursor: 'pointer' }}
sx={{
maxWidth: '120px',
overflow: 'hidden',
cursor: 'pointer',
}}
onClick={e => {
e.stopPropagation()
onViewNote?.(historyEntry.notes)

View File

@@ -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 (
<div className='grid gap-4 py-4' id='developer'>
<Typography level='h3'>Developer Settings</Typography>
@@ -308,6 +377,133 @@ const DeveloperSettings = () => {
</Box>
</Card>
{isNativePlatform && (
<Card variant='outlined'>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
<Box
sx={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
flexWrap: 'wrap',
gap: 1,
}}
>
<Typography level='title-lg'>
Scheduled Local Notifications
</Typography>
<Button
size='sm'
variant='soft'
startDecorator={<Refresh />}
onClick={handleRefreshNotifications}
loading={isLoadingNotifications}
disabled={isLoadingNotifications}
>
Refresh
</Button>
</Box>
<Divider />
{scheduledNotifications.length === 0 ? (
<Typography level='body-sm' color='neutral'>
No scheduled notifications
</Typography>
) : (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
<Typography level='body-sm'>
Total scheduled:{' '}
<Chip variant='soft' size='sm'>
{scheduledNotifications.length}
</Chip>
</Typography>
<Divider />
<Box
sx={{
display: 'flex',
flexDirection: 'column',
gap: 1.5,
maxHeight: '400px',
overflowY: 'auto',
}}
>
{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 (
<Card
key={notification.id || index}
variant='soft'
size='sm'
>
<Box
sx={{
display: 'flex',
flexDirection: 'column',
gap: 0.5,
}}
>
<Typography level='title-sm'>
{notification.title || 'No title'}
</Typography>
<Typography level='body-xs' color='neutral'>
{notification.body || 'No body'}
</Typography>
<Box
sx={{
display: 'flex',
gap: 1,
alignItems: 'center',
flexWrap: 'wrap',
mt: 0.5,
}}
>
{scheduledDate && (
<>
<Chip
size='sm'
variant='soft'
color={getNotificationStatusColor(
scheduleTime,
)}
>
{timeUntil && timeUntil > 0
? formatTimeLeft(timeUntil)
: 'Past due'}
</Chip>
<Typography level='body-xs' color='neutral'>
{scheduledDate.toLocaleString()}
</Typography>
</>
)}
{notification.extra?.choreId && (
<Chip size='sm' variant='outlined'>
Chore ID: {notification.extra.choreId}
</Chip>
)}
</Box>
</Box>
</Card>
)
})}
</Box>
</Box>
)}
</Box>
</Card>
)}
<Card variant='outlined'>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
<Typography level='title-lg'>Server-Sent Events (SSE)</Typography>

View File

@@ -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 <SupervisorAccount />
case 'TrendingUp':
return <TrendingUp />
case 'WavingHand':
return <WavingHand />
case 'Person':
@@ -73,27 +50,20 @@ const SidepanelSettings = () => {
return <CalendarMonth />
case 'History':
return <History />
case 'EmojiEvents':
return <EmojiEvents />
default:
return <Person />
}
}
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) => {

View File

@@ -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
</Button>
)}
{!hasDeadline && dueDate && (
<Button
startDecorator={<Add />}
variant='plain'
size='sm'
onClick={() => {
setHasDeadline(true)
setDeadlineOffset(86400)
}}
>
Set Deadline
</Button>
)}
</Box>
{hasDescription && (
@@ -959,6 +978,27 @@ const TaskInput = ({ autoFocus, onChoreUpdate, isModalOpen, onClose }) => {
)}
</Box>
</FormControl> */}
{hasDeadline && dueDate && (
<Box
sx={{
flexDirection: 'column',
alignItems: 'start',
}}
>
<Typography level='body-sm'>Deadline</Typography>
<Box
sx={{ display: 'flex', alignItems: 'center', gap: 1, mt: 0.5 }}
>
<DurationInput
value={deadlineOffset}
onChange={setDeadlineOffset}
size='sm'
minValue={0}
/>
<Typography level='body-sm'>after due date</Typography>
</Box>
</Box>
)}
{hasNotifications && dueDate && (
<Box
sx={{

View File

@@ -139,7 +139,7 @@ const NavBar = () => {
}
if (
['/chores', '/'].includes(location.pathname) &&
!searchParams.get('filter')
!searchParams.get('filterId')
) {
return menuRounded
}

View File

@@ -1,5 +1,8 @@
export default {
content: ['./index.html', './src/**/*.{js,ts,jsx,tsx}'],
corePlugins: {
preflight: false,
},
theme: {
extend: {
colors: {},