feat: Add unarchive functionality to ChoreView and enhance project management features

- Implemented unarchive button for archived chores in ChoreView.
- Updated chore status handling to disable actions for archived chores.
- Enhanced MyChores component to filter tasks based on project selection, including a default project.
- Improved ProjectModal to streamline project creation and editing with a new footer layout.
- Refactored ProjectView to accurately count tasks for default and user projects.
- Added project selection dropdown in AddTaskModal with default project handling.
- Updated NavBar to handle logout using apiClient.
- Enhanced ProjectSelector to manage projects with a new menu item for project management.
This commit is contained in:
Mo Tarbin
2026-01-17 19:35:36 -05:00
parent b326b1b3d7
commit 7c8fa27aaf
19 changed files with 1132 additions and 462 deletions

View File

@@ -31,6 +31,7 @@ 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'
import { apiClient } from '../utils/apiClient'
import { isPlusAccount } from '../utils/Helpers' import { isPlusAccount } 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'
@@ -76,9 +77,7 @@ const UserProfileAvatar = () => {
} }
const handleLogout = () => { const handleLogout = () => {
localStorage.removeItem('access_token') apiClient.handleLogout()
localStorage.removeItem('ca_expiration')
window.location.href = '/login'
} }
const handleSupportEmail = () => { const handleSupportEmail = () => {

View File

@@ -1,5 +1,5 @@
import { Close } from '@mui/icons-material' import { Close } from '@mui/icons-material'
import { IconButton, Modal, Sheet, Typography } from '@mui/joy' import { Divider, IconButton, Modal, Sheet, Typography } from '@mui/joy'
import { forwardRef, useEffect, useState } from 'react' import { forwardRef, useEffect, useState } from 'react'
import { Z_INDEX } from '../../constants/zIndex' import { Z_INDEX } from '../../constants/zIndex'
@@ -10,6 +10,7 @@ const BottomSheetModal = forwardRef(
onClose, onClose,
children, children,
title, title,
footer,
height = 'auto', height = 'auto',
maxHeight = '90vh', maxHeight = '90vh',
expandedHeight = '95vh', expandedHeight = '95vh',
@@ -79,7 +80,11 @@ const BottomSheetModal = forwardRef(
const currentHeight = isExpanded ? expandedHeight : height const currentHeight = isExpanded ? expandedHeight : height
// Filter out DOM props that shouldn't be passed to Modal // Filter out DOM props that shouldn't be passed to Modal
const { fullWidth: _fullWidth, unmountDelay: _unmountDelay, ...modalProps } = props const {
fullWidth: _fullWidth,
unmountDelay: _unmountDelay,
...modalProps
} = props
return ( return (
<Modal <Modal
@@ -223,6 +228,21 @@ const BottomSheetModal = forwardRef(
> >
{children} {children}
</div> </div>
{footer && (
<>
<Divider />
<footer
style={{
flexShrink: 0,
// borderTop: '1px solid var(--joy-palette-divider)',
padding: '16px 20px',
}}
>
{footer}
</footer>
</>
)}
</Sheet> </Sheet>
</Modal> </Modal>
) )

View File

@@ -2,6 +2,7 @@ import { createContext, useContext, useEffect, useState } from 'react'
import { useNavigate } from 'react-router-dom' import { useNavigate } from 'react-router-dom'
import { API_URL } from '../Config' import { API_URL } from '../Config'
import { apiClient } from '../utils/ApiClient' import { apiClient } from '../utils/ApiClient'
import { saveTokens, clearAllTokens } from '../utils/TokenStorage'
const AuthContext = createContext(null) const AuthContext = createContext(null)
@@ -28,14 +29,10 @@ export const AuthProvider = ({ children }) => {
return new Date() >= new Date(expiry) return new Date() >= new Date(expiry)
} }
const clearAuth = () => { const clearAuth = async () => {
setToken(null) setToken(null)
setUser(null) setUser(null)
localStorage.removeItem('token') await clearAllTokens()
localStorage.removeItem('token_expiry')
localStorage.removeItem('ca_token')
localStorage.removeItem('ca_expiration')
localStorage.removeItem('access_token')
} }
const login = async credentials => { const login = async credentials => {
@@ -58,14 +55,14 @@ export const AuthProvider = ({ children }) => {
if (userToken) { if (userToken) {
setToken(userToken) setToken(userToken)
localStorage.setItem('token', userToken)
if (data.expire || data.access_token_expiry) { // Use centralized token storage
localStorage.setItem( await saveTokens({
'token_expiry', accessToken: userToken,
data.expire || data.access_token_expiry, accessTokenExpiry: data.expire || data.access_token_expiry,
) refreshToken: data.refresh_token,
} refreshTokenExpiry: data.refresh_token_expiry,
})
} }
setIsLoading(false) setIsLoading(false)
@@ -86,7 +83,7 @@ export const AuthProvider = ({ children }) => {
} catch (error) { } catch (error) {
console.warn('Logout API call failed:', error) console.warn('Logout API call failed:', error)
} finally { } finally {
clearAuth() await clearAuth()
setIsLoading(false) setIsLoading(false)
navigate('/login') navigate('/login')
} }
@@ -112,22 +109,21 @@ export const AuthProvider = ({ children }) => {
} }
useEffect(() => { useEffect(() => {
const initAuth = async () => { // const initAuth = async () => {
if (token && !isTokenExpired()) { // if (token && !isTokenExpired()) {
await fetchUser() // await fetchUser()
} else if (token && isTokenExpired()) { // } else if (token && isTokenExpired()) {
// Token is expired, but don't refresh here // // Token is expired, but don't refresh here
// Let the first API call handle refresh via ApiClient // // Let the first API call handle refresh via ApiClient
// Just try to fetch user - if it fails, ApiClient will handle refresh // // Just try to fetch user - if it fails, ApiClient will handle refresh
await fetchUser() // await fetchUser()
} else { // } else {
clearAuth() // clearAuth()
navigate('/login') // navigate('/login')
} // }
setIsLoading(false) // setIsLoading(false)
} // }
// initAuth()
initAuth()
}, [token, navigate]) }, [token, navigate])
const value = { const value = {

View File

@@ -443,6 +443,13 @@ export const useSSE = () => {
return // Exit early, don't use exponential backoff for 401 errors return // Exit early, don't use exponential backoff for 401 errors
} else { } else {
// Check if refresh token expired
if (refreshResult.error === 'Refresh token expired') {
console.error('Refresh token expired, user must login again')
setError('Session expired - please log in again')
return // Don't attempt reconnection
}
console.error('Token refresh failed:', refreshResult.error) console.error('Token refresh failed:', refreshResult.error)
setError('Authentication failed - please log in again') setError('Authentication failed - please log in again')
// Don't attempt reconnection if token refresh failed // Don't attempt reconnection if token refresh failed

View File

@@ -46,24 +46,11 @@ export const useUserProfile = () => {
const { data, error, isLoading } = useQuery({ const { data, error, isLoading } = useQuery({
queryKey: ['userProfile'], queryKey: ['userProfile'],
queryFn: async () => { queryFn: async () => {
// the below code deleted because it cause issue when token expire and screen is off
// and then user comes back to the app after long time. the user profile fetch would fail
// and there is no retry for some reason. remove this seem to fix the issue.
if (!isTokenValid()) {
throw new Error('Invalid or expired token, cannot fetch user profile')
}
const resp = await GetUserProfile() const resp = await GetUserProfile()
const result = await resp.json() const result = await resp.json()
// if we got 403 then user probably deleted their account and token is still valid. navigate to login // if we got 403 then user probably deleted their account and token is still valid. navigate to login
if (resp.status === 403) {
localStorage.removeItem('access_token')
localStorage.removeItem('ca_expiration')
window.location.href = '/login'
throw new Error('User account deleted or access forbidden')
}
return result.res // Return the actual user profile data return result.res || null
}, },
staleTime: 30 * 60 * 1000, // 30 minutes in milliseconds staleTime: 30 * 60 * 1000, // 30 minutes in milliseconds
gcTime: 30 * 60 * 1000, // 30 minutes in milliseconds gcTime: 30 * 60 * 1000, // 30 minutes in milliseconds

View File

@@ -1,4 +1,4 @@
import { CheckCircle, Error, Info, Warning } from '@mui/icons-material' import { CheckCircle, Error, Info, Undo, Warning } from '@mui/icons-material'
import { Box, Button, Snackbar, Typography } from '@mui/joy' import { Box, Button, Snackbar, Typography } from '@mui/joy'
import React, { createContext, useContext, useState } from 'react' import React, { createContext, useContext, useState } from 'react'
@@ -24,10 +24,17 @@ const NOTIFICATION_TYPES = {
success: { success: {
color: 'success', color: 'success',
icon: <CheckCircle color='success' />, icon: <CheckCircle color='success' />,
autoHideDuration: 3000, autoHideDuration: 5000,
showDismissButton: false, showDismissButton: false,
defaultTitle: 'Success', defaultTitle: 'Success',
}, },
undo: {
color: 'success',
icon: <Undo color='success' />,
autoHideDuration: null,
showDismissButton: false,
defaultTitle: 'Undone Successfully',
},
warning: { warning: {
color: 'warning', color: 'warning',
icon: <Warning color='warning' />, icon: <Warning color='warning' />,
@@ -134,6 +141,10 @@ export const NotificationProvider = ({ children }) => {
return addNotification(normalizeNotification(error, 'error')) return addNotification(normalizeNotification(error, 'error'))
} }
const showUndo = message => {
return addNotification(normalizeNotification(message, 'undo'))
}
const showSuccess = message => { const showSuccess = message => {
return addNotification(normalizeNotification(message, 'success')) return addNotification(normalizeNotification(message, 'success'))
} }
@@ -189,7 +200,18 @@ export const NotificationProvider = ({ children }) => {
onClose={() => removeNotification(notification.id)} onClose={() => removeNotification(notification.id)}
startDecorator={notificationIcon} startDecorator={notificationIcon}
endDecorator={ endDecorator={
config.showDismissButton ? ( notification.undoAction ? (
<Button
variant='outlined'
color={config.color}
onClick={() => {
notification.undoAction()
removeNotification(notification.id)
}}
>
Undo
</Button>
) : config.showDismissButton ? (
<Button <Button
variant='outlined' variant='outlined'
color={config.color} color={config.color}
@@ -232,6 +254,7 @@ export const NotificationProvider = ({ children }) => {
showNotification, showNotification,
showError, showError,
showSuccess, showSuccess,
showUndo,
showWarning, showWarning,
showInfo, showInfo,
removeNotification, removeNotification,

View File

@@ -1,5 +1,10 @@
import { API_URL } from '../Config' import { API_URL } from '../Config'
import { RefreshToken } from './Fetcher' import { logout, RefreshToken } from './Fetcher'
import {
clearAllTokens,
isRefreshTokenExpired,
saveTokens,
} from './TokenStorage'
class ApiClient { class ApiClient {
constructor() { constructor() {
@@ -11,6 +16,17 @@ class ApiClient {
} }
async refreshToken() { async refreshToken() {
// Check if refresh token is expired BEFORE attempting refresh
const refreshExpired = await isRefreshTokenExpired()
if (refreshExpired) {
console.log('Refresh token expired, forcing logout')
await clearAllTokens()
if (window.location.pathname !== '/login') {
window.location.href = '/login'
}
return { success: false, error: 'Refresh token expired' }
}
if (this.isRefreshing) { if (this.isRefreshing) {
return { success: false, error: 'Already refreshing' } return { success: false, error: 'Already refreshing' }
} }
@@ -30,14 +46,13 @@ class ApiClient {
const data = await refreshReq.json() const data = await refreshReq.json()
const newToken = data.token || data.access_token const newToken = data.token || data.access_token
// Update Local Storage // Save all tokens including rotated refresh token
localStorage.setItem('token', newToken) await saveTokens({
if (data.expire || data.access_token_expiry) { accessToken: newToken,
localStorage.setItem( accessTokenExpiry: data.expire || data.access_token_expiry,
'token_expiry', refreshToken: data.refresh_token,
data.expire || data.access_token_expiry, refreshTokenExpiry: data.refresh_token_expiry,
) })
}
// Update last refresh time // Update last refresh time
this.lastRefreshTime = Date.now() this.lastRefreshTime = Date.now()
@@ -90,10 +105,11 @@ class ApiClient {
} }
// Helper to avoid repeating cleanup code // Helper to avoid repeating cleanup code
handleLogout() { async handleLogout() {
localStorage.removeItem('token') logout().then(async () => {
localStorage.removeItem('token_expiry') await clearAllTokens()
window.location.href = '/login' if (window.location.pathname !== '/login') window.location.href = '/login'
}) // fire and forget
} }
async request(endpoint, options = {}) { async request(endpoint, options = {}) {
const url = `${this.baseURL}${endpoint}` const url = `${this.baseURL}${endpoint}`

View File

@@ -55,6 +55,14 @@ const login = (username, password) => {
}) })
} }
const logout = () => {
const baseURL = apiManager.getApiURL()
return fetch(`${baseURL}/auth/logout`, {
method: 'POST',
credentials: 'include',
})
}
const GetAllUsers = () => { const GetAllUsers = () => {
return Fetch(`/users/`, { return Fetch(`/users/`, {
method: 'GET', method: 'GET',
@@ -888,6 +896,7 @@ export {
JoinCircle, JoinCircle,
LeaveCircle, LeaveCircle,
login, login,
logout,
MarkChoreComplete, MarkChoreComplete,
NudgeChore, NudgeChore,
PauseChore, PauseChore,

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

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

View File

@@ -33,6 +33,7 @@ import { useResource } from '../../queries/ResourceQueries'
import { useUserProfile } from '../../queries/UserQueries.jsx' import { useUserProfile } from '../../queries/UserQueries.jsx'
import { useNotification } from '../../service/NotificationProvider' import { useNotification } from '../../service/NotificationProvider'
import { apiClient } from '../../utils/apiClient' import { apiClient } from '../../utils/apiClient'
import { saveTokens } from '../../utils/TokenStorage'
import { buildChildUsername, getUserDisplayInfo } from '../../utils/UserHelpers' import { buildChildUsername, getUserDisplayInfo } from '../../utils/UserHelpers'
import MFAVerificationModal from './MFAVerificationModal' import MFAVerificationModal from './MFAVerificationModal'
@@ -202,10 +203,13 @@ const LoginView = () => {
const token = responseData.token || responseData.access_token const token = responseData.token || responseData.access_token
const expiry = responseData.expire || responseData.access_token_expiry const expiry = responseData.expire || responseData.access_token_expiry
localStorage.setItem('token', token) // Save all tokens including refresh tokens
if (expiry) { await saveTokens({
localStorage.setItem('token_expiry', expiry) accessToken: token,
} accessTokenExpiry: expiry,
refreshToken: responseData.refresh_token,
refreshTokenExpiry: responseData.refresh_token_expiry,
})
// Refetch user profile after successful OAuth login // Refetch user profile after successful OAuth login
queryClient.invalidateQueries(['userProfile']) queryClient.invalidateQueries(['userProfile'])
@@ -247,9 +251,15 @@ const LoginView = () => {
}) })
} }
const handleMFASuccess = data => { const handleMFASuccess = async data => {
localStorage.setItem('token', data.token) // Save all tokens including refresh tokens
localStorage.setItem('token_expiry', data.expire) await saveTokens({
accessToken: data.token,
accessTokenExpiry: data.expire,
refreshToken: data.refresh_token,
refreshTokenExpiry: data.refresh_token_expiry,
})
setMfaModalOpen(false) setMfaModalOpen(false)
setMfaSessionToken('') setMfaSessionToken('')
@@ -427,10 +437,7 @@ const LoginView = () => {
borderRadius: '8px', borderRadius: '8px',
}} }}
onClick={() => { onClick={() => {
localStorage.removeItem('ca_token') apiClient.handleLogout()
localStorage.removeItem('ca_expiration')
// go to login page:
window.location.href = '/login'
}} }}
> >
Logout Logout

View File

@@ -1,5 +1,6 @@
import { Add, HorizontalRule, Save } from '@mui/icons-material' import { Add, HorizontalRule, Save } from '@mui/icons-material'
import { import {
Avatar,
Box, Box,
Button, Button,
Card, Card,
@@ -24,6 +25,7 @@ import {
import moment from 'moment' import moment from 'moment'
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
import { useNavigate, useParams, useSearchParams } from 'react-router-dom' import { useNavigate, useParams, useSearchParams } from 'react-router-dom'
import KeyboardShortcutHint from '../../components/common/KeyboardShortcutHint'
import NotificationTemplate from '../../components/NotificationTemplate.jsx' import NotificationTemplate from '../../components/NotificationTemplate.jsx'
import { import {
useArchiveChore, useArchiveChore,
@@ -39,6 +41,7 @@ import { getTextColorFromBackgroundColor } from '../../utils/Colors.jsx'
import { GetAllCircleMembers, GetThings } from '../../utils/Fetcher' import { GetAllCircleMembers, GetThings } from '../../utils/Fetcher'
import { isPlusAccount } from '../../utils/Helpers' import { isPlusAccount } from '../../utils/Helpers'
import Priorities from '../../utils/Priorities.jsx' import Priorities from '../../utils/Priorities.jsx'
import { getIconComponent } from '../../utils/ProjectIcons'
import { getSafeBottomPadding } from '../../utils/SafeAreaUtils.js' import { getSafeBottomPadding } from '../../utils/SafeAreaUtils.js'
import LoadingComponent from '../components/Loading.jsx' import LoadingComponent from '../components/Loading.jsx'
import RichTextEditor from '../components/RichTextEditor.jsx' import RichTextEditor from '../components/RichTextEditor.jsx'
@@ -46,9 +49,8 @@ import SubTasks from '../components/SubTask.jsx'
import { useLabels } from '../Labels/LabelQueries' import { useLabels } from '../Labels/LabelQueries'
import ConfirmationModal from '../Modals/Inputs/ConfirmationModal' import ConfirmationModal from '../Modals/Inputs/ConfirmationModal'
import LabelModal from '../Modals/Inputs/LabelModal' import LabelModal from '../Modals/Inputs/LabelModal'
import RepeatSection from './RepeatSection'
import { useProjects } from '../Projects/ProjectQueries' import { useProjects } from '../Projects/ProjectQueries'
import { getIconComponent } from '../../utils/ProjectIcons' import RepeatSection from './RepeatSection'
const ASSIGN_STRATEGIES = [ const ASSIGN_STRATEGIES = [
'random', 'random',
@@ -79,6 +81,9 @@ const ChoreEdit = () => {
const [performers, setPerformers] = useState([]) const [performers, setPerformers] = useState([])
const [assignStrategy, setAssignStrategy] = useState(ASSIGN_STRATEGIES[2]) const [assignStrategy, setAssignStrategy] = useState(ASSIGN_STRATEGIES[2])
const [dueDate, setDueDate] = useState(null) const [dueDate, setDueDate] = useState(null)
const [dueDateOnly, setDueDateOnly] = useState(null)
const [dueTime, setDueTime] = useState(null)
const [useCustomTime, setUseCustomTime] = useState(false)
const [assignedTo, setAssignedTo] = useState(-1) const [assignedTo, setAssignedTo] = useState(-1)
const [frequencyType, setFrequencyType] = useState('once') const [frequencyType, setFrequencyType] = useState('once')
const [frequency, setFrequency] = useState(1) const [frequency, setFrequency] = useState(1)
@@ -114,6 +119,7 @@ const ChoreEdit = () => {
const [showSaveNotificationDefault, setShowSaveNotificationDefault] = const [showSaveNotificationDefault, setShowSaveNotificationDefault] =
useState(false) useState(false)
const [showSaveAssigneeDefault, setShowSaveAssigneeDefault] = useState(false) const [showSaveAssigneeDefault, setShowSaveAssigneeDefault] = useState(false)
const [showKeyboardShortcuts, setShowKeyboardShortcuts] = useState(false)
const { data: userLabelsRaw, isLoading: isUserLabelsLoading } = useLabels() const { data: userLabelsRaw, isLoading: isUserLabelsLoading } = useLabels()
const { data: projects = [], isLoading: isProjectsLoading } = useProjects() const { data: projects = [], isLoading: isProjectsLoading } = useProjects()
@@ -219,7 +225,88 @@ const ChoreEdit = () => {
} }
const handleDueDateChange = e => { const handleDueDateChange = e => {
setDueDate(e.target.value) const dateValue = e.target.value // YYYY-MM-DD format
setDueDateOnly(dateValue)
// Combine date with time or end of day
if (useCustomTime && dueTime) {
// Use the custom time
const combinedDateTime = moment(`${dateValue}T${dueTime}`).format(
'YYYY-MM-DDTHH:mm:00',
)
setDueDate(combinedDateTime)
// Update frequencyMetadata.time for REPEAT_ON_TYPE frequencies
if (REPEAT_ON_TYPE.includes(frequencyType)) {
setFrequencyMetadata({
...frequencyMetadata,
time: moment(`${dateValue}T${dueTime}`).format(),
timezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
})
}
} else {
// Default to end of day (23:59:59) in user's timezone
const endOfDay = moment(dateValue)
.endOf('day')
.format('YYYY-MM-DDTHH:mm:00')
setDueDate(endOfDay)
}
}
const handleDueTimeChange = e => {
const timeValue = e.target.value // HH:mm format
setDueTime(timeValue)
if (dueDateOnly) {
// Combine date with the selected time
const combinedDateTime = moment(`${dueDateOnly}T${timeValue}`).format(
'YYYY-MM-DDTHH:mm:00',
)
setDueDate(combinedDateTime)
// Update frequencyMetadata.time for REPEAT_ON_TYPE frequencies
if (REPEAT_ON_TYPE.includes(frequencyType)) {
setFrequencyMetadata({
...frequencyMetadata,
time: moment(`${dueDateOnly}T${timeValue}`).format(),
timezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
})
}
}
}
const handleUseCustomTimeChange = checked => {
setUseCustomTime(checked)
if (checked) {
// Initialize with current time or default to 18:00
const defaultTime = dueTime || '18:00'
setDueTime(defaultTime)
if (dueDateOnly) {
const combinedDateTime = moment(`${dueDateOnly}T${defaultTime}`).format(
'YYYY-MM-DDTHH:mm:00',
)
setDueDate(combinedDateTime)
// Update frequencyMetadata.time for REPEAT_ON_TYPE frequencies
if (REPEAT_ON_TYPE.includes(frequencyType)) {
setFrequencyMetadata({
...frequencyMetadata,
time: moment(`${dueDateOnly}T${defaultTime}`).format(),
timezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
})
}
}
} else {
// Revert to end of day
if (dueDateOnly) {
const endOfDay = moment(dueDateOnly)
.endOf('day')
.format('YYYY-MM-DDTHH:mm:00')
setDueDate(endOfDay)
}
}
} }
const HandleSaveChore = () => { const HandleSaveChore = () => {
setAttemptToSave(true) setAttemptToSave(true)
@@ -317,6 +404,47 @@ const ChoreEdit = () => {
} }
} }
}, []) }, [])
// Keyboard shortcuts
useEffect(() => {
const handleKeyDown = event => {
const isHoldingCmd = event.ctrlKey || event.metaKey
// Show keyboard shortcuts when holding Cmd/Ctrl
if (isHoldingCmd) {
setShowKeyboardShortcuts(true)
}
// Cmd/Ctrl + Enter to save
if (event.key === 'Enter' && (event.ctrlKey || event.metaKey)) {
event.preventDefault()
HandleSaveChore()
return
}
// Cmd/Ctrl + Escape key to cancel
if (event.key === 'Escape' && (event.ctrlKey || event.metaKey)) {
event.preventDefault()
window.history.back()
return
}
}
const handleKeyUp = event => {
if (event.key === 'Control' || event.key === 'Meta') {
setShowKeyboardShortcuts(false)
}
}
window.addEventListener('keydown', handleKeyDown)
window.addEventListener('keyup', handleKeyUp)
return () => {
window.removeEventListener('keydown', handleKeyDown)
window.removeEventListener('keyup', handleKeyUp)
}
}, [HandleSaveChore])
useEffect(() => { useEffect(() => {
if (isChoreLoading === false && choreData && choreId) { if (isChoreLoading === false && choreData && choreId) {
const data = choreData const data = choreData
@@ -373,11 +501,32 @@ const ChoreEdit = () => {
setIsNotificable(data.res.notification) setIsNotificable(data.res.notification)
setThingTrigger(data.res.thingChore) setThingTrigger(data.res.thingChore)
setDueDate(
data.res.nextDueDate // Parse existing due date into date and time components
? moment(data.res.nextDueDate).format('YYYY-MM-DDTHH:mm:00') if (data.res.nextDueDate) {
: null, const dueDateMoment = moment(data.res.nextDueDate)
) const dateOnly = dueDateMoment.format('YYYY-MM-DD')
const timeOnly = dueDateMoment.format('HH:mm')
const endOfDayTime = '23:59'
setDueDateOnly(dateOnly)
setDueDate(dueDateMoment.format('YYYY-MM-DDTHH:mm:00'))
// Check if it's a custom time (not end of day)
if (timeOnly !== endOfDayTime) {
setUseCustomTime(true)
setDueTime(timeOnly)
} else {
setUseCustomTime(false)
setDueTime(null)
}
} else {
setDueDateOnly(null)
setDueDate(null)
setUseCustomTime(false)
setDueTime(null)
}
setCreatedBy(data.res.createdBy) setCreatedBy(data.res.createdBy)
setUpdatedBy(data.res.updatedBy) setUpdatedBy(data.res.updatedBy)
} }
@@ -392,12 +541,20 @@ const ChoreEdit = () => {
// }, [userLabels, labelsV2]) // }, [userLabels, labelsV2])
useEffect(() => { useEffect(() => {
// if frequency type change to somthing need a due date then set it to the current date: // if frequency type change to something need a due date then set it to the current date:
if (!NO_DUE_DATE_REQUIRED_TYPE.includes(frequencyType) && !dueDate) { if (!NO_DUE_DATE_REQUIRED_TYPE.includes(frequencyType) && !dueDate) {
setDueDate(moment(new Date()).format('YYYY-MM-DDTHH:mm:00')) const today = moment(new Date()).format('YYYY-MM-DD')
setDueDateOnly(today)
// Default to end of day
setDueDate(moment(today).endOf('day').format('YYYY-MM-DDTHH:mm:00'))
setUseCustomTime(false)
setDueTime(null)
} }
if (NO_DUE_DATE_ALLOWED_TYPE.includes(frequencyType)) { if (NO_DUE_DATE_ALLOWED_TYPE.includes(frequencyType)) {
setDueDate(null) setDueDate(null)
setDueDateOnly(null)
setUseCustomTime(false)
setDueTime(null)
} }
}, [frequencyType]) }, [frequencyType])
@@ -555,7 +712,7 @@ const ChoreEdit = () => {
</Box> </Box>
{/* Project Selection - Show only if there are multiple projects */} {/* Project Selection - Show only if there are multiple projects */}
{projects.length > 1 && ( {projects.length >= 1 && (
<Box mb={3}> <Box mb={3}>
<Typography level='h4'>Project</Typography> <Typography level='h4'>Project</Typography>
<Typography level='body-md'> <Typography level='body-md'>
@@ -564,34 +721,68 @@ const ChoreEdit = () => {
<Select <Select
value={projectId} value={projectId}
onChange={(event, newValue) => setProjectId(newValue)} onChange={(event, newValue) => setProjectId(newValue)}
defaultValue='default'
sx={{ minWidth: '15rem' }} sx={{ minWidth: '15rem' }}
> >
{/* id: 'default',
name: 'Default Project',
color: LABEL_COLORS[0].value,
icon: 'FolderOpen',
*/}
<Option key='default' value='default'>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<Avatar
size='sm'
sx={{
width: 24,
height: 24,
bgcolor: '#1976d2',
}}
>
{(() => {
const IconComponent = getIconComponent('FolderOpen')
return (
<IconComponent
sx={{
fontSize: 14,
color: getTextColorFromBackgroundColor('#1976d2'),
}}
/>
)
})()}
</Avatar>
Default Project
</Box>
</Option>
{projects.map(project => ( {projects.map(project => (
<Option key={project.id} value={project.id}> <Option key={project.id} value={project.id}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}> <Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
{project.icon && <Avatar
size='sm'
sx={{
width: 24,
height: 24,
bgcolor: project.color || '#1976d2',
}}
>
{project.icon ? (
(() => { (() => {
const IconComponent = getIconComponent(project.icon) const IconComponent = getIconComponent(project.icon)
return ( return (
<IconComponent <IconComponent
sx={{ sx={{
fontSize: 16, fontSize: 14,
color: getTextColorFromBackgroundColor( color: getTextColorFromBackgroundColor(
project.color || '#1976d2', project.color || '#1976d2',
), ),
}} }}
/> />
) )
})()} })()
<Box ) : (
sx={{ <></>
width: 12, )}
height: 12, </Avatar>
borderRadius: '50%',
backgroundColor: project.color || '#1976d2',
mr: 1,
}}
/>
{project.name} {project.name}
</Box> </Box>
</Option> </Option>
@@ -901,9 +1092,18 @@ const ChoreEdit = () => {
<Checkbox <Checkbox
onChange={e => { onChange={e => {
if (e.target.checked) { if (e.target.checked) {
setDueDate(moment(new Date()).format('YYYY-MM-DDTHH:mm:00')) const today = moment(new Date()).format('YYYY-MM-DD')
setDueDateOnly(today)
setDueDate(
moment(today).endOf('day').format('YYYY-MM-DDTHH:mm:00'),
)
setUseCustomTime(false)
setDueTime(null)
} else { } else {
setDueDate(null) setDueDate(null)
setDueDateOnly(null)
setUseCustomTime(false)
setDueTime(null)
} }
}} }}
defaultChecked={dueDate !== null} defaultChecked={dueDate !== null}
@@ -917,19 +1117,50 @@ const ChoreEdit = () => {
</FormControl> </FormControl>
)} )}
{dueDate && ( {dueDate && (
<FormControl error={Boolean(errors.dueDate)}> <>
<FormControl error={Boolean(errors.dueDate)} sx={{ mt: 2 }}>
<Typography level='body-md'> <Typography level='body-md'>
{REPEAT_ON_TYPE.includes(frequencyType) {REPEAT_ON_TYPE.includes(frequencyType)
? 'When does this task start?' ? 'When does this task start?'
: 'When is the next first time this task is due?'} : 'When is the next first time this task is due?'}
</Typography> </Typography>
<Input <Input
type='datetime-local' type='date'
value={dueDate} value={dueDateOnly || ''}
onChange={handleDueDateChange} onChange={handleDueDateChange}
/> />
<FormHelperText>{errors.dueDate}</FormHelperText> <FormHelperText>{errors.dueDate}</FormHelperText>
</FormControl> </FormControl>
{/* Optional time picker */}
<FormControl sx={{ mt: 2 }}>
<Checkbox
checked={useCustomTime}
onChange={e => handleUseCustomTimeChange(e.target.checked)}
overlay
label='Set a specific time'
/>
<FormHelperText>
{useCustomTime
? 'Task will be due at the specified time'
: 'Task will be due at the end of the day (11:59 PM)'}
</FormHelperText>
</FormControl>
{useCustomTime && (
<Box sx={{ mt: 2, ml: 4 }}>
<Typography level='body-sm' mb={1}>
Time:
</Typography>
<Input
type='time'
value={dueTime || '18:00'}
onChange={handleDueTimeChange}
sx={{ maxWidth: 200 }}
/>
</Box>
)}
</>
)} )}
</Box> </Box>
@@ -1006,7 +1237,9 @@ const ChoreEdit = () => {
onChange={e => { onChange={e => {
if (e.target.checked) { if (e.target.checked) {
// Set deadline to 24 hours after due date by default // Set deadline to 24 hours after due date by default
const deadlineDate = moment(dueDate).add(1, 'day').format('YYYY-MM-DDTHH:mm:00') const deadlineDate = moment(dueDate)
.add(1, 'day')
.format('YYYY-MM-DDTHH:mm:00')
setDeadline(deadlineDate) setDeadline(deadlineDate)
} else { } else {
setDeadline(null) setDeadline(null)
@@ -1036,7 +1269,8 @@ const ChoreEdit = () => {
label='Set a deadline for this task' label='Set a deadline for this task'
/> />
<FormHelperText> <FormHelperText>
Task will be considered expired after the specified time from due date Task will be considered expired after the specified time from
due date
</FormHelperText> </FormHelperText>
</FormControl> </FormControl>
)} )}
@@ -1045,7 +1279,9 @@ const ChoreEdit = () => {
{deadline && ['once', 'no_repeat'].includes(frequencyType) && ( {deadline && ['once', 'no_repeat'].includes(frequencyType) && (
<Card variant='outlined' sx={{ mt: 2 }}> <Card variant='outlined' sx={{ mt: 2 }}>
<Box sx={{ p: 2 }}> <Box sx={{ p: 2 }}>
<Typography level='body-sm' mb={1}>Deadline Date:</Typography> <Typography level='body-sm' mb={1}>
Deadline Date:
</Typography>
<Input <Input
type='datetime-local' type='datetime-local'
value={deadline} value={deadline}
@@ -1061,11 +1297,16 @@ const ChoreEdit = () => {
)} )}
{/* Offset input for recurring tasks */} {/* Offset input for recurring tasks */}
{deadlineOffset !== -1 && !['once', 'no_repeat'].includes(frequencyType) && ( {deadlineOffset !== -1 &&
!['once', 'no_repeat'].includes(frequencyType) && (
<Card variant='outlined' sx={{ mt: 2 }}> <Card variant='outlined' sx={{ mt: 2 }}>
<Box sx={{ p: 2, display: 'flex', gap: 2, alignItems: 'end' }}> <Box
sx={{ p: 2, display: 'flex', gap: 2, alignItems: 'end' }}
>
<Box> <Box>
<Typography level='body-sm' mb={1}>Time after due date:</Typography> <Typography level='body-sm' mb={1}>
Time after due date:
</Typography>
<Input <Input
type='number' type='number'
value={deadlineOffset} value={deadlineOffset}
@@ -1083,10 +1324,14 @@ const ChoreEdit = () => {
/> />
</Box> </Box>
<Box> <Box>
<Typography level='body-sm' mb={1}>Unit:</Typography> <Typography level='body-sm' mb={1}>
Unit:
</Typography>
<Select <Select
value={deadlineUnit} value={deadlineUnit}
onChange={(event, newValue) => setDeadlineUnit(newValue)} onChange={(event, newValue) =>
setDeadlineUnit(newValue)
}
sx={{ minWidth: 100 }} sx={{ minWidth: 100 }}
> >
<Option value='hours'>Hours</Option> <Option value='hours'>Hours</Option>
@@ -1502,9 +1747,15 @@ const ChoreEdit = () => {
}} }}
> >
Cancel Cancel
{showKeyboardShortcuts && (
<KeyboardShortcutHint shortcut='Esc' sx={{ ml: 1 }} />
)}
</Button> </Button>
<Button color='primary' variant='solid' onClick={HandleSaveChore}> <Button color='primary' variant='solid' onClick={HandleSaveChore}>
{choreId > 0 ? 'Save' : 'Create'} {choreId > 0 ? 'Save' : 'Create'}
{showKeyboardShortcuts && (
<KeyboardShortcutHint shortcut='Enter' sx={{ ml: 1 }} />
)}
</Button> </Button>
</Sheet> </Sheet>
<ConfirmationModal config={confirmModelConfig} /> <ConfirmationModal config={confirmModelConfig} />

View File

@@ -1,4 +1,5 @@
import { import {
Archive,
CalendarMonth, CalendarMonth,
CancelScheduleSend, CancelScheduleSend,
Check, Check,
@@ -15,6 +16,7 @@ import {
SwitchAccessShortcut, SwitchAccessShortcut,
ThumbDown, ThumbDown,
ThumbUp, ThumbUp,
Unarchive,
} from '@mui/icons-material' } from '@mui/icons-material'
import { import {
Box, Box,
@@ -60,6 +62,7 @@ import {
MarkChoreComplete, MarkChoreComplete,
RejectChore, RejectChore,
SkipChore, SkipChore,
UnArchiveChore,
UpdateChorePriority, UpdateChorePriority,
} from '../../utils/Fetcher' } from '../../utils/Fetcher'
import Priorities from '../../utils/Priorities' import Priorities from '../../utils/Priorities'
@@ -354,6 +357,18 @@ const ChoreView = () => {
}) })
} }
const handleUnarchiveChore = () => {
UnArchiveChore(choreId).then(response => {
if (response.ok) {
response.json().then(data => {
setChore({ ...chore, isActive: true })
// Invalidate chores cache to refetch data
queryClient.invalidateQueries(['chores'])
})
}
})
}
// Check if the current user can approve/reject (admin, manager, or task owner) // Check if the current user can approve/reject (admin, manager, or task owner)
const canApproveReject = () => { const canApproveReject = () => {
if (!circleMembersData?.res || !chore) return false if (!circleMembersData?.res || !chore) return false
@@ -408,6 +423,16 @@ const ChoreView = () => {
> >
{chore.name} {chore.name}
</Typography> </Typography>
{chore.isActive === false && (
<Chip
startDecorator={<Archive />}
size='md'
color='warning'
sx={{ mb: 1 }}
>
Archived
</Chip>
)}
<Chip startDecorator={<CalendarMonth />} size='md' sx={{ mb: 1 }}> <Chip startDecorator={<CalendarMonth />} size='md' sx={{ mb: 1 }}>
{chore.nextDueDate {chore.nextDueDate
? `Due at ${moment(chore.nextDueDate).format('MM/DD/YYYY hh:mm A')}` ? `Due at ${moment(chore.nextDueDate).format('MM/DD/YYYY hh:mm A')}`
@@ -531,6 +556,7 @@ const ChoreView = () => {
> >
<Dropdown> <Dropdown>
<MenuButton <MenuButton
disabled={chore.isActive === false}
color={ color={
chorePriority?.name === 'P1' chorePriority?.name === 'P1'
? 'danger' ? 'danger'
@@ -591,6 +617,7 @@ const ChoreView = () => {
color='neutral' color='neutral'
variant='plain' variant='plain'
fullWidth fullWidth
disabled={chore.isActive === false}
onClick={() => { onClick={() => {
navigate(`/chores/${choreId}/history`) navigate(`/chores/${choreId}/history`)
}} }}
@@ -609,6 +636,7 @@ const ChoreView = () => {
color='neutral' color='neutral'
variant='plain' variant='plain'
fullWidth fullWidth
disabled={chore.isActive === false}
sx={{ sx={{
// top right of the card: // top right of the card:
flexDirection: 'column', flexDirection: 'column',
@@ -725,6 +753,7 @@ const ChoreView = () => {
<Checkbox <Checkbox
checked={note !== null} checked={note !== null}
size='lg' size='lg'
disabled={chore.isActive === false}
onChange={e => { onChange={e => {
if (e.target.checked) { if (e.target.checked) {
setNote('') setNote('')
@@ -764,6 +793,7 @@ const ChoreView = () => {
<Checkbox <Checkbox
checked={completedDate !== null} checked={completedDate !== null}
size='lg' size='lg'
disabled={chore.isActive === false}
onChange={e => { onChange={e => {
if (e.target.checked) { if (e.target.checked) {
setCompletedDate( setCompletedDate(
@@ -804,6 +834,29 @@ const ChoreView = () => {
/> />
)} )}
{chore.isActive === false ? (
// Archived chore - only show unarchive button
<Box
sx={{
display: 'flex',
flexDirection: 'column',
gap: 1,
alignContent: 'center',
justifyContent: 'center',
}}
>
<Button
fullWidth
size='lg'
onClick={handleUnarchiveChore}
color='primary'
startDecorator={<Unarchive />}
>
Unarchive
</Button>
</Box>
) : (
// Active chore - show all normal actions
<Box <Box
sx={{ sx={{
display: 'flex', display: 'flex',
@@ -962,6 +1015,7 @@ const ChoreView = () => {
</Button> </Button>
)} )}
</Box> </Box>
)}
<Snackbar <Snackbar
open={isPendingCompletion} open={isPendingCompletion}

View File

@@ -444,7 +444,6 @@ const ArchivedTasks = () => {
performers={performers} performers={performers}
viewOnly={false} viewOnly={false}
showActions={false} showActions={false}
// onAction={handleChoreAction}
// Multi-select props // Multi-select props
isMultiSelectMode={isMultiSelectMode} isMultiSelectMode={isMultiSelectMode}
isSelected={selectedChores.has(chore.id)} isSelected={selectedChores.has(chore.id)}

View File

@@ -209,9 +209,16 @@ const MyChores = () => {
} }
// Use project-filtered chores for section grouping // Use project-filtered chores for section grouping
const choresToGroup = selectedProject let choresToGroup = chores
? filterByProject(chores, selectedProject.id) if (selectedProject) {
: chores if (selectedProject.id === 'default') {
// Default project: only show tasks without a projectId
choresToGroup = chores.filter(chore => !chore.projectId)
} else {
// Other projects: use the existing filter function
choresToGroup = filterByProject(chores, selectedProject.id)
}
}
const sections = ChoresGrouper( const sections = ChoresGrouper(
selectedChoreSection, selectedChoreSection,
@@ -1084,8 +1091,8 @@ const MyChores = () => {
} }
const setSelectedProjectWithCache = project => { const setSelectedProjectWithCache = project => {
// Handle the case where project might be null (clearing selection) // Keep the project as-is, including the default project object
const finalProject = project?.id === 'default' || !project ? null : project const finalProject = project || null
setSelectedProject(finalProject) setSelectedProject(finalProject)
console.log('final project', finalProject) console.log('final project', finalProject)
@@ -1157,6 +1164,13 @@ const MyChores = () => {
if (!selectedProject) { if (!selectedProject) {
return chores return chores
} }
// Special case: Default project shows only tasks without a projectId
if (selectedProject.id === 'default') {
return chores.filter(chore => !chore.projectId)
}
// Other projects: use the existing filter function
return filterByProject(chores, selectedProject.id) return filterByProject(chores, selectedProject.id)
}, [chores, selectedProject]) }, [chores, selectedProject])
@@ -1183,11 +1197,17 @@ const MyChores = () => {
.search(searchTerm.toLowerCase()) .search(searchTerm.toLowerCase())
.map(result => result.item) .map(result => result.item)
} else { } else {
result = filteredChores.filter( result = filteredChores.filter(chore => {
chore => if (!selectedProject) return true
!selectedProject ||
filterByProject([chore], selectedProject).length > 0, // Default project: only show tasks without projectId
) if (selectedProject.id === 'default') {
return !chore.projectId
}
// Other projects: use existing filter
return filterByProject([chore], selectedProject.id).length > 0
})
} }
} else { } else {
let choresToFilter = baseChores let choresToFilter = baseChores

View File

@@ -125,12 +125,32 @@ const ProjectModal = ({ isOpen, onClose, onSave, project }) => {
size='md' size='md'
unmountDelay={250} unmountDelay={250}
fullWidth={true} fullWidth={true}
title={project ? 'Edit Project' : 'Create New Project'}
footer={
<Box display='flex' justifyContent='space-around' gap={1}>
<Button
type='submit'
form='project-form'
loading={isSubmitting}
disabled={!projectName.trim() || isSubmitting}
fullWidth
size='lg'
> >
<Typography level='h4' mb={2}> {project ? 'Update' : 'Create'}
{project ? 'Edit Project' : 'Create New Project'} </Button>
</Typography> <Button
variant='outlined'
<form onSubmit={handleSubmit}> onClick={handleClose}
disabled={isSubmitting}
fullWidth
size='lg'
>
Cancel
</Button>
</Box>
}
>
<form onSubmit={handleSubmit} id='project-form'>
<Stack spacing={3}> <Stack spacing={3}>
{/* Project Name */} {/* Project Name */}
<FormControl required> <FormControl required>
@@ -238,62 +258,6 @@ const ProjectModal = ({ isOpen, onClose, onSave, project }) => {
</Select> </Select>
</FormControl> </FormControl>
{/* Project Preview */}
<FormControl>
<FormLabel>Preview</FormLabel>
<Box
sx={{
display: 'flex',
alignItems: 'center',
gap: 1.5,
p: 2,
borderRadius: 'sm',
border: '1px solid',
borderColor: 'divider',
bgcolor: 'background.level1',
}}
>
<Avatar
size='sm'
sx={{
width: 32,
height: 32,
bgcolor: projectColor,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
'& svg': {
display: 'block',
margin: '0 auto',
},
}}
>
{(() => {
const IconComponent = getIconComponent(projectIcon)
return (
<IconComponent
sx={{
fontSize: 16,
color: getTextColorFromBackgroundColor(projectColor),
display: 'block',
}}
/>
)
})()}
</Avatar>
<Box>
<Typography level='title-sm' sx={{ fontWeight: 600 }}>
{projectName || 'Project Name'}
</Typography>
{projectDescription && (
<Typography level='body-xs' sx={{ color: 'text.tertiary' }}>
{projectDescription}
</Typography>
)}
</Box>
</Box>
</FormControl>
{/* Error Message */} {/* Error Message */}
{error && ( {error && (
<Typography color='danger' level='body-sm'> <Typography color='danger' level='body-sm'>
@@ -301,29 +265,7 @@ const ProjectModal = ({ isOpen, onClose, onSave, project }) => {
</Typography> </Typography>
)} )}
</Stack> </Stack>
<Box display='flex' justifyContent='space-around' gap={1} mt={3}>
<Button
type='submit'
loading={isSubmitting}
disabled={!projectName.trim() || isSubmitting}
fullWidth
size='lg'
>
{project ? 'Update' : 'Create'}
</Button>
<Button
variant='outlined'
onClick={handleClose}
disabled={isSubmitting}
fullWidth
size='lg'
>
Cancel
</Button>
</Box>
</form> </form>
<IconPickerModal <IconPickerModal
isOpen={isIconPickerOpen} isOpen={isIconPickerOpen}
onClose={() => setIsIconPickerOpen(false)} onClose={() => setIsIconPickerOpen(false)}

View File

@@ -413,18 +413,7 @@ const ProjectCard = ({
) )
})() })()
) : ( ) : (
<Typography <></>
level='body-xs'
sx={{
color: getTextColorFromBackgroundColor(
project.color || '#1976d2',
),
fontWeight: 'bold',
fontSize: 10,
}}
>
{project.name.charAt(0).toUpperCase()}
</Typography>
)} )}
</Avatar> </Avatar>
</Box> </Box>
@@ -545,7 +534,7 @@ const ProjectCard = ({
const ProjectView = () => { const ProjectView = () => {
const { data: projects, isProjectsLoading, isError } = useProjects() const { data: projects, isProjectsLoading, isError } = useProjects()
const { data: userProfile } = useUserProfile() const { data: userProfile } = useUserProfile()
const { data: chores = [] } = useChores(false) // false to exclude archived const { data: chores = { res: [] } } = useChores(false) // false to exclude archived
const [userProjects, setUserProjects] = useState([]) const [userProjects, setUserProjects] = useState([])
const [modalOpen, setModalOpen] = useState(false) const [modalOpen, setModalOpen] = useState(false)
@@ -602,23 +591,27 @@ const ProjectView = () => {
// Calculate real task counts from chores data // Calculate real task counts from chores data
useEffect(() => { useEffect(() => {
if (chores && chores.res && userProjects.length > 0) { if (chores && chores.res) {
const choresList = chores.res const choresList = chores.res
const realCounts = {} const realCounts = {}
userProjects.forEach(project => { // First, count tasks for the default project (tasks without a projectId)
// Count chores for this project const defaultProjectCount = choresList.filter(chore => {
const choreCount = choresList.filter(chore => { const choreProjectId = chore.projectId || chore.project_id
// Handle default project (projectId is null, undefined, empty string, or 'default')
if (project.id === 'default') {
return ( return (
!chore.projectId || !choreProjectId ||
chore.projectId === '' || choreProjectId === '' ||
chore.projectId === 'default' choreProjectId === 'default' ||
choreProjectId === null
) )
} }).length
// Handle custom projects - exact match with project ID realCounts['default'] = defaultProjectCount
return chore.projectId === project.id
// Then count tasks for each user project
userProjects.forEach(project => {
const choreCount = choresList.filter(chore => {
const choreProjectId = chore.projectId || chore.project_id
return choreProjectId === project.id
}).length }).length
realCounts[project.id] = choreCount realCounts[project.id] = choreCount

View File

@@ -1,5 +1,13 @@
import { Add, EditNotifications } from '@mui/icons-material' import { Add, EditNotifications } from '@mui/icons-material'
import { Box, Button, Input, Option, Select, Typography } from '@mui/joy' import {
Avatar,
Box,
Button,
Input,
Option,
Select,
Typography,
} from '@mui/joy'
import { FormControl } from '@mui/material' import { FormControl } from '@mui/material'
import * as chrono from 'chrono-node' import * as chrono from 'chrono-node'
import moment from 'moment' import moment from 'moment'
@@ -7,8 +15,11 @@ import { useCallback, useEffect, useRef, useState } from 'react'
import { useResponsiveModal } from '../../hooks/useResponsiveModal' import { useResponsiveModal } from '../../hooks/useResponsiveModal'
import { useCreateChore } from '../../queries/ChoreQueries' import { useCreateChore } from '../../queries/ChoreQueries'
import { useCircleMembers, useUserProfile } from '../../queries/UserQueries' import { useCircleMembers, useUserProfile } from '../../queries/UserQueries'
import { getTextColorFromBackgroundColor } from '../../utils/Colors.jsx'
import { isPlusAccount } from '../../utils/Helpers' import { isPlusAccount } from '../../utils/Helpers'
import { getIconComponent } from '../../utils/ProjectIcons'
import { useLabels } from '../Labels/LabelQueries' import { useLabels } from '../Labels/LabelQueries'
import { useProjects } from '../Projects/ProjectQueries'
import { import {
parseAssignees, parseAssignees,
parseDueDate, parseDueDate,
@@ -47,10 +58,25 @@ const TaskInput = ({ autoFocus, onChoreUpdate, isModalOpen, onClose }) => {
const { data: userLabels, isLoading: userLabelsLoading } = useLabels() const { data: userLabels, isLoading: userLabelsLoading } = useLabels()
const { data: circleMembers, isLoading: isCircleMembersLoading } = const { data: circleMembers, isLoading: isCircleMembersLoading } =
useCircleMembers() useCircleMembers()
const { data: projects = [], isLoading: isProjectsLoading } = useProjects()
const createChoreMutation = useCreateChore() const createChoreMutation = useCreateChore()
const { data: userProfile } = useUserProfile() const { data: userProfile } = useUserProfile()
// Get initial project from localStorage (current active project)
const getInitialProject = () => {
const saved = localStorage.getItem('selectedProject')
if (saved) {
try {
const project = JSON.parse(saved)
return project?.id || 'default'
} catch {
return 'default'
}
}
return 'default'
}
const [taskText, setTaskText] = useState('') const [taskText, setTaskText] = useState('')
const [taskTitle, setTaskTitle] = useState('') const [taskTitle, setTaskTitle] = useState('')
const [renderedParts, setRenderedParts] = useState([]) const [renderedParts, setRenderedParts] = useState([])
@@ -75,6 +101,7 @@ const TaskInput = ({ autoFocus, onChoreUpdate, isModalOpen, onClose }) => {
const [hasSubTasks, setHasSubTasks] = useState(false) const [hasSubTasks, setHasSubTasks] = useState(false)
const [hasNotifications, setHasNotifications] = useState(false) const [hasNotifications, setHasNotifications] = useState(false)
const [showKeyboardShortcuts, setShowKeyboardShortcuts] = useState(false) const [showKeyboardShortcuts, setShowKeyboardShortcuts] = useState(false)
const [projectId, setProjectId] = useState(getInitialProject())
// set showKeyboardShortcuts true as soon as the user hold ctrl or cmd key: // set showKeyboardShortcuts true as soon as the user hold ctrl or cmd key:
useEffect(() => { useEffect(() => {
@@ -473,6 +500,7 @@ const TaskInput = ({ autoFocus, onChoreUpdate, isModalOpen, onClose }) => {
setHasSubTasks(false) setHasSubTasks(false)
setLabelsV2([]) setLabelsV2([])
setAssignees([]) setAssignees([])
setProjectId(getInitialProject())
} }
const createChore = () => { const createChore = () => {
@@ -516,6 +544,7 @@ const TaskInput = ({ autoFocus, onChoreUpdate, isModalOpen, onClose }) => {
frequencyMetadata: {}, frequencyMetadata: {},
notificationMetadata: {}, notificationMetadata: {},
subTasks: subTasks?.length > 0 ? subTasks : null, subTasks: subTasks?.length > 0 ? subTasks : null,
projectId: projectId === 'default' ? null : projectId,
} }
if (frequency) { if (frequency) {
@@ -560,7 +589,7 @@ const TaskInput = ({ autoFocus, onChoreUpdate, isModalOpen, onClose }) => {
}) })
handleCloseModal(false) handleCloseModal(false)
} }
if (userLabelsLoading || isCircleMembersLoading) { if (userLabelsLoading || isCircleMembersLoading || isProjectsLoading) {
return <></> return <></>
} }
@@ -571,6 +600,44 @@ const TaskInput = ({ autoFocus, onChoreUpdate, isModalOpen, onClose }) => {
size='lg' size='lg'
fullWidth={true} fullWidth={true}
title='Create new task' title='Create new task'
footer={
<Box
sx={{
marginTop: 2,
display: 'flex',
flexDirection: 'row',
justifyContent: 'end',
gap: 1,
}}
>
<Button
size='lg'
variant='outlined'
color='neutral'
onClick={handleCloseModal}
>
Cancel
{showKeyboardShortcuts && (
<KeyboardShortcutHint
shortcut='Esc'
sx={{ ml: 1 }}
withCtrl={false}
/>
)}
</Button>
<Button
size='lg'
variant='solid'
color='primary'
onClick={createChore}
>
Create
{showKeyboardShortcuts && (
<KeyboardShortcutHint shortcut='Enter' sx={{ ml: 1 }} />
)}
</Button>
</Box>
}
> >
<Box> <Box>
<Box <Box
@@ -803,6 +870,75 @@ const TaskInput = ({ autoFocus, onChoreUpdate, isModalOpen, onClose }) => {
</FormControl> </FormControl>
)} )}
</Box> </Box>
{projects.length >= 1 && (
<FormControl>
<Typography level='body-sm'>Project</Typography>
<Select
value={projectId}
onChange={(event, newValue) => setProjectId(newValue)}
sx={{ minWidth: '15rem' }}
>
<Option key='default' value='default'>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<Avatar
size='sm'
sx={{
width: 24,
height: 24,
bgcolor: '#1976d2',
}}
>
{(() => {
const IconComponent = getIconComponent('FolderOpen')
return (
<IconComponent
sx={{
fontSize: 14,
color: getTextColorFromBackgroundColor('#1976d2'),
}}
/>
)
})()}
</Avatar>
Default Project
</Box>
</Option>
{projects.map(project => (
<Option key={project.id} value={project.id}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<Avatar
size='sm'
sx={{
width: 24,
height: 24,
bgcolor: project.color || '#1976d2',
}}
>
{project.icon ? (
(() => {
const IconComponent = getIconComponent(project.icon)
return (
<IconComponent
sx={{
fontSize: 14,
color: getTextColorFromBackgroundColor(
project.color || '#1976d2',
),
}}
/>
)
})()
) : (
<></>
)}
</Avatar>
{project.name}
</Box>
</Option>
))}
</Select>
</FormControl>
)}
<Box <Box
sx={{ sx={{
marginTop: 2, marginTop: 2,
@@ -861,37 +997,6 @@ const TaskInput = ({ autoFocus, onChoreUpdate, isModalOpen, onClose }) => {
</Box> </Box>
)} )}
</Box> </Box>
<Box
sx={{
marginTop: 2,
display: 'flex',
flexDirection: 'row',
justifyContent: 'end',
gap: 1,
}}
>
<Button
size='lg'
variant='outlined'
color='neutral'
onClick={handleCloseModal}
>
Cancel
{showKeyboardShortcuts && (
<KeyboardShortcutHint
shortcut='Esc'
sx={{ ml: 1 }}
withCtrl={false}
/>
)}
</Button>
<Button size='lg' variant='solid' color='primary' onClick={createChore}>
Create
{showKeyboardShortcuts && (
<KeyboardShortcutHint shortcut='Enter' sx={{ ml: 1 }} />
)}
</Button>
</Box>
</ResponsiveModal> </ResponsiveModal>
) )
} }

View File

@@ -94,13 +94,12 @@ const links = [
import { SafeArea } from 'capacitor-plugin-safe-area' import { SafeArea } from 'capacitor-plugin-safe-area'
import Z_INDEX from '../../constants/zIndex' import Z_INDEX from '../../constants/zIndex'
import { useAuth } from '../../hooks/useAuth.jsx'
import { useResource } from '../../queries/ResourceQueries' import { useResource } from '../../queries/ResourceQueries'
import { apiClient } from '../../utils/apiClient'
const publicPages = ['/landing', '/privacy', '/terms'] const publicPages = ['/landing', '/privacy', '/terms']
const NavBar = () => { const NavBar = () => {
const { data: resource } = useResource() const { data: resource } = useResource()
const { logout } = useAuth()
const navigate = useNavigate() const navigate = useNavigate()
const [drawerOpen, setDrawerOpen] = useState(false) const [drawerOpen, setDrawerOpen] = useState(false)
@@ -272,7 +271,9 @@ const NavBar = () => {
<ListItemContent>Upgrade to Plus</ListItemContent> <ListItemContent>Upgrade to Plus</ListItemContent>
</ListItemButton> */} </ListItemButton> */}
<ListItemButton <ListItemButton
onClick={logout} onClick={() => {
apiClient.handleLogout()
}}
sx={{ sx={{
py: 1.2, py: 1.2,
}} }}

View File

@@ -1,4 +1,4 @@
import { Add, Check, FolderOpen } from '@mui/icons-material' import { Add, Check, FolderOpen, Settings } from '@mui/icons-material'
import { import {
Avatar, Avatar,
Box, Box,
@@ -11,6 +11,7 @@ import {
Typography, Typography,
} from '@mui/joy' } from '@mui/joy'
import { useEffect, useRef, useState } from 'react' import { useEffect, useRef, useState } from 'react'
import { useNavigate } from 'react-router-dom'
import KeyboardShortcutHint from '../../components/common/KeyboardShortcutHint' import KeyboardShortcutHint from '../../components/common/KeyboardShortcutHint'
import LABEL_COLORS, { import LABEL_COLORS, {
getTextColorFromBackgroundColor, getTextColorFromBackgroundColor,
@@ -25,9 +26,11 @@ const ProjectSelector = ({
showKeyboardShortcuts = false, showKeyboardShortcuts = false,
}) => { }) => {
const { data: projects = [], isLoading } = useProjects() const { data: projects = [], isLoading } = useProjects()
const navigate = useNavigate()
const [anchorEl, setAnchorEl] = useState(null) const [anchorEl, setAnchorEl] = useState(null)
const [selectedIndex, setSelectedIndex] = useState(0) const [selectedIndex, setSelectedIndex] = useState(0)
const [isKeyboardNavigating, setIsKeyboardNavigating] = useState(false)
const [isProjectModalOpen, setIsProjectModalOpen] = useState(false) const [isProjectModalOpen, setIsProjectModalOpen] = useState(false)
const menuRef = useRef(null) const menuRef = useRef(null)
const buttonRef = useRef(null) const buttonRef = useRef(null)
@@ -56,6 +59,7 @@ const ProjectSelector = ({
const handleMenuOpen = event => { const handleMenuOpen = event => {
setAnchorEl(event.currentTarget) setAnchorEl(event.currentTarget)
setIsKeyboardNavigating(false)
} }
const handleMenuClose = () => { const handleMenuClose = () => {
@@ -76,6 +80,11 @@ const ProjectSelector = ({
handleProjectSelect(project) handleProjectSelect(project)
} }
const handleManageProjects = () => {
navigate('/projects')
handleMenuClose()
}
useEffect(() => { useEffect(() => {
const handleMenuOutsideClick = event => { const handleMenuOutsideClick = event => {
if (menuRef.current && !menuRef.current.contains(event.target)) { if (menuRef.current && !menuRef.current.contains(event.target)) {
@@ -100,6 +109,7 @@ const ProjectSelector = ({
if (!anchorEl) { if (!anchorEl) {
setAnchorEl(buttonRef.current) setAnchorEl(buttonRef.current)
setSelectedIndex(0) setSelectedIndex(0)
setIsKeyboardNavigating(true)
} else { } else {
handleMenuClose() handleMenuClose()
} }
@@ -112,20 +122,33 @@ const ProjectSelector = ({
switch (event.key) { switch (event.key) {
case 'ArrowDown': case 'ArrowDown':
event.preventDefault() event.preventDefault()
setIsKeyboardNavigating(true)
setSelectedIndex(prev => setSelectedIndex(prev =>
prev < defaultProjects.length ? prev + 1 : prev, prev < defaultProjects.length + 2 ? prev + 1 : prev,
) )
break break
case 'ArrowUp': case 'ArrowUp':
event.preventDefault() event.preventDefault()
setIsKeyboardNavigating(true)
setSelectedIndex(prev => (prev > 0 ? prev - 1 : prev)) setSelectedIndex(prev => (prev > 0 ? prev - 1 : prev))
break break
case 'Enter': case 'Enter':
event.preventDefault() event.preventDefault()
if (selectedIndex < defaultProjects.length) { if (selectedIndex === 0) {
handleProjectSelect(defaultProjects[selectedIndex]) // Hardcoded Default Project
} else { handleProjectSelect({
id: 'default',
name: 'Default Project',
color: LABEL_COLORS[0].value,
icon: 'FolderOpen',
})
} else if (selectedIndex <= defaultProjects.length) {
// Projects from the array (offset by 1)
handleProjectSelect(defaultProjects[selectedIndex - 1])
} else if (selectedIndex === defaultProjects.length + 1) {
handleAddProjectClick() handleAddProjectClick()
} else {
handleManageProjects()
} }
break break
case 'Escape': case 'Escape':
@@ -139,7 +162,7 @@ const ProjectSelector = ({
return () => { return () => {
document.removeEventListener('keydown', handleKeyDown) document.removeEventListener('keydown', handleKeyDown)
} }
}, [anchorEl, selectedIndex, defaultProjects]) }, [anchorEl, selectedIndex, defaultProjects, isKeyboardNavigating])
// Reset selected index when menu opens // Reset selected index when menu opens
useEffect(() => { useEffect(() => {
@@ -233,7 +256,6 @@ const ProjectSelector = ({
disabled disabled
sx={{ sx={{
borderRadius: 'var(--joy-radius-sm)', borderRadius: 'var(--joy-radius-sm)',
mb: 1,
cursor: 'default', cursor: 'default',
opacity: 1, opacity: 1,
}} }}
@@ -243,13 +265,7 @@ const ProjectSelector = ({
</ListItemDecorator> </ListItemDecorator>
<ListItemContent> <ListItemContent>
<Typography level='title-sm' sx={{ fontWeight: 600 }}> <Typography level='title-sm' sx={{ fontWeight: 600 }}>
Select Project Projects
</Typography>
<Typography
level='body-xs'
sx={{ color: 'var(--joy-palette-text-tertiary)' }}
>
Choose or create a project workspace
</Typography> </Typography>
</ListItemContent> </ListItemContent>
</MenuItem> </MenuItem>
@@ -266,12 +282,13 @@ const ProjectSelector = ({
icon: 'FolderOpen', icon: 'FolderOpen',
}) })
} }
onMouseEnter={() => setIsKeyboardNavigating(false)}
sx={{ sx={{
borderRadius: 'var(--joy-radius-sm)', borderRadius: 'var(--joy-radius-sm)',
backgroundColor: backgroundColor:
effectiveSelectedProject === 'Default Project' effectiveSelectedProject === 'Default Project'
? 'var(--joy-palette-primary-softBg)' ? 'var(--joy-palette-primary-softBg)'
: selectedIndex === 0 && anchorEl : selectedIndex === 0 && anchorEl && isKeyboardNavigating
? 'var(--joy-palette-neutral-softHoverBg)' ? 'var(--joy-palette-neutral-softHoverBg)'
: 'transparent', : 'transparent',
'&:hover': { '&:hover': {
@@ -345,12 +362,13 @@ const ProjectSelector = ({
<MenuItem <MenuItem
key={project.id} key={project.id}
onClick={() => handleProjectSelect(project)} onClick={() => handleProjectSelect(project)}
onMouseEnter={() => setIsKeyboardNavigating(false)}
sx={{ sx={{
borderRadius: 'var(--joy-radius-sm)', borderRadius: 'var(--joy-radius-sm)',
backgroundColor: backgroundColor:
effectiveSelectedProject === project.name effectiveSelectedProject === project.name
? 'var(--joy-palette-primary-softBg)' ? 'var(--joy-palette-primary-softBg)'
: selectedIndex === index && anchorEl : selectedIndex === index + 1 && anchorEl && isKeyboardNavigating
? 'var(--joy-palette-neutral-softHoverBg)' ? 'var(--joy-palette-neutral-softHoverBg)'
: 'transparent', : 'transparent',
'&:hover': { '&:hover': {
@@ -437,10 +455,11 @@ const ProjectSelector = ({
<MenuItem <MenuItem
onClick={handleAddProjectClick} onClick={handleAddProjectClick}
onMouseEnter={() => setIsKeyboardNavigating(false)}
sx={{ sx={{
borderRadius: 'var(--joy-radius-sm)', borderRadius: 'var(--joy-radius-sm)',
backgroundColor: backgroundColor:
selectedIndex === defaultProjects.length && anchorEl selectedIndex === defaultProjects.length + 1 && anchorEl && isKeyboardNavigating
? 'var(--joy-palette-success-softHoverBg)' ? 'var(--joy-palette-success-softHoverBg)'
: 'transparent', : 'transparent',
'&:hover': { '&:hover': {
@@ -456,7 +475,6 @@ const ProjectSelector = ({
level='body-sm' level='body-sm'
sx={{ sx={{
fontWeight: 500, fontWeight: 500,
color: 'var(--joy-palette-success-600)',
}} }}
> >
Create New Project Create New Project
@@ -469,6 +487,41 @@ const ProjectSelector = ({
</Typography> </Typography>
</ListItemContent> </ListItemContent>
</MenuItem> </MenuItem>
<MenuItem
onClick={handleManageProjects}
onMouseEnter={() => setIsKeyboardNavigating(false)}
sx={{
borderRadius: 'var(--joy-radius-sm)',
backgroundColor:
selectedIndex === defaultProjects.length + 2 && anchorEl && isKeyboardNavigating
? 'var(--joy-palette-neutral-softHoverBg)'
: 'transparent',
'&:hover': {
backgroundColor: 'var(--joy-palette-neutral-softHoverBg)',
},
}}
>
<ListItemDecorator sx={{ color: 'var(--joy-palette-neutral-500)' }}>
<Settings />
</ListItemDecorator>
<ListItemContent>
<Typography
level='body-sm'
sx={{
fontWeight: 500,
}}
>
Manage Projects
</Typography>
<Typography
level='body-xs'
sx={{ color: 'var(--joy-palette-text-tertiary)' }}
>
View, edit, and organize all projects
</Typography>
</ListItemContent>
</MenuItem>
</Menu> </Menu>
<ProjectModal <ProjectModal