Merge branch 'develop' into feature/ja-localization

This commit is contained in:
Mohamad Tarbin
2026-07-07 23:01:46 -04:00
committed by GitHub
117 changed files with 13081 additions and 4117 deletions

View File

@@ -1,14 +1,18 @@
import NavBar from '@/views/components/NavBar'
import { Button, Typography, useColorScheme } from '@mui/joy'
import Tracker from '@openreplay/tracker'
import { useCallback, useEffect } from 'react'
import { Outlet } from 'react-router-dom'
import { useRegisterSW } from 'virtual:pwa-register/react'
import { registerCapacitorListeners } from './CapacitorListener'
import PageTransition from './components/animations/PageTransition'
import { ImpersonateUserProvider } from './contexts/ImpersonateUserContext'
import SSEProvider from './contexts/SSEContext'
import { AuthProvider } from './hooks/useAuth.jsx'
import useStatusBar from './hooks/useStatusBar'
import { useResource } from './queries/ResourceQueries'
import './styles/safe-area.css'
import SSEProvider from './contexts/SSEContext'
import { useNotification } from './service/NotificationProvider'
import { useSyncOnReconnect } from './hooks/useSyncOnReconnect'
@@ -25,19 +29,16 @@ const remove = className => {
// TODO: Update the interval to at 60 minutes
const intervalMS = 5 * 60 * 1000 // 5 minutes
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()
useSyncOnReconnect()
// Initialize status bar with theme-aware configuration
useStatusBar()
const {
offlineReady: [offlineReady, setOfflineReady], // eslint-disable-line no-unused-vars
needRefresh: [needRefresh, setNeedRefresh],
updateServiceWorker,
} = useRegisterSW({
@@ -96,10 +97,11 @@ const AppContent = () => {
}
function App() {
// startOpenReplay()
const resource = useResource() // eslint-disable-line no-unused-vars
const { mode, systemMode } = useColorScheme()
// startOpenReplay()
const setThemeClass = useCallback(() => {
const value = JSON.parse(localStorage.getItem('themeMode')) || mode
@@ -126,7 +128,7 @@ function App() {
}, [])
return (
<>
<div>
<NetworkBanner />
<AuthProvider>
@@ -134,7 +136,7 @@ function App() {
<AppContent />
</SSEProvider>
</AuthProvider>
</>
</div>
)
}

View File

@@ -8,6 +8,35 @@ import { PushNotifications } from '@capacitor/push-notifications'
import { focusManager } from '@tanstack/react-query'
import { RegisterDeviceToken } from './utils/Fetcher'
// NFC chore deep link: donetick://chores/123?auto_complete=true
const handleNFCChoreDeepLink = url => {
try {
const urlObj = new URL(url)
// donetick://chores/123 → host='chores', pathname='/123'
const choreId = urlObj.pathname.slice(1)
const autoComplete = urlObj.searchParams.get('auto_complete')
const path = `/chores/${choreId}${autoComplete ? '?auto_complete=' + autoComplete : ''}`
// getLaunchUrl() persists across every WebView reload caused by window.location.href.
// If we're already on the target page, skip to avoid an infinite reload loop.
if (window.location.pathname + window.location.search === path) return
console.log('[NFC] navigating to', path)
window.location.href = path
} catch (error) {
console.error('[NFC] Error handling chore deep link:', error)
}
}
const handleUrlOpen = url => {
console.log('[NFC] handleUrlOpen:', url)
if (url.startsWith('donetick://chores/')) {
handleNFCChoreDeepLink(url)
} else if (url.startsWith('donetick://auth/')) {
handleOAuthDeepLink(url)
}
}
// OAuth callback handler for deep links
const handleOAuthDeepLink = async url => {
console.log('OAuth deep link received:', url)
@@ -215,16 +244,20 @@ const registerCapacitorListeners = () => {
return
}
localNotificationListenerRegistration()
// Register deep link handler for OAuth and other deep links
mobileApp.addListener('appUrlOpen', event => {
console.log('App URL opened:', event.url)
// Handle OAuth callback
if (event.url.startsWith('donetick://auth/')) {
handleOAuthDeepLink(event.url)
// Cold-start: app was launched by tapping an NFC tag (or other deep link)
mobileApp.getLaunchUrl().then(result => {
if (result?.url) {
console.log('[NFC] getLaunchUrl:', result.url)
handleUrlOpen(result.url)
}
})
// Foreground / singleTask resume: app was already running when the tag was tapped
mobileApp.addListener('appUrlOpen', event => {
console.log('[NFC] appUrlOpen:', event.url)
handleUrlOpen(event.url)
})
mobileApp.addListener('appStateChange', ({ isActive }) => {
focusManager.setFocused(isActive)
@@ -233,6 +266,9 @@ const registerCapacitorListeners = () => {
mobileApp.addListener('backButton', ({ canGoBack }) => {
if (canGoBack) {
window.history.back()
} else if (window.location.pathname !== '/') {
// No history (e.g. app launched directly to a chore via NFC) — go home
window.location.href = '/'
} else {
mobileApp.exitApp()
}

View File

@@ -12,7 +12,7 @@ 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, useRef } from 'react'
import { useCallback, useEffect, useRef, useState } from 'react'
import { NOTIFICATION_TYPE, TASK_COLOR } from '../utils/Colors'
import { TIME_UNITS } from '../utils/DurationUtils'
@@ -512,6 +512,7 @@ const NotificationTemplate = ({
'--Badge-fontSize': '0.7rem',
'--Badge-paddingX': '5px',
top: 10,
left: 10,
'& .MuiBadge-badge': {
background: colors.bgColor,
color: 'white',

View File

@@ -32,7 +32,7 @@ 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 { isPlusAccount, resolvePhotoURL } from '../utils/Helpers'
import UserModal from '../views/Modals/Inputs/UserModal'
import SubscriptionModal from './SubscriptionModal'
@@ -116,7 +116,7 @@ const UserProfileAvatar = () => {
{isImpersonating ? (
<Box sx={{ position: 'relative' }}>
<Avatar
src={currentUser?.image || currentUser?.avatar}
src={resolvePhotoURL(currentUser?.image)}
alt={currentUser?.displayName || currentUser?.name}
size='md'
sx={{
@@ -127,7 +127,7 @@ const UserProfileAvatar = () => {
}}
/>
<Avatar
src={userProfile?.image || userProfile?.avatar}
src={resolvePhotoURL(userProfile?.image || userProfile?.avatar)}
alt={userProfile?.displayName || userProfile?.name}
size='sm'
sx={{
@@ -162,7 +162,7 @@ const UserProfileAvatar = () => {
</Box>
) : (
<Avatar
src={currentUser?.image || currentUser?.avatar}
src={resolvePhotoURL(currentUser?.image || currentUser?.avatar)}
alt={currentUser?.displayName || currentUser?.name}
size='md'
sx={{
@@ -189,7 +189,7 @@ const UserProfileAvatar = () => {
<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}
src={resolvePhotoURL(currentUser?.image || currentUser?.avatar)}
alt={currentUser?.displayName || currentUser?.name}
size='lg'
sx={{

View File

@@ -1,4 +1,4 @@
import { Modal, ModalDialog, ModalOverflow, Typography } from '@mui/joy'
import { Modal, ModalClose, ModalDialog, ModalOverflow, Typography } from '@mui/joy'
import { Z_INDEX } from '../../constants/zIndex'
/**
@@ -78,6 +78,7 @@ const FadeModal = ({
},
}}
>
<ModalClose />
{title && (
<Typography level='title-lg' sx={{ fontWeight: 600, mb: 2 }}>
{title}

View File

@@ -0,0 +1,506 @@
import { Check, FilterList, Tune } from '@mui/icons-material'
import {
Avatar,
Badge,
Box,
Button,
Chip,
Divider,
Input,
Typography,
} from '@mui/joy'
import { useState } from 'react'
import BottomSheetModal from './BottomSheetModal'
import ActiveFilterChips from './filter/ActiveFilterChips'
/**
* Reusable filter bar component.
*
* Props:
* filterDefs - array of filter definitions:
* { id, label, type ('multi-select'|'single-select'|'boolean'|'date-range'),
* icon, options?, defaultValue?, filterFn }
* options item: { value, label, color?, icon?, avatar? }
* defaultValue: if the active value equals this, no chip is shown
* date-range value shape: { preset?, from?: ISO string, to?: ISO string }
* activeFilters - current filter state object { [id]: value }
* onSetFilter - (filterId, value | null) => void
* onClearAll - () => void
* resultCount - optional number shown in "Show N results" button
* totalCount - optional total for "N of M" label
*/
// ── Date range presets (no moment dependency — pure Date) ────────────────────
const d = (date, h = 0, m = 0, s = 0, ms = 0) =>
new Date(date.getFullYear(), date.getMonth(), date.getDate(), h, m, s, ms)
const DATE_RANGE_PRESETS = [
{
value: 'today',
label: 'Today',
getRange: () => {
const t = d(new Date())
return { from: t.toISOString(), to: d(new Date(), 23, 59, 59, 999).toISOString() }
},
},
{
value: 'yesterday',
label: 'Yesterday',
getRange: () => {
const t = d(new Date())
const y = new Date(t); y.setDate(t.getDate() - 1)
return { from: d(y).toISOString(), to: d(y, 23, 59, 59, 999).toISOString() }
},
},
{
value: 'this-week',
label: 'This Week',
getRange: () => {
const t = d(new Date())
const start = new Date(t); start.setDate(t.getDate() - t.getDay())
const end = new Date(start); end.setDate(start.getDate() + 6)
return { from: d(start).toISOString(), to: d(end, 23, 59, 59, 999).toISOString() }
},
},
{
value: 'last-7-days',
label: 'Last 7 Days',
getRange: () => {
const t = d(new Date())
const start = new Date(t); start.setDate(t.getDate() - 6)
return { from: d(start).toISOString(), to: d(new Date(), 23, 59, 59, 999).toISOString() }
},
},
{
value: 'this-month',
label: 'This Month',
getRange: () => {
const n = new Date()
const start = new Date(n.getFullYear(), n.getMonth(), 1)
const end = new Date(n.getFullYear(), n.getMonth() + 1, 0)
return { from: start.toISOString(), to: d(end, 23, 59, 59, 999).toISOString() }
},
},
{
value: 'last-30-days',
label: 'Last 30 Days',
getRange: () => {
const t = d(new Date())
const start = new Date(t); start.setDate(t.getDate() - 29)
return { from: d(start).toISOString(), to: d(new Date(), 23, 59, 59, 999).toISOString() }
},
},
{
value: 'last-3-months',
label: 'Last 3 Months',
getRange: () => {
const t = d(new Date())
const start = new Date(t); start.setMonth(t.getMonth() - 3)
return { from: d(start).toISOString(), to: d(new Date(), 23, 59, 59, 999).toISOString() }
},
},
]
const toInputDate = iso => (iso ? iso.split('T')[0] : '')
const fmtDisplayDate = iso => {
if (!iso) return null
const dt = new Date(iso)
return dt.toLocaleDateString(undefined, { month: 'short', day: 'numeric' })
}
// ── Component ────────────────────────────────────────────────────────────────
const FilterBar = ({
filterDefs,
activeFilters,
onSetFilter,
onClearAll,
resultCount,
totalCount,
}) => {
const [isOpen, setIsOpen] = useState(false)
// ── Active count ───────────────────────────────────────────────────────────
const activeFilterCount = filterDefs.filter(def => {
const value = activeFilters[def.id]
if (value === undefined || value === null) return false
if (def.defaultValue !== undefined && value === def.defaultValue) return false
if (Array.isArray(value) && value.length === 0) return false
if (def.type === 'date-range') return !!(value?.from || value?.to)
return true
}).length
const hasActive = activeFilterCount > 0
const selectableChipSx = {
cursor: 'pointer',
transition: 'all 0.15s ease',
userSelect: 'none',
alignItems: 'center',
'& .MuiChip-startDecorator': {
display: 'flex',
alignItems: 'center',
mr: 0.5,
},
'& .MuiChip-label': {
lineHeight: 1.2,
},
'&:hover': { opacity: 0.85 },
}
const sectionBadgeChipSx = {
ml: 'auto',
fontSize: '0.7rem',
minHeight: 22,
py: 0.25,
px: 0.75,
alignItems: 'center',
'& .MuiChip-label': {
lineHeight: 1.2,
px: 0,
},
}
const modalCountChipSx = {
ml: 0.5,
minHeight: 22,
py: 0.25,
px: 0.75,
alignItems: 'center',
'& .MuiChip-label': {
lineHeight: 1.2,
px: 0,
},
}
// ── Chip labels for inline bar ─────────────────────────────────────────────
const getActiveChipLabel = def => {
const value = activeFilters[def.id]
if (value === undefined || value === null) return null
if (def.type === 'single-select') {
if (def.defaultValue !== undefined && value === def.defaultValue) return null
return def.options?.find(o => o.value === value)?.label ?? def.label
}
if (def.type === 'boolean') return def.label
if (def.type === 'multi-select' && Array.isArray(value) && value.length > 0) {
if (value.length === 1) {
return def.options?.find(o => o.value === value[0])?.label ?? def.label
}
return `${def.label} (${value.length})`
}
if (def.type === 'date-range') {
if (!value?.from && !value?.to) return null
if (value.preset) {
return DATE_RANGE_PRESETS.find(p => p.value === value.preset)?.label ?? 'Date Range'
}
const from = fmtDisplayDate(value.from)
const to = fmtDisplayDate(value.to)
if (from && to) return `${from} ${to}`
if (from) return `From ${from}`
if (to) return `Until ${to}`
return null
}
return null
}
// ── Handlers ───────────────────────────────────────────────────────────────
const handleMultiToggle = (defId, optValue) => {
const current = activeFilters[defId] || []
const next = current.includes(optValue)
? current.filter(v => v !== optValue)
: [...current, optValue]
onSetFilter(defId, next.length > 0 ? next : null)
}
const handleSingleToggle = (defId, optValue) => {
onSetFilter(defId, activeFilters[defId] === optValue ? null : optValue)
}
const handleBoolToggle = defId => {
onSetFilter(defId, activeFilters[defId] ? null : true)
}
const handleDateRangePreset = (defId, presetValue) => {
const current = activeFilters[defId] || {}
if (current.preset === presetValue) {
onSetFilter(defId, null)
return
}
const preset = DATE_RANGE_PRESETS.find(p => p.value === presetValue)
onSetFilter(defId, { preset: presetValue, ...preset.getRange() })
}
const handleDateRangeInput = (defId, field, dateStr) => {
const current = activeFilters[defId] || {}
if (!dateStr) {
const next = { ...current, preset: null, [field]: null }
onSetFilter(defId, next.from || next.to ? next : null)
} else {
const iso =
field === 'to'
? new Date(dateStr + 'T23:59:59').toISOString()
: new Date(dateStr + 'T00:00:00').toISOString()
onSetFilter(defId, { ...current, preset: null, [field]: iso })
}
}
// ── Render ─────────────────────────────────────────────────────────────────
return (
<>
{/* ── Inline bar ─────────────────────────────────────── */}
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, flexWrap: 'wrap', mb: 2 }}>
<Badge
badgeContent={activeFilterCount || null}
color='primary'
size='sm'
anchorOrigin={{ vertical: 'top', horizontal: 'right' }}
sx={{ display: 'flex', alignItems: 'center' }}
>
<Button
size='md'
variant={hasActive ? 'solid' : 'outlined'}
color={hasActive ? 'primary' : 'neutral'}
startDecorator={<FilterList sx={{ fontSize: 16 }} />}
onClick={() => setIsOpen(true)}
sx={{
borderRadius: 'xl',
py: 0.5,
px: 1,
gap: 0.5,
alignItems: 'center',
'& .MuiButton-startDecorator': {
display: 'flex',
alignItems: 'center',
mr: 0.5,
},
}}
>
Filters
</Button>
</Badge>
<ActiveFilterChips
chips={filterDefs
.map(def => ({ def, label: getActiveChipLabel(def) }))
.filter(({ label }) => !!label)
.map(({ def, label }) => ({
key: def.id,
label,
onClear: () => onSetFilter(def.id, null),
}))}
onOpen={() => setIsOpen(true)}
onClearAll={hasActive ? onClearAll : undefined}
resultCount={hasActive ? resultCount : undefined}
totalCount={hasActive ? totalCount : undefined}
maxVisible={2}
chipSize='md'
/>
</Box>
{/* ── Bottom sheet ────────────────────────────────────── */}
<BottomSheetModal
open={isOpen}
onClose={() => setIsOpen(false)}
title={
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<Tune sx={{ fontSize: 20 }} />
Filters
{hasActive && (
<Chip size='sm' variant='solid' color='primary' sx={modalCountChipSx}>
{activeFilterCount}
</Chip>
)}
</Box>
}
footer={
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', gap: 1 }}>
<Button
variant='plain'
color='danger'
size='sm'
disabled={!hasActive}
onClick={onClearAll}
>
Clear all
</Button>
<Button onClick={() => setIsOpen(false)} sx={{ minWidth: 140 }}>
{resultCount !== undefined
? `Show ${resultCount} result${resultCount !== 1 ? 's' : ''}`
: 'Done'}
</Button>
</Box>
}
>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0 }}>
{filterDefs.map((def, idx) => (
<Box key={def.id}>
{idx > 0 && <Divider sx={{ my: 2.5 }} />}
{/* Section header */}
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 1.5 }}>
{def.icon && (
<Box sx={{ color: 'text.secondary', display: 'flex', alignItems: 'center', '& svg': { fontSize: 18 } }}>
{def.icon}
</Box>
)}
<Typography level='title-sm' sx={{ fontWeight: 600 }}>
{def.label}
</Typography>
{/* active badge in header */}
{def.type === 'multi-select' && (activeFilters[def.id]?.length ?? 0) > 0 && (
<Chip size='sm' variant='solid' color='primary' sx={sectionBadgeChipSx}>
{activeFilters[def.id].length} selected
</Chip>
)}
{def.type === 'single-select' && activeFilters[def.id] != null && (() => {
const opt = def.options?.find(o => o.value === activeFilters[def.id])
return opt ? (
<Chip size='sm' variant='solid' color='primary' sx={sectionBadgeChipSx}>
{opt.label}
</Chip>
) : null
})()}
{def.type === 'date-range' && getActiveChipLabel(def) && (
<Chip size='sm' variant='solid' color='primary' sx={sectionBadgeChipSx}>
{getActiveChipLabel(def)}
</Chip>
)}
</Box>
{/* multi-select */}
{def.type === 'multi-select' && (
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 1 }}>
{def.options?.map(opt => {
const isSelected = (activeFilters[def.id] || []).includes(opt.value)
return (
<Chip
key={opt.value}
variant={isSelected ? 'solid' : 'soft'}
color={isSelected ? (opt.color ?? 'primary') : 'neutral'}
startDecorator={
opt.avatar ? (
<Avatar src={opt.avatar} alt={opt.label} sx={{ '--Avatar-size': '20px' }} />
) : isSelected ? (
<Check sx={{ fontSize: 14 }} />
) : (opt.icon ?? null)
}
onClick={() => handleMultiToggle(def.id, opt.value)}
sx={selectableChipSx}
>
{opt.label}
</Chip>
)
})}
</Box>
)}
{/* single-select */}
{def.type === 'single-select' && (
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 1 }}>
{def.options?.map(opt => {
const isSelected = activeFilters[def.id] === opt.value
return (
<Chip
key={opt.value}
variant={isSelected ? 'solid' : 'soft'}
color={isSelected ? (opt.color ?? 'primary') : 'neutral'}
startDecorator={
opt.avatar ? (
<Avatar src={opt.avatar} alt={opt.label} sx={{ '--Avatar-size': '20px' }} />
) : isSelected ? (
<Check sx={{ fontSize: 14 }} />
) : (opt.icon ?? null)
}
onClick={() => handleSingleToggle(def.id, opt.value)}
sx={selectableChipSx}
>
{opt.label}
</Chip>
)
})}
</Box>
)}
{/* boolean */}
{def.type === 'boolean' && (
<Chip
variant={activeFilters[def.id] ? 'solid' : 'soft'}
color={activeFilters[def.id] ? 'primary' : 'neutral'}
startDecorator={activeFilters[def.id] ? <Check sx={{ fontSize: 14 }} /> : null}
onClick={() => handleBoolToggle(def.id)}
sx={selectableChipSx}
>
{def.label}
</Chip>
)}
{/* date-range */}
{def.type === 'date-range' && (() => {
const val = activeFilters[def.id] || {}
return (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1.5 }}>
{/* Preset chips */}
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 1 }}>
{DATE_RANGE_PRESETS.map(preset => {
const isSelected = val.preset === preset.value
return (
<Chip
key={preset.value}
variant={isSelected ? 'solid' : 'soft'}
color={isSelected ? 'primary' : 'neutral'}
startDecorator={isSelected ? <Check sx={{ fontSize: 14 }} /> : null}
onClick={() => handleDateRangePreset(def.id, preset.value)}
sx={selectableChipSx}
>
{preset.label}
</Chip>
)
})}
</Box>
{/* Custom date inputs */}
<Box sx={{ display: 'flex', gap: 1, alignItems: 'center' }}>
<Input
type='date'
size='sm'
value={toInputDate(val.from)}
onChange={e => handleDateRangeInput(def.id, 'from', e.target.value)}
slotProps={{ input: { max: toInputDate(val.to) || undefined } }}
sx={{ flex: 1, fontSize: '0.8rem' }}
/>
<Typography level='body-xs' sx={{ color: 'text.tertiary', flexShrink: 0 }}>
</Typography>
<Input
type='date'
size='sm'
value={toInputDate(val.to)}
onChange={e => handleDateRangeInput(def.id, 'to', e.target.value)}
slotProps={{ input: { min: toInputDate(val.from) || undefined } }}
sx={{ flex: 1, fontSize: '0.8rem' }}
/>
</Box>
</Box>
)
})()}
</Box>
))}
</Box>
</BottomSheetModal>
</>
)
}
export default FilterBar

View File

@@ -0,0 +1,131 @@
import { Close } from '@mui/icons-material'
import { Box, Button, Chip, Typography } from '@mui/joy'
const ActiveFilterChips = ({
chips = [],
onOpen,
onClearAll,
resultCount,
totalCount,
maxVisible = 2,
chipSize = 'md',
clearButtonSize = 'sm',
clearButtonSx,
containerSx,
chipSx,
overflowChipSx,
resultSx,
}) => {
if (!chips.length) {
return null
}
const visible = chips.slice(0, maxVisible)
const overflow = chips.length - maxVisible
return (
<Box
sx={{
display: 'flex',
alignItems: 'center',
gap: 1,
flexWrap: 'nowrap',
overflowX: 'auto',
py: 0.5,
'&::-webkit-scrollbar': { display: 'none' },
scrollbarWidth: 'none',
...containerSx,
}}
>
{visible.map(({ key, label, onClear, color = 'primary' }) => (
<Chip
key={key}
size={chipSize}
variant='soft'
color={color}
endDecorator={
<Close
sx={{ cursor: 'pointer', fontSize: chipSize === 'sm' ? 12 : 16 }}
onClick={e => {
e.stopPropagation()
onClear?.()
}}
/>
}
onClick={onOpen}
sx={{
cursor: 'pointer',
flexShrink: 0,
transition: 'all 0.15s ease',
alignItems: 'center',
'& .MuiChip-endDecorator': {
display: 'flex',
alignItems: 'center',
ml: 0.5,
},
'& .MuiChip-label': {
lineHeight: 1.2,
},
'&:hover': { opacity: 0.85 },
...chipSx,
}}
>
{label}
</Chip>
))}
{overflow > 0 && (
<Chip
size={chipSize}
variant='soft'
color='neutral'
onClick={onOpen}
sx={{
cursor: 'pointer',
flexShrink: 0,
transition: 'all 0.15s ease',
'&:hover': { opacity: 0.85 },
...overflowChipSx,
}}
>
+{overflow} more
</Chip>
)}
{resultCount != null && totalCount != null && (
<Typography
level='body-xs'
sx={{
color: 'text.tertiary',
ml: 'auto',
flexShrink: 0,
...resultSx,
}}
>
{resultCount} / {totalCount}
</Typography>
)}
{onClearAll && (
<Button
size={clearButtonSize}
variant='plain'
color='neutral'
onClick={onClearAll}
sx={{
px: 0.5,
fontSize: chipSize === 'sm' ? '0.72rem' : '0.75rem',
color: 'text.secondary',
minHeight: 0,
flexShrink: 0,
...clearButtonSx,
}}
>
Clear all
</Button>
)}
</Box>
)
}
export default ActiveFilterChips

View File

@@ -21,11 +21,13 @@ export const TIME_FORMATS = {
export const RTL_LANGUAGES = ['ar', 'he', 'fa', 'ur']
export const AVAILABLE_LANGUAGES = [
{ code: 'de', name: 'German', nativeName: 'Deutsch' },
{ code: 'en', name: 'English', nativeName: 'English' },
{ code: 'es', name: 'Spanish', nativeName: 'Español' },
{ code: 'fr', name: 'French', nativeName: 'Français' },
{ code: 'nl', name: 'Dutch', nativeName: 'Nederlands' },
{ code: 'ja', name: 'Japanese', nativeName: '日本語' },
{ code: 'pt', name: 'Portuguese (Brazil)', nativeName: 'Português (Brasil)' },
]
export const LocalizationProvider = ({ children }) => {

View File

@@ -1,47 +1,83 @@
import { Network } from '@capacitor/network'
import { isOfflineFeatureEnabled } from '../utils/OfflineFeatureToggle'
class NetworkManager {
constructor() {
this.isOnline = true
this.isNetworkOn = null
this.init()
this.deviceOnline = true
this.serverReachable = true
this.offlineReason = null // 'device' | 'server' | null
this.connectionStatusListeners = []
this.queueSyncListeners = []
this.lastChecked = null
this.offlineSince = null
this.init()
}
// Effective online status: both device network AND server must be reachable
get isOnline() {
return this.deviceOnline && this.serverReachable
}
// Alias for backward compatibility (DeveloperSettings uses this)
get isNetworkOn() {
return this.deviceOnline
}
async init() {
const status = await Network.getStatus()
this.isNetworkOn = status.connected
this.deviceOnline = status.connected
this.lastChecked = Date.now()
if (!status.connected) {
this.offlineReason = 'device'
this.offlineSince = Date.now()
}
Network.addListener('networkStatusChange', status => {
if (this.isNetworkOn !== status.connected) {
this.isNetworkOn = status.connected
if (this.deviceOnline !== status.connected) {
this.deviceOnline = status.connected
this.lastChecked = Date.now()
this.isOnline = status.connected
if (!status.connected) {
this.offlineReason = 'device'
this.offlineSince = Date.now()
} else {
// Device came back online — update reason based on server state
this.offlineReason = this.serverReachable ? null : 'server'
}
this.notifyConnectionStatus()
}
})
}
setOffline() {
if (this.isOnline === true) {
this.isOnline = false
// Called when a fetch() response is received (any HTTP status = server is up)
setServerReachable() {
if (!this.serverReachable) {
this.serverReachable = true
this.offlineReason = this.deviceOnline ? null : 'device'
this.notifyConnectionStatus()
this.offlineSince = Date.now() // Record the time when we went offline
}
}
setOnline() {
if (this.isOnline === false) {
this.isOnline = true
// Called when fetch() throws a network error (server unreachable)
// Only takes effect when offline mode is enabled
setServerUnreachable() {
if (!isOfflineFeatureEnabled()) return
if (this.serverReachable) {
this.serverReachable = false
this.offlineReason = 'server'
this.offlineSince = Date.now()
this.notifyConnectionStatus()
}
}
// Legacy methods kept for compatibility
setOffline() {
this.setServerUnreachable()
}
setOnline() {
this.setServerReachable()
}
notifyConnectionStatus() {
this.connectionStatusListeners.forEach(callback => {
callback(this.isOnline)
@@ -63,7 +99,6 @@ class NetworkManager {
)
}
registerBackendSyncListener(callback) {
// if callback is not in the list already, add it
if (!this.queueSyncListeners.includes(callback)) {
this.queueSyncListeners.push(callback)
}

View File

@@ -39,7 +39,7 @@ export const AuthProvider = ({ children }) => {
// Ensure apiClient is initialized with the correct URL
await apiClient.init()
const currentBaseURL = apiClient.getApiURL()
const isNative =
typeof window !== 'undefined' && window.Capacitor?.isNativePlatform?.()
@@ -57,8 +57,8 @@ export const AuthProvider = ({ children }) => {
const response = await fetch(`${currentBaseURL}/auth/login`, config)
if (!response.ok) {
const error = await response.json()
return { success: false, error: error.message || 'Login failed' }
const res = await response.json()
return { success: false, error: res?.error || 'Login failed' }
}
const data = await response.json()

View File

@@ -0,0 +1,50 @@
import { Capacitor } from '@capacitor/core'
/**
* Normalizes a raw image string from the native document scanner into a
* format that can be used as an <img> src and passed to Tesseract.js.
*
* Android returns file:// or absolute paths → convert via Capacitor.convertFileSrc
* iOS returns raw base64 (no data: prefix) → prepend the data URI scheme
*/
function normalizeScannedImage(raw) {
if (!raw) return null
if (raw.startsWith('data:')) return raw
if (raw.startsWith('http://') || raw.startsWith('https://') || raw.startsWith('content://')) return raw
if (raw.startsWith('/') || raw.startsWith('file://')) return Capacitor.convertFileSrc(raw)
// iOS base64 without prefix
return `data:image/jpeg;base64,${raw}`
}
/**
* Hook for native document scanning via @capgo/capacitor-document-scanner.
*
* On native: opens the OS document scanner (edge detection, perspective correction).
* On web: `scanDocument` returns null — callers should fall back to their own camera UI.
*/
export function useDocumentScanner() {
const isNativeScanner = Capacitor.isNativePlatform()
const scanDocument = async ({ maxDocuments = 1, quality = 90, letUserAdjustCrop = true } = {}) => {
if (!isNativeScanner) return { image: null, cancelled: false }
try {
const { DocumentScanner } = await import('@capgo/capacitor-document-scanner')
const { scannedImages } = await DocumentScanner.scanDocument({
croppedImageQuality: quality,
maxNumDocuments: maxDocuments,
letUserAdjustCrop,
})
if (!scannedImages?.length) return { image: null, cancelled: true }
const normalized = normalizeScannedImage(scannedImages[0])
return { image: normalized, cancelled: false }
} catch (e) {
console.error('[DocumentScanner] scan failed:', e)
return { image: null, cancelled: false, error: e.message }
}
}
return { isNativeScanner, scanDocument }
}

View File

@@ -0,0 +1,92 @@
import imageCompression from 'browser-image-compression'
import { useCallback } from 'react'
import { useUserProfile } from '../queries/UserQueries'
import { useNotification } from '../service/NotificationProvider'
import { apiClient } from '../utils/ApiClient'
import { isPlusAccount, resolvePhotoURL } from '../utils/Helpers'
export const useFileUpload = ({ entityType = 'chore_attachment', entityId, draftId } = {}) => {
const { showError } = useNotification()
const { data: userProfile } = useUserProfile()
const uploadFile = useCallback(
async file => {
if (!isPlusAccount(userProfile)) {
showError({
title: 'Plus Feature',
message:
'Image uploads are not available in the Basic plan. Upgrade to Plus to add images to your content.',
})
return null
}
try {
const compressionOptions = {
maxSizeMB: entityType === 'profile' ? 0.5 : 1,
maxWidthOrHeight: entityType === 'profile' ? 320 : 1200,
useWebWorker: true,
fileType: 'image/jpeg',
}
const compressedFile = await imageCompression(file, compressionOptions)
const compressedJpegFile = new File(
[compressedFile],
`${file.name.split('.')[0]}.jpg`,
{ type: 'image/jpeg' },
)
const formData = new FormData()
formData.append('file', compressedJpegFile)
formData.append('entityType', entityType)
if (entityId) formData.append('entityId', String(entityId))
if (draftId) formData.append('draftId', draftId)
const response = await apiClient.upload('/assets/chore', formData)
if (response.status === 507) {
showError({
title: 'Storage Quota Exceeded',
message: 'You have exceeded your quota for uploading files.',
})
return null
} else if (response.status === 413) {
showError({
title: 'File Too Large',
message: 'The file you are trying to upload is too large.',
})
return null
} else if (response.status === 403 && !isPlusAccount(userProfile)) {
showError({
title: 'Upgrade Required',
message: 'Image uploads are only available for Plus accounts.',
})
return null
} else if (response.status === 403) {
showError({
title: 'Permission Denied',
message: 'You do not have permission to upload files.',
})
return null
} else if (!response.ok) {
showError({
title: 'Upload Failed',
message: 'Failed to upload image.',
})
return null
}
const data = await response.json()
return resolvePhotoURL(data.url || data.sign)
} catch {
showError({
title: 'Upload Failed',
message: 'An error occurred while processing the image.',
})
return null
}
},
[entityType, entityId, draftId, showError, userProfile],
)
return { uploadFile, isPlus: isPlusAccount(userProfile) }
}

59
src/hooks/useFilter.js Normal file
View File

@@ -0,0 +1,59 @@
import { useMemo, useState } from 'react'
/**
* Generic client-side filter hook.
*
* @param {Array} data - the full list to filter
* @param {Array} filterDefs - array of filter definitions (see FilterBar)
* @returns {{ filteredData, activeFilters, setFilter, clearAll, activeFilterCount, hasActiveFilters }}
*
* Each filterDef must include:
* id - unique string key
* type - 'multi-select' | 'boolean'
* filterFn - (item, filterValue) => boolean
*/
export const useFilter = (data, filterDefs) => {
const [activeFilters, setActiveFilters] = useState({})
const setFilter = (filterId, value) => {
setActiveFilters(prev => {
const isEmpty =
value === null ||
value === undefined ||
(Array.isArray(value) && value.length === 0)
if (isEmpty) {
const { [filterId]: _removed, ...rest } = prev
return rest
}
return { ...prev, [filterId]: value }
})
}
const clearAll = () => setActiveFilters({})
const filteredData = useMemo(() => {
if (!data) return []
if (!Object.keys(activeFilters).length) return data
return data.filter(item =>
filterDefs.every(def => {
const value = activeFilters[def.id]
if (value === undefined || value === null) return true
if (Array.isArray(value) && value.length === 0) return true
return def.filterFn(item, value)
}),
)
}, [data, activeFilters, filterDefs])
const activeFilterCount = Object.keys(activeFilters).length
return {
filteredData,
activeFilters,
setFilter,
clearAll,
activeFilterCount,
hasActiveFilters: activeFilterCount > 0,
}
}

56
src/hooks/useStatusBar.js Normal file
View File

@@ -0,0 +1,56 @@
import { useColorScheme } from '@mui/joy'
import { useEffect } from 'react'
import statusBarManager from '../utils/StatusBarManager'
/**
* Custom hook to manage status bar integration with Joy UI themes
* This hook automatically syncs the status bar style with the current theme
*/
export const useStatusBar = () => {
const { mode, systemMode } = useColorScheme()
useEffect(() => {
// Initialize status bar on mount
const initializeStatusBar = async () => {
await statusBarManager.initialize(mode)
}
initializeStatusBar()
// Cleanup on unmount
return () => {
statusBarManager.cleanup()
}
}, [mode]) // Include mode dependency
useEffect(() => {
// Update status bar when theme changes
const updateStatusBarTheme = async () => {
let resolvedTheme = mode
// Handle system mode by using the detected system theme
if (mode === 'system') {
resolvedTheme = systemMode || 'light'
}
// Update the status bar with the resolved theme
await statusBarManager.updateResolvedTheme(resolvedTheme)
// Also update the base theme for future reference
await statusBarManager.setTheme(mode)
// Notify any custom listeners
statusBarManager.notifyThemeChange(resolvedTheme)
}
updateStatusBarTheme()
}, [mode, systemMode]) // Update when either mode or systemMode changes
return {
statusBarManager,
currentTheme: mode,
resolvedTheme: mode === 'system' ? systemMode : mode,
}
}
export default useStatusBar

View File

@@ -8,7 +8,8 @@ import { isOfflineFeatureEnabled } from '../utils/OfflineFeatureToggle'
import { syncEngine } from '../utils/SyncEngine'
import { networkManager } from './NetworkManager'
const PENDING_POLL_MS = 30_000 // retry pending commands every 30s
export const PENDING_POLL_MS = 30_000 // retry pending commands every 30s
export const SERVER_PROBE_MS = 15_000 // probe server when marked unreachable but device has network
const CACHE_REFRESH_MS = 5 * 60_000 // refresh IDB cache every 5 min while online
export function useSyncOnReconnect() {
@@ -18,6 +19,7 @@ export function useSyncOnReconnect() {
useEffect(() => {
let pendingPollInterval
let cacheRefreshInterval
let serverProbeInterval
let resumeListener
let networkListener
const handleVisibilityChange = () => {
@@ -77,13 +79,27 @@ export function useSyncOnReconnect() {
cacheRefreshInterval = setInterval(() => {
runSync()
}, CACHE_REFRESH_MS)
// 6. Probe server every 15s when server is unreachable but device has network
serverProbeInterval = setInterval(async () => {
if (!networkManager.isOnline && networkManager.deviceOnline) {
await runSync()
}
}, SERVER_PROBE_MS)
}
const runSync = async () => {
if (!isOfflineFeatureEnabled()) return
const wasOffline = !networkManager.isOnline
const didSync = await syncEngine.sync()
if (didSync) {
queryClient.invalidateQueries()
// After recovery from server-unreachable, run a second pass to flush
// any commands that were skipped while offline
if (wasOffline && networkManager.isOnline) {
const didSync2 = await syncEngine.sync()
if (didSync2) queryClient.invalidateQueries()
}
}
}
@@ -98,6 +114,10 @@ export function useSyncOnReconnect() {
clearInterval(cacheRefreshInterval)
}
if (serverProbeInterval) {
clearInterval(serverProbeInterval)
}
if (networkListener) {
networkManager.unregisterNetworkListener(networkListener)
}

View File

@@ -8,6 +8,7 @@ import {
CreateChore,
DeleteChore,
DeleteChoreHistory,
GetChoreAttachments,
GetChoreByID,
GetChoreDetailById,
GetChoreHistory,
@@ -58,7 +59,8 @@ const mergePendingCreates = async chores => {
}
const isNetworkError = error =>
error instanceof TypeError && error.message === 'Failed to fetch'
(error instanceof TypeError && error.message === 'Failed to fetch') ||
error?.name === 'AbortError'
const buildOfflineChore = task => ({
...task,
@@ -191,13 +193,7 @@ export const useCreateChore = () => {
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', false], oldData => {
if (!oldData) return { res: [createdChore.res] }
return { res: [...oldData.res, createdChore.res] }
})
return createdChore.res
return { ...newTask, id: createdChore.res }
} catch (error) {
if (isNetworkError(error)) {
return queueOfflineCreate(newTask)
@@ -258,7 +254,7 @@ export const useUpdateChore = () => {
),
}
})
return updatedChoreRes?.res || updatedChoreRes
return updatedChoreRes?.res || updatedChore
} catch (error) {
if (isNetworkError(error)) {
return queueOfflineUpdate()
@@ -577,7 +573,45 @@ export const useMarkChoreComplete = () => {
})
return { res: { _pending: 'complete' } }
}
return MarkChoreComplete(choreId, body, completedDate, performer)
const queueOfflineComplete = async () => {
await commandQueue.enqueue(CommandType.COMPLETE_CHORE, choreId, {
id: choreId,
body,
completedDate,
performer,
})
await offlineDB.savePendingHistory({
id: -Date.now(),
choreId: Number(choreId),
completedBy: body?.completedBy || 0,
performedAt: completedDate || new Date().toISOString(),
notes: body?.note || null,
status: 1,
points: 0,
pending: true,
})
queryClient.setQueryData(['chores'], oldData => {
if (!oldData) return oldData
return {
res: oldData.res.map(chore =>
chore.id === choreId
? { ...chore, _pending: 'complete' }
: chore,
),
}
})
return { res: { _pending: 'complete' } }
}
try {
return await MarkChoreComplete(choreId, body, completedDate, performer)
} catch (error) {
if (isNetworkError(error)) {
return queueOfflineComplete()
}
throw error
}
},
onSuccess: (_, { choreId }) => {
queryClient.invalidateQueries(['chores'])
@@ -608,7 +642,26 @@ export const useSkipChore = () => {
})
return { res: { _pending: 'skip' } }
}
return SkipChore(choreId)
try {
return await SkipChore(choreId)
} catch (error) {
if (isNetworkError(error)) {
await commandQueue.enqueue(CommandType.SKIP_CHORE, choreId, {
id: choreId,
})
queryClient.setQueryData(['chores'], oldData => {
if (!oldData) return oldData
return {
res: oldData.res.map(chore =>
chore.id === choreId ? { ...chore, _pending: 'skip' } : chore,
),
}
})
return { res: { _pending: 'skip' } }
}
throw error
}
},
onSuccess: (_, choreId) => {
queryClient.invalidateQueries(['chores'])
@@ -644,3 +697,19 @@ export const useRejectChore = () => {
},
})
}
export const useChoreAttachments = (choreId, hasAttachments = true) => {
return useQuery({
queryKey: ['choreAttachments', choreId],
queryFn: async () => {
const response = await GetChoreAttachments(choreId)
if (response && response.ok) {
return await response.json()
}
throw new Error('Failed to fetch attachments')
},
enabled: !!choreId && hasAttachments,
staleTime: 10 * 60 * 1000,
gcTime: 15 * 60 * 1000,
})
}

View File

@@ -0,0 +1,72 @@
const ENABLED_KEY = 'ai_prompt_cache_enabled'
const ENTRY_PREFIX = 'ai_prompt_cache_'
const INDEX_KEY = 'ai_prompt_cache_index'
function djb2(str) {
let hash = 5381
for (let i = 0; i < str.length; i++) {
hash = ((hash << 5) + hash) ^ str.charCodeAt(i)
hash = hash >>> 0
}
return hash.toString(36)
}
export function isCacheEnabled() {
try {
return localStorage.getItem(ENABLED_KEY) === 'true'
} catch {
return false
}
}
export function setCacheEnabled(enabled) {
try {
localStorage.setItem(ENABLED_KEY, String(enabled))
} catch { /* ignore */ }
}
export function hashContent(content) {
return djb2(typeof content === 'string' ? content : JSON.stringify(content))
}
function getIndex() {
try {
return JSON.parse(localStorage.getItem(INDEX_KEY) || '[]')
} catch {
return []
}
}
export function getCached(hash) {
if (!isCacheEnabled()) return null
try {
const raw = localStorage.getItem(ENTRY_PREFIX + hash)
return raw ? JSON.parse(raw) : null
} catch {
return null
}
}
export function setCached(hash, value) {
if (!isCacheEnabled()) return
try {
localStorage.setItem(ENTRY_PREFIX + hash, JSON.stringify(value))
const index = getIndex()
if (!index.includes(hash)) {
index.push(hash)
localStorage.setItem(INDEX_KEY, JSON.stringify(index))
}
} catch { /* storage full, ignore */ }
}
export function getCacheStats() {
return { count: getIndex().length }
}
export function clearCache() {
const index = getIndex()
index.forEach(h => {
try { localStorage.removeItem(ENTRY_PREFIX + h) } catch { /* ignore */ }
})
try { localStorage.removeItem(INDEX_KEY) } catch { /* ignore */ }
}

View File

@@ -0,0 +1,141 @@
import { Capacitor } from '@capacitor/core'
import { getCached, hashContent, setCached } from './AIPromptCache'
// Native-only local AI service using @capacitor/local-llm.
// On web, all methods return 'unavailable' / null — no WebLLM.
class LocalAIService {
constructor() {
this._availability = null
this._sessionId = 'donetick-summary'
this._warmedUp = false
}
get isNative() {
return Capacitor.isNativePlatform()
}
async checkAvailability() {
if (!this.isNative) {
this._availability = 'unavailable'
return 'unavailable'
}
try {
const { LocalLLM } = await import('@capacitor/local-llm')
const { status } = await LocalLLM.systemAvailability()
this._availability = status
return status
} catch (e) {
this._availability = 'unavailable'
return 'unavailable'
}
}
async getStatus() {
if (this._availability !== null) return this._availability
return this.checkAvailability()
}
async isAvailable() {
return (await this.getStatus()) === 'available'
}
resetAvailability() {
this._availability = null
}
async download(onStatusChange) {
if (!this.isNative) return
try {
const { LocalLLM } = await import('@capacitor/local-llm')
if (onStatusChange) {
LocalLLM.addListener('systemAvailabilityChange', ({ status }) => {
this._availability = status
onStatusChange(status)
})
}
await LocalLLM.download()
} catch {
// download not available on iOS, ignore
}
}
async warmup() {
if (this._warmedUp || !this.isNative) return
try {
const { LocalLLM } = await import('@capacitor/local-llm')
await LocalLLM.warmup({ sessionId: this._sessionId })
this._warmedUp = true
} catch {
// non-fatal
}
}
async _nativePrompt(text) {
await this.warmup()
try {
const { LocalLLM } = await import('@capacitor/local-llm')
const { text: out } = await LocalLLM.prompt({ prompt: text, sessionId: this._sessionId })
return out?.trim() || null
} finally {
try {
const { LocalLLM } = await import('@capacitor/local-llm')
await LocalLLM.endSession({ sessionId: this._sessionId })
this._warmedUp = false
} catch { /* ignore */ }
}
}
// Plain chat — no tools. Returns answer string or null.
async plainChat(messages) {
const available = await this.isAvailable()
if (!available) return null
const cacheHash = hashContent(['plain', ...messages])
const cached = getCached(cacheHash)
if (cached) return cached
if (!this.isNative) return null
try {
const systemMsg = messages.find(m => m.role === 'system')?.content || ''
const userMsg = messages.find(m => m.role === 'user')?.content || ''
const result = await this._nativePrompt(`${systemMsg}\n\nUser: ${userMsg}\nAssistant:`)
if (result) setCached(cacheHash, result)
return result
} catch (e) {
console.error('[LocalAI] plainChat() failed:', e)
return null
}
}
// Returns the summary string or null if LLM is unavailable
async summarize(prompt) {
const available = await this.isAvailable()
if (!available) return null
const cacheHash = hashContent(prompt)
const cached = getCached(cacheHash)
if (cached) return cached
if (!this.isNative) return null
try {
await this.warmup()
const { LocalLLM } = await import('@capacitor/local-llm')
const { text } = await LocalLLM.prompt({ prompt, sessionId: this._sessionId })
const result = text?.trim() || null
if (result) setCached(cacheHash, result)
return result
} catch {
return null
} finally {
try {
const { LocalLLM } = await import('@capacitor/local-llm')
await LocalLLM.endSession({ sessionId: this._sessionId })
this._warmedUp = false
} catch { /* ignore */ }
}
}
}
export const localAIService = new LocalAIService()

View File

@@ -1,11 +1,136 @@
import { CapacitorNfc } from '@capgo/capacitor-nfc'
// Encodes a URL into an NDEF URI record (TNF=0x01, type='U')
const buildUriRecord = url => {
const encoder = new TextEncoder()
let prefixByte = 0x00
let uriStr = url
if (url.startsWith('https://')) {
prefixByte = 0x04
uriStr = url.slice(8)
} else if (url.startsWith('http://')) {
prefixByte = 0x03
uriStr = url.slice(7)
}
return {
tnf: 0x01,
type: [0x55],
id: [],
payload: [prefixByte, ...Array.from(encoder.encode(uriStr))],
}
}
// Decodes a URL from an NDEF URI record payload. Returns null if not a URI record.
export const decodeNdefUrl = record => {
if (!record || record.tnf !== 0x01) return null
if (record.type.length !== 1 || record.type[0] !== 0x55) return null
const payload = record.payload
if (!payload || payload.length === 0) return null
const prefixes = [
'',
'http://www.',
'https://www.',
'http://',
'https://',
'tel:',
'mailto:',
]
const prefix = prefixes[payload[0]] ?? ''
const uri = new TextDecoder().decode(new Uint8Array(payload.slice(1)))
return prefix + uri
}
// Starts a native NFC write session. Calls onWaiting once scanning is active,
// then onSuccess or onError when the write completes. Returns a cancel function.
export const startNativeNFCWrite = async (url, { onWaiting, onSuccess, onError }) => {
let listener = null
let done = false
const cleanup = async () => {
if (listener) {
await listener.remove()
listener = null
}
await CapacitorNfc.stopScanning().catch(() => {})
}
try {
listener = await CapacitorNfc.addListener('nfcEvent', async () => {
if (done) return
done = true
try {
await CapacitorNfc.write({ records: [buildUriRecord(url)] })
await cleanup()
onSuccess()
} catch (err) {
await cleanup()
onError(err.message || 'Failed to write to NFC tag')
}
})
await CapacitorNfc.startScanning({
alertMessage: 'Hold your device near the NFC tag to write',
invalidateAfterFirstRead: true,
// Without FLAG_READER_SKIP_NDEF_CHECK (0x80), Android enumerates
// Ndef/NdefFormatable tech so the plugin can format blank tags on write.
androidReaderModeFlags: 0x0f, // NFC_A | NFC_B | NFC_F | NFC_V
})
onWaiting()
return cleanup
} catch (err) {
await cleanup()
onError(err.message || 'Failed to start NFC session')
return async () => {}
}
}
// Starts a native NFC scan session for reading. Calls onTag(url) when a URL
// NDEF record is found, or onError on failure. Returns a cancel function.
export const startNativeScan = async ({ onTag, onError }) => {
let listener = null
let done = false
const cleanup = async () => {
if (listener) {
await listener.remove()
listener = null
}
await CapacitorNfc.stopScanning().catch(() => {})
}
try {
listener = await CapacitorNfc.addListener('nfcEvent', async event => {
if (done) return
const records = event.tag?.ndefMessage ?? []
for (const record of records) {
const url = decodeNdefUrl(record)
if (url) {
done = true
await cleanup()
onTag(url)
return
}
}
})
await CapacitorNfc.startScanning({
alertMessage: 'Hold your device near the NFC tag',
invalidateAfterFirstRead: true,
})
return cleanup
} catch (err) {
await cleanup()
onError(err.message || 'Failed to start NFC session')
return async () => {}
}
}
// Legacy default export for web/PWA (NDEFReader API)
const writeToNFC = async url => {
if ('NDEFReader' in window) {
try {
const ndef = new window.NDEFReader()
await ndef.write({
records: [{ recordType: 'url', data: url }],
})
alert('URL written to NFC tag successfully!')
await ndef.write({ records: [{ recordType: 'url', data: url }] })
} catch (error) {
console.error('Error writing to NFC tag:', error)
alert('Error writing to NFC tag. Please try again.')

127
src/styles/safe-area.css Normal file
View File

@@ -0,0 +1,127 @@
/*
* Safe Area CSS Utilities
* These utilities provide consistent safe area handling across the app
* using CSS custom properties set by StatusBarManager
*/
:root {
/* Fallback values for safe area insets when not on native platforms */
--safe-area-inset-top: 0px;
--safe-area-inset-right: 0px;
--safe-area-inset-bottom: 0px;
--safe-area-inset-left: 0px;
}
/* Utility classes for safe area handling */
.safe-area-top {
padding-top: var(--safe-area-inset-top);
}
.safe-area-right {
padding-right: var(--safe-area-inset-right);
}
.safe-area-bottom {
padding-bottom: var(--safe-area-inset-bottom);
}
.safe-area-left {
padding-left: var(--safe-area-inset-left);
}
.safe-area-x {
padding-left: var(--safe-area-inset-left);
padding-right: var(--safe-area-inset-right);
}
.safe-area-y {
padding-top: var(--safe-area-inset-top);
padding-bottom: var(--safe-area-inset-bottom);
}
.safe-area-all {
padding-top: var(--safe-area-inset-top);
padding-right: var(--safe-area-inset-right);
padding-bottom: var(--safe-area-inset-bottom);
padding-left: var(--safe-area-inset-left);
}
/* Margin variants */
.safe-margin-top {
margin-top: var(--safe-area-inset-top);
}
.safe-margin-right {
margin-right: var(--safe-area-inset-right);
}
.safe-margin-bottom {
margin-bottom: var(--safe-area-inset-bottom);
}
.safe-margin-left {
margin-left: var(--safe-area-inset-left);
}
.safe-margin-x {
margin-left: var(--safe-area-inset-left);
margin-right: var(--safe-area-inset-right);
}
.safe-margin-y {
margin-top: var(--safe-area-inset-top);
margin-bottom: var(--safe-area-inset-bottom);
}
.safe-margin-all {
margin-top: var(--safe-area-inset-top);
margin-right: var(--safe-area-inset-right);
margin-bottom: var(--safe-area-inset-bottom);
margin-left: var(--safe-area-inset-left);
}
/* Height utilities that account for safe areas */
.min-h-screen-safe {
min-height: calc(100vh - var(--safe-area-inset-top) - var(--safe-area-inset-bottom));
}
.h-screen-safe {
height: calc(100vh - var(--safe-area-inset-top) - var(--safe-area-inset-bottom));
}
/* Top positioning that accounts for safe area */
.top-safe {
top: var(--safe-area-inset-top);
}
/* Bottom positioning that accounts for safe area */
.bottom-safe {
bottom: var(--safe-area-inset-bottom);
}
/* Fixed positioning utilities that respect safe areas */
.fixed-top-safe {
position: fixed;
top: var(--safe-area-inset-top);
left: 0;
right: 0;
z-index: 1030;
}
.fixed-bottom-safe {
position: fixed;
bottom: var(--safe-area-inset-bottom);
left: 0;
right: 0;
z-index: 1030;
}
/* Container that provides full safe area coverage */
.container-safe {
padding-top: var(--safe-area-inset-top);
padding-right: var(--safe-area-inset-right);
padding-bottom: var(--safe-area-inset-bottom);
padding-left: var(--safe-area-inset-left);
min-height: 100vh;
box-sizing: border-box;
}

View File

@@ -1,5 +1,6 @@
import { Preferences } from '@capacitor/preferences'
import { API_URL } from '../Config'
import { networkManager } from '../hooks/NetworkManager'
import { logout, RefreshToken } from './Fetcher'
import {
clearAllTokens,
@@ -17,7 +18,7 @@ class ApiClient {
}
async init(force = false) {
if (!force && this.initPromise) {
if (this.initPromise && !force) {
return this.initPromise
}
@@ -25,7 +26,9 @@ class ApiClient {
return Promise.resolve()
}
this.initPromise = this._doInit()
this.initPromise = this._doInit().finally(() => {
this.initPromise = null
})
return this.initPromise
}
@@ -144,15 +147,22 @@ class ApiClient {
async request(endpoint, options = {}) {
await this.init()
const url = `${this.customServerURL}${endpoint}`
// Abort after 10s so a dead/unreachable server doesn't hang the UI
const controller = new AbortController()
const timeoutId = setTimeout(() => controller.abort(), 10_000)
const config = {
// credentials: 'include',
...options,
headers: this.getHeaders(options.headers),
signal: options.signal ?? controller.signal,
}
try {
// 1. Initial Request
let response = await fetch(url, config)
clearTimeout(timeoutId)
// 2. Check for 401 (Unauthorized)
if (response.status === 401) {
@@ -220,6 +230,9 @@ class ApiClient {
return response
} catch (error) {
clearTimeout(timeoutId)
// fetch() threw = network-level failure or timeout — mark server unreachable
networkManager.setServerUnreachable()
console.error('Request failed', error)
throw error
}

View File

@@ -25,6 +25,67 @@ export const ChoreStatus = Object.freeze({
PAUSED: 2,
PENDING_APPROVAL: 3,
})
const getDateGroupKey = dueDate =>
moment(dueDate).startOf('day').format('YYYY-MM-DD')
const getDateGroupName = dateKey =>
moment(dateKey, 'YYYY-MM-DD').format('dddd, MMM D')
const getDateGroupColor = dateKey => {
const today = moment().startOf('day')
const tomorrow = moment().add(1, 'day').startOf('day')
const groupDate = moment(dateKey, 'YYYY-MM-DD')
if (groupDate.isBefore(today)) {
return TASK_COLOR.OVERDUE
}
if (groupDate.isSame(today)) {
return TASK_COLOR.TODAY
}
if (groupDate.isSame(tomorrow)) {
return TASK_COLOR.TOMORROW
}
if (groupDate.isBefore(moment(today).add(8, 'days'))) {
return TASK_COLOR.NEXT_7_DAYS
}
if (groupDate.isSame(today, 'month')) {
return TASK_COLOR.LATER_THIS_MONTH
}
return TASK_COLOR.FUTURE
}
const buildActualDateGroups = chores => {
const groupedByDate = {}
const anytime = []
chores.forEach(chore => {
if (!chore.nextDueDate) {
anytime.push(chore)
return
}
const dateKey = getDateGroupKey(chore.nextDueDate)
if (!groupedByDate[dateKey]) {
groupedByDate[dateKey] = []
}
groupedByDate[dateKey].push(chore)
})
const dateGroups = Object.keys(groupedByDate)
.sort(
(a, b) =>
moment(a, 'YYYY-MM-DD').valueOf() - moment(b, 'YYYY-MM-DD').valueOf(),
)
.map(dateKey => ({
name: getDateGroupName(dateKey),
content: groupedByDate[dateKey],
color: getDateGroupColor(dateKey),
}))
return { dateGroups, anytime }
}
export const ChoresGrouper = (groupBy, chores, filter) => {
if (filter) {
chores = chores.filter(chore => filter(chore))
@@ -34,7 +95,7 @@ export const ChoresGrouper = (groupBy, chores, filter) => {
chores.sort(ChoreSorter)
var groups = []
switch (groupBy) {
case 'default':
case 'default': {
// same as due_date but hide empty groups: and if status is 1 or 2 have seperated catigory as Started:
var groupRaw = {
PendingApproval: [],
@@ -147,82 +208,21 @@ export const ChoresGrouper = (groupBy, chores, filter) => {
})
}
break
}
case 'due_date':
var groupRaw = {
Today: [],
Tomorrow: [],
'Next 7 Days': [],
'Later This Month': [],
Future: [],
Overdue: [],
Anytime: [],
}
chores.forEach(chore => {
if (chore.nextDueDate === null) {
groupRaw['Anytime'].push(chore)
} else if (new Date(chore.nextDueDate) < new Date()) {
groupRaw['Overdue'].push(chore)
} else if (
new Date(chore.nextDueDate).toDateString() ===
new Date().toDateString()
) {
groupRaw['Today'].push(chore)
} else if (
new Date(chore.nextDueDate).toDateString() ===
new Date(Date.now() + 24 * 60 * 60 * 1000).toDateString()
) {
groupRaw['Tomorrow'].push(chore)
} else if (
new Date(chore.nextDueDate) <
new Date(Date.now() + 8 * 24 * 60 * 60 * 1000) &&
new Date(chore.nextDueDate) >
new Date(Date.now() + 24 * 60 * 60 * 1000)
) {
groupRaw['Next 7 Days'].push(chore)
} else if (
new Date(chore.nextDueDate).getMonth() === new Date().getMonth() &&
new Date(chore.nextDueDate).getFullYear() === new Date().getFullYear()
) {
groupRaw['Later This Month'].push(chore)
} else {
groupRaw['Future'].push(chore)
}
})
groups = [
{
name: 'Overdue',
content: groupRaw['Overdue'],
color: TASK_COLOR.OVERDUE,
},
{ name: 'Today', content: groupRaw['Today'], color: TASK_COLOR.TODAY },
{
name: 'Tomorrow',
content: groupRaw['Tomorrow'],
color: TASK_COLOR.TOMORROW,
},
{
name: 'Next 7 Days',
content: groupRaw['Next 7 Days'],
color: TASK_COLOR.NEXT_7_DAYS,
},
{
name: 'Later This Month',
content: groupRaw['Later This Month'],
color: TASK_COLOR.LATER_THIS_MONTH,
},
{
name: 'Future',
content: groupRaw['Future'],
color: TASK_COLOR.FUTURE,
},
{
case 'due_date': {
var { dateGroups: dueDateGroups, anytime: dueAnytime } =
buildActualDateGroups(chores)
groups = [...dueDateGroups]
if (dueAnytime.length > 0) {
groups.push({
name: 'Anytime',
content: groupRaw['Anytime'],
content: dueAnytime,
color: TASK_COLOR.ANYTIME,
},
]
})
}
break
}
case 'priority':
groupRaw = {
p1: [],

View File

@@ -728,6 +728,30 @@ const DeleteUser = (password, confirmation, transferOptions = []) => {
})
}
const UploadChoreAttachment = (file, entityType, { entityId, draftId } = {}) => {
const formData = new FormData()
formData.append('file', file)
formData.append('entityType', entityType)
if (entityId != null) formData.append('entityId', String(entityId))
if (draftId != null) formData.append('draftId', draftId)
return apiClient.upload('/assets/chore', formData)
}
const GetChoreAttachments = choreId => {
return Fetch(`/chores/${choreId}/attachments`, {
method: 'GET',
headers: HEADERS(),
})
}
const DeleteChoreAttachment = (choreId, filePath) => {
return Fetch(`/chores/${choreId}/attachments`, {
method: 'DELETE',
headers: HEADERS(),
body: JSON.stringify({ file_path: filePath }),
})
}
const CreateBackup = (encryptionKey, includeAssets = true, backupName = '') => {
return Fetch(`/backup/create`, {
method: 'POST',
@@ -933,6 +957,9 @@ const TrackFilterUsage = id => {
export {
AcceptCircleMemberRequest,
DeleteChoreAttachment,
GetChoreAttachments,
UploadChoreAttachment,
ApproveChore,
ArchiveChore,
CancelSubscription,

View File

@@ -10,9 +10,114 @@ const resolvePhotoURL = url => {
if (url.startsWith('http') || url.startsWith('https')) {
return url
}
if (url.startsWith('assets')) {
return apiClient.getAssetURL(url)
}
return url
return apiClient.getAssetURL(url)
}
export { isPlusAccount, resolvePhotoURL }
// Detect cloud storage pre-signed URLs (S3, GCS, Azure) that carry expiry params.
const isCloudSignedUrl = url => {
if (!url) return false
try {
return (
url.includes('X-Amz-Signature') ||
url.includes('X-Amz-Expires') ||
url.includes('X-Goog-Expires') ||
url.includes('expires')
)
} catch(e) {
return false
}
}
// Extract the storage key from a cloud signed URL so we can route it through
// the backend proxy (which re-signs on every request and never expires).
//
// Handles:
// Virtual-hosted S3: https://{bucket}.s3[.region].amazonaws.com/{key}?...
// Path-style S3: https://s3[.region].amazonaws.com/{bucket}/{key}?...
// Cloudflare R2: https://{bucket}.{accountid}.r2.cloudflarestorage.com/{key}?...
// GCS: https://storage.googleapis.com/{bucket}/{key}?...
// Azure Blob: https://{account}.blob.core.windows.net/{container}/{blob}?...
//
// The app stores files under an "assets/" prefix in the bucket but the backend
// proxy already mounts at /assets/, so we strip that leading segment when present.
const extractStorageKey = url => {
try {
const u = new URL(url)
const host = u.hostname
const rawPath = u.pathname.replace(/^\//, '')
let key
if (host.endsWith('.r2.cloudflarestorage.com')) {
// Virtual-hosted R2: bucket is in the host, key is the full path
key = rawPath
} else if (host.endsWith('.amazonaws.com')) {
if (host.startsWith('s3') || host.includes('.s3.')) {
// Path-style S3: first segment is the bucket — strip it
key = rawPath.split('/').slice(1).join('/')
} else {
// Virtual-hosted S3: bucket is in the host, path is the key
key = rawPath
}
} else if (host === 'storage.googleapis.com') {
// First segment is the bucket
key = rawPath.split('/').slice(1).join('/')
} else if (host.endsWith('.blob.core.windows.net')) {
// First segment is the container name
key = rawPath.split('/').slice(1).join('/')
} else {
key = rawPath
}
// The bucket stores files under an "assets/" prefix; the backend /assets/
// endpoint already adds that prefix, so strip it to avoid duplication.
if (key.startsWith('assets/')) {
key = key.slice('assets/'.length)
}
return key || null
} catch {
return null
}
}
// Scan an HTML string for <img> tags whose src is a cloud signed URL and
// replace them with backend proxy URLs (which generate fresh signed URLs on
// each request). Returns the patched HTML, or the original if nothing changed.
const refreshSignedUrlsInHtml = html => {
if (!html) return html
if (
!html.includes('dt-data-path') &&
!html.includes('X-Amz-') &&
!html.includes('X-Goog-') &&
!html.includes('sig') &&
!html.includes('.blob.core.windows.net')
) {
return html
}
const parser = new DOMParser()
const doc = parser.parseFromString(html, 'text/html')
const imgs = doc.querySelectorAll('img[src]')
let changed = false
imgs.forEach(img => {
const stablePath = img.getAttribute('dt-data-path')
const src = img.getAttribute('src')
let nextSrc = src
if (stablePath) {
nextSrc = resolvePhotoURL(stablePath)
} else if (isCloudSignedUrl(src)) {
nextSrc = resolvePhotoURL(extractStorageKey(src))
}
if (nextSrc && nextSrc !== src) {
img.setAttribute('src', nextSrc)
changed = true
}
})
return changed ? doc.body.innerHTML : html
}
export { isPlusAccount, refreshSignedUrlsInHtml, resolvePhotoURL }

View File

@@ -26,17 +26,32 @@ class SQLiteBackend {
constructor() {
this.db = null
this.initialized = false
this._initPromise = null
}
async init() {
if (this.initialized) return
// Return the in-flight promise if init is already underway (prevents double createConnection)
if (this._initPromise) return this._initPromise
this.db = await CapacitorSQLite.createConnection({
database: DB_NAME,
version: DB_VERSION,
encrypted: false,
mode: 'no-encryption',
this._initPromise = this._doInit().finally(() => {
this._initPromise = null
})
return this._initPromise
}
async _doInit() {
try {
this.db = await CapacitorSQLite.createConnection({
database: DB_NAME,
version: DB_VERSION,
encrypted: false,
mode: 'no-encryption',
})
} catch (err) {
// Connection already open (e.g. React StrictMode double-mount) — reuse it
if (!err?.message?.includes('already exists')) throw err
}
await CapacitorSQLite.open({ database: DB_NAME })
await CapacitorSQLite.execute({

View File

@@ -26,7 +26,7 @@ class StatusBarManager {
}
try {
// Configure basic status bar settings - use overlay: true for precise control
// Configure basic status bar settings
await StatusBar.setOverlaysWebView({ overlay: false })
await StatusBar.show()

View File

@@ -55,6 +55,8 @@ class SyncEngine {
// Step 3: Delta sync from server
await this._deltaSync()
// Sync succeeded — server is reachable (only sync success restores online status)
networkManager.setServerReachable()
this._notify({ syncing: false, lastSync: Date.now() })
return true
} catch (err) {
@@ -182,7 +184,7 @@ class SyncEngine {
let hasMore = true
let currentCursor = cursor
while (hasMore && networkManager.isOnline) {
while (hasMore && networkManager.deviceOnline) {
// Use apiClient.get which handles auth and returns a fetch Response
const response = await apiClient.get(
`/sync/changes?since=${currentCursor}`,

View File

@@ -9,6 +9,8 @@ import { Link, useNavigate, useParams } from 'react-router-dom'
import { useUserProfile } from '../../queries/UserQueries'
import { apiClient } from '../../utils/ApiClient'
import { GetUserProfile } from '../../utils/Fetcher'
import { saveTokens } from '../../utils/TokenStorage'
import MFAVerificationModal from './MFAVerificationModal'
const AuthenticationLoading = () => {
const { data: userProfile, refetch: refetchUserProfile } = useUserProfile()
@@ -17,6 +19,8 @@ const AuthenticationLoading = () => {
const [message, setMessage] = useState('Authenticating')
const [subMessage, setSubMessage] = useState('Please wait')
const [status, setStatus] = useState('pending')
const [mfaModalOpen, setMfaModalOpen] = useState(false)
const [mfaSessionToken, setMfaSessionToken] = useState('')
const { provider } = useParams()
useEffect(() => {
if (provider === 'oauth2' && !hasCalledHandleOAuth2.current) {
@@ -43,6 +47,29 @@ const AuthenticationLoading = () => {
})
})
}
const handleMFASuccess = async data => {
await saveTokens({
accessToken: data.token,
accessTokenExpiry: data.expire,
refreshToken: data.refresh_token,
refreshTokenExpiry: data.refresh_token_expiry,
})
setMfaModalOpen(false)
setMfaSessionToken('')
getUserProfileAndNavigateToHome()
}
const handleMFAClose = () => {
setMfaModalOpen(false)
setMfaSessionToken('')
setMessage('Authentication failed')
setSubMessage('Two-factor authentication was cancelled')
setStatus('error')
}
const handleOAuth2 = async () => {
// get provider from params:
const urlParams = new URLSearchParams(window.location.search)
@@ -64,37 +91,71 @@ const AuthenticationLoading = () => {
const redirectURI = Capacitor.isNativePlatform()
? 'donetick://auth/oauth2'
: `${window.location.origin}/auth/oauth2`
fetch(`${baseURL}/auth/oauth2/callback`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
code,
state: returnedState,
redirect_uri: redirectURI,
}),
}).then(response => {
if (response.status === 200) {
return response.json().then(data => {
localStorage.setItem('token', data.token)
localStorage.setItem('token_expiry', data.expire)
try {
const response = await fetch(`${baseURL}/auth/oauth2/callback`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
code,
state: returnedState,
redirect_uri: redirectURI,
}),
})
const redirectUrl = Cookies.get('ca_redirect')
if (redirectUrl) {
Cookies.remove('ca_redirect')
Navigate(redirectUrl)
} else {
getUserProfileAndNavigateToHome()
}
})
} else {
if (!response.ok) {
console.error('Authentication failed')
setMessage('Authentication failed')
setSubMessage('Please try again')
setStatus('error')
return
}
})
const data = await response.json()
if (data.mfaRequired) {
if (!data.sessionToken) {
setMessage('Authentication failed')
setSubMessage('MFA session is missing. Please try again')
setStatus('error')
return
}
setMfaSessionToken(data.sessionToken)
setMfaModalOpen(true)
setMessage('Two-Factor Authentication Required')
setSubMessage('Please verify your login to continue')
return
}
if (!data.token && !data.access_token) {
setMessage('Authentication failed')
setSubMessage('No valid authentication token returned')
setStatus('error')
return
}
await saveTokens({
accessToken: data.token || data.access_token,
accessTokenExpiry: data.expire || data.access_token_expiry,
refreshToken: data.refresh_token,
refreshTokenExpiry: data.refresh_token_expiry,
})
const redirectUrl = Cookies.get('ca_redirect')
if (redirectUrl) {
Cookies.remove('ca_redirect')
Navigate(redirectUrl)
} else {
getUserProfileAndNavigateToHome()
}
} catch (error) {
console.error('Authentication request failed', error)
setMessage('Authentication failed')
setSubMessage('Please try again')
setStatus('error')
}
}
}
@@ -138,6 +199,17 @@ const AuthenticationLoading = () => {
<Link to='/login'>Go back Login</Link>
</Button>
)}
<MFAVerificationModal
open={mfaModalOpen}
onClose={handleMFAClose}
sessionToken={mfaSessionToken}
onSuccess={handleMFASuccess}
onError={() => {
setMessage('Authentication failed')
setSubMessage('Two-factor authentication failed. Please try again')
}}
/>
</Box>
</Container>
)

View File

@@ -1,17 +1,32 @@
import { Preferences } from '@capacitor/preferences'
import { Box, Button, Container, Input, Sheet, Typography } from '@mui/joy'
import CheckCircleOutlineIcon from '@mui/icons-material/CheckCircleOutline'
import ErrorOutlineIcon from '@mui/icons-material/ErrorOutline'
import WifiIcon from '@mui/icons-material/Wifi'
import {
Alert,
Box,
Button,
CircularProgress,
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 { useResource } from '../../queries/ResourceQueries'
import { useNotification } from '../../service/NotificationProvider'
import { apiClient } from '../../utils/ApiClient'
const CONNECTION_TIMEOUT_MS = 8000
const LoginSettings = () => {
const Navigate = useNavigate()
const { refetch: refetchResource } = useResource()
const [serverURL, setServerURL] = React.useState('')
const { showError } = useNotification()
const [status, setStatus] = React.useState('idle') // 'idle' | 'testing' | 'success' | 'error'
const [errorMessage, setErrorMessage] = React.useState('')
React.useEffect(() => {
Preferences.get({ key: 'customServerUrl' }).then(result => {
@@ -19,10 +34,95 @@ const LoginSettings = () => {
})
}, [])
const isValidServerURL = () => {
return serverURL.match(/^(http|https):\/\/[^ "]+$/)
const isValidURL = url => {
return /^(http|https):\/\/[^ "]+$/.test(url.trim())
}
const testConnection = async url => {
const controller = new AbortController()
const timeoutId = setTimeout(
() => controller.abort(),
CONNECTION_TIMEOUT_MS,
)
try {
const testURL = url.replace(/\/+$/, '') + '/api/v1/resource'
const response = await fetch(testURL, {
method: 'GET',
signal: controller.signal,
})
clearTimeout(timeoutId)
// Any HTTP response (even 401/404) means the server is reachable
if (response.status < 500) {
return { ok: true }
}
return {
ok: false,
message: `Server responded with error ${response.status}. Please check your Donetick server.`,
}
} catch (err) {
clearTimeout(timeoutId)
if (err.name === 'AbortError') {
return {
ok: false,
message: `Connection timed out after ${CONNECTION_TIMEOUT_MS / 1000}s. Check the URL and ensure the server is running.`,
}
}
return {
ok: false,
message:
'Unable to reach the server. Check the URL, port, and network connection.',
}
}
}
const handleSave = async () => {
const trimmedURL = serverURL.trim()
if (trimmedURL === '') {
await Preferences.set({ key: 'customServerUrl', value: API_URL })
Navigate('/login')
return
}
if (!isValidURL(trimmedURL)) {
setStatus('error')
setErrorMessage(
'Invalid URL format. Include the protocol (http:// or https://) and port if needed.',
)
return
}
setStatus('testing')
setErrorMessage('')
const result = await testConnection(trimmedURL)
if (!result.ok) {
setStatus('error')
setErrorMessage(result.message)
return
}
await Preferences.set({ key: 'customServerUrl', value: trimmedURL })
await apiClient.init(true)
refetchResource()
setStatus('success')
setTimeout(() => {
Navigate('/login')
}, 1200)
}
const handleURLChange = e => {
setServerURL(e.target.value)
if (status !== 'idle') {
setStatus('idle')
setErrorMessage('')
}
}
const isTesting = status === 'testing'
return (
<Container component='main' maxWidth='xs'>
<Box
@@ -38,7 +138,6 @@ const LoginSettings = () => {
sx={{
mt: 1,
width: '100%',
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
@@ -51,13 +150,7 @@ const LoginSettings = () => {
<Typography level='h2'>
Done
<span
style={{
color: '#06b6d4',
}}
>
tick
</span>
<span style={{ color: '#06b6d4' }}>tick</span>
</Typography>
<Typography level='body2' alignSelf={'start'} mt={4}>
@@ -71,9 +164,22 @@ const LoginSettings = () => {
name='serverURL'
autoFocus
value={serverURL}
onChange={e => {
setServerURL(e.target.value)
}}
onChange={handleURLChange}
disabled={isTesting}
color={
status === 'success'
? 'success'
: status === 'error'
? 'danger'
: 'neutral'
}
endDecorator={
status === 'success' ? (
<CheckCircleOutlineIcon color='success' fontSize='small' />
) : status === 'error' ? (
<ErrorOutlineIcon color='error' fontSize='small' />
) : null
}
/>
<Typography mt={1} level='body-xs'>
@@ -81,72 +187,68 @@ const LoginSettings = () => {
own self-hosted Donetick server.
</Typography>
<Typography mt={1} level='body-xs'>
Please ensure to include the protocol (http:// or https://) and the
port number if necessary (default Donetick port is 2021).
Include the protocol (http:// or https://) and port if necessary
(default Donetick port is 2021).
</Typography>
{status === 'error' && (
<Alert
color='danger'
variant='soft'
startDecorator={<ErrorOutlineIcon />}
sx={{ mt: 2, width: '100%' }}
>
{errorMessage}
</Alert>
)}
{status === 'success' && (
<Alert
color='success'
variant='soft'
startDecorator={<CheckCircleOutlineIcon />}
sx={{ mt: 2, width: '100%' }}
>
Connected! Redirecting to login...
</Alert>
)}
{status === 'testing' && (
<Alert
color='neutral'
variant='soft'
startDecorator={<WifiIcon />}
sx={{ mt: 2, width: '100%' }}
>
Testing connection to server...
</Alert>
)}
<Button
fullWidth
size='lg'
variant='solid'
sx={{
width: '100%',
mt: 3,
mb: 2,
border: 'moccasin',
borderRadius: '8px',
}}
onClick={() => {
if (serverURL === '') {
Preferences.set({
key: 'customServerUrl',
value: API_URL,
}).then(() => {
Navigate('/login')
})
return
}
if (!isValidServerURL()) {
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(async () => {
// apiClient.customServerURL = serverURL + '/api/v1's
// Force re-initialization to reload from Preferences
await apiClient.init(true)
// refetch resource queries to update the API URL
refetchResource()
Navigate('/login')
})
}}
disabled={isTesting || status === 'success'}
sx={{ width: '100%', mt: 2, mb: 2, borderRadius: '8px' }}
onClick={handleSave}
startDecorator={
isTesting ? <CircularProgress size='sm' /> : undefined
}
>
Save
{isTesting ? 'Testing...' : 'Save & Connect'}
</Button>
<Button
fullWidth
size='lg'
variant='soft'
color='danger'
sx={{
width: '100%',
mb: 2,
border: 'moccasin',
borderRadius: '8px',
}}
onClick={() => {
Preferences.set({ key: 'customServerUrl', value: API_URL }).then(
() => {
refetchResource()
Navigate('/login')
},
)
disabled={isTesting}
sx={{ width: '100%', mb: 2, borderRadius: '8px' }}
onClick={async () => {
await Preferences.set({ key: 'customServerUrl', value: API_URL })
await apiClient.init(true)
refetchResource()
Navigate('/login')
}}
>
Cancel and Reset

View File

@@ -1,4 +1,12 @@
import { Add, ArrowDropDown, HorizontalRule, Save } from '@mui/icons-material'
import {
Add,
ArrowDropDown,
AttachFile,
Delete,
HorizontalRule,
Save,
UploadFile,
} from '@mui/icons-material'
import {
Avatar,
Box,
@@ -43,8 +51,13 @@ import {
import { useCircleMembers, useUserProfile } from '../../queries/UserQueries.jsx'
import { useNotification } from '../../service/NotificationProvider'
import { getTextColorFromBackgroundColor } from '../../utils/Colors.jsx'
import { GetAllCircleMembers, GetThings } from '../../utils/Fetcher'
import { isPlusAccount } from '../../utils/Helpers'
import {
DeleteChoreAttachment,
GetAllCircleMembers,
GetThings,
UploadChoreAttachment,
} from '../../utils/Fetcher'
import { isPlusAccount, resolvePhotoURL } from '../../utils/Helpers'
import Priorities from '../../utils/Priorities.jsx'
import { getIconComponent } from '../../utils/ProjectIcons'
import { getSafeBottomPadding } from '../../utils/SafeAreaUtils.js'
@@ -53,6 +66,7 @@ import LoadingComponent from '../components/Loading.jsx'
import RichTextEditor from '../components/RichTextEditor.jsx'
import SubTasks from '../components/SubTask.jsx'
import { useLabels } from '../Labels/LabelQueries'
import AttachmentViewerModal from '../Modals/Inputs/AttachmentViewerModal'
import ConfirmationModal from '../Modals/Inputs/ConfirmationModal'
import LabelModal from '../Modals/Inputs/LabelModal'
import { useProjects } from '../Projects/ProjectQueries'
@@ -83,7 +97,8 @@ const ChoreEdit = () => {
const [name, setName] = useState('')
const [description, setDescription] = useState('')
const [confirmModelConfig, setConfirmModelConfig] = useState({})
const [assignees, setAssignees] = useState([])
const [anyone, setAnyone] = useState(false)
const [assignableTo, setAssignableTo] = useState([])
const [performers, setPerformers] = useState([])
const [assignStrategy, setAssignStrategy] = useState(ASSIGN_STRATEGIES[2])
const [dueDate, setDueDate] = useState(null)
@@ -116,7 +131,13 @@ const ChoreEdit = () => {
const [createdBy, setCreatedBy] = useState(0)
const [errors, setErrors] = useState({})
const [attemptToSave, setAttemptToSave] = useState(false)
const [draftId] = useState(() => crypto.randomUUID())
const [attachments, setAttachments] = useState([])
const [isUploadingAttachment, setIsUploadingAttachment] = useState(false)
const [addLabelModalOpen, setAddLabelModalOpen] = useState(false)
const [attachmentViewerConfig, setAttachmentViewerConfig] = useState({
isOpen: false,
})
const [showSavePrivacyDefault, setShowSavePrivacyDefault] = useState(false)
const [privacySaved, setPrivacySaved] = useState(false)
const [showSaveNotificationDefault, setShowSaveNotificationDefault] =
@@ -158,6 +179,7 @@ const ChoreEdit = () => {
const Navigate = useNavigate()
const assignees = anyone ? performers : assignableTo
const HandleValidateChore = () => {
const errors = {}
@@ -330,6 +352,7 @@ const ChoreEdit = () => {
if (searchParams.get('clone') === 'true') {
newChoreId = null
}
const assignees = anyone ? [] : assignableTo
const chore = {
id: Number(newChoreId),
name: name,
@@ -359,6 +382,7 @@ const ChoreEdit = () => {
deadlineOffset: deadlineOffset < 0 ? null : deadlineOffset,
priority: priority,
projectId: projectId === 'default' ? null : projectId,
draftId: newChoreId > 0 ? undefined : draftId,
}
let SaveFunction = createChoreMutation.mutateAsync
if (newChoreId > 0) {
@@ -419,15 +443,29 @@ const ChoreEdit = () => {
setIsNotificable(JSON.parse(defaultNotificationSetting))
}
const defaultAnyoneSetting = localStorage.getItem('defaultAnyoneSetting')
if (defaultAnyoneSetting != null) {
const savedAnyone = JSON.parse(defaultAnyoneSetting)
setAnyone(savedAnyone)
}
const defaultAssigneeSetting = localStorage.getItem(
'defaultAssigneeSetting',
)
if (defaultAssigneeSetting !== null) {
const savedAssignees = JSON.parse(defaultAssigneeSetting)
setAssignees(savedAssignees)
setAssignableTo(savedAssignees)
}
}
}, [])
useEffect(() => {
const anyoneSetting = localStorage.getItem('defaultAnyoneSetting')
const anyoneDirty = anyoneSetting !== JSON.stringify(anyone)
const assigneeSetting = localStorage.getItem('defaultAssigneeSetting')
const assigneeDirty = assigneeSetting !== JSON.stringify(assignableTo)
const dirty = anyoneDirty || (!anyone && assigneeDirty)
setShowSaveAssigneeDefault(dirty)
}, [anyone, assignableTo])
// Keyboard shortcuts
useEffect(() => {
@@ -477,7 +515,8 @@ const ChoreEdit = () => {
setChore(data.res)
setName(data.res.name ? data.res.name : '')
setDescription(data.res.description ? data.res.description : '')
setAssignees(data.res.assignees ? data.res.assignees : [])
setAssignableTo(data.res.assignees ? data.res.assignees : [])
setAnyone((data.res.assignees?.length || 0) === 0)
setAssignedTo(data.res.assignedTo)
setFrequencyType(data.res.frequencyType ? data.res.frequencyType : 'once')
@@ -558,6 +597,7 @@ const ChoreEdit = () => {
setCreatedBy(data.res.createdBy)
setUpdatedBy(data.res.updatedBy)
setAttachments(data.res.attachments || [])
}
}, [choreData, isChoreLoading, searchParams])
@@ -591,13 +631,15 @@ const ChoreEdit = () => {
if (assignees.length === 0) {
setAssignStrategy('no_assignee')
setAssignedTo(null)
} else if (assignees.length === 1) {
setAssignedTo(assignees[0].userId)
} else {
if (!assignees.some(a => a.userId === assignedTo)) {
setAssignedTo(assignees[0].userId)
}
if (assignStrategy === 'no_assignee') {
setAssignStrategy(ASSIGN_STRATEGIES[2]) // default to least_completed
}
}
}, [assignees, assignStrategy])
}, [assignStrategy, assignedTo, assignees])
// useEffect(() => {
// if (performers.length > 0 && assignees.length === 0 && userProfile) {
@@ -614,7 +656,7 @@ const ChoreEdit = () => {
if (attemptToSave) {
HandleValidateChore()
}
}, [assignees, name, frequencyMetadata, attemptToSave, dueDate])
}, [assignableTo, name, frequencyMetadata, attemptToSave, dueDate])
const handleDelete = () => {
setConfirmModelConfig({
@@ -921,6 +963,175 @@ const ChoreEdit = () => {
/>
</Card>
</Box>
<Box mt={3}>
<Typography level='h4'>Attachments</Typography>
<Typography level='body-md'>Files attached to this task</Typography>
<Card variant='outlined' sx={{ mt: 2, p: 1.5 }}>
{attachments.length > 0 && (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1, mb: 1.5 }}>
{attachments.map((att, idx) => (
<Box
key={att.file_path || idx}
onClick={() => {
const url = resolvePhotoURL(att.sign || att.file_path)
const ext = (att.file_name || '')
.split('.')
.pop()
.toLowerCase()
const isImage = [
'jpg',
'jpeg',
'png',
'gif',
'webp',
'bmp',
'svg',
].includes(ext)
if (isImage) {
setAttachmentViewerConfig({
isOpen: true,
url,
fileName: att.file_name,
onClose: () =>
setAttachmentViewerConfig({ isOpen: false }),
})
} else {
const a = document.createElement('a')
a.href = url
a.download = att.file_name || 'attachment'
document.body.appendChild(a)
a.click()
document.body.removeChild(a)
}
}}
sx={{
display: 'flex',
alignItems: 'center',
gap: 1,
p: 1,
borderRadius: 'sm',
border: '1px solid',
borderColor: 'neutral.outlinedBorder',
cursor: 'pointer',
'&:hover': { bgcolor: 'neutral.softHoverBg' },
}}
>
<AttachFile sx={{ fontSize: 18, color: 'neutral.500' }} />
<Typography
level='body-sm'
sx={{ flex: 1, wordBreak: 'break-all' }}
>
{att.file_name}
</Typography>
{att.size_bytes && (
<Typography level='body-xs' color='neutral'>
{(att.size_bytes / 1024).toFixed(1)} KB
</Typography>
)}
{choreId && (
<IconButton
size='sm'
variant='plain'
color='danger'
onClick={event => {
event.stopPropagation()
DeleteChoreAttachment(choreId, att.file_path)
.then(() => {
setAttachments(prev =>
prev.filter(a => a.file_path !== att.file_path),
)
})
.catch(() => {
showError({
title: 'Delete Failed',
message: 'Failed to delete attachment.',
})
})
}}
>
<Delete sx={{ fontSize: 18 }} />
</IconButton>
)}
{!choreId && (
<IconButton
size='sm'
variant='plain'
color='danger'
onClick={event => {
event.stopPropagation()
setAttachments(prev =>
prev.filter((_, i) => i !== idx),
)
}}
>
<Delete sx={{ fontSize: 18 }} />
</IconButton>
)}
</Box>
))}
</Box>
)}
<Button
component='label'
variant='outlined'
color='neutral'
size='sm'
startDecorator={
isUploadingAttachment ? null : <UploadFile />
}
loading={isUploadingAttachment}
sx={{ alignSelf: 'flex-start' }}
>
Upload File
<input
type='file'
hidden
onChange={async e => {
const file = e.target.files[0]
if (!file) return
setIsUploadingAttachment(true)
try {
const response = choreId
? await UploadChoreAttachment(file, 'chore_attachment', {
entityId: choreId,
})
: await UploadChoreAttachment(
file,
'chore_attachment_draft',
{ draftId },
)
if (!response.ok) {
showError({
title: 'Upload Failed',
message: 'Failed to upload attachment.',
})
return
}
const data = await response.json()
setAttachments(prev => [
...prev,
{
file_path: data.path,
file_name: data.file_name,
size_bytes: data.size_bytes,
sign: data.sign,
},
])
} catch {
showError({
title: 'Upload Failed',
message: 'Failed to upload attachment.',
})
} finally {
setIsUploadingAttachment(false)
e.target.value = ''
}
}}
/>
</Button>
</Card>
</Box>
</Box>
{/* Section 2: Assignment & Responsibility */}
@@ -941,9 +1152,9 @@ const ChoreEdit = () => {
<ListItem key={'anyone'}>
<Checkbox
checked={assignees.length === 0}
checked={anyone}
onClick={() => {
setAssignees([])
setAnyone(!anyone)
setIsPrivate(false)
}}
overlay
@@ -956,19 +1167,25 @@ const ChoreEdit = () => {
{performers?.map((item, index) => (
<ListItem key={item.id}>
<Checkbox
checked={
assignees.find(a => a.userId == item.userId) != null
}
checked={assignableTo.some(a => a.userId == item.userId)}
disabled={anyone}
onClick={() => {
if (anyone) {
setAnyone(false)
setAssignableTo([{ userId: item.userId }])
return
}
const assignees = assignableTo
const setAssignees = setAssignableTo
if (assignees.some(a => a.userId === item.userId)) {
const newAssignees = assignees.filter(
a => a.userId !== item.userId,
)
setAnyone(newAssignees.length === 0)
setAssignees(newAssignees)
} else {
setAssignees([...assignees, { userId: item.userId }])
}
setShowSaveAssigneeDefault(true)
}}
overlay
disableIcon
@@ -998,9 +1215,13 @@ const ChoreEdit = () => {
},
}}
onClick={() => {
localStorage.setItem(
'defaultAnyoneSetting',
JSON.stringify(anyone),
)
localStorage.setItem(
'defaultAssigneeSetting',
JSON.stringify(assignees),
JSON.stringify(assignableTo),
)
setShowSaveAssigneeDefault(false)
}}
@@ -1026,17 +1247,12 @@ const ChoreEdit = () => {
}
disabled={assignees.length === 0}
value={assignedTo > -1 ? assignedTo : null}
onChange={(_, selectedUserId) => setAssignedTo(selectedUserId)}
>
{performers
?.filter(p => assignees.find(a => a.userId == p.userId))
?.filter(p => assignees.some(a => a.userId == p.userId))
.map((item, index) => (
<Option
value={item.userId}
key={item.displayName}
onClick={() => {
setAssignedTo(item.userId)
}}
>
<Option value={item.userId} key={item.displayName}>
{item.displayName}
</Option>
))}
@@ -1707,6 +1923,7 @@ const ChoreEdit = () => {
)}
</Button>
</Sheet>
<AttachmentViewerModal config={attachmentViewerConfig} />
<ConfirmationModal config={confirmModelConfig} />
{addLabelModalOpen && (
<LabelModal

View File

@@ -1,5 +1,6 @@
import {
Archive,
AttachFile,
CalendarMonth,
Check,
Checklist,
@@ -78,6 +79,7 @@ import {
import { offlineDB } from '../../utils/OfflineDB'
import Priorities from '../../utils/Priorities'
import { getSafeBottomPadding } from '../../utils/SafeAreaUtils.js'
import AttachmentBrowserModal from '../Modals/Inputs/AttachmentBrowserModal'
import ConfirmationModal from '../Modals/Inputs/ConfirmationModal'
import NoteViewerModal from '../Modals/Inputs/NoteViewerModal'
import LoadingComponent from '../components/Loading.jsx'
@@ -86,6 +88,7 @@ import RichTextEditor from '../components/RichTextEditor.jsx'
import SubTasks from '../components/SubTask.jsx'
import TimePassedCard from './TimePassedCard.jsx'
import TimerSplitButton from './TimerSplitButton.jsx'
import { refreshSignedUrlsInHtml } from '../../utils/Helpers.jsx'
const isNetworkError = err =>
err instanceof TypeError && err.message === 'Failed to fetch'
@@ -124,6 +127,7 @@ const ChoreView = () => {
const [chorePriority, setChorePriority] = useState(null)
const [noteViewerConfig, setNoteViewerConfig] = useState({ isOpen: false })
const [timerActionConfig, setTimerActionConfig] = useState({ isOpen: false })
const [attachmentBrowserOpen, setAttachmentBrowserOpen] = useState(false)
const { data: circleMembersData, isLoading: isCircleMembersLoading } =
useCircleMembers()
const { data: userProfile } = useUserProfile()
@@ -132,6 +136,7 @@ const ChoreView = () => {
const { data: choreData, isLoading: isChoreLoading } =
useChoreDetails(choreId)
const { data: choreHistoryData } = useChoreHistory(choreId)
const { data: pendingCmds } = usePendingCommands(choreId)
const choreHistory = choreHistoryData?.res || []
@@ -158,8 +163,8 @@ const ChoreView = () => {
document.title = 'Donetick: ' + choreData.res.name
setPerformers(circleMembersData.res)
const auto_complete = searchParams.get('auto_complete')
if (auto_complete === 'true') {
if (searchParams.get('auto_complete') === 'true') {
navigate({ search: '' }, { replace: true })
handleTaskCompletion()
}
}, [choreData, circleMembersData])
@@ -651,16 +656,14 @@ const ChoreView = () => {
justifyContent: 'center',
alignItems: 'center',
mb: 1,
flexWrap: 'wrap',
gap: 0.5,
}}
>
{chore?.labelsV2?.map((label, index) => (
<Chip
key={index}
sx={{
position: 'relative',
ml: index === 0 ? 0 : 0.5,
top: 2,
zIndex: 1,
backgroundColor: label?.color,
color: getTextColorFromBackgroundColor(label?.color),
}}
@@ -668,6 +671,20 @@ const ChoreView = () => {
{label?.name}
</Chip>
))}
{chore?.attachments?.length > 0 && (
<Chip
startDecorator={<AttachFile />}
size='md'
variant='soft'
color='neutral'
onClick={() => setAttachmentBrowserOpen(true)}
sx={{ cursor: 'pointer' }}
>
{chore.attachments.length}{' '}
{chore.attachments.length === 1 ? 'attachment' : 'attachments'}
</Chip>
)}
</Box>
</Box>
@@ -918,7 +935,7 @@ const ChoreView = () => {
whiteSpace: 'pre-wrap',
wordBreak: 'break-word',
}}
dangerouslySetInnerHTML={{ __html: raw }}
dangerouslySetInnerHTML={{ __html: refreshSignedUrlsInHtml(raw) }}
/>
) : (
<Typography
@@ -986,7 +1003,7 @@ const ChoreView = () => {
whiteSpace: 'pre-wrap',
wordBreak: 'break-word',
}}
dangerouslySetInnerHTML={{ __html: raw }}
dangerouslySetInnerHTML={{ __html: refreshSignedUrlsInHtml(raw) }}
/>
) : (
<Typography
@@ -1324,6 +1341,11 @@ const ChoreView = () => {
<ConfirmationModal config={confirmModelConfig} />
<ConfirmationModal config={timerActionConfig} />
<NoteViewerModal config={noteViewerConfig} />
<AttachmentBrowserModal
choreId={choreId}
isOpen={attachmentBrowserOpen}
onClose={() => setAttachmentBrowserOpen(false)}
/>
</Card>
</Container>
)

View File

@@ -108,7 +108,7 @@ const generateSchedulePreview = (metadata, formatTimeFn) => {
return `Every ${dayNames} at ${timeStr}`
}
const RepeatOnSections = ({
export const RepeatOnSections = ({
frequencyType,
frequency,
onFrequencyUpdate,

View File

@@ -1,13 +1,16 @@
import {
Archive,
CheckBox,
CheckBoxOutlineBlank,
Close,
Delete,
SelectAll,
Unarchive,
ViewAgenda,
ViewModule,
Archive,
CheckBox,
CheckBoxOutlineBlank,
Close,
Delete,
Label,
Person,
PriorityHigh,
SelectAll,
Unarchive,
ViewAgenda,
ViewModule,
} from '@mui/icons-material'
import {
Box,
@@ -22,15 +25,18 @@ import {
} from '@mui/joy'
import { useQueryClient } from '@tanstack/react-query'
import Fuse from 'fuse.js'
import { useEffect, useRef, useState } from 'react'
import { useEffect, useMemo, useRef, useState } from 'react'
import { useNavigate } from 'react-router-dom'
import FilterBar from '../../components/common/FilterBar'
import KeyboardShortcutHint from '../../components/common/KeyboardShortcutHint'
import { useImpersonateUser } from '../../contexts/ImpersonateUserContext.jsx'
import { useFilter } from '../../hooks/useFilter'
import { useUnArchiveChore } from '../../queries/ChoreQueries'
import { useCircleMembers, useUserProfile } from '../../queries/UserQueries'
import { useNotification } from '../../service/NotificationProvider'
import { commandQueue, CommandType } from '../../utils/CommandQueue'
import { DeleteChore, GetArchivedChores } from '../../utils/Fetcher'
import Priorities from '../../utils/Priorities'
import { offlineDB } from '../../utils/OfflineDB'
import LoadingComponent from '../components/Loading'
import ConfirmationModal from '../Modals/Inputs/ConfirmationModal'
@@ -107,6 +113,95 @@ const ArchivedTasks = () => {
const { data: membersData, isLoading: membersLoading } = useCircleMembers()
// Unique labels present across all archived chores
const availableLabels = useMemo(() => {
const seen = {}
archivedChores.forEach(c => {
c.labelsV2?.forEach(l => { seen[l.id] = l })
})
return Object.values(seen)
}, [archivedChores])
const filterDefs = useMemo(
() => [
{
id: 'assignee',
label: 'Assignee',
type: 'multi-select',
icon: <Person />,
options: performers.map(p => ({
value: p.userId,
label: p.displayName,
avatar: p.image,
})),
filterFn: (item, values) => values.includes(item.assignedTo),
},
{
id: 'priority',
label: 'Priority',
type: 'multi-select',
icon: <PriorityHigh />,
options: Priorities.map(p => ({
value: p.value,
label: p.name,
color: p.color || 'neutral',
icon: p.icon,
})),
filterFn: (item, values) => values.includes(item.priority ?? 0),
},
...(availableLabels.length > 0
? [
{
id: 'label',
label: 'Labels',
type: 'multi-select',
icon: <Label />,
options: availableLabels.map(l => ({
value: l.id,
label: l.name,
icon: (
<Box
component='span'
sx={{
display: 'inline-block',
width: 10,
height: 10,
borderRadius: '50%',
bgcolor: l.color || '#90a4ae',
flexShrink: 0,
}}
/>
),
})),
filterFn: (item, values) =>
item.labelsV2?.some(l => values.includes(l.id)) ?? false,
},
]
: []),
{
id: 'archivedAt',
label: 'Archived Date',
type: 'date-range',
icon: <Archive />,
filterFn: (item, value) => {
const date = new Date(item.updatedAt)
if (value.from && date < new Date(value.from)) return false
if (value.to && date > new Date(value.to)) return false
return true
},
},
],
[performers, availableLabels],
)
const {
filteredData: finalChores,
activeFilters,
setFilter,
clearAll,
hasActiveFilters,
} = useFilter(filteredChores, filterDefs)
useEffect(() => {
const loadArchivedChores = async () => {
if (!membersLoading && userProfile) {
@@ -335,11 +430,8 @@ const ArchivedTasks = () => {
}
const selectAllVisibleChores = () => {
const visibleChores =
searchTerm?.length > 0 ? filteredChores : archivedChores
if (visibleChores.length > 0) {
const allIds = new Set(visibleChores.map(chore => chore.id))
setSelectedChores(allIds)
if (finalChores.length > 0) {
setSelectedChores(new Set(finalChores.map(c => c.id)))
}
}
@@ -673,6 +765,15 @@ const ArchivedTasks = () => {
</Box>
</Box>
<FilterBar
filterDefs={filterDefs}
activeFilters={activeFilters}
onSetFilter={setFilter}
onClearAll={clearAll}
resultCount={finalChores.length}
totalCount={filteredChores.length}
/>
{/* Multi-select Toolbar */}
{isMultiSelectMode && (
<Box
@@ -745,7 +846,7 @@ const ArchivedTasks = () => {
variant='outlined'
onClick={selectAllVisibleChores}
startDecorator={<SelectAll />}
disabled={selectedChores.size === filteredChores.length}
disabled={selectedChores.size === finalChores.length}
sx={{
minWidth: 'auto',
'--Button-paddingInline': '0.75rem',
@@ -876,7 +977,7 @@ const ArchivedTasks = () => {
)}
{/* Content */}
{filteredChores.length === 0 ? (
{finalChores.length === 0 ? (
<Box
sx={{
display: 'flex',
@@ -886,42 +987,43 @@ const ArchivedTasks = () => {
height: '50vh',
}}
>
<Archive
sx={{
fontSize: '4rem',
mb: 1,
color: 'text.tertiary',
}}
/>
<Archive sx={{ fontSize: '4rem', mb: 1, color: 'text.tertiary' }} />
<Typography level='title-md' gutterBottom>
{searchTerm ? 'No archived tasks found' : 'No archived tasks'}
{searchTerm || hasActiveFilters
? 'No archived tasks found'
: 'No archived tasks'}
</Typography>
<Typography level='body-sm' color='text.secondary' sx={{ mb: 2 }}>
{searchTerm
? 'Try adjusting your search terms'
{searchTerm || hasActiveFilters
? 'Try adjusting your search or filters'
: '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>
{(searchTerm || hasActiveFilters) && (
<Box sx={{ display: 'flex', gap: 1 }}>
{searchTerm && (
<Button onClick={handleSearchClose} variant='outlined' color='neutral'>
Clear search
</Button>
)}
{hasActiveFilters && (
<Button onClick={clearAll} variant='outlined' color='neutral'>
Clear filters
</Button>
)}
</Box>
)}
</Box>
) : (
<Box>
<Typography level='body-sm' color='text.secondary' sx={{ mb: 2 }}>
{filteredChores.length} archived task
{filteredChores.length !== 1 ? 's' : ''}
{finalChores.length} archived task
{finalChores.length !== 1 ? 's' : ''}
{searchTerm && ` matching "${searchTerm}"`}
</Typography>
<List sx={{ gap: viewMode === 'compact' ? 0 : 1 }}>
<ChoreListView
chores={filteredChores}
chores={finalChores}
// viewOnly={true}
showActions={false}
viewMode={viewMode}

View File

@@ -190,7 +190,11 @@ const scheduleChoreNotification = async (
for (let i = 0; i < chores.length; i++) {
const chore = chores[i]
try {
if (chore.notification === false || chore.nextDueDate === null) {
if (
chore.notification === false ||
chore.nextDueDate === null ||
chore.isActive === false
) {
continue
}
scheduleNotificationFromTemplate(

View File

@@ -2,18 +2,12 @@ import {
Add,
Bolt,
CalendarMonth,
CancelRounded,
CheckBox,
CheckBoxOutlineBlank,
EditCalendar,
ExpandCircleDown,
Grain,
PriorityHigh,
Sort,
Style,
ViewAgenda,
ViewModule,
} from '@mui/icons-material'
import Logo from '../../Logo'
import {
Accordion,
AccordionDetails,
@@ -24,9 +18,6 @@ import {
Container,
Divider,
IconButton,
List,
Menu,
MenuItem,
Typography,
} from '@mui/joy'
import Fuse from 'fuse.js'
@@ -43,6 +34,7 @@ import IconButtonWithMenu from './IconButtonWithMenu'
import { useMediaQuery } from '@mui/material'
import { useQueryClient } from '@tanstack/react-query'
import KeyboardShortcutHint from '../../components/common/KeyboardShortcutHint'
import { useFilter } from '../../hooks/useFilter'
import { useImpersonateUser } from '../../contexts/ImpersonateUserContext.jsx'
import { useCircleMembers, useUserProfile } from '../../queries/UserQueries'
import {
@@ -55,15 +47,13 @@ import { getSafeBottom } from '../../utils/SafeAreaUtils.js'
import TaskInput from '../components/AddTaskModal'
import CalendarDual from '../components/CalendarDual'
import CalendarMonthly from '../components/CalendarMonthly.jsx'
import ProjectSelector from '../components/ProjectSelector'
import AdvancedFilterBuilder from '../Modals/Inputs/AdvancedFilterBuilder'
import { useProjects } from '../Projects/ProjectQueries.js'
import ChoreListView from './ChoreListView.jsx'
import ChoreToolbar from './components/ChoreToolbarPrototype'
import ChoreModals from './components/ChoreModals'
import FilterSection from './components/FilterSection'
import MultiSelectToolbar from './components/MultiSelectToolbar'
import MyChoreHeader from './components/MyChoreHeader'
import SearchBar from './components/SearchBar'
import { useChoreActions } from './hooks/useChoreActions'
import { useChoreFilters } from './hooks/useChoreFilters'
import { useChoreModals } from './hooks/useChoreModals'
@@ -78,7 +68,6 @@ import {
import NotificationAccessSnackbar from './NotificationAccessSnackbar'
import Sidepanel from './Sidepanel'
import { INSIGHT_FILTER_DEFS } from './SmartInsightsCard'
import SortAndGrouping from './SortAndGrouping'
const MyChores = () => {
const { data: userProfile, isLoading: isUserProfileLoading } =
@@ -107,7 +96,6 @@ const MyChores = () => {
const [chores, setChores] = useState([])
const [filteredChores, setFilteredChores] = useState([])
const [choreSections, setChoreSections] = useState([])
const [showSearchFilter, setShowSearchFilter] = useState(false)
const [addTaskModalOpen, setAddTaskModalOpen] = useState(false)
const [taskInputFocus, setTaskInputFocus] = useState(0)
const searchInputRef = useRef(null)
@@ -135,15 +123,12 @@ const MyChores = () => {
const {
searchTerm,
searchFilter,
selectedChoreFilter,
projectFilteredChores,
searchFilteredChores,
nonProjectFilteredChores,
setSearchTerm,
setSearchFilter,
setSelectedChoreFilterWithCache,
clearFilters,
} = useChoreFilters({
chores,
selectedProject,
@@ -193,6 +178,102 @@ const MyChores = () => {
useState(false)
const [editingFilter, setEditingFilter] = useState(null)
const quickFilterDefs = useMemo(
() => [
{
id: 'status',
label: 'Due Date',
type: 'single-select',
icon: <CalendarMonth />,
options: [
{ value: 'Overdue', label: 'Overdue', color: 'danger' },
{ value: 'Due today', label: 'Due Today', color: 'warning' },
{ value: 'Due in week', label: 'Due This Week' },
{ value: 'Due Later', label: 'Due Later' },
{ value: 'No Due Date', label: 'No Due Date' },
{ value: 'Pending Approval', label: 'Pending Approval' },
],
filterFn: (item, value) => {
const now = new Date()
const d = item.nextDueDate ? new Date(item.nextDueDate) : null
switch (value) {
case 'Overdue':
return d !== null && d < now
case 'Due today':
return d !== null && d.toDateString() === now.toDateString()
case 'Due in week':
return (
d !== null &&
d < new Date(now.getTime() + 7 * 24 * 60 * 60 * 1000) &&
d > now
)
case 'Due Later':
return (
d !== null &&
d > new Date(now.getTime() + 24 * 60 * 60 * 1000)
)
case 'No Due Date':
return item.nextDueDate === null
case 'Pending Approval':
return item.status === 3
default:
return true
}
},
},
{
id: 'priority',
label: 'Priority',
type: 'multi-select',
icon: <PriorityHigh />,
options: Priorities.map(p => ({
value: p.value,
label: p.name,
color: p.color || 'neutral',
icon: p.icon,
})),
filterFn: (item, values) => values.includes(item.priority ?? 0),
},
...(userLabels?.length > 0
? [
{
id: 'label',
label: 'Labels',
type: 'multi-select',
icon: <Style />,
options: userLabels.map(l => ({
value: l.id,
label: l.name,
icon: (
<Box
component='span'
sx={{
display: 'inline-block',
width: 10,
height: 10,
borderRadius: '50%',
bgcolor: l.color || '#90a4ae',
flexShrink: 0,
}}
/>
),
})),
filterFn: (item, values) =>
item.labelsV2?.some(l => values.includes(l.id)) ?? false,
},
]
: []),
],
[userLabels],
)
const {
filteredData: quickFilteredChores,
setFilter: setQuickFilter,
clearAll: clearQuickFilters,
hasActiveFilters: hasQuickFilters,
} = useFilter(projectFilteredChores, quickFilterDefs)
const processedChores = useMemo(() => {
if (!choresData?.res) {
return []
@@ -222,9 +303,9 @@ const MyChores = () => {
if (tempFilter || activeFilterId) {
// Advanced/custom filter active
choresToGroup = customFilteredChores
} else if (searchFilter !== 'All') {
// Quick filter active (Overdue, Due today, Label, Priority, etc.)
choresToGroup = filteredChores
} else if (hasQuickFilters) {
// Quick filter active (Due date, Priority, Labels)
choresToGroup = quickFilteredChores
} else if (!selectedProject || selectedProject.id === 'default') {
// No project selected or default project: only show tasks without a projectId
choresToGroup = chores.filter(chore => !chore.projectId)
@@ -244,11 +325,11 @@ const MyChores = () => {
return sections
}, [
chores,
filteredChores,
quickFilteredChores,
customFilteredChores,
tempFilter,
activeFilterId,
searchFilter,
hasQuickFilters,
selectedChoreSection,
selectedChoreFilter,
selectedProject,
@@ -398,7 +479,7 @@ const MyChores = () => {
}
// Handle legacy filter parameter (e.g., filter=unplanned)
if (oldFilter && searchFilter === 'All' && !activeFilterId) {
if (oldFilter && !hasQuickFilters && !activeFilterId) {
const filterMap = {
unplanned: 'No Due Date',
overdue: 'Overdue',
@@ -409,12 +490,8 @@ const MyChores = () => {
}
const filterName = filterMap[oldFilter.toLowerCase()]
if (filterName && FILTERS[filterName]) {
const filtered = FILTERS[filterName](
selectedProject ? projectFilteredChores : chores,
)
setFilteredChores(filtered)
setSearchFilter(filterName)
if (filterName) {
setQuickFilter('status', filterName)
setViewMode('default')
setSelectedCalendarDate(null)
}
@@ -422,7 +499,7 @@ const MyChores = () => {
}, [
searchParams,
chores,
searchFilter,
hasQuickFilters,
activeFilterId,
savedFilters,
applyCustomFilter,
@@ -430,8 +507,7 @@ const MyChores = () => {
clearActiveFilter,
selectedProject,
projectFilteredChores,
setSearchFilter,
setFilteredChores,
setQuickFilter,
setViewMode,
setSelectedCalendarDate,
])
@@ -496,13 +572,47 @@ const MyChores = () => {
clearSelection,
})
const getFilteredChores = useMemo(() => {
if (activeFilterId || tempFilter) {
return customFilteredChores
}
const baseChores = hasQuickFilters
? quickFilteredChores
: projectFilteredChores
if (searchTerm?.length > 0) {
const searchableChores = baseChores.map(c => ({
...c,
raw_label: c.labelsV2?.map(l => l.name).join(' '),
}))
const fuse = new Fuse(searchableChores, {
keys: ['name', 'raw_label'],
includeScore: true,
isCaseSensitive: false,
findAllMatches: true,
})
return fuse.search(searchTerm).map(result => result.item)
}
return baseChores
}, [
activeFilterId,
tempFilter,
customFilteredChores,
hasQuickFilters,
quickFilteredChores,
projectFilteredChores,
searchTerm,
])
const { showKeyboardShortcuts } = useKeyboardShortcuts({
isMultiSelectMode,
selectedChores,
addTaskModalOpen,
searchTerm,
searchFilter,
filteredChores,
searchFilter: hasQuickFilters || searchTerm?.length > 0 ? 'filtered' : 'All',
filteredChores: getFilteredChores,
choreSections,
openChoreSections,
handlers: {
@@ -553,25 +663,10 @@ const MyChores = () => {
const handleLabelFiltering = chipClicked => {
clearActiveFilter()
const baseChores = selectedProject ? projectFilteredChores : chores
if (chipClicked.label) {
const label = chipClicked.label
const labelFiltered = baseChores.filter(chore =>
chore.labelsV2.some(
l => l.id === label.id && l.created_by === label.created_by,
),
)
setFilteredChores(labelFiltered)
setSearchFilter('Label: ' + label.name)
setQuickFilter('label', [chipClicked.label.id])
} else if (chipClicked.priority) {
const priority = chipClicked.priority
const priorityFiltered = baseChores.filter(
chore => chore.priority === priority,
)
setFilteredChores(priorityFiltered)
setSearchFilter('Priority: ' + priority)
setQuickFilter('priority', [chipClicked.priority])
}
setSelectedCalendarDate(null)
}
@@ -623,8 +718,8 @@ const MyChores = () => {
const handleSearchChange = e => {
clearActiveFilter()
if (searchFilter !== 'All') {
setSearchFilter('All')
if (hasQuickFilters) {
clearQuickFilters()
}
const search = e.target.value
if (search === '') {
@@ -672,14 +767,13 @@ const MyChores = () => {
localStorage.setItem('openChoreSections', JSON.stringify(value))
}
const toggleViewMode = () => {
const modes = ['default', 'compact', 'calendar']
const currentIndex = modes.indexOf(viewMode)
const nextIndex = (currentIndex + 1) % modes.length
const newMode = modes[nextIndex]
const toggleViewMode = value => {
const newMode = value ?? (() => {
const modes = ['default', 'compact', 'calendar']
return modes[(modes.indexOf(viewMode) + 1) % modes.length]
})()
setViewMode(newMode)
localStorage.setItem('choreCardViewMode', newMode)
if (newMode !== 'calendar') {
setSelectedCalendarDate(null)
}
@@ -740,42 +834,6 @@ const MyChores = () => {
// )
// }
const getFilteredChores = useMemo(() => {
if (activeFilterId || tempFilter) {
return customFilteredChores
}
let baseChores = projectFilteredChores
if (searchTerm?.length > 0 || searchFilter !== 'All') {
if (searchTerm?.length > 0) {
const projectFilteredForSearch = baseChores.map(c => ({
...c,
raw_label: c.labelsV2?.map(l => l.name).join(' '),
}))
const fuse = new Fuse(projectFilteredForSearch, {
keys: ['name', 'raw_label'],
includeScore: true,
isCaseSensitive: false,
findAllMatches: true,
})
return fuse.search(searchTerm).map(result => result.item)
} else if (searchFilter !== 'All') {
return filteredChores
}
}
return baseChores
}, [
activeFilterId,
tempFilter,
customFilteredChores,
projectFilteredChores,
searchTerm,
searchFilter,
filteredChores,
])
const getChoresForDate = useCallback(
date => {
const filteredChoresData = getFilteredChores
@@ -800,7 +858,7 @@ const MyChores = () => {
setChores(newChores)
setFilteredChores(newChores)
setSearchFilter('All')
clearQuickFilters()
}
// Show error state when API is unreachable
@@ -875,322 +933,96 @@ const MyChores = () => {
tempFilter={tempFilter}
tempFilterMeta={tempFilterMeta}
/>
<Box
sx={{
display: 'flex',
justifyContent: 'space-between',
alignContent: 'center',
alignItems: 'center',
gap: 0.5,
<ChoreToolbar
members={membersData?.res || []}
labels={userLabels || []}
projects={projectsWithDefault}
tempFilter={tempFilter}
tempFilterMeta={tempFilterMeta}
applyTempFilter={applyTempFilter}
clearTempFilter={clearTempFilter}
saveFilter={saveFilter}
updateFilter={updateFilter}
onFilterSaved={name =>
showSuccess({
title: 'Filter Saved',
message: `"${name}" has been saved`,
})
}
onClearAllFilters={() => {
clearQuickFilters()
clearActiveFilter()
setSelectedChoreFilterWithCache('anyone')
setSelectedProjectWithCache(
projectsWithDefault.find(p => p.id === 'default') || null,
)
updateFilterUrl(null, null)
}}
>
<SearchBar
value={searchTerm}
onChange={handleSearchChange}
onClose={handleSearchClose}
onFocus={() => setShowSearchFilter(true)}
showKeyboardShortcuts={showKeyboardShortcuts}
inputRef={searchInputRef}
/>
<SortAndGrouping
title='Group by'
k={'icon-menu-group-by'}
icon={<Sort />}
selectedItem={selectedChoreSection}
selectedFilter={selectedChoreFilter}
setFilter={filter => {
setSelectedChoreFilterWithCache(filter)
// Clear active custom filter when quick filter is applied
if (activeFilterId) {
clearActiveFilter()
updateFilterUrl(null, null)
}
}}
onItemSelect={selected => {
setSelectedChoreSectionWithCache(selected.value)
setFilteredChores(chores)
setSearchFilter('All')
}}
onCreateNewFilter={() => {
setShowAdvancedFilterBuilder(true)
setEditingFilter(null)
}}
mouseClickHandler={handleMenuOutsideClick}
/>
{/* Project Selector - Hidden when active filter has project conditions */}
{projectsWithDefault.length > 1 &&
!hasProjectConditions &&
!hasFilterApplied && (
<ProjectSelector
selectedProject={selectedProject?.name || 'Default Project'}
onProjectSelect={project => {
setSelectedProjectWithCache(project)
clearActiveFilter()
}}
showKeyboardShortcuts={showKeyboardShortcuts}
/>
)}
{/* 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'
: viewMode === 'compact'
? 'Switch to Calendar View'
: 'Switch to Card View'
resultCount={
hasQuickFilters || hasFilterApplied
? getFilteredChores.length
: undefined
}
totalCount={
hasQuickFilters || hasFilterApplied
? projectFilteredChores.length
: undefined
}
selectedProject={selectedProject}
onProjectSelect={project => {
setSelectedProjectWithCache(project)
clearActiveFilter()
}}
selectedAssigneeFilter={selectedChoreFilter}
onAssigneeFilterChange={filter => {
setSelectedChoreFilterWithCache(filter)
if (activeFilterId) {
clearActiveFilter()
updateFilterUrl(null, null)
}
>
{viewMode === 'default' ? (
<ViewAgenda />
) : viewMode === 'compact' ? (
<CalendarMonth />
) : (
<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>
{/* Search Filter with animation */}
<Box
sx={{
overflow: 'hidden',
transition: 'all 0.3s ease-in-out',
maxHeight: showSearchFilter ? '150px' : '0',
opacity: showSearchFilter ? 1 : 0,
transform: showSearchFilter ? 'translateY(0)' : 'translateY(-10px)',
marginBottom: showSearchFilter ? 1 : 0,
}}
>
<div className='flex gap-4'>
<div className='grid flex-1 grid-cols-3 gap-4'>
<IconButtonWithMenu
label={' Priority'}
k={'icon-menu-priority-filter'}
icon={<PriorityHigh />}
options={Priorities}
selectedItem={searchFilter}
onItemSelect={selected => {
handleLabelFiltering({ priority: selected.value })
}}
mouseClickHandler={handleMenuOutsideClick}
isActive={searchFilter.startsWith('Priority: ')}
/>
<IconButtonWithMenu
k={'icon-menu-labels-filter'}
label={' Labels'}
icon={<Style />}
options={userLabels}
selectedItem={searchFilter}
onItemSelect={selected => {
handleLabelFiltering({ label: selected })
}}
isActive={searchFilter.startsWith('Label: ')}
mouseClickHandler={handleMenuOutsideClick}
useChips
/>
<Button
onClick={handleFilterMenuOpen}
variant='outlined'
startDecorator={<Grain />}
color={
searchFilter && FILTERS[searchFilter] && searchFilter != 'All'
? 'primary'
: 'neutral'
}
size='sm'
sx={{
height: 24,
borderRadius: 24,
}}
>
{' Other'}
</Button>
<List
orientation='horizontal'
wrap
sx={{
mt: 0.2,
}}
>
<Menu
ref={menuRef}
anchorEl={anchorEl}
open={Boolean(anchorEl)}
onClose={handleFilterMenuClose}
>
{Object.keys(FILTERS).map((filter, index) => (
<MenuItem
key={`filter-list-${filter}-${index}`}
onClick={() => {
const filterFunction = FILTERS[filter]
const baseChores = selectedProject
? projectFilteredChores
: chores
const filteredChores =
filterFunction.length === 2
? filterFunction(baseChores, userProfile?.id)
: filterFunction(baseChores)
setFilteredChores(filteredChores)
setSearchFilter(filter)
handleFilterMenuClose()
// Update URL with legacy filter parameter
const filterMap = {
'No Due Date': 'unplanned',
Overdue: 'overdue',
'Due today': 'today',
'Due in week': 'week',
'Due Later': 'later',
'Pending Approval': 'pending',
}
const urlFilter = filterMap[filter]
if (urlFilter) {
updateFilterUrl('filter', urlFilter)
}
}}
>
{filter}
<Chip
color={searchFilter === filter ? 'primary' : 'neutral'}
>
{(() => {
const baseChores = selectedProject
? projectFilteredChores
: chores
return FILTERS[filter].length === 2
? FILTERS[filter](baseChores, userProfile?.id)
.length
: FILTERS[filter](baseChores).length
})()}
</Chip>
</MenuItem>
))}
{searchFilter.startsWith('Label: ') ||
(searchFilter.startsWith('Priority: ') && (
<MenuItem
key={`filter-list-cancel-all-filters`}
onClick={() => {
setFilteredChores(
selectedProject ? projectFilteredChores : chores,
)
setSearchFilter('All')
updateFilterUrl(null, null)
}}
>
Cancel All Filters
</MenuItem>
))}
</Menu>
</List>
</div>
<IconButton
variant='outlined'
color='neutral'
size='sm'
sx={{
height: 24,
borderRadius: 24,
}}
onClick={() => {
setShowSearchFilter(false)
setSearchTerm('')
setFilteredChores(chores)
setSearchFilter('All')
updateFilterUrl(null, null)
}}
>
<CancelRounded />
</IconButton>
</div>
</Box>
{/* Custom Filters Section */}
<FilterSection
savedFilters={savedFilters}
activeFilterId={activeFilterId}
activeFilter={activeFilter}
hasProjectConditions={hasProjectConditions}
onFilterClick={filterId => {
onSavedFilterClick={filterId => {
if (activeFilterId === filterId) {
clearActiveFilter()
updateFilterUrl(null, null)
} else {
setSearchFilter('All')
clearQuickFilters()
setSearchTerm('')
setFilteredChores([])
// Reset quick filter to 'anyone' when custom filter is applied
if (selectedChoreFilter !== 'anyone') {
setSelectedChoreFilterWithCache('anyone')
}
// Clear project selection if the filter has project conditions
const filter = savedFilters.find(f => f.id === filterId)
if (filter?.conditions?.some(c => c.type === 'project')) {
setSelectedProjectWithCache(null)
}
applyCustomFilter(filterId)
updateFilterUrl('filterId', filterId)
}
}}
onFilterDelete={deleteFilter}
onFilterPin={pinFilter}
onFilterEdit={filter => {
onSavedFilterEdit={filter => {
setEditingFilter(filter)
setShowAdvancedFilterBuilder(true)
}}
onClearActiveFilter={clearActiveFilter}
onCreateAdvancedFilter={() => setShowAdvancedFilterBuilder(true)}
updateFilterUrl={updateFilterUrl}
onSavedFilterDelete={deleteFilter}
onSavedFilterPin={pinFilter}
selectedGroupBy={selectedChoreSection}
onGroupBySelect={value => {
setSelectedChoreSectionWithCache(value)
setFilteredChores(chores)
clearQuickFilters()
}}
viewMode={viewMode}
onToggleViewMode={toggleViewMode}
isMultiSelectMode={isMultiSelectMode}
onToggleMultiSelect={toggleMultiSelectMode}
searchTerm={searchTerm}
onSearchChange={handleSearchChange}
onSearchClose={handleSearchClose}
searchInputRef={searchInputRef}
showKeyboardShortcuts={showKeyboardShortcuts}
/>
<MultiSelectToolbar
@@ -1204,41 +1036,15 @@ const MyChores = () => {
onDelete={handleBulkDelete}
showKeyboardShortcuts={showKeyboardShortcuts}
selectAllDisabled={
searchTerm?.length > 0 || searchFilter !== 'All'
? selectedChores.size === filteredChores.length
searchTerm?.length > 0 || hasQuickFilters
? selectedChores.size === getFilteredChores.length
: selectedChores.size ===
choreSections.flatMap(s => s.content || []).length
}
/>
{/* Additional Filters Display */}
{searchFilter !== 'All' && (
<Chip
level='title-md'
gutterBottom
color='warning'
label={searchFilter}
onDelete={() => {
setFilteredChores(
selectedProject ? projectFilteredChores : chores,
)
setSearchFilter('All')
updateFilterUrl(null, null)
}}
endDecorator={<CancelRounded />}
onClick={() => {
setFilteredChores(
selectedProject ? projectFilteredChores : chores,
)
setSearchFilter('All')
updateFilterUrl(null, null)
}}
>
Additional Filter: {searchFilter}
</Chip>
)}
{/* Show "Nothing scheduled" when appropriate based on current view mode */}
{(searchTerm?.length > 0 || searchFilter !== 'All' || activeFilterId
{(searchTerm?.length > 0 || hasQuickFilters || activeFilterId
? getFilteredChores.length === 0
: projectFilteredChores.length === 0) &&
// only if not in calendar view:
@@ -1266,10 +1072,9 @@ const MyChores = () => {
<>
<Button
onClick={() => {
setSearchFilter('All')
clearQuickFilters()
setSearchTerm('')
clearActiveFilter()
// reset project and filters :
setSelectedProjectWithCache(null)
updateFilterUrl(null, null)
}}
@@ -1604,6 +1409,7 @@ const MyChores = () => {
/>
</IconButton>
<IconButton
data-testid='open-add-task-modal'
color='primary'
variant='soft'
sx={{
@@ -1719,59 +1525,4 @@ const MyChores = () => {
)
}
const FILTERS = {
All: function (chores) {
return chores
},
Overdue: function (chores) {
return chores.filter(chore => {
if (chore.nextDueDate === null) return false
return new Date(chore.nextDueDate) < new Date()
})
},
'Due today': function (chores) {
return chores.filter(chore => {
return (
new Date(chore.nextDueDate).toDateString() === new Date().toDateString()
)
})
},
'Due in week': function (chores) {
return chores.filter(chore => {
return (
new Date(chore.nextDueDate) <
new Date(Date.now() + 7 * 24 * 60 * 60 * 1000) &&
new Date(chore.nextDueDate) > new Date()
)
})
},
'Due Later': function (chores) {
return chores.filter(chore => {
return (
new Date(chore.nextDueDate) > new Date(Date.now() + 24 * 60 * 60 * 1000)
)
})
},
'Created By Me': function (chores, userID) {
return chores.filter(chore => {
return chore.createdBy === userID
})
},
'Assigned To Me': function (chores, userID) {
return chores.filter(chore => {
return chore.assignedTo === userID
})
},
'No Due Date': function (chores) {
return chores.filter(chore => {
return chore.nextDueDate === null
})
},
'Pending Approval': function (chores) {
return chores.filter(chore => {
return chore.status === 3
})
},
}
export default MyChores

View File

@@ -1,9 +1,15 @@
import { Capacitor } from '@capacitor/core'
import DateModal from '../../Modals/Inputs/DateModal'
import NudgeModal from '../../Modals/Inputs/NudgeModal'
import SelectModal from '../../Modals/Inputs/SelectModal'
import TextModal from '../../Modals/Inputs/TextModal'
import WriteNFCModal from '../../Modals/Inputs/WriteNFCModal'
const getNFCUrl = choreId =>
Capacitor.getPlatform() === 'android'
? `donetick://chores/${choreId}`
: `${window.location.origin}/chores/${choreId}`
const ChoreModals = ({
activeModal,
modalChore,
@@ -65,12 +71,13 @@ const ChoreModals = ({
<WriteNFCModal
config={{
isOpen: true,
url: `${window.location.origin}/chores/${modalChore.id}`,
url: getNFCUrl(modalChore.id),
onClose: onClose,
}}
/>
)}
{activeModal === 'nudge' && modalChore && (
<NudgeModal
config={{

View File

@@ -0,0 +1,920 @@
/**
* PROTOTYPE Unified Chore Toolbar
*
* Proposed design to replace the current 3-surface layout:
* OLD: [Search] [Sort+Group+AssigneeFilter+CreateFilter] [ProjectSelector] [View] [Multiselect]
* + FilterBar (Due Date / Priority / Labels chips row)
* + FilterSection (saved/pinned filter chips row)
*
* NEW: [Search] [Filter(n)] [Group ▾] [View] [Multiselect]
* + active filter chips appear inline next to Filter button
* + Filter button opens ONE unified bottom sheet containing:
* Assignee · Created By · Status · Priority · Due Date · Labels · Projects · Points
* + Saved Filters section
*
* How to try it: in MyChores.jsx, replace the <Box sx={{display:'flex'...}}> toolbar block
* and the two rows below it (FilterBar + FilterSection) with:
* <ChoreToolbar ... />
*/
import {
ArrowDropDown,
CalendarMonth,
Check,
CheckBox,
CheckBoxOutlineBlank,
FilterList,
Save,
Sort,
Tune,
ViewAgenda,
ViewComfy,
ViewModule,
} from '@mui/icons-material'
import {
Badge,
Box,
Button,
ButtonGroup,
Chip,
Divider,
IconButton,
Input,
Menu,
MenuItem,
Typography,
} from '@mui/joy'
import { useEffect, useRef, useState } from 'react'
import BottomSheetModal from '../../../components/common/BottomSheetModal'
import ActiveFilterChips from '../../../components/common/filter/ActiveFilterChips'
import { Z_INDEX } from '../../../constants/zIndex'
import KeyboardShortcutHint from '../../../components/common/KeyboardShortcutHint'
import { FILTER_COLORS } from '../../../utils/Colors'
import Priorities from '../../../utils/Priorities'
import FilterBuilderContent, {
CHORE_STATUSES,
DUE_DATE_OPTIONS,
POINTS_OPERATORS,
conditionsToSelections,
defaultSelections,
selectionsToConditions,
} from './FilterBuilderContent'
import SearchBar from './SearchBar'
import ProjectSelector from '../../components/ProjectSelector'
// ─── sub-components for the Display sheet ────────────────────────────────────
const SectionHeader = ({ icon, label, badge }) => (
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 1.5 }}>
{icon && (
<Box
sx={{
color: 'text.secondary',
display: 'flex',
alignItems: 'center',
'& svg': { fontSize: 18 },
}}
>
{icon}
</Box>
)}
<Typography level='title-sm' fontWeight={600}>
{label}
</Typography>
{badge != null && (
<Chip
size='sm'
variant='solid'
color='primary'
sx={{ ml: 'auto', fontSize: '0.7rem', height: 20 }}
>
{badge}
</Chip>
)}
</Box>
)
const OptionChips = ({ options, selected, multi, onToggle }) => (
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 1 }}>
{options.map(opt => {
const isSelected = multi
? (selected || []).includes(opt.value)
: selected === opt.value
return (
<Chip
key={opt.value}
variant={isSelected ? 'solid' : 'soft'}
color={isSelected ? opt.color ?? 'primary' : 'neutral'}
startDecorator={
opt.icon != null
? isSelected
? <Check sx={{ fontSize: 14 }} />
: opt.icon
: undefined
}
onClick={() => onToggle(opt.value)}
sx={{
py: 0.64,
cursor: 'pointer',
transition: 'all 0.15s ease',
userSelect: 'none',
'&:hover': { opacity: 0.85 },
}}
>
{opt.label}
</Chip>
)
})}
</Box>
)
// ─── main component ───────────────────────────────────────────────────────────
/**
* Props:
* -- Advanced filter (filter sheet) --
* members circle members for Assignee / Created By sections
* labels user labels for Labels section
* projects projects list (projectsWithDefault) for Projects section + Display sheet
* tempFilter current temp filter object { conditions, operator } or null
* tempFilterMeta metadata for temp filter, including saved-filter edit source when applicable
* applyTempFilter (filter) => void — called immediately as selections change
* clearTempFilter () => void
* saveFilter (filterData) => Promise — saves as a named filter
* updateFilter (filterId, filterData) => Promise — updates an existing saved filter
* onFilterSaved (name) => void — called after successful save (for notifications)
*
* -- Result counts --
* resultCount / totalCount
*
* -- Clear all --
* onClearAllFilters () => void
*
* -- Saved filters --
* savedFilters [{ id, name, color, count, isPinned }]
* activeFilterId number | null
* onSavedFilterClick (id) => void
* onSavedFilterEdit (filter) => void
* onSavedFilterDelete (id) => void
* onSavedFilterPin (id) => void
*
* -- Display sheet --
* selectedProject current project object (for Display sheet section)
* onProjectSelect (project) => void
* selectedAssigneeFilter 'anyone' | 'assigned_to_me' | 'available_for_me' | 'assigned_to_others'
* onAssigneeFilterChange (key) => void
* selectedGroupBy 'default' | 'due_date' | 'priority' | 'labels'
* onGroupBySelect (value) => void
* viewMode 'default' | 'compact' | 'calendar'
* onToggleViewMode (value?) => void
*
* -- Multi-select --
* isMultiSelectMode bool
* onToggleMultiSelect () => void
*
* -- Search --
* searchTerm / onSearchChange / onSearchClose / searchInputRef
* showKeyboardShortcuts
*/
const ChoreToolbar = ({
// advanced filter
members = [],
labels = [],
projects = [],
tempFilter,
tempFilterMeta,
applyTempFilter,
clearTempFilter,
saveFilter,
updateFilter,
onFilterSaved,
// result counts
resultCount,
totalCount,
// clear all
onClearAllFilters,
// project (for Display sheet)
selectedProject,
onProjectSelect,
// assignee (for Display sheet)
selectedAssigneeFilter = 'anyone',
onAssigneeFilterChange,
// saved / custom
savedFilters = [],
activeFilterId,
onSavedFilterClick,
onSavedFilterEdit,
onSavedFilterDelete,
onSavedFilterPin,
// grouping
selectedGroupBy = 'default',
onGroupBySelect,
// view + multiselect
viewMode = 'default',
onToggleViewMode,
isMultiSelectMode,
onToggleMultiSelect,
// search
searchTerm,
onSearchChange,
onSearchClose,
searchInputRef,
showKeyboardShortcuts,
}) => {
const [filterSheetOpen, setFilterSheetOpen] = useState(false)
const [displaySheetOpen, setDisplaySheetOpen] = useState(false)
const [localSelections, setLocalSelections] = useState(defaultSelections())
const [savingFilter, setSavingFilter] = useState(false)
const [saveFilterName, setSaveFilterName] = useState('')
const [saveMenuAnchorEl, setSaveMenuAnchorEl] = useState(null)
const [editingSavedFilter, setEditingSavedFilter] = useState(null)
const saveMenuRef = useRef(null)
const activeConditions = selectionsToConditions(localSelections)
// ── badge counts ─────────────────────────────────────────────────────────────
const tempConditionCount = tempFilter?.conditions?.length || 0
const savedFilterActive = activeFilterId != null ? 1 : 0
const totalActiveCount = tempConditionCount + savedFilterActive
const hasAnyActive = totalActiveCount > 0
// ── inline chip strip ────────────────────────────────────────────────────────
const inlineChips = []
const getConditionChipLabel = condition => {
if (!condition?.type) return 'Filter'
const typeLabels = {
assignee: 'Assignee',
createdBy: 'Created By',
status: 'Status',
priority: 'Priority',
label: 'Labels',
project: 'Project',
dueDate: 'Due Date',
points: 'Points',
}
const typeLabel = typeLabels[condition.type] || 'Filter'
const prefix = condition.operator === 'isNot' ? 'Not ' : ''
if (condition.type === 'dueDate') {
const dueDateLabel =
DUE_DATE_OPTIONS.find(o => o.value === condition.operator)?.label ||
'Custom'
return `${typeLabel}: ${dueDateLabel}`
}
if (condition.type === 'points') {
const pointsOp =
POINTS_OPERATORS.find(o => o.value === condition.operator)?.label ||
condition.operator ||
''
return `${typeLabel} ${pointsOp} ${condition.value ?? 0}`
}
const rawValues = Array.isArray(condition.value)
? condition.value
: condition.value != null
? [condition.value]
: []
const resolveLabel = value => {
if (condition.type === 'assignee' || condition.type === 'createdBy') {
const member = members.find(m => m.userId === value)
return member?.displayName || member?.username || String(value)
}
if (condition.type === 'status') {
return CHORE_STATUSES.find(s => s.value === value)?.label || String(value)
}
if (condition.type === 'priority') {
return Priorities.find(p => p.value === value)?.name || String(value)
}
if (condition.type === 'label') {
return labels.find(l => l.id === value)?.name || String(value)
}
if (condition.type === 'project') {
if (value === 'default') return 'Default Project'
return projects.find(p => p.id === value)?.name || String(value)
}
return String(value)
}
if (rawValues.length === 0) {
return `${prefix}${typeLabel}`
}
if (rawValues.length === 1) {
return `${prefix}${typeLabel}: ${resolveLabel(rawValues[0])}`
}
return `${prefix}${typeLabel} (${rawValues.length})`
}
const clearConditionAtIndex = index => {
const nextConditions = (tempFilter?.conditions || []).filter(
(_condition, conditionIndex) => conditionIndex !== index,
)
if (nextConditions.length === 0) {
setLocalSelections(defaultSelections())
clearTempFilter?.()
return
}
const nextFilter = {
...tempFilter,
operator: tempFilter?.operator || 'AND',
conditions: nextConditions,
}
setLocalSelections(conditionsToSelections(nextConditions))
applyTempFilter?.(nextFilter)
}
const activeSavedFilter = savedFilterActive
? savedFilters.find(f => f.id === activeFilterId)
: null
const activeChipConditions = savedFilterActive
? activeSavedFilter?.conditions || []
: tempFilter?.conditions || []
activeChipConditions.forEach((condition, index) => {
inlineChips.push({
key: `${savedFilterActive ? '__saved' : '__temp'}_${index}`,
label: getConditionChipLabel(condition),
onClear: () => {
if (savedFilterActive) {
onSavedFilterClick?.(activeFilterId)
return
}
clearConditionAtIndex(index)
},
})
})
// ── open filter sheet ────────────────────────────────────────────────────────
const openFilterSheet = () => {
if (tempFilter?.conditions?.length > 0) {
setLocalSelections(conditionsToSelections(tempFilter.conditions))
if (tempFilterMeta?.sourceFilterId) {
const sourceFilter =
savedFilters.find(f => f.id === tempFilterMeta.sourceFilterId) ||
null
setEditingSavedFilter(
sourceFilter ||
(tempFilterMeta.sourceFilterId
? {
id: tempFilterMeta.sourceFilterId,
name: tempFilterMeta.sourceFilterName,
description: tempFilterMeta.sourceFilterDescription,
color: tempFilterMeta.sourceFilterColor,
}
: null),
)
} else {
setEditingSavedFilter(null)
}
} else if (activeFilterId) {
const sf = savedFilters.find(f => f.id === activeFilterId)
setEditingSavedFilter(sf || null)
setLocalSelections(
sf?.conditions
? conditionsToSelections(sf.conditions)
: defaultSelections(),
)
} else {
setEditingSavedFilter(null)
setLocalSelections(defaultSelections())
}
setSavingFilter(false)
setSaveFilterName('')
setSaveMenuAnchorEl(null)
setFilterSheetOpen(true)
}
useEffect(() => {
if (!filterSheetOpen || savingFilter || activeConditions.length === 0) {
setSaveMenuAnchorEl(null)
}
}, [filterSheetOpen, savingFilter, activeConditions.length])
// ── selection changes → apply temp filter immediately ────────────────────────
const handleSelectionsChange = updater => {
setLocalSelections(prev => {
const next = typeof updater === 'function' ? updater(prev) : updater
const conditions = selectionsToConditions(next)
if (conditions.length > 0) {
applyTempFilter?.(
{ conditions, operator: 'AND' },
editingSavedFilter
? {
name: editingSavedFilter.name,
description: editingSavedFilter.description,
sourceFilterId: editingSavedFilter.id,
sourceFilterName: editingSavedFilter.name,
sourceFilterDescription: editingSavedFilter.description,
sourceFilterColor: editingSavedFilter.color,
isEditingSavedFilter: true,
}
: null,
)
} else {
clearTempFilter?.()
}
return next
})
}
// ── save filter ───────────────────────────────────────────────────────────────
const handleSaveFilter = () => {
const name = saveFilterName.trim()
if (!name) return
const conditions = selectionsToConditions(localSelections)
if (conditions.length === 0) return
const usedColors = savedFilters.map(f => f.color)
const color =
FILTER_COLORS.find(c => !usedColors.includes(c.value))?.value ??
FILTER_COLORS[0].value
saveFilter?.({ name, description: '', color, conditions, operator: 'AND' })?.then?.(() => {
applyTempFilter?.({ conditions, operator: 'AND' }, { name })
onFilterSaved?.(name)
})
setSavingFilter(false)
setSaveFilterName('')
setFilterSheetOpen(false)
}
const handleUpdateFilter = () => {
if (!editingSavedFilter?.id || !updateFilter) return
const conditions = selectionsToConditions(localSelections)
if (conditions.length === 0) return
updateFilter(
editingSavedFilter.id,
{
name: editingSavedFilter.name,
description: editingSavedFilter.description || '',
color: editingSavedFilter.color,
conditions,
operator: 'AND',
},
)?.then?.(() => {
clearTempFilter?.()
onSavedFilterClick?.(editingSavedFilter.id)
onFilterSaved?.(editingSavedFilter.name)
})
setSaveMenuAnchorEl(null)
setFilterSheetOpen(false)
}
// ── display sheet helpers ────────────────────────────────────────────────────
const filterActive = activeFilterId != null || tempConditionCount > 0
const projectActive = selectedProject && selectedProject.id !== 'default' ? 1 : 0
const assigneeActive = selectedAssigneeFilter !== 'anyone' ? 1 : 0
const displayActive =
selectedGroupBy !== 'default' ||
viewMode !== 'default' ||
projectActive > 0 ||
assigneeActive > 0
const groupByOptions = [
{ value: 'default', label: 'Smart' },
{ value: 'due_date', label: 'Due Date' },
{ value: 'priority', label: 'Priority' },
{ value: 'labels', label: 'Labels' },
]
const assigneeOptions = [
{ value: 'anyone', label: 'Everyone' },
{ value: 'assigned_to_me', label: 'Mine' },
{ value: 'available_for_me', label: 'Available to me' },
{ value: 'assigned_to_others', label: 'Others' },
]
const viewOptions = [
{ value: 'default', label: 'Cards', icon: <ViewAgenda sx={{ fontSize: 16 }} /> },
{ value: 'compact', label: 'Compact', icon: <ViewComfy sx={{ fontSize: 16 }} /> },
{ value: 'calendar', label: 'Calendar', icon: <CalendarMonth sx={{ fontSize: 16 }} /> },
]
return (
<>
{/* ── Row 1: main toolbar ─────────────────────────────────────────────── */}
<Box
sx={{
display: 'flex',
alignItems: 'center',
gap: 0.5,
justifyContent: 'space-between',
}}
>
{/* Search takes available space */}
<SearchBar
value={searchTerm}
onChange={onSearchChange}
onClose={onSearchClose}
showKeyboardShortcuts={showKeyboardShortcuts}
inputRef={searchInputRef}
/>
{/* Filter button */}
<Badge
badgeContent={totalActiveCount || null}
color='primary'
size='sm'
anchorOrigin={{ vertical: 'top', horizontal: 'right' }}
>
<IconButton
variant={hasAnyActive ? 'solid' : 'outlined'}
color={hasAnyActive ? 'primary' : 'neutral'}
size='sm'
sx={{ height: 32, width: 32, borderRadius: '50%' }}
onClick={openFilterSheet}
title='Filters'
>
<FilterList />
</IconButton>
</Badge>
{/* Project selector */}
{!filterActive && projects.filter(p => p.id !== 'default').length > 0 && (
<ProjectSelector
selectedProject={selectedProject?.name || 'Default Project'}
onProjectSelect={onProjectSelect}
showKeyboardShortcuts={showKeyboardShortcuts}
/>
)}
{/* Display button — View + Group combined */}
<IconButton
variant={displayActive ? 'solid' : 'outlined'}
color={displayActive ? 'primary' : 'neutral'}
size='sm'
sx={{ height: 32, width: 32, borderRadius: '50%' }}
onClick={() => setDisplaySheetOpen(true)}
title='View & Group'
>
{viewMode === 'calendar' ? (
<CalendarMonth />
) : viewMode === 'compact' ? (
<ViewModule />
) : (
<ViewAgenda />
)}
</IconButton>
{/* Multiselect */}
<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={onToggleMultiSelect}
title={
isMultiSelectMode
? 'Exit multi-select (Ctrl+S)'
: 'Multi-select (Ctrl+S)'
}
>
{isMultiSelectMode ? <CheckBox /> : <CheckBoxOutlineBlank />}
</IconButton>
<KeyboardShortcutHint
shortcut='S'
show={showKeyboardShortcuts}
sx={{ position: 'absolute', top: -8, right: -8, zIndex: 1000 }}
/>
</Box>
</Box>
{/* ── Row 2: active filter chips ──────────────────────────────────────── */}
{hasAnyActive && (
<ActiveFilterChips
chips={inlineChips}
onOpen={openFilterSheet}
onClearAll={() => {
setLocalSelections(defaultSelections())
onClearAllFilters?.()
}}
resultCount={resultCount}
totalCount={totalCount}
maxVisible={2}
chipSize='md'
clearButtonSize='sm'
clearButtonSx={{ color: 'text.secondary' }}
/>
)}
{/* ── Unified Filter bottom sheet ─────────────────────────────────────── */}
<BottomSheetModal
open={filterSheetOpen}
onClose={() => {
setSaveMenuAnchorEl(null)
setFilterSheetOpen(false)
}}
maxHeight='92vh'
title={
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<Tune sx={{ fontSize: 20 }} />
Filters
{hasAnyActive && (
<Chip size='sm' variant='solid' color='primary' sx={{ ml: 0.5 }}>
{totalActiveCount}
</Chip>
)}
</Box>
}
footer={
savingFilter ? (
<Box
sx={{ display: 'flex', gap: 1, width: '100%', alignItems: 'center' }}
>
<Input
size='sm'
placeholder='Filter name…'
value={saveFilterName}
onChange={e => setSaveFilterName(e.target.value)}
onKeyDown={e => e.key === 'Enter' && handleSaveFilter()}
autoFocus
sx={{ flex: 1 }}
/>
<Button
size='sm'
onClick={handleSaveFilter}
disabled={!saveFilterName.trim()}
>
Save
</Button>
<Button
size='sm'
variant='plain'
color='neutral'
onClick={() => setSavingFilter(false)}
>
Cancel
</Button>
</Box>
) : (
<Box
sx={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
gap: 1,
}}
>
<Button
variant='plain'
color='danger'
size='sm'
disabled={!hasAnyActive && activeConditions.length === 0}
onClick={() => {
setLocalSelections(defaultSelections())
setSaveMenuAnchorEl(null)
onClearAllFilters?.()
setFilterSheetOpen(false)
}}
>
Clear all
</Button>
{activeConditions.length > 0 ? (
<>
<ButtonGroup variant='solid' color='primary'>
<Button
onClick={() => {
setSaveMenuAnchorEl(null)
setFilterSheetOpen(false)
}}
sx={{ minWidth: 140 }}
>
{resultCount != null
? `Show ${resultCount}`
: 'Done'}
</Button>
<IconButton
ref={saveMenuRef}
onClick={e => setSaveMenuAnchorEl(e.currentTarget)}
>
<ArrowDropDown />
</IconButton>
</ButtonGroup>
<Menu
anchorEl={saveMenuAnchorEl}
open={Boolean(saveMenuAnchorEl)}
onClose={() => setSaveMenuAnchorEl(null)}
placement='top-end'
sx={{ zIndex: Z_INDEX.MODAL_CONTENT + 10 }}
>
<MenuItem
onClick={handleUpdateFilter}
disabled={!editingSavedFilter}
>
<Save sx={{ fontSize: 16, mr: 1 }} />
Save Filter
</MenuItem>
<MenuItem
onClick={() => {
setSaveMenuAnchorEl(null)
setSaveFilterName(
editingSavedFilter
? `${editingSavedFilter.name} Copy`
: '',
)
setSavingFilter(true)
}}
>
<Save sx={{ fontSize: 16, mr: 1 }} />
Save as New Filter
</MenuItem>
</Menu>
</>
) : (
<Button
variant='solid'
color='primary'
onClick={() => {
setSaveMenuAnchorEl(null)
setFilterSheetOpen(false)
}}
sx={{ minWidth: 140 }}
>
Done
</Button>
)}
</Box>
)
}
>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0 }}>
{/* Full advanced filter content */}
<FilterBuilderContent
selections={localSelections}
onSelectionsChange={handleSelectionsChange}
members={members}
labels={labels}
projects={projects}
/>
{/* Saved filters section */}
{savedFilters.length > 0 && (
<>
<Divider sx={{ my: 2.5 }} />
<Typography level='title-sm' fontWeight={600} sx={{ mb: 1.5 }}>
Saved Filters
</Typography>
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 1 }}>
{savedFilters.map(filter => {
const isActive = activeFilterId === filter.id
return (
<Chip
key={filter.id}
variant={isActive ? 'solid' : 'soft'}
color='neutral'
startDecorator={
isActive ? (
<Check sx={{ fontSize: 14 }} />
) : (
<Chip size='sm' variant='plain' color='neutral'>
{filter.count ?? 0}
</Chip>
)
}
onClick={() => {
onSavedFilterClick?.(filter.id)
if (!isActive) setFilterSheetOpen(false)
}}
sx={{
cursor: 'pointer',
transition: 'all 0.15s ease',
userSelect: 'none',
'&:hover': { opacity: 0.85 },
...(filter.color && !isActive
? { borderColor: filter.color }
: {}),
}}
>
{filter.name}
</Chip>
)
})}
</Box>
</>
)}
</Box>
</BottomSheetModal>
{/* ── Display bottom sheet (View + Group + Assignee + Project) ──────────── */}
<BottomSheetModal
open={displaySheetOpen}
onClose={() => setDisplaySheetOpen(false)}
title={
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<ViewAgenda sx={{ fontSize: 20 }} />
Display
</Box>
}
footer={
<Button onClick={() => setDisplaySheetOpen(false)} sx={{ minWidth: 140 }}>
Done
</Button>
}
>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0 }}>
{/* View section */}
<SectionHeader label='View' />
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 1 }}>
{viewOptions.map(opt => (
<Chip
key={opt.value}
variant={viewMode === opt.value ? 'solid' : 'soft'}
color={viewMode === opt.value ? 'primary' : 'neutral'}
startDecorator={
viewMode === opt.value
? <Check sx={{ fontSize: 14 }} />
: opt.icon
}
onClick={() => onToggleViewMode?.(opt.value)}
sx={{
py: 0.64,
cursor: 'pointer',
transition: 'all 0.15s ease',
userSelect: 'none',
'&:hover': { opacity: 0.85 },
}}
>
{opt.label}
</Chip>
))}
</Box>
<Divider sx={{ my: 2.5 }} />
{/* Group by section */}
<SectionHeader
icon={<Sort />}
label='Group by'
badge={
selectedGroupBy !== 'default'
? groupByOptions.find(o => o.value === selectedGroupBy)?.label
: null
}
/>
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 1 }}>
{groupByOptions.map(opt => (
<Chip
key={opt.value}
variant={selectedGroupBy === opt.value ? 'solid' : 'soft'}
color={selectedGroupBy === opt.value ? 'primary' : 'neutral'}
onClick={() => onGroupBySelect?.(opt.value)}
sx={{
py: 0.64,
cursor: 'pointer',
transition: 'all 0.15s ease',
userSelect: 'none',
'&:hover': { opacity: 0.85 },
}}
>
{opt.label}
</Chip>
))}
</Box>
{/* Show tasks for section */}
<Divider sx={{ my: 2.5 }} />
<SectionHeader
icon={<FilterList />}
label='Show tasks for'
badge={
selectedAssigneeFilter !== 'anyone'
? assigneeOptions.find(o => o.value === selectedAssigneeFilter)?.label
: null
}
/>
<OptionChips
options={assigneeOptions}
selected={selectedAssigneeFilter}
multi={false}
onToggle={v => onAssigneeFilterChange?.(v)}
/>
</Box>
</BottomSheetModal>
</>
)
}
export default ChoreToolbar

View File

@@ -9,11 +9,11 @@ import {
import {
Box,
Chip,
IconButton,
Menu,
MenuItem,
Tooltip,
Typography,
IconButton,
} from '@mui/joy'
import { useState } from 'react'
import { useNavigate } from 'react-router-dom'
@@ -116,6 +116,8 @@ const CustomFilterChips = ({
px: 1.0,
py: 0.5,
height: 32,
display: 'flex',
alignItems: 'center',
opacity: hasWarning ? 0.7 : isActive ? 1 : 0.85,
...(hasCustomColor && {
@@ -185,6 +187,10 @@ const CustomFilterChips = ({
maxWidth: 100,
overflow: 'hidden',
textOverflow: 'ellipsis',
display: 'flex',
alignItems: 'center',
height: '100%',
lineHeight: 1,
...(hasCustomColor && {
color: textColor,
}),

View File

@@ -0,0 +1,477 @@
import {
CalendarMonth,
Check,
FolderOpen,
Label,
Person,
PriorityHigh,
Stars,
TaskAlt,
} from '@mui/icons-material'
import { Avatar, Box, Chip, Divider, Input, Typography } from '@mui/joy'
import Priorities from '../../../utils/Priorities'
export const DUE_DATE_OPTIONS = [
{ value: 'isOverdue', label: 'Overdue', color: 'danger' },
{ value: 'isDueToday', label: 'Today', color: 'warning' },
{ value: 'isDueTomorrow', label: 'Tomorrow', color: 'primary' },
{ value: 'isDueThisWeek', label: 'This Week', color: 'primary' },
{ value: 'isDueThisMonth', label: 'This Month', color: 'neutral' },
{ value: 'hasNoDueDate', label: 'No Due Date', color: 'neutral' },
{ value: 'hasDueDate', label: 'Has Due Date', color: 'neutral' },
]
export const POINTS_OPERATORS = [
{ value: 'greaterThan', label: '>' },
{ value: 'greaterThanOrEqual', label: '>=' },
{ value: 'equals', label: '=' },
{ value: 'lessThanOrEqual', label: '<=' },
{ value: 'lessThan', label: '<' },
]
export const CHORE_STATUSES = [
{ value: 0, label: 'Active' },
{ value: 1, label: 'Started' },
{ value: 2, label: 'In Progress' },
{ value: 3, label: 'Pending Approval' },
]
export const defaultSelections = () => ({
assignee: { operator: 'is', values: [] },
createdBy: { operator: 'is', values: [] },
status: { operator: 'is', values: [] },
priority: { operator: 'is', values: [] },
label: { operator: 'is', values: [] },
project: { operator: 'is', values: [] },
dueDate: { operator: null },
points: { operator: 'greaterThan', value: 0, active: false },
})
export const conditionsToSelections = conditions => {
const sel = defaultSelections()
if (!conditions) return sel
conditions.forEach(c => {
if (c.type === 'dueDate') {
sel.dueDate = { operator: c.operator }
} else if (c.type === 'points') {
sel.points = { operator: c.operator, value: c.value ?? 0, active: true }
} else if (c.type in sel) {
sel[c.type] = {
operator: c.operator ?? 'is',
values: Array.isArray(c.value)
? c.value
: c.value != null
? [c.value]
: [],
}
}
})
return sel
}
export const selectionsToConditions = selections => {
const conditions = []
;['assignee', 'createdBy', 'status', 'priority', 'label', 'project'].forEach(
type => {
if (selections[type].values?.length > 0) {
conditions.push({
type,
operator: selections[type].operator,
value: selections[type].values,
})
}
},
)
if (selections.dueDate.operator) {
conditions.push({
type: 'dueDate',
operator: selections.dueDate.operator,
value: null,
})
}
if (selections.points.active) {
conditions.push({
type: 'points',
operator: selections.points.operator,
value: selections.points.value,
})
}
return conditions
}
const SectionHeader = ({ icon, label, children }) => (
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 1.5 }}>
<Box
sx={{
color: 'text.secondary',
display: 'flex',
alignItems: 'center',
'& svg': { fontSize: 18 },
}}
>
{icon}
</Box>
<Typography level='title-sm' sx={{ fontWeight: 600 }}>
{label}
</Typography>
{children}
</Box>
)
const IncludeExcludeToggle = ({
value,
onChange,
labels = ['Include', 'Exclude'],
}) => (
<Box sx={{ display: 'flex', gap: 0.5, ml: 'auto' }}>
{[
{ op: 'is', label: labels[0] },
{ op: 'isNot', label: labels[1] },
].map(o => (
<Chip
key={o.op}
size='sm'
variant={value === o.op ? 'solid' : 'soft'}
color={
value === o.op ? (o.op === 'isNot' ? 'danger' : 'primary') : 'neutral'
}
onClick={() => onChange(o.op)}
sx={{ cursor: 'pointer', userSelect: 'none', transition: 'all 0.15s ease' }}
>
{o.label}
</Chip>
))}
</Box>
)
/**
* Reusable filter conditions UI used by both the filter sheet in ChoreToolbar
* and the AdvancedFilterBuilder save modal.
*
* `onSelectionsChange` must accept either a new selections object or a
* functional updater `prev => next` (same contract as React's setState setter).
*/
const FilterBuilderContent = ({
selections,
onSelectionsChange,
members = [],
labels = [],
projects = [],
}) => {
const toggleValue = (type, value) =>
onSelectionsChange(prev => {
const cur = prev[type].values || []
const next = cur.includes(value)
? cur.filter(v => v !== value)
: [...cur, value]
return { ...prev, [type]: { ...prev[type], values: next } }
})
const setOperator = (type, op) =>
onSelectionsChange(prev => ({
...prev,
[type]: { ...prev[type], operator: op },
}))
const toggleDueDate = op =>
onSelectionsChange(prev => ({
...prev,
dueDate: { operator: prev.dueDate.operator === op ? null : op },
}))
const setPointsOperator = op =>
onSelectionsChange(prev => ({
...prev,
points: { ...prev.points, operator: op, active: true },
}))
const setPointsValue = val =>
onSelectionsChange(prev => ({
...prev,
points: { ...prev.points, value: val, active: val > 0 },
}))
const chipRow = (type, options, getChipProps) => {
const selected = selections[type].values || []
return (
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 1 }}>
{options.map(opt => {
const isSelected = selected.includes(opt.value)
const extra = getChipProps ? getChipProps(opt, isSelected) : {}
return (
<Chip
key={opt.value}
variant={isSelected ? 'solid' : 'soft'}
color={isSelected ? (extra.color ?? 'primary') : 'neutral'}
startDecorator={
isSelected
? <Check sx={{ fontSize: 14 }} />
: (extra.startDecorator ?? null)
}
onClick={() => toggleValue(type, opt.value)}
sx={{
cursor: 'pointer',
userSelect: 'none',
transition: 'all 0.15s ease',
}}
>
{opt.label}
</Chip>
)
})}
</Box>
)
}
const personChipRow = type => {
const selected = selections[type].values || []
return (
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 1 }}>
{members.map(m => {
const isSelected = selected.includes(m.userId)
return (
<Chip
key={m.userId}
variant={isSelected ? 'solid' : 'soft'}
color={isSelected ? 'primary' : 'neutral'}
startDecorator={
isSelected ? (
<Check sx={{ fontSize: 14 }} />
) : (
<Avatar
src={m.image}
alt={m.displayName}
sx={{ '--Avatar-size': '20px' }}
/>
)
}
onClick={() => toggleValue(type, m.userId)}
sx={{
cursor: 'pointer',
userSelect: 'none',
transition: 'all 0.15s ease',
}}
>
{m.displayName || m.username}
</Chip>
)
})}
</Box>
)
}
return (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0 }}>
{/* Assignee */}
{members.length > 0 && (
<>
<SectionHeader icon={<Person />} label='Assignee'>
<IncludeExcludeToggle
value={selections.assignee.operator}
onChange={op => setOperator('assignee', op)}
/>
</SectionHeader>
{personChipRow('assignee')}
<Divider sx={{ my: 2.5 }} />
</>
)}
{/* Created By */}
{members.length > 0 && (
<>
<SectionHeader icon={<Person />} label='Created By'>
<IncludeExcludeToggle
value={selections.createdBy.operator}
onChange={op => setOperator('createdBy', op)}
/>
</SectionHeader>
{personChipRow('createdBy')}
<Divider sx={{ my: 2.5 }} />
</>
)}
{/* Status */}
<SectionHeader icon={<TaskAlt />} label='Status'>
<IncludeExcludeToggle
value={selections.status.operator}
onChange={op => setOperator('status', op)}
/>
</SectionHeader>
{chipRow('status', CHORE_STATUSES)}
<Divider sx={{ my: 2.5 }} />
{/* Priority */}
<SectionHeader icon={<PriorityHigh />} label='Priority'>
<IncludeExcludeToggle
value={selections.priority.operator}
onChange={op => setOperator('priority', op)}
/>
</SectionHeader>
{chipRow(
'priority',
Priorities.map(p => ({ value: p.value, label: p.name })),
(opt, isSelected) => ({
color: isSelected
? (Priorities.find(p => p.value === opt.value)?.color || 'primary')
: 'neutral',
startDecorator: !isSelected
? Priorities.find(p => p.value === opt.value)?.icon
: null,
}),
)}
<Divider sx={{ my: 2.5 }} />
{/* Due Date */}
<SectionHeader icon={<CalendarMonth />} label='Due Date' />
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 1 }}>
{DUE_DATE_OPTIONS.map(opt => {
const isSelected = selections.dueDate.operator === opt.value
return (
<Chip
key={opt.value}
variant={isSelected ? 'solid' : 'soft'}
color={isSelected ? (opt.color ?? 'primary') : 'neutral'}
startDecorator={isSelected ? <Check sx={{ fontSize: 14 }} /> : null}
onClick={() => toggleDueDate(opt.value)}
sx={{
cursor: 'pointer',
userSelect: 'none',
transition: 'all 0.15s ease',
}}
>
{opt.label}
</Chip>
)
})}
</Box>
<Divider sx={{ my: 2.5 }} />
{/* Labels */}
{labels.length > 0 && (
<>
<SectionHeader icon={<Label />} label='Labels'>
<IncludeExcludeToggle
value={selections.label.operator}
onChange={op => setOperator('label', op)}
labels={['Has', "Doesn't Have"]}
/>
</SectionHeader>
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 1 }}>
{labels.map(lbl => {
const isSelected = selections.label.values.includes(lbl.id)
return (
<Chip
key={lbl.id}
variant={isSelected ? 'solid' : 'soft'}
color='neutral'
startDecorator={
<Box
sx={{
width: 10,
height: 10,
borderRadius: '50%',
bgcolor: lbl.color || '#90a4ae',
flexShrink: 0,
}}
/>
}
endDecorator={isSelected ? <Check sx={{ fontSize: 12 }} /> : null}
onClick={() => toggleValue('label', lbl.id)}
sx={{
cursor: 'pointer',
userSelect: 'none',
transition: 'all 0.15s ease',
...(isSelected && {
outline: '2px solid',
outlineColor: 'primary.400',
}),
}}
>
{lbl.name}
</Chip>
)
})}
</Box>
<Divider sx={{ my: 2.5 }} />
</>
)}
{/* Projects */}
{projects.length > 0 && (
<>
<SectionHeader icon={<FolderOpen />} label='Projects'>
<IncludeExcludeToggle
value={selections.project.operator}
onChange={op => setOperator('project', op)}
/>
</SectionHeader>
{chipRow('project', [
{ value: 'default', label: 'Default Project' },
...projects
.filter(p => p.id !== 'default')
.map(p => ({ value: p.id, label: p.name })),
])}
<Divider sx={{ my: 2.5 }} />
</>
)}
{/* Points */}
<SectionHeader icon={<Stars />} label='Points' />
<Box
sx={{ display: 'flex', gap: 1, alignItems: 'center', flexWrap: 'wrap' }}
>
{POINTS_OPERATORS.map(op => (
<Chip
key={op.value}
size='sm'
variant={
selections.points.operator === op.value && selections.points.active
? 'solid'
: 'soft'
}
color={
selections.points.operator === op.value && selections.points.active
? 'primary'
: 'neutral'
}
onClick={() => setPointsOperator(op.value)}
sx={{
cursor: 'pointer',
userSelect: 'none',
fontFamily: 'monospace',
fontWeight: 600,
}}
>
{op.label}
</Chip>
))}
<Input
type='number'
size='sm'
value={selections.points.value}
onChange={e => setPointsValue(parseInt(e.target.value) || 0)}
sx={{ width: 80 }}
slotProps={{ input: { min: 0 } }}
/>
{selections.points.active && (
<Chip
size='sm'
variant='soft'
color='danger'
onClick={() =>
onSelectionsChange(prev => ({
...prev,
points: { ...prev.points, active: false, value: 0 },
}))
}
sx={{ cursor: 'pointer' }}
>
Clear
</Chip>
)}
</Box>
</Box>
)
}
export default FilterBuilderContent

View File

@@ -9,12 +9,10 @@ const MyChoreHeader = ({
tempFilter,
tempFilterMeta,
}) => {
if (
!activeFilterId &&
!tempFilter &&
(!selectedProject || selectedProject.id === 'default')
)
return null
const isVisible =
!!activeFilterId ||
!!tempFilter ||
(!!selectedProject && selectedProject.id !== 'default')
const renderIcon = () => {
if (tempFilter) {
@@ -53,18 +51,41 @@ const MyChoreHeader = ({
: activeFilter?.description || selectedProject?.description
return (
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2, mb: 2 }}>
{renderIcon()}
<Stack sx={{ flex: 1 }}>
<Typography level='h3' sx={{ fontWeight: 'lg', color: 'text.primary' }}>
{name}
</Typography>
{description && (
<Typography level='body-sm' sx={{ color: 'text.secondary' }}>
{description}
<Box
sx={{
overflow: 'hidden',
maxHeight: isVisible ? '120px' : '0',
opacity: isVisible ? 1 : 0,
transform: isVisible ? 'translateY(0)' : 'translateY(-8px)',
transition:
'max-height 0.3s ease-in-out, opacity 0.3s ease-in-out, transform 0.3s ease-in-out',
marginBottom: isVisible ? 2 : 0,
}}
>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2 }}>
{renderIcon()}
<Stack sx={{ flex: 1 }}>
<Typography
level='h3'
sx={{ fontWeight: 'lg', color: 'text.primary' }}
>
{name}
</Typography>
)}
</Stack>
<Box
sx={{
overflow: 'hidden',
maxHeight: description ? '40px' : '0',
opacity: description ? 1 : 0,
transition:
'max-height 0.3s ease-in-out, opacity 0.3s ease-in-out',
}}
>
<Typography level='body-sm' sx={{ color: 'text.secondary' }}>
{description}
</Typography>
</Box>
</Stack>
</Box>
</Box>
)
}

View File

@@ -12,6 +12,7 @@ import {
MarkChoreComplete,
NudgeChore,
RejectChore,
SaveChore,
SkipChore,
UndoChoreAction,
UpdateChoreAssignee,
@@ -420,8 +421,8 @@ export const useChoreActions = ({
c => c.id !== chore.id,
)
setChores(newChores)
updateChoreInState(chore.id, 'deleted')
setFilteredChores(newFilteredChores)
queryClient.invalidateQueries(['chores'])
showSuccess({
title: 'Task Deleted',
message: 'The task has been deleted successfully.',
@@ -471,7 +472,7 @@ export const useChoreActions = ({
await new Promise((resolve, reject) => {
archiveChore.mutate(chore.id, {
onSuccess: data => {
updateChoreInState(data, 'archive')
updateChoreInState(chore, 'archive')
resolve(data)
},
onError: async error => {
@@ -664,6 +665,28 @@ export const useChoreActions = ({
}
break
case 'moveToProject': {
const project = extraData?.project
const projectId = project?.id === null ? null : project?.id
const updatedChore = { ...chore, projectId }
try {
const response = await SaveChore(updatedChore)
if (response.ok) {
updateChoreInState(updatedChore, 'moved-to-project')
showSuccess({
title: 'Task Moved',
message: `Task moved to ${project?.name || 'Default Project'}.`,
})
}
} catch (error) {
showError({
title: 'Failed to move task',
message: error?.message || 'Unable to move task to project',
})
}
break
}
case 'completeWithNote':
case 'completeWithPastDate':
case 'changeAssignee':

View File

@@ -90,6 +90,8 @@ export const useCustomFilters = (chores, membersData, labels, projects) => {
}, [chores, activeFilter, tempFilter, context])
const applyCustomFilter = useCallback(filterId => {
setTempFilter(null)
setTempFilterMeta(null)
setActiveFilterId(filterId)
}, [])

View File

@@ -1,52 +1,226 @@
import { HomeRounded, Login } from '@mui/icons-material'
import { Box, Button, CircularProgress, Container, Typography } from '@mui/joy'
import { Link } from 'react-router-dom'
import Logo from '../Logo' // Adjust the import path as necessary
import {
BugReportRounded,
CloudOffRounded,
ContentCopyRounded,
ErrorRounded,
ExpandMoreRounded,
HomeRounded,
LockRounded,
RefreshRounded,
SearchOffRounded,
} from '@mui/icons-material'
import { Box, Button, IconButton, Snackbar, Typography } from '@mui/joy'
import { useState } from 'react'
import { Link, useRouteError } from 'react-router-dom'
const getErrorKind = error => {
if (!error)
return { label: 'Unknown Error', color: 'danger', Icon: ErrorRounded }
const status = error?.status ?? error?.response?.status
if (status === 404)
return {
label: '404 · Not Found',
color: 'warning',
Icon: SearchOffRounded,
}
if (status === 401 || status === 403)
return {
label: `${status} · Unauthorized`,
color: 'warning',
Icon: LockRounded,
}
if (status >= 500)
return {
label: `${status} · Server Error`,
color: 'danger',
Icon: CloudOffRounded,
}
if (error?.name === 'TypeError')
return { label: 'Runtime Error', color: 'danger', Icon: BugReportRounded }
if (error?.name === 'SyntaxError')
return { label: 'Syntax Error', color: 'danger', Icon: BugReportRounded }
return { label: 'Unexpected Error', color: 'danger', Icon: ErrorRounded }
}
const safeMessage = error => {
const msg = error?.message ?? error?.statusText
if (!msg || msg === '[object Object]') return null
return msg
}
const buildErrorText = (error, url) => {
const lines = [
`URL: ${url}`,
`Time: ${new Date().toISOString()}`,
`Error: ${safeMessage(error) ?? String(error)}`,
]
if (error?.stack) lines.push(`\nStack:\n${error.stack}`)
return lines.join('\n')
}
const Error = () => {
const error = useRouteError()
const [showDetails, setShowDetails] = useState(false)
const [copied, setCopied] = useState(false)
const { color, Icon } = getErrorKind(error)
const message = safeMessage(error)
const url = window.location.href
const handleCopy = () => {
navigator.clipboard.writeText(buildErrorText(error, url)).then(() => {
setCopied(true)
})
}
return (
<Container className='flex h-full items-center justify-center'>
<Box
sx={{
position: 'relative',
minHeight: '100dvh',
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
px: 3,
py: 6,
maxWidth: 440,
mx: 'auto',
overflow: 'hidden',
}}
>
{/* Decorative dots */}
<Box
className='flex flex-col items-center justify-center'
sx={{
minHeight: '80vh',
position: 'absolute',
top: '14%',
left: '6%',
width: 14,
height: 14,
borderRadius: '50%',
bgcolor: `${color}.100`,
opacity: 0.7,
}}
/>
<Box
sx={{
position: 'absolute',
top: '22%',
right: '8%',
width: 9,
height: 9,
borderRadius: '50%',
bgcolor: `${color}.200`,
}}
/>
<Box
sx={{
position: 'absolute',
bottom: '28%',
right: '6%',
width: 7,
height: 7,
borderRadius: '50%',
bgcolor: `${color}.100`,
}}
/>
{/* Icon with concentric rings */}
<Box
sx={{
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
mb: 4,
}}
>
<CircularProgress
value={100}
color='danger' // Set the color to 'error' for danger color
sx={{ '--CircularProgress-size': '200px' }}
>
<Logo />
</CircularProgress>
<Box
className='flex items-center gap-2'
sx={{
fontWeight: 700,
fontSize: 24,
mt: 2,
width: 172,
height: 172,
borderRadius: '50%',
border: '1.5px solid',
borderColor: `${color}.100`,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}}
>
Ops, something went wrong
</Box>
<Typography level='body-md' fontWeight={500} textAlign={'center'}>
if you think this is a mistake, please contact us or{' '}
<a
href='https://github.com/donetick/donetick/issues/new'
style={{
textDecoration: 'underline',
<Box
sx={{
width: 128,
height: 128,
borderRadius: '50%',
border: '1.5px solid',
borderColor: `${color}.200`,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}}
>
open issue here
</a>{' '}
</Typography>
<Box
sx={{
width: 84,
height: 84,
borderRadius: '50%',
bgcolor: `${color}.50`,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}}
>
<Icon sx={{ fontSize: 42, color: `${color}.500` }} />
</Box>
</Box>
</Box>
</Box>
{/* Title */}
<Typography
level='h3'
fontWeight={700}
textAlign='center'
sx={{ mb: 1.5 }}
>
Something went wrong
</Typography>
{/* Error message */}
<Typography
level='body-sm'
textAlign='center'
sx={{
color: 'text.secondary',
mb: 4,
maxWidth: 320,
minHeight: '2.5em',
wordBreak: 'break-word',
}}
>
{message ??
'An unexpected error occurred. Try reloading — it usually fixes it.'}
</Typography>
{/* Primary CTA */}
<Button
variant='solid'
color='primary'
size='lg'
startDecorator={<RefreshRounded />}
onClick={() => window.location.reload()}
sx={{ width: '100%', mb: 2 }}
>
Try again
</Button>
{/* Secondary actions */}
<Box sx={{ display: 'flex', gap: 3, mb: 5 }}>
<Button
component={Link}
to='/chores'
variant='outlined'
color='primary'
sx={{ mt: 4 }}
variant='plain'
color='neutral'
size='lg'
startDecorator={<HomeRounded />}
>
@@ -55,16 +229,108 @@ const Error = () => {
<Button
component={Link}
to='/login'
variant='outlined'
color='primary'
sx={{ mt: 1 }}
variant='plain'
color='neutral'
size='lg'
startDecorator={<Login />}
>
Login
</Button>
</Box>
</Container>
{/* Report hint + collapsible details */}
<Box
sx={{
width: '100%',
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
}}
>
<Typography
level='body-xs'
textAlign='center'
sx={{ color: 'text.tertiary', mb: 1.5 }}
>
If this keeps happening,{' '}
<a
href='https://github.com/donetick/donetick/issues/new'
target='_blank'
rel='noopener noreferrer'
style={{ textDecoration: 'underline' }}
>
open an issue
</a>{' '}
and include the error details below.
</Typography>
{(error?.stack || message) && (
<>
<Button
variant='plain'
color='neutral'
size='sm'
onClick={() => setShowDetails(v => !v)}
endDecorator={
<ExpandMoreRounded
sx={{
transition: 'transform 0.2s',
transform: showDetails ? 'rotate(180deg)' : 'rotate(0deg)',
}}
/>
}
sx={{ mb: 1 }}
>
{showDetails ? 'Hide' : 'Show'} error details
</Button>
{showDetails && (
<Box
sx={{
position: 'relative',
bgcolor: 'background.level2',
borderRadius: 'sm',
p: 2,
width: '100%',
}}
>
<IconButton
size='sm'
variant='plain'
color='neutral'
onClick={handleCopy}
sx={{ position: 'absolute', top: 8, right: 8 }}
title='Copy to clipboard'
>
<ContentCopyRounded fontSize='small' />
</IconButton>
<Typography
level='body-xs'
sx={{
fontFamily: 'monospace',
whiteSpace: 'pre-wrap',
wordBreak: 'break-all',
pr: 4,
color: 'text.secondary',
}}
>
{buildErrorText(error, url)}
</Typography>
</Box>
)}
</>
)}
</Box>
<Snackbar
open={copied}
autoHideDuration={2500}
onClose={() => setCopied(false)}
anchorOrigin={{ vertical: 'bottom', horizontal: 'center' }}
size='sm'
>
Error details copied to clipboard
</Snackbar>
</Box>
)
}

View File

@@ -8,11 +8,21 @@ import {
import '@meauxt/react-swipeable-list/dist/styles.css'
import {
Analytics,
CalendarMonth,
Check,
Checklist,
EventBusy,
EventNote,
FilterList,
Group,
History,
HourglassEmpty,
Person,
Redo,
RunningWithErrors,
Schedule,
Star,
ThumbDown,
Timelapse,
TrendingUp,
} from '@mui/icons-material'
@@ -22,8 +32,10 @@ import { Box, Button, Card, Container, Grid, Sheet, Typography } from '@mui/joy'
import moment from 'moment'
import { useEffect, useMemo, useState } from 'react'
import { Link, useParams } from 'react-router-dom'
import FilterBar from '../../components/common/FilterBar'
import { useLocalization } from '../../contexts/LocalizationContext'
import useConfirmationModal from '../../hooks/useConfirmationModal'
import { useFilter } from '../../hooks/useFilter'
import { usePendingCommands } from '../../hooks/usePendingCommands'
import {
useChoreHistory,
@@ -35,6 +47,7 @@ import { useNotification } from '../../service/NotificationProvider'
import { ChoreHistoryStatus } from '../../utils/Chores'
import LoadingComponent from '../components/Loading'
import EditHistoryModal from '../Modals/EditHistoryModal'
import HistoryDetailModal from '../Modals/HistoryDetailModal'
import ConfirmationModal from '../Modals/Inputs/ConfirmationModal'
import NoteViewerModal from '../Modals/Inputs/NoteViewerModal'
import HistoryCard from './HistoryCard'
@@ -49,7 +62,8 @@ const ChoreHistory = () => {
const { fmt } = useLocalization()
const [showMoreInfoId, setShowMoreInfoId] = useState(null)
const [noteViewerConfig, setNoteViewerConfig] = useState({ isOpen: false })
const { showSuccess } = useNotification()
const [detailModalConfig, setDetailModalConfig] = useState({ isOpen: false })
const { showSuccess, showError } = useNotification()
// React Query hooks
const { data: choreHistoryData, isLoading } = useChoreHistory(choreId)
const { data: circleMembersData } = useCircleMembers()
@@ -77,6 +91,61 @@ const ChoreHistory = () => {
}, {})
}, [pendingCmds])
const filterDefs = useMemo(
() => [
{
id: 'status',
label: 'Status',
type: 'multi-select',
icon: <FilterList />,
options: [
{ value: ChoreHistoryStatus.COMPLETED, label: 'Completed', color: 'success', icon: <Check sx={{ fontSize: 14 }} /> },
{ value: ChoreHistoryStatus.SKIPPED, label: 'Skipped', color: 'warning', icon: <Redo sx={{ fontSize: 14 }} /> },
{ value: ChoreHistoryStatus.PENDING_APPROVAL, label: 'Pending', color: 'neutral', icon: <HourglassEmpty sx={{ fontSize: 14 }} /> },
{ value: ChoreHistoryStatus.REJECTED, label: 'Rejected', color: 'danger', icon: <ThumbDown sx={{ fontSize: 14 }} /> },
{ value: 5, label: 'Missed', color: 'danger', icon: <RunningWithErrors sx={{ fontSize: 14 }} /> },
{ value: 6, label: 'Rescheduled', color: 'warning', icon: <Schedule sx={{ fontSize: 14 }} /> },
],
filterFn: (item, values) => values.includes(item.status),
},
{
id: 'hasNotes',
label: 'Has Notes',
type: 'boolean',
icon: <EventNote />,
filterFn: item => !!item.notes,
},
{
id: 'completedBy',
label: 'Completed By',
type: 'multi-select',
icon: <Person />,
options: performers.map(p => ({
value: p.userId,
label: p.displayName,
avatar: p.image,
})),
filterFn: (item, values) => values.includes(item.completedBy),
},
{
id: 'dateRange',
label: 'Completed At',
type: 'date-range',
icon: <CalendarMonth />,
filterFn: (item, value) => {
const performed = new Date(item.performedAt || item.updatedAt)
if (value.from && performed < new Date(value.from)) return false
if (value.to && performed > new Date(value.to)) return false
return true
},
},
],
[performers],
)
const { filteredData: filteredHistory, activeFilters, setFilter, clearAll, activeFilterCount } =
useFilter(choreHistory, filterDefs)
const handleDelete = historyEntry => {
showConfirmation(
`Are you sure you want to delete this history record?`,
@@ -224,9 +293,10 @@ const ChoreHistory = () => {
}
return (
<Container maxWidth='md'>
<Container maxWidth='md' sx={{ px: 0 }}>
{/* Enhanced Header Section */}
<Box sx={{ mb: 4 }}>
<Box sx={{ gap: 2, p: 2 }}>
{/* <Box sx={{ display: 'flex', alignItems: 'center', gap: 2, mb: 2, p: 2 }}> */}
{/* Statistics Cards Grid - Compact Design */}
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2, mb: 3 }}>
<History sx={{ fontSize: '1.5rem' }} />
@@ -304,7 +374,9 @@ const ChoreHistory = () => {
</Box>
{/* History Section Header */}
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2, mb: 3 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2, p: 2 }}>
<Analytics sx={{ fontSize: '1.5rem' }} />
<Typography
level='title-md'
@@ -313,14 +385,50 @@ const ChoreHistory = () => {
Task Activity
</Typography>
</Box>
<Box sx={{ px: 2 }}>
<FilterBar
filterDefs={filterDefs}
activeFilters={activeFilters}
onSetFilter={setFilter}
onClearAll={clearAll}
resultCount={filteredHistory.length}
totalCount={choreHistory.length}
/>
</Box>
{filteredHistory.length === 0 && activeFilterCount > 0 && (
<Box
sx={{
textAlign: 'center',
py: 6,
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
gap: 1.5,
}}
>
<FilterList sx={{ fontSize: '3rem', color: 'text.tertiary' }} />
<Typography level='title-md' sx={{ color: 'text.secondary' }}>
No results match your filters
</Typography>
<Typography level='body-sm' sx={{ color: 'text.tertiary' }}>
Try adjusting or clearing the active filters.
</Typography>
<Button variant='soft' size='sm' onClick={clearAll} sx={{ mt: 0.5 }}>
Clear filters
</Button>
</Box>
)}
{filteredHistory.length > 0 && (
<Sheet
variant='plain'
sx={{ borderRadius: 'sm', boxShadow: 'md', overflow: 'hidden' }}
sx={{ borderRadius: 'sm', overflow: 'hidden' }}
>
{/* Chore History List (Updated Style) */}
<SwipeableList type={ListType.IOS} fullSwipe={false}>
{choreHistory.map((historyEntry, index) => (
{filteredHistory.map((historyEntry, index) => (
<SwipeableListItem
key={historyEntry.id || index}
swipeActionOpen={
@@ -385,6 +493,19 @@ const ChoreHistory = () => {
performers={performers}
allHistory={choreHistory}
index={index}
onViewDetails={() => {
setDetailModalConfig({
isOpen: true,
entry: historyEntry,
performers,
onClose: () => setDetailModalConfig({ isOpen: false }),
onEdit: record => {
setDetailModalConfig({ isOpen: false })
setEditHistory(record)
setIsEditModalOpen(true)
},
})
}}
pendingCommands={pendingByHistoryId[historyEntry.id] || []}
onViewNote={notes => {
setNoteViewerConfig({
@@ -407,6 +528,7 @@ const ChoreHistory = () => {
))}
</SwipeableList>
</Sheet>
)}
<EditHistoryModal
config={{
isOpen: isEditModalOpen,
@@ -481,7 +603,8 @@ const ChoreHistory = () => {
/>
<ConfirmationModal config={confirmModalConfig} />
<NoteViewerModal config={noteViewerConfig} />
</Container>
<HistoryDetailModal config={detailModalConfig} />
</Container>
)
}

View File

@@ -1,99 +1,47 @@
import {
AccessTime,
CalendarMonth,
Check,
EventNote,
HourglassEmpty,
MoreVert,
Person,
Redo,
RunningWithErrors,
Schedule,
ThumbDown,
Timelapse,
Toll,
} from '@mui/icons-material'
import { Avatar, Box, Chip, Grid, IconButton, Typography } from '@mui/joy'
import { Avatar, Box, Card, Chip, IconButton, Typography } from '@mui/joy'
import moment from 'moment'
import { useLocalization } from '../../contexts/LocalizationContext'
import { TASK_COLOR } from '../../utils/Colors.jsx'
import PendingBadge from '../components/PendingBadge'
const getCompletedChip = historyEntry => {
if (
historyEntry.status === 0 ||
historyEntry.status === 5 ||
historyEntry.status === 6
) {
return null
}
if (!historyEntry.dueDate) {
return null
// <Chip
// size='sm'
// variant='soft'
// color='neutral'
// startDecorator={<CalendarViewDay />}
// >
// No Due Date
// </Chip>
}
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 {
return (
<Chip
size='sm'
variant='solid'
sx={{ backgroundColor: TASK_COLOR.LATE, color: 'white' }}
startDecorator={<Timelapse />}
>
Late
</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')}`
if (typeof seconds !== 'number' || isNaN(seconds) || seconds < 0) return null
const h = Math.floor(seconds / 3600)
const m = Math.floor((seconds % 3600) / 60)
const s = seconds % 60
return `${h.toString().padStart(2, '0')}:${m.toString().padStart(2, '0')}:${s.toString().padStart(2, '0')}`
}
const stripHtmlTags = html => {
if (!html) return ''
if (typeof document === 'undefined') {
return String(html).replace(/<[^>]*>/g, '')
}
const div = document.createElement('div')
div.innerHTML = html
return div.textContent || div.innerText || ''
}
const statusConfig = {
0: { label: 'In Progress', color: 'primary', icon: <AccessTime /> },
1: { label: 'Completed', color: 'success', icon: <Check /> },
2: { label: 'Skipped', color: 'warning', icon: <Redo /> },
3: { label: 'Pending Approval', color: 'neutral', icon: <HourglassEmpty /> },
4: { label: 'Rejected', color: 'danger', icon: <ThumbDown /> },
5: { label: 'Missed', color: 'danger', icon: <RunningWithErrors /> },
6: { label: 'Rescheduled', color: 'warning', icon: <Schedule /> },
}
/**
* Compact HistoryCard component - content only
*/
const HistoryCard = ({
allHistory,
performers,
@@ -102,235 +50,137 @@ const HistoryCard = ({
pendingCommands,
onToggleActions,
onViewNote,
onViewDetails,
}) => {
const { fmt } = useLocalization()
const performer = performers.find(p => p.userId === historyEntry.completedBy)
const assignedTo = performers.find(p => p.userId === historyEntry.assignedTo)
const config = statusConfig[historyEntry.status] ?? statusConfig[1]
const displayLabel =
historyEntry.status === 6 && !historyEntry.dueDate ? 'Scheduled' : config.label
const actionDate = historyEntry.performedAt || historyEntry.updatedAt
const formatTimeDifference = (startDate, endDate) => {
const diffInMinutes = moment(startDate).diff(endDate, 'minutes')
let timeValue = diffInMinutes
let unit = 'minute'
const getTimingLine = () => {
const { status, performedAt, dueDate } = historyEntry
if (!dueDate) return null
if (diffInMinutes >= 60) {
const diffInHours = moment(startDate).diff(endDate, 'hours')
timeValue = diffInHours
unit = 'hour'
if (diffInHours >= 24) {
const diffInDays = moment(startDate).diff(endDate, 'days')
timeValue = diffInDays
unit = 'day'
}
if (status === 6) {
return `Was due ${moment(dueDate).format('MMM D')}`
}
return `${timeValue} ${unit}${timeValue !== 1 ? 's' : ''}`
if (status === 5) {
return `Was due ${moment(dueDate).format('MMM D')}`
}
if ((status === 1 || status === 2 || status === 0) && performedAt) {
const diffHours = moment(performedAt).diff(dueDate, 'hours')
const abs = Math.abs(diffHours)
if (abs <= 6) return null // chip already says "On Time"
if (diffHours < 0) return abs >= 48 ? `${Math.floor(abs / 24)}d before due date` : `${abs}h before due date`
return abs >= 48 ? `${Math.floor(abs / 24)}d after due date` : `${abs}h after due date`
}
return null
}
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
5: { icon: <RunningWithErrors />, color: 'danger' }, // Missed
6: { icon: <Schedule />, color: 'warning' }, // Rescheduled
}
const timingLine = getTimingLine()
const noteLabel = historyEntry.status === 2 || historyEntry.status === 4 ? 'Reason' : 'Note'
const plainTextNotes = historyEntry.notes ? stripHtmlTags(historyEntry.notes) : ''
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>
)
}
const metaTextParts = [
fmt.dateTime(actionDate),
historyEntry.completedBy !== historyEntry.assignedTo && assignedTo
? `Assigned to ${assignedTo.displayName}`
: null,
historyEntry?.duration > 0 ? `${formatTime(historyEntry.duration)}` : null,
historyEntry?.points > 0 ? `${historyEntry.points} pt${historyEntry.points > 1 ? 's' : ''}` : null,
].filter(Boolean)
return (
<Box
onClick={() => onViewDetails?.()}
sx={{
display: 'flex',
alignItems: 'center',
minHeight: 64,
minWidth: '100%',
px: 2,
py: 1.5,
bgcolor: 'background.body',
borderBottom: '1px solid',
borderColor: 'divider',
borderLeft: '3px solid',
borderLeftColor: `${config.color}.400`,
cursor: onViewDetails ? 'pointer' : 'default',
'&:hover': onViewDetails ? { bgcolor: 'background.level1' } : {},
}}
>
<Box sx={{ flex: 1, minWidth: 0 }}>
<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',
}}
<Box sx={{ flex: 1, minWidth: 0, px: 2, py: 1.5 }}>
{/* Status + timing chip */}
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 0.5 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.75 }}>
<Avatar
size='sm'
color={config.color}
variant='soft'
sx={{ width: 20, height: 20, '& svg': { fontSize: '11px' } }}
>
{getStatusAvatar()}
{config.icon}
</Avatar>
<Typography level='title-sm' fontWeight='lg' sx={{ color: `${config.color}.plainColor` }}>
{displayLabel}
</Typography>
</Box>
</Box>
<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'
: historyEntry.status === 5
? 'Missed'
: historyEntry.status === 6
? 'Rescheduled'
: 'Completed'}
</Typography>
{/* Timing relationship line */}
{timingLine && (
<Typography level='body-xs' sx={{ color: 'text.tertiary', mb: 0.25 }}>
{timingLine}
</Typography>
)}
<Chip size='sm' startDecorator={<EventNote />}>
{fmt.dateTime(
historyEntry.performedAt || historyEntry.updatedAt,
)}
</Chip>
{/* Notes inline */}
<Box sx={{ display: 'flex', gap: 0.5 }}>
{getCompletedChip(historyEntry)}
</Box>
</Box>
</Grid>
{plainTextNotes && (
<Card
variant='soft'
color='neutral'
size='sm'
sx={{ mt: 0.5, whiteSpace: 'pre-wrap', overflow: 'hidden', textOverflow: 'ellipsis' }}
>
<Typography
level='body-xs'
sx={{ color: 'text.secondary', fontStyle: 'italic', mb: 0.25, cursor: 'pointer' }}
onClick={e => { e.stopPropagation(); onViewNote?.(historyEntry.notes) }}
>
{plainTextNotes.length > 80 ? `${plainTextNotes.slice(0, 80)}` : plainTextNotes}
</Typography>
</Card>
)}
{/* 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,
}}
{/* Metadata strip: performer chip + date + extras */}
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.75, mt: 0.5, flexWrap: 'wrap' }}>
{performer && (
<Chip
size='sm'
variant='soft'
color='neutral'
startDecorator={
<Avatar src={performer.image} alt={performer.displayName} sx={{ width: 14, height: 14 }} />
}
>
{historyEntry.dueDate && (
<Chip size='sm' startDecorator={<CalendarMonth />}>
{fmt.dateTime(historyEntry.dueDate)}
</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,
}}
>
{performer && (
<Chip
size='sm'
variant='solid'
color='success'
startDecorator={
<Avatar
src={performer?.image}
alt={performer?.displayName}
/>
}
>
{performer?.displayName || 'Unknown'}
</Chip>
)}
{historyEntry.completedBy !== historyEntry.assignedTo &&
assignedTo && (
<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',
cursor: 'pointer',
}}
onClick={e => {
e.stopPropagation()
onViewNote?.(historyEntry.notes)
}}
>
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>
{performer.displayName}
</Chip>
)}
{metaTextParts.length > 0 && (
<Typography level='body-xs' sx={{ color: 'text.tertiary' }}>
{metaTextParts.join(' · ')}
</Typography>
)}
</Box>
</Box>
<Box>
<Box sx={{ display: 'flex', alignItems: 'center', pr: 0.5 }} onClick={e => e.stopPropagation()}>
{onToggleActions && (
<IconButton
color='neutral'
variant='plain'
size='sm'
onClick={e => {
e.stopPropagation()
onToggleActions()
}}
onClick={e => { e.stopPropagation(); onToggleActions() }}
>
<MoreVert sx={{ fontSize: 18 }} />
</IconButton>

View File

@@ -1,14 +1,18 @@
import { Card, Grid, Typography } from '@mui/joy'
import moment from 'moment'
import { useState } from 'react'
import ChoreCard from '../Chores/ChoreCard'
const DemoMyChore = () => {
const [selectedCalendarDate, setSelectedCalendarDate] = useState(null)
const cards = [
{
id: 12,
name: '♻️ Take out recycle ',
frequencyType: 'days_of_the_week',
frequency: 1,
priority: 1,
frequencyMetadata:
'{"days":["thursday"],"time":"2024-07-07T22:00:00-04:00"}',
nextDueDate: moment().add(1, 'days').hour(8).minute(0).toISOString(),
@@ -96,6 +100,17 @@ const DemoMyChore = () => {
]
const users = [{ displayName: 'Me', id: 1, userId: 1 }]
// Helper function to get chores for a specific date
const getChoresForDate = date => {
return cards.filter(chore => {
if (!chore.nextDueDate) return false
const choreDate = new Date(chore.nextDueDate).toLocaleDateString()
const selectedDate = date.toLocaleDateString()
return choreDate === selectedDate
})
}
return (
<>
<Grid item xs={12} sm={5} data-aos-first-tasks-list>

View File

@@ -17,6 +17,10 @@ import TabletInstallationSection from './TabletInstallationSection'
const Landing = () => {
const Navigate = useNavigate()
useEffect(() => {
// if the host is https://app.donetick.com/ then redirect to https://app.donetick.com/my/chores:
if (window.location.host === 'app.donetick.com') {
Navigate('/chores')
}
AOS.init({
once: false, // whether animation should happen only once - while scrolling down
})

View File

@@ -1,5 +1,6 @@
import ipad_screenshot from '@/assets/ipad_dashbard_calendar.png'
import { Box, Container, Typography } from '@mui/joy'
const TabletInstallationSection = () => {
return (
<Container maxWidth='xl' sx={{ py: { xs: 6, sm: 8, md: 12 } }}>

View File

@@ -0,0 +1,230 @@
import {
AccessTime,
CalendarMonth,
Check,
Edit,
HourglassEmpty,
OpenInNew,
Person,
Redo,
RunningWithErrors,
Schedule,
ThumbDown,
Update,
} from '@mui/icons-material'
import { Avatar, Box, Button, Chip, Divider, Stack, Typography } from '@mui/joy'
import moment from 'moment'
import { useNavigate } from 'react-router-dom'
import { useLocalization } from '../../contexts/LocalizationContext'
import { useResponsiveModal } from '../../hooks/useResponsiveModal'
import { TASK_COLOR } from '../../utils/Colors.jsx'
import RichTextEditor from '../components/RichTextEditor.jsx'
const STATUS_CONFIG = {
0: { label: 'In Progress', color: 'primary', icon: <AccessTime /> },
1: { label: 'Completed', color: 'success', icon: <Check /> },
2: { label: 'Skipped', color: 'warning', icon: <Redo /> },
3: { label: 'Pending Approval', color: 'neutral', icon: <HourglassEmpty /> },
4: { label: 'Rejected', color: 'danger', icon: <ThumbDown /> },
5: { label: 'Missed', color: 'danger', icon: <RunningWithErrors /> },
6: { label: 'Rescheduled', color: 'warning', icon: <Schedule /> },
}
const DetailRow = ({ icon, label, value, children }) => (
<Box sx={{ display: 'flex', alignItems: 'flex-start', gap: 1.5, py: 0.75 }}>
<Box sx={{ color: 'text.tertiary', mt: 0.25, flexShrink: 0, display: 'flex' }}>{icon}</Box>
<Box sx={{ flex: 1, minWidth: 0 }}>
<Typography level='body-xs' sx={{ color: 'text.tertiary', mb: 0.15 }}>{label}</Typography>
{children ?? (
<Typography level='body-sm' sx={{ color: 'text.primary', fontWeight: 'md' }}>{value}</Typography>
)}
</Box>
</Box>
)
const TimingBadge = ({ historyEntry }) => {
if (!historyEntry.dueDate || !historyEntry.performedAt) return null
if ([0, 5, 6].includes(historyEntry.status)) return null
const performedAt = moment(historyEntry.performedAt)
const dueDate = moment(historyEntry.dueDate)
const diffHours = performedAt.diff(dueDate, 'hours')
const gracePeriod = 6 * 60 * 60 * 1000
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)) {
const abs = Math.abs(diffHours)
const label = abs >= 48 ? `${Math.floor(abs / 24)}d early` : `${abs}h early`
return <Chip size='sm' variant='soft' sx={{ backgroundColor: TASK_COLOR.SCHEDULED, color: 'white' }} startDecorator={<Check />}>{label}</Chip>
} else {
const abs = Math.abs(diffHours)
const label = abs >= 48 ? `${Math.floor(abs / 24)}d late` : `${abs}h late`
return <Chip size='sm' variant='solid' sx={{ backgroundColor: TASK_COLOR.LATE, color: 'white' }}>{label}</Chip>
}
}
function HistoryDetailModal({ config }) {
const { ResponsiveModal } = useResponsiveModal()
const { fmt } = useLocalization()
const navigate = useNavigate()
const entry = config?.entry
const performers = config?.performers ?? []
if (!entry) return null
const statusCfg = STATUS_CONFIG[entry.status] ?? STATUS_CONFIG[1]
const isFirstSchedule = entry.status === 6 && !entry.dueDate
const statusLabel = isFirstSchedule ? 'Scheduled' : statusCfg.label
const performer = performers.find(p => p.userId === entry.completedBy)
const assignedTo = performers.find(p => p.userId === entry.assignedTo)
const isDifferentAssignee = entry.assignedTo && entry.completedBy !== entry.assignedTo
// updatedAt is only meaningful if it differs from performedAt by more than a minute
const showUpdatedAt =
entry.updatedAt &&
entry.performedAt &&
Math.abs(moment(entry.updatedAt).diff(entry.performedAt, 'minutes')) > 1
const formatDuration = seconds => {
if (!seconds || seconds <= 0) return null
const h = Math.floor(seconds / 3600)
const m = Math.floor((seconds % 3600) / 60)
const s = seconds % 60
return `${h.toString().padStart(2, '0')}:${m.toString().padStart(2, '0')}:${s.toString().padStart(2, '0')}`
}
return (
<ResponsiveModal
open={config?.isOpen}
onClose={config?.onClose}
title='Activity Detail'
>
{/* Status header */}
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 1.5 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<Avatar size='sm' color={statusCfg.color} variant='soft'>
{statusCfg.icon}
</Avatar>
<Typography level='title-md' fontWeight='lg' sx={{ color: `${statusCfg.color}.plainColor` }}>
{statusLabel}
</Typography>
</Box>
<TimingBadge historyEntry={entry} />
</Box>
<Divider sx={{ mb: 1.5 }} />
<Stack spacing={0}>
{/* Who performed it */}
{performer && (
<DetailRow icon={<Check sx={{ fontSize: 16 }} />} label='Performed by'>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.75 }}>
<Avatar src={performer.image} alt={performer.displayName} size='sm' sx={{ width: 20, height: 20 }} />
<Typography level='body-sm' fontWeight='md'>{performer.displayName}</Typography>
</Box>
</DetailRow>
)}
{/* Assigned to (only if different) */}
{isDifferentAssignee && assignedTo && (
<DetailRow icon={<Person sx={{ fontSize: 16 }} />} label='Assigned to' value={assignedTo.displayName} />
)}
<Divider />
{/* Performed at */}
{entry.performedAt && (
<DetailRow
icon={<AccessTime sx={{ fontSize: 16 }} />}
label={isFirstSchedule ? 'Scheduled on' : entry.status === 6 ? 'Rescheduled on' : entry.status === 2 ? 'Skipped on' : 'Completed on'}
value={fmt.dateTime(entry.performedAt)}
/>
)}
{/* Due date */}
{entry.dueDate && (
<DetailRow
icon={<CalendarMonth sx={{ fontSize: 16 }} />}
label={entry.status === 6 ? 'Previous due date' : entry.status === 5 ? 'Was due' : 'Due date'}
value={fmt.dateTime(entry.dueDate)}
/>
)}
{/* Last updated (only if meaningfully different from performedAt) */}
{showUpdatedAt && (
<DetailRow
icon={<Update sx={{ fontSize: 16 }} />}
label='Last updated'
value={fmt.dateTime(entry.updatedAt)}
/>
)}
{/* Duration */}
{entry.duration > 0 && (
<DetailRow
icon={<Schedule sx={{ fontSize: 16 }} />}
label='Duration'
value={formatDuration(entry.duration)}
/>
)}
{/* Points */}
{entry.points > 0 && (
<DetailRow
icon={<Typography sx={{ fontSize: 14 }}></Typography>}
label='Points earned'
value={`${entry.points} pt${entry.points > 1 ? 's' : ''}`}
/>
)}
{/* Notes */}
{entry.notes && (
<>
<Divider />
<Box sx={{ pt: 1 }}>
<Typography level='body-xs' sx={{ color: 'text.tertiary', mb: 0.5 }}>
{entry.status === 2 || entry.status === 4 ? 'Reason' : 'Notes'}
</Typography>
<Box sx={{ overflowY: 'auto', maxHeight: '60vh' }}>
<RichTextEditor value={entry.notes || ''} isEditable={false} />
</Box>
</Box>
</>
)}
</Stack>
{/* Action buttons */}
<Box sx={{ display: 'flex', gap: 1, mt: 2, justifyContent: 'flex-end' }}>
{entry.choreId && (
<Button
variant='soft'
color='neutral'
size='sm'
startDecorator={<OpenInNew sx={{ fontSize: 16 }} />}
onClick={() => {
config?.onClose?.()
navigate(`/chores/${entry.choreId}`)
}}
>
Open Task
</Button>
)}
{config?.onEdit && (
<Button
variant='soft'
color='neutral'
size='md'
startDecorator={<Edit sx={{ fontSize: 16 }} />}
onClick={() => config.onEdit(entry)}
>
Edit Entry
</Button>
)}
</Box>
</ResponsiveModal>
)
}
export default HistoryDetailModal

View File

@@ -1,22 +1,21 @@
import { Add, Delete } from '@mui/icons-material'
import { Save } from '@mui/icons-material'
import {
Box,
Button,
Chip,
IconButton,
Divider,
Input,
List,
ListItem,
Option,
Select,
Textarea,
Typography,
} from '@mui/joy'
import { useEffect, useMemo, useRef, useState } from 'react'
import { useResponsiveModal } from '../../../hooks/useResponsiveModal'
import { useEffect, useMemo, useState } from 'react'
import BottomSheetModal from '../../../components/common/BottomSheetModal'
import FilterBuilderContent, {
conditionsToSelections,
defaultSelections,
selectionsToConditions,
} from '../../Chores/components/FilterBuilderContent'
import { FILTER_COLORS } from '../../../utils/Colors'
import { applyFilter } from '../../../utils/FilterEngine'
import Priorities from '../../../utils/Priorities'
import { useFilters } from '../../Filters/FilterQueries'
const AdvancedFilterBuilder = ({
@@ -30,527 +29,149 @@ const AdvancedFilterBuilder = ({
userProfile = null,
editingFilter = null,
}) => {
const { ResponsiveModal } = useResponsiveModal()
const listContainerRef = useRef(null)
const conditionRefs = useRef([])
const [filterName, setFilterName] = useState('')
const [filterDescription, setFilterDescription] = useState('')
const [filterColor, setFilterColor] = useState(FILTER_COLORS[0].value)
const [conditions, setConditions] = useState([
{ type: 'assignee', operator: 'is', value: [] },
])
const [selections, setSelections] = useState(defaultSelections())
const [error, setError] = useState('')
const { data: existedFilters = [] } = useFilters()
const filterNameExists = (name, excludeId = null) => {
return existedFilters.some(
filter =>
filter.name.toLowerCase() === name.toLowerCase() &&
filter.id !== excludeId,
const filterNameExists = (name, excludeId = null) =>
existedFilters.some(
f => f.name.toLowerCase() === name.toLowerCase() && f.id !== excludeId,
)
}
// Initialize refs array when conditions change
useEffect(() => {
conditionRefs.current = conditionRefs.current.slice(0, conditions.length)
}, [conditions.length])
// Initialize state when editing a filter
useEffect(() => {
if (!isOpen) return
if (editingFilter) {
setFilterName(editingFilter.name)
setFilterDescription(editingFilter.description || '')
setFilterColor(editingFilter.color || FILTER_COLORS[0].value)
setConditions(editingFilter.conditions || [])
setError('')
setSelections(conditionsToSelections(editingFilter.conditions))
} else {
setFilterName('')
setFilterDescription('')
// find color no filter has it :
const potentialColor = FILTER_COLORS.find(
color => !existedFilters.some(filter => filter.color === color.value),
c => !existedFilters.some(f => f.color === c.value),
)
setFilterColor(
potentialColor ? potentialColor.value : FILTER_COLORS[0].value,
)
setConditions([{ type: 'assignee', operator: 'is', value: [] }])
setError('')
setFilterColor(potentialColor?.value ?? FILTER_COLORS[0].value)
setSelections(defaultSelections())
}
setError('')
}, [editingFilter, isOpen])
const conditions = useMemo(() => selectionsToConditions(selections), [selections])
const previewChores = useMemo(() => {
const validConditions = conditions.filter(c => {
if (c.type === 'dueDate' || c.type === 'points') return true
return c.value && (Array.isArray(c.value) ? c.value.length > 0 : true)
})
if (validConditions.length === 0) return []
const result = applyFilter(
if (conditions.length === 0) return []
return applyFilter(
allChores,
{ conditions: validConditions, operator: 'AND' },
{
userId: userProfile?.id,
members,
labels,
projects,
},
{ conditions, operator: 'AND' },
{ userId: userProfile?.id, members, labels, projects },
)
return result
}, [conditions, allChores, userProfile, members, labels, projects])
const previewCount = previewChores.length
const previewOverdueCount = previewChores.filter(
chore => chore.nextDueDate && new Date(chore.nextDueDate) < new Date(),
c => c.nextDueDate && new Date(c.nextDueDate) < new Date(),
).length
const addCondition = () => {
setConditions([
...conditions,
{ type: 'assignee', operator: 'is', value: [] },
])
// Scroll to the new condition after it's rendered
setTimeout(() => {
const newIndex = conditions.length
const newConditionElement = conditionRefs.current[newIndex]
if (newConditionElement && listContainerRef.current) {
newConditionElement.scrollIntoView({
behavior: 'smooth',
block: 'nearest',
})
}
}, 100)
}
const removeCondition = index => {
setConditions(conditions.filter((_, i) => i !== index))
}
const updateCondition = (index, field, value) => {
const updated = [...conditions]
updated[index] = { ...updated[index], [field]: value }
if (field === 'type') {
updated[index].value = []
if (value === 'dueDate') {
updated[index].operator = 'isOverdue'
updated[index].value = null
} else if (value === 'status') {
updated[index].value = []
} else if (value === 'points') {
updated[index].operator = 'greaterThan'
updated[index].value = 0
}
}
setConditions(updated)
}
const activeConditionCount = conditions.length
const handleSave = () => {
if (!filterName.trim()) {
setError('Please enter a filter name')
return
}
// Check for duplicate name, excluding current filter if editing
if (filterNameExists(filterName.trim(), editingFilter?.id)) {
setError('A filter with this name already exists')
return
}
const validConditions = conditions.filter(c => {
if (c.type === 'dueDate' || c.type === 'points') return true
return c.value && (Array.isArray(c.value) ? c.value.length > 0 : true)
})
if (conditions.length === 0 || validConditions.length === 0) {
setError('Please add at least one filter condition')
if (conditions.length === 0) {
setError('Please configure at least one filter condition')
return
}
const filterData = {
onSave({
name: filterName.trim(),
description: filterDescription.trim(),
description: editingFilter?.description ?? '',
color: filterColor,
conditions: validConditions,
conditions,
operator: 'AND',
}
// Include ID if editing
if (editingFilter) {
filterData.id = editingFilter.id
}
onSave(filterData)
...(editingFilter ? { id: editingFilter.id } : {}),
})
onClose()
}
const renderValueSelector = (condition, index) => {
switch (condition.type) {
case 'assignee':
return (
<Select
multiple
value={condition.value || []}
onChange={(_, newValue) =>
updateCondition(index, 'value', newValue)
}
placeholder='Select assignees'
sx={{ width: '100%' }}
slotProps={{
listbox: {
placement: 'bottom-start',
disablePortal: false,
},
}}
renderValue={selected => (
<Box sx={{ display: 'flex', gap: 0.5, flexWrap: 'wrap' }}>
{selected.map((selectedElement, idx) => {
const value = selectedElement.value
const member = members.find(
m => String(m.userId) === String(value),
)
return (
<Chip key={`${value}-${idx}`} size='sm'>
{member?.displayName || member?.username || 'Unknown'}
</Chip>
)
})}
</Box>
)}
>
{members.map((member, idx) => (
<Option
key={`member-${member.userId}-${idx}`}
value={member.userId}
>
{member.displayName || member.username} ({member.userId})
</Option>
))}
</Select>
)
case 'createdBy':
return (
<Select
multiple
value={condition.value || []}
onChange={(_, newValue) =>
updateCondition(index, 'value', newValue)
}
placeholder='Select creators'
sx={{ width: '100%' }}
slotProps={{
listbox: {
placement: 'bottom-start',
disablePortal: false,
},
}}
renderValue={selected => (
<Box sx={{ display: 'flex', gap: 0.5, flexWrap: 'wrap' }}>
{selected.map((selectedElement, idx) => {
const value = selectedElement.value
const member = members.find(
m => String(m.userId) === String(value),
)
return (
<Chip key={`${value}-${idx}`} size='sm'>
{member?.displayName || member?.username || 'Unknown'}
</Chip>
)
})}
</Box>
)}
>
{members.map((member, idx) => (
<Option
key={`creator-${member.userId}-${idx}`}
value={member.userId}
>
{member.displayName || member.username}
</Option>
))}
</Select>
)
case 'priority':
return (
<Select
multiple
value={condition.value || []}
onChange={(_, newValue) =>
updateCondition(index, 'value', newValue)
}
placeholder='Select priorities'
sx={{ width: '100%' }}
slotProps={{
listbox: {
placement: 'bottom-start',
disablePortal: false,
},
}}
renderValue={selected => (
<Box sx={{ display: 'flex', gap: 0.5, flexWrap: 'wrap' }}>
{selected.map((selectedElement, idx) => {
const value = selectedElement.value
const priority = Priorities.find(p => p.value === value)
return (
<Chip key={`priority-${value}-${idx}`} size='sm'>
{priority?.name || `Priority ${value}`}
</Chip>
)
})}
</Box>
)}
>
{Priorities.map((priority, idx) => (
<Option
key={`priority-opt-${priority.value}-${idx}`}
value={priority.value}
>
{priority.name}
</Option>
))}
</Select>
)
case 'label':
return (
<Select
multiple
value={condition.value || []}
onChange={(_, newValue) =>
updateCondition(index, 'value', newValue)
}
placeholder='Select labels'
sx={{ width: '100%' }}
slotProps={{
listbox: {
placement: 'bottom-start',
disablePortal: false,
},
}}
renderValue={selected => (
<Box sx={{ display: 'flex', gap: 0.5, flexWrap: 'wrap' }}>
{selected.map((selectedElement, idx) => {
const value = selectedElement.value
const label = labels.find(l => String(l.id) === String(value))
return (
<Chip key={`label-chip-${value}-${idx}`} size='sm'>
{label?.name || 'Unknown'}
</Chip>
)
})}
</Box>
)}
>
{labels.map((label, idx) => (
<Option key={`label-opt-${label.id}-${idx}`} value={label.id}>
{label.name}
</Option>
))}
</Select>
)
case 'project':
return (
<Select
multiple
value={condition.value || []}
onChange={(_, newValue) =>
updateCondition(index, 'value', newValue)
}
placeholder='Select projects'
sx={{ width: '100%' }}
slotProps={{
listbox: {
placement: 'bottom-start',
disablePortal: false,
},
}}
renderValue={selected => (
<Box sx={{ display: 'flex', gap: 0.5, flexWrap: 'wrap' }}>
{selected.map((event, idx) => {
const value = event.value
if (value === 'default')
return (
<Chip key={`default-${idx}`} size='sm'>
Default
</Chip>
)
const project = projects.find(
p => String(p.id) === String(value),
)
return (
<Chip key={`project-chip-${value}-${idx}`} size='sm'>
{project?.name || 'Unknown'}
</Chip>
)
})}
</Box>
)}
>
<Option value='default'>Default Project</Option>
{projects
.filter(p => p.id !== 'default')
.map((project, idx) => (
<Option
key={`project-opt-${project.id}-${idx}`}
value={project.id}
>
{project.name}
</Option>
))}
</Select>
)
case 'status':
return (
<Select
multiple
value={condition.value || []}
onChange={(_, newValue) =>
updateCondition(index, 'value', newValue)
}
placeholder='Select statuses'
sx={{ width: '100%' }}
slotProps={{
listbox: {
placement: 'bottom-start',
disablePortal: false,
},
}}
renderValue={selected => (
<Box sx={{ display: 'flex', gap: 0.5, flexWrap: 'wrap' }}>
{selected.map((selectedElement, idx) => {
const value = selectedElement.value
const statusLabels = {
0: 'Active',
1: 'Started',
2: 'In Progress',
3: 'Pending Approval',
}
return (
<Chip key={`status-chip-${value}-${idx}`} size='sm'>
{statusLabels[value] || 'Unknown'}
</Chip>
)
})}
</Box>
)}
>
<Option value={0}>Active</Option>
<Option value={1}>Started</Option>
<Option value={2}>In Progress</Option>
<Option value={3}>Pending Approval</Option>
</Select>
)
case 'dueDate':
return (
<Select
value={condition.operator}
onChange={(_, newValue) =>
updateCondition(index, 'operator', newValue)
}
sx={{ width: '100%' }}
slotProps={{
listbox: {
placement: 'bottom-start',
disablePortal: false,
},
}}
>
<Option value='isOverdue'>Is Overdue</Option>
<Option value='isDueToday'>Is Due Today</Option>
<Option value='isDueTomorrow'>Is Due Tomorrow</Option>
<Option value='isDueThisWeek'>Is Due This Week</Option>
<Option value='isDueThisMonth'>Is Due This Month</Option>
<Option value='hasNoDueDate'>Has No Due Date</Option>
<Option value='hasDueDate'>Has Due Date</Option>
</Select>
)
case 'points':
return (
<Box sx={{ display: 'flex', gap: 1, width: '100%' }}>
<Select
value={condition.operator}
onChange={(_, newValue) =>
updateCondition(index, 'operator', newValue)
}
sx={{ flex: 1 }}
slotProps={{
listbox: {
placement: 'bottom-start',
disablePortal: false,
},
}}
>
<Option value='equals'>Equals</Option>
<Option value='greaterThan'>Greater Than</Option>
<Option value='lessThan'>Less Than</Option>
<Option value='greaterThanOrEqual'>Greater Than or Equal</Option>
<Option value='lessThanOrEqual'>Less Than or Equal</Option>
</Select>
<Input
type='number'
value={condition.value ?? 0}
onChange={e =>
updateCondition(index, 'value', parseInt(e.target.value) || 0)
}
sx={{ flex: 1 }}
slotProps={{
input: {
min: 0,
},
}}
/>
</Box>
)
default:
return null
}
}
return (
<ResponsiveModal
<BottomSheetModal
open={isOpen}
onClose={onClose}
size='lg'
fullWidth={true}
title={editingFilter ? 'Edit Filter' : 'Create Advanced Filter'}
maxHeight='92vh'
title={
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
{editingFilter ? 'Edit Filter' : 'New Filter'}
{activeConditionCount > 0 && (
<Chip size='sm' variant='solid' color='primary'>
{activeConditionCount} condition{activeConditionCount !== 1 ? 's' : ''}
</Chip>
)}
</Box>
}
footer={
<Box sx={{ display: 'flex', gap: 1, justifyContent: 'flex-end' }}>
<Button variant='outlined' color='neutral' onClick={onClose}>
Cancel
</Button>
<Button variant='solid' color='primary' onClick={handleSave}>
Save
</Button>
<Box
sx={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
gap: 1,
}}
>
{/* Preview */}
<Box sx={{ display: 'flex', gap: 1, flexShrink: 0 }}>
{conditions.length > 0 ? (
<>
<Chip size='sm' variant='soft' color='neutral'>
{previewCount} task{previewCount !== 1 ? 's' : ''}
</Chip>
{previewOverdueCount > 0 && (
<Chip size='sm' variant='solid' color='danger'>
{previewOverdueCount} overdue
</Chip>
)}
</>
) : (
<Typography level='body-xs' sx={{ color: 'text.tertiary' }}>
Add conditions to preview
</Typography>
)}
</Box>
{/* Actions */}
<Box sx={{ display: 'flex', gap: 1 }}>
<Button variant='plain' color='neutral' size='sm' onClick={onClose}>
Cancel
</Button>
<Button
variant='solid'
color='primary'
size='sm'
startDecorator={<Save sx={{ fontSize: 16 }} />}
onClick={handleSave}
>
Save Filter
</Button>
</Box>
</Box>
}
>
<Box
sx={{
display: 'flex',
flexDirection: 'column',
gap: 2,
height: '100%',
}}
>
<Box>
<Typography level='body-sm' sx={{ mb: 1 }}>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0 }}>
{/* Name */}
<Box sx={{ mb: 2 }}>
<Typography
level='body-xs'
sx={{ mb: 0.75, color: 'text.secondary', fontWeight: 600 }}
>
Filter Name
</Typography>
<Input
placeholder='e.g. Important Tasks due soon, Tasks for John, etc.'
placeholder='e.g. Overdue tasks for Alice'
value={filterName}
onChange={e => {
setFilterName(e.target.value)
@@ -560,253 +181,57 @@ const AdvancedFilterBuilder = ({
autoFocus
/>
{error && (
<Typography level='body-sm' color='danger' sx={{ mt: 0.5 }}>
<Typography level='body-xs' color='danger' sx={{ mt: 0.5 }}>
{error}
</Typography>
)}
</Box>
<Box>
<Typography level='body-sm' sx={{ mb: 1 }}>
Description (Optional)
</Typography>
<Textarea
placeholder='Optional description for this filter...'
value={filterDescription}
onChange={e => setFilterDescription(e.target.value)}
minRows={2}
maxRows={3}
/>
</Box>
<Box>
<Typography level='body-sm' sx={{ mb: 1 }}>
{/* Color */}
<Box sx={{ mb: 2 }}>
<Typography
level='body-xs'
sx={{ mb: 0.75, color: 'text.secondary', fontWeight: 600 }}
>
Color
</Typography>
<Select
value={filterColor}
onChange={(_, value) => value && setFilterColor(value)}
slotProps={{
listbox: {
placement: 'bottom-start',
disablePortal: false,
},
}}
renderValue={selected => (
<Typography
startDecorator={
<Box
sx={{
width: 16,
height: 16,
borderRadius: '50%',
background: selected.value,
}}
/>
}
>
{selected.label}
</Typography>
)}
>
{FILTER_COLORS.map(color => (
<Option key={color.value} value={color.value}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<Box
sx={{
width: 20,
height: 20,
borderRadius: '50%',
background: color.value,
}}
/>
<Typography>{color.name}</Typography>
</Box>
</Option>
))}
</Select>
</Box>
<Box
sx={{
flex: 1,
minHeight: 0,
display: 'flex',
flexDirection: 'column',
}}
>
<Typography level='body-sm' sx={{ mb: 1 }}>
Filter Conditions (All must match)
</Typography>
<List
ref={listContainerRef}
sx={{
gap: 1,
overflowY: 'auto',
overflowX: 'hidden',
maxHeight: { xs: '40vh', sm: '50vh' },
pr: 0.5,
position: 'relative',
}}
>
{conditions.map((condition, index) => (
<ListItem
key={index}
ref={el => (conditionRefs.current[index] = el)}
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 1 }}>
{FILTER_COLORS.map(c => (
<Box
key={c.value}
title={c.name}
onClick={() => setFilterColor(c.value)}
sx={{
display: 'flex',
flexDirection: 'column',
gap: 1,
p: 1.5,
bgcolor: 'background.level1',
borderRadius: 'sm',
position: 'relative',
width: 26,
height: 26,
borderRadius: '50%',
background: c.value,
cursor: 'pointer',
outline:
filterColor === c.value
? '3px solid var(--joy-palette-primary-500)'
: '2px solid transparent',
outlineOffset: '2px',
transition: 'all 0.15s ease',
flexShrink: 0,
'&:hover': { transform: 'scale(1.2)' },
}}
>
<Box
sx={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
width: '100%',
}}
>
<Typography level='body-xs' color='neutral'>
Condition {index + 1}
</Typography>
<IconButton
size='sm'
color='danger'
variant='plain'
onClick={() => removeCondition(index)}
disabled={conditions.length === 1}
>
<Delete />
</IconButton>
</Box>
<Box sx={{ width: '100%' }}>
<Typography level='body-xs' sx={{ mb: 0.5 }}>
Field
</Typography>
<Select
value={condition.type}
onChange={(_, newValue) =>
updateCondition(index, 'type', newValue)
}
sx={{ width: '100%' }}
slotProps={{
listbox: {
placement: 'bottom-start',
disablePortal: false,
},
}}
>
<Option value='assignee'>Assignee</Option>
<Option value='createdBy'>Created By</Option>
<Option value='priority'>Priority</Option>
<Option value='label'>Label</Option>
<Option value='project'>Project</Option>
<Option value='status'>Status</Option>
<Option value='dueDate'>Due Date</Option>
<Option value='points'>Points</Option>
</Select>
</Box>
<Box sx={{ width: '100%' }}>
<Typography level='body-xs' sx={{ mb: 0.5 }}>
{condition.type === 'dueDate' || condition.type === 'points'
? 'Condition'
: 'Value'}
</Typography>
{renderValueSelector(condition, index)}
</Box>
</ListItem>
/>
))}
</List>
<Button
size='sm'
variant='outlined'
startDecorator={<Add />}
onClick={addCondition}
sx={{ mt: 1 }}
>
Add Condition
</Button>
</Box>
<Box>
<Box
sx={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
mb: 1,
}}
>
<Typography level='body-sm'>Preview</Typography>
<Box sx={{ display: 'flex', gap: 1 }}>
<Chip size='sm' variant='soft' color='neutral'>
{previewCount} tasks
</Chip>
{previewOverdueCount > 0 && (
<Chip size='sm' variant='solid' color='danger'>
{previewOverdueCount} overdue
</Chip>
)}
</Box>
</Box>
<Box
sx={{
maxHeight: 150,
overflowY: 'auto',
overflowX: 'hidden',
bgcolor: 'background.level1',
p: 1,
borderRadius: 'sm',
position: 'relative',
}}
>
{previewCount === 0 ? (
<Typography
level='body-sm'
color='neutral'
sx={{ textAlign: 'center', py: 2 }}
>
No tasks match these filters
</Typography>
) : (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
{previewChores.slice(0, 3).map(chore => (
<Box
key={chore.id}
sx={{
bgcolor: 'background.surface',
p: 1,
borderRadius: 'sm',
}}
>
<Typography level='body-sm'>{chore.name}</Typography>
</Box>
))}
{previewCount > 3 && (
<Typography
level='body-xs'
color='neutral'
sx={{ textAlign: 'center', mt: 0.5 }}
>
...and {previewCount - 3} more
</Typography>
)}
</Box>
)}
</Box>
</Box>
<Divider sx={{ mb: 2.5 }} />
<FilterBuilderContent
selections={selections}
onSelectionsChange={setSelections}
members={members}
labels={labels}
projects={projects}
/>
</Box>
</ResponsiveModal>
</BottomSheetModal>
)
}

View File

@@ -0,0 +1,138 @@
import { AttachFile, Close, Image } from '@mui/icons-material'
import { Box, Button, CircularProgress, List, ListItem, ListItemButton, Typography } from '@mui/joy'
import { useEffect, useState } from 'react'
import { useResponsiveModal } from '../../../hooks/useResponsiveModal'
import { GetChoreAttachments } from '../../../utils/Fetcher'
import { resolvePhotoURL } from '../../../utils/Helpers'
import AttachmentViewerModal from './AttachmentViewerModal'
const IMAGE_EXTENSIONS = ['jpg', 'jpeg', 'png', 'gif', 'webp', 'bmp', 'svg']
const isImageFile = fileName => {
if (!fileName) return false
const ext = fileName.split('.').pop().toLowerCase()
return IMAGE_EXTENSIONS.includes(ext)
}
const downloadFile = (url, fileName) => {
const a = document.createElement('a')
a.href = url
a.download = fileName || 'attachment'
a.rel = 'noopener'
document.body.appendChild(a)
a.click()
document.body.removeChild(a)
}
function AttachmentBrowserModal({ choreId, isOpen, onClose }) {
const { ResponsiveModal } = useResponsiveModal()
const [attachments, setAttachments] = useState([])
const [isLoading, setIsLoading] = useState(false)
const [viewerConfig, setViewerConfig] = useState({ isOpen: false })
useEffect(() => {
if (!isOpen || !choreId) return
setIsLoading(true)
GetChoreAttachments(choreId)
.then(async res => {
if (!res.ok) throw new Error('Failed to fetch attachments')
return res.json()
})
.then(data => setAttachments(Array.isArray(data) ? data : []))
.catch(() => setAttachments([]))
.finally(() => setIsLoading(false))
}, [isOpen, choreId])
const handleClose = () => {
setAttachments([])
onClose?.()
}
const handleAttachmentClick = attachment => {
const url = resolvePhotoURL(attachment.sign)
if (isImageFile(attachment.file_name)) {
setViewerConfig({
isOpen: true,
url,
fileName: attachment.file_name,
onClose: () => setViewerConfig({ isOpen: false }),
})
} else {
downloadFile(url, attachment.file_name)
}
}
return (
<>
<ResponsiveModal
open={!!isOpen}
onClose={handleClose}
title='Attachments'
footer={
<Box sx={{ display: 'flex', justifyContent: 'flex-end' }}>
<Button
variant='plain'
color='neutral'
startDecorator={<Close />}
onClick={handleClose}
>
Close
</Button>
</Box>
}
>
{isLoading ? (
<Box sx={{ display: 'flex', justifyContent: 'center', py: 4 }}>
<CircularProgress size='md' />
</Box>
) : attachments.length === 0 ? (
<Typography
level='body-sm'
sx={{ color: 'text.secondary', py: 2, textAlign: 'center' }}
>
No attachments found.
</Typography>
) : (
<List sx={{ '--ListItem-paddingX': '0px' }}>
{attachments.map((attachment, index) => (
<ListItem
key={
attachment.id ||
attachment.file_path ||
attachment.sign ||
attachment.file_name ||
index
}
sx={{ p: 0 }}
>
<ListItemButton
onClick={() => handleAttachmentClick(attachment)}
sx={{ borderRadius: 'sm', gap: 1.5, py: 1 }}
>
{isImageFile(attachment.file_name) ? (
<Image fontSize='small' />
) : (
<AttachFile fontSize='small' />
)}
<Box sx={{ flex: 1, minWidth: 0 }}>
<Typography level='body-sm' noWrap>
{attachment.file_name || `File ${index + 1}`}
</Typography>
{attachment.size_bytes > 0 && (
<Typography level='body-xs' sx={{ color: 'text.tertiary' }}>
{(attachment.size_bytes / 1024).toFixed(1)} KB
</Typography>
)}
</Box>
</ListItemButton>
</ListItem>
))}
</List>
)}
</ResponsiveModal>
<AttachmentViewerModal config={viewerConfig} />
</>
)
}
export default AttachmentBrowserModal

View File

@@ -0,0 +1,116 @@
import { Browser } from '@capacitor/browser'
import { Capacitor } from '@capacitor/core'
import { Close, Download } from '@mui/icons-material'
import { Box, Button, CircularProgress, Typography } from '@mui/joy'
import { useState } from 'react'
import { useResponsiveModal } from '../../../hooks/useResponsiveModal'
const openUrl = async url => {
if (Capacitor.isNativePlatform()) {
await Browser.open({ url })
} else {
window.open(url, '_blank', 'noopener,noreferrer')
}
}
const downloadUrl = (url, fileName) => {
if (Capacitor.isNativePlatform()) {
Browser.open({ url })
} else {
const a = document.createElement('a')
a.href = url
a.download = fileName || 'attachment'
a.rel = 'noopener'
document.body.appendChild(a)
a.click()
document.body.removeChild(a)
}
}
function AttachmentViewerModal({ config }) {
const { ResponsiveModal } = useResponsiveModal()
const [imgLoaded, setImgLoaded] = useState(false)
const [imgError, setImgError] = useState(false)
const { isOpen, url, fileName, onClose } = config || {}
const handleClose = () => {
setImgLoaded(false)
setImgError(false)
onClose?.()
}
return (
<ResponsiveModal
open={!!isOpen}
onClose={handleClose}
title={fileName || 'Attachment'}
maxHeight='92vh'
footer={
<Box sx={{ display: 'flex', gap: 1, justifyContent: 'flex-end' }}>
<Button
variant='plain'
color='neutral'
startDecorator={<Close />}
onClick={handleClose}
>
Close
</Button>
<Button
variant='soft'
color='neutral'
startDecorator={<Download />}
onClick={() => downloadUrl(url, fileName)}
disabled={!url}
>
Download
</Button>
</Box>
}
>
<Box
sx={{
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
minHeight: 200,
position: 'relative',
}}
>
{!imgLoaded && !imgError && (
<CircularProgress
sx={{ position: 'absolute' }}
size='md'
/>
)}
{imgError ? (
<Typography level='body-sm' sx={{ color: 'text.secondary' }}>
Failed to load image.
</Typography>
) : (
<Box
component='img'
src={url}
alt={fileName}
onClick={() => url && openUrl(url)}
onLoad={() => setImgLoaded(true)}
onError={() => {
setImgLoaded(true)
setImgError(true)
}}
sx={{
cursor: url ? 'zoom-in' : 'default',
maxWidth: '100%',
maxHeight: '65vh',
borderRadius: 'md',
objectFit: 'contain',
display: imgLoaded && !imgError ? 'block' : 'none',
}}
/>
)}
</Box>
</ResponsiveModal>
)
}
export default AttachmentViewerModal

View File

@@ -1,114 +1,175 @@
import { CopyAll } from '@mui/icons-material'
import { Box, Button, Checkbox, Input, ListItem, Typography } from '@mui/joy'
import { useState } from 'react'
import {
Box,
Button,
Checkbox,
CircularProgress,
Input,
ListItem,
Typography,
} from '@mui/joy'
import { useRef, useState } from 'react'
import { Capacitor } from '@capacitor/core'
import { useResponsiveModal } from '../../../hooks/useResponsiveModal'
import { startNativeNFCWrite } from '../../../service/NFCWriter'
function WriteNFCModal({ config }) {
const { ResponsiveModal } = useResponsiveModal()
const [nfcStatus, setNfcStatus] = useState('idle') // 'idle', 'writing', 'success', 'error'
const [nfcStatus, setNfcStatus] = useState('idle') // 'idle' | 'writing' | 'waiting_for_tag' | 'success' | 'error'
const [errorMessage, setErrorMessage] = useState('')
const [isAutoCompleteWhenScan, setIsAutoCompleteWhenScan] = useState(false)
const cancelScanRef = useRef(null)
const isNative = Capacitor.isNativePlatform()
const requestNFCAccess = async () => {
if ('NDEFReader' in window) {
// Assuming permission request is implicit in 'write' or 'scan' methods
setNfcStatus('idle')
} else {
alert('NFC is not supported by this browser.')
}
const getURL = () => {
let url = config.url
if (isAutoCompleteWhenScan) url += '?auto_complete=true'
return url
}
const writeToNFC = async url => {
if ('NDEFReader' in window) {
try {
const ndef = new window.NDEFReader()
await ndef.write({
records: [{ recordType: 'url', data: url }],
})
setNfcStatus('success')
} catch (error) {
console.error('Error writing to NFC tag:', error)
setNfcStatus('error')
setErrorMessage('Error writing to NFC tag. Please try again.')
}
} else {
setNfcStatus('error')
setErrorMessage(
'NFC is not supported by this browser. You can still copy the URL and write it to an NFC tag using a compatible device.',
)
const handleClose = async () => {
if (cancelScanRef.current) {
await cancelScanRef.current()
cancelScanRef.current = null
}
}
const handleClose = () => {
config.onClose()
setNfcStatus('idle')
setErrorMessage('')
}
const getURL = () => {
let url = config.url
if (isAutoCompleteWhenScan) {
url = url + '?auto_complete=true'
const handleCancel = async () => {
if (cancelScanRef.current) {
await cancelScanRef.current()
cancelScanRef.current = null
}
setNfcStatus('idle')
}
const writeToNFC = async () => {
const url = getURL()
if (isNative) {
setNfcStatus('writing')
const cancel = await startNativeNFCWrite(url, {
onWaiting: () => setNfcStatus('waiting_for_tag'),
onSuccess: () => {
cancelScanRef.current = null
setNfcStatus('success')
},
onError: msg => {
cancelScanRef.current = null
setNfcStatus('error')
setErrorMessage(msg)
},
})
cancelScanRef.current = cancel
} else {
if ('NDEFReader' in window) {
try {
setNfcStatus('writing')
const ndef = new window.NDEFReader()
await ndef.write({ records: [{ recordType: 'url', data: url }] })
setNfcStatus('success')
} catch (error) {
console.error('Error writing to NFC tag:', error)
setNfcStatus('error')
setErrorMessage('Error writing to NFC tag. Please try again.')
}
} else {
setNfcStatus('error')
setErrorMessage(
'NFC is not supported by this browser. You can still copy the URL and write it to an NFC tag using a compatible device.',
)
}
}
}
const renderBody = () => {
if (nfcStatus === 'success') {
return (
<Typography level='body-md' gutterBottom>
URL written to NFC tag successfully!
</Typography>
)
}
return url
if (nfcStatus === 'waiting_for_tag') {
return (
<>
<Box
display='flex'
flexDirection='column'
alignItems='center'
gap={2}
py={3}
>
<CircularProgress size='lg' />
<Typography level='body-md' textAlign='center'>
Hold your device near the NFC tag
</Typography>
</Box>
<Button
variant='outlined'
color='neutral'
fullWidth
onClick={handleCancel}
>
Cancel
</Button>
</>
)
}
return (
<>
<Typography level='body-md' gutterBottom>
{nfcStatus === 'error'
? errorMessage
: 'Press the button below to write to NFC.'}
</Typography>
<Input
value={getURL()}
fullWidth
readOnly
label='URL'
sx={{ mt: 1 }}
endDecorator={
<CopyAll
sx={{ cursor: 'pointer' }}
onClick={() => {
navigator.clipboard.writeText(getURL())
alert('URL copied to clipboard!')
}}
/>
}
/>
<ListItem>
<Checkbox
checked={isAutoCompleteWhenScan}
onChange={e => setIsAutoCompleteWhenScan(e.target.checked)}
label='Auto-complete when scanned'
/>
</ListItem>
<Box display='flex' justifyContent='space-around' mt={1}>
<Button
size='lg'
onClick={writeToNFC}
fullWidth
disabled={nfcStatus === 'writing'}
>
Write NFC
</Button>
</Box>
</>
)
}
return (
<ResponsiveModal open={config?.isOpen} onClose={handleClose}>
<Typography level='h4' mb={1}>
{nfcStatus === 'success' ? 'Success!' : 'Write to NFC'}
</Typography>
{nfcStatus === 'success' ? (
<Typography level='body-md' gutterBottom>
URL written to NFC tag successfully!
</Typography>
) : (
<>
<Typography level='body-md' gutterBottom>
{nfcStatus === 'error'
? errorMessage
: 'Press the button below to write to NFC.'}
</Typography>
<Input
value={getURL()}
fullWidth
readOnly
label='URL'
sx={{ mt: 1 }}
endDecorator={
<CopyAll
sx={{ cursor: 'pointer' }}
onClick={() => {
navigator.clipboard.writeText(getURL())
alert('URL copied to clipboard!')
}}
/>
}
/>
<ListItem>
<Checkbox
checked={isAutoCompleteWhenScan}
onChange={e => setIsAutoCompleteWhenScan(e.target.checked)}
label='Auto-complete when scanned'
/>
</ListItem>
<Box display={'flex'} justifyContent={'space-around'} mt={1}>
<Button
size='lg'
onClick={() => writeToNFC(getURL())}
fullWidth
sx={{ mr: 1 }}
disabled={nfcStatus === 'writing'}
>
Write NFC
</Button>
<Button size='lg' onClick={requestNFCAccess} variant='outlined'>
Request Access
</Button>
</Box>
</>
)}
{renderBody()}
</ResponsiveModal>
)
}

View File

@@ -1,5 +1,3 @@
import React from 'react'
const PrivacyPolicyView = () => {
return (
<div>

View File

@@ -29,7 +29,10 @@ const AccountSettings = () => {
async function configurePurchases() {
if (Capacitor.isNativePlatform() && userProfile) {
await Purchases.configure({
apiKey: import.meta.env.VITE_REACT_APP_REVENUECAT_API_KEY,
apiKey:
Capacitor.getPlatform() === 'ios'
? import.meta.env.VITE_REACT_APP_REVENUECAT_API_KEY_IOS
: import.meta.env.VITE_REACT_APP_REVENUECAT_API_KEY_ANDROID,
appUserID: String(userProfile?.id),
})
}

View File

@@ -25,13 +25,12 @@ import SettingsLayout from './SettingsLayout'
const ProfileSettings = () => {
const { t } = useTranslation('settings')
const queryClient = useQueryClient()
const { data: userProfile } = useUserProfile()
const { data: userProfile, refetch: refetchUserProfile } = useUserProfile()
const { showSuccess, showError } = useNotification()
const [displayName, setDisplayName] = useState(userProfile?.displayName || '')
const [timezone, setTimezone] = useState(
userProfile?.timezone || Intl.DateTimeFormat().resolvedOptions().timeZone,
)
const [photoURL, setPhotoURL] = useState(userProfile?.image || '')
const [isUploading, setIsUploading] = useState(false)
const [isSaving, setIsSaving] = useState(false)
const fileInputRef = useRef()
@@ -89,10 +88,9 @@ const ProfileSettings = () => {
formData.append('file', compressedFile, 'profile.jpg')
const response = await apiClient.upload('/users/profile_photo', formData)
if (!response.ok) throw new Error('Upload failed')
const data = await response.json()
const url = resolvePhotoURL(data.url || data.sign)
await response.json()
setPhotoURL(url)
refetchUserProfile() // Refresh user profile to get the new photoURL
showSuccess({
title: t('profile.photoUpdated'),
message: t('profile.photoUpdatedMessage'),
@@ -155,7 +153,7 @@ const ProfileSettings = () => {
maxWidth: 400,
}}
>
<Avatar src={photoURL} sx={{ width: 64, height: 64 }} />
<Avatar src={resolvePhotoURL(userProfile?.image)} sx={{ width: 64, height: 64 }} />
<Box sx={{ flex: 1 }}>
<Button
variant='soft'

View File

@@ -139,7 +139,10 @@ const Settings = () => {
async function configurePurchases() {
if (Capacitor.isNativePlatform() && userProfile) {
await Purchases.configure({
apiKey: import.meta.env.VITE_REACT_APP_REVENUECAT_API_KEY,
apiKey:
Capacitor.getPlatform() === 'ios'
? import.meta.env.VITE_REACT_APP_REVENUECAT_API_KEY_IOS
: import.meta.env.VITE_REACT_APP_REVENUECAT_API_KEY_ANDROID,
appUserID: String(userProfile?.id),
})
}

View File

@@ -2,14 +2,17 @@ import { Cell, Pie, PieChart, Tooltip } from 'recharts'
import {
AccessTime,
CalendarMonth,
Check,
Checklist,
EventBusy,
EventNote,
Group,
HourglassEmpty,
Person,
Redo,
RunningWithErrors,
Schedule,
Style,
ThumbDown,
Timeline,
Toll,
@@ -24,23 +27,27 @@ import {
Divider,
Grid,
Link,
Option,
Select,
Stack,
Tab,
TabList,
Tabs,
Typography,
} from '@mui/joy'
import React, { useEffect, useState } from 'react'
import React, { useEffect, useMemo, useState } from 'react'
import FilterBar from '../../components/common/FilterBar'
import { useFilter } from '../../hooks/useFilter'
import { useLocalization } from '../../contexts/LocalizationContext'
import { useChores, useChoresHistory } from '../../queries/ChoreQueries'
import {
useChores,
useChoresHistory,
useDeleteChoreHistory,
useUpdateChoreHistory,
} from '../../queries/ChoreQueries'
import EditHistoryModal from '../Modals/EditHistoryModal'
import HistoryDetailModal from '../Modals/HistoryDetailModal'
import NoteViewerModal from '../Modals/Inputs/NoteViewerModal'
import { useCircleMembers, useUserProfile } from '../../queries/UserQueries.jsx'
import { useLabels } from '../Labels/LabelQueries'
import { ChoresGrouper } from '../../utils/Chores'
import { COLORS, TASK_COLOR } from '../../utils/Colors.jsx'
import { resolvePhotoURL } from '../../utils/Helpers.jsx'
import LoadingComponent from '../components/Loading'
const groupByDate = history => {
@@ -58,47 +65,57 @@ const groupByDate = history => {
return aggregated
}
const ChoreHistoryItem = ({ time, name, points, status, performer, notes, onViewNote }) => {
const getStatusIcon = status => {
switch (status) {
case 0:
return <AccessTime color='primary' />
case 1:
return <Check color='success' />
case 2:
return <Redo color='warning' />
case 3:
return <HourglassEmpty color='neutral' />
case 4:
return <ThumbDown color='error' />
case 5:
return <RunningWithErrors color='error' />
case 6:
return <Schedule color='warning' />
default:
return <Check color='success' />
}
}
const statusConfig = {
0: { color: 'primary', icon: <AccessTime /> },
1: { color: 'success', icon: <Check /> },
2: { color: 'warning', icon: <Redo /> },
3: { color: 'neutral', icon: <HourglassEmpty /> },
4: { color: 'danger', icon: <ThumbDown /> },
5: { color: 'danger', icon: <RunningWithErrors /> },
6: { color: 'warning', icon: <Schedule /> },
}
const ChoreHistoryItem = ({
time,
name,
points,
status,
notes,
onViewNote,
onViewDetails,
}) => {
const cfg = statusConfig[status] ?? statusConfig[1]
return (
<Stack direction='row' alignItems='center' spacing={2}>
<Stack
direction='row'
alignItems='center'
spacing={1}
onClick={onViewDetails}
sx={{
cursor: onViewDetails ? 'pointer' : 'default',
borderRadius: 'sm',
'&:hover': onViewDetails
? { backgroundColor: 'background.level1' }
: {},
}}
>
<Typography level='body-md' sx={{ minWidth: 80 }}>
{time}
</Typography>
<Box
<Avatar
size='sm'
color={cfg.color}
variant='soft'
sx={{
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
minWidth: 32,
minHeight: 32,
borderRadius: '50%',
backgroundColor: 'background.level2',
boxShadow: 'sm',
width: 32,
height: 32,
flexShrink: 0,
'& svg': { fontSize: '16px' },
}}
>
{getStatusIcon(status)}
</Box>
{cfg.icon}
</Avatar>
<Box
sx={{
display: 'flex',
@@ -143,25 +160,18 @@ const ChoreHistoryItem = ({ time, name, points, status, performer, notes, onView
)
}
const ChoreHistoryTimeline = ({ history, onViewNote }) => {
const ChoreHistoryTimeline = ({
history,
performers,
onViewNote,
onViewDetails,
}) => {
const { fmt } = useLocalization()
const groupedHistory = groupByDate(history)
const sortedEntries = Object.entries(groupedHistory).sort(
([a], [b]) => new Date(b) - new Date(a),
)
return (
<Container sx={{ p: 2 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2, mb: 2 }}>
<Timeline sx={{ fontSize: '1.5rem', color: 'primary.500' }} />
<Typography level='h4' sx={{ fontWeight: 'lg', color: 'text.primary' }}>
Activities Timeline
</Typography>
</Box>
<Box sx={{ py: 2, width: '100%' }}>
{Object.entries(groupedHistory).map(([date, items]) => (
<Box key={date} sx={{ mb: 4 }}>
<Typography level='title-sm' sx={{ mb: 0.5 }}>
@@ -170,25 +180,21 @@ const ChoreHistoryTimeline = ({ history, onViewNote }) => {
<Divider />
<Stack spacing={1}>
{items.map(record => (
<>
<ChoreHistoryItem
key={record.id}
time={fmt.time(
record.performedAt || record.updatedAt,
)}
name={record.choreName}
points={record.points}
status={record.status}
notes={record.notes}
onViewNote={onViewNote}
/>
</>
<ChoreHistoryItem
key={record.id}
time={fmt.time(record.performedAt || record.updatedAt)}
name={record.choreName}
points={record.points}
status={record.status}
notes={record.notes}
onViewNote={onViewNote}
onViewDetails={() => onViewDetails?.(record, performers)}
/>
))}
</Stack>
</Box>
))}
</Container>
</Box>
)
}
@@ -402,6 +408,11 @@ const UserActivites = () => {
const [enrichedHistory, setEnrichedHistory] = React.useState([])
const [selectedChart, setSelectedChart] = React.useState('history')
const [noteViewerConfig, setNoteViewerConfig] = useState({ isOpen: false })
const [detailModalConfig, setDetailModalConfig] = useState({ isOpen: false })
const [editModalConfig, setEditModalConfig] = useState({ isOpen: false })
const [editHistoryRecord, setEditHistoryRecord] = useState(null)
const updateChoreHistory = useUpdateChoreHistory()
const deleteChoreHistory = useDeleteChoreHistory()
const [historyPieChartData, setHistoryPieChartData] = React.useState([])
const [choreDuePieChartData, setChoreDuePieChartData] = React.useState([])
@@ -416,6 +427,7 @@ const UserActivites = () => {
choresAssigneeBreakdownChartData,
setChoresAssigneeBreakdownChartData,
] = React.useState([])
const { data: userLabels } = useLabels()
const { data: choresData, isLoading: isChoresLoading } = useChores(true)
const {
data: choresHistory,
@@ -432,6 +444,142 @@ const UserActivites = () => {
}
}, [circleMembersData])
// Client-side filters applied on top of the user+time-window slice
const clientFilterDefs = useMemo(
() => [
{
id: 'status',
label: 'Status',
type: 'multi-select',
icon: <Checklist />,
options: [
{ value: 1, label: 'Completed', color: 'success', icon: <Check sx={{ fontSize: 14 }} /> },
{ value: 2, label: 'Skipped', color: 'warning', icon: <Redo sx={{ fontSize: 14 }} /> },
{ value: 3, label: 'Pending', color: 'neutral', icon: <HourglassEmpty sx={{ fontSize: 14 }} /> },
{ value: 4, label: 'Rejected', color: 'danger', icon: <ThumbDown sx={{ fontSize: 14 }} /> },
{ value: 5, label: 'Missed', color: 'danger', icon: <RunningWithErrors sx={{ fontSize: 14 }} /> },
{ value: 6, label: 'Rescheduled', color: 'warning', icon: <Schedule sx={{ fontSize: 14 }} /> },
],
filterFn: (item, values) => values.includes(item.status),
},
...(userLabels?.length > 0
? [
{
id: 'label',
label: 'Labels',
type: 'multi-select',
icon: <Style />,
options: userLabels.map(l => ({
value: l.id,
label: l.name,
icon: (
<Box
component='span'
sx={{
display: 'inline-block',
width: 10,
height: 10,
borderRadius: '50%',
bgcolor: l.color || '#90a4ae',
flexShrink: 0,
}}
/>
),
})),
filterFn: (item, values) =>
item.labelsV2?.some(l => values.includes(l.id)) ?? false,
},
]
: []),
{
id: 'hasNotes',
label: 'Has Notes',
type: 'boolean',
icon: <EventNote />,
filterFn: item => !!item.notes,
},
{
id: 'hasPoints',
label: 'Has Points',
type: 'boolean',
icon: <Toll />,
filterFn: item => (item.points ?? 0) > 0,
},
],
[userLabels],
)
const {
filteredData: filteredTimeline,
activeFilters: clientActiveFilters,
setFilter: setClientFilter,
clearAll: clearClientFilters,
} = useFilter(selectedHistory, clientFilterDefs)
// All filter defs merged for FilterBar display
const filterDefs = useMemo(
() => [
{
id: 'timePeriod',
label: 'Time Period',
type: 'single-select',
icon: <CalendarMonth />,
defaultValue: 7,
options: [
{ value: 7, label: '7 Days' },
{ value: 30, label: '30 Days' },
{ value: 90, label: '90 Days' },
{ value: 365, label: 'All Time' },
],
},
{
id: 'completedBy',
label: 'User',
type: 'single-select',
icon: <Person />,
options: circleUsers.map(u => ({
value: u.userId,
label: u.displayName,
avatar: u.image,
})),
},
...clientFilterDefs,
],
[circleUsers, clientFilterDefs],
)
// Merge server-driven and client-driven active filter states for the bar
const activeFilters = useMemo(
() => ({
timePeriod: tabValue,
...(selectedUser !== 'all' ? { completedBy: selectedUser } : {}),
...clientActiveFilters,
}),
[tabValue, selectedUser, clientActiveFilters],
)
const handleSetFilter = (id, value) => {
if (id === 'completedBy') {
const userId = value ?? 'all'
setSelectedUser(userId)
setSelectedHistory(enrichedHistory.filter(h => USER_FILTER(h, userId)))
} else if (id === 'timePeriod') {
const days = value ?? 7
setTabValue(days)
refetchHistory(days)
} else {
setClientFilter(id, value)
}
}
const handleClearAll = () => {
setSelectedUser('all')
setSelectedHistory(enrichedHistory)
setTabValue(7)
refetchHistory(7)
clearClientFilters()
}
useEffect(() => {
if (
!isChoresHistoryLoading &&
@@ -444,6 +592,7 @@ const UserActivites = () => {
return {
...item,
choreName: chore?.name,
labelsV2: chore?.labelsV2,
}
})
setEnrichedHistory(enrichedHistory)
@@ -823,222 +972,24 @@ const UserActivites = () => {
}}
>
<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' }}
>
User Activities
</Typography>
<Typography level='body-sm' sx={{ color: 'text.secondary' }}>
Overview of user activities and task statistics
</Typography>
</Stack>
</Box>
{/* Filter Controls - Always visible */}
<Card
variant='outlined'
sx={{
width: '100%',
p: 2,
mb: 3,
borderRadius: 12,
background:
'linear-gradient(135deg, rgba(255,255,255,0.1) 0%, rgba(255,255,255,0.05) 100%)',
backdropFilter: 'blur(10px)',
}}
>
<Stack spacing={2}>
<Typography level='title-sm' sx={{ color: 'text.secondary' }}>
Filter Activities
</Typography>
<Stack
direction={{ xs: 'column', sm: 'row' }}
spacing={2}
alignItems={{ xs: 'stretch', sm: 'center' }}
>
{/* User Filter */}
<Box sx={{ flex: 1, minWidth: 200 }}>
<Typography level='body-sm' sx={{ mb: 1, fontWeight: 500 }}>
Show activities for:
</Typography>
<Select
sx={{
width: '100%',
}}
variant='outlined'
value={selectedUser}
onChange={(e, selected) => {
setSelectedUser(selected)
setSelectedHistory(
enrichedHistory.filter(h => USER_FILTER(h, selected)),
)
}}
renderValue={() => {
if (selectedUser === undefined || selectedUser === 'all') {
return (
<Typography
startDecorator={
<Avatar color='primary' size='sm'>
<Group />
</Avatar>
}
>
All Users
</Typography>
)
}
return (
<Typography
startDecorator={
<Avatar
color='primary'
size='sm'
src={resolvePhotoURL(
circleUsers.find(
user => user.userId === selectedUser,
)?.image,
)}
>
{circleUsers
.find(user => user.userId === selectedUser)
?.displayName?.charAt(0)}
</Avatar>
}
>
{
circleUsers.find(user => user.userId === selectedUser)
?.displayName
}
</Typography>
)
}}
>
<Option value='all'>
<Typography
startDecorator={
<Avatar color='primary' size='sm'>
<Group />
</Avatar>
}
>
All Users
</Typography>
</Option>
{circleUsers.map(user => (
<Option key={user.userId} value={user.userId}>
<Avatar
color='primary'
size='sm'
src={resolvePhotoURL(user.image)}
>
{user.displayName?.charAt(0)}
</Avatar>
<Typography>{user.displayName}</Typography>
<Chip
color='success'
size='sm'
variant='soft'
startDecorator={<Toll />}
>
{user.points - user.pointsRedeemed}
</Chip>
</Option>
))}
</Select>
</Box>
{/* Time Period Filter */}
<Box sx={{ flex: 1, minWidth: 200 }}>
<Typography level='body-sm' sx={{ mb: 1, fontWeight: 500 }}>
Time period:
</Typography>
<Tabs
onChange={(e, tabValue) => {
setTabValue(tabValue)
refetchHistory(tabValue)
}}
value={tabValue}
sx={{
borderRadius: 8,
backgroundColor: 'background.surface',
border: '1px solid',
borderColor: 'divider',
}}
>
<TabList
disableUnderline
sx={{
borderRadius: 8,
backgroundColor: 'transparent',
p: 0.5,
gap: 0.5,
}}
>
{[
{ label: '7 Days', value: 7 },
{ label: '30 Days', value: 30 },
{ label: '90 Days', value: 90 },
{ label: 'All Time', value: 365 },
].map((tab, index) => (
<Tab
key={index}
sx={{
borderRadius: 6,
minWidth: 'auto',
px: 2,
py: 1,
fontSize: 'sm',
fontWeight: 500,
color: 'text.secondary',
'&.Mui-selected': {
color: 'primary.plainColor',
backgroundColor: 'primary.softBg',
fontWeight: 600,
},
'&:hover': {
backgroundColor: 'neutral.softHoverBg',
},
}}
disableIndicator
value={tab.value}
>
{tab.label}
</Tab>
))}
</TabList>
</Tabs>
</Box>
</Stack>
</Stack>
</Card>
{/* Current Filter Summary */}
<Box sx={{ mb: 3, textAlign: 'center' }}>
<Typography level='body-sm' sx={{ color: 'text.secondary' }}>
Showing activities for{' '}
<Typography
component='span'
sx={{ fontWeight: 600, color: 'primary.500' }}
>
{selectedUser === undefined || selectedUser === 'all'
? 'All Users'
: circleUsers.find(user => user.userId === selectedUser)
?.displayName || 'Unknown User'}
</Typography>{' '}
over the{' '}
<Typography
component='span'
sx={{ fontWeight: 600, color: 'primary.500' }}
>
{tabValue === 365 ? 'All Time' : `Last ${tabValue} Days`}
</Typography>
<Timeline sx={{ fontSize: '1.5rem' }} />
<Typography
level='title-md'
sx={{ fontWeight: 'lg', color: 'text.primary' }}
>
Activities
</Typography>
</Box>
<FilterBar
filterDefs={filterDefs}
activeFilters={activeFilters}
onSetFilter={handleSetFilter}
onClearAll={handleClearAll}
resultCount={filteredTimeline.length}
totalCount={selectedHistory.length}
/>
{/* Conditional Content Based on Data Availability */}
{!choresData.res?.length > 0 || !choresHistory?.length > 0 ? (
<Container
@@ -1103,7 +1054,8 @@ const UserActivites = () => {
{/* Left Side - Timeline (Mobile: Full width, Desktop: Flexible) */}
<Box sx={{ flex: 1, minWidth: 0, width: '100%' }}>
<ChoreHistoryTimeline
history={selectedHistory}
history={filteredTimeline}
performers={circleUsers}
onViewNote={notes => {
setNoteViewerConfig({
isOpen: true,
@@ -1112,19 +1064,68 @@ const UserActivites = () => {
onClose: () => setNoteViewerConfig({ isOpen: false }),
})
}}
onViewDetails={(entry, performers) => {
setDetailModalConfig({
isOpen: true,
entry,
performers,
onClose: () => setDetailModalConfig({ isOpen: false }),
onEdit: record => {
setDetailModalConfig(prev => ({ ...prev, isOpen: false }))
setEditHistoryRecord(record)
setEditModalConfig({
isOpen: true,
onClose: () => {
setEditModalConfig({ isOpen: false })
setEditHistoryRecord(null)
},
onSave: updated => {
updateChoreHistory.mutate(
{
choreId: record.choreId,
historyId: record.id,
historyData: {
performedAt: updated.performedAt,
dueDate: updated.dueDate,
notes: updated.notes,
},
},
{
onSuccess: () => {
setEditModalConfig({ isOpen: false })
setEditHistoryRecord(null)
},
},
)
},
onDelete: () => {
deleteChoreHistory.mutate(
{ choreId: record.choreId, historyId: record.id },
{
onSuccess: () => {
setEditModalConfig({ isOpen: false })
setEditHistoryRecord(null)
},
},
)
},
})
},
})
}}
/>
</Box>
{/* Right Sidebar - Charts (Mobile: Full width, Desktop: Fixed width + sticky) */}
{/* Right Sidebar - Charts (Desktop only, hidden on mobile) */}
<Box
sx={{
width: { xs: '100%', lg: '350px' },
position: { xs: 'static', lg: 'sticky' },
top: { lg: '60px' },
alignSelf: { lg: 'flex-start' },
maxHeight: { lg: 'calc(100vh - 40px)' },
overflowY: { lg: 'auto' },
order: { xs: -1, lg: 1 }, // Show charts first on mobile, last on desktop
display: { xs: 'none', lg: 'block' },
width: '350px',
position: 'sticky',
top: '60px',
alignSelf: 'flex-start',
maxHeight: 'calc(100vh - 40px)',
overflowY: 'auto',
}}
>
{/* Charts Container */}
@@ -1263,6 +1264,11 @@ const UserActivites = () => {
</>
)}
<NoteViewerModal config={noteViewerConfig} />
<HistoryDetailModal config={detailModalConfig} />
<EditHistoryModal
config={editModalConfig}
historyRecord={editHistoryRecord}
/>
</Container>
)
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,43 @@
import { Person } from '@mui/icons-material'
import BaseOptionPicker from './BaseOptionPicker'
const AssigneePickerField = ({
value = null,
onChange,
onClear,
members = [],
includeAnyone = true,
emptyDisplay,
currentUserId = null,
}) => {
const options = [
...(includeAnyone ? [{ userId: 'anyone', displayName: 'Anyone' }] : []),
...members.map(member => ({
userId: member.userId,
displayName: member.displayName || member.username || 'Unknown',
})),
]
const displayValue = currentUserId && value === currentUserId ? null : value
return (
<BaseOptionPicker
items={options}
value={displayValue}
onChange={onChange}
onClear={onClear}
emptyDisplay={emptyDisplay}
emptyLabel='Assignee'
getItemValue={item => item.userId}
getItemLabel={item => item.displayName}
renderTriggerIcon={() => <Person sx={{ fontSize: '20px' }} />}
renderItemStart={() => <Person sx={{ fontSize: '18px' }} />}
getTriggerText={({ selectedItems, isEmpty }) =>
isEmpty ? 'Assignee' : selectedItems[0].displayName
}
menuMinWidth={220}
/>
)
}
export default AssigneePickerField

View File

@@ -0,0 +1,259 @@
import { AttachFile, Close, DeleteOutline, Image } from '@mui/icons-material'
import {
Box,
Button,
CircularProgress,
IconButton,
Sheet,
Typography,
} from '@mui/joy'
import { ClickAwayListener, Popper } from '@mui/material'
import { useEffect, useRef, useState } from 'react'
import { Z_INDEX } from '../../constants/zIndex'
import { useFileUpload } from '../../hooks/useFileUpload'
const AttachmentPickerField = ({
attachments = [],
onChange,
onClear,
emptyDisplay = 'icon-text',
entityType = 'chore_attachment',
entityId,
draftId,
}) => {
const [isOpen, setIsOpen] = useState(false)
const [isUploading, setIsUploading] = useState(false)
const buttonRef = useRef(null)
const { uploadFile } = useFileUpload({ entityType, entityId, draftId })
useEffect(() => {
if (!isOpen) return
const handleEscape = e => {
if (e.key === 'Escape') setIsOpen(false)
}
document.addEventListener('keydown', handleEscape)
return () => document.removeEventListener('keydown', handleEscape)
}, [isOpen])
const handleAddFile = () => {
const input = document.createElement('input')
input.setAttribute('type', 'file')
input.setAttribute('accept', 'image/*')
input.click()
input.onchange = async () => {
const file = input.files?.[0]
if (!file) return
setIsUploading(true)
try {
const url = await uploadFile(file)
if (url) {
onChange([...attachments, { url, name: file.name }])
}
} finally {
setIsUploading(false)
}
}
}
const handleRemove = index => {
const updated = attachments.filter((_, i) => i !== index)
onChange(updated)
if (updated.length === 0) setIsOpen(false)
}
const handleClear = e => {
e.stopPropagation()
onClear?.()
setIsOpen(false)
}
const isEmpty = attachments.length === 0
const shouldShowLabel = !isEmpty || emptyDisplay === 'icon-text'
return (
<>
<Box sx={{ position: 'relative', display: 'flex', alignItems: 'center' }}>
<Button
ref={buttonRef}
size='sm'
variant={isEmpty ? 'outlined' : 'soft'}
color='neutral'
onClick={() => setIsOpen(prev => !prev)}
sx={{
borderRadius: '128px',
minHeight: 40,
minWidth: 'min-content',
px: shouldShowLabel ? 1.25 : 0.75,
gap: shouldShowLabel ? 1 : 0,
justifyContent: 'flex-start',
whiteSpace: 'nowrap',
transition: 'all 0.25s ease-in-out',
}}
>
{isUploading ? (
<CircularProgress size='sm' sx={{ '--CircularProgress-size': '16px' }} />
) : (
<AttachFile sx={{ fontSize: '20px' }} />
)}
<Typography
level='body-sm'
sx={{
whiteSpace: 'nowrap',
overflow: 'hidden',
textOverflow: 'ellipsis',
maxWidth: shouldShowLabel ? 180 : 0,
opacity: shouldShowLabel ? 1 : 0,
transform: shouldShowLabel ? 'translateX(0)' : 'translateX(-4px)',
transition:
'max-width 0.25s ease-in-out, opacity 0.2s ease-in-out, transform 0.25s ease-in-out',
}}
>
{isEmpty
? 'Attachments'
: `${attachments.length} file${attachments.length !== 1 ? 's' : ''}`}
</Typography>
</Button>
{!isEmpty && onClear && (
<IconButton
size='sm'
variant='soft'
color='danger'
onClick={handleClear}
sx={{
position: 'absolute',
top: -12,
right: -16,
zIndex: 10,
maxHeight: 18,
maxWidth: 18,
borderRadius: '50%',
'&:hover': { bgcolor: 'danger.softBg' },
}}
>
<Close sx={{ fontSize: '18px' }} />
</IconButton>
)}
</Box>
{isOpen && (
<Popper
open={isOpen}
anchorEl={buttonRef.current}
placement='top-start'
modifiers={[
{ name: 'offset', options: { offset: [0, 8] } },
{
name: 'flip',
options: { fallbackPlacements: ['bottom-start', 'top-start'] },
},
]}
sx={{ zIndex: Z_INDEX.MODAL_CLOSE_BUTTON + 1 }}
>
<ClickAwayListener onClickAway={() => setIsOpen(false)}>
<Sheet
variant='outlined'
sx={{
minWidth: 240,
maxWidth: 320,
p: 1,
borderRadius: 'md',
boxShadow: 'lg',
bgcolor: 'background.popup',
}}
>
{attachments.length > 0 && (
<Box sx={{ mb: 1, display: 'flex', flexDirection: 'column', gap: 0.5 }}>
{attachments.map((attachment, index) => (
<Box
key={index}
sx={{
display: 'flex',
alignItems: 'center',
gap: 1,
p: 0.5,
borderRadius: 'sm',
'&:hover': { bgcolor: 'background.level1' },
}}
>
<Box
component='img'
src={attachment.url}
alt={attachment.name}
sx={{
width: 36,
height: 36,
objectFit: 'cover',
borderRadius: 'sm',
flexShrink: 0,
bgcolor: 'background.level2',
}}
onError={e => {
e.target.style.display = 'none'
e.target.nextSibling.style.display = 'flex'
}}
/>
<Box
sx={{
display: 'none',
width: 36,
height: 36,
alignItems: 'center',
justifyContent: 'center',
borderRadius: 'sm',
bgcolor: 'background.level2',
flexShrink: 0,
}}
>
<Image sx={{ fontSize: 20, color: 'text.tertiary' }} />
</Box>
<Typography
level='body-xs'
sx={{
flex: 1,
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
}}
>
{attachment.name}
</Typography>
<IconButton
size='sm'
variant='plain'
color='danger'
onClick={() => handleRemove(index)}
sx={{ flexShrink: 0 }}
>
<DeleteOutline sx={{ fontSize: 16 }} />
</IconButton>
</Box>
))}
</Box>
)}
<Button
fullWidth
size='sm'
variant='outlined'
color='neutral'
startDecorator={
isUploading ? (
<CircularProgress size='sm' sx={{ '--CircularProgress-size': '14px' }} />
) : (
<AttachFile sx={{ fontSize: 16 }} />
)
}
onClick={handleAddFile}
disabled={isUploading}
>
{isUploading ? 'Uploading…' : 'Add image'}
</Button>
</Sheet>
</ClickAwayListener>
</Popper>
)}
</>
)
}
export default AttachmentPickerField

View File

@@ -0,0 +1,253 @@
import { Close } from '@mui/icons-material'
import { Box, Button, IconButton, Sheet, Typography } from '@mui/joy'
import { ClickAwayListener, Popper } from '@mui/material'
import { useEffect, useMemo, useRef, useState } from 'react'
import { Z_INDEX } from '../../constants/zIndex'
const BaseOptionPicker = ({
items = [],
value = null,
values = [],
multiple = false,
onChange,
onValuesChange,
emptyDisplay = 'icon',
emptyLabel = 'Select',
placement = 'top-start',
menuMinWidth = 180,
menuMaxHeight = 280,
getItemValue = item => item.id,
getItemLabel = item => item.label,
renderItemStart,
renderTriggerIcon,
getItemColor,
getTriggerText,
onClear,
}) => {
const [isOpen, setIsOpen] = useState(false)
const buttonRef = useRef(null)
useEffect(() => {
if (!isOpen) return
const handleEscape = event => {
if (event.key === 'Escape') {
setIsOpen(false)
}
}
document.addEventListener('keydown', handleEscape)
return () => {
document.removeEventListener('keydown', handleEscape)
}
}, [isOpen])
const selectedItems = useMemo(() => {
if (multiple) {
const selectedSet = new Set(values)
return items.filter(item => selectedSet.has(getItemValue(item)))
}
if (value === null || value === undefined) return []
return items.filter(item => getItemValue(item) === value)
}, [items, multiple, value, values, getItemValue])
const isEmpty = selectedItems.length === 0
const shouldShowLabel = !isEmpty || emptyDisplay === 'icon-text'
const triggerText = getTriggerText
? getTriggerText({ selectedItems, isEmpty })
: isEmpty
? emptyLabel
: getItemLabel(selectedItems[0])
const triggerColor = isEmpty
? undefined
: getItemColor
? getItemColor(selectedItems[0])
: undefined
const handleSelect = selectedValue => {
if (multiple) {
const selectedSet = new Set(values)
if (selectedSet.has(selectedValue)) {
selectedSet.delete(selectedValue)
} else {
selectedSet.add(selectedValue)
}
onValuesChange?.(Array.from(selectedSet))
return
}
onChange?.(selectedValue)
setIsOpen(false)
}
const isSelected = item => {
const optionValue = getItemValue(item)
if (multiple) {
return values.includes(optionValue)
}
return value === optionValue
}
const handleClear = e => {
e.stopPropagation()
onClear?.()
}
return (
<>
<Box
sx={{
position: 'relative',
display: 'flex',
alignItems: 'center',
}}
>
<Button
ref={buttonRef}
size={'sm'}
variant={isEmpty ? 'outlined' : 'soft'}
color='neutral'
onClick={() => setIsOpen(prev => !prev)}
sx={{
borderRadius: '128px',
minHeight: 40,
minWidth: 'min-content',
px: shouldShowLabel ? 1.25 : 0.75,
gap: shouldShowLabel ? 1 : 0,
justifyContent: 'flex-start',
whiteSpace: 'nowrap',
transition: 'all 0.25s ease-in-out',
backgroundColor: triggerColor ? `${triggerColor}20` : undefined,
borderColor: triggerColor || undefined,
color: triggerColor || undefined,
'&:hover': {
backgroundColor: triggerColor ? `${triggerColor}28` : undefined,
borderColor: triggerColor || undefined,
},
}}
>
{renderTriggerIcon?.({ selectedItems, isEmpty })}
<Typography
level='body-sm'
sx={{
whiteSpace: 'nowrap',
overflow: 'hidden',
textOverflow: 'ellipsis',
maxWidth: shouldShowLabel ? 180 : 0,
opacity: shouldShowLabel ? 1 : 0,
transform: shouldShowLabel ? 'translateX(0)' : 'translateX(-4px)',
transition:
'max-width 0.25s ease-in-out, opacity 0.2s ease-in-out, transform 0.25s ease-in-out',
}}
>
{triggerText}
</Typography>
</Button>
{!isEmpty && onClear && (
<IconButton
size='sm'
variant='soft'
color='danger'
onClick={handleClear}
sx={{
position: 'absolute',
top: -12,
right: -16,
zIndex: 10,
maxHeight: 18,
maxWidth: 18,
borderRadius: '50%',
'&:hover': {
bgcolor: 'danger.softBg',
},
}}
>
<Close sx={{ fontSize: '18px' }} />
</IconButton>
)}
</Box>
{isOpen && (
<Popper
open={isOpen}
anchorEl={buttonRef.current}
placement={placement}
modifiers={[
{
name: 'offset',
options: {
offset: [0, 8],
},
},
{
name: 'flip',
options: {
fallbackPlacements: ['bottom-start', 'top-start'],
},
},
]}
sx={{ zIndex: Z_INDEX.MODAL_CLOSE_BUTTON + 1 }}
>
<ClickAwayListener onClickAway={() => setIsOpen(false)}>
<Sheet
variant='outlined'
sx={{
minWidth: menuMinWidth,
maxHeight: menuMaxHeight,
overflowY: 'auto',
overflowX: 'hidden',
p: 0.75,
borderRadius: 'md',
boxShadow: 'lg',
bgcolor: 'background.popup',
}}
>
{items.map((item, index) => {
const optionValue = getItemValue(item)
const selected = isSelected(item)
const itemColor = getItemColor ? getItemColor(item) : undefined
return (
<Button
key={optionValue ?? index}
variant={selected ? 'soft' : 'plain'}
color='neutral'
onClick={() => handleSelect(optionValue)}
sx={{
width: '100%',
display: 'flex',
justifyContent: 'flex-start',
gap: 1,
whiteSpace: 'nowrap',
mb: index === items.length - 1 ? 0 : 0.5,
color: selected
? itemColor || 'text.primary'
: 'text.primary',
}}
>
{renderItemStart?.({ item, selected })}
<Typography
level='body-sm'
sx={{
whiteSpace: 'nowrap',
overflow: 'hidden',
textOverflow: 'ellipsis',
}}
>
{getItemLabel(item)}
</Typography>
</Button>
)
})}
</Sheet>
</ClickAwayListener>
</Popper>
)}
</>
)
}
export default BaseOptionPicker

View File

@@ -1,8 +1,10 @@
import {
Archive,
ArrowBack,
Cancel,
CopyAll,
Delete,
DriveFileMove,
Edit,
ManageSearch,
MoreTime,
@@ -20,10 +22,25 @@ import {
WbSunny,
Weekend,
} from '@mui/icons-material'
import { Divider, IconButton, Menu, MenuItem, Tooltip } from '@mui/joy'
import {
Avatar,
Divider,
IconButton,
ListItemContent,
ListItemDecorator,
Menu,
MenuItem,
Tooltip,
Typography,
} from '@mui/joy'
import React, { useEffect, useState } from 'react'
import { useNavigate } from 'react-router-dom'
import LABEL_COLORS, {
getTextColorFromBackgroundColor,
} from '../../utils/Colors'
import { isOfficialDonetickInstanceSync } from '../../utils/FeatureToggle'
import { getIconComponent } from '../../utils/ProjectIcons'
import { useProjects } from '../Projects/ProjectQueries'
const ChoreActionMenu = ({
chore,
@@ -43,10 +60,11 @@ const ChoreActionMenu = ({
}) => {
const [anchorEl, setAnchorEl] = React.useState(null)
const [isOfficialInstance, setIsOfficialInstance] = useState(false)
const [showProjectPicker, setShowProjectPicker] = useState(false)
const menuRef = React.useRef(null)
const navigate = useNavigate()
const { data: projects = [] } = useProjects()
// Check if this is the official donetick.com instance
useEffect(() => {
try {
setIsOfficialInstance(isOfficialDonetickInstanceSync())
@@ -83,6 +101,12 @@ const ChoreActionMenu = ({
const handleMenuClose = () => {
setAnchorEl(null)
setShowProjectPicker(false)
}
const handleMoveToProject = project => {
onAction?.('moveToProject', chore, { project })
handleMenuClose()
}
const handleEdit = () => {
@@ -134,7 +158,6 @@ const ChoreActionMenu = ({
switch (option) {
case 'today': {
// Schedule for today at the next available slot: 9am, 12pm, 5pm, or now if after 5pm
const nowHour = now.getHours()
const scheduled = new Date(today)
if (nowHour < 9) {
@@ -144,7 +167,6 @@ const ChoreActionMenu = ({
} else if (nowHour < 17) {
scheduled.setHours(17, 0, 0, 0)
} else {
// After 5pm, use current time
scheduled.setHours(
now.getHours(),
now.getMinutes(),
@@ -163,7 +185,7 @@ const ChoreActionMenu = ({
case 'tomorrow': {
const tomorrow = new Date(today)
tomorrow.setDate(today.getDate() + 1)
tomorrow.setHours(12, 0, 0, 0) // Set to noon
tomorrow.setHours(12, 0, 0, 0)
return tomorrow
}
case 'tomorrow-afternoon': {
@@ -195,6 +217,18 @@ const ChoreActionMenu = ({
handleMenuClose()
}
const renderProjectAvatar = (color, icon) => {
const bg = color || LABEL_COLORS[0].value
const IconComponent = getIconComponent(icon || 'FolderOpen')
return (
<Avatar size='sm' sx={{ width: 22, height: 22, backgroundColor: bg }}>
<IconComponent
sx={{ fontSize: 13, color: getTextColorFromBackgroundColor(bg) }}
/>
</Avatar>
)
}
return (
<>
<IconButton
@@ -227,218 +261,267 @@ const ChoreActionMenu = ({
left: '50%',
}}
>
<MenuItem
onClick={e => {
e.stopPropagation()
onCompleteWithNote?.()
handleMenuClose()
}}
>
<NoteAdd />
Complete with note
</MenuItem>
<MenuItem
onClick={e => {
e.stopPropagation()
onCompleteWithPastDate?.()
handleMenuClose()
}}
>
<Update />
Complete in past
</MenuItem>
<MenuItem
onClick={e => {
e.stopPropagation()
handleSkip()
}}
>
<SwitchAccessShortcut />
Skip to next due date
</MenuItem>
<MenuItem
onClick={e => {
e.stopPropagation()
onChangeAssignee?.()
handleMenuClose()
}}
>
<RecordVoiceOver />
Delegate to someone else
</MenuItem>
{isOfficialInstance && (
<MenuItem
onClick={e => {
e.stopPropagation()
onNudge?.()
handleMenuClose()
}}
>
<Notifications />
Send nudge
</MenuItem>
)}
<Divider />
<MenuItem
onClick={e => {
e.stopPropagation()
handleHistory()
}}
>
<ManageSearch />
History
</MenuItem>
<Divider />
<MenuItem
sx={{
display: 'flex',
justifyContent: 'space-around',
alignItems: 'center',
gap: 1,
cursor: 'default',
'&:hover': {
backgroundColor: 'transparent',
},
}}
onClick={e => e.stopPropagation()}
>
<Tooltip title='Today' placement='top'>
<IconButton
size='sm'
{showProjectPicker ? (
<>
<MenuItem
onClick={e => {
e.stopPropagation()
handleQuickSchedule('today')
setShowProjectPicker(false)
}}
sx={{ gap: 1 }}
>
<Today />
</IconButton>
</Tooltip>
<Tooltip title='Tomorrow' placement='top'>
<IconButton
size='sm'
<ArrowBack fontSize='small' />
<Typography level='body-sm' fontWeight={600}>
Move to project
</Typography>
</MenuItem>
<Divider />
<MenuItem
onClick={e => {
e.stopPropagation()
handleQuickSchedule('tomorrow')
handleMoveToProject({ id: null, name: 'Default Project' })
}}
>
<WbSunny />
</IconButton>
</Tooltip>
{/* <Tooltip title='Tomorrow afternoon' placement='top'>
<IconButton
size='sm'
<ListItemDecorator>
{renderProjectAvatar(LABEL_COLORS[0].value, 'FolderOpen')}
</ListItemDecorator>
<ListItemContent>
<Typography level='body-sm'>Default Project</Typography>
</ListItemContent>
</MenuItem>
{projects.map(project => (
<MenuItem
key={project.id}
onClick={e => {
e.stopPropagation()
handleMoveToProject(project)
}}
>
<ListItemDecorator>
{renderProjectAvatar(project.color, project.icon)}
</ListItemDecorator>
<ListItemContent>
<Typography level='body-sm'>{project.name}</Typography>
</ListItemContent>
</MenuItem>
))}
</>
) : (
<>
<MenuItem
onClick={e => {
e.stopPropagation()
handleQuickSchedule('tomorrow-afternoon')
onCompleteWithNote?.()
handleMenuClose()
}}
>
<WbTwilight />
</IconButton>
</Tooltip> */}
<Tooltip title='Weekend' placement='top'>
<IconButton
size='sm'
<NoteAdd />
Complete with note
</MenuItem>
<MenuItem
onClick={e => {
e.stopPropagation()
handleQuickSchedule('weekend')
onCompleteWithPastDate?.()
handleMenuClose()
}}
>
<Weekend />
</IconButton>
</Tooltip>
<Tooltip title='Next week' placement='top'>
<IconButton
size='sm'
<Update />
Complete in past
</MenuItem>
<MenuItem
onClick={e => {
e.stopPropagation()
handleQuickSchedule('next-week')
handleSkip()
}}
>
<NextWeek />
</IconButton>
</Tooltip>
<Tooltip title='Remove due date' placement='top'>
<IconButton
size='sm'
<SwitchAccessShortcut />
Skip to next due date
</MenuItem>
<MenuItem
onClick={e => {
e.stopPropagation()
onChangeAssignee?.()
handleMenuClose()
}}
>
<RecordVoiceOver />
Delegate to someone else
</MenuItem>
{isOfficialInstance && (
<MenuItem
onClick={e => {
e.stopPropagation()
onNudge?.()
handleMenuClose()
}}
>
<Notifications />
Send nudge
</MenuItem>
)}
<Divider />
<MenuItem
onClick={e => {
e.stopPropagation()
handleHistory()
}}
>
<ManageSearch />
History
</MenuItem>
<Divider />
<MenuItem
sx={{
display: 'flex',
justifyContent: 'space-around',
alignItems: 'center',
gap: 1,
cursor: 'default',
'&:hover': {
backgroundColor: 'transparent',
},
}}
onClick={e => e.stopPropagation()}
>
<Tooltip title='Today' placement='top'>
<IconButton
size='sm'
onClick={e => {
e.stopPropagation()
handleQuickSchedule('today')
}}
>
<Today />
</IconButton>
</Tooltip>
<Tooltip title='Tomorrow' placement='top'>
<IconButton
size='sm'
onClick={e => {
e.stopPropagation()
handleQuickSchedule('tomorrow')
}}
>
<WbSunny />
</IconButton>
</Tooltip>
<Tooltip title='Weekend' placement='top'>
<IconButton
size='sm'
onClick={e => {
e.stopPropagation()
handleQuickSchedule('weekend')
}}
>
<Weekend />
</IconButton>
</Tooltip>
<Tooltip title='Next week' placement='top'>
<IconButton
size='sm'
onClick={e => {
e.stopPropagation()
handleQuickSchedule('next-week')
}}
>
<NextWeek />
</IconButton>
</Tooltip>
<Tooltip title='Remove due date' placement='top'>
<IconButton
size='sm'
color='neutral'
onClick={e => {
e.stopPropagation()
handleQuickSchedule('remove')
}}
>
<Cancel />
</IconButton>
</Tooltip>
</MenuItem>
<Divider />
<MenuItem
onClick={e => {
e.stopPropagation()
onChangeDueDate?.()
handleMenuClose()
}}
>
<MoreTime />
Change due date
</MenuItem>
<MenuItem
onClick={e => {
e.stopPropagation()
onWriteNFC?.()
handleMenuClose()
}}
>
<Nfc />
Write to NFC
</MenuItem>
<MenuItem
onClick={e => {
e.stopPropagation()
handleEdit()
}}
>
<Edit />
Edit
</MenuItem>
<MenuItem
onClick={e => {
e.stopPropagation()
handleClone()
}}
>
<CopyAll />
Clone
</MenuItem>
<MenuItem
onClick={e => {
e.stopPropagation()
handleView()
}}
>
<ViewCarousel />
View
</MenuItem>
<MenuItem
onClick={e => {
e.stopPropagation()
handleArchive()
}}
color='neutral'
>
{chore.isActive ? <Archive /> : <Unarchive />}
{chore.isActive ? 'Archive' : 'Unarchive'}
</MenuItem>
{projects.length > 0 && (
<MenuItem
onClick={e => {
e.stopPropagation()
setShowProjectPicker(true)
}}
>
<DriveFileMove />
Move to project
</MenuItem>
)}
<Divider />
<MenuItem
onClick={e => {
e.stopPropagation()
handleQuickSchedule('remove')
handleDelete()
}}
color='danger'
>
<Cancel />
</IconButton>
</Tooltip>
</MenuItem>
<Divider />
<MenuItem
onClick={e => {
e.stopPropagation()
onChangeDueDate?.()
handleMenuClose()
}}
>
<MoreTime />
Change due date
</MenuItem>
<MenuItem
onClick={e => {
e.stopPropagation()
onWriteNFC?.()
handleMenuClose()
}}
>
<Nfc />
Write to NFC
</MenuItem>
<MenuItem
onClick={e => {
e.stopPropagation()
handleEdit()
}}
>
<Edit />
Edit
</MenuItem>
<MenuItem
onClick={e => {
e.stopPropagation()
handleClone()
}}
>
<CopyAll />
Clone
</MenuItem>
<MenuItem
onClick={e => {
e.stopPropagation()
handleView()
}}
>
<ViewCarousel />
View
</MenuItem>
<MenuItem
onClick={e => {
e.stopPropagation()
handleArchive()
}}
color='neutral'
>
{chore.isActive ? <Archive /> : <Unarchive />}
{chore.isActive ? 'Archive' : 'Unarchive'}
</MenuItem>
<Divider />
<MenuItem
onClick={e => {
e.stopPropagation()
handleDelete()
}}
color='danger'
>
<Delete />
Delete
</MenuItem>
<Delete />
Delete
</MenuItem>
</>
)}
</Menu>
</>
)

View File

@@ -0,0 +1,628 @@
import {
Bedtime,
CalendarMonth,
Close,
EventNote,
LightMode,
NextWeek,
NightsStay,
Today,
WbSunny,
WbTwilight,
Weekend,
} from '@mui/icons-material'
import {
Box,
Button,
Checkbox,
IconButton,
Input,
List,
ListItem,
Typography,
} from '@mui/joy'
import moment from 'moment'
import { useEffect, useMemo, useState } from 'react'
import Calendar from 'react-calendar'
import { useLocalization } from '../../contexts/LocalizationContext'
import { useResponsiveModal } from '../../hooks/useResponsiveModal'
const DueDatePickerField = ({
dueDateOnly,
dueTime,
useCustomTime,
onDueDateChange,
onDueTimeChange,
onUseCustomTimeChange,
onClear,
emptyDisplay = 'icon-text',
size = 'sm',
}) => {
const [isOpen, setIsOpen] = useState(false)
const { ResponsiveModal } = useResponsiveModal()
const { firstDayOfWeek } = useLocalization()
// Local buffered state — only committed on Apply
const [localDueDateOnly, setLocalDueDateOnly] = useState(dueDateOnly)
const [localDueTime, setLocalDueTime] = useState(dueTime)
const [localUseCustomTime, setLocalUseCustomTime] = useState(useCustomTime)
// Sync local state from props whenever the modal opens
useEffect(() => {
if (isOpen) {
setLocalDueDateOnly(dueDateOnly)
setLocalDueTime(dueTime)
setLocalUseCustomTime(useCustomTime)
}
}, [isOpen, dueDateOnly, dueTime, useCustomTime])
const calendarType =
firstDayOfWeek === 1
? 'iso8601'
: firstDayOfWeek === 6
? 'islamic'
: 'gregory'
const pillListSx = {
'--List-gap': '8px',
'--ListItem-radius': '20px',
}
const getQuickScheduleDate = option => {
const now = new Date()
const today = new Date(now.getFullYear(), now.getMonth(), now.getDate())
switch (option) {
case 'today':
return today
case 'tomorrow': {
const tomorrow = new Date(today)
tomorrow.setDate(today.getDate() + 1)
return tomorrow
}
case 'weekend': {
const weekend = new Date(today)
const daysUntilSaturday = (6 - today.getDay() + 7) % 7 || 7
weekend.setDate(today.getDate() + daysUntilSaturday)
return weekend
}
case 'next-week': {
const nextWeek = new Date(today)
const daysUntilMonday = (1 - today.getDay() + 7) % 7 || 7
nextWeek.setDate(today.getDate() + daysUntilMonday)
return nextWeek
}
case 'next-month': {
const nextMonth = new Date(today)
nextMonth.setMonth(today.getMonth() + 1)
return nextMonth
}
default:
return today
}
}
const handleQuickSchedule = option => {
const date = getQuickScheduleDate(option)
setLocalDueDateOnly(date.toISOString().split('T')[0])
}
const handleQuickTime = timeStr => {
// Tap the active chip again to deselect it
if (localUseCustomTime && localDueTime === timeStr) {
setLocalUseCustomTime(false)
setLocalDueTime(null)
return
}
if (!localDueDateOnly) {
setLocalDueDateOnly(new Date().toISOString().split('T')[0])
}
setLocalUseCustomTime(true)
setLocalDueTime(timeStr)
}
const handleCalendarChange = selected => {
if (!selected || Array.isArray(selected)) return
setLocalDueDateOnly(moment(selected).format('YYYY-MM-DD'))
}
const handleLocalTimeInputChange = e => {
setLocalUseCustomTime(true)
setLocalDueTime(e.target.value)
}
const handleSave = () => {
onDueDateChange?.({ target: { value: localDueDateOnly || '' } })
onUseCustomTimeChange?.(localUseCustomTime)
if (localUseCustomTime && localDueTime) {
onDueTimeChange?.({ target: { value: localDueTime } })
} else {
onDueTimeChange?.({ target: { value: '' } })
}
setIsOpen(false)
}
const hasDueDate = Boolean(dueDateOnly)
const shouldShowLabel = hasDueDate || emptyDisplay === 'icon-text'
const dueDateLabel = useMemo(() => {
if (!dueDateOnly) {
return 'Due'
}
const formattedDate = moment(dueDateOnly).format('MMM D')
if (useCustomTime && dueTime) {
return `${formattedDate}, ${dueTime}`
}
return formattedDate
}, [dueDateOnly, dueTime, useCustomTime])
return (
<>
<Box
sx={{
position: 'relative',
display: 'flex',
alignItems: 'center',
}}
>
<Button
size={size}
variant={hasDueDate ? 'soft' : 'outlined'}
color='neutral'
onClick={() => setIsOpen(true)}
sx={{
minHeight: 40,
borderRadius: '128px',
minWidth: 'min-content',
px: shouldShowLabel ? 1.25 : 0.75,
gap: shouldShowLabel ? 1 : 0,
justifyContent: 'flex-start',
whiteSpace: 'nowrap',
transition: 'all 0.25s ease-in-out',
}}
>
<CalendarMonth sx={{ fontSize: '20px' }} />
<Typography
level='body-sm'
sx={{
whiteSpace: 'nowrap',
overflow: 'hidden',
textOverflow: 'ellipsis',
maxWidth: shouldShowLabel ? 220 : 0,
opacity: shouldShowLabel ? 1 : 0,
transform: shouldShowLabel ? 'translateX(0)' : 'translateX(-4px)',
transition:
'max-width 0.25s ease-in-out, opacity 0.2s ease-in-out, transform 0.25s ease-in-out',
}}
>
{dueDateLabel}
</Typography>
</Button>
{hasDueDate && onClear && (
<IconButton
size='sm'
variant='soft'
color='danger'
onClick={e => {
e.stopPropagation()
onClear?.()
}}
sx={{
position: 'absolute',
top: -12,
right: -16,
zIndex: 10,
maxHeight: 18,
maxWidth: 18,
borderRadius: '50%',
'&:hover': {
bgcolor: 'danger.softBg',
},
}}
>
<Close sx={{ fontSize: '18px' }} />
</IconButton>
)}
</Box>
<ResponsiveModal
open={isOpen}
onClose={() => setIsOpen(false)}
title='Due Date'
footer={
<Box sx={{ display: 'flex', justifyContent: 'flex-end', gap: 1 }}>
{hasDueDate && (
<Button
variant='plain'
color='danger'
size='lg'
onClick={() => {
onClear?.()
setIsOpen(false)
}}
sx={{ mr: 'auto' }}
>
Remove
</Button>
)}
<Button
variant='outlined'
color='neutral'
size='lg'
onClick={() => setIsOpen(false)}
>
Cancel
</Button>
<Button
variant='solid'
color='primary'
size='lg'
onClick={handleSave}
>
Apply
</Button>
</Box>
}
>
<Box sx={{ fontFamily: 'var(--joy-fontFamily-body)' }}>
{/* Date shortcuts */}
<Typography
level='body-xs'
sx={{
mb: 0.75,
color: 'text.tertiary',
textTransform: 'uppercase',
letterSpacing: '0.05em',
}}
>
Quick date
</Typography>
<List orientation='horizontal' wrap sx={{ ...pillListSx, mb: 1.5 }}>
{[
{
key: 'today',
label: 'Today',
icon: <Today sx={{ fontSize: 14 }} />,
},
{
key: 'tomorrow',
label: 'Tomorrow',
icon: <WbSunny sx={{ fontSize: 14 }} />,
},
{
key: 'weekend',
label: 'Weekend',
icon: <Weekend sx={{ fontSize: 14 }} />,
},
{
key: 'next-week',
label: 'Next week',
icon: <NextWeek sx={{ fontSize: 14 }} />,
},
{
key: 'next-month',
label: 'Next month',
icon: <EventNote sx={{ fontSize: 14 }} />,
},
].map(opt => {
const dateStr = getQuickScheduleDate(opt.key)
.toISOString()
.split('T')[0]
return (
<ListItem key={opt.key}>
<Checkbox
checked={localDueDateOnly === dateStr}
onClick={() => handleQuickSchedule(opt.key)}
overlay
disableIcon
variant='soft'
label={
<Box
sx={{
display: 'flex',
alignItems: 'center',
gap: 0.5,
}}
>
{opt.icon}
{opt.label}
</Box>
}
/>
</ListItem>
)
})}
</List>
{/* Time shortcuts */}
<Typography
level='body-xs'
sx={{
mb: 0.75,
color: 'text.tertiary',
textTransform: 'uppercase',
letterSpacing: '0.05em',
}}
>
Quick time
</Typography>
<List orientation='horizontal' wrap sx={{ ...pillListSx, mb: 1.5 }}>
{[
{
time: '09:00',
label: 'Morning',
icon: <LightMode sx={{ fontSize: 14 }} />,
},
{
time: '12:00',
label: 'Noon',
icon: <WbSunny sx={{ fontSize: 14 }} />,
},
{
time: '15:00',
label: 'Afternoon',
icon: <WbTwilight sx={{ fontSize: 14 }} />,
},
{
time: '18:00',
label: 'Evening',
icon: <NightsStay sx={{ fontSize: 14 }} />,
},
{
time: '22:00',
label: 'Night',
icon: <Bedtime sx={{ fontSize: 14 }} />,
},
].map(opt => (
<ListItem key={opt.time}>
<Checkbox
checked={localUseCustomTime && localDueTime === opt.time}
onClick={() => handleQuickTime(opt.time)}
overlay
disableIcon
variant='soft'
label={
<Box
sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}
>
{opt.icon}
{opt.label}
</Box>
}
/>
</ListItem>
))}
</List>
<Box
sx={{
mb: 1.5,
borderRadius: 'md',
border: '1px solid',
borderColor: 'neutral.outlinedBorder',
bgcolor: 'background.surface',
p: 1,
// Fix the height so switching views (month/year/decade) doesn't
// cause layout shift — month view with 6 rows is the tallest.
minHeight: 300,
display: 'flex',
flexDirection: 'column',
'& .react-calendar': {
flex: 1,
display: 'flex',
flexDirection: 'column',
},
'& .react-calendar__viewContainer': {
flex: 1,
display: 'flex',
flexDirection: 'column',
},
'& .react-calendar__month-view, & .react-calendar__year-view, & .react-calendar__decade-view, & .react-calendar__century-view':
{
flex: 1,
},
// Navigation row
'& .react-calendar__navigation': {
display: 'flex',
alignItems: 'center',
gap: '4px',
mb: 1,
},
// All nav buttons — large tap targets
'& .react-calendar__navigation button': {
background: 'none',
border: 'none',
borderRadius: '8px',
color: 'var(--joy-palette-text-primary)',
fontFamily: 'var(--joy-fontFamily-body)',
fontSize: '0.875rem',
fontWeight: 600,
cursor: 'pointer',
minHeight: '40px',
minWidth: '40px',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
padding: '0 8px',
transition: 'background 0.15s',
'&:hover': {
backgroundColor: 'var(--joy-palette-neutral-softBg)',
},
'&:disabled': {
opacity: 0.35,
cursor: 'default',
},
},
// Label button (month/year text) takes remaining space
'& .react-calendar__navigation__label': {
flex: 1,
fontSize: '0.9rem',
fontWeight: 700,
letterSpacing: '0.01em',
},
// Prev/next arrow buttons — slightly larger icon feel
'& .react-calendar__navigation__prev-button, & .react-calendar__navigation__next-button':
{
fontSize: '1.75rem',
},
'& .react-calendar__navigation__prev2-button, & .react-calendar__navigation__next2-button':
{
fontSize: '1.4rem',
},
// Weekday headers
'& .react-calendar__month-view__weekdays__weekday': {
fontSize: '0.7rem',
fontWeight: 600,
color: 'var(--joy-palette-text-tertiary)',
textAlign: 'center',
padding: '4px 0',
textTransform: 'uppercase',
letterSpacing: '0.04em',
},
'& .react-calendar__month-view__weekdays__weekday abbr': {
textDecoration: 'none',
},
// All tiles — shared base
'& .react-calendar__tile': {
border: 'none',
background: 'none',
color: 'var(--joy-palette-text-primary)',
fontFamily: 'var(--joy-fontFamily-body)',
fontSize: '0.8rem',
cursor: 'pointer',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
transition: 'background 0.15s',
'&:hover': {
background: 'var(--joy-palette-neutral-softBg)',
},
},
// Day tiles only — circular
'& .react-calendar__month-view__days .react-calendar__tile': {
aspectRatio: '1',
borderRadius: '50%',
},
// Month tiles (year view) — pill shape, no huge circle
'& .react-calendar__year-view .react-calendar__tile': {
borderRadius: '8px',
padding: '10px 4px',
fontSize: '0.875rem',
},
// Year tiles (decade view) — pill shape
'& .react-calendar__decade-view .react-calendar__tile': {
borderRadius: '8px',
padding: '10px 4px',
fontSize: '0.875rem',
},
// Century tiles — pill shape
'& .react-calendar__century-view .react-calendar__tile': {
borderRadius: '8px',
padding: '10px 4px',
fontSize: '0.875rem',
},
'& .react-calendar__tile--now': {
border:
'1.5px solid var(--joy-palette-primary-solidBg) !important',
color: 'var(--joy-palette-primary-solidBg) !important',
fontWeight: 700,
background: 'none !important',
},
'& .react-calendar__tile--active, & .react-calendar__tile--active:hover':
{
background: 'var(--joy-palette-primary-solidBg) !important',
color: 'var(--joy-palette-primary-solidColor) !important',
fontWeight: 700,
},
'& .react-calendar__month-view__days__day--neighboringMonth': {
color: 'var(--joy-palette-text-tertiary)',
},
'& .react-calendar__month-view__days': {
display: 'grid !important',
gridTemplateColumns: 'repeat(7, 1fr) !important',
},
'& .react-calendar__month-view__weekdays': {
display: 'grid !important',
gridTemplateColumns: 'repeat(7, 1fr) !important',
},
}}
>
<Calendar
value={
localDueDateOnly
? new Date(`${localDueDateOnly}T00:00:00`)
: null
}
calendarType={calendarType}
onChange={handleCalendarChange}
formatShortWeekday={(locale, date) =>
['Su', 'Mo', 'Tu', 'We', 'Th', 'Fr', 'Sa'][date.getDay()]
}
formatMonth={(locale, date) =>
[
'Jan',
'Feb',
'Mar',
'Apr',
'May',
'Jun',
'Jul',
'Aug',
'Sep',
'Oct',
'Nov',
'Dec',
][date.getMonth()]
}
/>
</Box>
<Typography
level='body-xs'
sx={{
mb: 0.5,
mt: 0.5,
color: 'text.tertiary',
textTransform: 'uppercase',
letterSpacing: '0.05em',
}}
>
Custom time
</Typography>
<Input
type='time'
size='sm'
value={localUseCustomTime ? localDueTime || '' : ''}
disabled={!localDueDateOnly}
onChange={handleLocalTimeInputChange}
sx={{ maxWidth: 200, mb: 1 }}
slotProps={{ input: { style: { fontFamily: 'inherit' } } }}
/>
<Box sx={{ display: 'flex', gap: 0.75, mb: 0.5 }}>
<Button
size='sm'
variant={!localUseCustomTime ? 'soft' : 'plain'}
color='neutral'
disabled={!localDueDateOnly}
onClick={() => setLocalUseCustomTime(false)}
>
Anytime
</Button>
<Button
size='sm'
variant={localUseCustomTime ? 'soft' : 'plain'}
color='neutral'
disabled={!localDueDateOnly}
onClick={() => setLocalUseCustomTime(true)}
>
Specific time
</Button>
</Box>
</Box>
</ResponsiveModal>
</>
)
}
export default DueDatePickerField

View File

@@ -0,0 +1,48 @@
import { Label } from '@mui/icons-material'
import BaseOptionPicker from './BaseOptionPicker'
const LabelsPickerField = ({
values = [],
onChange,
onClear,
labels = [],
emptyDisplay = 'icon-text',
}) => {
const options = labels.map(label => ({
id: label.id,
name: label.name,
color: label.color,
}))
return (
<BaseOptionPicker
items={options}
multiple
values={values}
onValuesChange={onChange}
onClear={onClear}
emptyDisplay={emptyDisplay}
emptyLabel='Labels'
getItemValue={item => item.id}
getItemLabel={item => item.name}
getItemColor={item => item.color}
renderTriggerIcon={() => <Label sx={{ fontSize: '20px' }} />}
renderItemStart={({ item }) => (
<Label
sx={{
fontSize: '18px',
color: item.color || 'text.secondary',
}}
/>
)}
getTriggerText={({ selectedItems, isEmpty }) => {
if (isEmpty) return 'Labels'
if (selectedItems.length === 1) return selectedItems[0].name
return `${selectedItems.length} labels`
}}
menuMinWidth={220}
/>
)
}
export default LabelsPickerField

View File

@@ -223,7 +223,7 @@ const NavBar = () => {
},
}}
>
<div>
<div className='drawer-content'>
{/* <div className='align-center flex px-5 pt-4'>
<ModalClose size='sm' sx={{ top: 'unset', right: 20 }} />
</div> */}

View File

@@ -6,17 +6,47 @@ import { networkManager } from '../../hooks/NetworkManager'
const NetworkBanner = () => {
const [isOnline, setIsOnline] = useState(networkManager.isOnline)
const [offlineReason, setOfflineReason] = useState(
networkManager.offlineReason,
)
const [isBannerVisible, setIsBannerVisible] = useState(
!networkManager.isOnline,
)
useEffect(() => {
const handleNetworkChange = isOnline => {
setIsOnline(isOnline)
setOfflineReason(networkManager.offlineReason)
if (!isOnline) {
setIsBannerVisible(true)
}
}
networkManager.registerNetworkListener(handleNetworkChange)
return () => networkManager.unregisterNetworkListener(handleNetworkChange)
}, [])
useEffect(() => {
if (isOnline || !isBannerVisible) {
return
}
const timerId = setTimeout(() => {
setIsBannerVisible(false)
}, 5000)
return () => clearTimeout(timerId)
}, [isOnline, isBannerVisible])
const message =
offlineReason === 'server'
? 'Server unreachable. Changes will sync when connection is restored.'
: 'No internet connection. Some features may not be available.'
return (
<Box sx={{}}>
{!isOnline && (
{!isOnline && isBannerVisible && (
<Alert
variant='soft'
color='warning'
@@ -36,7 +66,7 @@ const NetworkBanner = () => {
}}
startDecorator={<WifiOff />}
>
You are currently offline. Some features may not be available.
{message}
</Alert>
)}
</Box>

View File

@@ -0,0 +1,160 @@
import { Close, NotificationsNone } from '@mui/icons-material'
import { Box, Button, IconButton, Typography } from '@mui/joy'
import { useEffect, useRef, useState } from 'react'
import NotificationTemplate from '../../components/NotificationTemplate'
import { useResponsiveModal } from '../../hooks/useResponsiveModal'
const getDisplayLabel = templates => {
if (!templates || templates.length === 0) return 'Remind'
const count = templates.length
if (count === 1) {
const n = templates[0]
const numericValue = Number(n.value)
if (numericValue === 0) return 'On due date'
const unitName =
n.unit === 'm' ? 'min' : n.unit === 'h' ? 'hr' : 'day'
const absValue = Math.abs(numericValue)
const plural = absValue !== 1 ? 's' : ''
return `${absValue} ${unitName}${plural} ${numericValue < 0 ? 'before' : 'after'}`
}
return `${count} reminders`
}
const NotificationPickerField = ({
value,
onChange,
onClear,
emptyDisplay = 'icon-text',
size = 'sm',
}) => {
const [isOpen, setIsOpen] = useState(false)
const latestTemplatesRef = useRef(value?.templates || [])
const { ResponsiveModal } = useResponsiveModal()
useEffect(() => {
if (isOpen) {
latestTemplatesRef.current = value?.templates || []
}
}, [isOpen, value])
const templates = value?.templates || []
const hasNotifications = templates.length > 0
const shouldShowLabel = hasNotifications || emptyDisplay === 'icon-text'
const displayLabel = getDisplayLabel(templates)
const handleSave = () => {
onChange({ ...value, templates: latestTemplatesRef.current })
setIsOpen(false)
}
const footer = (
<Box sx={{ display: 'flex', justifyContent: 'flex-end', gap: 1 }}>
{hasNotifications && (
<Button
variant='plain'
color='danger'
size='lg'
onClick={() => {
onClear?.()
setIsOpen(false)
}}
sx={{ mr: 'auto' }}
>
Remove all
</Button>
)}
<Button
variant='outlined'
color='neutral'
size='lg'
onClick={() => setIsOpen(false)}
>
Cancel
</Button>
<Button variant='solid' color='primary' size='lg' onClick={handleSave}>
Apply
</Button>
</Box>
)
return (
<>
<Box sx={{ position: 'relative', display: 'flex', alignItems: 'center' }}>
<Button
size={size}
variant={hasNotifications ? 'soft' : 'outlined'}
color='neutral'
onClick={() => setIsOpen(true)}
sx={{
minHeight: 40,
borderRadius: '128px',
minWidth: 'min-content',
px: shouldShowLabel ? 1.25 : 0.75,
gap: shouldShowLabel ? 1 : 0,
justifyContent: 'flex-start',
whiteSpace: 'nowrap',
transition: 'all 0.25s ease-in-out',
}}
>
<NotificationsNone sx={{ fontSize: '20px' }} />
<Typography
level='body-sm'
sx={{
whiteSpace: 'nowrap',
overflow: 'hidden',
textOverflow: 'ellipsis',
maxWidth: shouldShowLabel ? 220 : 0,
opacity: shouldShowLabel ? 1 : 0,
transform: shouldShowLabel ? 'translateX(0)' : 'translateX(-4px)',
transition:
'max-width 0.25s ease-in-out, opacity 0.2s ease-in-out, transform 0.25s ease-in-out',
}}
>
{displayLabel}
</Typography>
</Button>
{hasNotifications && onClear && (
<IconButton
size='sm'
variant='soft'
color='danger'
onClick={e => {
e.stopPropagation()
onClear?.()
}}
sx={{
position: 'absolute',
top: -12,
right: -16,
zIndex: 10,
maxHeight: 18,
maxWidth: 18,
borderRadius: '50%',
'&:hover': { bgcolor: 'danger.softBg' },
}}
>
<Close sx={{ fontSize: '18px' }} />
</IconButton>
)}
</Box>
<ResponsiveModal
open={isOpen}
onClose={() => setIsOpen(false)}
title='Reminders'
footer={footer}
>
<NotificationTemplate
value={value}
onChange={({ notifications }) => {
latestTemplatesRef.current = notifications
}}
showTimeline
/>
</ResponsiveModal>
</>
)
}
export default NotificationPickerField

View File

@@ -0,0 +1,595 @@
import {
ArrowBack,
CameraAlt,
CheckCircle,
Close,
DocumentScanner,
PhotoCamera,
Replay,
TextSnippet,
} from '@mui/icons-material'
import {
Box,
Button,
CircularProgress,
IconButton,
LinearProgress,
Typography,
} from '@mui/joy'
import { useCallback, useEffect, useRef, useState } from 'react'
import { useDocumentScanner } from '../../hooks/useDocumentScanner'
import { useResponsiveModal } from '../../hooks/useResponsiveModal'
import { localAIService } from '../../service/LocalAIService'
const SYSTEM_PROMPT = `You are helping create tasks for a household task management app.
Given OCR text extracted from a photo, identify the most useful task a person should add to their task list.
The task title should always start with an action verb when possible.
Examples:
Bill -> "Pay water bill"
Appointment -> "Attend eye doctor appointment"
Invitation -> "RSVP for wedding"
Renewal Notice -> "Renew vehicle registration"
Package Notice -> "Pick up package"
School Form -> "Complete school permission form"
Action Priority Rules:
1. Payments and bills
2. Deadlines and renewals
3. Appointments
4. Required forms
5. Informational actions (view, read, review)
Rules:
Generate at most one task.
Focus on the most important action.
Extract due dates and deadlines.
Use appointment dates as due dates when appropriate.
Do not invent information.
If the content contains no actionable item, return null values.
Include any important ID or URL or instructions in the description
Titles must be specific and useful at a glance.
Include the organization, provider, event, or subject when available.
Avoid generic document names.
Return valid JSON only.
Output:
{
"taskName": string | null,
"description": string | null,
"dueDate": string | null,
"confidence": number
}`
async function runNativeOCR(imageSource) {
const { Ocr } = await import('@jcesarmobile/capacitor-ocr')
// Convert Capacitor WebView file URL → native file:// URL the plugin can read
const image = imageSource.includes('/_capacitor_file_/')
? 'file://' + imageSource.replace(/^https?:\/\/localhost\/_capacitor_file_/, '')
: imageSource
const result = await Ocr.process({ image })
return result.results.map(r => r.text).join('\n').trim()
}
async function runOCR(imageSource, onProgress) {
const { createWorker } = await import('tesseract.js')
const worker = await createWorker('eng', 1, {
logger: m => {
if (m.status === 'recognizing text' && onProgress) {
onProgress(Math.round(m.progress * 100))
}
},
})
const { data } = await worker.recognize(imageSource)
await worker.terminate()
return data.text?.trim() || ''
}
async function extractTaskFromOCR(ocrText) {
const messages = [
{ role: 'system', content: SYSTEM_PROMPT },
{ role: 'user', content: `OCR Text:\n${ocrText}` },
]
const result = await localAIService.plainChat(messages)
if (!result) return null
const jsonMatch = result.match(/\{[\s\S]*\}/)
if (!jsonMatch) return null
try {
return JSON.parse(jsonMatch[0])
} catch {
return null
}
}
const PhotoTaskModal = ({ open, onClose, onTaskExtracted }) => {
const { ResponsiveModal } = useResponsiveModal()
const { isNativeScanner, scanDocument } = useDocumentScanner()
const videoRef = useRef(null)
const canvasRef = useRef(null)
const streamRef = useRef(null)
const fileInputRef = useRef(null)
const [phase, setPhase] = useState('capture') // capture | preview | ocr | llm | done | error
const [capturedImage, setCapturedImage] = useState(null)
const [ocrProgress, setOcrProgress] = useState(0)
const [ocrText, setOcrText] = useState('')
const [ocrMethod, setOcrMethod] = useState('tesseract')
const [showRawText, setShowRawText] = useState(false)
const [taskResult, setTaskResult] = useState(null)
const [errorMsg, setErrorMsg] = useState('')
const [cameraAvailable, setCameraAvailable] = useState(true)
const startCamera = useCallback(async () => {
try {
const stream = await navigator.mediaDevices.getUserMedia({
video: { facingMode: 'environment' },
})
streamRef.current = stream
if (videoRef.current) {
videoRef.current.srcObject = stream
}
setCameraAvailable(true)
} catch {
setCameraAvailable(false)
}
}, [])
const stopCamera = useCallback(() => {
if (streamRef.current) {
streamRef.current.getTracks().forEach(t => t.stop())
streamRef.current = null
}
}, [])
useEffect(() => {
if (open && phase === 'capture' && !isNativeScanner) {
startCamera()
}
return () => {
stopCamera()
}
}, [open, phase, isNativeScanner, startCamera, stopCamera])
const handleCapture = () => {
if (!videoRef.current || !canvasRef.current) return
const video = videoRef.current
const canvas = canvasRef.current
canvas.width = video.videoWidth
canvas.height = video.videoHeight
canvas.getContext('2d').drawImage(video, 0, 0)
const dataUrl = canvas.toDataURL('image/jpeg', 0.9)
setCapturedImage(dataUrl)
stopCamera()
setPhase('preview')
}
const handleFileSelect = e => {
const file = e.target.files?.[0]
if (!file) return
const reader = new FileReader()
reader.onload = ev => {
setCapturedImage(ev.target.result)
stopCamera()
setPhase('preview')
}
reader.readAsDataURL(file)
}
const handleBackToPreview = () => {
setOcrText('')
setTaskResult(null)
setErrorMsg('')
setOcrProgress(0)
setShowRawText(false)
setPhase('preview')
}
const handleProcess = async (method = 'tesseract') => {
setOcrMethod(method)
setPhase('ocr')
setOcrProgress(0)
setErrorMsg('')
setShowRawText(false)
try {
let text
if (method === 'native') {
try {
text = await runNativeOCR(capturedImage)
} catch {
throw new Error('Native OCR is only available on iOS and Android devices.')
}
} else {
text = await runOCR(capturedImage, pct => setOcrProgress(pct))
}
setOcrText(text)
if (!text) {
setErrorMsg('No text found in the image. Please try a clearer photo.')
setPhase('error')
return
}
setPhase('llm')
const task = await extractTaskFromOCR(text)
if (!task || !task.taskName) {
setErrorMsg('Could not identify a task from this image. Please try a different photo.')
setPhase('error')
return
}
setTaskResult(task)
setPhase('done')
} catch (e) {
setErrorMsg(`Processing failed: ${e.message || 'Unknown error'}`)
setPhase('error')
}
}
const handleRetake = () => {
setCapturedImage(null)
setOcrText('')
setTaskResult(null)
setErrorMsg('')
setOcrProgress(0)
setShowRawText(false)
setPhase('capture')
}
const handleNativeScan = async () => {
const { image, cancelled, error } = await scanDocument()
if (cancelled) return
if (error || !image) {
setErrorMsg(error ? `Scanner error: ${error}` : 'Scan cancelled or failed.')
setPhase('error')
return
}
setCapturedImage(image)
stopCamera()
setPhase('preview')
}
const handleConfirm = () => {
if (taskResult) {
onTaskExtracted(taskResult)
}
handleClose()
}
const handleClose = () => {
stopCamera()
setCapturedImage(null)
setOcrText('')
setTaskResult(null)
setErrorMsg('')
setOcrProgress(0)
setShowRawText(false)
setPhase('capture')
onClose()
}
const isProcessing = phase === 'ocr' || phase === 'llm'
return (
<ResponsiveModal
open={open}
onClose={handleClose}
size='md'
fullWidth
title='Scan photo to create task'
>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
{(phase === 'capture' || phase === 'preview') && (
<Box
sx={{
position: 'relative',
width: '100%',
borderRadius: 'md',
overflow: 'hidden',
bgcolor: 'background.level1',
minHeight: 240,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}}
>
{phase === 'capture' && !isNativeScanner && cameraAvailable && (
<video
ref={videoRef}
autoPlay
playsInline
muted
style={{ width: '100%', display: 'block' }}
/>
)}
{phase === 'capture' && isNativeScanner && (
<Box sx={{ textAlign: 'center', p: 4 }}>
<DocumentScanner sx={{ fontSize: 64, opacity: 0.4, mb: 1 }} />
<Typography level='body-sm' sx={{ opacity: 0.6 }}>
Tap &quot;Scan Document&quot; to open the scanner
</Typography>
</Box>
)}
{phase === 'capture' && !isNativeScanner && !cameraAvailable && (
<Box sx={{ textAlign: 'center', p: 3 }}>
<CameraAlt sx={{ fontSize: 48, opacity: 0.5, mb: 1 }} />
<Typography level='body-sm' sx={{ opacity: 0.7 }}>
Camera not available
</Typography>
</Box>
)}
{phase === 'preview' && capturedImage && (
<img
src={capturedImage}
alt='Captured document'
style={{ width: '100%', display: 'block' }}
/>
)}
</Box>
)}
<canvas ref={canvasRef} style={{ display: 'none' }} />
{isProcessing && (
<Box
sx={{
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
gap: 2,
py: 4,
}}
>
{capturedImage && (
<img
src={capturedImage}
alt='Processing'
style={{
width: '100%',
borderRadius: 8,
opacity: 0.6,
maxHeight: 200,
objectFit: 'contain',
}}
/>
)}
<CircularProgress size='md' />
{phase === 'ocr' && ocrMethod === 'tesseract' && (
<>
<Typography level='body-sm'>
Reading text from image {ocrProgress}%
</Typography>
<LinearProgress determinate value={ocrProgress} sx={{ width: '100%' }} />
</>
)}
{phase === 'ocr' && ocrMethod === 'native' && (
<Typography level='body-sm'>Running native OCR</Typography>
)}
{phase === 'llm' && (
<Typography level='body-sm'>Identifying task with AI</Typography>
)}
</Box>
)}
{phase === 'done' && taskResult && (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1.5 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<CheckCircle color='success' />
<Typography level='title-sm'>Task identified</Typography>
</Box>
<Box
sx={{
p: 1.5,
borderRadius: 'md',
bgcolor: 'background.level1',
border: '1px solid',
borderColor: 'divider',
}}
>
<Typography level='title-sm'>{taskResult.taskName}</Typography>
{taskResult.description && (
<Typography level='body-xs' sx={{ mt: 0.5, opacity: 0.8 }}>
{taskResult.description}
</Typography>
)}
{taskResult.dueDate && (
<Typography level='body-xs' sx={{ mt: 0.5, opacity: 0.7 }}>
Due: {taskResult.dueDate}
</Typography>
)}
</Box>
{ocrText && (
<>
<Button
size='sm'
variant='plain'
color='neutral'
startDecorator={<TextSnippet />}
onClick={() => setShowRawText(v => !v)}
sx={{ alignSelf: 'flex-start' }}
>
{showRawText ? 'Hide Raw Text' : 'Show Raw Text'}
</Button>
{showRawText && (
<Box
sx={{
p: 1.5,
borderRadius: 'md',
bgcolor: 'background.level2',
border: '1px solid',
borderColor: 'divider',
maxHeight: 180,
overflowY: 'auto',
}}
>
<Typography
level='body-xs'
sx={{ whiteSpace: 'pre-wrap', fontFamily: 'monospace' }}
>
{ocrText}
</Typography>
</Box>
)}
</>
)}
</Box>
)}
{phase === 'error' && (
<Box
sx={{
p: 2,
borderRadius: 'md',
bgcolor: 'danger.softBg',
color: 'danger.softColor',
}}
>
<Typography level='body-sm'>{errorMsg}</Typography>
</Box>
)}
<Box sx={{ display: 'flex', gap: 1, justifyContent: 'flex-end' }}>
{phase === 'capture' && (
<>
<Button
variant='outlined'
color='neutral'
startDecorator={<PhotoCamera />}
onClick={() => fileInputRef.current?.click()}
>
Upload Photo
</Button>
<input
ref={fileInputRef}
type='file'
accept='image/*'
style={{ display: 'none' }}
onChange={handleFileSelect}
/>
{isNativeScanner ? (
<Button
variant='solid'
color='primary'
startDecorator={<DocumentScanner />}
onClick={handleNativeScan}
>
Scan Document
</Button>
) : (
cameraAvailable && (
<Button
variant='solid'
color='primary'
startDecorator={<CameraAlt />}
onClick={handleCapture}
>
Capture
</Button>
)
)}
</>
)}
{phase === 'preview' && (
<>
<Button
variant='outlined'
color='neutral'
startDecorator={<Replay />}
onClick={handleRetake}
>
Retake
</Button>
{isNativeScanner && (
<Button
variant='outlined'
color='primary'
onClick={() => handleProcess('native')}
>
Process Natively
</Button>
)}
<Button
variant='solid'
color='primary'
onClick={() => handleProcess('tesseract')}
>
Process Image
</Button>
</>
)}
{phase === 'error' && (
<>
<Button
variant='outlined'
color='neutral'
startDecorator={<ArrowBack />}
onClick={handleBackToPreview}
>
Back
</Button>
<Button
variant='outlined'
color='neutral'
startDecorator={<Replay />}
onClick={handleRetake}
>
Retake
</Button>
</>
)}
{phase === 'done' && (
<>
<Button
variant='outlined'
color='neutral'
startDecorator={<ArrowBack />}
onClick={handleBackToPreview}
>
Back
</Button>
<Button
variant='outlined'
color='neutral'
startDecorator={<Replay />}
onClick={handleRetake}
>
Retake
</Button>
<Button variant='solid' color='primary' onClick={handleConfirm}>
Create Task
</Button>
</>
)}
{!isProcessing && (
<IconButton
variant='plain'
color='neutral'
onClick={handleClose}
sx={{ ml: 'auto' }}
>
<Close />
</IconButton>
)}
</Box>
</Box>
</ResponsiveModal>
)
}
export default PhotoTaskModal

View File

@@ -0,0 +1,74 @@
import { Flag } from '@mui/icons-material'
import BaseOptionPicker from './BaseOptionPicker'
const defaultPriorityColors = {
0: '#9CA3AF',
1: '#EF4444',
2: '#F97316',
3: '#FBBF24',
4: '#3B82F6',
}
const defaultPriorityLabels = {
0: 'No Priority',
1: 'P1',
2: 'P2',
3: 'P3',
4: 'P4',
}
const PriorityPickerField = ({
value = 0,
onChange,
onClear,
emptyDisplay = 'icon-text',
priorityColors = defaultPriorityColors,
priorityLabels = defaultPriorityLabels,
size = 'sm',
}) => {
const options = [1, 2, 3, 4].map(priorityOption => ({
id: priorityOption,
label: priorityLabels[priorityOption],
color: priorityColors[priorityOption],
}))
// Don't add the 0 option to the menu - priority 0 is the "empty" state (icon only)
return (
<BaseOptionPicker
items={options}
value={value}
onChange={onChange}
onClear={onClear}
emptyDisplay={emptyDisplay}
size={size}
getItemValue={item => item.id}
getItemLabel={item => item.label}
getItemColor={item => item.color}
getTriggerText={({ selectedItems, isEmpty }) => {
// For priority 0 (no priority), show empty string (icon only)
if (value === 0 || isEmpty) return 'Priority'
return selectedItems[0]?.label || ''
}}
renderTriggerIcon={({ selectedItems, isEmpty }) => (
<Flag
sx={{
color: isEmpty || value === 0 ? '' : selectedItems[0]?.color,
fontSize: '20px',
}}
/>
)}
renderItemStart={({ item }) => (
<Flag
sx={{
color: item.color,
fontSize: '18px',
}}
/>
)}
menuMinWidth={180}
/>
)
}
export default PriorityPickerField

View File

@@ -0,0 +1,48 @@
import { FolderOpen } from '@mui/icons-material'
import BaseOptionPicker from './BaseOptionPicker'
const ProjectPickerField = ({
value = 'default',
onChange,
onClear,
projects = [],
emptyDisplay = 'icon-text',
}) => {
const options = [
{ id: 'default', name: 'Default Project', color: '#9CA3AF' },
...projects.map(project => ({
id: project.id,
name: project.name,
color: project.color,
})),
]
return (
<BaseOptionPicker
items={options}
value={value}
onChange={onChange}
onClear={onClear}
emptyDisplay={emptyDisplay}
emptyLabel='Project'
getItemValue={item => item.id}
getItemLabel={item => item.name}
getItemColor={item => item.color}
renderTriggerIcon={() => <FolderOpen sx={{ fontSize: '20px' }} />}
renderItemStart={({ item }) => (
<FolderOpen
sx={{
fontSize: '18px',
color: item.color || 'text.secondary',
}}
/>
)}
getTriggerText={({ selectedItems, isEmpty }) =>
isEmpty ? 'Project' : selectedItems[0].name
}
menuMinWidth={240}
/>
)
}
export default ProjectPickerField

View File

@@ -0,0 +1,639 @@
import { Close, Repeat } from '@mui/icons-material'
import {
Box,
Button,
Checkbox,
Divider,
IconButton,
Input,
List,
ListItem,
Radio,
RadioGroup,
Typography,
} from '@mui/joy'
import moment from 'moment'
import { useEffect, useState } from 'react'
import { getRecurrentChipText } from '../../utils/ChoreCardHelpers'
import { useResponsiveModal } from '../../hooks/useResponsiveModal'
const FREQUENCY_TYPES = [
'daily',
'weekly',
'monthly',
'yearly',
'adaptive',
'custom',
]
const REPEAT_ON_TYPE = ['interval', 'days_of_the_week', 'day_of_the_month']
const DAYS = [
'monday',
'tuesday',
'wednesday',
'thursday',
'friday',
'saturday',
'sunday',
]
const MONTHS = [
'january',
'february',
'march',
'april',
'may',
'june',
'july',
'august',
'september',
'october',
'november',
'december',
]
const OCCURRENCE_OPTIONS = [
{ value: 1, label: '1st' },
{ value: 2, label: '2nd' },
{ value: 3, label: '3rd' },
{ value: 4, label: '4th' },
{ value: -1, label: 'Last' },
]
const defaultMetadata = () => ({
unit: 'days',
time: moment(moment(new Date()).format('YYYY-MM-DD') + 'T18:00').format(),
timezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
})
const initLocalState = value => {
if (!value) {
return {
frequencyType: 'daily',
frequency: 1,
frequencyMetadata: defaultMetadata(),
}
}
let { frequencyType, frequency, frequencyMetadata } = value
// Normalize parser output: interval/1/days → daily, etc.
if (frequencyType === 'interval' && frequency === 1) {
const unitTypeMap = {
days: 'daily',
weeks: 'weekly',
months: 'monthly',
years: 'yearly',
}
frequencyType = unitTypeMap[frequencyMetadata?.unit] || frequencyType
}
return {
frequencyType,
frequency: frequency ?? 1,
frequencyMetadata: {
...defaultMetadata(),
...frequencyMetadata,
},
}
}
const getDisplayType = frequencyType =>
REPEAT_ON_TYPE.includes(frequencyType) ? 'custom' : frequencyType
// Shared section label
const SectionLabel = ({ children }) => (
<Typography
level='body-xs'
sx={{
color: 'text.tertiary',
mb: 0.75,
textTransform: 'uppercase',
letterSpacing: '0.05em',
}}
>
{children}
</Typography>
)
// Shared time-of-day picker
const TimeRow = ({ metadata, onUpdate }) => (
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1.5, mt: 2 }}>
<SectionLabel>Time of day</SectionLabel>
<Input
type='time'
size='sm'
value={moment(metadata?.time).format('HH:mm')}
onChange={e =>
onUpdate({
...metadata,
timezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
time: moment(
moment(new Date()).format('YYYY-MM-DD') + 'T' + e.target.value,
).format(),
})
}
sx={{ width: 120 }}
/>
</Box>
)
const pillListSx = {
'--List-gap': '8px',
'--ListItem-radius': '20px',
}
// Interval section
const IntervalSection = ({
frequency,
frequencyMetadata,
onFrequencyUpdate,
onFrequencyMetadataUpdate,
}) => (
<Box>
<SectionLabel>Repeat every</SectionLabel>
<Box
sx={{ display: 'flex', alignItems: 'center', gap: 1, flexWrap: 'wrap' }}
>
<Input
type='number'
size='sm'
value={frequency}
onChange={e =>
onFrequencyUpdate(Math.max(1, parseInt(e.target.value, 10) || 1))
}
sx={{ width: 72 }}
slotProps={{ input: { min: 1, max: 999 } }}
/>
<List orientation='horizontal' wrap sx={pillListSx}>
{['days', 'weeks', 'months', 'years'].map(unit => (
<ListItem key={unit}>
<Checkbox
checked={frequencyMetadata?.unit === unit}
onClick={() =>
onFrequencyMetadataUpdate({ ...frequencyMetadata, unit })
}
overlay
disableIcon
variant='soft'
label={unit.charAt(0).toUpperCase() + unit.slice(1)}
/>
</ListItem>
))}
</List>
</Box>
<TimeRow
metadata={frequencyMetadata}
onUpdate={onFrequencyMetadataUpdate}
/>
</Box>
)
// Days of week section
const DaysOfWeekSection = ({
frequencyMetadata,
onFrequencyMetadataUpdate,
}) => {
const selectedDays = frequencyMetadata?.days || []
const weekPattern = frequencyMetadata?.weekPattern || 'every_week'
const selectedOccurrences = frequencyMetadata?.occurrences || []
const toggleDay = day => {
const next = selectedDays.includes(day)
? selectedDays.filter(d => d !== day)
: [...selectedDays, day]
onFrequencyMetadataUpdate({ ...frequencyMetadata, days: next })
}
const toggleOccurrence = val => {
const next = selectedOccurrences.includes(val)
? selectedOccurrences.filter(v => v !== val)
: [...selectedOccurrences, val]
onFrequencyMetadataUpdate({ ...frequencyMetadata, occurrences: next })
}
return (
<Box>
<SectionLabel>Days</SectionLabel>
<List orientation='horizontal' wrap sx={pillListSx}>
{DAYS.map(day => (
<ListItem key={day}>
<Checkbox
checked={selectedDays.includes(day)}
onClick={() => toggleDay(day)}
overlay
disableIcon
variant='soft'
label={day.charAt(0).toUpperCase() + day.slice(1, 3)}
/>
</ListItem>
))}
</List>
<Box sx={{ mt: 2 }}>
<SectionLabel>Pattern</SectionLabel>
<RadioGroup
orientation='horizontal'
value={weekPattern}
onChange={e =>
onFrequencyMetadataUpdate({
...frequencyMetadata,
weekPattern: e.target.value,
occurrences:
e.target.value === 'every_week' ? [] : selectedOccurrences,
})
}
sx={{
padding: '3px',
borderRadius: '10px',
bgcolor: 'neutral.softBg',
'--RadioGroup-gap': '3px',
'--Radio-actionRadius': '7px',
display: 'inline-flex',
}}
>
{[
{ value: 'every_week', label: 'Every week' },
{ value: 'week_of_month', label: 'Specific weeks' },
].map(opt => (
<Radio
key={opt.value}
value={opt.value}
color='neutral'
disableIcon
label={opt.label}
variant='plain'
sx={{ px: 1.5, py: 0.5 }}
slotProps={{
action: ({ checked }) => ({
sx: checked
? {
bgcolor: 'background.surface',
boxShadow: 'sm',
'&:hover': { bgcolor: 'background.surface' },
}
: {},
}),
}}
/>
))}
</RadioGroup>
</Box>
{weekPattern === 'week_of_month' && (
<Box sx={{ mt: 1.5 }}>
<SectionLabel>Occurrences</SectionLabel>
<List orientation='horizontal' wrap sx={pillListSx}>
{OCCURRENCE_OPTIONS.map(opt => (
<ListItem key={opt.value}>
<Checkbox
checked={selectedOccurrences.includes(opt.value)}
onClick={() => toggleOccurrence(opt.value)}
overlay
disableIcon
variant='soft'
label={opt.label}
/>
</ListItem>
))}
</List>
</Box>
)}
<TimeRow
metadata={frequencyMetadata}
onUpdate={onFrequencyMetadataUpdate}
/>
</Box>
)
}
// Day of month section
const DayOfMonthSection = ({
frequency,
frequencyMetadata,
onFrequencyUpdate,
onFrequencyMetadataUpdate,
}) => {
const selectedMonths = frequencyMetadata?.months || []
const toggleMonth = month => {
const next = selectedMonths.includes(month)
? selectedMonths.filter(m => m !== month)
: [...selectedMonths, month]
onFrequencyMetadataUpdate({ ...frequencyMetadata, months: next })
}
return (
<Box>
<SectionLabel>Months</SectionLabel>
<List orientation='horizontal' wrap sx={pillListSx}>
{MONTHS.map(month => (
<ListItem key={month}>
<Checkbox
checked={selectedMonths.includes(month)}
onClick={() => toggleMonth(month)}
overlay
disableIcon
variant='soft'
label={month.charAt(0).toUpperCase() + month.slice(1, 3)}
/>
</ListItem>
))}
</List>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1.5, mt: 2 }}>
<SectionLabel>Day of month</SectionLabel>
<Input
type='number'
size='sm'
value={frequency}
onChange={e => {
const v = Math.min(
31,
Math.max(1, parseInt(e.target.value, 10) || 1),
)
onFrequencyUpdate(v)
}}
sx={{ width: 72 }}
slotProps={{ input: { min: 1, max: 31 } }}
/>
</Box>
<TimeRow
metadata={frequencyMetadata}
onUpdate={onFrequencyMetadataUpdate}
/>
</Box>
)
}
const RepeatPickerField = ({
value,
onChange,
onClear,
emptyDisplay = 'icon-text',
size = 'sm',
}) => {
const [isOpen, setIsOpen] = useState(false)
const [localFrequencyType, setLocalFrequencyType] = useState('daily')
const [localFrequency, setLocalFrequency] = useState(1)
const [localFrequencyMetadata, setLocalFrequencyMetadata] =
useState(defaultMetadata)
const { ResponsiveModal } = useResponsiveModal()
useEffect(() => {
if (!isOpen) return
const init = initLocalState(value)
setLocalFrequencyType(init.frequencyType)
setLocalFrequency(init.frequency)
setLocalFrequencyMetadata(init.frequencyMetadata)
}, [isOpen, value])
const hasRepeat = Boolean(value)
const shouldShowLabel = hasRepeat || emptyDisplay === 'icon-text'
const displayLabel = hasRepeat ? getRecurrentChipText(value) : 'Repeat'
const displayType = getDisplayType(localFrequencyType)
const handleTypeSelect = type => {
if (type === 'custom') {
setLocalFrequencyType('interval')
setLocalFrequency(1)
setLocalFrequencyMetadata({ ...defaultMetadata(), unit: 'days' })
} else {
setLocalFrequencyType(type)
setLocalFrequency(1)
}
}
const handleSubTypeSelect = newType => {
setLocalFrequencyType(newType)
if (newType === 'interval') {
setLocalFrequency(1)
setLocalFrequencyMetadata(prev => ({ ...prev, unit: 'days' }))
} else if (newType === 'days_of_the_week') {
setLocalFrequencyMetadata(prev => ({
...prev,
days: [],
weekPattern: 'every_week',
occurrences: [],
}))
} else if (newType === 'day_of_the_month') {
setLocalFrequency(1)
setLocalFrequencyMetadata(prev => ({ ...prev, months: [] }))
}
}
const handleSave = () => {
onChange({
frequencyType: localFrequencyType,
frequency: localFrequency,
frequencyMetadata: localFrequencyMetadata,
})
setIsOpen(false)
}
return (
<>
<Box sx={{ position: 'relative', display: 'flex', alignItems: 'center' }}>
<Button
size={size}
variant={hasRepeat ? 'soft' : 'outlined'}
color='neutral'
onClick={() => setIsOpen(true)}
sx={{
minHeight: 40,
borderRadius: '128px',
minWidth: 'min-content',
px: shouldShowLabel ? 1.25 : 0.75,
gap: shouldShowLabel ? 1 : 0,
justifyContent: 'flex-start',
whiteSpace: 'nowrap',
transition: 'all 0.25s ease-in-out',
}}
>
<Repeat sx={{ fontSize: '20px' }} />
<Typography
level='body-sm'
sx={{
whiteSpace: 'nowrap',
overflow: 'hidden',
textOverflow: 'ellipsis',
maxWidth: shouldShowLabel ? 220 : 0,
opacity: shouldShowLabel ? 1 : 0,
transform: shouldShowLabel ? 'translateX(0)' : 'translateX(-4px)',
transition:
'max-width 0.25s ease-in-out, opacity 0.2s ease-in-out, transform 0.25s ease-in-out',
}}
>
{displayLabel}
</Typography>
</Button>
{hasRepeat && onClear && (
<IconButton
size='sm'
variant='soft'
color='danger'
onClick={e => {
e.stopPropagation()
onClear?.()
}}
sx={{
position: 'absolute',
top: -12,
right: -16,
zIndex: 10,
maxHeight: 18,
maxWidth: 18,
borderRadius: '50%',
'&:hover': { bgcolor: 'danger.softBg' },
}}
>
<Close sx={{ fontSize: '18px' }} />
</IconButton>
)}
</Box>
<ResponsiveModal
open={isOpen}
onClose={() => setIsOpen(false)}
title='Repeat Schedule'
footer={
<Box sx={{ display: 'flex', justifyContent: 'flex-end', gap: 1 }}>
{hasRepeat && (
<Button
variant='plain'
color='danger'
size='lg'
onClick={() => {
onClear?.()
setIsOpen(false)
}}
sx={{ mr: 'auto' }}
>
Remove
</Button>
)}
<Button
variant='outlined'
color='neutral'
size='lg'
onClick={() => setIsOpen(false)}
>
Cancel
</Button>
<Button
variant='solid'
color='primary'
size='lg'
onClick={handleSave}
>
Apply
</Button>
</Box>
}
>
{/* Frequency type selector */}
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2.5 }}>
<Box>
<SectionLabel>Frequency</SectionLabel>
<List orientation='horizontal' wrap sx={pillListSx}>
{FREQUENCY_TYPES.map(type => (
<ListItem key={type}>
<Checkbox
checked={displayType === type}
onClick={() => handleTypeSelect(type)}
overlay
disableIcon
variant='soft'
label={type.charAt(0).toUpperCase() + type.slice(1)}
/>
</ListItem>
))}
</List>
</Box>
{/* Custom sub-type + detail panel */}
{displayType === 'custom' && (
<>
<Box>
<SectionLabel>Schedule type</SectionLabel>
<RadioGroup
orientation='horizontal'
value={localFrequencyType}
onChange={e => handleSubTypeSelect(e.target.value)}
sx={{
padding: '3px',
borderRadius: '10px',
bgcolor: 'neutral.softBg',
'--RadioGroup-gap': '3px',
'--Radio-actionRadius': '7px',
display: 'inline-flex',
}}
>
{REPEAT_ON_TYPE.map(type => (
<Radio
key={type}
value={type}
color='neutral'
disableIcon
label={type
.split('_')
.map((w, i, arr) =>
i === 0 || i === arr.length - 1
? w.charAt(0).toUpperCase() + w.slice(1)
: w,
)
.join(' ')}
variant='plain'
sx={{ px: 1.5, py: 0.5 }}
slotProps={{
action: ({ checked }) => ({
sx: checked
? {
bgcolor: 'background.surface',
boxShadow: 'sm',
'&:hover': { bgcolor: 'background.surface' },
}
: {},
}),
}}
/>
))}
</RadioGroup>
</Box>
<Divider />
{localFrequencyType === 'interval' && (
<IntervalSection
frequency={localFrequency}
frequencyMetadata={localFrequencyMetadata}
onFrequencyUpdate={setLocalFrequency}
onFrequencyMetadataUpdate={setLocalFrequencyMetadata}
/>
)}
{localFrequencyType === 'days_of_the_week' && (
<DaysOfWeekSection
frequencyMetadata={localFrequencyMetadata}
onFrequencyMetadataUpdate={setLocalFrequencyMetadata}
/>
)}
{localFrequencyType === 'day_of_the_month' && (
<DayOfMonthSection
frequency={localFrequency}
frequencyMetadata={localFrequencyMetadata}
onFrequencyUpdate={setLocalFrequency}
onFrequencyMetadataUpdate={setLocalFrequencyMetadata}
/>
)}
</>
)}
</Box>
</ResponsiveModal>
</>
)
}
export default RepeatPickerField

View File

@@ -53,20 +53,33 @@
}
/* Material-UI style customizations for Quill toolbar */
.quill-root {
border-radius: 10px;
overflow: hidden;
background-color: var(--joy-palette-neutral-softBg, #f0f4f8);
transition: box-shadow 0.15s ease, background-color 0.15s ease;
}
.quill-root:focus-within {
box-shadow: 0 0 0 1px var(--joy-palette-primary-outlinedBorder, rgba(11, 107, 203, 0.15));
}
.quill-root:hover:not(:focus-within) {
background-color: var(--joy-palette-neutral-softHoverBg, #dde7ee);
}
.quill-root .ql-toolbar.ql-snow {
border: 1px solid var(--joy-palette-neutral-outlinedBorder, #dde7ee);
border-bottom: none;
border-radius: 8px 8px 0 0;
background: var(--joy-palette-background-surface, #fff);
box-shadow: var(--joy-shadow-xs, 0px 1px 2px 0px rgba(16, 24, 40, 0.05));
padding: 12px 16px;
border: none;
border-bottom: 1px solid var(--joy-palette-neutral-softActiveBg, rgba(99, 107, 116, 0.16));
border-radius: 0;
background: transparent;
box-shadow: none;
padding: 8px 12px;
}
.quill-root .ql-container.ql-snow {
border: 1px solid var(--joy-palette-neutral-outlinedBorder, #dde7ee);
border-top: none;
border-radius: 0 0 8px 8px;
background: var(--joy-palette-background-surface, #fff);
border: none;
background: transparent;
}
/* Style toolbar buttons with Material-UI look */

View File

@@ -2,6 +2,33 @@ import imageCompression from 'browser-image-compression'
import Quill from 'quill'
import 'quill/dist/quill.snow.css'
import QuillMarkdown from 'quilljs-markdown'
// Extend the built-in Image blot to preserve dt-data-path
const ImageBlot = Quill.import('formats/image')
class DtImageBlot extends ImageBlot {
static create(value) {
const node = super.create(typeof value === 'string' ? value : value.src)
if (value?.path) node.setAttribute('dt-data-path', value.path)
return node
}
static value(node) {
return { src: node.getAttribute('src'), path: node.getAttribute('dt-data-path') }
}
static formats(node) {
return { 'dt-data-path': node.getAttribute('dt-data-path') }
}
format(name, value) {
if (name === 'dt-data-path') {
if (value) this.domNode.setAttribute('dt-data-path', value)
else this.domNode.removeAttribute('dt-data-path')
} else {
super.format(name, value)
}
}
}
DtImageBlot.blotName = 'image'
DtImageBlot.tagName = 'img'
Quill.register(DtImageBlot, true)
import {
forwardRef,
useCallback,
@@ -12,7 +39,11 @@ import {
import { useUserProfile } from '../../queries/UserQueries'
import { useNotification } from '../../service/NotificationProvider'
import { apiClient } from '../../utils/ApiClient'
import { isPlusAccount, resolvePhotoURL } from '../../utils/Helpers'
import {
isPlusAccount,
refreshSignedUrlsInHtml,
resolvePhotoURL,
} from '../../utils/Helpers'
import './RichTextEditor.css'
const RichTextEditor = forwardRef(
@@ -32,6 +63,7 @@ const RichTextEditor = forwardRef(
const { data: userProfile } = useUserProfile()
const quillRef = useRef(null)
const editorRef = useRef(null)
const initialContentSet = useRef(false)
// Expose focus method to parent components
useImperativeHandle(
@@ -141,11 +173,16 @@ const RichTextEditor = forwardRef(
return
}
const data = await response.json()
const url = resolvePhotoURL(data.url || data.sign)
// Insert image into Quill
// Prefer the backend-proxied path (data.sign) over the direct cloud
// signed URL (data.url) — the proxy re-signs on every request so the
// embedded src never expires.
const path = data.path
const url = resolvePhotoURL(data.sign || data.url)
// Insert image into Quill with dt-data-path tracked by the custom blot
const quill = editorRef.current
const range = quill.getSelection()
quill.insertEmbed(range ? range.index : 0, 'image', url)
const insertIndex = range ? range.index : 0
quill.insertEmbed(insertIndex, 'image', { src: url, path })
} catch (error) {
console.error('Error during image processing or upload:', error)
showError({
@@ -202,7 +239,11 @@ const RichTextEditor = forwardRef(
useEffect(() => {
if (editorRef.current && isEditable) {
if (editorRef.current.root.innerHTML !== value) {
editorRef.current.root.innerHTML = value || ''
const html = !initialContentSet.current
? refreshSignedUrlsInHtml(value || '')
: value || ''
initialContentSet.current = true
editorRef.current.root.innerHTML = html
}
}
}, [value, isEditable])
@@ -227,7 +268,7 @@ const RichTextEditor = forwardRef(
boxShadow:
'var(--joy-shadow-xs, 0px 1px 2px 0px rgba(16, 24, 40, 0.05))',
}}
dangerouslySetInnerHTML={{ __html: value }}
dangerouslySetInnerHTML={{ __html: refreshSignedUrlsInHtml(value) }}
/>
)
}

View File

@@ -0,0 +1,309 @@
import {
CameraAlt,
DocumentScanner,
PhotoCamera,
Replay,
WarningAmber,
} from '@mui/icons-material'
import {
Box,
Button,
CircularProgress,
LinearProgress,
Typography,
} from '@mui/joy'
import { useEffect } from 'react'
import { useScanToTask } from './useScanToTask'
/**
* Inline scan-to-task panel. Mounts inside AddTaskModal — no second modal.
*
* Flow: capture → (auto) processing → done [calls onTaskExtracted + onClose]
* → error [retake or cancel]
*/
const ScanPanel = ({ open, onTaskExtracted, onClose, initialImageUrl }) => {
const {
isNativeScanner,
phase,
capturedImage,
ocrProgress,
taskResult,
errorMsg,
cameraAvailable,
videoRef,
canvasRef,
fileInputRef,
startCamera,
stopCamera,
capture,
handleFileSelect,
handleNativeScan,
retake,
activate,
reset,
} = useScanToTask()
// Start/stop based on open state
useEffect(() => {
if (open) {
activate(initialImageUrl)
} else {
reset()
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [open, initialImageUrl])
// Start camera when entering capture phase on web
useEffect(() => {
if (phase === 'capture' && !isNativeScanner) {
startCamera()
}
if (phase !== 'capture') {
stopCamera()
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [phase, isNativeScanner])
// Auto-close and populate when done
useEffect(() => {
if (phase === 'done' && taskResult) {
onTaskExtracted(taskResult)
onClose()
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [phase, taskResult])
if (!open) return null
const isProcessing = phase === 'processing'
return (
<Box
sx={{
borderRadius: 'md',
border: '1px solid',
borderColor: 'primary.outlinedBorder',
overflow: 'hidden',
bgcolor: 'background.level1',
}}
>
{/* ── Capture phase ── */}
{phase === 'capture' && (
<>
<Box
sx={{
position: 'relative',
width: '100%',
minHeight: 200,
maxHeight: 300,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
bgcolor: 'neutral.900',
overflow: 'hidden',
}}
>
{isNativeScanner && (
<Box sx={{ textAlign: 'center', p: 4 }}>
<DocumentScanner
sx={{ fontSize: 56, color: 'white', opacity: 0.5, mb: 1 }}
/>
<Typography level='body-sm' sx={{ color: 'white', opacity: 0.6 }}>
Tap &quot;Scan Document&quot; to open the scanner
</Typography>
</Box>
)}
{!isNativeScanner && cameraAvailable && (
<video
ref={videoRef}
autoPlay
playsInline
muted
style={{
width: '100%',
display: 'block',
maxHeight: 300,
objectFit: 'cover',
}}
/>
)}
{!isNativeScanner && !cameraAvailable && (
<Box sx={{ textAlign: 'center', p: 4 }}>
<CameraAlt
sx={{ fontSize: 48, color: 'white', opacity: 0.4, mb: 1 }}
/>
<Typography level='body-sm' sx={{ color: 'white', opacity: 0.6 }}>
Camera not available use Upload instead
</Typography>
</Box>
)}
</Box>
<Box
sx={{
px: 1.5,
py: 1,
display: 'flex',
alignItems: 'center',
gap: 1,
}}
>
<Button
size='sm'
variant='plain'
color='neutral'
startDecorator={<PhotoCamera fontSize='small' />}
onClick={() => fileInputRef.current?.click()}
>
Upload
</Button>
<input
ref={fileInputRef}
type='file'
accept='image/*'
style={{ display: 'none' }}
onChange={handleFileSelect}
/>
<Box sx={{ ml: 'auto', display: 'flex', gap: 1 }}>
<Button size='sm' variant='plain' color='neutral' onClick={onClose}>
Cancel
</Button>
{isNativeScanner ? (
<Button
size='sm'
variant='solid'
color='primary'
startDecorator={<DocumentScanner fontSize='small' />}
onClick={handleNativeScan}
>
Scan Document
</Button>
) : (
cameraAvailable && (
<Button
size='sm'
variant='solid'
color='primary'
startDecorator={<CameraAlt fontSize='small' />}
onClick={capture}
>
Capture
</Button>
)
)}
</Box>
</Box>
</>
)}
{/* ── Processing phase ── */}
{isProcessing && (
<Box
sx={{
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
gap: 1.5,
p: 2.5,
}}
>
{capturedImage && (
<Box
sx={{
position: 'relative',
width: '100%',
borderRadius: 'sm',
overflow: 'hidden',
}}
>
<img
src={capturedImage}
alt='Processing'
style={{
width: '100%',
display: 'block',
maxHeight: 180,
objectFit: 'contain',
opacity: 0.45,
}}
/>
<Box
sx={{
position: 'absolute',
inset: 0,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}}
>
<CircularProgress size='md' />
</Box>
</Box>
)}
<Typography level='body-sm' sx={{ opacity: 0.7 }}>
{ocrProgress > 0 && ocrProgress < 100
? `Reading text… ${ocrProgress}%`
: ocrProgress >= 100
? 'Identifying task with AI…'
: 'Starting…'}
</Typography>
{ocrProgress > 0 && ocrProgress < 100 && (
<LinearProgress
determinate
value={ocrProgress}
sx={{ width: '100%' }}
/>
)}
</Box>
)}
{/* ── Error phase ── */}
{phase === 'error' && (
<Box sx={{ p: 2 }}>
{capturedImage && (
<img
src={capturedImage}
alt='Failed scan'
style={{
width: '100%',
display: 'block',
borderRadius: 8,
maxHeight: 160,
objectFit: 'contain',
opacity: 0.4,
marginBottom: 12,
}}
/>
)}
<Box sx={{ display: 'flex', alignItems: 'flex-start', gap: 1, mb: 2 }}>
<WarningAmber color='warning' sx={{ mt: 0.25, flexShrink: 0 }} />
<Typography level='body-sm'>{errorMsg}</Typography>
</Box>
<Box sx={{ display: 'flex', gap: 1 }}>
<Button
size='sm'
variant='outlined'
color='neutral'
startDecorator={<Replay fontSize='small' />}
onClick={retake}
>
Retake
</Button>
<Button size='sm' variant='plain' color='neutral' onClick={onClose}>
Cancel
</Button>
</Box>
</Box>
)}
<canvas ref={canvasRef} style={{ display: 'none' }} />
</Box>
)
}
export default ScanPanel

View File

@@ -0,0 +1,256 @@
import { useCallback, useRef, useState } from 'react'
import { useDocumentScanner } from '../../../hooks/useDocumentScanner'
import { localAIService } from '../../../service/LocalAIService'
const SYSTEM_PROMPT = `You are helping create tasks for a household task management app.
Given OCR text extracted from a photo, identify the most useful task a person should add to their task list.
The task title should always start with an action verb when possible.
Examples:
Bill -> "Pay water bill"
Appointment -> "Attend eye doctor appointment"
Invitation -> "RSVP for wedding"
Renewal Notice -> "Renew vehicle registration"
Package Notice -> "Pick up package"
School Form -> "Complete school permission form"
Action Priority Rules:
1. Payments and bills
2. Deadlines and renewals
3. Appointments
4. Required forms
5. Informational actions (view, read, review)
Rules:
Generate at most one task.
Focus on the most important action.
Extract due dates and deadlines.
Use appointment dates as due dates when appropriate.
Do not invent information.
If the content contains no actionable item, return null values.
Include any important ID or URL or instructions in the description.
Titles must be specific and useful at a glance.
Include the organization, provider, event, or subject when available.
Avoid generic document names.
Return valid JSON only.
Output:
{
"taskName": string | null,
"description": string | null,
"dueDate": string | null,
"confidence": number
}`
async function runNativeOCR(imageSource) {
const { Ocr } = await import('@jcesarmobile/capacitor-ocr')
const image = imageSource.includes('/_capacitor_file_/')
? 'file://' + imageSource.replace(/^https?:\/\/localhost\/_capacitor_file_/, '')
: imageSource
const result = await Ocr.process({ image })
return result.results.map(r => r.text).join('\n').trim()
}
async function runBrowserOCR(imageSource, onProgress) {
const { createWorker } = await import('tesseract.js')
const worker = await createWorker('eng', 1, {
logger: m => {
if (m.status === 'recognizing text' && onProgress) {
onProgress(Math.round(m.progress * 100))
}
},
})
const { data } = await worker.recognize(imageSource)
await worker.terminate()
return data.text?.trim() || ''
}
async function extractTaskFromOCR(ocrText) {
const messages = [
{ role: 'system', content: SYSTEM_PROMPT },
{ role: 'user', content: `OCR Text:\n${ocrText}` },
]
const result = await localAIService.plainChat(messages)
if (!result) return null
const jsonMatch = result.match(/\{[\s\S]*\}/)
if (!jsonMatch) return null
try {
return JSON.parse(jsonMatch[0])
} catch {
return null
}
}
// phases: idle | capture | processing | done | error
export function useScanToTask() {
const { isNativeScanner, scanDocument } = useDocumentScanner()
const videoRef = useRef(null)
const canvasRef = useRef(null)
const streamRef = useRef(null)
const fileInputRef = useRef(null)
const [phase, setPhase] = useState('idle')
const [capturedImage, setCapturedImage] = useState(null)
const [ocrProgress, setOcrProgress] = useState(0)
const [taskResult, setTaskResult] = useState(null)
const [errorMsg, setErrorMsg] = useState('')
const [cameraAvailable, setCameraAvailable] = useState(true)
const startCamera = useCallback(async () => {
try {
const stream = await navigator.mediaDevices.getUserMedia({
video: { facingMode: 'environment' },
})
streamRef.current = stream
if (videoRef.current) {
videoRef.current.srcObject = stream
}
setCameraAvailable(true)
} catch {
setCameraAvailable(false)
}
}, [])
const stopCamera = useCallback(() => {
if (streamRef.current) {
streamRef.current.getTracks().forEach(t => t.stop())
streamRef.current = null
}
}, [])
const processImage = useCallback(async (imageData, method = 'browser') => {
setPhase('processing')
setOcrProgress(0)
setErrorMsg('')
try {
let text
if (method === 'native') {
text = await runNativeOCR(imageData)
} else {
text = await runBrowserOCR(imageData, pct => setOcrProgress(pct))
}
if (!text) {
setErrorMsg('No text found in image. Try a clearer photo.')
setPhase('error')
return
}
const task = await extractTaskFromOCR(text)
if (!task || !task.taskName) {
setErrorMsg('Could not identify a task. Try a different photo.')
setPhase('error')
return
}
setTaskResult(task)
setPhase('done')
} catch (e) {
setErrorMsg(e.message || 'Processing failed.')
setPhase('error')
}
}, [])
const capture = useCallback(() => {
if (!videoRef.current || !canvasRef.current) return
const video = videoRef.current
const canvas = canvasRef.current
canvas.width = video.videoWidth
canvas.height = video.videoHeight
canvas.getContext('2d').drawImage(video, 0, 0)
const dataUrl = canvas.toDataURL('image/jpeg', 0.9)
setCapturedImage(dataUrl)
stopCamera()
processImage(dataUrl, 'browser')
}, [stopCamera, processImage])
const handleFileSelect = useCallback(
e => {
const file = e.target.files?.[0]
if (!file) return
const reader = new FileReader()
reader.onload = ev => {
const dataUrl = ev.target.result
setCapturedImage(dataUrl)
stopCamera()
processImage(dataUrl, 'browser')
}
reader.readAsDataURL(file)
},
[stopCamera, processImage],
)
const handleNativeScan = useCallback(async () => {
const { image, cancelled, error } = await scanDocument()
if (cancelled) return
if (error || !image) {
setErrorMsg(error ? `Scanner error: ${error}` : 'Scan failed.')
setPhase('error')
return
}
setCapturedImage(image)
processImage(image, 'native')
}, [scanDocument, processImage])
const retake = useCallback(() => {
setCapturedImage(null)
setTaskResult(null)
setErrorMsg('')
setOcrProgress(0)
setPhase('capture')
}, [])
const activate = useCallback(
(initialImageUrl = null) => {
setCapturedImage(initialImageUrl)
setTaskResult(null)
setErrorMsg('')
setOcrProgress(0)
if (initialImageUrl) {
processImage(initialImageUrl, 'browser')
return
}
setPhase('capture')
},
[processImage],
)
const reset = useCallback(() => {
stopCamera()
setCapturedImage(null)
setTaskResult(null)
setErrorMsg('')
setOcrProgress(0)
setPhase('idle')
}, [stopCamera])
return {
isNativeScanner,
phase,
capturedImage,
ocrProgress,
taskResult,
errorMsg,
cameraAvailable,
videoRef,
canvasRef,
fileInputRef,
startCamera,
stopCamera,
capture,
handleFileSelect,
handleNativeScan,
retake,
activate,
reset,
}
}

View File

@@ -1,3 +1,18 @@
:root,
[data-joy-color-scheme='light'] {
--highlight-date-color: #b45309;
--highlight-repeat-color: #15803d;
--highlight-label-color: #1d4ed8;
--highlight-priority-color: #be123c;
}
[data-joy-color-scheme='dark'] {
--highlight-date-color: #fca5a5;
--highlight-repeat-color: #86efac;
--highlight-label-color: #93c5fd;
--highlight-priority-color: #f9a8d4;
}
.smart-task-display {
position: absolute;
width: 100%;
@@ -11,32 +26,52 @@
white-space: pre-wrap;
box-sizing: border-box;
}
.smart-task-common {
font-size: 1.2em;
line-height: 1.2em;
font-family: inherit;
caret-color: #f08080;
}
.highlight-date {
color: #f08080;
color: var(--highlight-date-color);
}
.highlight-repeat {
color: #90ee90;
color: var(--highlight-repeat-color);
}
.highlight-label {
color: #add8e6;
color: var(--highlight-label-color);
}
.highlight-priority {
color: #ffb6c1;
color: var(--highlight-priority-color);
}
.highlight-assignee {
color: var(--highlight-repeat-color);
}
.highlight-points {
color: var(--highlight-label-color);
}
.task-input {
position: relative;
width: 100%;
border-radius: 8px;
border-radius: 10px;
box-sizing: border-box;
border: none;
background-color: var(--joy-palette-neutral-softBg, #f0f4f8);
overflow: auto;
transition: box-shadow 0.15s ease, background-color 0.15s ease;
}
.task-input:focus-within {
box-shadow: 0 0 0 2px var(--joy-palette-primary-outlinedBorder, rgba(11, 107, 203, 0.15));
}
.task-input:hover:not(:focus-within) {
background-color: var(--joy-palette-neutral-softHoverBg, #dde7ee);
}

View File

@@ -1,4 +1,5 @@
import { useColorScheme } from '@mui/joy'
import { CameraEnhance, PhotoFilter } from '@mui/icons-material'
import { IconButton, Tooltip, useColorScheme } from '@mui/joy'
import { useEffect, useRef, useState } from 'react'
import AutocompleteDropdown from '../TestView/AutocompleteDropdown'
import './SmartTaskTitleInput.css'
@@ -52,9 +53,13 @@ const SmartTaskTitleInput = ({
suggestions,
onEnterPressed,
customRenderer,
isNativeScanner,
onScanClick,
onPhotoSelected,
}) => {
const { mode, setMode } = useColorScheme()
const titleInputRef = useRef(null)
const photoInputRef = useRef(null)
const [cursorPosition, setCursorPosition] = useState(value?.length)
const dropdownRef = useRef(null)
const [lastWord, setLastWord] = useState('')
@@ -181,12 +186,26 @@ const SmartTaskTitleInput = ({
}
}
const handlePhotoInputChange = e => {
const file = e.target.files?.[0]
if (!file || !onPhotoSelected) return
const reader = new FileReader()
reader.onload = ev => onPhotoSelected(ev.target.result)
reader.readAsDataURL(file)
e.target.value = ''
}
const showNativeButtons = isNativeScanner && !value
const MIC_BUTTON_WIDTH =
showNativeButtons && onPhotoSelected && onScanClick
? '5rem'
: showNativeButtons
? '2.5rem'
: '0rem'
return (
<div>
<div
className='task-input overflow-auto rounded border'
style={{ minHeight: '2.4em' }}
>
<div className='task-input' style={{ minHeight: '2.8em' }}>
<textarea
ref={titleInputRef}
autoFocus={autoFocus}
@@ -199,13 +218,12 @@ const SmartTaskTitleInput = ({
position: 'absolute',
top: 0,
left: 0,
width: '100%',
width: `calc(100% - ${MIC_BUTTON_WIDTH})`,
height: '100%',
// opacity: 100,
zIndex: 1,
resize: 'none',
overflow: 'hidden',
padding: '0.5rem',
padding: '0.6rem 0.75rem',
boxSizing: 'border-box',
whiteSpace: 'pre-wrap',
wordBreak: 'break-word',
@@ -215,6 +233,8 @@ const SmartTaskTitleInput = ({
backgroundColor: 'transparent',
color: 'transparent',
caretColor: mode === 'dark' ? '#fff' : '#000',
border: 'none',
outline: 'none',
}}
/>
<div
@@ -224,7 +244,7 @@ const SmartTaskTitleInput = ({
position: 'relative',
zIndex: 1,
minHeight: '1.2em',
padding: '0.5rem',
padding: '0.6rem 0.75rem',
whiteSpace: 'pre-wrap',
wordBreak: 'break-word',
fontFamily: 'inherit',
@@ -234,7 +254,12 @@ const SmartTaskTitleInput = ({
onClick={handleDisplayClick}
>
{placeholder && !value && (
<span className='pointer-events-none text-gray-400'>
<span
style={{
pointerEvents: 'none',
color: 'var(--joy-palette-text-tertiary, #9fa6ad)',
}}
>
{placeholder}
</span>
)}
@@ -244,6 +269,55 @@ const SmartTaskTitleInput = ({
{/* Zero-width space to maintain consistent height */}
&#8203;
</div>
{showNativeButtons && (
<span
style={{
position: 'absolute',
top: '50%',
right: '0.3rem',
transform: 'translateY(-50%)',
zIndex: 2,
display: 'flex',
gap: '0.1rem',
}}
>
{onPhotoSelected && (
<>
<Tooltip title='Select photo' placement='top' size='sm'>
<IconButton
size='sm'
variant='plain'
color='neutral'
onClick={() => photoInputRef.current?.click()}
sx={{ borderRadius: 'xl' }}
>
<PhotoFilter fontSize='small' />
</IconButton>
</Tooltip>
<input
ref={photoInputRef}
type='file'
accept='image/*'
style={{ display: 'none' }}
onChange={handlePhotoInputChange}
/>
</>
)}
{onScanClick && (
<Tooltip title='Scan to create task' placement='top' size='sm'>
<IconButton
size='sm'
variant='plain'
color='neutral'
onClick={onScanClick}
sx={{ borderRadius: 'xl' }}
>
<CameraEnhance fontSize='small' />
</IconButton>
</Tooltip>
)}
</span>
)}
</div>
{showSuggestions && (
<AutocompleteDropdown

View File

@@ -23,8 +23,12 @@ import {
Typography,
} from '@mui/joy'
import { useQueryClient } from '@tanstack/react-query'
import { useEffect, useState } from 'react'
import { useEffect, useMemo, useState } from 'react'
import { networkManager } from '../../hooks/NetworkManager'
import {
PENDING_POLL_MS,
SERVER_PROBE_MS,
} from '../../hooks/useSyncOnReconnect'
import { commandQueue } from '../../utils/CommandQueue'
import {
isOfflineFeatureEnabled,
@@ -57,8 +61,6 @@ const formatCommandLabel = commandType => {
)
}
const RETRY_INTERVAL = 30
function SyncStatusIndicator() {
const queryClient = useQueryClient()
const [pendingCommands, setPendingCommands] = useState([])
@@ -70,7 +72,18 @@ function SyncStatusIndicator() {
})
const [isOnline, setIsOnline] = useState(networkManager.isOnline)
const [offlineSince, setOfflineSince] = useState(networkManager.offlineSince)
const [retryIn, setRetryIn] = useState(RETRY_INTERVAL)
const [offlineReason, setOfflineReason] = useState(networkManager.offlineReason)
// Mirror the actual intervals used by useSyncOnReconnect so the countdown is accurate
const retryInterval = useMemo(
() =>
!isOnline && offlineReason === 'server'
? SERVER_PROBE_MS / 1000
: PENDING_POLL_MS / 1000,
[isOnline, offlineReason],
)
const [retryIn, setRetryIn] = useState(retryInterval)
const [offlineFeatureEnabled, setOfflineFeatureEnabled] = useState(
isOfflineFeatureEnabled(),
)
@@ -89,24 +102,49 @@ function SyncStatusIndicator() {
useEffect(() => {
if (!syncState.syncing) {
setRetryIn(RETRY_INTERVAL)
setRetryIn(retryInterval)
}
}, [syncState.syncing, syncState.lastSync])
}, [syncState.syncing, syncState.lastSync, retryInterval])
useEffect(() => {
networkManager.registerNetworkListener(online => {
setIsOnline(online)
setOfflineReason(networkManager.offlineReason)
if (!online) setOfflineSince(networkManager.offlineSince)
})
}, [])
useEffect(() => {
if (!isOnline || syncState.syncing) return
// Run countdown both when online (pending commands) and when server-unreachable (probe interval)
if (syncState.syncing) return
if (isOnline && pendingCommands.length === 0) return
if (!isOnline && offlineReason === 'device') return
const interval = setInterval(() => {
setRetryIn(prev => (prev <= 1 ? RETRY_INTERVAL : prev - 1))
setRetryIn(prev => {
if (prev <= 1) {
console.debug('[SyncStatusIndicator] Retry timer fired', {
isOnline,
offlineReason,
syncing: syncState.syncing,
lastSync: syncState.lastSync,
error: syncState.error,
pendingCommands: pendingCommands.length,
})
return retryInterval
}
return prev - 1
})
}, 1000)
return () => clearInterval(interval)
}, [isOnline, syncState.syncing, syncState.lastSync])
}, [
isOnline,
offlineReason,
syncState.syncing,
syncState.lastSync,
syncState.error,
pendingCommands.length,
retryInterval,
])
useEffect(() => {
const update = async () => {
@@ -461,7 +499,19 @@ function SyncStatusIndicator() {
</Typography>
</Box>
)}
{!isOnline && (
{!isOnline && offlineReason === 'server' && (
<Box sx={{ px: 1, pb: 0.5 }}>
<Typography
level='body-xs'
sx={{ color: 'var(--joy-palette-text-tertiary)' }}
>
{syncState.syncing
? 'Checking server...'
: `Retrying in ${retryIn}s`}
</Typography>
</Box>
)}
{!isOnline && offlineReason !== 'server' && (
<Box sx={{ px: 1, pb: 0.5 }}>
<Typography
level='body-xs'