diff --git a/e2e/debug.mjs b/e2e/debug.mjs index c575e8f..a278f09 100644 --- a/e2e/debug.mjs +++ b/e2e/debug.mjs @@ -6,7 +6,9 @@ const browser = await chromium.launch() const context = await browser.newContext({ storageState: state }) const page = await context.newPage() 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.getByTestId('chore-name-input').fill('Debug Chore ' + Date.now()) diff --git a/e2e/fixtures/auth.js b/e2e/fixtures/auth.js index 06b1747..33369f4 100644 --- a/e2e/fixtures/auth.js +++ b/e2e/fixtures/auth.js @@ -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 { 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 * 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.locator('#username').fill(username) 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. * 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.locator('#username').fill(username) await page.locator('#password').fill(password) diff --git a/e2e/global-setup.js b/e2e/global-setup.js index 1d949d9..e509587 100644 --- a/e2e/global-setup.js +++ b/e2e/global-setup.js @@ -1,4 +1,4 @@ -import { writeFile, mkdir } from 'fs/promises' +import { mkdir, writeFile } from 'fs/promises' import path from 'path' import { fileURLToPath } from 'url' @@ -56,7 +56,7 @@ export default async function globalSetup() { 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 const stateDir = path.join(__dirname, '.auth') @@ -84,7 +84,11 @@ export default async function globalSetup() { async function waitForServer(url, retries = 20, delayMs = 1000) { for (let i = 0; i < retries; i++) { 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 } catch { // server not up yet diff --git a/e2e/tests/auth.spec.js b/e2e/tests/auth.spec.js index 9c049a7..508e0a3 100644 --- a/e2e/tests/auth.spec.js +++ b/e2e/tests/auth.spec.js @@ -1,12 +1,14 @@ -import { test, expect } from '@playwright/test' -import { signUpViaUI, loginViaUI } from '../fixtures/auth.js' +import { expect, test } from '@playwright/test' + +import { loginViaUI, signUpViaUI } from '../fixtures/auth.js' // Username must match /^[a-z.-]+$/ — no digits allowed. // Generate a random lowercase-only suffix for uniqueness across runs. function randomSuffix(len = 8) { const chars = 'abcdefghijklmnopqrstuvwxyz' - return Array.from({ length: len }, () => - chars[Math.floor(Math.random() * 26)], + return Array.from( + { length: len }, + () => chars[Math.floor(Math.random() * 26)], ).join('') } @@ -41,7 +43,9 @@ test.describe('Auth – Sign Up', () => { test.describe('Auth – Login', () => { // 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, { username: 'e2e.user', password: 'E2ePassword123!', diff --git a/src/components/NotificationTemplate.jsx b/src/components/NotificationTemplate.jsx index d942681..2db2d90 100644 --- a/src/components/NotificationTemplate.jsx +++ b/src/components/NotificationTemplate.jsx @@ -14,6 +14,7 @@ import Select from '@mui/joy/Select' import Typography from '@mui/joy/Typography' import { useCallback, useEffect, useRef, useState } from 'react' import { useTranslation } from 'react-i18next' + import { NOTIFICATION_TYPE, TASK_COLOR } from '../utils/Colors' import { TIME_UNITS } from '../utils/DurationUtils' @@ -26,7 +27,7 @@ const timingOptions = [ ] function getRelativeLabel(notification, t) { - const { value, unit } = notification + const { unit, value } = notification const numericValue = Number(value) if (numericValue === 0) { return t('notifTemplate.onDueDate') @@ -71,8 +72,8 @@ const NotificationTemplate = ({ // Consumers that own an empty state themselves pass 0. minNotifications = 1, onChange, - value, showTimeline = true, + value, }) => { const { t } = useTranslation('chores') const [notifications, setNotifications] = useState( diff --git a/src/components/SSEConnectionStatus.jsx b/src/components/SSEConnectionStatus.jsx index 718d355..df7b744 100644 --- a/src/components/SSEConnectionStatus.jsx +++ b/src/components/SSEConnectionStatus.jsx @@ -1,13 +1,14 @@ import { Circle, SignalWifi4Bar, SignalWifiOff } from '@mui/icons-material' import { Box, Chip, Tooltip, Typography } from '@mui/joy' + import { useSSEContext } from '../hooks/useSSEContext' const SSEConnectionStatus = ({ - variant = 'minimal', showError = false, sx = {}, + variant = 'minimal', }) => { - const { isConnected, isConnecting, error, getConnectionStatus } = + const { error, getConnectionStatus, isConnected, isConnecting } = useSSEContext() const getStatusColor = () => { diff --git a/src/components/SSESettings.jsx b/src/components/SSESettings.jsx index 3187bdc..679206d 100644 --- a/src/components/SSESettings.jsx +++ b/src/components/SSESettings.jsx @@ -9,22 +9,23 @@ import { Switch, Typography, } from '@mui/joy' +import { useTranslation } from 'react-i18next' + import { useSSEContext } from '../hooks/useSSEContext' import { useUserProfile } from '../queries/UserQueries' import { isPlusAccount } from '../utils/Helpers' import SSEConnectionStatus from './SSEConnectionStatus' -import { useTranslation } from 'react-i18next' const SSESettings = () => { const { t } = useTranslation('settings') const { data: userProfile } = useUserProfile() const { - isConnected, - isConnecting, error, getConnectionStatus, - toggleSSEEnabled, + isConnected, + isConnecting, isSSEEnabled, + toggleSSEEnabled, } = useSSEContext() const handleToggle = () => { diff --git a/src/components/SubscriptionModal.jsx b/src/components/SubscriptionModal.jsx index e5fe314..c4079f4 100644 --- a/src/components/SubscriptionModal.jsx +++ b/src/components/SubscriptionModal.jsx @@ -1,13 +1,14 @@ import { Check, Star } from '@mui/icons-material' import { Box, Card, Chip, Divider, Radio, Typography } from '@mui/joy' 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' -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 [selectedPlan, setSelectedPlan] = useState('yearly') const [isLoading, setIsLoading] = useState(false) @@ -81,7 +82,11 @@ const SubscriptionModal = ({ open, onClose }) => { footer={ { const { t } = useTranslation('common') @@ -43,11 +44,11 @@ const UserProfileAvatar = () => { const { mode, setMode } = useColorScheme() const { data: userProfile } = useUserProfile() const { + canImpersonate, + getEffectiveUser, isImpersonating, startImpersonation, stopImpersonation, - canImpersonate, - getEffectiveUser, } = useImpersonateUser() const { data: circleMembersData } = useCircleMembers() const [isModalOpen, setIsModalOpen] = useState(false) @@ -129,7 +130,9 @@ const UserProfileAvatar = () => { }} /> { // Handle both children and items patterns let childrenArray if (items && renderItem) { - childrenArray = items.map((item, index) => - React.cloneElement(renderItem(item, index), { - key: keyExtractor ? keyExtractor(item, index) : index - }) + childrenArray = items.map((item, index) => + React.cloneElement(renderItem(item, index), { + key: keyExtractor ? keyExtractor(item, index) : index, + }), ) } else { childrenArray = React.Children.toArray(children) } - + const visibleItems = useStaggeredAnimation(childrenArray.length, staggerDelay) const prefersReducedMotion = useReducedMotion() // If user prefers reduced motion, render without animations if (prefersReducedMotion) { return ( - - {items && renderItem ? childrenArray : children} - + {items && renderItem ? childrenArray : children} ) } @@ -55,7 +58,7 @@ const AnimatedList = ({ {childrenArray.map((child, index) => { const isVisible = visibleItems.has(index) - + return ( { +const LoadingScreen = ({ message = null, showLogo = true, size = 'lg' }) => { const { t } = useTranslation('common') return ( @@ -90,7 +86,7 @@ const LoadingScreen = ({ )} - + - + {message ?? t('loading')} diff --git a/src/components/animations/PageTransition.jsx b/src/components/animations/PageTransition.jsx index bff11ed..cb51333 100644 --- a/src/components/animations/PageTransition.jsx +++ b/src/components/animations/PageTransition.jsx @@ -1,7 +1,8 @@ +import './PageTransition.css' + import { useLayoutEffect, useRef, useState } from 'react' import { flushSync } from 'react-dom' import { useLocation } from 'react-router-dom' -import './PageTransition.css' // Route hierarchy for determining navigation direction const routeHierarchy = { diff --git a/src/components/animations/SkeletonLoader.jsx b/src/components/animations/SkeletonLoader.jsx index 244bddf..af2095f 100644 --- a/src/components/animations/SkeletonLoader.jsx +++ b/src/components/animations/SkeletonLoader.jsx @@ -1,11 +1,11 @@ import { Box, Skeleton } from '@mui/joy' const SkeletonLoader = ({ - type = 'card', count = 1, height = 100, - width = '100%', + type = 'card', variant = 'rectangular', + width = '100%', ...props }) => { const renderSkeleton = () => { diff --git a/src/components/animations/SmoothCard.jsx b/src/components/animations/SmoothCard.jsx index 9d26f1e..839b072 100644 --- a/src/components/animations/SmoothCard.jsx +++ b/src/components/animations/SmoothCard.jsx @@ -1,23 +1,23 @@ -import React from 'react' import { Card } from '@mui/joy' import { styled } from '@mui/joy/styles' +import React from 'react' const AnimatedCard = styled(Card)(({ theme }) => ({ transition: 'all 0.3s cubic-bezier(0.4, 0, 0.2, 1)', transform: 'translateZ(0)', // Enable GPU acceleration cursor: 'pointer', position: 'relative', - + '&:hover': { transform: 'translateY(-4px) translateZ(0)', boxShadow: theme.shadow.lg, }, - + '&:active': { transform: 'translateY(-2px) translateZ(0)', transition: 'all 0.1s cubic-bezier(0.4, 0, 0.2, 1)', }, - + // Subtle background animation on hover '&::before': { content: '""', @@ -26,49 +26,50 @@ const AnimatedCard = styled(Card)(({ theme }) => ({ left: 0, right: 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, transition: 'opacity 0.3s ease', pointerEvents: 'none', borderRadius: 'inherit', }, - + '&:hover::before': { opacity: 1, }, - + // Focus states for accessibility '&:focus-visible': { outline: '2px solid', outlineColor: theme.palette.primary[500], outlineOffset: '2px', }, - + // Reduced motion support '@media (prefers-reduced-motion: reduce)': { transition: 'none', transform: 'none !important', - + '&:hover': { transform: 'none', boxShadow: theme.shadow.md, // Still provide visual feedback }, - + '&:active': { transform: 'none', }, - + '&::before': { display: 'none', }, }, })) -const SmoothCard = ({ - children, - onClick, +const SmoothCard = ({ animationDisabled = false, - ...props + children, + onClick, + ...props }) => { if (animationDisabled) { return ( diff --git a/src/components/animations/StaggeredList.jsx b/src/components/animations/StaggeredList.jsx index 32970c5..96a139e 100644 --- a/src/components/animations/StaggeredList.jsx +++ b/src/components/animations/StaggeredList.jsx @@ -1,13 +1,14 @@ +import './PageTransition.css' + import { Box } from '@mui/joy' import React, { useEffect, useState } from 'react' import { CSSTransition, TransitionGroup } from 'react-transition-group' -import './PageTransition.css' const StaggeredList = ({ - children, - staggerDelay = 50, - initialDelay = 0, animate = true, + children, + initialDelay = 0, + staggerDelay = 50, }) => { const [isVisible, setIsVisible] = useState(!animate) diff --git a/src/components/common/AppModal.jsx b/src/components/common/AppModal.jsx index 84b1677..228bba5 100644 --- a/src/components/common/AppModal.jsx +++ b/src/components/common/AppModal.jsx @@ -17,27 +17,27 @@ const WIDTH_BY_SIZE = { const AppModal = forwardRef( ( { - open, - onClose, + backdropBlur = true, children, - title, + closeOnBackdrop = true, + closeOnEscape = true, + contentSx, description, footer, - size = 'md', + footerSx, fullWidth = true, isMobile: isMobileProp, keepMounted = false, + maxHeight = '90dvh', mobilePresentation = 'sheet', + onClose, + open, role = 'dialog', showCloseButton = true, showHandle = false, - closeOnBackdrop = true, - closeOnEscape = true, - backdropBlur = true, - maxHeight = '90dvh', - contentSx, - footerSx, + size = 'md', sx, + title, unmountDelay = 180, ...modalProps }, diff --git a/src/components/common/DurationInput.jsx b/src/components/common/DurationInput.jsx index a1bac5f..91ece5d 100644 --- a/src/components/common/DurationInput.jsx +++ b/src/components/common/DurationInput.jsx @@ -1,6 +1,7 @@ import { Add, Remove } from '@mui/icons-material' import { Box, IconButton, Input, Option, Select } from '@mui/joy' import { useEffect, useState } from 'react' + import { secondsToValueAndUnit, TIME_UNITS, @@ -16,7 +17,7 @@ import { * size – Joy UI size ('sm' | 'md') * minValue – minimum numeric value (default 1) */ -const DurationInput = ({ value, onChange, size = 'md', minValue = 1 }) => { +const DurationInput = ({ minValue = 1, onChange, size = 'md', value }) => { const derived = value != null && value >= 0 ? secondsToValueAndUnit(value) @@ -26,7 +27,7 @@ const DurationInput = ({ value, onChange, size = 'md', minValue = 1 }) => { useEffect(() => { if (value != null && value >= 0) { - const { value: v, unit: u } = secondsToValueAndUnit(value) + const { unit: u, value: v } = secondsToValueAndUnit(value) setDisplayValue(v) setUnit(u) } diff --git a/src/components/common/EmptyState.jsx b/src/components/common/EmptyState.jsx index 95250fd..b02bf2e 100644 --- a/src/components/common/EmptyState.jsx +++ b/src/components/common/EmptyState.jsx @@ -55,7 +55,7 @@ const SIZES = { } const ActionButton = ({ action, ...buttonProps }) => { - const { label, to, onClick, ...rest } = action + const { label, onClick, to, ...rest } = action return ( ) -export const SocialButton = ({ icon, children, sx, ...props }) => ( +export const SocialButton = ({ children, icon, sx, ...props }) => (