Merge branch 'main' into feature/internationalization-support
This commit is contained in:
@@ -5,6 +5,7 @@ import { Device } from '@capacitor/device'
|
|||||||
import { LocalNotifications } from '@capacitor/local-notifications'
|
import { LocalNotifications } from '@capacitor/local-notifications'
|
||||||
import { Preferences } from '@capacitor/preferences'
|
import { Preferences } from '@capacitor/preferences'
|
||||||
import { PushNotifications } from '@capacitor/push-notifications'
|
import { PushNotifications } from '@capacitor/push-notifications'
|
||||||
|
import { focusManager } from '@tanstack/react-query'
|
||||||
import { RegisterDeviceToken } from './utils/Fetcher'
|
import { RegisterDeviceToken } from './utils/Fetcher'
|
||||||
|
|
||||||
// OAuth callback handler for deep links
|
// OAuth callback handler for deep links
|
||||||
@@ -225,6 +226,10 @@ const registerCapacitorListeners = () => {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
mobileApp.addListener('appStateChange', ({ isActive }) => {
|
||||||
|
focusManager.setFocused(isActive)
|
||||||
|
})
|
||||||
|
|
||||||
mobileApp.addListener('backButton', ({ canGoBack }) => {
|
mobileApp.addListener('backButton', ({ canGoBack }) => {
|
||||||
if (canGoBack) {
|
if (canGoBack) {
|
||||||
window.history.back()
|
window.history.back()
|
||||||
|
|||||||
@@ -14,12 +14,9 @@ import Select from '@mui/joy/Select'
|
|||||||
import Typography from '@mui/joy/Typography'
|
import Typography from '@mui/joy/Typography'
|
||||||
import { useCallback, useEffect, useState } from 'react'
|
import { useCallback, useEffect, useState } from 'react'
|
||||||
import { NOTIFICATION_TYPE, TASK_COLOR } from '../utils/Colors'
|
import { NOTIFICATION_TYPE, TASK_COLOR } from '../utils/Colors'
|
||||||
|
import { TIME_UNITS } from '../utils/DurationUtils'
|
||||||
|
|
||||||
const timeUnits = [
|
const timeUnits = TIME_UNITS
|
||||||
{ label: 'Mins', value: 'm' },
|
|
||||||
{ label: 'Hours', value: 'h' },
|
|
||||||
{ label: 'Days', value: 'd' },
|
|
||||||
]
|
|
||||||
|
|
||||||
const timingOptions = [
|
const timingOptions = [
|
||||||
{ label: 'Before', value: 'before' },
|
{ label: 'Before', value: 'before' },
|
||||||
|
|||||||
104
src/components/common/DurationInput.jsx
Normal file
104
src/components/common/DurationInput.jsx
Normal 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
|
||||||
@@ -25,6 +25,7 @@ import { localStore } from '../utils/LocalStore'
|
|||||||
export const useChores = includeArchive => {
|
export const useChores = includeArchive => {
|
||||||
return useQuery({
|
return useQuery({
|
||||||
queryKey: ['chores', includeArchive],
|
queryKey: ['chores', includeArchive],
|
||||||
|
refetchOnWindowFocus: true,
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
const onlineChores = await GetChoresNew(includeArchive)
|
const onlineChores = await GetChoresNew(includeArchive)
|
||||||
|
|
||||||
@@ -272,6 +273,7 @@ export const useChoresHistory = (initialLimit, includeMembers) => {
|
|||||||
export const useChoreDetails = choreId => {
|
export const useChoreDetails = choreId => {
|
||||||
return useQuery({
|
return useQuery({
|
||||||
queryKey: ['choreDetails', choreId],
|
queryKey: ['choreDetails', choreId],
|
||||||
|
refetchOnWindowFocus: true,
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
var onlineChore = null
|
var onlineChore = null
|
||||||
|
|
||||||
|
|||||||
@@ -324,7 +324,7 @@ export const notInCompletionWindow = chore => {
|
|||||||
chore.completionWindow &&
|
chore.completionWindow &&
|
||||||
chore.completionWindow > -1 &&
|
chore.completionWindow > -1 &&
|
||||||
chore.nextDueDate &&
|
chore.nextDueDate &&
|
||||||
moment().add(chore.completionWindow, 'hours') < moment(chore.nextDueDate)
|
moment() < moment(chore.nextDueDate).add(-chore.completionWindow, 'seconds')
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
export const ChoreFilters = userId => ({
|
export const ChoreFilters = userId => ({
|
||||||
|
|||||||
18
src/utils/DurationUtils.js
Normal file
18
src/utils/DurationUtils.js
Normal 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)
|
||||||
|
}
|
||||||
@@ -7,13 +7,21 @@ export const DEFAULT_SIDEPANEL_CONFIG = [
|
|||||||
enabled: true,
|
enabled: true,
|
||||||
order: 0,
|
order: 0,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: 'smartInsights',
|
||||||
|
name: 'Smart Insights',
|
||||||
|
description: 'Quick actions based on your tasks',
|
||||||
|
iconName: 'TrendingUp',
|
||||||
|
enabled: false,
|
||||||
|
order: 1,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
id: 'assignees',
|
id: 'assignees',
|
||||||
name: 'Tasks by Assignee',
|
name: 'Tasks by Assignee',
|
||||||
description: 'Groups tasks by who they are assigned to',
|
description: 'Groups tasks by who they are assigned to',
|
||||||
iconName: 'Person',
|
iconName: 'Person',
|
||||||
enabled: true,
|
enabled: true,
|
||||||
order: 1,
|
order: 2,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'calendar',
|
id: 'calendar',
|
||||||
@@ -21,7 +29,7 @@ export const DEFAULT_SIDEPANEL_CONFIG = [
|
|||||||
description: 'Shows tasks in a calendar format',
|
description: 'Shows tasks in a calendar format',
|
||||||
iconName: 'CalendarMonth',
|
iconName: 'CalendarMonth',
|
||||||
enabled: true,
|
enabled: true,
|
||||||
order: 2,
|
order: 3,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'activities',
|
id: 'activities',
|
||||||
@@ -29,7 +37,7 @@ export const DEFAULT_SIDEPANEL_CONFIG = [
|
|||||||
description: 'Shows recent task completions and activities',
|
description: 'Shows recent task completions and activities',
|
||||||
iconName: 'History',
|
iconName: 'History',
|
||||||
enabled: true,
|
enabled: true,
|
||||||
order: 3,
|
order: 4,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'weeklyGoals',
|
id: 'weeklyGoals',
|
||||||
@@ -37,21 +45,37 @@ export const DEFAULT_SIDEPANEL_CONFIG = [
|
|||||||
description: 'Shows weekly progress and family completion stats',
|
description: 'Shows weekly progress and family completion stats',
|
||||||
iconName: 'EmojiEvents',
|
iconName: 'EmojiEvents',
|
||||||
enabled: true,
|
enabled: true,
|
||||||
order: 4,
|
order: 5,
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
export const getSidepanelConfig = () => {
|
export const getSidepanelConfig = () => {
|
||||||
const saved = localStorage.getItem('sidepanelConfig')
|
const saved = localStorage.getItem('sidepanelConfig')
|
||||||
|
let savedConfig = []
|
||||||
|
|
||||||
if (saved) {
|
if (saved) {
|
||||||
try {
|
try {
|
||||||
return JSON.parse(saved)
|
savedConfig = JSON.parse(saved)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error parsing sidepanel config:', error)
|
console.error('Error parsing sidepanel config:', error)
|
||||||
return DEFAULT_SIDEPANEL_CONFIG
|
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 => {
|
export const saveSidepanelConfig = config => {
|
||||||
|
|||||||
@@ -70,7 +70,12 @@ const SignupView = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (password.length < 8) {
|
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
|
isValid = false
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -218,6 +223,7 @@ const SignupView = () => {
|
|||||||
label='Password'
|
label='Password'
|
||||||
type='password'
|
type='password'
|
||||||
id='password'
|
id='password'
|
||||||
|
placeholder='Enter password (8-64 characters)'
|
||||||
value={password}
|
value={password}
|
||||||
onChange={e => {
|
onChange={e => {
|
||||||
setPasswordError(null)
|
setPasswordError(null)
|
||||||
@@ -237,6 +243,7 @@ const SignupView = () => {
|
|||||||
name='displayName'
|
name='displayName'
|
||||||
label='Display Name'
|
label='Display Name'
|
||||||
id='displayName'
|
id='displayName'
|
||||||
|
placeholder='Confirm password'
|
||||||
value={displayName}
|
value={displayName}
|
||||||
onChange={e => {
|
onChange={e => {
|
||||||
setDisplayNameError(null)
|
setDisplayNameError(null)
|
||||||
|
|||||||
@@ -31,8 +31,8 @@ const UpdatePasswordView = () => {
|
|||||||
const handlePasswordChange = e => {
|
const handlePasswordChange = e => {
|
||||||
const password = e.target.value
|
const password = e.target.value
|
||||||
setPassword(password)
|
setPassword(password)
|
||||||
if (password.length < 8) {
|
if (password.length < 8 || password.length > 64) {
|
||||||
setPasswordError('Password must be at least 8 characters')
|
setPasswordError('Password must be between 8 and 64 characters')
|
||||||
} else {
|
} else {
|
||||||
setPasswordError(null)
|
setPasswordError(null)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,12 +19,12 @@ import {
|
|||||||
RadioGroup,
|
RadioGroup,
|
||||||
Select,
|
Select,
|
||||||
Sheet,
|
Sheet,
|
||||||
Switch,
|
|
||||||
Typography,
|
Typography,
|
||||||
} from '@mui/joy'
|
} from '@mui/joy'
|
||||||
import moment from 'moment'
|
import moment from 'moment'
|
||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
import { useNavigate, useParams, useSearchParams } from 'react-router-dom'
|
import { useNavigate, useParams, useSearchParams } from 'react-router-dom'
|
||||||
|
import DurationInput from '../../components/common/DurationInput'
|
||||||
import KeyboardShortcutHint from '../../components/common/KeyboardShortcutHint'
|
import KeyboardShortcutHint from '../../components/common/KeyboardShortcutHint'
|
||||||
import NotificationTemplate from '../../components/NotificationTemplate.jsx'
|
import NotificationTemplate from '../../components/NotificationTemplate.jsx'
|
||||||
import {
|
import {
|
||||||
@@ -97,9 +97,7 @@ const ChoreEdit = () => {
|
|||||||
const [isPrivate, setIsPrivate] = useState(false)
|
const [isPrivate, setIsPrivate] = useState(false)
|
||||||
const [subTasks, setSubTasks] = useState(null)
|
const [subTasks, setSubTasks] = useState(null)
|
||||||
const [completionWindow, setCompletionWindow] = useState(-1)
|
const [completionWindow, setCompletionWindow] = useState(-1)
|
||||||
const [deadline, setDeadline] = useState(null)
|
|
||||||
const [deadlineOffset, setDeadlineOffset] = useState(-1)
|
const [deadlineOffset, setDeadlineOffset] = useState(-1)
|
||||||
const [deadlineUnit, setDeadlineUnit] = useState('hours')
|
|
||||||
const [allUserThings, setAllUserThings] = useState([])
|
const [allUserThings, setAllUserThings] = useState([])
|
||||||
const [thingTrigger, setThingTrigger] = useState(null)
|
const [thingTrigger, setThingTrigger] = useState(null)
|
||||||
const [isThingValid, setIsThingValid] = useState(false)
|
const [isThingValid, setIsThingValid] = useState(false)
|
||||||
@@ -352,6 +350,7 @@ const ChoreEdit = () => {
|
|||||||
completionWindow:
|
completionWindow:
|
||||||
// if completionWindow is -1 then set it to null or dueDate is null
|
// if completionWindow is -1 then set it to null or dueDate is null
|
||||||
completionWindow < 0 || dueDate === null ? null : completionWindow,
|
completionWindow < 0 || dueDate === null ? null : completionWindow,
|
||||||
|
deadlineOffset: deadlineOffset < 0 ? null : deadlineOffset,
|
||||||
priority: priority,
|
priority: priority,
|
||||||
projectId: projectId === 'default' ? null : projectId,
|
projectId: projectId === 'default' ? null : projectId,
|
||||||
}
|
}
|
||||||
@@ -477,6 +476,11 @@ const ChoreEdit = () => {
|
|||||||
? data.res.completionWindow
|
? data.res.completionWindow
|
||||||
: -1,
|
: -1,
|
||||||
)
|
)
|
||||||
|
setDeadlineOffset(
|
||||||
|
data.res.deadlineOffset && data.res.deadlineOffset > -1
|
||||||
|
? data.res.deadlineOffset
|
||||||
|
: -1,
|
||||||
|
)
|
||||||
|
|
||||||
setLabelsV2(data.res.labelsV2)
|
setLabelsV2(data.res.labelsV2)
|
||||||
|
|
||||||
@@ -1174,181 +1178,88 @@ const ChoreEdit = () => {
|
|||||||
|
|
||||||
{dueDate && (
|
{dueDate && (
|
||||||
<Box mb={3}>
|
<Box mb={3}>
|
||||||
<Typography level='h4'>Completion Window</Typography>
|
<Typography level='h4'>Task 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='body-md'>
|
<Typography level='body-md'>
|
||||||
When should this task be considered expired?
|
Define when this task can be completed and when it expires
|
||||||
</Typography>
|
</Typography>
|
||||||
|
|
||||||
{/* One-time tasks: Date picker */}
|
{/* Available From (Completion Window) */}
|
||||||
{['once', 'no_repeat'].includes(frequencyType) ? (
|
<FormControl sx={{ mt: 1 }}>
|
||||||
<FormControl sx={{ mt: 1 }}>
|
<Checkbox
|
||||||
<Checkbox
|
checked={completionWindow !== -1}
|
||||||
onChange={e => {
|
onChange={e => {
|
||||||
if (e.target.checked) {
|
if (e.target.checked) {
|
||||||
// Set deadline to 24 hours after due date by default
|
setCompletionWindow(3600) // default 1 hour in seconds
|
||||||
const deadlineDate = moment(dueDate)
|
} else {
|
||||||
.add(1, 'day')
|
setCompletionWindow(-1)
|
||||||
.format('YYYY-MM-DDTHH:mm:00')
|
}
|
||||||
setDeadline(deadlineDate)
|
}}
|
||||||
} else {
|
overlay
|
||||||
setDeadline(null)
|
label='Set earliest completion time'
|
||||||
}
|
/>
|
||||||
}}
|
<FormHelperText>
|
||||||
checked={deadline !== null}
|
Task becomes available to complete X hours before the due date
|
||||||
overlay
|
</FormHelperText>
|
||||||
label='Set a deadline for this task'
|
</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>
|
<Typography level='body-sm'>before due date</Typography>
|
||||||
Task will be considered expired after this date
|
</Box>
|
||||||
</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>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Date picker for one-time tasks */}
|
{/* Expires After (Deadline) */}
|
||||||
{deadline && ['once', 'no_repeat'].includes(frequencyType) && (
|
<FormControl sx={{ mt: 2 }}>
|
||||||
<Card variant='outlined' sx={{ mt: 2 }}>
|
<Checkbox
|
||||||
<Box sx={{ p: 2 }}>
|
checked={deadlineOffset !== -1}
|
||||||
<Typography level='body-sm' mb={1}>
|
onChange={e => {
|
||||||
Deadline Date:
|
if (e.target.checked) {
|
||||||
</Typography>
|
setDeadlineOffset(86400) // default 1 day in seconds
|
||||||
<Input
|
} else {
|
||||||
type='datetime-local'
|
setDeadlineOffset(-1)
|
||||||
value={deadline}
|
}
|
||||||
onChange={e => setDeadline(e.target.value)}
|
}}
|
||||||
slotProps={{
|
overlay
|
||||||
input: {
|
label='Set a deadline'
|
||||||
min: dueDate, // Deadline cannot be before due date
|
/>
|
||||||
},
|
<FormHelperText>
|
||||||
}}
|
Task will be considered expired after the due date
|
||||||
/>
|
</FormHelperText>
|
||||||
</Box>
|
</FormControl>
|
||||||
</Card>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Offset input for recurring tasks */}
|
{deadlineOffset !== -1 && (
|
||||||
{deadlineOffset !== -1 &&
|
<Box
|
||||||
!['once', 'no_repeat'].includes(frequencyType) && (
|
sx={{
|
||||||
<Card variant='outlined' sx={{ mt: 2 }}>
|
mt: 1,
|
||||||
<Box
|
ml: 4,
|
||||||
sx={{ p: 2, display: 'flex', gap: 2, alignItems: 'end' }}
|
display: 'flex',
|
||||||
>
|
gap: 1,
|
||||||
<Box>
|
alignItems: 'center',
|
||||||
<Typography level='body-sm' mb={1}>
|
}}
|
||||||
Time after due date:
|
>
|
||||||
</Typography>
|
<DurationInput
|
||||||
<Input
|
value={deadlineOffset}
|
||||||
type='number'
|
onChange={setDeadlineOffset}
|
||||||
value={deadlineOffset}
|
size='sm'
|
||||||
sx={{ maxWidth: 100 }}
|
minValue={0}
|
||||||
slotProps={{
|
/>
|
||||||
input: {
|
<Typography level='body-sm'>after due date</Typography>
|
||||||
min: 1,
|
</Box>
|
||||||
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>
|
|
||||||
)}
|
|
||||||
</Box>
|
</Box>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -156,7 +156,7 @@ const ChoreView = () => {
|
|||||||
chore.lastCompletedDate
|
chore.lastCompletedDate
|
||||||
? performers.find(p => p.userId === chore.lastCompletedBy)
|
? performers.find(p => p.userId === chore.lastCompletedBy)
|
||||||
?.displayName
|
?.displayName
|
||||||
: '--'
|
: 'N/A'
|
||||||
}`,
|
}`,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -171,6 +171,11 @@ const ChoreView = () => {
|
|||||||
? moment(chore.lastCompletedDate).fromNow()
|
? moment(chore.lastCompletedDate).fromNow()
|
||||||
: t('choreView.na')
|
: t('choreView.na')
|
||||||
}`,
|
}`,
|
||||||
|
|
||||||
|
subtext2:
|
||||||
|
chore.deadlineOffset > 0 && chore.nextDueDate
|
||||||
|
? `Deadline: ${moment(chore.nextDueDate).add(chore.deadlineOffset, 'seconds').fromNow()}`
|
||||||
|
: null,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
size: 6,
|
size: 6,
|
||||||
@@ -520,7 +525,10 @@ const ChoreView = () => {
|
|||||||
handleAction={action => {
|
handleAction={action => {
|
||||||
if (action === 'pause') {
|
if (action === 'pause') {
|
||||||
handleChorePause()
|
handleChorePause()
|
||||||
} else if (action === 'resume') {
|
} else if (
|
||||||
|
action === 'resume' &&
|
||||||
|
!notInCompletionWindow(chore)
|
||||||
|
) {
|
||||||
handleChoreStart()
|
handleChoreStart()
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
@@ -578,6 +586,14 @@ const ChoreView = () => {
|
|||||||
>
|
>
|
||||||
{card.subtext}
|
{card.subtext}
|
||||||
</Typography>
|
</Typography>
|
||||||
|
{card.subtext2 && (
|
||||||
|
<Typography
|
||||||
|
level='body-sm'
|
||||||
|
sx={{ color: 'danger.plainColor', lineHeight: 1.5 }}
|
||||||
|
>
|
||||||
|
{card.subtext2}
|
||||||
|
</Typography>
|
||||||
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
@@ -1034,8 +1050,9 @@ const ChoreView = () => {
|
|||||||
})
|
})
|
||||||
}}
|
}}
|
||||||
disabled={
|
disabled={
|
||||||
chore.lastCompletedDate !== null &&
|
notInCompletionWindow(chore) ||
|
||||||
chore.frequencyType === 'once'
|
(chore.lastCompletedDate !== null &&
|
||||||
|
chore.frequencyType === 'once')
|
||||||
}
|
}
|
||||||
startDecorator={<SwitchAccessShortcut />}
|
startDecorator={<SwitchAccessShortcut />}
|
||||||
sx={{
|
sx={{
|
||||||
@@ -1047,12 +1064,25 @@ const ChoreView = () => {
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</Box>
|
</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 */}
|
{/* Timer Button - Show split button when timer is active, regular button otherwise */}
|
||||||
{[ChoreStatus.ACTIVE, ChoreStatus.PAUSED].includes(chore.status) ? (
|
{[ChoreStatus.ACTIVE, ChoreStatus.PAUSED].includes(chore.status) ? (
|
||||||
<TimerSplitButton
|
<TimerSplitButton
|
||||||
disabled={
|
disabled={
|
||||||
chore.lastCompletedDate !== null &&
|
(chore.status === ChoreStatus.PAUSED &&
|
||||||
chore.frequencyType === 'once'
|
notInCompletionWindow(chore)) ||
|
||||||
|
(chore.lastCompletedDate !== null &&
|
||||||
|
chore.frequencyType === 'once')
|
||||||
}
|
}
|
||||||
chore={chore}
|
chore={chore}
|
||||||
onAction={action => {
|
onAction={action => {
|
||||||
@@ -1078,8 +1108,9 @@ const ChoreView = () => {
|
|||||||
variant='soft'
|
variant='soft'
|
||||||
color='success'
|
color='success'
|
||||||
disabled={
|
disabled={
|
||||||
chore.lastCompletedDate !== null &&
|
notInCompletionWindow(chore) ||
|
||||||
chore.frequencyType === 'once'
|
(chore.lastCompletedDate !== null &&
|
||||||
|
chore.frequencyType === 'once')
|
||||||
}
|
}
|
||||||
startDecorator={<PlayArrow />}
|
startDecorator={<PlayArrow />}
|
||||||
sx={{
|
sx={{
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import {
|
|||||||
Chip,
|
Chip,
|
||||||
Divider,
|
Divider,
|
||||||
IconButton,
|
IconButton,
|
||||||
|
Link,
|
||||||
List,
|
List,
|
||||||
ListItem,
|
ListItem,
|
||||||
ListItemContent,
|
ListItemContent,
|
||||||
@@ -25,16 +26,31 @@ import {
|
|||||||
Typography,
|
Typography,
|
||||||
} from '@mui/joy'
|
} from '@mui/joy'
|
||||||
import moment from 'moment'
|
import moment from 'moment'
|
||||||
|
import { useState } from 'react'
|
||||||
import { useChores, useChoresHistory } from '../../queries/ChoreQueries'
|
import { useChores, useChoresHistory } from '../../queries/ChoreQueries'
|
||||||
import { useCircleMembers } from '../../queries/UserQueries'
|
import { useCircleMembers } from '../../queries/UserQueries'
|
||||||
import { resolvePhotoURL } from '../../utils/Helpers'
|
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
|
// Find the member who completed the activity
|
||||||
const completedByMember = members?.find(
|
const completedByMember = members?.find(
|
||||||
member => member.userId === activity.completedBy,
|
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 getTimeDisplay = dateToDisplay => {
|
||||||
const now = moment()
|
const now = moment()
|
||||||
const completed = moment(dateToDisplay)
|
const completed = moment(dateToDisplay)
|
||||||
@@ -177,7 +193,7 @@ const ActivityItem = ({ activity, members }) => {
|
|||||||
></Box>
|
></Box>
|
||||||
|
|
||||||
{/* Notes */}
|
{/* Notes */}
|
||||||
{activity.notes && (
|
{plainTextNotes && (
|
||||||
<Box sx={{ mt: 0.5, ml: 2.5 }}>
|
<Box sx={{ mt: 0.5, ml: 2.5 }}>
|
||||||
<Typography
|
<Typography
|
||||||
level='body-xs'
|
level='body-xs'
|
||||||
@@ -189,8 +205,36 @@ const ActivityItem = ({ activity, members }) => {
|
|||||||
color: 'text.secondary',
|
color: 'text.secondary',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Notes sx={{ fontSize: 14, mt: 0.1 }} />
|
<Notes sx={{ fontSize: 14, mt: 0.1, flexShrink: 0 }} />
|
||||||
{activity.notes}
|
<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>
|
</Typography>
|
||||||
</Box>
|
</Box>
|
||||||
)}
|
)}
|
||||||
@@ -217,6 +261,8 @@ const groupActivitiesByDate = activities => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const ActivitiesCard = ({ title = 'Recent Activities' }) => {
|
const ActivitiesCard = ({ title = 'Recent Activities' }) => {
|
||||||
|
const [noteViewerConfig, setNoteViewerConfig] = useState({ isOpen: false })
|
||||||
|
|
||||||
// Use hooks to fetch data
|
// Use hooks to fetch data
|
||||||
const {
|
const {
|
||||||
data: choresData,
|
data: choresData,
|
||||||
@@ -430,6 +476,14 @@ const ActivitiesCard = ({ title = 'Recent Activities' }) => {
|
|||||||
key={activity.id}
|
key={activity.id}
|
||||||
activity={activity}
|
activity={activity}
|
||||||
members={members}
|
members={members}
|
||||||
|
onViewNote={notes => {
|
||||||
|
setNoteViewerConfig({
|
||||||
|
isOpen: true,
|
||||||
|
title: `Note - ${activity.choreName}`,
|
||||||
|
content: notes,
|
||||||
|
onClose: () => setNoteViewerConfig({ isOpen: false }),
|
||||||
|
})
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</List>
|
</List>
|
||||||
@@ -437,6 +491,8 @@ const ActivitiesCard = ({ title = 'Recent Activities' }) => {
|
|||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
|
<NoteViewerModal config={noteViewerConfig} />
|
||||||
</Sheet>
|
</Sheet>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -213,7 +213,7 @@ const ChoreCard = ({
|
|||||||
<Typography level='title-md'>
|
<Typography level='title-md'>
|
||||||
{getName(chore.name)}
|
{getName(chore.name)}
|
||||||
</Typography>
|
</Typography>
|
||||||
{chore.assignedTo && chore.assignedTo !== userProfile?.id && (
|
{chore.assignedTo && (
|
||||||
<Box display='flex' alignItems='center' gap={0.5}>
|
<Box display='flex' alignItems='center' gap={0.5}>
|
||||||
<Chip
|
<Chip
|
||||||
variant='outlined'
|
variant='outlined'
|
||||||
|
|||||||
@@ -83,8 +83,8 @@ const CompactChoreCard = ({
|
|||||||
// Frequency
|
// Frequency
|
||||||
parts.push(getRecurrentChipText(chore))
|
parts.push(getRecurrentChipText(chore))
|
||||||
|
|
||||||
// Assignee (if not current user)
|
// Assignee
|
||||||
if (chore.assignedTo && chore.assignedTo !== userProfile.id) {
|
if (chore.assignedTo) {
|
||||||
const assignee = performers.find(
|
const assignee = performers.find(
|
||||||
p => p.userId === chore.assignedTo,
|
p => p.userId === chore.assignedTo,
|
||||||
)?.displayName
|
)?.displayName
|
||||||
|
|||||||
@@ -62,6 +62,7 @@ import ChoreListView from './ChoreListView.jsx'
|
|||||||
import ChoreModals from './components/ChoreModals'
|
import ChoreModals from './components/ChoreModals'
|
||||||
import FilterSection from './components/FilterSection'
|
import FilterSection from './components/FilterSection'
|
||||||
import MultiSelectToolbar from './components/MultiSelectToolbar'
|
import MultiSelectToolbar from './components/MultiSelectToolbar'
|
||||||
|
import MyChoreHeader from './components/MyChoreHeader'
|
||||||
import SearchBar from './components/SearchBar'
|
import SearchBar from './components/SearchBar'
|
||||||
import { useChoreActions } from './hooks/useChoreActions'
|
import { useChoreActions } from './hooks/useChoreActions'
|
||||||
import { useChoreFilters } from './hooks/useChoreFilters'
|
import { useChoreFilters } from './hooks/useChoreFilters'
|
||||||
@@ -76,6 +77,7 @@ import {
|
|||||||
} from './LocalNotificationScheduler'
|
} from './LocalNotificationScheduler'
|
||||||
import NotificationAccessSnackbar from './NotificationAccessSnackbar'
|
import NotificationAccessSnackbar from './NotificationAccessSnackbar'
|
||||||
import Sidepanel from './Sidepanel'
|
import Sidepanel from './Sidepanel'
|
||||||
|
import { INSIGHT_FILTER_DEFS } from './SmartInsightsCard'
|
||||||
import SortAndGrouping from './SortAndGrouping'
|
import SortAndGrouping from './SortAndGrouping'
|
||||||
|
|
||||||
const MyChores = () => {
|
const MyChores = () => {
|
||||||
@@ -167,6 +169,7 @@ const MyChores = () => {
|
|||||||
activeFilter,
|
activeFilter,
|
||||||
activeFilterId,
|
activeFilterId,
|
||||||
tempFilter,
|
tempFilter,
|
||||||
|
tempFilterMeta,
|
||||||
filteredChores: customFilteredChores,
|
filteredChores: customFilteredChores,
|
||||||
applyCustomFilter,
|
applyCustomFilter,
|
||||||
clearActiveFilter,
|
clearActiveFilter,
|
||||||
@@ -376,6 +379,18 @@ const MyChores = () => {
|
|||||||
|
|
||||||
const oldFilter = searchParams.get('filter')
|
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 is no longer in URL but filter is still active in state, clear it
|
||||||
if (!filterId && !oldFilter && activeFilterId) {
|
if (!filterId && !oldFilter && activeFilterId) {
|
||||||
clearActiveFilter()
|
clearActiveFilter()
|
||||||
@@ -422,6 +437,7 @@ const MyChores = () => {
|
|||||||
activeFilterId,
|
activeFilterId,
|
||||||
savedFilters,
|
savedFilters,
|
||||||
applyCustomFilter,
|
applyCustomFilter,
|
||||||
|
applyTempFilter,
|
||||||
clearActiveFilter,
|
clearActiveFilter,
|
||||||
selectedProject,
|
selectedProject,
|
||||||
projectFilteredChores,
|
projectFilteredChores,
|
||||||
@@ -431,6 +447,35 @@ const MyChores = () => {
|
|||||||
setSelectedCalendarDate,
|
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 {
|
const {
|
||||||
handleChoreAction,
|
handleChoreAction,
|
||||||
handleChangeDueDate,
|
handleChangeDueDate,
|
||||||
@@ -830,6 +875,13 @@ const MyChores = () => {
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Container maxWidth='md'>
|
<Container maxWidth='md'>
|
||||||
|
<MyChoreHeader
|
||||||
|
activeFilterId={activeFilterId}
|
||||||
|
activeFilter={activeFilter}
|
||||||
|
selectedProject={selectedProject}
|
||||||
|
tempFilter={tempFilter}
|
||||||
|
tempFilterMeta={tempFilterMeta}
|
||||||
|
/>
|
||||||
<Box
|
<Box
|
||||||
sx={{
|
sx={{
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
|
|||||||
@@ -6,10 +6,17 @@ import { ChoresGrouper } from '../../utils/Chores'
|
|||||||
import { getSidepanelConfig } from '../../utils/SidepanelConfig'
|
import { getSidepanelConfig } from '../../utils/SidepanelConfig'
|
||||||
import CalendarCard from '../components/CalendarCard'
|
import CalendarCard from '../components/CalendarCard'
|
||||||
import ActivitiesCard from './ActivitesCard'
|
import ActivitiesCard from './ActivitesCard'
|
||||||
|
import SmartInsightsCard from './SmartInsightsCard'
|
||||||
import TasksByAssigneeCard from './TasksByAssigneeCard'
|
import TasksByAssigneeCard from './TasksByAssigneeCard'
|
||||||
import UserSwitcher from './UserSwitcher'
|
import UserSwitcher from './UserSwitcher'
|
||||||
|
|
||||||
const Sidepanel = ({ chores }) => {
|
const Sidepanel = ({
|
||||||
|
chores,
|
||||||
|
allChores,
|
||||||
|
applyTempFilter,
|
||||||
|
clearTempFilter,
|
||||||
|
tempFilter,
|
||||||
|
}) => {
|
||||||
const isLargeScreen = useMediaQuery(theme => theme.breakpoints.up('lg'))
|
const isLargeScreen = useMediaQuery(theme => theme.breakpoints.up('lg'))
|
||||||
const [dueDatePieChartData, setDueDatePieChartData] = useState([])
|
const [dueDatePieChartData, setDueDatePieChartData] = useState([])
|
||||||
const [sidepanelConfig, setSidepanelConfig] = useState([])
|
const [sidepanelConfig, setSidepanelConfig] = useState([])
|
||||||
@@ -55,6 +62,16 @@ const Sidepanel = ({ chores }) => {
|
|||||||
switch (cardConfig.id) {
|
switch (cardConfig.id) {
|
||||||
case 'welcome':
|
case 'welcome':
|
||||||
return <UserSwitcher key='welcome' chores={chores} />
|
return <UserSwitcher key='welcome' chores={chores} />
|
||||||
|
case 'smartInsights':
|
||||||
|
return (
|
||||||
|
<SmartInsightsCard
|
||||||
|
key='smartInsights'
|
||||||
|
chores={allChores || chores}
|
||||||
|
applyTempFilter={applyTempFilter}
|
||||||
|
clearTempFilter={clearTempFilter}
|
||||||
|
tempFilter={tempFilter}
|
||||||
|
/>
|
||||||
|
)
|
||||||
case 'assignees':
|
case 'assignees':
|
||||||
return <TasksByAssigneeCard key='assignees' chores={chores} />
|
return <TasksByAssigneeCard key='assignees' chores={chores} />
|
||||||
case 'calendar':
|
case 'calendar':
|
||||||
|
|||||||
379
src/views/Chores/SmartInsightsCard.jsx
Normal file
379
src/views/Chores/SmartInsightsCard.jsx
Normal 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
|
||||||
72
src/views/Chores/components/MyChoreHeader.jsx
Normal file
72
src/views/Chores/components/MyChoreHeader.jsx
Normal 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
|
||||||
@@ -26,6 +26,7 @@ export const useCustomFilters = (chores, membersData, labels, projects) => {
|
|||||||
|
|
||||||
const [activeFilterId, setActiveFilterId] = useState(null)
|
const [activeFilterId, setActiveFilterId] = useState(null)
|
||||||
const [tempFilter, setTempFilter] = useState(null)
|
const [tempFilter, setTempFilter] = useState(null)
|
||||||
|
const [tempFilterMeta, setTempFilterMeta] = useState(null)
|
||||||
|
|
||||||
const context = useMemo(
|
const context = useMemo(
|
||||||
() => ({
|
() => ({
|
||||||
@@ -95,15 +96,18 @@ export const useCustomFilters = (chores, membersData, labels, projects) => {
|
|||||||
const clearActiveFilter = useCallback(() => {
|
const clearActiveFilter = useCallback(() => {
|
||||||
setActiveFilterId(null)
|
setActiveFilterId(null)
|
||||||
setTempFilter(null)
|
setTempFilter(null)
|
||||||
|
setTempFilterMeta(null)
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
const applyTempFilter = useCallback(filter => {
|
const applyTempFilter = useCallback((filter, meta = null) => {
|
||||||
setTempFilter(filter)
|
setTempFilter(filter)
|
||||||
|
setTempFilterMeta(meta)
|
||||||
setActiveFilterId(null)
|
setActiveFilterId(null)
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
const clearTempFilter = useCallback(() => {
|
const clearTempFilter = useCallback(() => {
|
||||||
setTempFilter(null)
|
setTempFilter(null)
|
||||||
|
setTempFilterMeta(null)
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
const saveFilter = useCallback(
|
const saveFilter = useCallback(
|
||||||
@@ -256,6 +260,7 @@ export const useCustomFilters = (chores, membersData, labels, projects) => {
|
|||||||
activeFilter,
|
activeFilter,
|
||||||
activeFilterId,
|
activeFilterId,
|
||||||
tempFilter,
|
tempFilter,
|
||||||
|
tempFilterMeta,
|
||||||
filteredChores,
|
filteredChores,
|
||||||
applyCustomFilter,
|
applyCustomFilter,
|
||||||
clearActiveFilter,
|
clearActiveFilter,
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import {
|
|||||||
MoreVert,
|
MoreVert,
|
||||||
Person,
|
Person,
|
||||||
Redo,
|
Redo,
|
||||||
|
RunningWithErrors,
|
||||||
ThumbDown,
|
ThumbDown,
|
||||||
Timelapse,
|
Timelapse,
|
||||||
Toll,
|
Toll,
|
||||||
@@ -17,9 +18,10 @@ import { useLocalization } from '../../contexts/LocalizationContext'
|
|||||||
import { TASK_COLOR } from '../../utils/Colors.jsx'
|
import { TASK_COLOR } from '../../utils/Colors.jsx'
|
||||||
|
|
||||||
const getCompletedChip = historyEntry => {
|
const getCompletedChip = historyEntry => {
|
||||||
if (historyEntry.status === 0) {
|
if (historyEntry.status === 0 || historyEntry.status === 5) {
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!historyEntry.dueDate) {
|
if (!historyEntry.dueDate) {
|
||||||
return null
|
return null
|
||||||
// <Chip
|
// <Chip
|
||||||
@@ -125,6 +127,7 @@ const HistoryCard = ({
|
|||||||
2: { icon: <Redo />, color: 'warning' }, // Skipped
|
2: { icon: <Redo />, color: 'warning' }, // Skipped
|
||||||
3: { icon: <HourglassEmpty />, color: 'neutral' }, // Pending Approval
|
3: { icon: <HourglassEmpty />, color: 'neutral' }, // Pending Approval
|
||||||
4: { icon: <ThumbDown />, color: 'danger' }, // Rejected
|
4: { icon: <ThumbDown />, color: 'danger' }, // Rejected
|
||||||
|
5: { icon: <RunningWithErrors />, color: 'danger' }, // Missed
|
||||||
}
|
}
|
||||||
|
|
||||||
const config = statusMap[historyEntry.status] || statusMap[1]
|
const config = statusMap[historyEntry.status] || statusMap[1]
|
||||||
@@ -189,7 +192,9 @@ const HistoryCard = ({
|
|||||||
? 'Pending Approval'
|
? 'Pending Approval'
|
||||||
: historyEntry.status === 4
|
: historyEntry.status === 4
|
||||||
? 'Rejected'
|
? 'Rejected'
|
||||||
: 'Completed'}
|
: historyEntry.status === 5
|
||||||
|
? 'Missed'
|
||||||
|
: 'Completed'}
|
||||||
</Typography>
|
</Typography>
|
||||||
|
|
||||||
<Chip size='sm' startDecorator={<EventNote />}>
|
<Chip size='sm' startDecorator={<EventNote />}>
|
||||||
@@ -233,16 +238,21 @@ const HistoryCard = ({
|
|||||||
mt: 0.5,
|
mt: 0.5,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Chip
|
{performer && (
|
||||||
size='sm'
|
<Chip
|
||||||
variant='solid'
|
size='sm'
|
||||||
color='success'
|
variant='solid'
|
||||||
startDecorator={
|
color='success'
|
||||||
<Avatar src={performer?.image} alt={performer?.displayName} />
|
startDecorator={
|
||||||
}
|
<Avatar
|
||||||
>
|
src={performer?.image}
|
||||||
{performer?.displayName || 'Unknown'}
|
alt={performer?.displayName}
|
||||||
</Chip>
|
/>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{performer?.displayName || 'Unknown'}
|
||||||
|
</Chip>
|
||||||
|
)}
|
||||||
|
|
||||||
{historyEntry.completedBy !== historyEntry.assignedTo &&
|
{historyEntry.completedBy !== historyEntry.assignedTo &&
|
||||||
assignedTo && (
|
assignedTo && (
|
||||||
@@ -262,7 +272,11 @@ const HistoryCard = ({
|
|||||||
variant='plain'
|
variant='plain'
|
||||||
color='neutral'
|
color='neutral'
|
||||||
startDecorator={<EventNote />}
|
startDecorator={<EventNote />}
|
||||||
sx={{ maxWidth: '120px', overflow: 'hidden', cursor: 'pointer' }}
|
sx={{
|
||||||
|
maxWidth: '120px',
|
||||||
|
overflow: 'hidden',
|
||||||
|
cursor: 'pointer',
|
||||||
|
}}
|
||||||
onClick={e => {
|
onClick={e => {
|
||||||
e.stopPropagation()
|
e.stopPropagation()
|
||||||
onViewNote?.(historyEntry.notes)
|
onViewNote?.(historyEntry.notes)
|
||||||
|
|||||||
@@ -40,9 +40,9 @@ function CreateChildUserModal({ isOpen, onClose, onSuccess }) {
|
|||||||
if (!password) {
|
if (!password) {
|
||||||
newErrors.password = 'Password is required'
|
newErrors.password = 'Password is required'
|
||||||
} else if (password.length < 8) {
|
} else if (password.length < 8) {
|
||||||
newErrors.password = 'Password must be at least 8 characters'
|
newErrors.password = 'Password must be between 8 and 64 characters'
|
||||||
} else if (password.length > 45) {
|
} else if (password.length > 64) {
|
||||||
newErrors.password = 'Password must be less than 45 characters'
|
newErrors.password = 'Password must be between 8 and 64 characters'
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -165,7 +165,7 @@ function CreateChildUserModal({ isOpen, onClose, onSuccess }) {
|
|||||||
name='password'
|
name='password'
|
||||||
type='password'
|
type='password'
|
||||||
id='password'
|
id='password'
|
||||||
placeholder='Enter password (8-45 characters)'
|
placeholder='Enter password (8-64 characters)'
|
||||||
value={password}
|
value={password}
|
||||||
onChange={e => {
|
onChange={e => {
|
||||||
setPassword(e.target.value)
|
setPassword(e.target.value)
|
||||||
|
|||||||
@@ -25,8 +25,8 @@ function PassowrdChangeModal({ isOpen, onClose }) {
|
|||||||
setPasswordError('Passwords do not match')
|
setPasswordError('Passwords do not match')
|
||||||
} else if (password.length < 8) {
|
} else if (password.length < 8) {
|
||||||
setPasswordError('Password must be at least 8 characters')
|
setPasswordError('Password must be at least 8 characters')
|
||||||
} else if (password.length > 50) {
|
} else if (password.length > 64) {
|
||||||
setPasswordError('Password must be less than 50 characters')
|
setPasswordError('Password must be less than 64 characters')
|
||||||
} else {
|
} else {
|
||||||
setPasswordError(null)
|
setPasswordError(null)
|
||||||
}
|
}
|
||||||
@@ -63,6 +63,7 @@ function PassowrdChangeModal({ isOpen, onClose }) {
|
|||||||
label='Password'
|
label='Password'
|
||||||
type='password'
|
type='password'
|
||||||
id='password'
|
id='password'
|
||||||
|
placeholder='Enter password (8-64 characters)'
|
||||||
value={password}
|
value={password}
|
||||||
onChange={e => {
|
onChange={e => {
|
||||||
setPasswordTouched(true)
|
setPasswordTouched(true)
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { Refresh, Token } from '@mui/icons-material'
|
import { Refresh, Token } from '@mui/icons-material'
|
||||||
import { Box, Button, Card, Chip, Divider, Typography } from '@mui/joy'
|
import { Box, Button, Card, Chip, Divider, Typography } from '@mui/joy'
|
||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
|
import { LocalNotifications } from '@capacitor/local-notifications'
|
||||||
import { useSSEContext } from '../../hooks/useSSEContext'
|
import { useSSEContext } from '../../hooks/useSSEContext'
|
||||||
import { useNotification } from '../../service/NotificationProvider'
|
import { useNotification } from '../../service/NotificationProvider'
|
||||||
import { apiClient } from '../../utils/ApiClient'
|
import { apiClient } from '../../utils/ApiClient'
|
||||||
@@ -28,6 +29,8 @@ const DeveloperSettings = () => {
|
|||||||
const [timeSinceLastHeartbeat, setTimeSinceLastHeartbeat] = useState(null)
|
const [timeSinceLastHeartbeat, setTimeSinceLastHeartbeat] = useState(null)
|
||||||
const [isRefreshing, setIsRefreshing] = useState(false)
|
const [isRefreshing, setIsRefreshing] = useState(false)
|
||||||
const [isRefreshingDirect, setIsRefreshingDirect] = useState(false)
|
const [isRefreshingDirect, setIsRefreshingDirect] = useState(false)
|
||||||
|
const [scheduledNotifications, setScheduledNotifications] = useState([])
|
||||||
|
const [isLoadingNotifications, setIsLoadingNotifications] = useState(false)
|
||||||
|
|
||||||
const { showNotification } = useNotification()
|
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()
|
loadTokenData()
|
||||||
|
loadScheduledNotifications()
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
useEffect(() => {
|
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 (
|
return (
|
||||||
<div className='grid gap-4 py-4' id='developer'>
|
<div className='grid gap-4 py-4' id='developer'>
|
||||||
<Typography level='h3'>Developer Settings</Typography>
|
<Typography level='h3'>Developer Settings</Typography>
|
||||||
@@ -308,6 +377,133 @@ const DeveloperSettings = () => {
|
|||||||
</Box>
|
</Box>
|
||||||
</Card>
|
</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'>
|
<Card variant='outlined'>
|
||||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
|
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
|
||||||
<Typography level='title-lg'>Server-Sent Events (SSE)</Typography>
|
<Typography level='title-lg'>Server-Sent Events (SSE)</Typography>
|
||||||
|
|||||||
@@ -2,8 +2,11 @@ import { DragDropContext, Draggable, Droppable } from '@hello-pangea/dnd'
|
|||||||
import {
|
import {
|
||||||
CalendarMonth,
|
CalendarMonth,
|
||||||
DragIndicator,
|
DragIndicator,
|
||||||
|
EmojiEvents,
|
||||||
History,
|
History,
|
||||||
Person,
|
Person,
|
||||||
|
SupervisorAccount,
|
||||||
|
TrendingUp,
|
||||||
Visibility,
|
Visibility,
|
||||||
VisibilityOff,
|
VisibilityOff,
|
||||||
WavingHand,
|
WavingHand,
|
||||||
@@ -23,48 +26,22 @@ import {
|
|||||||
Typography,
|
Typography,
|
||||||
} from '@mui/joy'
|
} from '@mui/joy'
|
||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
|
import {
|
||||||
|
DEFAULT_SIDEPANEL_CONFIG,
|
||||||
|
getSidepanelConfig,
|
||||||
|
saveSidepanelConfig,
|
||||||
|
} from '../../utils/SidepanelConfig'
|
||||||
import SettingsLayout from './SettingsLayout'
|
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 SidepanelSettings = () => {
|
||||||
const [config, setConfig] = useState(DEFAULT_SIDEPANEL_CONFIG)
|
const [config, setConfig] = useState(getSidepanelConfig())
|
||||||
|
|
||||||
const getIcon = iconName => {
|
const getIcon = iconName => {
|
||||||
switch (iconName) {
|
switch (iconName) {
|
||||||
|
case 'SupervisorAccount':
|
||||||
|
return <SupervisorAccount />
|
||||||
|
case 'TrendingUp':
|
||||||
|
return <TrendingUp />
|
||||||
case 'WavingHand':
|
case 'WavingHand':
|
||||||
return <WavingHand />
|
return <WavingHand />
|
||||||
case 'Person':
|
case 'Person':
|
||||||
@@ -73,27 +50,20 @@ const SidepanelSettings = () => {
|
|||||||
return <CalendarMonth />
|
return <CalendarMonth />
|
||||||
case 'History':
|
case 'History':
|
||||||
return <History />
|
return <History />
|
||||||
|
case 'EmojiEvents':
|
||||||
|
return <EmojiEvents />
|
||||||
default:
|
default:
|
||||||
return <Person />
|
return <Person />
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const saved = localStorage.getItem('sidepanelConfig')
|
setConfig(getSidepanelConfig())
|
||||||
if (saved) {
|
|
||||||
try {
|
|
||||||
const parsed = JSON.parse(saved)
|
|
||||||
setConfig(parsed)
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error parsing sidepanel config:', error)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
const saveConfig = newConfig => {
|
const saveConfig = newConfig => {
|
||||||
setConfig(newConfig)
|
setConfig(newConfig)
|
||||||
localStorage.setItem('sidepanelConfig', JSON.stringify(newConfig))
|
saveSidepanelConfig(newConfig)
|
||||||
window.dispatchEvent(new Event('sidepanelConfigChanged'))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleToggleEnabled = (id, enabled) => {
|
const handleToggleEnabled = (id, enabled) => {
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ import {
|
|||||||
} from './CustomParsers'
|
} from './CustomParsers'
|
||||||
import SmartTaskTitleInput from './SmartTaskTitleInput'
|
import SmartTaskTitleInput from './SmartTaskTitleInput'
|
||||||
|
|
||||||
|
import DurationInput from '../../components/common/DurationInput'
|
||||||
import KeyboardShortcutHint from '../../components/common/KeyboardShortcutHint'
|
import KeyboardShortcutHint from '../../components/common/KeyboardShortcutHint'
|
||||||
import NotificationTemplate from '../../components/NotificationTemplate'
|
import NotificationTemplate from '../../components/NotificationTemplate'
|
||||||
import LearnMoreButton from './LearnMore'
|
import LearnMoreButton from './LearnMore'
|
||||||
@@ -90,6 +91,8 @@ const TaskInput = ({ autoFocus, onChoreUpdate, isModalOpen, onClose }) => {
|
|||||||
const [hasDescription, setHasDescription] = useState(false)
|
const [hasDescription, setHasDescription] = useState(false)
|
||||||
const [hasSubTasks, setHasSubTasks] = useState(false)
|
const [hasSubTasks, setHasSubTasks] = useState(false)
|
||||||
const [hasNotifications, setHasNotifications] = useState(false)
|
const [hasNotifications, setHasNotifications] = useState(false)
|
||||||
|
const [hasDeadline, setHasDeadline] = useState(false)
|
||||||
|
const [deadlineOffset, setDeadlineOffset] = useState(-1)
|
||||||
const [showKeyboardShortcuts, setShowKeyboardShortcuts] = useState(false)
|
const [showKeyboardShortcuts, setShowKeyboardShortcuts] = useState(false)
|
||||||
const [projectId, setProjectId] = useState(getInitialProject())
|
const [projectId, setProjectId] = useState(getInitialProject())
|
||||||
|
|
||||||
@@ -491,6 +494,8 @@ const TaskInput = ({ autoFocus, onChoreUpdate, isModalOpen, onClose }) => {
|
|||||||
setLabelsV2([])
|
setLabelsV2([])
|
||||||
setAssignees([])
|
setAssignees([])
|
||||||
setProjectId(getInitialProject())
|
setProjectId(getInitialProject())
|
||||||
|
setHasDeadline(false)
|
||||||
|
setDeadlineOffset(-1)
|
||||||
}
|
}
|
||||||
|
|
||||||
const createChore = () => {
|
const createChore = () => {
|
||||||
@@ -529,6 +534,7 @@ const TaskInput = ({ autoFocus, onChoreUpdate, isModalOpen, onClose }) => {
|
|||||||
labelsV2: labelsV2,
|
labelsV2: labelsV2,
|
||||||
priority: priority ? Number(priority) : 0,
|
priority: priority ? Number(priority) : 0,
|
||||||
points: points > -1 ? points : null,
|
points: points > -1 ? points : null,
|
||||||
|
deadlineOffset: deadlineOffset < 0 ? null : deadlineOffset,
|
||||||
status: 0,
|
status: 0,
|
||||||
frequencyType: 'once',
|
frequencyType: 'once',
|
||||||
frequencyMetadata: {},
|
frequencyMetadata: {},
|
||||||
@@ -798,6 +804,19 @@ const TaskInput = ({ autoFocus, onChoreUpdate, isModalOpen, onClose }) => {
|
|||||||
Edit Notifications
|
Edit Notifications
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
|
{!hasDeadline && dueDate && (
|
||||||
|
<Button
|
||||||
|
startDecorator={<Add />}
|
||||||
|
variant='plain'
|
||||||
|
size='sm'
|
||||||
|
onClick={() => {
|
||||||
|
setHasDeadline(true)
|
||||||
|
setDeadlineOffset(86400)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Set Deadline
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
{hasDescription && (
|
{hasDescription && (
|
||||||
@@ -959,6 +978,27 @@ const TaskInput = ({ autoFocus, onChoreUpdate, isModalOpen, onClose }) => {
|
|||||||
)}
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
</FormControl> */}
|
</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 && (
|
{hasNotifications && dueDate && (
|
||||||
<Box
|
<Box
|
||||||
sx={{
|
sx={{
|
||||||
|
|||||||
@@ -123,7 +123,7 @@ const NavBar = () => {
|
|||||||
}
|
}
|
||||||
if (
|
if (
|
||||||
['/chores', '/'].includes(location.pathname) &&
|
['/chores', '/'].includes(location.pathname) &&
|
||||||
!searchParams.get('filter')
|
!searchParams.get('filterId')
|
||||||
) {
|
) {
|
||||||
return menuRounded
|
return menuRounded
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user