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 useStickyState from '../hooks/useStickyState'
import { useCircleMembers, useUserProfile } from '../queries/UserQueries'
import { apiClient } from '../utils/apiClient'
import { isPlusAccount } from '../utils/Helpers'
import UserModal from '../views/Modals/Inputs/UserModal'
import SubscriptionModal from './SubscriptionModal'
@@ -76,9 +77,7 @@ const UserProfileAvatar = () => {
}
const handleLogout = () => {
localStorage.removeItem('access_token')
localStorage.removeItem('ca_expiration')
window.location.href = '/login'
apiClient.handleLogout()
}
const handleSupportEmail = () => {

View File

@@ -1,5 +1,5 @@
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 { Z_INDEX } from '../../constants/zIndex'
@@ -10,6 +10,7 @@ const BottomSheetModal = forwardRef(
onClose,
children,
title,
footer,
height = 'auto',
maxHeight = '90vh',
expandedHeight = '95vh',
@@ -79,7 +80,11 @@ const BottomSheetModal = forwardRef(
const currentHeight = isExpanded ? expandedHeight : height
// 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 (
<Modal
@@ -223,6 +228,21 @@ const BottomSheetModal = forwardRef(
>
{children}
</div>
{footer && (
<>
<Divider />
<footer
style={{
flexShrink: 0,
// borderTop: '1px solid var(--joy-palette-divider)',
padding: '16px 20px',
}}
>
{footer}
</footer>
</>
)}
</Sheet>
</Modal>
)

View File

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

View File

@@ -443,6 +443,13 @@ export const useSSE = () => {
return // Exit early, don't use exponential backoff for 401 errors
} 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)
setError('Authentication failed - please log in again')
// Don't attempt reconnection if token refresh failed

View File

@@ -46,24 +46,11 @@ export const useUserProfile = () => {
const { data, error, isLoading } = useQuery({
queryKey: ['userProfile'],
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 result = await resp.json()
// 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
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 React, { createContext, useContext, useState } from 'react'
@@ -24,10 +24,17 @@ const NOTIFICATION_TYPES = {
success: {
color: 'success',
icon: <CheckCircle color='success' />,
autoHideDuration: 3000,
autoHideDuration: 5000,
showDismissButton: false,
defaultTitle: 'Success',
},
undo: {
color: 'success',
icon: <Undo color='success' />,
autoHideDuration: null,
showDismissButton: false,
defaultTitle: 'Undone Successfully',
},
warning: {
color: 'warning',
icon: <Warning color='warning' />,
@@ -134,6 +141,10 @@ export const NotificationProvider = ({ children }) => {
return addNotification(normalizeNotification(error, 'error'))
}
const showUndo = message => {
return addNotification(normalizeNotification(message, 'undo'))
}
const showSuccess = message => {
return addNotification(normalizeNotification(message, 'success'))
}
@@ -189,7 +200,18 @@ export const NotificationProvider = ({ children }) => {
onClose={() => removeNotification(notification.id)}
startDecorator={notificationIcon}
endDecorator={
config.showDismissButton ? (
notification.undoAction ? (
<Button
variant='outlined'
color={config.color}
onClick={() => {
notification.undoAction()
removeNotification(notification.id)
}}
>
Undo
</Button>
) : config.showDismissButton ? (
<Button
variant='outlined'
color={config.color}
@@ -232,6 +254,7 @@ export const NotificationProvider = ({ children }) => {
showNotification,
showError,
showSuccess,
showUndo,
showWarning,
showInfo,
removeNotification,

View File

@@ -1,5 +1,10 @@
import { API_URL } from '../Config'
import { RefreshToken } from './Fetcher'
import { logout, RefreshToken } from './Fetcher'
import {
clearAllTokens,
isRefreshTokenExpired,
saveTokens,
} from './TokenStorage'
class ApiClient {
constructor() {
@@ -11,6 +16,17 @@ class ApiClient {
}
async refreshToken() {
// Check if refresh token is expired BEFORE attempting refresh
const refreshExpired = await isRefreshTokenExpired()
if (refreshExpired) {
console.log('Refresh token expired, forcing logout')
await clearAllTokens()
if (window.location.pathname !== '/login') {
window.location.href = '/login'
}
return { success: false, error: 'Refresh token expired' }
}
if (this.isRefreshing) {
return { success: false, error: 'Already refreshing' }
}
@@ -30,14 +46,13 @@ class ApiClient {
const data = await refreshReq.json()
const newToken = data.token || data.access_token
// Update Local Storage
localStorage.setItem('token', newToken)
if (data.expire || data.access_token_expiry) {
localStorage.setItem(
'token_expiry',
data.expire || data.access_token_expiry,
)
}
// Save all tokens including rotated refresh token
await saveTokens({
accessToken: newToken,
accessTokenExpiry: data.expire || data.access_token_expiry,
refreshToken: data.refresh_token,
refreshTokenExpiry: data.refresh_token_expiry,
})
// Update last refresh time
this.lastRefreshTime = Date.now()
@@ -90,10 +105,11 @@ class ApiClient {
}
// Helper to avoid repeating cleanup code
handleLogout() {
localStorage.removeItem('token')
localStorage.removeItem('token_expiry')
window.location.href = '/login'
async handleLogout() {
logout().then(async () => {
await clearAllTokens()
if (window.location.pathname !== '/login') window.location.href = '/login'
}) // fire and forget
}
async request(endpoint, options = {}) {
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 = () => {
return Fetch(`/users/`, {
method: 'GET',
@@ -888,6 +896,7 @@ export {
JoinCircle,
LeaveCircle,
login,
logout,
MarkChoreComplete,
NudgeChore,
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 { useNotification } from '../../service/NotificationProvider'
import { apiClient } from '../../utils/apiClient'
import { saveTokens } from '../../utils/TokenStorage'
import { buildChildUsername, getUserDisplayInfo } from '../../utils/UserHelpers'
import MFAVerificationModal from './MFAVerificationModal'
@@ -202,10 +203,13 @@ const LoginView = () => {
const token = responseData.token || responseData.access_token
const expiry = responseData.expire || responseData.access_token_expiry
localStorage.setItem('token', token)
if (expiry) {
localStorage.setItem('token_expiry', expiry)
}
// Save all tokens including refresh tokens
await saveTokens({
accessToken: token,
accessTokenExpiry: expiry,
refreshToken: responseData.refresh_token,
refreshTokenExpiry: responseData.refresh_token_expiry,
})
// Refetch user profile after successful OAuth login
queryClient.invalidateQueries(['userProfile'])
@@ -247,9 +251,15 @@ const LoginView = () => {
})
}
const handleMFASuccess = data => {
localStorage.setItem('token', data.token)
localStorage.setItem('token_expiry', data.expire)
const handleMFASuccess = async data => {
// Save all tokens including refresh tokens
await saveTokens({
accessToken: data.token,
accessTokenExpiry: data.expire,
refreshToken: data.refresh_token,
refreshTokenExpiry: data.refresh_token_expiry,
})
setMfaModalOpen(false)
setMfaSessionToken('')
@@ -427,10 +437,7 @@ const LoginView = () => {
borderRadius: '8px',
}}
onClick={() => {
localStorage.removeItem('ca_token')
localStorage.removeItem('ca_expiration')
// go to login page:
window.location.href = '/login'
apiClient.handleLogout()
}}
>
Logout

View File

@@ -1,5 +1,6 @@
import { Add, HorizontalRule, Save } from '@mui/icons-material'
import {
Avatar,
Box,
Button,
Card,
@@ -24,6 +25,7 @@ import {
import moment from 'moment'
import { useEffect, useState } from 'react'
import { useNavigate, useParams, useSearchParams } from 'react-router-dom'
import KeyboardShortcutHint from '../../components/common/KeyboardShortcutHint'
import NotificationTemplate from '../../components/NotificationTemplate.jsx'
import {
useArchiveChore,
@@ -39,6 +41,7 @@ import { getTextColorFromBackgroundColor } from '../../utils/Colors.jsx'
import { GetAllCircleMembers, GetThings } from '../../utils/Fetcher'
import { isPlusAccount } from '../../utils/Helpers'
import Priorities from '../../utils/Priorities.jsx'
import { getIconComponent } from '../../utils/ProjectIcons'
import { getSafeBottomPadding } from '../../utils/SafeAreaUtils.js'
import LoadingComponent from '../components/Loading.jsx'
import RichTextEditor from '../components/RichTextEditor.jsx'
@@ -46,9 +49,8 @@ import SubTasks from '../components/SubTask.jsx'
import { useLabels } from '../Labels/LabelQueries'
import ConfirmationModal from '../Modals/Inputs/ConfirmationModal'
import LabelModal from '../Modals/Inputs/LabelModal'
import RepeatSection from './RepeatSection'
import { useProjects } from '../Projects/ProjectQueries'
import { getIconComponent } from '../../utils/ProjectIcons'
import RepeatSection from './RepeatSection'
const ASSIGN_STRATEGIES = [
'random',
@@ -79,6 +81,9 @@ const ChoreEdit = () => {
const [performers, setPerformers] = useState([])
const [assignStrategy, setAssignStrategy] = useState(ASSIGN_STRATEGIES[2])
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 [frequencyType, setFrequencyType] = useState('once')
const [frequency, setFrequency] = useState(1)
@@ -114,6 +119,7 @@ const ChoreEdit = () => {
const [showSaveNotificationDefault, setShowSaveNotificationDefault] =
useState(false)
const [showSaveAssigneeDefault, setShowSaveAssigneeDefault] = useState(false)
const [showKeyboardShortcuts, setShowKeyboardShortcuts] = useState(false)
const { data: userLabelsRaw, isLoading: isUserLabelsLoading } = useLabels()
const { data: projects = [], isLoading: isProjectsLoading } = useProjects()
@@ -219,7 +225,88 @@ const ChoreEdit = () => {
}
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 = () => {
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(() => {
if (isChoreLoading === false && choreData && choreId) {
const data = choreData
@@ -373,11 +501,32 @@ const ChoreEdit = () => {
setIsNotificable(data.res.notification)
setThingTrigger(data.res.thingChore)
setDueDate(
data.res.nextDueDate
? moment(data.res.nextDueDate).format('YYYY-MM-DDTHH:mm:00')
: null,
)
// Parse existing due date into date and time components
if (data.res.nextDueDate) {
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)
setUpdatedBy(data.res.updatedBy)
}
@@ -392,12 +541,20 @@ const ChoreEdit = () => {
// }, [userLabels, labelsV2])
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) {
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)) {
setDueDate(null)
setDueDateOnly(null)
setUseCustomTime(false)
setDueTime(null)
}
}, [frequencyType])
@@ -555,7 +712,7 @@ const ChoreEdit = () => {
</Box>
{/* Project Selection - Show only if there are multiple projects */}
{projects.length > 1 && (
{projects.length >= 1 && (
<Box mb={3}>
<Typography level='h4'>Project</Typography>
<Typography level='body-md'>
@@ -564,34 +721,68 @@ const ChoreEdit = () => {
<Select
value={projectId}
onChange={(event, newValue) => setProjectId(newValue)}
defaultValue='default'
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 => (
<Option key={project.id} value={project.id}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
{project.icon &&
(() => {
const IconComponent = getIconComponent(project.icon)
return (
<IconComponent
sx={{
fontSize: 16,
color: getTextColorFromBackgroundColor(
project.color || '#1976d2',
),
}}
/>
)
})()}
<Box
<Avatar
size='sm'
sx={{
width: 12,
height: 12,
borderRadius: '50%',
backgroundColor: project.color || '#1976d2',
mr: 1,
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>
@@ -901,9 +1092,18 @@ const ChoreEdit = () => {
<Checkbox
onChange={e => {
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 {
setDueDate(null)
setDueDateOnly(null)
setUseCustomTime(false)
setDueTime(null)
}
}}
defaultChecked={dueDate !== null}
@@ -917,19 +1117,50 @@ const ChoreEdit = () => {
</FormControl>
)}
{dueDate && (
<FormControl error={Boolean(errors.dueDate)}>
<Typography level='body-md'>
{REPEAT_ON_TYPE.includes(frequencyType)
? 'When does this task start?'
: 'When is the next first time this task is due?'}
</Typography>
<Input
type='datetime-local'
value={dueDate}
onChange={handleDueDateChange}
/>
<FormHelperText>{errors.dueDate}</FormHelperText>
</FormControl>
<>
<FormControl error={Boolean(errors.dueDate)} sx={{ mt: 2 }}>
<Typography level='body-md'>
{REPEAT_ON_TYPE.includes(frequencyType)
? 'When does this task start?'
: 'When is the next first time this task is due?'}
</Typography>
<Input
type='date'
value={dueDateOnly || ''}
onChange={handleDueDateChange}
/>
<FormHelperText>{errors.dueDate}</FormHelperText>
</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>
@@ -1006,7 +1237,9 @@ const ChoreEdit = () => {
onChange={e => {
if (e.target.checked) {
// 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)
} else {
setDeadline(null)
@@ -1036,7 +1269,8 @@ const ChoreEdit = () => {
label='Set a deadline for this task'
/>
<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>
</FormControl>
)}
@@ -1045,7 +1279,9 @@ const ChoreEdit = () => {
{deadline && ['once', 'no_repeat'].includes(frequencyType) && (
<Card variant='outlined' sx={{ mt: 2 }}>
<Box sx={{ p: 2 }}>
<Typography level='body-sm' mb={1}>Deadline Date:</Typography>
<Typography level='body-sm' mb={1}>
Deadline Date:
</Typography>
<Input
type='datetime-local'
value={deadline}
@@ -1061,41 +1297,50 @@ const ChoreEdit = () => {
)}
{/* Offset input for recurring tasks */}
{deadlineOffset !== -1 && !['once', 'no_repeat'].includes(frequencyType) && (
<Card variant='outlined' sx={{ mt: 2 }}>
<Box sx={{ p: 2, display: 'flex', gap: 2, alignItems: 'end' }}>
<Box>
<Typography level='body-sm' mb={1}>Time after due date:</Typography>
<Input
type='number'
value={deadlineOffset}
sx={{ maxWidth: 100 }}
slotProps={{
input: {
min: 1,
max: 720, // Max 30 days in hours
},
}}
placeholder='Time'
onChange={e => {
setDeadlineOffset(parseInt(e.target.value) || 1)
}}
/>
{deadlineOffset !== -1 &&
!['once', 'no_repeat'].includes(frequencyType) && (
<Card variant='outlined' sx={{ mt: 2 }}>
<Box
sx={{ p: 2, display: 'flex', gap: 2, alignItems: 'end' }}
>
<Box>
<Typography level='body-sm' mb={1}>
Time after due date:
</Typography>
<Input
type='number'
value={deadlineOffset}
sx={{ maxWidth: 100 }}
slotProps={{
input: {
min: 1,
max: 720, // Max 30 days in hours
},
}}
placeholder='Time'
onChange={e => {
setDeadlineOffset(parseInt(e.target.value) || 1)
}}
/>
</Box>
<Box>
<Typography level='body-sm' mb={1}>
Unit:
</Typography>
<Select
value={deadlineUnit}
onChange={(event, newValue) =>
setDeadlineUnit(newValue)
}
sx={{ minWidth: 100 }}
>
<Option value='hours'>Hours</Option>
<Option value='days'>Days</Option>
</Select>
</Box>
</Box>
<Box>
<Typography level='body-sm' mb={1}>Unit:</Typography>
<Select
value={deadlineUnit}
onChange={(event, newValue) => setDeadlineUnit(newValue)}
sx={{ minWidth: 100 }}
>
<Option value='hours'>Hours</Option>
<Option value='days'>Days</Option>
</Select>
</Box>
</Box>
</Card>
)}
</Card>
)}
</Box>
)}
@@ -1502,9 +1747,15 @@ const ChoreEdit = () => {
}}
>
Cancel
{showKeyboardShortcuts && (
<KeyboardShortcutHint shortcut='Esc' sx={{ ml: 1 }} />
)}
</Button>
<Button color='primary' variant='solid' onClick={HandleSaveChore}>
{choreId > 0 ? 'Save' : 'Create'}
{showKeyboardShortcuts && (
<KeyboardShortcutHint shortcut='Enter' sx={{ ml: 1 }} />
)}
</Button>
</Sheet>
<ConfirmationModal config={confirmModelConfig} />

View File

@@ -1,4 +1,5 @@
import {
Archive,
CalendarMonth,
CancelScheduleSend,
Check,
@@ -15,6 +16,7 @@ import {
SwitchAccessShortcut,
ThumbDown,
ThumbUp,
Unarchive,
} from '@mui/icons-material'
import {
Box,
@@ -60,6 +62,7 @@ import {
MarkChoreComplete,
RejectChore,
SkipChore,
UnArchiveChore,
UpdateChorePriority,
} from '../../utils/Fetcher'
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)
const canApproveReject = () => {
if (!circleMembersData?.res || !chore) return false
@@ -408,6 +423,16 @@ const ChoreView = () => {
>
{chore.name}
</Typography>
{chore.isActive === false && (
<Chip
startDecorator={<Archive />}
size='md'
color='warning'
sx={{ mb: 1 }}
>
Archived
</Chip>
)}
<Chip startDecorator={<CalendarMonth />} size='md' sx={{ mb: 1 }}>
{chore.nextDueDate
? `Due at ${moment(chore.nextDueDate).format('MM/DD/YYYY hh:mm A')}`
@@ -531,6 +556,7 @@ const ChoreView = () => {
>
<Dropdown>
<MenuButton
disabled={chore.isActive === false}
color={
chorePriority?.name === 'P1'
? 'danger'
@@ -591,6 +617,7 @@ const ChoreView = () => {
color='neutral'
variant='plain'
fullWidth
disabled={chore.isActive === false}
onClick={() => {
navigate(`/chores/${choreId}/history`)
}}
@@ -609,6 +636,7 @@ const ChoreView = () => {
color='neutral'
variant='plain'
fullWidth
disabled={chore.isActive === false}
sx={{
// top right of the card:
flexDirection: 'column',
@@ -725,6 +753,7 @@ const ChoreView = () => {
<Checkbox
checked={note !== null}
size='lg'
disabled={chore.isActive === false}
onChange={e => {
if (e.target.checked) {
setNote('')
@@ -764,6 +793,7 @@ const ChoreView = () => {
<Checkbox
checked={completedDate !== null}
size='lg'
disabled={chore.isActive === false}
onChange={e => {
if (e.target.checked) {
setCompletedDate(
@@ -804,164 +834,188 @@ const ChoreView = () => {
/>
)}
<Box
sx={{
display: 'flex',
flexDirection: 'column',
gap: 1,
alignContent: 'center',
justifyContent: 'center',
}}
>
{chore.isActive === false ? (
// Archived chore - only show unarchive button
<Box
sx={{
display: 'flex',
flexDirection: 'row',
flexDirection: 'column',
gap: 1,
alignContent: 'center',
justifyContent: 'center',
mb: 1,
}}
>
{chore.status === 3 ? (
// Pending approval: Show approve/reject for admins/managers/owners, grayed out button for others
canApproveReject() ? (
<Button
fullWidth
size='lg'
onClick={handleUnarchiveChore}
color='primary'
startDecorator={<Unarchive />}
>
Unarchive
</Button>
</Box>
) : (
// Active chore - show all normal actions
<Box
sx={{
display: 'flex',
flexDirection: 'column',
gap: 1,
alignContent: 'center',
justifyContent: 'center',
}}
>
<Box
sx={{
display: 'flex',
flexDirection: 'row',
gap: 1,
alignContent: 'center',
justifyContent: 'center',
mb: 1,
}}
>
{chore.status === 3 ? (
// Pending approval: Show approve/reject for admins/managers/owners, grayed out button for others
canApproveReject() ? (
<>
<Button
fullWidth
size='lg'
onClick={handleApproveChore}
color='success'
startDecorator={<ThumbUp />}
sx={{
flex: 1,
}}
>
Approve
</Button>
<Button
fullWidth
size='lg'
onClick={handleRejectChore}
color='danger'
startDecorator={<ThumbDown />}
sx={{
flex: 1,
}}
>
<Box>Reject</Box>
</Button>
</>
) : (
<Button
fullWidth
size='lg'
disabled={true}
color='neutral'
startDecorator={<HourglassEmpty />}
>
<Box>Pending Approval</Box>
</Button>
)
) : (
// Normal completion flow
<>
<Button
fullWidth
size='lg'
onClick={handleApproveChore}
color='success'
startDecorator={<ThumbUp />}
onClick={handleTaskCompletion}
disabled={
isPendingCompletion ||
notInCompletionWindow(chore) ||
(chore.lastCompletedDate !== null &&
chore.frequencyType === 'once')
}
color={isPendingCompletion ? 'danger' : 'success'}
startDecorator={<Check />}
sx={{
flex: 1,
flex: 4,
}}
>
Approve
<Box>Mark as done</Box>
</Button>
<Button
fullWidth
size='lg'
onClick={handleRejectChore}
color='danger'
startDecorator={<ThumbDown />}
onClick={() => {
setConfirmModelConfig({
isOpen: true,
title: 'Skip Task',
message: 'Are you sure you want to skip this task?',
confirmText: 'Skip',
cancelText: 'Cancel',
onClose: confirmed => {
if (confirmed) {
handleSkippingTask()
}
setConfirmModelConfig({})
},
})
}}
disabled={
chore.lastCompletedDate !== null &&
chore.frequencyType === 'once'
}
startDecorator={<SwitchAccessShortcut />}
sx={{
flex: 1,
}}
>
<Box>Reject</Box>
<Box>Skip</Box>
</Button>
</>
) : (
<Button
fullWidth
size='lg'
disabled={true}
color='neutral'
startDecorator={<HourglassEmpty />}
>
<Box>Pending Approval</Box>
</Button>
)
)}
</Box>
{/* Timer Button - Show split button when timer is active, regular button otherwise */}
{[ChoreStatus.ACTIVE, ChoreStatus.PAUSED].includes(chore.status) ? (
<TimerSplitButton
disabled={
chore.lastCompletedDate !== null &&
chore.frequencyType === 'once'
}
chore={chore}
onAction={action => {
if (action === 'pause') {
handleChorePause()
} else if (action === 'resume') {
handleChoreStart()
}
}}
onShowDetails={() => navigate(`/chores/${choreId}/timer`)}
onResetTimer={handleResetTimer}
onClearAllTime={handleClearAllTime}
fullWidth
/>
) : chore.status === ChoreStatus.PENDING_APPROVAL ? (
<></>
) : (
// Normal completion flow
<>
<Button
fullWidth
size='lg'
onClick={handleTaskCompletion}
disabled={
isPendingCompletion ||
notInCompletionWindow(chore) ||
(chore.lastCompletedDate !== null &&
chore.frequencyType === 'once')
}
color={isPendingCompletion ? 'danger' : 'success'}
startDecorator={<Check />}
sx={{
flex: 4,
}}
>
<Box>Mark as done</Box>
</Button>
<Button
fullWidth
size='lg'
onClick={() => {
setConfirmModelConfig({
isOpen: true,
title: 'Skip Task',
message: 'Are you sure you want to skip this task?',
confirmText: 'Skip',
cancelText: 'Cancel',
onClose: confirmed => {
if (confirmed) {
handleSkippingTask()
}
setConfirmModelConfig({})
},
})
}}
disabled={
chore.lastCompletedDate !== null &&
chore.frequencyType === 'once'
}
startDecorator={<SwitchAccessShortcut />}
sx={{
flex: 1,
}}
>
<Box>Skip</Box>
</Button>
</>
<Button
size='lg'
onClick={() => {
handleChoreStart()
}}
variant='soft'
color='success'
disabled={
chore.lastCompletedDate !== null &&
chore.frequencyType === 'once'
}
startDecorator={<PlayArrow />}
sx={{
flex: 1,
}}
>
Start
</Button>
)}
</Box>
{/* Timer Button - Show split button when timer is active, regular button otherwise */}
{[ChoreStatus.ACTIVE, ChoreStatus.PAUSED].includes(chore.status) ? (
<TimerSplitButton
disabled={
chore.lastCompletedDate !== null &&
chore.frequencyType === 'once'
}
chore={chore}
onAction={action => {
if (action === 'pause') {
handleChorePause()
} else if (action === 'resume') {
handleChoreStart()
}
}}
onShowDetails={() => navigate(`/chores/${choreId}/timer`)}
onResetTimer={handleResetTimer}
onClearAllTime={handleClearAllTime}
fullWidth
/>
) : chore.status === ChoreStatus.PENDING_APPROVAL ? (
<></>
) : (
<Button
size='lg'
onClick={() => {
handleChoreStart()
}}
variant='soft'
color='success'
disabled={
chore.lastCompletedDate !== null &&
chore.frequencyType === 'once'
}
startDecorator={<PlayArrow />}
sx={{
flex: 1,
}}
>
Start
</Button>
)}
</Box>
)}
<Snackbar
open={isPendingCompletion}

View File

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

View File

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

View File

@@ -125,12 +125,32 @@ const ProjectModal = ({ isOpen, onClose, onSave, project }) => {
size='md'
unmountDelay={250}
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'
>
{project ? 'Update' : 'Create'}
</Button>
<Button
variant='outlined'
onClick={handleClose}
disabled={isSubmitting}
fullWidth
size='lg'
>
Cancel
</Button>
</Box>
}
>
<Typography level='h4' mb={2}>
{project ? 'Edit Project' : 'Create New Project'}
</Typography>
<form onSubmit={handleSubmit}>
<form onSubmit={handleSubmit} id='project-form'>
<Stack spacing={3}>
{/* Project Name */}
<FormControl required>
@@ -238,62 +258,6 @@ const ProjectModal = ({ isOpen, onClose, onSave, project }) => {
</Select>
</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 && (
<Typography color='danger' level='body-sm'>
@@ -301,29 +265,7 @@ const ProjectModal = ({ isOpen, onClose, onSave, project }) => {
</Typography>
)}
</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>
<IconPickerModal
isOpen={isIconPickerOpen}
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>
</Box>
@@ -545,7 +534,7 @@ const ProjectCard = ({
const ProjectView = () => {
const { data: projects, isProjectsLoading, isError } = useProjects()
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 [modalOpen, setModalOpen] = useState(false)
@@ -602,23 +591,27 @@ const ProjectView = () => {
// Calculate real task counts from chores data
useEffect(() => {
if (chores && chores.res && userProjects.length > 0) {
if (chores && chores.res) {
const choresList = chores.res
const realCounts = {}
// First, count tasks for the default project (tasks without a projectId)
const defaultProjectCount = choresList.filter(chore => {
const choreProjectId = chore.projectId || chore.project_id
return (
!choreProjectId ||
choreProjectId === '' ||
choreProjectId === 'default' ||
choreProjectId === null
)
}).length
realCounts['default'] = defaultProjectCount
// Then count tasks for each user project
userProjects.forEach(project => {
// Count chores for this project
const choreCount = choresList.filter(chore => {
// Handle default project (projectId is null, undefined, empty string, or 'default')
if (project.id === 'default') {
return (
!chore.projectId ||
chore.projectId === '' ||
chore.projectId === 'default'
)
}
// Handle custom projects - exact match with project ID
return chore.projectId === project.id
const choreProjectId = chore.projectId || chore.project_id
return choreProjectId === project.id
}).length
realCounts[project.id] = choreCount

View File

@@ -1,5 +1,13 @@
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 * as chrono from 'chrono-node'
import moment from 'moment'
@@ -7,8 +15,11 @@ import { useCallback, useEffect, useRef, useState } from 'react'
import { useResponsiveModal } from '../../hooks/useResponsiveModal'
import { useCreateChore } from '../../queries/ChoreQueries'
import { useCircleMembers, useUserProfile } from '../../queries/UserQueries'
import { getTextColorFromBackgroundColor } from '../../utils/Colors.jsx'
import { isPlusAccount } from '../../utils/Helpers'
import { getIconComponent } from '../../utils/ProjectIcons'
import { useLabels } from '../Labels/LabelQueries'
import { useProjects } from '../Projects/ProjectQueries'
import {
parseAssignees,
parseDueDate,
@@ -47,10 +58,25 @@ const TaskInput = ({ autoFocus, onChoreUpdate, isModalOpen, onClose }) => {
const { data: userLabels, isLoading: userLabelsLoading } = useLabels()
const { data: circleMembers, isLoading: isCircleMembersLoading } =
useCircleMembers()
const { data: projects = [], isLoading: isProjectsLoading } = useProjects()
const createChoreMutation = useCreateChore()
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 [taskTitle, setTaskTitle] = useState('')
const [renderedParts, setRenderedParts] = useState([])
@@ -75,6 +101,7 @@ const TaskInput = ({ autoFocus, onChoreUpdate, isModalOpen, onClose }) => {
const [hasSubTasks, setHasSubTasks] = useState(false)
const [hasNotifications, setHasNotifications] = 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:
useEffect(() => {
@@ -473,6 +500,7 @@ const TaskInput = ({ autoFocus, onChoreUpdate, isModalOpen, onClose }) => {
setHasSubTasks(false)
setLabelsV2([])
setAssignees([])
setProjectId(getInitialProject())
}
const createChore = () => {
@@ -516,6 +544,7 @@ const TaskInput = ({ autoFocus, onChoreUpdate, isModalOpen, onClose }) => {
frequencyMetadata: {},
notificationMetadata: {},
subTasks: subTasks?.length > 0 ? subTasks : null,
projectId: projectId === 'default' ? null : projectId,
}
if (frequency) {
@@ -560,7 +589,7 @@ const TaskInput = ({ autoFocus, onChoreUpdate, isModalOpen, onClose }) => {
})
handleCloseModal(false)
}
if (userLabelsLoading || isCircleMembersLoading) {
if (userLabelsLoading || isCircleMembersLoading || isProjectsLoading) {
return <></>
}
@@ -571,6 +600,44 @@ const TaskInput = ({ autoFocus, onChoreUpdate, isModalOpen, onClose }) => {
size='lg'
fullWidth={true}
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
@@ -803,6 +870,75 @@ const TaskInput = ({ autoFocus, onChoreUpdate, isModalOpen, onClose }) => {
</FormControl>
)}
</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
sx={{
marginTop: 2,
@@ -861,37 +997,6 @@ const TaskInput = ({ autoFocus, onChoreUpdate, isModalOpen, onClose }) => {
</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>
)
}

View File

@@ -94,13 +94,12 @@ const links = [
import { SafeArea } from 'capacitor-plugin-safe-area'
import Z_INDEX from '../../constants/zIndex'
import { useAuth } from '../../hooks/useAuth.jsx'
import { useResource } from '../../queries/ResourceQueries'
import { apiClient } from '../../utils/apiClient'
const publicPages = ['/landing', '/privacy', '/terms']
const NavBar = () => {
const { data: resource } = useResource()
const { logout } = useAuth()
const navigate = useNavigate()
const [drawerOpen, setDrawerOpen] = useState(false)
@@ -272,7 +271,9 @@ const NavBar = () => {
<ListItemContent>Upgrade to Plus</ListItemContent>
</ListItemButton> */}
<ListItemButton
onClick={logout}
onClick={() => {
apiClient.handleLogout()
}}
sx={{
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 {
Avatar,
Box,
@@ -11,6 +11,7 @@ import {
Typography,
} from '@mui/joy'
import { useEffect, useRef, useState } from 'react'
import { useNavigate } from 'react-router-dom'
import KeyboardShortcutHint from '../../components/common/KeyboardShortcutHint'
import LABEL_COLORS, {
getTextColorFromBackgroundColor,
@@ -25,9 +26,11 @@ const ProjectSelector = ({
showKeyboardShortcuts = false,
}) => {
const { data: projects = [], isLoading } = useProjects()
const navigate = useNavigate()
const [anchorEl, setAnchorEl] = useState(null)
const [selectedIndex, setSelectedIndex] = useState(0)
const [isKeyboardNavigating, setIsKeyboardNavigating] = useState(false)
const [isProjectModalOpen, setIsProjectModalOpen] = useState(false)
const menuRef = useRef(null)
const buttonRef = useRef(null)
@@ -56,6 +59,7 @@ const ProjectSelector = ({
const handleMenuOpen = event => {
setAnchorEl(event.currentTarget)
setIsKeyboardNavigating(false)
}
const handleMenuClose = () => {
@@ -76,6 +80,11 @@ const ProjectSelector = ({
handleProjectSelect(project)
}
const handleManageProjects = () => {
navigate('/projects')
handleMenuClose()
}
useEffect(() => {
const handleMenuOutsideClick = event => {
if (menuRef.current && !menuRef.current.contains(event.target)) {
@@ -100,6 +109,7 @@ const ProjectSelector = ({
if (!anchorEl) {
setAnchorEl(buttonRef.current)
setSelectedIndex(0)
setIsKeyboardNavigating(true)
} else {
handleMenuClose()
}
@@ -112,20 +122,33 @@ const ProjectSelector = ({
switch (event.key) {
case 'ArrowDown':
event.preventDefault()
setIsKeyboardNavigating(true)
setSelectedIndex(prev =>
prev < defaultProjects.length ? prev + 1 : prev,
prev < defaultProjects.length + 2 ? prev + 1 : prev,
)
break
case 'ArrowUp':
event.preventDefault()
setIsKeyboardNavigating(true)
setSelectedIndex(prev => (prev > 0 ? prev - 1 : prev))
break
case 'Enter':
event.preventDefault()
if (selectedIndex < defaultProjects.length) {
handleProjectSelect(defaultProjects[selectedIndex])
} else {
if (selectedIndex === 0) {
// Hardcoded Default Project
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()
} else {
handleManageProjects()
}
break
case 'Escape':
@@ -139,7 +162,7 @@ const ProjectSelector = ({
return () => {
document.removeEventListener('keydown', handleKeyDown)
}
}, [anchorEl, selectedIndex, defaultProjects])
}, [anchorEl, selectedIndex, defaultProjects, isKeyboardNavigating])
// Reset selected index when menu opens
useEffect(() => {
@@ -233,7 +256,6 @@ const ProjectSelector = ({
disabled
sx={{
borderRadius: 'var(--joy-radius-sm)',
mb: 1,
cursor: 'default',
opacity: 1,
}}
@@ -243,13 +265,7 @@ const ProjectSelector = ({
</ListItemDecorator>
<ListItemContent>
<Typography level='title-sm' sx={{ fontWeight: 600 }}>
Select Project
</Typography>
<Typography
level='body-xs'
sx={{ color: 'var(--joy-palette-text-tertiary)' }}
>
Choose or create a project workspace
Projects
</Typography>
</ListItemContent>
</MenuItem>
@@ -266,12 +282,13 @@ const ProjectSelector = ({
icon: 'FolderOpen',
})
}
onMouseEnter={() => setIsKeyboardNavigating(false)}
sx={{
borderRadius: 'var(--joy-radius-sm)',
backgroundColor:
effectiveSelectedProject === 'Default Project'
? 'var(--joy-palette-primary-softBg)'
: selectedIndex === 0 && anchorEl
: selectedIndex === 0 && anchorEl && isKeyboardNavigating
? 'var(--joy-palette-neutral-softHoverBg)'
: 'transparent',
'&:hover': {
@@ -345,12 +362,13 @@ const ProjectSelector = ({
<MenuItem
key={project.id}
onClick={() => handleProjectSelect(project)}
onMouseEnter={() => setIsKeyboardNavigating(false)}
sx={{
borderRadius: 'var(--joy-radius-sm)',
backgroundColor:
effectiveSelectedProject === project.name
? 'var(--joy-palette-primary-softBg)'
: selectedIndex === index && anchorEl
: selectedIndex === index + 1 && anchorEl && isKeyboardNavigating
? 'var(--joy-palette-neutral-softHoverBg)'
: 'transparent',
'&:hover': {
@@ -437,10 +455,11 @@ const ProjectSelector = ({
<MenuItem
onClick={handleAddProjectClick}
onMouseEnter={() => setIsKeyboardNavigating(false)}
sx={{
borderRadius: 'var(--joy-radius-sm)',
backgroundColor:
selectedIndex === defaultProjects.length && anchorEl
selectedIndex === defaultProjects.length + 1 && anchorEl && isKeyboardNavigating
? 'var(--joy-palette-success-softHoverBg)'
: 'transparent',
'&:hover': {
@@ -456,7 +475,6 @@ const ProjectSelector = ({
level='body-sm'
sx={{
fontWeight: 500,
color: 'var(--joy-palette-success-600)',
}}
>
Create New Project
@@ -469,6 +487,41 @@ const ProjectSelector = ({
</Typography>
</ListItemContent>
</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>
<ProjectModal