Merge branch 'main' into fix-notification-typo

This commit is contained in:
Mohamad Tarbin
2026-01-25 19:58:31 -05:00
committed by GitHub
193 changed files with 39749 additions and 11104 deletions

View File

@@ -1,19 +1,18 @@
import NavBar from '@/views/components/NavBar'
import { Button, Snackbar, Typography, useColorScheme } from '@mui/joy'
import { Button, Typography, useColorScheme } from '@mui/joy'
import Tracker from '@openreplay/tracker'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { useEffect, useState } from 'react'
import { useCallback, useEffect } from 'react'
import { Outlet, useNavigate } from 'react-router-dom'
import { useRegisterSW } from 'virtual:pwa-register/react'
import { registerCapacitorListeners } from './CapacitorListener'
import PageTransition from './components/animations/PageTransition'
import { AuthProvider } from './hooks/useAuth.jsx'
import { ImpersonateUserProvider } from './contexts/ImpersonateUserContext'
import { UserContext } from './contexts/UserContext'
import { useResource } from './queries/ResourceQueries'
import { AuthenticationProvider } from './service/AuthenticationService'
import { ErrorProvider } from './service/ErrorProvider'
import { GetUserProfile } from './utils/Fetcher'
import { apiManager, isTokenValid } from './utils/TokenManager'
import SSEProvider from './contexts/SSEContext'
import { useNotification } from './service/NotificationProvider'
import NetworkBanner from './views/components/NetworkBanner'
const add = className => {
document.getElementById('root').classList.add(className)
}
@@ -21,26 +20,27 @@ const add = className => {
const remove = className => {
document.getElementById('root').classList.remove(className)
}
// TODO: Update the interval to at 60 minutes
const intervalMS = 5 * 60 * 1000 // 5 minutes
function App() {
const resource = useResource()
const navigate = useNavigate()
startApiManager(navigate)
startOpenReplay()
const queryClient = new QueryClient()
const { mode, systemMode } = useColorScheme()
const [userProfile, setUserProfile] = useState(null)
const [showUpdateSnackbar, setShowUpdateSnackbar] = useState(true)
const startOpenReplay = () => {
if (!import.meta.env.VITE_OPENREPLAY_PROJECT_KEY) return
const tracker = new Tracker({
projectKey: import.meta.env.VITE_OPENREPLAY_PROJECT_KEY,
})
tracker.start()
}
const AppContent = () => {
const { showNotification } = useNotification()
const {
offlineReady: [offlineReady, setOfflineReady],
needRefresh: [needRefresh, setNeedRefresh],
updateServiceWorker,
} = useRegisterSW({
onRegistered(r) {
// eslint-disable-next-line prefer-template
console.log('SW Registered: ' + r)
r &&
setInterval(() => {
@@ -51,12 +51,54 @@ function App() {
console.log('SW registration error', error)
},
})
const close = () => {
setOfflineReady(false)
setNeedRefresh(false)
}
const setThemeClass = () => {
useEffect(() => {
if (needRefresh) {
showNotification({
type: 'custom',
component: (
<div>
<Typography level='body-md'>
A new version is now available. Click on reload button to update.
</Typography>
<Button
color='secondary'
size='small'
onClick={() => {
updateServiceWorker(true)
setNeedRefresh(false)
}}
sx={{ ml: 2 }}
>
Refresh
</Button>
</div>
),
snackbarProps: {
autoHideDuration: null, // Persistent until user action
},
})
}
}, [needRefresh, showNotification, updateServiceWorker, setNeedRefresh])
return (
<div>
<ImpersonateUserProvider>
<NavBar />
<PageTransition>
<Outlet />
</PageTransition>
</ImpersonateUserProvider>
</div>
)
}
function App() {
// startOpenReplay()
const { mode, systemMode } = useColorScheme()
const setThemeClass = useCallback(() => {
const value = JSON.parse(localStorage.getItem('themeMode')) || mode
if (value === 'system') {
@@ -71,75 +113,27 @@ function App() {
}
return remove('dark')
}
const getUserProfile = () => {
GetUserProfile()
.then(res => {
res.json().then(data => {
setUserProfile(data.res)
})
})
.catch(error => {})
}
}, [mode, systemMode])
useEffect(() => {
setThemeClass()
}, [mode, systemMode])
}, [setThemeClass])
useEffect(() => {
registerCapacitorListeners()
if (isTokenValid()) {
if (!userProfile) getUserProfile()
}
}, [])
return (
<div className='min-h-screen'>
<>
<NetworkBanner />
<QueryClientProvider client={queryClient}>
<AuthenticationProvider />
<ErrorProvider>
<ImpersonateUserProvider>
<UserContext.Provider value={{ userProfile, setUserProfile }}>
<NavBar />
<Outlet />
</UserContext.Provider>
</ImpersonateUserProvider>
</ErrorProvider>
{needRefresh && (
<Snackbar open={showUpdateSnackbar}>
<Typography level='body-md'>
A new version is now available.Click on reload button to update.
</Typography>
<Button
color='secondary'
size='small'
onClick={() => {
updateServiceWorker(true)
setShowUpdateSnackbar(false)
}}
>
Refresh
</Button>
</Snackbar>
)}
</QueryClientProvider>
</div>
<AuthProvider>
<SSEProvider>
<AppContent />
</SSEProvider>
</AuthProvider>
</>
)
}
const startOpenReplay = () => {
if (!import.meta.env.VITE_OPENREPLAY_PROJECT_KEY) return
const tracker = new Tracker({
projectKey: import.meta.env.VITE_OPENREPLAY_PROJECT_KEY,
})
tracker.start()
}
export default App
const startApiManager = navigate => {
apiManager.init()
apiManager.setNavigateToLogin(() => {
navigate('/login')
})
}

View File

@@ -1,78 +1,195 @@
import { LocalNotifications } from '@capacitor/local-notifications';
import { App as mobileApp } from '@capacitor/app';
import { App as mobileApp } from '@capacitor/app'
import { Capacitor } from '@capacitor/core'
import { Device } from '@capacitor/device'
import { LocalNotifications } from '@capacitor/local-notifications'
import { Preferences } from '@capacitor/preferences'
import { PushNotifications } from '@capacitor/push-notifications'
import { PutNotificationTarget } from './utils/Fetcher';
import { Capacitor } from '@capacitor/core';
import { RegisterDeviceToken } from './utils/Fetcher'
const localNotificationListenerRegistration = () => {
LocalNotifications.addListener('localNotificationReceived', (notification) => {
console.log('Notification received', notification);
});
LocalNotifications.addListener('localNotificationActionPerformed', (event) => {
console.log('Notification action performed', event);
if (event.actionId === 'tap') {
console.log('Notification opened, navigate to chore', event.notification.extra.choreId);
window.location.href = `/chores/${event.notification.extra.choreId}`
}
});
}
const pushNotificationListenerRegistration = () => {
PushNotifications.register();
PushNotifications.addListener('registration', (token) => {
if (Capacitor.isNativePlatform()) {
const type = Capacitor.getPlatform() === 'android' ? 1 : 2; // 1 for android, 2 for ios
PutNotificationTarget(type, token.value).then((response) => {
console.log('Notification target updated', response);
}
).catch((error) => {
console.error('Error updating notification target', error);
}
);
LocalNotifications.addListener('localNotificationReceived', notification => {
console.log('Notification received', notification)
})
LocalNotifications.addListener('localNotificationActionPerformed', event => {
console.log('Notification action performed', event)
if (event.actionId === 'tap') {
console.log(
'Notification opened, navigate to chore',
event.notification.extra.choreId,
)
window.location.href = `/chores/${event.notification.extra.choreId}`
}
})
}
// TODO save the token in preferences and only send it if it has changed:
console.log('Push registration success, token: ' + token.value);
}
const registerTokenIfNeeded = async (token, deviceInfo, deviceId, platform) => {
try {
// const stored = await Preferences.get({ key: 'deviceRegistration' })
// const lastReg = stored.value ? JSON.parse(stored.value) : null
const current = {
token: token.value,
deviceId: deviceId.identifier,
platform,
appVersion: deviceInfo.appVersion,
registeredAt: Date.now(),
}
);
PushNotifications.addListener('registrationError', (error) => {
console.error('Error on registration: ' + JSON.stringify(error));
// const shouldRegister =
// !lastReg ||
// lastReg.token !== current.token ||
// lastReg.appVersion !== current.appVersion ||
// Date.now() - lastReg.registeredAt > 7 * 24 * 60 * 60 * 1000
// console.log('Registering device token:', {
// reason: !lastReg
// ? 'first_time'
// : lastReg.token !== current.token
// ? 'token_changed'
// : lastReg.appVersion !== current.appVersion
// ? 'app_updated'
// : 'periodic_refresh',
// })
const result = await RegisterDeviceToken(
token.value,
deviceId.identifier,
platform,
deviceInfo.appVersion,
deviceInfo.model,
)
if (result && result.ok) {
await Preferences.set({
key: 'deviceRegistration',
value: JSON.stringify(current),
})
console.log('Device token registered successfully')
// Emit event to notify UI components of successful registration
window.dispatchEvent(new CustomEvent('deviceTokenRegistered'))
} else if (result) {
// Handle registration errors
console.error('Device registration failed:', result.status)
// Emit event with error details for UI to handle
window.dispatchEvent(
new CustomEvent('deviceTokenRegistrationFailed', {
detail: {
status: result.status,
error: await result.text().catch(() => 'Unknown error'),
},
}),
)
}
);
PushNotifications.addListener('pushNotificationActionPerformed', fcmEvent => {
if(fcmEvent.actionId === 'tap') {
if (fcmEvent.notification.data.type === 'chore_due') {
window.location.href = `/chores/${fcmEvent.notification.data.choreId}`
}
else {
window.location.href = `/my/chores`
}
} catch (error) {
console.error(
'Error in token registration check, registering anyway:',
error,
)
const fallbackResult = await RegisterDeviceToken(
token.value,
deviceId.identifier,
platform,
deviceInfo.appVersion,
deviceInfo.model,
)
if (fallbackResult && fallbackResult.ok) {
// Emit event to notify UI components of successful registration
window.dispatchEvent(new CustomEvent('deviceTokenRegistered'))
} else if (fallbackResult) {
// Handle registration errors
console.error(
'Fallback device registration failed:',
fallbackResult.status,
)
// Emit event with error details for UI to handle
window.dispatchEvent(
new CustomEvent('deviceTokenRegistrationFailed', {
detail: {
status: fallbackResult.status,
error: await fallbackResult.text().catch(() => 'Unknown error'),
},
}),
)
}
}
);
}
}
const pushNotificationListenerRegistration = async () => {
// Check and request permissions for Android 13+
if (Capacitor.isNativePlatform()) {
let permStatus = await PushNotifications.checkPermissions()
const registerCapacitorListeners = () => {
if(!Capacitor.isNativePlatform()) {
console.log('Not a native platform, skipping registration of native listeners');
return
if (permStatus.receive === 'prompt') {
permStatus = await PushNotifications.requestPermissions()
}
localNotificationListenerRegistration();
pushNotificationListenerRegistration();
mobileApp.addListener('backButton', ({ canGoBack }) => {
if (canGoBack) {
window.history.back();
} else {
mobileApp.exitApp();
}
});
if (permStatus.receive !== 'granted') {
console.warn('Push notification permission not granted')
return
}
}
export { registerCapacitorListeners }
await PushNotifications.register()
PushNotifications.addListener('registration', async token => {
if (Capacitor.isNativePlatform()) {
try {
const deviceInfo = await Device.getInfo()
const deviceId = await Device.getId()
const platform =
Capacitor.getPlatform() === 'android' ? 'android' : 'ios'
await registerTokenIfNeeded(token, deviceInfo, deviceId, platform)
} catch (error) {
console.error('Error registering device token', error)
}
}
})
PushNotifications.addListener('registrationError', error => {
console.error('Error on registration: ' + JSON.stringify(error))
})
PushNotifications.addListener('pushNotificationReceived', notification => {
console.log('Push notification received: ', notification)
})
PushNotifications.addListener('pushNotificationActionPerformed', fcmEvent => {
if (fcmEvent.actionId === 'tap') {
if (
fcmEvent.notification.data.type === 'chore_due' ||
fcmEvent.notification.data.type === 'nudge'
) {
window.location.href = `/chores/${fcmEvent.notification.data.choreId}`
} else {
window.location.href = `/chores`
}
}
})
}
const registerCapacitorListeners = () => {
if (!Capacitor.isNativePlatform()) {
console.log(
'Not a native platform, skipping registration of native listeners',
)
return
}
localNotificationListenerRegistration()
mobileApp.addListener('backButton', ({ canGoBack }) => {
if (canGoBack) {
window.history.back()
} else {
mobileApp.exitApp()
}
})
}
export {
registerCapacitorListeners,
pushNotificationListenerRegistration as registerPushNotifications,
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 419 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 145 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 406 KiB

After

Width:  |  Height:  |  Size: 147 KiB

View File

@@ -0,0 +1,744 @@
import { Save } from '@mui/icons-material'
import AddIcon from '@mui/icons-material/Add'
import DeleteIcon from '@mui/icons-material/Delete'
import InfoIcon from '@mui/icons-material/Info'
import NotificationsIcon from '@mui/icons-material/Notifications'
import Alert from '@mui/joy/Alert'
import Badge from '@mui/joy/Badge'
import Box from '@mui/joy/Box'
import Button from '@mui/joy/Button'
import IconButton from '@mui/joy/IconButton'
import Input from '@mui/joy/Input'
import Option from '@mui/joy/Option'
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'
const timeUnits = [
{ label: 'Mins', value: 'm' },
{ label: 'Hours', value: 'h' },
{ label: 'Days', value: 'd' },
]
const timingOptions = [
{ label: 'Before', value: 'before' },
{ label: 'On Due', value: 'ondue' },
{ label: 'After', value: 'after' },
]
function getRelativeLabel(notification) {
const { value, unit } = notification
const numericValue = Number(value)
if (numericValue === 0) {
return 'On due date'
}
const unitName = unit === 'm' ? 'minutes' : unit === 'h' ? 'hours' : 'days'
const absValue = Math.abs(numericValue)
return `${absValue} ${unitName} ${numericValue < 0 ? 'before' : 'after'} due`
}
// Helper functions to convert between internal value and UI representation
function getUIRepresentation(notification) {
const numericValue = Number(notification.value)
if (numericValue === 0) {
return { timing: 'ondue', displayValue: 0, unit: notification.unit }
} else if (numericValue < 0) {
return {
timing: 'before',
displayValue: Math.abs(numericValue),
unit: notification.unit,
}
} else {
return {
timing: 'after',
displayValue: numericValue,
unit: notification.unit,
}
}
}
function getInternalValue(timing, displayValue) {
if (timing === 'ondue') return 0
if (timing === 'before') return -Math.abs(displayValue)
return Math.abs(displayValue) // 'after'
}
const NotificationTemplate = ({
maxNotifications = 5,
onChange,
value,
showTimeline = true,
}) => {
const [notifications, setNotifications] = useState(
value?.templates ||
JSON.parse(localStorage.getItem('defaultNotificationTemplate')) ||
[],
)
const [error, setError] = useState(null)
const [showSaveDefault, setShowSaveDefault] = useState(false)
// Create a map of notification indices for timeline display
const [notificationIndexMap, setNotificationIndexMap] = useState({})
const updateNotificationIndices = useCallback(() => {
// Convert notifications to minutes for proper chronological sorting
const convertToMinutes = (value, unit) => {
const numericValue = Number(value)
if (numericValue === 0) return 0
let minutes = Math.abs(numericValue)
if (unit === 'h') minutes *= 60
if (unit === 'd') minutes *= 24 * 60
return numericValue < 0 ? -minutes : minutes
}
// Sort notifications for consistent ordering by actual time duration
const sorted = [...notifications].sort((a, b) => {
const aMinutes = convertToMinutes(a.value, a.unit)
const bMinutes = convertToMinutes(b.value, b.unit)
return aMinutes - bMinutes
})
const indexMap = {}
// Map original array indices to their chronological position numbers
notifications.forEach((originalNotification, originalIdx) => {
const chronologicalPosition = sorted.findIndex(
sortedNotification =>
Number(sortedNotification.value) ===
Number(originalNotification.value) &&
sortedNotification.unit === originalNotification.unit,
)
indexMap[originalIdx] = chronologicalPosition + 1
})
setNotificationIndexMap(indexMap)
}, [notifications])
// Sort notifications and update the index mapping
useEffect(() => {
updateNotificationIndices()
setError(null)
}, [updateNotificationIndices])
// Notify parent component of changes including the template name
useEffect(() => {
if (onChange) {
onChange({ notifications })
}
}, [notifications, onChange])
// Validates if a notification configuration already exists
const isDuplicate = (notification, currentIdx = -1) => {
return notifications.some((n, idx) => {
if (idx === currentIdx) return false
return (
Number(n.value) === Number(notification.value) &&
n.unit === notification.unit
)
})
}
const handleChange = (idx, field, value) => {
const currentNotification = notifications[idx]
const uiRep = getUIRepresentation(currentNotification)
let updatedUIRep = { ...uiRep }
let updatedNotification = { ...currentNotification }
// Update the UI representation based on the field being changed
if (field === 'timing') {
updatedUIRep.timing = value
// Reset display value when switching to "On Due"
if (value === 'ondue') {
updatedUIRep.displayValue = 0
}
} else if (field === 'displayValue') {
updatedUIRep.displayValue = Math.max(0, Number(value))
} else if (field === 'unit') {
updatedUIRep.unit = value
updatedNotification.unit = value
}
// Convert back to internal representation
const newInternalValue = getInternalValue(
updatedUIRep.timing,
updatedUIRep.displayValue,
)
updatedNotification = {
...updatedNotification,
value: newInternalValue,
unit: updatedUIRep.unit,
}
// Check if another notification is already "On Due" (value = 0)
if (newInternalValue === 0) {
const existingOnDue = notifications.findIndex(
(n, i) => i !== idx && Number(n.value) === 0,
)
if (existingOnDue !== -1) {
setError(
'Only one notification can be set to "On Due". Please choose a different timing.',
)
return
}
}
if (isDuplicate(updatedNotification, idx)) {
setError(
'This notification setting already exists. Please use a different timing.',
)
return
}
const updated = notifications.map((n, i) =>
i === idx ? updatedNotification : n,
)
setNotifications(updated)
setError(null)
}
const addSmartNotification = type => {
if (notifications.length >= maxNotifications) return
setShowSaveDefault(true)
let newNotification
let suggestions = []
switch (type) {
case 'reminder':
// Suggest common reminder times that don't exist
suggestions = [
{ value: -1, unit: 'd' }, // 1 day before
{ value: -3, unit: 'h' }, // 3 hours before
{ value: -30, unit: 'm' }, // 3 days before
]
break
case 'due':
if (notifications.some(n => Number(n.value) === 0)) {
setError('Only one "Due Alert" notification is allowed.')
return
}
newNotification = { value: 0, unit: 'm' }
break
case 'followup':
suggestions = [
{ value: 1, unit: 'd' }, // 1 day after
{ value: 3, unit: 'd' }, // 3 days after
{ value: 7, unit: 'd' }, // 1 week after
]
break
}
// For reminder/followup, find first non-duplicate suggestion
if (suggestions.length > 0) {
newNotification = suggestions.find(suggestion => !isDuplicate(suggestion))
if (!newNotification) {
setError(`All common ${type} times are already configured.`)
return
}
}
// Add the new notification to the end (don't sort, keep form order)
const updatedNotifications = [...notifications, newNotification]
setNotifications(updatedNotifications)
setError(null)
}
const removeNotification = idx => {
const updated = notifications.filter((_, i) => i !== idx)
setNotifications(updated)
onChange && onChange(updated)
setShowSaveDefault(true)
}
const renderTimeline = () => {
// Convert notifications to minutes for proper chronological sorting
const convertToMinutes = (value, unit) => {
const numericValue = Number(value)
if (numericValue === 0) return 0
let minutes = Math.abs(numericValue)
if (unit === 'h') minutes *= 60
if (unit === 'd') minutes *= 24 * 60
return numericValue < 0 ? -minutes : minutes
}
// Sort notifications chronologically by actual time (in minutes)
const sorted = [...notifications].sort((a, b) => {
const aMinutes = convertToMinutes(a.value, a.unit)
const bMinutes = convertToMinutes(b.value, b.unit)
return aMinutes - bMinutes
})
// Get min and max notification times in minutes for dynamic scaling
const minutesValues = sorted.map(n => convertToMinutes(n.value, n.unit))
const minBefore = Math.min(0, ...minutesValues) // Default to 0 if no "before" notifications
const maxAfter = Math.max(0, ...minutesValues) // Default to 0 if no "after" notifications
const getPositionPercent = (value, unit) => {
const minutes = convertToMinutes(value, unit)
// Due date is always at center (50%)
if (minutes === 0) return 50
// For notifications before due date (negative values)
if (minutes < 0) {
if (minBefore === 0) return 30 // Default position if no before notifications
// Scale between 10% (furthest left) and 45% (closest to due)
return 45 - (Math.abs(minutes) / Math.abs(minBefore)) * 35
}
// For notifications after due date (positive values)
if (maxAfter === 0) return 70 // Default position if no after notifications
// Scale between 55% (closest to due) and 90% (furthest right)
return 55 + (minutes / maxAfter) * 35
}
return (
<Box sx={{ mt: 3, mb: 2 }}>
<Typography level={'body-md'} sx={{ mb: 1 }}>
Notification Timeline
</Typography>
<Box
sx={{
display: 'flex',
flexDirection: 'column',
position: 'relative',
height: 90,
bgcolor: 'background.surface',
borderRadius: 'md',
border: '1px solid',
borderColor: 'neutral.outlinedBorder',
p: 2,
transition: 'height 0.3s ease',
// '&:hover': {
// height: 130,
// },
}}
>
{/* Timeline line */}
<Box
sx={{
position: 'relative',
width: '100%',
height: 3,
bgcolor: 'neutral.outlinedBorder',
mt: 2,
}}
>
{/* Due date marker */}
<Box
sx={{
position: 'absolute',
left: '50%',
height: 16,
width: 3,
bgcolor: 'warning.500',
top: -8,
transform: 'translateX(-50%)',
borderRadius: 'sm',
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
}}
>
<Typography
level={'body-xs'}
sx={{
mt: 4,
fontWeight: 'md',
color: 'warning.700',
fontSize: '0.6rem',
}}
>
Due Date
</Typography>
</Box>
{/* Notification markers */}
{sorted.map((n, i) => {
// Calculate position based on actual time duration
const percent = getPositionPercent(n.value, n.unit)
return (
<Box
key={i}
sx={{
position: 'absolute',
left: `${percent}%`,
transform: 'translateX(-50%)',
color:
convertToMinutes(n.value, n.unit) < 0
? TASK_COLOR.SCHEDULED
: convertToMinutes(n.value, n.unit) === 0
? TASK_COLOR.TODAY
: TASK_COLOR.LATE,
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
transition: 'all 0.3s ease',
cursor: 'pointer',
opacity: 0.85,
'&:hover': {
opacity: 1,
transform: 'translateX(-50%) scale(1.1)',
zIndex: 10,
},
}}
title={getRelativeLabel(n)}
>
<Badge
badgeContent={
notificationIndexMap[
notifications.findIndex(
original =>
Number(original.value) === Number(n.value) &&
original.unit === n.unit,
)
] || i + 1
}
size={'sm'}
variant={'solid'}
sx={{
'--Badge-paddingX': '4px',
'--Badge-minHeight': '16px',
'--Badge-fontSize': '0.65rem',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
'& .MuiBadge-badge': {
background:
convertToMinutes(n.value, n.unit) < 0
? NOTIFICATION_TYPE.PREDUE
: convertToMinutes(n.value, n.unit) === 0
? NOTIFICATION_TYPE.DUE_DATE
: NOTIFICATION_TYPE.POSTDUE,
color: 'white',
},
}}
>
<NotificationsIcon
fontSize={'small'}
sx={{
height: 18,
width: 18,
}}
/>
</Badge>
</Box>
)
})}
</Box>
</Box>
</Box>
)
}
return (
<Box>
{error && (
<Alert
variant='soft'
color='danger'
sx={{ mb: 2 }}
startDecorator={<InfoIcon />}
>
{error}
</Alert>
)}
{notifications
.map((n, idx) => ({ notification: n, originalIndex: idx }))
.sort((a, b) => {
const aBadgeNumber = notificationIndexMap[a.originalIndex] || 0
const bBadgeNumber = notificationIndexMap[b.originalIndex] || 0
return aBadgeNumber - bBadgeNumber
})
.map(({ notification: n, originalIndex: idx }) => {
// Get ordered badge number from timeline sorting
const badgeNumber = notificationIndexMap[idx]
const uiRep = getUIRepresentation(n)
const getNotificationColors = value => {
if (Number(value) < 0) {
return {
bgColor: NOTIFICATION_TYPE.PREDUE,
lightBg: `${NOTIFICATION_TYPE.PREDUE}20`,
borderColor: `${NOTIFICATION_TYPE.PREDUE}40`,
textColor: NOTIFICATION_TYPE.PREDUE,
}
} else if (Number(value) === 0) {
return {
bgColor: NOTIFICATION_TYPE.DUE_DATE,
lightBg: `${NOTIFICATION_TYPE.DUE_DATE}20`,
borderColor: `${NOTIFICATION_TYPE.DUE_DATE}40`,
textColor: NOTIFICATION_TYPE.DUE_DATE,
}
} else {
return {
bgColor: NOTIFICATION_TYPE.POSTDUE,
lightBg: `${NOTIFICATION_TYPE.POSTDUE}20`,
borderColor: `${NOTIFICATION_TYPE.POSTDUE}40`,
textColor: NOTIFICATION_TYPE.POSTDUE,
}
}
}
const colors = getNotificationColors(n.value)
return (
<Box
key={idx}
sx={{
mb: 1.5,
p: 2,
borderRadius: 8,
border: '1px solid',
borderColor: 'neutral.outlinedBorder',
background: 'background.surface',
transition: 'all 0.2s ease',
display: 'flex',
alignItems: 'center',
gap: 1.5,
}}
>
<Box
className='notification-icon'
sx={{
display: 'inline-flex',
alignItems: 'center',
justifyContent: 'center',
width: 32,
height: 32,
borderRadius: 6,
background: `${colors.bgColor}15`,
color: colors.textColor,
position: 'relative',
flexShrink: 0,
'& svg': {
fontSize: 16,
},
}}
>
<Badge
badgeContent={badgeNumber}
size={'sm'}
sx={{
'--Badge-minHeight': '16px',
'--Badge-fontSize': '0.7rem',
'--Badge-paddingX': '5px',
position: 'absolute',
top: -6,
right: -6,
'& .MuiBadge-badge': {
background: colors.bgColor,
color: 'white',
},
}}
/>
<NotificationsIcon />
</Box>
<Box
sx={{
minWidth: 0,
flex: '1',
display: { xs: 'none', md: 'flex' }, // Show only on md and up
}}
>
<Typography
level='body-sm'
sx={{
fontWeight: 600,
color: 'text.primary',
fontSize: 14,
}}
>
{getRelativeLabel(n)}
</Typography>
</Box>
<Box
sx={{
display: 'flex',
alignItems: 'center',
gap: 1,
flexShrink: 0,
}}
>
<Select
value={uiRep.timing}
onChange={(_, value) => handleChange(idx, 'timing', value)}
sx={{ minWidth: 80 }}
size={'sm'}
>
{timingOptions.map(opt => (
<Option key={opt.value} value={opt.value}>
{opt.label}
</Option>
))}
</Select>
<Input
type={'number'}
min={0}
value={uiRep.displayValue}
disabled={uiRep.timing === 'ondue'}
onChange={e =>
handleChange(idx, 'displayValue', e.target.value)
}
sx={{
width: 60,
opacity: uiRep.timing === 'ondue' ? 0.6 : 1,
}}
size={'sm'}
placeholder='0'
/>
<Select
value={n.unit}
disabled={uiRep.timing === 'ondue'}
onChange={(_, value) => handleChange(idx, 'unit', value)}
sx={{
minWidth: 70,
opacity: uiRep.timing === 'ondue' ? 0.6 : 1,
}}
size={'sm'}
>
{timeUnits.map(opt => (
<Option key={opt.value} value={opt.value}>
{opt.label}
</Option>
))}
</Select>
<IconButton
onClick={() => removeNotification(idx)}
disabled={notifications.length === 1}
color={'danger'}
size={'sm'}
variant={'soft'}
sx={{
transition: 'all 0.2s ease',
'&:hover': {
transform: 'scale(1.1)',
},
}}
>
<DeleteIcon fontSize={'small'} />
</IconButton>
</Box>
</Box>
)
})}
<Box
sx={{
display: 'flex',
gap: 1.5,
mt: 1,
mb: 2,
flexWrap: 'wrap',
}}
>
<Button
onClick={() => addSmartNotification('reminder')}
disabled={notifications.length >= maxNotifications}
startDecorator={<AddIcon />}
size={'sm'}
variant={'outlined'}
sx={{
borderRadius: 6,
fontWeight: 500,
borderColor: `${TASK_COLOR.SCHEDULED}60`,
color: TASK_COLOR.SCHEDULED,
'&:hover': {
borderColor: TASK_COLOR.SCHEDULED,
background: `${TASK_COLOR.SCHEDULED}10`,
},
}}
>
Reminder
</Button>
<Button
onClick={() => addSmartNotification('due')}
disabled={
notifications.length >= maxNotifications ||
notifications.some(n => Number(n.value) === 0)
}
startDecorator={<AddIcon />}
size={'sm'}
variant={'outlined'}
sx={{
borderRadius: 6,
fontWeight: 500,
borderColor: `${NOTIFICATION_TYPE.DUE_DATE}60`,
color: NOTIFICATION_TYPE.DUE_DATE,
'&:hover': {
borderColor: NOTIFICATION_TYPE.DUE_DATE,
background: `${NOTIFICATION_TYPE.DUE_DATE}10`,
},
}}
>
Due Alert
</Button>
<Button
onClick={() => addSmartNotification('followup')}
disabled={notifications.length >= maxNotifications}
startDecorator={<AddIcon />}
size={'sm'}
variant={'outlined'}
sx={{
borderRadius: 6,
fontWeight: 500,
borderColor: `${NOTIFICATION_TYPE.POSTDUE}60`,
color: NOTIFICATION_TYPE.POSTDUE,
'&:hover': {
borderColor: NOTIFICATION_TYPE.POSTDUE,
background: `${NOTIFICATION_TYPE.POSTDUE}10`,
},
}}
>
Follow-up
</Button>
</Box>
{showSaveDefault && (
<Box
sx={{
mt: 1,
display: 'flex',
justifyContent: 'start',
alignItems: 'center',
gap: 1,
}}
>
<Button
variant='outlined'
size='sm'
color='neutral'
startDecorator={<Save />}
sx={{
borderRadius: 6,
fontWeight: 500,
'&:hover': {
background: 'neutral.softHoverBg',
},
}}
onClick={() => {
localStorage.setItem(
'defaultNotificationTemplate',
JSON.stringify(notifications),
)
setShowSaveDefault(false)
}}
>
Remember for Future Tasks
</Button>
</Box>
)}
{showTimeline && renderTimeline()}
</Box>
)
}
export default NotificationTemplate

View File

@@ -0,0 +1,192 @@
import { Sync, SyncDisabled } from '@mui/icons-material'
import { Box, Card, Chip, FormHelperText, Switch, Typography } from '@mui/joy'
import { useState } from 'react'
import { useSSEContext } from '../hooks/useSSEContext'
import { useUserProfile } from '../queries/UserQueries'
import { isPlusAccount } from '../utils/Helpers'
import SSEConnectionStatus from './SSEConnectionStatus'
const REALTIME_TYPES = {
DISABLED: 'disabled',
SSE: 'sse',
}
const RealTimeSettings = () => {
const { data: userProfile } = useUserProfile()
// SSE context
const sseContext = useSSEContext()
// Get current realtime type from localStorage
const getCurrentRealtimeType = () => {
const sseEnabled = localStorage.getItem('sse_enabled') === 'true'
return sseEnabled ? REALTIME_TYPES.SSE : REALTIME_TYPES.DISABLED
}
const [realtimeType, setRealtimeType] = useState(getCurrentRealtimeType())
const handleRealtimeTypeChange = (event, newValue) => {
if (!isPlusAccount(userProfile)) {
return // Don't allow changes for non-Plus users
}
setRealtimeType(newValue)
// Update localStorage and toggle connections
switch (newValue) {
case REALTIME_TYPES.DISABLED:
localStorage.setItem('sse_enabled', 'false')
sseContext.disconnect()
break
case REALTIME_TYPES.SSE:
localStorage.setItem('sse_enabled', 'true')
sseContext.connect()
break
}
}
const getCurrentContext = () => {
switch (realtimeType) {
case REALTIME_TYPES.SSE:
return sseContext
default:
return {
isConnected: false,
isConnecting: false,
error: null,
getConnectionStatus: () => 'disabled',
}
}
}
const context = getCurrentContext()
const getStatusDescription = () => {
if (!isPlusAccount(userProfile)) {
return 'Real-time updates are not available in the Basic plan. Upgrade to Plus to receive instant notifications when tasks are updated.'
}
if (realtimeType === REALTIME_TYPES.DISABLED) {
return 'Real-time updates are disabled. Enable them to see live changes when you or other circle members complete, skip, or modify tasks.'
}
if (context.isConnected) {
return "Real-time updates are working. You'll see live changes when you or other circle members complete, skip, or modify tasks."
}
if (context.isConnecting) {
return 'Connecting to real-time updates...'
}
if (context.error) {
return `Real-time updates are enabled but not working: ${context.error}`
}
return 'Real-time updates are enabled but not currently connected.'
}
const getConnectionStatusComponent = () => {
switch (realtimeType) {
case REALTIME_TYPES.SSE:
return <SSEConnectionStatus variant='chip' />
default:
return null
}
}
return (
<Card sx={{ mt: 2, p: 3 }}>
<Box sx={{ display: 'flex', alignItems: 'flex-start', gap: 2, mb: 2 }}>
<Switch
checked={realtimeType !== REALTIME_TYPES.DISABLED}
onChange={e => {
handleRealtimeTypeChange(
null,
e.target.checked ? REALTIME_TYPES.SSE : REALTIME_TYPES.DISABLED,
)
}}
color={
realtimeType !== REALTIME_TYPES.DISABLED ? 'success' : 'neutral'
}
disabled={!isPlusAccount(userProfile)}
inputProps={{ 'aria-label': 'Enable Real-time Updates' }}
/>
<Box sx={{ flex: 1 }}>
<Box
sx={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
mb: 0.5,
}}
>
<Typography level='title-md'>
Real-time Updates
{!isPlusAccount(userProfile) && (
<Chip variant='soft' color='warning' sx={{ ml: 1 }}>
Plus Feature
</Chip>
)}
</Typography>
{realtimeType !== REALTIME_TYPES.DISABLED &&
isPlusAccount(userProfile) ? (
<Sync color={context.isConnected ? 'success' : 'disabled'} />
) : (
<SyncDisabled color='disabled' />
)}
</Box>
<Typography level='body-sm' color='neutral'>
Get instant notifications when tasks are updated
</Typography>
</Box>
</Box>
{/* <FormControl orientation='horizontal' sx={{ mb: 2 }}>
<Box sx={{ flex: 1 }}>
<FormLabel>Real-time Connection Type</FormLabel>
<FormHelperText sx={{ mt: 0 }}>
Choose how to receive real-time updates
</FormHelperText>
</Box>
<Select
value={realtimeType}
onChange={handleRealtimeTypeChange}
disabled={!isPlusAccount(userProfile)}
sx={{ minWidth: 140 }}
>
<Option value={REALTIME_TYPES.DISABLED}>Disabled</Option>
<Option value={REALTIME_TYPES.WEBSOCKET}>WebSocket</Option>
<Option value={REALTIME_TYPES.SSE}>SSE</Option>
</Select>
</FormControl> */}
<FormHelperText sx={{ mb: 2 }}>{getStatusDescription()}</FormHelperText>
{realtimeType !== REALTIME_TYPES.DISABLED &&
isPlusAccount(userProfile) && (
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mt: 1 }}>
<Typography level='body-xs' color='neutral'>
Status:
</Typography>
{getConnectionStatusComponent()}
{context.error && (
<Typography level='body-xs' color='danger'>
{context.error}
</Typography>
)}
</Box>
)}
{!isPlusAccount(userProfile) && (
<Typography level='body-sm' color='warning' sx={{ mt: 1 }}>
Real-time updates are not available in the Basic plan. Upgrade to Plus
to receive instant notifications when you or other circle members
complete, skip, or modify tasks.
</Typography>
)}
</Card>
)
}
export default RealTimeSettings

View File

@@ -0,0 +1,106 @@
import { Circle, SignalWifi4Bar, SignalWifiOff } from '@mui/icons-material'
import { Box, Chip, Tooltip, Typography } from '@mui/joy'
import { useSSEContext } from '../hooks/useSSEContext'
const SSEConnectionStatus = ({
variant = 'minimal',
showError = false,
sx = {},
}) => {
const { isConnected, isConnecting, error, getConnectionStatus } =
useSSEContext()
const getStatusColor = () => {
if (isConnected) return 'success'
if (isConnecting) return 'warning'
return 'danger'
}
const getStatusIcon = () => {
if (isConnected) return <SignalWifi4Bar />
if (isConnecting) return <Circle />
return <SignalWifiOff />
}
const getStatusText = () => {
if (isConnected) return 'Connected'
if (isConnecting) return 'Connecting...'
return 'Disconnected'
}
const getTooltipText = () => {
const status = getConnectionStatus()
if (error) return `Real-time updates (SSE): ${status} - ${error}`
if (!isConnected && !isConnecting) {
return `Real-time updates (SSE): ${status} - Join a circle to enable real-time updates`
}
return `Real-time updates (SSE): ${status}`
}
if (variant === 'minimal') {
return (
<Tooltip title={getTooltipText()} size='sm'>
<Box
sx={{
display: 'flex',
alignItems: 'center',
gap: 0.5,
...sx,
}}
>
<Circle
sx={{
fontSize: 8,
color:
getStatusColor() === 'success'
? 'success.main'
: getStatusColor() === 'warning'
? 'warning.main'
: 'danger.main',
}}
/>
{showError && error && (
<Typography level='body-xs' color='danger'>
{error}
</Typography>
)}
</Box>
</Tooltip>
)
}
if (variant === 'chip') {
return (
<Tooltip title={getTooltipText()} size='sm'>
<Chip
color={getStatusColor()}
size='sm'
variant='soft'
startDecorator={getStatusIcon()}
sx={sx}
>
{getStatusText()}
</Chip>
</Tooltip>
)
}
// Full variant
return (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1, ...sx }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
{getStatusIcon()}
<Typography level='body-sm' color={getStatusColor()}>
{getStatusText()}
</Typography>
</Box>
{showError && error && (
<Typography level='body-xs' color='danger'>
{error}
</Typography>
)}
</Box>
)
}
export default SSEConnectionStatus

View File

@@ -0,0 +1,149 @@
import { Sync, SyncDisabled } from '@mui/icons-material'
import {
Box,
Card,
Chip,
FormControl,
FormHelperText,
FormLabel,
Switch,
Typography,
} from '@mui/joy'
import { useSSEContext } from '../hooks/useSSEContext'
import { useUserProfile } from '../queries/UserQueries'
import { isPlusAccount } from '../utils/Helpers'
import SSEConnectionStatus from './SSEConnectionStatus'
const SSESettings = () => {
const { data: userProfile } = useUserProfile()
const {
isConnected,
isConnecting,
error,
getConnectionStatus,
toggleSSEEnabled,
isSSEEnabled,
} = useSSEContext()
const handleToggle = () => {
console.log('=== TOGGLE CLICKED ===')
if (!isPlusAccount(userProfile)) {
console.log('Not a Plus account, returning early')
return // Don't allow toggle for non-Plus users
}
const currentlyEnabled = isSSEEnabled()
console.log('SSE Settings - Toggle clicked:', {
currentlyEnabled,
newState: !currentlyEnabled,
userProfile,
isPlusAccount: isPlusAccount(userProfile),
})
toggleSSEEnabled(!currentlyEnabled)
}
const getStatusDescription = () => {
if (!isPlusAccount(userProfile)) {
return 'Real-time updates (SSE) are not available in the Basic plan. Upgrade to Plus to receive instant notifications when chores are updated.'
}
if (!isSSEEnabled()) {
return 'Real-time updates (SSE) are disabled. Enable to see live changes when you or other circle members complete, skip, or modify chores.'
}
if (isConnected) {
return "Real-time updates (SSE) are working. You'll see live changes when you or other circle members complete, skip, or modify chores."
}
if (isConnecting) {
return 'Connecting to real-time updates (SSE)...'
}
if (error) {
return `Real-time updates (SSE) are enabled but not working: ${error}`
}
return 'Real-time updates (SSE) are enabled but not currently connected.'
}
return (
<Card sx={{ mt: 2, p: 3 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2, mb: 2 }}>
{isSSEEnabled() && isPlusAccount(userProfile) ? (
<Sync color={isConnected ? 'success' : 'disabled'} />
) : (
<SyncDisabled color='disabled' />
)}
<Box sx={{ flex: 1 }}>
<Typography level='title-md'>
Real-time Updates (SSE)
{!isPlusAccount(userProfile) && (
<Chip variant='soft' color='warning' sx={{ ml: 1 }}>
Plus Feature
</Chip>
)}
</Typography>
<Typography level='body-sm' color='neutral'>
Get instant notifications via Server-Sent Events
</Typography>
</Box>
{isSSEEnabled() && isPlusAccount(userProfile) && (
<SSEConnectionStatus variant='chip' />
)}
</Box>
<FormControl orientation='horizontal' sx={{ mb: 2 }}>
<Box sx={{ flex: 1 }}>
<FormLabel>Enable Real-time Updates (SSE)</FormLabel>
<FormHelperText sx={{ mt: 0 }}>
{getStatusDescription()}
</FormHelperText>
</Box>
<Switch
checked={isSSEEnabled() && isPlusAccount(userProfile)}
onChange={handleToggle}
disabled={!isPlusAccount(userProfile)}
color={
isSSEEnabled() && isPlusAccount(userProfile) ? 'success' : 'neutral'
}
variant='solid'
endDecorator={
isSSEEnabled() && isPlusAccount(userProfile) ? 'On' : 'Off'
}
slotProps={{ endDecorator: { sx: { minWidth: 24 } } }}
/>
</FormControl>
{isSSEEnabled() && isPlusAccount(userProfile) && (
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mt: 1 }}>
<Typography level='body-xs' color='neutral'>
Status:
</Typography>
<Chip
size='sm'
variant='soft'
color={
isConnected ? 'success' : isConnecting ? 'warning' : 'danger'
}
>
{getConnectionStatus()}
</Chip>
{error && (
<Typography level='body-xs' color='danger'>
{error}
</Typography>
)}
</Box>
)}
{!isPlusAccount(userProfile) && (
<Typography level='body-sm' color='warning' sx={{ mt: 1 }}>
Real-time updates (SSE) are not available in the Basic plan. Upgrade
to Plus to receive instant notifications when you or other circle
members complete, skip, or modify chores.
</Typography>
)}
</Card>
)
}
export default SSESettings

View File

@@ -0,0 +1,259 @@
import { Check, Star } from '@mui/icons-material'
import {
Box,
Button,
Card,
Chip,
Divider,
Modal,
ModalDialog,
Radio,
Typography,
} from '@mui/joy'
import { useState } from 'react'
import { useNotification } from '../service/NotificationProvider'
import { GetSubscriptionSession } from '../utils/Fetcher'
const SubscriptionModal = ({ open, onClose }) => {
const [selectedPlan, setSelectedPlan] = useState('yearly')
const [isLoading, setIsLoading] = useState(false)
const { showError } = useNotification()
const plans = {
yearly: {
price: '$39.00',
period: 'year',
total: '$39.00/year',
// savings: 'Save $20.88',
// popular: true,
},
// monthly: {
// price: '$4.99',
// period: 'month',
// total: '$4.99/month',
// savings: null,
// },
}
const features = [
'Task notifications and reminders',
'Rich text descriptions with images uploads',
'Thing-based task triggers',
'API tokens for integrations',
'Image uploads in descriptions',
'Advanced task automation',
// 'Unlimited task history',
// 'Unlimited things history',
]
const handleSubscribe = async () => {
setIsLoading(true)
try {
// Call the backend with the selected plan
const response = await GetSubscriptionSession()
if (!response.ok) {
throw new Error('Failed to create subscription session')
}
const data = await response.json()
// Redirect to Stripe
if (data.sessionURL) {
window.location.href = data.sessionURL
} else {
throw new Error('No session URL received')
}
} catch (error) {
console.error('Subscription error:', error)
showError({
title: 'Subscription Error',
message: 'Failed to start subscription process. Please try again.',
})
} finally {
setIsLoading(false)
}
}
return (
<Modal open={open} onClose={onClose}>
<ModalDialog
layout='center'
sx={{
width: 600,
maxWidth: '95vw',
maxHeight: '95vh',
overflow: 'auto',
p: 0,
}}
>
<Box sx={{ p: 4 }}>
{/* Header */}
<Box sx={{ textAlign: 'center', mb: 4 }}>
<Typography level='h3' sx={{ mb: 1 }}>
Upgrade to Plus
</Typography>
</Box>
{/* Features List */}
<Box sx={{ mb: 2 }}>
<Typography level='title-lg' sx={{ mb: 2 }}>
What&apos;s included:
</Typography>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1.5 }}>
{features.map((feature, index) => (
<Box
key={index}
sx={{ display: 'flex', alignItems: 'center', gap: 2 }}
>
<Check color='success' sx={{ fontSize: 20 }} />
<Typography level='body-md'>{feature}</Typography>
</Box>
))}
</Box>
</Box>
<Divider sx={{ my: 3 }} />
{/* Plan Selection */}
<Box
sx={{ display: 'flex', flexDirection: 'column', gap: 1.2, mb: 4 }}
>
{Object.entries(plans).map(([key, plan]) => (
<Card
key={key}
color={selectedPlan === key ? 'primary' : 'neutral'}
onClick={() => setSelectedPlan(key)}
sx={{
width: '100%',
minHeight: 48,
maxHeight: 64,
cursor: 'pointer',
transition: 'all 0.2s',
mb: 0.2,
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
px: 2.5,
py: 1.2,
position: 'relative',
overflow: 'visible',
}}
>
<Box
sx={{
display: 'flex',
alignItems: 'center',
gap: 2,
justifyContent: 'flex-start',
width: '100%',
}}
>
<Radio
checked={selectedPlan === key}
onChange={() => setSelectedPlan(key)}
value={key}
name='subscription-plan'
color='primary'
sx={{ mr: 1 }}
/>
<Typography level='body-md' sx={{ fontWeight: 600 }}>
{key.charAt(0).toUpperCase() + key.slice(1)}
</Typography>
<Typography level='body-sm' sx={{ fontWeight: 500, ml: 1 }}>
{plan.price}
<span style={{ color: '#888', fontWeight: 400 }}>
{' '}
/ {plan.period}
</span>
</Typography>
</Box>
<Box
sx={{
display: 'flex',
alignItems: 'center',
gap: 0.5,
position: 'absolute',
right: 16,
top: -18,
}}
>
{plan.popular && (
<Chip
variant='solid'
color='warning'
size='sm'
startDecorator={<Star />}
sx={{
fontWeight: 600,
fontSize: 12,
px: 1,
py: 0.1,
boxShadow: 2,
mt: 0.8,
}}
>
Most Popular
</Chip>
)}
{plan.savings && (
<Chip
variant='soft'
color='success'
size='sm'
sx={{
fontWeight: 600,
fontSize: 12,
px: 1,
py: 0.1,
boxShadow: 2,
mt: 0.8,
}}
>
{plan.savings}
</Chip>
)}
</Box>
</Card>
))}
</Box>
{/* Action Buttons */}
<Box
sx={{ display: 'flex', flexDirection: 'column', gap: 1.2, mt: 2 }}
>
<Button
variant='solid'
color='primary'
onClick={handleSubscribe}
loading={isLoading}
fullWidth
size='lg'
sx={{ mb: 1 }}
>
Subscribe
</Button>
<Button
variant='plain'
onClick={onClose}
disabled={isLoading}
fullWidth
>
Cancel
</Button>
</Box>
{/* Footer */}
<Typography
level='body-xs'
color='neutral'
sx={{ textAlign: 'center', mt: 3 }}
>
Cancel anytime. No hidden fees. Secure payment powered by Stripe.
</Typography>
</Box>
</ModalDialog>
</Modal>
)
}
export default SubscriptionModal

View File

@@ -0,0 +1,539 @@
import {
AdminPanelSettings,
DarkModeOutlined,
GroupAdd,
LightModeOutlined,
Logout,
Person,
Settings,
SwapHoriz,
Tune,
WorkspacePremium,
} from '@mui/icons-material'
import {
Avatar,
Box,
Divider,
Dropdown,
ListItemContent,
ListItemDecorator,
Menu,
MenuButton,
MenuItem,
Sheet,
Typography,
useColorScheme,
} from '@mui/joy'
import { useMediaQuery } from '@mui/material'
import moment from 'moment'
import { useState } from 'react'
import { useNavigate } from 'react-router-dom'
import { useImpersonateUser } from '../contexts/ImpersonateUserContext'
import useStickyState from '../hooks/useStickyState'
import { useCircleMembers, useUserProfile } from '../queries/UserQueries'
import { apiClient } from '../utils/ApiClient'
import { isPlusAccount } from '../utils/Helpers'
import UserModal from '../views/Modals/Inputs/UserModal'
import SubscriptionModal from './SubscriptionModal'
const UserProfileAvatar = () => {
const navigate = useNavigate()
const { mode, setMode } = useColorScheme()
const { data: userProfile } = useUserProfile()
const {
isImpersonating,
startImpersonation,
stopImpersonation,
canImpersonate,
getEffectiveUser,
} = useImpersonateUser()
const { data: circleMembersData } = useCircleMembers()
const [isModalOpen, setIsModalOpen] = useState(false)
const [isSubscriptionModalOpen, setIsSubscriptionModalOpen] = useState(false)
const [themeMode, setThemeMode] = useStickyState(mode, 'themeMode')
const isLargeScreen = useMediaQuery(theme => theme.breakpoints.up('lg'))
if (!userProfile) return null
const currentUser = getEffectiveUser(userProfile)
const isAdmin = canImpersonate(userProfile, circleMembersData?.res)
const isPlusUser = isPlusAccount(userProfile)
const getSubscriptionStatus = () => {
if (!userProfile) return 'Free'
if (userProfile.subscription === 'active') {
return 'Plus'
}
if (
userProfile.subscription === 'cancelled' &&
moment().isBefore(userProfile.expiration)
) {
return 'Plus (expires soon)'
}
return 'Free'
}
const handleLogout = () => {
apiClient.handleLogout()
}
const handleSupportEmail = () => {
window.location.href = 'mailto:support@donetick.com'
}
const isDarkMode = themeMode === 'dark'
const handleThemeToggle = () => {
const newThemeMode = isDarkMode ? 'light' : 'dark'
setThemeMode(newThemeMode)
setMode(newThemeMode)
}
return (
<>
<Dropdown>
<MenuButton
variant='plain'
sx={{
p: 0,
border: 'none',
backgroundColor: 'transparent',
borderRadius: '50%',
'&:hover': {
backgroundColor: 'var(--joy-palette-neutral-softHoverBg)',
// transform: 'scale(1.05)',
// transition: 'all 0.2s ease',
},
'&:active': {
// transform: 'scale(0.95)',
},
}}
>
<Box sx={{ position: 'relative' }}>
{isImpersonating ? (
<Box sx={{ position: 'relative' }}>
<Avatar
src={currentUser?.image || currentUser?.avatar}
alt={currentUser?.displayName || currentUser?.name}
size='md'
sx={{
width: 36,
height: 36,
border: '2px solid var(--joy-palette-background-surface)',
boxShadow: '0 2px 8px rgba(0,0,0,0.1)',
}}
/>
<Avatar
src={userProfile?.image || userProfile?.avatar}
alt={userProfile?.displayName || userProfile?.name}
size='sm'
sx={{
position: 'absolute',
bottom: -2,
left: -2,
width: 18,
height: 18,
border: '2px solid var(--joy-palette-background-surface)',
backgroundColor: 'var(--joy-palette-background-surface)',
boxShadow: '0 1px 4px rgba(0,0,0,0.2)',
}}
/>
<Box
sx={{
position: 'absolute',
top: -2,
right: -2,
width: 14,
height: 14,
borderRadius: '50%',
backgroundColor: 'var(--joy-palette-primary-500)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
border: '1px solid var(--joy-palette-background-surface)',
boxShadow: '0 1px 2px rgba(0,0,0,0.2)',
}}
>
<SwapHoriz sx={{ fontSize: 8, color: 'white' }} />
</Box>
</Box>
) : (
<Avatar
src={currentUser?.image || currentUser?.avatar}
alt={currentUser?.displayName || currentUser?.name}
size='md'
sx={{
width: 36,
height: 36,
border: '2px solid var(--joy-palette-background-surface)',
boxShadow: '0 2px 8px rgba(0,0,0,0.1)',
}}
/>
)}
</Box>
</MenuButton>
<Menu
placement='bottom-end'
sx={{
minWidth: 280,
p: 1,
'--List-gap': '4px',
boxShadow: 'var(--joy-shadow-lg)',
border: '1px solid var(--joy-palette-divider)',
borderRadius: 'var(--joy-radius-md)',
}}
>
<Sheet sx={{ p: 2, borderRadius: 'var(--joy-radius-sm)', mb: 1 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2 }}>
<Avatar
src={currentUser?.image || currentUser?.avatar}
alt={currentUser?.displayName || currentUser?.name}
size='lg'
sx={{
width: 48,
height: 48,
border: '2px solid var(--joy-palette-background-surface)',
}}
/>
<Box sx={{ flex: 1, minWidth: 0 }}>
<Typography
level='title-md'
sx={{
fontWeight: 600,
color: 'var(--joy-palette-text-primary)',
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
mb: 0.25,
}}
>
{currentUser?.displayName || currentUser?.name}
</Typography>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
<Typography
level='body-sm'
sx={{
color: 'var(--joy-palette-text-tertiary)',
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
flex: 1,
}}
>
{currentUser?.email}
</Typography>
{isPlusUser && (
<Typography
level='body-xs'
sx={{
color: 'var(--joy-palette-warning-600)',
fontWeight: 500,
fontSize: '11px',
}}
>
{getSubscriptionStatus()}
</Typography>
)}
</Box>
{isImpersonating && (
<Box
sx={{
display: 'flex',
alignItems: 'center',
gap: 0.5,
mt: 0.5,
px: 1,
py: 0.25,
backgroundColor: 'var(--joy-palette-primary-softBg)',
borderRadius: 'var(--joy-radius-sm)',
width: 'fit-content',
}}
>
<SwapHoriz
sx={{
fontSize: 12,
color: 'var(--joy-palette-primary-600)',
}}
/>
<Typography
level='body-xs'
sx={{
color: 'var(--joy-palette-primary-600)',
fontWeight: 500,
}}
>
Impersonating
</Typography>
</Box>
)}
</Box>
</Box>
</Sheet>
{isAdmin && (
<>
<MenuItem
onClick={() => setIsModalOpen(true)}
sx={{
borderRadius: 'var(--joy-radius-sm)',
'&:hover': {
backgroundColor: 'var(--joy-palette-neutral-softHoverBg)',
},
}}
>
<ListItemDecorator
sx={{ color: 'var(--joy-palette-primary-500)' }}
>
<AdminPanelSettings />
</ListItemDecorator>
<ListItemContent>
<Typography level='body-sm' sx={{ fontWeight: 500 }}>
{isImpersonating ? 'Switch User' : 'Impersonate User'}
</Typography>
<Typography
level='body-xs'
sx={{ color: 'var(--joy-palette-text-tertiary)' }}
>
Act as another user
</Typography>
</ListItemContent>
</MenuItem>
{isImpersonating && (
<MenuItem
onClick={() => stopImpersonation()}
sx={{
borderRadius: 'var(--joy-radius-sm)',
'&:hover': {
backgroundColor: 'var(--joy-palette-neutral-softHoverBg)',
},
}}
>
<ListItemDecorator
sx={{ color: 'var(--joy-palette-success-500)' }}
>
<Person />
</ListItemDecorator>
<ListItemContent>
<Typography level='body-sm' sx={{ fontWeight: 500 }}>
Stop Impersonating
</Typography>
<Typography
level='body-xs'
sx={{ color: 'var(--joy-palette-text-tertiary)' }}
>
Return to your account
</Typography>
</ListItemContent>
</MenuItem>
)}
<Divider sx={{ my: 1 }} />
</>
)}
<MenuItem
onClick={() => navigate('/settings')}
sx={{
borderRadius: 'var(--joy-radius-sm)',
'&:hover': {
backgroundColor: 'var(--joy-palette-neutral-softHoverBg)',
},
}}
>
<ListItemDecorator sx={{ color: 'var(--joy-palette-neutral-500)' }}>
<Settings />
</ListItemDecorator>
<ListItemContent>
<Typography level='body-sm' sx={{ fontWeight: 500 }}>
Settings
</Typography>
<Typography
level='body-xs'
sx={{ color: 'var(--joy-palette-text-tertiary)' }}
>
Account & preferences
</Typography>
</ListItemContent>
</MenuItem>
<MenuItem
onClick={() => navigate('/settings/circle')}
sx={{
borderRadius: 'var(--joy-radius-sm)',
'&:hover': {
backgroundColor: 'var(--joy-palette-neutral-softHoverBg)',
},
}}
>
<ListItemDecorator sx={{ color: 'var(--joy-palette-primary-500)' }}>
<GroupAdd />
</ListItemDecorator>
<ListItemContent>
<Typography level='body-sm' sx={{ fontWeight: 500 }}>
Invite People
</Typography>
<Typography
level='body-xs'
sx={{ color: 'var(--joy-palette-text-tertiary)' }}
>
Add members to your circle
</Typography>
</ListItemContent>
</MenuItem>
{isLargeScreen && (
<MenuItem
onClick={() => navigate('/settings/detailed#sidepanel')}
sx={{
borderRadius: 'var(--joy-radius-sm)',
'&:hover': {
backgroundColor: 'var(--joy-palette-neutral-softHoverBg)',
},
}}
>
<ListItemDecorator
sx={{ color: 'var(--joy-palette-neutral-500)' }}
>
<Tune />
</ListItemDecorator>
<ListItemContent>
<Typography level='body-sm' sx={{ fontWeight: 500 }}>
Side Panel Settings
</Typography>
<Typography
level='body-xs'
sx={{ color: 'var(--joy-palette-text-tertiary)' }}
>
Customize layout & cards
</Typography>
</ListItemContent>
</MenuItem>
)}
<MenuItem
onClick={handleThemeToggle}
sx={{
borderRadius: 'var(--joy-radius-sm)',
'&:hover': {
backgroundColor: 'var(--joy-palette-neutral-softHoverBg)',
},
}}
>
<ListItemDecorator sx={{ color: 'var(--joy-palette-neutral-500)' }}>
{isDarkMode ? <LightModeOutlined /> : <DarkModeOutlined />}
</ListItemDecorator>
<ListItemContent>
<Typography level='body-sm' sx={{ fontWeight: 500 }}>
{isDarkMode ? 'Switch to Light' : 'Switch to Dark'}
</Typography>
<Typography
level='body-xs'
sx={{ color: 'var(--joy-palette-text-tertiary)' }}
>
Toggle theme appearance
</Typography>
</ListItemContent>
</MenuItem>
{!isPlusUser && (
<MenuItem
onClick={() => setIsSubscriptionModalOpen(true)}
sx={{
borderRadius: 'var(--joy-radius-sm)',
'&:hover': {
backgroundColor: 'var(--joy-palette-warning-softHoverBg)',
},
}}
>
<ListItemDecorator
sx={{ color: 'var(--joy-palette-warning-500)' }}
>
<WorkspacePremium />
</ListItemDecorator>
<ListItemContent>
<Typography
level='body-sm'
sx={{
fontWeight: 500,
}}
>
Upgrade to Plus
</Typography>
<Typography level='body-xs'>Unlock premium features</Typography>
</ListItemContent>
</MenuItem>
)}
{/* <MenuItem
onClick={handleSupportEmail}
sx={{
borderRadius: 'var(--joy-radius-sm)',
'&:hover': {
backgroundColor: 'var(--joy-palette-neutral-softHoverBg)',
},
}}
>
<ListItemDecorator sx={{ color: 'var(--joy-palette-info-500)' }}>
<Email />
</ListItemDecorator>
<ListItemContent>
<Typography level='body-sm' sx={{ fontWeight: 500 }}>
Support
</Typography>
<Typography
level='body-xs'
sx={{ color: 'var(--joy-palette-text-tertiary)' }}
>
support@donetick.com
</Typography>
</ListItemContent>
</MenuItem> */}
<Divider sx={{ my: 1 }} />
<MenuItem
onClick={handleLogout}
sx={{
borderRadius: 'var(--joy-radius-sm)',
'&:hover': {
backgroundColor: 'var(--joy-palette-danger-softHoverBg)',
},
}}
>
<ListItemDecorator sx={{ color: 'var(--joy-palette-danger-500)' }}>
<Logout />
</ListItemDecorator>
<ListItemContent>
<Typography
level='body-sm'
sx={{ fontWeight: 500, color: 'var(--joy-palette-danger-500)' }}
>
Logout
</Typography>
</ListItemContent>
</MenuItem>
</Menu>
</Dropdown>
<UserModal
isOpen={isModalOpen}
performers={circleMembersData?.res}
onSelect={user => {
startImpersonation(user, userProfile)
setIsModalOpen(false)
}}
onClose={() => setIsModalOpen(false)}
/>
<SubscriptionModal
open={isSubscriptionModalOpen}
onClose={() => setIsSubscriptionModalOpen(false)}
/>
</>
)
}
export default UserProfileAvatar

View File

@@ -0,0 +1,88 @@
import React from 'react'
import { Box } from '@mui/joy'
import { CSSTransition, TransitionGroup } from 'react-transition-group'
import { useStaggeredAnimation, useReducedMotion } from '../../hooks/useAnimations'
import './PageTransition.css'
const AnimatedList = ({
children,
staggerDelay = 50,
animationType = 'stagger', // 'stagger', 'fade', 'slide'
direction = 'up', // 'up', 'down', 'left', 'right'
renderItem,
keyExtractor,
items,
...boxProps
}) => {
// Handle both children and items patterns
let childrenArray
if (items && renderItem) {
childrenArray = items.map((item, index) =>
React.cloneElement(renderItem(item, index), {
key: keyExtractor ? keyExtractor(item, index) : index
})
)
} else {
childrenArray = React.Children.toArray(children)
}
const visibleItems = useStaggeredAnimation(childrenArray.length, staggerDelay)
const prefersReducedMotion = useReducedMotion()
// If user prefers reduced motion, render without animations
if (prefersReducedMotion) {
return (
<Box {...boxProps}>
{items && renderItem ? childrenArray : children}
</Box>
)
}
const getAnimationClass = () => {
switch (animationType) {
case 'fade':
return 'fade'
case 'slide':
return direction === 'up' ? 'slide-up' : 'page'
case 'stagger':
default:
return 'stagger'
}
}
return (
<Box {...boxProps}>
<TransitionGroup component={null}>
{childrenArray.map((child, index) => {
const isVisible = visibleItems.has(index)
return (
<CSSTransition
key={child.key || index}
in={isVisible}
timeout={{
enter: 300,
exit: 200,
}}
classNames={getAnimationClass()}
unmountOnExit={false}
>
<Box
sx={{
opacity: isVisible ? 1 : 0,
transform: isVisible ? 'none' : 'translateY(20px)',
transition: 'opacity 0.3s ease, transform 0.3s ease',
transitionDelay: `${index * staggerDelay}ms`,
}}
>
{child}
</Box>
</CSSTransition>
)
})}
</TransitionGroup>
</Box>
)
}
export default AnimatedList

View File

@@ -0,0 +1,105 @@
import { Box, CircularProgress, Typography } from '@mui/joy'
import { styled } from '@mui/joy/styles'
// Styled components with keyframe animations
const LoadingContainer = styled(Box)({
position: 'fixed',
top: 0,
left: 0,
right: 0,
bottom: 0,
backgroundColor: 'rgba(255, 255, 255, 0.95)',
backdropFilter: 'blur(8px)',
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
zIndex: 9999,
animation: 'fadeIn 0.3s ease-out',
'@keyframes fadeIn': {
from: {
opacity: 0,
},
to: {
opacity: 1,
},
},
})
const LoadingContent = styled(Box)({
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
gap: '24px',
animation: 'slideUp 0.5s ease-out 0.2s both',
'@keyframes slideUp': {
from: {
opacity: 0,
transform: 'translateY(20px)',
},
to: {
opacity: 1,
transform: 'translateY(0)',
},
},
})
const PulsingText = styled(Typography)({
animation: 'pulse 2s ease-in-out infinite',
'@keyframes pulse': {
'0%, 100%': {
opacity: 1,
},
'50%': {
opacity: 0.5,
},
},
})
const LogoContainer = styled(Box)({
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
marginBottom: '24px',
})
const LoadingScreen = ({
message = 'Loading...',
showLogo = true,
size = 'lg',
}) => {
return (
<LoadingContainer>
<LoadingContent>
{showLogo && (
<LogoContainer>
<Typography
level='h1'
sx={{
fontSize: { xs: '2rem', sm: '2.5rem' },
fontWeight: 700,
color: 'primary.500',
mb: 1,
}}
>
Done
<span style={{ color: '#06b6d4' }}>tick</span>
</Typography>
</LogoContainer>
)}
<CircularProgress
size={size}
sx={{
'--CircularProgress-size': size === 'lg' ? '60px' : '40px',
mb: 2,
}}
/>
<PulsingText level='body-md'>{message}</PulsingText>
</LoadingContent>
</LoadingContainer>
)
}
export default LoadingScreen

View File

@@ -0,0 +1,248 @@
/* Page Wrapper */
.page-wrapper {
position: relative;
width: 100%;
min-height: 100vh;
}
/* View Transitions API - Native browser page transitions */
/* These run automatically when the browser supports view transitions */
/* Forward navigation (going deeper) */
::view-transition-old(root):only-child {
animation: slide-out-left 250ms cubic-bezier(0.4, 0, 0.2, 1);
}
::view-transition-new(root):only-child {
animation: slide-in-right 250ms cubic-bezier(0.4, 0, 0.2, 1);
}
/* Back navigation (going up) */
html[data-transition='back']::view-transition-old(root):only-child {
animation: slide-out-right 250ms cubic-bezier(0.4, 0, 0.2, 1);
}
html[data-transition='back']::view-transition-new(root):only-child {
animation: slide-in-left 250ms cubic-bezier(0.4, 0, 0.2, 1);
}
/* Fade for auth pages */
html[data-transition='fade']::view-transition-old(root):only-child,
html[data-transition='fade']::view-transition-new(root):only-child {
animation: fade 200ms ease-in-out;
}
/* Animation keyframes */
@keyframes slide-out-left {
from {
transform: translateX(0);
opacity: 1;
}
to {
transform: translateX(-30%);
opacity: 0;
}
}
@keyframes slide-in-right {
from {
transform: translateX(100%);
opacity: 0;
}
to {
transform: translateX(0);
opacity: 1;
}
}
@keyframes slide-out-right {
from {
transform: translateX(0);
opacity: 1;
}
to {
transform: translateX(100%);
opacity: 0;
}
}
@keyframes slide-in-left {
from {
transform: translateX(-30%);
opacity: 0;
}
to {
transform: translateX(0);
opacity: 1;
}
}
@keyframes fade {
0%,
100% {
opacity: 0;
}
50% {
opacity: 1;
}
}
/* Modal/overlay animations (still useful for non-route transitions) */
.modal-enter {
opacity: 0;
transform: scale(0.95) translateY(10px);
}
.modal-enter-active {
opacity: 1;
transform: scale(1) translateY(0);
transition:
opacity 250ms ease-out,
transform 250ms ease-out;
}
.modal-exit {
opacity: 1;
transform: scale(1) translateY(0);
}
.modal-exit-active {
opacity: 0;
transform: scale(0.95) translateY(10px);
transition:
opacity 200ms ease-in,
transform 200ms ease-in;
}
/* Slide up animation for bottom sheets/panels */
.slide-up-enter {
opacity: 0;
transform: translateY(100%);
}
.slide-up-enter-active {
opacity: 1;
transform: translateY(0);
transition:
opacity 300ms ease-out,
transform 300ms ease-out;
}
.slide-up-exit {
opacity: 1;
transform: translateY(0);
}
.slide-up-exit-active {
opacity: 0;
transform: translateY(100%);
transition:
opacity 250ms ease-in,
transform 250ms ease-in;
}
/* Loading spinner animation */
@keyframes spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
.loading-spinner {
animation: spin 1s linear infinite;
}
/* Skeleton loading animations */
@keyframes skeleton-loading {
0% {
background-position: -200px 0;
}
100% {
background-position: calc(200px + 100%) 0;
}
}
.skeleton {
background: linear-gradient(90deg, #f0f0f0 25%, #e0e0e0 50%, #f0f0f0 75%);
background-size: 200px 100%;
animation: skeleton-loading 1.5s infinite;
}
/* Micro-interactions */
.interactive-element {
transition: all 0.2s ease-out;
transform: translateZ(0); /* Enable GPU acceleration */
}
.interactive-element:hover {
transform: translateY(-2px) translateZ(0);
}
.interactive-element:active {
transform: translateY(0) translateZ(0);
transition: all 0.1s ease-out;
}
/* Stagger animation for lists */
.stagger-enter {
opacity: 0;
transform: translateY(20px);
}
.stagger-enter-active {
opacity: 1;
transform: translateY(0);
transition:
opacity 300ms ease-out,
transform 300ms ease-out;
}
/* Add animation delays for staggered effects */
.stagger-enter-active:nth-child(1) {
transition-delay: 0ms;
}
.stagger-enter-active:nth-child(2) {
transition-delay: 50ms;
}
.stagger-enter-active:nth-child(3) {
transition-delay: 100ms;
}
.stagger-enter-active:nth-child(4) {
transition-delay: 150ms;
}
.stagger-enter-active:nth-child(5) {
transition-delay: 200ms;
}
.stagger-enter-active:nth-child(6) {
transition-delay: 250ms;
}
.stagger-enter-active:nth-child(7) {
transition-delay: 300ms;
}
.stagger-enter-active:nth-child(8) {
transition-delay: 350ms;
}
/* Reduced motion for accessibility */
@media (prefers-reduced-motion: reduce) {
/* Disable View Transitions for users who prefer reduced motion */
::view-transition-old(root),
::view-transition-new(root) {
animation: none !important;
}
.modal-enter,
.modal-enter-active,
.modal-exit,
.modal-exit-active,
.interactive-element,
.stagger-enter,
.stagger-enter-active {
transition: none !important;
animation: none !important;
transform: none !important;
}
}

View File

@@ -0,0 +1,130 @@
import { useEffect, useLayoutEffect, useRef, useState } from 'react'
import { flushSync } from 'react-dom'
import { useLocation } from 'react-router-dom'
import './PageTransition.css'
// Route hierarchy for determining navigation direction
const routeHierarchy = {
'/': 0,
'/my/chores': 0,
'/chores': 1,
'/chores/create': 2,
'/settings': 1,
'/things': 1,
'/activities': 1,
'/points': 1,
'/labels': 1,
'/projects': 1,
'/login': 0,
'/signup': 1,
'/landing': 0,
'/archived': 1,
}
const getRouteLevel = pathname => {
// Check for exact matches first
if (routeHierarchy[pathname] !== undefined) {
return routeHierarchy[pathname]
}
// Check for dynamic routes (e.g., /chores/123/edit)
if (pathname.includes('/chores/') && pathname.includes('/edit')) {
return 3
}
if (pathname.includes('/chores/') && pathname.includes('/history')) {
return 3
}
if (pathname.includes('/chores/') && pathname.includes('/timer')) {
return 3
}
if (
pathname.includes('/chores/') &&
!pathname.includes('/edit') &&
!pathname.includes('/history') &&
!pathname.includes('/timer')
) {
return 2
}
if (pathname.includes('/things/')) {
return 2
}
if (pathname.includes('/settings/')) {
return 2
}
// Default level
return 1
}
const PageTransition = ({ children }) => {
const location = useLocation()
const prevLevel = useRef(0)
const [displayLocation, setDisplayLocation] = useState(location)
const isFirstRender = useRef(true)
useLayoutEffect(() => {
// Skip transition on first render
if (isFirstRender.current) {
isFirstRender.current = false
setDisplayLocation(location)
prevLevel.current = getRouteLevel(location.pathname)
return
}
// Don't transition if location hasn't actually changed
if (location.pathname === displayLocation.pathname) {
return
}
const currentLevel = getRouteLevel(location.pathname)
const previousLevel = prevLevel.current
// Determine navigation direction
const isBack = currentLevel < previousLevel
const isFade =
location.pathname.includes('/login') ||
location.pathname.includes('/signup') ||
location.pathname.includes('/landing') ||
location.pathname.includes('/auth/')
// Apply transition type as data attribute for CSS
document.documentElement.dataset.transition = isFade
? 'fade'
: isBack
? 'back'
: 'forward'
// Trigger View Transition if supported
if (document.startViewTransition) {
document.startViewTransition(() => {
// flushSync forces React to update the DOM synchronously
// This ensures the View Transition captures the actual DOM change
flushSync(() => {
setDisplayLocation(location)
})
// Scroll to top after DOM update
window.scrollTo({ top: 0, left: 0, behavior: 'instant' })
})
} else {
// Fallback for browsers without View Transitions API
setDisplayLocation(location)
window.scrollTo({ top: 0, left: 0, behavior: 'instant' })
}
// Update refs
prevLevel.current = currentLevel
}, [location, displayLocation.pathname])
return (
<div
className='page-wrapper'
style={{
paddingBottom: `var(--safe-area-inset-bottom, 0px)`,
}}
>
{displayLocation.pathname === location.pathname ? children : null}
</div>
)
}
export default PageTransition

View File

@@ -0,0 +1,138 @@
import { Box, Skeleton } from '@mui/joy'
const SkeletonLoader = ({
type = 'card',
count = 1,
height = 100,
width = '100%',
variant = 'rectangular',
...props
}) => {
const renderSkeleton = () => {
switch (type) {
case 'card':
return (
<Box
sx={{
p: 2,
border: '1px solid',
borderColor: 'divider',
borderRadius: 'md',
}}
>
<Skeleton variant='text' height={24} width='60%' sx={{ mb: 1 }} />
<Skeleton
variant='text'
height={16}
width='100%'
sx={{ mb: 0.5 }}
/>
<Skeleton variant='text' height={16} width='80%' sx={{ mb: 2 }} />
<Box sx={{ display: 'flex', gap: 1 }}>
<Skeleton variant='circular' width={32} height={32} />
<Skeleton variant='rectangular' height={32} width={80} />
</Box>
</Box>
)
case 'list':
return (
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2, p: 1.5 }}>
<Skeleton variant='circular' width={40} height={40} />
<Box sx={{ flex: 1 }}>
<Skeleton
variant='text'
height={20}
width='70%'
sx={{ mb: 0.5 }}
/>
<Skeleton variant='text' height={16} width='50%' />
</Box>
<Skeleton variant='rectangular' width={60} height={24} />
</Box>
)
case 'chore':
return (
<Box
sx={{
p: 2,
border: '1px solid',
borderColor: 'divider',
borderRadius: 'md',
}}
>
<Box
sx={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'flex-start',
mb: 1,
}}
>
<Skeleton variant='text' height={24} width='50%' />
<Skeleton variant='circular' width={24} height={24} />
</Box>
<Skeleton variant='text' height={16} width='80%' sx={{ mb: 1 }} />
<Box sx={{ display: 'flex', gap: 1, mb: 2 }}>
<Skeleton variant='rectangular' height={20} width={60} />
<Skeleton variant='rectangular' height={20} width={40} />
</Box>
<Box
sx={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
}}
>
<Skeleton variant='circular' width={32} height={32} />
<Skeleton variant='text' height={16} width='30%' />
</Box>
</Box>
)
case 'profile':
return (
<Box
sx={{
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
p: 3,
}}
>
<Skeleton
variant='circular'
width={80}
height={80}
sx={{ mb: 2 }}
/>
<Skeleton variant='text' height={24} width={150} sx={{ mb: 1 }} />
<Skeleton variant='text' height={16} width={100} />
</Box>
)
default:
return (
<Skeleton
variant={variant}
height={height}
width={width}
{...props}
/>
)
}
}
return (
<>
{Array.from({ length: count }, (_, index) => (
<Box key={index} sx={{ mb: type === 'list' ? 0 : 2 }}>
{renderSkeleton()}
</Box>
))}
</>
)
}
export default SkeletonLoader

View File

@@ -0,0 +1,107 @@
import { Button } from '@mui/joy'
import { styled } from '@mui/joy/styles'
const AnimatedButton = styled(Button)(({ theme }) => ({
transition: 'all 0.2s cubic-bezier(0.4, 0, 0.2, 1)',
transform: 'translateZ(0)', // Enable GPU acceleration
position: 'relative',
overflow: 'hidden',
'&:hover:not(:disabled)': {
transform: 'translateY(-2px) translateZ(0)',
boxShadow: theme.shadow.lg,
},
'&:active:not(:disabled)': {
transform: 'translateY(0) translateZ(0)',
transition: 'all 0.1s cubic-bezier(0.4, 0, 0.2, 1)',
},
'&:focus-visible': {
outline: '2px solid',
outlineColor: theme.palette.primary[500],
outlineOffset: '2px',
},
// Ripple effect
'&::before': {
content: '""',
position: 'absolute',
top: '50%',
left: '50%',
width: '0',
height: '0',
borderRadius: '50%',
background: 'currentColor',
opacity: 0.1,
transform: 'translate(-50%, -50%)',
transition: 'width 0.6s, height 0.6s',
},
'&:active::before': {
width: '300px',
height: '300px',
},
// Loading state
'&[data-loading="true"]': {
pointerEvents: 'none',
position: 'relative',
'& > *': {
opacity: 0.6,
},
'&::after': {
content: '""',
position: 'absolute',
top: '50%',
left: '50%',
width: '16px',
height: '16px',
border: '2px solid currentColor',
borderTop: '2px solid transparent',
borderRadius: '50%',
transform: 'translate(-50%, -50%)',
animation: 'spin 1s linear infinite',
},
},
// Reduced motion support
'@media (prefers-reduced-motion: reduce)': {
transition: 'none',
transform: 'none !important',
'&:hover:not(:disabled)': {
transform: 'none',
},
'&:active:not(:disabled)': {
transform: 'none',
},
'&::before': {
display: 'none',
},
},
}))
const SmoothButton = ({ children, loading = false, onClick, ...props }) => {
const handleClick = event => {
if (loading) return
onClick?.(event)
}
return (
<AnimatedButton
{...props}
onClick={handleClick}
data-loading={loading}
disabled={props.disabled || loading}
>
{children}
</AnimatedButton>
)
}
export default SmoothButton

View File

@@ -0,0 +1,88 @@
import React from 'react'
import { Card } from '@mui/joy'
import { styled } from '@mui/joy/styles'
const AnimatedCard = styled(Card)(({ theme }) => ({
transition: 'all 0.3s cubic-bezier(0.4, 0, 0.2, 1)',
transform: 'translateZ(0)', // Enable GPU acceleration
cursor: 'pointer',
position: 'relative',
'&:hover': {
transform: 'translateY(-4px) translateZ(0)',
boxShadow: theme.shadow.lg,
},
'&:active': {
transform: 'translateY(-2px) translateZ(0)',
transition: 'all 0.1s cubic-bezier(0.4, 0, 0.2, 1)',
},
// Subtle background animation on hover
'&::before': {
content: '""',
position: 'absolute',
top: 0,
left: 0,
right: 0,
bottom: 0,
background: 'linear-gradient(45deg, transparent, rgba(255,255,255,0.1), transparent)',
opacity: 0,
transition: 'opacity 0.3s ease',
pointerEvents: 'none',
borderRadius: 'inherit',
},
'&:hover::before': {
opacity: 1,
},
// Focus states for accessibility
'&:focus-visible': {
outline: '2px solid',
outlineColor: theme.palette.primary[500],
outlineOffset: '2px',
},
// Reduced motion support
'@media (prefers-reduced-motion: reduce)': {
transition: 'none',
transform: 'none !important',
'&:hover': {
transform: 'none',
boxShadow: theme.shadow.md, // Still provide visual feedback
},
'&:active': {
transform: 'none',
},
'&::before': {
display: 'none',
},
},
}))
const SmoothCard = ({
children,
onClick,
animationDisabled = false,
...props
}) => {
if (animationDisabled) {
return (
<Card {...props} onClick={onClick}>
{children}
</Card>
)
}
return (
<AnimatedCard {...props} onClick={onClick}>
{children}
</AnimatedCard>
)
}
export default SmoothCard

View File

@@ -0,0 +1,54 @@
import { Box } from '@mui/joy'
import React, { useEffect, useState } from 'react'
import { CSSTransition, TransitionGroup } from 'react-transition-group'
import './PageTransition.css'
const StaggeredList = ({
children,
staggerDelay = 50,
initialDelay = 0,
animate = true,
}) => {
const [isVisible, setIsVisible] = useState(!animate)
useEffect(() => {
if (animate) {
const timer = setTimeout(() => {
setIsVisible(true)
}, initialDelay)
return () => clearTimeout(timer)
}
}, [animate, initialDelay])
if (!animate) {
return <Box>{children}</Box>
}
const childrenArray = React.Children.toArray(children)
return (
<Box>
<TransitionGroup component={null}>
{isVisible &&
childrenArray.map((child, index) => (
<CSSTransition
key={child.key || index}
classNames='stagger'
timeout={{
enter: 300 + index * staggerDelay,
exit: 200,
}}
style={{
transitionDelay: `${index * staggerDelay}ms`,
}}
>
<Box sx={{ mb: 1 }}>{child}</Box>
</CSSTransition>
))}
</TransitionGroup>
</Box>
)
}
export default StaggeredList

View File

@@ -0,0 +1,9 @@
// Animation Components
export { default as AnimatedList } from './AnimatedList'
export { default as PageTransition } from './PageTransition'
export { default as SmoothButton } from './SmoothButton'
export { default as SmoothCard } from './SmoothCard'
export { default as StaggeredList } from './StaggeredList'
// Animation Styles
import './PageTransition.css'

View File

@@ -0,0 +1,254 @@
import { Close } from '@mui/icons-material'
import { Divider, IconButton, Modal, Sheet, Typography } from '@mui/joy'
import { forwardRef, useEffect, useState } from 'react'
import { Z_INDEX } from '../../constants/zIndex'
const BottomSheetModal = forwardRef(
(
{
open,
onClose,
children,
title,
footer,
height = 'auto',
maxHeight = '90vh',
expandedHeight = '95vh',
backdropBlur = true,
showHandle = true,
showCloseButton = true,
...props
},
ref,
) => {
const [isExpanded, setIsExpanded] = useState(false)
const [isClosing, setIsClosing] = useState(false)
const [internalOpen, setInternalOpen] = useState(open)
// Handle opening
useEffect(() => {
if (open) {
setInternalOpen(true)
setIsClosing(false)
}
}, [open])
// Handle closing with animation
useEffect(() => {
if (!open && internalOpen) {
setIsClosing(true)
// Wait for animation to complete before hiding modal
const timer = setTimeout(() => {
setInternalOpen(false)
setIsClosing(false)
setIsExpanded(false)
}, 250) // Match transition duration
return () => clearTimeout(timer)
}
}, [open, internalOpen])
// Handle toggle expansion
const handleToggleExpansion = () => {
setIsExpanded(prev => !prev)
}
// Close on escape key
useEffect(() => {
const handleEscape = event => {
if (event.key === 'Escape' && internalOpen) {
onClose?.()
}
}
if (internalOpen) {
document.addEventListener('keydown', handleEscape)
// Prevent body scroll when modal is open
// document.body.style.overflow = 'hidden'
} else {
// Restore scroll immediately when modal starts closing
// document.body.style.overflow = 'unset'
}
return () => {
document.removeEventListener('keydown', handleEscape)
document.body.style.overflow = 'unset'
}
}, [internalOpen, onClose])
// Calculate current height
const currentHeight = isExpanded ? expandedHeight : height
// Filter out DOM props that shouldn't be passed to Modal
const {
fullWidth: _fullWidth,
unmountDelay: _unmountDelay,
...modalProps
} = props
return (
<Modal
open={internalOpen}
onClose={onClose}
sx={{
'& .MuiModal-backdrop': {
backdropFilter: backdropBlur ? 'blur(3px)' : 'none',
backgroundColor: 'rgba(0, 0, 0, 0.4)',
},
display: 'flex',
alignItems: 'flex-end',
justifyContent: 'center',
}}
keepMounted
{...modalProps}
>
<Sheet
ref={ref}
sx={{
zIndex: Z_INDEX.MODAL_CONTENT,
minHeight: '20%',
width: '100%',
height: currentHeight,
maxHeight: isExpanded ? expandedHeight : maxHeight,
borderTopLeftRadius: 16,
borderTopRightRadius: 16,
borderBottomLeftRadius: 0,
borderBottomRightRadius: 0,
p: 0,
overflow: 'hidden',
display: 'flex',
flexDirection: 'column',
transition:
'height 0.3s cubic-bezier(0.32, 0.72, 0, 1), max-height 0.3s cubic-bezier(0.32, 0.72, 0, 1), transform 0.3s cubic-bezier(0.32, 0.72, 0, 1)',
transform:
open && !isClosing ? 'translateY(0)' : 'translateY(100%)',
// Handle safe area on mobile devices
paddingBottom: 'env(safe-area-inset-bottom)',
}}
>
{/* Header Section with drag handle, title, and close button */}
<div
style={{
display: 'flex',
flexDirection: 'column',
backgroundColor: 'inherit',
borderTopLeftRadius: 16,
borderTopRightRadius: 16,
position: 'relative',
}}
>
{/* Close button positioned absolutely in top-right */}
{showCloseButton && (
<IconButton
variant='soft'
color='neutral'
size='sm'
onClick={onClose}
sx={{
position: 'absolute',
top: 8,
right: 16,
zIndex: 1,
borderRadius: '50%',
width: 32,
height: 32,
backgroundColor: 'neutral.softBg',
color: 'neutral.softColor',
'&:hover': {
backgroundColor: 'neutral.softHoverBg',
transform: 'scale(1.05)',
},
transition: 'all 0.2s ease',
flexShrink: 0,
}}
>
<Close fontSize='small' />
</IconButton>
)}
{/* Drag Handle */}
{showHandle && (
<div
style={{
display: 'flex',
justifyContent: 'center',
padding: '12px 0 8px 0',
cursor: 'pointer',
userSelect: 'none',
marginBottom: 16,
}}
onClick={handleToggleExpansion}
title={isExpanded ? 'Collapse' : 'Expand'}
>
<div
style={{
width: 30,
height: 4,
borderRadius: 2,
backgroundColor: 'var(--joy-palette-neutral-300)',
transition: 'background-color 0.2s ease',
}}
/>
</div>
)}
{/* Title Row */}
{title && (
<div
style={{
display: 'flex',
alignItems: 'center',
padding: showHandle
? '0 20px 16px 20px'
: '16px 20px 16px 20px',
paddingRight: showCloseButton ? '60px' : '20px', // Add space for close button
minHeight: 24,
}}
>
<Typography
level='title-lg'
sx={{
fontWeight: 600,
flex: 1,
}}
>
{title}
</Typography>
</div>
)}
</div>
{/* Content area */}
<div
style={{
flex: 1,
overflow: 'auto',
padding: '0 20px 20px 20px',
minHeight: 0, // Important for flex child with overflow
}}
>
{children}
</div>
{footer && (
<>
<Divider />
<footer
style={{
flexShrink: 0,
// borderTop: '1px solid var(--joy-palette-divider)',
padding: '16px 20px',
}}
>
{footer}
</footer>
</>
)}
</Sheet>
</Modal>
)
},
)
BottomSheetModal.displayName = 'BottomSheetModal'
export default BottomSheetModal

View File

@@ -0,0 +1,86 @@
import { Modal, ModalDialog, ModalOverflow } from '@mui/joy'
import { Z_INDEX } from '../../constants/zIndex'
/**
* FadeModal component with consistent fade-in/out animations
* Can be used as a drop-in replacement for Joy UI's Modal component
*/
const FadeModal = ({
open,
onClose,
children,
size = 'md',
fullWidth = false,
backdropBlur = true,
...props
}) => {
// Filter out props that shouldn't be passed to Modal
const { unmountDelay: _unmountDelay, ...modalProps } = props
return (
<Modal
open={open}
onClose={onClose}
sx={{
'& .MuiModal-backdrop': {
backdropFilter: backdropBlur ? 'blur(3px)' : 'none',
},
}}
keepMounted
// These transition properties create a smooth fade + slide effect
transition={{
mount: { opacity: 1, transform: 'translateY(0px)' },
unmount: { opacity: 0, transform: 'translateY(20px)' },
duration: 250, // Animation duration in ms
easing: {
enter: 'cubic-bezier(0.34, 1.56, 0.64, 1)', // Slight overshoot for natural feel
exit: 'cubic-bezier(0.4, 0, 0.2, 1)', // Standard ease out
},
}}
{...modalProps}
>
<ModalOverflow>
<ModalDialog
size={size}
sx={{
zIndex: Z_INDEX.MODAL_CONTENT,
minWidth: fullWidth ? '90%' : 'auto',
animation: open
? 'modalFadeIn 0.35s forwards'
: 'modalFadeOut 0.25s forwards',
'@keyframes modalFadeIn': {
from: { opacity: 0, transform: 'translateY(8px)' },
to: { opacity: 1, transform: 'translateY(0)' },
},
'@keyframes modalFadeOut': {
from: { opacity: 1, transform: 'translateY(0)' },
to: { opacity: 0, transform: 'translateY(8px)' },
},
// Add staggered animation for child elements
'& > *': {
opacity: 0,
animation: open
? 'contentFadeIn 0.35s forwards'
: 'contentFadeOut 0.2s forwards',
},
// Stagger child animations
'& > *:nth-of-type(1)': { animationDelay: '0.05s' },
'& > *:nth-of-type(2)': { animationDelay: '0.1s' },
'& > *:nth-of-type(3)': { animationDelay: '0.15s' },
'& > *:nth-of-type(4)': { animationDelay: '0.2s' },
'& > *:nth-of-type(5)': { animationDelay: '0.25s' },
'@keyframes contentFadeIn': {
to: { opacity: 1 },
},
'@keyframes contentFadeOut': {
to: { opacity: 0 },
},
}}
>
{children}
</ModalDialog>
</ModalOverflow>
</Modal>
)
}
export default FadeModal

View File

@@ -0,0 +1,75 @@
import { Chip } from '@mui/joy'
import PropTypes from 'prop-types'
/**
* A component that displays keyboard shortcut hints as small chips
* Only visible on non-mobile devices
* Supports platform-specific shortcuts (Cmd on Mac, Ctrl on Windows) and Shift key
*/
function KeyboardShortcutHint({
shortcut,
show = true,
withCmd = true,
withCtrl, // Legacy prop for backward compatibility
withShift = false,
sx = {},
...props
}) {
if (!show) return null
const isMac = navigator.platform.toUpperCase().indexOf('MAC') >= 0
const modifierKey = isMac ? '⌘' : 'Ctrl'
// Build the shortcut display string
let displayShortcut = ''
// Support both withCmd and withCtrl for backward compatibility
const shouldShowModifier = withCmd || withCtrl
if (shouldShowModifier) {
displayShortcut += modifierKey
}
if (withShift) {
displayShortcut += (displayShortcut ? ' + ' : '') + 'Shift'
}
if (shortcut) {
displayShortcut += (displayShortcut ? ' + ' : '') + shortcut
}
return (
<Chip
size='sm'
variant='outlined'
color='neutral'
sx={{
fontSize: '0.75rem',
maxHeight: '1.5rem',
fontFamily: 'system-ui, -apple-system, sans-serif',
fontWeight: '500',
letterSpacing: '0.025em',
border: '1px solid',
borderColor: 'neutral.300',
// backgroundColor: 'background.surface',
color: 'text.secondary',
borderRadius: '8px',
px: 0.5,
py: 0.125,
boxShadow: '0 1px 2px rgba(0, 0, 0, 0.05)',
display: { xs: 'none', md: 'inline-flex' }, // Hide on mobile
...sx,
}}
{...props}
>
{displayShortcut}
</Chip>
)
}
KeyboardShortcutHint.propTypes = {
shortcut: PropTypes.string.isRequired,
show: PropTypes.bool,
withCmd: PropTypes.bool,
withCtrl: PropTypes.bool, // Legacy prop for backward compatibility
withShift: PropTypes.bool,
sx: PropTypes.object,
}
export default KeyboardShortcutHint

View File

@@ -0,0 +1,11 @@
import { SvgIcon } from '@mui/joy'
const DiscordIcon = props => {
return (
<SvgIcon {...props} viewBox='0 0 24 24'>
<path d='M20.317 4.3698a19.7913 19.7913 0 00-4.8851-1.5152.0741.0741 0 00-.0785.0371c-.211.3753-.4447.8648-.6083 1.2495-1.8447-.2762-3.68-.2762-5.4868 0-.1636-.3933-.4058-.8742-.6177-1.2495a.077.077 0 00-.0785-.037 19.7363 19.7363 0 00-4.8852 1.515.0699.0699 0 00-.0321.0277C.5334 9.0458-.319 13.5799.0992 18.0578a.0824.0824 0 00.0312.0561c2.0528 1.5076 4.0413 2.4228 5.9929 3.0294a.0777.0777 0 00.0842-.0276c.4616-.6304.8731-1.2952 1.226-1.9942a.076.076 0 00-.0416-.1057c-.6528-.2476-1.2743-.5495-1.8722-.8923a.077.077 0 01-.0076-.1277c.1258-.0943.2517-.1923.3718-.2914a.0743.0743 0 01.0776-.0105c3.9278 1.7933 8.18 1.7933 12.0614 0a.0739.0739 0 01.0785.0095c.1202.099.246.1981.3728.2924a.077.077 0 01-.0066.1276 12.2986 12.2986 0 01-1.873.8914.0766.0766 0 00-.0407.1067c.3604.698.7719 1.3628 1.225 1.9932a.076.076 0 00.0842.0286c1.961-.6067 3.9495-1.5219 6.0023-3.0294a.077.077 0 00.0313-.0552c.5004-5.177-.8382-9.6739-3.5485-13.6604a.061.061 0 00-.0312-.0286zM8.02 15.3312c-1.1825 0-2.1569-1.0857-2.1569-2.419 0-1.3332.9555-2.4189 2.157-2.4189 1.2108 0 2.1757 1.0952 2.1568 2.419-.0190 1.3332-.9555 2.4189-2.1569 2.4189zm7.9748 0c-1.1825 0-2.1569-1.0857-2.1569-2.419 0-1.3332.9554-2.4189 2.1569-2.4189 1.2108 0 2.1757 1.0952 2.1568 2.419 0 1.3332-.9555 2.4189-2.1568 2.4189Z' />
</SvgIcon>
)
}
export default DiscordIcon

View File

@@ -0,0 +1,11 @@
import { SvgIcon } from '@mui/joy'
const RedditIcon = props => {
return (
<SvgIcon {...props} viewBox='0 0 24 24'>
<path d='M12 0A12 12 0 0 0 0 12a12 12 0 0 0 12 12 12 12 0 0 0 12-12A12 12 0 0 0 12 0zm5.01 4.744c.688 0 1.25.561 1.25 1.249a1.25 1.25 0 0 1-2.498.056l-2.597-.547-.8 3.747c1.824.07 3.48.632 4.674 1.488.308-.309.73-.491 1.207-.491.968 0 1.754.786 1.754 1.754 0 .716-.435 1.333-1.01 1.614a3.111 3.111 0 0 1 .042.52c0 2.694-3.13 4.87-7.004 4.87-3.874 0-7.004-2.176-7.004-4.87 0-.183.015-.366.043-.534A1.748 1.748 0 0 1 4.028 12c0-.968.786-1.754 1.754-1.754.463 0 .898.196 1.207.49 1.207-.883 2.878-1.43 4.744-1.487l.885-4.182a.342.342 0 0 1 .14-.197.35.35 0 0 1 .238-.042l2.906.617a1.214 1.214 0 0 1 1.108-.701zM9.25 12C8.561 12 8 12.562 8 13.25c0 .687.561 1.248 1.25 1.248.687 0 1.248-.561 1.248-1.249 0-.688-.561-1.249-1.249-1.249zm5.5 0c-.687 0-1.248.561-1.248 1.25 0 .687.561 1.248 1.249 1.248.688 0 1.249-.561 1.249-1.249 0-.687-.562-1.249-1.25-1.249zm-5.466 3.99a.327.327 0 0 0-.231.094.33.33 0 0 0 0 .463c.842.842 2.484.913 2.961.913.477 0 2.105-.056 2.961-.913a.361.361 0 0 0 .029-.463.33.33 0 0 0-.464 0c-.547.533-1.684.73-2.512.73-.828 0-1.979-.196-2.512-.73a.326.326 0 0 0-.232-.095z' />
</SvgIcon>
)
}
export default RedditIcon

36
src/constants/zIndex.js Normal file
View File

@@ -0,0 +1,36 @@
// Z-index constants for consistent layering
// Lower values appear behind higher values
export const Z_INDEX = {
// Base layer (0-99)
BASE: 0,
CARD_OVERLAY: 1,
DROPDOWN_ITEM: 2,
TOOLTIP: 3,
// UI Components (100-999)
SAFE_AREA: 100,
CALENDAR: 110,
SMART_INPUT: 110,
AUTOCOMPLETE: 200,
// Navigation (1000-1999)
NAVBAR: 1000,
DRAWER: 999,
// Modals and Overlays (2000-8999)
MODAL_BACKDROP: 2000,
MODAL_CONTENT: 2001,
MODAL_CLOSE_BUTTON: 2002,
TOAST: 3000,
// Critical System UI (9000-9999)
LOADING_SCREEN: 9000,
ALERTS: 9500,
NETWORK_BANNER: 9600,
// Maximum (10000+) - Reserved for absolute emergencies
EMERGENCY: 10000,
}
export default Z_INDEX

View File

@@ -1,13 +1,21 @@
import { AlertsProvider } from '../service/AlertsProvider'
import { NotificationProvider } from '../service/NotificationProvider'
import QueryContext from './QueryContext'
import RouterContext from './RouterContext'
import ThemeContext from './ThemeContext'
const Contexts = () => {
const contexts = [ThemeContext, QueryContext, RouterContext]
const Contexts = ({ children }) => {
const contexts = [
AlertsProvider,
ThemeContext,
QueryContext,
NotificationProvider,
RouterContext,
]
return contexts.reduceRight((acc, Context) => {
return <Context>{acc}</Context>
}, {})
}, children)
}
export default Contexts

View File

@@ -1,15 +1,150 @@
import { createContext, useContext, useState } from 'react'
import {
createContext,
useCallback,
useContext,
useEffect,
useState,
} from 'react'
const ImpersonateUserContext = createContext()
export const useImpersonateUser = () => useContext(ImpersonateUserContext)
export const ImpersonateUserProvider = ({ children }) => {
const [impersonatedUser, setImpersonatedUser] = useState(null)
const [impersonationState, setImpersonationState] = useState({
isImpersonating: false,
impersonatedUser: null,
originalUser: null,
})
// Start impersonation
const startImpersonation = useCallback((userToImpersonate, currentUser) => {
console.log('Starting impersonation:', { userToImpersonate, currentUser })
const newState = {
isImpersonating: true,
impersonatedUser: userToImpersonate,
originalUser: currentUser,
}
setImpersonationState(newState)
// Store in localStorage for persistence across page refreshes
localStorage.setItem('impersonation', JSON.stringify(newState))
localStorage.setItem('impersonatedUserId', userToImpersonate.userId)
}, [])
// Stop impersonation
const stopImpersonation = useCallback(() => {
console.log('Stopping impersonation')
setImpersonationState({
isImpersonating: false,
impersonatedUser: null,
originalUser: null,
})
// Remove from localStorage
localStorage.removeItem('impersonation')
localStorage.removeItem('impersonatedUserId')
}, [])
// Get effective user (impersonated user if impersonating, otherwise current user)
const getEffectiveUser = useCallback(
currentUser => {
if (
impersonationState.isImpersonating &&
impersonationState.impersonatedUser
) {
return impersonationState.impersonatedUser
}
return currentUser
},
[impersonationState],
)
// Get impersonation headers for API calls
const getImpersonationHeaders = useCallback(() => {
if (
impersonationState.isImpersonating &&
impersonationState.impersonatedUser
) {
return {
'X-Impersonate-User-ID':
impersonationState.impersonatedUser.id.toString(),
}
}
return {}
}, [impersonationState])
// Check if user can impersonate (admin or manager)
// Note: This is a basic check. The component using this should also check circle membership
const canImpersonate = useCallback((user, circleMembers = []) => {
if (!user?.id) return false
// If circleMembers is provided, check role from there
if (circleMembers.length > 0) {
const member = circleMembers.find(m => m.userId === user.id)
return member?.role === 'admin' || member?.role === 'manager'
}
// Fallback to user.role property (if available)
return user?.role === 'admin' || user?.role === 'manager'
}, [])
// Restore impersonation state from localStorage on mount
useEffect(() => {
const storedImpersonation = localStorage.getItem('impersonation')
if (storedImpersonation) {
try {
const parsed = JSON.parse(storedImpersonation)
if (
parsed.isImpersonating &&
parsed.impersonatedUser &&
parsed.originalUser
) {
setImpersonationState(parsed)
}
} catch (error) {
console.error('Failed to restore impersonation state:', error)
localStorage.removeItem('impersonation')
}
}
}, [])
const value = {
// State
...impersonationState,
// Actions
startImpersonation,
stopImpersonation,
getEffectiveUser,
getImpersonationHeaders,
canImpersonate,
// Computed properties
isImpersonating: impersonationState.isImpersonating,
impersonatedUser: impersonationState.impersonatedUser,
originalUser: impersonationState.originalUser,
// Legacy support
setImpersonatedUser: user => {
if (user) {
// If setting a user, assume we're starting impersonation
// Note: This won't have originalUser, so it's for backward compatibility only
setImpersonationState(prev => ({
isImpersonating: true,
impersonatedUser: user,
originalUser: prev.originalUser,
}))
} else {
stopImpersonation()
}
},
}
return (
<ImpersonateUserContext.Provider
value={{ impersonatedUser, setImpersonatedUser }}
>
<ImpersonateUserContext.Provider value={value}>
{children}
</ImpersonateUserContext.Provider>
)

View File

@@ -1,7 +1,16 @@
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
const QueryContext = ({ children }) => {
const queryClient = new QueryClient()
const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 300000, // 5 minutes
gcTime: 600000, // 10 minutes
refetchOnWindowFocus: false,
retry: 1,
},
},
})
return (
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>

View File

@@ -1,8 +1,15 @@
import App from '@/App'
import ChoreEdit from '@/views/ChoreEdit/ChoreEdit'
import ChoresOverview from '@/views/ChoresOverview'
import Error from '@/views/Error'
import AccountSettings from '@/views/Settings/AccountSettings'
import AdvancedSettings from '@/views/Settings/AdvancedSettings'
import ChildUserSettings from '@/views/Settings/ChildUserSettings'
import CircleSettings from '@/views/Settings/CircleSettings'
import DeveloperSettings from '@/views/Settings/DeveloperSettings'
import Settings from '@/views/Settings/Settings'
import SettingsOverview from '@/views/Settings/SettingsOverview'
import SettingsRoutes from '@/views/Settings/SettingsRoutes'
import ThemeSettings from '@/views/Settings/ThemeSettings'
import { Capacitor } from '@capacitor/core'
import { RouterProvider, createBrowserRouter } from 'react-router-dom'
import AuthenticationLoading from '../views/Authorization/Authenticating'
@@ -12,21 +19,30 @@ import LoginView from '../views/Authorization/LoginView'
import SignupView from '../views/Authorization/Signup'
import UpdatePasswordView from '../views/Authorization/UpdatePasswordView'
import ChoreView from '../views/ChoreEdit/ChoreView'
import ArchivedTasks from '../views/Chores/ArchivedTasks'
import MyChores from '../views/Chores/MyChores'
import JoinCircleView from '../views/Circles/JoinCircle'
import NotFound from '../views/components/NotFound'
import ChoreHistory from '../views/History/ChoreHistory'
import LabelView from '../views/Labels/LabelView'
import Landing from '../views/Landing/Landing'
import PaymentCancelledView from '../views/Payments/PaymentFailView'
import PaymentSuccessView from '../views/Payments/PaymentSuccessView'
import PrivacyPolicyView from '../views/PrivacyPolicy/PrivacyPolicyView'
import ProjectView from '../views/Projects/ProjectView'
import APITokenSettings from '../views/Settings/APITokenSettings'
import MFASettings from '../views/Settings/MFASettings'
import NotificationSetting from '../views/Settings/NotificationSetting'
import ProfileSettings from '../views/Settings/ProfileSettings'
import SidepanelSettings from '../views/Settings/SidepanelSettings'
import StorageSettings from '../views/Settings/StorageSettings'
import TermsView from '../views/Terms/TermsView'
import TestView from '../views/TestView/Test'
import ThingsHistory from '../views/Things/ThingsHistory'
import ThingsView from '../views/Things/ThingsView'
import TimerDetails from '../views/Timer/TimerDetails'
import UserActivities from '../views/User/UserActivities'
import UserPoints from '../views/User/UserPoints'
import NotFound from '../views/components/NotFound'
const getMainRoute = () => {
if (
import.meta.env.VITE_IS_LANDING_DEFAULT === 'true' &&
@@ -48,11 +64,73 @@ const Router = createBrowserRouter([
},
{
path: '/settings',
element: <Settings />,
element: <SettingsRoutes />,
children: [
{
index: true,
element: <SettingsOverview />,
},
{
path: 'detailed',
element: <Settings />,
},
{
path: 'profile',
element: <ProfileSettings />,
},
{
path: 'circle',
element: <CircleSettings />,
},
{
path: 'account',
element: <AccountSettings />,
},
{
path: 'subaccounts',
element: <ChildUserSettings />,
},
{
path: 'notifications',
element: <NotificationSetting />,
},
{
path: 'mfa',
element: <MFASettings />,
},
{
path: 'apitokens',
element: <APITokenSettings />,
},
{
path: 'storage',
element: <StorageSettings />,
},
{
path: 'sidepanel',
element: <SidepanelSettings />,
},
{
path: 'theme',
element: <ThemeSettings />,
},
{
path: 'advanced',
element: <AdvancedSettings />,
},
{
path: 'developer',
element: <DeveloperSettings />,
},
],
},
{
path: '/chores',
element: <ChoresOverview />,
element: <MyChores />,
},
{
path: '/archived',
element: <ArchivedTasks />,
},
{
path: '/chores/:choreId/edit',
@@ -70,6 +148,10 @@ const Router = createBrowserRouter([
path: '/chores/:choreId/history',
element: <ChoreHistory />,
},
{
path: '/chores/:choreId/timer',
element: <TimerDetails />,
},
{
path: '/my/chores',
element: <MyChores />,
@@ -94,6 +176,7 @@ const Router = createBrowserRouter([
path: '/signup',
element: <SignupView />,
},
{
path: '/auth/:provider',
element: <AuthenticationLoading />,
@@ -146,6 +229,10 @@ const Router = createBrowserRouter([
path: 'labels/',
element: <LabelView />,
},
{
path: 'projects/',
element: <ProjectView />,
},
{
path: '*',
element: <NotFound />,

View File

@@ -0,0 +1,25 @@
import { createContext, useContext } from 'react'
import { useSSE } from '../hooks/useSSE'
export const SSEContext = createContext({
connectionState: 2, // CLOSED
isConnected: false,
isConnecting: false,
lastEvent: null,
error: null,
connect: () => {},
disconnect: () => {},
getConnectionStatus: () => 'disconnected',
})
export const useSSEContext = () => {
return useContext(SSEContext)
}
export const SSEProvider = ({ children }) => {
const sseState = useSSE()
return <SSEContext.Provider value={sseState}>{children}</SSEContext.Provider>
}
export default SSEProvider

View File

@@ -1,8 +0,0 @@
import { createContext } from 'react'
const UserContext = createContext({
userProfile: null,
setUserProfile: () => {},
})
export { UserContext }

View File

@@ -0,0 +1,39 @@
import { useState } from 'react'
const useAcknowledgmentModal = () => {
const [ackModalConfig, setAckModalConfig] = useState({})
const showAcknowledgment = (
message,
title,
onAcknowledge,
acknowledgeText = 'Got it',
color = 'primary',
) => {
setAckModalConfig({
isOpen: true,
message,
title,
acknowledgeText,
color,
onClose: () => {
if (onAcknowledge) {
onAcknowledge()
}
setAckModalConfig({})
},
})
}
const hideAcknowledgment = () => {
setAckModalConfig({})
}
return {
ackModalConfig,
showAcknowledgment,
hideAcknowledgment,
}
}
export default useAcknowledgmentModal

View File

@@ -0,0 +1,90 @@
import { useEffect, useState } from 'react'
// Hook to detect user's motion preferences
export const useReducedMotion = () => {
const [prefersReducedMotion, setPrefersReducedMotion] = useState(false)
useEffect(() => {
const mediaQuery = window.matchMedia('(prefers-reduced-motion: reduce)')
setPrefersReducedMotion(mediaQuery.matches)
const handleChange = (event) => {
setPrefersReducedMotion(event.matches)
}
mediaQuery.addEventListener('change', handleChange)
return () => mediaQuery.removeEventListener('change', handleChange)
}, [])
return prefersReducedMotion
}
// Hook for staggered animations
export const useStaggeredAnimation = (itemCount, delay = 50) => {
const [visibleItems, setVisibleItems] = useState(new Set())
const prefersReducedMotion = useReducedMotion()
useEffect(() => {
if (prefersReducedMotion) {
// Show all items immediately if reduced motion is preferred
setVisibleItems(new Set(Array.from({ length: itemCount }, (_, i) => i)))
return
}
const timeouts = []
// Stagger the appearance of items
for (let i = 0; i < itemCount; i++) {
const timeout = setTimeout(() => {
setVisibleItems(prev => new Set([...prev, i]))
}, i * delay)
timeouts.push(timeout)
}
return () => {
timeouts.forEach(clearTimeout)
}
}, [itemCount, delay, prefersReducedMotion])
return visibleItems
}
// Hook for intersection observer animations
export const useInViewAnimation = (threshold = 0.1) => {
const [isInView, setIsInView] = useState(false)
const [element, setElement] = useState(null)
useEffect(() => {
if (!element) return
const observer = new IntersectionObserver(
([entry]) => {
setIsInView(entry.isIntersecting)
},
{ threshold }
)
observer.observe(element)
return () => {
observer.unobserve(element)
}
}, [element, threshold])
return [setElement, isInView]
}
// Hook for page transition context
export const usePageTransition = () => {
const [isTransitioning, setIsTransitioning] = useState(false)
const startTransition = () => setIsTransitioning(true)
const endTransition = () => setIsTransitioning(false)
return {
isTransitioning,
startTransition,
endTransition,
}
}

121
src/hooks/useAuth.jsx Normal file
View File

@@ -0,0 +1,121 @@
import { createContext, useContext, useEffect, useState } from 'react'
import { useNavigate } from 'react-router-dom'
import { clearAllTokens, saveTokens } from '../utils/TokenStorage'
import { apiClient } from '../utils/ApiClient'
const AuthContext = createContext(null)
export const useAuth = () => {
const context = useContext(AuthContext)
if (!context) {
throw new Error('useAuth must be used within an AuthProvider')
}
return context
}
export const AuthProvider = ({ children }) => {
const [token, setToken] = useState(() => localStorage.getItem('token'))
const [user, setUser] = useState(null)
const [isLoading, setIsLoading] = useState(true)
const navigate = useNavigate()
const baseURL = apiClient.getApiURL()
const isAuthenticated = !!token
const isTokenExpired = () => {
const expiry = localStorage.getItem('token_expiry')
if (!expiry) return false
return new Date() >= new Date(expiry)
}
const clearAuth = async () => {
setToken(null)
setUser(null)
await clearAllTokens()
}
const login = async credentials => {
setIsLoading(true)
try {
const response = await fetch(`${baseURL}/auth/login`, {
method: 'POST',
credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(credentials),
})
if (!response.ok) {
const error = await response.json()
return { success: false, error: error.message || 'Login failed' }
}
const data = await response.json()
const userToken = data.token || data.access_token
if (userToken) {
setToken(userToken)
// Use centralized token storage
await saveTokens({
accessToken: userToken,
accessTokenExpiry: data.expire || data.access_token_expiry,
refreshToken: data.refresh_token,
refreshTokenExpiry: data.refresh_token_expiry,
})
}
setIsLoading(false)
return { success: true, data }
} catch (error) {
setIsLoading(false)
return { success: false, error: 'Network error' }
}
}
const fetchUser = async () => {
if (!token) return null
try {
const response = await apiClient.get('/users/profile')
if (!response.ok) {
return null
}
const userData = await response.json()
setUser(userData)
return userData
} catch (error) {
console.error('Fetch user error:', error)
return null
}
}
useEffect(() => {
// const initAuth = async () => {
// if (token && !isTokenExpired()) {
// await fetchUser()
// } else if (token && isTokenExpired()) {
// // Token is expired, but don't refresh here
// // Let the first API call handle refresh via ApiClient
// // Just try to fetch user - if it fails, ApiClient will handle refresh
// await fetchUser()
// } else {
// clearAuth()
// navigate('/login')
// }
// setIsLoading(false)
// }
// initAuth()
}, [token, navigate])
const value = {
token,
user,
isLoading,
isAuthenticated,
login,
fetchUser,
}
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>
}

View File

@@ -0,0 +1,41 @@
import { useState } from 'react'
const useConfirmationModal = () => {
const [confirmModalConfig, setConfirmModalConfig] = useState({})
const showConfirmation = (
message,
title,
onConfirm,
confirmText = 'Confirm',
cancelText = 'Cancel',
color = 'primary',
) => {
setConfirmModalConfig({
isOpen: true,
message,
title,
confirmText,
cancelText,
color,
onClose: isConfirmed => {
if (isConfirmed) {
onConfirm()
}
setConfirmModalConfig({})
},
})
}
const hideConfirmation = () => {
setConfirmModalConfig({})
}
return {
confirmModalConfig,
showConfirmation,
hideConfirmation,
}
}
export default useConfirmationModal

View File

@@ -0,0 +1,18 @@
import BottomSheetModal from '../components/common/BottomSheetModal'
import FadeModal from '../components/common/FadeModal'
import useWindowWidth from './useWindowWidth'
/**
* Hook that returns the appropriate modal component based on screen size
* @param {number} breakpoint - Screen width breakpoint to switch between modals (default: 768px)
* @returns {Object} - { Modal: Component, isMobile: boolean }
*/
export const useResponsiveModal = (breakpoint = 768) => {
const windowWidth = useWindowWidth()
const isMobile = windowWidth <= breakpoint
return {
ResponsiveModal: isMobile ? BottomSheetModal : FadeModal,
isMobile,
}
}

750
src/hooks/useSSE.js Normal file
View File

@@ -0,0 +1,750 @@
import { useQueryClient } from '@tanstack/react-query'
import { EventSourcePolyfill } from 'event-source-polyfill'
import { useCallback, useEffect, useRef, useState } from 'react'
import { useUserProfile } from '../queries/UserQueries'
import { useAlerts } from '../service/AlertsProvider'
import { useNotification } from '../service/NotificationProvider'
import { apiClient } from '../utils/ApiClient'
import { useAuth } from './useAuth.jsx'
const SSE_STATES = {
CONNECTING: 0,
OPEN: 1,
CLOSED: 2,
}
const RECONNECT_INTERVALS = [10000, 30000, 360000, 600000, 900000, 6000000] // 10s, 30s, 6m, 10m, 15m , 1h
const MAX_RECONNECT_ATTEMPTS = 10 // Circuit breaker limit
const CIRCUIT_BREAKER_RESET_TIME = 600000 // 10 minutes
export const useSSE = () => {
const { data: userProfile } = useUserProfile()
const { isAuthenticated, token } = useAuth()
const [connectionState, setConnectionState] = useState(SSE_STATES.CLOSED)
const [lastEvent, setLastEvent] = useState(null)
const [error, setError] = useState(null)
const [isCircuitBreakerOpen, setIsCircuitBreakerOpen] = useState(false)
const eventSourceRef = useRef(null)
const reconnectTimeoutRef = useRef(null)
const reconnectAttemptsRef = useRef(0)
const isManuallyClosedRef = useRef(false)
const lastHeartbeatRef = useRef(Date.now())
const heartbeatMonitorRef = useRef(null)
const nextReconnectTimeRef = useRef(null)
// Track if reconnect is already scheduled to prevent duplicates
const isReconnectScheduledRef = useRef(false)
const queryClient = useQueryClient()
const { showError, showNotification } = useNotification()
const { showAlert } = useAlerts()
const getSSEUrl = useCallback(() => {
const authToken = token
if (!authToken || !isAuthenticated) {
console.log(
'SSE: No valid authentication token',
authToken,
isAuthenticated,
)
return null
}
// Get the API URL from apiManager
const apiUrl = apiClient.getApiURL() // e.g., "http://localhost:8080/api/v1"
// Build SSE URL - let backend determine circle from authenticated user
const sseUrl = `${apiUrl}/realtime/sse`
return { url: sseUrl, token }
}, [token, isAuthenticated]) // Fixed: Added missing dependencies
const handleSSEMessage = useCallback(
event => {
try {
const eventData = JSON.parse(event.data)
setLastEvent(eventData)
// Update heartbeat timestamp
if (eventData.type === 'heartbeat') {
lastHeartbeatRef.current = Date.now()
}
console.debug('SSE Message received:', eventData)
// Handle different event types and update React Query cache accordingly
switch (eventData.type) {
case 'chore.created':
showNotification({
type: 'info',
title: 'New Task Created',
message: `${eventData.data.user.displayName} created "${eventData.data.chore.name}"`,
duration: 5000,
})
const newChore = eventData.data.chore
// Update individual chore cache
queryClient.setQueryData(['chore', newChore.id], {
res: newChore,
})
// Update chores list cache
queryClient.setQueryData(['chores', false], oldData => {
if (!oldData || !oldData.res) {
return { res: [newChore] }
}
return { res: [newChore, ...oldData.res] }
})
break
case 'chore.updated':
case 'chore.completed':
case 'chore.status':
case 'chore.skipped': {
console.log('userProfile: ', userProfile, eventData.data.user)
if (eventData?.data?.user?.id !== userProfile?.id) {
showNotification({
type: 'info',
title: `Task ${eventData.type.replace('chore.', '')}`,
message: `${eventData.data.user.displayName} ${eventData.type.replace('chore.', '')} "${eventData.data.chore.name}"`,
duration: 5000,
})
}
const updatedChore = eventData.data.chore
// Update individual chore cache
queryClient.setQueryData(['chore', updatedChore.id], oldData => {
if (!oldData) return { res: updatedChore }
return { res: { ...oldData.res, ...updatedChore } }
})
// If chore update then also refetch chore details:
if (
eventData.type === 'chore.updated' ||
eventData.type === 'chore.status'
) {
queryClient.invalidateQueries(['choreDetails', updatedChore.id])
queryClient.refetchQueries({
queryKey: ['choreDetails', updatedChore.id],
})
}
// Update chores list cache - add debugging
queryClient.setQueryData(['chores', false], oldData => {
if (!oldData) return { res: [updatedChore] }
if (!oldData.res || !Array.isArray(oldData.res)) {
return { res: [updatedChore] }
}
// If it's a one-time chore that's completed, we might need to remove it
if (
eventData.type === 'chore.completed' &&
updatedChore.frequencyType === 'once'
) {
return {
res: oldData.res.filter(
chore => chore.id !== updatedChore.id,
),
}
}
// Otherwise update the existing chore or add if it doesn't exist
const newData = oldData.res.map(chore => {
if (chore.id === updatedChore.id) {
return { ...updatedChore }
}
return chore
})
return { res: newData }
})
break
}
case 'chore.deleted':
// update chores list cache
queryClient.setQueryData(['chores', false], oldData => {
if (!oldData || !oldData.res) return oldData
return {
res: oldData.res.filter(
chore => chore.id !== eventData.data.choreId,
),
}
})
// same logic for archived chores view:
queryClient.setQueryData(['chores', true], oldData => {
if (!oldData || !oldData.res) return oldData
return {
res: oldData.res.filter(
chore => chore.id !== eventData.data.choreId,
),
}
})
break
case 'subtask.updated':
case 'subtask.completed':
queryClient.setQueryData(
['choreDetails', String(eventData.data.choreId)], // this should be string to match the query key type which is param in the url in choreView
oldData => {
if (!oldData) return oldData
console.log('Old choreDetails data:', oldData)
// Update the specific subtask within the chore details
const newChoreData = { ...oldData.res }
newChoreData.subTasks = newChoreData.subTasks.map(subtask => {
if (subtask.id === eventData.data.subtaskId) {
return {
...subtask,
completedAt: eventData.data.completedAt,
completedBy: eventData.data.user.id,
}
}
return subtask
})
return { res: newChoreData }
},
)
break
case 'heartbeat':
// Heartbeat events don't need cache invalidation
console.debug('SSE Heartbeat received at', new Date().toISOString())
break
case 'connection.established':
console.log('SSE connection established')
setError(null)
lastHeartbeatRef.current = Date.now()
showAlert({
type: 'success',
color: 'success',
message: 'You are now receiving real-time as they happen.',
})
break
case 'error':
console.error('SSE error event:', eventData.data)
showError({
title: 'Real-time Error',
message:
eventData.data.message ||
'An error occurred with real-time updates',
})
break
default:
console.log('Unknown SSE event type:', eventData.type)
}
} catch (err) {
console.error('Failed to parse SSE message:', err)
showError({
title: 'Message Error',
message: 'Failed to parse server message',
})
return // Stop processing if JSON parsing fails
}
},
[queryClient, showNotification, showError, userProfile, showAlert],
)
const stopHeartbeatMonitor = useCallback(() => {
if (heartbeatMonitorRef.current) {
clearInterval(heartbeatMonitorRef.current)
heartbeatMonitorRef.current = null
}
}, [])
// Centralized reconnect scheduling function to prevent duplicate scheduling
const scheduleReconnect = useCallback((delay, reason) => {
// Prevent duplicate scheduling
if (isReconnectScheduledRef.current) {
console.log('SSE: Reconnect already scheduled, skipping duplicate')
return
}
if (reconnectTimeoutRef.current) {
clearTimeout(reconnectTimeoutRef.current)
}
console.log(
`SSE: Scheduling reconnect in ${delay}ms (attempt ${reconnectAttemptsRef.current + 1}, reason: ${reason})`,
)
isReconnectScheduledRef.current = true
nextReconnectTimeRef.current = Date.now() + delay
reconnectTimeoutRef.current = setTimeout(() => {
isReconnectScheduledRef.current = false
reconnectAttemptsRef.current++
nextReconnectTimeRef.current = null
// Note: connect will be called by the caller after this returns
// We need to trigger it here
window.dispatchEvent(new CustomEvent('sse-reconnect'))
}, delay)
}, [])
// Create connect function that can be called from anywhere
const connect = useCallback(() => {
// Clear the scheduled flag when actually connecting
isReconnectScheduledRef.current = false
if (isCircuitBreakerOpen) {
console.log('SSE: Circuit breaker is open, preventing connection attempt')
showError({
title: 'Connection Temporarily Disabled',
message:
'Connection blocked due to repeated failures. Please try again later.',
})
return
}
if (reconnectAttemptsRef.current >= MAX_RECONNECT_ATTEMPTS) {
console.error(
'SSE: Maximum reconnection attempts reached, opening circuit breaker',
)
setIsCircuitBreakerOpen(true)
showError({
title: 'Connection Failed',
message:
'Maximum connection attempts reached. SSE disabled for 10 minutes.',
})
// Reset circuit breaker after timeout
setTimeout(() => {
console.log('SSE: Resetting circuit breaker')
setIsCircuitBreakerOpen(false)
reconnectAttemptsRef.current = 0
}, CIRCUIT_BREAKER_RESET_TIME)
return
}
// Prevent race conditions by checking if already connecting or connected
if (eventSourceRef.current?.readyState === SSE_STATES.OPEN) {
console.log('SSE: Already connected')
return // Already connected
}
if (eventSourceRef.current?.readyState === SSE_STATES.CONNECTING) {
console.log('SSE: Connection already in progress')
return // Already connecting
}
const sseConfig = getSSEUrl()
console.log('SSE connect - Config:', sseConfig)
if (!sseConfig) {
console.log('Cannot connect to SSE: missing URL, token, or user profile')
return
}
// Create connection logic inline to avoid circular dependency
try {
console.log('Connecting to SSE:', sseConfig.url)
setConnectionState(SSE_STATES.CONNECTING)
isManuallyClosedRef.current = false
eventSourceRef.current = new EventSourcePolyfill(sseConfig.url, {
headers: {
Authorization: `Bearer ${localStorage.getItem('token')}`,
'Cache-Control': 'no-cache',
Accept: 'text/event-stream',
},
withCredentials: true,
heartbeatTimeout: 120000,
silentTimeoutRetry: true,
})
eventSourceRef.current.onopen = () => {
console.log('SSE connection opened')
setConnectionState(SSE_STATES.OPEN)
setError(null)
reconnectAttemptsRef.current = 0
nextReconnectTimeRef.current = null
isReconnectScheduledRef.current = false
lastHeartbeatRef.current = Date.now()
// Start heartbeat monitor
stopHeartbeatMonitor()
heartbeatMonitorRef.current = setInterval(() => {
const timeSinceLastHeartbeat = Date.now() - lastHeartbeatRef.current
const heartbeatTimeout = 150000 // 2.5 minutes
console.debug(
`SSE: Heartbeat check - ${Math.round(timeSinceLastHeartbeat / 1000)}s since last heartbeat`,
)
if (timeSinceLastHeartbeat > heartbeatTimeout) {
console.warn(
`SSE: No heartbeat received for ${Math.round(timeSinceLastHeartbeat / 1000)}s, connection may be stale. Reconnecting...`,
)
if (!isManuallyClosedRef.current) {
// Clear current heartbeat monitor before reconnecting
stopHeartbeatMonitor()
// Close current connection gracefully
if (eventSourceRef.current) {
eventSourceRef.current.close()
eventSourceRef.current = null
}
setConnectionState(SSE_STATES.CLOSED)
// Calculate delay based on current attempt
const attemptIndex = Math.min(
reconnectAttemptsRef.current,
RECONNECT_INTERVALS.length - 1,
)
const delay = RECONNECT_INTERVALS[attemptIndex]
// Schedule reconnect
if (reconnectTimeoutRef.current) {
clearTimeout(reconnectTimeoutRef.current)
}
console.log(
`SSE: Scheduling heartbeat-triggered reconnect in ${delay}ms (attempt ${reconnectAttemptsRef.current + 1})`,
)
isReconnectScheduledRef.current = true
nextReconnectTimeRef.current = Date.now() + delay
reconnectTimeoutRef.current = setTimeout(() => {
isReconnectScheduledRef.current = false
reconnectAttemptsRef.current++
nextReconnectTimeRef.current = null
connect()
}, delay)
}
}
}, 60000) // Check every minute
}
eventSourceRef.current.onmessage = handleSSEMessage
eventSourceRef.current.onerror = async error => {
console.error('SSE error:', error)
setConnectionState(SSE_STATES.CLOSED)
stopHeartbeatMonitor()
// Close the EventSource to prevent it from retrying on its own
if (eventSourceRef.current) {
eventSourceRef.current.close()
eventSourceRef.current = null
}
if (isManuallyClosedRef.current) {
console.log('SSE: Manually closed, not reconnecting')
return
}
// Check if reconnect is already scheduled
if (isReconnectScheduledRef.current) {
console.log('SSE: Reconnect already scheduled, skipping')
return
}
// Check if this is a 401 unauthorized error
const is401Error =
error.status === 401 ||
error.error?.message?.includes('401') ||
error.error?.message?.includes('Unauthorized')
// Check if this is a timeout error specifically
const isTimeoutError =
error.error?.message?.includes('No activity within') ||
error.error?.message?.includes('timeout')
if (is401Error) {
console.log('SSE 401 error detected, attempting token refresh...')
setError('Authentication expired - refreshing token...')
try {
const refreshResult = await apiClient.refreshToken()
if (refreshResult.success) {
console.log(
'Token refreshed successfully, retrying SSE connection...',
)
setError('Token refreshed - reconnecting...')
if (apiClient.failedQueue && apiClient.failedQueue.length > 0) {
console.log(
`Processing ${apiClient.failedQueue.length} queued requests after SSE token refresh`,
)
apiClient.processQueue(null, refreshResult.token)
}
// Reset reconnect attempts since we have a fresh token
reconnectAttemptsRef.current = 0
// Schedule immediate reconnect with fresh token
if (reconnectTimeoutRef.current) {
clearTimeout(reconnectTimeoutRef.current)
}
isReconnectScheduledRef.current = true
nextReconnectTimeRef.current = Date.now() + 1000
reconnectTimeoutRef.current = setTimeout(() => {
isReconnectScheduledRef.current = false
nextReconnectTimeRef.current = null
connect()
}, 1000)
return
} else if (
refreshResult.error === 'Already refreshing' ||
refreshResult.error === 'Refresh cooldown active'
) {
console.log(
'SSE: Token refresh in progress by another request, waiting...',
)
setError('Token refresh in progress - reconnecting soon...')
reconnectAttemptsRef.current = 0
if (reconnectTimeoutRef.current) {
clearTimeout(reconnectTimeoutRef.current)
}
isReconnectScheduledRef.current = true
nextReconnectTimeRef.current = Date.now() + 1500
reconnectTimeoutRef.current = setTimeout(() => {
isReconnectScheduledRef.current = false
nextReconnectTimeRef.current = null
connect()
}, 1500)
return
} else if (refreshResult.error === 'Refresh token expired') {
console.error('Refresh token expired, user must login again')
setError('Session expired - please log in again')
return
} else {
console.error('Token refresh failed:', refreshResult.error)
setError('Authentication failed - please log in again')
return
}
} catch (refreshError) {
console.error('Token refresh error:', refreshError)
setError('Authentication error - please log in again')
return
}
} else if (isTimeoutError) {
console.log('SSE timeout detected, attempting reconnection...')
setError('Connection timeout - reconnecting...')
} else {
setError('Connection error occurred')
}
// Schedule reconnect for non-401 errors
const attemptIndex = Math.min(
reconnectAttemptsRef.current,
RECONNECT_INTERVALS.length - 1,
)
const delay = RECONNECT_INTERVALS[attemptIndex]
console.log(
`SSE: Scheduling error-triggered reconnect in ${delay}ms (attempt ${reconnectAttemptsRef.current + 1})`,
)
if (reconnectTimeoutRef.current) {
clearTimeout(reconnectTimeoutRef.current)
}
isReconnectScheduledRef.current = true
nextReconnectTimeRef.current = Date.now() + delay
reconnectTimeoutRef.current = setTimeout(() => {
isReconnectScheduledRef.current = false
reconnectAttemptsRef.current++
nextReconnectTimeRef.current = null
connect()
}, delay)
}
} catch (err) {
console.error('Failed to create SSE connection:', err)
showError({
title: 'Connection Error',
message: 'Failed to establish real-time connection. Please try again.',
})
setConnectionState(SSE_STATES.CLOSED)
}
}, [
getSSEUrl,
handleSSEMessage,
stopHeartbeatMonitor,
isCircuitBreakerOpen,
showError,
])
const disconnect = useCallback(() => {
isManuallyClosedRef.current = true
isReconnectScheduledRef.current = false
if (reconnectTimeoutRef.current) {
clearTimeout(reconnectTimeoutRef.current)
reconnectTimeoutRef.current = null
}
nextReconnectTimeRef.current = null
stopHeartbeatMonitor()
if (eventSourceRef.current) {
eventSourceRef.current.close()
eventSourceRef.current = null
}
setConnectionState(SSE_STATES.CLOSED)
}, [stopHeartbeatMonitor])
const toggleSSEEnabled = useCallback(
enabled => {
console.log('SSE toggleSSEEnabled called:', {
enabled,
isTokenValid: isAuthenticated,
})
localStorage.setItem('sse_enabled', enabled.toString())
if (enabled && isAuthenticated) {
console.log('SSE toggleSSEEnabled: Calling connect()')
connect()
} else {
console.log('SSE toggleSSEEnabled: Calling disconnect()')
disconnect()
}
},
[connect, disconnect, isAuthenticated],
)
const isSSEEnabled = useCallback(() => {
return localStorage.getItem('sse_enabled') === 'true'
}, [])
// Auto-connect when SSE is enabled and token is valid
useEffect(() => {
console.log('SSE auto-connect effect triggered')
console.log('Token valid:', isAuthenticated)
const isSSEEnabledSetting = localStorage.getItem('sse_enabled') === 'true'
console.log('SSE enabled in settings:', isSSEEnabledSetting)
if (isAuthenticated && isSSEEnabledSetting) {
console.log('SSE: Conditions met, attempting to connect')
connect()
} else {
console.log('SSE: Conditions not met, disconnecting')
disconnect()
}
return () => {
disconnect()
}
}, [isAuthenticated]) // Fixed: Added isAuthenticated dependency
// Cleanup timeouts on unmount
useEffect(() => {
return () => {
if (reconnectTimeoutRef.current) {
clearTimeout(reconnectTimeoutRef.current)
}
stopHeartbeatMonitor()
}
}, [stopHeartbeatMonitor])
// Update EventSource message handler when handleSSEMessage changes
useEffect(() => {
if (
eventSourceRef.current &&
eventSourceRef.current.readyState === SSE_STATES.OPEN
) {
console.log('SSE: Updating message handler with latest userProfile')
eventSourceRef.current.onmessage = handleSSEMessage
}
}, [handleSSEMessage])
// Handle visibility changes for better performance
useEffect(() => {
const handleVisibilityChange = () => {
if (document.hidden) {
console.log(
'SSE: App backgrounded, maintaining connection but reducing activity',
)
} else {
console.log('SSE: App foregrounded, ensuring connection is active')
const isSSEEnabledSetting =
localStorage.getItem('sse_enabled') === 'true'
// Check actual EventSource state, not React state
const isCurrentlyConnected =
eventSourceRef.current?.readyState === SSE_STATES.OPEN
const isCurrentlyConnecting =
eventSourceRef.current?.readyState === SSE_STATES.CONNECTING
if (
isAuthenticated &&
isSSEEnabledSetting &&
!isCurrentlyConnected &&
!isCurrentlyConnecting &&
!isReconnectScheduledRef.current
) {
console.log('SSE: Reconnecting after visibility change')
connect()
}
}
}
document.addEventListener('visibilitychange', handleVisibilityChange)
return () => {
document.removeEventListener('visibilitychange', handleVisibilityChange)
}
}, [connect, isAuthenticated])
return {
connectionState,
isConnected: connectionState === SSE_STATES.OPEN,
isConnecting: connectionState === SSE_STATES.CONNECTING,
lastEvent,
error,
connect,
disconnect,
toggleSSEEnabled,
isSSEEnabled,
getConnectionStatus: () => {
switch (connectionState) {
case SSE_STATES.CONNECTING:
return 'connecting'
case SSE_STATES.OPEN:
return 'connected'
case SSE_STATES.CLOSED:
default:
return 'disconnected'
}
},
getDebugInfo: () => ({
connectionState,
reconnectAttempts: reconnectAttemptsRef.current,
isCircuitBreakerOpen,
isReconnectScheduled: isReconnectScheduledRef.current,
lastHeartbeat: lastHeartbeatRef.current,
timeSinceLastHeartbeat: Date.now() - lastHeartbeatRef.current,
isManuallyCloseRef: isManuallyClosedRef.current,
nextReconnectTime: nextReconnectTimeRef.current,
timeUntilReconnect: nextReconnectTimeRef.current
? nextReconnectTimeRef.current - Date.now()
: null,
reconnectIntervals: RECONNECT_INTERVALS,
currentReconnectDelay:
reconnectAttemptsRef.current < RECONNECT_INTERVALS.length
? RECONNECT_INTERVALS[reconnectAttemptsRef.current]
: RECONNECT_INTERVALS[RECONNECT_INTERVALS.length - 1],
maxReconnectAttempts: MAX_RECONNECT_ATTEMPTS,
circuitBreakerResetTime: CIRCUIT_BREAKER_RESET_TIME,
heartbeatTimeout: 120000,
heartbeatMonitorInterval: 60000,
heartbeatMonitorTimeout: 150000,
}),
}
}

View File

@@ -0,0 +1,6 @@
import { useContext } from 'react'
import { SSEContext } from '../contexts/SSEContext'
export const useSSEContext = () => {
return useContext(SSEContext)
}

115
src/hooks/useTimer.js Normal file
View File

@@ -0,0 +1,115 @@
import { useCallback, useEffect, useRef, useState } from 'react'
/**
* Custom hook for timer functionality with high-resolution timing
* Fixes timing drift issues by using timestamps instead of interval counting
*/
const useTimer = (onTimeUpdate = () => {}) => {
const [timerState, setTimerState] = useState('stopped') // 'stopped' | 'running' | 'paused'
const [time, setTime] = useState(0) // Current time in seconds
// Refs for timing calculations
const startTimeRef = useRef(null)
const pausedTimeRef = useRef(0)
const intervalRef = useRef(null)
const lastNotifiedTimeRef = useRef(0)
// Update display and notify parent
const updateTime = useCallback(() => {
if (timerState === 'running' && startTimeRef.current) {
const elapsed = Math.floor((Date.now() - startTimeRef.current) / 1000)
const newTime = pausedTimeRef.current + elapsed
setTime(newTime)
// Only call onTimeUpdate when the second changes to avoid excessive calls
if (newTime !== lastNotifiedTimeRef.current) {
lastNotifiedTimeRef.current = newTime
onTimeUpdate(newTime)
}
}
}, [timerState, onTimeUpdate])
// Timer effect with high-frequency updates for smooth display
useEffect(() => {
if (timerState === 'running') {
intervalRef.current = setInterval(updateTime, 200)
} else {
if (intervalRef.current) {
clearInterval(intervalRef.current)
intervalRef.current = null
}
}
return () => {
if (intervalRef.current) {
clearInterval(intervalRef.current)
intervalRef.current = null
}
}
}, [timerState, updateTime])
// Timer control functions
const startTimer = useCallback(() => {
const now = Date.now()
startTimeRef.current = now
pausedTimeRef.current = 0
lastNotifiedTimeRef.current = 0
setTime(0)
setTimerState('running')
onTimeUpdate(0)
}, [onTimeUpdate])
const pauseTimer = useCallback(() => {
if (timerState === 'running' && startTimeRef.current) {
// Calculate and store the elapsed time
const elapsed = Math.floor((Date.now() - startTimeRef.current) / 1000)
pausedTimeRef.current = pausedTimeRef.current + elapsed
setTimerState('paused')
}
}, [timerState])
const resumeTimer = useCallback(() => {
if (timerState === 'paused') {
// Reset start time for resumed session
startTimeRef.current = Date.now()
setTimerState('running')
}
}, [timerState])
const stopTimer = useCallback(() => {
setTimerState('stopped')
setTime(0)
pausedTimeRef.current = 0
startTimeRef.current = null
lastNotifiedTimeRef.current = 0
onTimeUpdate(0)
}, [onTimeUpdate])
const resetTimer = useCallback(() => {
stopTimer()
}, [stopTimer])
// Computed properties
const isRunning = timerState === 'running'
const isPaused = timerState === 'paused'
const isStopped = timerState === 'stopped'
return {
// State
time,
timerState,
isRunning,
isPaused,
isStopped,
// Actions
startTimer,
pauseTimer,
resumeTimer,
stopTimer,
resetTimer,
}
}
export default useTimer

View File

@@ -1,3 +1,51 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
/* Global animations for smooth interactions */
@keyframes spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
/* Smooth scrolling for better UX */
html {
scroll-behavior: smooth;
}
@media (prefers-reduced-motion: reduce) {
html {
scroll-behavior: auto;
}
}
/* Global focus styles for better accessibility */
*:focus-visible {
outline: 2px solid #0ea5e9;
outline-offset: 2px;
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 */
* {
box-sizing: border-box;
}
/* Optimize animations for performance */
.animate-optimized {
will-change: transform, opacity;
}
.animate-optimized.animation-complete {
will-change: auto;
}

View File

@@ -1,10 +1,13 @@
import React from 'react'
import ReactDOM from 'react-dom/client'
import App from './App.jsx'
import Contexts from './contexts/Contexts.jsx'
import './index.css'
ReactDOM.createRoot(document.getElementById('root')).render(
<React.StrictMode>
<Contexts />
<Contexts>
<App />
</Contexts>
</React.StrictMode>,
)

View File

@@ -1,22 +1,38 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { useState } from 'react'
import { networkManager } from '../hooks/NetworkManager'
import { FEATURES, isFeatureEnabled } from '../utils/FeatureToggle'
import {
ApproveChore,
ArchiveChore,
CreateChore,
DeleteChore,
DeleteChoreHistory,
GetChoreByID,
GetChoreDetailById,
GetChoreHistory,
GetChoresHistory,
GetChoresNew,
MarkChoreComplete,
RejectChore,
SaveChore,
SkipChore,
UnArchiveChore,
UpdateChoreHistory,
} from '../utils/Fetcher'
import { localStore } from '../utils/LocalStore'
export const useChores = includeArchive => {
return useQuery({
queryKey: ['chores'],
queryKey: ['chores', includeArchive],
queryFn: async () => {
const onlineChores = await GetChoresNew(includeArchive)
// Only handle offline tasks if experimental offline mode is enabled
if (!isFeatureEnabled(FEATURES.OFFLINE_MODE)) {
return onlineChores
}
const offlineTasks = (await localStore.getFromCache('offlineTasks')) || []
// go throught each and if there is two chores with same id in offline and online, prefer the offline one:
var finalChores = []
@@ -51,41 +67,106 @@ export const useChores = includeArchive => {
},
})
}
export const useDeleteChores = () => {
const queryClient = useQueryClient()
return useMutation({
mutationFn: async choreIds => {
// If offline mode is enabled and we're offline, handle deletion locally
if (!networkManager.isOnline && isFeatureEnabled(FEATURES.OFFLINE_MODE)) {
const offlineTasks =
(await localStore.getFromCache('offlineTasks')) || []
const updatedOfflineTasks = offlineTasks.filter(
task =>
!choreIds.includes(task.id) && !choreIds.includes(task.tempId),
)
await localStore.saveToCache('offlineTasks', updatedOfflineTasks)
// Force the chores query to refetch
queryClient.invalidateQueries(['chores'])
return
}
// If online, proceed with server-side deletion
await Promise.all(
choreIds.map(async id => {
const resp = await DeleteChore(id)
if (!resp || !resp.ok) {
throw new Error(`Failed to delete chore with ID: ${id}`)
}
}),
)
},
onSuccess: () => {
queryClient.invalidateQueries(['chores'])
},
})
}
export const useCreateChore = () => {
const queryClient = useQueryClient()
return useMutation({
mutationFn: CreateChore,
onMutate: async newTask => {
if (!networkManager.isOnline) {
const tempId = crypto.randomUUID() // Generate temp ID
const offlineTasks =
(await localStore.getFromCache('offlineTasks')) || []
const updateOfflineTasks = [
...offlineTasks,
{ ...newTask, id: tempId, tempId }, // Use the tempId for offline tracking
]
await localStore.saveToCache('offlineTasks', updateOfflineTasks) // Save to local storage
// force useChores to refetch:
queryClient.invalidateQueries(['chores'])
// Force the chores query to refetch
queryClient.refetchQueries(['chores'])
// Update the chores query cache immediately
// queryClient.setQueryData(['chores'], oldData => {
// console.log('ATTEMPT TO SAVE OFFLINE TASKS:', updateOfflineTasks)
// if (!oldData)
// return {
// res: [{ ...newTask, id: tempId, tempId }],
// } // If no data, return offline tasks
// return {
// res: [...oldData.res, { ...newTask, id: tempId, tempId }],
// }
// })
return { tempId }
mutationFn: async newTask => {
const resp = await CreateChore(newTask)
if (!resp || !resp.ok) {
throw new Error('Failed to create chore')
}
return { tempId: null }
const createdChore = await resp.json()
if (!createdChore) {
throw new Error('Failed to get created chore data')
}
// Successfully created the chore on the server, return the created chore
// update the local chores cache with the new chore:
queryClient.setQueryData(['chores'], oldData => {
if (!oldData) return { res: [createdChore.res] }
return { res: [...oldData.res, createdChore.res] }
})
return { res: createdChore }
},
// onMutate: async newTask => {
// if (!networkManager.isOnline && isFeatureEnabled(FEATURES.OFFLINE_MODE)) {
// const tempId = crypto.randomUUID() // Generate temp ID
// const offlineTasks =
// (await localStore.getFromCache('offlineTasks')) || []
// const updateOfflineTasks = [
// ...offlineTasks,
// { ...newTask, id: tempId, tempId }, // Use the tempId for offline tracking
// ]
// await localStore.saveToCache('offlineTasks', updateOfflineTasks) // Save to local storage
// // force useChores to refetch:
// queryClient.invalidateQueries(['chores'])
// // Force the chores query to refetch
// queryClient.refetchQueries(['chores'])
// // Update the chores query cache immediately
// // queryClient.setQueryData(['chores'], oldData => {
// // console.log('ATTEMPT TO SAVE OFFLINE TASKS:', updateOfflineTasks)
// // if (!oldData)
// // return {
// // res: [{ ...newTask, id: tempId, tempId }],
// // } // If no data, return offline tasks
// // return {
// // res: [...oldData.res, { ...newTask, id: tempId, tempId }],
// // }
// // })
// return { tempId }
// }
// const tempId = crypto.randomUUID() // Generate temp ID
// // Update the chores query cache immediately
// queryClient.setQueryData(['chores'], oldData => {
// if (!oldData)
// return {
// res: [{ ...newTask, id: tempId, tempId }],
// } // If no data, return offline tasks
// return {
// res: [...oldData.res, { ...newTask, id: tempId, tempId }],
// }
// })
// return { tempId: null }
// },
onSuccess: () => {
// Invalidate the chores query to refresh the data
queryClient.invalidateQueries(['chores'])
},
})
}
@@ -95,7 +176,7 @@ export const useUpdateChore = () => {
return useMutation({
mutationFn: async updatedChore => {
if (!networkManager.isOnline) {
if (!networkManager.isOnline && isFeatureEnabled(FEATURES.OFFLINE_MODE)) {
updatedChore['updatedAt'] = new Date().toISOString()
if (!updatedChore['nextDueDate']) {
updatedChore['nextDueDate'] = updatedChore['dueDate']
@@ -142,15 +223,26 @@ export const useUpdateChore = () => {
throw new Error('Failed to get updated chore data')
}
// Successfully updated the chore on the server, return the updated chore
// update the local chores cache with the updated chore:
queryClient.setQueryData(['chores'], oldData => {
if (!oldData) return { res: [updatedChore] }
return {
res: oldData.res.map(chore =>
chore.id === updatedChore.id ? updatedChore : chore,
),
}
})
return updatedChoreRes?.res || updatedChoreRes
}
},
onSuccess: (data, variables) => {
// Invalidate the chores query to refresh the data
queryClient.invalidateQueries(['chores'])
// Invalidate history for the specific chore
queryClient.invalidateQueries(['choreHistory', variables.id])
},
onMutate: async updatedChore => {
if (!networkManager.isOnline) {
if (!networkManager.isOnline && isFeatureEnabled(FEATURES.OFFLINE_MODE)) {
// Handle offline case here if needed
return
}
@@ -167,6 +259,7 @@ export const useChoresHistory = (initialLimit, includeMembers) => {
const resp = await GetChoresHistory(limit, includeMembers)
return resp?.res || []
},
staleTime: 0,
})
const handleLimitChange = newLimit => {
@@ -178,7 +271,7 @@ export const useChoresHistory = (initialLimit, includeMembers) => {
export const useChoreDetails = choreId => {
return useQuery({
queryKey: ['chore', choreId],
queryKey: ['choreDetails', choreId],
queryFn: async () => {
var onlineChore = null
@@ -192,6 +285,11 @@ export const useChoreDetails = choreId => {
console.error('Error fetching chore detail:', error)
}
// Only check offline tasks if experimental offline mode is enabled
if (!isFeatureEnabled(FEATURES.OFFLINE_MODE)) {
return onlineChore
}
const offlineTasks = (await localStore.getFromCache('offlineTasks')) || []
const offline = offlineTasks.find(task => {
// Match by tempId or id if it was created offline
@@ -224,6 +322,11 @@ export const useChore = choreId => {
console.error('Error fetching chore detail:', error)
}
// Only check offline tasks if experimental offline mode is enabled
if (!isFeatureEnabled(FEATURES.OFFLINE_MODE)) {
return onlineChore
}
const offlineTasks = (await localStore.getFromCache('offlineTasks')) || []
const offline = offlineTasks.find(task => {
return (
@@ -239,3 +342,123 @@ export const useChore = choreId => {
},
})
}
export const useArchiveChore = () => {
const queryClient = useQueryClient()
return useMutation({
mutationFn: ArchiveChore,
onSuccess: () => {
queryClient.invalidateQueries(['chores'])
},
})
}
export const useUnArchiveChore = () => {
const queryClient = useQueryClient()
return useMutation({
mutationFn: UnArchiveChore,
onSuccess: () => {
queryClient.invalidateQueries(['chores'])
},
})
}
export const useChoreHistory = choreId => {
return useQuery({
queryKey: ['choreHistory', choreId],
queryFn: async () => {
if (!choreId) {
throw new Error('Chore ID is required to fetch history')
}
const response = await GetChoreHistory(choreId)
if (response && response.ok) {
return await response.json()
}
throw new Error('Failed to fetch chore history')
},
enabled: !!choreId,
staleTime: 0, // Always consider data stale
cacheTime: 0, // Don't cache the data
refetchOnMount: true, // Always refetch when component mounts
refetchOnWindowFocus: true, // Refetch when window gains focus
})
}
export const useUpdateChoreHistory = () => {
const queryClient = useQueryClient()
return useMutation({
mutationFn: ({ choreId, historyId, historyData }) =>
UpdateChoreHistory(choreId, historyId, historyData),
onSuccess: (data, { choreId }) => {
queryClient.invalidateQueries(['choreHistory', choreId])
},
})
}
export const useDeleteChoreHistory = () => {
const queryClient = useQueryClient()
return useMutation({
mutationFn: ({ choreId, historyId }) =>
DeleteChoreHistory(choreId, historyId),
onSuccess: (data, { choreId }) => {
queryClient.invalidateQueries(['choreHistory', choreId])
},
})
}
export const useMarkChoreComplete = () => {
const queryClient = useQueryClient()
return useMutation({
mutationFn: ({ choreId, body, completedDate, performer }) =>
MarkChoreComplete(choreId, body, completedDate, performer),
onSuccess: (data, { choreId }) => {
queryClient.invalidateQueries(['chores'])
queryClient.invalidateQueries(['choreHistory', choreId])
queryClient.invalidateQueries(['choreDetails', choreId])
},
})
}
export const useSkipChore = () => {
const queryClient = useQueryClient()
return useMutation({
mutationFn: SkipChore,
onSuccess: (data, choreId) => {
queryClient.invalidateQueries(['chores'])
queryClient.invalidateQueries(['choreHistory', choreId])
queryClient.invalidateQueries(['choreDetails', choreId])
},
})
}
export const useApproveChore = () => {
const queryClient = useQueryClient()
return useMutation({
mutationFn: ApproveChore,
onSuccess: (data, choreId) => {
queryClient.invalidateQueries(['chores'])
queryClient.invalidateQueries(['choreHistory', choreId])
queryClient.invalidateQueries(['choreDetails', choreId])
},
})
}
export const useRejectChore = () => {
const queryClient = useQueryClient()
return useMutation({
mutationFn: RejectChore,
onSuccess: (data, choreId) => {
queryClient.invalidateQueries(['chores'])
queryClient.invalidateQueries(['choreHistory', choreId])
queryClient.invalidateQueries(['choreDetails', choreId])
},
})
}

View File

@@ -3,7 +3,7 @@ import { GetResource } from '../utils/Fetcher'
export const useResource = () => {
const { data, isLoading, error } = useQuery({
queryKey: [],
queryKey: ['resource'],
queryFn: async () => {
const response = await GetResource()
return response

View File

@@ -0,0 +1,29 @@
import { useInfiniteQuery } from '@tanstack/react-query'
import { GetThingHistory } from '../utils/Fetcher'
export const useThingHistory = (thingId, limit = 10) => {
return useInfiniteQuery({
queryKey: ['thingHistory', thingId],
queryFn: async ({ pageParam = 0 }) => {
const response = await GetThingHistory(thingId, pageParam)
if (!response.ok) {
throw new Error('Failed to fetch thing history')
}
const data = await response.json()
return data
},
getNextPageParam: (lastPage, allPages) => {
// If the last page has fewer items than the limit, there are no more pages
if (lastPage.res.length < limit) {
return undefined
}
// Calculate the offset for the next page
const totalItems = allPages.reduce(
(acc, page) => acc + page.res.length,
0,
)
return totalItems
},
enabled: !!thingId, // Only run query if thingId exists
})
}

107
src/queries/TimeQueries.jsx Normal file
View File

@@ -0,0 +1,107 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import {
ClearChoreTimer,
DeleteTimeSession,
GetChoreTimer,
PauseChore,
ResetChoreTimer,
StartChore,
UpdateTimeSession,
} from '../utils/Fetcher'
export const useChoreTimer = choreId => {
return useQuery({
queryKey: ['choreTimer', choreId],
queryFn: async () => {
if (!choreId) {
throw new Error('Chore ID is required to fetch timer')
}
const response = await GetChoreTimer(choreId)
if (response && response.ok) {
return await response.json()
}
throw new Error('Failed to fetch chore timer')
},
enabled: !!choreId,
})
}
export const useStartChore = () => {
const queryClient = useQueryClient()
return useMutation({
mutationFn: StartChore,
onSuccess: (data, choreId) => {
queryClient.invalidateQueries(['choreTimer', choreId])
queryClient.invalidateQueries(['chores'])
queryClient.invalidateQueries(['choreHistory', choreId])
},
})
}
export const usePauseChore = () => {
const queryClient = useQueryClient()
return useMutation({
mutationFn: PauseChore,
onSuccess: (data, choreId) => {
queryClient.invalidateQueries(['choreTimer', choreId])
queryClient.invalidateQueries(['chores'])
queryClient.invalidateQueries(['choreHistory', choreId])
},
})
}
export const useUpdateTimeSession = () => {
const queryClient = useQueryClient()
return useMutation({
mutationFn: ({ choreId, sessionId, sessionData }) =>
UpdateTimeSession(choreId, sessionId, sessionData),
onSuccess: (data, { choreId }) => {
queryClient.invalidateQueries(['choreTimer', choreId])
queryClient.invalidateQueries(['chores'])
queryClient.invalidateQueries(['choreHistory', choreId])
},
})
}
export const useDeleteTimeSession = () => {
const queryClient = useQueryClient()
return useMutation({
mutationFn: ({ choreId, sessionId }) =>
DeleteTimeSession(choreId, sessionId),
onSuccess: (data, { choreId }) => {
queryClient.invalidateQueries(['choreTimer', choreId])
queryClient.invalidateQueries(['chores'])
queryClient.invalidateQueries(['choreHistory', choreId])
},
})
}
export const useResetChoreTimer = () => {
const queryClient = useQueryClient()
return useMutation({
mutationFn: ResetChoreTimer,
onSuccess: (data, choreId) => {
queryClient.invalidateQueries(['choreTimer', choreId])
queryClient.invalidateQueries(['chores'])
queryClient.invalidateQueries(['choreHistory', choreId])
},
})
}
export const useClearChoreTimer = () => {
const queryClient = useQueryClient()
return useMutation({
mutationFn: ClearChoreTimer,
onSuccess: (data, choreId) => {
queryClient.invalidateQueries(['choreTimer', choreId])
queryClient.invalidateQueries(['chores'])
queryClient.invalidateQueries(['choreHistory', choreId])
},
})
}

View File

@@ -1,5 +1,22 @@
import { useQuery, useQueryClient } from '@tanstack/react-query'
import { GetAllCircleMembers, GetAllUsers } from '../utils/Fetcher'
import {
GetAllCircleMembers,
GetAllUsers,
GetChildUsers,
GetDeviceTokens,
GetUserProfile,
} from '../utils/Fetcher'
// Helper to check if we have a valid token
const isTokenValid = () => {
const token = localStorage.getItem('token')
if (!token) return false
const expiry = localStorage.getItem('token_expiry')
if (!expiry) return true // No expiry set, assume valid
return new Date() < new Date(expiry)
}
export const useAllUsers = () => {
return useQuery({
@@ -22,3 +39,76 @@ export const useCircleMembers = () => {
return { data, error, isLoading, handleRefetch }
}
export const useUserProfile = () => {
const queryClient = useQueryClient()
const { data, error, isLoading } = useQuery({
queryKey: ['userProfile'],
queryFn: async () => {
const resp = await GetUserProfile()
const result = await resp.json()
// if we got 403 then user probably deleted their account and token is still valid. navigate to login
return result.res || null
},
staleTime: 30 * 60 * 1000, // 30 minutes in milliseconds
gcTime: 30 * 60 * 1000, // 30 minutes in milliseconds
})
return {
data,
error,
isLoading,
refetch: () => queryClient.invalidateQueries(['userProfile']),
}
}
export const useDeviceTokens = () => {
const queryClient = useQueryClient()
const { data, error, isLoading } = useQuery({
queryKey: ['deviceTokens'],
queryFn: async () => {
if (!isTokenValid()) {
return null
}
const resp = await GetDeviceTokens(true) // Only get active devices
const result = await resp.json()
return result.res || []
},
staleTime: 0, // Always fetch fresh data
gcTime: 10 * 60 * 1000, // 10 minutes
})
return {
data,
error,
isLoading,
refetch: () => queryClient.invalidateQueries(['deviceTokens']),
}
}
export const useChildUsers = () => {
const queryClient = useQueryClient()
const { data, error, isLoading } = useQuery({
queryKey: ['childUsers'],
queryFn: async () => {
if (!isTokenValid()) {
return null
}
const resp = await GetChildUsers()
const result = await resp.json()
return result.res || []
},
staleTime: 5 * 60 * 1000, // 5 minutes
gcTime: 10 * 60 * 1000, // 10 minutes
})
return {
data,
error,
isLoading,
refetch: () => queryClient.invalidateQueries(['childUsers']),
}
}

View File

@@ -0,0 +1,87 @@
import { Alert, Box } from '@mui/joy'
import PropTypes from 'prop-types'
import { createContext, useCallback, useContext, useState } from 'react'
import Z_INDEX from '../constants/zIndex'
const FADE_DURATION = 400 // ms
const ALERT_DURATION = 5000 // ms
const AlertsContext = createContext()
// Helper function to create a delay
const delay = ms => new Promise(res => setTimeout(res, ms))
export const AlertsProvider = ({ children }) => {
const [show, setShow] = useState(false)
const [visibleAlert, setVisibleAlert] = useState(null)
const showAlert = useCallback(async alertObj => {
setVisibleAlert(alertObj)
setShow(false)
await delay(10)
setShow(true)
await delay(ALERT_DURATION)
setShow(false)
await delay(FADE_DURATION)
setVisibleAlert(null)
}, [])
const hideAlert = useCallback(() => {
setShow(false)
// Wait for the fade out transition to complete before unmounting
setTimeout(() => {
setVisibleAlert(null)
}, FADE_DURATION)
}, [])
return (
<AlertsContext.Provider value={{ showAlert, hideAlert }}>
{children}
{visibleAlert && (
<Box
sx={{
position: 'fixed',
top: 0,
left: 0,
width: '100%',
zIndex: Z_INDEX.ALERTS,
overflow: 'hidden',
}}
>
<Alert
variant='soft'
color={visibleAlert.color || 'primary'}
startDecorator={visibleAlert.icon}
onClick={hideAlert}
sx={{
transition: `transform ${FADE_DURATION}ms ease-in-out, opacity ${FADE_DURATION}ms ease-in-out`,
transform: show ? 'translateY(0)' : 'translateY(-100%)',
opacity: show ? 1 : 0,
pointerEvents: show ? 'auto' : 'none',
width: '100%',
justifyContent: 'center',
alignItems: 'center',
padding: '4px',
paddingTop: `calc(var(--safe-area-inset-top, 0px))`,
fontSize: '10px',
fontWeight: 'md',
}}
>
{visibleAlert.message}
</Alert>
</Box>
)}
</AlertsContext.Provider>
)
}
AlertsProvider.propTypes = {
children: PropTypes.node.isRequired,
}
export const useAlerts = () => useContext(AlertsContext)

View File

@@ -1,18 +0,0 @@
import React, { createContext, useState } from 'react'
const AuthenticationContext = createContext({})
const AuthenticationProvider = ({ children }) => {
const [isLoggedIn, setIsLoggedIn] = useState(false)
const [userProfile, setUserProfile] = useState({})
return (
<AuthenticationContext.Provider
value={{ isLoggedIn, setIsLoggedIn, userProfile, setUserProfile }}
>
{children}
</AuthenticationContext.Provider>
)
}
export { AuthenticationContext, AuthenticationProvider }
// export default AuthenticationProvider;

View File

@@ -1,51 +0,0 @@
import { Error } from '@mui/icons-material'
import { Box, Button, Snackbar, Typography } from '@mui/joy'
import React, { createContext, useContext, useState } from 'react'
const ErrorContext = createContext()
export const useError = () => useContext(ErrorContext)
export const ErrorProvider = ({ children }) => {
const [error, setError] = useState(null)
const showError = error => {
setError(error)
}
return (
<ErrorContext.Provider value={{ showError }}>
{children}
<Snackbar
open={Boolean(error)}
autoHideDuration={6000}
onClose={() => setError(null)}
startDecorator={<Error color='danger' />}
endDecorator={
<Button
variant='outlined'
color='danger'
onClick={() => setError(null)}
>
Dismiss
</Button>
}
>
{typeof error === 'string' ? (
<Typography color='danger' level='body-md'>
{error}
</Typography>
) : (
<Box>
<Typography color='danger' level='title-sm'>
{error?.title}
</Typography>
<Typography color='danger' level='body-sm'>
{error?.message}
</Typography>
</Box>
)}
</Snackbar>
</ErrorContext.Provider>
)
}

View File

@@ -0,0 +1,269 @@
import { CheckCircle, Error, Info, Undo, Warning } from '@mui/icons-material'
import { Box, Button, Snackbar, Typography } from '@mui/joy'
import React, { createContext, useContext, useState } from 'react'
const NotificationContext = createContext()
export const useNotification = () => useContext(NotificationContext)
// For backward compatibility
export const useError = () => {
const { showError } = useNotification()
return { showError }
}
// Notification types configuration with default titles
const NOTIFICATION_TYPES = {
error: {
color: 'danger',
icon: <Error color='danger' />,
autoHideDuration: 6000,
showDismissButton: true,
defaultTitle: 'Error',
},
success: {
color: 'success',
icon: <CheckCircle color='success' />,
autoHideDuration: 5000,
showDismissButton: false,
defaultTitle: 'Success',
},
undo: {
color: 'success',
icon: <Undo color='success' />,
autoHideDuration: null,
showDismissButton: false,
defaultTitle: 'Undone Successfully',
},
warning: {
color: 'warning',
icon: <Warning color='warning' />,
autoHideDuration: 4000,
showDismissButton: false,
defaultTitle: 'Warning',
},
info: {
color: 'primary',
icon: <Info color='primary' />,
autoHideDuration: 4000,
showDismissButton: false,
defaultTitle: 'Information',
},
custom: {
color: 'neutral',
icon: null,
autoHideDuration: null,
showDismissButton: false,
defaultTitle: 'Notification',
},
}
export const NotificationProvider = ({ children }) => {
const [notifications, setNotifications] = useState([])
const addNotification = notification => {
const id = Date.now() + Math.random()
const newNotification = {
id,
...notification,
timestamp: Date.now(),
}
setNotifications(prev => [...prev, newNotification])
// Auto-remove notification if it has a duration
const config =
NOTIFICATION_TYPES[notification.type] || NOTIFICATION_TYPES.info
if (config.autoHideDuration) {
setTimeout(() => {
removeNotification(id)
}, config.autoHideDuration)
}
return id
}
const removeNotification = id => {
setNotifications(prev => prev.filter(n => n.id !== id))
}
const clearAllNotifications = () => {
setNotifications([])
}
// Helper function to normalize notification input
const normalizeNotification = (input, type) => {
if (typeof input === 'string') {
return {
type,
message: input,
}
}
if (typeof input === 'object' && input !== null) {
// If it's already a properly structured notification
if (input.title || input.message) {
return {
type,
...input,
}
}
// If it's a simple object with just message content
return {
type,
message: input.message || input.toString(),
title: input.title,
...input,
}
}
return {
type,
message: input?.toString() || 'Unknown notification',
}
}
// Unified notification method
const showNotification = notification => {
// Handle different input formats
if (typeof notification === 'string') {
return addNotification(normalizeNotification(notification, 'info'))
}
return addNotification(
normalizeNotification(notification, notification.type || 'info'),
)
}
// Specific notification methods with enhanced language
const showError = error => {
return addNotification(normalizeNotification(error, 'error'))
}
const showUndo = message => {
return addNotification(normalizeNotification(message, 'undo'))
}
const showSuccess = message => {
return addNotification(normalizeNotification(message, 'success'))
}
const showWarning = message => {
return addNotification(normalizeNotification(message, 'warning'))
}
const showInfo = message => {
return addNotification(normalizeNotification(message, 'info'))
}
const renderNotification = notification => {
const config =
NOTIFICATION_TYPES[notification.type] || NOTIFICATION_TYPES.info
// Handle custom notifications with components
if (notification.type === 'custom' && notification.component) {
return (
<Snackbar
key={notification.id}
open={true}
onClose={() => removeNotification(notification.id)}
anchorOrigin={
notification.anchorOrigin || {
vertical: 'bottom',
horizontal: 'right',
}
}
{...(notification.snackbarProps || {})}
>
{React.cloneElement(notification.component, {
onClose: () => removeNotification(notification.id),
...notification.componentProps,
})}
</Snackbar>
)
}
// Handle standard notifications
// Determine the icon to use
const notificationIcon = notification.icon || config.icon
// Determine title and message
const title = notification.title || config.defaultTitle
const message = notification.message
return (
<Snackbar
key={notification.id}
open={true}
autoHideDuration={config.autoHideDuration}
onClose={() => removeNotification(notification.id)}
startDecorator={notificationIcon}
endDecorator={
notification.undoAction ? (
<Button
variant='outlined'
color={config.color}
onClick={() => {
notification.undoAction()
removeNotification(notification.id)
}}
>
Undo
</Button>
) : config.showDismissButton ? (
<Button
variant='outlined'
color={config.color}
onClick={() => removeNotification(notification.id)}
>
Dismiss
</Button>
) : null
}
anchorOrigin={
notification.anchorOrigin || {
vertical: 'bottom',
horizontal: 'right',
}
}
{...(notification.snackbarProps || {})}
>
{/* Enhanced structure like ErrorProvider - always show title and message for consistency */}
{title && message ? (
<Box>
<Typography color={config.color} level='title-sm'>
{title}
</Typography>
<Typography color={config.color} level='body-sm'>
{message}
</Typography>
</Box>
) : (
<Typography color={config.color} level='body-md'>
{message || title || 'Notification'}
</Typography>
)}
</Snackbar>
)
}
return (
<NotificationContext.Provider
value={{
showNotification,
showError,
showSuccess,
showUndo,
showWarning,
showInfo,
removeNotification,
clearAllNotifications,
notifications,
}}
>
{children}
{notifications.map(renderNotification)}
</NotificationContext.Provider>
)
}

270
src/utils/ApiClient.js Normal file
View File

@@ -0,0 +1,270 @@
import { Preferences } from '@capacitor/preferences'
import { API_URL } from '../Config'
import { logout, RefreshToken } from './Fetcher'
import {
clearAllTokens,
isRefreshTokenExpired,
saveTokens,
} from './TokenStorage'
class ApiClient {
constructor() {
this.customServerURL = `${API_URL}/api/v1`
this.isRefreshing = false
this.failedQueue = []
this.lastRefreshTime = 0
this.refreshCooldown = 3 * 1000 // 3 seconds in milliseconds
}
async init() {
if (this.initPromise) {
return this.initPromise
}
if (this.initialized) {
return Promise.resolve()
}
this.initPromise = this._doInit()
return this.initPromise
}
async _doInit() {
const { value: serverURL } = await Preferences.get({
key: 'customServerUrl',
})
this.customServerURL = `${serverURL || API_URL}/api/v1`
this.initialized = true
}
getApiURL() {
return this.customServerURL
}
async refreshToken() {
// Check if refresh token is expired BEFORE attempting refresh
const refreshExpired = await isRefreshTokenExpired()
if (refreshExpired) {
console.log('Refresh token expired, forcing logout')
await clearAllTokens()
if (window.location.pathname !== '/login') {
window.location.href = '/login'
}
return { success: false, error: 'Refresh token expired' }
}
if (this.isRefreshing) {
return { success: false, error: 'Already refreshing' }
}
// Check cooldown
const now = Date.now()
if (now - this.lastRefreshTime < this.refreshCooldown) {
return { success: false, error: 'Refresh cooldown active' }
}
this.isRefreshing = true
try {
const refreshReq = await RefreshToken()
if (refreshReq.ok) {
const data = await refreshReq.json()
const newToken = data.token || data.access_token
// Save all tokens including rotated refresh token
await saveTokens({
accessToken: newToken,
accessTokenExpiry: data.expire || data.access_token_expiry,
refreshToken: data.refresh_token,
refreshTokenExpiry: data.refresh_token_expiry,
})
// Update last refresh time
this.lastRefreshTime = Date.now()
return { success: true, token: newToken }
} else {
return { success: false, error: 'Refresh failed' }
}
} catch (error) {
return { success: false, error: error.message }
} finally {
this.isRefreshing = false
}
}
getToken() {
return localStorage.getItem('token')
}
getHeaders(customHeaders = {}) {
const headers = {
'Content-Type': 'application/json',
...customHeaders,
}
const token = this.getToken()
if (token) {
headers.Authorization = `Bearer ${token}`
}
const impersonateUserId = localStorage.getItem('impersonatedUserId')
if (impersonateUserId) {
headers['X-Impersonate-User-ID'] = impersonateUserId
}
return headers
}
// Process queued requests after refresh attempt
processQueue(error, token = null) {
this.failedQueue.forEach(({ resolve, reject }) => {
if (error) {
reject(error)
} else {
resolve(token)
}
})
this.failedQueue = []
}
// Helper to avoid repeating cleanup code
async handleLogout() {
await clearAllTokens()
try {
await logout()
} catch (e) {
console.error('Error during logout', e)
}
if (window.location.pathname !== '/login') window.location.href = '/login'
// fire and forget
}
async request(endpoint, options = {}) {
await this.init()
const url = `${this.customServerURL}${endpoint}`
const config = {
// credentials: 'include',
...options,
headers: this.getHeaders(options.headers),
}
try {
// 1. Initial Request
let response = await fetch(url, config)
// 2. Check for 401 (Unauthorized)
if (response.status === 401) {
// Always queue this request first
const queuedPromise = new Promise((resolve, reject) => {
this.failedQueue.push({
resolve: async token => {
if (!token) {
reject(new Error('Token refresh failed'))
return
}
try {
const newHeaders = this.getHeaders(options?.headers)
const retryConfig = {
...config,
headers: newHeaders,
}
const retryResponse = await fetch(url, retryConfig)
resolve(retryResponse)
} catch (error) {
reject(error)
}
},
reject,
})
})
// If already refreshing, just return the queued promise
if (this.isRefreshing) {
console.log('Token refresh already in progress, queueing request')
return queuedPromise
}
// Attempt to refresh the token
const refreshResult = await this.refreshToken()
if (refreshResult.success) {
// Process queue with success - this will retry all queued requests
this.processQueue(null, refreshResult.token)
} else if (refreshResult.error === 'Refresh cooldown active') {
// We're in cooldown - token was just refreshed, retry with current token
console.log('Refresh cooldown - retrying with current token')
const currentToken = this.getToken()
if (currentToken) {
this.processQueue(null, currentToken)
} else {
this.processQueue(new Error('No token available'), null)
this.handleLogout()
return null
}
} else if (refreshResult.error === 'Already refreshing') {
// This shouldn't happen since we check isRefreshing above, but handle it anyway
console.log('Already refreshing - waiting for refresh to complete')
return queuedPromise
} else {
// Actual refresh failure - logout
this.processQueue(new Error(refreshResult.error), null)
this.handleLogout()
return null
}
// Return the queued promise for this request
return queuedPromise
}
return response
} catch (error) {
console.error('Request failed', error)
throw error
}
}
async get(endpoint, options = {}) {
return this.request(endpoint, { ...options, method: 'GET' })
}
async post(endpoint, data, options = {}) {
return this.request(endpoint, {
...options,
method: 'POST',
body: data ? JSON.stringify(data) : undefined,
})
}
async put(endpoint, data, options = {}) {
return this.request(endpoint, {
...options,
method: 'PUT',
body: data ? JSON.stringify(data) : undefined,
})
}
async delete(endpoint, options = {}) {
return this.request(endpoint, { ...options, method: 'DELETE' })
}
async upload(endpoint, formData, options = {}) {
const headers = options.headers || {}
delete headers['Content-Type']
return this.request(endpoint, {
...options,
method: 'POST',
body: formData,
headers,
})
}
getAssetURL(path) {
return `${this.customServerURL}/assets/${path}`
}
}
export const apiClient = new ApiClient()

View File

@@ -2,7 +2,27 @@ import moment from 'moment'
import { TASK_COLOR } from './Colors.jsx'
const priorityOrder = [1, 2, 3, 4, 0]
// ChoreGrouperOptions enum:
export const GROUPING_OPTIONS = Object.freeze({
SMART: 'default',
DUE_DATE: 'due_date',
PRIORITY: 'priority',
LABELS: 'labels',
})
export const ChoreHistoryStatus = Object.freeze({
STARTED: 0,
COMPLETED: 1,
SKIPPED: 2,
PENDING_APPROVAL: 3,
REJECTED: 4,
})
export const ChoreStatus = Object.freeze({
INACTIVE: 0,
ACTIVE: 1,
PAUSED: 2,
PENDING_APPROVAL: 3,
})
export const ChoresGrouper = (groupBy, chores, filter) => {
if (filter) {
chores = chores.filter(chore => filter(chore))
@@ -12,12 +32,127 @@ export const ChoresGrouper = (groupBy, chores, filter) => {
chores.sort(ChoreSorter)
var groups = []
switch (groupBy) {
case 'default':
// same as due_date but hide empty groups: and if status is 1 or 2 have seperated catigory as Started:
var groupRaw = {
PendingApproval: [],
Started: [],
Today: [],
Tomorrow: [],
'Next 7 Days': [],
'Later This Month': [],
Future: [],
Overdue: [],
Anytime: [],
}
chores.forEach(chore => {
if (chore.status === 1 || chore.status === 2) {
groupRaw['Started'].push(chore)
} else if (chore.status === 3) {
groupRaw['PendingApproval'].push(chore)
} else if (chore.nextDueDate === null) {
groupRaw['Anytime'].push(chore)
} else if (new Date(chore.nextDueDate) < new Date()) {
groupRaw['Overdue'].push(chore)
} else if (
new Date(chore.nextDueDate).toDateString() ===
new Date().toDateString()
) {
groupRaw['Today'].push(chore)
} else if (
new Date(chore.nextDueDate).toDateString() ===
new Date(Date.now() + 24 * 60 * 60 * 1000).toDateString()
) {
groupRaw['Tomorrow'].push(chore)
} else if (
new Date(chore.nextDueDate) <
new Date(Date.now() + 8 * 24 * 60 * 60 * 1000) &&
new Date(chore.nextDueDate) >
new Date(Date.now() + 24 * 60 * 60 * 1000)
) {
groupRaw['Next 7 Days'].push(chore)
} else if (
new Date(chore.nextDueDate).getMonth() === new Date().getMonth() &&
new Date(chore.nextDueDate).getFullYear() === new Date().getFullYear()
) {
groupRaw['Later This Month'].push(chore)
} else {
groupRaw['Future'].push(chore)
}
})
groups = []
if (groupRaw['Started'].length > 0) {
groups.push({
name: 'Started',
content: groupRaw['Started'],
color: TASK_COLOR.STARTED,
})
}
if (groupRaw['PendingApproval'].length > 0) {
groups.push({
name: 'Pending Approval',
content: groupRaw['PendingApproval'],
color: TASK_COLOR.LATE,
})
}
if (groupRaw['Overdue'].length > 0) {
groups.push({
name: 'Overdue',
content: groupRaw['Overdue'],
color: TASK_COLOR.OVERDUE,
})
}
if (groupRaw['Today'].length > 0) {
groups.push({
name: 'Today',
content: groupRaw['Today'],
color: TASK_COLOR.TODAY,
})
}
if (groupRaw['Tomorrow'].length > 0) {
groups.push({
name: 'Tomorrow',
content: groupRaw['Tomorrow'],
color: TASK_COLOR.TOMORROW,
})
}
if (groupRaw['Next 7 Days'].length > 0) {
groups.push({
name: 'Next 7 Days',
content: groupRaw['Next 7 Days'],
color: TASK_COLOR.NEXT_7_DAYS,
})
}
if (groupRaw['Later This Month'].length > 0) {
groups.push({
name: 'Later This Month',
content: groupRaw['Later This Month'],
color: TASK_COLOR.LATER_THIS_MONTH,
})
}
if (groupRaw['Future'].length > 0) {
groups.push({
name: 'Future',
content: groupRaw['Future'],
color: TASK_COLOR.FUTURE,
})
}
if (groupRaw['Anytime'].length > 0) {
groups.push({
name: 'Anytime',
content: groupRaw['Anytime'],
color: TASK_COLOR.ANYTIME,
})
}
break
case 'due_date':
var groupRaw = {
Today: [],
'In a week': [],
'This month': [],
Later: [],
Tomorrow: [],
'Next 7 Days': [],
'Later This Month': [],
Future: [],
Overdue: [],
Anytime: [],
}
@@ -32,17 +167,24 @@ export const ChoresGrouper = (groupBy, chores, filter) => {
) {
groupRaw['Today'].push(chore)
} else if (
new Date(chore.nextDueDate) <
new Date(Date.now() + 7 * 24 * 60 * 60 * 1000) &&
new Date(chore.nextDueDate) > new Date()
new Date(chore.nextDueDate).toDateString() ===
new Date(Date.now() + 24 * 60 * 60 * 1000).toDateString()
) {
groupRaw['In a week'].push(chore)
groupRaw['Tomorrow'].push(chore)
} else if (
new Date(chore.nextDueDate).getMonth() === new Date().getMonth()
new Date(chore.nextDueDate) <
new Date(Date.now() + 8 * 24 * 60 * 60 * 1000) &&
new Date(chore.nextDueDate) >
new Date(Date.now() + 24 * 60 * 60 * 1000)
) {
groupRaw['This month'].push(chore)
groupRaw['Next 7 Days'].push(chore)
} else if (
new Date(chore.nextDueDate).getMonth() === new Date().getMonth() &&
new Date(chore.nextDueDate).getFullYear() === new Date().getFullYear()
) {
groupRaw['Later This Month'].push(chore)
} else {
groupRaw['Later'].push(chore)
groupRaw['Future'].push(chore)
}
})
groups = [
@@ -53,16 +195,25 @@ export const ChoresGrouper = (groupBy, chores, filter) => {
},
{ name: 'Today', content: groupRaw['Today'], color: TASK_COLOR.TODAY },
{
name: 'In a week',
content: groupRaw['In a week'],
color: TASK_COLOR.IN_A_WEEK,
name: 'Tomorrow',
content: groupRaw['Tomorrow'],
color: TASK_COLOR.TOMORROW,
},
{
name: 'This month',
content: groupRaw['This month'],
color: TASK_COLOR.THIS_MONTH,
name: 'Next 7 Days',
content: groupRaw['Next 7 Days'],
color: TASK_COLOR.NEXT_7_DAYS,
},
{
name: 'Later This Month',
content: groupRaw['Later This Month'],
color: TASK_COLOR.LATER_THIS_MONTH,
},
{
name: 'Future',
content: groupRaw['Future'],
color: TASK_COLOR.FUTURE,
},
{ name: 'Later', content: groupRaw['Later'], color: TASK_COLOR.LATER },
{
name: 'Anytime',
content: groupRaw['Anytime'],
@@ -176,12 +327,43 @@ export const notInCompletionWindow = chore => {
moment().add(chore.completionWindow, 'hours') < moment(chore.nextDueDate)
)
}
export const ChoreFilters = userProfile => ({
export const ChoreFilters = userId => ({
anyone: () => true,
assigned_to_me: chore => {
return chore.assignedTo && chore.assignedTo === userProfile?.id
return chore.assignedTo && chore.assignedTo === userId
},
assigned_to_others: chore => {
return chore.assignedTo && chore.assignedTo !== userProfile?.id
return chore.assignedTo && chore.assignedTo !== userId
},
assigned_to_me_tasks: chore => {
return (
chore.assignees &&
chore.assignees.some(assignee => assignee.userId === userId)
)
},
created_by_me: chore => {
return chore.createdBy && chore.createdBy === userId
},
})
// Project filter function - separate from ChoreFilters since it's independent
export const filterByProject = (chores, selectedProject) => {
if (
!selectedProject ||
selectedProject === 'Default Project' ||
selectedProject === 'default'
) {
// Default project should show tasks without a project (projectId is null/undefined/empty)
// Based on ChoreEdit.jsx, default projects save projectId as null
return chores.filter(
chore =>
!chore.projectId || chore.projectId === null || chore.projectId === '',
)
}
// For custom projects, match by project ID
return chores.filter(chore => {
// Match by project ID (this should be the primary way chores are linked to projects)
return chore.projectId === selectedProject
})
}

View File

@@ -27,7 +27,6 @@ const LABEL_COLORS = [
]
export const COLORS = {
white: '#FFFFFF',
salmon: '#ff7961',
teal: '#26a69a',
skyBlue: '#80d8ff',
@@ -52,6 +51,12 @@ export const COLORS = {
blush: '#f8bbd0',
ash: '#90a4ae',
sand: '#d7ccc8',
white: '#FFFFFF',
}
export const NOTIFICATION_TYPE = {
PREDUE: '#4ec1a2',
DUE_DATE: '#f6ad55',
POSTDUE: '#F03A47',
}
export const TASK_COLOR = {
@@ -60,25 +65,47 @@ export const TASK_COLOR = {
MISSED: '#F03A47',
UPCOMING: '#AF5B5B',
SKIPPED: '#E2C2FF',
IN_PROGRESS: '#00bcd4',
// PENDING_REVIEW: '#b39ddb',
OVERDUE: '#F03A47',
SCHEDULED: '#10B982',
PENDING_REVIEW: '#8B6CE1',
// For the calendar
OVERDUE: '#F03A47',
TODAY: '#ffc107',
TOMORROW: '#4ec1a2',
NEXT_7_DAYS: '#00bcd4',
LATER_THIS_MONTH: '#b39ddb',
FUTURE: '#d7ccc8',
ANYTIME: '#90a4ae',
// Legacy colors for backward compatibility
IN_A_WEEK: '#4ec1a2',
THIS_MONTH: '#00bcd4',
LATER: '#d7ccc8',
ANYTIME: '#90a4ae',
// FOR ASSIGNEE:
ASSIGNED_TO_ME: '#4ec1a2',
ASSIGNED_TO_OTHER: '#b39ddb',
UNASSIGNED: '#ffc107',
// FOR PRIORITY:
PRIORITY_1: '#F03A47',
PRIORITY_2: '#ffc107',
PRIORITY_3: '#00bcd4',
PRIORITY_4: '#7e57c2',
NO_PRIORITY: '#90a4ae',
// PRIORITY_1: '#F03A47',
// PRIORITY_2: '#ffc107',
// PRIORITY_3: '#00bcd4',
// PRIORITY_4: '#7e57c2',
// NO_PRIORITY: '#90a4ae',
// FOR PRIORITY:
// PRIORITY_1: '#F03A4780',
// PRIORITY_2: '#ffc10780',
// PRIORITY_3: '#00bcd480',
// PRIORITY_4: '#7e57c280',
PRIORITY_1: '#d32f2f',
PRIORITY_2: '#ed6c02',
PRIORITY_3: '#0288d1',
// PRIORITY_4: '#388e3c',
PRIORITY_4: '#90a4ae',
NO_PRIORITY: '#90a4ae80',
}
export default LABEL_COLORS
@@ -90,3 +117,18 @@ export const getTextColorFromBackgroundColor = bgColor => {
const b = parseInt(hex.substring(4, 6), 16)
return r * 0.299 + g * 0.587 + b * 0.114 > 186 ? '#000000' : '#ffffff'
}
export const getPriorityColor = priority => {
switch (priority) {
case 1:
return TASK_COLOR.PRIORITY_1
case 2:
return TASK_COLOR.PRIORITY_2
case 3:
return TASK_COLOR.PRIORITY_3
case 4:
return TASK_COLOR.PRIORITY_4
default:
return TASK_COLOR.NO_PRIORITY
}
}

134
src/utils/FeatureToggle.js Normal file
View File

@@ -0,0 +1,134 @@
export const FEATURES = {
OFFLINE_MODE: 'experimental_feature_offline_mode',
}
/**
* Get the current state of a feature flag from localStorage
* @param {string} featureKey - The feature key from FEATURES constant
* @param {boolean} defaultValue - Default value if feature is not set (default: false)
* @returns {boolean} - Whether the feature is enabled
*/
export const isFeatureEnabled = (featureKey, defaultValue = false) => {
try {
const value = localStorage.getItem(featureKey)
if (value === 'true') return true
if (value === 'false') return false
if (value === null || value === undefined) return defaultValue
return Boolean(value)
} catch (error) {
console.warn(`FeatureToggle: Error reading feature "${featureKey}":`, error)
return defaultValue
}
}
/**
* Set the state of a feature flag in localStorage
* @param {string} featureKey - The feature key from FEATURES constant
* @param {boolean} enabled - Whether to enable the feature
*/
export const setFeatureEnabled = (featureKey, enabled) => {
try {
localStorage.setItem(featureKey, enabled.toString())
} catch (error) {
console.error(
`FeatureToggle: Error setting feature "${featureKey}":`,
error,
)
}
}
export const toggleFeature = featureKey => {
const currentState = isFeatureEnabled(featureKey)
const newState = !currentState
setFeatureEnabled(featureKey, newState)
return newState
}
export const getAllFeatureStates = () => {
const states = {}
Object.entries(FEATURES).forEach(([name, key]) => {
states[name] = isFeatureEnabled(key)
})
return states
}
export const clearAllFeatures = () => {
try {
Object.values(FEATURES).forEach(featureKey => {
localStorage.removeItem(featureKey)
})
} catch (error) {
console.error('FeatureToggle: Error clearing features:', error)
}
}
/**
* Check if the current instance is the official donetick.com service
* @returns {Promise<boolean>} - Whether this is the official donetick.com instance
*/
export const isOfficialDonetickInstance = async () => {
try {
// Import here to avoid circular dependencies
const { Preferences } = await import('@capacitor/preferences')
const { API_URL } = await import('../Config')
// Get custom server URL from preferences
const { value: customServerUrl } = await Preferences.get({
key: 'customServerUrl',
})
// Use custom URL if set, otherwise fall back to API_URL
const serverUrl = customServerUrl || API_URL
// Check if the server URL contains donetick.com
return serverUrl.toLowerCase().includes('donetick.com')
} catch (error) {
console.warn('FeatureToggle: Error checking server instance:', error)
// Default to false for safety (self-hosted assumption)
return false
}
}
/**
* Synchronous version that checks based on current API manager state
* Note: This requires apiManager to be initialized first
* @returns {boolean} - Whether this is the official donetick.com instance
*/
export const isOfficialDonetickInstanceSync = () => {
try {
// Dynamic import to avoid circular dependencies
return import('./ApiClient')
.then(({ apiClient }) => {
const currentApiUrl = apiClient.baseURL
// Check if the API URL contains donetick.com
return currentApiUrl.toLowerCase().includes('donetick.com')
})
.catch(error => {
console.warn(
'FeatureToggle: Error checking server instance (sync):',
error,
)
// Default to false for safety (self-hosted assumption)
return false
})
} catch (error) {
console.warn('FeatureToggle: Error checking server instance (sync):', error)
// Default to false for safety (self-hosted assumption)
return false
}
}
// Export default object for easier imports
export default {
FEATURES,
isFeatureEnabled,
setFeatureEnabled,
toggleFeature,
getAllFeatureStates,
clearAllFeatures,
isOfficialDonetickInstance,
isOfficialDonetickInstanceSync,
}

View File

@@ -1,4 +1,18 @@
import { Fetch, HEADERS, apiManager } from './TokenManager'
import { apiClient } from './ApiClient'
// Migration helpers to maintain compatibility with existing code
const Fetch = async (endpoint, options = {}) => {
const response = await apiClient.request(endpoint, options)
return response
}
const HEADERS = () => {
return apiClient.getHeaders()
}
const apiManager = {
getApiURL: () => apiClient.getApiURL(),
}
const createChore = userID => {
return Fetch(`/chores/`, {
@@ -41,6 +55,14 @@ const login = (username, password) => {
})
}
const logout = () => {
const baseURL = apiManager.getApiURL()
return fetch(`${baseURL}/auth/logout`, {
method: 'POST',
credentials: 'include',
})
}
const GetAllUsers = () => {
return Fetch(`/users/`, {
method: 'GET',
@@ -123,12 +145,26 @@ const MarkChoreComplete = (id, body, completedDate, performer) => {
})
}
const CompleteSubTask = (id, choreId, performedAt) => {
const StartChore = id => {
return Fetch(`/chores/${id}/start`, {
method: 'PUT',
headers: HEADERS(),
})
}
const PauseChore = id => {
return Fetch(`/chores/${id}/pause`, {
method: 'PUT',
headers: HEADERS(),
})
}
const CompleteSubTask = (id, choreId, completedAt) => {
var markChoreURL = `/chores/${choreId}/subtask`
return Fetch(markChoreURL, {
method: 'PUT',
headers: HEADERS(),
body: JSON.stringify({ performedAt, id, choreId }),
body: JSON.stringify({ completedAt, id, choreId }),
})
}
@@ -142,11 +178,49 @@ const SkipChore = id => {
})
}
const ApproveChore = id => {
return Fetch(`/chores/${id}/approve`, {
method: 'POST',
headers: HEADERS(),
body: JSON.stringify({}),
})
}
const RejectChore = id => {
return Fetch(`/chores/${id}/reject`, {
method: 'POST',
headers: HEADERS(),
body: JSON.stringify({}),
})
}
const UndoChoreAction = id => {
return Fetch(`/chores/${id}/undo`, {
method: 'POST',
headers: HEADERS(),
body: JSON.stringify({}),
})
}
const NudgeChore = (id, { message, notifyAllAssignees }) => {
return Fetch(`/chores/${id}/nudge`, {
method: 'POST',
headers: HEADERS(),
body: JSON.stringify({
all_assignees: notifyAllAssignees,
message: message || '',
}),
})
}
const UpdateChoreAssignee = (id, assignee) => {
return Fetch(`/chores/${id}/assignee`, {
method: 'PUT',
headers: HEADERS(),
body: JSON.stringify({ assignee: Number(assignee) }),
body: JSON.stringify({
assignee: Number(assignee),
updatedAt: new Date().toISOString(),
}),
})
}
@@ -201,14 +275,6 @@ const UpdateChoreHistory = (choreId, id, choreHistory) => {
})
}
const UpdateChoreStatus = (choreId, status) => {
return Fetch(`/chores/${choreId}/status`, {
method: 'PUT',
headers: HEADERS(),
body: JSON.stringify({ status }),
})
}
const GetAllCircleMembers = async () => {
const resp = await Fetch(`/circles/members`, {
method: 'GET',
@@ -499,6 +565,7 @@ const UpdateDueDate = (id, dueDate) => {
},
body: JSON.stringify({
dueDate: dueDate ? new Date(dueDate).toISOString() : null,
updatedAt: new Date().toISOString(),
}),
})
}
@@ -510,12 +577,39 @@ const RedeemPoints = (userId, points, circleID) => {
body: JSON.stringify({ points, userId }),
})
}
const RefreshToken = () => {
const RefreshToken = async () => {
const basedURL = apiManager.getApiURL()
return fetch(`${basedURL}/auth/refresh`, {
method: 'GET',
headers: HEADERS(),
})
// Check if running on native platform
const isNative =
typeof window !== 'undefined' && window.Capacitor?.isNativePlatform?.()
if (isNative) {
// For native platforms, send refresh token in request body
const { Preferences } = await import('@capacitor/preferences')
const { value: refreshToken } = await Preferences.get({
key: 'refresh_token',
})
if (!refreshToken) {
throw new Error('No refresh token available')
}
return fetch(`${basedURL}/auth/refresh`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ refresh_token: refreshToken }),
})
} else {
// For web, continue using cookies
return fetch(`${basedURL}/auth/refresh`, {
method: 'POST',
credentials: 'include',
headers: HEADERS(),
})
}
}
const GetChoresHistory = async (limit, includeMembers) => {
var url = `/chores/history`
@@ -549,37 +643,249 @@ const GetStorageUsage = () => {
})
}
// Timer/TimeSession API functions
const GetChoreTimer = choreId => {
return Fetch(`/chores/${choreId}/timer`, {
method: 'GET',
headers: HEADERS(),
})
}
const UpdateTimeSession = (choreId, sessionId, sessionData) => {
return Fetch(`/chores/${choreId}/timer/${sessionId}`, {
method: 'PUT',
headers: HEADERS(),
body: JSON.stringify(sessionData),
})
}
const DeleteTimeSession = (choreId, sessionId) => {
return Fetch(`/chores/${choreId}/timer/${sessionId}`, {
method: 'DELETE',
headers: HEADERS(),
})
}
const ResetChoreTimer = choreId => {
return Fetch(`/chores/${choreId}/timer/reset`, {
method: 'PUT',
headers: HEADERS(),
})
}
const ClearChoreTimer = choreId => {
return Fetch(`/chores/${choreId}/timer`, {
method: 'DELETE',
headers: HEADERS(),
})
}
const CheckUserDeletion = password => {
return Fetch(`/users/delete/check`, {
method: 'POST',
headers: HEADERS(),
body: JSON.stringify({
password,
}),
})
}
const DeleteUser = (password, confirmation, transferOptions = []) => {
return Fetch(`/users/delete`, {
method: 'DELETE',
headers: HEADERS(),
body: JSON.stringify({
password,
confirmation,
transferOptions,
}),
})
}
const CreateBackup = (encryptionKey, includeAssets = true, backupName = '') => {
return Fetch(`/backup/create`, {
method: 'POST',
headers: HEADERS(),
body: JSON.stringify({
encryption_key: encryptionKey,
include_assets: includeAssets,
backup_name: backupName,
}),
})
}
const RestoreBackup = (encryptionKey, backupData) => {
return Fetch(`/backup/restore`, {
method: 'POST',
headers: HEADERS(),
body: JSON.stringify({
encryption_key: encryptionKey,
backup_data: backupData,
}),
})
}
const RegisterDeviceToken = (
token,
deviceId,
platform,
appVersion,
deviceModel,
) => {
return Fetch(`/devices/tokens`, {
method: 'POST',
headers: HEADERS(),
body: JSON.stringify({
token,
deviceId,
platform,
appVersion,
deviceModel,
}),
})
}
const UnregisterDeviceToken = (deviceId, token) => {
return Fetch(`/devices/tokens`, {
method: 'DELETE',
headers: HEADERS(),
body: JSON.stringify({
deviceId,
token,
}),
})
}
const GetDeviceTokens = (active = true) => {
return Fetch(`/devices/tokens?active=${active}`, {
method: 'GET',
headers: HEADERS(),
})
}
// Child User Management Functions
const CreateChildUser = (childName, displayName, password) => {
return Fetch(`/users/subaccounts`, {
method: 'POST',
headers: HEADERS(),
body: JSON.stringify({
childName,
displayName,
password,
}),
})
}
const GetChildUsers = () => {
return Fetch(`/users/subaccounts`, {
method: 'GET',
headers: HEADERS(),
})
}
const UpdateChildPassword = (childUserId, password) => {
return Fetch(`/users/subaccounts/password`, {
method: 'PUT',
headers: HEADERS(),
body: JSON.stringify({
childUserId,
password,
}),
})
}
const DeleteChildUser = childUserId => {
return Fetch(`/users/subaccounts/${childUserId}`, {
method: 'DELETE',
headers: HEADERS(),
})
}
// Project-related API functions
const GetProjects = () => {
return Fetch(`/projects`, {
method: 'GET',
headers: HEADERS(),
})
}
const GetProjectById = id => {
return Fetch(`/projects/${id}`, {
method: 'GET',
headers: HEADERS(),
})
}
const CreateProject = project => {
return Fetch(`/projects`, {
method: 'POST',
headers: HEADERS(),
body: JSON.stringify(project),
})
}
const UpdateProject = (id, project) => {
return Fetch(`/projects/${id}`, {
method: 'PUT',
headers: HEADERS(),
body: JSON.stringify(project),
})
}
const DeleteProject = id => {
return Fetch(`/projects/${id}`, {
method: 'DELETE',
headers: HEADERS(),
})
}
export {
AcceptCircleMemberRequest,
ApproveChore,
ArchiveChore,
CancelSubscription,
ChangePassword,
CheckUserDeletion,
ClearChoreTimer,
CompleteSubTask,
ConfirmMFA,
CreateBackup,
CreateChildUser,
CreateChore,
createChore,
CreateLabel,
CreateLongLiveToken,
CreateProject,
CreateThing,
DeleteChildUser,
DeleteChore,
DeleteChoreHistory,
DeleteCircleMember,
DeleteLabel,
DeleteLongLiveToken,
DeleteProject,
DeleteThing,
DeleteTimeSession,
DeleteUser,
DisableMFA,
GetAllCircleMembers,
GetAllUsers,
GetArchivedChores,
GetChildUsers,
GetChoreByID,
GetChoreDetailById,
GetChoreHistory,
GetChores,
GetChoresHistory,
GetChoresNew,
GetChoreTimer,
GetCircleMemberRequests,
GetDeviceTokens,
GetLabels,
GetLongLiveTokens,
GetMFAStatus,
GetProjectById,
GetProjects,
GetResource,
GetStorageUsage,
GetSubscriptionSession,
@@ -589,31 +895,42 @@ export {
GetUserProfile,
JoinCircle,
LeaveCircle,
login,
logout,
MarkChoreComplete,
NudgeChore,
PauseChore,
PutNotificationTarget,
PutWebhookURL,
RedeemPoints,
RefreshToken,
RegenerateBackupCodes,
RegisterDeviceToken,
RejectChore,
ResetChoreTimer,
ResetPassword,
RestoreBackup,
SaveChore,
SaveThing,
SetupMFA,
signUp,
SkipChore,
StartChore,
UnArchiveChore,
UndoChoreAction,
UnregisterDeviceToken,
UpdateChildPassword,
UpdateChoreAssignee,
UpdateChoreHistory,
UpdateChorePriority,
UpdateChoreStatus,
UpdateDueDate,
UpdateLabel,
UpdateMemberRole,
UpdateNotificationTarget,
UpdatePassword,
UpdateProject,
UpdateThingState,
UpdateTimeSession,
UpdateUserDetails,
VerifyMFA,
createChore,
login,
signUp,
}

View File

@@ -1,5 +1,5 @@
import moment from 'moment'
import { getAssetURL } from './TokenManager'
import { apiClient } from './ApiClient'
const isPlusAccount = userProfile => {
return userProfile?.expiration && moment(userProfile?.expiration).isAfter()
@@ -11,7 +11,7 @@ const resolvePhotoURL = url => {
return url
}
if (url.startsWith('assets')) {
return getAssetURL(url)
return apiClient.getAssetURL(url)
}
return url
}

View File

@@ -0,0 +1,63 @@
/**
* Utility functions for platform detection
*/
/**
* Detects if the current platform is macOS using modern APIs with fallback
* @returns {boolean} True if running on macOS, false otherwise
*/
export const isMacOS = () => {
// Modern approach using User-Agent Client Hints API
if (navigator.userAgentData) {
return navigator.userAgentData.platform === 'macOS'
}
// Fallback for older browsers
return /Mac|iPhone|iPad|iPod/.test(navigator.userAgent)
}
/**
* Gets the appropriate keyboard shortcut text for the current platform
* @param {string} key - The key combination (e.g., 'F', 'K', 'S')
* @param {boolean} withCtrl - Whether to include Ctrl/Cmd modifier
* @param {boolean} withShift - Whether to include Shift modifier
* @returns {string} Platform-appropriate keyboard shortcut text
*/
export const getKeyboardShortcut = (
key,
withCtrl = true,
withShift = false,
) => {
let shortcut = ''
if (withCtrl) {
const modifier = isMacOS() ? '⌘' : 'Ctrl+'
shortcut += modifier
}
if (withShift) {
if (isMacOS()) {
shortcut += '⇧'
} else {
shortcut += 'Shift+'
}
}
shortcut += key
return shortcut
}
/**
* Gets common keyboard shortcuts for the current platform
*/
export const getCommonShortcuts = () => ({
search: getKeyboardShortcut('F'),
newTask: getKeyboardShortcut('K'),
selectAll: getKeyboardShortcut('A'),
multiSelect: getKeyboardShortcut('S'),
save: getKeyboardShortcut('S'),
copy: getKeyboardShortcut('C'),
paste: getKeyboardShortcut('V'),
undo: getKeyboardShortcut('Z'),
redo: getKeyboardShortcut('Z', true, true), // Ctrl/Cmd + Shift + Z
})

View File

@@ -0,0 +1,60 @@
import {
AccountBalance,
Book,
Build,
BusinessCenter,
Code,
Computer,
DirectionsCar,
FitnessCenter,
Flight,
FolderOpen,
Games,
Home,
LocalHospital,
MusicNote,
Palette,
Pets,
PhotoCamera,
Restaurant,
School,
Science,
ShoppingCart,
SportsSoccer,
Work,
Yard,
} from '@mui/icons-material'
const PROJECT_ICONS = [
{ name: 'Folder', icon: FolderOpen, value: 'FolderOpen' },
{ name: 'Work', icon: Work, value: 'Work' },
{ name: 'Home', icon: Home, value: 'Home' },
{ name: 'School', icon: School, value: 'School' },
{ name: 'Business', icon: BusinessCenter, value: 'BusinessCenter' },
{ name: 'Code', icon: Code, value: 'Code' },
{ name: 'Build', icon: Build, value: 'Build' },
{ name: 'Design', icon: Palette, value: 'Palette' },
{ name: 'Sports', icon: SportsSoccer, value: 'SportsSoccer' },
{ name: 'Fitness', icon: FitnessCenter, value: 'FitnessCenter' },
{ name: 'Shopping', icon: ShoppingCart, value: 'ShoppingCart' },
{ name: 'Food', icon: Restaurant, value: 'Restaurant' },
{ name: 'Travel', icon: Flight, value: 'Flight' },
{ name: 'Study', icon: Book, value: 'Book' },
{ name: 'Music', icon: MusicNote, value: 'MusicNote' },
{ name: 'Photo', icon: PhotoCamera, value: 'PhotoCamera' },
{ name: 'Games', icon: Games, value: 'Games' },
{ name: 'Science', icon: Science, value: 'Science' },
{ name: 'Finance', icon: AccountBalance, value: 'AccountBalance' },
{ name: 'Health', icon: LocalHospital, value: 'LocalHospital' },
{ name: 'Auto', icon: DirectionsCar, value: 'DirectionsCar' },
{ name: 'Pets', icon: Pets, value: 'Pets' },
{ name: 'Garden', icon: Yard, value: 'Garden' },
{ name: 'Tech', icon: Computer, value: 'Computer' },
]
export default PROJECT_ICONS
export const getIconComponent = iconValue => {
const iconData = PROJECT_ICONS.find(icon => icon.value === iconValue)
return iconData ? iconData.icon : FolderOpen
}

View File

@@ -0,0 +1,89 @@
import { Capacitor } from '@capacitor/core'
/**
* Utility functions for handling safe area insets consistently across the app
*/
/**
* Get the appropriate bottom value that accounts for safe area insets
* @param {number|string} baseBottom - The base bottom value (default: 0)
* @param {number|string} extraPadding - Additional padding to add (default: 0)
* @returns {string} - CSS calc() expression for bottom positioning
*/
export const getSafeBottom = (baseBottom = 0, extraPadding = 0) => {
const base = typeof baseBottom === 'number' ? `${baseBottom}px` : baseBottom
const extra =
typeof extraPadding === 'number' ? `${extraPadding}px` : extraPadding
if (Capacitor.getPlatform() === 'android') {
if (extraPadding) {
return `calc(var(--safe-area-inset-bottom, 0px) + ${base} + ${extra})`
}
return `calc(var(--safe-area-inset-bottom, 0px) + ${base})`
}
// For iOS and web, safe area is already handled by the system
if (extraPadding) {
return `calc(${base} + ${extra})`
}
return base
}
/**
* Get safe area padding for bottom elements
* @param {number|string} extraPadding - Additional padding to add
* @returns {string} - CSS calc() expression for padding
*/
export const getSafeBottomPadding = (extraPadding = 0) => {
const extra =
typeof extraPadding === 'number' ? `${extraPadding * 8}px` : extraPadding
if (Capacitor.getPlatform() === 'android') {
if (extraPadding) {
return `calc(var(--safe-area-inset-bottom, 0px) + ${extra})`
}
return `var(--safe-area-inset-bottom, 0px)`
}
return extra || '0px'
}
/**
* Get safe area styles object for common bottom-positioned elements
* @param {object} options - Configuration options
* @param {number|string} options.bottom - Bottom position value
* @param {number|string} options.padding - Additional padding
* @param {'fixed'|'absolute'|'sticky'} options.position - Position type
* @returns {object} - Style object
*/
export const getSafeBottomStyles = ({
bottom = 0,
padding = 0,
position = 'fixed',
} = {}) => {
return {
position,
bottom: getSafeBottom(bottom, padding),
}
}
/**
* Hook-like function to get safe area values for use in components
* @returns {object} - Object with safe area utility functions
*/
export const useSafeArea = () => {
return {
getSafeBottom,
getSafeBottomPadding,
getSafeBottomStyles,
isAndroid: Capacitor.getPlatform() === 'android',
isNative: Capacitor.isNativePlatform(),
}
}
export default {
getSafeBottom,
getSafeBottomPadding,
getSafeBottomStyles,
useSafeArea,
}

View File

@@ -0,0 +1,65 @@
export const DEFAULT_SIDEPANEL_CONFIG = [
{
id: 'welcome', // legacy name, now represents User Switcher
name: 'User Switcher',
description: 'Allows admins/managers to view tasks as different users',
iconName: 'SupervisorAccount',
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,
},
{
id: 'weeklyGoals',
name: 'Weekly Goals',
description: 'Shows weekly progress and family completion stats',
iconName: 'EmojiEvents',
enabled: true,
order: 4,
},
]
export const getSidepanelConfig = () => {
const saved = localStorage.getItem('sidepanelConfig')
if (saved) {
try {
return JSON.parse(saved)
} catch (error) {
console.error('Error parsing sidepanel config:', error)
return DEFAULT_SIDEPANEL_CONFIG
}
}
return DEFAULT_SIDEPANEL_CONFIG
}
export const saveSidepanelConfig = config => {
localStorage.setItem('sidepanelConfig', JSON.stringify(config))
window.dispatchEvent(new Event('sidepanelConfigChanged'))
}
export const resetSidepanelConfig = () => {
saveSidepanelConfig(DEFAULT_SIDEPANEL_CONFIG)
return DEFAULT_SIDEPANEL_CONFIG
}

View File

@@ -0,0 +1,228 @@
import { Capacitor } from '@capacitor/core'
import { StatusBar, Style } from '@capacitor/status-bar'
import { SafeArea } from 'capacitor-plugin-safe-area'
/**
* StatusBarManager - A utility class to handle status bar configuration
* following Capacitor best practices and theme-aware styling
*/
class StatusBarManager {
constructor() {
this.isNativePlatform = Capacitor.isNativePlatform()
this.platform = Capacitor.getPlatform()
this.listeners = []
this.currentTheme = 'light'
this.safeAreaApplied = false
}
/**
* Initialize the status bar with proper configuration
* @param {string} initialTheme - The initial theme ('light' | 'dark' | 'system')
*/
async initialize(initialTheme = 'light') {
if (!this.isNativePlatform) {
console.log('StatusBarManager: Not running on native platform')
return
}
try {
// Configure basic status bar settings - use overlay: true for precise control
await StatusBar.setOverlaysWebView({ overlay: false })
await StatusBar.show()
// Set initial theme
await this.setTheme(initialTheme)
// Apply safe area insets
await this.applySafeAreaInsets()
console.log('StatusBarManager: Initialized successfully')
} catch (error) {
console.error('StatusBarManager: Failed to initialize:', error)
}
}
/**
* Set status bar style based on theme
* @param {string} theme - The theme ('light' | 'dark' | 'system')
*/
async setTheme(theme) {
if (!this.isNativePlatform) return
this.currentTheme = theme
try {
let style = Style.Light // Default to light content (dark status bar)
if (theme === 'dark') {
style = Style.Dark // Dark content (light status bar)
} else if (theme === 'system') {
// For system theme, we need to detect the actual system preference
// Joy UI's useColorScheme will handle this, but we default to light
style = Style.Light
}
await StatusBar.setStyle({ style })
console.log(`StatusBarManager: Theme set to ${theme}, style: ${style}`)
} catch (error) {
console.error('StatusBarManager: Failed to set theme:', error)
}
}
/**
* Apply safe area insets using CSS custom properties
* Components should use env() variables or CSS custom properties for proper safe area handling
*/
async applySafeAreaInsets() {
if (!this.isNativePlatform || this.safeAreaApplied) return
try {
// Get safe area insets
const { insets } = await SafeArea.getSafeAreaInsets()
// Apply CSS custom properties for safe area
this.applySafeAreaCSS(insets)
this.safeAreaApplied = true
console.log('StatusBarManager: Safe area insets applied:', insets)
} catch (error) {
console.error(
'StatusBarManager: Failed to apply safe area insets:',
error,
)
}
}
/**
* Apply safe area insets using CSS custom properties
* @param {Object} insets - The safe area insets
*/
applySafeAreaCSS(insets) {
const root = document.documentElement
// Set CSS custom properties that can be used throughout the app
root.style.setProperty('--safe-area-inset-top', `${insets.top}px`)
root.style.setProperty('--safe-area-inset-right', `${insets.right}px`)
root.style.setProperty('--safe-area-inset-bottom', `${insets.bottom}px`)
root.style.setProperty('--safe-area-inset-left', `${insets.left}px`)
// Note: We no longer apply padding directly to the body to avoid double
// application with component-level safe area handling. Components should
// use the CSS custom properties or the utility classes from safe-area.css
// Let's apply it directly to body for now:
if (Capacitor.getPlatform() === 'android') {
// removing the top padding on android as it is handled by the navbar
// and adding it causes double padding
// document.body.style.paddingTop = `${insets.top}px`
document.body.style.paddingRight = `${insets.right}px`
document.body.style.paddingBottom = `${insets.bottom}px`
document.body.style.paddingLeft = `${insets.left}px`
}
}
/**
* Add a listener for theme changes
* @param {Function} callback - Function to call when theme changes
* @returns {Function} - Cleanup function to remove the listener
*/
addThemeChangeListener(callback) {
this.listeners.push(callback)
// Return cleanup function
return () => {
const index = this.listeners.indexOf(callback)
if (index > -1) {
this.listeners.splice(index, 1)
}
}
}
/**
* Notify all listeners of theme change
* @param {string} newTheme - The new theme
*/
notifyThemeChange(newTheme) {
this.listeners.forEach(callback => {
try {
callback(newTheme)
} catch (error) {
console.error(
'StatusBarManager: Error in theme change listener:',
error,
)
}
})
}
/**
* Update status bar based on resolved theme (after system detection)
* @param {string} resolvedTheme - The actual theme being used ('light' | 'dark')
*/
async updateResolvedTheme(resolvedTheme) {
if (!this.isNativePlatform) return
try {
const style = resolvedTheme === 'dark' ? Style.Dark : Style.Light
await StatusBar.setStyle({ style })
console.log(
`StatusBarManager: Resolved theme updated to ${resolvedTheme}`,
)
} catch (error) {
console.error('StatusBarManager: Failed to update resolved theme:', error)
}
}
/**
* Hide the status bar
*/
async hide() {
if (!this.isNativePlatform) return
try {
await StatusBar.hide()
} catch (error) {
console.error('StatusBarManager: Failed to hide status bar:', error)
}
}
/**
* Show the status bar
*/
async show() {
if (!this.isNativePlatform) return
try {
await StatusBar.show()
} catch (error) {
console.error('StatusBarManager: Failed to show status bar:', error)
}
}
/**
* Get current status bar info
*/
async getInfo() {
if (!this.isNativePlatform) return null
try {
return await StatusBar.getInfo()
} catch (error) {
console.error('StatusBarManager: Failed to get status bar info:', error)
return null
}
}
/**
* Clean up all listeners and reset state
*/
cleanup() {
this.listeners = []
this.safeAreaApplied = false
console.log('StatusBarManager: Cleaned up')
}
}
// Create and export a singleton instance
const statusBarManager = new StatusBarManager()
export default statusBarManager

View File

@@ -1,202 +0,0 @@
import { Network } from '@capacitor/network'
import { Preferences } from '@capacitor/preferences'
import Cookies from 'js-cookie'
import murmurhash from 'murmurhash'
import { API_URL } from '../Config'
import { networkManager } from '../hooks/NetworkManager'
import { RefreshToken } from './Fetcher'
import { localStore } from './LocalStore'
class ApiManager {
constructor() {
this.customServerURL = `${API_URL}/api/v1`
this.initialized = false
this.navigateToLogin = () => {}
}
async init() {
if (this.initialized) {
return
}
const { value: serverURL } = await Preferences.get({
key: 'customServerUrl',
})
this.customServerURL = `${serverURL || API_URL}/api/v1`
await localStore.initDatabase()
this.initialized = true
}
getApiURL() {
return this.customServerURL
}
updateApiURL(url) {
this.customServerURL = url
this.init()
}
setNavigateToLogin(callback) {
this.navigateToLogin = callback
}
}
export const apiManager = new ApiManager()
export const getAssetURL = path => {
const baseURL = apiManager.getApiURL()
return `${baseURL}/assets/${path}`
}
export async function UploadFile(url, options) {
if (!isTokenValid()) {
Cookies.set('ca_redirect', window.location.pathname)
window.location.href = '/login'
}
if (!options) {
options = {}
}
const headers = HEADERS()
options.headers = { Authorization: headers['Authorization'] }
const baseURL = apiManager.getApiURL()
const fullURL = `${baseURL}${url}`
return fetch(fullURL, options)
}
export async function Fetch(url, options) {
if (!isTokenValid()) {
Cookies.set('ca_redirect', window.location.pathname)
window.location.href = '/login'
}
if (!options) {
options = {}
}
options.headers = { ...options.headers, ...HEADERS() }
const baseURL = apiManager.getApiURL()
const fullURL = `${baseURL}${url}`
const networkStatus = await Network.getStatus()
if (!networkStatus.connected) {
return handleOfflineRequest(fullURL, options)
}
// Online: Perform the fetch
try {
const response = await fetch(fullURL, options)
if (response.ok) {
const data = await response.clone().json()
const optionsHash = murmurhash.v3(JSON.stringify(options))
await localStore.saveToCache(fullURL + optionsHash, data)
networkManager.setOnline()
} else if (response.status === 401) {
// Handle 401 Unauthorized
const errorData = await response.json()
console.error('Unauthorized:', errorData)
localStorage.removeItem('ca_token')
localStorage.removeItem('ca_expiration')
apiManager.navigateToLogin()
} else if (
response.status === 503 ||
response.type === 'opaque' ||
response.status === 0
) {
networkManager.setOffline()
return handleOfflineRequest(fullURL, options)
}
// return promise that resolves to response object:
return Promise.resolve(response)
} catch (error) {
networkManager.setOffline()
console.error('Fetch error:', error)
// throw error
return handleOfflineRequest(fullURL, options)
}
}
export const HEADERS = () => {
return {
'Content-Type': 'application/json',
Authorization: 'Bearer ' + localStorage.getItem('ca_token'),
}
}
export const isTokenValid = () => {
const expiration = localStorage.getItem('ca_expiration')
const token = localStorage.getItem('ca_token')
if (token) {
const now = new Date()
const expire = new Date(expiration)
if (now < expire) {
if (now.getTime() + 24 * 60 * 60 * 1000 > expire.getTime()) {
refreshAccessToken()
}
return true
} else {
localStorage.removeItem('ca_token')
localStorage.removeItem('ca_expiration')
}
return false
}
}
export const refreshAccessToken = () => {
RefreshToken().then(res => {
if (res.status === 200) {
res.json().then(data => {
localStorage.setItem('ca_token', data.token)
localStorage.setItem('ca_expiration', data.expire)
})
} else {
return res.json().then(error => {
console.log(error)
})
}
})
}
async function handleOfflineRequest(url, options) {
// if get request then attempt to fetch from cache otherewise queue it :
if (options.method === 'GET') {
return attemptFetchFromCache(url, options)
} else {
// Queue the request for later processing
const requestId = murmurhash.v3(JSON.stringify({ url, options }))
await localStore.queueRequest(requestId, { url, options })
console.log('Request queued for later processing:', requestId)
return Promise.reject({
error: 'Offline and request queued',
requestId,
queued: true,
})
}
}
async function attemptFetchFromCache(url, options) {
const optionsHash = murmurhash.v3(JSON.stringify(options))
const cachedData = await localStore.getFromCache(url + optionsHash)
networkManager.setOffline()
if (cachedData) {
return Promise.resolve({
ok: true,
status: 200,
json: async () => cachedData,
})
} else {
// TODO: change this to throw error instead of returning promise
return Promise.reject(
new Error(
'No cached data found for URL: ' +
url +
' with options hash: ' +
optionsHash,
),
)
}
}

190
src/utils/TokenStorage.js Normal file
View File

@@ -0,0 +1,190 @@
import { Capacitor } from '@capacitor/core'
import { Preferences } from '@capacitor/preferences'
// Token storage keys
const TOKEN_KEYS = {
ACCESS_TOKEN: 'token',
ACCESS_TOKEN_EXPIRY: 'token_expiry',
REFRESH_TOKEN: 'refresh_token',
REFRESH_TOKEN_EXPIRY: 'refresh_token_expiry',
}
// Cache platform detection to avoid repeated checks
let _isNativePlatform = null
// Platform detection
const isNativePlatform = () => {
if (_isNativePlatform === null) {
try {
_isNativePlatform =
typeof window !== 'undefined' && window.Capacitor?.isNativePlatform?.()
} catch (error) {
console.warn('Platform detection failed, defaulting to web:', error)
_isNativePlatform = false
}
}
return _isNativePlatform
}
// Track if we're currently clearing tokens to prevent race conditions
let clearingTokens = false
/**
* Save tokens based on platform
* Web: Only access tokens to localStorage
* Native: Access tokens to localStorage + refresh tokens to Capacitor Preferences
*/
export const saveTokens = async ({
accessToken,
accessTokenExpiry,
refreshToken,
refreshTokenExpiry,
}) => {
try {
// Always save access tokens to localStorage
if (accessToken) {
localStorage.setItem(TOKEN_KEYS.ACCESS_TOKEN, accessToken)
}
if (accessTokenExpiry) {
localStorage.setItem(TOKEN_KEYS.ACCESS_TOKEN_EXPIRY, accessTokenExpiry)
}
if (refreshTokenExpiry) {
localStorage.setItem(TOKEN_KEYS.REFRESH_TOKEN_EXPIRY, refreshTokenExpiry)
}
if (Capacitor.isNativePlatform()) {
// On native platforms, also save refresh tokens to Capacitor Preferences
try {
if (refreshToken) {
await Preferences.set({
key: TOKEN_KEYS.REFRESH_TOKEN,
value: refreshToken,
})
}
if (refreshTokenExpiry) {
await Preferences.set({
key: TOKEN_KEYS.REFRESH_TOKEN_EXPIRY,
value: refreshTokenExpiry,
})
}
} catch (error) {
console.error(
'Failed to save refresh tokens to Capacitor Preferences:',
error,
)
// Don't throw - access token is still saved to localStorage
}
}
} catch (error) {
console.error('Error saving tokens:', error)
throw error
}
}
/**
* Get refresh token from storage
* Web: Returns null (uses HTTP-only cookies)
* Native: Returns from Capacitor Preferences
*/
export const getRefreshToken = async () => {
if (!isNativePlatform()) {
return null // Web uses HTTP-only cookies
}
try {
const { value } = await Preferences.get({ key: TOKEN_KEYS.REFRESH_TOKEN })
return value
} catch (error) {
console.error('Error reading refresh token from Preferences:', error)
return null
}
}
/**
* Get refresh token expiry from storage
* Web: Returns null (uses HTTP-only cookies)
* Native: Returns from Capacitor Preferences
*/
export const getRefreshTokenExpiry = async () => {
if (!isNativePlatform()) {
return null // Web uses HTTP-only cookies
}
try {
const { value } = await Preferences.get({
key: TOKEN_KEYS.REFRESH_TOKEN_EXPIRY,
})
return value
} catch (error) {
console.error('Error reading refresh token expiry from Preferences:', error)
return null
}
}
/**
* Check if refresh token is expired
* Web: Returns false (backend handles cookie expiration)
* Native: Checks expiry from Capacitor Preferences
*/
export const isRefreshTokenExpired = async () => {
if (!isNativePlatform()) {
return false // Web uses cookies, backend handles expiration
}
const expiry = await getRefreshTokenExpiry()
if (!expiry) {
return true // No expiry means no token
}
try {
const expiryDate = new Date(expiry)
if (isNaN(expiryDate.getTime())) {
console.error('Invalid refresh token expiry date:', expiry)
return true // Treat invalid date as expired
}
return new Date() >= expiryDate
} catch (error) {
console.error('Error parsing refresh token expiry:', error)
return true // Treat parse error as expired
}
}
/**
* Clear all tokens from all storage locations
* Idempotent - safe to call multiple times
*/
export const clearAllTokens = async () => {
if (clearingTokens) {
return // Already clearing, don't run concurrently
}
clearingTokens = true
try {
// Clear localStorage
localStorage.removeItem(TOKEN_KEYS.ACCESS_TOKEN)
localStorage.removeItem(TOKEN_KEYS.ACCESS_TOKEN_EXPIRY)
// Clean up legacy keys
localStorage.removeItem('ca_token')
localStorage.removeItem('ca_expiration')
localStorage.removeItem('access_token')
// Clear Capacitor Preferences on native
if (isNativePlatform()) {
try {
await Preferences.remove({ key: TOKEN_KEYS.REFRESH_TOKEN })
await Preferences.remove({ key: TOKEN_KEYS.REFRESH_TOKEN_EXPIRY })
} catch (error) {
console.error('Error clearing tokens from Preferences:', error)
}
}
} catch (error) {
console.error('Error clearing tokens:', error)
} finally {
clearingTokens = false
}
}
/**
* Export platform detection helper
*/
export const isNative = isNativePlatform

66
src/utils/UserHelpers.js Normal file
View File

@@ -0,0 +1,66 @@
// User type detection and permission utilities
export const USER_TYPES = {
PARENT: 0,
CHILD: 1,
}
export const isParentUser = (user) => {
if (!user) return false
return user.userType === USER_TYPES.PARENT && !user.parentUserId
}
export const isChildUser = (user) => {
if (!user) return false
return user.userType === USER_TYPES.CHILD && user.parentUserId !== null
}
export const canManageChildUsers = (user) => {
return isParentUser(user)
}
export const canCreateChores = (user) => {
// Both parent and child users can create chores
return user && (isParentUser(user) || isChildUser(user))
}
export const canManageCircle = (user) => {
// Only parent users can manage circle settings
return isParentUser(user)
}
export const canAccessAdminSettings = (user) => {
// Only parent users can access admin settings like API tokens, MFA, etc.
return isParentUser(user)
}
export const getUserDisplayInfo = (user) => {
if (!user) return { displayName: '', username: '', userType: 'unknown' }
return {
displayName: user.displayName || user.username,
username: user.username,
userType: isParentUser(user) ? 'parent' : isChildUser(user) ? 'child' : 'unknown',
parentUserId: user.parentUserId,
circleID: user.circleID,
}
}
export const getChildUsernameFromCombined = (combinedUsername) => {
// Extract child name from format: parent_child
const parts = combinedUsername.split('_')
if (parts.length >= 2) {
return parts.slice(1).join('_') // In case child name contains underscores
}
return combinedUsername
}
export const getParentUsernameFromCombined = (combinedUsername) => {
// Extract parent name from format: parent_child
const parts = combinedUsername.split('_')
return parts[0] || combinedUsername
}
export const buildChildUsername = (parentUsername, childName) => {
return `${parentUsername}_${childName}`
}

View File

@@ -1,16 +1,16 @@
import { Box, Button, CircularProgress, Container, Typography } from '@mui/joy'
import { useContext, useEffect, useState } from 'react'
import { useEffect, useState } from 'react'
import Logo from '../../Logo'
import { apiManager } from '../../utils/TokenManager'
import { apiClient } from '../../utils/ApiClient'
import Cookies from 'js-cookie'
import { useRef } from 'react'
import { Link, useNavigate, useParams } from 'react-router-dom'
import { UserContext } from '../../contexts/UserContext'
import { useUserProfile } from '../../queries/UserQueries'
import { GetUserProfile } from '../../utils/Fetcher'
const AuthenticationLoading = () => {
const { userProfile, setUserProfile } = useContext(UserContext)
const { data: userProfile, refetch: refetchUserProfile } = useUserProfile()
const Navigate = useNavigate()
const hasCalledHandleOAuth2 = useRef(false)
const [message, setMessage] = useState('Authenticating')
@@ -29,15 +29,16 @@ const AuthenticationLoading = () => {
const getUserProfileAndNavigateToHome = () => {
GetUserProfile().then(data => {
data.json().then(data => {
setUserProfile(data.res)
// check if redirect url is set in cookie:
const redirectUrl = Cookies.get('ca_redirect')
if (redirectUrl) {
Cookies.remove('ca_redirect')
Navigate(redirectUrl)
} else {
Navigate('/my/chores')
}
refetchUserProfile().then(() => {
// check if redirect url is set in cookie:
const redirectUrl = Cookies.get('ca_redirect')
if (redirectUrl) {
Cookies.remove('ca_redirect')
Navigate(redirectUrl)
} else {
Navigate('/chores')
}
})
})
})
}
@@ -57,7 +58,7 @@ const AuthenticationLoading = () => {
}
if (code) {
const baseURL = apiManager.getApiURL()
const baseURL = apiClient.baseURL
fetch(`${baseURL}/auth/${provider}/callback`, {
method: 'POST',
headers: {
@@ -70,8 +71,8 @@ const AuthenticationLoading = () => {
}).then(response => {
if (response.status === 200) {
return response.json().then(data => {
localStorage.setItem('ca_token', data.token)
localStorage.setItem('ca_expiration', data.expire)
localStorage.setItem('token', data.token)
localStorage.setItem('token_expiry', data.expire)
const redirectUrl = Cookies.get('ca_redirect')
if (redirectUrl) {

View File

@@ -1,5 +1,4 @@
// create boilerplate for ResetPasswordView:
import LogoSVG from '@/assets/logo.svg'
import {
Box,
Button,
@@ -8,21 +7,20 @@ import {
FormHelperText,
Input,
Sheet,
Snackbar,
Typography,
} from '@mui/joy'
import { useState } from 'react'
import { useNavigate } from 'react-router-dom'
import { API_URL } from './../../Config'
import { ResetPassword } from '../../utils/Fetcher'
import Logo from '../../Logo'
import { useNotification } from '../../service/NotificationProvider'
import { ResetPassword } from '../../utils/Fetcher'
const ForgotPasswordView = () => {
const navigate = useNavigate()
// const [showLoginSnackbar, setShowLoginSnackbar] = useState(false)
// const [snackbarMessage, setSnackbarMessage] = useState('')
const [resetStatusOk, setResetStatusOk] = useState(null)
const [email, setEmail] = useState('')
const [emailError, setEmailError] = useState(null)
const { showError, showNotification } = useNotification()
const validateEmail = email => {
return !/^[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}$/i.test(email)
@@ -48,12 +46,24 @@ const ForgotPasswordView = () => {
if (response.ok) {
setResetStatusOk(true)
// wait 3 seconds and then redirect to login:
showNotification({
type: 'success',
title: 'Reset Email Sent',
message: 'Check your email for password reset instructions',
})
} else {
setResetStatusOk(false)
showError({
title: 'Reset Failed',
message: 'Failed to send reset email, please try again later',
})
}
} catch (error) {
setResetStatusOk(false)
showError({
title: 'Reset Failed',
message: 'Failed to send reset email, please try again later',
})
}
}
@@ -67,19 +77,12 @@ const ForgotPasswordView = () => {
}
return (
<Container
component='main'
maxWidth='xs'
// make content center in the middle of the page:
>
<Container component='main' maxWidth='xs'>
<Box
sx={{
marginTop: 4,
display: 'flex',
flexDirection: 'column',
justifyContent: 'space-between',
alignItems: 'center',
}}
>
@@ -94,120 +97,111 @@ const ForgotPasswordView = () => {
padding: 2,
borderRadius: '8px',
boxShadow: 'md',
minHeight: '70vh',
justifyContent: 'space-between',
justifyItems: 'center',
}}
>
<Box>
<img src={LogoSVG} alt='logo' width='128px' height='128px' />
{/* <Logo /> */}
<Typography level='h2'>
Done
<span
style={{
color: '#06b6d4',
}}
>
tick
</span>
</Typography>
</Box>
{/* HERE */}
<Box sx={{ textAlign: 'center' }}></Box>
<Logo />
<Typography level='h2'>
Done
<span style={{ color: '#06b6d4' }}>tick</span>
</Typography>
{resetStatusOk === null && (
<form onSubmit={handleSubmit}>
<div className='grid gap-6'>
<Typography level='body2' gutterBottom>
Enter your email, and we'll send you a link to get into your
account.
</Typography>
<FormControl error={emailError !== null}>
<Input
placeholder='Email'
type='email'
variant='soft'
fullWidth
size='lg'
value={email}
onChange={handleEmailChange}
error={emailError !== null}
onKeyDown={e => {
if (e.key === 'Enter') {
e.preventDefault()
handleSubmit()
}
}}
/>
<FormHelperText>{emailError}</FormHelperText>
</FormControl>
<Box>
<Button
variant='solid'
size='lg'
fullWidth
sx={{
mb: 1,
}}
onClick={handleSubmit}
>
Reset Password
</Button>
<Button
fullWidth
size='lg'
variant='soft'
sx={{
width: '100%',
border: 'moccasin',
borderRadius: '8px',
}}
onClick={() => {
navigate('/login')
}}
color='neutral'
>
Back to Login
</Button>
</Box>
</div>
</form>
)}
{resetStatusOk != null && (
<>
<Box mt={-30}>
<Typography level='body-md'>
if there is an account associated with the email you entered,
you will receive an email with instructions on how to reset
your
</Typography>
</Box>
<Typography level='body2' sx={{ mb: 3 }}>
Enter your email, and we'll send you a link to get into your
account.
</Typography>
<Typography level='body2' alignSelf={'start'} mb={1}>
Email Address
</Typography>
<FormControl
error={emailError !== null}
sx={{ width: '100%', mb: 2 }}
>
<Input
margin='normal'
required
fullWidth
id='email'
placeholder='Enter your email address'
type='email'
name='email'
autoComplete='email'
autoFocus
value={email}
onChange={handleEmailChange}
error={emailError !== null}
onKeyDown={e => {
if (e.key === 'Enter') {
e.preventDefault()
handleSubmit()
}
}}
/>
<FormHelperText>{emailError}</FormHelperText>
</FormControl>
<Button
variant='soft'
type='submit'
fullWidth
size='lg'
sx={{ position: 'relative', bottom: '0' }}
variant='solid'
sx={{
width: '100%',
mt: 3,
mb: 2,
border: 'moccasin',
borderRadius: '8px',
}}
onClick={handleSubmit}
>
Reset Password
</Button>
<Button
type='submit'
fullWidth
size='lg'
variant='plain'
sx={{
width: '100%',
mb: 2,
border: 'moccasin',
borderRadius: '8px',
}}
onClick={() => {
navigate('/login')
}}
color='neutral'
>
Back to Login
</Button>
</>
)}
{resetStatusOk != null && (
<>
<Typography
level='body-md'
sx={{ textAlign: 'center', mt: 2, mb: 3 }}
>
If there is an account associated with the email you entered,
you will receive an email with instructions on how to reset your
password.
</Typography>
<Button
variant='solid'
size='lg'
fullWidth
onClick={() => {
navigate('/login')
}}
>
Go to Login
</Button>
</>
)}
<Snackbar
open={resetStatusOk ? resetStatusOk : resetStatusOk === false}
autoHideDuration={5000}
onClose={() => {
if (resetStatusOk) {
navigate('/login')
}
}}
>
{resetStatusOk
? 'Reset email sent, check your email'
: 'Reset email failed, try again later'}
</Snackbar>
</Sheet>
</Box>
</Container>

View File

@@ -1,23 +1,15 @@
import { Preferences } from '@capacitor/preferences'
import {
Box,
Button,
Container,
Input,
Sheet,
Snackbar,
Typography,
} from '@mui/joy'
import { Box, Button, Container, Input, Sheet, Typography } from '@mui/joy'
import React from 'react'
import { useNavigate } from 'react-router-dom'
import { API_URL } from '../../Config'
import Logo from '../../Logo'
import { apiManager } from '../../utils/TokenManager'
import { useNotification } from '../../service/NotificationProvider'
import { apiClient } from '../../utils/ApiClient'
const LoginSettings = () => {
const [error, setError] = React.useState(null)
const Navigate = useNavigate()
const [serverURL, setServerURL] = React.useState('')
const { showError } = useNotification()
React.useEffect(() => {
Preferences.get({ key: 'customServerUrl' }).then(result => {
@@ -112,14 +104,18 @@ const LoginSettings = () => {
return
}
if (!isValidServerURL()) {
setError('Invalid server URL')
showError({
title: 'Invalid Server URL',
message:
'Please enter a valid server URL with protocol (http:// or https://)',
})
return
}
Preferences.set({
key: 'customServerUrl',
value: serverURL,
}).then(() => {
apiManager.updateApiURL(serverURL + '/api/v1')
apiClient.customServerURL = serverURL + '/api/v1'
Navigate('/login')
})
}}
@@ -150,14 +146,6 @@ const LoginSettings = () => {
</Button>
</Sheet>
</Box>
<Snackbar
open={error !== null}
onClose={() => setError(null)}
autoHideDuration={3000}
message={error}
>
{error}
</Snackbar>
</Container>
)
}

View File

@@ -1,7 +1,10 @@
import { Browser } from '@capacitor/browser'
import { Capacitor } from '@capacitor/core'
import { Device } from '@capacitor/device'
// import { GoogleAuth } from '@codetrix-studio/capacitor-google-auth'
import { SocialLogin } from '@capgo/capacitor-social-login'
import { Settings } from '@mui/icons-material'
import AppleIcon from '@mui/icons-material/Apple'
import GoogleIcon from '@mui/icons-material/Google'
import {
Avatar,
@@ -12,29 +15,55 @@ import {
IconButton,
Input,
Sheet,
Snackbar,
Tab,
TabList,
TabPanel,
Tabs,
Typography,
} from '@mui/joy'
import { useQueryClient } from '@tanstack/react-query'
import Cookies from 'js-cookie'
import React, { useEffect } from 'react'
import { useEffect, useState } from 'react'
import { useNavigate } from 'react-router-dom'
import { LoginSocialGoogle } from 'reactjs-social-login'
import { GOOGLE_CLIENT_ID, REDIRECT_URL } from '../../Config'
import { UserContext } from '../../contexts/UserContext'
import { useAuth } from '../../hooks/useAuth.jsx'
import Logo from '../../Logo'
import { useResource } from '../../queries/ResourceQueries'
import { GetUserProfile, login } from '../../utils/Fetcher'
import { apiManager } from '../../utils/TokenManager'
import { useUserProfile } from '../../queries/UserQueries.jsx'
import { useNotification } from '../../service/NotificationProvider'
import { apiClient } from '../../utils/ApiClient'
import { saveTokens } from '../../utils/TokenStorage'
import { buildChildUsername, getUserDisplayInfo } from '../../utils/UserHelpers'
import MFAVerificationModal from './MFAVerificationModal'
const LoginView = () => {
const { userProfile, setUserProfile } = React.useContext(UserContext)
const [username, setUsername] = React.useState('')
const [password, setPassword] = React.useState('')
const [error, setError] = React.useState(null)
const [mfaModalOpen, setMfaModalOpen] = React.useState(false)
const [mfaSessionToken, setMfaSessionToken] = React.useState('')
// Use React Query client directly to invalidate the user profile query
const queryClient = useQueryClient()
// const [userProfile, setUserProfile] = useState(null)
const { data: userProfile } = useUserProfile()
const [username, setUsername] = useState('')
const [password, setPassword] = useState('')
const [mfaModalOpen, setMfaModalOpen] = useState(false)
const [mfaSessionToken, setMfaSessionToken] = useState('')
const [isAppleSignInSupported, setIsAppleSignInSupported] = useState(false)
// Child login state
const [loginType, setLoginType] = useState('primary')
const [parentUsername, setParentUsername] = useState('')
const [childName, setChildName] = useState('')
// Clear fields when switching login modes
const handleLoginModeChange = (event, newValue) => {
setLoginType(newValue)
setUsername('')
setParentUsername('')
setChildName('')
setPassword('')
}
const { data: resource } = useResource()
const { showError } = useNotification()
const { isAuthenticated, login: authLogin, user } = useAuth()
const Navigate = useNavigate()
useEffect(() => {
const initializeSocialLogin = async () => {
@@ -45,81 +74,145 @@ const LoginView = () => {
mode: 'online', // replaces grantOfflineAccess
},
})
// Check if Apple Sign In is supported (iOS 13+)
if (Capacitor.isNativePlatform()) {
try {
const deviceInfo = await Device.getInfo()
if (deviceInfo.platform === 'ios') {
const majorVersion = parseInt(deviceInfo.osVersion.split('.')[0])
setIsAppleSignInSupported(majorVersion >= 13)
}
} catch (error) {
console.log(
'Could not determine device info for Apple Sign In support',
)
}
}
}
initializeSocialLogin()
}, [])
useEffect(() => {
if (isAuthenticated && user) {
setUserProfile(user)
Navigate('/chores')
}
}, [isAuthenticated, user, Navigate])
const handleSubmit = async e => {
e.preventDefault()
login(username, password)
.then(response => {
if (response.status === 200) {
return response.json().then(data => {
// Check if MFA is required
if (data.mfaRequired) {
setMfaSessionToken(data.sessionToken)
setMfaModalOpen(true)
return
}
// Normal login without MFA
localStorage.setItem('ca_token', data.token)
localStorage.setItem('ca_expiration', data.expire)
const redirectUrl = Cookies.get('ca_redirect')
if (redirectUrl) {
Cookies.remove('ca_redirect')
Navigate(redirectUrl)
} else {
Navigate('/my/chores')
}
})
} else if (response.status === 401) {
setError('Wrong username or password')
} else {
setError('An error occurred, please try again')
console.log('Login failed')
}
})
.catch(err => {
setError('Unable to communicate with server, please try again')
console.log('Login failed', err)
})
}
const loggedWithProvider = function (provider, data) {
const baseURL = apiManager.getApiURL()
const getAccessToken = data => {
if (data['access_token']) {
// data["access_token"] is for Google
return data['access_token']
} else if (data['accessToken']) {
// data["accessToken"] is for Google Capacitor
return data['accessToken']['token']
// Validation for child login
if (loginType === 'sub') {
if (!parentUsername.trim()) {
showError({
title: 'Validation Error',
message: 'Primary username is required for sub account login',
})
return
}
if (!childName.trim()) {
showError({
title: 'Validation Error',
message: 'Sub account name is required for sub account login',
})
return
}
} else {
if (!username.trim()) {
showError({
title: 'Validation Error',
message: 'Username is required',
})
return
}
}
return fetch(`${baseURL}/auth/${provider}/callback`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
if (!password) {
showError({
title: 'Validation Error',
message: 'Password is required',
})
return
}
// Determine the actual username to send
const actualUsername =
loginType === 'sub'
? buildChildUsername(parentUsername, childName)
: username
const result = await authLogin({ username: actualUsername, password })
if (result.success) {
if (result.data?.mfaRequired) {
setMfaSessionToken(result.data.sessionToken)
setMfaModalOpen(true)
return
}
// Refetch user profile after successful login
queryClient.refetchQueries(['userProfile'])
const redirectUrl = Cookies.get('ca_redirect')
if (redirectUrl && redirectUrl !== '/') {
Cookies.remove('ca_redirect')
Navigate(redirectUrl)
} else {
Navigate('/chores')
}
} else {
showError({
title: 'Login Failed',
message: result.error || 'An error occurred, please try again',
})
}
}
const loggedWithProvider = async function (provider, data) {
const getAccessToken = data => {
if (data['access_token']) {
return data['access_token']
} else if (data['accessToken']) {
return data['accessToken']['token']
} else if (data['response'] && data['response']['id_token']) {
return data['response']['id_token']
} else if (data['id_token']) {
return data['id_token']
}
}
try {
const response = await apiClient.post(`/auth/${provider}/callback`, {
provider: provider,
token: getAccessToken(data),
data: data,
}),
}).then(response => {
if (response.status === 200) {
return response.json().then(data => {
// Check if MFA is required for OAuth login
if (data.mfaRequired) {
setMfaSessionToken(data.sessionToken)
setMfaModalOpen(true)
return
}
})
// Normal OAuth login without MFA
localStorage.setItem('ca_token', data.token)
localStorage.setItem('ca_expiration', data.expire)
if (response.ok) {
const responseData = await response.json()
// Check if MFA is required for OAuth login
if (responseData.mfaRequired) {
setMfaSessionToken(responseData.sessionToken)
setMfaModalOpen(true)
return
}
// Use new auth system to handle token storage
if (responseData.token || responseData.access_token) {
const token = responseData.token || responseData.access_token
const expiry = responseData.expire || responseData.access_token_expiry
// Save all tokens including refresh tokens
await saveTokens({
accessToken: token,
accessTokenExpiry: expiry,
refreshToken: responseData.refresh_token,
refreshTokenExpiry: responseData.refresh_token_expiry,
})
// Refetch user profile after successful OAuth login
queryClient.invalidateQueries(['userProfile'])
const redirectUrl = Cookies.get('ca_redirect')
if (redirectUrl) {
@@ -128,46 +221,65 @@ const LoginView = () => {
} else {
getUserProfileAndNavigateToHome()
}
}
} else {
const providerName = provider === 'apple' ? 'Apple' : 'Google'
showError({
title: `${providerName} Login Failed`,
message: `Couldn't log in with ${providerName}, please try again`,
})
}
return response.json().then(() => {
setError("Couldn't log in with Google, please try again")
} catch (error) {
const providerName = provider === 'apple' ? 'Apple' : 'Google'
showError({
title: `${providerName} Login Error`,
message: 'Network error occurred, please try again',
})
})
}
}
const getUserProfileAndNavigateToHome = () => {
GetUserProfile().then(data => {
data.json().then(data => {
setUserProfile(data.res)
// check if redirect url is set in cookie:
const redirectUrl = Cookies.get('ca_redirect')
if (redirectUrl) {
Cookies.remove('ca_redirect')
Navigate(redirectUrl)
} else {
Navigate('/my/chores')
}
})
// Refetch user profile after login using React Query
queryClient.invalidateQueries(['userProfile']).then(() => {
// check if redirect url is set in cookie:
const redirectUrl = Cookies.get('ca_redirect')
if (redirectUrl) {
Cookies.remove('ca_redirect')
Navigate(redirectUrl)
} else {
Navigate('/chores')
}
})
}
const handleMFASuccess = data => {
localStorage.setItem('ca_token', data.token)
localStorage.setItem('ca_expiration', data.expire)
const handleMFASuccess = async data => {
// Save all tokens including refresh tokens
await saveTokens({
accessToken: data.token,
accessTokenExpiry: data.expire,
refreshToken: data.refresh_token,
refreshTokenExpiry: data.refresh_token_expiry,
})
setMfaModalOpen(false)
setMfaSessionToken('')
// Refetch user profile after MFA success
queryClient.invalidateQueries(['userProfile'])
const redirectUrl = Cookies.get('ca_redirect')
if (redirectUrl) {
Cookies.remove('ca_redirect')
Navigate(redirectUrl)
} else {
Navigate('/my/chores')
Navigate('/chores')
}
}
const handleMFAError = errorMessage => {
setError(errorMessage)
showError({
title: 'Two-Factor Authentication Failed',
message: errorMessage,
})
}
const handleMFAClose = () => {
@@ -185,19 +297,52 @@ const LoginView = () => {
return randomState
}
const handleAuthentikLogin = () => {
const handleAuthentikLogin = async () => {
const authentikAuthorizeUrl = resource?.identity_provider?.auth_url
const state = generateRandomState()
const params = new URLSearchParams({
response_type: 'code',
client_id: resource?.identity_provider?.client_id,
redirect_uri: `${window.location.origin}/auth/oauth2`,
scope: 'openid profile email', // Your scopes
state: generateRandomState(),
})
console.log('redirect', `${authentikAuthorizeUrl}?${params.toString()}`)
if (Capacitor.isNativePlatform()) {
// For mobile devices, use a custom URL scheme for the redirect
const redirectUri = 'donetick://auth/oauth2'
window.location.href = `${authentikAuthorizeUrl}?${params.toString()}`
const params = new URLSearchParams({
response_type: 'code',
client_id: resource?.identity_provider?.client_id,
redirect_uri: redirectUri,
scope: 'openid profile email',
state: state,
})
const authUrl = `${authentikAuthorizeUrl}?${params.toString()}`
console.log('Opening OAuth in browser:', authUrl)
try {
// Open OAuth flow in system browser
await Browser.open({ url: authUrl })
// Note: The OAuth callback will be handled by deep link handling
// You'll need to implement deep link handling to catch the redirect
// and extract the authorization code
} catch (error) {
console.error('Failed to open OAuth browser:', error)
showError({
title: 'OAuth Error',
message: 'Failed to open authentication browser',
})
}
} else {
// For web platforms, use the current approach
const params = new URLSearchParams({
response_type: 'code',
client_id: resource?.identity_provider?.client_id,
redirect_uri: `${window.location.origin}/auth/oauth2`,
scope: 'openid profile email',
state: state,
})
console.log('redirect', `${authentikAuthorizeUrl}?${params.toString()}`)
window.location.href = `${authentikAuthorizeUrl}?${params.toString()}`
}
}
return (
@@ -258,6 +403,16 @@ const LoginView = () => {
<Typography level='body-md' alignSelf={'center'}>
Welcome back,{' '}
{userProfile?.displayName || userProfile?.username}
{getUserDisplayInfo(userProfile).userType === 'child' && (
<Typography
component='span'
level='body-xs'
color='neutral'
sx={{ ml: 1 }}
>
(Sub Account)
</Typography>
)}
</Typography>
<Button
@@ -274,7 +429,6 @@ const LoginView = () => {
type='submit'
fullWidth
size='lg'
q
variant='plain'
sx={{
width: '100%',
@@ -283,11 +437,7 @@ const LoginView = () => {
borderRadius: '8px',
}}
onClick={() => {
setUserProfile(null)
localStorage.removeItem('ca_token')
localStorage.removeItem('ca_expiration')
// go to login page:
window.location.href = '/login'
apiClient.handleLogout()
}}
>
Logout
@@ -296,27 +446,109 @@ const LoginView = () => {
)}
{!userProfile && (
<>
<Typography level='body2'>
<Typography level='body2' sx={{ mb: 3 }}>
Sign in to your account to continue
</Typography>
<Typography level='body2' alignSelf={'start'} mt={4}>
Username
</Typography>
<Input
margin='normal'
required
fullWidth
id='email'
label='Email Address'
name='email'
autoComplete='email'
autoFocus
value={username}
onChange={e => {
setUsername(e.target.value)
}}
/>
<Typography level='body2' alignSelf={'start'}>
{/* Login Type Tabs */}
<Tabs
value={loginType}
onChange={handleLoginModeChange}
sx={{ width: '100%', mb: 3 }}
>
<TabList
sx={{
width: '100%',
p: 0.5,
borderBottom: 'none',
boxShadow: 'none',
'&::after': {
display: 'none',
},
}}
>
<Tab
value='primary'
variant='plain'
sx={{
flex: 1,
borderRadius: '6px',
fontSize: '0.875rem',
fontWeight: 500,
}}
>
Primary Account
</Tab>
<Tab
value='sub'
variant='plain'
sx={{
flex: 1,
borderRadius: '6px',
fontSize: '0.875rem',
fontWeight: 500,
}}
>
Sub Account
</Tab>
</TabList>
<TabPanel value='primary' sx={{ p: 0, mt: 2 }}>
<Typography level='body2' alignSelf={'start'} mb={1}>
Username
</Typography>
<Input
margin='normal'
required
fullWidth
id='email'
label='Email Address'
name='email'
autoComplete='email'
autoFocus
value={username}
onChange={e => {
setUsername(e.target.value)
}}
/>
</TabPanel>
<TabPanel value='sub' sx={{ p: 0, mt: 2 }}>
<Typography level='body2' alignSelf={'start'} mb={1}>
Primary Account Username
</Typography>
<Input
margin='normal'
required
fullWidth
id='parentUsername'
name='parentUsername'
placeholder='Enter primary account username'
autoFocus
value={parentUsername}
onChange={e => {
setParentUsername(e.target.value)
}}
/>
<Typography level='body2' alignSelf={'start'} mt={1} mb={1}>
Sub Account Username
</Typography>
<Input
margin='normal'
required
fullWidth
id='childName'
name='childName'
placeholder='Enter sub account name'
value={childName}
onChange={e => {
setChildName(e.target.value)
}}
/>
</TabPanel>
</Tabs>
<Typography level='body2' alignSelf={'start'} mb={1}>
Password:
</Typography>
<Input
@@ -327,6 +559,7 @@ const LoginView = () => {
label='Password'
type='password'
id='password'
autoComplete='password'
value={password}
onChange={e => {
setPassword(e.target.value)
@@ -347,13 +580,12 @@ const LoginView = () => {
}}
onClick={handleSubmit}
>
Sign In
{loginType === 'sub' ? 'Sign In as Sub Account' : 'Sign In'}
</Button>
<Button
type='submit'
fullWidth
size='lg'
q
variant='plain'
sx={{
width: '100%',
@@ -382,8 +614,12 @@ const LoginView = () => {
onResolve={({ provider, data }) => {
loggedWithProvider(provider, data)
}}
onReject={err => {
setError("Couldn't log in with Google, please try again")
onReject={() => {
showError({
title: 'Google Login Failed',
message:
"Couldn't log in with Google, please try again",
})
}}
>
<Button
@@ -405,8 +641,50 @@ const LoginView = () => {
</div>
</Button>
</LoginSocialGoogle>
{/* <Button
fullWidth
variant='soft'
color='neutral'
size='lg'
sx={{
mt: 1,
mb: 1,
backgroundColor: 'black',
color: 'white',
'&:hover': {
backgroundColor: '#333',
},
}}
onClick={() => {
SocialLogin.login({
provider: 'apple',
options: {
scopes: ['email', 'name'],
},
})
.then(user => {
console.log('Apple user', user)
loggedWithProvider('apple', user)
})
.catch(error => {
console.error('Apple login error:', error)
showError({
title: 'Apple Login Failed',
message:
"Couldn't log in with Apple, please try again",
})
})
}}
>
<div className='flex gap-2'>
<AppleIcon />
Continue with Apple
</div>
</Button> */}
</Box>
)}
{Capacitor.isNativePlatform() && (
<Box sx={{ width: '100%' }}>
<Button
@@ -415,16 +693,6 @@ const LoginView = () => {
size='lg'
sx={{ mt: 3, mb: 2 }}
onClick={() => {
// GoogleAuth.initialize({
// clientId: import.meta.env.VITE_APP_GOOGLE_CLIENT_ID,
// scopes: ['profile', 'email', 'openid'],
// grantOfflineAccess: true,
// })
// GoogleAuth.signIn().then(user => {
// console.log('Google user', user)
// loggedWithProvider('google', user.authentication)
// })
SocialLogin.login({
provider: 'google',
options: { scopes: ['profile', 'email', 'openid'] },
@@ -439,6 +707,45 @@ const LoginView = () => {
Continue with Google
</div>
</Button>
{/* Apple Sign In Button for Native Platforms */}
{isAppleSignInSupported && (
<Button
fullWidth
variant='soft'
color='neutral'
size='lg'
sx={{
mb: 1,
}}
onClick={() => {
SocialLogin.login({
provider: 'apple',
options: {
scopes: ['email', 'name'],
state: 'random_string',
},
})
.then(user => {
console.log('Apple user', user)
loggedWithProvider('apple', user)
})
.catch(error => {
console.error('Apple login error:', error)
showError({
title: 'Apple Login Failed',
message:
"Couldn't log in with Apple, please try again",
})
})
}}
>
<div className='flex gap-2'>
<AppleIcon />
Continue with Apple
</div>
</Button>
)}
</Box>
)}
</>
@@ -467,16 +774,31 @@ const LoginView = () => {
>
Create new account
</Button>
<Box
sx={{ display: 'flex', justifyContent: 'center', gap: 2, mt: 2 }}
>
<Button
variant='plain'
size='sm'
onClick={() => {
window.open('https://donetick.com/privacy', '_blank')
}}
>
Privacy Policy
</Button>
<Button
variant='plain'
size='sm'
onClick={() => {
window.open('https://donetick.com/terms', '_blank')
}}
>
Terms of Use
</Button>
</Box>
</Sheet>
</Box>
<Snackbar
open={error !== null}
onClose={() => setError(null)}
autoHideDuration={3000}
message={error}
>
{error}
</Snackbar>
<MFAVerificationModal
open={mfaModalOpen}

View File

@@ -5,13 +5,13 @@ import {
Button,
Input,
Link,
Modal,
ModalClose,
ModalDialog,
Stack,
Typography,
} from '@mui/joy'
import { useState } from 'react'
import { useResponsiveModal } from '../../hooks/useResponsiveModal'
import { VerifyMFA } from '../../utils/Fetcher'
const MFAVerificationModal = ({
@@ -25,7 +25,7 @@ const MFAVerificationModal = ({
const [isBackupCode, setIsBackupCode] = useState(false)
const [loading, setLoading] = useState(false)
const [error, setError] = useState('')
const { ResponsiveModal } = useResponsiveModal()
const handleVerify = async () => {
if (!verificationCode.trim()) {
setError('Please enter a verification code')
@@ -70,90 +70,88 @@ const MFAVerificationModal = ({
}
return (
<Modal open={open} onClose={handleClose}>
<ModalDialog size='sm' sx={{ maxWidth: 400 }}>
<ModalClose />
<ResponsiveModal open={open} onClose={handleClose} size='sm'>
<ModalClose />
<Box className='mb-4 text-center'>
<Security sx={{ fontSize: 48, color: 'primary.main', mb: 2 }} />
<Typography level='h4' sx={{ mb: 1 }}>
Two-Factor Authentication
</Typography>
<Typography level='body-md' sx={{ color: 'text.secondary' }}>
Enter the verification code from your authenticator app
<Box className='mb-4 text-center'>
<Security sx={{ fontSize: 48, color: 'primary.main', mb: 2 }} />
<Typography level='h4' sx={{ mb: 1 }}>
Two-Factor Authentication
</Typography>
<Typography level='body-md' sx={{ color: 'text.secondary' }}>
Enter the verification code from your authenticator app
</Typography>
</Box>
<Stack spacing={3}>
<Box>
<Typography level='body-sm' sx={{ mb: 1 }}>
{isBackupCode ? 'Backup Code' : 'Verification Code'}
</Typography>
<Input
placeholder={
isBackupCode ? 'Enter backup code' : 'Enter 6-digit code'
}
value={verificationCode}
onChange={e => setVerificationCode(e.target.value)}
onKeyPress={handleKeyPress}
sx={{
textAlign: 'center',
fontSize: '1.1em',
letterSpacing: isBackupCode ? 'normal' : '0.1em',
}}
slotProps={{
input: {
maxLength: isBackupCode ? 50 : 6,
pattern: isBackupCode ? undefined : '[0-9]*',
},
}}
startDecorator={<Smartphone />}
autoFocus
/>
</Box>
<Stack spacing={3}>
<Box>
<Typography level='body-sm' sx={{ mb: 1 }}>
{isBackupCode ? 'Backup Code' : 'Verification Code'}
</Typography>
<Input
placeholder={
isBackupCode ? 'Enter backup code' : 'Enter 6-digit code'
}
value={verificationCode}
onChange={e => setVerificationCode(e.target.value)}
onKeyPress={handleKeyPress}
sx={{
textAlign: 'center',
fontSize: '1.1em',
letterSpacing: isBackupCode ? 'normal' : '0.1em',
}}
slotProps={{
input: {
maxLength: isBackupCode ? 50 : 6,
pattern: isBackupCode ? undefined : '[0-9]*',
},
}}
startDecorator={<Smartphone />}
autoFocus
/>
</Box>
{error && (
<Alert color='danger' size='sm'>
{error}
</Alert>
)}
<Button
color='primary'
loading={loading}
onClick={handleVerify}
disabled={!verificationCode.trim()}
size='lg'
>
Verify & Sign In
</Button>
<Box className='text-center'>
<Link
component='button'
type='button'
onClick={() => {
setIsBackupCode(!isBackupCode)
setVerificationCode('')
setError('')
}}
sx={{ fontSize: 'sm' }}
>
{isBackupCode
? 'Use authenticator app instead'
: "Can't access your authenticator? Use a backup code"}
</Link>
</Box>
<Alert color='neutral' size='sm'>
<Typography level='body-xs'>
Having trouble? Make sure your authenticator app is synced and try
again. Each backup code can only be used once.
</Typography>
{error && (
<Alert color='danger' size='sm'>
{error}
</Alert>
</Stack>
</ModalDialog>
</Modal>
)}
<Button
color='primary'
loading={loading}
onClick={handleVerify}
disabled={!verificationCode.trim()}
size='lg'
>
Verify & Sign In
</Button>
<Box className='text-center'>
<Link
component='button'
type='button'
onClick={() => {
setIsBackupCode(!isBackupCode)
setVerificationCode('')
setError('')
}}
sx={{ fontSize: 'sm' }}
>
{isBackupCode
? 'Use authenticator app instead'
: "Can't access your authenticator? Use a backup code"}
</Link>
</Box>
<Alert color='neutral' size='sm'>
<Typography level='body-xs'>
Having trouble? Make sure your authenticator app is synced and try
again. Each backup code can only be used once.
</Typography>
</Alert>
</Stack>
</ResponsiveModal>
)
}

View File

@@ -7,38 +7,38 @@ import {
FormHelperText,
Input,
Sheet,
Snackbar,
Typography,
} from '@mui/joy'
import { useQueryClient } from '@tanstack/react-query'
import React from 'react'
import { useNavigate } from 'react-router-dom'
import Logo from '../../Logo'
import { useNotification } from '../../service/NotificationProvider'
import { login, signUp } from '../../utils/Fetcher'
const SignupView = () => {
const [username, setUsername] = React.useState('')
const [password, setPassword] = React.useState('')
const Navigate = useNavigate()
const queryClient = useQueryClient()
const [displayName, setDisplayName] = React.useState('')
const [email, setEmail] = React.useState('')
const [usernameError, setUsernameError] = React.useState('')
const [passwordError, setPasswordError] = React.useState('')
const [emailError, setEmailError] = React.useState('')
const [displayNameError, setDisplayNameError] = React.useState('')
const [error, setError] = React.useState(null)
const [snackbarOpen, setSnackbarOpen] = React.useState(false)
const [snackbarMessage, setSnackbarMessage] = React.useState('')
const { showError } = useNotification()
const handleLogin = (username, password) => {
login(username, password).then(response => {
if (response.status === 200) {
response.json().then(res => {
localStorage.setItem('ca_token', res.token)
localStorage.setItem('ca_expiration', res.expire)
setTimeout(() => {
// TODO: not sure if there is a race condition here
// but on first sign up it renavigates to login.
Navigate('/my/chores')
}, 500)
localStorage.setItem('token', res.token)
localStorage.setItem('token_expiry', res.expire)
// Invalidate user profile queries to ensure fresh data
queryClient.invalidateQueries(['userProfile'])
Navigate('/chores')
})
} else {
console.log('Login failed', response)
@@ -85,10 +85,10 @@ const SignupView = () => {
isValid = false
}
// username should only contain letters , numbers , dot and dash:
if (!/^[a-zA-Z0-9.-]+$/.test(username)) {
// username should only contain lowercase letters, dot and dash:
if (!/^[a-z.-]+$/.test(username)) {
setUsernameError(
'Username can only contain letters, numbers, dot and dash',
'Username can only contain lowercase letters, dot and dash',
)
isValid = false
}
@@ -104,11 +104,17 @@ const SignupView = () => {
if (response.status === 201) {
handleLogin(username, password)
} else if (response.status === 403) {
setError('Signup disabled, please contact admin')
showError({
title: 'Signup Failed',
message: 'Signup disabled, please contact admin',
})
} else {
console.log('Signup failed')
response.json().then(res => {
setError(res.error)
showError({
title: 'Signup Failed',
message: res.error || 'An error occurred during signup',
})
})
}
})
@@ -240,12 +246,18 @@ const SignupView = () => {
<FormControl error={displayNameError}>
<FormHelperText>{displayNameError}</FormHelperText>
</FormControl>
<Typography
level='body2'
sx={{ mt: 2, mb: 1, textAlign: 'center', color: 'text.secondary' }}
>
By signing up, you agree to our Terms of Service and Privacy Policy
</Typography>
<Button
// type='submit'
size='lg'
fullWidth
variant='solid'
sx={{ mt: 3, mb: 1 }}
sx={{ mt: 1, mb: 1 }}
onClick={handleSubmit}
>
Sign Up
@@ -262,16 +274,31 @@ const SignupView = () => {
>
Login
</Button>
<Box
sx={{ display: 'flex', justifyContent: 'center', gap: 2, mt: 2 }}
>
<Button
variant='plain'
size='sm'
onClick={() => {
window.open('https://donetick.com/privacy-policy', '_blank')
}}
>
Privacy Policy
</Button>
<Button
variant='plain'
size='sm'
onClick={() => {
window.open('https://donetick.com/terms', '_blank')
}}
>
Terms of Use
</Button>
</Box>
</Sheet>
</Box>
<Snackbar
open={error !== null}
onClose={() => setError(null)}
autoHideDuration={5000}
message={error}
>
{error}
</Snackbar>
</Container>
)
}

View File

@@ -7,13 +7,13 @@ import {
FormHelperText,
Input,
Sheet,
Snackbar,
Typography,
} from '@mui/joy'
import { useState } from 'react'
import { useNavigate, useSearchParams } from 'react-router-dom'
import Logo from '../../Logo'
import { useNotification } from '../../service/NotificationProvider'
import { ChangePassword } from '../../utils/Fetcher'
const UpdatePasswordView = () => {
@@ -24,8 +24,7 @@ const UpdatePasswordView = () => {
const [passworConfirmationError, setPasswordConfirmationError] =
useState(null)
const [searchParams] = useSearchParams()
const [updateStatusOk, setUpdateStatusOk] = useState(null)
const { showError, showNotification } = useNotification()
const verifiticationCode = searchParams.get('c')
@@ -55,16 +54,27 @@ const UpdatePasswordView = () => {
const response = await ChangePassword(verifiticationCode, password)
if (response.ok) {
setUpdateStatusOk(true)
showNotification({
type: 'success',
title: 'Password Updated',
message:
'Your password has been updated successfully. Redirecting to login...',
})
// wait 3 seconds and then redirect to login:
setTimeout(() => {
navigate('/login')
}, 3000)
} else {
setUpdateStatusOk(false)
showError({
title: 'Password Update Failed',
message: 'Failed to update password, please try again later',
})
}
} catch (error) {
setUpdateStatusOk(false)
showError({
title: 'Password Update Failed',
message: 'Failed to update password, please try again later',
})
}
}
return (
@@ -169,15 +179,6 @@ const UpdatePasswordView = () => {
</Button>
</Sheet>
</Box>
<Snackbar
open={updateStatusOk === false}
autoHideDuration={6000}
onClose={() => {
setUpdateStatusOk(null)
}}
>
Password update failed, try again later
</Snackbar>
</Container>
)
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,4 +1,5 @@
import {
Archive,
CalendarMonth,
CancelScheduleSend,
Check,
@@ -6,11 +7,16 @@ import {
CloseFullscreen,
Edit,
History,
HourglassEmpty,
LowPriority,
OpenInFull,
PeopleAlt,
Person,
PlayArrow,
SwitchAccessShortcut,
ThumbDown,
ThumbUp,
Unarchive,
} from '@mui/icons-material'
import {
Box,
@@ -33,26 +39,40 @@ import {
Typography,
} from '@mui/joy'
import { Divider } from '@mui/material'
import { useQueryClient } from '@tanstack/react-query'
import moment from 'moment'
import { useEffect, useState } from 'react'
import { useNavigate, useParams, useSearchParams } from 'react-router-dom'
import { useImpersonateUser } from '../../contexts/ImpersonateUserContext.jsx'
import { useChoreDetails } from '../../queries/ChoreQueries.jsx'
import { useCircleMembers } from '../../queries/UserQueries.jsx'
import { notInCompletionWindow } from '../../utils/Chores.jsx'
import {
useChoreTimer,
useDeleteTimeSession,
usePauseChore,
useResetChoreTimer,
useStartChore,
} from '../../queries/TimeQueries'
import { useCircleMembers, useUserProfile } from '../../queries/UserQueries.jsx'
import { ChoreStatus, notInCompletionWindow } from '../../utils/Chores.jsx'
import { getTextColorFromBackgroundColor } from '../../utils/Colors.jsx'
import {
ApproveChore,
GetChoreDetailById,
MarkChoreComplete,
RejectChore,
SkipChore,
UnArchiveChore,
UpdateChorePriority,
} from '../../utils/Fetcher'
import Priorities from '../../utils/Priorities'
import { getSafeBottomPadding } from '../../utils/SafeAreaUtils.js'
import ConfirmationModal from '../Modals/Inputs/ConfirmationModal'
import LoadingComponent from '../components/Loading.jsx'
import RichTextEditor from '../components/RichTextEditor.jsx'
import SubTasks from '../components/SubTask.jsx'
import TimePassedCard from './TimePassedCard.jsx'
import TimerSplitButton from './TimerSplitButton.jsx'
const ChoreView = () => {
const [chore, setChore] = useState({})
@@ -61,6 +81,7 @@ const ChoreView = () => {
const [infoCards, setInfoCards] = useState([])
const { choreId } = useParams()
const [note, setNote] = useState(null)
const queryClient = useQueryClient()
const [searchParams] = useSearchParams()
@@ -68,21 +89,25 @@ const ChoreView = () => {
const [timeoutId, setTimeoutId] = useState(null)
const [secondsLeftToCancel, setSecondsLeftToCancel] = useState(null)
const [completedDate, setCompletedDate] = useState(null)
const [confirmModelConfig, setConfirmModelConfig] = useState({})
const [confirmModelConfig, setConfirmModelConfig] = useState({
isOpen: false,
})
const [chorePriority, setChorePriority] = useState(null)
const [isDescriptionOpen, setIsDescriptionOpen] = useState(false)
const {
data: circleMembersData,
isLoading: isCircleMembersLoading,
handleRefetch: handleCircleMembersRefetch,
} = useCircleMembers()
const [timerActionConfig, setTimerActionConfig] = useState({ isOpen: false })
const { data: circleMembersData, isLoading: isCircleMembersLoading } =
useCircleMembers()
const { data: userProfile } = useUserProfile()
const { impersonatedUser } = useImpersonateUser()
const {
data: choreData,
isLoading: isChoreLoading,
refetch: refetchChore,
} = useChoreDetails(choreId)
const { data: choreData, isLoading: isChoreLoading } =
useChoreDetails(choreId)
const startChore = useStartChore()
const pauseChore = usePauseChore()
const deleteTimeSession = useDeleteTimeSession()
const resetChoreTimer = useResetChoreTimer()
const { data: choreTimer } = useChoreTimer(choreId)
useEffect(() => {
if (!choreData || !choreData.res || !circleMembersData) {
@@ -107,8 +132,10 @@ const ChoreView = () => {
const handleUpdatePriority = priority => {
UpdateChorePriority(choreId, priority.value).then(response => {
if (response.ok) {
response.json().then(data => {
response.json().then(() => {
setChorePriority(priority)
// Invalidate chores cache to refetch data
queryClient.invalidateQueries(['chores'])
})
}
})
@@ -120,11 +147,13 @@ const ChoreView = () => {
icon: <PeopleAlt />,
title: 'Assignment',
text: `Assigned: ${
performers.find(p => p.id === chore.assignedTo)?.displayName || 'N/A'
performers.find(p => p.userId === chore.assignedTo)?.displayName ||
'N/A'
}`,
subtext: ` Last: ${
chore.lastCompletedDate
? performers.find(p => p.id === chore.lastCompletedBy)?.displayName
? performers.find(p => p.userId === chore.lastCompletedBy)
?.displayName
: '--'
}`,
},
@@ -152,7 +181,8 @@ const ChoreView = () => {
icon: <Person />,
title: 'Details',
subtext: `Created By: ${
performers.find(p => p.id === chore.createdBy)?.displayName || 'N/A'
performers.find(p => p.userId === chore.createdBy)?.displayName ||
'N/A'
}`,
},
]
@@ -195,6 +225,8 @@ const ChoreView = () => {
clearInterval(countdownInterval) // Ensure to clear this interval as well
setTimeoutId(null)
setSecondsLeftToCancel(null)
// Invalidate chores cache to refetch data
queryClient.invalidateQueries(['chores'])
})
.then(() => {
// refetch the chore details
@@ -216,10 +248,145 @@ const ChoreView = () => {
response.json().then(data => {
const newChore = data.res
setChore(newChore)
// Invalidate chores cache to refetch data
queryClient.invalidateQueries(['chores'])
})
}
})
}
const handleChoreStart = () => {
startChore.mutate(choreId, {
onSuccess: data => {
const newChore = {
...chore,
...data.res,
}
setChore(newChore)
},
})
}
const handleChorePause = () => {
pauseChore.mutate(choreId, {
onSuccess: data => {
const newChore = {
...chore,
...data.res,
}
setChore(newChore)
},
})
}
const handleResetTimer = () => {
setTimerActionConfig({
isOpen: true,
title: 'Reset Timer',
message:
'Are you sure you want to reset the timer? This will clear all time records since you started the task.',
confirmText: 'Reset Timer',
cancelText: 'Cancel',
onClose: confirmed => {
if (confirmed) {
resetChoreTimer.mutate(choreId, {
onSuccess: data => {
const newChore = {
...chore,
...data.res,
}
setChore(newChore)
},
})
}
setTimerActionConfig({})
},
})
}
const handleClearAllTime = () => {
setTimerActionConfig({
isOpen: true,
title: 'Clear All Time Records',
message:
'This will permanently delete all timers for this task and set it back to "not started".',
confirmText: 'Clear All Time',
cancelText: 'Cancel',
onClose: async confirmed => {
if (confirmed) {
if (choreTimer?.res?.id) {
deleteTimeSession.mutate(
{ choreId, sessionId: choreTimer.res.id },
{
onSuccess: data => {
const newChore = {
...chore,
...data.res,
}
setChore(newChore)
},
},
)
}
}
setTimerActionConfig({})
},
})
}
const handleApproveChore = () => {
ApproveChore(choreId).then(response => {
if (response.ok) {
response.json().then(data => {
setChore(data.res)
// Invalidate chores cache to refetch data
queryClient.invalidateQueries(['chores'])
})
}
})
}
const handleRejectChore = () => {
RejectChore(choreId).then(response => {
if (response.ok) {
response.json().then(data => {
setChore(data.res)
// Invalidate chores cache to refetch data
queryClient.invalidateQueries(['chores'])
})
}
})
}
const handleUnarchiveChore = () => {
UnArchiveChore(choreId).then(response => {
if (response.ok) {
response.json().then(data => {
setChore({ ...chore, isActive: true })
// Invalidate chores cache to refetch data
queryClient.invalidateQueries(['chores'])
})
}
})
}
// Check if the current user can approve/reject (admin, manager, or task owner)
const canApproveReject = () => {
if (!circleMembersData?.res || !chore) return false
const currentUser = circleMembersData.res.find(
member => member.userId === (impersonatedUser?.userId || userProfile?.id),
)
// User can approve/reject if they are:
// 1. Admin or manager of the circle
// 2. Owner/creator of the task
return (
currentUser?.role === 'admin' ||
currentUser?.role === 'manager' ||
chore.createdBy === (impersonatedUser?.userId || userProfile?.id)
)
}
if (isChoreLoading || isCircleMembersLoading) {
// while loading the chore or circle members, return a loading state
return <LoadingComponent />
@@ -256,6 +423,16 @@ const ChoreView = () => {
>
{chore.name}
</Typography>
{chore.isActive === false && (
<Chip
startDecorator={<Archive />}
size='md'
color='warning'
sx={{ mb: 1 }}
>
Archived
</Chip>
)}
<Chip startDecorator={<CalendarMonth />} size='md' sx={{ mb: 1 }}>
{chore.nextDueDate
? `Due at ${moment(chore.nextDueDate).format('MM/DD/YYYY hh:mm A')}`
@@ -296,8 +473,23 @@ const ChoreView = () => {
mb: 1,
}}
>
{[ChoreStatus.ACTIVE, ChoreStatus.PAUSED].includes(chore.status) && (
<Grid xs={12}>
<TimePassedCard
chore={chore}
handleAction={action => {
if (action === 'pause') {
handleChorePause()
} else if (action === 'resume') {
handleChoreStart()
}
}}
onShowDetails={() => navigate(`/chores/${choreId}/timer`)}
/>
</Grid>
)}
{infoCards.map((card, index) => (
<Grid item xs={6} sm={6} key={index}>
<Grid xs={6} sm={6} key={index}>
<Card
variant='soft'
sx={{
@@ -306,6 +498,7 @@ const ChoreView = () => {
px: 2,
py: 1,
minHeight: 90,
height: '100%',
// change from space-between to start:
justifyContent: 'start',
}}
@@ -363,6 +556,7 @@ const ChoreView = () => {
>
<Dropdown>
<MenuButton
disabled={chore.isActive === false}
color={
chorePriority?.name === 'P1'
? 'danger'
@@ -375,8 +569,8 @@ const ChoreView = () => {
alignItems: 'center',
justifyContent: 'center',
p: 1,
width: '100%',
}}
fullWidth
variant='plain'
>
{chorePriority ? chorePriority.icon : <LowPriority />}
@@ -423,6 +617,7 @@ const ChoreView = () => {
color='neutral'
variant='plain'
fullWidth
disabled={chore.isActive === false}
onClick={() => {
navigate(`/chores/${choreId}/history`)
}}
@@ -441,6 +636,7 @@ const ChoreView = () => {
color='neutral'
variant='plain'
fullWidth
disabled={chore.isActive === false}
sx={{
// top right of the card:
flexDirection: 'column',
@@ -525,6 +721,7 @@ const ChoreView = () => {
>
<SubTasks
editMode={false}
performers={performers}
tasks={chore.subTasks}
setTasks={tasks => {
setChore({
@@ -544,17 +741,19 @@ const ChoreView = () => {
p: 2,
borderRadius: 'md',
boxShadow: 'sm',
paddingBottom: getSafeBottomPadding(2, '8px'),
}}
variant='soft'
>
<Typography level='body-md' sx={{ mb: 1 }}>
Complete the task
Task Actions
</Typography>
<FormControl size='sm'>
<Checkbox
checked={note !== null}
size='lg'
disabled={chore.isActive === false}
onChange={e => {
if (e.target.checked) {
setNote('')
@@ -571,35 +770,30 @@ const ChoreView = () => {
alignItems: 'center',
}}
>
Add Additional Notes
Add a note
</Typography>
}
/>
</FormControl>
{note !== null && (
<Input
fullWidth
multiline
label='Additional Notes'
placeholder='note or information about the task'
value={note || ''}
onChange={e => {
if (e.target.value.trim() === '') {
setNote(null)
return
}
setNote(e.target.value)
}}
sx={{
mb: 1,
}}
/>
<Box sx={{ mb: 1 }}>
<Typography level='body-sm' sx={{ mb: 1 }}>
Additional Notes:
</Typography>
<RichTextEditor
value={note || ''}
onChange={setNote}
entityType={'chore_completion_note'}
placeholder='Add a note about the completion...'
/>
</Box>
)}
<FormControl size='sm'>
<Checkbox
checked={completedDate !== null}
size='lg'
disabled={chore.isActive === false}
onChange={e => {
if (e.target.checked) {
setCompletedDate(
@@ -624,7 +818,7 @@ const ChoreView = () => {
alignItems: 'center',
}}
>
Specify completion date
Set custom completion time
</Typography>
}
/>
@@ -640,65 +834,188 @@ const ChoreView = () => {
/>
)}
<Box
sx={{
display: 'flex',
flexDirection: 'row',
gap: 1,
alignContent: 'center',
justifyContent: 'center',
}}
>
<Button
fullWidth
size='lg'
onClick={handleTaskCompletion}
disabled={
isPendingCompletion ||
notInCompletionWindow(chore) ||
(chore.lastCompletedDate !== null &&
chore.frequencyType === 'once')
}
color={isPendingCompletion ? 'danger' : 'success'}
startDecorator={<Check />}
{chore.isActive === false ? (
// Archived chore - only show unarchive button
<Box
sx={{
flex: 4,
display: 'flex',
flexDirection: 'column',
gap: 1,
alignContent: 'center',
justifyContent: 'center',
}}
>
<Box>Mark as done</Box>
</Button>
<Button
fullWidth
size='lg'
onClick={handleUnarchiveChore}
color='primary'
startDecorator={<Unarchive />}
>
Unarchive
</Button>
</Box>
) : (
// Active chore - show all normal actions
<Box
sx={{
display: 'flex',
flexDirection: 'column',
gap: 1,
alignContent: 'center',
justifyContent: 'center',
}}
>
<Box
sx={{
display: 'flex',
flexDirection: 'row',
gap: 1,
alignContent: 'center',
justifyContent: 'center',
mb: 1,
}}
>
{chore.status === 3 ? (
// Pending approval: Show approve/reject for admins/managers/owners, grayed out button for others
canApproveReject() ? (
<>
<Button
fullWidth
size='lg'
onClick={handleApproveChore}
color='success'
startDecorator={<ThumbUp />}
sx={{
flex: 1,
}}
>
Approve
</Button>
<Button
fullWidth
size='lg'
onClick={handleRejectChore}
color='danger'
startDecorator={<ThumbDown />}
sx={{
flex: 1,
}}
>
<Box>Reject</Box>
</Button>
</>
) : (
<Button
fullWidth
size='lg'
disabled={true}
color='neutral'
startDecorator={<HourglassEmpty />}
>
<Box>Pending Approval</Box>
</Button>
)
) : (
// Normal completion flow
<>
<Button
fullWidth
size='lg'
onClick={handleTaskCompletion}
disabled={
isPendingCompletion ||
notInCompletionWindow(chore) ||
(chore.lastCompletedDate !== null &&
chore.frequencyType === 'once')
}
color={isPendingCompletion ? 'danger' : 'success'}
startDecorator={<Check />}
sx={{
flex: 4,
}}
>
<Box>Mark as done</Box>
</Button>
<Button
fullWidth
size='lg'
onClick={() => {
setConfirmModelConfig({
isOpen: true,
title: 'Skip Task',
<Button
fullWidth
size='lg'
onClick={() => {
setConfirmModelConfig({
isOpen: true,
title: 'Skip Task',
message: 'Are you sure you want to skip this task?',
message: 'Are you sure you want to skip this task?',
confirmText: 'Skip',
cancelText: 'Cancel',
onClose: confirmed => {
if (confirmed) {
handleSkippingTask()
confirmText: 'Skip',
cancelText: 'Cancel',
onClose: confirmed => {
if (confirmed) {
handleSkippingTask()
}
setConfirmModelConfig({})
},
})
}}
disabled={
chore.lastCompletedDate !== null &&
chore.frequencyType === 'once'
}
startDecorator={<SwitchAccessShortcut />}
sx={{
flex: 1,
}}
>
<Box>Skip</Box>
</Button>
</>
)}
</Box>
{/* 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={chore}
onAction={action => {
if (action === 'pause') {
handleChorePause()
} else if (action === 'resume') {
handleChoreStart()
}
setConfirmModelConfig({})
},
})
}}
disabled={
chore.lastCompletedDate !== null && chore.frequencyType === 'once'
}
startDecorator={<SwitchAccessShortcut />}
sx={{
flex: 1,
}}
>
<Box>Skip</Box>
</Button>
</Box>
}}
onShowDetails={() => navigate(`/chores/${choreId}/timer`)}
onResetTimer={handleResetTimer}
onClearAllTime={handleClearAllTime}
fullWidth
/>
) : chore.status === ChoreStatus.PENDING_APPROVAL ? (
<></>
) : (
<Button
size='lg'
onClick={() => {
handleChoreStart()
}}
variant='soft'
color='success'
disabled={
chore.lastCompletedDate !== null &&
chore.frequencyType === 'once'
}
startDecorator={<PlayArrow />}
sx={{
flex: 1,
}}
>
Start
</Button>
)}
</Box>
)}
<Snackbar
open={isPendingCompletion}
@@ -726,6 +1043,7 @@ const ChoreView = () => {
</Typography>
</Snackbar>
<ConfirmationModal config={confirmModelConfig} />
<ConfirmationModal config={timerActionConfig} />
</Card>
</Container>
)

View File

@@ -17,8 +17,9 @@ import {
Typography,
} from '@mui/joy'
import moment from 'moment'
import { useContext, useEffect, useState } from 'react'
import { UserContext } from '../../contexts/UserContext'
import { useEffect } from 'react'
import { useUserProfile } from '../../queries/UserQueries'
import { isPlusAccount } from '../../utils/Helpers'
import ThingTriggerSection from './ThingTriggerSection'
@@ -61,6 +62,51 @@ const DAYS = [
'saturday',
'sunday',
]
const WEEK_PATTERNS = {
every_week: 'Every week',
week_of_month: 'Specific occurrences in the month',
}
const DAY_OCCURRENCE_OPTIONS = [
{ value: 1, label: '1st occurrence' },
{ value: 2, label: '2nd occurrence' },
{ value: 3, label: '3rd occurrence' },
{ value: 4, label: '4th occurrence' },
{ value: -1, label: 'Last occurrence' },
]
// Helper function to generate schedule preview text
const generateSchedulePreview = metadata => {
if (!metadata?.days?.length) return ''
const dayNames = metadata.days
.map(day => day.charAt(0).toUpperCase() + day.slice(1, 3))
.join(', ')
const timeStr = metadata.time
? moment(metadata.time).format('h:mm A')
: '6:00 PM'
if (metadata.weekPattern === 'every_week' || !metadata.weekPattern) {
return `Every ${dayNames} at ${timeStr}`
}
if (
metadata.weekPattern === 'week_of_month' &&
metadata.occurrences?.length
) {
const occurrenceStr = metadata.occurrences
.map(w => {
if (w === -1) return 'last'
return `${w}${w === 1 ? 'st' : w === 2 ? 'nd' : w === 3 ? 'rd' : 'th'}`
})
.join(', ')
return `Every ${occurrenceStr} ${dayNames} of the month at ${timeStr}`
}
return `Every ${dayNames} at ${timeStr}`
}
const RepeatOnSections = ({
frequencyType,
frequency,
@@ -68,7 +114,6 @@ const RepeatOnSections = ({
frequencyMetadata,
onFrequencyMetadataUpdate,
}) => {
const [intervalUnit, setIntervalUnit] = useState('days')
// if time on frequencyMetadata is not set, try to set it to the nextDueDate if available,
// otherwise set it to 18:00 of the current day
useEffect(() => {
@@ -77,13 +122,30 @@ const RepeatOnSections = ({
moment(new Date()).format('YYYY-MM-DD') + 'T' + '18:00',
).format()
}
}, [frequencyMetadata])
// Initialize weekPattern if not set
if (!frequencyMetadata?.weekPattern) {
onFrequencyMetadataUpdate({
...frequencyMetadata,
weekPattern: 'every_week',
occurrences: [],
})
}
}, [frequencyMetadata, onFrequencyMetadataUpdate])
const timePickerComponent = (
<Grid item sm={12} sx={{ display: 'flex', alignItems: 'center' }}>
<Typography level='h5'>At: </Typography>
<Grid
item
sm={12}
sx={{
display: 'flex',
direction: 'column',
flexDirection: 'column',
}}
>
<Typography level='h5'>Time of day: </Typography>
<Input
type='time'
sx={{ width: '150px' }}
defaultValue={moment(frequencyMetadata?.time).format('HH:mm')}
onChange={e => {
onFrequencyMetadataUpdate({
@@ -117,13 +179,16 @@ const RepeatOnSections = ({
onFrequencyUpdate(e.target.value)
}}
/>
<Select placeholder='Unit' value={intervalUnit}>
<Select
placeholder='Unit'
value={frequencyMetadata?.unit || 'days'}
sx={{ ml: 1 }}
>
{['hours', 'days', 'weeks', 'months', 'years'].map(item => (
<Option
key={item}
value={item}
onClick={() => {
setIntervalUnit(item)
onFrequencyMetadataUpdate({
...frequencyMetadata,
unit: item,
@@ -189,6 +254,8 @@ const RepeatOnSections = ({
onFrequencyMetadataUpdate({
...frequencyMetadata,
days: [],
weekPattern: 'every_week',
occurrences: [],
})
} else {
onFrequencyMetadataUpdate({
@@ -206,6 +273,140 @@ const RepeatOnSections = ({
</Button>
</Card>
</Grid>
<Grid item sm={12} sx={{ display: 'flex', alignItems: 'center' }}>
<Box>
<RadioGroup
value={frequencyMetadata?.weekPattern || 'every_week'}
onChange={event => {
const newPattern = event.target.value
onFrequencyMetadataUpdate({
...frequencyMetadata,
weekPattern: newPattern,
occurrences:
newPattern === 'every_week'
? []
: frequencyMetadata?.occurrences || [],
})
}}
sx={{ gap: 1, '& > div': { p: 1 } }}
>
{Object.entries(WEEK_PATTERNS).map(([value, label]) => (
<FormControl key={value}>
<Radio value={value} label={label} variant='soft' />
{value === 'every_week' && (
<FormHelperText>
Task repeats every week on selected days
</FormHelperText>
)}
{value === 'week_of_month' && (
<FormHelperText>
Task repeats on specific day occurrences each month
(e.g., 1st Monday, 3rd Friday)
</FormHelperText>
)}
</FormControl>
))}
</RadioGroup>
{frequencyMetadata?.weekPattern === 'week_of_month' && (
<Box mt={2}>
<Typography level='body-sm' mb={1}>
Select which occurrences of the selected days:
</Typography>
<Typography level='body-xs' color='neutral' mb={2}>
Example: "1st Monday" means the first Monday of each month
</Typography>
<Card>
<List
orientation='horizontal'
wrap
sx={{
'--List-gap': '8px',
'--ListItem-radius': '20px',
}}
>
{DAY_OCCURRENCE_OPTIONS.map(option => (
<ListItem key={option.value}>
<Checkbox
checked={
frequencyMetadata?.occurrences?.includes(
option.value,
) || false
}
onChange={() => {
const currentOccurrences =
frequencyMetadata?.occurrences || []
const newOccurrences =
currentOccurrences.includes(option.value)
? currentOccurrences.filter(
w => w !== option.value,
)
: [...currentOccurrences, option.value]
onFrequencyMetadataUpdate({
...frequencyMetadata,
occurrences: newOccurrences.sort((a, b) => {
if (a === -1) return 1 // Last occurrence goes to end
if (b === -1) return -1
return a - b
}),
})
}}
overlay
disableIcon
variant='soft'
label={option.label}
/>
</ListItem>
))}
</List>
<Button
size='sm'
variant='soft'
color='neutral'
onClick={() => {
if (
frequencyMetadata?.occurrences?.length ===
DAY_OCCURRENCE_OPTIONS.length
) {
onFrequencyMetadataUpdate({
...frequencyMetadata,
occurrences: [],
})
} else {
onFrequencyMetadataUpdate({
...frequencyMetadata,
occurrences: DAY_OCCURRENCE_OPTIONS.map(
option => option.value,
),
})
}
}}
overlay
disableIcon
>
{frequencyMetadata?.occurrences?.length ===
DAY_OCCURRENCE_OPTIONS.length
? 'Unselect All'
: 'Select All'}
</Button>
</Card>
</Box>
)}
{/* Quarter week pattern removed - doesn't make sense with Nth day approach */}
{/* Live Preview */}
{frequencyMetadata?.days?.length > 0 && (
<Card mt={2} p={2}>
<Typography level='body-sm' color='primary'>
{generateSchedulePreview(frequencyMetadata)}
</Typography>
</Card>
)}
</Box>
</Grid>
{timePickerComponent}
</>
)
@@ -337,10 +538,11 @@ const RepeatSection = ({
isAttemptToSave,
selectedThing,
}) => {
const { userProfile } = useContext(UserContext)
const { data: userProfile } = useUserProfile()
return (
<Box mt={2}>
<Typography level='h4'>Repeat :</Typography>
<Typography level='h4'>Repeat:</Typography>
<FormControl sx={{ mt: 1 }}>
<Checkbox
onChange={e => {
@@ -457,6 +659,8 @@ const RepeatSection = ({
onFrequencyMetadataUpdate({
...frequencyMetadata,
days: [],
weekPattern: 'every_week',
weekNumbers: [],
})
} else if (item === 'day_of_the_month') {
onFrequencyMetadataUpdate({

View File

@@ -7,9 +7,6 @@ import {
Chip,
FormControl,
Input,
ListItem,
ListItemContent,
ListItemDecorator,
Option,
Select,
TextField,
@@ -113,7 +110,7 @@ const ThingTriggerSection = ({
onChange={(e, newValue) => setSelectedThing(newValue)}
getOptionLabel={option => option.name}
renderOption={(props, option) => (
<ListItem {...props}>
<Box {...props}>
<Box
sx={{
display: 'flex',
@@ -123,19 +120,19 @@ const ThingTriggerSection = ({
p: 1,
}}
>
<ListItemDecorator sx={{ alignSelf: 'flex-start' }}>
<Box sx={{ alignSelf: 'flex-start' }}>
<Typography level='body-lg' textColor='primary'>
{option.name}
</Typography>
</ListItemDecorator>
<ListItemContent>
</Box>
<Box>
<Typography level='body2' textColor='text.secondary'>
<Chip>type: {option.type}</Chip>{' '}
<Chip>state: {option.state}</Chip>
</Typography>
</ListItemContent>
</Box>
</Box>
</ListItem>
</Box>
)}
renderInput={params => (
<TextField {...params} label='Select a thing' />

View File

@@ -0,0 +1,233 @@
import {
Flag,
OpenInFull,
Pause,
PlayArrow,
Schedule,
} from '@mui/icons-material'
import { Box, Card, Chip, Typography } from '@mui/joy'
import { useEffect, useRef, useState } from 'react'
import { useNavigate } from 'react-router-dom'
const TimePassedCard = ({ chore, handleAction, onShowDetails }) => {
const navigate = useNavigate()
const [time, setTime] = useState(0)
const [shouldAnimate, setShouldAnimate] = useState(false)
const [prevStatus, setPrevStatus] = useState(null) // Initialize as null
const intervalRef = useRef(null)
// Track status changes to trigger animation
useEffect(() => {
// Only trigger animation if we have a previous status and it changed from 0 to 1
if (prevStatus !== null && prevStatus === 0 && chore.status === 1) {
setShouldAnimate(true)
// Reset animation after it completes
const timer = setTimeout(() => setShouldAnimate(false), 300)
return () => clearTimeout(timer)
}
setPrevStatus(chore.status)
}, [chore.status, prevStatus])
// Single effect to handle both time calculation and timer
useEffect(() => {
// Calculate current time based on chore data
const calculateCurrentTime = () => {
if (chore.timerUpdatedAt && chore.status === 1) {
// Active session: base duration + time since start
const timeSinceStart = Math.floor(
(Date.now() - new Date(chore.timerUpdatedAt).getTime()) / 1000,
)
return timeSinceStart + (chore.duration || 0)
}
// Not active: just return accumulated duration
return chore.duration || 0
}
// Clear any existing timer first
if (intervalRef.current) {
clearInterval(intervalRef.current)
intervalRef.current = null
}
// Set initial time
const currentTime = calculateCurrentTime()
setTime(currentTime)
// Handle timer based on status
if (chore.status === 1) {
// Active: start interval timer
intervalRef.current = setInterval(() => {
const newTime = calculateCurrentTime()
setTime(newTime)
}, 1000)
}
// Cleanup function
return () => {
if (intervalRef.current) {
clearInterval(intervalRef.current)
intervalRef.current = null
}
}
}, [chore.status, chore.duration, chore.timerUpdatedAt])
const formatTime = seconds => {
const hours = Math.floor(seconds / 3600)
const minutes = Math.floor((seconds % 3600) / 60)
const secs = seconds % 60
return `${hours.toString().padStart(2, '0')}:${minutes.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`
}
return (
<Card
variant='soft'
sx={{
borderRadius: 'md',
boxShadow: 1,
gap: 0,
px: 2,
py: 1,
height: '75px',
alignItems: 'center',
...(shouldAnimate && {
animation: 'slideInUp 0.3s ease-out',
}),
'@keyframes slideInUp': {
'0%': {
opacity: 0,
transform: 'translateY(20px) scale(0.95)',
},
'100%': {
opacity: 1,
transform: 'translateY(0) scale(1)',
},
},
transition: 'all 0.3s ease',
cursor: 'pointer',
}}
onClick={e => {
// if this click on this element itself and not its children:
if (e.target !== e.currentTarget) return
navigate('./timer')
}}
>
<OpenInFull
sx={{
position: 'absolute',
top: 8,
right: 8,
zIndex: 2,
cursor: 'pointer',
color: 'text.secondary',
fontSize: '15px',
'&:hover': { color: 'primary.main' },
}}
onClick={e => {
e.stopPropagation()
navigate('./timer')
}}
></OpenInFull>
<Typography
level='h4'
sx={{
fontWeight: 600,
color: chore.status === 1 ? 'success.main' : 'text.primary',
// mb: 0.5,
mb: 0.5,
transition: 'all 0.3s ease',
transform: chore.status === 1 ? 'scale(1.40)' : 'scale(1)',
cursor: 'pointer',
'&:hover': {
textDecoration: 'underline',
},
}}
onClick={() => onShowDetails?.()}
>
{formatTime(time)}
</Typography>
{/* Status and info section */}
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mt: 0 }}>
{/* Show start time and user if active */}
{chore.status === 1 ? (
<Chip
variant='soft'
color='warning'
size='md'
startDecorator={<Pause sx={{ fontSize: 14 }} />}
onClick={() => {
handleAction('pause')
}}
>
Pause
</Chip>
) : (
<Chip
variant='solid'
color='success'
size='md'
startDecorator={<PlayArrow sx={{ fontSize: 14 }} />}
onClick={() => {
handleAction('resume')
}}
>
Resume
</Chip>
)}
{/* Chips for start time and current session */}
{chore.status === 1 && chore.timerUpdatedAt && (
<>
{/* Original start time */}
{chore.startTime && (
<Chip
variant='plain'
color='primary'
size='md'
startDecorator={<Flag sx={{ fontSize: 14 }} />}
>
{new Date(chore.startTime).toLocaleTimeString([], {
hour: '2-digit',
minute: '2-digit',
})}
</Chip>
)}
{/* Current session start time */}
{chore.timerUpdatedAt !== chore.startTime && (
<Chip
variant='plain'
color='neutral'
size='md'
startDecorator={<Schedule sx={{ fontSize: 14 }} />}
>
{new Date(chore.timerUpdatedAt).toLocaleTimeString([], {
hour: '2-digit',
minute: '2-digit',
})}
</Chip>
)}
</>
)}
{/* Chips for paused state */}
{chore.status === 2 && (
<Chip
variant='plain'
color='neutral'
size='md'
startDecorator={<Schedule sx={{ fontSize: 14 }} />}
>
{new Date(chore.timerUpdatedAt).toLocaleTimeString([], {
hour: '2-digit',
minute: '2-digit',
})}
</Chip>
)}
</Box>
</Card>
)
}
export default TimePassedCard

View File

@@ -0,0 +1,161 @@
import {
ArrowDropDown,
DeleteSweep,
Info,
Pause,
PlayArrow,
} from '@mui/icons-material'
import { Box, ButtonGroup, IconButton, Menu, MenuItem } from '@mui/joy'
import { useEffect, useRef, useState } from 'react'
const TimerSplitButton = ({
chore,
onAction,
onShowDetails,
onResetTimer,
onClearAllTime,
disabled = false,
fullWidth = false,
}) => {
const [anchorEl, setAnchorEl] = useState(null)
const isMenuOpen = Boolean(anchorEl)
const menuRef = useRef(null)
const handleMainAction = () => {
if (chore.status === 1) {
onAction('pause')
} else if (chore.status === 2) {
onAction('resume')
}
}
const handleMenuOpen = event => {
setAnchorEl(event.currentTarget)
}
const handleMenuClose = () => {
setAnchorEl(null)
}
const handleShowDetails = () => {
onShowDetails()
handleMenuClose()
}
const handleResetTimer = () => {
onResetTimer()
handleMenuClose()
}
const handleClearAllTime = () => {
onClearAllTime()
handleMenuClose()
}
// Handle outside clicks to close menu
useEffect(() => {
const handleMenuOutsideClick = event => {
if (
anchorEl &&
!anchorEl.contains(event.target) &&
menuRef.current &&
!menuRef.current.contains(event.target)
) {
handleMenuClose()
}
}
document.addEventListener('mousedown', handleMenuOutsideClick)
return () => {
document.removeEventListener('mousedown', handleMenuOutsideClick)
}
}, [anchorEl])
// Only show the split button when there's an active timer (status 1 or 2)
if (chore.status === 0) {
return null
}
return (
<Box
sx={{
display: 'flex',
alignItems: 'center',
width: fullWidth ? '100%' : 'auto',
}}
>
<ButtonGroup
variant='soft'
color={chore.status === 1 ? 'warning' : 'success'}
sx={{
'--ButtonGroup-separatorSize': '1px',
'--ButtonGroup-connected': '1',
width: fullWidth ? '100%' : 'auto',
}}
disabled={disabled}
>
{/* Main action button */}
<IconButton
onClick={handleMainAction}
disabled={disabled}
size='md'
sx={{
px: 3,
py: 1,
borderTopRightRadius: 0,
borderBottomRightRadius: 0,
minWidth: fullWidth ? 'auto' : 120,
flex: fullWidth ? 1 : 'none',
}}
>
{chore.status === 1 ? <Pause /> : <PlayArrow />}
{chore.status === 1 ? 'Pause' : 'Resume'}
</IconButton>
{/* Dropdown arrow button */}
<IconButton
onClick={handleMenuOpen}
disabled={disabled}
size='lg'
sx={{
px: 1,
borderTopLeftRadius: 0,
borderBottomLeftRadius: 0,
borderLeft: '1px solid',
borderLeftColor: 'divider',
minWidth: 'auto',
}}
>
<ArrowDropDown />
</IconButton>
</ButtonGroup>
{/* Dropdown menu */}
<Menu
ref={menuRef}
anchorEl={anchorEl}
open={isMenuOpen}
onClose={handleMenuClose}
placement='bottom-end'
sx={{
mt: 1,
}}
>
<MenuItem onClick={handleShowDetails}>
<Info sx={{ mr: 1 }} />
Timer Details
</MenuItem>
{/* <MenuItem onClick={handleResetTimer}>
<RestartAlt sx={{ mr: 1 }} />
Restart timer
</MenuItem> */}
<MenuItem onClick={handleClearAllTime} color='danger'>
<DeleteSweep sx={{ mr: 1 }} />
Clear & Reset
</MenuItem>
</Menu>
</Box>
)
}
export default TimerSplitButton

View File

@@ -1,10 +1,13 @@
import {
CheckCircle,
EventNote,
HourglassEmpty,
Notes,
Person,
Redo,
Refresh,
ThumbDown,
Timelapse,
Toll,
WatchLater,
} from '@mui/icons-material'
@@ -32,9 +35,9 @@ const ActivityItem = ({ activity, members }) => {
member => member.userId === activity.completedBy,
)
const getTimeDisplay = performedAt => {
const getTimeDisplay = dateToDisplay => {
const now = moment()
const completed = moment(performedAt)
const completed = moment(dateToDisplay)
const diffInHours = now.diff(completed, 'hours')
const diffInDays = now.diff(completed, 'days')
@@ -50,37 +53,55 @@ const ActivityItem = ({ activity, members }) => {
}
const getStatusInfo = activity => {
if (!activity.status === 1) {
if (activity.status === 0) {
return {
color: 'neutral',
text: 'Completed',
icon: <CheckCircle />,
color: 'primary',
text: 'Started',
icon: <Timelapse />,
}
} else if (activity.status === 1) {
const wasOnTime = moment(activity.performedAt).isSameOrBefore(
moment(activity.dueDate),
)
if (wasOnTime) {
return {
color: 'success',
text: 'Done',
icon: <CheckCircle />,
}
} else {
return {
color: 'primary',
text: 'Late',
icon: <WatchLater />,
}
}
} else if (activity.status === 2) {
// skipped
return {
color: 'warning',
text: 'Skipped',
icon: <Redo />,
}
} else if (activity.status === 3) {
return {
color: 'neutral',
text: 'Pending Approval',
icon: <HourglassEmpty />,
}
} else if (activity.status === 4) {
return {
color: 'danger',
text: 'Rejected',
icon: <ThumbDown />,
}
}
const wasOnTime = moment(activity.performedAt).isSameOrBefore(
moment(activity.dueDate),
)
if (wasOnTime) {
return {
color: 'success',
text: 'Done',
icon: <CheckCircle />,
}
} else {
return {
color: 'primary',
text: 'Late',
icon: <WatchLater />,
}
// Fallback for completed status
return {
color: 'success',
text: 'Completed',
icon: <CheckCircle />,
}
}
@@ -105,7 +126,11 @@ const ActivityItem = ({ activity, members }) => {
{activity.choreName}
</Typography>
<Typography level='body-xs' color='text.secondary'>
{getTimeDisplay(activity.performedAt)}
{getTimeDisplay(
activity.performedAt ||
activity.updatedAt ||
activity.createdAt,
)}
</Typography>
</Box>
@@ -127,18 +152,6 @@ const ActivityItem = ({ activity, members }) => {
completedByMember?.name ||
'Unknown'}
</Typography>
</Box>
{/* Status, Points, and Notes */}
<Box
sx={{
display: 'flex',
flexWrap: 'wrap',
gap: 0.5,
mt: 0.5,
ml: 2.5,
}}
>
{/* Points chip */}
{activity.points && activity.points > 0 && (
<Chip
@@ -152,6 +165,17 @@ const ActivityItem = ({ activity, members }) => {
)}
</Box>
{/* Status, Points, and Notes */}
<Box
sx={{
display: 'flex',
flexWrap: 'wrap',
gap: 0.5,
mt: 0.5,
ml: 2.5,
}}
></Box>
{/* Notes */}
{activity.notes && (
<Box sx={{ mt: 0.5, ml: 2.5 }}>
@@ -180,7 +204,9 @@ const groupActivitiesByDate = activities => {
const groups = {}
activities.forEach(activity => {
const date = moment(activity.performedAt).format('YYYY-MM-DD')
const date = moment(
activity.performedAt || activity.updatedAt || activity.createdAt,
).format('YYYY-MM-DD')
if (!groups[date]) {
groups[date] = []
}
@@ -216,7 +242,7 @@ const ActivitiesCard = ({ title = 'Recent Activities' }) => {
// Refresh function to refetch all data
const handleRefresh = async () => {
await Promise.all([refetchChores(), refetchHistory(), refetchMembers()])
await Promise.all([refetchChores(), refetchHistory, refetchMembers])
}
// Show loading state
@@ -228,12 +254,11 @@ const ActivitiesCard = ({ title = 'Recent Activities' }) => {
p: 2,
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'space-between',
boxShadow: 'sm',
borderRadius: 20,
width: '310px',
minHeight: 300,
width: '315px',
maxHeight: 400,
mb: 1,
}}
>
@@ -270,7 +295,8 @@ const ActivitiesCard = ({ title = 'Recent Activities' }) => {
const sortedHistory = enrichedHistory
.sort(
(a, b) =>
moment(b.performedAt).valueOf() - moment(a.performedAt).valueOf(),
moment(b.performedAt || b.updatedAt).valueOf() -
moment(a.performedAt || a.updatedAt).valueOf(),
)
.slice(0, 10) // Show only latest 10 activities
@@ -284,12 +310,11 @@ const ActivitiesCard = ({ title = 'Recent Activities' }) => {
p: 2,
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'space-between',
boxShadow: 'sm',
borderRadius: 20,
// width: '290px',
width: '310px',
minHeight: 300,
maxHeight: 400,
mb: 1,
}}
>

View File

@@ -0,0 +1,868 @@
import {
Archive,
CheckBox,
CheckBoxOutlineBlank,
Close,
Delete,
SelectAll,
Unarchive,
ViewAgenda,
ViewModule,
} from '@mui/icons-material'
import {
Box,
Button,
Container,
Divider,
IconButton,
Input,
List,
Stack,
Typography,
} from '@mui/joy'
import Fuse from 'fuse.js'
import { useEffect, useRef, useState } from 'react'
import { useNavigate } from 'react-router-dom'
import KeyboardShortcutHint from '../../components/common/KeyboardShortcutHint'
import { useImpersonateUser } from '../../contexts/ImpersonateUserContext.jsx'
import { useUnArchiveChore } from '../../queries/ChoreQueries'
import { useCircleMembers, useUserProfile } from '../../queries/UserQueries'
import { useNotification } from '../../service/NotificationProvider'
import { ChoreSorter } from '../../utils/Chores'
import { DeleteChore, GetArchivedChores } from '../../utils/Fetcher'
import LoadingComponent from '../components/Loading'
import ConfirmationModal from '../Modals/Inputs/ConfirmationModal'
import ChoreCard from './ChoreCard'
import CompactChoreCard from './CompactChoreCard'
import MultiSelectHelp from './MultiSelectHelp'
const ArchivedTasks = () => {
const { data: userProfile, isLoading: isUserProfileLoading } =
useUserProfile()
const { showSuccess, showError } = useNotification()
const { impersonatedUser } = useImpersonateUser()
const unArchiveChore = useUnArchiveChore()
const [archivedChores, setArchivedChores] = useState([])
const [filteredChores, setFilteredChores] = useState([])
const [searchTerm, setSearchTerm] = useState('')
const [performers, setPerformers] = useState([])
const navigate = useNavigate()
const [viewMode, setViewMode] = useState(
localStorage.getItem('archivedChoreCardViewMode') || 'default',
)
const [isLoading, setIsLoading] = useState(true)
const [showKeyboardShortcuts, setShowKeyboardShortcuts] = useState(false)
const searchInputRef = useRef(null)
// Multi-select state
const [isMultiSelectMode, setIsMultiSelectMode] = useState(false)
const [selectedChores, setSelectedChores] = useState(new Set())
const [confirmModelConfig, setConfirmModelConfig] = useState({})
const { data: membersData, isLoading: membersLoading } = useCircleMembers()
useEffect(() => {
const loadArchivedChores = async () => {
if (!membersLoading && userProfile) {
setPerformers(membersData.res)
try {
const response = await GetArchivedChores()
const data = await response.json()
const sortedChores = data.res.sort(ChoreSorter)
setArchivedChores(sortedChores)
setFilteredChores(sortedChores)
} catch (error) {
showError({
title: 'Failed to load archived tasks',
message: 'Please try again later.',
})
} finally {
setIsLoading(false)
}
}
}
loadArchivedChores()
}, [membersLoading, userProfile, membersData])
// Keyboard shortcuts
useEffect(() => {
const handleKeyDown = event => {
const isHoldingCmdOrCtrl = event.ctrlKey || event.metaKey
if (isHoldingCmdOrCtrl) {
setShowKeyboardShortcuts(true)
}
// Ctrl/Cmd + F to focus search input
if (isHoldingCmdOrCtrl && event.key === 'f') {
event.preventDefault()
searchInputRef.current?.focus()
return
}
// Ctrl/Cmd + S Toggle Multi-select mode
if (isHoldingCmdOrCtrl && event.key === 's') {
event.preventDefault()
toggleMultiSelectMode()
return
}
// Ctrl/Cmd + A to select all
if (
isHoldingCmdOrCtrl &&
!event.shiftKey &&
event.key === 'a' &&
!['INPUT', 'TEXTAREA'].includes(document.activeElement.tagName)
) {
event.preventDefault()
if (!isMultiSelectMode) {
setIsMultiSelectMode(true)
setTimeout(() => {
selectAllVisibleChores()
}, 0)
} else {
selectAllVisibleChores()
}
}
// Multi-select keyboard shortcuts
if (isMultiSelectMode) {
// Escape to clear selection or exit multi-select mode
if (event.key === 'Escape') {
event.preventDefault()
if (selectedChores.size > 0) {
clearSelection()
} else {
setIsMultiSelectMode(false)
}
return
}
// "r" key for bulk restore (unarchive)
if (
isHoldingCmdOrCtrl &&
event.key === 'r' &&
selectedChores.size > 0
) {
event.preventDefault()
handleBulkRestore()
return
}
// "e" key for bulk delete
if (
isHoldingCmdOrCtrl &&
event.key === 'e' &&
selectedChores.size > 0
) {
event.preventDefault()
handleBulkDelete()
return
}
}
}
const handleKeyUp = event => {
if (!event.ctrlKey && !event.metaKey) {
setShowKeyboardShortcuts(false)
}
}
document.addEventListener('keydown', handleKeyDown)
document.addEventListener('keyup', handleKeyUp)
return () => {
document.removeEventListener('keydown', handleKeyDown)
document.removeEventListener('keyup', handleKeyUp)
}
}, [isMultiSelectMode, selectedChores.size])
const toggleViewMode = () => {
const modes = ['default', 'compact']
const currentIndex = modes.indexOf(viewMode)
const nextIndex = (currentIndex + 1) % modes.length
const newMode = modes[nextIndex]
setViewMode(newMode)
localStorage.setItem('archivedChoreCardViewMode', newMode)
}
const searchOptions = {
keys: ['name', 'raw_label'],
includeScore: true,
isCaseSensitive: false,
findAllMatches: true,
}
const fuse = new Fuse(
archivedChores.map(c => ({
...c,
raw_label: c.labelsV2?.map(c => c.name).join(' '),
})),
searchOptions,
)
const handleSearchChange = e => {
const search = e.target.value
if (search === '') {
setFilteredChores(archivedChores)
setSearchTerm('')
return
}
const term = search.toLowerCase()
setSearchTerm(term)
setFilteredChores(fuse.search(term).map(result => result.item))
}
const handleSearchClose = () => {
setSearchTerm('')
setFilteredChores(archivedChores)
searchInputRef.current?.blur()
}
const handleChoreUpdated = (updatedChore, event) => {
if (event === 'unarchive') {
// Remove from archived list when unarchived
const newArchivedChores = archivedChores.filter(
chore => chore.id !== updatedChore.id,
)
const newFilteredChores = filteredChores.filter(
chore => chore.id !== updatedChore.id,
)
setArchivedChores(newArchivedChores)
setFilteredChores(newFilteredChores)
showSuccess({
title: 'Task Restored',
message: 'The task has been restored and is now active.',
})
}
}
const handleChoreDeleted = deletedChore => {
const newArchivedChores = archivedChores.filter(
chore => chore.id !== deletedChore.id,
)
const newFilteredChores = filteredChores.filter(
chore => chore.id !== deletedChore.id,
)
setArchivedChores(newArchivedChores)
setFilteredChores(newFilteredChores)
showSuccess({
title: 'Task Deleted',
message: 'The archived task has been permanently deleted.',
})
}
// Multi-select helper functions
const toggleMultiSelectMode = () => {
const newMode = !isMultiSelectMode
setIsMultiSelectMode(newMode)
if (!newMode) {
setSelectedChores(new Set())
}
}
const toggleChoreSelection = choreId => {
const newSelection = new Set(selectedChores)
if (newSelection.has(choreId)) {
newSelection.delete(choreId)
} else {
newSelection.add(choreId)
}
setSelectedChores(newSelection)
}
const selectAllVisibleChores = () => {
const visibleChores =
searchTerm?.length > 0 ? filteredChores : archivedChores
if (visibleChores.length > 0) {
const allIds = new Set(visibleChores.map(chore => chore.id))
setSelectedChores(allIds)
}
}
const clearSelection = () => {
if (selectedChores.size === 0) {
setIsMultiSelectMode(false)
return
}
setSelectedChores(new Set())
}
const getSelectedChoresData = () => {
return Array.from(selectedChores)
.map(id => archivedChores.find(chore => chore.id === id))
.filter(Boolean)
}
// Bulk operations
const handleBulkRestore = async () => {
const selectedData = getSelectedChoresData()
if (selectedData.length === 0) return
setConfirmModelConfig({
isOpen: true,
title: 'Restore Tasks',
confirmText: 'Restore',
cancelText: 'Cancel',
message: `Restore ${selectedData.length} task${selectedData.length > 1 ? 's' : ''} to active list?`,
onClose: async isConfirmed => {
if (isConfirmed === true) {
try {
const restoredTasks = []
const failedTasks = []
for (const chore of selectedData) {
try {
await new Promise((resolve, reject) => {
unArchiveChore.mutate(chore.id, {
onSuccess: data => {
restoredTasks.push(chore)
resolve(data)
},
onError: error => {
failedTasks.push(chore)
reject(error)
},
})
})
} catch (error) {
// Error already handled in onError callback
}
}
if (restoredTasks.length > 0) {
showSuccess({
title: '📤 Tasks Restored',
message: `Successfully restored ${restoredTasks.length} task${restoredTasks.length > 1 ? 's' : ''}.`,
})
// Remove restored tasks from archived list
const restoredIds = new Set(restoredTasks.map(c => c.id))
const newArchivedChores = archivedChores.filter(
c => !restoredIds.has(c.id),
)
const newFilteredChores = filteredChores.filter(
c => !restoredIds.has(c.id),
)
setArchivedChores(newArchivedChores)
setFilteredChores(newFilteredChores)
}
if (failedTasks.length > 0) {
showError({
title: 'Some Tasks Failed',
message: `${failedTasks.length} task${failedTasks.length > 1 ? 's' : ''} could not be restored.`,
})
}
clearSelection()
} catch (error) {
showError({
title: 'Bulk Restore Failed',
message: 'An unexpected error occurred. Please try again.',
})
}
}
setConfirmModelConfig({})
},
})
}
const handleBulkDelete = async () => {
const selectedData = getSelectedChoresData()
if (selectedData.length === 0) return
setConfirmModelConfig({
isOpen: true,
title: 'Delete Archived Tasks',
confirmText: 'Delete',
cancelText: 'Cancel',
message: `Permanently delete ${selectedData.length} archived task${selectedData.length > 1 ? 's' : ''}?\n\nThis action cannot be undone.`,
onClose: async isConfirmed => {
if (isConfirmed === true) {
try {
const deletedTasks = []
const failedTasks = []
for (const chore of selectedData) {
try {
await DeleteChore(chore.id)
deletedTasks.push(chore)
} catch (error) {
failedTasks.push(chore)
}
}
if (deletedTasks.length > 0) {
showSuccess({
title: '🗑️ Tasks Deleted',
message: `Successfully deleted ${deletedTasks.length} task${deletedTasks.length > 1 ? 's' : ''}.`,
})
const deletedIds = new Set(deletedTasks.map(c => c.id))
const newArchivedChores = archivedChores.filter(
c => !deletedIds.has(c.id),
)
const newFilteredChores = filteredChores.filter(
c => !deletedIds.has(c.id),
)
setArchivedChores(newArchivedChores)
setFilteredChores(newFilteredChores)
}
if (failedTasks.length > 0) {
showError({
title: 'Some Tasks Failed',
message: `${failedTasks.length} task${failedTasks.length > 1 ? 's' : ''} could not be deleted.`,
})
}
clearSelection()
} catch (error) {
showError({
title: 'Bulk Delete Failed',
message: 'An unexpected error occurred. Please try again.',
})
}
}
setConfirmModelConfig({})
},
})
}
// Helper function to render the appropriate card component
const renderChoreCard = (chore, key) => {
const CardComponent = viewMode === 'compact' ? CompactChoreCard : ChoreCard
return (
<CardComponent
key={key || chore.id}
chore={chore}
onChoreUpdate={handleChoreUpdated}
onChoreRemove={handleChoreDeleted}
performers={performers}
viewOnly={false}
showActions={false}
// Multi-select props
isMultiSelectMode={isMultiSelectMode}
isSelected={selectedChores.has(chore.id)}
onSelectionToggle={() => toggleChoreSelection(chore.id)}
/>
)
}
if (isUserProfileLoading || performers.length === 0 || isLoading) {
return <LoadingComponent />
}
return (
<Container maxWidth='md'>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2, mb: 2 }}>
{/* <EmojiEvents sx={{ fontSize: '2rem', color: '#FFD700' }} /> */}
<Stack sx={{ flex: 1 }}>
<Typography
level='h3'
sx={{ fontWeight: 'lg', color: 'text.primary' }}
>
Archived Tasks
</Typography>
<Typography level='body-sm' sx={{ color: 'text.secondary' }}>
View and manage tasks that have been archived or completed.
</Typography>
</Stack>
</Box>
{/* Header */}
{/* <Box
sx={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
mb: 2,
pt: 2,
}}
>
<Typography
level='h3'
sx={{ display: 'flex', alignItems: 'center', gap: 1 }}
>
<Archive />
Archived Tasks
</Typography>
<Button
variant='outlined'
color='neutral'
startDecorator={<Close />}
onClick={() => navigate('/chores')}
sx={{ ml: 'auto' }}
>
Close
</Button>
</Box> */}
{/* Search and Controls */}
<Box
sx={{
display: 'flex',
justifyContent: 'space-between',
alignContent: 'center',
alignItems: 'center',
gap: 0.5,
mb: 2,
}}
>
<Input
slotProps={{ input: { ref: searchInputRef } }}
placeholder='Search archived tasks'
value={searchTerm}
fullWidth
sx={{
borderRadius: 24,
height: 24,
borderColor: 'text.disabled',
padding: 1,
}}
onChange={handleSearchChange}
startDecorator={
<KeyboardShortcutHint shortcut='F' show={showKeyboardShortcuts} />
}
endDecorator={
searchTerm && (
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
<KeyboardShortcutHint
shortcut='X'
show={showKeyboardShortcuts}
/>
<IconButton
variant='plain'
size='sm'
onClick={handleSearchClose}
sx={{ borderRadius: '50%' }}
>
<Close />
</IconButton>
</Box>
)
}
/>
{/* View Mode Toggle Button */}
<IconButton
variant='outlined'
color='neutral'
size='sm'
sx={{
height: 32,
width: 32,
borderRadius: '50%',
}}
onClick={toggleViewMode}
title={
viewMode === 'default'
? 'Switch to Compact View'
: 'Switch to Card View'
}
>
{viewMode === 'default' ? <ViewAgenda /> : <ViewModule />}
</IconButton>
{/* Multi-select Toggle Button */}
<Box sx={{ position: 'relative', display: 'inline-flex' }}>
<IconButton
variant={isMultiSelectMode ? 'solid' : 'outlined'}
color={isMultiSelectMode ? 'primary' : 'neutral'}
size='sm'
sx={{
height: 32,
width: 32,
borderRadius: '50%',
}}
onClick={toggleMultiSelectMode}
title={
isMultiSelectMode
? 'Exit Multi-select Mode (Ctrl+S)'
: 'Enable Multi-select Mode (Ctrl+S)'
}
>
{isMultiSelectMode ? <CheckBox /> : <CheckBoxOutlineBlank />}
</IconButton>
<KeyboardShortcutHint
shortcut='S'
show={showKeyboardShortcuts}
sx={{
position: 'absolute',
top: -8,
right: -8,
zIndex: 1000,
}}
/>
</Box>
</Box>
{/* Multi-select Toolbar */}
{isMultiSelectMode && (
<Box
sx={{
position: 'sticky',
top: 0,
zIndex: 1000,
mb: 2,
}}
>
<Box
sx={{
backgroundColor: 'background.surface',
backdropFilter: 'blur(8px)',
borderRadius: 'lg',
p: 2,
border: '1px solid',
borderColor: 'divider',
boxShadow: 'm',
gap: 2,
display: 'flex',
flexDirection: {
sm: 'column',
md: 'row',
},
alignItems: {
xs: 'stretch',
sm: 'center',
},
justifyContent: {
xs: 'center',
sm: 'space-between',
},
}}
>
{/* Selection Info and Controls */}
<Box
sx={{
display: 'flex',
alignItems: 'center',
gap: 2,
flexWrap: {
xs: 'wrap',
sm: 'nowrap',
},
justifyContent: {
xs: 'center',
sm: 'flex-start',
},
}}
>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<CheckBox sx={{ color: 'primary.500' }} />
<Typography level='body-sm' fontWeight='md'>
{selectedChores.size} task
{selectedChores.size !== 1 ? 's' : ''} selected
</Typography>
</Box>
<Divider
orientation='vertical'
sx={{
display: { xs: 'none', sm: 'block' },
}}
/>
<Box sx={{ display: 'flex', gap: 1 }}>
<Button
size='sm'
variant='outlined'
onClick={selectAllVisibleChores}
startDecorator={<SelectAll />}
disabled={selectedChores.size === filteredChores.length}
sx={{
minWidth: 'auto',
'--Button-paddingInline': '0.75rem',
position: 'relative',
}}
title='Select all visible tasks (Ctrl+A)'
>
All
{showKeyboardShortcuts && (
<KeyboardShortcutHint
shortcut='A'
sx={{
position: 'absolute',
top: -8,
right: -8,
zIndex: 1000,
}}
/>
)}
</Button>
<Button
size='sm'
variant='outlined'
onClick={clearSelection}
startDecorator={
selectedChores.size === 0 ? (
<Close />
) : (
<CheckBoxOutlineBlank />
)
}
sx={{
minWidth: 'auto',
'--Button-paddingInline': '0.75rem',
position: 'relative',
}}
title={`${selectedChores.size === 0 ? 'Close' : 'Clear'} multi-select (Esc)`}
>
{selectedChores.size === 0 ? 'Close' : 'Clear'}
{showKeyboardShortcuts && (
<KeyboardShortcutHint
withCtrl={false}
shortcut='Esc'
sx={{
position: 'absolute',
top: -8,
right: -8,
zIndex: 1000,
}}
/>
)}
</Button>
</Box>
</Box>
{/* Action Buttons */}
<Box
sx={{
display: 'flex',
alignItems: 'center',
gap: 1,
flexWrap: {
xs: 'wrap',
sm: 'nowrap',
},
justifyContent: {
xs: 'center',
sm: 'flex-end',
},
}}
>
<Button
size='sm'
variant='solid'
color='success'
onClick={handleBulkRestore}
startDecorator={<Unarchive />}
disabled={selectedChores.size === 0}
sx={{
'--Button-paddingInline': { xs: '0.75rem', sm: '1rem' },
position: 'relative',
}}
title='Restore selected tasks (R)'
>
Restore
{showKeyboardShortcuts && selectedChores.size > 0 && (
<KeyboardShortcutHint
shortcut='R'
sx={{
position: 'absolute',
top: -8,
right: -8,
zIndex: 1000,
}}
/>
)}
</Button>
<Button
size='sm'
variant='soft'
color='danger'
onClick={handleBulkDelete}
startDecorator={<Delete />}
disabled={selectedChores.size === 0}
sx={{
'--Button-paddingInline': { xs: '0.75rem', sm: '1rem' },
position: 'relative',
}}
title='Delete selected tasks (E)'
>
Delete
{showKeyboardShortcuts && selectedChores.size > 0 && (
<KeyboardShortcutHint
shortcut='E'
sx={{
position: 'absolute',
top: -8,
right: -8,
zIndex: 1000,
}}
/>
)}
</Button>
</Box>
</Box>
</Box>
)}
{/* Content */}
{filteredChores.length === 0 ? (
<Box
sx={{
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
flexDirection: 'column',
height: '50vh',
}}
>
<Archive
sx={{
fontSize: '4rem',
mb: 1,
color: 'text.tertiary',
}}
/>
<Typography level='title-md' gutterBottom>
{searchTerm ? 'No archived tasks found' : 'No archived tasks'}
</Typography>
<Typography level='body-sm' color='text.secondary' sx={{ mb: 2 }}>
{searchTerm
? 'Try adjusting your search terms'
: 'Archived tasks will appear here when you archive them from the main task list'}
</Typography>
{searchTerm && (
<Button
onClick={handleSearchClose}
variant='outlined'
color='neutral'
>
Clear search
</Button>
)}
</Box>
) : (
<Box>
<Typography level='body-sm' color='text.secondary' sx={{ mb: 2 }}>
{filteredChores.length} archived task
{filteredChores.length !== 1 ? 's' : ''}
{searchTerm && ` matching "${searchTerm}"`}
</Typography>
<List sx={{ gap: viewMode === 'compact' ? 0 : 1 }}>
{filteredChores.map(chore =>
renderChoreCard(chore, `archived-${chore.id}`),
)}
</List>
</Box>
)}
{/* Multi-select Help */}
<MultiSelectHelp isVisible={isMultiSelectMode} />
{/* Confirmation Modal */}
{confirmModelConfig?.isOpen && (
<ConfirmationModal config={confirmModelConfig} />
)}
</Container>
)
}
export default ArchivedTasks

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -1,128 +1,216 @@
import { Capacitor } from '@capacitor/core';
import { LocalNotifications } from '@capacitor/local-notifications';
import { Preferences } from '@capacitor/preferences';
import { Capacitor } from '@capacitor/core'
import { LocalNotifications } from '@capacitor/local-notifications'
import { Preferences } from '@capacitor/preferences'
import murmurhash from 'murmurhash'
const getNotificationPreferences = async () => {
const ret = await Preferences.get({ key: 'notificationPreferences' });
return JSON.parse(ret.value);
};
const canScheduleNotification = () => {
if (Capacitor.isNativePlatform() === false) {
return false;
}
const notificationPreferences = getNotificationPreferences();
if (notificationPreferences["granted"] === false) {
return false;
}
return true;
const ret = await Preferences.get({ key: 'notificationPreferences' })
return JSON.parse(ret.value)
}
const canScheduleNotification = async () => {
if (Capacitor.isNativePlatform() === false) {
return false
}
const notificationPreferences = await getNotificationPreferences()
console.log('Notification preferences:', notificationPreferences)
const scheduleChoreNotification = async (chores, userProfile,allPerformers) => {
// for each chore will create local notification:
const notifications = [];
if (notificationPreferences['granted'] === false) {
return false
}
return true
}
const getIdFromTemplate = (choreId, template) => {
const hash = murmurhash.v3(`${choreId}-${template.value}-${template.unit}`)
// Use Math.abs() with modulo to ensure positive ID within Java int range
// This guarantees the ID is always positive and within 1 to 2^31-1
return Math.abs(hash) % 2147483647
}
const getTimeFromTemplate = (template, relativeTime) => {
let time = relativeTime
switch (template.unit) {
case 'm':
time = new Date(relativeTime.getTime() + template.value * 60 * 1000)
break
case 'h':
time = new Date(relativeTime.getTime() + template.value * 60 * 60 * 1000)
break
case 'd':
time = new Date(
relativeTime.getTime() + template.value * 24 * 60 * 60 * 1000,
)
break
default:
time = relativeTime
}
return time
}
const scheduleNotificationFromTemplate = (
chore,
userProfile,
allPerformers,
notifications,
) => {
for (const template of chore.notificationMetadata?.templates || []) {
// convert the template to time:
const dueDate = new Date(chore.nextDueDate)
const now = new Date()
const devicePreferences = await getNotificationPreferences();
for (let i = 0; i < chores.length; i++) {
const time = getTimeFromTemplate(template, dueDate)
const notificationId = getIdFromTemplate(chore.id, template)
const { title, body } = getNotificationText(chore.name, template)
if (time > now) {
notifications.push({
title,
body: `${body} at ${time.toLocaleTimeString()}`,
id: notificationId,
allowWhileIdle: true,
schedule: {
at: time,
},
extra: {
choreId: chore.id,
},
})
}
}
}
const chore = chores[i];
const chorePreferences = JSON.parse(chore.notificationMetadata)
if ( chore.notification ===false || chore.nextDueDate === null) {
continue;
const getNotificationText = (choreName, template = {}) => {
// Determine notification type based on template value
const getNotificationType = () => {
if (!template || template.value === undefined) {
return 'due'
}
if (template.value < 0) {
return 'reminder'
} else if (template.value === 0) {
return 'due'
} else {
return 'overdue'
}
}
const notificationType = getNotificationType()
// Truncate chore name if too long for better readability
const maxChoreNameLength = 25
const truncatedName =
choreName.length > maxChoreNameLength
? `${choreName.substring(0, maxChoreNameLength)}...`
: choreName
// Generate time-based descriptive text
const getTimeDescription = () => {
if (!template || !template.value || !template.unit) {
return 'soon'
}
const { value, unit } = template
const absValue = Math.abs(value)
switch (unit) {
case 'm':
if (absValue === 1) return value < 0 ? 'in 1 minute' : '1 minute ago'
if (absValue < 60)
return value < 0
? `in ${absValue} minutes`
: `${absValue} minutes ago`
break
case 'h':
if (absValue === 1) return value < 0 ? 'in 1 hour' : '1 hour ago'
if (absValue < 24)
return value < 0 ? `in ${absValue} hours` : `${absValue} hours ago`
break
case 'd':
if (absValue === 1) return value < 0 ? 'tomorrow' : 'yesterday'
if (absValue === 7) return value < 0 ? 'next week' : 'last week'
if (absValue < 7)
return value < 0 ? `in ${absValue} days` : `${absValue} days ago`
if (absValue < 30) {
const weeks = Math.round(absValue / 7)
return value < 0 ? `in ${weeks} weeks` : `${weeks} weeks ago`
}
scheduleDueNotification(chore, userProfile, allPerformers,chorePreferences,devicePreferences, notifications)
schedulePreDueNotification(chore, userProfile, allPerformers,chorePreferences, devicePreferences,notifications)
scheduleNaggingNotification(chore, userProfile, allPerformers,chorePreferences,devicePreferences, notifications)
break
default:
return value < 0 ? `in ${absValue} ${unit}` : `${absValue} ${unit} ago`
}
LocalNotifications.schedule({
return value < 0 ? `in ${absValue} ${unit}` : `${absValue} ${unit} ago`
}
const messages = {
reminder: {
title: `📋 ${truncatedName}`,
body: `Reminder: Due ${getTimeDescription()}`,
},
due: {
title: `🔔 ${truncatedName}`,
body: 'Due now - Time to get started!',
},
overdue: {
title: `${truncatedName}`,
body: `Overdue ${getTimeDescription()} - Complete when you can`,
},
}
// Fallback to due if type not found
const messageTemplate = messages[notificationType] || messages.due
return {
title: messageTemplate.title,
body: messageTemplate.body,
}
}
const cancelPendingNotifications = async () => {
try {
const pending = await LocalNotifications.getPending()
if (pending.notifications.length > 0) {
await LocalNotifications.cancel({ notifications: pending.notifications })
console.log('Cancelled pending notifications:', pending.notifications)
} else {
console.log('No pending notifications to cancel.')
}
} catch (error) {
console.error('Error cancelling pending notifications:', error)
}
}
const scheduleChoreNotification = async (
chores,
userProfile,
allPerformers,
) => {
await cancelPendingNotifications()
const notifications = []
for (let i = 0; i < chores.length; i++) {
const chore = chores[i]
try {
if (chore.notification === false || chore.nextDueDate === null) {
continue
}
scheduleNotificationFromTemplate(
chore,
userProfile,
allPerformers,
notifications,
});
)
} catch (error) {
console.error(
'Error parsing notification metadata for chore:',
chore.id,
error,
)
continue
}
}
LocalNotifications.schedule({
notifications,
})
return notifications
}
const scheduleDueNotification = (chore, userProfile, allPerformers,chorePreferences,devicePreferences, notifications) => {
if (devicePreferences['dueNotification'] !== true || chorePreferences['dueDate'] !== true){
return
}
const nextDueDate = new Date(chore.nextDueDate)
const diff = nextDueDate - now
if (diff < 0) {
return
}
const notification = {
title: `${chore.name} is due! 🕒`,
body: userProfile.id === chore.assignedTo ? `It's assigned to you!` : `It is ${allPerformers[chore.assignedTo].name}'s turn`,
id: chore.id,
allowWhileIdle: true,
schedule: {
at: new Date(chore.nextDueDate),
},
extra: {
choreId: chore.id,
},
};
notifications.push(notification);
}
const schedulePreDueNotification = (chore, userProfile, allPerformers,chorePreferences,devicePreferences, notifications) => {
if (devicePreferences['preDueNotification'] !== true || chorePreferences['preDue'] !== true){
return
}
const nextDueDate = new Date(chore.nextDueDate)
const diff = nextDueDate - now
if (diff < 0 || userProfile.id !== chore.assignedTo) {
return
}
const notification = {
title: `${chore.name} is due soon! 🕒`,
body: `is due at ${nextDueDate.toLocaleTimeString()}`,
id: chore.id,
allowWhileIdle: true,
schedule: {
// 1 hour before
at: new Date(nextDueDate - 60 * 60 * 1000),
},
extra: {
choreId: chore.id,
},
};
notifications.push(notification);
}
const scheduleNaggingNotification = (chore, userProfile, allPerformers,chorePreferences,devicePreferences, notifications) => {
if (devicePreferences['naggingNotification'] === false || chorePreferences.nagging !== true){
return
}
const nextDueDate = new Date(chore.nextDueDate)
const diff = nextDueDate - now
if (diff > 0 || userProfile.id !== chore.assignedTo) {
return
}
const notification = {
title: `${chore.name} is overdue! 🕒`,
body: `❗ It was due at ${nextDueDate.toLocaleTimeString()}`,
id: chore.id,
allowWhileIdle: true,
schedule: {
at: new Date(chore.nextDueDate),
},
extra: {
choreId: chore.id,
},
};
notifications.push(notification);
}
export{ scheduleChoreNotification, canScheduleNotification }
export { canScheduleNotification, scheduleChoreNotification }

View File

@@ -0,0 +1,170 @@
import { Close, HelpOutline, Keyboard } from '@mui/icons-material'
import { Box, Button, Card, Divider, IconButton, Typography } from '@mui/joy'
import { useState } from 'react'
import { useResponsiveModal } from '../../hooks/useResponsiveModal'
const MultiSelectHelp = ({ isVisible = true }) => {
const { ResponsiveModal } = useResponsiveModal()
const [isHelpOpen, setIsHelpOpen] = useState(false)
if (!isVisible) return null
return (
<>
{/* Help Button */}
<IconButton
size='sm'
variant='soft'
color='neutral'
onClick={() => setIsHelpOpen(true)}
sx={{
position: 'fixed',
bottom: 24,
right: 24,
zIndex: 1000,
width: 48,
height: 48,
borderRadius: '50%',
boxShadow: 'lg',
}}
title='Show keyboard shortcuts'
>
<HelpOutline />
</IconButton>
{/* Help Modal */}
<ResponsiveModal open={isHelpOpen} onClose={() => setIsHelpOpen(false)}>
<Box
sx={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
mb: 2,
}}
>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<Keyboard color='primary' />
<Typography level='title-lg'>Multi-select Mode</Typography>
</Box>
<IconButton
variant='plain'
size='sm'
onClick={() => setIsHelpOpen(false)}
>
<Close />
</IconButton>
</Box>
<Typography level='body-md' sx={{ mb: 3, color: 'text.secondary' }}>
Use these keyboard shortcuts to work more efficiently with multiple
tasks:
</Typography>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
{/* Selection shortcuts */}
<Card variant='soft' sx={{ p: 2 }}>
<Typography level='title-sm' sx={{ mb: 1.5, color: 'primary.600' }}>
Selection
</Typography>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
<ShortcutItem
keys={['Ctrl', 'A']}
description='Select all visible tasks'
/>
<ShortcutItem
keys={['Esc']}
description='Clear selection or exit multi-select mode'
/>
</Box>
</Card>
{/* Action shortcuts */}
<Card variant='soft' sx={{ p: 2 }}>
<Typography level='title-sm' sx={{ mb: 1.5, color: 'success.600' }}>
Actions
</Typography>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
<ShortcutItem
keys={['Enter']}
description='Mark selected tasks as completed'
/>
<ShortcutItem
keys={['Del', '⌫']}
description='Delete selected tasks'
/>
</Box>
</Card>
{/* Interface shortcuts */}
<Card variant='soft' sx={{ p: 2 }}>
<Typography level='title-sm' sx={{ mb: 1.5, color: 'warning.600' }}>
Interface
</Typography>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
<ShortcutItem
keys={['Ctrl', 'K']}
description='Quick add new task'
/>
</Box>
</Card>
</Box>
<Divider sx={{ my: 3 }} />
<Box sx={{ display: 'flex', justifyContent: 'center' }}>
<Button
variant='soft'
onClick={() => setIsHelpOpen(false)}
sx={{ minWidth: 120 }}
>
Got it!
</Button>
</Box>
</ResponsiveModal>
</>
)
}
const ShortcutItem = ({ keys, description }) => (
<Box
sx={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
gap: 2,
}}
>
<Box sx={{ flex: 1, display: 'flex', alignItems: 'center' }}>
<Typography level='body-sm'>{description}</Typography>
</Box>
<Box sx={{ display: 'flex', gap: 0.5 }}>
{keys.map((key, index) => (
<Box
key={index}
sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}
>
{index > 0 && (
<Typography level='body-xs' color='text.secondary'>
+
</Typography>
)}
<Box
sx={{
px: 1,
py: 0.25,
bgcolor: 'background.level2',
borderRadius: 'sm',
border: '1px solid',
borderColor: 'divider',
minWidth: 32,
textAlign: 'center',
}}
>
<Typography level='body-xs' fontWeight='bold'>
{key}
</Typography>
</Box>
</Box>
))}
</Box>
</Box>
)
export default MultiSelectHelp

File diff suppressed because it is too large Load Diff

View File

@@ -1,78 +1,94 @@
import { Capacitor } from '@capacitor/core';
import { Button, Snackbar, Stack, Typography } from '@mui/joy'
import { Preferences } from '@capacitor/preferences';
import { LocalNotifications } from '@capacitor/local-notifications';
import {React, useEffect, useState} from 'react';
import { Capacitor } from '@capacitor/core'
import { LocalNotifications } from '@capacitor/local-notifications'
import { Preferences } from '@capacitor/preferences'
import { Button, Snackbar, Stack, Typography } from '@mui/joy'
import { useEffect, useState } from 'react'
import { registerPushNotifications } from '../../CapacitorListener'
const NotificationAccessSnackbar = () => {
const [open, setOpen] = useState(false)
// Define the function outside of useEffect
const getNotificationPreferences = async () => {
const ret = await Preferences.get({ key: 'notificationPreferences' })
return JSON.parse(ret.value) || {}
}
const [open, setOpen] = useState(false);
if (!Capacitor.isNativePlatform()) {
return null;
useEffect(() => {
// Only run the effect on native platforms
if (Capacitor.isNativePlatform()) {
getNotificationPreferences().then(data => {
// if optOut is true then don't show the snackbar
if (data?.optOut === true || data?.granted === true) {
return
}
setOpen(true)
})
}
const getNotificationPreferences = async () => {
const ret = await Preferences.get({ key: 'notificationPreferences' });
return JSON.parse(ret.value);
};
useEffect(() => {
getNotificationPreferences().then((data) => {
// if optOut is true then don't show the snackbar
if(data?.optOut === true || data?.granted === true) {
return;
}
setOpen(true);
});
}
, []);
}, [])
// Return early if not on a native platform
if (!Capacitor.isNativePlatform()) {
return null
}
return (
return (
<Snackbar
// autoHideDuration={5000}
variant="solid"
color="primary"
size="lg"
variant='solid'
color='primary'
size='lg'
invertedColors
open={open}
onClose={() => setOpen(false)}
anchorOrigin={{ vertical: 'bottom', horizontal: 'center' }}
sx={(theme) => ({
sx={theme => ({
background: `linear-gradient(45deg, ${theme.palette.primary[600]} 30%, ${theme.palette.primary[500]} 90%})`,
maxWidth: 360,
})}
>
<div>
<Typography level="title-lg">Need Notification?</Typography>
<Typography level='title-lg'>Need Notification?</Typography>
<Typography sx={{ mt: 1, mb: 2 }}>
You need to enable permission to receive notifications, do you want to enable it?
You need to enable permission to receive notifications, do you want to
enable it?
</Typography>
<Stack direction="row" spacing={1}>
<Button variant="solid" color="primary" onClick={() => {
const notificationPreferences = { optOut: false };
LocalNotifications.requestPermissions().then((resp) => {
<Stack direction='row' spacing={1}>
<Button
variant='solid'
color='primary'
onClick={async () => {
const notificationPreferences = { optOut: false }
try {
const resp = await LocalNotifications.requestPermissions()
if (resp.display === 'granted') {
notificationPreferences['granted'] = true;
notificationPreferences['granted'] = true
// Register for push notifications after local permission is granted
await registerPushNotifications()
}
} catch (error) {
console.error('Error setting up notifications:', error)
}
await Preferences.set({
key: 'notificationPreferences',
value: JSON.stringify(notificationPreferences),
})
Preferences.set({ key: 'notificationPreferences', value: JSON.stringify(notificationPreferences) });
setOpen(false);
}}>
Yes
setOpen(false)
}}
>
Yes
</Button>
<Button
variant="outlined"
color="primary"
variant='outlined'
color='primary'
onClick={() => {
const notificationPreferences = { optOut: true };
Preferences.set({ key: 'notificationPreferences', value: JSON.stringify(notificationPreferences) });
setOpen(false);
const notificationPreferences = { optOut: true }
Preferences.set({
key: 'notificationPreferences',
value: JSON.stringify(notificationPreferences),
})
setOpen(false)
}}
>
No, Keep it Disabled
@@ -80,8 +96,7 @@ return (
</Stack>
</div>
</Snackbar>
)
)
}
export default NotificationAccessSnackbar;
export default NotificationAccessSnackbar

View File

@@ -3,13 +3,16 @@ import { useMediaQuery } from '@mui/material'
import { useEffect, useState } from 'react'
import { useChoresHistory } from '../../queries/ChoreQueries'
import { ChoresGrouper } from '../../utils/Chores'
import CalendarView from '../components/CalendarView'
import { getSidepanelConfig } from '../../utils/SidepanelConfig'
import CalendarCard from '../components/CalendarCard'
import ActivitiesCard from './ActivitesCard'
import WelcomeCard from './WelcomeCard'
import TasksByAssigneeCard from './TasksByAssigneeCard'
import UserSwitcher from './UserSwitcher'
const Sidepanel = ({ chores }) => {
const isLargeScreen = useMediaQuery(theme => theme.breakpoints.up('md'))
const isLargeScreen = useMediaQuery(theme => theme.breakpoints.up('lg'))
const [dueDatePieChartData, setDueDatePieChartData] = useState([])
const [sidepanelConfig, setSidepanelConfig] = useState([])
const {
data: choresHistory,
isChoresHistoryLoading,
@@ -18,6 +21,18 @@ const Sidepanel = ({ chores }) => {
useEffect(() => {
setDueDatePieChartData(generateChoreDuePieChartData(chores))
setSidepanelConfig(getSidepanelConfig())
}, [])
useEffect(() => {
const handleConfigChange = () => {
setSidepanelConfig(getSidepanelConfig())
}
window.addEventListener('sidepanelConfigChanged', handleConfigChange)
return () => {
window.removeEventListener('sidepanelConfigChanged', handleConfigChange)
}
}, [])
const generateChoreDuePieChartData = chores => {
@@ -34,34 +49,60 @@ const Sidepanel = ({ chores }) => {
.filter(item => item.value > 0)
}
const renderCard = cardConfig => {
if (!cardConfig.enabled) return null
switch (cardConfig.id) {
case 'welcome':
return <UserSwitcher key='welcome' chores={chores} />
case 'assignees':
return <TasksByAssigneeCard key='assignees' chores={chores} />
case 'calendar':
return (
<Sheet
key='calendar'
variant='plain'
sx={{
my: 1,
p: 2,
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
mr: 10,
justifyContent: 'space-between',
boxShadow: 'sm',
borderRadius: 20,
width: '315px',
}}
>
<Box
sx={{ width: '100%', overflowY: 'hidden', overflowX: 'hidden' }}
>
<CalendarCard chores={chores} />
</Box>
</Sheet>
)
case 'activities':
return (
<ActivitiesCard
key='activities'
chores={chores}
choreHistory={choresHistory}
/>
)
default:
return null
}
}
if (!isLargeScreen) {
return null
}
return (
<Box>
<WelcomeCard chores={chores} />
<Sheet
variant='plain'
sx={{
my: 1,
p: 2,
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
mr: 10,
justifyContent: 'space-between',
boxShadow: 'sm',
borderRadius: 20,
width: '315px',
}}
>
<Box sx={{ width: '100%', overflowY: 'hidden' }}>
<CalendarView chores={chores} />
</Box>
</Sheet>
<ActivitiesCard chores={chores} choreHistory={choresHistory} />
</Box>
)
const sortedCards = [...sidepanelConfig].sort((a, b) => a.order - b.order)
return <Box>{sortedCards.map(cardConfig => renderCard(cardConfig))}</Box>
}
export default Sidepanel

View File

@@ -8,7 +8,7 @@ import {
Typography,
} from '@mui/joy'
import IconButton from '@mui/joy/IconButton'
import React, { useEffect, useRef, useState } from 'react'
import { useEffect, useRef, useState } from 'react'
import { getTextColorFromBackgroundColor } from '../../utils/Colors.jsx'
const SortAndGrouping = ({
@@ -100,6 +100,7 @@ const SortAndGrouping = ({
</MenuItem>
{[
{ name: 'Smart', value: 'default' },
{ name: 'Due Date', value: 'due_date' },
{ name: 'Priority', value: 'priority' },
{ name: 'Labels', value: 'labels' },
@@ -144,7 +145,7 @@ const SortAndGrouping = ({
<MenuItem key={`${k}-assignee-title`} disabled>
<Typography level='body-xs' fontWeight='md'>
Assigned to:
Assigned to :
</Typography>
</MenuItem>
@@ -173,6 +174,20 @@ const SortAndGrouping = ({
<Typography level='body-sm'>Assigned to me</Typography>
</MenuItem>
{/* <MenuItem
key={`${k}-assignee-assignable-to-me`}
onClick={() => {
setFilter('assignable_to_me')
handleMenuClose()
}}
>
<Radio
checked={selectedFilter === 'assignable_to_me'}
variant='outlined'
/>
<Typography level='body-sm'>Available for me</Typography>
</MenuItem> */}
<MenuItem
key={`${k}-assignee-assigned-to-others`}
onClick={() => {
@@ -186,6 +201,21 @@ const SortAndGrouping = ({
/>
<Typography level='body-sm'>Assigned to others</Typography>
</MenuItem>
{/*
// i need this but i think it have a bad UX and confusing so commenting it for now
<MenuItem
key={`${k}-assignee-created-by-me`}
onClick={() => {
setFilter('created_by_me')
handleMenuClose()
}}
>
<Radio
checked={selectedFilter === 'created_by_me'}
variant='outlined'
/>
<Typography level='body-sm'>Created by me</Typography>
</MenuItem> */}
</Menu>
</>
)

View File

@@ -0,0 +1,398 @@
import { BarChart, Person } from '@mui/icons-material'
import { Avatar, Box, Sheet, Typography } from '@mui/joy'
import { useEffect, useState } from 'react'
import { useCircleMembers } from '../../queries/UserQueries'
import { TASK_COLOR } from '../../utils/Colors'
import { resolvePhotoURL } from '../../utils/Helpers'
const TasksByAssigneeCard = ({ chores = [] }) => {
const [assigneeData, setAssigneeData] = useState([])
const { data: circleMembersData, isLoading: isCircleMembersLoading } =
useCircleMembers()
useEffect(() => {
if (
!isCircleMembersLoading &&
circleMembersData?.res &&
chores.length > 0
) {
const members = circleMembersData.res
const data = processTasksByAssignee(chores, members)
setAssigneeData(data)
}
}, [chores, circleMembersData, isCircleMembersLoading])
const processTasksByAssignee = (chores, members) => {
const assigneeStats = {}
// Initialize stats for all members
members.forEach(member => {
assigneeStats[member.userId] = {
id: member.userId,
name: member.displayName || member.name,
image: member.image,
inProgress: 0,
overdue: 0,
scheduled: 0,
pendingReview: 0,
total: 0,
}
})
// Count tasks by status for each assignee
chores.forEach(chore => {
if (chore.assignedTo && assigneeStats[chore.assignedTo]) {
const assignee = assigneeStats[chore.assignedTo]
assignee.total++
// Map chore status to our categories based on your system
if (chore.status === 3) {
// Pending approval/review
assignee.pendingReview++
} else if (chore.status === 1 || chore.status === 2) {
// In progress (started or paused)
assignee.inProgress++
} else if (
chore.nextDueDate &&
new Date(chore.nextDueDate) < new Date()
) {
// Overdue - past due date
assignee.overdue++
} else {
// Scheduled/planned - future due date or no due date
assignee.scheduled++
}
}
})
// Filter out members with no tasks and sort by total tasks
return Object.values(assigneeStats)
.filter(assignee => assignee.total > 0)
.sort((a, b) => b.total - a.total)
}
const getStatusColor = status => {
switch (status) {
case 'inProgress':
return TASK_COLOR.IN_PROGRESS
case 'overdue':
return TASK_COLOR.OVERDUE
case 'scheduled':
return TASK_COLOR.COMPLETED
case 'pendingReview':
return TASK_COLOR.PENDING_REVIEW
default:
return TASK_COLOR.DEFAULT
}
}
const maxTasks = Math.max(...assigneeData.map(a => a.total), 1)
if (isCircleMembersLoading) {
return (
<Sheet
variant='plain'
sx={{
p: 2,
display: 'flex',
flexDirection: 'column',
boxShadow: 'sm',
borderRadius: 20,
width: '315px',
minHeight: 300,
mb: 1,
}}
>
<Typography level='body-sm' color='neutral'>
Loading tasks by assignee...
</Typography>
</Sheet>
)
}
if (assigneeData.length === 0) {
return (
<Sheet
variant='plain'
sx={{
p: 2,
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
boxShadow: 'sm',
borderRadius: 20,
width: '315px',
minHeight: 300,
mb: 1,
}}
>
<Person sx={{ fontSize: 48, opacity: 0.3, mb: 1 }} />
<Typography level='body-sm' color='neutral'>
No assigned tasks found
</Typography>
</Sheet>
)
}
return (
<Sheet
variant='plain'
sx={{
p: 2,
display: 'flex',
flexDirection: 'column',
boxShadow: 'sm',
borderRadius: 20,
width: '315px',
minHeight: 300,
mb: 1,
}}
>
{/* Header */}
<Box sx={{ mb: 2 }}>
<Box
sx={{
display: 'flex',
alignItems: 'center',
justifyContent: 'flex-start',
gap: 1,
}}
>
<BarChart color='' />
<Typography level='title-md'>Tasks by Assignee</Typography>
</Box>
</Box>
{/* Legend */}
<Box
sx={{
display: 'grid',
gridTemplateColumns: 'repeat(2, 1fr)',
gap: 1,
mb: 3,
px: 1,
}}
>
{[
{
key: 'inProgress',
label: 'In Progress',
color: getStatusColor('inProgress'),
},
{
key: 'overdue',
label: 'Overdue',
color: getStatusColor('overdue'),
},
{
key: 'scheduled',
label: 'Scheduled',
color: getStatusColor('scheduled'),
},
{
key: 'pendingReview',
label: 'Pending Review',
color: getStatusColor('pendingReview'),
},
].map(status => (
<Box
key={status.key}
sx={{
display: 'flex',
alignItems: 'center',
gap: 0.5,
minWidth: 0,
}}
>
<Box
sx={{
width: 10,
height: 10,
borderRadius: '50%',
backgroundColor: status.color,
flexShrink: 0,
}}
/>
<Typography
level='body-xs'
sx={{
fontSize: '10px',
whiteSpace: 'nowrap',
overflow: 'hidden',
textOverflow: 'ellipsis',
}}
>
{status.label}
</Typography>
</Box>
))}
</Box>
{/* Chart Container */}
<Box sx={{ position: 'relative', height: 200 }}>
{/* Chart */}
<Box
sx={{
display: 'flex',
alignItems: 'end',
gap: 1,
height: '100%',
pl: 4,
pr: 2,
pt: 2,
}}
>
{assigneeData.slice(0, 6).map((assignee, index) => {
const barHeight = Math.max((assignee.total / maxTasks) * 140, 8)
return (
<Box
key={assignee.id}
sx={{
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
flex: 1,
minWidth: 0,
maxWidth: 45,
}}
>
{/* Avatar */}
<Avatar
size='sm'
src={resolvePhotoURL(assignee.image)}
sx={{
width: 28,
height: 28,
mb: 1,
border: '2px solid white',
boxShadow: 'sm',
fontSize: '12px',
}}
>
{assignee.name?.charAt(0) || <Person />}
</Avatar>
{/* Stacked bars */}
<Box
sx={{
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
height: barHeight,
width: '100%',
maxWidth: 28,
borderRadius: 4,
overflow: 'hidden',
border: '1px solid #E5E7EB',
backgroundColor: '#F9FAFB',
}}
>
{/* Pending Review - bottom */}
{assignee.pendingReview > 0 && (
<Box
sx={{
width: '100%',
height: `${(assignee.pendingReview / assignee.total) * 100}%`,
backgroundColor: getStatusColor('pendingReview'),
order: 4,
}}
/>
)}
{/* Scheduled */}
{assignee.scheduled > 0 && (
<Box
sx={{
width: '100%',
height: `${(assignee.scheduled / assignee.total) * 100}%`,
backgroundColor: getStatusColor('scheduled'),
order: 3,
}}
/>
)}
{/* In Progress */}
{assignee.inProgress > 0 && (
<Box
sx={{
width: '100%',
height: `${(assignee.inProgress / assignee.total) * 100}%`,
backgroundColor: getStatusColor('inProgress'),
order: 2,
}}
/>
)}
{/* Overdue - top */}
{assignee.overdue > 0 && (
<Box
sx={{
width: '100%',
height: `${(assignee.overdue / assignee.total) * 100}%`,
backgroundColor: getStatusColor('overdue'),
order: 1,
}}
/>
)}
</Box>
{/* Name */}
<Box sx={{ mt: 1, textAlign: 'center', width: '100%' }}>
<Typography
level='body-xs'
sx={{
fontWeight: 500,
fontSize: '10px',
whiteSpace: 'nowrap',
overflow: 'hidden',
textOverflow: 'ellipsis',
}}
>
{assignee.name}
</Typography>
</Box>
</Box>
)
})}
</Box>
{/* Y-axis labels */}
<Box
sx={{
position: 'absolute',
left: 0,
bottom: 20,
height: 140,
width: 32,
pr: 0.5,
}}
>
{[0, 20, 40, 60, 80, 100].map((value, index) => {
const yPosition = (value / 100) * 140
return (
<Typography
key={value}
level='body-xs'
sx={{
fontSize: '9px',
color: 'text.secondary',
lineHeight: 1,
position: 'absolute',
bottom: `${yPosition}px`,
right: 4,
transform: 'translateY(50%)',
}}
>
{Math.round((value / 100) * maxTasks)}
</Typography>
)
})}
</Box>
</Box>
</Sheet>
)
}
export default TasksByAssigneeCard

View File

@@ -1,35 +1,29 @@
import { Person } from '@mui/icons-material'
import { SupervisorAccount } from '@mui/icons-material'
import { Avatar, Box, Button, Sheet, Typography } from '@mui/joy'
import { useContext, useEffect, useState } from 'react'
import { useEffect, useState } from 'react'
import { useImpersonateUser } from '../../contexts/ImpersonateUserContext'
import { UserContext } from '../../contexts/UserContext'
import { useCircleMembers } from '../../queries/UserQueries'
import { useCircleMembers, useUserProfile } from '../../queries/UserQueries'
import UserModal from '../Modals/Inputs/UserModal'
const WelcomeCard = () => {
const { impersonatedUser, setImpersonatedUser } = useImpersonateUser()
const [isAdmin, setIsAdmin] = useState(false)
const { userProfile } = useContext(UserContext)
const UserSwitcher = () => {
const {
impersonatedUser,
isImpersonating,
startImpersonation,
stopImpersonation,
canImpersonate
} = useImpersonateUser()
const { data: userProfile } = useUserProfile()
const [isModalOpen, setIsModalOpen] = useState(false)
const { data: circleMembersData, isLoading: isCircleMembersLoading } =
useCircleMembers()
useEffect(() => {
if (userProfile && userProfile?.id) {
const members = circleMembersData?.res || []
const isUserAdmin = members.some(
member =>
member.userId === userProfile?.id &&
(member.role === 'admin' || member.role === 'manager'),
)
setIsAdmin(isUserAdmin)
}
}, [userProfile, circleMembersData])
// Check if current user can impersonate
const isAdmin = canImpersonate(userProfile, circleMembersData?.res)
if (!isAdmin) {
return null
} else if (isCircleMembersLoading || impersonatedUser === null) {
} else if (isCircleMembersLoading || !isImpersonating) {
return (
<Sheet
variant='plain'
@@ -57,13 +51,16 @@ const WelcomeCard = () => {
gap: 1,
}}
>
<Person color='' />
<Typography level='title-md'>Current User</Typography>
<SupervisorAccount color='' />
<Typography level='title-md'>View tasks as</Typography>
</Box>
</Box>
<Box sx={{ mb: 2 }}>
<Typography level='title-md' sx={{ mb: 0.5 }}>
Who&apos;s checking in?
Switch to user view
</Typography>
<Typography level='body-sm' sx={{ mb: 1, color: 'text.secondary' }}>
Tasks will be filtered to show only assignments for selected user
</Typography>
</Box>
<Button
@@ -72,13 +69,13 @@ const WelcomeCard = () => {
onClick={() => setIsModalOpen(true)}
size='sm'
>
Select User
Choose User
</Button>
<UserModal
isOpen={isModalOpen}
performers={circleMembersData?.res}
onSelect={user => {
setImpersonatedUser(user)
startImpersonation(user, userProfile)
setIsModalOpen(false)
}}
onClose={() => setIsModalOpen(false)}
@@ -122,8 +119,8 @@ const WelcomeCard = () => {
gap: 1,
}}
>
<Person color='' />
<Typography level='title-md'>Current User</Typography>
<SupervisorAccount color='' />
<Typography level='title-md'>View tasks as</Typography>
</Box>
</Box>
@@ -164,7 +161,7 @@ const WelcomeCard = () => {
size='sm'
sx={{ ml: 0.5 }}
onClick={() => {
setImpersonatedUser(null)
stopImpersonation()
}}
>
Cancel
@@ -177,7 +174,7 @@ const WelcomeCard = () => {
isOpen={isModalOpen}
performers={circleMembersData?.res}
onSelect={user => {
setImpersonatedUser(user)
startImpersonation(user, userProfile)
setIsModalOpen(false)
}}
onClose={() => {
@@ -187,4 +184,4 @@ const WelcomeCard = () => {
</Sheet>
)
}
export default WelcomeCard
export default UserSwitcher

View File

@@ -2,12 +2,20 @@ import { Box, Container, Input, Sheet, Typography } from '@mui/joy'
import Logo from '../../Logo'
import { Button } from '@mui/joy'
import { useContext } from 'react'
import { useState } from 'react'
import { useNavigate, useSearchParams } from 'react-router-dom'
import { UserContext } from '../../contexts/UserContext'
import useAcknowledgmentModal from '../../hooks/useAcknowledgmentModal'
import { useUserProfile } from '../../queries/UserQueries'
import { useNotification } from '../../service/NotificationProvider'
import { JoinCircle } from '../../utils/Fetcher'
import AcknowledgmentModal from '../Modals/Inputs/AcknowledgmentModal'
const JoinCircleView = () => {
const { userProfile, setUserProfile } = useContext(UserContext)
const { data: userProfile } = useUserProfile()
const { showError } = useNotification()
const { ackModalConfig, showAcknowledgment } = useAcknowledgmentModal()
const [isJoining, setIsJoining] = useState(false)
let [searchParams, setSearchParams] = useSearchParams()
const navigate = useNavigate()
const code = searchParams.get('code')
@@ -79,25 +87,31 @@ const JoinCircleView = () => {
fullWidth
size='lg'
sx={{ mt: 3, mb: 2 }}
disabled={isJoining}
onClick={() => {
setIsJoining(true)
JoinCircle(code).then(resp => {
if (resp.ok) {
alert(
'Joined circle successfully, wait for the circle owner to accept your request.',
showAcknowledgment(
'Your join request has been sent successfully! The circle admin will need to approve your request before you can access the circle and its chores. You will receive a notification once your request is approved.',
'Join Request Sent!',
() => navigate('/'),
'Got it',
'success',
)
navigate('/my/chores')
} else {
setIsJoining(false)
if (resp.status === 409) {
alert('You are already a member of this circle')
showError('You are already a member of this circle')
} else {
alert('Failed to join circle')
showError('Failed to join circle')
}
navigate('/my/chores')
navigate('/')
}
})
}}
>
Join Circle
{isJoining ? 'Joining...' : 'Join Circle'}
</Button>
<Button
fullWidth
@@ -111,7 +125,7 @@ const JoinCircleView = () => {
borderRadius: '8px',
}}
onClick={() => {
navigate('/my/chores')
navigate('/chores')
}}
>
Cancel
@@ -141,6 +155,7 @@ const JoinCircleView = () => {
))}
</Sheet>
</Box>
<AcknowledgmentModal config={ackModalConfig} />
</Container>
)
}

View File

@@ -1,12 +1,5 @@
import { HomeRounded, Login } from '@mui/icons-material'
import {
Box,
Button,
CircularProgress,
Container,
Textarea,
Typography,
} from '@mui/joy'
import { Box, Button, CircularProgress, Container, Typography } from '@mui/joy'
import { Link } from 'react-router-dom'
import Logo from '../Logo' // Adjust the import path as necessary
@@ -50,7 +43,7 @@ const Error = () => {
<Button
component={Link}
to='/my/chores'
to='/chores'
variant='outlined'
color='primary'
sx={{ mt: 4 }}

View File

@@ -1,68 +1,88 @@
import { Checklist, EventBusy, Group, Timelapse } from '@mui/icons-material'
import {
Avatar,
Analytics,
Checklist,
EventBusy,
Group,
History,
Star,
Timelapse,
TrendingUp,
} from '@mui/icons-material'
import {
Box,
Button,
Chip,
Card,
Container,
Grid,
List,
ListItem,
ListItemContent,
Sheet,
Typography,
} from '@mui/joy'
import moment from 'moment'
import { useEffect, useState } from 'react'
import { Link, useParams } from 'react-router-dom'
import useConfirmationModal from '../../hooks/useConfirmationModal'
import { ChoreHistoryStatus } from '../../utils/Chores'
import {
DeleteChoreHistory,
GetAllCircleMembers,
GetChoreHistory,
UpdateChoreHistory,
} from '../../utils/Fetcher'
useChoreHistory,
useDeleteChoreHistory,
useUpdateChoreHistory,
} from '../../queries/ChoreQueries'
import { useCircleMembers } from '../../queries/UserQueries'
import LoadingComponent from '../components/Loading'
import EditHistoryModal from '../Modals/EditHistoryModal'
import ConfirmationModal from '../Modals/Inputs/ConfirmationModal'
import HistoryCard from './HistoryCard'
const ChoreHistory = () => {
const [choreHistory, setChoresHistory] = useState([])
const [userHistory, setUserHistory] = useState([])
const [performers, setPerformers] = useState([])
const [historyInfo, setHistoryInfo] = useState([])
const [isLoading, setIsLoading] = useState(true) // Add loading state
const { choreId } = useParams()
const [isEditModalOpen, setIsEditModalOpen] = useState(false)
const [editHistory, setEditHistory] = useState({})
const { confirmModalConfig, showConfirmation } = useConfirmationModal()
// React Query hooks
const { data: choreHistoryData, isLoading } = useChoreHistory(choreId)
const { data: circleMembersData } = useCircleMembers()
const updateChoreHistory = useUpdateChoreHistory()
const deleteChoreHistory = useDeleteChoreHistory()
const choreHistory = choreHistoryData?.res || []
const performers = circleMembersData?.res || []
const handleDelete = historyEntry => {
showConfirmation(
`Are you sure you want to delete this history record?`,
'Delete History Record',
() => {
deleteChoreHistory.mutate({
choreId,
historyId: historyEntry.id,
})
},
'Delete',
'Cancel',
'danger',
)
}
const handleEdit = historyEntry => {
setIsEditModalOpen(true)
setEditHistory(historyEntry)
}
useEffect(() => {
setIsLoading(true) // Start loading
Promise.all([
GetChoreHistory(choreId).then(res => res.json()),
GetAllCircleMembers(),
])
.then(([historyData, usersData]) => {
setChoresHistory(historyData.res)
const newUserChoreHistory = {}
historyData.res.forEach(choreHistory => {
const userId = choreHistory.completedBy
newUserChoreHistory[userId] = (newUserChoreHistory[userId] || 0) + 1
})
setUserHistory(newUserChoreHistory)
setPerformers(usersData.res)
updateHistoryInfo(historyData.res, newUserChoreHistory, usersData.res)
if (choreHistory.length > 0 && performers.length > 0) {
const newUserChoreHistory = {}
choreHistory.forEach(historyEntry => {
const userId = historyEntry.completedBy
newUserChoreHistory[userId] = (newUserChoreHistory[userId] || 0) + 1
})
.catch(error => {
console.error('Error fetching data:', error)
// Handle errors, e.g., show an error message to the user
})
.finally(() => {
setIsLoading(false) // Finish loading
})
}, [choreId])
setUserHistory(newUserChoreHistory)
updateHistoryInfo(choreHistory, newUserChoreHistory, performers)
}
}, [choreHistory, performers])
const updateHistoryInfo = (histories, userHistories, performers) => {
// average delay for task completaion from due date:
@@ -91,46 +111,46 @@ const ChoreHistory = () => {
const userCompletedByMost = Object.keys(userHistories).reduce((a, b) =>
userHistories[a] > userHistories[b] ? a : b,
)
const userCompletedByLeast = Object.keys(userHistories).reduce((a, b) =>
userHistories[a] < userHistories[b] ? a : b,
)
const historyInfo = [
{
icon: <Checklist />,
text: 'Total Completed',
subtext: `${histories.length} times`,
text: 'All Completed',
subtext: `${histories.filter(h => h.status === ChoreHistoryStatus.COMPLETED).length} times`,
},
{
icon: <TrendingUp />,
text: 'Average Timing',
subtext: moment.duration(averageDelayMoment).isValid()
? moment.duration(averageDelayMoment).humanize()
: 'On time',
},
{
icon: <Timelapse />,
text: 'Usually Within',
subtext: moment.duration(averageDelayMoment).humanize(),
text: 'Longest Delay',
subtext: moment.duration(maxDelayMoment).isValid()
? moment.duration(maxDelayMoment).humanize()
: 'Never late',
},
{
icon: <Timelapse />,
text: 'Maximum Delay',
subtext: moment.duration(maxDelayMoment).humanize(),
},
{
icon: <Avatar />,
text: ' Completed Most',
icon: <Star />,
text: 'Completed Most',
subtext: `${
performers.find(p => p.userId === Number(userCompletedByMost))
?.displayName
} `,
?.displayName || 'Unknown'
}`,
},
// contributes:
{
icon: <Group />,
text: 'Total Performers',
subtext: `${Object.keys(userHistories).length} users`,
text: 'Members Involved',
subtext: `${Object.keys(userHistories).length} members`,
},
{
icon: <Avatar />,
icon: <Analytics />,
text: 'Last Completed',
subtext: `${
performers.find(p => p.userId === Number(histories[0].completedBy))
?.displayName
?.displayName || 'Unknown'
}`,
},
]
@@ -171,7 +191,7 @@ const ChoreHistory = () => {
they'll show up here.
</Typography>
<Button variant='soft' sx={{ mt: 2 }}>
<Link to='/my/chores'>Go back to chores</Link>
<Link to='/chores'>Go back to chores</Link>
</Button>
</Container>
)
@@ -179,52 +199,103 @@ const ChoreHistory = () => {
return (
<Container maxWidth='md'>
<Typography level='title-md' mb={1.5}>
Summary:
</Typography>
<Sheet
// sx={{
// mb: 1,
// borderRadius: 'lg',
// p: 2,
// }}
sx={{ borderRadius: 'sm', p: 2 }}
variant='outlined'
>
<Grid container spacing={1}>
{/* Enhanced Header Section */}
<Box sx={{ mb: 4 }}>
{/* Statistics Cards Grid - Compact Design */}
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2, mb: 3 }}>
<History sx={{ fontSize: '1.5rem' }} />
<Typography
level='title-md'
sx={{ fontWeight: 'lg', color: 'text.primary' }}
>
Task Summary
</Typography>
</Box>
<Grid container spacing={0.5} sx={{ mb: 2 }}>
{historyInfo.map((info, index) => (
<Grid item xs={4} key={index}>
{/* divider between the list items: */}
<ListItem key={index}>
<ListItemContent>
<Typography level='body-xs' sx={{ fontWeight: 'md' }}>
<Grid item xs={4} sm={2} key={index}>
<Card
variant='soft'
sx={{
borderRadius: 'sm',
p: 1,
height: 85,
textAlign: 'center',
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'space-between',
overflow: 'hidden',
}}
>
<Box sx={{ opacity: 0.8, flexShrink: 0 }}>{info.icon}</Box>
<Box
sx={{
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
gap: 0.25,
flex: 1,
justifyContent: 'center',
}}
>
<Typography
level='body-xs'
sx={{
fontWeight: '600',
color: 'text.primary',
textAlign: 'center',
lineHeight: 1.1,
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
width: '100%',
fontSize: '0.75rem',
}}
>
{info.text}
</Typography>
<Chip color='primary' size='md' startDecorator={info.icon}>
{info.subtext ? info.subtext : '--'}
</Chip>
</ListItemContent>
</ListItem>
<Typography
level='body-xs'
sx={{
color: 'text.secondary',
textAlign: 'center',
lineHeight: 1.1,
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
width: '100%',
fontSize: '0.7rem',
}}
>
{info.subtext || '--'}
</Typography>
</Box>
</Card>
</Grid>
))}
</Grid>
</Sheet>
</Box>
{/* User History Cards */}
<Typography level='title-md' my={1.5}>
History:
</Typography>
<Sheet sx={{ borderRadius: 'sm', p: 2, boxShadow: 'md' }}>
{/* History Section Header */}
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2, mb: 3 }}>
<Analytics sx={{ fontSize: '1.5rem' }} />
<Typography
level='title-md'
sx={{ fontWeight: 'lg', color: 'text.primary' }}
>
Task Activity
</Typography>
</Box>
<Sheet variant='plain' sx={{ borderRadius: 'sm', boxShadow: 'md' }}>
{/* Chore History List (Updated Style) */}
<List sx={{ p: 0 }}>
{choreHistory.map((historyEntry, index) => (
<HistoryCard
onClick={() => {
setIsEditModalOpen(true)
setEditHistory(historyEntry)
}}
onClick={() => handleEdit(historyEntry)}
onEditClick={handleEdit}
onDeleteClick={handleDelete}
historyEntry={historyEntry}
performers={performers}
allHistory={choreHistory}
@@ -241,39 +312,44 @@ const ChoreHistory = () => {
setIsEditModalOpen(false)
},
onSave: updated => {
UpdateChoreHistory(choreId, editHistory.id, {
performedAt: updated.performedAt,
dueDate: updated.dueDate,
notes: updated.notes,
}).then(res => {
if (!res.ok) {
console.error('Failed to update chore history:', res)
return
}
const newRecord = res.json().then(data => {
const newRecord = data.res
const newHistory = choreHistory.map(record =>
record.id === newRecord.id ? newRecord : record,
)
setChoresHistory(newHistory)
setEditHistory(newRecord)
setIsEditModalOpen(false)
})
})
updateChoreHistory.mutate(
{
choreId,
historyId: editHistory.id,
historyData: {
performedAt: updated.performedAt,
dueDate: updated.dueDate,
notes: updated.notes,
},
},
{
onSuccess: data => {
setEditHistory(data.res)
setIsEditModalOpen(false)
},
onError: error => {
console.error('Failed to update chore history:', error)
},
},
)
},
onDelete: () => {
DeleteChoreHistory(choreId, editHistory.id).then(() => {
const newHistory = choreHistory.filter(
record => record.id !== editHistory.id,
)
setChoresHistory(newHistory)
setIsEditModalOpen(false)
})
deleteChoreHistory.mutate(
{
choreId,
historyId: editHistory.id,
},
{
onSuccess: () => {
setIsEditModalOpen(false)
},
},
)
},
}}
historyRecord={editHistory}
/>
<ConfirmationModal config={confirmModalConfig} />
</Container>
)
}

View File

@@ -1,67 +1,126 @@
import { CalendarViewDay, Check, Timelapse } from '@mui/icons-material'
import {
AccessTime,
CalendarMonth,
Check,
CheckCircle,
Delete,
Edit,
EventNote,
HourglassEmpty,
Person,
Redo,
ThumbDown,
Timelapse,
Toll,
} from '@mui/icons-material'
import {
Avatar,
Box,
Chip,
Grid,
IconButton,
ListDivider,
ListItem,
ListItemContent,
ListItemDecorator,
Typography,
} from '@mui/joy'
import moment from 'moment'
import { useEffect, useRef, useState } from 'react'
import { TASK_COLOR } from '../../utils/Colors.jsx'
export const getCompletedChip = historyEntry => {
var text = 'No Due Date'
var color = 'info'
var icon = <CalendarViewDay />
// if completed few hours +-6 hours
if (
historyEntry.dueDate &&
historyEntry.performedAt > historyEntry.dueDate - 1000 * 60 * 60 * 6 &&
historyEntry.performedAt < historyEntry.dueDate + 1000 * 60 * 60 * 6
) {
text = 'On Time'
color = 'success'
icon = <Check />
} else if (
historyEntry.dueDate &&
historyEntry.performedAt < historyEntry.dueDate
) {
text = 'On Time'
color = 'success'
icon = <Check />
const getCompletedChip = historyEntry => {
if (historyEntry.status === 0) {
return null
}
if (!historyEntry.dueDate) {
return null
// <Chip
// size='sm'
// variant='soft'
// color='neutral'
// startDecorator={<CalendarViewDay />}
// >
// No Due Date
// </Chip>
}
// if completed after due date then it's late
else if (
historyEntry.dueDate &&
historyEntry.performedAt > historyEntry.dueDate
) {
text = 'Late'
color = 'warning'
icon = <Timelapse />
const performedAt = moment(historyEntry.performedAt)
const dueDate = moment(historyEntry.dueDate)
// TODO: make this a config at some point
const gracePeriod = 6 * 60 * 60 * 1000 // 6 hours in milliseconds
if (Math.abs(performedAt - dueDate) <= gracePeriod) {
return (
<Chip
size='sm'
variant='solid'
sx={{ backgroundColor: TASK_COLOR.COMPLETED, color: 'white' }}
startDecorator={<Check />}
>
On Time
</Chip>
)
} else if (performedAt.isBefore(dueDate)) {
return (
<Chip
size='sm'
variant='soft'
sx={{ backgroundColor: TASK_COLOR.SCHEDULED, color: 'white' }}
startDecorator={<Check />}
>
Early
</Chip>
)
} else {
text = 'No Due Date'
color = 'neutral'
icon = <CalendarViewDay />
return (
<Chip
size='sm'
variant='solid'
sx={{ backgroundColor: TASK_COLOR.LATE, color: 'white' }}
startDecorator={<Timelapse />}
>
Late
</Chip>
)
}
return (
<Chip startDecorator={icon} color={color}>
{text}
</Chip>
)
}
const formatTime = seconds => {
if (typeof seconds !== 'number' || isNaN(seconds) || seconds < 0) {
return null
}
const hours = Math.floor(seconds / 3600)
const minutes = Math.floor((seconds % 3600) / 60)
const secs = seconds % 60
return `${hours.toString().padStart(2, '0')}:${minutes.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`
}
/**
* Compact HistoryCard component with improved UX and 2-row height design
*/
const HistoryCard = ({
allHistory,
performers,
historyEntry,
index,
onClick,
onEditClick,
onDeleteClick,
}) => {
function formatTimeDifference(startDate, endDate) {
const performer = performers.find(p => p.userId === historyEntry.completedBy)
const assignedTo = performers.find(p => p.userId === historyEntry.assignedTo)
// Swipe functionality state
const [swipeTranslateX, setSwipeTranslateX] = useState(0)
const [isDragging, setIsDragging] = useState(false)
const [isSwipeRevealed, setIsSwipeRevealed] = useState(false)
const [hoverTimer, setHoverTimer] = useState(null)
const swipeThreshold = 80
const maxSwipeDistance = 200
const dragStartX = useRef(0)
const cardRef = useRef(null)
const formatTimeDifference = (startDate, endDate) => {
const diffInMinutes = moment(startDate).diff(endDate, 'minutes')
let timeValue = diffInMinutes
let unit = 'minute'
@@ -81,87 +140,502 @@ const HistoryCard = ({
return `${timeValue} ${unit}${timeValue !== 1 ? 's' : ''}`
}
const getStatusAvatar = () => {
const statusMap = {
0: { icon: <AccessTime />, color: 'primary' }, // Started
1: { icon: <Check />, color: 'success' }, // Completed
2: { icon: <Redo />, color: 'warning' }, // Skipped
3: { icon: <HourglassEmpty />, color: 'neutral' }, // Pending Approval
4: { icon: <ThumbDown />, color: 'danger' }, // Rejected
}
const config = statusMap[historyEntry.status] || statusMap[1]
return (
<Avatar
size='sm'
color={config.color}
variant='soft'
sx={{
width: 24,
height: 24,
'& svg': { fontSize: '14px' },
}}
>
{config.icon}
</Avatar>
)
}
// Swipe gesture handlers
const handleTouchStart = e => {
dragStartX.current = e.touches[0].clientX
setIsDragging(true)
}
const handleTouchMove = e => {
if (!isDragging) return
const currentX = e.touches[0].clientX
const deltaX = currentX - dragStartX.current
if (isSwipeRevealed) {
if (deltaX > 0) {
const clampedDelta = Math.min(deltaX - maxSwipeDistance, 0)
setSwipeTranslateX(clampedDelta)
}
} else {
if (deltaX < 0) {
const clampedDelta = Math.max(deltaX, -maxSwipeDistance)
setSwipeTranslateX(clampedDelta)
}
}
}
const handleTouchEnd = () => {
if (!isDragging) return
setIsDragging(false)
if (isSwipeRevealed) {
if (swipeTranslateX > -swipeThreshold) {
setSwipeTranslateX(0)
setIsSwipeRevealed(false)
} else {
setSwipeTranslateX(-maxSwipeDistance)
}
} else {
if (Math.abs(swipeTranslateX) > swipeThreshold) {
setSwipeTranslateX(-maxSwipeDistance)
setIsSwipeRevealed(true)
} else {
setSwipeTranslateX(0)
setIsSwipeRevealed(false)
}
}
}
const handleMouseDown = e => {
dragStartX.current = e.clientX
setIsDragging(true)
}
const handleMouseMove = e => {
if (!isDragging) return
const currentX = e.clientX
const deltaX = currentX - dragStartX.current
if (isSwipeRevealed) {
if (deltaX > 0) {
const clampedDelta = Math.min(deltaX - maxSwipeDistance, 0)
setSwipeTranslateX(clampedDelta)
}
} else {
if (deltaX < 0) {
const clampedDelta = Math.max(deltaX, -maxSwipeDistance)
setSwipeTranslateX(clampedDelta)
}
}
}
const handleMouseUp = () => {
if (!isDragging) return
setIsDragging(false)
if (isSwipeRevealed) {
if (swipeTranslateX > -swipeThreshold) {
setSwipeTranslateX(0)
setIsSwipeRevealed(false)
} else {
setSwipeTranslateX(-maxSwipeDistance)
}
} else {
if (Math.abs(swipeTranslateX) > swipeThreshold) {
setSwipeTranslateX(-maxSwipeDistance)
setIsSwipeRevealed(true)
} else {
setSwipeTranslateX(0)
setIsSwipeRevealed(false)
}
}
}
const resetSwipe = () => {
setSwipeTranslateX(0)
setIsSwipeRevealed(false)
}
// Hover functionality for desktop - only trigger from drag area
const handleMouseEnter = () => {
if (isSwipeRevealed) return
const timer = setTimeout(() => {
setSwipeTranslateX(-maxSwipeDistance)
setIsSwipeRevealed(true)
setHoverTimer(null)
}, 800) // Shorter delay for drag area
setHoverTimer(timer)
}
const handleMouseLeave = () => {
if (hoverTimer) {
clearTimeout(hoverTimer)
setHoverTimer(null)
}
// Only add hide timer if we're leaving the drag area and actions are NOT revealed
// If actions are revealed, let the action area handle the hiding
if (!isSwipeRevealed) {
// Actions are not revealed, so we can safely hide after delay
const hideTimer = setTimeout(() => {
resetSwipe()
}, 300)
setHoverTimer(hideTimer)
}
}
const handleActionAreaMouseEnter = () => {
// Clear any pending timer when entering action area
if (hoverTimer) {
clearTimeout(hoverTimer)
setHoverTimer(null)
}
}
const handleActionAreaMouseLeave = () => {
// Hide immediately when leaving action area
if (isSwipeRevealed) {
resetSwipe()
}
}
// Clean up timer on unmount
useEffect(() => {
return () => {
if (hoverTimer) {
clearTimeout(hoverTimer)
}
}
}, [hoverTimer])
return (
<>
<ListItem sx={{ gap: 1.5, alignItems: 'flex-start' }} onClick={onClick}>
{' '}
{/* Adjusted spacing and alignment */}
<ListItemDecorator>
<Avatar sx={{ mr: 1 }}>
{performers
.find(p => p.userId === historyEntry.completedBy)
?.displayName?.charAt(0) || '?'}
</Avatar>
</ListItemDecorator>
<ListItemContent sx={{ my: 0 }}>
{' '}
{/* Removed vertical margin */}
<Box
sx={{
position: 'relative',
overflow: 'hidden',
}}
onMouseLeave={() => {
// Only clear timers, don't auto-hide
if (hoverTimer) {
clearTimeout(hoverTimer)
setHoverTimer(null)
}
}}
>
{/* Action buttons underneath (revealed on swipe) */}
{(onEditClick || onDeleteClick) && (
<Box
sx={{
position: 'absolute',
right: 0,
top: 0,
bottom: 0,
width: maxSwipeDistance,
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
boxShadow: 'inset 2px 0 4px rgba(0,0,0,0.06)',
zIndex: 0,
}}
onMouseEnter={handleActionAreaMouseEnter}
onMouseLeave={handleActionAreaMouseLeave}
>
{onEditClick && (
<IconButton
variant='soft'
color='neutral'
size='sm'
onClick={e => {
e.stopPropagation()
resetSwipe()
onEditClick(historyEntry)
}}
sx={{
width: 40,
height: 40,
mx: 1,
}}
>
<Edit sx={{ fontSize: 16 }} />
</IconButton>
)}
{onDeleteClick && (
<IconButton
variant='soft'
color='danger'
size='sm'
onClick={e => {
e.stopPropagation()
resetSwipe()
onDeleteClick(historyEntry)
}}
sx={{
width: 40,
height: 40,
mx: 1,
}}
>
<Delete sx={{ fontSize: 16 }} />
</IconButton>
)}
</Box>
)}
{/* Main card content */}
<ListItem
ref={cardRef}
onClick={() => {
if (isSwipeRevealed) {
resetSwipe()
return
}
if (onClick) onClick()
}}
sx={{
cursor: onClick ? 'pointer' : 'default',
py: 1.5,
px: 2,
position: 'relative',
bgcolor: 'background.surface',
transform: `translateX(${swipeTranslateX}px)`,
transition: isDragging ? 'none' : 'transform 0.3s ease-out',
zIndex: 1,
width: '100%',
'&:hover': onClick
? {
bgcolor: isSwipeRevealed
? 'background.surface'
: 'background.level1',
}
: {},
borderRadius: 'sm',
}}
onTouchStart={handleTouchStart}
onTouchMove={handleTouchMove}
onTouchEnd={handleTouchEnd}
onMouseDown={handleMouseDown}
onMouseMove={handleMouseMove}
onMouseUp={handleMouseUp}
>
<ListItemContent>
<Grid container spacing={1} alignItems='center'>
{/* First Row/Column: Status and Time Info */}
<Grid xs={12} sm={8}>
<Box
sx={{
display: 'flex',
alignItems: 'center',
gap: 1,
flexWrap: 'wrap',
}}
>
{getStatusAvatar()}
<Typography
level='body-sm'
sx={{
color: 'text.secondary',
fontWeight: 'md',
}}
>
{historyEntry.status === 0
? 'In Progress'
: historyEntry.status === 1
? 'Completed'
: historyEntry.status === 2
? 'Skipped'
: historyEntry.status === 3
? 'Pending Approval'
: historyEntry.status === 4
? 'Rejected'
: 'Completed'}
</Typography>
<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)}
</Box>
</Box>
</Grid>
{/* Second Row/Column: Completion Status (right side on desktop) */}
<Grid xs={12} sm={4}>
<Box
sx={{
display: 'flex',
justifyContent: { xs: 'flex-start', sm: 'flex-end' },
alignItems: 'center',
gap: 1,
}}
>
{historyEntry.dueDate && (
<Chip size='sm' startDecorator={<CalendarMonth />}>
{moment(historyEntry.dueDate).format('MMM DD h:mm A')}
</Chip>
)}
</Box>
</Grid>
{/* Third Row: Performer and Assignment Info */}
<Grid xs={12}>
<Box
sx={{
display: 'flex',
alignItems: 'center',
gap: 1,
flexWrap: 'wrap',
mt: 0.5,
}}
>
<Chip
size='sm'
variant='solid'
color='success'
startDecorator={<CheckCircle />}
>
Done by {performer?.displayName || 'Unknown'}
</Chip>
{historyEntry.completedBy !== historyEntry.assignedTo &&
assignedTo && (
<Chip
size='sm'
variant='outlined'
color='neutral'
startDecorator={<Person />}
>
Assigned to {assignedTo.displayName}
</Chip>
)}
{historyEntry.notes && (
<Chip
size='sm'
variant='plain'
color='neutral'
startDecorator={<EventNote />}
sx={{ maxWidth: '120px', overflow: 'hidden' }}
>
Note
</Chip>
)}
{/* add a duration chip if we have duration */}
{historyEntry?.duration > 0 && (
<Chip
size='sm'
variant='soft'
color='primary'
startDecorator={<AccessTime />}
>
{formatTime(historyEntry.duration)}
</Chip>
)}
{historyEntry?.points > 0 && (
<Chip
size='sm'
variant='solid'
color='success'
startDecorator={<Toll />}
>
{historyEntry.points} pt
{historyEntry.points > 1 ? 's' : ''}
</Chip>
)}
</Box>
</Grid>
</Grid>
</ListItemContent>
{/* Right drag area - only triggers reveal on hover */}
{(onEditClick || onDeleteClick) && (
<Box
sx={{
position: 'absolute',
right: 0,
top: 0,
bottom: 0,
width: '20px',
cursor: 'grab',
zIndex: 2,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
opacity: isSwipeRevealed ? 0 : 0.3, // Hide when action area is revealed
transition: 'opacity 0.2s ease',
pointerEvents: isSwipeRevealed ? 'none' : 'auto', // Disable pointer events when revealed
'&:hover': {
opacity: isSwipeRevealed ? 0 : 0.7,
},
'&:active': {
cursor: 'grabbing',
},
}}
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
>
{/* Drag indicator dots */}
<Box
sx={{
display: 'flex',
flexDirection: 'column',
gap: 0.25,
}}
>
{[...Array(3)].map((_, i) => (
<Box
key={i}
sx={{
width: 3,
height: 3,
borderRadius: '50%',
backgroundColor: 'text.tertiary',
}}
/>
))}
</Box>
</Box>
)}
</ListItem>
{/* Compact Divider with Time Difference */}
{index < allHistory.length - 1 && allHistory[index + 1].performedAt && (
<ListDivider
component='li'
sx={{
my: 0.5,
}}
>
<Typography level='body1' sx={{ fontWeight: 'md' }}>
{historyEntry.performedAt
? moment(historyEntry.performedAt).format(
'ddd MM/DD/yyyy HH:mm',
)
: 'Skipped'}
<Typography
level='body-xs'
sx={{
color: 'text.tertiary',
backgroundColor: 'background.surface',
px: 1,
fontSize: '0.75rem',
}}
>
{formatTimeDifference(
historyEntry.performedAt || historyEntry.updatedAt,
allHistory[index + 1].performedAt,
)}{' '}
before
</Typography>
{getCompletedChip(historyEntry)}
</Box>
<Typography level='body2' color='text.tertiary'>
<Chip>
{
performers.find(p => p.userId === historyEntry.completedBy)
?.displayName
}
</Chip>{' '}
completed
{historyEntry.completedBy !== historyEntry.assignedTo && (
<>
{', '}
assigned to{' '}
<Chip>
{
performers.find(p => p.userId === historyEntry.assignedTo)
?.displayName
}
</Chip>
</>
)}
</Typography>
{historyEntry.dueDate && (
<Typography level='body2' color='text.tertiary'>
Due: {moment(historyEntry.dueDate).format('ddd MM/DD/yyyy')}
</Typography>
)}
{historyEntry.notes && (
<Typography level='body2' color='text.tertiary'>
Note: {historyEntry.notes}
</Typography>
)}
</ListItemContent>
</ListItem>
{index < allHistory.length - 1 && (
<>
<ListDivider component='li'>
{/* time between two completion: */}
{index < allHistory.length - 1 &&
allHistory[index + 1].performedAt && (
<Typography level='body3' color='text.tertiary'>
{formatTimeDifference(
historyEntry.performedAt,
allHistory[index + 1].performedAt,
)}{' '}
before
</Typography>
)}
</ListDivider>
</>
)}
)}
</Box>
</>
)
}

View File

@@ -33,7 +33,7 @@ const Home = () => {
<Button
sx={{ mt: 1 }}
onClick={() => {
Navigate('/my/chores')
Navigate('/chores')
}}
>
Get Started!

View File

@@ -1,27 +1,464 @@
import DeleteIcon from '@mui/icons-material/Delete'
import EditIcon from '@mui/icons-material/Edit'
import {
Avatar,
Box,
Button,
Chip,
CircularProgress,
Container,
IconButton,
Stack,
Typography,
} from '@mui/joy'
import { useEffect, useState } from 'react'
import { useEffect, useRef, useState } from 'react'
import LabelModal from '../Modals/Inputs/LabelModal'
// import { useMutation, useQueryClient } from '@tanstack/react-query'
import { Add } from '@mui/icons-material'
import { Add, ColorLens } from '@mui/icons-material'
import { useQueryClient } from '@tanstack/react-query'
import { getTextColorFromBackgroundColor } from '../../utils/Colors'
import { useUserProfile } from '../../queries/UserQueries'
import LABEL_COLORS, {
getTextColorFromBackgroundColor,
} from '../../utils/Colors'
import { DeleteLabel } from '../../utils/Fetcher'
import { getSafeBottomStyles } from '../../utils/SafeAreaUtils'
import ConfirmationModal from '../Modals/Inputs/ConfirmationModal'
import { useLabels } from './LabelQueries'
const LabelCard = ({ label, onEditClick, onDeleteClick, currentUserId }) => {
// Helper function to get color name from hex value
const getColorName = hexValue => {
const colorObj = LABEL_COLORS.find(
color => color.value.toLowerCase() === hexValue.toLowerCase(),
)
return colorObj ? colorObj.name : hexValue
}
// Check if current user owns this label
const isOwnedByCurrentUser = label.created_by === currentUserId
// Swipe functionality state
const [swipeTranslateX, setSwipeTranslateX] = useState(0)
const [isDragging, setIsDragging] = useState(false)
const [isSwipeRevealed, setIsSwipeRevealed] = useState(false)
const [hoverTimer, setHoverTimer] = useState(null)
const swipeThreshold = 80
const maxSwipeDistance = 160
const dragStartX = useRef(0)
const cardRef = useRef(null)
// Swipe gesture handlers
const handleTouchStart = e => {
dragStartX.current = e.touches[0].clientX
setIsDragging(true)
}
const handleTouchMove = e => {
if (!isDragging) return
const currentX = e.touches[0].clientX
const deltaX = currentX - dragStartX.current
if (isSwipeRevealed) {
if (deltaX > 0) {
const clampedDelta = Math.min(deltaX - maxSwipeDistance, 0)
setSwipeTranslateX(clampedDelta)
}
} else {
if (deltaX < 0) {
const clampedDelta = Math.max(deltaX, -maxSwipeDistance)
setSwipeTranslateX(clampedDelta)
}
}
}
const handleTouchEnd = () => {
if (!isDragging) return
setIsDragging(false)
if (isSwipeRevealed) {
if (swipeTranslateX > -swipeThreshold) {
setSwipeTranslateX(0)
setIsSwipeRevealed(false)
} else {
setSwipeTranslateX(-maxSwipeDistance)
}
} else {
if (Math.abs(swipeTranslateX) > swipeThreshold) {
setSwipeTranslateX(-maxSwipeDistance)
setIsSwipeRevealed(true)
} else {
setSwipeTranslateX(0)
setIsSwipeRevealed(false)
}
}
}
const handleMouseDown = e => {
dragStartX.current = e.clientX
setIsDragging(true)
}
const handleMouseMove = e => {
if (!isDragging) return
const currentX = e.clientX
const deltaX = currentX - dragStartX.current
if (isSwipeRevealed) {
if (deltaX > 0) {
const clampedDelta = Math.min(deltaX - maxSwipeDistance, 0)
setSwipeTranslateX(clampedDelta)
}
} else {
if (deltaX < 0) {
const clampedDelta = Math.max(deltaX, -maxSwipeDistance)
setSwipeTranslateX(clampedDelta)
}
}
}
const handleMouseUp = () => {
if (!isDragging) return
setIsDragging(false)
if (isSwipeRevealed) {
if (swipeTranslateX > -swipeThreshold) {
setSwipeTranslateX(0)
setIsSwipeRevealed(false)
} else {
setSwipeTranslateX(-maxSwipeDistance)
}
} else {
if (Math.abs(swipeTranslateX) > swipeThreshold) {
setSwipeTranslateX(-maxSwipeDistance)
setIsSwipeRevealed(true)
} else {
setSwipeTranslateX(0)
setIsSwipeRevealed(false)
}
}
}
const resetSwipe = () => {
setSwipeTranslateX(0)
setIsSwipeRevealed(false)
}
// Hover functionality for desktop - only trigger from drag area
const handleMouseEnter = () => {
if (isSwipeRevealed) return
const timer = setTimeout(() => {
setSwipeTranslateX(-maxSwipeDistance)
setIsSwipeRevealed(true)
setHoverTimer(null)
}, 800) // Shorter delay for drag area
setHoverTimer(timer)
}
const handleMouseLeave = () => {
if (hoverTimer) {
clearTimeout(hoverTimer)
setHoverTimer(null)
}
// Only add hide timer if we're leaving the drag area and actions are NOT revealed
// If actions are revealed, let the action area handle the hiding
if (!isSwipeRevealed) {
// Actions are not revealed, so we can safely hide after delay
const hideTimer = setTimeout(() => {
resetSwipe()
}, 300)
setHoverTimer(hideTimer)
}
}
const handleActionAreaMouseEnter = () => {
// Clear any pending timer when entering action area
if (hoverTimer) {
clearTimeout(hoverTimer)
setHoverTimer(null)
}
}
const handleActionAreaMouseLeave = () => {
// Hide immediately when leaving action area
if (isSwipeRevealed) {
resetSwipe()
}
}
// Clean up timer on unmount
useEffect(() => {
return () => {
if (hoverTimer) {
clearTimeout(hoverTimer)
}
}
}, [hoverTimer])
return (
<Box key={label.id + '-compact-box'}>
<Box
sx={{
position: 'relative',
overflow: 'hidden',
borderBottom: '1px solid',
borderColor: 'divider',
'&:last-child': {
borderBottom: 'none',
},
}}
onMouseLeave={() => {
// Only clear timers, don't auto-hide
if (hoverTimer) {
clearTimeout(hoverTimer)
setHoverTimer(null)
}
}}
>
{/* Action buttons underneath (revealed on swipe) */}
<Box
sx={{
position: 'absolute',
right: 0,
top: 0,
bottom: 0,
width: maxSwipeDistance,
display: 'flex',
alignItems: 'center',
boxShadow: 'inset 2px 0 4px rgba(0,0,0,0.06)',
zIndex: 0,
}}
onMouseEnter={handleActionAreaMouseEnter}
onMouseLeave={handleActionAreaMouseLeave}
>
<IconButton
variant='soft'
color='neutral'
size='sm'
onClick={e => {
e.stopPropagation()
resetSwipe()
onEditClick(label)
}}
sx={{
width: 40,
height: 40,
mx: 1,
}}
>
<EditIcon sx={{ fontSize: 16 }} />
</IconButton>
<IconButton
variant='soft'
color='danger'
size='sm'
onClick={e => {
e.stopPropagation()
resetSwipe()
onDeleteClick(label.id)
}}
sx={{
width: 40,
height: 40,
mx: 1,
}}
>
<DeleteIcon sx={{ fontSize: 16 }} />
</IconButton>
</Box>
{/* Main card content */}
<Box
ref={cardRef}
sx={{
display: 'flex',
alignItems: 'center',
minHeight: 64,
cursor: 'pointer',
position: 'relative',
px: 2,
py: 1.5,
bgcolor: 'background.body',
transform: `translateX(${swipeTranslateX}px)`,
transition: isDragging ? 'none' : 'transform 0.3s ease-out',
zIndex: 1,
'&:hover': {
bgcolor: isSwipeRevealed
? 'background.surface'
: 'background.level1',
boxShadow: isSwipeRevealed ? 'none' : 'sm',
},
}}
onClick={() => {
if (isSwipeRevealed) {
resetSwipe()
return
}
// Optional: Navigate to label details or edit directly
onEditClick(label)
}}
onTouchStart={handleTouchStart}
onTouchMove={handleTouchMove}
onTouchEnd={handleTouchEnd}
onMouseDown={handleMouseDown}
onMouseMove={handleMouseMove}
onMouseUp={handleMouseUp}
>
{/* Right drag area - only triggers reveal on hover */}
<Box
sx={{
position: 'absolute',
right: 0,
top: 0,
bottom: 0,
width: '20px',
cursor: 'grab',
zIndex: 2,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
opacity: isSwipeRevealed ? 0 : 0.3, // Hide when action area is revealed
transition: 'opacity 0.2s ease',
pointerEvents: isSwipeRevealed ? 'none' : 'auto', // Disable pointer events when revealed
'&:hover': {
opacity: isSwipeRevealed ? 0 : 0.7,
},
'&:active': {
cursor: 'grabbing',
},
}}
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
>
{/* Drag indicator dots */}
<Box
sx={{
display: 'flex',
flexDirection: 'column',
gap: 0.25,
}}
>
{[...Array(3)].map((_, i) => (
<Box
key={i}
sx={{
width: 3,
height: 3,
borderRadius: '50%',
bgcolor: 'text.tertiary',
}}
/>
))}
</Box>
</Box>
{/* Color Avatar */}
<Box
sx={{
display: 'flex',
alignItems: 'center',
mr: 2,
flexShrink: 0,
}}
>
<Avatar
size='sm'
sx={{
width: 32,
height: 32,
bgcolor: label.color,
border: '2px solid',
borderColor: isOwnedByCurrentUser
? 'background.surface'
: 'warning.300',
boxShadow: isOwnedByCurrentUser
? 'sm'
: '0 0 0 1px var(--joy-palette-warning-300)',
}}
>
<Typography
level='body-xs'
sx={{
color: getTextColorFromBackgroundColor(label.color),
fontWeight: 'bold',
fontSize: 10,
}}
>
{label.name.charAt(0).toUpperCase()}
</Typography>
</Avatar>
</Box>
{/* Content - Center */}
<Box
sx={{
flex: 1,
minWidth: 0,
display: 'flex',
flexDirection: 'column',
}}
>
{/* Label Name */}
<Typography
level='title-sm'
sx={{
fontWeight: 600,
fontSize: 14,
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
mb: 0.25,
}}
>
{label.name}
</Typography>
{/* Color Info */}
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
{label.color && (
<Chip
size='sm'
variant='soft'
startDecorator={<ColorLens />}
sx={{
fontSize: 10,
height: 18,
px: 0.75,
bgcolor: `${label.color}20`,
color: label.color,
border: `1px solid ${label.color}30`,
}}
>
{getColorName(label.color)}
</Chip>
)}
{!isOwnedByCurrentUser && (
<Chip
size='sm'
variant='soft'
color='warning'
sx={{
fontSize: 9,
height: 16,
px: 0.5,
fontWeight: 'md',
}}
>
Shared
</Chip>
)}
</Box>
</Box>
</Box>
</Box>
</Box>
)
}
const LabelView = () => {
const { data: labels, isLabelsLoading, isError } = useLabels()
const { data: userProfile } = useUserProfile()
const [userLabels, setUserLabels] = useState([])
const [modalOpen, setModalOpen] = useState(false)
@@ -61,7 +498,7 @@ const LabelView = () => {
}
const handleDeleteLabel = id => {
DeleteLabel(id).then(res => {
DeleteLabel(id).then(() => {
const updatedLabels = userLabels.filter(label => label.id !== id)
setUserLabels(updatedLabels)
@@ -106,54 +543,57 @@ const LabelView = () => {
}
return (
<Container maxWidth='md'>
<div className='flex flex-col gap-2'>
{userLabels.map(label => (
<div
key={label}
className='grid w-full grid-cols-[1fr,auto,auto] rounded-lg border border-zinc-200/80 p-4 shadow-sm dark:bg-zinc-900'
<Container maxWidth='md' sx={{ px: 0 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2, mb: 2, p: 2 }}>
{/* <EmojiEvents sx={{ fontSize: '2rem', color: '#FFD700' }} /> */}
<Stack sx={{ flex: 1 }}>
<Typography
level='h3'
sx={{ fontWeight: 'lg', color: 'text.primary' }}
>
<Chip
variant='outlined'
color='primary'
size='lg'
sx={{
background: label.color,
borderColor: label.color,
color: getTextColorFromBackgroundColor(label.color),
}}
>
{label.name}
</Chip>
<div className='flex gap-2'>
<Button
size='sm'
variant='soft'
color='neutral'
onClick={() => handleEditLabel(label)}
startDecorator={<EditIcon />}
>
Edit
</Button>
<IconButton
size='sm'
variant='soft'
onClick={() => handleDeleteClicked(label.id)}
color='danger'
>
<DeleteIcon />
</IconButton>
</div>
</div>
Labels
</Typography>
<Typography level='body-sm' sx={{ color: 'text.secondary' }}>
Manage your labels and organize your tasks effectively. Labels will
be automatically shared with your circle if they are used on a
shared task.
</Typography>
</Stack>
</Box>
<Box
sx={{
// bgcolor: 'background.body',
// border: '1px solid',
// borderColor: 'divider',
// borderRadius: 'md',
overflow: 'hidden',
}}
>
{userLabels.length === 0 && (
<Box
sx={{
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
flexDirection: 'column',
height: '50vh',
}}
>
<Typography level='title-md' gutterBottom>
No labels available. Add a new label to get started.
</Typography>
</Box>
)}
{userLabels.map(label => (
<LabelCard
key={label.id}
label={label}
onEditClick={handleEditLabel}
onDeleteClick={handleDeleteClicked}
currentUserId={userProfile?.id}
/>
))}
</div>
{userLabels.length === 0 && (
<Typography textAlign='center' mt={2}>
No labels available. Add a new label to get started.
</Typography>
)}
</Box>
{modalOpen && (
<LabelModal
@@ -166,10 +606,8 @@ const LabelView = () => {
<Box
sx={{
position: 'fixed',
bottom: 0,
...getSafeBottomStyles({ bottom: 0, padding: 16 }),
left: 10,
p: 2,
display: 'flex',
justifyContent: 'flex-end',
gap: 2,

View File

@@ -52,7 +52,7 @@ const DemoAssignee = () => {
data-aos-anchor='[data-aos-create-chore-assignee]'
data-aos='fade-right'
>
<Typography level='h4'>Assignees :</Typography>
<Typography level='h4'>Assignees:</Typography>
<Typography level='h5'>Who can do this chore?</Typography>
<Card>
<List
@@ -93,7 +93,7 @@ const DemoAssignee = () => {
data-aos-anchor='[data-aos-create-chore-assignee]'
data-aos='fade-right'
>
<Typography level='h4'>Assigned :</Typography>
<Typography level='h4'>Assigned:</Typography>
<Typography level='h5'>
Who is assigned the next due chore?
</Typography>
@@ -128,7 +128,7 @@ const DemoAssignee = () => {
data-aos-anchor='[data-aos-create-chore-assignee]'
data-aos='fade-right'
>
<Typography level='h4'>Picking Mode :</Typography>
<Typography level='h4'>Picking Mode:</Typography>
<Typography level='h5'>
How to pick the next assignee for the following chore?
</Typography>

View File

@@ -0,0 +1,207 @@
import { Card, Grid, Typography } from '@mui/joy'
import moment from 'moment'
import CalendarMonthly from '../components/CalendarMonthly'
const DemoCalendar = () => {
// Generate sample chore data across different dates
const generateSampleChores = () => {
const today = moment()
const chores = []
// High priority tasks
chores.push({
id: 1,
name: '🧹 Deep Clean Living Room',
priority: 1,
nextDueDate: today
.clone()
.add(2, 'days')
.hour(10)
.minute(0)
.toISOString(),
assignedTo: 1,
})
chores.push({
id: 2,
name: '🚗 Car Maintenance Check',
priority: 1,
nextDueDate: today
.clone()
.add(5, 'days')
.hour(14)
.minute(30)
.toISOString(),
assignedTo: 1,
})
// Medium priority tasks
chores.push({
id: 3,
name: '🌱 Water Indoor Plants',
priority: 2,
nextDueDate: today.clone().add(1, 'days').hour(8).minute(0).toISOString(),
assignedTo: 1,
})
chores.push({
id: 4,
name: '🛒 Weekly Grocery Shopping',
priority: 2,
nextDueDate: today
.clone()
.add(3, 'days')
.hour(16)
.minute(0)
.toISOString(),
assignedTo: 1,
})
chores.push({
id: 5,
name: '📧 Organize Email Inbox',
priority: 2,
nextDueDate: today
.clone()
.add(7, 'days')
.hour(11)
.minute(0)
.toISOString(),
assignedTo: 1,
})
// Low priority tasks
chores.push({
id: 6,
name: '📚 Organize Bookshelf',
priority: 3,
nextDueDate: today
.clone()
.add(4, 'days')
.hour(15)
.minute(0)
.toISOString(),
assignedTo: 1,
})
chores.push({
id: 7,
name: '🎨 Paint Bedroom Wall',
priority: 3,
nextDueDate: today
.clone()
.add(10, 'days')
.hour(9)
.minute(0)
.toISOString(),
assignedTo: 1,
})
// Tasks for today
chores.push({
id: 8,
name: '🍽️ Do Dishes',
priority: 2,
nextDueDate: today.clone().hour(19).minute(0).toISOString(),
assignedTo: 1,
})
chores.push({
id: 9,
name: '🗑️ Take Out Trash',
priority: 1,
nextDueDate: today.clone().hour(7).minute(30).toISOString(),
assignedTo: 1,
})
// Tasks with no priority
chores.push({
id: 10,
name: '🎵 Practice Guitar',
priority: null,
nextDueDate: today
.clone()
.add(6, 'days')
.hour(18)
.minute(0)
.toISOString(),
assignedTo: 1,
})
// Multiple tasks on same day
chores.push({
id: 11,
name: '🧺 Do Laundry',
priority: 2,
nextDueDate: today
.clone()
.add(2, 'days')
.hour(12)
.minute(0)
.toISOString(),
assignedTo: 1,
})
chores.push({
id: 12,
name: '🏃 Morning Jog',
priority: 3,
nextDueDate: today
.clone()
.add(2, 'days')
.hour(6)
.minute(30)
.toISOString(),
assignedTo: 1,
})
return chores
}
const sampleChores = generateSampleChores()
return (
<>
<Grid item xs={12} sm={7} data-aos-calendar-demo-section>
<Card
sx={{
p: 5,
height: 'fit-content',
overflow: 'hidden',
boxShadow: 'lg',
}}
data-aos-delay={100}
data-aos-anchor='[data-aos-calendar-demo-section]'
data-aos='fade-up'
>
<CalendarMonthly chores={sampleChores} />
</Card>
</Grid>
<Grid item xs={12} sm={5} data-aos-calendar-description>
<Card
sx={{
p: 4,
py: 6,
height: 'fit-content',
}}
data-aos-delay={200}
data-aos-anchor='[data-aos-calendar-description]'
data-aos='fade-left'
>
<Typography level='h3' textAlign='center' sx={{ mt: 2, mb: 4 }}>
Visual Task Calendar
</Typography>
<Typography level='body-lg' textAlign='center' sx={{ mb: 4 }}>
Get a bird's-eye view of all your tasks with the interactive
calendar. See priority-coded dots for each day, click to view
detailed task lists, and easily track your upcoming
responsibilities. The color-coded priority system helps you focus on
what matters most.
</Typography>
</Card>
</Grid>
</>
)
}
export default DemoCalendar

View File

@@ -14,6 +14,7 @@ const DemoMyChore = () => {
nextDueDate: moment().add(1, 'days').hour(8).minute(0).toISOString(),
isRolling: false,
assignedTo: 1,
status: 0,
},
{
id: 9,
@@ -24,6 +25,7 @@ const DemoMyChore = () => {
nextDueDate: moment().subtract(7, 'day').toISOString(),
isRolling: false,
assignedTo: 1,
status: 0,
},
{
id: 6,
@@ -93,7 +95,7 @@ const DemoMyChore = () => {
// },
]
const users = [{ displayName: 'Me', id: 1 }]
const users = [{ displayName: 'Me', id: 1, userId: 1 }]
return (
<>
<Grid item xs={12} sm={5} data-aos-first-tasks-list>

View File

@@ -0,0 +1,57 @@
import { Card, Grid, Typography } from '@mui/joy'
import NotificationTemplate from '../../components/NotificationTemplate'
const DemoNotificationTemplate = () => {
const demoNotifications = [
{ value: -3, unit: 'd' }, // 3 days before
{ value: 0, unit: 'm' }, // On due
{ value: 1, unit: 'd' }, // 1 day after
]
const handleNotificationChange = data => {
// Demo handler - doesn't need to do anything
console.log('Demo notification change:', data)
}
return (
<>
<Grid item xs={12} sm={7} data-aos-notification-template-list>
<div
data-aos-delay={100}
data-aos-anchor='[data-aos-notification-template-list]'
data-aos='fade-up'
>
<NotificationTemplate
value={{ templates: demoNotifications }}
onChange={handleNotificationChange}
maxNotifications={5}
showTimeline={false}
/>
</div>
</Grid>
<Grid item xs={12} sm={5} data-aos-notification-demo-section>
<Card
sx={{
p: 4,
py: 6,
height: 'fit-content',
}}
data-aos-delay={200}
data-aos-anchor='[data-aos-notification-demo-section]'
data-aos='fade-left'
>
<Typography level='h3' textAlign='center' sx={{ mt: 2, mb: 4 }}>
Smart Notification Scheduling
</Typography>
<Typography level='body-lg' textAlign='center' sx={{ mb: 4 }}>
Set up intelligent reminders for your tasks with flexible timing
options. Get notified before, on, or after due dates with
customizable intervals.
</Typography>
</Card>
</Grid>
</>
)
}
export default DemoNotificationTemplate

Some files were not shown because too many files have changed in this diff Show More