Merge pull request #234 from donetick/eslint3

ran npm run lint:fix
This commit is contained in:
Mohamad Tarbin
2026-08-16 15:33:13 -04:00
committed by GitHub
176 changed files with 1101 additions and 767 deletions

View File

@@ -6,7 +6,9 @@ const browser = await chromium.launch()
const context = await browser.newContext({ storageState: state }) const context = await browser.newContext({ storageState: state })
const page = await context.newPage() const page = await context.newPage()
page.on('console', msg => console.log('[console]', msg.type(), msg.text())) page.on('console', msg => console.log('[console]', msg.type(), msg.text()))
page.on('pageerror', err => console.log('[pageerror]', err.message, '\n', err.stack)) page.on('pageerror', err =>
console.log('[pageerror]', err.message, '\n', err.stack),
)
await page.goto('http://localhost:5173/chores/create') await page.goto('http://localhost:5173/chores/create')
await page.getByTestId('chore-name-input').fill('Debug Chore ' + Date.now()) await page.getByTestId('chore-name-input').fill('Debug Chore ' + Date.now())

View File

@@ -1,4 +1,4 @@
import { test as base, expect } from '@playwright/test' import { expect, test as base } from '@playwright/test'
import path from 'path' import path from 'path'
import { fileURLToPath } from 'url' import { fileURLToPath } from 'url'
@@ -9,7 +9,10 @@ const __dirname = path.dirname(fileURLToPath(import.meta.url))
* After successful signup the app auto-logs in and walks through the * After successful signup the app auto-logs in and walks through the
* onboarding flow (/circle-setup, then /ready) before landing on /chores. * onboarding flow (/circle-setup, then /ready) before landing on /chores.
*/ */
export async function signUpViaUI(page, { username, email, password, displayName }) { export async function signUpViaUI(
page,
{ displayName, email, password, username },
) {
await page.goto('/signup') await page.goto('/signup')
await page.locator('#username').fill(username) await page.locator('#username').fill(username)
await page.locator('#email').fill(email) await page.locator('#email').fill(email)
@@ -30,7 +33,7 @@ export async function signUpViaUI(page, { username, email, password, displayName
* Fill and submit the login form through the UI. * Fill and submit the login form through the UI.
* After successful login the app redirects to /chores. * After successful login the app redirects to /chores.
*/ */
export async function loginViaUI(page, { username, password }) { export async function loginViaUI(page, { password, username }) {
await page.goto('/login') await page.goto('/login')
await page.locator('#username').fill(username) await page.locator('#username').fill(username)
await page.locator('#password').fill(password) await page.locator('#password').fill(password)

View File

@@ -1,4 +1,4 @@
import { writeFile, mkdir } from 'fs/promises' import { mkdir, writeFile } from 'fs/promises'
import path from 'path' import path from 'path'
import { fileURLToPath } from 'url' import { fileURLToPath } from 'url'
@@ -56,7 +56,7 @@ export default async function globalSetup() {
throw new Error(`Login failed (${loginRes.status}): ${body}`) throw new Error(`Login failed (${loginRes.status}): ${body}`)
} }
const { token, expire } = await loginRes.json() const { expire, token } = await loginRes.json()
// Write a Playwright storage-state file containing the token in localStorage // Write a Playwright storage-state file containing the token in localStorage
const stateDir = path.join(__dirname, '.auth') const stateDir = path.join(__dirname, '.auth')
@@ -84,7 +84,11 @@ export default async function globalSetup() {
async function waitForServer(url, retries = 20, delayMs = 1000) { async function waitForServer(url, retries = 20, delayMs = 1000) {
for (let i = 0; i < retries; i++) { for (let i = 0; i < retries; i++) {
try { try {
const res = await fetch(`${url}/api/v1/auth/login`, { method: 'POST', body: '{}', headers: { 'Content-Type': 'application/json' } }) const res = await fetch(`${url}/api/v1/auth/login`, {
method: 'POST',
body: '{}',
headers: { 'Content-Type': 'application/json' },
})
if (res.status < 500) return if (res.status < 500) return
} catch { } catch {
// server not up yet // server not up yet

View File

@@ -1,12 +1,14 @@
import { test, expect } from '@playwright/test' import { expect, test } from '@playwright/test'
import { signUpViaUI, loginViaUI } from '../fixtures/auth.js'
import { loginViaUI, signUpViaUI } from '../fixtures/auth.js'
// Username must match /^[a-z.-]+$/ — no digits allowed. // Username must match /^[a-z.-]+$/ — no digits allowed.
// Generate a random lowercase-only suffix for uniqueness across runs. // Generate a random lowercase-only suffix for uniqueness across runs.
function randomSuffix(len = 8) { function randomSuffix(len = 8) {
const chars = 'abcdefghijklmnopqrstuvwxyz' const chars = 'abcdefghijklmnopqrstuvwxyz'
return Array.from({ length: len }, () => return Array.from(
chars[Math.floor(Math.random() * 26)], { length: len },
() => chars[Math.floor(Math.random() * 26)],
).join('') ).join('')
} }
@@ -41,7 +43,9 @@ test.describe('Auth Sign Up', () => {
test.describe('Auth Login', () => { test.describe('Auth Login', () => {
// Re-use the shared E2E user that global-setup already created // Re-use the shared E2E user that global-setup already created
test('logs in with valid credentials and lands on /chores', async ({ page }) => { test('logs in with valid credentials and lands on /chores', async ({
page,
}) => {
await loginViaUI(page, { await loginViaUI(page, {
username: 'e2e.user', username: 'e2e.user',
password: 'E2ePassword123!', password: 'E2ePassword123!',

View File

@@ -14,6 +14,7 @@ import Select from '@mui/joy/Select'
import Typography from '@mui/joy/Typography' import Typography from '@mui/joy/Typography'
import { useCallback, useEffect, useRef, useState } from 'react' import { useCallback, useEffect, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { NOTIFICATION_TYPE, TASK_COLOR } from '../utils/Colors' import { NOTIFICATION_TYPE, TASK_COLOR } from '../utils/Colors'
import { TIME_UNITS } from '../utils/DurationUtils' import { TIME_UNITS } from '../utils/DurationUtils'
@@ -26,7 +27,7 @@ const timingOptions = [
] ]
function getRelativeLabel(notification, t) { function getRelativeLabel(notification, t) {
const { value, unit } = notification const { unit, value } = notification
const numericValue = Number(value) const numericValue = Number(value)
if (numericValue === 0) { if (numericValue === 0) {
return t('notifTemplate.onDueDate') return t('notifTemplate.onDueDate')
@@ -71,8 +72,8 @@ const NotificationTemplate = ({
// Consumers that own an empty state themselves pass 0. // Consumers that own an empty state themselves pass 0.
minNotifications = 1, minNotifications = 1,
onChange, onChange,
value,
showTimeline = true, showTimeline = true,
value,
}) => { }) => {
const { t } = useTranslation('chores') const { t } = useTranslation('chores')
const [notifications, setNotifications] = useState( const [notifications, setNotifications] = useState(

View File

@@ -1,13 +1,14 @@
import { Circle, SignalWifi4Bar, SignalWifiOff } from '@mui/icons-material' import { Circle, SignalWifi4Bar, SignalWifiOff } from '@mui/icons-material'
import { Box, Chip, Tooltip, Typography } from '@mui/joy' import { Box, Chip, Tooltip, Typography } from '@mui/joy'
import { useSSEContext } from '../hooks/useSSEContext' import { useSSEContext } from '../hooks/useSSEContext'
const SSEConnectionStatus = ({ const SSEConnectionStatus = ({
variant = 'minimal',
showError = false, showError = false,
sx = {}, sx = {},
variant = 'minimal',
}) => { }) => {
const { isConnected, isConnecting, error, getConnectionStatus } = const { error, getConnectionStatus, isConnected, isConnecting } =
useSSEContext() useSSEContext()
const getStatusColor = () => { const getStatusColor = () => {

View File

@@ -9,22 +9,23 @@ import {
Switch, Switch,
Typography, Typography,
} from '@mui/joy' } from '@mui/joy'
import { useTranslation } from 'react-i18next'
import { useSSEContext } from '../hooks/useSSEContext' import { useSSEContext } from '../hooks/useSSEContext'
import { useUserProfile } from '../queries/UserQueries' import { useUserProfile } from '../queries/UserQueries'
import { isPlusAccount } from '../utils/Helpers' import { isPlusAccount } from '../utils/Helpers'
import SSEConnectionStatus from './SSEConnectionStatus' import SSEConnectionStatus from './SSEConnectionStatus'
import { useTranslation } from 'react-i18next'
const SSESettings = () => { const SSESettings = () => {
const { t } = useTranslation('settings') const { t } = useTranslation('settings')
const { data: userProfile } = useUserProfile() const { data: userProfile } = useUserProfile()
const { const {
isConnected,
isConnecting,
error, error,
getConnectionStatus, getConnectionStatus,
toggleSSEEnabled, isConnected,
isConnecting,
isSSEEnabled, isSSEEnabled,
toggleSSEEnabled,
} = useSSEContext() } = useSSEContext()
const handleToggle = () => { const handleToggle = () => {

View File

@@ -1,13 +1,14 @@
import { Check, Star } from '@mui/icons-material' import { Check, Star } from '@mui/icons-material'
import { Box, Card, Chip, Divider, Radio, Typography } from '@mui/joy' import { Box, Card, Chip, Divider, Radio, Typography } from '@mui/joy'
import { useState } from 'react' import { useState } from 'react'
import AppModal from './common/AppModal'
import ModalActions from './common/ModalActions'
import { useNotification } from '../service/NotificationProvider'
import { GetSubscriptionSession } from '../utils/Fetcher'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
const SubscriptionModal = ({ open, onClose }) => { import { useNotification } from '../service/NotificationProvider'
import { GetSubscriptionSession } from '../utils/Fetcher'
import AppModal from './common/AppModal'
import ModalActions from './common/ModalActions'
const SubscriptionModal = ({ onClose, open }) => {
const { t } = useTranslation('settings') const { t } = useTranslation('settings')
const [selectedPlan, setSelectedPlan] = useState('yearly') const [selectedPlan, setSelectedPlan] = useState('yearly')
const [isLoading, setIsLoading] = useState(false) const [isLoading, setIsLoading] = useState(false)
@@ -81,7 +82,11 @@ const SubscriptionModal = ({ open, onClose }) => {
footer={ footer={
<ModalActions <ModalActions
stackOnMobile stackOnMobile
secondary={{ label: t('accountSettings.cancel'), onClick: onClose, disabled: isLoading }} secondary={{
label: t('accountSettings.cancel'),
onClick: onClose,
disabled: isLoading,
}}
primary={{ primary={{
label: t('subscription.subscribe'), label: t('subscription.subscribe'),
onClick: handleSubscribe, onClick: handleSubscribe,

View File

@@ -27,7 +27,9 @@ import {
import { useMediaQuery } from '@mui/material' import { useMediaQuery } from '@mui/material'
import moment from 'moment' import moment from 'moment'
import { useState } from 'react' import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import { useNavigate } from 'react-router-dom' import { useNavigate } from 'react-router-dom'
import { useImpersonateUser } from '../contexts/ImpersonateUserContext' import { useImpersonateUser } from '../contexts/ImpersonateUserContext'
import useStickyState from '../hooks/useStickyState' import useStickyState from '../hooks/useStickyState'
import { useCircleMembers, useUserProfile } from '../queries/UserQueries' import { useCircleMembers, useUserProfile } from '../queries/UserQueries'
@@ -35,7 +37,6 @@ import { apiClient } from '../utils/ApiClient'
import { isPlusAccount, resolvePhotoURL } from '../utils/Helpers' import { isPlusAccount, resolvePhotoURL } from '../utils/Helpers'
import UserModal from '../views/Modals/Inputs/UserModal' import UserModal from '../views/Modals/Inputs/UserModal'
import SubscriptionModal from './SubscriptionModal' import SubscriptionModal from './SubscriptionModal'
import { useTranslation } from 'react-i18next'
const UserProfileAvatar = () => { const UserProfileAvatar = () => {
const { t } = useTranslation('common') const { t } = useTranslation('common')
@@ -43,11 +44,11 @@ const UserProfileAvatar = () => {
const { mode, setMode } = useColorScheme() const { mode, setMode } = useColorScheme()
const { data: userProfile } = useUserProfile() const { data: userProfile } = useUserProfile()
const { const {
canImpersonate,
getEffectiveUser,
isImpersonating, isImpersonating,
startImpersonation, startImpersonation,
stopImpersonation, stopImpersonation,
canImpersonate,
getEffectiveUser,
} = useImpersonateUser() } = useImpersonateUser()
const { data: circleMembersData } = useCircleMembers() const { data: circleMembersData } = useCircleMembers()
const [isModalOpen, setIsModalOpen] = useState(false) const [isModalOpen, setIsModalOpen] = useState(false)
@@ -129,7 +130,9 @@ const UserProfileAvatar = () => {
}} }}
/> />
<Avatar <Avatar
src={resolvePhotoURL(userProfile?.image || userProfile?.avatar)} src={resolvePhotoURL(
userProfile?.image || userProfile?.avatar,
)}
alt={userProfile?.displayName || userProfile?.name} alt={userProfile?.displayName || userProfile?.name}
size='sm' size='sm'
sx={{ sx={{

View File

@@ -1,40 +1,43 @@
import React from 'react'
import { Box } from '@mui/joy'
import { CSSTransition, TransitionGroup } from 'react-transition-group'
import { useStaggeredAnimation, useReducedMotion } from '../../hooks/useAnimations'
import './PageTransition.css' import './PageTransition.css'
const AnimatedList = ({ import { Box } from '@mui/joy'
children, import React from 'react'
staggerDelay = 50, import { CSSTransition, TransitionGroup } from 'react-transition-group'
animationType = 'stagger', // 'stagger', 'fade', 'slide'
direction = 'up', // 'up', 'down', 'left', 'right' import {
renderItem, useReducedMotion,
useStaggeredAnimation,
} from '../../hooks/useAnimations'
const AnimatedList = ({
animationType = 'stagger',
children,
direction = 'up', // 'stagger', 'fade', 'slide'
items, // 'up', 'down', 'left', 'right'
keyExtractor, keyExtractor,
items, renderItem,
...boxProps staggerDelay = 50,
...boxProps
}) => { }) => {
// Handle both children and items patterns // Handle both children and items patterns
let childrenArray let childrenArray
if (items && renderItem) { if (items && renderItem) {
childrenArray = items.map((item, index) => childrenArray = items.map((item, index) =>
React.cloneElement(renderItem(item, index), { React.cloneElement(renderItem(item, index), {
key: keyExtractor ? keyExtractor(item, index) : index key: keyExtractor ? keyExtractor(item, index) : index,
}) }),
) )
} else { } else {
childrenArray = React.Children.toArray(children) childrenArray = React.Children.toArray(children)
} }
const visibleItems = useStaggeredAnimation(childrenArray.length, staggerDelay) const visibleItems = useStaggeredAnimation(childrenArray.length, staggerDelay)
const prefersReducedMotion = useReducedMotion() const prefersReducedMotion = useReducedMotion()
// If user prefers reduced motion, render without animations // If user prefers reduced motion, render without animations
if (prefersReducedMotion) { if (prefersReducedMotion) {
return ( return (
<Box {...boxProps}> <Box {...boxProps}>{items && renderItem ? childrenArray : children}</Box>
{items && renderItem ? childrenArray : children}
</Box>
) )
} }
@@ -55,7 +58,7 @@ const AnimatedList = ({
<TransitionGroup component={null}> <TransitionGroup component={null}>
{childrenArray.map((child, index) => { {childrenArray.map((child, index) => {
const isVisible = visibleItems.has(index) const isVisible = visibleItems.has(index)
return ( return (
<CSSTransition <CSSTransition
key={child.key || index} key={child.key || index}

View File

@@ -65,11 +65,7 @@ const LogoContainer = styled(Box)({
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
const LoadingScreen = ({ const LoadingScreen = ({ message = null, showLogo = true, size = 'lg' }) => {
message = null,
showLogo = true,
size = 'lg',
}) => {
const { t } = useTranslation('common') const { t } = useTranslation('common')
return ( return (
<LoadingContainer> <LoadingContainer>
@@ -90,7 +86,7 @@ const LoadingScreen = ({
</Typography> </Typography>
</LogoContainer> </LogoContainer>
)} )}
<CircularProgress <CircularProgress
size={size} size={size}
sx={{ sx={{
@@ -98,7 +94,7 @@ const LoadingScreen = ({
mb: 2, mb: 2,
}} }}
/> />
<PulsingText level='body-md'>{message ?? t('loading')}</PulsingText> <PulsingText level='body-md'>{message ?? t('loading')}</PulsingText>
</LoadingContent> </LoadingContent>
</LoadingContainer> </LoadingContainer>

View File

@@ -1,7 +1,8 @@
import './PageTransition.css'
import { useLayoutEffect, useRef, useState } from 'react' import { useLayoutEffect, useRef, useState } from 'react'
import { flushSync } from 'react-dom' import { flushSync } from 'react-dom'
import { useLocation } from 'react-router-dom' import { useLocation } from 'react-router-dom'
import './PageTransition.css'
// Route hierarchy for determining navigation direction // Route hierarchy for determining navigation direction
const routeHierarchy = { const routeHierarchy = {

View File

@@ -1,11 +1,11 @@
import { Box, Skeleton } from '@mui/joy' import { Box, Skeleton } from '@mui/joy'
const SkeletonLoader = ({ const SkeletonLoader = ({
type = 'card',
count = 1, count = 1,
height = 100, height = 100,
width = '100%', type = 'card',
variant = 'rectangular', variant = 'rectangular',
width = '100%',
...props ...props
}) => { }) => {
const renderSkeleton = () => { const renderSkeleton = () => {

View File

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

View File

@@ -1,13 +1,14 @@
import './PageTransition.css'
import { Box } from '@mui/joy' import { Box } from '@mui/joy'
import React, { useEffect, useState } from 'react' import React, { useEffect, useState } from 'react'
import { CSSTransition, TransitionGroup } from 'react-transition-group' import { CSSTransition, TransitionGroup } from 'react-transition-group'
import './PageTransition.css'
const StaggeredList = ({ const StaggeredList = ({
children,
staggerDelay = 50,
initialDelay = 0,
animate = true, animate = true,
children,
initialDelay = 0,
staggerDelay = 50,
}) => { }) => {
const [isVisible, setIsVisible] = useState(!animate) const [isVisible, setIsVisible] = useState(!animate)

View File

@@ -17,27 +17,27 @@ const WIDTH_BY_SIZE = {
const AppModal = forwardRef( const AppModal = forwardRef(
( (
{ {
open, backdropBlur = true,
onClose,
children, children,
title, closeOnBackdrop = true,
closeOnEscape = true,
contentSx,
description, description,
footer, footer,
size = 'md', footerSx,
fullWidth = true, fullWidth = true,
isMobile: isMobileProp, isMobile: isMobileProp,
keepMounted = false, keepMounted = false,
maxHeight = '90dvh',
mobilePresentation = 'sheet', mobilePresentation = 'sheet',
onClose,
open,
role = 'dialog', role = 'dialog',
showCloseButton = true, showCloseButton = true,
showHandle = false, showHandle = false,
closeOnBackdrop = true, size = 'md',
closeOnEscape = true,
backdropBlur = true,
maxHeight = '90dvh',
contentSx,
footerSx,
sx, sx,
title,
unmountDelay = 180, unmountDelay = 180,
...modalProps ...modalProps
}, },

View File

@@ -1,6 +1,7 @@
import { Add, Remove } from '@mui/icons-material' import { Add, Remove } from '@mui/icons-material'
import { Box, IconButton, Input, Option, Select } from '@mui/joy' import { Box, IconButton, Input, Option, Select } from '@mui/joy'
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
import { import {
secondsToValueAndUnit, secondsToValueAndUnit,
TIME_UNITS, TIME_UNITS,
@@ -16,7 +17,7 @@ import {
* size Joy UI size ('sm' | 'md') * size Joy UI size ('sm' | 'md')
* minValue minimum numeric value (default 1) * minValue minimum numeric value (default 1)
*/ */
const DurationInput = ({ value, onChange, size = 'md', minValue = 1 }) => { const DurationInput = ({ minValue = 1, onChange, size = 'md', value }) => {
const derived = const derived =
value != null && value >= 0 value != null && value >= 0
? secondsToValueAndUnit(value) ? secondsToValueAndUnit(value)
@@ -26,7 +27,7 @@ const DurationInput = ({ value, onChange, size = 'md', minValue = 1 }) => {
useEffect(() => { useEffect(() => {
if (value != null && value >= 0) { if (value != null && value >= 0) {
const { value: v, unit: u } = secondsToValueAndUnit(value) const { unit: u, value: v } = secondsToValueAndUnit(value)
setDisplayValue(v) setDisplayValue(v)
setUnit(u) setUnit(u)
} }

View File

@@ -55,7 +55,7 @@ const SIZES = {
} }
const ActionButton = ({ action, ...buttonProps }) => { const ActionButton = ({ action, ...buttonProps }) => {
const { label, to, onClick, ...rest } = action const { label, onClick, to, ...rest } = action
return ( return (
<Button <Button
{...buttonProps} {...buttonProps}
@@ -73,15 +73,15 @@ ActionButton.propTypes = {
} }
const EmptyState = ({ const EmptyState = ({
variant = 'empty',
icon,
title,
description, description,
fullHeight = false,
icon,
primaryAction, primaryAction,
secondaryAction, secondaryAction,
size = 'md', size = 'md',
fullHeight = false,
sx, sx,
title,
variant = 'empty',
...rest ...rest
}) => { }) => {
const tone = TONES[variant] || TONES.empty const tone = TONES[variant] || TONES.empty

View File

@@ -10,9 +10,10 @@ import {
Typography, Typography,
} from '@mui/joy' } from '@mui/joy'
import { useState } from 'react' import { useState } from 'react'
import AppModal from './AppModal' import AppModal from './AppModal'
import ModalActions from './ModalActions'
import ActiveFilterChips from './filter/ActiveFilterChips' import ActiveFilterChips from './filter/ActiveFilterChips'
import ModalActions from './ModalActions'
/** /**
* Reusable filter bar component. * Reusable filter bar component.
@@ -141,17 +142,17 @@ const fmtDisplayDate = iso => {
// ── Component ──────────────────────────────────────────────────────────────── // ── Component ────────────────────────────────────────────────────────────────
const FilterBar = ({ const FilterBar = ({
filterDefs,
activeFilters, activeFilters,
onSetFilter, filterDefs,
onClearAll, onClearAll,
resultCount, onOpenChange,
totalCount, onSetFilter,
open,
// When the host renders its own trigger (e.g. an icon button in a toolbar // When the host renders its own trigger (e.g. an icon button in a toolbar
// row), it drives the sheet through `open`/`onOpenChange` and hides ours. // row), it drives the sheet through `open`/`onOpenChange` and hides ours.
open, resultCount,
onOpenChange,
showTrigger = true, showTrigger = true,
totalCount,
}) => { }) => {
const [internalOpen, setInternalOpen] = useState(false) const [internalOpen, setInternalOpen] = useState(false)
const isControlled = open !== undefined const isControlled = open !== undefined

View File

@@ -9,10 +9,10 @@ import PropTypes from 'prop-types'
function KeyboardShortcutHint({ function KeyboardShortcutHint({
shortcut, shortcut,
show = true, show = true,
withCmd = true,
withCtrl, // Legacy prop for backward compatibility
withShift = false,
sx = {}, sx = {},
withCmd = true, // Legacy prop for backward compatibility
withCtrl,
withShift = false,
...props ...props
}) { }) {
if (!show) return null if (!show) return null

View File

@@ -16,12 +16,12 @@ const ActionButton = ({ action, defaults, sx }) => {
* primary action is always the final, highest-emphasis control. * primary action is always the final, highest-emphasis control.
*/ */
const ModalActions = ({ const ModalActions = ({
children,
primary, primary,
secondary, secondary,
tertiary,
children,
stackOnMobile = false, stackOnMobile = false,
sx, sx,
tertiary,
}) => { }) => {
const responsiveButtonStyles = stackOnMobile const responsiveButtonStyles = stackOnMobile
? { '& > button': { width: { xs: '100%', sm: 'auto' } } } ? { '& > button': { width: { xs: '100%', sm: 'auto' } } }

View File

@@ -1,6 +1,5 @@
import { Add, Close } from '@mui/icons-material' import { Add, Close } from '@mui/icons-material'
import { Box, Button, Chip, ChipDelete, Typography } from '@mui/joy' import { Box, Button, Chip, ChipDelete, Typography } from '@mui/joy'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
const ActiveFilterChips = ({ const ActiveFilterChips = ({

View File

@@ -1,5 +1,6 @@
import { QueryClient, QueryClientProvider } from '@tanstack/react-query' import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
import { initWidgetSync } from '../service/WidgetService' import { initWidgetSync } from '../service/WidgetService'
const QueryContext = ({ children }) => { const QueryContext = ({ children }) => {

View File

@@ -1,4 +1,5 @@
import { createContext, useContext } from 'react' import { createContext, useContext } from 'react'
import { useSSE } from '../hooks/useSSE' import { useSSE } from '../hooks/useSSE'
export const SSEContext = createContext({ export const SSEContext = createContext({

View File

@@ -1,4 +1,5 @@
import { Network } from '@capacitor/network' import { Network } from '@capacitor/network'
import { isOfflineFeatureEnabled } from '../utils/OfflineFeatureToggle' import { isOfflineFeatureEnabled } from '../utils/OfflineFeatureToggle'
class NetworkManager { class NetworkManager {

View File

@@ -36,4 +36,4 @@ const useAcknowledgmentModal = () => {
} }
} }
export default useAcknowledgmentModal export default useAcknowledgmentModal

View File

@@ -8,7 +8,7 @@ export const useReducedMotion = () => {
const mediaQuery = window.matchMedia('(prefers-reduced-motion: reduce)') const mediaQuery = window.matchMedia('(prefers-reduced-motion: reduce)')
setPrefersReducedMotion(mediaQuery.matches) setPrefersReducedMotion(mediaQuery.matches)
const handleChange = (event) => { const handleChange = event => {
setPrefersReducedMotion(event.matches) setPrefersReducedMotion(event.matches)
} }
@@ -32,13 +32,13 @@ export const useStaggeredAnimation = (itemCount, delay = 50) => {
} }
const timeouts = [] const timeouts = []
// Stagger the appearance of items // Stagger the appearance of items
for (let i = 0; i < itemCount; i++) { for (let i = 0; i < itemCount; i++) {
const timeout = setTimeout(() => { const timeout = setTimeout(() => {
setVisibleItems(prev => new Set([...prev, i])) setVisibleItems(prev => new Set([...prev, i]))
}, i * delay) }, i * delay)
timeouts.push(timeout) timeouts.push(timeout)
} }
@@ -62,7 +62,7 @@ export const useInViewAnimation = (threshold = 0.1) => {
([entry]) => { ([entry]) => {
setIsInView(entry.isIntersecting) setIsInView(entry.isIntersecting)
}, },
{ threshold } { threshold },
) )
observer.observe(element) observer.observe(element)

View File

@@ -1,5 +1,6 @@
import { createContext, useContext, useEffect, useState } from 'react' import { createContext, useContext, useEffect, useState } from 'react'
import { useNavigate } from 'react-router-dom' import { useNavigate } from 'react-router-dom'
import { apiClient } from '../utils/ApiClient' import { apiClient } from '../utils/ApiClient'
import { offlineDB } from '../utils/OfflineDB' import { offlineDB } from '../utils/OfflineDB'
import { clearAllTokens, saveTokens } from '../utils/TokenStorage' import { clearAllTokens, saveTokens } from '../utils/TokenStorage'

View File

@@ -40,4 +40,4 @@ const useConfirmationModal = () => {
} }
} }
export default useConfirmationModal export default useConfirmationModal

View File

@@ -1,4 +1,5 @@
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
import { patchDescriptionHtml } from '../utils/ImageCache' import { patchDescriptionHtml } from '../utils/ImageCache'
// Returns description HTML safe to render: embedded images with an expired // Returns description HTML safe to render: embedded images with an expired

View File

@@ -10,8 +10,14 @@ import { Capacitor } from '@capacitor/core'
function normalizeScannedImage(raw) { function normalizeScannedImage(raw) {
if (!raw) return null if (!raw) return null
if (raw.startsWith('data:')) return raw if (raw.startsWith('data:')) return raw
if (raw.startsWith('http://') || raw.startsWith('https://') || raw.startsWith('content://')) return raw if (
if (raw.startsWith('/') || raw.startsWith('file://')) return Capacitor.convertFileSrc(raw) 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 // iOS base64 without prefix
return `data:image/jpeg;base64,${raw}` return `data:image/jpeg;base64,${raw}`
} }
@@ -25,11 +31,16 @@ function normalizeScannedImage(raw) {
export function useDocumentScanner() { export function useDocumentScanner() {
const isNativeScanner = Capacitor.isNativePlatform() const isNativeScanner = Capacitor.isNativePlatform()
const scanDocument = async ({ maxDocuments = 1, quality = 90, letUserAdjustCrop = false } = {}) => { const scanDocument = async ({
letUserAdjustCrop = false,
maxDocuments = 1,
quality = 90,
} = {}) => {
if (!isNativeScanner) return { image: null, cancelled: false } if (!isNativeScanner) return { image: null, cancelled: false }
try { try {
const { DocumentScanner } = await import('@capgo/capacitor-document-scanner') const { DocumentScanner } =
await import('@capgo/capacitor-document-scanner')
const { scannedImages } = await DocumentScanner.scanDocument({ const { scannedImages } = await DocumentScanner.scanDocument({
croppedImageQuality: quality, croppedImageQuality: quality,
maxNumDocuments: maxDocuments, maxNumDocuments: maxDocuments,

View File

@@ -1,11 +1,11 @@
import imageCompression from 'browser-image-compression' import imageCompression from 'browser-image-compression'
import { useCallback } from 'react' import { useCallback } from 'react'
import { useTranslation } from 'react-i18next'
import { useUserProfile } from '../queries/UserQueries' import { useUserProfile } from '../queries/UserQueries'
import { useNotification } from '../service/NotificationProvider' import { useNotification } from '../service/NotificationProvider'
import { apiClient } from '../utils/ApiClient' import { apiClient } from '../utils/ApiClient'
import { isPlusAccount, resolvePhotoURL } from '../utils/Helpers' import { isPlusAccount, resolvePhotoURL } from '../utils/Helpers'
import { useTranslation } from 'react-i18next'
export const useFileUpload = ({ export const useFileUpload = ({
draftId, draftId,

View File

@@ -1,4 +1,5 @@
import { useQuery } from '@tanstack/react-query' import { useQuery } from '@tanstack/react-query'
import { commandQueue } from '../utils/CommandQueue' import { commandQueue } from '../utils/CommandQueue'
// Hook to get pending commands for a specific chore (for showing pending badges/undo) // Hook to get pending commands for a specific chore (for showing pending badges/undo)

View File

@@ -1,5 +1,6 @@
import useMediaQuery from '@mui/material/useMediaQuery' import useMediaQuery from '@mui/material/useMediaQuery'
import { createElement } from 'react' import { createElement } from 'react'
import AppModal from '../components/common/AppModal' import AppModal from '../components/common/AppModal'
const MobileAppModal = props => const MobileAppModal = props =>

View File

@@ -2,12 +2,13 @@ import { Capacitor } from '@capacitor/core'
import { useQueryClient } from '@tanstack/react-query' import { useQueryClient } from '@tanstack/react-query'
import { EventSourcePolyfill } from 'event-source-polyfill' import { EventSourcePolyfill } from 'event-source-polyfill'
import { useCallback, useEffect, useRef, useState } from 'react' import { useCallback, useEffect, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { useUserProfile } from '../queries/UserQueries' import { useUserProfile } from '../queries/UserQueries'
import { useAlerts } from '../service/AlertsProvider' import { useAlerts } from '../service/AlertsProvider'
import { useNotification } from '../service/NotificationProvider' import { useNotification } from '../service/NotificationProvider'
import { apiClient } from '../utils/ApiClient' import { apiClient } from '../utils/ApiClient'
import { useAuth } from './useAuth.jsx' import { useAuth } from './useAuth.jsx'
import { useTranslation } from 'react-i18next'
const SSE_STATES = { const SSE_STATES = {
CONNECTING: 0, CONNECTING: 0,

View File

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

View File

@@ -1,4 +1,5 @@
import { useQuery } from '@tanstack/react-query' import { useQuery } from '@tanstack/react-query'
import { GetResource } from '../utils/Fetcher' import { GetResource } from '../utils/Fetcher'
// Helper to check if we have a valid token // Helper to check if we have a valid token
@@ -13,7 +14,7 @@ const isTokenValid = () => {
} }
export const useResource = () => { export const useResource = () => {
const { data, isLoading, error, refetch } = useQuery({ const { data, error, isLoading, refetch } = useQuery({
queryKey: ['resource'], queryKey: ['resource'],
queryFn: async () => { queryFn: async () => {
const response = await GetResource() const response = await GetResource()

View File

@@ -1,4 +1,5 @@
import { useMutation, useQueryClient } from '@tanstack/react-query' import { useMutation, useQueryClient } from '@tanstack/react-query'
import { networkManager } from '../hooks/NetworkManager' import { networkManager } from '../hooks/NetworkManager'
import { CompleteSubTask, SaveChore } from '../utils/Fetcher' import { CompleteSubTask, SaveChore } from '../utils/Fetcher'

View File

@@ -1,4 +1,5 @@
import { useInfiniteQuery } from '@tanstack/react-query' import { useInfiniteQuery } from '@tanstack/react-query'
import { GetThingHistory } from '../utils/Fetcher' import { GetThingHistory } from '../utils/Fetcher'
export const useThingHistory = (thingId, limit = 10) => { export const useThingHistory = (thingId, limit = 10) => {

View File

@@ -1,4 +1,5 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { import {
ClearChoreTimer, ClearChoreTimer,
DeleteTimeSession, DeleteTimeSession,
@@ -56,7 +57,7 @@ export const useUpdateTimeSession = () => {
const queryClient = useQueryClient() const queryClient = useQueryClient()
return useMutation({ return useMutation({
mutationFn: ({ choreId, sessionId, sessionData }) => mutationFn: ({ choreId, sessionData, sessionId }) =>
UpdateTimeSession(choreId, sessionId, sessionData), UpdateTimeSession(choreId, sessionId, sessionData),
onSuccess: (_, { choreId }) => { onSuccess: (_, { choreId }) => {
queryClient.invalidateQueries(['choreTimer', choreId]) queryClient.invalidateQueries(['choreTimer', choreId])

View File

@@ -22,7 +22,9 @@ export function isCacheEnabled() {
export function setCacheEnabled(enabled) { export function setCacheEnabled(enabled) {
try { try {
localStorage.setItem(ENABLED_KEY, String(enabled)) localStorage.setItem(ENABLED_KEY, String(enabled))
} catch { /* ignore */ } } catch {
/* ignore */
}
} }
export function hashContent(content) { export function hashContent(content) {
@@ -56,7 +58,9 @@ export function setCached(hash, value) {
index.push(hash) index.push(hash)
localStorage.setItem(INDEX_KEY, JSON.stringify(index)) localStorage.setItem(INDEX_KEY, JSON.stringify(index))
} }
} catch { /* storage full, ignore */ } } catch {
/* storage full, ignore */
}
} }
export function getCacheStats() { export function getCacheStats() {
@@ -66,7 +70,15 @@ export function getCacheStats() {
export function clearCache() { export function clearCache() {
const index = getIndex() const index = getIndex()
index.forEach(h => { index.forEach(h => {
try { localStorage.removeItem(ENTRY_PREFIX + h) } catch { /* ignore */ } try {
localStorage.removeItem(ENTRY_PREFIX + h)
} catch {
/* ignore */
}
}) })
try { localStorage.removeItem(INDEX_KEY) } catch { /* ignore */ } try {
localStorage.removeItem(INDEX_KEY)
} catch {
/* ignore */
}
} }

View File

@@ -1,4 +1,5 @@
import { Capacitor } from '@capacitor/core' import { Capacitor } from '@capacitor/core'
import { getCached, hashContent, setCached } from './AIPromptCache' import { getCached, hashContent, setCached } from './AIPromptCache'
// Native-only local AI service using @capacitor/local-llm. // Native-only local AI service using @capacitor/local-llm.
@@ -74,14 +75,19 @@ class LocalAIService {
await this.warmup() await this.warmup()
try { try {
const { LocalLLM } = await import('@capacitor/local-llm') const { LocalLLM } = await import('@capacitor/local-llm')
const { text: out } = await LocalLLM.prompt({ prompt: text, sessionId: this._sessionId }) const { text: out } = await LocalLLM.prompt({
prompt: text,
sessionId: this._sessionId,
})
return out?.trim() || null return out?.trim() || null
} finally { } finally {
try { try {
const { LocalLLM } = await import('@capacitor/local-llm') const { LocalLLM } = await import('@capacitor/local-llm')
await LocalLLM.endSession({ sessionId: this._sessionId }) await LocalLLM.endSession({ sessionId: this._sessionId })
this._warmedUp = false this._warmedUp = false
} catch { /* ignore */ } } catch {
/* ignore */
}
} }
} }
@@ -99,7 +105,9 @@ class LocalAIService {
try { try {
const systemMsg = messages.find(m => m.role === 'system')?.content || '' const systemMsg = messages.find(m => m.role === 'system')?.content || ''
const userMsg = messages.find(m => m.role === 'user')?.content || '' const userMsg = messages.find(m => m.role === 'user')?.content || ''
const result = await this._nativePrompt(`${systemMsg}\n\nUser: ${userMsg}\nAssistant:`) const result = await this._nativePrompt(
`${systemMsg}\n\nUser: ${userMsg}\nAssistant:`,
)
if (result) setCached(cacheHash, result) if (result) setCached(cacheHash, result)
return result return result
} catch (e) { } catch (e) {
@@ -122,7 +130,10 @@ class LocalAIService {
try { try {
await this.warmup() await this.warmup()
const { LocalLLM } = await import('@capacitor/local-llm') const { LocalLLM } = await import('@capacitor/local-llm')
const { text } = await LocalLLM.prompt({ prompt, sessionId: this._sessionId }) const { text } = await LocalLLM.prompt({
prompt,
sessionId: this._sessionId,
})
const result = text?.trim() || null const result = text?.trim() || null
if (result) setCached(cacheHash, result) if (result) setCached(cacheHash, result)
return result return result
@@ -133,7 +144,9 @@ class LocalAIService {
const { LocalLLM } = await import('@capacitor/local-llm') const { LocalLLM } = await import('@capacitor/local-llm')
await LocalLLM.endSession({ sessionId: this._sessionId }) await LocalLLM.endSession({ sessionId: this._sessionId })
this._warmedUp = false this._warmedUp = false
} catch { /* ignore */ } } catch {
/* ignore */
}
} }
} }
} }

View File

@@ -42,7 +42,10 @@ export const decodeNdefUrl = record => {
// Starts a native NFC write session. Calls onWaiting once scanning is active, // Starts a native NFC write session. Calls onWaiting once scanning is active,
// then onSuccess or onError when the write completes. Returns a cancel function. // then onSuccess or onError when the write completes. Returns a cancel function.
export const startNativeNFCWrite = async (url, { onWaiting, onSuccess, onError }) => { export const startNativeNFCWrite = async (
url,
{ onError, onSuccess, onWaiting },
) => {
let listener = null let listener = null
let done = false let done = false
@@ -86,7 +89,7 @@ export const startNativeNFCWrite = async (url, { onWaiting, onSuccess, onError }
// Starts a native NFC scan session for reading. Calls onTag(url) when a URL // 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. // NDEF record is found, or onError on failure. Returns a cancel function.
export const startNativeScan = async ({ onTag, onError }) => { export const startNativeScan = async ({ onError, onTag }) => {
let listener = null let listener = null
let done = false let done = false

View File

@@ -113,9 +113,8 @@ class VoiceInputService {
async isSupported() { async isSupported() {
if (this.isNative) { if (this.isNative) {
try { try {
const { SpeechRecognition } = await import( const { SpeechRecognition } =
'@capacitor-community/speech-recognition' await import('@capacitor-community/speech-recognition')
)
const { available } = await SpeechRecognition.available() const { available } = await SpeechRecognition.available()
return !!available return !!available
} catch { } catch {
@@ -134,9 +133,8 @@ class VoiceInputService {
return 'granted' return 'granted'
} }
try { try {
const { SpeechRecognition } = await import( const { SpeechRecognition } =
'@capacitor-community/speech-recognition' await import('@capacitor-community/speech-recognition')
)
const current = await SpeechRecognition.checkPermissions() const current = await SpeechRecognition.checkPermissions()
if (current.speechRecognition === 'granted') return 'granted' if (current.speechRecognition === 'granted') return 'granted'
const res = await SpeechRecognition.requestPermissions() const res = await SpeechRecognition.requestPermissions()
@@ -189,9 +187,8 @@ class VoiceInputService {
if (this.isNative) { if (this.isNative) {
let SpeechRecognition let SpeechRecognition
try { try {
;({ SpeechRecognition } = await import( ;({ SpeechRecognition } =
'@capacitor-community/speech-recognition' await import('@capacitor-community/speech-recognition'))
))
await withTimeout(SpeechRecognition.stop(), NATIVE_CALL_TIMEOUT_MS) await withTimeout(SpeechRecognition.stop(), NATIVE_CALL_TIMEOUT_MS)
} catch { } catch {
// recognizer may already be stopped // recognizer may already be stopped
@@ -282,9 +279,8 @@ class VoiceInputService {
} }
async _startNative() { async _startNative() {
const { SpeechRecognition } = await import( const { SpeechRecognition } =
'@capacitor-community/speech-recognition' await import('@capacitor-community/speech-recognition')
)
await SpeechRecognition.removeAllListeners() await SpeechRecognition.removeAllListeners()
await SpeechRecognition.addListener('partialResults', ({ matches }) => { await SpeechRecognition.addListener('partialResults', ({ matches }) => {
@@ -342,9 +338,8 @@ class VoiceInputService {
} }
async _doRestartNative() { async _doRestartNative() {
const { SpeechRecognition } = await import( const { SpeechRecognition } =
'@capacitor-community/speech-recognition' await import('@capacitor-community/speech-recognition')
)
try { try {
await withTimeout(SpeechRecognition.stop(), NATIVE_CALL_TIMEOUT_MS) await withTimeout(SpeechRecognition.stop(), NATIVE_CALL_TIMEOUT_MS)
} catch { } catch {

View File

@@ -1,4 +1,5 @@
import { Capacitor, registerPlugin } from '@capacitor/core' import { Capacitor, registerPlugin } from '@capacitor/core'
import { apiClient } from '../utils/ApiClient' import { apiClient } from '../utils/ApiClient'
// Native bridge implemented in ios/App/App/WidgetBridgePlugin.swift and // Native bridge implemented in ios/App/App/WidgetBridgePlugin.swift and

View File

@@ -19,7 +19,11 @@ const allMonths = [
* @param {Object} chore - The chore object (needed for nextDueDate null check) * @param {Object} chore - The chore object (needed for nextDueDate null check)
* @returns {string} The formatted due date text * @returns {string} The formatted due date text
*/ */
export const getDueDateChipText = (nextDueDate, chore, timeFormat = 'h:mm A') => { export const getDueDateChipText = (
nextDueDate,
chore,
timeFormat = 'h:mm A',
) => {
if (chore?.nextDueDate === null || nextDueDate === null) return 'No Due Date' if (chore?.nextDueDate === null || nextDueDate === null) return 'No Due Date'
const dueDate = moment(nextDueDate) const dueDate = moment(nextDueDate)
@@ -34,16 +38,22 @@ export const getDueDateChipText = (nextDueDate, chore, timeFormat = 'h:mm A') =>
sameElse: `MMM D ${timeFormat}`, sameElse: `MMM D ${timeFormat}`,
} }
// if time is 23:59:59, treat as end-of-day (date only, no specific time) // if time is 23:59:59, treat as end-of-day (date only, no specific time)
if (dueDate.hours() === 23 && dueDate.minutes() === 59 && dueDate.seconds() === 59) { if (
dueDate.hours() === 23 &&
dueDate.minutes() === 59 &&
dueDate.seconds() === 59
) {
if (diff < 0) { if (diff < 0) {
// For overdue dates, show calendar format for recent dates // For overdue dates, show calendar format for recent dates
const absDiff = Math.abs(diff) const absDiff = Math.abs(diff)
if (absDiff <= 48) { if (absDiff <= 48) {
return ( return (
'Overdue ' + 'Overdue ' +
moment(nextDueDate).calendar(null, calendarFormat).split(' ')[0].toLowerCase() moment(nextDueDate)
.calendar(null, calendarFormat)
.split(' ')[0]
.toLowerCase()
) )
} }
return 'Overdue ' + dueDate.fromNow() return 'Overdue ' + dueDate.fromNow()

View File

@@ -1,4 +1,5 @@
import moment from 'moment' import moment from 'moment'
import { TASK_COLOR } from './Colors.jsx' import { TASK_COLOR } from './Colors.jsx'
const priorityOrder = [1, 2, 3, 4, 0] const priorityOrder = [1, 2, 3, 4, 0]
@@ -213,7 +214,7 @@ export const ChoresGrouper = (groupBy, chores, filter) => {
} }
case 'due_date': { case 'due_date': {
var { dateGroups: dueDateGroups, anytime: dueAnytime } = var { anytime: dueAnytime, dateGroups: dueDateGroups } =
buildActualDateGroups(chores) buildActualDateGroups(chores)
groups = [...dueDateGroups] groups = [...dueDateGroups]
if (dueAnytime.length > 0) { if (dueAnytime.length > 0) {

View File

@@ -1,10 +1,6 @@
import moment from 'moment' import moment from 'moment'
export const createDateFormatter = ( export const createDateFormatter = (dateFormat, timeFormat, firstDayOfWeek) => {
dateFormat,
timeFormat,
firstDayOfWeek,
) => {
moment.updateLocale('en', { moment.updateLocale('en', {
week: { week: {
dow: firstDayOfWeek, dow: firstDayOfWeek,

View File

@@ -740,7 +740,7 @@ const DeleteUser = (password, confirmation, transferOptions = []) => {
const UploadChoreAttachment = ( const UploadChoreAttachment = (
file, file,
entityType, entityType,
{ entityId, draftId } = {}, { draftId, entityId } = {},
) => { ) => {
const formData = new FormData() const formData = new FormData()
formData.append('file', file) formData.append('file', file)
@@ -986,11 +986,6 @@ const TrackFilterUsage = id => {
export { export {
AcceptCircleMemberRequest, AcceptCircleMemberRequest,
DeleteChoreAttachment,
DeleteDraftAttachment,
GetChoreAttachments,
SignAssetURL,
UploadChoreAttachment,
ApproveChore, ApproveChore,
ArchiveChore, ArchiveChore,
CancelSubscription, CancelSubscription,
@@ -1010,8 +1005,10 @@ export {
CreateThing, CreateThing,
DeleteChildUser, DeleteChildUser,
DeleteChore, DeleteChore,
DeleteChoreAttachment,
DeleteChoreHistory, DeleteChoreHistory,
DeleteCircleMember, DeleteCircleMember,
DeleteDraftAttachment,
DeleteFilter, DeleteFilter,
DeleteLabel, DeleteLabel,
DeleteLongLiveToken, DeleteLongLiveToken,
@@ -1024,6 +1021,7 @@ export {
GetAllUsers, GetAllUsers,
GetArchivedChores, GetArchivedChores,
GetChildUsers, GetChildUsers,
GetChoreAttachments,
GetChoreByID, GetChoreByID,
GetChoreDetailById, GetChoreDetailById,
GetChoreHistory, GetChoreHistory,
@@ -1069,6 +1067,7 @@ export {
SaveChore, SaveChore,
SaveThing, SaveThing,
SetupMFA, SetupMFA,
SignAssetURL,
signUp, signUp,
SkipChore, SkipChore,
StartChore, StartChore,
@@ -1091,5 +1090,6 @@ export {
UpdateThingState, UpdateThingState,
UpdateTimeSession, UpdateTimeSession,
UpdateUserDetails, UpdateUserDetails,
UploadChoreAttachment,
VerifyMFA, VerifyMFA,
} }

View File

@@ -13,7 +13,7 @@
* @returns {boolean} - Whether the chore matches the condition * @returns {boolean} - Whether the chore matches the condition
*/ */
export const evaluateCondition = (chore, condition, context = {}) => { export const evaluateCondition = (chore, condition, context = {}) => {
const { type, operator, value } = condition const { operator, type, value } = condition
switch (type) { switch (type) {
case 'assignee': case 'assignee':
@@ -343,7 +343,7 @@ export const getFilterOverdueCount = (chores, filter, context = {}) => {
* @returns {Object} - { isValid: boolean, issues: Array } * @returns {Object} - { isValid: boolean, issues: Array }
*/ */
export const validateFilter = (filter, context = {}) => { export const validateFilter = (filter, context = {}) => {
const { members = [], labels = [], projects = [] } = context const { labels = [], members = [], projects = [] } = context
const issues = [] const issues = []
if (!filter.conditions || filter.conditions.length === 0) { if (!filter.conditions || filter.conditions.length === 0) {

View File

@@ -273,7 +273,7 @@ const patchDescriptionHtml = async (html, meta = {}) => {
const images = extractDescriptionImages(html) const images = extractDescriptionImages(html)
if (images.length === 0) return html if (images.length === 0) return html
let patched = html let patched = html
for (const { path, src, rawSrc } of images) { for (const { path, rawSrc, src } of images) {
try { try {
const nextSrc = await getImageSrc(path, src, { const nextSrc = await getImageSrc(path, src, {
kind: 'description', kind: 'description',

View File

@@ -1,5 +1,6 @@
import { CapacitorSQLite } from '@capacitor-community/sqlite'
import { Capacitor } from '@capacitor/core' import { Capacitor } from '@capacitor/core'
import { CapacitorSQLite } from '@capacitor-community/sqlite'
import { isOfflineFeatureEnabled } from './OfflineFeatureToggle' import { isOfflineFeatureEnabled } from './OfflineFeatureToggle'
const DB_NAME = 'donetick_offline' const DB_NAME = 'donetick_offline'
@@ -630,7 +631,7 @@ class IndexedDBBackend {
async saveChores(chores) { async saveChores(chores) {
if (!chores.length) return if (!chores.length) return
const { tx, store } = await this._tx('cached_chores', 'readwrite') const { store, tx } = await this._tx('cached_chores', 'readwrite')
for (const chore of chores) { for (const chore of chores) {
store.put({ store.put({
@@ -670,7 +671,7 @@ class IndexedDBBackend {
async deleteChores(ids) { async deleteChores(ids) {
if (!ids.length) return if (!ids.length) return
const { tx, store } = await this._tx('cached_chores', 'readwrite') const { store, tx } = await this._tx('cached_chores', 'readwrite')
for (const id of ids) { for (const id of ids) {
store.delete(id) store.delete(id)
} }
@@ -693,7 +694,7 @@ class IndexedDBBackend {
const choreIds = [...new Set(entries.map(e => Number(e.choreId)))] const choreIds = [...new Set(entries.map(e => Number(e.choreId)))]
await this._deletePendingHistoryByChoreIds(choreIds) await this._deletePendingHistoryByChoreIds(choreIds)
// Upsert real entries // Upsert real entries
const { tx, store } = await this._tx('cached_history', 'readwrite') const { store, tx } = await this._tx('cached_history', 'readwrite')
for (const entry of entries) { for (const entry of entries) {
store.put({ store.put({
id: entry.id, id: entry.id,
@@ -736,7 +737,7 @@ class IndexedDBBackend {
.map(row => row.id) .map(row => row.id)
if (!toDelete.length) return if (!toDelete.length) return
// Tx 2: delete them // Tx 2: delete them
const { tx, store: writeStore } = await this._tx( const { store: writeStore, tx } = await this._tx(
'cached_history', 'cached_history',
'readwrite', 'readwrite',
) )
@@ -772,7 +773,7 @@ class IndexedDBBackend {
async deleteHistory(ids) { async deleteHistory(ids) {
if (!ids.length) return if (!ids.length) return
const { tx, store } = await this._tx('cached_history', 'readwrite') const { store, tx } = await this._tx('cached_history', 'readwrite')
for (const id of ids) { for (const id of ids) {
store.delete(id) store.delete(id)
} }

View File

@@ -194,7 +194,7 @@ class SyncEngine {
break break
case CommandType.COMPLETE_CHORE: { case CommandType.COMPLETE_CHORE: {
const { id, body, completedDate, performer } = cmd.payload const { body, completedDate, id, performer } = cmd.payload
response = await MarkChoreComplete( response = await MarkChoreComplete(
id, id,
body || {}, body || {},
@@ -221,7 +221,7 @@ class SyncEngine {
break break
case CommandType.UPDATE_CHORE_HISTORY: { case CommandType.UPDATE_CHORE_HISTORY: {
const { choreId, historyId, historyData } = cmd.payload const { choreId, historyData, historyId } = cmd.payload
response = await UpdateChoreHistory(choreId, historyId, historyData) response = await UpdateChoreHistory(choreId, historyId, historyData)
break break
} }
@@ -233,7 +233,7 @@ class SyncEngine {
} }
case CommandType.RESCHEDULE_CHORE: { case CommandType.RESCHEDULE_CHORE: {
const { id, dueDate } = cmd.payload const { dueDate, id } = cmd.payload
response = await UpdateDueDate(id, dueDate) response = await UpdateDueDate(id, dueDate)
break break
} }

View File

@@ -5,48 +5,52 @@ export const USER_TYPES = {
CHILD: 1, CHILD: 1,
} }
export const isParentUser = (user) => { export const isParentUser = user => {
if (!user) return false if (!user) return false
return user.userType === USER_TYPES.PARENT && !user.parentUserId return user.userType === USER_TYPES.PARENT && !user.parentUserId
} }
export const isChildUser = (user) => { export const isChildUser = user => {
if (!user) return false if (!user) return false
return user.userType === USER_TYPES.CHILD && user.parentUserId !== null return user.userType === USER_TYPES.CHILD && user.parentUserId !== null
} }
export const canManageChildUsers = (user) => { export const canManageChildUsers = user => {
return isParentUser(user) return isParentUser(user)
} }
export const canCreateChores = (user) => { export const canCreateChores = user => {
// Both parent and child users can create chores // Both parent and child users can create chores
return user && (isParentUser(user) || isChildUser(user)) return user && (isParentUser(user) || isChildUser(user))
} }
export const canManageCircle = (user) => { export const canManageCircle = user => {
// Only parent users can manage circle settings // Only parent users can manage circle settings
return isParentUser(user) return isParentUser(user)
} }
export const canAccessAdminSettings = (user) => { export const canAccessAdminSettings = user => {
// Only parent users can access admin settings like API tokens, MFA, etc. // Only parent users can access admin settings like API tokens, MFA, etc.
return isParentUser(user) return isParentUser(user)
} }
export const getUserDisplayInfo = (user) => { export const getUserDisplayInfo = user => {
if (!user) return { displayName: '', username: '', userType: 'unknown' } if (!user) return { displayName: '', username: '', userType: 'unknown' }
return { return {
displayName: user.displayName || user.username, displayName: user.displayName || user.username,
username: user.username, username: user.username,
userType: isParentUser(user) ? 'parent' : isChildUser(user) ? 'child' : 'unknown', userType: isParentUser(user)
? 'parent'
: isChildUser(user)
? 'child'
: 'unknown',
parentUserId: user.parentUserId, parentUserId: user.parentUserId,
circleID: user.circleID, circleID: user.circleID,
} }
} }
export const getChildUsernameFromCombined = (combinedUsername) => { export const getChildUsernameFromCombined = combinedUsername => {
// Extract child name from format: parent_child // Extract child name from format: parent_child
const parts = combinedUsername.split('_') const parts = combinedUsername.split('_')
if (parts.length >= 2) { if (parts.length >= 2) {
@@ -55,7 +59,7 @@ export const getChildUsernameFromCombined = (combinedUsername) => {
return combinedUsername return combinedUsername
} }
export const getParentUsernameFromCombined = (combinedUsername) => { export const getParentUsernameFromCombined = combinedUsername => {
// Extract parent name from format: parent_child // Extract parent name from format: parent_child
const parts = combinedUsername.split('_') const parts = combinedUsername.split('_')
return parts[0] || combinedUsername return parts[0] || combinedUsername
@@ -63,4 +67,4 @@ export const getParentUsernameFromCombined = (combinedUsername) => {
export const buildChildUsername = (parentUsername, childName) => { export const buildChildUsername = (parentUsername, childName) => {
return `${parentUsername}_${childName}` return `${parentUsername}_${childName}`
} }

View File

@@ -13,11 +13,12 @@ import {
} from '@mui/joy' } from '@mui/joy'
import { useState } from 'react' import { useState } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { authButtonSx, authInputSx } from './authStyles' import { authButtonSx, authInputSx } from './authStyles'
const labelSx = { fontSize: '0.875rem', fontWeight: 600, mb: 0.75 } const labelSx = { fontSize: '0.875rem', fontWeight: 600, mb: 0.75 }
export const AuthField = ({ label, error, helper, children, ...formProps }) => ( export const AuthField = ({ children, error, helper, label, ...formProps }) => (
<FormControl error={Boolean(error)} {...formProps}> <FormControl error={Boolean(error)} {...formProps}>
<FormLabel sx={labelSx}>{label}</FormLabel> <FormLabel sx={labelSx}>{label}</FormLabel>
{children} {children}
@@ -34,16 +35,16 @@ export const AuthField = ({ label, error, helper, children, ...formProps }) => (
</FormControl> </FormControl>
) )
export const AuthTextField = ({ label, error, helper, sx, ...inputProps }) => ( export const AuthTextField = ({ error, helper, label, sx, ...inputProps }) => (
<AuthField label={label} error={error} helper={helper} id={inputProps.id}> <AuthField label={label} error={error} helper={helper} id={inputProps.id}>
<Input size='lg' sx={{ ...authInputSx, ...sx }} {...inputProps} /> <Input size='lg' sx={{ ...authInputSx, ...sx }} {...inputProps} />
</AuthField> </AuthField>
) )
export const AuthPasswordField = ({ export const AuthPasswordField = ({
label,
error, error,
helper, helper,
label,
sx, sx,
...inputProps ...inputProps
}) => { }) => {
@@ -92,7 +93,7 @@ export const AuthSubmitButton = ({ children, sx, ...props }) => (
</Button> </Button>
) )
export const SocialButton = ({ icon, children, sx, ...props }) => ( export const SocialButton = ({ children, icon, sx, ...props }) => (
<Button <Button
type='button' type='button'
size='lg' size='lg'

View File

@@ -1,5 +1,6 @@
import { Capacitor } from '@capacitor/core' import { Capacitor } from '@capacitor/core'
import { Box, Sheet, Typography } from '@mui/joy' import { Box, Sheet, Typography } from '@mui/joy'
import Logo from '../../Logo' import Logo from '../../Logo'
/** /**
@@ -8,18 +9,18 @@ import Logo from '../../Logo'
* its own safe-area padding (the top inset is already reserved by NavBar). * its own safe-area padding (the top inset is already reserved by NavBar).
*/ */
const AuthShell = ({ const AuthShell = ({
title,
subtitle,
action, action,
children, children,
footer, footer,
logoSize = 48, logoSize = 48,
showLogo = !Capacitor.isNativePlatform(),
subtitle,
// In the app the user already came through the app icon and the Get Started // In the app the user already came through the app icon and the Get Started
// mark, so repeating it here is noise. On the web these routes are the first // mark, so repeating it here is noise. On the web these routes are the first
// thing a visitor sees — often on a self-hosted domain, and with no navbar — // thing a visitor sees — often on a self-hosted domain, and with no navbar —
// so the mark is the only thing identifying the app. Views reached from an // so the mark is the only thing identifying the app. Views reached from an
// emailed link override this to always show it. // emailed link override this to always show it.
showLogo = !Capacitor.isNativePlatform(), title,
}) => { }) => {
return ( return (
<Box <Box

View File

@@ -1,16 +1,16 @@
import { Box, Button, LinearProgress } from '@mui/joy'
import { useEffect, useState } from 'react'
import { Capacitor } from '@capacitor/core' import { Capacitor } from '@capacitor/core'
import { Box, Button, LinearProgress } from '@mui/joy'
import Cookies from 'js-cookie' import Cookies from 'js-cookie'
import { useEffect, useState } from 'react'
import { useRef } from 'react' import { useRef } from 'react'
import { useTranslation } from 'react-i18next'
import { Link, useNavigate, useParams } from 'react-router-dom' import { Link, useNavigate, useParams } from 'react-router-dom'
import { useUserProfile } from '../../queries/UserQueries' import { useUserProfile } from '../../queries/UserQueries'
import { apiClient } from '../../utils/ApiClient' import { apiClient } from '../../utils/ApiClient'
import { endOAuthExchange } from '../../utils/OAuthExchangeState'
import { GetUserProfile } from '../../utils/Fetcher' import { GetUserProfile } from '../../utils/Fetcher'
import { endOAuthExchange } from '../../utils/OAuthExchangeState'
import { saveTokens } from '../../utils/TokenStorage' import { saveTokens } from '../../utils/TokenStorage'
import { useTranslation } from 'react-i18next'
import AuthShell from './AuthShell' import AuthShell from './AuthShell'
import { authButtonSx } from './authStyles' import { authButtonSx } from './authStyles'
import MFAVerificationModal from './MFAVerificationModal' import MFAVerificationModal from './MFAVerificationModal'

View File

@@ -3,6 +3,7 @@ import { Box, Button, Link, Typography } from '@mui/joy'
import { useState } from 'react' import { useState } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { useNavigate } from 'react-router-dom' import { useNavigate } from 'react-router-dom'
import { useNotification } from '../../service/NotificationProvider' import { useNotification } from '../../service/NotificationProvider'
import { ResetPassword } from '../../utils/Fetcher' import { ResetPassword } from '../../utils/Fetcher'
import { AuthSubmitButton, AuthTextField, LegalLinks } from './AuthFields' import { AuthSubmitButton, AuthTextField, LegalLinks } from './AuthFields'

View File

@@ -4,7 +4,9 @@ import ErrorOutlineIcon from '@mui/icons-material/ErrorOutline'
import WifiIcon from '@mui/icons-material/Wifi' import WifiIcon from '@mui/icons-material/Wifi'
import { Alert, Box, Button, CircularProgress, Typography } from '@mui/joy' import { Alert, Box, Button, CircularProgress, Typography } from '@mui/joy'
import React from 'react' import React from 'react'
import { useTranslation } from 'react-i18next'
import { useNavigate } from 'react-router-dom' import { useNavigate } from 'react-router-dom'
import { API_URL } from '../../Config' import { API_URL } from '../../Config'
import { useResource } from '../../queries/ResourceQueries' import { useResource } from '../../queries/ResourceQueries'
import { apiClient } from '../../utils/ApiClient' import { apiClient } from '../../utils/ApiClient'
@@ -12,7 +14,6 @@ import { offlineDB } from '../../utils/OfflineDB'
import { AuthSubmitButton, AuthTextField } from './AuthFields' import { AuthSubmitButton, AuthTextField } from './AuthFields'
import AuthShell from './AuthShell' import AuthShell from './AuthShell'
import { authButtonSx } from './authStyles' import { authButtonSx } from './authStyles'
import { useTranslation } from 'react-i18next'
const CONNECTION_TIMEOUT_MS = 8000 const CONNECTION_TIMEOUT_MS = 8000
@@ -106,8 +107,7 @@ const LoginSettings = () => {
) { ) {
return { return {
ok: false, ok: false,
message: message: t('server.dnsFailed'),
t('server.dnsFailed'),
} }
} }
return { return {
@@ -119,16 +119,14 @@ const LoginSettings = () => {
// no-cors also timed out → server/host truly unreachable // no-cors also timed out → server/host truly unreachable
return { return {
ok: false, ok: false,
message: message: t('server.unreachable'),
t('server.unreachable'),
} }
} }
// Fallback (should rarely hit) // Fallback (should rarely hit)
return { return {
ok: false, ok: false,
message: message: t('server.unreachable'),
t('server.unreachable'),
} }
} }
} }
@@ -145,9 +143,7 @@ const LoginSettings = () => {
if (!isValidURL(trimmedURL)) { if (!isValidURL(trimmedURL)) {
setStatus('error') setStatus('error')
setErrorMessage( setErrorMessage(t('server.invalidUrl'))
t('server.invalidUrl'),
)
return return
} }

View File

@@ -35,7 +35,7 @@ import AuthShell from './AuthShell'
import { authButtonSx } from './authStyles' import { authButtonSx } from './authStyles'
import MFAVerificationModal from './MFAVerificationModal' import MFAVerificationModal from './MFAVerificationModal'
const SegmentedControl = ({ value, onChange, options }) => ( const SegmentedControl = ({ onChange, options, value }) => (
<Box <Box
role='tablist' role='tablist'
sx={{ sx={{
@@ -582,7 +582,7 @@ const LoginView = () => {
discoveryDocs='claims_supported' discoveryDocs='claims_supported'
access_type='online' access_type='online'
isOnlyGetToken={true} isOnlyGetToken={true}
onResolve={({ provider, data }) => { onResolve={({ data, provider }) => {
loggedWithProvider(provider, data) loggedWithProvider(provider, data)
}} }}
onReject={() => { onReject={() => {

View File

@@ -1,18 +1,18 @@
import { Alert, Box, Input, Link, Stack, Typography } from '@mui/joy' import { Alert, Box, Input, Link, Stack, Typography } from '@mui/joy'
import { useState } from 'react' import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import ModalActions from '../../components/common/ModalActions' import ModalActions from '../../components/common/ModalActions'
import { useResponsiveModal } from '../../hooks/useResponsiveModal' import { useResponsiveModal } from '../../hooks/useResponsiveModal'
import { VerifyMFA } from '../../utils/Fetcher' import { VerifyMFA } from '../../utils/Fetcher'
import { authInputSx } from './authStyles' import { authInputSx } from './authStyles'
import { useTranslation } from 'react-i18next'
const MFAVerificationModal = ({ const MFAVerificationModal = ({
open,
onClose, onClose,
sessionToken,
onSuccess,
onError, onError,
onSuccess,
open,
sessionToken,
}) => { }) => {
const { t } = useTranslation('auth') const { t } = useTranslation('auth')
const [verificationCode, setVerificationCode] = useState('') const [verificationCode, setVerificationCode] = useState('')
@@ -38,8 +38,7 @@ const MFAVerificationModal = ({
onSuccess(data) onSuccess(data)
} else { } else {
const errorData = await response.json() const errorData = await response.json()
const message = const message = errorData.message || t('mfaModal.invalidCode')
errorData.message || t('mfaModal.invalidCode')
setError(message) setError(message)
onError?.(message) onError?.(message)
} }
@@ -77,9 +76,7 @@ const MFAVerificationModal = ({
size='md' size='md'
title={t('mfaModal.title')} title={t('mfaModal.title')}
description={ description={
isBackupCode isBackupCode ? t('mfaModal.backupHint') : t('mfaModal.codeHint')
? t('mfaModal.backupHint')
: t('mfaModal.codeHint')
} }
closeOnBackdrop={!loading} closeOnBackdrop={!loading}
closeOnEscape={!loading} closeOnEscape={!loading}
@@ -112,7 +109,9 @@ const MFAVerificationModal = ({
<Input <Input
id='mfa-code' id='mfa-code'
size='lg' size='lg'
placeholder={isBackupCode ? t('mfaModal.backupPlaceholder') : '000000'} placeholder={
isBackupCode ? t('mfaModal.backupPlaceholder') : '000000'
}
value={verificationCode} value={verificationCode}
onChange={e => setVerificationCode(e.target.value)} onChange={e => setVerificationCode(e.target.value)}
onKeyDown={handleKeyDown} onKeyDown={handleKeyDown}

View File

@@ -39,8 +39,7 @@ const SignupView = () => {
if (!result.success) { if (!result.success) {
showError({ showError({
title: 'Almost there', title: 'Almost there',
message: message: t('signupSignInFailed'),
t('signupSignInFailed'),
}) })
Navigate('/login') Navigate('/login')
return return

View File

@@ -41,6 +41,7 @@ import { useNavigate, useParams, useSearchParams } from 'react-router-dom'
import { useImpersonateUser } from '../../contexts/ImpersonateUserContext.jsx' import { useImpersonateUser } from '../../contexts/ImpersonateUserContext.jsx'
import { useLocalization } from '../../contexts/LocalizationContext' import { useLocalization } from '../../contexts/LocalizationContext'
import { useDescriptionHtml } from '../../hooks/useDescriptionHtml'
import { usePendingCommands } from '../../hooks/usePendingCommands' import { usePendingCommands } from '../../hooks/usePendingCommands'
import { import {
useChoreDetails, useChoreDetails,
@@ -80,12 +81,6 @@ import {
} from '../../utils/Fetcher' } from '../../utils/Fetcher'
import { offlineDB } from '../../utils/OfflineDB' import { offlineDB } from '../../utils/OfflineDB'
import { getSafeBottomPadding } from '../../utils/SafeAreaUtils.js' 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 NudgeModal from '../Modals/Inputs/NudgeModal'
import SelectModal from '../Modals/Inputs/SelectModal'
import WriteNFCModal from '../Modals/Inputs/WriteNFCModal'
import ChoreActionMenu from '../components/ChoreActionMenu' import ChoreActionMenu from '../components/ChoreActionMenu'
import DueDatePickerModal, { import DueDatePickerModal, {
combineDueDate, combineDueDate,
@@ -95,9 +90,14 @@ import LoadingComponent from '../components/Loading.jsx'
import PendingBadge from '../components/PendingBadge' import PendingBadge from '../components/PendingBadge'
import RichTextEditor from '../components/RichTextEditor.jsx' import RichTextEditor from '../components/RichTextEditor.jsx'
import SubTasks from '../components/SubTask.jsx' import SubTasks from '../components/SubTask.jsx'
import AttachmentBrowserModal from '../Modals/Inputs/AttachmentBrowserModal'
import ConfirmationModal from '../Modals/Inputs/ConfirmationModal'
import NoteViewerModal from '../Modals/Inputs/NoteViewerModal'
import NudgeModal from '../Modals/Inputs/NudgeModal'
import SelectModal from '../Modals/Inputs/SelectModal'
import WriteNFCModal from '../Modals/Inputs/WriteNFCModal'
import TimePassedCard from './TimePassedCard.jsx' import TimePassedCard from './TimePassedCard.jsx'
import TimerSplitButton from './TimerSplitButton.jsx' import TimerSplitButton from './TimerSplitButton.jsx'
import { useDescriptionHtml } from '../../hooks/useDescriptionHtml'
const isNetworkError = err => const isNetworkError = err =>
err instanceof TypeError && err.message === 'Failed to fetch' err instanceof TypeError && err.message === 'Failed to fetch'
@@ -130,7 +130,7 @@ const ChoreView = () => {
const { choreId } = useParams() const { choreId } = useParams()
const [note, setNote] = useState(null) const [note, setNote] = useState(null)
const queryClient = useQueryClient() const queryClient = useQueryClient()
const { showSuccess, showError, showUndo } = useNotification() const { showError, showSuccess, showUndo } = useNotification()
const [searchParams] = useSearchParams() const [searchParams] = useSearchParams()

View File

@@ -13,8 +13,8 @@ import {
Typography, Typography,
} from '@mui/joy' } from '@mui/joy'
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
import { useNavigate } from 'react-router-dom'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { useNavigate } from 'react-router-dom'
const isValidTrigger = (thing, condition, triggerState) => { const isValidTrigger = (thing, condition, triggerState) => {
const newErrors = {} const newErrors = {}
if (!thing || !triggerState) { if (!thing || !triggerState) {
@@ -49,11 +49,11 @@ const isValidTrigger = (thing, condition, triggerState) => {
} }
const ThingTriggerSection = ({ const ThingTriggerSection = ({
things, isAttepmtingToSave,
onTriggerUpdate, onTriggerUpdate,
onValidate, onValidate,
selected, selected,
isAttepmtingToSave, things,
}) => { }) => {
const { t } = useTranslation('chores') const { t } = useTranslation('chores')
const [selectedThing, setSelectedThing] = useState(null) const [selectedThing, setSelectedThing] = useState(null)
@@ -86,9 +86,7 @@ const ThingTriggerSection = ({
return ( return (
<Card sx={{ mt: 1 }}> <Card sx={{ mt: 1 }}>
<Typography level='h5'> <Typography level='h5'>{t('thing.triggerHint')}</Typography>
{t('thing.triggerHint')}
</Typography>
{things?.length === 0 && ( {things?.length === 0 && (
<Typography level='body-sm'> <Typography level='body-sm'>
it's look like you don't have any things yet, create a thing to it's look like you don't have any things yet, create a thing to

View File

@@ -8,6 +8,7 @@ import {
import { Box, Card, Chip, Typography } from '@mui/joy' import { Box, Card, Chip, Typography } from '@mui/joy'
import { useEffect, useRef, useState } from 'react' import { useEffect, useRef, useState } from 'react'
import { useNavigate } from 'react-router-dom' import { useNavigate } from 'react-router-dom'
import { useLocalization } from '../../contexts/LocalizationContext' import { useLocalization } from '../../contexts/LocalizationContext'
const TimePassedCard = ({ chore, handleAction, onShowDetails }) => { const TimePassedCard = ({ chore, handleAction, onShowDetails }) => {

View File

@@ -10,12 +10,12 @@ import { useEffect, useRef, useState } from 'react'
const TimerSplitButton = ({ const TimerSplitButton = ({
chore, chore,
onAction,
onShowDetails,
onResetTimer,
onClearAllTime,
disabled = false, disabled = false,
fullWidth = false, fullWidth = false,
onAction,
onClearAllTime,
onResetTimer,
onShowDetails,
}) => { }) => {
const [anchorEl, setAnchorEl] = useState(null) const [anchorEl, setAnchorEl] = useState(null)
const isMenuOpen = Boolean(anchorEl) const isMenuOpen = Boolean(anchorEl)

View File

@@ -27,11 +27,12 @@ import {
} from '@mui/joy' } from '@mui/joy'
import moment from 'moment' import moment from 'moment'
import { useState } from 'react' import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import { useChores, useChoresHistory } from '../../queries/ChoreQueries' import { useChores, useChoresHistory } from '../../queries/ChoreQueries'
import { useCircleMembers } from '../../queries/UserQueries' import { useCircleMembers } from '../../queries/UserQueries'
import { resolvePhotoURL } from '../../utils/Helpers' import { resolvePhotoURL } from '../../utils/Helpers'
import NoteViewerModal from '../Modals/Inputs/NoteViewerModal' import NoteViewerModal from '../Modals/Inputs/NoteViewerModal'
import { useTranslation } from 'react-i18next'
const ActivityItem = ({ activity, members, onViewNote }) => { const ActivityItem = ({ activity, members, onViewNote }) => {
const { t } = useTranslation('chores') const { t } = useTranslation('chores')

View File

@@ -1109,7 +1109,10 @@ const ArchivedTasks = () => {
} }
primaryAction={ primaryAction={
searchTerm searchTerm
? { label: t('archived.clearSearch'), onClick: handleSearchClose } ? {
label: t('archived.clearSearch'),
onClick: handleSearchClose,
}
: { label: t('archived.clearFilters'), onClick: clearAll } : { label: t('archived.clearFilters'), onClick: clearAll }
} }
secondaryAction={ secondaryAction={

View File

@@ -1,11 +1,12 @@
import '@meauxt/react-swipeable-list/dist/styles.css'
import { import {
Type as ListType,
SwipeableList, SwipeableList,
SwipeableListItem, SwipeableListItem,
SwipeAction, SwipeAction,
TrailingActions, TrailingActions,
Type as ListType,
} from '@meauxt/react-swipeable-list' } from '@meauxt/react-swipeable-list'
import '@meauxt/react-swipeable-list/dist/styles.css'
import { import {
Check, Check,
Delete, Delete,
@@ -19,6 +20,7 @@ import {
import { Box, Typography } from '@mui/joy' import { Box, Typography } from '@mui/joy'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { useNavigate } from 'react-router-dom' import { useNavigate } from 'react-router-dom'
import { useLongPress } from '../../hooks/useLongPress' import { useLongPress } from '../../hooks/useLongPress'
import ChoreCard from './ChoreCard' import ChoreCard from './ChoreCard'
import CompactChoreCard from './CompactChoreCard' import CompactChoreCard from './CompactChoreCard'
@@ -28,16 +30,16 @@ import CompactChoreCard from './CompactChoreCard'
* can't live in the render loop because it needs a hook. * can't live in the render loop because it needs a hook.
*/ */
const ChoreSwipeableItem = ({ const ChoreSwipeableItem = ({
trailingActions, children,
longPressEnabled,
onClick, onClick,
onLongPress, onLongPress,
longPressEnabled, trailingActions,
children,
// SwipeableList clones its children to inject list-level config // SwipeableList clones its children to inject list-level config
// (listType, fullSwipe, thresholds…), so it has to be passed through. // (listType, fullSwipe, thresholds…), so it has to be passed through.
...listProps ...listProps
}) => { }) => {
const { handlers: longPressHandlers, cancel: cancelLongPress } = useLongPress( const { cancel: cancelLongPress, handlers: longPressHandlers } = useLongPress(
onLongPress, onLongPress,
{ enabled: longPressEnabled }, { enabled: longPressEnabled },
) )
@@ -75,19 +77,19 @@ const ChoreSwipeableItem = ({
const ChoreListView = ({ const ChoreListView = ({
chores, chores,
viewMode,
membersData,
userLabels,
handleLabelFiltering,
handleChoreAction, handleChoreAction,
handleLabelFiltering,
isMultiSelectMode, isMultiSelectMode,
selectedChores,
toggleChoreSelection,
userProfile,
isOfficialInstance, isOfficialInstance,
toggleMultiSelectMode, membersData,
onLongPressChore, onLongPressChore,
selectedChores,
showActions = true, showActions = true,
toggleChoreSelection,
toggleMultiSelectMode,
userLabels,
userProfile,
viewMode,
}) => { }) => {
const navigate = useNavigate() const navigate = useNavigate()
const { t } = useTranslation('chores') const { t } = useTranslation('chores')
@@ -204,7 +206,9 @@ const ChoreListView = ({
<Check sx={{ fontSize: 20 }} /> <Check sx={{ fontSize: 20 }} />
)} )}
<Typography level='body-xs' sx={{ mt: 0.5 }}> <Typography level='body-xs' sx={{ mt: 0.5 }}>
{chore.status !== 1 ? t('choreView.start') : t('list.complete')} {chore.status !== 1
? t('choreView.start')
: t('list.complete')}
</Typography> </Typography>
</Box> </Box>
</SwipeAction> </SwipeAction>

View File

@@ -11,6 +11,7 @@ import {
import { Box, Checkbox, Chip, IconButton, Typography } from '@mui/joy' import { Box, Checkbox, Chip, IconButton, Typography } from '@mui/joy'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { useNavigate } from 'react-router-dom' import { useNavigate } from 'react-router-dom'
import { useImpersonateUser } from '../../contexts/ImpersonateUserContext.jsx' import { useImpersonateUser } from '../../contexts/ImpersonateUserContext.jsx'
import { useLocalization } from '../../contexts/LocalizationContext' import { useLocalization } from '../../contexts/LocalizationContext'
import { usePendingCommands } from '../../hooks/usePendingCommands' import { usePendingCommands } from '../../hooks/usePendingCommands'
@@ -30,17 +31,17 @@ import PendingBadge from '../components/PendingBadge'
const CompactChoreCard = ({ const CompactChoreCard = ({
chore, chore,
performers,
sx,
viewOnly,
showActions = true,
onChipClick,
onAction,
// Multi-select props
isMultiSelectMode = false, isMultiSelectMode = false,
isSelected = false, isSelected = false,
onAction,
onChipClick,
onSelectionToggle, onSelectionToggle,
onlyClickable = false, onlyClickable = false,
// Multi-select props
performers,
showActions = true,
sx,
viewOnly,
}) => { }) => {
const navigate = useNavigate() const navigate = useNavigate()
const { t } = useTranslation('chores') const { t } = useTranslation('chores')

View File

@@ -1,20 +1,21 @@
import { Button, Chip, Menu, MenuItem, Typography } from '@mui/joy' import { Button, Chip, Menu, MenuItem, Typography } from '@mui/joy'
import { useTranslation } from 'react-i18next'
import IconButton from '@mui/joy/IconButton' import IconButton from '@mui/joy/IconButton'
import { useEffect, useRef, useState } from 'react' import { useEffect, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { getTextColorFromBackgroundColor } from '../../utils/Colors.jsx' import { getTextColorFromBackgroundColor } from '../../utils/Colors.jsx'
const IconButtonWithMenu = ({ const IconButtonWithMenu = ({
label,
k,
icon, icon,
options, isActive,
k,
label,
onItemSelect, onItemSelect,
options,
selectedItem, selectedItem,
setSelectedItem, setSelectedItem,
isActive,
useChips,
title, title,
useChips,
}) => { }) => {
const { t } = useTranslation('chores') const { t } = useTranslation('chores')
const [anchorEl, setAnchorEl] = useState(null) const [anchorEl, setAnchorEl] = useState(null)

View File

@@ -78,7 +78,7 @@ const scheduleNotificationFromTemplate = (
const now = new Date() const now = new Date()
const time = getTimeFromTemplate(template, dueDate) const time = getTimeFromTemplate(template, dueDate)
const notificationId = getIdFromTemplate(chore.id, template) const notificationId = getIdFromTemplate(chore.id, template)
const { title, body } = getNotificationText( const { body, title } = getNotificationText(
chore.name, chore.name,
template, template,
dueDate, dueDate,

View File

@@ -1,9 +1,10 @@
import { HelpOutline } from '@mui/icons-material' import { HelpOutline } from '@mui/icons-material'
import { Box, Card, IconButton, Typography } from '@mui/joy' import { Box, Card, IconButton, Typography } from '@mui/joy'
import { useState } from 'react' import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import ModalActions from '../../components/common/ModalActions' import ModalActions from '../../components/common/ModalActions'
import { useResponsiveModal } from '../../hooks/useResponsiveModal' import { useResponsiveModal } from '../../hooks/useResponsiveModal'
import { useTranslation } from 'react-i18next'
const MultiSelectHelp = ({ isVisible = true }) => { const MultiSelectHelp = ({ isVisible = true }) => {
const { t } = useTranslation('chores') const { t } = useTranslation('chores')
@@ -102,7 +103,7 @@ const MultiSelectHelp = ({ isVisible = true }) => {
) )
} }
const ShortcutItem = ({ keys, description }) => ( const ShortcutItem = ({ description, keys }) => (
<Box <Box
sx={{ sx={{
display: 'flex', display: 'flex',

View File

@@ -23,6 +23,7 @@ import {
import { useMediaQuery } from '@mui/material' import { useMediaQuery } from '@mui/material'
import { useQueryClient } from '@tanstack/react-query' import { useQueryClient } from '@tanstack/react-query'
import { useEffect, useMemo, useRef, useState } from 'react' import { useEffect, useMemo, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { useNavigate, useSearchParams } from 'react-router-dom' import { useNavigate, useSearchParams } from 'react-router-dom'
import EmptyState from '../../components/common/EmptyState' import EmptyState from '../../components/common/EmptyState'
@@ -73,7 +74,6 @@ import {
import NotificationAccessSnackbar from './NotificationAccessSnackbar' import NotificationAccessSnackbar from './NotificationAccessSnackbar'
import Sidepanel from './Sidepanel' import Sidepanel from './Sidepanel'
import { INSIGHT_FILTER_DEFS } from './SmartInsightsCard' import { INSIGHT_FILTER_DEFS } from './SmartInsightsCard'
import { useTranslation } from 'react-i18next'
// Mirrors the assignee options in the toolbar, phrased to drop into a // Mirrors the assignee options in the toolbar, phrased to drop into a
// sentence ("none of them are assigned to you"). // sentence ("none of them are assigned to you").
@@ -1667,7 +1667,9 @@ const MyChores = () => {
saveFilter(filter) saveFilter(filter)
showSuccess({ showSuccess({
title: t('list.advancedFilterCreated'), title: t('list.advancedFilterCreated'),
message: t('list.advancedFilterCreatedMsg', { name: filter.name }), message: t('list.advancedFilterCreatedMsg', {
name: filter.name,
}),
}) })
} }
setShowAdvancedFilterBuilder(false) setShowAdvancedFilterBuilder(false)

View File

@@ -4,6 +4,7 @@ import { Preferences } from '@capacitor/preferences'
import { Button, Snackbar, Stack, Typography } from '@mui/joy' import { Button, Snackbar, Stack, Typography } from '@mui/joy'
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { registerPushNotifications } from '../../CapacitorListener' import { registerPushNotifications } from '../../CapacitorListener'
const NotificationAccessSnackbar = () => { const NotificationAccessSnackbar = () => {

View File

@@ -1,6 +1,7 @@
import { Box, Sheet } from '@mui/joy' import { Box, Sheet } from '@mui/joy'
import { useMediaQuery } from '@mui/material' import { useMediaQuery } from '@mui/material'
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
import { useChoresHistory } from '../../queries/ChoreQueries' import { useChoresHistory } from '../../queries/ChoreQueries'
import { ChoresGrouper } from '../../utils/Chores' import { ChoresGrouper } from '../../utils/Chores'
import { getSidepanelConfig } from '../../utils/SidepanelConfig' import { getSidepanelConfig } from '../../utils/SidepanelConfig'
@@ -11,9 +12,9 @@ import TasksByAssigneeCard from './TasksByAssigneeCard'
import UserSwitcher from './UserSwitcher' import UserSwitcher from './UserSwitcher'
const Sidepanel = ({ const Sidepanel = ({
chores,
allChores, allChores,
applyTempFilter, applyTempFilter,
chores,
clearTempFilter, clearTempFilter,
tempFilter, tempFilter,
}) => { }) => {
@@ -22,8 +23,8 @@ const Sidepanel = ({
const [sidepanelConfig, setSidepanelConfig] = useState([]) const [sidepanelConfig, setSidepanelConfig] = useState([])
const { const {
data: choresHistory, data: choresHistory,
isChoresHistoryLoading,
handleLimitChange: refetchHistory, handleLimitChange: refetchHistory,
isChoresHistoryLoading,
} = useChoresHistory(7, true) } = useChoresHistory(7, true)
useEffect(() => { useEffect(() => {

View File

@@ -8,9 +8,10 @@ import {
} from '@mui/icons-material' } from '@mui/icons-material'
import { Box, Button, Chip, Sheet, Typography } from '@mui/joy' import { Box, Button, Chip, Sheet, Typography } from '@mui/joy'
import { useMemo } from 'react' import { useMemo } from 'react'
import { TASK_COLOR } from '../../utils/Colors'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { TASK_COLOR } from '../../utils/Colors'
// Static insight filter definitions used for URL restoration // Static insight filter definitions used for URL restoration
export const INSIGHT_FILTER_DEFS = { export const INSIGHT_FILTER_DEFS = {
overdue: { overdue: {
@@ -58,8 +59,8 @@ export const INSIGHT_FILTER_DEFS = {
} }
const SmartInsightsCard = ({ const SmartInsightsCard = ({
chores,
applyTempFilter, applyTempFilter,
chores,
clearTempFilter, clearTempFilter,
tempFilter, tempFilter,
}) => { }) => {

View File

@@ -13,21 +13,22 @@ import {
import IconButton from '@mui/joy/IconButton' import IconButton from '@mui/joy/IconButton'
import { useEffect, useRef, useState } from 'react' import { useEffect, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import KeyboardShortcutHint from '../../components/common/KeyboardShortcutHint' import KeyboardShortcutHint from '../../components/common/KeyboardShortcutHint'
const SortAndGrouping = ({ const SortAndGrouping = ({
label,
k,
icon, icon,
onItemSelect,
selectedItem,
setSelectedItem,
selectedFilter,
setFilter,
isActive, isActive,
useChips, k,
title, label,
onCreateNewFilter, onCreateNewFilter,
onItemSelect,
selectedFilter,
selectedItem,
setFilter,
setSelectedItem,
title,
useChips,
}) => { }) => {
const { t } = useTranslation('chores') const { t } = useTranslation('chores')
const [anchorEl, setAnchorEl] = useState(null) const [anchorEl, setAnchorEl] = useState(null)

View File

@@ -1,11 +1,12 @@
import { BarChart, Person } from '@mui/icons-material' import { BarChart, Person } from '@mui/icons-material'
import { Avatar, Box, Sheet, Typography } from '@mui/joy' import { Avatar, Box, Sheet, Typography } from '@mui/joy'
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import EmptyState from '../../components/common/EmptyState' import EmptyState from '../../components/common/EmptyState'
import { useCircleMembers } from '../../queries/UserQueries' import { useCircleMembers } from '../../queries/UserQueries'
import { TASK_COLOR } from '../../utils/Colors' import { TASK_COLOR } from '../../utils/Colors'
import { resolvePhotoURL } from '../../utils/Helpers' import { resolvePhotoURL } from '../../utils/Helpers'
import { useTranslation } from 'react-i18next'
const TasksByAssigneeCard = ({ chores = [] }) => { const TasksByAssigneeCard = ({ chores = [] }) => {
const { t } = useTranslation('chores') const { t } = useTranslation('chores')

View File

@@ -1,19 +1,19 @@
import { SupervisorAccount } from '@mui/icons-material' import { SupervisorAccount } from '@mui/icons-material'
import { Avatar, Box, Button, Sheet, Typography } from '@mui/joy' import { Avatar, Box, Button, Sheet, Typography } from '@mui/joy'
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { useImpersonateUser } from '../../contexts/ImpersonateUserContext' import { useImpersonateUser } from '../../contexts/ImpersonateUserContext'
import { useCircleMembers, useUserProfile } from '../../queries/UserQueries' import { useCircleMembers, useUserProfile } from '../../queries/UserQueries'
import UserModal from '../Modals/Inputs/UserModal' import UserModal from '../Modals/Inputs/UserModal'
const UserSwitcher = () => { const UserSwitcher = () => {
const { t } = useTranslation('chores') const { t } = useTranslation('chores')
const { const {
impersonatedUser, canImpersonate,
impersonatedUser,
isImpersonating, isImpersonating,
startImpersonation, startImpersonation,
stopImpersonation, stopImpersonation,
canImpersonate
} = useImpersonateUser() } = useImpersonateUser()
const { data: userProfile } = useUserProfile() const { data: userProfile } = useUserProfile()
const [isModalOpen, setIsModalOpen] = useState(false) const [isModalOpen, setIsModalOpen] = useState(false)
@@ -54,7 +54,9 @@ const UserSwitcher = () => {
}} }}
> >
<SupervisorAccount color='' /> <SupervisorAccount color='' />
<Typography level='title-md'>{t('impersonate.viewAs')}</Typography> <Typography level='title-md'>
{t('impersonate.viewAs')}
</Typography>
</Box> </Box>
</Box> </Box>
<Box sx={{ mb: 2 }}> <Box sx={{ mb: 2 }}>

View File

@@ -1,10 +1,11 @@
import { Capacitor } from '@capacitor/core' import { Capacitor } from '@capacitor/core'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import DateModal from '../../Modals/Inputs/DateModal'
import DueDatePickerModal, { import DueDatePickerModal, {
combineDueDate, combineDueDate,
splitDueDate, splitDueDate,
} from '../../components/DueDatePickerModal' } from '../../components/DueDatePickerModal'
import DateModal from '../../Modals/Inputs/DateModal'
import NudgeModal from '../../Modals/Inputs/NudgeModal' import NudgeModal from '../../Modals/Inputs/NudgeModal'
import SelectModal from '../../Modals/Inputs/SelectModal' import SelectModal from '../../Modals/Inputs/SelectModal'
import TextModal from '../../Modals/Inputs/TextModal' import TextModal from '../../Modals/Inputs/TextModal'
@@ -17,14 +18,14 @@ const getNFCUrl = choreId =>
const ChoreModals = ({ const ChoreModals = ({
activeModal, activeModal,
modalChore,
membersData, membersData,
onChangeDueDate, modalChore,
onCompleteWithPastDate,
onAssigneeChange, onAssigneeChange,
onCompleteWithNote, onChangeDueDate,
onNudge,
onClose, onClose,
onCompleteWithNote,
onCompleteWithPastDate,
onNudge,
}) => { }) => {
const { t } = useTranslation('chores') const { t } = useTranslation('chores')
return ( return (

View File

@@ -47,27 +47,28 @@ import {
Typography, Typography,
} from '@mui/joy' } from '@mui/joy'
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import AppModal from '../../../components/common/AppModal' import AppModal from '../../../components/common/AppModal'
import ActiveFilterChips from '../../../components/common/filter/ActiveFilterChips' import ActiveFilterChips from '../../../components/common/filter/ActiveFilterChips'
import KeyboardShortcutHint from '../../../components/common/KeyboardShortcutHint' import KeyboardShortcutHint from '../../../components/common/KeyboardShortcutHint'
import { FILTER_COLORS } from '../../../utils/Colors' import { FILTER_COLORS } from '../../../utils/Colors'
import Priorities from '../../../utils/Priorities' import Priorities from '../../../utils/Priorities'
import ProjectSelector from '../../components/ProjectSelector'
import CustomFilterChips from './CustomFilterChips'
import FilterBuilderContent, { import FilterBuilderContent, {
CHORE_STATUSES, CHORE_STATUSES,
DUE_DATE_OPTIONS,
POINTS_OPERATORS,
conditionsToSelections, conditionsToSelections,
defaultSelections, defaultSelections,
DUE_DATE_OPTIONS,
POINTS_OPERATORS,
selectionsToConditions, selectionsToConditions,
} from './FilterBuilderContent' } from './FilterBuilderContent'
import SearchBar from './SearchBar' import SearchBar from './SearchBar'
import ProjectSelector from '../../components/ProjectSelector'
import CustomFilterChips from './CustomFilterChips'
import { useTranslation } from 'react-i18next'
// ─── sub-components for the Display sheet ──────────────────────────────────── // ─── sub-components for the Display sheet ────────────────────────────────────
const SectionHeader = ({ icon, label, badge }) => ( const SectionHeader = ({ badge, icon, label }) => (
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 1.5 }}> <Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 1.5 }}>
{icon && ( {icon && (
<Box <Box
@@ -97,7 +98,7 @@ const SectionHeader = ({ icon, label, badge }) => (
</Box> </Box>
) )
const OptionChips = ({ options, selected, multi, onToggle }) => ( const OptionChips = ({ multi, onToggle, options, selected }) => (
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 1 }}> <Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 1 }}>
{options.map(opt => { {options.map(opt => {
const isSelected = multi const isSelected = multi
@@ -183,48 +184,48 @@ const OptionChips = ({ options, selected, multi, onToggle }) => (
*/ */
const ChoreToolbar = ({ const ChoreToolbar = ({
// advanced filter // advanced filter
members = [], activeFilterId,
labels = [],
projects = [],
tempFilter,
tempFilterMeta,
applyTempFilter, applyTempFilter,
clearTempFilter, 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, isMultiSelectMode,
onToggleMultiSelect, labels = [],
// search members = [],
searchTerm, onAssigneeFilterChange,
onClearAllFilters,
onFilterSaved,
onGroupBySelect,
// result counts
onProjectSelect,
onSavedFilterClick,
// clear all
onSavedFilterDelete,
// project (for Display sheet)
onSavedFilterEdit,
onSavedFilterPin,
// assignee (for Display sheet)
onSearchChange, onSearchChange,
onSearchClose, onSearchClose,
// saved / custom
onToggleMultiSelect,
onToggleViewMode,
projects = [],
resultCount,
saveFilter,
savedFilters = [],
// grouping
searchInputRef, searchInputRef,
searchTerm,
// view + multiselect
selectedAssigneeFilter = 'anyone',
selectedGroupBy = 'default',
selectedProject,
showKeyboardShortcuts, showKeyboardShortcuts,
// search
tempFilter,
tempFilterMeta,
totalCount,
updateFilter,
viewMode = 'default',
}) => { }) => {
const { t } = useTranslation('chores') const { t } = useTranslation('chores')
const [filterSheetOpen, setFilterSheetOpen] = useState(false) const [filterSheetOpen, setFilterSheetOpen] = useState(false)

View File

@@ -7,7 +7,6 @@ import {
Star, Star,
StarBorder, StarBorder,
} from '@mui/icons-material' } from '@mui/icons-material'
import { useTranslation } from 'react-i18next'
import { import {
Box, Box,
Chip, Chip,
@@ -18,16 +17,18 @@ import {
Typography, Typography,
} from '@mui/joy' } from '@mui/joy'
import { useState } from 'react' import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import { useNavigate } from 'react-router-dom' import { useNavigate } from 'react-router-dom'
import { getTextColorFromBackgroundColor } from '../../../utils/Colors' import { getTextColorFromBackgroundColor } from '../../../utils/Colors'
const CustomFilterChips = ({ const CustomFilterChips = ({
filters = [],
activeFilterId, activeFilterId,
filters = [],
onFilterClick, onFilterClick,
onFilterDelete, onFilterDelete,
onFilterPin,
onFilterEdit, onFilterEdit,
onFilterPin,
}) => { }) => {
const { t } = useTranslation('chores') const { t } = useTranslation('chores')
const navigate = useNavigate() const navigate = useNavigate()
@@ -98,8 +99,7 @@ const CustomFilterChips = ({
const badgeTextColor = filter.color const badgeTextColor = filter.color
? getTextColorFromBackgroundColor(filter.color) ? getTextColorFromBackgroundColor(filter.color)
: '#ffffff' : '#ffffff'
const displayCount = const displayCount = filter.count > 99 ? '99+' : (filter.count ?? 0)
filter.count > 99 ? '99+' : (filter.count ?? 0)
return ( return (
<Tooltip <Tooltip
@@ -113,7 +113,9 @@ const CustomFilterChips = ({
> >
<Chip <Chip
variant={isActive ? 'solid' : 'outlined'} variant={isActive ? 'solid' : 'outlined'}
color={hasWarning ? 'warning' : isActive ? 'primary' : 'neutral'} color={
hasWarning ? 'warning' : isActive ? 'primary' : 'neutral'
}
size='md' size='md'
onClick={() => !hasWarning && onFilterClick(filter.id)} onClick={() => !hasWarning && onFilterClick(filter.id)}
sx={{ sx={{
@@ -124,7 +126,9 @@ const CustomFilterChips = ({
flexShrink: 0, flexShrink: 0,
fontWeight: isActive ? 600 : 500, fontWeight: isActive ? 600 : 500,
'&:hover': { '&:hover': {
backgroundColor: isActive ? undefined : 'neutral.softHoverBg', backgroundColor: isActive
? undefined
: 'neutral.softHoverBg',
}, },
}} }}
startDecorator={ startDecorator={

View File

@@ -9,6 +9,7 @@ import {
TaskAlt, TaskAlt,
} from '@mui/icons-material' } from '@mui/icons-material'
import { Avatar, Box, Chip, Divider, Input, Typography } from '@mui/joy' import { Avatar, Box, Chip, Divider, Input, Typography } from '@mui/joy'
import Priorities from '../../../utils/Priorities' import Priorities from '../../../utils/Priorities'
export const DUE_DATE_OPTIONS = [ export const DUE_DATE_OPTIONS = [
@@ -99,7 +100,7 @@ export const selectionsToConditions = selections => {
return conditions return conditions
} }
const SectionHeader = ({ icon, label, children }) => ( const SectionHeader = ({ children, icon, label }) => (
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 1.5 }}> <Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 1.5 }}>
<Box <Box
sx={{ sx={{
@@ -119,9 +120,9 @@ const SectionHeader = ({ icon, label, children }) => (
) )
const IncludeExcludeToggle = ({ const IncludeExcludeToggle = ({
value,
onChange,
labels = ['Include', 'Exclude'], labels = ['Include', 'Exclude'],
onChange,
value,
}) => ( }) => (
<Box sx={{ display: 'flex', gap: 0.5, ml: 'auto' }}> <Box sx={{ display: 'flex', gap: 0.5, ml: 'auto' }}>
{[ {[
@@ -136,7 +137,11 @@ const IncludeExcludeToggle = ({
value === o.op ? (o.op === 'isNot' ? 'danger' : 'primary') : 'neutral' value === o.op ? (o.op === 'isNot' ? 'danger' : 'primary') : 'neutral'
} }
onClick={() => onChange(o.op)} onClick={() => onChange(o.op)}
sx={{ cursor: 'pointer', userSelect: 'none', transition: 'all 0.15s ease' }} sx={{
cursor: 'pointer',
userSelect: 'none',
transition: 'all 0.15s ease',
}}
> >
{o.label} {o.label}
</Chip> </Chip>
@@ -152,11 +157,11 @@ const IncludeExcludeToggle = ({
* functional updater `prev => next` (same contract as React's setState setter). * functional updater `prev => next` (same contract as React's setState setter).
*/ */
const FilterBuilderContent = ({ const FilterBuilderContent = ({
selections,
onSelectionsChange,
members = [],
labels = [], labels = [],
members = [],
onSelectionsChange,
projects = [], projects = [],
selections,
}) => { }) => {
const toggleValue = (type, value) => const toggleValue = (type, value) =>
onSelectionsChange(prev => { onSelectionsChange(prev => {
@@ -204,9 +209,11 @@ const FilterBuilderContent = ({
variant={isSelected ? 'solid' : 'soft'} variant={isSelected ? 'solid' : 'soft'}
color={isSelected ? (extra.color ?? 'primary') : 'neutral'} color={isSelected ? (extra.color ?? 'primary') : 'neutral'}
startDecorator={ startDecorator={
isSelected isSelected ? (
? <Check sx={{ fontSize: 14 }} /> <Check sx={{ fontSize: 14 }} />
: (extra.startDecorator ?? null) ) : (
(extra.startDecorator ?? null)
)
} }
onClick={() => toggleValue(type, opt.value)} onClick={() => toggleValue(type, opt.value)}
sx={{ sx={{
@@ -312,7 +319,7 @@ const FilterBuilderContent = ({
Priorities.map(p => ({ value: p.value, label: p.name })), Priorities.map(p => ({ value: p.value, label: p.name })),
(opt, isSelected) => ({ (opt, isSelected) => ({
color: isSelected color: isSelected
? (Priorities.find(p => p.value === opt.value)?.color || 'primary') ? Priorities.find(p => p.value === opt.value)?.color || 'primary'
: 'neutral', : 'neutral',
startDecorator: !isSelected startDecorator: !isSelected
? Priorities.find(p => p.value === opt.value)?.icon ? Priorities.find(p => p.value === opt.value)?.icon
@@ -331,7 +338,9 @@ const FilterBuilderContent = ({
key={opt.value} key={opt.value}
variant={isSelected ? 'solid' : 'soft'} variant={isSelected ? 'solid' : 'soft'}
color={isSelected ? (opt.color ?? 'primary') : 'neutral'} color={isSelected ? (opt.color ?? 'primary') : 'neutral'}
startDecorator={isSelected ? <Check sx={{ fontSize: 14 }} /> : null} startDecorator={
isSelected ? <Check sx={{ fontSize: 14 }} /> : null
}
onClick={() => toggleDueDate(opt.value)} onClick={() => toggleDueDate(opt.value)}
sx={{ sx={{
cursor: 'pointer', cursor: 'pointer',
@@ -375,7 +384,9 @@ const FilterBuilderContent = ({
}} }}
/> />
} }
endDecorator={isSelected ? <Check sx={{ fontSize: 12 }} /> : null} endDecorator={
isSelected ? <Check sx={{ fontSize: 12 }} /> : null
}
onClick={() => toggleValue('label', lbl.id)} onClick={() => toggleValue('label', lbl.id)}
sx={{ sx={{
cursor: 'pointer', cursor: 'pointer',
@@ -425,12 +436,14 @@ const FilterBuilderContent = ({
key={op.value} key={op.value}
size='sm' size='sm'
variant={ variant={
selections.points.operator === op.value && selections.points.active selections.points.operator === op.value &&
selections.points.active
? 'solid' ? 'solid'
: 'soft' : 'soft'
} }
color={ color={
selections.points.operator === op.value && selections.points.active selections.points.operator === op.value &&
selections.points.active
? 'primary' ? 'primary'
: 'neutral' : 'neutral'
} }

View File

@@ -1,14 +1,15 @@
import { Box } from '@mui/joy' import { Box } from '@mui/joy'
import CustomFilterChips from './CustomFilterChips' import CustomFilterChips from './CustomFilterChips'
const FilterSection = ({ const FilterSection = ({
savedFilters,
activeFilterId, activeFilterId,
onFilterClick, onFilterClick,
onFilterDelete, onFilterDelete,
onFilterPin,
onFilterEdit, onFilterEdit,
onFilterPin,
savedFilters,
}) => { }) => {
return ( return (
<> <>

View File

@@ -32,6 +32,7 @@ import {
import moment from 'moment' import moment from 'moment'
import { useRef, useState } from 'react' import { useRef, useState } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import AppModal from '../../../components/common/AppModal' import AppModal from '../../../components/common/AppModal'
import KeyboardShortcutHint from '../../../components/common/KeyboardShortcutHint' import KeyboardShortcutHint from '../../../components/common/KeyboardShortcutHint'
import LABEL_COLORS, { import LABEL_COLORS, {
@@ -134,26 +135,26 @@ const selectableChipSx = {
const MultiSelectToolbar = ({ const MultiSelectToolbar = ({
isVisible, isVisible,
selectedCount, labels = [],
onSelectAll, members = [],
onArchive,
onClear, onClear,
onComplete, onComplete,
onSkip,
onArchive,
onDelete, onDelete,
onMoveToProject, onMoveToProject,
onSetDueDate, onSelectAll,
onSetAssignee, onSetAssignee,
onSetDueDate,
onSetPriority, onSetPriority,
onToggleLabel, onSkip,
// Shape produced by useMultiSelect.getSelectionSummary — drives which value // Shape produced by useMultiSelect.getSelectionSummary — drives which value
// each control shows as current, and which labels can be added vs removed. // each control shows as current, and which labels can be added vs removed.
selectionSummary, onToggleLabel,
members = [],
labels = [],
projects = [], projects = [],
showKeyboardShortcuts,
selectAllDisabled, selectAllDisabled,
selectedCount,
selectionSummary,
showKeyboardShortcuts,
}) => { }) => {
const { t } = useTranslation('chores') const { t } = useTranslation('chores')
const [moreOpen, setMoreOpen] = useState(false) const [moreOpen, setMoreOpen] = useState(false)

View File

@@ -1,10 +1,11 @@
import { FilterAlt } from '@mui/icons-material' import { FilterAlt } from '@mui/icons-material'
import { Box, Stack, Typography } from '@mui/joy' import { Box, Stack, Typography } from '@mui/joy'
import { getIconComponent } from '../../../utils/ProjectIcons.jsx' import { getIconComponent } from '../../../utils/ProjectIcons.jsx'
const MyChoreHeader = ({ const MyChoreHeader = ({
activeFilterId,
activeFilter, activeFilter,
activeFilterId,
selectedProject, selectedProject,
tempFilter, tempFilter,
tempFilterMeta, tempFilterMeta,

View File

@@ -1,6 +1,8 @@
import { useQueryClient } from '@tanstack/react-query' import { useQueryClient } from '@tanstack/react-query'
import moment from 'moment' import moment from 'moment'
import { useCallback } from 'react' import { useCallback } from 'react'
import { useTranslation } from 'react-i18next'
import { import {
useArchiveChore, useArchiveChore,
useUnArchiveChore, useUnArchiveChore,
@@ -22,7 +24,6 @@ import {
} from '../../../utils/Fetcher' } from '../../../utils/Fetcher'
import { offlineDB } from '../../../utils/OfflineDB' import { offlineDB } from '../../../utils/OfflineDB'
import { isOfflineFeatureEnabled } from '../../../utils/OfflineFeatureToggle' import { isOfflineFeatureEnabled } from '../../../utils/OfflineFeatureToggle'
import { useTranslation } from 'react-i18next'
// Effectively "can this action be queued offline?" — requires the offline // Effectively "can this action be queued offline?" — requires the offline
// feature, otherwise there is no command queue to replay it later. // feature, otherwise there is no command queue to replay it later.
@@ -55,22 +56,22 @@ const expectOk = async request => {
export const useChoreActions = ({ export const useChoreActions = ({
chores, chores,
filteredChores,
setChores,
setFilteredChores,
userProfile,
impersonatedUser,
showSuccess,
showError,
showWarning,
showUndo,
refetchChores,
setConfirmModelConfig,
openModal,
closeModal,
modalChore,
getSelectedChoresData,
clearSelection, clearSelection,
closeModal,
filteredChores,
getSelectedChoresData,
impersonatedUser,
modalChore,
openModal,
refetchChores,
setChores,
setConfirmModelConfig,
setFilteredChores,
showError,
showSuccess,
showUndo,
showWarning,
userProfile,
}) => { }) => {
const { t } = useTranslation('chores') const { t } = useTranslation('chores')
const queryClient = useQueryClient() const queryClient = useQueryClient()

View File

@@ -1,4 +1,4 @@
import { useState, useCallback } from 'react' import { useCallback, useState } from 'react'
export const useChoreModals = () => { export const useChoreModals = () => {
const [activeModal, setActiveModal] = useState(null) const [activeModal, setActiveModal] = useState(null)

View File

@@ -9,7 +9,6 @@ import {
SearchRounded, SearchRounded,
Warning, Warning,
} from '@mui/icons-material' } from '@mui/icons-material'
import { useTranslation } from 'react-i18next'
import { import {
Avatar, Avatar,
Button, Button,
@@ -25,6 +24,7 @@ import {
} from '@mui/joy' } from '@mui/joy'
import moment from 'moment' import moment from 'moment'
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { useNavigate } from 'react-router-dom' import { useNavigate } from 'react-router-dom'
import { GetAllUsers, GetChores, MarkChoreComplete } from '../utils/Fetcher' import { GetAllUsers, GetChores, MarkChoreComplete } from '../utils/Fetcher'

View File

@@ -11,6 +11,7 @@ import {
} from '@mui/icons-material' } from '@mui/icons-material'
import { Box, Button, IconButton, Snackbar, Typography } from '@mui/joy' import { Box, Button, IconButton, Snackbar, Typography } from '@mui/joy'
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { Link, useRouteError } from 'react-router-dom' import { Link, useRouteError } from 'react-router-dom'
import { import {
@@ -18,7 +19,6 @@ import {
formatErrorReport, formatErrorReport,
} from '../service/ErrorReportService' } from '../service/ErrorReportService'
import ErrorReportModal from './Modals/ErrorReportModal' import ErrorReportModal from './Modals/ErrorReportModal'
import { useTranslation } from 'react-i18next'
const getErrorKind = error => { const getErrorKind = error => {
if (!error) if (!error)
@@ -274,7 +274,8 @@ const Error = () => {
textAlign='center' textAlign='center'
sx={{ color: 'text.tertiary', mb: 1.5 }} sx={{ color: 'text.tertiary', mb: 1.5 }}
> >
If this keeps happening, send us a report please consider sending us report so we can take a look. If this keeps happening, send us a report please consider sending us
report so we can take a look.
</Typography> </Typography>
{(error?.stack || message) && ( {(error?.stack || message) && (

View File

@@ -33,6 +33,7 @@ import {
} from '@mui/joy' } from '@mui/joy'
import Fuse from 'fuse.js' import Fuse from 'fuse.js'
import { useEffect, useMemo, useRef, useState } from 'react' import { useEffect, useMemo, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { useNavigate, useSearchParams } from 'react-router-dom' import { useNavigate, useSearchParams } from 'react-router-dom'
import EmptyState from '../../components/common/EmptyState' import EmptyState from '../../components/common/EmptyState'
@@ -52,7 +53,6 @@ import {
useToggleFilterPin, useToggleFilterPin,
useUpdateFilter, useUpdateFilter,
} from './FilterQueries' } from './FilterQueries'
import { useTranslation } from 'react-i18next'
const FilterCardContent = ({ const FilterCardContent = ({
filter, filter,

View File

@@ -1,11 +1,12 @@
import '@meauxt/react-swipeable-list/dist/styles.css'
import { import {
Type as ListType,
SwipeableList, SwipeableList,
SwipeableListItem, SwipeableListItem,
SwipeAction, SwipeAction,
TrailingActions, TrailingActions,
Type as ListType,
} from '@meauxt/react-swipeable-list' } from '@meauxt/react-swipeable-list'
import '@meauxt/react-swipeable-list/dist/styles.css'
import { import {
Analytics, Analytics,
CalendarMonth, CalendarMonth,
@@ -31,7 +32,9 @@ import EditIcon from '@mui/icons-material/Edit'
import { Box, Card, Container, Grid, Sheet, Typography } from '@mui/joy' import { Box, Card, Container, Grid, Sheet, Typography } from '@mui/joy'
import moment from 'moment' import moment from 'moment'
import { useEffect, useMemo, useState } from 'react' import { useEffect, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { useParams } from 'react-router-dom' import { useParams } from 'react-router-dom'
import EmptyState from '../../components/common/EmptyState' import EmptyState from '../../components/common/EmptyState'
import FilterBar from '../../components/common/FilterBar' import FilterBar from '../../components/common/FilterBar'
import { useLocalization } from '../../contexts/LocalizationContext' import { useLocalization } from '../../contexts/LocalizationContext'
@@ -52,7 +55,6 @@ import HistoryDetailModal from '../Modals/HistoryDetailModal'
import ConfirmationModal from '../Modals/Inputs/ConfirmationModal' import ConfirmationModal from '../Modals/Inputs/ConfirmationModal'
import NoteViewerModal from '../Modals/Inputs/NoteViewerModal' import NoteViewerModal from '../Modals/Inputs/NoteViewerModal'
import HistoryCard from './HistoryCard' import HistoryCard from './HistoryCard'
import { useTranslation } from 'react-i18next'
const ChoreHistory = () => { const ChoreHistory = () => {
const { t } = useTranslation('history') const { t } = useTranslation('history')
@@ -66,7 +68,7 @@ const ChoreHistory = () => {
const [showMoreInfoId, setShowMoreInfoId] = useState(null) const [showMoreInfoId, setShowMoreInfoId] = useState(null)
const [noteViewerConfig, setNoteViewerConfig] = useState({ isOpen: false }) const [noteViewerConfig, setNoteViewerConfig] = useState({ isOpen: false })
const [detailModalConfig, setDetailModalConfig] = useState({ isOpen: false }) const [detailModalConfig, setDetailModalConfig] = useState({ isOpen: false })
const { showSuccess, showError } = useNotification() const { showError, showSuccess } = useNotification()
// React Query hooks // React Query hooks
const { data: choreHistoryData, isLoading } = useChoreHistory(choreId) const { data: choreHistoryData, isLoading } = useChoreHistory(choreId)
const { data: circleMembersData } = useCircleMembers() const { data: circleMembersData } = useCircleMembers()
@@ -177,11 +179,11 @@ const ChoreHistory = () => {
) )
const { const {
filteredData: filteredHistory,
activeFilters,
setFilter,
clearAll,
activeFilterCount, activeFilterCount,
activeFilters,
clearAll,
filteredData: filteredHistory,
setFilter,
} = useFilter(choreHistory, filterDefs) } = useFilter(choreHistory, filterDefs)
const sortedHistory = useMemo( const sortedHistory = useMemo(

View File

@@ -11,6 +11,7 @@ import {
import { Avatar, Box, Card, Chip, IconButton, Typography } from '@mui/joy' import { Avatar, Box, Card, Chip, IconButton, Typography } from '@mui/joy'
import moment from 'moment' import moment from 'moment'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { useLocalization } from '../../contexts/LocalizationContext' import { useLocalization } from '../../contexts/LocalizationContext'
import { TASK_COLOR } from '../../utils/Colors.jsx' import { TASK_COLOR } from '../../utils/Colors.jsx'
import PendingBadge from '../components/PendingBadge' import PendingBadge from '../components/PendingBadge'
@@ -34,24 +35,32 @@ const stripHtmlTags = html => {
} }
const statusConfig = { const statusConfig = {
0: { labelKey: 'status.inProgress', color: 'primary', icon: <AccessTime /> }, 0: { labelKey: 'status.inProgress', color: 'primary', icon: <AccessTime /> },
1: { labelKey: 'status.completed', color: 'success', icon: <Check /> }, 1: { labelKey: 'status.completed', color: 'success', icon: <Check /> },
2: { labelKey: 'status.skipped', color: 'warning', icon: <Redo /> }, 2: { labelKey: 'status.skipped', color: 'warning', icon: <Redo /> },
3: { labelKey: 'status.pendingApproval', color: 'neutral', icon: <HourglassEmpty /> }, 3: {
4: { labelKey: 'status.rejected', color: 'danger', icon: <ThumbDown /> }, labelKey: 'status.pendingApproval',
5: { labelKey: 'status.missed', color: 'danger', icon: <RunningWithErrors /> }, color: 'neutral',
6: { labelKey: 'status.rescheduled', color: 'warning', icon: <Schedule /> }, icon: <HourglassEmpty />,
},
4: { labelKey: 'status.rejected', color: 'danger', icon: <ThumbDown /> },
5: {
labelKey: 'status.missed',
color: 'danger',
icon: <RunningWithErrors />,
},
6: { labelKey: 'status.rescheduled', color: 'warning', icon: <Schedule /> },
} }
const HistoryCard = ({ const HistoryCard = ({
allHistory, allHistory,
performers,
historyEntry, historyEntry,
index, index,
pendingCommands,
onToggleActions, onToggleActions,
onViewNote,
onViewDetails, onViewDetails,
onViewNote,
pendingCommands,
performers,
}) => { }) => {
const { t } = useTranslation('history') const { t } = useTranslation('history')
const { fmt } = useLocalization() const { fmt } = useLocalization()
@@ -65,7 +74,7 @@ const HistoryCard = ({
const actionDate = historyEntry.performedAt || historyEntry.updatedAt const actionDate = historyEntry.performedAt || historyEntry.updatedAt
const getTimingLine = () => { const getTimingLine = () => {
const { status, performedAt, dueDate } = historyEntry const { dueDate, performedAt, status } = historyEntry
if (!dueDate) return null if (!dueDate) return null
if (status === 6 || status === 5) { if (status === 6 || status === 5) {
@@ -90,14 +99,18 @@ const HistoryCard = ({
} }
const timingLine = getTimingLine() const timingLine = getTimingLine()
const plainTextNotes = historyEntry.notes ? stripHtmlTags(historyEntry.notes) : '' const plainTextNotes = historyEntry.notes
? stripHtmlTags(historyEntry.notes)
: ''
const metaTextParts = [ const metaTextParts = [
fmt.dateTime(actionDate), fmt.dateTime(actionDate),
historyEntry.completedBy !== historyEntry.assignedTo && assignedTo historyEntry.completedBy !== historyEntry.assignedTo && assignedTo
? t('card.assignedTo', { name: assignedTo.displayName }) ? t('card.assignedTo', { name: assignedTo.displayName })
: null, : null,
historyEntry?.duration > 0 ? `${formatTime(historyEntry.duration)}` : null, historyEntry?.duration > 0
? `${formatTime(historyEntry.duration)}`
: null,
historyEntry?.points > 0 historyEntry?.points > 0
? t('card.points', { count: historyEntry.points }) ? t('card.points', { count: historyEntry.points })
: null, : null,
@@ -120,7 +133,14 @@ const HistoryCard = ({
> >
<Box sx={{ flex: 1, minWidth: 0, px: 2, py: 1.5 }}> <Box sx={{ flex: 1, minWidth: 0, px: 2, py: 1.5 }}>
{/* Status + timing chip */} {/* Status + timing chip */}
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 0.5 }}> <Box
sx={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
mb: 0.5,
}}
>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.75 }}> <Box sx={{ display: 'flex', alignItems: 'center', gap: 0.75 }}>
<Avatar <Avatar
size='sm' size='sm'
@@ -130,7 +150,11 @@ const HistoryCard = ({
> >
{config.icon} {config.icon}
</Avatar> </Avatar>
<Typography level='title-sm' fontWeight='lg' sx={{ color: `${config.color}.plainColor` }}> <Typography
level='title-sm'
fontWeight='lg'
sx={{ color: `${config.color}.plainColor` }}
>
{displayLabel} {displayLabel}
</Typography> </Typography>
</Box> </Box>
@@ -146,31 +170,58 @@ const HistoryCard = ({
{/* Notes inline */} {/* Notes inline */}
{plainTextNotes && ( {plainTextNotes && (
<Card <Card
variant='soft' variant='soft'
color='neutral' color='neutral'
size='sm' size='sm'
sx={{ mt: 0.5, whiteSpace: 'pre-wrap', overflow: 'hidden', textOverflow: 'ellipsis' }} sx={{
> mt: 0.5,
<Typography whiteSpace: 'pre-wrap',
level='body-xs' overflow: 'hidden',
sx={{ color: 'text.secondary', fontStyle: 'italic', mb: 0.25, cursor: 'pointer' }} textOverflow: 'ellipsis',
onClick={e => { e.stopPropagation(); onViewNote?.(historyEntry.notes) }} }}
> >
{plainTextNotes.length > 80 ? `${plainTextNotes.slice(0, 80)}` : plainTextNotes} <Typography
</Typography> level='body-xs'
</Card> 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>
)} )}
{/* Metadata strip: performer chip + date + extras */} {/* Metadata strip: performer chip + date + extras */}
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.75, mt: 0.5, flexWrap: 'wrap' }}> <Box
sx={{
display: 'flex',
alignItems: 'center',
gap: 0.75,
mt: 0.5,
flexWrap: 'wrap',
}}
>
{performer && ( {performer && (
<Chip <Chip
size='sm' size='sm'
variant='soft' variant='soft'
color='neutral' color='neutral'
startDecorator={ startDecorator={
<Avatar src={performer.image} alt={performer.displayName} sx={{ width: 14, height: 14 }} /> <Avatar
src={performer.image}
alt={performer.displayName}
sx={{ width: 14, height: 14 }}
/>
} }
> >
{performer.displayName} {performer.displayName}
@@ -184,13 +235,19 @@ const HistoryCard = ({
</Box> </Box>
</Box> </Box>
<Box sx={{ display: 'flex', alignItems: 'center', pr: 0.5 }} onClick={e => e.stopPropagation()}> <Box
sx={{ display: 'flex', alignItems: 'center', pr: 0.5 }}
onClick={e => e.stopPropagation()}
>
{onToggleActions && ( {onToggleActions && (
<IconButton <IconButton
color='neutral' color='neutral'
variant='plain' variant='plain'
size='sm' size='sm'
onClick={e => { e.stopPropagation(); onToggleActions() }} onClick={e => {
e.stopPropagation()
onToggleActions()
}}
> >
<MoreVert sx={{ fontSize: 18 }} /> <MoreVert sx={{ fontSize: 18 }} />
</IconButton> </IconButton>

View File

@@ -1,9 +1,9 @@
import { Box, Button, Container, Typography } from '@mui/joy' import { Box, Button, Container, Typography } from '@mui/joy'
import { useTranslation } from 'react-i18next'
import { useEffect } from 'react' import { useEffect } from 'react'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import { useNavigate } from 'react-router-dom' import { useNavigate } from 'react-router-dom'
import { useState } from 'react'
import Logo from '../Logo' import Logo from '../Logo'
const Home = () => { const Home = () => {
const { t } = useTranslation('common') const { t } = useTranslation('common')

View File

@@ -1,4 +1,5 @@
import { useQuery } from '@tanstack/react-query' import { useQuery } from '@tanstack/react-query'
import { CreateLabel, GetLabels } from '../../utils/Fetcher' import { CreateLabel, GetLabels } from '../../utils/Fetcher'
import { offlineDB } from '../../utils/OfflineDB' import { offlineDB } from '../../utils/OfflineDB'

View File

@@ -1,5 +1,6 @@
import { Card, Grid, Typography } from '@mui/joy' import { Card, Grid, Typography } from '@mui/joy'
import moment from 'moment' import moment from 'moment'
import CalendarMonthly from '../components/CalendarMonthly' import CalendarMonthly from '../components/CalendarMonthly'
const DemoCalendar = () => { const DemoCalendar = () => {

View File

@@ -1,4 +1,5 @@
import { Card, Grid, Typography } from '@mui/joy' import { Card, Grid, Typography } from '@mui/joy'
import NotificationTemplate from '../../components/NotificationTemplate' import NotificationTemplate from '../../components/NotificationTemplate'
const DemoNotificationTemplate = () => { const DemoNotificationTemplate = () => {

View File

@@ -1,4 +1,3 @@
import LogoSVG from '@/assets/logo.svg'
import { Email, GitHub } from '@mui/icons-material' import { Email, GitHub } from '@mui/icons-material'
import { import {
Box, Box,
@@ -9,6 +8,9 @@ import {
Link, Link,
Typography, Typography,
} from '@mui/joy' } from '@mui/joy'
import LogoSVG from '@/assets/logo.svg'
import { version } from '../../../package.json' import { version } from '../../../package.json'
import DiscordIcon from '../../components/icons/DiscordIcon' import DiscordIcon from '../../components/icons/DiscordIcon'
import RedditIcon from '../../components/icons/RedditIcon' import RedditIcon from '../../components/icons/RedditIcon'
@@ -333,7 +335,6 @@ const Footer = () => {
<Typography level='body-sm' sx={{ color: 'text.secondary' }}> <Typography level='body-sm' sx={{ color: 'text.secondary' }}>
Version {version} Version {version}
</Typography> </Typography>
</Box> </Box>
</Box> </Box>
</Container> </Container>

View File

@@ -11,7 +11,7 @@ import {
import { Box, Button, Card, Container, Grid, Typography } from '@mui/joy' import { Box, Button, Card, Container, Grid, Typography } from '@mui/joy'
import { useNavigate } from 'react-router-dom' import { useNavigate } from 'react-router-dom'
function StartOptionCard({ icon: Icon, title, description, button, index }) { function StartOptionCard({ button, description, icon: Icon, index, title }) {
return ( return (
<Card <Card
data-aos='fade-up' data-aos='fade-up'

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