From dfa07aa5d16f13c5ceea6f2f09ece77b19851590 Mon Sep 17 00:00:00 2001 From: Scott Anderson <662325+scottanderson@users.noreply.github.com> Date: Tue, 28 Jul 2026 22:40:10 -0400 Subject: [PATCH 1/5] dependabot --- .github/dependabot.yml | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) create mode 100644 .github/dependabot.yml diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..dffa19f --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,23 @@ +# Please see the documentation for all configuration options: +# https://docs.github.com/code-security/dependabot/dependabot-version-updates/configuration-options-for-the-dependabot.yml-file + +version: 2 +updates: + - package-ecosystem: 'npm' # See documentation for possible values + directory: '/' # Location of package manifests + schedule: + interval: 'monthly' + groups: + npm-minor-patch: # Group minor and patch revisions to limit the number of pull requests generated by dependabot. + update-types: + - 'minor' + - 'patch' + - package-ecosystem: 'github-actions' + directory: '/' + schedule: + interval: 'monthly' + groups: + actions-minor-patch: # Group minor and patch revisions to limit the number of pull requests generated by dependabot. + update-types: + - 'minor' + - 'patch' From 6e97c70570ed81cdc524b6819f15e8aee154c036 Mon Sep 17 00:00:00 2001 From: everysingletear Date: Fri, 14 Aug 2026 11:10:35 +0800 Subject: [PATCH 2/5] i18n: extract the strings left behind in already-localized screens MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Part of #145. Two files you localized yourself, each with a handful of strings that the pass didn't reach: - `ChoreView` — the offline-sync notices ("You're offline — completion will sync when back online" and its skip/start/pause/restore siblings) and the error toasts they fall back to. 12 keys added to `en/chores.json`. - `MFASettings` — the "Generate New Codes" button. 1 key added to `en/settings.json`. Both extend namespaces that already exist, so `src/i18n/config.js` is untouched and this cannot conflict with my other open PRs. 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 (126 call sites total across both files). --- public/locales/en/chores.json | 14 +++++++++++++- public/locales/en/settings.json | 3 ++- src/views/ChoreEdit/ChoreView.jsx | 26 +++++++++++++------------- src/views/Settings/MFASettings.jsx | 2 +- 4 files changed, 29 insertions(+), 16 deletions(-) diff --git a/public/locales/en/chores.json b/public/locales/en/chores.json index 50b151f..f80f09c 100644 --- a/public/locales/en/chores.json +++ b/public/locales/en/chores.json @@ -70,7 +70,19 @@ "paused": "Timer Paused", "reset": "Reset Timer", "delete": "Delete Session" - } + }, + "unableUndo": "Failed to undo", + "offlineComplete": "You're offline — completion will sync when back online", + "unableComplete": "Unable to complete task", + "offlineSkip": "You're offline — skip will sync when back online", + "unableSkip": "Unable to skip task", + "offlineStart": "You're offline — start will sync when back online", + "unableStart": "Unable to start task", + "offlinePause": "You're offline — pause will sync when back online", + "unablePause": "Unable to pause task", + "offlineRestore": "You're offline — restore will sync when back online", + "restoreFailed": "Failed to restore", + "unableRestore": "Unable to restore task" }, "toolbar": { "defaultProject": "Default Project" diff --git a/public/locales/en/settings.json b/public/locales/en/settings.json index 43698c4..a4af5a3 100644 --- a/public/locales/en/settings.json +++ b/public/locales/en/settings.json @@ -348,7 +348,8 @@ "backupCodesModal": { "title": "New Backup Codes", "warning": "Your previous backup codes are now invalid. Save these new codes in a safe place. Each code can only be used once." - } + }, + "generateCodes": "Generate New Codes" }, "apiTokens": { "title": "API Tokens", diff --git a/src/views/ChoreEdit/ChoreView.jsx b/src/views/ChoreEdit/ChoreView.jsx index d9ba621..ba6f9b0 100644 --- a/src/views/ChoreEdit/ChoreView.jsx +++ b/src/views/ChoreEdit/ChoreView.jsx @@ -280,7 +280,7 @@ const ChoreView = () => { message: t('choreView.taskCompletionUndone'), }) } else { - throw new Error('Failed to undo') + throw new Error(t('choreView.unableUndo')) } } catch (error) { showError({ @@ -318,7 +318,7 @@ const ChoreView = () => { }) queryClient.invalidateQueries({ queryKey: ['pendingCommands'] }) showSuccess({ - message: "You're offline — completion will sync when back online", + message: t('choreView.offlineComplete'), undoAction: async () => { await commandQueue.cancel(cmdId) queryClient.invalidateQueries({ queryKey: ['pendingCommands'] }) @@ -327,7 +327,7 @@ const ChoreView = () => { } else { showError({ title: t('choreView.undoFailed'), - message: error?.message || 'Unable to complete task', + message: error?.message || t('choreView.unableComplete'), }) } } @@ -356,7 +356,7 @@ const ChoreView = () => { message: t('choreView.taskSkipUndone'), }) } else { - throw new Error('Failed to undo') + throw new Error(t('choreView.unableUndo')) } } catch (error) { showError({ @@ -376,7 +376,7 @@ const ChoreView = () => { ) queryClient.invalidateQueries({ queryKey: ['pendingCommands'] }) showSuccess({ - message: "You're offline — skip will sync when back online", + message: t('choreView.offlineSkip'), undoAction: async () => { await commandQueue.cancel(cmdId) queryClient.invalidateQueries({ queryKey: ['pendingCommands'] }) @@ -385,7 +385,7 @@ const ChoreView = () => { } else { showError({ title: t('choreView.undoFailed'), - message: error?.message || 'Unable to skip task', + message: error?.message || t('choreView.unableSkip'), }) } } @@ -411,7 +411,7 @@ const ChoreView = () => { setChore(startedChore) queryClient.invalidateQueries({ queryKey: ['pendingCommands'] }) showSuccess({ - message: "You're offline — start will sync when back online", + message: t('choreView.offlineStart'), undoAction: async () => { await commandQueue.cancel(cmdId) queryClient.invalidateQueries({ queryKey: ['pendingCommands'] }) @@ -423,7 +423,7 @@ const ChoreView = () => { showError({ title: t('choreView.undoFailed'), - message: error?.message || 'Unable to start task', + message: error?.message || t('choreView.unableStart'), }) }, }) @@ -450,7 +450,7 @@ const ChoreView = () => { setChore(pausedChore) queryClient.invalidateQueries({ queryKey: ['pendingCommands'] }) showSuccess({ - message: "You're offline — pause will sync when back online", + message: t('choreView.offlinePause'), undoAction: async () => { await commandQueue.cancel(cmdId) queryClient.invalidateQueries({ queryKey: ['pendingCommands'] }) @@ -462,7 +462,7 @@ const ChoreView = () => { showError({ title: t('choreView.undoFailed'), - message: error?.message || 'Unable to pause task', + message: error?.message || t('choreView.unablePause'), }) }, }) @@ -566,7 +566,7 @@ const ChoreView = () => { setChore({ ...chore, isActive: true }) queryClient.invalidateQueries({ queryKey: ['pendingCommands'] }) showSuccess({ - message: "You're offline — restore will sync when back online", + message: t('choreView.offlineRestore'), undoAction: async () => { await commandQueue.cancel(cmdId) await offlineDB.saveChores([{ ...chore, isActive: false }]) @@ -576,8 +576,8 @@ const ChoreView = () => { }) } else { showError({ - title: 'Failed to restore', - message: error.message || 'Unable to restore task', + title: t('choreView.restoreFailed'), + message: error.message || t('choreView.unableRestore'), }) } } diff --git a/src/views/Settings/MFASettings.jsx b/src/views/Settings/MFASettings.jsx index 5d61ca0..023c532 100644 --- a/src/views/Settings/MFASettings.jsx +++ b/src/views/Settings/MFASettings.jsx @@ -249,7 +249,7 @@ const MFASettings = () => { size='sm' onClick={handleRegenerateBackupCodes} > - Generate New Codes + {t('mfa.generateCodes')} From ede1a11099a63750299e9489210cee27d02a217e Mon Sep 17 00:00:00 2001 From: everysingletear Date: Fri, 14 Aug 2026 11:26:24 +0800 Subject: [PATCH 3/5] i18n: extract the remaining task, history, filter and timer screens MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- public/locales/en/chores.json | 119 ++++++++++++- public/locales/en/common.json | 37 +++++ public/locales/en/filters.json | 8 +- public/locales/en/history.json | 25 ++- public/locales/en/labels.json | 10 +- public/locales/en/projects.json | 7 + public/locales/en/settings.json | 3 + public/locales/en/timer.json | 14 +- src/components/SSESettings.jsx | 4 +- src/components/UserProfileAvatar.jsx | 18 +- src/hooks/useSSE.js | 38 +++-- src/views/ChoreEdit/ThingTriggerSection.jsx | 8 +- src/views/Chores/ActivitesCard.jsx | 8 +- src/views/Chores/SmartInsightsCard.jsx | 14 +- .../components/ChoreToolbarPrototype.jsx | 54 +++--- src/views/Chores/hooks/useChoreActions.js | 156 +++++++++--------- src/views/Filters/FilterView.jsx | 14 +- src/views/History/ChoreHistory.jsx | 40 ++--- src/views/History/HistoryCard.jsx | 46 ++++-- src/views/Modals/Inputs/LabelModal.jsx | 20 ++- src/views/Modals/Inputs/ProjectModal.jsx | 14 +- src/views/Timer/TimerDetails.jsx | 60 +++---- .../components/NotificationPickerField.jsx | 10 +- src/views/components/PendingBadge.jsx | 6 +- src/views/components/SyncStatusIndicator.jsx | 10 +- 25 files changed, 496 insertions(+), 247 deletions(-) diff --git a/public/locales/en/chores.json b/public/locales/en/chores.json index 50b151f..72c8dc6 100644 --- a/public/locales/en/chores.json +++ b/public/locales/en/chores.json @@ -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" } } diff --git a/public/locales/en/common.json b/public/locales/en/common.json index d8dff3b..fe1f4d4 100644 --- a/public/locales/en/common.json +++ b/public/locales/en/common.json @@ -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" } } diff --git a/public/locales/en/filters.json b/public/locales/en/filters.json index 6b10047..973cf6f 100644 --- a/public/locales/en/filters.json +++ b/public/locales/en/filters.json @@ -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" + } } diff --git a/public/locales/en/history.json b/public/locales/en/history.json index 1452849..9d4b256 100644 --- a/public/locales/en/history.json +++ b/public/locales/en/history.json @@ -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" + } } } diff --git a/public/locales/en/labels.json b/public/locales/en/labels.json index 93f96cb..165851b 100644 --- a/public/locales/en/labels.json +++ b/public/locales/en/labels.json @@ -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" + } } diff --git a/public/locales/en/projects.json b/public/locales/en/projects.json index 0e7eed4..276d267 100644 --- a/public/locales/en/projects.json +++ b/public/locales/en/projects.json @@ -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..." } } diff --git a/public/locales/en/settings.json b/public/locales/en/settings.json index 43698c4..5e9e6c8 100644 --- a/public/locales/en/settings.json +++ b/public/locales/en/settings.json @@ -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)" } } diff --git a/public/locales/en/timer.json b/public/locales/en/timer.json index 848fd53..a448c49 100644 --- a/public/locales/en/timer.json +++ b/public/locales/en/timer.json @@ -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" } diff --git a/src/components/SSESettings.jsx b/src/components/SSESettings.jsx index d9623ad..3187bdc 100644 --- a/src/components/SSESettings.jsx +++ b/src/components/SSESettings.jsx @@ -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 = () => { )} - Real-time Updates (SSE) + {t('realtime.titleSse')} {!isPlusAccount(userProfile) && ( Plus Feature diff --git a/src/components/UserProfileAvatar.jsx b/src/components/UserProfileAvatar.jsx index e306c99..74b43e3 100644 --- a/src/components/UserProfileAvatar.jsx +++ b/src/components/UserProfileAvatar.jsx @@ -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')} @@ -319,13 +321,13 @@ const UserProfileAvatar = () => { - Stop Impersonating + {t('userMenu.stopImpersonating')} - Return to your account + {t('userMenu.returnToAccount')} @@ -349,13 +351,13 @@ const UserProfileAvatar = () => { - Settings + {t('settings')} - Account & preferences + {t('userMenu.accountPrefs')} @@ -374,13 +376,13 @@ const UserProfileAvatar = () => { - Invite People + {t('userMenu.invitePeople')} - Add members to your circle + {t('userMenu.addMembers')} @@ -401,7 +403,7 @@ const UserProfileAvatar = () => { - Side Panel Settings + {t('userMenu.sidePanelSettings')} { + 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) } diff --git a/src/views/ChoreEdit/ThingTriggerSection.jsx b/src/views/ChoreEdit/ThingTriggerSection.jsx index e4c4d24..3636372 100644 --- a/src/views/ChoreEdit/ThingTriggerSection.jsx +++ b/src/views/ChoreEdit/ThingTriggerSection.jsx @@ -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 ( - Trigger a task when a thing state changes to a desired state + {t('thing.triggerHint')} {things?.length === 0 && ( @@ -135,7 +137,7 @@ const ThingTriggerSection = ({ )} renderInput={params => ( - + )} /> @@ -215,7 +217,7 @@ const ThingTriggerSection = ({ setTriggerState(e.target.value)} - label='Enter the text to trigger the task' + label={t('thing.enterText')} /> )} diff --git a/src/views/Chores/ActivitesCard.jsx b/src/views/Chores/ActivitesCard.jsx index 5108633..f3088b7 100644 --- a/src/views/Chores/ActivitesCard.jsx +++ b/src/views/Chores/ActivitesCard.jsx @@ -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')} )} @@ -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' }) => { }} > - Loading activities... + {t('activity.loading')} diff --git a/src/views/Chores/SmartInsightsCard.jsx b/src/views/Chores/SmartInsightsCard.jsx index be316d0..81e1eb6 100644 --- a/src/views/Chores/SmartInsightsCard.jsx +++ b/src/views/Chores/SmartInsightsCard.jsx @@ -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', diff --git a/src/views/Chores/components/ChoreToolbarPrototype.jsx b/src/views/Chores/components/ChoreToolbarPrototype.jsx index cd4e28d..c64aa65 100644 --- a/src/views/Chores/components/ChoreToolbarPrototype.jsx +++ b/src/views/Chores/components/ChoreToolbarPrototype.jsx @@ -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: , }, { value: 'compact', - label: 'Compact', + label: t('toolbar.compact'), icon: , }, { value: 'calendar', - label: 'Calendar', + label: t('toolbar.calendar'), icon: , }, ] @@ -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')} > @@ -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' ? ( @@ -708,7 +710,7 @@ const ChoreToolbar = ({ > 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')} ) : ( @@ -752,7 +754,7 @@ const ChoreToolbar = ({ setFilterSheetOpen(false) }} > - Clear all + {t('toolbar.clearAll')} {activeConditions.length > 0 ? ( @@ -784,7 +786,7 @@ const ChoreToolbar = ({ disabled={!editingSavedFilter} > - Save Filter + {t('toolbar.saveFilter')} { @@ -798,7 +800,7 @@ const ChoreToolbar = ({ }} > - Save as New Filter + {t('toolbar.saveAsNew')} @@ -812,7 +814,7 @@ const ChoreToolbar = ({ }} sx={{ minWidth: 140 }} > - Done + {t('activity.status.done')} )} @@ -893,13 +895,13 @@ const ChoreToolbar = ({ onClick={() => setDisplaySheetOpen(false)} sx={{ minWidth: 140 }} > - Done + {t('activity.status.done')} } > {/* View section */} - + {viewOptions.map(opt => ( } - label='Group by' + label={t('toolbar.groupBy')} badge={ selectedGroupBy !== 'default' ? groupByOptions.find(o => o.value === selectedGroupBy)?.label @@ -963,7 +965,7 @@ const ChoreToolbar = ({ } - label='Show tasks for' + label={t('toolbar.showTasksFor')} badge={ selectedAssigneeFilter !== 'anyone' ? assigneeOptions.find(o => o.value === selectedAssigneeFilter) diff --git a/src/views/Chores/hooks/useChoreActions.js b/src/views/Chores/hooks/useChoreActions.js index 55d9a2c..f4b27f0 100644 --- a/src/views/Chores/hooks/useChoreActions.js +++ b/src/views/Chores/hooks/useChoreActions.js @@ -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.`, }) } diff --git a/src/views/Filters/FilterView.jsx b/src/views/Filters/FilterView.jsx index be37bd8..fa6618a 100644 --- a/src/views/Filters/FilterView.jsx +++ b/src/views/Filters/FilterView.jsx @@ -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 = () => { } - 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 = () => { > - Edit + {t('common:edit')} @@ -515,7 +517,7 @@ const FilterView = () => { sx={{ mt: 0.5 }} color='danger' > - Delete + {t('common:delete')} diff --git a/src/views/History/ChoreHistory.jsx b/src/views/History/ChoreHistory.jsx index cf5906c..d1983ba 100644 --- a/src/views/History/ChoreHistory.jsx +++ b/src/views/History/ChoreHistory.jsx @@ -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: , options: [ { value: ChoreHistoryStatus.COMPLETED, - label: 'Completed', + label: t('status.completed'), color: 'success', icon: , }, { value: ChoreHistoryStatus.SKIPPED, - label: 'Skipped', + label: t('status.skipped'), color: 'warning', icon: , }, { value: ChoreHistoryStatus.PENDING_APPROVAL, - label: 'Pending', + label: t('filter.pending'), color: 'neutral', icon: , }, { value: ChoreHistoryStatus.REJECTED, - label: 'Rejected', + label: t('status.rejected'), color: 'danger', icon: , }, { value: 5, - label: 'Missed', + label: t('status.missed'), color: 'danger', icon: , }, { value: 6, - label: 'Rescheduled', + label: t('status.rescheduled'), color: 'warning', icon: , }, @@ -141,14 +143,14 @@ const ChoreHistory = () => { }, { id: 'hasNotes', - label: 'Has Notes', + label: t('filter.hasNotes'), type: 'boolean', icon: , filterFn: item => !!item.notes, }, { id: 'completedBy', - label: 'Completed By', + label: t('filter.completedBy'), type: 'multi-select', icon: , options: performers.map(p => ({ @@ -160,7 +162,7 @@ const ChoreHistory = () => { }, { id: 'dateRange', - label: 'Completed At', + label: t('filter.completedAt'), type: 'date-range', icon: , filterFn: (item, value) => { @@ -328,7 +330,7 @@ const ChoreHistory = () => { level='title-md' sx={{ fontWeight: 'lg', color: 'text.primary' }} > - Task Summary + {t('title.summary')} @@ -405,7 +407,7 @@ const ChoreHistory = () => { level='title-md' sx={{ fontWeight: 'lg', color: 'text.primary' }} > - Task Activity + {t('title.activity')} @@ -425,7 +427,7 @@ const ChoreHistory = () => { icon={} 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 = () => { > - Edit + {t('common:edit')} @@ -486,7 +488,7 @@ const ChoreHistory = () => { > - Delete + {t('common:delete')} @@ -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.`, }) } diff --git a/src/views/History/HistoryCard.jsx b/src/views/History/HistoryCard.jsx index 555bdbb..0204f0b 100644 --- a/src/views/History/HistoryCard.jsx +++ b/src/views/History/HistoryCard.jsx @@ -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: }, - 1: { label: 'Completed', color: 'success', icon: }, - 2: { label: 'Skipped', color: 'warning', icon: }, - 3: { label: 'Pending Approval', color: 'neutral', icon: }, - 4: { label: 'Rejected', color: 'danger', icon: }, - 5: { label: 'Missed', color: 'danger', icon: }, - 6: { label: 'Rescheduled', color: 'warning', icon: }, + 0: { labelKey: 'status.inProgress', color: 'primary', icon: }, + 1: { labelKey: 'status.completed', color: 'success', icon: }, + 2: { labelKey: 'status.skipped', color: 'warning', icon: }, + 3: { labelKey: 'status.pendingApproval', color: 'neutral', icon: }, + 4: { labelKey: 'status.rejected', color: 'danger', icon: }, + 5: { labelKey: 'status.missed', color: 'danger', icon: }, + 6: { labelKey: 'status.rescheduled', color: 'warning', icon: }, } 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 ( diff --git a/src/views/Modals/Inputs/LabelModal.jsx b/src/views/Modals/Inputs/LabelModal.jsx index 7b771f0..41e47cf 100644 --- a/src/views/Modals/Inputs/LabelModal.jsx +++ b/src/views/Modals/Inputs/LabelModal.jsx @@ -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={ - Name + {t('modal.name')} { + 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={ { 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 }) => {