i18n: extract the remaining task, history, filter and timer screens
Part of #145. Sixteen files that were left out of my earlier PRs because my branch also carried unrelated changes in them. Those are stripped here: each file is your current `develop` version with the string extraction applied on top, nothing else. Covered: the chore action hook and its toasts, activities and smart-insight cards, the chore toolbar, chore history and its card, saved filters, the timer details view, project and label modals, the notification picker, the pending badge, the sync status indicator, the SSE settings and hook, and the profile avatar menu. All namespaces already exist, so `src/i18n/config.js` is untouched. Keys added: 83 `chores`, 31 `common`, 10 `timer`, 9 `history`, 6 `labels`, 5 `projects`, 2 `filters`, 1 `settings`. English only — no translations, no behaviour change. Every t() value is checked to appear character-for-character in the code it replaces, or to match the value already in your dictionary for the same key: 221 call sites, no mismatches. Five files from the same batch are deliberately left out. They build translated labels in module-level constant tables, where the hook cannot be called — `FilterBar`, `RepeatSection`, `RepeatPickerField`, `FilterBuilderContent` and `AdvancedOptionsSection`. Those need the key to travel as data and be resolved inside the component, which is a design change rather than an extraction, so it deserves its own PR.
This commit is contained in:
@@ -70,12 +70,125 @@
|
||||
"paused": "Timer Paused",
|
||||
"reset": "Reset Timer",
|
||||
"delete": "Delete Session"
|
||||
}
|
||||
},
|
||||
"unableUndo": "Failed to undo",
|
||||
"restoreFailed": "Failed to restore"
|
||||
},
|
||||
"toolbar": {
|
||||
"defaultProject": "Default Project"
|
||||
"defaultProject": "Default Project",
|
||||
"everyone": "Everyone",
|
||||
"mine": "Mine",
|
||||
"availableToMe": "Available to me",
|
||||
"others": "Others",
|
||||
"cards": "Cards",
|
||||
"compact": "Compact",
|
||||
"calendar": "Calendar",
|
||||
"filters": "Filters",
|
||||
"viewGroup": "View & Group",
|
||||
"filterNamePlaceholder": "Filter name…",
|
||||
"clearAll": "Clear all",
|
||||
"saveFilter": "Save Filter",
|
||||
"saveAsNew": "Save as New Filter",
|
||||
"groupBy": "Group by",
|
||||
"showTasksFor": "Show tasks for"
|
||||
},
|
||||
"sort": {
|
||||
"assignedToMe": "Assigned to me"
|
||||
"assignedToMe": "Assigned to me",
|
||||
"smart": "Smart"
|
||||
},
|
||||
"thing": {
|
||||
"triggerHint": "Trigger a task when a thing state changes to a desired state",
|
||||
"selectThing": "Select a thing",
|
||||
"enterText": "Enter the text to trigger the task"
|
||||
},
|
||||
"activity": {
|
||||
"showMore": "Show more",
|
||||
"title": "Recent Activities",
|
||||
"loading": "Loading activities...",
|
||||
"status": {
|
||||
"done": "Done"
|
||||
}
|
||||
},
|
||||
"group": {
|
||||
"overdue": "Overdue",
|
||||
"dueToday": "Due Today",
|
||||
"pendingApproval": "Pending Approval",
|
||||
"dueThisWeek": "Due This Week",
|
||||
"noDueDate": "No Due Date"
|
||||
},
|
||||
"insights": {
|
||||
"highPriority": "High Priority"
|
||||
},
|
||||
"labels": {
|
||||
"label": "Labels"
|
||||
},
|
||||
"actionMenu": {
|
||||
"view": "View",
|
||||
"archive": "Archive"
|
||||
},
|
||||
"actions": {
|
||||
"rescheduledTitle": "Task Rescheduled",
|
||||
"rescheduledMessage": "The task due date has been updated successfully.",
|
||||
"unplannedTitle": "Task Unplanned",
|
||||
"unplannedMessage": "The task is now unplanned and has no due date.",
|
||||
"archivedTitle": "Task Archived",
|
||||
"startedTitle": "Task Started",
|
||||
"startedMessage": "The task has been marked as started.",
|
||||
"pausedTitle": "Task Paused",
|
||||
"pausedMessage": "The task has been paused.",
|
||||
"deletedMessage": "The task has been deleted.",
|
||||
"undoable": {
|
||||
"completed": "Task completed"
|
||||
},
|
||||
"completionPending": "Task completion pending",
|
||||
"failCompleteTitle": "Failed to complete",
|
||||
"failStartTitle": "Failed to start",
|
||||
"failPauseTitle": "Failed to pause",
|
||||
"failApproveTitle": "Failed to approve",
|
||||
"failRejectTitle": "Failed to reject",
|
||||
"deletedMessageLong": "The task has been deleted successfully.",
|
||||
"failDeleteTitle": "Failed to delete",
|
||||
"failArchiveTitle": "Failed to archive",
|
||||
"failSkipTitle": "Failed to skip",
|
||||
"movedTitle": "Task Moved",
|
||||
"failMoveTitle": "Failed to move task",
|
||||
"failRescheduleTitle": "Failed to reschedule",
|
||||
"nudgeSentTitle": "Nudge Sent!",
|
||||
"nudgeFailed": "Failed to send nudge",
|
||||
"failNudgeTitle": "Failed to Send Nudge",
|
||||
"bulk": {
|
||||
"completeTitle": "Complete Tasks",
|
||||
"completedTitle": "✅ Tasks Completed",
|
||||
"completeFailedTitle": "Bulk Complete Failed",
|
||||
"archiveTitle": "Archive Tasks",
|
||||
"archivedTitle": "📦 Tasks Archived",
|
||||
"archiveFailedTitle": "Bulk Archive Failed",
|
||||
"deleteTitle": "Delete Tasks",
|
||||
"skipTitle": "Skip Tasks",
|
||||
"skippedTitle": "⏭️ Tasks Skipped",
|
||||
"skipFailedTitle": "Bulk Skip Failed"
|
||||
}
|
||||
},
|
||||
"archived": {
|
||||
"restoredTitle": "Task Restored",
|
||||
"restoredMsg": "The task has been restored and is now active.",
|
||||
"deletedTitle": "Task Deleted",
|
||||
"delete": "Delete",
|
||||
"someFailedTitle": "Some Tasks Failed",
|
||||
"unexpectedError": "An unexpected error occurred. Please try again.",
|
||||
"deletedBulkTitle": "🗑️ Tasks Deleted",
|
||||
"bulkDeleteFailTitle": "Bulk Delete Failed"
|
||||
},
|
||||
"edit": {
|
||||
"deleteConfirm": "Are you sure you want to delete this chore?"
|
||||
},
|
||||
"list": {
|
||||
"complete": "Complete"
|
||||
},
|
||||
"multiToolbar": {
|
||||
"skip": "Skip"
|
||||
},
|
||||
"remind": {
|
||||
"title": "Reminders"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -147,5 +147,42 @@
|
||||
"showDetails": "Show error details",
|
||||
"copyToClipboard": "Copy to clipboard",
|
||||
"copied": "Error details copied to clipboard"
|
||||
},
|
||||
"userMenu": {
|
||||
"actAsAnother": "Act as another user",
|
||||
"stopImpersonating": "Stop Impersonating",
|
||||
"returnToAccount": "Return to your account",
|
||||
"accountPrefs": "Account & preferences",
|
||||
"invitePeople": "Invite People",
|
||||
"addMembers": "Add members to your circle",
|
||||
"sidePanelSettings": "Side Panel Settings"
|
||||
},
|
||||
"realtime": {
|
||||
"newTaskTitle": "New Task Created",
|
||||
"connected": "You are now receiving real-time as they happen.",
|
||||
"errorTitle": "Real-time Error",
|
||||
"parseTitle": "Message Error",
|
||||
"parseMessage": "Failed to parse server message",
|
||||
"disabledTitle": "Connection Temporarily Disabled",
|
||||
"failedTitle": "Connection Failed",
|
||||
"stateConnectionError": "Connection error occurred",
|
||||
"stateAuthExpired": "Authentication expired - refreshing token...",
|
||||
"stateTokenRefreshed": "Token refreshed - reconnecting...",
|
||||
"stateRefreshInProgress": "Token refresh in progress - reconnecting soon...",
|
||||
"stateSessionExpired": "Session expired - please log in again",
|
||||
"stateAuthFailed": "Authentication failed - please log in again",
|
||||
"stateAuthError": "Authentication error - please log in again",
|
||||
"stateTimeout": "Connection timeout - reconnecting...",
|
||||
"connectErrorTitle": "Connection Error",
|
||||
"connectErrorMessage": "Failed to establish real-time connection. Please try again."
|
||||
},
|
||||
"removeAll": "Remove all",
|
||||
"apply": "Apply",
|
||||
"cancelAll": "Cancel all",
|
||||
"sync": {
|
||||
"aria": "Open sync and network status",
|
||||
"allSynced": "All changes synced",
|
||||
"willSync": "Will sync when back online",
|
||||
"cancelAll": "Cancel All"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,5 +13,11 @@
|
||||
"nameExists": "A filter with this name already exists",
|
||||
"tasks_one": "{{count}} task",
|
||||
"tasks_other": "{{count}} tasks",
|
||||
"overdue": "{{count}} overdue"
|
||||
"overdue": "{{count}} overdue",
|
||||
"delete": {
|
||||
"title": "Delete Filter"
|
||||
},
|
||||
"empty": {
|
||||
"title": "No saved filters yet"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -76,7 +76,9 @@
|
||||
"hasNotes": "Has Notes",
|
||||
"hasPoints": "Has Points",
|
||||
"timePeriod": "Time Period",
|
||||
"user": "User"
|
||||
"user": "User",
|
||||
"completedBy": "Completed By",
|
||||
"completedAt": "Completed At"
|
||||
},
|
||||
"period": {
|
||||
"days_one": "{{count}} Day",
|
||||
@@ -85,5 +87,26 @@
|
||||
},
|
||||
"activities": {
|
||||
"title": "Activities"
|
||||
},
|
||||
"title": {
|
||||
"summary": "Task Summary",
|
||||
"activity": "Task Activity"
|
||||
},
|
||||
"noResults": {
|
||||
"clear": "Clear filters"
|
||||
},
|
||||
"toast": {
|
||||
"updateQueued": {
|
||||
"title": "History Update Queued"
|
||||
},
|
||||
"updated": {
|
||||
"title": "History Updated"
|
||||
},
|
||||
"deleteQueued": {
|
||||
"title": "History Delete Queued"
|
||||
},
|
||||
"deleted": {
|
||||
"title": "History Deleted"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,5 +5,13 @@
|
||||
"message": "Are you sure you want to delete this label? This will remove the label from all tasks."
|
||||
},
|
||||
"loadError": "Failed to load labels. Please try again.",
|
||||
"blurb": "Manage your labels and organize your tasks effectively. Labels will be automatically shared with your circle if they are used on a shared task."
|
||||
"blurb": "Manage your labels and organize your tasks effectively. Labels will be automatically shared with your circle if they are used on a shared task.",
|
||||
"modal": {
|
||||
"errorEmptyName": "Name cannot be empty",
|
||||
"errorDuplicate": "Label with this name already exists",
|
||||
"errorNoColor": "Please select a color",
|
||||
"saveFailedTitle": "Failed to save label",
|
||||
"saveFailedMessage": "Unable to save label. Please try again.",
|
||||
"name": "Name"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,5 +20,12 @@
|
||||
"createNewDescription": "Add a custom project workspace",
|
||||
"manage": "Manage Projects",
|
||||
"manageDescription": "View, edit, and organize all projects"
|
||||
},
|
||||
"modal": {
|
||||
"errorNameRequired": "Project name is required",
|
||||
"errorUpdate": "Failed to update project",
|
||||
"errorCreate": "Failed to create project",
|
||||
"namePlaceholder": "Enter project name...",
|
||||
"descriptionPlaceholder": "Optional project description..."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -563,5 +563,8 @@
|
||||
"selectOwner": "Select new owner",
|
||||
"confirmPrompt": "Please enter your password and type DELETE to confirm",
|
||||
"typeDelete": "DELETE"
|
||||
},
|
||||
"realtime": {
|
||||
"titleSse": "Real-time Updates (SSE)"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,7 +9,16 @@
|
||||
"sessionDeletedMessage": "Timer session has been deleted successfully.",
|
||||
"sessionDeleteErrorTitle": "Error deleting session",
|
||||
"deleteConfirmTitle": "Delete Timer Session",
|
||||
"deleteConfirmMessage": "Are you sure you want to delete this timer session?"
|
||||
"deleteConfirmMessage": "Are you sure you want to delete this timer session?",
|
||||
"startedTitle": "Timer Started",
|
||||
"startedMessage": "Work session has been started successfully.",
|
||||
"startQueuedTitle": "Start queued",
|
||||
"startFailTitle": "Failed to start timer",
|
||||
"pausedTitle": "Timer Paused",
|
||||
"pausedMessage": "Work session has been paused.",
|
||||
"pauseQueuedTitle": "Pause queued",
|
||||
"pauseFailTitle": "Failed to pause timer",
|
||||
"deleteSessionTitle": "Delete Session"
|
||||
},
|
||||
"loading": "Loading timer data...",
|
||||
"noData": "No timer data found for this chore.",
|
||||
@@ -25,5 +34,6 @@
|
||||
"startTime": "Start Time",
|
||||
"endTime": "End Time",
|
||||
"leaveEmpty": "Leave empty if session is ongoing",
|
||||
"noSessionForChore": "No timer session found for this chore."
|
||||
"noSessionForChore": "No timer session found for this chore.",
|
||||
"saveChanges": "Save Changes"
|
||||
}
|
||||
|
||||
@@ -13,8 +13,10 @@ import { useSSEContext } from '../hooks/useSSEContext'
|
||||
import { useUserProfile } from '../queries/UserQueries'
|
||||
import { isPlusAccount } from '../utils/Helpers'
|
||||
import SSEConnectionStatus from './SSEConnectionStatus'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
const SSESettings = () => {
|
||||
const { t } = useTranslation('settings')
|
||||
const { data: userProfile } = useUserProfile()
|
||||
const {
|
||||
isConnected,
|
||||
@@ -75,7 +77,7 @@ const SSESettings = () => {
|
||||
)}
|
||||
<Box sx={{ flex: 1 }}>
|
||||
<Typography level='title-md'>
|
||||
Real-time Updates (SSE)
|
||||
{t('realtime.titleSse')}
|
||||
{!isPlusAccount(userProfile) && (
|
||||
<Chip variant='soft' color='warning' sx={{ ml: 1 }}>
|
||||
Plus Feature
|
||||
|
||||
@@ -35,8 +35,10 @@ import { apiClient } from '../utils/ApiClient'
|
||||
import { isPlusAccount, resolvePhotoURL } from '../utils/Helpers'
|
||||
import UserModal from '../views/Modals/Inputs/UserModal'
|
||||
import SubscriptionModal from './SubscriptionModal'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
const UserProfileAvatar = () => {
|
||||
const { t } = useTranslation('common')
|
||||
const navigate = useNavigate()
|
||||
const { mode, setMode } = useColorScheme()
|
||||
const { data: userProfile } = useUserProfile()
|
||||
@@ -297,7 +299,7 @@ const UserProfileAvatar = () => {
|
||||
level='body-xs'
|
||||
sx={{ color: 'var(--joy-palette-text-tertiary)' }}
|
||||
>
|
||||
Act as another user
|
||||
{t('userMenu.actAsAnother')}
|
||||
</Typography>
|
||||
</ListItemContent>
|
||||
</MenuItem>
|
||||
@@ -319,13 +321,13 @@ const UserProfileAvatar = () => {
|
||||
</ListItemDecorator>
|
||||
<ListItemContent>
|
||||
<Typography level='body-sm' sx={{ fontWeight: 500 }}>
|
||||
Stop Impersonating
|
||||
{t('userMenu.stopImpersonating')}
|
||||
</Typography>
|
||||
<Typography
|
||||
level='body-xs'
|
||||
sx={{ color: 'var(--joy-palette-text-tertiary)' }}
|
||||
>
|
||||
Return to your account
|
||||
{t('userMenu.returnToAccount')}
|
||||
</Typography>
|
||||
</ListItemContent>
|
||||
</MenuItem>
|
||||
@@ -349,13 +351,13 @@ const UserProfileAvatar = () => {
|
||||
</ListItemDecorator>
|
||||
<ListItemContent>
|
||||
<Typography level='body-sm' sx={{ fontWeight: 500 }}>
|
||||
Settings
|
||||
{t('settings')}
|
||||
</Typography>
|
||||
<Typography
|
||||
level='body-xs'
|
||||
sx={{ color: 'var(--joy-palette-text-tertiary)' }}
|
||||
>
|
||||
Account & preferences
|
||||
{t('userMenu.accountPrefs')}
|
||||
</Typography>
|
||||
</ListItemContent>
|
||||
</MenuItem>
|
||||
@@ -374,13 +376,13 @@ const UserProfileAvatar = () => {
|
||||
</ListItemDecorator>
|
||||
<ListItemContent>
|
||||
<Typography level='body-sm' sx={{ fontWeight: 500 }}>
|
||||
Invite People
|
||||
{t('userMenu.invitePeople')}
|
||||
</Typography>
|
||||
<Typography
|
||||
level='body-xs'
|
||||
sx={{ color: 'var(--joy-palette-text-tertiary)' }}
|
||||
>
|
||||
Add members to your circle
|
||||
{t('userMenu.addMembers')}
|
||||
</Typography>
|
||||
</ListItemContent>
|
||||
</MenuItem>
|
||||
@@ -401,7 +403,7 @@ const UserProfileAvatar = () => {
|
||||
</ListItemDecorator>
|
||||
<ListItemContent>
|
||||
<Typography level='body-sm' sx={{ fontWeight: 500 }}>
|
||||
Side Panel Settings
|
||||
{t('userMenu.sidePanelSettings')}
|
||||
</Typography>
|
||||
<Typography
|
||||
level='body-xs'
|
||||
|
||||
@@ -7,6 +7,7 @@ import { useAlerts } from '../service/AlertsProvider'
|
||||
import { useNotification } from '../service/NotificationProvider'
|
||||
import { apiClient } from '../utils/ApiClient'
|
||||
import { useAuth } from './useAuth.jsx'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
const SSE_STATES = {
|
||||
CONNECTING: 0,
|
||||
@@ -19,6 +20,7 @@ const MAX_RECONNECT_ATTEMPTS = 10 // Circuit breaker limit
|
||||
const CIRCUIT_BREAKER_RESET_TIME = 600000 // 10 minutes
|
||||
|
||||
export const useSSE = () => {
|
||||
const { t } = useTranslation('common')
|
||||
const { isAuthenticated, token } = useAuth()
|
||||
// Only fetch user profile if authenticated - prevents unnecessary API calls on landing page
|
||||
const { data: userProfile } = useUserProfile()
|
||||
@@ -97,7 +99,7 @@ export const useSSE = () => {
|
||||
case 'chore.created':
|
||||
showNotification({
|
||||
type: 'info',
|
||||
title: 'New Task Created',
|
||||
title: t('realtime.newTaskTitle'),
|
||||
message: `${eventData.data.user.displayName} created "${eventData.data.chore.name}"`,
|
||||
duration: 5000,
|
||||
})
|
||||
@@ -242,14 +244,14 @@ export const useSSE = () => {
|
||||
showAlert({
|
||||
type: 'success',
|
||||
color: 'success',
|
||||
message: 'You are now receiving real-time as they happen.',
|
||||
message: t('realtime.connected'),
|
||||
})
|
||||
break
|
||||
|
||||
case 'error':
|
||||
console.error('SSE error event:', eventData.data)
|
||||
showError({
|
||||
title: 'Real-time Error',
|
||||
title: t('realtime.errorTitle'),
|
||||
message:
|
||||
eventData.data.message ||
|
||||
'An error occurred with real-time updates',
|
||||
@@ -262,8 +264,8 @@ export const useSSE = () => {
|
||||
} catch (err) {
|
||||
console.error('Failed to parse SSE message:', err)
|
||||
showError({
|
||||
title: 'Message Error',
|
||||
message: 'Failed to parse server message',
|
||||
title: t('realtime.parseTitle'),
|
||||
message: t('realtime.parseMessage'),
|
||||
})
|
||||
return // Stop processing if JSON parsing fails
|
||||
}
|
||||
@@ -315,7 +317,7 @@ export const useSSE = () => {
|
||||
if (isCircuitBreakerOpen) {
|
||||
console.log('SSE: Circuit breaker is open, preventing connection attempt')
|
||||
showError({
|
||||
title: 'Connection Temporarily Disabled',
|
||||
title: t('realtime.disabledTitle'),
|
||||
message:
|
||||
'Connection blocked due to repeated failures. Please try again later.',
|
||||
})
|
||||
@@ -328,7 +330,7 @@ export const useSSE = () => {
|
||||
)
|
||||
setIsCircuitBreakerOpen(true)
|
||||
showError({
|
||||
title: 'Connection Failed',
|
||||
title: t('realtime.failedTitle'),
|
||||
message:
|
||||
'Maximum connection attempts reached. SSE disabled for 10 minutes.',
|
||||
})
|
||||
@@ -378,7 +380,7 @@ export const useSSE = () => {
|
||||
const ticket = await fetchSSETicket()
|
||||
if (!ticket) {
|
||||
console.error('SSE: Failed to obtain connection ticket')
|
||||
setError('Connection error occurred')
|
||||
setError(t('realtime.stateConnectionError'))
|
||||
setConnectionState(SSE_STATES.CLOSED)
|
||||
scheduleReconnect(
|
||||
RECONNECT_INTERVALS[
|
||||
@@ -511,7 +513,7 @@ export const useSSE = () => {
|
||||
|
||||
if (is401Error) {
|
||||
console.log('SSE 401 error detected, attempting token refresh...')
|
||||
setError('Authentication expired - refreshing token...')
|
||||
setError(t('realtime.stateAuthExpired'))
|
||||
|
||||
try {
|
||||
const refreshResult = await apiClient.refreshToken()
|
||||
@@ -520,7 +522,7 @@ export const useSSE = () => {
|
||||
console.log(
|
||||
'Token refreshed successfully, retrying SSE connection...',
|
||||
)
|
||||
setError('Token refreshed - reconnecting...')
|
||||
setError(t('realtime.stateTokenRefreshed'))
|
||||
|
||||
if (apiClient.failedQueue && apiClient.failedQueue.length > 0) {
|
||||
console.log(
|
||||
@@ -553,7 +555,7 @@ export const useSSE = () => {
|
||||
console.log(
|
||||
'SSE: Token refresh in progress by another request, waiting...',
|
||||
)
|
||||
setError('Token refresh in progress - reconnecting soon...')
|
||||
setError(t('realtime.stateRefreshInProgress'))
|
||||
|
||||
reconnectAttemptsRef.current = 0
|
||||
|
||||
@@ -572,23 +574,23 @@ export const useSSE = () => {
|
||||
return
|
||||
} else if (refreshResult.error === 'Refresh token expired') {
|
||||
console.error('Refresh token expired, user must login again')
|
||||
setError('Session expired - please log in again')
|
||||
setError(t('realtime.stateSessionExpired'))
|
||||
return
|
||||
} else {
|
||||
console.error('Token refresh failed:', refreshResult.error)
|
||||
setError('Authentication failed - please log in again')
|
||||
setError(t('realtime.stateAuthFailed'))
|
||||
return
|
||||
}
|
||||
} catch (refreshError) {
|
||||
console.error('Token refresh error:', refreshError)
|
||||
setError('Authentication error - please log in again')
|
||||
setError(t('realtime.stateAuthError'))
|
||||
return
|
||||
}
|
||||
} else if (isTimeoutError) {
|
||||
console.log('SSE timeout detected, attempting reconnection...')
|
||||
setError('Connection timeout - reconnecting...')
|
||||
setError(t('realtime.stateTimeout'))
|
||||
} else {
|
||||
setError('Connection error occurred')
|
||||
setError(t('realtime.stateConnectionError'))
|
||||
}
|
||||
|
||||
// Schedule reconnect for non-401 errors
|
||||
@@ -618,8 +620,8 @@ export const useSSE = () => {
|
||||
} catch (err) {
|
||||
console.error('Failed to create SSE connection:', err)
|
||||
showError({
|
||||
title: 'Connection Error',
|
||||
message: 'Failed to establish real-time connection. Please try again.',
|
||||
title: t('realtime.connectErrorTitle'),
|
||||
message: t('realtime.connectErrorMessage'),
|
||||
})
|
||||
setConnectionState(SSE_STATES.CLOSED)
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
} from '@mui/joy'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
const isValidTrigger = (thing, condition, triggerState) => {
|
||||
const newErrors = {}
|
||||
if (!thing || !triggerState) {
|
||||
@@ -54,6 +55,7 @@ const ThingTriggerSection = ({
|
||||
selected,
|
||||
isAttepmtingToSave,
|
||||
}) => {
|
||||
const { t } = useTranslation('chores')
|
||||
const [selectedThing, setSelectedThing] = useState(null)
|
||||
const [condition, setCondition] = useState(null)
|
||||
const [triggerState, setTriggerState] = useState(null)
|
||||
@@ -85,7 +87,7 @@ const ThingTriggerSection = ({
|
||||
return (
|
||||
<Card sx={{ mt: 1 }}>
|
||||
<Typography level='h5'>
|
||||
Trigger a task when a thing state changes to a desired state
|
||||
{t('thing.triggerHint')}
|
||||
</Typography>
|
||||
{things?.length === 0 && (
|
||||
<Typography level='body-sm'>
|
||||
@@ -135,7 +137,7 @@ const ThingTriggerSection = ({
|
||||
</Box>
|
||||
)}
|
||||
renderInput={params => (
|
||||
<TextField {...params} label='Select a thing' />
|
||||
<TextField {...params} label={t('thing.selectThing')} />
|
||||
)}
|
||||
/>
|
||||
</FormControl>
|
||||
@@ -215,7 +217,7 @@ const ThingTriggerSection = ({
|
||||
<Input
|
||||
value={triggerState}
|
||||
onChange={e => setTriggerState(e.target.value)}
|
||||
label='Enter the text to trigger the task'
|
||||
label={t('thing.enterText')}
|
||||
/>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
@@ -31,8 +31,10 @@ import { useChores, useChoresHistory } from '../../queries/ChoreQueries'
|
||||
import { useCircleMembers } from '../../queries/UserQueries'
|
||||
import { resolvePhotoURL } from '../../utils/Helpers'
|
||||
import NoteViewerModal from '../Modals/Inputs/NoteViewerModal'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
const ActivityItem = ({ activity, members, onViewNote }) => {
|
||||
const { t } = useTranslation('chores')
|
||||
// Find the member who completed the activity
|
||||
const completedByMember = members?.find(
|
||||
member => member.userId === activity.completedBy,
|
||||
@@ -243,7 +245,7 @@ const ActivityItem = ({ activity, members, onViewNote }) => {
|
||||
display: 'inline-block',
|
||||
}}
|
||||
>
|
||||
Show more
|
||||
{t('activity.showMore')}
|
||||
</Link>
|
||||
)}
|
||||
</Box>
|
||||
@@ -272,7 +274,7 @@ const groupActivitiesByDate = activities => {
|
||||
return groups
|
||||
}
|
||||
|
||||
const ActivitiesCard = ({ title = 'Recent Activities' }) => {
|
||||
const ActivitiesCard = ({ title = t('activity.title') }) => {
|
||||
const [noteViewerConfig, setNoteViewerConfig] = useState({ isOpen: false })
|
||||
|
||||
// Use hooks to fetch data
|
||||
@@ -332,7 +334,7 @@ const ActivitiesCard = ({ title = 'Recent Activities' }) => {
|
||||
}}
|
||||
>
|
||||
<Typography level='body-sm' color='neutral'>
|
||||
Loading activities...
|
||||
{t('activity.loading')}
|
||||
</Typography>
|
||||
</Box>
|
||||
</Sheet>
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
import { Box, Button, Chip, Sheet, Typography } from '@mui/joy'
|
||||
import { useMemo } from 'react'
|
||||
import { TASK_COLOR } from '../../utils/Colors'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
// Static insight filter definitions – used for URL restoration
|
||||
export const INSIGHT_FILTER_DEFS = {
|
||||
@@ -62,6 +63,7 @@ const SmartInsightsCard = ({
|
||||
clearTempFilter,
|
||||
tempFilter,
|
||||
}) => {
|
||||
const { t } = useTranslation('chores')
|
||||
// Detect all possible insights from chores
|
||||
const insights = useMemo(() => {
|
||||
if (!chores || chores.length === 0) return []
|
||||
@@ -84,7 +86,7 @@ const SmartInsightsCard = ({
|
||||
id: 'overdue',
|
||||
priority: 1,
|
||||
count: overdueTasks.length,
|
||||
title: 'Overdue',
|
||||
title: t('group.overdue'),
|
||||
description: `${overdueTasks.length} ${overdueTasks.length === 1 ? 'task is' : 'tasks are'} overdue`,
|
||||
color: 'danger',
|
||||
bgColor: TASK_COLOR.OVERDUE,
|
||||
@@ -113,7 +115,7 @@ const SmartInsightsCard = ({
|
||||
id: 'due-today',
|
||||
priority: 2,
|
||||
count: dueTodayTasks.length,
|
||||
title: 'Due Today',
|
||||
title: t('group.dueToday'),
|
||||
description: `${dueTodayTasks.length} ${dueTodayTasks.length === 1 ? 'task' : 'tasks'} due by end of day`,
|
||||
color: 'warning',
|
||||
bgColor: '#FFA500',
|
||||
@@ -138,7 +140,7 @@ const SmartInsightsCard = ({
|
||||
id: 'pending-approval',
|
||||
priority: 3,
|
||||
count: pendingApprovalTasks.length,
|
||||
title: 'Pending Approval',
|
||||
title: t('group.pendingApproval'),
|
||||
description: `${pendingApprovalTasks.length} ${pendingApprovalTasks.length === 1 ? 'task awaits' : 'tasks await'} approval`,
|
||||
color: 'neutral',
|
||||
bgColor: TASK_COLOR.PENDING_REVIEW,
|
||||
@@ -167,7 +169,7 @@ const SmartInsightsCard = ({
|
||||
id: 'due-this-week',
|
||||
priority: 4,
|
||||
count: dueThisWeekTasks.length,
|
||||
title: 'Due This Week',
|
||||
title: t('group.dueThisWeek'),
|
||||
description: `${dueThisWeekTasks.length} ${dueThisWeekTasks.length === 1 ? 'task' : 'tasks'} due in the next 7 days`,
|
||||
color: 'primary',
|
||||
bgColor: TASK_COLOR.IN_PROGRESS,
|
||||
@@ -194,7 +196,7 @@ const SmartInsightsCard = ({
|
||||
id: 'high-priority',
|
||||
priority: 5,
|
||||
count: highPriorityTasks.length,
|
||||
title: 'High Priority',
|
||||
title: t('insights.highPriority'),
|
||||
description: `${highPriorityTasks.length} ${highPriorityTasks.length === 1 ? 'task requires' : 'tasks require'} immediate attention`,
|
||||
color: 'warning',
|
||||
bgColor: '#FF6B6B',
|
||||
@@ -221,7 +223,7 @@ const SmartInsightsCard = ({
|
||||
id: 'no-due-date',
|
||||
priority: 6,
|
||||
count: noDueDateTasks.length,
|
||||
title: 'No Due Date',
|
||||
title: t('group.noDueDate'),
|
||||
description: `${noDueDateTasks.length} ${noDueDateTasks.length === 1 ? 'task needs' : 'tasks need'} a deadline`,
|
||||
color: 'neutral',
|
||||
bgColor: '#9E9E9E',
|
||||
|
||||
@@ -63,6 +63,7 @@ import FilterBuilderContent, {
|
||||
import SearchBar from './SearchBar'
|
||||
import ProjectSelector from '../../components/ProjectSelector'
|
||||
import CustomFilterChips from './CustomFilterChips'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
// ─── sub-components for the Display sheet ────────────────────────────────────
|
||||
|
||||
@@ -225,6 +226,7 @@ const ChoreToolbar = ({
|
||||
searchInputRef,
|
||||
showKeyboardShortcuts,
|
||||
}) => {
|
||||
const { t } = useTranslation('chores')
|
||||
const [filterSheetOpen, setFilterSheetOpen] = useState(false)
|
||||
const [displaySheetOpen, setDisplaySheetOpen] = useState(false)
|
||||
const [localSelections, setLocalSelections] = useState(defaultSelections())
|
||||
@@ -253,7 +255,7 @@ const ChoreToolbar = ({
|
||||
createdBy: 'Created By',
|
||||
status: 'Status',
|
||||
priority: 'Priority',
|
||||
label: 'Labels',
|
||||
label: t('labels.label'),
|
||||
project: 'Project',
|
||||
dueDate: 'Due Date',
|
||||
points: 'Points',
|
||||
@@ -513,33 +515,33 @@ const ChoreToolbar = ({
|
||||
const filterActive = activeFilterId != null || tempConditionCount > 0
|
||||
|
||||
const groupByOptions = [
|
||||
{ value: 'default', label: 'Smart' },
|
||||
{ value: 'due_date', label: 'Due Date' },
|
||||
{ value: 'priority', label: 'Priority' },
|
||||
{ value: 'labels', label: 'Labels' },
|
||||
{ value: 'default', label: t('sort.smart') },
|
||||
{ value: 'due_date', label: t('dueDate') },
|
||||
{ value: 'priority', label: t('priority') },
|
||||
{ value: 'labels', label: t('labels.label') },
|
||||
]
|
||||
|
||||
const assigneeOptions = [
|
||||
{ value: 'anyone', label: 'Everyone' },
|
||||
{ value: 'assigned_to_me', label: 'Mine' },
|
||||
{ value: 'available_for_me', label: 'Available to me' },
|
||||
{ value: 'assigned_to_others', label: 'Others' },
|
||||
{ value: 'anyone', label: t('toolbar.everyone') },
|
||||
{ value: 'assigned_to_me', label: t('toolbar.mine') },
|
||||
{ value: 'available_for_me', label: t('toolbar.availableToMe') },
|
||||
{ value: 'assigned_to_others', label: t('toolbar.others') },
|
||||
]
|
||||
|
||||
const viewOptions = [
|
||||
{
|
||||
value: 'default',
|
||||
label: 'Cards',
|
||||
label: t('toolbar.cards'),
|
||||
icon: <ViewAgenda sx={{ fontSize: 16 }} />,
|
||||
},
|
||||
{
|
||||
value: 'compact',
|
||||
label: 'Compact',
|
||||
label: t('toolbar.compact'),
|
||||
icon: <ViewComfy sx={{ fontSize: 16 }} />,
|
||||
},
|
||||
{
|
||||
value: 'calendar',
|
||||
label: 'Calendar',
|
||||
label: t('toolbar.calendar'),
|
||||
icon: <CalendarMonth sx={{ fontSize: 16 }} />,
|
||||
},
|
||||
]
|
||||
@@ -577,8 +579,8 @@ const ChoreToolbar = ({
|
||||
size='sm'
|
||||
sx={{ height: 32, width: 32, borderRadius: '50%' }}
|
||||
onClick={openFilterSheet}
|
||||
aria-label='Filters'
|
||||
title='Filters'
|
||||
aria-label={t('toolbar.filters')}
|
||||
title={t('toolbar.filters')}
|
||||
>
|
||||
<FilterList />
|
||||
</IconButton>
|
||||
@@ -602,7 +604,7 @@ const ChoreToolbar = ({
|
||||
sx={{ height: 32, width: 32, borderRadius: '50%' }}
|
||||
onClick={() => setDisplaySheetOpen(true)}
|
||||
aria-label='View and group options'
|
||||
title='View & Group'
|
||||
title={t('toolbar.viewGroup')}
|
||||
>
|
||||
{viewMode === 'calendar' ? (
|
||||
<CalendarMonth />
|
||||
@@ -708,7 +710,7 @@ const ChoreToolbar = ({
|
||||
>
|
||||
<Input
|
||||
size='sm'
|
||||
placeholder='Filter name…'
|
||||
placeholder={t('toolbar.filterNamePlaceholder')}
|
||||
value={saveFilterName}
|
||||
onChange={e => setSaveFilterName(e.target.value)}
|
||||
onKeyDown={e => e.key === 'Enter' && handleSaveFilter()}
|
||||
@@ -720,7 +722,7 @@ const ChoreToolbar = ({
|
||||
onClick={handleSaveFilter}
|
||||
disabled={!saveFilterName.trim()}
|
||||
>
|
||||
Save
|
||||
{t('common:save')}
|
||||
</Button>
|
||||
<Button
|
||||
size='sm'
|
||||
@@ -728,7 +730,7 @@ const ChoreToolbar = ({
|
||||
color='neutral'
|
||||
onClick={() => setSavingFilter(false)}
|
||||
>
|
||||
Cancel
|
||||
{t('choreView.cancel')}
|
||||
</Button>
|
||||
</Box>
|
||||
) : (
|
||||
@@ -752,7 +754,7 @@ const ChoreToolbar = ({
|
||||
setFilterSheetOpen(false)
|
||||
}}
|
||||
>
|
||||
Clear all
|
||||
{t('toolbar.clearAll')}
|
||||
</Button>
|
||||
|
||||
{activeConditions.length > 0 ? (
|
||||
@@ -784,7 +786,7 @@ const ChoreToolbar = ({
|
||||
disabled={!editingSavedFilter}
|
||||
>
|
||||
<Save sx={{ fontSize: 16, mr: 1 }} />
|
||||
Save Filter
|
||||
{t('toolbar.saveFilter')}
|
||||
</MenuItem>
|
||||
<MenuItem
|
||||
onClick={() => {
|
||||
@@ -798,7 +800,7 @@ const ChoreToolbar = ({
|
||||
}}
|
||||
>
|
||||
<Save sx={{ fontSize: 16, mr: 1 }} />
|
||||
Save as New Filter
|
||||
{t('toolbar.saveAsNew')}
|
||||
</MenuItem>
|
||||
</Menu>
|
||||
</Dropdown>
|
||||
@@ -812,7 +814,7 @@ const ChoreToolbar = ({
|
||||
}}
|
||||
sx={{ minWidth: 140 }}
|
||||
>
|
||||
Done
|
||||
{t('activity.status.done')}
|
||||
</Button>
|
||||
)}
|
||||
</Box>
|
||||
@@ -893,13 +895,13 @@ const ChoreToolbar = ({
|
||||
onClick={() => setDisplaySheetOpen(false)}
|
||||
sx={{ minWidth: 140 }}
|
||||
>
|
||||
Done
|
||||
{t('activity.status.done')}
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0 }}>
|
||||
{/* View section */}
|
||||
<SectionHeader label='View' />
|
||||
<SectionHeader label={t('actionMenu.view')} />
|
||||
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 1 }}>
|
||||
{viewOptions.map(opt => (
|
||||
<Chip
|
||||
@@ -932,7 +934,7 @@ const ChoreToolbar = ({
|
||||
{/* Group by section */}
|
||||
<SectionHeader
|
||||
icon={<Sort />}
|
||||
label='Group by'
|
||||
label={t('toolbar.groupBy')}
|
||||
badge={
|
||||
selectedGroupBy !== 'default'
|
||||
? groupByOptions.find(o => o.value === selectedGroupBy)?.label
|
||||
@@ -963,7 +965,7 @@ const ChoreToolbar = ({
|
||||
<Divider sx={{ my: 2.5 }} />
|
||||
<SectionHeader
|
||||
icon={<FilterList />}
|
||||
label='Show tasks for'
|
||||
label={t('toolbar.showTasksFor')}
|
||||
badge={
|
||||
selectedAssigneeFilter !== 'anyone'
|
||||
? assigneeOptions.find(o => o.value === selectedAssigneeFilter)
|
||||
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
} from '../../../utils/Fetcher'
|
||||
import { offlineDB } from '../../../utils/OfflineDB'
|
||||
import { isOfflineFeatureEnabled } from '../../../utils/OfflineFeatureToggle'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
// Effectively "can this action be queued offline?" — requires the offline
|
||||
// feature, otherwise there is no command queue to replay it later.
|
||||
@@ -47,6 +48,7 @@ export const useChoreActions = ({
|
||||
getSelectedChoresData,
|
||||
clearSelection,
|
||||
}) => {
|
||||
const { t } = useTranslation('chores')
|
||||
const queryClient = useQueryClient()
|
||||
const archiveChore = useArchiveChore()
|
||||
const unarchiveChore = useUnArchiveChore()
|
||||
@@ -102,16 +104,16 @@ export const useChoreActions = ({
|
||||
skipped: 'Task skip has been undone.',
|
||||
}
|
||||
showUndo({
|
||||
title: 'Undo Successful',
|
||||
title: t('choreView.undoSuccessful'),
|
||||
message: undoMessages[event],
|
||||
})
|
||||
} else {
|
||||
throw new Error('Failed to undo')
|
||||
throw new Error(t('choreView.unableUndo'))
|
||||
}
|
||||
} catch (error) {
|
||||
showError({
|
||||
title: 'Undo Failed',
|
||||
message: 'Unable to undo the action. Please try again.',
|
||||
title: t('choreView.undoFailed'),
|
||||
message: t('choreView.undoFailedMessage'),
|
||||
})
|
||||
}
|
||||
},
|
||||
@@ -122,39 +124,39 @@ export const useChoreActions = ({
|
||||
const notifications = {
|
||||
rescheduled: {
|
||||
type: 'success',
|
||||
title: 'Task Rescheduled',
|
||||
message: 'The task due date has been updated successfully.',
|
||||
title: t('actions.rescheduledTitle'),
|
||||
message: t('actions.rescheduledMessage'),
|
||||
},
|
||||
'due-date-removed': {
|
||||
type: 'success',
|
||||
title: 'Task Unplanned',
|
||||
message: 'The task is now unplanned and has no due date.',
|
||||
title: t('actions.unplannedTitle'),
|
||||
message: t('actions.unplannedMessage'),
|
||||
},
|
||||
unarchive: {
|
||||
type: 'success',
|
||||
title: 'Task Restored',
|
||||
message: 'The task has been restored and is now active.',
|
||||
title: t('archived.restoredTitle'),
|
||||
message: t('archived.restoredMsg'),
|
||||
},
|
||||
archive: {
|
||||
type: 'success',
|
||||
title: 'Task Archived',
|
||||
title: t('actions.archivedTitle'),
|
||||
message:
|
||||
'The task has been archived and hidden from the active list.',
|
||||
},
|
||||
started: {
|
||||
type: 'success',
|
||||
title: 'Task Started',
|
||||
message: 'The task has been marked as started.',
|
||||
title: t('actions.startedTitle'),
|
||||
message: t('actions.startedMessage'),
|
||||
},
|
||||
paused: {
|
||||
type: 'warning',
|
||||
title: 'Task Paused',
|
||||
message: 'The task has been paused.',
|
||||
title: t('actions.pausedTitle'),
|
||||
message: t('actions.pausedMessage'),
|
||||
},
|
||||
deleted: {
|
||||
type: 'success',
|
||||
title: 'Task Deleted',
|
||||
message: 'The task has been deleted.',
|
||||
title: t('archived.deletedTitle'),
|
||||
message: t('actions.deletedMessage'),
|
||||
},
|
||||
}
|
||||
|
||||
@@ -203,21 +205,21 @@ export const useChoreActions = ({
|
||||
}
|
||||
})
|
||||
showSuccess({
|
||||
message: 'Task completed',
|
||||
message: t('actions.undoable.completed'),
|
||||
undoAction: async () => {
|
||||
try {
|
||||
const undoResponse = await UndoChoreAction(chore.id)
|
||||
if (undoResponse.ok) {
|
||||
queryClient.invalidateQueries(['chores'])
|
||||
showUndo({
|
||||
title: 'Undo Successful',
|
||||
message: 'Task completion has been undone.',
|
||||
title: t('choreView.undoSuccessful'),
|
||||
message: t('choreView.taskCompletionUndone'),
|
||||
})
|
||||
} else throw new Error('Failed to undo')
|
||||
} else throw new Error(t('choreView.unableUndo'))
|
||||
} catch {
|
||||
showError({
|
||||
title: 'Undo Failed',
|
||||
message: 'Unable to undo the action. Please try again.',
|
||||
title: t('choreView.undoFailed'),
|
||||
message: t('choreView.undoFailedMessage'),
|
||||
})
|
||||
}
|
||||
},
|
||||
@@ -254,7 +256,7 @@ export const useChoreActions = ({
|
||||
})
|
||||
queryClient.invalidateQueries({ queryKey: ['pendingCommands'] })
|
||||
showSuccess({
|
||||
title: 'Task completion pending',
|
||||
title: t('actions.completionPending'),
|
||||
message:
|
||||
"You're offline — completion will sync when back online",
|
||||
undoAction: async () => {
|
||||
@@ -266,7 +268,7 @@ export const useChoreActions = ({
|
||||
})
|
||||
} else {
|
||||
showError({
|
||||
title: 'Failed to complete',
|
||||
title: t('actions.failCompleteTitle'),
|
||||
message: error?.message || 'Unable to complete chore',
|
||||
})
|
||||
}
|
||||
@@ -318,7 +320,7 @@ export const useChoreActions = ({
|
||||
})
|
||||
} else {
|
||||
showError({
|
||||
title: 'Failed to start',
|
||||
title: t('actions.failStartTitle'),
|
||||
message: error?.message || 'Unable to start chore',
|
||||
})
|
||||
}
|
||||
@@ -371,7 +373,7 @@ export const useChoreActions = ({
|
||||
})
|
||||
} else {
|
||||
showError({
|
||||
title: 'Failed to pause',
|
||||
title: t('actions.failPauseTitle'),
|
||||
message: error?.message || 'Unable to pause chore',
|
||||
})
|
||||
}
|
||||
@@ -388,7 +390,7 @@ export const useChoreActions = ({
|
||||
}
|
||||
} catch (error) {
|
||||
showError({
|
||||
title: 'Failed to approve',
|
||||
title: t('actions.failApproveTitle'),
|
||||
message: error.message || 'Unable to approve chore',
|
||||
})
|
||||
}
|
||||
@@ -403,7 +405,7 @@ export const useChoreActions = ({
|
||||
}
|
||||
} catch (error) {
|
||||
showError({
|
||||
title: 'Failed to reject',
|
||||
title: t('actions.failRejectTitle'),
|
||||
message: error.message || 'Unable to reject chore',
|
||||
})
|
||||
}
|
||||
@@ -412,10 +414,10 @@ export const useChoreActions = ({
|
||||
case 'delete':
|
||||
setConfirmModelConfig({
|
||||
isOpen: true,
|
||||
title: 'Delete Chore',
|
||||
confirmText: 'Delete',
|
||||
cancelText: 'Cancel',
|
||||
message: 'Are you sure you want to delete this chore?',
|
||||
title: t('deleteChore'),
|
||||
confirmText: t('archived.delete'),
|
||||
cancelText: t('choreView.cancel'),
|
||||
message: t('edit.deleteConfirm'),
|
||||
onClose: async isConfirmed => {
|
||||
if (isConfirmed === true) {
|
||||
try {
|
||||
@@ -429,8 +431,8 @@ export const useChoreActions = ({
|
||||
setFilteredChores(newFilteredChores)
|
||||
queryClient.invalidateQueries(['chores'])
|
||||
showSuccess({
|
||||
title: 'Task Deleted',
|
||||
message: 'The task has been deleted successfully.',
|
||||
title: t('archived.deletedTitle'),
|
||||
message: t('actions.deletedMessageLong'),
|
||||
})
|
||||
}
|
||||
} catch (error) {
|
||||
@@ -461,7 +463,7 @@ export const useChoreActions = ({
|
||||
})
|
||||
} else {
|
||||
showError({
|
||||
title: 'Failed to delete',
|
||||
title: t('actions.failDeleteTitle'),
|
||||
message: error?.message || 'Unable to delete chore',
|
||||
})
|
||||
}
|
||||
@@ -515,7 +517,7 @@ export const useChoreActions = ({
|
||||
resolve()
|
||||
} else {
|
||||
showError({
|
||||
title: 'Failed to archive',
|
||||
title: t('actions.failArchiveTitle'),
|
||||
message: error.message || 'Unable to archive chore',
|
||||
})
|
||||
reject(error)
|
||||
@@ -563,7 +565,7 @@ export const useChoreActions = ({
|
||||
resolve()
|
||||
} else {
|
||||
showError({
|
||||
title: 'Failed to restore',
|
||||
title: t('choreView.restoreFailed'),
|
||||
message: error.message || 'Unable to restore chore',
|
||||
})
|
||||
reject(error)
|
||||
@@ -604,7 +606,7 @@ export const useChoreActions = ({
|
||||
})
|
||||
} else {
|
||||
showError({
|
||||
title: 'Failed to skip',
|
||||
title: t('actions.failSkipTitle'),
|
||||
message: error?.message || 'Unable to skip chore',
|
||||
})
|
||||
}
|
||||
@@ -679,13 +681,13 @@ export const useChoreActions = ({
|
||||
if (response.ok) {
|
||||
updateChoreInState(updatedChore, 'moved-to-project')
|
||||
showSuccess({
|
||||
title: 'Task Moved',
|
||||
title: t('actions.movedTitle'),
|
||||
message: `Task moved to ${project?.name || 'Default Project'}.`,
|
||||
})
|
||||
}
|
||||
} catch (error) {
|
||||
showError({
|
||||
title: 'Failed to move task',
|
||||
title: t('actions.failMoveTitle'),
|
||||
message: error?.message || 'Unable to move task to project',
|
||||
})
|
||||
}
|
||||
@@ -763,7 +765,7 @@ export const useChoreActions = ({
|
||||
})
|
||||
} else {
|
||||
showError({
|
||||
title: 'Failed to reschedule',
|
||||
title: t('actions.failRescheduleTitle'),
|
||||
message: error.message || 'Unable to update due date',
|
||||
})
|
||||
}
|
||||
@@ -849,15 +851,15 @@ export const useChoreActions = ({
|
||||
if (response.ok) {
|
||||
const data = await response.json()
|
||||
showSuccess({
|
||||
title: 'Nudge Sent!',
|
||||
title: t('actions.nudgeSentTitle'),
|
||||
message: data.message || 'Nudge sent successfully',
|
||||
})
|
||||
} else {
|
||||
throw new Error('Failed to send nudge')
|
||||
throw new Error(t('actions.nudgeFailed'))
|
||||
}
|
||||
} catch (error) {
|
||||
showError({
|
||||
title: 'Failed to Send Nudge',
|
||||
title: t('actions.failNudgeTitle'),
|
||||
message: error.message || 'Unable to send nudge at this time',
|
||||
})
|
||||
} finally {
|
||||
@@ -873,9 +875,9 @@ export const useChoreActions = ({
|
||||
|
||||
setConfirmModelConfig({
|
||||
isOpen: true,
|
||||
title: 'Complete Tasks',
|
||||
confirmText: 'Complete',
|
||||
cancelText: 'Cancel',
|
||||
title: t('actions.bulk.completeTitle'),
|
||||
confirmText: t('list.complete'),
|
||||
cancelText: t('choreView.cancel'),
|
||||
message: `Mark ${selectedData.length} task${selectedData.length > 1 ? 's' : ''} as completed?`,
|
||||
onClose: async isConfirmed => {
|
||||
if (isConfirmed === true) {
|
||||
@@ -901,14 +903,14 @@ export const useChoreActions = ({
|
||||
|
||||
if (completedTasks.length > 0) {
|
||||
showSuccess({
|
||||
title: '✅ Tasks Completed',
|
||||
title: t('actions.bulk.completedTitle'),
|
||||
message: `Successfully completed ${completedTasks.length} task${completedTasks.length > 1 ? 's' : ''}.`,
|
||||
})
|
||||
}
|
||||
|
||||
if (failedTasks.length > 0) {
|
||||
showError({
|
||||
title: 'Some Tasks Failed',
|
||||
title: t('archived.someFailedTitle'),
|
||||
message: `${failedTasks.length} task${failedTasks.length > 1 ? 's' : ''} could not be completed.`,
|
||||
})
|
||||
}
|
||||
@@ -917,8 +919,8 @@ export const useChoreActions = ({
|
||||
clearSelection()
|
||||
} catch (error) {
|
||||
showError({
|
||||
title: 'Bulk Complete Failed',
|
||||
message: 'An unexpected error occurred. Please try again.',
|
||||
title: t('actions.bulk.completeFailedTitle'),
|
||||
message: t('archived.unexpectedError'),
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -941,9 +943,9 @@ export const useChoreActions = ({
|
||||
|
||||
setConfirmModelConfig({
|
||||
isOpen: true,
|
||||
title: 'Archive Tasks',
|
||||
confirmText: 'Archive',
|
||||
cancelText: 'Cancel',
|
||||
title: t('actions.bulk.archiveTitle'),
|
||||
confirmText: t('actionMenu.archive'),
|
||||
cancelText: t('choreView.cancel'),
|
||||
message: `Archive ${selectedData.length} task${selectedData.length > 1 ? 's' : ''}?`,
|
||||
onClose: async isConfirmed => {
|
||||
if (isConfirmed === true) {
|
||||
@@ -972,13 +974,13 @@ export const useChoreActions = ({
|
||||
}
|
||||
if (archivedTasks.length > 0) {
|
||||
showSuccess({
|
||||
title: '📦 Tasks Archived',
|
||||
title: t('actions.bulk.archivedTitle'),
|
||||
message: `Successfully archived ${archivedTasks.length} task${archivedTasks.length > 1 ? 's' : ''}.`,
|
||||
})
|
||||
}
|
||||
if (failedTasks.length > 0) {
|
||||
showError({
|
||||
title: 'Some Tasks Failed',
|
||||
title: t('archived.someFailedTitle'),
|
||||
message: `${failedTasks.length} task${failedTasks.length > 1 ? 's' : ''} could not be archived.`,
|
||||
})
|
||||
}
|
||||
@@ -986,8 +988,8 @@ export const useChoreActions = ({
|
||||
clearSelection()
|
||||
} catch (error) {
|
||||
showError({
|
||||
title: 'Bulk Archive Failed',
|
||||
message: 'An unexpected error occurred. Please try again.',
|
||||
title: t('actions.bulk.archiveFailedTitle'),
|
||||
message: t('archived.unexpectedError'),
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1012,9 +1014,9 @@ export const useChoreActions = ({
|
||||
|
||||
setConfirmModelConfig({
|
||||
isOpen: true,
|
||||
title: 'Delete Tasks',
|
||||
confirmText: 'Delete',
|
||||
cancelText: 'Cancel',
|
||||
title: t('actions.bulk.deleteTitle'),
|
||||
confirmText: t('archived.delete'),
|
||||
cancelText: t('choreView.cancel'),
|
||||
message: `Delete ${selectedData.length} task${selectedData.length > 1 ? 's' : ''}?\n\nThis action cannot be undone.`,
|
||||
onClose: async isConfirmed => {
|
||||
if (isConfirmed === true) {
|
||||
@@ -1033,7 +1035,7 @@ export const useChoreActions = ({
|
||||
|
||||
if (deletedTasks.length > 0) {
|
||||
showSuccess({
|
||||
title: '🗑️ Tasks Deleted',
|
||||
title: t('archived.deletedBulkTitle'),
|
||||
message: `Successfully deleted ${deletedTasks.length} task${deletedTasks.length > 1 ? 's' : ''}.`,
|
||||
})
|
||||
|
||||
@@ -1048,7 +1050,7 @@ export const useChoreActions = ({
|
||||
|
||||
if (failedTasks.length > 0) {
|
||||
showError({
|
||||
title: 'Some Tasks Failed',
|
||||
title: t('archived.someFailedTitle'),
|
||||
message: `${failedTasks.length} task${failedTasks.length > 1 ? 's' : ''} could not be deleted.`,
|
||||
})
|
||||
}
|
||||
@@ -1056,8 +1058,8 @@ export const useChoreActions = ({
|
||||
clearSelection()
|
||||
} catch (error) {
|
||||
showError({
|
||||
title: 'Bulk Delete Failed',
|
||||
message: 'An unexpected error occurred. Please try again.',
|
||||
title: t('archived.bulkDeleteFailTitle'),
|
||||
message: t('archived.unexpectedError'),
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1083,9 +1085,9 @@ export const useChoreActions = ({
|
||||
|
||||
setConfirmModelConfig({
|
||||
isOpen: true,
|
||||
title: 'Skip Tasks',
|
||||
confirmText: 'Skip',
|
||||
cancelText: 'Cancel',
|
||||
title: t('actions.bulk.skipTitle'),
|
||||
confirmText: t('multiToolbar.skip'),
|
||||
cancelText: t('choreView.cancel'),
|
||||
message: `Skip ${selectedData.length} task${selectedData.length > 1 ? 's' : ''} to next due date?`,
|
||||
onClose: async isConfirmed => {
|
||||
if (isConfirmed === true) {
|
||||
@@ -1104,7 +1106,7 @@ export const useChoreActions = ({
|
||||
|
||||
if (skippedTasks.length > 0) {
|
||||
showSuccess({
|
||||
title: '⏭️ Tasks Skipped',
|
||||
title: t('actions.bulk.skippedTitle'),
|
||||
message: `Successfully skipped ${skippedTasks.length} task${skippedTasks.length > 1 ? 's' : ''}.`,
|
||||
undoAction: async () => {
|
||||
try {
|
||||
@@ -1113,13 +1115,13 @@ export const useChoreActions = ({
|
||||
}
|
||||
queryClient.invalidateQueries(['chores'])
|
||||
showUndo({
|
||||
title: 'Undo Successful',
|
||||
title: t('choreView.undoSuccessful'),
|
||||
message: `Undo skip for ${skippedTasks.length} task${skippedTasks.length > 1 ? 's' : ''}.`,
|
||||
})
|
||||
} catch (error) {
|
||||
showError({
|
||||
title: 'Undo Failed',
|
||||
message: 'Unable to undo the action. Please try again.',
|
||||
title: t('choreView.undoFailed'),
|
||||
message: t('choreView.undoFailedMessage'),
|
||||
})
|
||||
}
|
||||
},
|
||||
@@ -1128,7 +1130,7 @@ export const useChoreActions = ({
|
||||
|
||||
if (failedTasks.length > 0) {
|
||||
showError({
|
||||
title: 'Some Tasks Failed',
|
||||
title: t('archived.someFailedTitle'),
|
||||
message: `${failedTasks.length > 1 ? 's' : ''} could not be skipped.`,
|
||||
})
|
||||
}
|
||||
@@ -1137,8 +1139,8 @@ export const useChoreActions = ({
|
||||
clearSelection()
|
||||
} catch (error) {
|
||||
showError({
|
||||
title: 'Bulk Skip Failed',
|
||||
message: 'An unexpected error occurred. Please try again.',
|
||||
title: t('actions.bulk.skipFailedTitle'),
|
||||
message: t('archived.unexpectedError'),
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1190,7 +1192,7 @@ export const useChoreActions = ({
|
||||
}
|
||||
if (failedTasks.length > 0) {
|
||||
showError({
|
||||
title: 'Some Tasks Failed',
|
||||
title: t('archived.someFailedTitle'),
|
||||
message: `${failedTasks.length} task${failedTasks.length > 1 ? 's' : ''} could not be moved.`,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -46,6 +46,7 @@ import {
|
||||
useToggleFilterPin,
|
||||
useUpdateFilter,
|
||||
} from './FilterQueries'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
const FilterCardContent = ({
|
||||
filter,
|
||||
@@ -245,6 +246,7 @@ const FilterCardContent = ({
|
||||
}
|
||||
|
||||
const FilterView = () => {
|
||||
const { t } = useTranslation('filters')
|
||||
const navigate = useNavigate()
|
||||
const { data: userProfile } = useUserProfile()
|
||||
const { data: chores = { res: [] } } = useChores(false)
|
||||
@@ -330,11 +332,11 @@ const FilterView = () => {
|
||||
const filter = savedFilters.find(f => f.id === id)
|
||||
setConfirmationModel({
|
||||
isOpen: true,
|
||||
title: 'Delete Filter',
|
||||
title: t('delete.title'),
|
||||
message: `Are you sure you want to delete "${filter?.name}"? This cannot be undone.`,
|
||||
confirmText: 'Delete',
|
||||
confirmText: t('common:delete'),
|
||||
color: 'danger',
|
||||
cancelText: 'Cancel',
|
||||
cancelText: t('common:cancel'),
|
||||
onClose: confirmed => {
|
||||
if (confirmed === true) {
|
||||
handleDeleteFilter(id)
|
||||
@@ -421,7 +423,7 @@ const FilterView = () => {
|
||||
<EmptyState
|
||||
fullHeight
|
||||
icon={<FilterAlt />}
|
||||
title='No saved filters yet'
|
||||
title={t('empty.title')}
|
||||
description='Save a set of conditions once, like "overdue and assigned to me", and jump straight back to it from anywhere.'
|
||||
primaryAction={{
|
||||
label: 'Create a filter',
|
||||
@@ -490,7 +492,7 @@ const FilterView = () => {
|
||||
>
|
||||
<EditIcon sx={{ fontSize: 20 }} />
|
||||
<Typography level='body-xs' sx={{ mt: 0.5 }}>
|
||||
Edit
|
||||
{t('common:edit')}
|
||||
</Typography>
|
||||
</Box>
|
||||
</SwipeAction>
|
||||
@@ -515,7 +517,7 @@ const FilterView = () => {
|
||||
sx={{ mt: 0.5 }}
|
||||
color='danger'
|
||||
>
|
||||
Delete
|
||||
{t('common:delete')}
|
||||
</Typography>
|
||||
</Box>
|
||||
</SwipeAction>
|
||||
|
||||
@@ -52,8 +52,10 @@ import HistoryDetailModal from '../Modals/HistoryDetailModal'
|
||||
import ConfirmationModal from '../Modals/Inputs/ConfirmationModal'
|
||||
import NoteViewerModal from '../Modals/Inputs/NoteViewerModal'
|
||||
import HistoryCard from './HistoryCard'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
const ChoreHistory = () => {
|
||||
const { t } = useTranslation('history')
|
||||
const [userHistory, setUserHistory] = useState([])
|
||||
const [historyInfo, setHistoryInfo] = useState([])
|
||||
const { choreId } = useParams()
|
||||
@@ -96,43 +98,43 @@ const ChoreHistory = () => {
|
||||
() => [
|
||||
{
|
||||
id: 'status',
|
||||
label: 'Status',
|
||||
label: t('charts.status.title'),
|
||||
type: 'multi-select',
|
||||
icon: <FilterList />,
|
||||
options: [
|
||||
{
|
||||
value: ChoreHistoryStatus.COMPLETED,
|
||||
label: 'Completed',
|
||||
label: t('status.completed'),
|
||||
color: 'success',
|
||||
icon: <Check sx={{ fontSize: 14 }} />,
|
||||
},
|
||||
{
|
||||
value: ChoreHistoryStatus.SKIPPED,
|
||||
label: 'Skipped',
|
||||
label: t('status.skipped'),
|
||||
color: 'warning',
|
||||
icon: <Redo sx={{ fontSize: 14 }} />,
|
||||
},
|
||||
{
|
||||
value: ChoreHistoryStatus.PENDING_APPROVAL,
|
||||
label: 'Pending',
|
||||
label: t('filter.pending'),
|
||||
color: 'neutral',
|
||||
icon: <HourglassEmpty sx={{ fontSize: 14 }} />,
|
||||
},
|
||||
{
|
||||
value: ChoreHistoryStatus.REJECTED,
|
||||
label: 'Rejected',
|
||||
label: t('status.rejected'),
|
||||
color: 'danger',
|
||||
icon: <ThumbDown sx={{ fontSize: 14 }} />,
|
||||
},
|
||||
{
|
||||
value: 5,
|
||||
label: 'Missed',
|
||||
label: t('status.missed'),
|
||||
color: 'danger',
|
||||
icon: <RunningWithErrors sx={{ fontSize: 14 }} />,
|
||||
},
|
||||
{
|
||||
value: 6,
|
||||
label: 'Rescheduled',
|
||||
label: t('status.rescheduled'),
|
||||
color: 'warning',
|
||||
icon: <Schedule sx={{ fontSize: 14 }} />,
|
||||
},
|
||||
@@ -141,14 +143,14 @@ const ChoreHistory = () => {
|
||||
},
|
||||
{
|
||||
id: 'hasNotes',
|
||||
label: 'Has Notes',
|
||||
label: t('filter.hasNotes'),
|
||||
type: 'boolean',
|
||||
icon: <EventNote />,
|
||||
filterFn: item => !!item.notes,
|
||||
},
|
||||
{
|
||||
id: 'completedBy',
|
||||
label: 'Completed By',
|
||||
label: t('filter.completedBy'),
|
||||
type: 'multi-select',
|
||||
icon: <Person />,
|
||||
options: performers.map(p => ({
|
||||
@@ -160,7 +162,7 @@ const ChoreHistory = () => {
|
||||
},
|
||||
{
|
||||
id: 'dateRange',
|
||||
label: 'Completed At',
|
||||
label: t('filter.completedAt'),
|
||||
type: 'date-range',
|
||||
icon: <CalendarMonth />,
|
||||
filterFn: (item, value) => {
|
||||
@@ -328,7 +330,7 @@ const ChoreHistory = () => {
|
||||
level='title-md'
|
||||
sx={{ fontWeight: 'lg', color: 'text.primary' }}
|
||||
>
|
||||
Task Summary
|
||||
{t('title.summary')}
|
||||
</Typography>
|
||||
</Box>
|
||||
<Grid container spacing={0.5} sx={{ mb: 2 }}>
|
||||
@@ -405,7 +407,7 @@ const ChoreHistory = () => {
|
||||
level='title-md'
|
||||
sx={{ fontWeight: 'lg', color: 'text.primary' }}
|
||||
>
|
||||
Task Activity
|
||||
{t('title.activity')}
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
@@ -425,7 +427,7 @@ const ChoreHistory = () => {
|
||||
icon={<FilterList />}
|
||||
title='No history matches these filters'
|
||||
description='There is history here, but none of it fits the filters that are currently on.'
|
||||
primaryAction={{ label: 'Clear filters', onClick: clearAll }}
|
||||
primaryAction={{ label: t('noResults.clear'), onClick: clearAll }}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -467,7 +469,7 @@ const ChoreHistory = () => {
|
||||
>
|
||||
<EditIcon sx={{ fontSize: 20 }} />
|
||||
<Typography level='body-xs' sx={{ mt: 0.5 }}>
|
||||
Edit
|
||||
{t('common:edit')}
|
||||
</Typography>
|
||||
</Box>
|
||||
</SwipeAction>
|
||||
@@ -486,7 +488,7 @@ const ChoreHistory = () => {
|
||||
>
|
||||
<DeleteIcon sx={{ fontSize: 20 }} />
|
||||
<Typography level='body-xs' sx={{ mt: 0.5 }}>
|
||||
Delete
|
||||
{t('common:delete')}
|
||||
</Typography>
|
||||
</Box>
|
||||
</SwipeAction>
|
||||
@@ -560,13 +562,13 @@ const ChoreHistory = () => {
|
||||
setEditHistory(null)
|
||||
if (data?.queued) {
|
||||
showSuccess({
|
||||
title: 'History Update Queued',
|
||||
title: t('toast.updateQueued.title'),
|
||||
message:
|
||||
'You are offline. The history update will sync when connection is restored.',
|
||||
})
|
||||
} else {
|
||||
showSuccess({
|
||||
title: 'History Updated',
|
||||
title: t('toast.updated.title'),
|
||||
message: `The history record has been updated successfully.`,
|
||||
})
|
||||
}
|
||||
@@ -590,13 +592,13 @@ const ChoreHistory = () => {
|
||||
setEditHistory(null)
|
||||
if (data?.queued) {
|
||||
showSuccess({
|
||||
title: 'History Delete Queued',
|
||||
title: t('toast.deleteQueued.title'),
|
||||
message:
|
||||
'You are offline. The history delete will sync when connection is restored.',
|
||||
})
|
||||
} else {
|
||||
showSuccess({
|
||||
title: 'History Deleted',
|
||||
title: t('toast.deleted.title'),
|
||||
message: `The history record has been deleted successfully.`,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
} from '@mui/icons-material'
|
||||
import { Avatar, Box, Card, Chip, IconButton, Typography } from '@mui/joy'
|
||||
import moment from 'moment'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useLocalization } from '../../contexts/LocalizationContext'
|
||||
import { TASK_COLOR } from '../../utils/Colors.jsx'
|
||||
import PendingBadge from '../components/PendingBadge'
|
||||
@@ -33,13 +34,13 @@ const stripHtmlTags = html => {
|
||||
}
|
||||
|
||||
const statusConfig = {
|
||||
0: { label: 'In Progress', color: 'primary', icon: <AccessTime /> },
|
||||
1: { label: 'Completed', color: 'success', icon: <Check /> },
|
||||
2: { label: 'Skipped', color: 'warning', icon: <Redo /> },
|
||||
3: { label: 'Pending Approval', color: 'neutral', icon: <HourglassEmpty /> },
|
||||
4: { label: 'Rejected', color: 'danger', icon: <ThumbDown /> },
|
||||
5: { label: 'Missed', color: 'danger', icon: <RunningWithErrors /> },
|
||||
6: { label: 'Rescheduled', color: 'warning', icon: <Schedule /> },
|
||||
0: { labelKey: 'status.inProgress', color: 'primary', icon: <AccessTime /> },
|
||||
1: { labelKey: 'status.completed', color: 'success', icon: <Check /> },
|
||||
2: { labelKey: 'status.skipped', color: 'warning', icon: <Redo /> },
|
||||
3: { labelKey: 'status.pendingApproval', color: 'neutral', icon: <HourglassEmpty /> },
|
||||
4: { labelKey: 'status.rejected', color: 'danger', icon: <ThumbDown /> },
|
||||
5: { labelKey: 'status.missed', color: 'danger', icon: <RunningWithErrors /> },
|
||||
6: { labelKey: 'status.rescheduled', color: 'warning', icon: <Schedule /> },
|
||||
}
|
||||
|
||||
const HistoryCard = ({
|
||||
@@ -52,45 +53,54 @@ const HistoryCard = ({
|
||||
onViewNote,
|
||||
onViewDetails,
|
||||
}) => {
|
||||
const { t } = useTranslation('history')
|
||||
const { fmt } = useLocalization()
|
||||
const performer = performers.find(p => p.userId === historyEntry.completedBy)
|
||||
const assignedTo = performers.find(p => p.userId === historyEntry.assignedTo)
|
||||
const config = statusConfig[historyEntry.status] ?? statusConfig[1]
|
||||
const displayLabel =
|
||||
historyEntry.status === 6 && !historyEntry.dueDate ? 'Scheduled' : config.label
|
||||
historyEntry.status === 6 && !historyEntry.dueDate
|
||||
? t('status.scheduled')
|
||||
: t(config.labelKey)
|
||||
const actionDate = historyEntry.performedAt || historyEntry.updatedAt
|
||||
|
||||
const getTimingLine = () => {
|
||||
const { status, performedAt, dueDate } = historyEntry
|
||||
if (!dueDate) return null
|
||||
|
||||
if (status === 6) {
|
||||
return `Was due ${moment(dueDate).format('MMM D')}`
|
||||
}
|
||||
if (status === 5) {
|
||||
return `Was due ${moment(dueDate).format('MMM D')}`
|
||||
if (status === 6 || status === 5) {
|
||||
return t('card.wasDue', { date: fmt.date(dueDate) })
|
||||
}
|
||||
if ((status === 1 || status === 2 || status === 0) && performedAt) {
|
||||
const diffHours = moment(performedAt).diff(dueDate, 'hours')
|
||||
const abs = Math.abs(diffHours)
|
||||
if (abs <= 6) return null // chip already says "On Time"
|
||||
if (diffHours < 0) return abs >= 48 ? `${Math.floor(abs / 24)}d before due date` : `${abs}h before due date`
|
||||
return abs >= 48 ? `${Math.floor(abs / 24)}d after due date` : `${abs}h after due date`
|
||||
const early = diffHours < 0
|
||||
if (abs >= 48) {
|
||||
const days = Math.floor(abs / 24)
|
||||
return early
|
||||
? t('card.daysBeforeDue', { count: days })
|
||||
: t('card.daysAfterDue', { count: days })
|
||||
}
|
||||
return early
|
||||
? t('card.hoursBeforeDue', { count: abs })
|
||||
: t('card.hoursAfterDue', { count: abs })
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
const timingLine = getTimingLine()
|
||||
const noteLabel = historyEntry.status === 2 || historyEntry.status === 4 ? 'Reason' : 'Note'
|
||||
const plainTextNotes = historyEntry.notes ? stripHtmlTags(historyEntry.notes) : ''
|
||||
|
||||
const metaTextParts = [
|
||||
fmt.dateTime(actionDate),
|
||||
historyEntry.completedBy !== historyEntry.assignedTo && assignedTo
|
||||
? `Assigned to ${assignedTo.displayName}`
|
||||
? t('card.assignedTo', { name: assignedTo.displayName })
|
||||
: null,
|
||||
historyEntry?.duration > 0 ? `⏱ ${formatTime(historyEntry.duration)}` : null,
|
||||
historyEntry?.points > 0 ? `★ ${historyEntry.points} pt${historyEntry.points > 1 ? 's' : ''}` : null,
|
||||
historyEntry?.points > 0
|
||||
? t('card.points', { count: historyEntry.points })
|
||||
: null,
|
||||
].filter(Boolean)
|
||||
|
||||
return (
|
||||
|
||||
@@ -8,8 +8,10 @@ import { useNotification } from '../../../service/NotificationProvider.jsx'
|
||||
import LABEL_COLORS from '../../../utils/Colors.jsx'
|
||||
import { CreateLabel, UpdateLabel } from '../../../utils/Fetcher'
|
||||
import { useLabels } from '../../Labels/LabelQueries'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
function LabelModal({ isOpen, onClose, label }) {
|
||||
const { t } = useTranslation('labels')
|
||||
const { ResponsiveModal } = useResponsiveModal()
|
||||
|
||||
const [labelName, setLabelName] = useState('')
|
||||
@@ -34,7 +36,7 @@ function LabelModal({ isOpen, onClose, label }) {
|
||||
// Validation logic
|
||||
const validateLabel = () => {
|
||||
if (!labelName.trim()) {
|
||||
setError('Name cannot be empty')
|
||||
setError(t('modal.errorEmptyName'))
|
||||
return false
|
||||
}
|
||||
if (
|
||||
@@ -42,11 +44,11 @@ function LabelModal({ isOpen, onClose, label }) {
|
||||
userLabel => userLabel.name === labelName && userLabel.id !== label?.id,
|
||||
)
|
||||
) {
|
||||
setError('Label with this name already exists')
|
||||
setError(t('modal.errorDuplicate'))
|
||||
return false
|
||||
}
|
||||
if (!color) {
|
||||
setError('Please select a color')
|
||||
setError(t('modal.errorNoColor'))
|
||||
return false
|
||||
}
|
||||
return true
|
||||
@@ -71,13 +73,13 @@ function LabelModal({ isOpen, onClose, label }) {
|
||||
.catch(err => {
|
||||
if (err.queued) {
|
||||
showError({
|
||||
title: 'Failed to save label',
|
||||
message: 'Unable to save label. Please try again.',
|
||||
title: t('modal.saveFailedTitle'),
|
||||
message: t('modal.saveFailedMessage'),
|
||||
})
|
||||
} else {
|
||||
showError({
|
||||
title: 'Failed to save label',
|
||||
message: 'Unable to save label. Please try again.',
|
||||
title: t('modal.saveFailedTitle'),
|
||||
message: t('modal.saveFailedMessage'),
|
||||
})
|
||||
}
|
||||
})
|
||||
@@ -92,7 +94,7 @@ function LabelModal({ isOpen, onClose, label }) {
|
||||
title={label ? 'Edit Label' : 'Add Label'}
|
||||
footer={
|
||||
<ModalActions
|
||||
secondary={{ label: 'Cancel', onClick: onClose }}
|
||||
secondary={{ label: t('common:cancel'), onClick: onClose }}
|
||||
primary={{
|
||||
label: label ? 'Save Changes' : 'Add Label',
|
||||
onClick: handleSave,
|
||||
@@ -103,7 +105,7 @@ function LabelModal({ isOpen, onClose, label }) {
|
||||
<Box>
|
||||
<FormControl>
|
||||
<Typography gutterBottom level='body-sm' alignSelf='start'>
|
||||
Name
|
||||
{t('modal.name')}
|
||||
</Typography>
|
||||
<Input
|
||||
fullWidth
|
||||
|
||||
@@ -21,8 +21,10 @@ import {
|
||||
useUpdateProject,
|
||||
} from '../../Projects/ProjectQueries'
|
||||
import IconPickerModal from './IconPickerModal'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
const ProjectModal = ({ isOpen, onClose, onSave, project }) => {
|
||||
const { t } = useTranslation('projects')
|
||||
const { ResponsiveModal } = useResponsiveModal()
|
||||
const [projectName, setProjectName] = useState('')
|
||||
const [projectDescription, setProjectDescription] = useState('')
|
||||
@@ -58,7 +60,7 @@ const ProjectModal = ({ isOpen, onClose, onSave, project }) => {
|
||||
e.preventDefault()
|
||||
|
||||
if (!projectName.trim()) {
|
||||
setError('Project name is required')
|
||||
setError(t('modal.errorNameRequired'))
|
||||
return
|
||||
}
|
||||
|
||||
@@ -82,7 +84,7 @@ const ProjectModal = ({ isOpen, onClose, onSave, project }) => {
|
||||
},
|
||||
onError: error => {
|
||||
console.error('Error updating project:', error)
|
||||
setError('Failed to update project')
|
||||
setError(t('modal.errorUpdate'))
|
||||
},
|
||||
},
|
||||
)
|
||||
@@ -95,7 +97,7 @@ const ProjectModal = ({ isOpen, onClose, onSave, project }) => {
|
||||
},
|
||||
onError: error => {
|
||||
console.error('Error creating project:', error)
|
||||
setError('Failed to create project')
|
||||
setError(t('modal.errorCreate'))
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -130,7 +132,7 @@ const ProjectModal = ({ isOpen, onClose, onSave, project }) => {
|
||||
footer={
|
||||
<ModalActions
|
||||
secondary={{
|
||||
label: 'Cancel',
|
||||
label: t('common:cancel'),
|
||||
onClick: handleClose,
|
||||
disabled: isSubmitting,
|
||||
}}
|
||||
@@ -152,7 +154,7 @@ const ProjectModal = ({ isOpen, onClose, onSave, project }) => {
|
||||
<Input
|
||||
value={projectName}
|
||||
onChange={e => setProjectName(e.target.value)}
|
||||
placeholder='Enter project name...'
|
||||
placeholder={t('modal.namePlaceholder')}
|
||||
autoFocus
|
||||
disabled={isSubmitting}
|
||||
/>
|
||||
@@ -164,7 +166,7 @@ const ProjectModal = ({ isOpen, onClose, onSave, project }) => {
|
||||
<Textarea
|
||||
value={projectDescription}
|
||||
onChange={e => setProjectDescription(e.target.value)}
|
||||
placeholder='Optional project description...'
|
||||
placeholder={t('modal.descriptionPlaceholder')}
|
||||
minRows={2}
|
||||
maxRows={4}
|
||||
disabled={isSubmitting}
|
||||
|
||||
@@ -53,6 +53,7 @@ import { isOfflineFeatureEnabled } from '../../utils/OfflineFeatureToggle'
|
||||
import { resolvePhotoURL } from '../../utils/Helpers'
|
||||
import { getSafeBottom } from '../../utils/SafeAreaUtils'
|
||||
import LoadingComponent from '../components/Loading'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
// Effectively "can this action be queued offline?" — requires the offline
|
||||
// feature, otherwise there is no command queue to replay it later.
|
||||
@@ -62,6 +63,7 @@ const isNetworkError = err =>
|
||||
err.message === 'Failed to fetch'
|
||||
|
||||
const TimerDetails = () => {
|
||||
const { t } = useTranslation('timer')
|
||||
const { choreId } = useParams()
|
||||
const { fmt } = useLocalization()
|
||||
const [timerData, setTimerData] = useState(null)
|
||||
@@ -231,23 +233,23 @@ const TimerDetails = () => {
|
||||
{
|
||||
onSuccess: () => {
|
||||
showSuccess({
|
||||
title: 'Session updated',
|
||||
message: 'Timer session has been updated successfully.',
|
||||
title: t('toast.sessionUpdatedTitle'),
|
||||
message: t('toast.sessionUpdatedMessage'),
|
||||
})
|
||||
refetchTimer()
|
||||
cancelEditingSession(sessionId)
|
||||
},
|
||||
onError: () => {
|
||||
showError({
|
||||
title: 'Failed to update session',
|
||||
message: 'Please try again.',
|
||||
title: t('toast.sessionUpdateFailTitle'),
|
||||
message: t('toast.tryAgain'),
|
||||
})
|
||||
},
|
||||
},
|
||||
)
|
||||
} catch (error) {
|
||||
showError({
|
||||
title: 'Error updating session',
|
||||
title: t('toast.sessionUpdateErrorTitle'),
|
||||
message: error.message,
|
||||
})
|
||||
} finally {
|
||||
@@ -261,8 +263,8 @@ const TimerDetails = () => {
|
||||
startChore.mutate(choreId, {
|
||||
onSuccess: () => {
|
||||
showSuccess({
|
||||
title: 'Timer Started',
|
||||
message: 'Work session has been started successfully.',
|
||||
title: t('toast.startedTitle'),
|
||||
message: t('toast.startedMessage'),
|
||||
})
|
||||
refetchTimer()
|
||||
},
|
||||
@@ -274,7 +276,7 @@ const TimerDetails = () => {
|
||||
{ id: choreId },
|
||||
)
|
||||
showSuccess({
|
||||
title: 'Start queued',
|
||||
title: t('toast.startQueuedTitle'),
|
||||
message: "You're offline — start will sync when back online",
|
||||
undoAction: async () => {
|
||||
await commandQueue.cancel(cmdId)
|
||||
@@ -284,8 +286,8 @@ const TimerDetails = () => {
|
||||
}
|
||||
|
||||
showError({
|
||||
title: 'Failed to start timer',
|
||||
message: 'Please try again.',
|
||||
title: t('toast.startFailTitle'),
|
||||
message: t('toast.tryAgain'),
|
||||
})
|
||||
},
|
||||
onSettled: () => {
|
||||
@@ -299,8 +301,8 @@ const TimerDetails = () => {
|
||||
pauseChore.mutate(choreId, {
|
||||
onSuccess: () => {
|
||||
showSuccess({
|
||||
title: 'Timer Paused',
|
||||
message: 'Work session has been paused.',
|
||||
title: t('toast.pausedTitle'),
|
||||
message: t('toast.pausedMessage'),
|
||||
})
|
||||
refetchTimer()
|
||||
},
|
||||
@@ -312,7 +314,7 @@ const TimerDetails = () => {
|
||||
{ id: choreId },
|
||||
)
|
||||
showSuccess({
|
||||
title: 'Pause queued',
|
||||
title: t('toast.pauseQueuedTitle'),
|
||||
message: "You're offline — pause will sync when back online",
|
||||
undoAction: async () => {
|
||||
await commandQueue.cancel(cmdId)
|
||||
@@ -322,8 +324,8 @@ const TimerDetails = () => {
|
||||
}
|
||||
|
||||
showError({
|
||||
title: 'Failed to pause timer',
|
||||
message: 'Please try again.',
|
||||
title: t('toast.pauseFailTitle'),
|
||||
message: t('toast.tryAgain'),
|
||||
})
|
||||
},
|
||||
onSettled: () => {
|
||||
@@ -387,7 +389,7 @@ const TimerDetails = () => {
|
||||
const handleDeleteSession = sessionIndex => {
|
||||
// For now, just show an alert since we'd need to implement session deletion API
|
||||
showError({
|
||||
title: 'Delete Session',
|
||||
title: t('toast.deleteSessionTitle'),
|
||||
message: `Session #${sessionIndex + 1} deletion would be implemented here`,
|
||||
})
|
||||
}
|
||||
@@ -402,13 +404,13 @@ const TimerDetails = () => {
|
||||
|
||||
{loading && (
|
||||
<Alert color='neutral' sx={{ mb: 2 }}>
|
||||
Loading timer data...
|
||||
{t('loading')}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{!loading && !timerData && (
|
||||
<Alert color='warning' sx={{ mb: 2 }}>
|
||||
No timer data found for this chore.
|
||||
{t('noData')}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
@@ -514,7 +516,7 @@ const TimerDetails = () => {
|
||||
color: 'text.primary',
|
||||
}}
|
||||
>
|
||||
Break Time
|
||||
{t('breakTime')}
|
||||
</Typography>
|
||||
</Box>
|
||||
<Box>
|
||||
@@ -624,7 +626,7 @@ const TimerDetails = () => {
|
||||
color: 'text.primary',
|
||||
}}
|
||||
>
|
||||
Total Time
|
||||
{t('totalTime')}
|
||||
</Typography>
|
||||
</Box>
|
||||
<Box>
|
||||
@@ -824,7 +826,7 @@ const TimerDetails = () => {
|
||||
level='body-xs'
|
||||
sx={{ color: 'text.tertiary' }}
|
||||
>
|
||||
Break Time
|
||||
{t('breakTime')}
|
||||
</Typography>
|
||||
</Box>
|
||||
{isTimerRunning() && (
|
||||
@@ -924,7 +926,7 @@ const TimerDetails = () => {
|
||||
onClick={() => startEditingSession()}
|
||||
size='sm'
|
||||
>
|
||||
Edit
|
||||
{t('common:edit')}
|
||||
</Button>
|
||||
)}
|
||||
{editingSessions[timerData.id] && (
|
||||
@@ -934,7 +936,7 @@ const TimerDetails = () => {
|
||||
onClick={() => cancelEditingSession(timerData.id)}
|
||||
size='sm'
|
||||
>
|
||||
Cancel
|
||||
{t('common:cancel')}
|
||||
</Button>
|
||||
<Button
|
||||
variant='solid'
|
||||
@@ -943,7 +945,7 @@ const TimerDetails = () => {
|
||||
loading={loading}
|
||||
size='sm'
|
||||
>
|
||||
Save Changes
|
||||
{t('saveChanges')}
|
||||
</Button>
|
||||
</Box>
|
||||
)}
|
||||
@@ -1019,7 +1021,7 @@ const TimerDetails = () => {
|
||||
level='body-xs'
|
||||
sx={{ mt: 0.5 }}
|
||||
>
|
||||
Edit
|
||||
{t('common:edit')}
|
||||
</Typography>
|
||||
</Box>
|
||||
</SwipeAction>
|
||||
@@ -1045,7 +1047,7 @@ const TimerDetails = () => {
|
||||
level='body-xs'
|
||||
sx={{ mt: 0.5 }}
|
||||
>
|
||||
Delete
|
||||
{t('common:delete')}
|
||||
</Typography>
|
||||
</Box>
|
||||
</SwipeAction>
|
||||
@@ -1242,7 +1244,7 @@ const TimerDetails = () => {
|
||||
startDecorator={<Add />}
|
||||
onClick={() => addPauseLogEntry(timerData.id)}
|
||||
>
|
||||
Add Session
|
||||
{t('addSession')}
|
||||
</Button>
|
||||
</Box>
|
||||
|
||||
@@ -1292,7 +1294,7 @@ const TimerDetails = () => {
|
||||
level='body-sm'
|
||||
sx={{ fontWeight: 'bold', mb: 1 }}
|
||||
>
|
||||
Start Time
|
||||
{t('startTime')}
|
||||
</Typography>
|
||||
<Input
|
||||
type='datetime-local'
|
||||
@@ -1315,7 +1317,7 @@ const TimerDetails = () => {
|
||||
level='body-sm'
|
||||
sx={{ fontWeight: 'bold', mb: 1 }}
|
||||
>
|
||||
End Time
|
||||
{t('endTime')}
|
||||
</Typography>
|
||||
<Input
|
||||
type='datetime-local'
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useEffect, useRef, useState } from 'react'
|
||||
import ModalActions from '../../components/common/ModalActions'
|
||||
import NotificationTemplate from '../../components/NotificationTemplate'
|
||||
import { useResponsiveModal } from '../../hooks/useResponsiveModal'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
const getDisplayLabel = templates => {
|
||||
if (!templates || templates.length === 0) return 'Remind'
|
||||
@@ -27,6 +28,7 @@ const NotificationPickerField = ({
|
||||
emptyDisplay = 'icon-text',
|
||||
size = 'sm',
|
||||
}) => {
|
||||
const { t } = useTranslation('chores')
|
||||
const [isOpen, setIsOpen] = useState(false)
|
||||
const latestTemplatesRef = useRef(value?.templates || [])
|
||||
const { ResponsiveModal } = useResponsiveModal()
|
||||
@@ -52,7 +54,7 @@ const NotificationPickerField = ({
|
||||
tertiary={
|
||||
hasNotifications
|
||||
? {
|
||||
label: 'Remove all',
|
||||
label: t('common:removeAll'),
|
||||
color: 'danger',
|
||||
onClick: () => {
|
||||
onClear?.()
|
||||
@@ -61,8 +63,8 @@ const NotificationPickerField = ({
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
secondary={{ label: 'Cancel', onClick: () => setIsOpen(false) }}
|
||||
primary={{ label: 'Apply', onClick: handleSave }}
|
||||
secondary={{ label: t('choreView.cancel'), onClick: () => setIsOpen(false) }}
|
||||
primary={{ label: t('common:apply'), onClick: handleSave }}
|
||||
/>
|
||||
)
|
||||
|
||||
@@ -130,7 +132,7 @@ const NotificationPickerField = ({
|
||||
<ResponsiveModal
|
||||
open={isOpen}
|
||||
onClose={() => setIsOpen(false)}
|
||||
title='Reminders'
|
||||
title={t('remind.title')}
|
||||
footer={footer}
|
||||
>
|
||||
<NotificationTemplate
|
||||
|
||||
@@ -12,6 +12,7 @@ import { useState } from 'react'
|
||||
import ModalActions from '../../components/common/ModalActions'
|
||||
import { useResponsiveModal } from '../../hooks/useResponsiveModal'
|
||||
import { commandQueue } from '../../utils/CommandQueue'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
const LABELS = {
|
||||
complete_chore: 'Complete pending',
|
||||
@@ -39,6 +40,7 @@ const formatCommandLabel = commandType => {
|
||||
}
|
||||
|
||||
function PendingBadge({ commands, size = 'sm', sx = {} }) {
|
||||
const { t } = useTranslation('common')
|
||||
const { ResponsiveModal } = useResponsiveModal()
|
||||
const queryClient = useQueryClient()
|
||||
const [isOpen, setIsOpen] = useState(false)
|
||||
@@ -145,9 +147,9 @@ function PendingBadge({ commands, size = 'sm', sx = {} }) {
|
||||
title='Pending actions'
|
||||
footer={
|
||||
<ModalActions
|
||||
secondary={{ label: 'Close', onClick: handleClose }}
|
||||
secondary={{ label: t('close'), onClick: handleClose }}
|
||||
primary={{
|
||||
label: 'Cancel all',
|
||||
label: t('cancelAll'),
|
||||
color: 'danger',
|
||||
onClick: handleCancelAll,
|
||||
loading: isCancelingAll,
|
||||
|
||||
@@ -35,6 +35,7 @@ import {
|
||||
subscribeToOfflineFeature,
|
||||
} from '../../utils/OfflineFeatureToggle'
|
||||
import { syncEngine } from '../../utils/SyncEngine'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
const COMMAND_LABELS = {
|
||||
create_chore: 'Create chore',
|
||||
@@ -62,6 +63,7 @@ const formatCommandLabel = commandType => {
|
||||
}
|
||||
|
||||
function SyncStatusIndicator() {
|
||||
const { t } = useTranslation('common')
|
||||
const queryClient = useQueryClient()
|
||||
const [pendingCommands, setPendingCommands] = useState([])
|
||||
const [failedCommands, setFailedCommands] = useState([])
|
||||
@@ -239,7 +241,7 @@ function SyncStatusIndicator() {
|
||||
return (
|
||||
<Dropdown>
|
||||
<MenuButton
|
||||
aria-label='Open sync and network status'
|
||||
aria-label={t('sync.aria')}
|
||||
variant='plain'
|
||||
sx={{
|
||||
p: 0.5,
|
||||
@@ -483,7 +485,7 @@ function SyncStatusIndicator() {
|
||||
level='body-sm'
|
||||
sx={{ color: 'var(--joy-palette-text-secondary)' }}
|
||||
>
|
||||
All changes synced
|
||||
{t('sync.allSynced')}
|
||||
</Typography>
|
||||
</Box>
|
||||
)}
|
||||
@@ -517,7 +519,7 @@ function SyncStatusIndicator() {
|
||||
level='body-xs'
|
||||
sx={{ color: 'var(--joy-palette-text-tertiary)' }}
|
||||
>
|
||||
Will sync when back online
|
||||
{t('sync.willSync')}
|
||||
</Typography>
|
||||
</Box>
|
||||
)}
|
||||
@@ -538,7 +540,7 @@ function SyncStatusIndicator() {
|
||||
<ClearAll sx={{ fontSize: 18 }} />
|
||||
</ListItemDecorator>
|
||||
<Typography level='body-sm' sx={{ fontWeight: 500 }}>
|
||||
Cancel All
|
||||
{t('sync.cancelAll')}
|
||||
</Typography>
|
||||
</MenuItem>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user