From 6e56e164df45c555b7edbf36cc54074376871129 Mon Sep 17 00:00:00 2001 From: "google-labs-jules[bot]" <161369871+google-labs-jules[bot]@users.noreply.github.com> Date: Sat, 7 Jun 2025 20:30:52 +0000 Subject: [PATCH 1/7] Fix: Prevent automatic logout when starting app offline Problem: The application would inadvertently log you out if it was started while offline. This occurred because the `fetchCurrentUser` action in the `authStore` would attempt to fetch your profile, and if this network request failed (as it does when offline), the catch block would unconditionally call `clearTokens()`. This removed the authentication token, effectively logging you out and preventing access to any cached data or offline functionality. Solution: I modified the `fetchCurrentUser` action in `fe/src/stores/auth.ts`: - The `catch` block now inspects the error. - `clearTokens()` is only called if the error is a specific HTTP authentication error from the server (401 Unauthorized or 403 Forbidden) when online. - For network errors (indicating offline status) or other non-auth HTTP errors, tokens are preserved. The user object (`user.value`) might remain null if no cached profile is available, but the authentication token itself is kept. This change allows the application to remain in a logged-in state when started offline. The service worker can then serve cached API responses, and you can view previously accessed data. Navigation guards rely on `isAuthenticated` (which now remains true offline as long as a token exists), so you are not incorrectly redirected to the login page. --- fe/src/stores/auth.ts | 24 +++++++++++++++++++++++- 1 file changed, 23 insertions(+), 1 deletion(-) diff --git a/fe/src/stores/auth.ts b/fe/src/stores/auth.ts index 1996562..7c6d386 100644 --- a/fe/src/stores/auth.ts +++ b/fe/src/stores/auth.ts @@ -57,6 +57,7 @@ export const useAuthStore = defineStore('auth', () => { const fetchCurrentUser = async () => { if (!accessToken.value) { + // No token, so definitely clear any residual state and return. clearTokens() return null } @@ -65,7 +66,28 @@ export const useAuthStore = defineStore('auth', () => { setUser(response.data) return response.data } catch (error: any) { - clearTokens() + // Check if the error is from an Axios request and has a response status + if (error.isAxiosError && error.response) { + const status = error.response.status + if (status === 401 || status === 403) { + // Authentication error from the server, clear tokens. + console.error('Authentication error fetching user, clearing tokens:', error) + clearTokens() + } else { + // Other HTTP error, log it but don't clear tokens. + // The user might be null, but the token remains for other cached calls. + console.error('HTTP error fetching user, token preserved:', error) + } + } else { + // Network error (offline) or other non-HTTP error. + // Log the error but preserve tokens. + // This allows the app to function with cached data if available. + console.error('Network or other error fetching user, token preserved:', error) + } + // In all error cases where tokens are not cleared, return null for the user object. + // The existing user object (if any) will remain until explicitly cleared or overwritten. + // If the intention is to clear the user object on any fetch error, uncomment the next line: + // setUser(null); return null } } -- 2.45.2 From 198222c3ffe2a28fc1d96ac8c563cfffbf79ed28 Mon Sep 17 00:00:00 2001 From: "google-labs-jules[bot]" <161369871+google-labs-jules[bot]@users.noreply.github.com> Date: Sat, 7 Jun 2025 20:40:49 +0000 Subject: [PATCH 2/7] feat: Add missing i18n translations for page components (partial) This commit introduces internationalization for several page components by identifying hardcoded strings, adding them to translation files, and updating the components to use translation keys. Processed pages: - fe/src/pages/AuthCallbackPage.vue: I internationalized an error message. - fe/src/pages/ChoresPage.vue: I internationalized console error messages and an input placeholder. - fe/src/pages/ErrorNotFound.vue: I found no missing translations. - fe/src/pages/GroupDetailPage.vue: I internationalized various UI elements (ARIA labels, button text, fallback user display names) and console/error messages. - fe/src/pages/GroupsPage.vue: I internationalized error messages and console logs. - fe/src/pages/IndexPage.vue: I found no missing user-facing translations. - fe/src/pages/ListDetailPage.vue: My analysis is complete, and I identified a console message and a fallback string for translation (implementation of changes for this page is pending). For each processed page where changes were needed: - I added new keys to `fe/src/i18n/en.json`. - I added corresponding placeholder keys `"[TRANSLATE] Original Text"` to `fe/src/i18n/de.json`, `fe/src/i18n/es.json`, and `fe/src/i18n/fr.json`. - I updated the Vue component to use the `t()` function with the new keys. Further pages in `fe/src/pages/` are pending analysis and internationalization as per our original plan. --- fe/src/i18n/de.json | 37 +++++++++++++++++++++++++++---- fe/src/i18n/en.json | 37 +++++++++++++++++++++++++++---- fe/src/i18n/es.json | 37 +++++++++++++++++++++++++++---- fe/src/i18n/fr.json | 37 +++++++++++++++++++++++++++---- fe/src/pages/AuthCallbackPage.vue | 2 +- fe/src/pages/ChoresPage.vue | 16 ++++++------- fe/src/pages/GroupDetailPage.vue | 26 ++++++++++------------ fe/src/pages/GroupsPage.vue | 6 ++--- 8 files changed, 156 insertions(+), 42 deletions(-) diff --git a/fe/src/i18n/de.json b/fe/src/i18n/de.json index ab6a3ff..49c77bf 100644 --- a/fe/src/i18n/de.json +++ b/fe/src/i18n/de.json @@ -73,7 +73,10 @@ "groupNameRequired": "DE: Group name is required", "createFailed": "DE: Failed to create group. Please try again.", "inviteCodeRequired": "DE: Invite code is required", - "joinFailed": "DE: Failed to join group. Please check the invite code and try again." + "joinFailed": "DE: Failed to join group. Please check the invite code and try again.", + "invalidDataFromServer": "[TRANSLATE] Invalid data received from server.", + "createFailedConsole": "[TRANSLATE] Error creating group:", + "joinFailedConsole": "[TRANSLATE] Error joining group:" }, "notifications": { "groupCreatedSuccess": "DE: Group '{groupName}' created successfully.", @@ -85,7 +88,8 @@ "authCallbackPage": { "redirecting": "DE: Redirecting...", "errors": { - "authenticationFailed": "DE: Authentication failed" + "authenticationFailed": "DE: Authentication failed", + "noTokenProvided": "[TRANSLATE] No token provided" } }, "choresPage": { @@ -165,7 +169,17 @@ "quickDueDateTomorrow": "DE: Tomorrow", "quickDueDateNextWeek": "DE: Next Week", "cancelButton": "DE: Cancel", - "saveButton": "DE: Save" + "saveButton": "DE: Save", + "intervalPlaceholder": "[TRANSLATE] e.g., 10" + }, + "consoleErrors": { + "loadFailed": "[TRANSLATE] Failed to load all chores:", + "loadGroupsFailed": "[TRANSLATE] Failed to load groups", + "createAssignmentForNewChoreFailed": "[TRANSLATE] Failed to create assignment for new chore:", + "saveFailed": "[TRANSLATE] Failed to save chore:", + "deleteFailed": "[TRANSLATE] Failed to delete chore:", + "createAssignmentFailed": "[TRANSLATE] Failed to create assignment:", + "updateCompletionStatusFailed": "[TRANSLATE] Failed to update chore completion status:" }, "deleteDialog": { "title": "DE: Delete Chore", @@ -228,10 +242,14 @@ "title": "DE: Group Members", "defaultRole": "DE: Member", "removeButton": "DE: Remove", - "emptyState": "DE: No members found." + "emptyState": "DE: No members found.", + "closeMenuLabel": "[TRANSLATE] Close menu" }, "invites": { "title": "DE: Invite Members", + "description": "[TRANSLATE] Invite new members by generating a shareable code.", + "addMemberButtonLabel": "[TRANSLATE] Add member", + "closeInviteLabel": "[TRANSLATE] Close invite", "regenerateButton": "DE: Regenerate Invite Code", "generateButton": "DE: Generate Invite Code", "activeCodeLabel": "DE: Current Active Invite Code:", @@ -242,6 +260,15 @@ "newDataInvalid": "DE: New invite code data is invalid." } }, + "errors": { + "failedToFetchActiveInvite": "[TRANSLATE] Failed to fetch active invite code.", + "failedToFetchGroupDetails": "[TRANSLATE] Failed to fetch group details.", + "failedToLoadUpcomingChores": "[TRANSLATE] Error loading upcoming chores:", + "failedToLoadRecentExpenses": "[TRANSLATE] Error loading recent expenses:" + }, + "console": { + "noActiveInvite": "[TRANSLATE] No active invite code found for this group." + }, "chores": { "title": "DE: Group Chores", "manageButton": "DE: Manage Chores", @@ -252,6 +279,8 @@ "title": "DE: Group Expenses", "manageButton": "DE: Manage Expenses", "emptyState": "DE: No expenses recorded. Click \"Manage Expenses\" to add some!", + "fallbackUserName": "[TRANSLATE] User ID: {userId}", + "activityByUserFallback": "[TRANSLATE] User {userId}", "splitTypes": { "equal": "DE: Equal", "exactAmounts": "DE: Exact Amounts", diff --git a/fe/src/i18n/en.json b/fe/src/i18n/en.json index 62afc82..57abdd0 100644 --- a/fe/src/i18n/en.json +++ b/fe/src/i18n/en.json @@ -73,7 +73,10 @@ "groupNameRequired": "Group name is required", "createFailed": "Failed to create group. Please try again.", "inviteCodeRequired": "Invite code is required", - "joinFailed": "Failed to join group. Please check the invite code and try again." + "joinFailed": "Failed to join group. Please check the invite code and try again.", + "invalidDataFromServer": "Invalid data received from server.", + "createFailedConsole": "Error creating group:", + "joinFailedConsole": "Error joining group:" }, "notifications": { "groupCreatedSuccess": "Group '{groupName}' created successfully.", @@ -85,7 +88,8 @@ "authCallbackPage": { "redirecting": "Redirecting...", "errors": { - "authenticationFailed": "Authentication failed" + "authenticationFailed": "Authentication failed", + "noTokenProvided": "No token provided" } }, "choresPage": { @@ -125,7 +129,17 @@ "save": "Save Changes", "create": "Create", "editChore": "Edit Chore", - "createChore": "Create Chore" + "createChore": "Create Chore", + "intervalPlaceholder": "e.g., 10" + }, + "consoleErrors": { + "loadFailed": "Failed to load all chores:", + "loadGroupsFailed": "Failed to load groups", + "createAssignmentForNewChoreFailed": "Failed to create assignment for new chore:", + "saveFailed": "Failed to save chore:", + "deleteFailed": "Failed to delete chore:", + "createAssignmentFailed": "Failed to create assignment:", + "updateCompletionStatusFailed": "Failed to update chore completion status:" }, "deleteConfirm": { "title": "Confirm Deletion", @@ -160,10 +174,14 @@ "title": "Group Members", "defaultRole": "Member", "removeButton": "Remove", - "emptyState": "No members found." + "emptyState": "No members found.", + "closeMenuLabel": "Close menu" }, "invites": { "title": "Invite Members", + "description": "Invite new members by generating a shareable code.", + "addMemberButtonLabel": "Add member", + "closeInviteLabel": "Close invite", "regenerateButton": "Regenerate Invite Code", "generateButton": "Generate Invite Code", "activeCodeLabel": "Current Active Invite Code:", @@ -174,6 +192,15 @@ "newDataInvalid": "New invite code data is invalid." } }, + "errors": { + "failedToFetchActiveInvite": "Failed to fetch active invite code.", + "failedToFetchGroupDetails": "Failed to fetch group details.", + "failedToLoadUpcomingChores": "Error loading upcoming chores:", + "failedToLoadRecentExpenses": "Error loading recent expenses:" + }, + "console": { + "noActiveInvite": "No active invite code found for this group." + }, "chores": { "title": "Group Chores", "manageButton": "Manage Chores", @@ -191,6 +218,8 @@ "settleShareButton": "Settle My Share", "activityLabel": "Activity:", "byUser": "by", + "fallbackUserName": "User ID: {userId}", + "activityByUserFallback": "User {userId}", "splitTypes": { "equal": "Equal", "exactAmounts": "Exact Amounts", diff --git a/fe/src/i18n/es.json b/fe/src/i18n/es.json index 665870e..5560da0 100644 --- a/fe/src/i18n/es.json +++ b/fe/src/i18n/es.json @@ -73,7 +73,10 @@ "groupNameRequired": "ES: Group name is required", "createFailed": "ES: Failed to create group. Please try again.", "inviteCodeRequired": "ES: Invite code is required", - "joinFailed": "ES: Failed to join group. Please check the invite code and try again." + "joinFailed": "ES: Failed to join group. Please check the invite code and try again.", + "invalidDataFromServer": "[TRANSLATE] Invalid data received from server.", + "createFailedConsole": "[TRANSLATE] Error creating group:", + "joinFailedConsole": "[TRANSLATE] Error joining group:" }, "notifications": { "groupCreatedSuccess": "ES: Group '{groupName}' created successfully.", @@ -85,7 +88,8 @@ "authCallbackPage": { "redirecting": "ES: Redirecting...", "errors": { - "authenticationFailed": "ES: Authentication failed" + "authenticationFailed": "ES: Authentication failed", + "noTokenProvided": "[TRANSLATE] No token provided" } }, "choresPage": { @@ -165,7 +169,17 @@ "quickDueDateTomorrow": "ES: Tomorrow", "quickDueDateNextWeek": "ES: Next Week", "cancelButton": "ES: Cancel", - "saveButton": "ES: Save" + "saveButton": "ES: Save", + "intervalPlaceholder": "[TRANSLATE] e.g., 10" + }, + "consoleErrors": { + "loadFailed": "[TRANSLATE] Failed to load all chores:", + "loadGroupsFailed": "[TRANSLATE] Failed to load groups", + "createAssignmentForNewChoreFailed": "[TRANSLATE] Failed to create assignment for new chore:", + "saveFailed": "[TRANSLATE] Failed to save chore:", + "deleteFailed": "[TRANSLATE] Failed to delete chore:", + "createAssignmentFailed": "[TRANSLATE] Failed to create assignment:", + "updateCompletionStatusFailed": "[TRANSLATE] Failed to update chore completion status:" }, "deleteDialog": { "title": "ES: Delete Chore", @@ -228,10 +242,14 @@ "title": "ES: Group Members", "defaultRole": "ES: Member", "removeButton": "ES: Remove", - "emptyState": "ES: No members found." + "emptyState": "ES: No members found.", + "closeMenuLabel": "[TRANSLATE] Close menu" }, "invites": { "title": "ES: Invite Members", + "description": "[TRANSLATE] Invite new members by generating a shareable code.", + "addMemberButtonLabel": "[TRANSLATE] Add member", + "closeInviteLabel": "[TRANSLATE] Close invite", "regenerateButton": "ES: Regenerate Invite Code", "generateButton": "ES: Generate Invite Code", "activeCodeLabel": "ES: Current Active Invite Code:", @@ -242,6 +260,15 @@ "newDataInvalid": "ES: New invite code data is invalid." } }, + "errors": { + "failedToFetchActiveInvite": "[TRANSLATE] Failed to fetch active invite code.", + "failedToFetchGroupDetails": "[TRANSLATE] Failed to fetch group details.", + "failedToLoadUpcomingChores": "[TRANSLATE] Error loading upcoming chores:", + "failedToLoadRecentExpenses": "[TRANSLATE] Error loading recent expenses:" + }, + "console": { + "noActiveInvite": "[TRANSLATE] No active invite code found for this group." + }, "chores": { "title": "ES: Group Chores", "manageButton": "ES: Manage Chores", @@ -252,6 +279,8 @@ "title": "ES: Group Expenses", "manageButton": "ES: Manage Expenses", "emptyState": "ES: No expenses recorded. Click \"Manage Expenses\" to add some!", + "fallbackUserName": "[TRANSLATE] User ID: {userId}", + "activityByUserFallback": "[TRANSLATE] User {userId}", "splitTypes": { "equal": "ES: Equal", "exactAmounts": "ES: Exact Amounts", diff --git a/fe/src/i18n/fr.json b/fe/src/i18n/fr.json index cf8c4fc..887eb41 100644 --- a/fe/src/i18n/fr.json +++ b/fe/src/i18n/fr.json @@ -73,7 +73,10 @@ "groupNameRequired": "FR: Group name is required", "createFailed": "FR: Failed to create group. Please try again.", "inviteCodeRequired": "FR: Invite code is required", - "joinFailed": "FR: Failed to join group. Please check the invite code and try again." + "joinFailed": "FR: Failed to join group. Please check the invite code and try again.", + "invalidDataFromServer": "[TRANSLATE] Invalid data received from server.", + "createFailedConsole": "[TRANSLATE] Error creating group:", + "joinFailedConsole": "[TRANSLATE] Error joining group:" }, "notifications": { "groupCreatedSuccess": "FR: Group '{groupName}' created successfully.", @@ -85,7 +88,8 @@ "authCallbackPage": { "redirecting": "FR: Redirecting...", "errors": { - "authenticationFailed": "FR: Authentication failed" + "authenticationFailed": "FR: Authentication failed", + "noTokenProvided": "[TRANSLATE] No token provided" } }, "choresPage": { @@ -165,7 +169,17 @@ "quickDueDateTomorrow": "FR: Tomorrow", "quickDueDateNextWeek": "FR: Next Week", "cancelButton": "FR: Cancel", - "saveButton": "FR: Save" + "saveButton": "FR: Save", + "intervalPlaceholder": "[TRANSLATE] e.g., 10" + }, + "consoleErrors": { + "loadFailed": "[TRANSLATE] Failed to load all chores:", + "loadGroupsFailed": "[TRANSLATE] Failed to load groups", + "createAssignmentForNewChoreFailed": "[TRANSLATE] Failed to create assignment for new chore:", + "saveFailed": "[TRANSLATE] Failed to save chore:", + "deleteFailed": "[TRANSLATE] Failed to delete chore:", + "createAssignmentFailed": "[TRANSLATE] Failed to create assignment:", + "updateCompletionStatusFailed": "[TRANSLATE] Failed to update chore completion status:" }, "deleteDialog": { "title": "FR: Delete Chore", @@ -228,10 +242,14 @@ "title": "FR: Group Members", "defaultRole": "FR: Member", "removeButton": "FR: Remove", - "emptyState": "FR: No members found." + "emptyState": "FR: No members found.", + "closeMenuLabel": "[TRANSLATE] Close menu" }, "invites": { "title": "FR: Invite Members", + "description": "[TRANSLATE] Invite new members by generating a shareable code.", + "addMemberButtonLabel": "[TRANSLATE] Add member", + "closeInviteLabel": "[TRANSLATE] Close invite", "regenerateButton": "FR: Regenerate Invite Code", "generateButton": "FR: Generate Invite Code", "activeCodeLabel": "FR: Current Active Invite Code:", @@ -242,6 +260,15 @@ "newDataInvalid": "FR: New invite code data is invalid." } }, + "errors": { + "failedToFetchActiveInvite": "[TRANSLATE] Failed to fetch active invite code.", + "failedToFetchGroupDetails": "[TRANSLATE] Failed to fetch group details.", + "failedToLoadUpcomingChores": "[TRANSLATE] Error loading upcoming chores:", + "failedToLoadRecentExpenses": "[TRANSLATE] Error loading recent expenses:" + }, + "console": { + "noActiveInvite": "[TRANSLATE] No active invite code found for this group." + }, "chores": { "title": "FR: Group Chores", "manageButton": "FR: Manage Chores", @@ -252,6 +279,8 @@ "title": "FR: Group Expenses", "manageButton": "FR: Manage Expenses", "emptyState": "FR: No expenses recorded. Click \"Manage Expenses\" to add some!", + "fallbackUserName": "[TRANSLATE] User ID: {userId}", + "activityByUserFallback": "[TRANSLATE] User {userId}", "splitTypes": { "equal": "FR: Equal", "exactAmounts": "FR: Exact Amounts", diff --git a/fe/src/pages/AuthCallbackPage.vue b/fe/src/pages/AuthCallbackPage.vue index 81ed4d7..d50a510 100644 --- a/fe/src/pages/AuthCallbackPage.vue +++ b/fe/src/pages/AuthCallbackPage.vue @@ -38,7 +38,7 @@ onMounted(async () => { const tokenToUse = accessToken || legacyToken; if (!tokenToUse) { - throw new Error('No token provided'); + throw new Error(t('authCallbackPage.errors.noTokenProvided')); } await authStore.setTokens({ access_token: tokenToUse, refresh_token: refreshToken }); diff --git a/fe/src/pages/ChoresPage.vue b/fe/src/pages/ChoresPage.vue index b4104b8..0ec5b6c 100644 --- a/fe/src/pages/ChoresPage.vue +++ b/fe/src/pages/ChoresPage.vue @@ -80,7 +80,7 @@ const loadChores = async () => { cachedChores.value = mappedChores; cachedTimestamp.value = Date.now() } catch (error) { - console.error('Failed to load all chores:', error) + console.error(t('choresPage.consoleErrors.loadFailed'), error) notificationStore.addNotification({ message: t('choresPage.notifications.loadFailed', 'Failed to load chores.'), type: 'error' }) } finally { isLoading.value = false @@ -91,7 +91,7 @@ const loadGroups = async () => { try { groups.value = await groupService.getUserGroups(); } catch (error) { - console.error("Failed to load groups", error); + console.error(t('choresPage.consoleErrors.loadGroupsFailed'), error); notificationStore.addNotification({ message: t('choresPage.notifications.loadGroupsFailed', 'Failed to load groups.'), type: 'error' }); } } @@ -227,7 +227,7 @@ const handleFormSubmit = async () => { due_date: createdChore.next_due_date }); } catch (assignmentError) { - console.error('Failed to create assignment for new chore:', assignmentError); + console.error(t('choresPage.consoleErrors.createAssignmentForNewChoreFailed'), assignmentError); // Continue anyway since the chore was created } } @@ -237,7 +237,7 @@ const handleFormSubmit = async () => { showChoreModal.value = false; await loadChores(); } catch (error) { - console.error('Failed to save chore:', error); + console.error(t('choresPage.consoleErrors.saveFailed'), error); notificationStore.addNotification({ message: t('choresPage.notifications.saveFailed', 'Failed to save the chore.'), type: 'error' }); } } @@ -255,7 +255,7 @@ const deleteChore = async () => { showDeleteDialog.value = false await loadChores() } catch (error) { - console.error('Failed to delete chore:', error) + console.error(t('choresPage.consoleErrors.deleteFailed'), error) notificationStore.addNotification({ message: t('choresPage.notifications.deleteFailed', 'Failed to delete chore.'), type: 'error' }) } } @@ -271,7 +271,7 @@ const toggleCompletion = async (chore: ChoreWithCompletion) => { }); chore.current_assignment_id = assignment.id; } catch (error) { - console.error('Failed to create assignment:', error); + console.error(t('choresPage.consoleErrors.createAssignmentFailed'), error); notificationStore.addNotification({ message: t('choresPage.notifications.createAssignmentFailed', 'Failed to create assignment for chore.'), type: 'error' @@ -299,7 +299,7 @@ const toggleCompletion = async (chore: ChoreWithCompletion) => { }); await loadChores(); } catch (error) { - console.error('Failed to update chore completion status:', error); + console.error(t('choresPage.consoleErrors.updateCompletionStatusFailed'), error); notificationStore.addNotification({ message: t('choresPage.notifications.updateFailed', 'Failed to update chore status.'), type: 'error' }); chore.is_completed = originalCompleted; } finally { @@ -403,7 +403,7 @@ const toggleCompletion = async (chore: ChoreWithCompletion) => { + class="form-input" :placeholder="t('choresPage.form.intervalPlaceholder')" min="1">
diff --git a/fe/src/pages/GroupDetailPage.vue b/fe/src/pages/GroupDetailPage.vue index 231454d..9c704be 100644 --- a/fe/src/pages/GroupDetailPage.vue +++ b/fe/src/pages/GroupDetailPage.vue @@ -21,7 +21,7 @@
@@ -47,9 +46,9 @@ {{ t('groupDetailPage.invites.title') }} + :aria-label="t('groupDetailPage.invites.closeInviteLabel')" />
-

Invite new members by generating a shareable code.

+

{{ t('groupDetailPage.invites.description') }}

{{ inviteCode ? t('groupDetailPage.invites.regenerateButton') : @@ -146,7 +145,7 @@
- {{ split.user?.name || split.user?.email || `User ID: ${split.user_id}` }} + {{ split.user?.name || split.user?.email || t('groupDetailPage.expenses.fallbackUserName', { userId: split.user_id }) }}
{{ t('groupDetailPage.expenses.owes') }} {{ @@ -178,8 +177,7 @@ {{ t('groupDetailPage.expenses.activityLabel') }} {{ formatCurrency(activity.amount_paid) }} {{ - t('groupDetailPage.expenses.byUser') }} {{ activity.payer?.name || `User - ${activity.paid_by_user_id}` }} {{ t('groupDetailPage.expenses.onDate') }} {{ new + t('groupDetailPage.expenses.byUser') }} {{ activity.payer?.name || t('groupDetailPage.expenses.activityByUserFallback', { userId: activity.paid_by_user_id }) }} {{ t('groupDetailPage.expenses.onDate') }} {{ new Date(activity.paid_at).toLocaleDateString() }} @@ -209,7 +207,7 @@

{{ t('groupDetailPage.settleShareModal.settleAmountFor', { userName: selectedSplitForSettlement?.user?.name - || selectedSplitForSettlement?.user?.email || `User ID: ${selectedSplitForSettlement?.user_id}` + || selectedSplitForSettlement?.user?.email || t('groupDetailPage.expenses.fallbackUserName', { userId: selectedSplitForSettlement?.user_id }) }) }}

@@ -383,9 +381,9 @@ const fetchActiveInviteCode = async () => { inviteCode.value = null; // Explicitly set to null on 404 inviteExpiresAt.value = null; // Optional: notify user or set a flag to show "generate one" message more prominently - console.info('No active invite code found for this group.'); + console.info(t('groupDetailPage.console.noActiveInvite')); } else { - const message = err instanceof Error ? err.message : 'Failed to fetch active invite code.'; + const message = err instanceof Error ? err.message : t('groupDetailPage.errors.failedToFetchActiveInvite'); // error.value = message; // This would display a large error banner, might be too much console.error('Error fetching active invite code:', err); notificationStore.addNotification({ message, type: 'error' }); @@ -418,7 +416,7 @@ const fetchGroupDetails = async () => { timestamp: Date.now(), }; } catch (err: unknown) { - const message = err instanceof Error ? err.message : 'Failed to fetch group details.'; + const message = err instanceof Error ? err.message : t('groupDetailPage.errors.failedToFetchGroupDetails'); // Only show the main error banner if we have no data at all to show if (!group.value) { error.value = message; @@ -524,7 +522,7 @@ const loadUpcomingChores = async () => { timestamp: Date.now() }; } catch (error) { - console.error('Error loading upcoming chores:', error) + console.error(t('groupDetailPage.errors.failedToLoadUpcomingChores'), error) } } @@ -563,7 +561,7 @@ const loadRecentExpenses = async () => { ) recentExpenses.value = response.data } catch (error) { - console.error('Error loading recent expenses:', error) + console.error(t('groupDetailPage.errors.failedToLoadRecentExpenses'), error) notificationStore.addNotification({ message: t('groupDetailPage.notifications.loadExpensesFailed'), type: 'error' }); } } diff --git a/fe/src/pages/GroupsPage.vue b/fe/src/pages/GroupsPage.vue index d08674d..b50482b 100644 --- a/fe/src/pages/GroupsPage.vue +++ b/fe/src/pages/GroupsPage.vue @@ -281,12 +281,12 @@ const handleCreateGroup = async () => { cachedGroups.value = groups.value; cachedTimestamp.value = Date.now(); } else { - throw new Error('Invalid data received from server.'); + throw new Error(t('groupsPage.errors.invalidDataFromServer')); } } catch (error: any) { const message = error.response?.data?.detail || (error instanceof Error ? error.message : t('groupsPage.errors.createFailed')); createGroupFormError.value = message; - console.error('Error creating group:', error); + console.error(t('groupsPage.errors.createFailedConsole'), error); notificationStore.addNotification({ message, type: 'error' }); } finally { creatingGroup.value = false; @@ -327,7 +327,7 @@ const handleJoinGroup = async () => { } catch (error: any) { const message = error.response?.data?.detail || (error instanceof Error ? error.message : t('groupsPage.errors.joinFailed')); joinGroupFormError.value = message; - console.error('Error joining group:', error); + console.error(t('groupsPage.errors.joinFailedConsole'), error); notificationStore.addNotification({ message, type: 'error' }); } finally { joiningGroup.value = false; -- 2.45.2 From f8788ee42df01b908635b425b56d8c2c8005606e Mon Sep 17 00:00:00 2001 From: mohamad Date: Sun, 8 Jun 2025 00:03:38 +0200 Subject: [PATCH 3/7] feat: Add Dutch translations and update de, es, fr --- fe/src/i18n/de.json | 853 ++++++++++++++++++++++-------------------- fe/src/i18n/es.json | 855 +++++++++++++++++++++++-------------------- fe/src/i18n/fr.json | 853 ++++++++++++++++++++++-------------------- fe/src/i18n/index.ts | 2 + fe/src/i18n/nl.json | 631 +++++++++++++++++++++++++++++++ fe/src/main.ts | 1 + 6 files changed, 1976 insertions(+), 1219 deletions(-) create mode 100644 fe/src/i18n/nl.json diff --git a/fe/src/i18n/de.json b/fe/src/i18n/de.json index 49c77bf..31586fc 100644 --- a/fe/src/i18n/de.json +++ b/fe/src/i18n/de.json @@ -3,588 +3,629 @@ "hello": "Hallo" }, "loginPage": { - "emailLabel": "DE: Email", - "passwordLabel": "DE: Password", - "togglePasswordVisibilityLabel": "DE: Toggle password visibility", - "loginButton": "DE: Login", - "signupLink": "DE: Don't have an account? Sign up", + "emailLabel": "E-Mail", + "passwordLabel": "Passwort", + "togglePasswordVisibilityLabel": "Passwort-Sichtbarkeit umschalten", + "loginButton": "Anmelden", + "signupLink": "Kein Konto? Registrieren", "errors": { - "emailRequired": "DE: Email is required", - "emailInvalid": "DE: Invalid email format", - "passwordRequired": "DE: Password is required", - "loginFailed": "DE: Login failed. Please check your credentials." + "emailRequired": "E-Mail ist erforderlich", + "emailInvalid": "Ungültiges E-Mail-Format", + "passwordRequired": "Passwort ist erforderlich", + "loginFailed": "Anmeldung fehlgeschlagen. Bitte überprüfen Sie Ihre Anmeldedaten." }, "notifications": { - "loginSuccess": "DE: Login successful" + "loginSuccess": "Anmeldung erfolgreich" } }, "listsPage": { - "retryButton": "DE: Retry", + "retryButton": "Erneut versuchen", "emptyState": { - "noListsForGroup": "DE: No lists found for this group.", - "noListsYet": "DE: You have no lists yet.", - "personalGlobalInfo": "DE: Create a personal list or join a group to see shared lists.", - "groupSpecificInfo": "DE: This group doesn't have any lists yet." + "noListsForGroup": "Keine Listen für diese Gruppe gefunden.", + "noListsYet": "Sie haben noch keine Listen.", + "personalGlobalInfo": "Erstellen Sie eine persönliche Liste oder treten Sie einer Gruppe bei, um geteilte Listen zu sehen.", + "groupSpecificInfo": "Diese Gruppe hat noch keine Listen." }, - "createNewListButton": "DE: Create New List", - "loadingLists": "DE: Loading lists...", - "noDescription": "DE: No description", - "addItemPlaceholder": "DE: Add new item...", + "createNewListButton": "Neue Liste erstellen", + "loadingLists": "Listen werden geladen...", + "noDescription": "Keine Beschreibung", + "addItemPlaceholder": "Neues Element hinzufügen...", "createCard": { - "title": "DE: + Create a new list" + "title": "+ Liste" }, "pageTitle": { - "forGroup": "DE: Lists for {groupName}", - "forGroupId": "DE: Lists for Group {groupId}", - "myLists": "DE: My Lists" + "forGroup": "Listen für {groupName}", + "forGroupId": "Listen für Gruppe {groupId}", + "myLists": "Meine Listen" }, "errors": { - "fetchFailed": "DE: Failed to fetch lists." + "fetchFailed": "Abrufen der Listen fehlgeschlagen." } }, "groupsPage": { - "retryButton": "DE: Retry", + "retryButton": "Erneut versuchen", "emptyState": { - "title": "DE: No Groups Yet!", - "description": "DE: You are not a member of any groups yet. Create one or join using an invite code.", - "createButton": "DE: Create New Group" + "title": "Noch keine Gruppen!", + "description": "Sie sind noch kein Mitglied einer Gruppe. Erstellen Sie eine oder treten Sie mit einem Einladungscode bei.", + "createButton": "Neue Gruppe erstellen" }, "groupCard": { - "newListButton": "DE: List" + "newListButton": "Liste" }, "createCard": { - "title": "DE: + Group" + "title": "+ Gruppe" }, "joinGroup": { - "title": "DE: Join a Group with Invite Code", - "inputLabel": "DE: Enter Invite Code", - "inputPlaceholder": "DE: Enter Invite Code", - "joinButton": "DE: Join" + "title": "Gruppe mit Einladungscode beitreten", + "inputLabel": "Einladungscode eingeben", + "inputPlaceholder": "Einladungscode eingeben", + "joinButton": "Beitreten" }, "createDialog": { - "title": "DE: Create New Group", - "closeButtonLabel": "DE: Close", - "groupNameLabel": "DE: Group Name", - "cancelButton": "DE: Cancel", - "createButton": "DE: Create" + "title": "Neue Gruppe erstellen", + "closeButtonLabel": "Schließen", + "groupNameLabel": "Gruppenname", + "cancelButton": "Abbrechen", + "createButton": "Erstellen" }, "errors": { - "fetchFailed": "DE: Failed to load groups", - "groupNameRequired": "DE: Group name is required", - "createFailed": "DE: Failed to create group. Please try again.", - "inviteCodeRequired": "DE: Invite code is required", - "joinFailed": "DE: Failed to join group. Please check the invite code and try again.", - "invalidDataFromServer": "[TRANSLATE] Invalid data received from server.", - "createFailedConsole": "[TRANSLATE] Error creating group:", - "joinFailedConsole": "[TRANSLATE] Error joining group:" + "fetchFailed": "Laden der Gruppen fehlgeschlagen", + "groupNameRequired": "Gruppenname ist erforderlich", + "createFailed": "Erstellen der Gruppe fehlgeschlagen. Bitte versuchen Sie es erneut.", + "inviteCodeRequired": "Einladungscode ist erforderlich", + "joinFailed": "Beitritt zur Gruppe fehlgeschlagen. Bitte überprüfen Sie den Einladungscode und versuchen Sie es erneut.", + "invalidDataFromServer": "Ungültige Daten vom Server empfangen.", + "createFailedConsole": "Fehler beim Erstellen der Gruppe:", + "joinFailedConsole": "Fehler beim Beitreten zur Gruppe:" }, "notifications": { - "groupCreatedSuccess": "DE: Group '{groupName}' created successfully.", - "joinSuccessNamed": "DE: Successfully joined group '{groupName}'.", - "joinSuccessGeneric": "DE: Successfully joined group.", - "listCreatedSuccess": "DE: List '{listName}' created successfully." + "groupCreatedSuccess": "Gruppe '{groupName}' erfolgreich erstellt.", + "joinSuccessNamed": "Erfolgreich der Gruppe '{groupName}' beigetreten.", + "joinSuccessGeneric": "Erfolgreich der Gruppe beigetreten.", + "listCreatedSuccess": "Liste '{listName}' erfolgreich erstellt." } }, "authCallbackPage": { - "redirecting": "DE: Redirecting...", + "redirecting": "Weiterleitung...", "errors": { - "authenticationFailed": "DE: Authentication failed", - "noTokenProvided": "[TRANSLATE] No token provided" + "authenticationFailed": "Authentifizierung fehlgeschlagen", + "noTokenProvided": "Kein Token bereitgestellt" } }, "choresPage": { - "title": "DE: Chores", + "title": "Aufgaben", "tabs": { - "overdue": "DE: Overdue", - "today": "DE: Today", - "upcoming": "DE: Upcoming", - "allPending": "DE: All Pending", - "completed": "DE: Completed" + "overdue": "Überfällig", + "today": "Heute", + "upcoming": "Anstehend", + "allPending": "Alle ausstehenden", + "completed": "Abgeschlossen" }, "viewToggle": { - "calendarLabel": "DE: Calendar View", - "calendarText": "DE: Calendar", - "listLabel": "DE: List View", - "listText": "DE: List" + "calendarLabel": "Kalenderansicht", + "calendarText": "Kalender", + "listLabel": "Listenansicht", + "listText": "Liste" }, - "newChoreButtonLabel": "DE: New Chore", - "newChoreButtonText": "DE: New Chore", + "newChoreButtonLabel": "Neue Aufgabe", + "newChoreButtonText": "Neue Aufgabe", "loadingState": { - "loadingChores": "DE: Loading chores..." + "loadingChores": "Aufgaben werden geladen..." }, "calendar": { - "prevMonthLabel": "DE: Previous month", - "nextMonthLabel": "DE: Next month", + "prevMonthLabel": "Vorheriger Monat", + "nextMonthLabel": "Nächster Monat", "weekdays": { - "sun": "DE: Sun", - "mon": "DE: Mon", - "tue": "DE: Tue", - "wed": "DE: Wed", - "thu": "DE: Thu", - "fri": "DE: Fri", - "sat": "DE: Sat" + "sun": "So", + "mon": "Mo", + "tue": "Di", + "wed": "Mi", + "thu": "Do", + "fri": "Fr", + "sat": "Sa" }, - "addChoreToDayLabel": "DE: Add chore to this day", - "emptyState": "DE: No chores to display for this period." + "addChoreToDayLabel": "Aufgabe zu diesem Tag hinzufügen", + "emptyState": "Keine Aufgaben für diesen Zeitraum anzuzeigen." }, "listView": { - "choreTypePersonal": "DE: Personal", - "choreTypeGroupFallback": "DE: Group", - "completedDatePrefix": "DE: Completed:", + "choreTypePersonal": "Persönlich", + "choreTypeGroupFallback": "Gruppe", + "completedDatePrefix": "Abgeschlossen:", "actions": { - "doneTitle": "DE: Mark as Done", - "doneText": "DE: Done", - "undoTitle": "DE: Mark as Not Done", - "undoText": "DE: Undo", - "editTitle": "DE: Edit", - "editLabel": "DE: Edit chore", - "editText": "DE: Edit", - "deleteTitle": "DE: Delete", - "deleteLabel": "DE: Delete chore", - "deleteText": "DE: Delete" + "doneTitle": "Als erledigt markieren", + "doneText": "Erledigt", + "undoTitle": "Als nicht erledigt markieren", + "undoText": "Rückgängig", + "editTitle": "Bearbeiten", + "editLabel": "Aufgabe bearbeiten", + "editText": "Bearbeiten", + "deleteTitle": "Löschen", + "deleteLabel": "Aufgabe löschen", + "deleteText": "Löschen" }, "emptyState": { - "message": "DE: No chores in this view. Well done!", - "viewAllButton": "DE: View All Pending" + "message": "Keine Aufgaben in dieser Ansicht. Gut gemacht!", + "viewAllButton": "Alle ausstehenden anzeigen" } }, "choreModal": { - "editTitle": "DE: Edit Chore", - "newTitle": "DE: New Chore", - "closeButtonLabel": "DE: Close modal", - "nameLabel": "DE: Name", - "namePlaceholder": "DE: Enter chore name", - "typeLabel": "DE: Type", - "typePersonal": "DE: Personal", - "typeGroup": "DE: Group", - "groupLabel": "DE: Group", - "groupSelectDefault": "DE: Select a group", - "descriptionLabel": "DE: Description", - "descriptionPlaceholder": "DE: Add a description (optional)", - "frequencyLabel": "DE: Frequency", - "intervalLabel": "DE: Interval (days)", - "intervalPlaceholder": "DE: e.g. 3", - "dueDateLabel": "DE: Due Date", - "quickDueDateToday": "DE: Today", - "quickDueDateTomorrow": "DE: Tomorrow", - "quickDueDateNextWeek": "DE: Next Week", - "cancelButton": "DE: Cancel", - "saveButton": "DE: Save", - "intervalPlaceholder": "[TRANSLATE] e.g., 10" + "editTitle": "Aufgabe bearbeiten", + "newTitle": "Neue Aufgabe", + "closeButtonLabel": "Modal schließen", + "nameLabel": "Name", + "namePlaceholder": "Aufgabennamen eingeben", + "typeLabel": "Typ", + "typePersonal": "Persönlich", + "typeGroup": "Gruppe", + "groupLabel": "Gruppe", + "groupSelectDefault": "Gruppe auswählen", + "descriptionLabel": "Beschreibung", + "descriptionPlaceholder": "Beschreibung hinzufügen (optional)", + "frequencyLabel": "Häufigkeit", + "intervalLabel": "Intervall (Tage)", + "intervalPlaceholder": "z.B. 10", + "dueDateLabel": "Fälligkeitsdatum", + "quickDueDateToday": "Heute", + "quickDueDateTomorrow": "Morgen", + "quickDueDateNextWeek": "Nächste Woche", + "cancelButton": "Abbrechen", + "saveButton": "Speichern" }, "consoleErrors": { - "loadFailed": "[TRANSLATE] Failed to load all chores:", - "loadGroupsFailed": "[TRANSLATE] Failed to load groups", - "createAssignmentForNewChoreFailed": "[TRANSLATE] Failed to create assignment for new chore:", - "saveFailed": "[TRANSLATE] Failed to save chore:", - "deleteFailed": "[TRANSLATE] Failed to delete chore:", - "createAssignmentFailed": "[TRANSLATE] Failed to create assignment:", - "updateCompletionStatusFailed": "[TRANSLATE] Failed to update chore completion status:" + "loadFailed": "Laden aller Aufgaben fehlgeschlagen:", + "loadGroupsFailed": "Laden der Gruppen fehlgeschlagen", + "createAssignmentForNewChoreFailed": "Zuweisung für neue Aufgabe konnte nicht erstellt werden:", + "saveFailed": "Speichern der Aufgabe fehlgeschlagen:", + "deleteFailed": "Löschen der Aufgabe fehlgeschlagen:", + "createAssignmentFailed": "Zuweisung konnte nicht erstellt werden:", + "updateCompletionStatusFailed": "Abschlussstatus der Aufgabe konnte nicht aktualisiert werden:" }, "deleteDialog": { - "title": "DE: Delete Chore", - "confirmationText": "DE: Are you sure you want to delete this chore? This action cannot be undone.", - "deleteButton": "DE: Delete" + "title": "Aufgabe löschen", + "confirmationText": "Sind Sie sicher, dass Sie diese Aufgabe löschen möchten? Diese Aktion kann nicht rückgängig gemacht werden.", + "deleteButton": "Löschen" }, "shortcutsModal": { - "title": "DE: Keyboard Shortcuts", - "descNewChore": "DE: New Chore", - "descToggleView": "DE: Toggle View (List/Calendar)", - "descToggleShortcuts": "DE: Show/Hide Shortcuts", - "descCloseModal": "DE: Close any open Modal/Dialog" + "title": "Tastaturkürzel", + "descNewChore": "Neue Aufgabe", + "descToggleView": "Ansicht umschalten (Liste/Kalender)", + "descToggleShortcuts": "Kürzel anzeigen/ausblenden", + "descCloseModal": "Geöffnetes Modal/Dialog schließen" }, "frequencyOptions": { - "oneTime": "DE: One Time", - "daily": "DE: Daily", - "weekly": "DE: Weekly", - "monthly": "DE: Monthly", - "custom": "DE: Custom" + "oneTime": "Einmalig", + "daily": "Täglich", + "weekly": "Wöchentlich", + "monthly": "Monatlich", + "custom": "Benutzerdefiniert" + }, + "frequency": { + "customInterval": "Alle {n} Tag | Alle {n} Tage" }, "formatters": { - "noDueDate": "DE: No due date", - "dueToday": "DE: Due Today", - "dueTomorrow": "DE: Due Tomorrow", - "overdueFull": "DE: Overdue: {date}", - "dueFull": "DE: Due {date}", - "invalidDate": "DE: Invalid Date" + "noDueDate": "Kein Fälligkeitsdatum", + "dueToday": "Heute fällig", + "dueTomorrow": "Morgen fällig", + "overdueFull": "Überfällig: {date}", + "dueFull": "Fällig am {date}", + "invalidDate": "Ungültiges Datum" }, "notifications": { - "loadFailed": "DE: Failed to load chores", - "updateSuccess": "DE: Chore '{name}' updated successfully", - "createSuccess": "DE: Chore '{name}' created successfully", - "updateFailed": "DE: Failed to update chore", - "createFailed": "DE: Failed to create chore", - "deleteSuccess": "DE: Chore '{name}' deleted successfully", - "deleteFailed": "DE: Failed to delete chore", - "markedDone": "DE: {name} marked as done.", - "markedNotDone": "DE: {name} marked as not done.", - "statusUpdateFailed": "DE: Failed to update chore status." + "loadFailed": "Laden der Aufgaben fehlgeschlagen.", + "loadGroupsFailed": "Laden der Gruppen fehlgeschlagen.", + "updateSuccess": "Aufgabe '{name}' erfolgreich aktualisiert.", + "createSuccess": "Aufgabe '{name}' erfolgreich erstellt.", + "updateFailed": "Aktualisieren der Aufgabe fehlgeschlagen.", + "createFailed": "Erstellen der Aufgabe fehlgeschlagen.", + "deleteSuccess": "Aufgabe '{name}' erfolgreich gelöscht.", + "deleteFailed": "Löschen der Aufgabe fehlgeschlagen.", + "markedDone": "{name} als erledigt markiert.", + "markedNotDone": "{name} als nicht erledigt markiert.", + "statusUpdateFailed": "Status der Aufgabe konnte nicht aktualisiert werden.", + "createAssignmentFailed": "Zuweisung für Aufgabe konnte nicht erstellt werden." }, "validation": { - "nameRequired": "DE: Chore name is required.", - "groupRequired": "DE: Please select a group for group chores.", - "intervalRequired": "DE: Custom interval must be at least 1 day.", - "dueDateRequired": "DE: Due date is required.", - "invalidDueDate": "DE: Invalid due date format." + "nameRequired": "Aufgabenname ist erforderlich.", + "groupRequired": "Bitte wählen Sie eine Gruppe für Gruppenaufgaben.", + "intervalRequired": "Benutzerdefiniertes Intervall muss mindestens 1 Tag betragen.", + "dueDateRequired": "Fälligkeitsdatum ist erforderlich.", + "invalidDueDate": "Ungültiges Fälligkeitsdatumformat." }, - "unsavedChangesConfirmation": "DE: You have unsaved changes in the chore form. Are you sure you want to leave?" + "unsavedChangesConfirmation": "Sie haben ungespeicherte Änderungen im Aufgabenformular. Sind Sie sicher, dass Sie die Seite verlassen möchten?" }, "errorNotFoundPage": { - "errorCode": "DE: 404", - "errorMessage": "DE: Oops. Nothing here...", - "goHomeButton": "DE: Go Home" + "errorCode": "404", + "errorMessage": "Upps. Hier gibt es nichts...", + "goHomeButton": "Zur Startseite" }, "groupDetailPage": { - "loadingLabel": "DE: Loading group details...", - "retryButton": "DE: Retry", - "groupNotFound": "DE: Group not found or an error occurred.", + "loadingLabel": "Gruppendetails werden geladen...", + "retryButton": "Erneut versuchen", + "groupNotFound": "Gruppe nicht gefunden oder ein Fehler ist aufgetreten.", "members": { - "title": "DE: Group Members", - "defaultRole": "DE: Member", - "removeButton": "DE: Remove", - "emptyState": "DE: No members found.", - "closeMenuLabel": "[TRANSLATE] Close menu" + "title": "Gruppenmitglieder", + "defaultRole": "Mitglied", + "removeButton": "Entfernen", + "emptyState": "Keine Mitglieder gefunden.", + "closeMenuLabel": "Menü schließen" }, "invites": { - "title": "DE: Invite Members", - "description": "[TRANSLATE] Invite new members by generating a shareable code.", - "addMemberButtonLabel": "[TRANSLATE] Add member", - "closeInviteLabel": "[TRANSLATE] Close invite", - "regenerateButton": "DE: Regenerate Invite Code", - "generateButton": "DE: Generate Invite Code", - "activeCodeLabel": "DE: Current Active Invite Code:", - "copyButtonLabel": "DE: Copy invite code", - "copySuccess": "DE: Invite code copied to clipboard!", - "emptyState": "DE: No active invite code. Click the button above to generate one.", + "title": "Mitglieder einladen", + "description": "Laden Sie neue Mitglieder ein, indem Sie einen teilbaren Code generieren.", + "addMemberButtonLabel": "Mitglied hinzufügen", + "closeInviteLabel": "Einladung schließen", + "regenerateButton": "Einladungscode neu generieren", + "generateButton": "Einladungscode generieren", + "activeCodeLabel": "Aktueller aktiver Einladungscode:", + "copyButtonLabel": "Einladungscode kopieren", + "copySuccess": "Einladungscode in die Zwischenablage kopiert!", + "emptyState": "Kein aktiver Einladungscode. Klicken Sie auf die Schaltfläche oben, um einen zu generieren.", "errors": { - "newDataInvalid": "DE: New invite code data is invalid." + "newDataInvalid": "Daten des neuen Einladungscodes sind ungültig." } }, "errors": { - "failedToFetchActiveInvite": "[TRANSLATE] Failed to fetch active invite code.", - "failedToFetchGroupDetails": "[TRANSLATE] Failed to fetch group details.", - "failedToLoadUpcomingChores": "[TRANSLATE] Error loading upcoming chores:", - "failedToLoadRecentExpenses": "[TRANSLATE] Error loading recent expenses:" + "failedToFetchActiveInvite": "Abrufen des aktiven Einladungscodes fehlgeschlagen.", + "failedToFetchGroupDetails": "Abrufen der Gruppendetails fehlgeschlagen.", + "failedToLoadUpcomingChores": "Fehler beim Laden anstehender Aufgaben:", + "failedToLoadRecentExpenses": "Fehler beim Laden der letzten Ausgaben:" }, "console": { - "noActiveInvite": "[TRANSLATE] No active invite code found for this group." + "noActiveInvite": "Kein aktiver Einladungscode für diese Gruppe gefunden." }, "chores": { - "title": "DE: Group Chores", - "manageButton": "DE: Manage Chores", - "duePrefix": "DE: Due:", - "emptyState": "DE: No chores scheduled. Click \"Manage Chores\" to create some!" + "title": "Gruppenaufgaben", + "manageButton": "Aufgaben verwalten", + "duePrefix": "Fällig:", + "emptyState": "Keine Aufgaben geplant. Klicken Sie auf \"Aufgaben verwalten\", um welche zu erstellen!" }, "expenses": { - "title": "DE: Group Expenses", - "manageButton": "DE: Manage Expenses", - "emptyState": "DE: No expenses recorded. Click \"Manage Expenses\" to add some!", - "fallbackUserName": "[TRANSLATE] User ID: {userId}", - "activityByUserFallback": "[TRANSLATE] User {userId}", + "title": "Gruppenausgaben", + "manageButton": "Ausgaben verwalten", + "emptyState": "Keine Ausgaben erfasst. Klicken Sie auf \"Ausgaben verwalten\", um welche hinzuzufügen!", + "paidBy": "Bezahlt von:", + "owes": "schuldet", + "paidAmount": "Bezahlt:", + "onDate": "am", + "settleShareButton": "Meinen Anteil begleichen", + "activityLabel": "Aktivität:", + "byUser": "von", + "fallbackUserName": "Benutzer-ID: {userId}", + "activityByUserFallback": "Benutzer {userId}", "splitTypes": { - "equal": "DE: Equal", - "exactAmounts": "DE: Exact Amounts", - "percentage": "DE: Percentage", - "shares": "DE: Shares", - "itemBased": "DE: Item Based" + "equal": "Gleichmäßig", + "exactAmounts": "Genaue Beträge", + "percentage": "Prozentual", + "shares": "Anteile", + "itemBased": "Artikelbasiert" } }, "notifications": { - "fetchDetailsFailed": "DE: Failed to fetch group details.", - "fetchInviteFailed": "DE: Failed to fetch active invite code.", - "generateInviteSuccess": "DE: New invite code generated successfully!", - "generateInviteError": "DE: Failed to generate invite code.", - "clipboardNotSupported": "DE: Clipboard not supported or no code to copy.", - "copyInviteFailed": "DE: Failed to copy invite code.", - "removeMemberSuccess": "DE: Member removed successfully", - "removeMemberFailed": "DE: Failed to remove member" + "fetchDetailsFailed": "Abrufen der Gruppendetails fehlgeschlagen.", + "fetchInviteFailed": "Abrufen des aktiven Einladungscodes fehlgeschlagen.", + "generateInviteSuccess": "Neuer Einladungscode erfolgreich generiert!", + "generateInviteError": "Generieren des Einladungscodes fehlgeschlagen.", + "clipboardNotSupported": "Zwischenablage nicht unterstützt oder kein Code zum Kopieren vorhanden.", + "copyInviteFailed": "Kopieren des Einladungscodes fehlgeschlagen.", + "removeMemberSuccess": "Mitglied erfolgreich entfernt", + "removeMemberFailed": "Entfernen des Mitglieds fehlgeschlagen", + "loadExpensesFailed": "Laden der letzten Ausgaben fehlgeschlagen.", + "cannotSettleOthersShares": "Sie können nur Ihre eigenen Anteile begleichen.", + "settlementDataMissing": "Abrechnung kann nicht verarbeitet werden: fehlende Daten.", + "settleShareSuccess": "Anteil erfolgreich beglichen!", + "settleShareFailed": "Begleichen des Anteils fehlgeschlagen." + }, + "loading": { + "settlement": "Abrechnung wird verarbeitet..." + }, + "settleShareModal": { + "title": "Anteil begleichen", + "settleAmountFor": "Betrag für {userName} begleichen:", + "amountLabel": "Betrag", + "cancelButton": "Abbrechen", + "confirmButton": "Bestätigen", + "errors": { + "enterAmount": "Bitte geben Sie einen Betrag ein.", + "positiveAmount": "Bitte geben Sie einen positiven Betrag ein.", + "exceedsRemaining": "Betrag darf den Restbetrag nicht überschreiten: {amount}.", + "noSplitSelected": "Fehler: Keine Aufteilung ausgewählt." + } + }, + "status": { + "settled": "Beglichen", + "partiallySettled": "Teilweise beglichen", + "unsettled": "Unbeglichen", + "paid": "Bezahlt", + "partiallyPaid": "Teilweise bezahlt", + "unpaid": "Unbezahlt", + "unknown": "Unbekannter Status" } }, "accountPage": { - "title": "Account Settings", - "loadingProfile": "Loading profile...", - "retryButton": "Retry", + "title": "Kontoeinstellungen", + "loadingProfile": "Profil wird geladen...", + "retryButton": "Erneut versuchen", "profileSection": { - "header": "Profile Information", + "header": "Profilinformationen", "nameLabel": "Name", - "emailLabel": "Email", - "saveButton": "Save Changes" + "emailLabel": "E-Mail", + "saveButton": "Änderungen speichern" }, "passwordSection": { - "header": "Change Password", - "currentPasswordLabel": "Current Password", - "newPasswordLabel": "New Password", - "changeButton": "Change Password" + "header": "Passwort ändern", + "currentPasswordLabel": "Aktuelles Passwort", + "newPasswordLabel": "Neues Passwort", + "changeButton": "Passwort ändern" }, "notificationsSection": { - "header": "Notification Preferences", - "emailNotificationsLabel": "Email Notifications", - "emailNotificationsDescription": "Receive email notifications for important updates", - "listUpdatesLabel": "List Updates", - "listUpdatesDescription": "Get notified when lists are updated", - "groupActivitiesLabel": "Group Activities", - "groupActivitiesDescription": "Receive notifications for group activities" + "header": "Benachrichtigungseinstellungen", + "emailNotificationsLabel": "E-Mail-Benachrichtigungen", + "emailNotificationsDescription": "E-Mail-Benachrichtigungen für wichtige Updates erhalten", + "listUpdatesLabel": "Listen-Updates", + "listUpdatesDescription": "Benachrichtigungen erhalten, wenn Listen aktualisiert werden", + "groupActivitiesLabel": "Gruppenaktivitäten", + "groupActivitiesDescription": "Benachrichtigungen für Gruppenaktivitäten erhalten" }, "notifications": { - "profileLoadFailed": "Failed to load profile", - "profileUpdateSuccess": "Profile updated successfully", - "profileUpdateFailed": "Failed to update profile", - "passwordFieldsRequired": "Please fill in both current and new password fields.", - "passwordTooShort": "New password must be at least 8 characters long.", - "passwordChangeSuccess": "Password changed successfully", - "passwordChangeFailed": "Failed to change password", - "preferencesUpdateSuccess": "Preferences updated successfully", - "preferencesUpdateFailed": "Failed to update preferences" + "profileLoadFailed": "Laden des Profils fehlgeschlagen", + "profileUpdateSuccess": "Profil erfolgreich aktualisiert", + "profileUpdateFailed": "Aktualisieren des Profils fehlgeschlagen", + "passwordFieldsRequired": "Bitte füllen Sie sowohl das aktuelle als auch das neue Passwortfeld aus.", + "passwordTooShort": "Neues Passwort muss mindestens 8 Zeichen lang sein.", + "passwordChangeSuccess": "Passwort erfolgreich geändert", + "passwordChangeFailed": "Ändern des Passworts fehlgeschlagen", + "preferencesUpdateSuccess": "Einstellungen erfolgreich aktualisiert", + "preferencesUpdateFailed": "Aktualisieren der Einstellungen fehlgeschlagen" }, - "saving": "Saving..." + "saving": "Speichern..." }, "signupPage": { - "header": "Sign Up", - "fullNameLabel": "Full Name", - "emailLabel": "Email", - "passwordLabel": "Password", - "confirmPasswordLabel": "Confirm Password", - "togglePasswordVisibility": "Toggle password visibility", - "submitButton": "Sign Up", - "loginLink": "Already have an account? Login", + "header": "Registrieren", + "fullNameLabel": "Vollständiger Name", + "emailLabel": "E-Mail", + "passwordLabel": "Passwort", + "confirmPasswordLabel": "Passwort bestätigen", + "togglePasswordVisibility": "Passwort-Sichtbarkeit umschalten", + "submitButton": "Registrieren", + "loginLink": "Bereits ein Konto? Anmelden", "validation": { - "nameRequired": "Name is required", - "emailRequired": "Email is required", - "emailInvalid": "Invalid email format", - "passwordRequired": "Password is required", - "passwordLength": "Password must be at least 8 characters", - "confirmPasswordRequired": "Please confirm your password", - "passwordsNoMatch": "Passwords do not match" + "nameRequired": "Name ist erforderlich", + "emailRequired": "E-Mail ist erforderlich", + "emailInvalid": "Ungültiges E-Mail-Format", + "passwordRequired": "Passwort ist erforderlich", + "passwordLength": "Passwort muss mindestens 8 Zeichen lang sein", + "confirmPasswordRequired": "Bitte bestätigen Sie Ihr Passwort", + "passwordsNoMatch": "Passwörter stimmen nicht überein" }, "notifications": { - "signupFailed": "Signup failed. Please try again.", - "signupSuccess": "Account created successfully. Please login." + "signupFailed": "Registrierung fehlgeschlagen. Bitte versuchen Sie es erneut.", + "signupSuccess": "Konto erfolgreich erstellt. Bitte anmelden." } }, "listDetailPage": { "loading": { - "list": "Loading list...", - "items": "Loading items...", - "ocrProcessing": "Processing image...", - "addingOcrItems": "Adding OCR items...", - "costSummary": "Loading summary...", - "expenses": "Loading expenses...", - "settlement": "Processing settlement..." + "list": "Liste wird geladen...", + "items": "Elemente werden geladen...", + "ocrProcessing": "Bild wird verarbeitet...", + "addingOcrItems": "OCR-Elemente werden hinzugefügt...", + "costSummary": "Zusammenfassung wird geladen...", + "expenses": "Ausgaben werden geladen...", + "settlement": "Abrechnung wird verarbeitet..." }, "errors": { - "fetchFailed": "Failed to load list details.", - "genericLoadFailure": "Group not found or an error occurred.", - "ocrNoItems": "No items extracted from the image.", - "ocrFailed": "Failed to process image.", - "addItemFailed": "Failed to add item.", - "updateItemFailed": "Failed to update item.", - "updateItemPriceFailed": "Failed to update item price.", - "deleteItemFailed": "Failed to delete item.", - "addOcrItemsFailed": "Failed to add OCR items.", - "fetchItemsFailed": "Failed to load items: {errorMessage}", - "loadCostSummaryFailed": "Failed to load cost summary." + "fetchFailed": "Laden der Listendetails fehlgeschlagen.", + "genericLoadFailure": "Gruppe nicht gefunden oder ein Fehler ist aufgetreten.", + "ocrNoItems": "Keine Elemente aus dem Bild extrahiert.", + "ocrFailed": "Verarbeitung des Bildes fehlgeschlagen.", + "addItemFailed": "Hinzufügen des Elements fehlgeschlagen.", + "updateItemFailed": "Aktualisieren des Elements fehlgeschlagen.", + "updateItemPriceFailed": "Aktualisieren des Elementpreises fehlgeschlagen.", + "deleteItemFailed": "Löschen des Elements fehlgeschlagen.", + "addOcrItemsFailed": "Hinzufügen der OCR-Elemente fehlgeschlagen.", + "fetchItemsFailed": "Laden der Elemente fehlgeschlagen: {errorMessage}", + "loadCostSummaryFailed": "Laden der Kostenzusammenfassung fehlgeschlagen." }, - "retryButton": "Retry", + "retryButton": "Erneut versuchen", "buttons": { - "addViaOcr": "Add via OCR", - "addItem": "Add", - "addItems": "Add Items", - "cancel": "Cancel", - "confirm": "Confirm", - "saveChanges": "Save Changes", - "close": "Close", - "costSummary": "Cost Summary" + "addViaOcr": "Über OCR hinzufügen", + "addItem": "Hinzufügen", + "addItems": "Elemente hinzufügen", + "cancel": "Abbrechen", + "confirm": "Bestätigen", + "saveChanges": "Änderungen speichern", + "close": "Schließen", + "costSummary": "Kostenzusammenfassung" }, "badges": { - "groupList": "Group List", - "personalList": "Personal List" + "groupList": "Gruppenliste", + "personalList": "Persönliche Liste" }, "items": { "emptyState": { - "title": "No Items Yet!", - "message": "Add some items using the form below." + "title": "Noch keine Elemente!", + "message": "Fügen Sie einige Elemente über das Formular unten hinzu." }, "addItemForm": { - "placeholder": "Add a new item", - "quantityPlaceholder": "Qty", - "itemNameSrLabel": "New item name", - "quantitySrLabel": "Quantity" + "placeholder": "Neues Element hinzufügen", + "quantityPlaceholder": "Menge", + "itemNameSrLabel": "Name des neuen Elements", + "quantitySrLabel": "Menge" }, - "pricePlaceholder": "Price", - "editItemAriaLabel": "Edit item", - "deleteItemAriaLabel": "Delete item" + "pricePlaceholder": "Preis", + "editItemAriaLabel": "Element bearbeiten", + "deleteItemAriaLabel": "Element löschen" }, "modals": { "ocr": { - "title": "Add Items via OCR", - "uploadLabel": "Upload Image" + "title": "Elemente über OCR hinzufügen", + "uploadLabel": "Bild hochladen" }, "confirmation": { - "title": "Confirmation" + "title": "Bestätigung" }, "editItem": { - "title": "Edit Item", - "nameLabel": "Item Name", - "quantityLabel": "Quantity" + "title": "Element bearbeiten", + "nameLabel": "Elementname", + "quantityLabel": "Menge" }, "costSummary": { - "title": "List Cost Summary", - "totalCostLabel": "Total List Cost:", - "equalShareLabel": "Equal Share Per User:", - "participantsLabel": "Participating Users:", - "userBalancesHeader": "User Balances", + "title": "Listenkostenzusammenfassung", + "totalCostLabel": "Gesamtkosten der Liste:", + "equalShareLabel": "Gleicher Anteil pro Benutzer:", + "participantsLabel": "Teilnehmende Benutzer:", + "userBalancesHeader": "Benutzersalden", "tableHeaders": { - "user": "User", - "itemsAddedValue": "Items Added Value", - "amountDue": "Amount Due", - "balance": "Balance" + "user": "Benutzer", + "itemsAddedValue": "Wert der hinzugefügten Elemente", + "amountDue": "Fälliger Betrag", + "balance": "Saldo" }, - "emptyState": "No cost summary available." + "emptyState": "Keine Kostenzusammenfassung verfügbar." }, "settleShare": { - "title": "Settle Share", - "settleAmountFor": "Settle amount for {userName}:", - "amountLabel": "Amount", + "title": "Anteil begleichen", + "settleAmountFor": "Betrag für {userName} begleichen:", + "amountLabel": "Betrag", "errors": { - "enterAmount": "Please enter an amount.", - "positiveAmount": "Please enter a positive amount.", - "exceedsRemaining": "Amount cannot exceed remaining: {amount}.", - "noSplitSelected": "Error: No split selected." + "enterAmount": "Bitte geben Sie einen Betrag ein.", + "positiveAmount": "Bitte geben Sie einen positiven Betrag ein.", + "exceedsRemaining": "Betrag darf den Restbetrag nicht überschreiten: {amount}.", + "noSplitSelected": "Fehler: Keine Aufteilung ausgewählt." } } }, "confirmations": { - "updateMessage": "Mark '{itemName}' as {status}?", - "statusComplete": "complete", - "statusIncomplete": "incomplete", - "deleteMessage": "Delete '{itemName}'? This cannot be undone." + "updateMessage": "'{itemName}' als {status} markieren?", + "statusComplete": "abgeschlossen", + "statusIncomplete": "unvollständig", + "deleteMessage": "'{itemName}' löschen? Dies kann nicht rückgängig gemacht werden." }, "notifications": { - "itemAddedSuccess": "Item added successfully.", - "itemsAddedSuccessOcr": "{count} item(s) added successfully from OCR.", - "itemUpdatedSuccess": "Item updated successfully.", - "itemDeleteSuccess": "Item deleted successfully.", - "enterItemName": "Please enter an item name.", - "costSummaryLoadFailed": "Failed to load cost summary.", - "cannotSettleOthersShares": "You can only settle your own shares.", - "settlementDataMissing": "Cannot process settlement: missing data.", - "settleShareSuccess": "Share settled successfully!", - "settleShareFailed": "Failed to settle share." + "itemAddedSuccess": "Element erfolgreich hinzugefügt.", + "itemsAddedSuccessOcr": "{count} Element(e) erfolgreich über OCR hinzugefügt.", + "itemUpdatedSuccess": "Element erfolgreich aktualisiert.", + "itemDeleteSuccess": "Element erfolgreich gelöscht.", + "enterItemName": "Bitte geben Sie einen Elementnamen ein.", + "costSummaryLoadFailed": "Laden der Kostenzusammenfassung fehlgeschlagen.", + "cannotSettleOthersShares": "Sie können nur Ihre eigenen Anteile begleichen.", + "settlementDataMissing": "Abrechnung kann nicht verarbeitet werden: fehlende Daten.", + "settleShareSuccess": "Anteil erfolgreich beglichen!", + "settleShareFailed": "Begleichen des Anteils fehlgeschlagen." }, "expensesSection": { - "title": "Expenses", - "addExpenseButton": "Add Expense", - "loading": "Loading expenses...", - "emptyState": "No expenses recorded for this list yet.", - "paidBy": "Paid by:", - "onDate": "on", - "owes": "owes", - "paidAmount": "Paid:", - "activityLabel": "Activity:", - "byUser": "by", - "settleShareButton": "Settle My Share", - "retryButton": "Retry" + "title": "Ausgaben", + "addExpenseButton": "Ausgabe hinzufügen", + "loading": "Ausgaben werden geladen...", + "emptyState": "Noch keine Ausgaben für diese Liste erfasst.", + "paidBy": "Bezahlt von:", + "onDate": "am", + "owes": "schuldet", + "paidAmount": "Bezahlt:", + "activityLabel": "Aktivität:", + "byUser": "von", + "settleShareButton": "Meinen Anteil begleichen", + "retryButton": "Erneut versuchen" }, "status": { - "settled": "Settled", - "partiallySettled": "Partially Settled", - "unsettled": "Unsettled", - "paid": "Paid", - "partiallyPaid": "Partially Paid", - "unpaid": "Unpaid", - "unknown": "Unknown Status" + "settled": "Beglichen", + "partiallySettled": "Teilweise beglichen", + "unsettled": "Unbeglichen", + "paid": "Bezahlt", + "partiallyPaid": "Teilweise bezahlt", + "unpaid": "Unbezahlt", + "unknown": "Unbekannter Status" } }, "myChoresPage": { - "title": "My Assigned Chores", - "showCompletedToggle": "Show Completed", + "title": "Meine zugewiesenen Aufgaben", + "showCompletedToggle": "Abgeschlossene anzeigen", "timelineHeaders": { - "overdue": "Overdue", - "today": "Due Today", - "thisWeek": "This Week", - "later": "Later", - "completed": "Completed" + "overdue": "Überfällig", + "today": "Heute fällig", + "thisWeek": "Diese Woche", + "later": "Später", + "completed": "Abgeschlossen" }, "choreCard": { - "personal": "Personal", - "group": "Group", - "duePrefix": "Due", - "completedPrefix": "Completed", - "dueToday": "Due Today", - "markCompleteButton": "Mark Complete" + "personal": "Persönlich", + "group": "Gruppe", + "duePrefix": "Fällig", + "completedPrefix": "Abgeschlossen", + "dueToday": "Heute fällig", + "markCompleteButton": "Als erledigt markieren" }, "frequencies": { - "one_time": "One Time", - "daily": "Daily", - "weekly": "Weekly", - "monthly": "Monthly", - "custom": "Custom", - "unknown": "Unknown Frequency" + "one_time": "Einmalig", + "daily": "Täglich", + "weekly": "Wöchentlich", + "monthly": "Monatlich", + "custom": "Benutzerdefiniert", + "unknown": "Unbekannte Häufigkeit" }, "dates": { - "invalidDate": "Invalid Date", - "unknownDate": "Unknown Date" + "invalidDate": "Ungültiges Datum", + "unknownDate": "Unbekanntes Datum" }, "emptyState": { - "title": "No Assignments Yet!", - "noAssignmentsPending": "You have no pending chore assignments.", - "noAssignmentsAll": "You have no chore assignments (completed or pending).", - "viewAllChoresButton": "View All Chores" + "title": "Noch keine Zuweisungen!", + "noAssignmentsPending": "Sie haben keine ausstehenden Aufgabenzuweisungen.", + "noAssignmentsAll": "Sie haben keine Aufgabenzuweisungen (abgeschlossen oder ausstehend).", + "viewAllChoresButton": "Alle Aufgaben anzeigen" }, "notifications": { - "loadFailed": "Failed to load assignments", - "markedComplete": "Marked \"{choreName}\" as complete!", - "markCompleteFailed": "Failed to mark assignment as complete" + "loadFailed": "Laden der Zuweisungen fehlgeschlagen", + "markedComplete": "\"{choreName}\" als erledigt markiert!", + "markCompleteFailed": "Markieren der Zuweisung als erledigt fehlgeschlagen" } }, "personalChoresPage": { - "title": "Personal Chores", - "newChoreButton": "New Chore", - "editButton": "Edit", - "deleteButton": "Delete", - "cancelButton": "Cancel", - "saveButton": "Save", + "title": "Persönliche Aufgaben", + "newChoreButton": "Neue Aufgabe", + "editButton": "Bearbeiten", + "deleteButton": "Löschen", + "cancelButton": "Abbrechen", + "saveButton": "Speichern", "modals": { - "editChoreTitle": "Edit Chore", - "newChoreTitle": "New Chore", - "deleteChoreTitle": "Delete Chore" + "editChoreTitle": "Aufgabe bearbeiten", + "newChoreTitle": "Neue Aufgabe", + "deleteChoreTitle": "Aufgabe löschen" }, "form": { "nameLabel": "Name", - "descriptionLabel": "Description", - "frequencyLabel": "Frequency", - "intervalLabel": "Interval (days)", - "dueDateLabel": "Next Due Date" + "descriptionLabel": "Beschreibung", + "frequencyLabel": "Häufigkeit", + "intervalLabel": "Intervall (Tage)", + "dueDateLabel": "Nächstes Fälligkeitsdatum" }, "deleteDialog": { - "confirmationText": "Are you sure you want to delete this chore?" + "confirmationText": "Sind Sie sicher, dass Sie diese Aufgabe löschen möchten?" }, "frequencies": { - "one_time": "One Time", - "daily": "Daily", - "weekly": "Weekly", - "monthly": "Monthly", - "custom": "Custom", - "unknown": "Unknown Frequency" + "one_time": "Einmalig", + "daily": "Täglich", + "weekly": "Wöchentlich", + "monthly": "Monatlich", + "custom": "Benutzerdefiniert", + "unknown": "Unbekannte Häufigkeit" }, "dates": { - "invalidDate": "Invalid Date", - "duePrefix": "Due" + "invalidDate": "Ungültiges Datum", + "duePrefix": "Fällig" }, "notifications": { - "loadFailed": "Failed to load personal chores", - "updateSuccess": "Personal chore updated successfully", - "createSuccess": "Personal chore created successfully", - "saveFailed": "Failed to save personal chore", - "deleteSuccess": "Personal chore deleted successfully", - "deleteFailed": "Failed to delete personal chore" + "loadFailed": "Laden der persönlichen Aufgaben fehlgeschlagen", + "updateSuccess": "Persönliche Aufgabe erfolgreich aktualisiert", + "createSuccess": "Persönliche Aufgabe erfolgreich erstellt", + "saveFailed": "Speichern der persönlichen Aufgabe fehlgeschlagen", + "deleteSuccess": "Persönliche Aufgabe erfolgreich gelöscht", + "deleteFailed": "Löschen der persönlichen Aufgabe fehlgeschlagen" } }, "indexPage": { - "welcomeMessage": "Welcome to Valerie UI App", - "mainPageInfo": "This is the main index page.", - "sampleTodosHeader": "Sample Todos (from IndexPage data)", - "totalCountLabel": "Total count from meta:", - "noTodos": "No todos to display." + "welcomeMessage": "Willkommen bei der Valerie UI App", + "mainPageInfo": "Dies ist die Hauptindexseite.", + "sampleTodosHeader": "Beispiel-Todos (aus IndexPage-Daten)", + "totalCountLabel": "Gesamtzahl aus Meta:", + "noTodos": "Keine Todos zum Anzeigen." } -} +} \ No newline at end of file diff --git a/fe/src/i18n/es.json b/fe/src/i18n/es.json index 5560da0..d89c8d7 100644 --- a/fe/src/i18n/es.json +++ b/fe/src/i18n/es.json @@ -3,588 +3,629 @@ "hello": "Hola" }, "loginPage": { - "emailLabel": "ES: Email", - "passwordLabel": "ES: Password", - "togglePasswordVisibilityLabel": "ES: Toggle password visibility", - "loginButton": "ES: Login", - "signupLink": "ES: Don't have an account? Sign up", + "emailLabel": "Correo electrónico", + "passwordLabel": "Contraseña", + "togglePasswordVisibilityLabel": "Alternar visibilidad de contraseña", + "loginButton": "Iniciar sesión", + "signupLink": "¿No tienes una cuenta? Regístrate", "errors": { - "emailRequired": "ES: Email is required", - "emailInvalid": "ES: Invalid email format", - "passwordRequired": "ES: Password is required", - "loginFailed": "ES: Login failed. Please check your credentials." + "emailRequired": "El correo electrónico es obligatorio", + "emailInvalid": "Formato de correo electrónico inválido", + "passwordRequired": "La contraseña es obligatoria", + "loginFailed": "Inicio de sesión fallido. Por favor, comprueba tus credenciales." }, "notifications": { - "loginSuccess": "ES: Login successful" + "loginSuccess": "Inicio de sesión exitoso" } }, "listsPage": { - "retryButton": "ES: Retry", + "retryButton": "Reintentar", "emptyState": { - "noListsForGroup": "ES: No lists found for this group.", - "noListsYet": "ES: You have no lists yet.", - "personalGlobalInfo": "ES: Create a personal list or join a group to see shared lists.", - "groupSpecificInfo": "ES: This group doesn't have any lists yet." + "noListsForGroup": "No se encontraron listas para este grupo.", + "noListsYet": "Aún no tienes listas.", + "personalGlobalInfo": "Crea una lista personal o únete a un grupo para ver listas compartidas.", + "groupSpecificInfo": "Este grupo aún no tiene listas." }, - "createNewListButton": "ES: Create New List", - "loadingLists": "ES: Loading lists...", - "noDescription": "ES: No description", - "addItemPlaceholder": "ES: Add new item...", + "createNewListButton": "Crear nueva lista", + "loadingLists": "Cargando listas...", + "noDescription": "Sin descripción", + "addItemPlaceholder": "Añadir nuevo artículo...", "createCard": { - "title": "ES: + Create a new list" + "title": "+ Lista" }, "pageTitle": { - "forGroup": "ES: Lists for {groupName}", - "forGroupId": "ES: Lists for Group {groupId}", - "myLists": "ES: My Lists" + "forGroup": "Listas para {groupName}", + "forGroupId": "Listas para el Grupo {groupId}", + "myLists": "Mis Listas" }, "errors": { - "fetchFailed": "ES: Failed to fetch lists." + "fetchFailed": "Error al obtener las listas." } }, "groupsPage": { - "retryButton": "ES: Retry", + "retryButton": "Reintentar", "emptyState": { - "title": "ES: No Groups Yet!", - "description": "ES: You are not a member of any groups yet. Create one or join using an invite code.", - "createButton": "ES: Create New Group" + "title": "¡Aún no hay grupos!", + "description": "Aún no eres miembro de ningún grupo. Crea uno o únete usando un código de invitación.", + "createButton": "Crear nuevo grupo" }, "groupCard": { - "newListButton": "ES: List" + "newListButton": "Lista" }, "createCard": { - "title": "ES: + Group" + "title": "+ Grupo" }, "joinGroup": { - "title": "ES: Join a Group with Invite Code", - "inputLabel": "ES: Enter Invite Code", - "inputPlaceholder": "ES: Enter Invite Code", - "joinButton": "ES: Join" + "title": "Unirse a un grupo con código de invitación", + "inputLabel": "Ingresar código de invitación", + "inputPlaceholder": "Ingresar código de invitación", + "joinButton": "Unirse" }, "createDialog": { - "title": "ES: Create New Group", - "closeButtonLabel": "ES: Close", - "groupNameLabel": "ES: Group Name", - "cancelButton": "ES: Cancel", - "createButton": "ES: Create" + "title": "Crear nuevo grupo", + "closeButtonLabel": "Cerrar", + "groupNameLabel": "Nombre del grupo", + "cancelButton": "Cancelar", + "createButton": "Crear" }, "errors": { - "fetchFailed": "ES: Failed to load groups", - "groupNameRequired": "ES: Group name is required", - "createFailed": "ES: Failed to create group. Please try again.", - "inviteCodeRequired": "ES: Invite code is required", - "joinFailed": "ES: Failed to join group. Please check the invite code and try again.", - "invalidDataFromServer": "[TRANSLATE] Invalid data received from server.", - "createFailedConsole": "[TRANSLATE] Error creating group:", - "joinFailedConsole": "[TRANSLATE] Error joining group:" + "fetchFailed": "Error al cargar los grupos", + "groupNameRequired": "El nombre del grupo es obligatorio", + "createFailed": "Error al crear el grupo. Por favor, inténtalo de nuevo.", + "inviteCodeRequired": "El código de invitación es obligatorio", + "joinFailed": "Error al unirse al grupo. Por favor, comprueba el código de invitación e inténtalo de nuevo.", + "invalidDataFromServer": "Datos inválidos recibidos del servidor.", + "createFailedConsole": "Error creando grupo:", + "joinFailedConsole": "Error al unirse al grupo:" }, "notifications": { - "groupCreatedSuccess": "ES: Group '{groupName}' created successfully.", - "joinSuccessNamed": "ES: Successfully joined group '{groupName}'.", - "joinSuccessGeneric": "ES: Successfully joined group.", - "listCreatedSuccess": "ES: List '{listName}' created successfully." + "groupCreatedSuccess": "Grupo '{groupName}' creado exitosamente.", + "joinSuccessNamed": "Te uniste exitosamente al grupo '{groupName}'.", + "joinSuccessGeneric": "Te uniste exitosamente al grupo.", + "listCreatedSuccess": "Lista '{listName}' creada exitosamente." } }, "authCallbackPage": { - "redirecting": "ES: Redirecting...", + "redirecting": "Redirigiendo...", "errors": { - "authenticationFailed": "ES: Authentication failed", - "noTokenProvided": "[TRANSLATE] No token provided" + "authenticationFailed": "Autenticación fallida", + "noTokenProvided": "No se proporcionó ningún token" } }, "choresPage": { - "title": "ES: Chores", + "title": "Tareas", "tabs": { - "overdue": "ES: Overdue", - "today": "ES: Today", - "upcoming": "ES: Upcoming", - "allPending": "ES: All Pending", - "completed": "ES: Completed" + "overdue": "Vencidas", + "today": "Hoy", + "upcoming": "Próximas", + "allPending": "Todas pendientes", + "completed": "Completadas" }, "viewToggle": { - "calendarLabel": "ES: Calendar View", - "calendarText": "ES: Calendar", - "listLabel": "ES: List View", - "listText": "ES: List" + "calendarLabel": "Vista de calendario", + "calendarText": "Calendario", + "listLabel": "Vista de lista", + "listText": "Lista" }, - "newChoreButtonLabel": "ES: New Chore", - "newChoreButtonText": "ES: New Chore", + "newChoreButtonLabel": "Nueva tarea", + "newChoreButtonText": "Nueva tarea", "loadingState": { - "loadingChores": "ES: Loading chores..." + "loadingChores": "Cargando tareas..." }, "calendar": { - "prevMonthLabel": "ES: Previous month", - "nextMonthLabel": "ES: Next month", + "prevMonthLabel": "Mes anterior", + "nextMonthLabel": "Mes siguiente", "weekdays": { - "sun": "ES: Sun", - "mon": "ES: Mon", - "tue": "ES: Tue", - "wed": "ES: Wed", - "thu": "ES: Thu", - "fri": "ES: Fri", - "sat": "ES: Sat" + "sun": "Dom", + "mon": "Lun", + "tue": "Mar", + "wed": "Mié", + "thu": "Jue", + "fri": "Vie", + "sat": "Sáb" }, - "addChoreToDayLabel": "ES: Add chore to this day", - "emptyState": "ES: No chores to display for this period." + "addChoreToDayLabel": "Añadir tarea a este día", + "emptyState": "No hay tareas para mostrar en este período." }, "listView": { - "choreTypePersonal": "ES: Personal", - "choreTypeGroupFallback": "ES: Group", - "completedDatePrefix": "ES: Completed:", + "choreTypePersonal": "Personal", + "choreTypeGroupFallback": "Grupo", + "completedDatePrefix": "Completada:", "actions": { - "doneTitle": "ES: Mark as Done", - "doneText": "ES: Done", - "undoTitle": "ES: Mark as Not Done", - "undoText": "ES: Undo", - "editTitle": "ES: Edit", - "editLabel": "ES: Edit chore", - "editText": "ES: Edit", - "deleteTitle": "ES: Delete", - "deleteLabel": "ES: Delete chore", - "deleteText": "ES: Delete" + "doneTitle": "Marcar como hecha", + "doneText": "Hecha", + "undoTitle": "Marcar como no hecha", + "undoText": "Deshacer", + "editTitle": "Editar", + "editLabel": "Editar tarea", + "editText": "Editar", + "deleteTitle": "Eliminar", + "deleteLabel": "Eliminar tarea", + "deleteText": "Eliminar" }, "emptyState": { - "message": "ES: No chores in this view. Well done!", - "viewAllButton": "ES: View All Pending" + "message": "No hay tareas en esta vista. ¡Bien hecho!", + "viewAllButton": "Ver todas las pendientes" } }, "choreModal": { - "editTitle": "ES: Edit Chore", - "newTitle": "ES: New Chore", - "closeButtonLabel": "ES: Close modal", - "nameLabel": "ES: Name", - "namePlaceholder": "ES: Enter chore name", - "typeLabel": "ES: Type", - "typePersonal": "ES: Personal", - "typeGroup": "ES: Group", - "groupLabel": "ES: Group", - "groupSelectDefault": "ES: Select a group", - "descriptionLabel": "ES: Description", - "descriptionPlaceholder": "ES: Add a description (optional)", - "frequencyLabel": "ES: Frequency", - "intervalLabel": "ES: Interval (days)", - "intervalPlaceholder": "ES: e.g. 3", - "dueDateLabel": "ES: Due Date", - "quickDueDateToday": "ES: Today", - "quickDueDateTomorrow": "ES: Tomorrow", - "quickDueDateNextWeek": "ES: Next Week", - "cancelButton": "ES: Cancel", - "saveButton": "ES: Save", - "intervalPlaceholder": "[TRANSLATE] e.g., 10" + "editTitle": "Editar tarea", + "newTitle": "Nueva tarea", + "closeButtonLabel": "Cerrar modal", + "nameLabel": "Nombre", + "namePlaceholder": "Ingresar nombre de la tarea", + "typeLabel": "Tipo", + "typePersonal": "Personal", + "typeGroup": "Grupo", + "groupLabel": "Grupo", + "groupSelectDefault": "Seleccionar un grupo", + "descriptionLabel": "Descripción", + "descriptionPlaceholder": "Añadir una descripción (opcional)", + "frequencyLabel": "Frecuencia", + "intervalLabel": "Intervalo (días)", + "intervalPlaceholder": "p.ej. 10", + "dueDateLabel": "Fecha de vencimiento", + "quickDueDateToday": "Hoy", + "quickDueDateTomorrow": "Mañana", + "quickDueDateNextWeek": "Próxima semana", + "cancelButton": "Cancelar", + "saveButton": "Guardar" }, "consoleErrors": { - "loadFailed": "[TRANSLATE] Failed to load all chores:", - "loadGroupsFailed": "[TRANSLATE] Failed to load groups", - "createAssignmentForNewChoreFailed": "[TRANSLATE] Failed to create assignment for new chore:", - "saveFailed": "[TRANSLATE] Failed to save chore:", - "deleteFailed": "[TRANSLATE] Failed to delete chore:", - "createAssignmentFailed": "[TRANSLATE] Failed to create assignment:", - "updateCompletionStatusFailed": "[TRANSLATE] Failed to update chore completion status:" + "loadFailed": "Error al cargar todas las tareas:", + "loadGroupsFailed": "Error al cargar grupos", + "createAssignmentForNewChoreFailed": "Error al crear asignación para nueva tarea:", + "saveFailed": "Error al guardar tarea:", + "deleteFailed": "Error al eliminar tarea:", + "createAssignmentFailed": "Error al crear asignación:", + "updateCompletionStatusFailed": "Error al actualizar estado de finalización de la tarea:" }, "deleteDialog": { - "title": "ES: Delete Chore", - "confirmationText": "ES: Are you sure you want to delete this chore? This action cannot be undone.", - "deleteButton": "ES: Delete" + "title": "Eliminar tarea", + "confirmationText": "¿Estás seguro de que quieres eliminar esta tarea? Esta acción no se puede deshacer.", + "deleteButton": "Eliminar" }, "shortcutsModal": { - "title": "ES: Keyboard Shortcuts", - "descNewChore": "ES: New Chore", - "descToggleView": "ES: Toggle View (List/Calendar)", - "descToggleShortcuts": "ES: Show/Hide Shortcuts", - "descCloseModal": "ES: Close any open Modal/Dialog" + "title": "Atajos de teclado", + "descNewChore": "Nueva tarea", + "descToggleView": "Alternar vista (Lista/Calendario)", + "descToggleShortcuts": "Mostrar/Ocultar atajos", + "descCloseModal": "Cerrar cualquier Modal/Diálogo abierto" }, "frequencyOptions": { - "oneTime": "ES: One Time", - "daily": "ES: Daily", - "weekly": "ES: Weekly", - "monthly": "ES: Monthly", - "custom": "ES: Custom" + "oneTime": "Una vez", + "daily": "Diariamente", + "weekly": "Semanalmente", + "monthly": "Mensualmente", + "custom": "Personalizado" + }, + "frequency": { + "customInterval": "Cada {n} día | Cada {n} días" }, "formatters": { - "noDueDate": "ES: No due date", - "dueToday": "ES: Due Today", - "dueTomorrow": "ES: Due Tomorrow", - "overdueFull": "ES: Overdue: {date}", - "dueFull": "ES: Due {date}", - "invalidDate": "ES: Invalid Date" + "noDueDate": "Sin fecha de vencimiento", + "dueToday": "Vence hoy", + "dueTomorrow": "Vence mañana", + "overdueFull": "Vencida: {date}", + "dueFull": "Vence el {date}", + "invalidDate": "Fecha inválida" }, "notifications": { - "loadFailed": "ES: Failed to load chores", - "updateSuccess": "ES: Chore '{name}' updated successfully", - "createSuccess": "ES: Chore '{name}' created successfully", - "updateFailed": "ES: Failed to update chore", - "createFailed": "ES: Failed to create chore", - "deleteSuccess": "ES: Chore '{name}' deleted successfully", - "deleteFailed": "ES: Failed to delete chore", - "markedDone": "ES: {name} marked as done.", - "markedNotDone": "ES: {name} marked as not done.", - "statusUpdateFailed": "ES: Failed to update chore status." + "loadFailed": "Error al cargar las tareas.", + "loadGroupsFailed": "Error al cargar los grupos.", + "updateSuccess": "Tarea '{name}' actualizada exitosamente.", + "createSuccess": "Tarea '{name}' creada exitosamente.", + "updateFailed": "Error al actualizar la tarea.", + "createFailed": "Error al crear la tarea.", + "deleteSuccess": "Tarea '{name}' eliminada exitosamente.", + "deleteFailed": "Error al eliminar la tarea.", + "markedDone": "{name} marcada como hecha.", + "markedNotDone": "{name} marcada como no hecha.", + "statusUpdateFailed": "Error al actualizar el estado de la tarea.", + "createAssignmentFailed": "Error al crear la asignación para la tarea." }, "validation": { - "nameRequired": "ES: Chore name is required.", - "groupRequired": "ES: Please select a group for group chores.", - "intervalRequired": "ES: Custom interval must be at least 1 day.", - "dueDateRequired": "ES: Due date is required.", - "invalidDueDate": "ES: Invalid due date format." + "nameRequired": "El nombre de la tarea es obligatorio.", + "groupRequired": "Por favor, selecciona un grupo para tareas grupales.", + "intervalRequired": "El intervalo personalizado debe ser de al menos 1 día.", + "dueDateRequired": "La fecha de vencimiento es obligatoria.", + "invalidDueDate": "Formato de fecha de vencimiento inválido." }, - "unsavedChangesConfirmation": "ES: You have unsaved changes in the chore form. Are you sure you want to leave?" + "unsavedChangesConfirmation": "Tienes cambios sin guardar en el formulario de la tarea. ¿Estás seguro de que quieres salir?" }, "errorNotFoundPage": { - "errorCode": "ES: 404", - "errorMessage": "ES: Oops. Nothing here...", - "goHomeButton": "ES: Go Home" + "errorCode": "404", + "errorMessage": "Vaya. Aquí no hay nada...", + "goHomeButton": "Ir al inicio" }, "groupDetailPage": { - "loadingLabel": "ES: Loading group details...", - "retryButton": "ES: Retry", - "groupNotFound": "ES: Group not found or an error occurred.", + "loadingLabel": "Cargando detalles del grupo...", + "retryButton": "Reintentar", + "groupNotFound": "Grupo no encontrado o se produjo un error.", "members": { - "title": "ES: Group Members", - "defaultRole": "ES: Member", - "removeButton": "ES: Remove", - "emptyState": "ES: No members found.", - "closeMenuLabel": "[TRANSLATE] Close menu" + "title": "Miembros del grupo", + "defaultRole": "Miembro", + "removeButton": "Eliminar", + "emptyState": "No se encontraron miembros.", + "closeMenuLabel": "Cerrar menú" }, "invites": { - "title": "ES: Invite Members", - "description": "[TRANSLATE] Invite new members by generating a shareable code.", - "addMemberButtonLabel": "[TRANSLATE] Add member", - "closeInviteLabel": "[TRANSLATE] Close invite", - "regenerateButton": "ES: Regenerate Invite Code", - "generateButton": "ES: Generate Invite Code", - "activeCodeLabel": "ES: Current Active Invite Code:", - "copyButtonLabel": "ES: Copy invite code", - "copySuccess": "ES: Invite code copied to clipboard!", - "emptyState": "ES: No active invite code. Click the button above to generate one.", + "title": "Invitar miembros", + "description": "Invita a nuevos miembros generando un código compartible.", + "addMemberButtonLabel": "Añadir miembro", + "closeInviteLabel": "Cerrar invitación", + "regenerateButton": "Regenerar código de invitación", + "generateButton": "Generar código de invitación", + "activeCodeLabel": "Código de invitación activo actual:", + "copyButtonLabel": "Copiar código de invitación", + "copySuccess": "¡Código de invitación copiado al portapapeles!", + "emptyState": "No hay código de invitación activo. Haz clic en el botón de arriba para generar uno.", "errors": { - "newDataInvalid": "ES: New invite code data is invalid." + "newDataInvalid": "Los datos del nuevo código de invitación son inválidos." } }, "errors": { - "failedToFetchActiveInvite": "[TRANSLATE] Failed to fetch active invite code.", - "failedToFetchGroupDetails": "[TRANSLATE] Failed to fetch group details.", - "failedToLoadUpcomingChores": "[TRANSLATE] Error loading upcoming chores:", - "failedToLoadRecentExpenses": "[TRANSLATE] Error loading recent expenses:" + "failedToFetchActiveInvite": "Error al obtener el código de invitación activo.", + "failedToFetchGroupDetails": "Error al obtener los detalles del grupo.", + "failedToLoadUpcomingChores": "Error al cargar las próximas tareas:", + "failedToLoadRecentExpenses": "Error al cargar los gastos recientes:" }, "console": { - "noActiveInvite": "[TRANSLATE] No active invite code found for this group." + "noActiveInvite": "No se encontró ningún código de invitación activo para este grupo." }, "chores": { - "title": "ES: Group Chores", - "manageButton": "ES: Manage Chores", - "duePrefix": "ES: Due:", - "emptyState": "ES: No chores scheduled. Click \"Manage Chores\" to create some!" + "title": "Tareas del grupo", + "manageButton": "Gestionar tareas", + "duePrefix": "Vence:", + "emptyState": "No hay tareas programadas. ¡Haz clic en \"Gestionar tareas\" para crear algunas!" }, "expenses": { - "title": "ES: Group Expenses", - "manageButton": "ES: Manage Expenses", - "emptyState": "ES: No expenses recorded. Click \"Manage Expenses\" to add some!", - "fallbackUserName": "[TRANSLATE] User ID: {userId}", - "activityByUserFallback": "[TRANSLATE] User {userId}", + "title": "Gastos del grupo", + "manageButton": "Gestionar gastos", + "emptyState": "No hay gastos registrados. ¡Haz clic en \"Gestionar gastos\" para añadir algunos!", + "paidBy": "Pagado por:", + "owes": "debe", + "paidAmount": "Pagado:", + "onDate": "el", + "settleShareButton": "Saldar mi parte", + "activityLabel": "Actividad:", + "byUser": "por", + "fallbackUserName": "ID de usuario: {userId}", + "activityByUserFallback": "Usuario {userId}", "splitTypes": { - "equal": "ES: Equal", - "exactAmounts": "ES: Exact Amounts", - "percentage": "ES: Percentage", - "shares": "ES: Shares", - "itemBased": "ES: Item Based" + "equal": "Igual", + "exactAmounts": "Cantidades exactas", + "percentage": "Porcentaje", + "shares": "Partes", + "itemBased": "Basado en artículos" } }, "notifications": { - "fetchDetailsFailed": "ES: Failed to fetch group details.", - "fetchInviteFailed": "ES: Failed to fetch active invite code.", - "generateInviteSuccess": "ES: New invite code generated successfully!", - "generateInviteError": "ES: Failed to generate invite code.", - "clipboardNotSupported": "ES: Clipboard not supported or no code to copy.", - "copyInviteFailed": "ES: Failed to copy invite code.", - "removeMemberSuccess": "ES: Member removed successfully", - "removeMemberFailed": "ES: Failed to remove member" + "fetchDetailsFailed": "Error al obtener los detalles del grupo.", + "fetchInviteFailed": "Error al obtener el código de invitación activo.", + "generateInviteSuccess": "¡Nuevo código de invitación generado exitosamente!", + "generateInviteError": "Error al generar el código de invitación.", + "clipboardNotSupported": "Portapapeles no compatible o no hay código para copiar.", + "copyInviteFailed": "Error al copiar el código de invitación.", + "removeMemberSuccess": "Miembro eliminado exitosamente", + "removeMemberFailed": "Error al eliminar miembro", + "loadExpensesFailed": "Error al cargar los gastos recientes.", + "cannotSettleOthersShares": "Solo puedes saldar tus propias partes.", + "settlementDataMissing": "No se puede procesar el saldo: faltan datos.", + "settleShareSuccess": "¡Parte saldada exitosamente!", + "settleShareFailed": "Error al saldar la parte." + }, + "loading": { + "settlement": "Procesando saldo..." + }, + "settleShareModal": { + "title": "Saldar parte", + "settleAmountFor": "Saldar cantidad para {userName}:", + "amountLabel": "Cantidad", + "cancelButton": "Cancelar", + "confirmButton": "Confirmar", + "errors": { + "enterAmount": "Por favor, ingresa una cantidad.", + "positiveAmount": "Por favor, ingresa una cantidad positiva.", + "exceedsRemaining": "La cantidad no puede exceder el restante: {amount}.", + "noSplitSelected": "Error: No se seleccionó ninguna división." + } + }, + "status": { + "settled": "Saldado", + "partiallySettled": "Parcialmente saldado", + "unsettled": "Pendiente", + "paid": "Pagado", + "partiallyPaid": "Parcialmente pagado", + "unpaid": "No pagado", + "unknown": "Estado desconocido" } }, "accountPage": { - "title": "Account Settings", - "loadingProfile": "Loading profile...", - "retryButton": "Retry", + "title": "Configuración de la cuenta", + "loadingProfile": "Cargando perfil...", + "retryButton": "Reintentar", "profileSection": { - "header": "Profile Information", - "nameLabel": "Name", - "emailLabel": "Email", - "saveButton": "Save Changes" + "header": "Información del perfil", + "nameLabel": "Nombre", + "emailLabel": "Correo electrónico", + "saveButton": "Guardar cambios" }, "passwordSection": { - "header": "Change Password", - "currentPasswordLabel": "Current Password", - "newPasswordLabel": "New Password", - "changeButton": "Change Password" + "header": "Cambiar contraseña", + "currentPasswordLabel": "Contraseña actual", + "newPasswordLabel": "Nueva contraseña", + "changeButton": "Cambiar contraseña" }, "notificationsSection": { - "header": "Notification Preferences", - "emailNotificationsLabel": "Email Notifications", - "emailNotificationsDescription": "Receive email notifications for important updates", - "listUpdatesLabel": "List Updates", - "listUpdatesDescription": "Get notified when lists are updated", - "groupActivitiesLabel": "Group Activities", - "groupActivitiesDescription": "Receive notifications for group activities" + "header": "Preferencias de notificación", + "emailNotificationsLabel": "Notificaciones por correo electrónico", + "emailNotificationsDescription": "Recibir notificaciones por correo electrónico para actualizaciones importantes", + "listUpdatesLabel": "Actualizaciones de listas", + "listUpdatesDescription": "Recibir notificaciones cuando se actualicen las listas", + "groupActivitiesLabel": "Actividades de grupo", + "groupActivitiesDescription": "Recibir notificaciones para actividades de grupo" }, "notifications": { - "profileLoadFailed": "Failed to load profile", - "profileUpdateSuccess": "Profile updated successfully", - "profileUpdateFailed": "Failed to update profile", - "passwordFieldsRequired": "Please fill in both current and new password fields.", - "passwordTooShort": "New password must be at least 8 characters long.", - "passwordChangeSuccess": "Password changed successfully", - "passwordChangeFailed": "Failed to change password", - "preferencesUpdateSuccess": "Preferences updated successfully", - "preferencesUpdateFailed": "Failed to update preferences" + "profileLoadFailed": "Error al cargar el perfil", + "profileUpdateSuccess": "Perfil actualizado exitosamente", + "profileUpdateFailed": "Error al actualizar el perfil", + "passwordFieldsRequired": "Por favor, completa los campos de contraseña actual y nueva.", + "passwordTooShort": "La nueva contraseña debe tener al menos 8 caracteres.", + "passwordChangeSuccess": "Contraseña cambiada exitosamente", + "passwordChangeFailed": "Error al cambiar la contraseña", + "preferencesUpdateSuccess": "Preferencias actualizadas exitosamente", + "preferencesUpdateFailed": "Error al actualizar las preferencias" }, - "saving": "Saving..." + "saving": "Guardando..." }, "signupPage": { - "header": "Sign Up", - "fullNameLabel": "Full Name", - "emailLabel": "Email", - "passwordLabel": "Password", - "confirmPasswordLabel": "Confirm Password", - "togglePasswordVisibility": "Toggle password visibility", - "submitButton": "Sign Up", - "loginLink": "Already have an account? Login", + "header": "Regístrate", + "fullNameLabel": "Nombre completo", + "emailLabel": "Correo electrónico", + "passwordLabel": "Contraseña", + "confirmPasswordLabel": "Confirmar contraseña", + "togglePasswordVisibility": "Alternar visibilidad de contraseña", + "submitButton": "Regístrate", + "loginLink": "¿Ya tienes una cuenta? Iniciar sesión", "validation": { - "nameRequired": "Name is required", - "emailRequired": "Email is required", - "emailInvalid": "Invalid email format", - "passwordRequired": "Password is required", - "passwordLength": "Password must be at least 8 characters", - "confirmPasswordRequired": "Please confirm your password", - "passwordsNoMatch": "Passwords do not match" + "nameRequired": "El nombre es obligatorio", + "emailRequired": "El correo electrónico es obligatorio", + "emailInvalid": "Formato de correo electrónico inválido", + "passwordRequired": "La contraseña es obligatoria", + "passwordLength": "La contraseña debe tener al menos 8 caracteres", + "confirmPasswordRequired": "Por favor, confirma tu contraseña", + "passwordsNoMatch": "Las contraseñas no coinciden" }, "notifications": { - "signupFailed": "Signup failed. Please try again.", - "signupSuccess": "Account created successfully. Please login." + "signupFailed": "Registro fallido. Por favor, inténtalo de nuevo.", + "signupSuccess": "Cuenta creada exitosamente. Por favor, inicia sesión." } }, "listDetailPage": { "loading": { - "list": "Loading list...", - "items": "Loading items...", - "ocrProcessing": "Processing image...", - "addingOcrItems": "Adding OCR items...", - "costSummary": "Loading summary...", - "expenses": "Loading expenses...", - "settlement": "Processing settlement..." + "list": "Cargando lista...", + "items": "Cargando artículos...", + "ocrProcessing": "Procesando imagen...", + "addingOcrItems": "Añadiendo artículos OCR...", + "costSummary": "Cargando resumen...", + "expenses": "Cargando gastos...", + "settlement": "Procesando saldo..." }, "errors": { - "fetchFailed": "Failed to load list details.", - "genericLoadFailure": "Group not found or an error occurred.", - "ocrNoItems": "No items extracted from the image.", - "ocrFailed": "Failed to process image.", - "addItemFailed": "Failed to add item.", - "updateItemFailed": "Failed to update item.", - "updateItemPriceFailed": "Failed to update item price.", - "deleteItemFailed": "Failed to delete item.", - "addOcrItemsFailed": "Failed to add OCR items.", - "fetchItemsFailed": "Failed to load items: {errorMessage}", - "loadCostSummaryFailed": "Failed to load cost summary." + "fetchFailed": "Error al cargar los detalles de la lista.", + "genericLoadFailure": "Grupo no encontrado o se produjo un error.", + "ocrNoItems": "No se extrajeron artículos de la imagen.", + "ocrFailed": "Error al procesar la imagen.", + "addItemFailed": "Error al añadir el artículo.", + "updateItemFailed": "Error al actualizar el artículo.", + "updateItemPriceFailed": "Error al actualizar el precio del artículo.", + "deleteItemFailed": "Error al eliminar el artículo.", + "addOcrItemsFailed": "Error al añadir artículos OCR.", + "fetchItemsFailed": "Error al cargar artículos: {errorMessage}", + "loadCostSummaryFailed": "Error al cargar el resumen de costos." }, - "retryButton": "Retry", + "retryButton": "Reintentar", "buttons": { - "addViaOcr": "Add via OCR", - "addItem": "Add", - "addItems": "Add Items", - "cancel": "Cancel", - "confirm": "Confirm", - "saveChanges": "Save Changes", - "close": "Close", - "costSummary": "Cost Summary" + "addViaOcr": "Añadir vía OCR", + "addItem": "Añadir", + "addItems": "Añadir artículos", + "cancel": "Cancelar", + "confirm": "Confirmar", + "saveChanges": "Guardar cambios", + "close": "Cerrar", + "costSummary": "Resumen de costos" }, "badges": { - "groupList": "Group List", - "personalList": "Personal List" + "groupList": "Lista de grupo", + "personalList": "Lista personal" }, "items": { "emptyState": { - "title": "No Items Yet!", - "message": "Add some items using the form below." + "title": "¡Aún no hay artículos!", + "message": "Añade algunos artículos usando el formulario de abajo." }, "addItemForm": { - "placeholder": "Add a new item", - "quantityPlaceholder": "Qty", - "itemNameSrLabel": "New item name", - "quantitySrLabel": "Quantity" + "placeholder": "Añadir un nuevo artículo", + "quantityPlaceholder": "Cant.", + "itemNameSrLabel": "Nombre del nuevo artículo", + "quantitySrLabel": "Cantidad" }, - "pricePlaceholder": "Price", - "editItemAriaLabel": "Edit item", - "deleteItemAriaLabel": "Delete item" + "pricePlaceholder": "Precio", + "editItemAriaLabel": "Editar artículo", + "deleteItemAriaLabel": "Eliminar artículo" }, "modals": { "ocr": { - "title": "Add Items via OCR", - "uploadLabel": "Upload Image" + "title": "Añadir artículos vía OCR", + "uploadLabel": "Subir imagen" }, "confirmation": { - "title": "Confirmation" + "title": "Confirmación" }, "editItem": { - "title": "Edit Item", - "nameLabel": "Item Name", - "quantityLabel": "Quantity" + "title": "Editar artículo", + "nameLabel": "Nombre del artículo", + "quantityLabel": "Cantidad" }, "costSummary": { - "title": "List Cost Summary", - "totalCostLabel": "Total List Cost:", - "equalShareLabel": "Equal Share Per User:", - "participantsLabel": "Participating Users:", - "userBalancesHeader": "User Balances", + "title": "Resumen de costos de la lista", + "totalCostLabel": "Costo total de la lista:", + "equalShareLabel": "Parte igual por usuario:", + "participantsLabel": "Usuarios participantes:", + "userBalancesHeader": "Saldos de usuarios", "tableHeaders": { - "user": "User", - "itemsAddedValue": "Items Added Value", - "amountDue": "Amount Due", - "balance": "Balance" + "user": "Usuario", + "itemsAddedValue": "Valor de los artículos añadidos", + "amountDue": "Cantidad debida", + "balance": "Saldo" }, - "emptyState": "No cost summary available." + "emptyState": "No hay resumen de costos disponible." }, "settleShare": { - "title": "Settle Share", - "settleAmountFor": "Settle amount for {userName}:", - "amountLabel": "Amount", + "title": "Saldar parte", + "settleAmountFor": "Saldar cantidad para {userName}:", + "amountLabel": "Cantidad", "errors": { - "enterAmount": "Please enter an amount.", - "positiveAmount": "Please enter a positive amount.", - "exceedsRemaining": "Amount cannot exceed remaining: {amount}.", - "noSplitSelected": "Error: No split selected." + "enterAmount": "Por favor, ingresa una cantidad.", + "positiveAmount": "Por favor, ingresa una cantidad positiva.", + "exceedsRemaining": "La cantidad no puede exceder el restante: {amount}.", + "noSplitSelected": "Error: No se seleccionó ninguna división." } } }, "confirmations": { - "updateMessage": "Mark '{itemName}' as {status}?", - "statusComplete": "complete", - "statusIncomplete": "incomplete", - "deleteMessage": "Delete '{itemName}'? This cannot be undone." + "updateMessage": "¿Marcar '{itemName}' como {status}?", + "statusComplete": "completado", + "statusIncomplete": "incompleto", + "deleteMessage": "¿Eliminar '{itemName}'? Esto no se puede deshacer." }, "notifications": { - "itemAddedSuccess": "Item added successfully.", - "itemsAddedSuccessOcr": "{count} item(s) added successfully from OCR.", - "itemUpdatedSuccess": "Item updated successfully.", - "itemDeleteSuccess": "Item deleted successfully.", - "enterItemName": "Please enter an item name.", - "costSummaryLoadFailed": "Failed to load cost summary.", - "cannotSettleOthersShares": "You can only settle your own shares.", - "settlementDataMissing": "Cannot process settlement: missing data.", - "settleShareSuccess": "Share settled successfully!", - "settleShareFailed": "Failed to settle share." + "itemAddedSuccess": "Artículo añadido exitosamente.", + "itemsAddedSuccessOcr": "{count} artículo(s) añadido(s) exitosamente desde OCR.", + "itemUpdatedSuccess": "Artículo actualizado exitosamente.", + "itemDeleteSuccess": "Artículo eliminado exitosamente.", + "enterItemName": "Por favor, ingresa un nombre de artículo.", + "costSummaryLoadFailed": "Error al cargar el resumen de costos.", + "cannotSettleOthersShares": "Solo puedes saldar tus propias partes.", + "settlementDataMissing": "No se puede procesar el saldo: faltan datos.", + "settleShareSuccess": "¡Parte saldada exitosamente!", + "settleShareFailed": "Error al saldar la parte." }, "expensesSection": { - "title": "Expenses", - "addExpenseButton": "Add Expense", - "loading": "Loading expenses...", - "emptyState": "No expenses recorded for this list yet.", - "paidBy": "Paid by:", - "onDate": "on", - "owes": "owes", - "paidAmount": "Paid:", - "activityLabel": "Activity:", - "byUser": "by", - "settleShareButton": "Settle My Share", - "retryButton": "Retry" + "title": "Gastos", + "addExpenseButton": "Añadir gasto", + "loading": "Cargando gastos...", + "emptyState": "Aún no hay gastos registrados para esta lista.", + "paidBy": "Pagado por:", + "onDate": "el", + "owes": "debe", + "paidAmount": "Pagado:", + "activityLabel": "Actividad:", + "byUser": "por", + "settleShareButton": "Saldar mi parte", + "retryButton": "Reintentar" }, "status": { - "settled": "Settled", - "partiallySettled": "Partially Settled", - "unsettled": "Unsettled", - "paid": "Paid", - "partiallyPaid": "Partially Paid", - "unpaid": "Unpaid", - "unknown": "Unknown Status" + "settled": "Saldado", + "partiallySettled": "Parcialmente saldado", + "unsettled": "Pendiente", + "paid": "Pagado", + "partiallyPaid": "Parcialmente pagado", + "unpaid": "No pagado", + "unknown": "Estado desconocido" } }, "myChoresPage": { - "title": "My Assigned Chores", - "showCompletedToggle": "Show Completed", + "title": "Mis tareas asignadas", + "showCompletedToggle": "Mostrar completadas", "timelineHeaders": { - "overdue": "Overdue", - "today": "Due Today", - "thisWeek": "This Week", - "later": "Later", - "completed": "Completed" + "overdue": "Vencidas", + "today": "Vencen hoy", + "thisWeek": "Esta semana", + "later": "Más tarde", + "completed": "Completadas" }, "choreCard": { "personal": "Personal", - "group": "Group", - "duePrefix": "Due", - "completedPrefix": "Completed", - "dueToday": "Due Today", - "markCompleteButton": "Mark Complete" + "group": "Grupo", + "duePrefix": "Vence", + "completedPrefix": "Completada", + "dueToday": "Vence hoy", + "markCompleteButton": "Marcar como completada" }, "frequencies": { - "one_time": "One Time", - "daily": "Daily", - "weekly": "Weekly", - "monthly": "Monthly", - "custom": "Custom", - "unknown": "Unknown Frequency" + "one_time": "Una vez", + "daily": "Diariamente", + "weekly": "Semanalmente", + "monthly": "Mensualmente", + "custom": "Personalizado", + "unknown": "Frecuencia desconocida" }, "dates": { - "invalidDate": "Invalid Date", - "unknownDate": "Unknown Date" + "invalidDate": "Fecha inválida", + "unknownDate": "Fecha desconocida" }, "emptyState": { - "title": "No Assignments Yet!", - "noAssignmentsPending": "You have no pending chore assignments.", - "noAssignmentsAll": "You have no chore assignments (completed or pending).", - "viewAllChoresButton": "View All Chores" + "title": "¡Aún no hay asignaciones!", + "noAssignmentsPending": "No tienes asignaciones de tareas pendientes.", + "noAssignmentsAll": "No tienes asignaciones de tareas (completadas o pendientes).", + "viewAllChoresButton": "Ver todas las tareas" }, "notifications": { - "loadFailed": "Failed to load assignments", - "markedComplete": "Marked \"{choreName}\" as complete!", - "markCompleteFailed": "Failed to mark assignment as complete" + "loadFailed": "Error al cargar las asignaciones", + "markedComplete": "¡Se marcó \"{choreName}\" como completada!", + "markCompleteFailed": "Error al marcar la asignación como completada" } }, "personalChoresPage": { - "title": "Personal Chores", - "newChoreButton": "New Chore", - "editButton": "Edit", - "deleteButton": "Delete", - "cancelButton": "Cancel", - "saveButton": "Save", + "title": "Tareas personales", + "newChoreButton": "Nueva tarea", + "editButton": "Editar", + "deleteButton": "Eliminar", + "cancelButton": "Cancelar", + "saveButton": "Guardar", "modals": { - "editChoreTitle": "Edit Chore", - "newChoreTitle": "New Chore", - "deleteChoreTitle": "Delete Chore" + "editChoreTitle": "Editar tarea", + "newChoreTitle": "Nueva tarea", + "deleteChoreTitle": "Eliminar tarea" }, "form": { - "nameLabel": "Name", - "descriptionLabel": "Description", - "frequencyLabel": "Frequency", - "intervalLabel": "Interval (days)", - "dueDateLabel": "Next Due Date" + "nameLabel": "Nombre", + "descriptionLabel": "Descripción", + "frequencyLabel": "Frecuencia", + "intervalLabel": "Intervalo (días)", + "dueDateLabel": "Próxima fecha de vencimiento" }, "deleteDialog": { - "confirmationText": "Are you sure you want to delete this chore?" + "confirmationText": "¿Estás seguro de que quieres eliminar esta tarea?" }, "frequencies": { - "one_time": "One Time", - "daily": "Daily", - "weekly": "Weekly", - "monthly": "Monthly", - "custom": "Custom", - "unknown": "Unknown Frequency" + "one_time": "Una vez", + "daily": "Diariamente", + "weekly": "Semanalmente", + "monthly": "Mensualmente", + "custom": "Personalizado", + "unknown": "Frecuencia desconocida" }, "dates": { - "invalidDate": "Invalid Date", - "duePrefix": "Due" + "invalidDate": "Fecha inválida", + "duePrefix": "Vence" }, "notifications": { - "loadFailed": "Failed to load personal chores", - "updateSuccess": "Personal chore updated successfully", - "createSuccess": "Personal chore created successfully", - "saveFailed": "Failed to save personal chore", - "deleteSuccess": "Personal chore deleted successfully", - "deleteFailed": "Failed to delete personal chore" + "loadFailed": "Error al cargar las tareas personales", + "updateSuccess": "Tarea personal actualizada exitosamente", + "createSuccess": "Tarea personal creada exitosamente", + "saveFailed": "Error al guardar la tarea personal", + "deleteSuccess": "Tarea personal eliminada exitosamente", + "deleteFailed": "Error al eliminar la tarea personal" } }, "indexPage": { - "welcomeMessage": "Welcome to Valerie UI App", - "mainPageInfo": "This is the main index page.", - "sampleTodosHeader": "Sample Todos (from IndexPage data)", - "totalCountLabel": "Total count from meta:", - "noTodos": "No todos to display." + "welcomeMessage": "Bienvenido a la aplicación Valerie UI", + "mainPageInfo": "Esta es la página de índice principal.", + "sampleTodosHeader": "Tareas de ejemplo (de datos de IndexPage)", + "totalCountLabel": "Recuento total de meta:", + "noTodos": "No hay tareas para mostrar." } -} +} \ No newline at end of file diff --git a/fe/src/i18n/fr.json b/fe/src/i18n/fr.json index 887eb41..cc89b52 100644 --- a/fe/src/i18n/fr.json +++ b/fe/src/i18n/fr.json @@ -3,588 +3,629 @@ "hello": "Bonjour" }, "loginPage": { - "emailLabel": "FR: Email", - "passwordLabel": "FR: Password", - "togglePasswordVisibilityLabel": "FR: Toggle password visibility", - "loginButton": "FR: Login", - "signupLink": "FR: Don't have an account? Sign up", + "emailLabel": "E-mail", + "passwordLabel": "Mot de passe", + "togglePasswordVisibilityLabel": "Basculer la visibilité du mot de passe", + "loginButton": "Connexion", + "signupLink": "Vous n'avez pas de compte ? S'inscrire", "errors": { - "emailRequired": "FR: Email is required", - "emailInvalid": "FR: Invalid email format", - "passwordRequired": "FR: Password is required", - "loginFailed": "FR: Login failed. Please check your credentials." + "emailRequired": "L'e-mail est requis", + "emailInvalid": "Format d'e-mail invalide", + "passwordRequired": "Le mot de passe est requis", + "loginFailed": "Échec de la connexion. Veuillez vérifier vos identifiants." }, "notifications": { - "loginSuccess": "FR: Login successful" + "loginSuccess": "Connexion réussie" } }, "listsPage": { - "retryButton": "FR: Retry", + "retryButton": "Réessayer", "emptyState": { - "noListsForGroup": "FR: No lists found for this group.", - "noListsYet": "FR: You have no lists yet.", - "personalGlobalInfo": "FR: Create a personal list or join a group to see shared lists.", - "groupSpecificInfo": "FR: This group doesn't have any lists yet." + "noListsForGroup": "Aucune liste trouvée pour ce groupe.", + "noListsYet": "Vous n'avez pas encore de listes.", + "personalGlobalInfo": "Créez une liste personnelle ou rejoignez un groupe pour voir les listes partagées.", + "groupSpecificInfo": "Ce groupe n'a pas encore de listes." }, - "createNewListButton": "FR: Create New List", - "loadingLists": "FR: Loading lists...", - "noDescription": "FR: No description", - "addItemPlaceholder": "FR: Add new item...", + "createNewListButton": "Créer une nouvelle liste", + "loadingLists": "Chargement des listes...", + "noDescription": "Pas de description", + "addItemPlaceholder": "Ajouter un nouvel article...", "createCard": { - "title": "FR: + Create a new list" + "title": "+ Liste" }, "pageTitle": { - "forGroup": "FR: Lists for {groupName}", - "forGroupId": "FR: Lists for Group {groupId}", - "myLists": "FR: My Lists" + "forGroup": "Listes pour {groupName}", + "forGroupId": "Listes pour le groupe {groupId}", + "myLists": "Mes listes" }, "errors": { - "fetchFailed": "FR: Failed to fetch lists." + "fetchFailed": "Échec de la récupération des listes." } }, "groupsPage": { - "retryButton": "FR: Retry", + "retryButton": "Réessayer", "emptyState": { - "title": "FR: No Groups Yet!", - "description": "FR: You are not a member of any groups yet. Create one or join using an invite code.", - "createButton": "FR: Create New Group" + "title": "Pas encore de groupes !", + "description": "Vous n'êtes membre d'aucun groupe pour le moment. Créez-en un ou rejoignez-en un à l'aide d'un code d'invitation.", + "createButton": "Créer un nouveau groupe" }, "groupCard": { - "newListButton": "FR: List" + "newListButton": "Liste" }, "createCard": { - "title": "FR: + Group" + "title": "+ Groupe" }, "joinGroup": { - "title": "FR: Join a Group with Invite Code", - "inputLabel": "FR: Enter Invite Code", - "inputPlaceholder": "FR: Enter Invite Code", - "joinButton": "FR: Join" + "title": "Rejoindre un groupe avec un code d'invitation", + "inputLabel": "Entrez le code d'invitation", + "inputPlaceholder": "Entrez le code d'invitation", + "joinButton": "Rejoindre" }, "createDialog": { - "title": "FR: Create New Group", - "closeButtonLabel": "FR: Close", - "groupNameLabel": "FR: Group Name", - "cancelButton": "FR: Cancel", - "createButton": "FR: Create" + "title": "Créer un nouveau groupe", + "closeButtonLabel": "Fermer", + "groupNameLabel": "Nom du groupe", + "cancelButton": "Annuler", + "createButton": "Créer" }, "errors": { - "fetchFailed": "FR: Failed to load groups", - "groupNameRequired": "FR: Group name is required", - "createFailed": "FR: Failed to create group. Please try again.", - "inviteCodeRequired": "FR: Invite code is required", - "joinFailed": "FR: Failed to join group. Please check the invite code and try again.", - "invalidDataFromServer": "[TRANSLATE] Invalid data received from server.", - "createFailedConsole": "[TRANSLATE] Error creating group:", - "joinFailedConsole": "[TRANSLATE] Error joining group:" + "fetchFailed": "Échec du chargement des groupes", + "groupNameRequired": "Le nom du groupe est requis", + "createFailed": "Échec de la création du groupe. Veuillez réessayer.", + "inviteCodeRequired": "Le code d'invitation est requis", + "joinFailed": "Échec pour rejoindre le groupe. Veuillez vérifier le code d'invitation et réessayer.", + "invalidDataFromServer": "Données invalides reçues du serveur.", + "createFailedConsole": "Erreur lors de la création du groupe :", + "joinFailedConsole": "Erreur pour rejoindre le groupe :" }, "notifications": { - "groupCreatedSuccess": "FR: Group '{groupName}' created successfully.", - "joinSuccessNamed": "FR: Successfully joined group '{groupName}'.", - "joinSuccessGeneric": "FR: Successfully joined group.", - "listCreatedSuccess": "FR: List '{listName}' created successfully." + "groupCreatedSuccess": "Groupe '{groupName}' créé avec succès.", + "joinSuccessNamed": "Vous avez rejoint le groupe '{groupName}' avec succès.", + "joinSuccessGeneric": "Vous avez rejoint le groupe avec succès.", + "listCreatedSuccess": "Liste '{listName}' créée avec succès." } }, "authCallbackPage": { - "redirecting": "FR: Redirecting...", + "redirecting": "Redirection...", "errors": { - "authenticationFailed": "FR: Authentication failed", - "noTokenProvided": "[TRANSLATE] No token provided" + "authenticationFailed": "Échec de l'authentification", + "noTokenProvided": "Aucun jeton fourni" } }, "choresPage": { - "title": "FR: Chores", + "title": "Tâches", "tabs": { - "overdue": "FR: Overdue", - "today": "FR: Today", - "upcoming": "FR: Upcoming", - "allPending": "FR: All Pending", - "completed": "FR: Completed" + "overdue": "En retard", + "today": "Aujourd'hui", + "upcoming": "À venir", + "allPending": "Toutes en attente", + "completed": "Terminées" }, "viewToggle": { - "calendarLabel": "FR: Calendar View", - "calendarText": "FR: Calendar", - "listLabel": "FR: List View", - "listText": "FR: List" + "calendarLabel": "Vue Calendrier", + "calendarText": "Calendrier", + "listLabel": "Vue Liste", + "listText": "Liste" }, - "newChoreButtonLabel": "FR: New Chore", - "newChoreButtonText": "FR: New Chore", + "newChoreButtonLabel": "Nouvelle tâche", + "newChoreButtonText": "Nouvelle tâche", "loadingState": { - "loadingChores": "FR: Loading chores..." + "loadingChores": "Chargement des tâches..." }, "calendar": { - "prevMonthLabel": "FR: Previous month", - "nextMonthLabel": "FR: Next month", + "prevMonthLabel": "Mois précédent", + "nextMonthLabel": "Mois suivant", "weekdays": { - "sun": "FR: Sun", - "mon": "FR: Mon", - "tue": "FR: Tue", - "wed": "FR: Wed", - "thu": "FR: Thu", - "fri": "FR: Fri", - "sat": "FR: Sat" + "sun": "Dim", + "mon": "Lun", + "tue": "Mar", + "wed": "Mer", + "thu": "Jeu", + "fri": "Ven", + "sat": "Sam" }, - "addChoreToDayLabel": "FR: Add chore to this day", - "emptyState": "FR: No chores to display for this period." + "addChoreToDayLabel": "Ajouter une tâche à ce jour", + "emptyState": "Aucune tâche à afficher pour cette période." }, "listView": { - "choreTypePersonal": "FR: Personal", - "choreTypeGroupFallback": "FR: Group", - "completedDatePrefix": "FR: Completed:", + "choreTypePersonal": "Personnel", + "choreTypeGroupFallback": "Groupe", + "completedDatePrefix": "Terminée le :", "actions": { - "doneTitle": "FR: Mark as Done", - "doneText": "FR: Done", - "undoTitle": "FR: Mark as Not Done", - "undoText": "FR: Undo", - "editTitle": "FR: Edit", - "editLabel": "FR: Edit chore", - "editText": "FR: Edit", - "deleteTitle": "FR: Delete", - "deleteLabel": "FR: Delete chore", - "deleteText": "FR: Delete" + "doneTitle": "Marquer comme terminée", + "doneText": "Terminée", + "undoTitle": "Marquer comme non terminée", + "undoText": "Annuler", + "editTitle": "Modifier", + "editLabel": "Modifier la tâche", + "editText": "Modifier", + "deleteTitle": "Supprimer", + "deleteLabel": "Supprimer la tâche", + "deleteText": "Supprimer" }, "emptyState": { - "message": "FR: No chores in this view. Well done!", - "viewAllButton": "FR: View All Pending" + "message": "Aucune tâche dans cette vue. Bien joué !", + "viewAllButton": "Voir toutes les tâches en attente" } }, "choreModal": { - "editTitle": "FR: Edit Chore", - "newTitle": "FR: New Chore", - "closeButtonLabel": "FR: Close modal", - "nameLabel": "FR: Name", - "namePlaceholder": "FR: Enter chore name", - "typeLabel": "FR: Type", - "typePersonal": "FR: Personal", - "typeGroup": "FR: Group", - "groupLabel": "FR: Group", - "groupSelectDefault": "FR: Select a group", - "descriptionLabel": "FR: Description", - "descriptionPlaceholder": "FR: Add a description (optional)", - "frequencyLabel": "FR: Frequency", - "intervalLabel": "FR: Interval (days)", - "intervalPlaceholder": "FR: e.g. 3", - "dueDateLabel": "FR: Due Date", - "quickDueDateToday": "FR: Today", - "quickDueDateTomorrow": "FR: Tomorrow", - "quickDueDateNextWeek": "FR: Next Week", - "cancelButton": "FR: Cancel", - "saveButton": "FR: Save", - "intervalPlaceholder": "[TRANSLATE] e.g., 10" + "editTitle": "Modifier la tâche", + "newTitle": "Nouvelle tâche", + "closeButtonLabel": "Fermer la modale", + "nameLabel": "Nom", + "namePlaceholder": "Entrez le nom de la tâche", + "typeLabel": "Type", + "typePersonal": "Personnel", + "typeGroup": "Groupe", + "groupLabel": "Groupe", + "groupSelectDefault": "Sélectionnez un groupe", + "descriptionLabel": "Description", + "descriptionPlaceholder": "Ajoutez une description (facultatif)", + "frequencyLabel": "Fréquence", + "intervalLabel": "Intervalle (jours)", + "intervalPlaceholder": "ex: 10", + "dueDateLabel": "Date d'échéance", + "quickDueDateToday": "Aujourd'hui", + "quickDueDateTomorrow": "Demain", + "quickDueDateNextWeek": "Semaine prochaine", + "cancelButton": "Annuler", + "saveButton": "Enregistrer" }, "consoleErrors": { - "loadFailed": "[TRANSLATE] Failed to load all chores:", - "loadGroupsFailed": "[TRANSLATE] Failed to load groups", - "createAssignmentForNewChoreFailed": "[TRANSLATE] Failed to create assignment for new chore:", - "saveFailed": "[TRANSLATE] Failed to save chore:", - "deleteFailed": "[TRANSLATE] Failed to delete chore:", - "createAssignmentFailed": "[TRANSLATE] Failed to create assignment:", - "updateCompletionStatusFailed": "[TRANSLATE] Failed to update chore completion status:" + "loadFailed": "Échec du chargement de toutes les tâches :", + "loadGroupsFailed": "Échec du chargement des groupes", + "createAssignmentForNewChoreFailed": "Échec de la création de l'affectation pour la nouvelle tâche :", + "saveFailed": "Échec de l'enregistrement de la tâche :", + "deleteFailed": "Échec de la suppression de la tâche :", + "createAssignmentFailed": "Échec de la création de l'affectation :", + "updateCompletionStatusFailed": "Échec de la mise à jour du statut d'achèvement de la tâche :" }, "deleteDialog": { - "title": "FR: Delete Chore", - "confirmationText": "FR: Are you sure you want to delete this chore? This action cannot be undone.", - "deleteButton": "FR: Delete" + "title": "Supprimer la tâche", + "confirmationText": "Êtes-vous sûr de vouloir supprimer cette tâche ? Cette action est irréversible.", + "deleteButton": "Supprimer" }, "shortcutsModal": { - "title": "FR: Keyboard Shortcuts", - "descNewChore": "FR: New Chore", - "descToggleView": "FR: Toggle View (List/Calendar)", - "descToggleShortcuts": "FR: Show/Hide Shortcuts", - "descCloseModal": "FR: Close any open Modal/Dialog" + "title": "Raccourcis clavier", + "descNewChore": "Nouvelle tâche", + "descToggleView": "Basculer la vue (Liste/Calendrier)", + "descToggleShortcuts": "Afficher/Masquer les raccourcis", + "descCloseModal": "Fermer toute modale/dialogue ouvert" }, "frequencyOptions": { - "oneTime": "FR: One Time", - "daily": "FR: Daily", - "weekly": "FR: Weekly", - "monthly": "FR: Monthly", - "custom": "FR: Custom" + "oneTime": "Une fois", + "daily": "Quotidien", + "weekly": "Hebdomadaire", + "monthly": "Mensuel", + "custom": "Personnalisé" + }, + "frequency": { + "customInterval": "Tous les {n} jour | Tous les {n} jours" }, "formatters": { - "noDueDate": "FR: No due date", - "dueToday": "FR: Due Today", - "dueTomorrow": "FR: Due Tomorrow", - "overdueFull": "FR: Overdue: {date}", - "dueFull": "FR: Due {date}", - "invalidDate": "FR: Invalid Date" + "noDueDate": "Pas de date d'échéance", + "dueToday": "Pour aujourd'hui", + "dueTomorrow": "Pour demain", + "overdueFull": "En retard : {date}", + "dueFull": "Échéance le {date}", + "invalidDate": "Date invalide" }, "notifications": { - "loadFailed": "FR: Failed to load chores", - "updateSuccess": "FR: Chore '{name}' updated successfully", - "createSuccess": "FR: Chore '{name}' created successfully", - "updateFailed": "FR: Failed to update chore", - "createFailed": "FR: Failed to create chore", - "deleteSuccess": "FR: Chore '{name}' deleted successfully", - "deleteFailed": "FR: Failed to delete chore", - "markedDone": "FR: {name} marked as done.", - "markedNotDone": "FR: {name} marked as not done.", - "statusUpdateFailed": "FR: Failed to update chore status." + "loadFailed": "Échec du chargement des tâches.", + "loadGroupsFailed": "Échec du chargement des groupes.", + "updateSuccess": "Tâche '{name}' mise à jour avec succès.", + "createSuccess": "Tâche '{name}' créée avec succès.", + "updateFailed": "Échec de la mise à jour de la tâche.", + "createFailed": "Échec de la création de la tâche.", + "deleteSuccess": "Tâche '{name}' supprimée avec succès.", + "deleteFailed": "Échec de la suppression de la tâche.", + "markedDone": "{name} marquée comme terminée.", + "markedNotDone": "{name} marquée comme non terminée.", + "statusUpdateFailed": "Échec de la mise à jour du statut de la tâche.", + "createAssignmentFailed": "Échec de la création de l'affectation pour la tâche." }, "validation": { - "nameRequired": "FR: Chore name is required.", - "groupRequired": "FR: Please select a group for group chores.", - "intervalRequired": "FR: Custom interval must be at least 1 day.", - "dueDateRequired": "FR: Due date is required.", - "invalidDueDate": "FR: Invalid due date format." + "nameRequired": "Le nom de la tâche est requis.", + "groupRequired": "Veuillez sélectionner un groupe pour les tâches de groupe.", + "intervalRequired": "L'intervalle personnalisé doit être d'au moins 1 jour.", + "dueDateRequired": "La date d'échéance est requise.", + "invalidDueDate": "Format de date d'échéance invalide." }, - "unsavedChangesConfirmation": "FR: You have unsaved changes in the chore form. Are you sure you want to leave?" + "unsavedChangesConfirmation": "Vous avez des modifications non enregistrées dans le formulaire de tâche. Êtes-vous sûr de vouloir quitter ?" }, "errorNotFoundPage": { - "errorCode": "FR: 404", - "errorMessage": "FR: Oops. Nothing here...", - "goHomeButton": "FR: Go Home" + "errorCode": "404", + "errorMessage": "Oups. Rien ici...", + "goHomeButton": "Aller à l'accueil" }, "groupDetailPage": { - "loadingLabel": "FR: Loading group details...", - "retryButton": "FR: Retry", - "groupNotFound": "FR: Group not found or an error occurred.", + "loadingLabel": "Chargement des détails du groupe...", + "retryButton": "Réessayer", + "groupNotFound": "Groupe non trouvé ou une erreur s'est produite.", "members": { - "title": "FR: Group Members", - "defaultRole": "FR: Member", - "removeButton": "FR: Remove", - "emptyState": "FR: No members found.", - "closeMenuLabel": "[TRANSLATE] Close menu" + "title": "Membres du groupe", + "defaultRole": "Membre", + "removeButton": "Retirer", + "emptyState": "Aucun membre trouvé.", + "closeMenuLabel": "Fermer le menu" }, "invites": { - "title": "FR: Invite Members", - "description": "[TRANSLATE] Invite new members by generating a shareable code.", - "addMemberButtonLabel": "[TRANSLATE] Add member", - "closeInviteLabel": "[TRANSLATE] Close invite", - "regenerateButton": "FR: Regenerate Invite Code", - "generateButton": "FR: Generate Invite Code", - "activeCodeLabel": "FR: Current Active Invite Code:", - "copyButtonLabel": "FR: Copy invite code", - "copySuccess": "FR: Invite code copied to clipboard!", - "emptyState": "FR: No active invite code. Click the button above to generate one.", + "title": "Inviter des membres", + "description": "Invitez de nouveaux membres en générant un code partageable.", + "addMemberButtonLabel": "Ajouter un membre", + "closeInviteLabel": "Fermer l'invitation", + "regenerateButton": "Régénérer le code d'invitation", + "generateButton": "Générer le code d'invitation", + "activeCodeLabel": "Code d'invitation actif actuel :", + "copyButtonLabel": "Copier le code d'invitation", + "copySuccess": "Code d'invitation copié dans le presse-papiers !", + "emptyState": "Aucun code d'invitation actif. Cliquez sur le bouton ci-dessus pour en générer un.", "errors": { - "newDataInvalid": "FR: New invite code data is invalid." + "newDataInvalid": "Les données du nouveau code d'invitation sont invalides." } }, "errors": { - "failedToFetchActiveInvite": "[TRANSLATE] Failed to fetch active invite code.", - "failedToFetchGroupDetails": "[TRANSLATE] Failed to fetch group details.", - "failedToLoadUpcomingChores": "[TRANSLATE] Error loading upcoming chores:", - "failedToLoadRecentExpenses": "[TRANSLATE] Error loading recent expenses:" + "failedToFetchActiveInvite": "Échec de la récupération du code d'invitation actif.", + "failedToFetchGroupDetails": "Échec de la récupération des détails du groupe.", + "failedToLoadUpcomingChores": "Erreur lors du chargement des tâches à venir :", + "failedToLoadRecentExpenses": "Erreur lors du chargement des dépenses récentes :" }, "console": { - "noActiveInvite": "[TRANSLATE] No active invite code found for this group." + "noActiveInvite": "Aucun code d'invitation actif trouvé pour ce groupe." }, "chores": { - "title": "FR: Group Chores", - "manageButton": "FR: Manage Chores", - "duePrefix": "FR: Due:", - "emptyState": "FR: No chores scheduled. Click \"Manage Chores\" to create some!" + "title": "Tâches du groupe", + "manageButton": "Gérer les tâches", + "duePrefix": "Échéance :", + "emptyState": "Aucune tâche planifiée. Cliquez sur \"Gérer les tâches\" pour en créer !" }, "expenses": { - "title": "FR: Group Expenses", - "manageButton": "FR: Manage Expenses", - "emptyState": "FR: No expenses recorded. Click \"Manage Expenses\" to add some!", - "fallbackUserName": "[TRANSLATE] User ID: {userId}", - "activityByUserFallback": "[TRANSLATE] User {userId}", + "title": "Dépenses du groupe", + "manageButton": "Gérer les dépenses", + "emptyState": "Aucune dépense enregistrée. Cliquez sur \"Gérer les dépenses\" pour en ajouter !", + "paidBy": "Payé par :", + "owes": "doit", + "paidAmount": "Payé :", + "onDate": "le", + "settleShareButton": "Régler ma part", + "activityLabel": "Activité :", + "byUser": "par", + "fallbackUserName": "ID utilisateur : {userId}", + "activityByUserFallback": "Utilisateur {userId}", "splitTypes": { - "equal": "FR: Equal", - "exactAmounts": "FR: Exact Amounts", - "percentage": "FR: Percentage", - "shares": "FR: Shares", - "itemBased": "FR: Item Based" + "equal": "Égal", + "exactAmounts": "Montants exacts", + "percentage": "Pourcentage", + "shares": "Parts", + "itemBased": "Basé sur l'article" } }, "notifications": { - "fetchDetailsFailed": "FR: Failed to fetch group details.", - "fetchInviteFailed": "FR: Failed to fetch active invite code.", - "generateInviteSuccess": "FR: New invite code generated successfully!", - "generateInviteError": "FR: Failed to generate invite code.", - "clipboardNotSupported": "FR: Clipboard not supported or no code to copy.", - "copyInviteFailed": "FR: Failed to copy invite code.", - "removeMemberSuccess": "FR: Member removed successfully", - "removeMemberFailed": "FR: Failed to remove member" + "fetchDetailsFailed": "Échec de la récupération des détails du groupe.", + "fetchInviteFailed": "Échec de la récupération du code d'invitation actif.", + "generateInviteSuccess": "Nouveau code d'invitation généré avec succès !", + "generateInviteError": "Échec de la génération du code d'invitation.", + "clipboardNotSupported": "Presse-papiers non pris en charge ou aucun code à copier.", + "copyInviteFailed": "Échec de la copie du code d'invitation.", + "removeMemberSuccess": "Membre retiré avec succès", + "removeMemberFailed": "Échec du retrait du membre", + "loadExpensesFailed": "Échec du chargement des dépenses récentes.", + "cannotSettleOthersShares": "Vous ne pouvez régler que vos propres parts.", + "settlementDataMissing": "Impossible de traiter le règlement : données manquantes.", + "settleShareSuccess": "Part réglée avec succès !", + "settleShareFailed": "Échec du règlement de la part." + }, + "loading": { + "settlement": "Traitement du règlement..." + }, + "settleShareModal": { + "title": "Régler la part", + "settleAmountFor": "Régler le montant pour {userName} :", + "amountLabel": "Montant", + "cancelButton": "Annuler", + "confirmButton": "Confirmer", + "errors": { + "enterAmount": "Veuillez entrer un montant.", + "positiveAmount": "Veuillez entrer un montant positif.", + "exceedsRemaining": "Le montant ne peut pas dépasser le reste : {amount}.", + "noSplitSelected": "Erreur : Aucune répartition sélectionnée." + } + }, + "status": { + "settled": "Réglé", + "partiallySettled": "Partiellement réglé", + "unsettled": "Non réglé", + "paid": "Payé", + "partiallyPaid": "Partiellement payé", + "unpaid": "Non payé", + "unknown": "Statut inconnu" } }, "accountPage": { - "title": "Account Settings", - "loadingProfile": "Loading profile...", - "retryButton": "Retry", + "title": "Paramètres du compte", + "loadingProfile": "Chargement du profil...", + "retryButton": "Réessayer", "profileSection": { - "header": "Profile Information", - "nameLabel": "Name", - "emailLabel": "Email", - "saveButton": "Save Changes" + "header": "Informations du profil", + "nameLabel": "Nom", + "emailLabel": "E-mail", + "saveButton": "Enregistrer les modifications" }, "passwordSection": { - "header": "Change Password", - "currentPasswordLabel": "Current Password", - "newPasswordLabel": "New Password", - "changeButton": "Change Password" + "header": "Changer le mot de passe", + "currentPasswordLabel": "Mot de passe actuel", + "newPasswordLabel": "Nouveau mot de passe", + "changeButton": "Changer le mot de passe" }, "notificationsSection": { - "header": "Notification Preferences", - "emailNotificationsLabel": "Email Notifications", - "emailNotificationsDescription": "Receive email notifications for important updates", - "listUpdatesLabel": "List Updates", - "listUpdatesDescription": "Get notified when lists are updated", - "groupActivitiesLabel": "Group Activities", - "groupActivitiesDescription": "Receive notifications for group activities" + "header": "Préférences de notification", + "emailNotificationsLabel": "Notifications par e-mail", + "emailNotificationsDescription": "Recevoir des notifications par e-mail pour les mises à jour importantes", + "listUpdatesLabel": "Mises à jour des listes", + "listUpdatesDescription": "Être notifié lorsque les listes sont mises à jour", + "groupActivitiesLabel": "Activités de groupe", + "groupActivitiesDescription": "Recevoir des notifications pour les activités de groupe" }, "notifications": { - "profileLoadFailed": "Failed to load profile", - "profileUpdateSuccess": "Profile updated successfully", - "profileUpdateFailed": "Failed to update profile", - "passwordFieldsRequired": "Please fill in both current and new password fields.", - "passwordTooShort": "New password must be at least 8 characters long.", - "passwordChangeSuccess": "Password changed successfully", - "passwordChangeFailed": "Failed to change password", - "preferencesUpdateSuccess": "Preferences updated successfully", - "preferencesUpdateFailed": "Failed to update preferences" + "profileLoadFailed": "Échec du chargement du profil", + "profileUpdateSuccess": "Profil mis à jour avec succès", + "profileUpdateFailed": "Échec de la mise à jour du profil", + "passwordFieldsRequired": "Veuillez remplir les champs du mot de passe actuel et du nouveau mot de passe.", + "passwordTooShort": "Le nouveau mot de passe doit comporter au moins 8 caractères.", + "passwordChangeSuccess": "Mot de passe changé avec succès", + "passwordChangeFailed": "Échec du changement de mot de passe", + "preferencesUpdateSuccess": "Préférences mises à jour avec succès", + "preferencesUpdateFailed": "Échec de la mise à jour des préférences" }, - "saving": "Saving..." + "saving": "Enregistrement..." }, "signupPage": { - "header": "Sign Up", - "fullNameLabel": "Full Name", - "emailLabel": "Email", - "passwordLabel": "Password", - "confirmPasswordLabel": "Confirm Password", - "togglePasswordVisibility": "Toggle password visibility", - "submitButton": "Sign Up", - "loginLink": "Already have an account? Login", + "header": "S'inscrire", + "fullNameLabel": "Nom complet", + "emailLabel": "E-mail", + "passwordLabel": "Mot de passe", + "confirmPasswordLabel": "Confirmer le mot de passe", + "togglePasswordVisibility": "Basculer la visibilité du mot de passe", + "submitButton": "S'inscrire", + "loginLink": "Vous avez déjà un compte ? Connexion", "validation": { - "nameRequired": "Name is required", - "emailRequired": "Email is required", - "emailInvalid": "Invalid email format", - "passwordRequired": "Password is required", - "passwordLength": "Password must be at least 8 characters", - "confirmPasswordRequired": "Please confirm your password", - "passwordsNoMatch": "Passwords do not match" + "nameRequired": "Le nom est requis", + "emailRequired": "L'e-mail est requis", + "emailInvalid": "Format d'e-mail invalide", + "passwordRequired": "Le mot de passe est requis", + "passwordLength": "Le mot de passe doit comporter au moins 8 caractères", + "confirmPasswordRequired": "Veuillez confirmer votre mot de passe", + "passwordsNoMatch": "Les mots de passe ne correspondent pas" }, "notifications": { - "signupFailed": "Signup failed. Please try again.", - "signupSuccess": "Account created successfully. Please login." + "signupFailed": "Échec de l'inscription. Veuillez réessayer.", + "signupSuccess": "Compte créé avec succès. Veuillez vous connecter." } }, "listDetailPage": { "loading": { - "list": "Loading list...", - "items": "Loading items...", - "ocrProcessing": "Processing image...", - "addingOcrItems": "Adding OCR items...", - "costSummary": "Loading summary...", - "expenses": "Loading expenses...", - "settlement": "Processing settlement..." + "list": "Chargement de la liste...", + "items": "Chargement des articles...", + "ocrProcessing": "Traitement de l'image...", + "addingOcrItems": "Ajout des articles OCR...", + "costSummary": "Chargement du résumé...", + "expenses": "Chargement des dépenses...", + "settlement": "Traitement du règlement..." }, "errors": { - "fetchFailed": "Failed to load list details.", - "genericLoadFailure": "Group not found or an error occurred.", - "ocrNoItems": "No items extracted from the image.", - "ocrFailed": "Failed to process image.", - "addItemFailed": "Failed to add item.", - "updateItemFailed": "Failed to update item.", - "updateItemPriceFailed": "Failed to update item price.", - "deleteItemFailed": "Failed to delete item.", - "addOcrItemsFailed": "Failed to add OCR items.", - "fetchItemsFailed": "Failed to load items: {errorMessage}", - "loadCostSummaryFailed": "Failed to load cost summary." + "fetchFailed": "Échec du chargement des détails de la liste.", + "genericLoadFailure": "Groupe non trouvé ou une erreur s'est produite.", + "ocrNoItems": "Aucun article extrait de l'image.", + "ocrFailed": "Échec du traitement de l'image.", + "addItemFailed": "Échec de l'ajout de l'article.", + "updateItemFailed": "Échec de la mise à jour de l'article.", + "updateItemPriceFailed": "Échec de la mise à jour du prix de l'article.", + "deleteItemFailed": "Échec de la suppression de l'article.", + "addOcrItemsFailed": "Échec de l'ajout des articles OCR.", + "fetchItemsFailed": "Échec du chargement des articles : {errorMessage}", + "loadCostSummaryFailed": "Échec du chargement du résumé des coûts." }, - "retryButton": "Retry", + "retryButton": "Réessayer", "buttons": { - "addViaOcr": "Add via OCR", - "addItem": "Add", - "addItems": "Add Items", - "cancel": "Cancel", - "confirm": "Confirm", - "saveChanges": "Save Changes", - "close": "Close", - "costSummary": "Cost Summary" + "addViaOcr": "Ajouter via OCR", + "addItem": "Ajouter", + "addItems": "Ajouter des articles", + "cancel": "Annuler", + "confirm": "Confirmer", + "saveChanges": "Enregistrer les modifications", + "close": "Fermer", + "costSummary": "Résumé des coûts" }, "badges": { - "groupList": "Group List", - "personalList": "Personal List" + "groupList": "Liste de groupe", + "personalList": "Liste personnelle" }, "items": { "emptyState": { - "title": "No Items Yet!", - "message": "Add some items using the form below." + "title": "Pas encore d'articles !", + "message": "Ajoutez des articles en utilisant le formulaire ci-dessous." }, "addItemForm": { - "placeholder": "Add a new item", - "quantityPlaceholder": "Qty", - "itemNameSrLabel": "New item name", - "quantitySrLabel": "Quantity" + "placeholder": "Ajouter un nouvel article", + "quantityPlaceholder": "Qté", + "itemNameSrLabel": "Nom du nouvel article", + "quantitySrLabel": "Quantité" }, - "pricePlaceholder": "Price", - "editItemAriaLabel": "Edit item", - "deleteItemAriaLabel": "Delete item" + "pricePlaceholder": "Prix", + "editItemAriaLabel": "Modifier l'article", + "deleteItemAriaLabel": "Supprimer l'article" }, "modals": { "ocr": { - "title": "Add Items via OCR", - "uploadLabel": "Upload Image" + "title": "Ajouter des articles via OCR", + "uploadLabel": "Télécharger une image" }, "confirmation": { "title": "Confirmation" }, "editItem": { - "title": "Edit Item", - "nameLabel": "Item Name", - "quantityLabel": "Quantity" + "title": "Modifier l'article", + "nameLabel": "Nom de l'article", + "quantityLabel": "Quantité" }, "costSummary": { - "title": "List Cost Summary", - "totalCostLabel": "Total List Cost:", - "equalShareLabel": "Equal Share Per User:", - "participantsLabel": "Participating Users:", - "userBalancesHeader": "User Balances", + "title": "Résumé des coûts de la liste", + "totalCostLabel": "Coût total de la liste :", + "equalShareLabel": "Part égale par utilisateur :", + "participantsLabel": "Utilisateurs participants :", + "userBalancesHeader": "Soldes des utilisateurs", "tableHeaders": { - "user": "User", - "itemsAddedValue": "Items Added Value", - "amountDue": "Amount Due", - "balance": "Balance" + "user": "Utilisateur", + "itemsAddedValue": "Valeur des articles ajoutés", + "amountDue": "Montant dû", + "balance": "Solde" }, - "emptyState": "No cost summary available." + "emptyState": "Aucun résumé des coûts disponible." }, "settleShare": { - "title": "Settle Share", - "settleAmountFor": "Settle amount for {userName}:", - "amountLabel": "Amount", + "title": "Régler la part", + "settleAmountFor": "Régler le montant pour {userName} :", + "amountLabel": "Montant", "errors": { - "enterAmount": "Please enter an amount.", - "positiveAmount": "Please enter a positive amount.", - "exceedsRemaining": "Amount cannot exceed remaining: {amount}.", - "noSplitSelected": "Error: No split selected." + "enterAmount": "Veuillez entrer un montant.", + "positiveAmount": "Veuillez entrer un montant positif.", + "exceedsRemaining": "Le montant ne peut pas dépasser le reste : {amount}.", + "noSplitSelected": "Erreur : Aucune répartition sélectionnée." } } }, "confirmations": { - "updateMessage": "Mark '{itemName}' as {status}?", - "statusComplete": "complete", - "statusIncomplete": "incomplete", - "deleteMessage": "Delete '{itemName}'? This cannot be undone." + "updateMessage": "Marquer '{itemName}' comme {status} ?", + "statusComplete": "terminé", + "statusIncomplete": "incomplet", + "deleteMessage": "Supprimer '{itemName}' ? Ceci ne peut pas être annulé." }, "notifications": { - "itemAddedSuccess": "Item added successfully.", - "itemsAddedSuccessOcr": "{count} item(s) added successfully from OCR.", - "itemUpdatedSuccess": "Item updated successfully.", - "itemDeleteSuccess": "Item deleted successfully.", - "enterItemName": "Please enter an item name.", - "costSummaryLoadFailed": "Failed to load cost summary.", - "cannotSettleOthersShares": "You can only settle your own shares.", - "settlementDataMissing": "Cannot process settlement: missing data.", - "settleShareSuccess": "Share settled successfully!", - "settleShareFailed": "Failed to settle share." + "itemAddedSuccess": "Article ajouté avec succès.", + "itemsAddedSuccessOcr": "{count} article(s) ajouté(s) avec succès depuis l'OCR.", + "itemUpdatedSuccess": "Article mis à jour avec succès.", + "itemDeleteSuccess": "Article supprimé avec succès.", + "enterItemName": "Veuillez entrer un nom d'article.", + "costSummaryLoadFailed": "Échec du chargement du résumé des coûts.", + "cannotSettleOthersShares": "Vous ne pouvez régler que vos propres parts.", + "settlementDataMissing": "Impossible de traiter le règlement : données manquantes.", + "settleShareSuccess": "Part réglée avec succès !", + "settleShareFailed": "Échec du règlement de la part." }, "expensesSection": { - "title": "Expenses", - "addExpenseButton": "Add Expense", - "loading": "Loading expenses...", - "emptyState": "No expenses recorded for this list yet.", - "paidBy": "Paid by:", - "onDate": "on", - "owes": "owes", - "paidAmount": "Paid:", - "activityLabel": "Activity:", - "byUser": "by", - "settleShareButton": "Settle My Share", - "retryButton": "Retry" + "title": "Dépenses", + "addExpenseButton": "Ajouter une dépense", + "loading": "Chargement des dépenses...", + "emptyState": "Aucune dépense enregistrée pour cette liste pour le moment.", + "paidBy": "Payé par :", + "onDate": "le", + "owes": "doit", + "paidAmount": "Payé :", + "activityLabel": "Activité :", + "byUser": "par", + "settleShareButton": "Régler ma part", + "retryButton": "Réessayer" }, "status": { - "settled": "Settled", - "partiallySettled": "Partially Settled", - "unsettled": "Unsettled", - "paid": "Paid", - "partiallyPaid": "Partially Paid", - "unpaid": "Unpaid", - "unknown": "Unknown Status" + "settled": "Réglé", + "partiallySettled": "Partiellement réglé", + "unsettled": "Non réglé", + "paid": "Payé", + "partiallyPaid": "Partiellement payé", + "unpaid": "Non payé", + "unknown": "Statut inconnu" } }, "myChoresPage": { - "title": "My Assigned Chores", - "showCompletedToggle": "Show Completed", + "title": "Mes tâches assignées", + "showCompletedToggle": "Afficher les tâches terminées", "timelineHeaders": { - "overdue": "Overdue", - "today": "Due Today", - "thisWeek": "This Week", - "later": "Later", - "completed": "Completed" + "overdue": "En retard", + "today": "Pour aujourd'hui", + "thisWeek": "Cette semaine", + "later": "Plus tard", + "completed": "Terminées" }, "choreCard": { - "personal": "Personal", - "group": "Group", - "duePrefix": "Due", - "completedPrefix": "Completed", - "dueToday": "Due Today", - "markCompleteButton": "Mark Complete" + "personal": "Personnel", + "group": "Groupe", + "duePrefix": "Échéance", + "completedPrefix": "Terminée", + "dueToday": "Pour aujourd'hui", + "markCompleteButton": "Marquer comme terminée" }, "frequencies": { - "one_time": "One Time", - "daily": "Daily", - "weekly": "Weekly", - "monthly": "Monthly", - "custom": "Custom", - "unknown": "Unknown Frequency" + "one_time": "Une fois", + "daily": "Quotidien", + "weekly": "Hebdomadaire", + "monthly": "Mensuel", + "custom": "Personnalisé", + "unknown": "Fréquence inconnue" }, "dates": { - "invalidDate": "Invalid Date", - "unknownDate": "Unknown Date" + "invalidDate": "Date invalide", + "unknownDate": "Date inconnue" }, "emptyState": { - "title": "No Assignments Yet!", - "noAssignmentsPending": "You have no pending chore assignments.", - "noAssignmentsAll": "You have no chore assignments (completed or pending).", - "viewAllChoresButton": "View All Chores" + "title": "Pas encore d'affectations !", + "noAssignmentsPending": "Vous n'avez aucune affectation de tâche en attente.", + "noAssignmentsAll": "Vous n'avez aucune affectation de tâche (terminée ou en attente).", + "viewAllChoresButton": "Voir toutes les tâches" }, "notifications": { - "loadFailed": "Failed to load assignments", - "markedComplete": "Marked \"{choreName}\" as complete!", - "markCompleteFailed": "Failed to mark assignment as complete" + "loadFailed": "Échec du chargement des affectations", + "markedComplete": "\"{choreName}\" marquée comme terminée !", + "markCompleteFailed": "Échec du marquage de l'affectation comme terminée" } }, "personalChoresPage": { - "title": "Personal Chores", - "newChoreButton": "New Chore", - "editButton": "Edit", - "deleteButton": "Delete", - "cancelButton": "Cancel", - "saveButton": "Save", + "title": "Tâches personnelles", + "newChoreButton": "Nouvelle tâche", + "editButton": "Modifier", + "deleteButton": "Supprimer", + "cancelButton": "Annuler", + "saveButton": "Enregistrer", "modals": { - "editChoreTitle": "Edit Chore", - "newChoreTitle": "New Chore", - "deleteChoreTitle": "Delete Chore" + "editChoreTitle": "Modifier la tâche", + "newChoreTitle": "Nouvelle tâche", + "deleteChoreTitle": "Supprimer la tâche" }, "form": { - "nameLabel": "Name", + "nameLabel": "Nom", "descriptionLabel": "Description", - "frequencyLabel": "Frequency", - "intervalLabel": "Interval (days)", - "dueDateLabel": "Next Due Date" + "frequencyLabel": "Fréquence", + "intervalLabel": "Intervalle (jours)", + "dueDateLabel": "Prochaine date d'échéance" }, "deleteDialog": { - "confirmationText": "Are you sure you want to delete this chore?" + "confirmationText": "Êtes-vous sûr de vouloir supprimer cette tâche ?" }, "frequencies": { - "one_time": "One Time", - "daily": "Daily", - "weekly": "Weekly", - "monthly": "Monthly", - "custom": "Custom", - "unknown": "Unknown Frequency" + "one_time": "Une fois", + "daily": "Quotidien", + "weekly": "Hebdomadaire", + "monthly": "Mensuel", + "custom": "Personnalisé", + "unknown": "Fréquence inconnue" }, "dates": { - "invalidDate": "Invalid Date", - "duePrefix": "Due" + "invalidDate": "Date invalide", + "duePrefix": "Échéance" }, "notifications": { - "loadFailed": "Failed to load personal chores", - "updateSuccess": "Personal chore updated successfully", - "createSuccess": "Personal chore created successfully", - "saveFailed": "Failed to save personal chore", - "deleteSuccess": "Personal chore deleted successfully", - "deleteFailed": "Failed to delete personal chore" + "loadFailed": "Échec du chargement des tâches personnelles", + "updateSuccess": "Tâche personnelle mise à jour avec succès", + "createSuccess": "Tâche personnelle créée avec succès", + "saveFailed": "Échec de l'enregistrement de la tâche personnelle", + "deleteSuccess": "Tâche personnelle supprimée avec succès", + "deleteFailed": "Échec de la suppression de la tâche personnelle" } }, "indexPage": { - "welcomeMessage": "Welcome to Valerie UI App", - "mainPageInfo": "This is the main index page.", - "sampleTodosHeader": "Sample Todos (from IndexPage data)", - "totalCountLabel": "Total count from meta:", - "noTodos": "No todos to display." + "welcomeMessage": "Bienvenue sur l'application Valerie UI", + "mainPageInfo": "Ceci est la page d'index principale.", + "sampleTodosHeader": "Exemples de tâches (depuis les données IndexPage)", + "totalCountLabel": "Nombre total depuis meta :", + "noTodos": "Aucune tâche à afficher." } -} +} \ No newline at end of file diff --git a/fe/src/i18n/index.ts b/fe/src/i18n/index.ts index cf5cbc9..fc030cf 100644 --- a/fe/src/i18n/index.ts +++ b/fe/src/i18n/index.ts @@ -1,11 +1,13 @@ import en from './en.json'; import de from './de.json'; +import nl from './nl.json'; import fr from './fr.json'; import es from './es.json'; export default { en, de, + nl, fr, es }; diff --git a/fe/src/i18n/nl.json b/fe/src/i18n/nl.json new file mode 100644 index 0000000..4e017bb --- /dev/null +++ b/fe/src/i18n/nl.json @@ -0,0 +1,631 @@ +{ + "message": { + "hello": "Hallo" + }, + "loginPage": { + "emailLabel": "E-mail", + "passwordLabel": "Wachtwoord", + "togglePasswordVisibilityLabel": "Wachtwoord zichtbaarheid wisselen", + "loginButton": "Inloggen", + "signupLink": "Geen account? Aanmelden", + "errors": { + "emailRequired": "E-mail is vereist", + "emailInvalid": "Ongeldig e-mailformaat", + "passwordRequired": "Wachtwoord is vereist", + "loginFailed": "Inloggen mislukt. Controleer uw gegevens." + }, + "notifications": { + "loginSuccess": "Succesvol ingelogd" + } + }, + "listsPage": { + "retryButton": "Opnieuw proberen", + "emptyState": { + "noListsForGroup": "Geen lijsten gevonden voor deze groep.", + "noListsYet": "U heeft nog geen lijsten.", + "personalGlobalInfo": "Maak een persoonlijke lijst of word lid van een groep om gedeelde lijsten te zien.", + "groupSpecificInfo": "Deze groep heeft nog geen lijsten." + }, + "createNewListButton": "Nieuwe lijst maken", + "loadingLists": "Lijsten laden...", + "noDescription": "Geen beschrijving", + "addItemPlaceholder": "Nieuw item toevoegen...", + "createCard": { + "title": "+ Lijst" + }, + "pageTitle": { + "forGroup": "Lijsten voor {groupName}", + "forGroupId": "Lijsten voor Groep {groupId}", + "myLists": "Mijn Lijsten" + }, + "errors": { + "fetchFailed": "Ophalen van lijsten mislukt." + } + }, + "groupsPage": { + "retryButton": "Opnieuw proberen", + "emptyState": { + "title": "Nog geen groepen!", + "description": "U bent nog geen lid van groepen. Maak er een aan of word lid met een uitnodigingscode.", + "createButton": "Nieuwe groep maken" + }, + "groupCard": { + "newListButton": "Lijst" + }, + "createCard": { + "title": "+ Groep" + }, + "joinGroup": { + "title": "Lid worden van een groep met uitnodigingscode", + "inputLabel": "Voer uitnodigingscode in", + "inputPlaceholder": "Voer uitnodigingscode in", + "joinButton": "Deelnemen" + }, + "createDialog": { + "title": "Nieuwe groep maken", + "closeButtonLabel": "Sluiten", + "groupNameLabel": "Groepsnaam", + "cancelButton": "Annuleren", + "createButton": "Maken" + }, + "errors": { + "fetchFailed": "Laden van groepen mislukt", + "groupNameRequired": "Groepsnaam is vereist", + "createFailed": "Maken van groep mislukt. Probeer het opnieuw.", + "inviteCodeRequired": "Uitnodigingscode is vereist", + "joinFailed": "Deelnemen aan groep mislukt. Controleer de uitnodigingscode en probeer het opnieuw.", + "invalidDataFromServer": "Ongeldige gegevens ontvangen van server.", + "createFailedConsole": "Fout bij het maken van groep:", + "joinFailedConsole": "Fout bij het deelnemen aan groep:" + }, + "notifications": { + "groupCreatedSuccess": "Groep '{groupName}' succesvol aangemaakt.", + "joinSuccessNamed": "Succesvol lid geworden van groep '{groupName}'.", + "joinSuccessGeneric": "Succesvol lid geworden van groep.", + "listCreatedSuccess": "Lijst '{listName}' succesvol aangemaakt." + } + }, + "authCallbackPage": { + "redirecting": "Bezig met omleiden...", + "errors": { + "authenticationFailed": "Authenticatie mislukt", + "noTokenProvided": "Geen token opgegeven" + } + }, + "choresPage": { + "title": "Taken", + "tabs": { + "overdue": "Achterstallig", + "today": "Vandaag", + "upcoming": "Aankomend", + "allPending": "Alle openstaande", + "completed": "Voltooid" + }, + "viewToggle": { + "calendarLabel": "Kalenderweergave", + "calendarText": "Kalender", + "listLabel": "Lijstweergave", + "listText": "Lijst" + }, + "newChoreButtonLabel": "Nieuwe taak", + "newChoreButtonText": "Nieuwe taak", + "loadingState": { + "loadingChores": "Taken laden..." + }, + "calendar": { + "prevMonthLabel": "Vorige maand", + "nextMonthLabel": "Volgende maand", + "weekdays": { + "sun": "Zo", + "mon": "Ma", + "tue": "Di", + "wed": "Wo", + "thu": "Do", + "fri": "Vr", + "sat": "Za" + }, + "addChoreToDayLabel": "Taak aan deze dag toevoegen", + "emptyState": "Geen taken om weer te geven voor deze periode." + }, + "listView": { + "choreTypePersonal": "Persoonlijk", + "choreTypeGroupFallback": "Groep", + "completedDatePrefix": "Voltooid:", + "actions": { + "doneTitle": "Markeer als voltooid", + "doneText": "Gedaan", + "undoTitle": "Markeer als niet voltooid", + "undoText": "Ongedaan maken", + "editTitle": "Bewerken", + "editLabel": "Taak bewerken", + "editText": "Bewerken", + "deleteTitle": "Verwijderen", + "deleteLabel": "Taak verwijderen", + "deleteText": "Verwijderen" + }, + "emptyState": { + "message": "Geen taken in deze weergave. Goed gedaan!", + "viewAllButton": "Alle openstaande bekijken" + } + }, + "choreModal": { + "editTitle": "Taak bewerken", + "newTitle": "Nieuwe taak", + "closeButtonLabel": "Modal sluiten", + "nameLabel": "Naam", + "namePlaceholder": "Voer taaknaam in", + "typeLabel": "Type", + "typePersonal": "Persoonlijk", + "typeGroup": "Groep", + "groupLabel": "Groep", + "groupSelectDefault": "Selecteer een groep", + "descriptionLabel": "Beschrijving", + "descriptionPlaceholder": "Voeg een beschrijving toe (optioneel)", + "frequencyLabel": "Frequentie", + "intervalLabel": "Interval (dagen)", + "intervalPlaceholder": "bijv. 10", + "dueDateLabel": "Vervaldatum", + "quickDueDateToday": "Vandaag", + "quickDueDateTomorrow": "Morgen", + "quickDueDateNextWeek": "Volgende week", + "cancelButton": "Annuleren", + "saveButton": "Opslaan" + }, + "consoleErrors": { + "loadFailed": "Laden van alle taken mislukt:", + "loadGroupsFailed": "Laden van groepen mislukt", + "createAssignmentForNewChoreFailed": "Toewijzing voor nieuwe taak kon niet worden gemaakt:", + "saveFailed": "Opslaan van taak mislukt:", + "deleteFailed": "Verwijderen van taak mislukt:", + "createAssignmentFailed": "Toewijzing kon niet worden gemaakt:", + "updateCompletionStatusFailed": "Voltooiingsstatus van taak kon niet worden bijgewerkt:" + }, + "deleteDialog": { + "title": "Taak verwijderen", + "confirmationText": "Weet u zeker dat u deze taak wilt verwijderen? Deze actie kan niet ongedaan worden gemaakt.", + "deleteButton": "Verwijderen" + }, + "shortcutsModal": { + "title": "Sneltoetsen", + "descNewChore": "Nieuwe taak", + "descToggleView": "Weergave wisselen (Lijst/Kalender)", + "descToggleShortcuts": "Sneltoetsen tonen/verbergen", + "descCloseModal": "Open Modal/Dialoog sluiten" + }, + "frequencyOptions": { + "oneTime": "Eenmalig", + "daily": "Dagelijks", + "weekly": "Wekelijks", + "monthly": "Maandelijks", + "custom": "Aangepast" + }, + "frequency": { + "customInterval": "Elke {n} dag | Elke {n} dagen" + }, + "formatters": { + "noDueDate": "Geen vervaldatum", + "dueToday": "Vandaag te doen", + "dueTomorrow": "Morgen te doen", + "overdueFull": "Achterstallig: {date}", + "dueFull": "Vervalt op {date}", + "invalidDate": "Ongeldige datum" + }, + "notifications": { + "loadFailed": "Laden van taken mislukt.", + "loadGroupsFailed": "Laden van groepen mislukt.", + "updateSuccess": "Taak '{name}' succesvol bijgewerkt.", + "createSuccess": "Taak '{name}' succesvol aangemaakt.", + "updateFailed": "Bijwerken van taak mislukt.", + "createFailed": "Aanmaken van taak mislukt.", + "deleteSuccess": "Taak '{name}' succesvol verwijderd.", + "deleteFailed": "Verwijderen van taak mislukt.", + "markedDone": "{name} gemarkeerd als voltooid.", + "markedNotDone": "{name} gemarkeerd als niet voltooid.", + "statusUpdateFailed": "Status van taak kon niet worden bijgewerkt.", + "createAssignmentFailed": "Toewijzing voor taak kon niet worden gemaakt." + }, + "validation": { + "nameRequired": "Taaknaam is vereist.", + "groupRequired": "Selecteer een groep voor groepstaken.", + "intervalRequired": "Aangepast interval moet minimaal 1 dag zijn.", + "dueDateRequired": "Vervaldatum is vereist.", + "invalidDueDate": "Ongeldig formaat vervaldatum." + }, + "unsavedChangesConfirmation": "U heeft niet-opgeslagen wijzigingen in het taakformulier. Weet u zeker dat u wilt vertrekken?" + }, + "errorNotFoundPage": { + "errorCode": "404", + "errorMessage": "Oeps. Hier is niets...", + "goHomeButton": "Naar de startpagina" + }, + "groupDetailPage": { + "loadingLabel": "Groepsdetails laden...", + "retryButton": "Opnieuw proberen", + "groupNotFound": "Groep niet gevonden of er is een fout opgetreden.", + "members": { + "title": "Groepsleden", + "defaultRole": "Lid", + "removeButton": "Verwijderen", + "emptyState": "Geen leden gevonden.", + "closeMenuLabel": "Menu sluiten" + }, + "invites": { + "title": "Leden uitnodigen", + "description": "Nodig nieuwe leden uit door een deelbare code te genereren.", + "addMemberButtonLabel": "Lid toevoegen", + "closeInviteLabel": "Uitnodiging sluiten", + "regenerateButton": "Uitnodigingscode opnieuw genereren", + "generateButton": "Uitnodigingscode genereren", + "activeCodeLabel": "Huidige actieve uitnodigingscode:", + "copyButtonLabel": "Kopieer uitnodigingscode", + "copySuccess": "Uitnodigingscode gekopieerd naar klembord!", + "emptyState": "Geen actieve uitnodigingscode. Klik op de knop hierboven om er een te genereren.", + "errors": { + "newDataInvalid": "Gegevens van nieuwe uitnodigingscode zijn ongeldig." + } + }, + "errors": { + "failedToFetchActiveInvite": "Ophalen van actieve uitnodigingscode mislukt.", + "failedToFetchGroupDetails": "Ophalen van groepsdetails mislukt.", + "failedToLoadUpcomingChores": "Fout bij het laden van aankomende taken:", + "failedToLoadRecentExpenses": "Fout bij het laden van recente uitgaven:" + }, + "console": { + "noActiveInvite": "Geen actieve uitnodigingscode gevonden voor deze groep." + }, + "chores": { + "title": "Groepstaken", + "manageButton": "Taken beheren", + "duePrefix": "Vervalt:", + "emptyState": "Geen taken gepland. Klik op \"Taken beheren\" om er enkele aan te maken!" + }, + "expenses": { + "title": "Groepsuitgaven", + "manageButton": "Uitgaven beheren", + "emptyState": "Geen uitgaven geregistreerd. Klik op \"Uitgaven beheren\" om er enkele toe te voegen!", + "paidBy": "Betaald door:", + "owes": "is verschuldigd", + "paidAmount": "Betaald:", + "onDate": "op", + "settleShareButton": "Mijn deel vereffenen", + "activityLabel": "Activiteit:", + "byUser": "door", + "fallbackUserName": "Gebruikers-ID: {userId}", + "activityByUserFallback": "Gebruiker {userId}", + "splitTypes": { + "equal": "Gelijk", + "exactAmounts": "Exacte bedragen", + "percentage": "Percentage", + "shares": "Aandelen", + "itemBased": "Op item gebaseerd" + } + }, + "notifications": { + "fetchDetailsFailed": "Ophalen van groepsdetails mislukt.", + "fetchInviteFailed": "Ophalen van actieve uitnodigingscode mislukt.", + "generateInviteSuccess": "Nieuwe uitnodigingscode succesvol gegenereerd!", + "generateInviteError": "Genereren van uitnodigingscode mislukt.", + "clipboardNotSupported": "Klembord niet ondersteund of geen code om te kopiëren.", + "copyInviteFailed": "Kopiëren van uitnodigingscode mislukt.", + "removeMemberSuccess": "Lid succesvol verwijderd", + "removeMemberFailed": "Verwijderen van lid mislukt", + "loadExpensesFailed": "Laden van recente uitgaven mislukt.", + "cannotSettleOthersShares": "U kunt alleen uw eigen aandelen vereffenen.", + "settlementDataMissing": "Kan vereffening niet verwerken: gegevens ontbreken.", + "settleShareSuccess": "Aandeel succesvol vereffend!", + "settleShareFailed": "Vereffenen van aandeel mislukt." + }, + "loading": { + "settlement": "Bezig met vereffenen..." + }, + "settleShareModal": { + "title": "Aandeel vereffenen", + "settleAmountFor": "Bedrag vereffenen voor {userName}:", + "amountLabel": "Bedrag", + "cancelButton": "Annuleren", + "confirmButton": "Bevestigen", + "errors": { + "enterAmount": "Voer een bedrag in.", + "positiveAmount": "Voer een positief bedrag in.", + "exceedsRemaining": "Bedrag mag resterend bedrag niet overschrijden: {amount}.", + "noSplitSelected": "Fout: Geen verdeling geselecteerd." + } + }, + "status": { + "settled": "Vereffend", + "partiallySettled": "Gedeeltelijk vereffend", + "unsettled": "Openstaand", + "paid": "Betaald", + "partiallyPaid": "Gedeeltelijk betaald", + "unpaid": "Onbetaald", + "unknown": "Onbekende status" + } + }, + "accountPage": { + "title": "Accountinstellingen", + "loadingProfile": "Profiel laden...", + "retryButton": "Opnieuw proberen", + "profileSection": { + "header": "Profielinformatie", + "nameLabel": "Naam", + "emailLabel": "E-mail", + "saveButton": "Wijzigingen opslaan" + }, + "passwordSection": { + "header": "Wachtwoord wijzigen", + "currentPasswordLabel": "Huidig wachtwoord", + "newPasswordLabel": "Nieuw wachtwoord", + "changeButton": "Wachtwoord wijzigen" + }, + "notificationsSection": { + "header": "Notificatievoorkeuren", + "emailNotificationsLabel": "E-mailnotificaties", + "emailNotificationsDescription": "Ontvang e-mailnotificaties voor belangrijke updates", + "listUpdatesLabel": "Lijstupdates", + "listUpdatesDescription": "Ontvang een melding wanneer lijsten worden bijgewerkt", + "groupActivitiesLabel": "Groepsactiviteiten", + "groupActivitiesDescription": "Ontvang meldingen voor groepsactiviteiten" + }, + "notifications": { + "profileLoadFailed": "Laden van profiel mislukt", + "profileUpdateSuccess": "Profiel succesvol bijgewerkt", + "profileUpdateFailed": "Bijwerken van profiel mislukt", + "passwordFieldsRequired": "Vul zowel het huidige als het nieuwe wachtwoordveld in.", + "passwordTooShort": "Nieuw wachtwoord moet minimaal 8 tekens lang zijn.", + "passwordChangeSuccess": "Wachtwoord succesvol gewijzigd", + "passwordChangeFailed": "Wijzigen van wachtwoord mislukt", + "preferencesUpdateSuccess": "Voorkeuren succesvol bijgewerkt", + "preferencesUpdateFailed": "Bijwerken van voorkeuren mislukt" + }, + "saving": "Opslaan..." + }, + "signupPage": { + "header": "Aanmelden", + "fullNameLabel": "Volledige naam", + "emailLabel": "E-mail", + "passwordLabel": "Wachtwoord", + "confirmPasswordLabel": "Bevestig wachtwoord", + "togglePasswordVisibility": "Wachtwoord zichtbaarheid wisselen", + "submitButton": "Aanmelden", + "loginLink": "Heeft u al een account? Inloggen", + "validation": { + "nameRequired": "Naam is vereist", + "emailRequired": "E-mail is vereist", + "emailInvalid": "Ongeldig e-mailformaat", + "passwordRequired": "Wachtwoord is vereist", + "passwordLength": "Wachtwoord moet minimaal 8 tekens lang zijn", + "confirmPasswordRequired": "Bevestig uw wachtwoord", + "passwordsNoMatch": "Wachtwoorden komen niet overeen" + }, + "notifications": { + "signupFailed": "Aanmelden mislukt. Probeer het opnieuw.", + "signupSuccess": "Account succesvol aangemaakt. Log in alstublieft." + } + }, + "listDetailPage": { + "loading": { + "list": "Lijst laden...", + "items": "Items laden...", + "ocrProcessing": "Afbeelding verwerken...", + "addingOcrItems": "OCR-items toevoegen...", + "costSummary": "Samenvatting laden...", + "expenses": "Uitgaven laden...", + "settlement": "Bezig met vereffenen..." + }, + "errors": { + "fetchFailed": "Laden van lijstdetails mislukt.", + "genericLoadFailure": "Groep niet gevonden of er is een fout opgetreden.", + "ocrNoItems": "Geen items uit de afbeelding gehaald.", + "ocrFailed": "Verwerken van afbeelding mislukt.", + "addItemFailed": "Toevoegen van item mislukt.", + "updateItemFailed": "Bijwerken van item mislukt.", + "updateItemPriceFailed": "Bijwerken van itemprijs mislukt.", + "deleteItemFailed": "Verwijderen van item mislukt.", + "addOcrItemsFailed": "Toevoegen van OCR-items mislukt.", + "fetchItemsFailed": "Laden van items mislukt: {errorMessage}", + "loadCostSummaryFailed": "Laden van kostensamenvatting mislukt." + }, + "retryButton": "Opnieuw proberen", + "buttons": { + "addViaOcr": "Toevoegen via OCR", + "addItem": "Toevoegen", + "addItems": "Items toevoegen", + "cancel": "Annuleren", + "confirm": "Bevestigen", + "saveChanges": "Wijzigingen opslaan", + "close": "Sluiten", + "costSummary": "Kostensamenvatting" + }, + "badges": { + "groupList": "Groepslijst", + "personalList": "Persoonlijke lijst" + }, + "items": { + "emptyState": { + "title": "Nog geen items!", + "message": "Voeg items toe via het onderstaande formulier." + }, + "addItemForm": { + "placeholder": "Nieuw item toevoegen", + "quantityPlaceholder": "Aantal", + "itemNameSrLabel": "Naam nieuw item", + "quantitySrLabel": "Hoeveelheid" + }, + "pricePlaceholder": "Prijs", + "editItemAriaLabel": "Item bewerken", + "deleteItemAriaLabel": "Item verwijderen" + }, + "modals": { + "ocr": { + "title": "Items toevoegen via OCR", + "uploadLabel": "Afbeelding uploaden" + }, + "confirmation": { + "title": "Bevestiging" + }, + "editItem": { + "title": "Item bewerken", + "nameLabel": "Itemnaam", + "quantityLabel": "Hoeveelheid" + }, + "costSummary": { + "title": "Kostensamenvatting lijst", + "totalCostLabel": "Totale kosten lijst:", + "equalShareLabel": "Gelijk deel per gebruiker:", + "participantsLabel": "Deelnemende gebruikers:", + "userBalancesHeader": "Gebruikerssaldi", + "tableHeaders": { + "user": "Gebruiker", + "itemsAddedValue": "Waarde toegevoegde items", + "amountDue": "Verschuldigd bedrag", + "balance": "Saldo" + }, + "emptyState": "Geen kostensamenvatting beschikbaar." + }, + "settleShare": { + "title": "Aandeel vereffenen", + "settleAmountFor": "Bedrag vereffenen voor {userName}:", + "amountLabel": "Bedrag", + "errors": { + "enterAmount": "Voer een bedrag in.", + "positiveAmount": "Voer een positief bedrag in.", + "exceedsRemaining": "Bedrag mag resterend bedrag niet overschrijden: {amount}.", + "noSplitSelected": "Fout: Geen verdeling geselecteerd." + } + } + }, + "confirmations": { + "updateMessage": "'{itemName}' markeren als {status}?", + "statusComplete": "voltooid", + "statusIncomplete": "onvolledig", + "deleteMessage": "'{itemName}' verwijderen? Dit kan niet ongedaan worden gemaakt." + }, + "notifications": { + "itemAddedSuccess": "Item succesvol toegevoegd.", + "itemsAddedSuccessOcr": "{count} item(s) succesvol toegevoegd via OCR.", + "itemUpdatedSuccess": "Item succesvol bijgewerkt.", + "itemDeleteSuccess": "Item succesvol verwijderd.", + "enterItemName": "Voer een itemnaam in.", + "costSummaryLoadFailed": "Laden van kostensamenvatting mislukt.", + "cannotSettleOthersShares": "U kunt alleen uw eigen aandelen vereffenen.", + "settlementDataMissing": "Kan vereffening niet verwerken: gegevens ontbreken.", + "settleShareSuccess": "Aandeel succesvol vereffend!", + "settleShareFailed": "Vereffenen van aandeel mislukt." + }, + "expensesSection": { + "title": "Uitgaven", + "addExpenseButton": "Uitgave toevoegen", + "loading": "Uitgaven laden...", + "emptyState": "Nog geen uitgaven geregistreerd voor deze lijst.", + "paidBy": "Betaald door:", + "onDate": "op", + "owes": "is verschuldigd", + "paidAmount": "Betaald:", + "activityLabel": "Activiteit:", + "byUser": "door", + "settleShareButton": "Mijn deel vereffenen", + "retryButton": "Opnieuw proberen" + }, + "status": { + "settled": "Vereffend", + "partiallySettled": "Gedeeltelijk vereffend", + "unsettled": "Openstaand", + "paid": "Betaald", + "partiallyPaid": "Gedeeltelijk betaald", + "unpaid": "Onbetaald", + "unknown": "Onbekende status" + } + }, + "myChoresPage": { + "title": "Mijn toegewezen taken", + "showCompletedToggle": "Voltooide tonen", + "timelineHeaders": { + "overdue": "Achterstallig", + "today": "Vandaag te doen", + "thisWeek": "Deze week", + "later": "Later", + "completed": "Voltooid" + }, + "choreCard": { + "personal": "Persoonlijk", + "group": "Groep", + "duePrefix": "Vervalt", + "completedPrefix": "Voltooid", + "dueToday": "Vandaag te doen", + "markCompleteButton": "Markeer als voltooid" + }, + "frequencies": { + "one_time": "Eenmalig", + "daily": "Dagelijks", + "weekly": "Wekelijks", + "monthly": "Maandelijks", + "custom": "Aangepast", + "unknown": "Onbekende frequentie" + }, + "dates": { + "invalidDate": "Ongeldige datum", + "unknownDate": "Onbekende datum" + }, + "emptyState": { + "title": "Nog geen toewijzingen!", + "noAssignmentsPending": "U heeft geen openstaande taaktoewijzingen.", + "noAssignmentsAll": "U heeft geen taaktoewijzingen (voltooid of openstaand).", + "viewAllChoresButton": "Alle taken bekijken" + }, + "notifications": { + "loadFailed": "Laden van toewijzingen mislukt", + "markedComplete": "\"{choreName}\" gemarkeerd als voltooid!", + "markCompleteFailed": "Markeren van toewijzing als voltooid mislukt" + } + }, + "personalChoresPage": { + "title": "Persoonlijke taken", + "newChoreButton": "Nieuwe taak", + "editButton": "Bewerken", + "deleteButton": "Verwijderen", + "cancelButton": "Annuleren", + "saveButton": "Opslaan", + "modals": { + "editChoreTitle": "Taak bewerken", + "newChoreTitle": "Nieuwe taak", + "deleteChoreTitle": "Taak verwijderen" + }, + "form": { + "nameLabel": "Naam", + "descriptionLabel": "Beschrijving", + "frequencyLabel": "Frequentie", + "intervalLabel": "Interval (dagen)", + "dueDateLabel": "Volgende vervaldatum" + }, + "deleteDialog": { + "confirmationText": "Weet u zeker dat u deze taak wilt verwijderen?" + }, + "frequencies": { + "one_time": "Eenmalig", + "daily": "Dagelijks", + "weekly": "Wekelijks", + "monthly": "Maandelijks", + "custom": "Aangepast", + "unknown": "Onbekende frequentie" + }, + "dates": { + "invalidDate": "Ongeldige datum", + "duePrefix": "Vervalt" + }, + "notifications": { + "loadFailed": "Laden van persoonlijke taken mislukt", + "updateSuccess": "Persoonlijke taak succesvol bijgewerkt", + "createSuccess": "Persoonlijke taak succesvol aangemaakt", + "saveFailed": "Opslaan van persoonlijke taak mislukt", + "deleteSuccess": "Persoonlijke taak succesvol verwijderd", + "deleteFailed": "Verwijderen van persoonlijke taak mislukt" + } + }, + "indexPage": { + "welcomeMessage": "Welkom bij de Valerie UI App", + "mainPageInfo": "Dit is de hoofdindexpagina.", + "sampleTodosHeader": "Voorbeeldtaken (uit IndexPage-gegevens)", + "totalCountLabel": "Totaal aantal uit meta:", + "noTodos": "Geen taken om weer te geven." + } +} \ No newline at end of file diff --git a/fe/src/main.ts b/fe/src/main.ts index 30196f2..e9ec648 100644 --- a/fe/src/main.ts +++ b/fe/src/main.ts @@ -9,6 +9,7 @@ import enMessages from './i18n/en.json' // Import en.json directly import deMessages from './i18n/de.json' import frMessages from './i18n/fr.json' import esMessages from './i18n/es.json' +import nlMessages from './i18n/nl.json' // Global styles import './assets/main.scss' -- 2.45.2 From fb951acb720aa2d4e0a56bd02687f9620887e132 Mon Sep 17 00:00:00 2001 From: mohamad Date: Sun, 8 Jun 2025 01:17:53 +0200 Subject: [PATCH 4/7] feat: Add chore history and scheduling functionality This commit introduces new models and endpoints for managing chore history and scheduling within the application. Key changes include: - Added `ChoreHistory` and `ChoreAssignmentHistory` models to track changes and events related to chores and assignments. - Implemented CRUD operations for chore history in the `history.py` module. - Created endpoints to retrieve chore and assignment history in the `chores.py` and `groups.py` files. - Introduced a scheduling feature for group chores, allowing for round-robin assignment generation. - Updated existing chore and assignment CRUD operations to log history entries for create, update, and delete actions. This enhancement improves the tracking of chore-related events and facilitates better management of group chore assignments. --- ...add_chore_history_and_scheduling_tables.py | 75 +++++++ be/app/api/v1/endpoints/chores.py | 71 ++++++- be/app/api/v1/endpoints/groups.py | 58 +++++- be/app/core/exceptions.py | 12 +- be/app/crud/chore.py | 143 ++++++++++--- be/app/crud/group.py | 6 +- be/app/crud/history.py | 83 ++++++++ be/app/crud/schedule.py | 120 +++++++++++ be/app/models.py | 58 ++++++ be/app/schemas/chore.py | 39 +++- be/app/schemas/group.py | 15 +- fe/src/config/api.ts | 7 +- fe/src/pages/ChoresPage.vue | 12 +- fe/src/pages/GroupDetailPage.vue | 194 +++++++++++++++++- fe/src/services/choreService.ts | 10 +- fe/src/services/groupService.ts | 22 +- fe/src/stores/auth.ts | 2 +- fe/src/types/chore.ts | 38 ++-- fe/src/types/group.ts | 12 ++ 19 files changed, 890 insertions(+), 87 deletions(-) create mode 100644 be/alembic/versions/05bf96a9e18b_add_chore_history_and_scheduling_tables.py create mode 100644 be/app/crud/history.py create mode 100644 be/app/crud/schedule.py create mode 100644 fe/src/types/group.ts diff --git a/be/alembic/versions/05bf96a9e18b_add_chore_history_and_scheduling_tables.py b/be/alembic/versions/05bf96a9e18b_add_chore_history_and_scheduling_tables.py new file mode 100644 index 0000000..12395df --- /dev/null +++ b/be/alembic/versions/05bf96a9e18b_add_chore_history_and_scheduling_tables.py @@ -0,0 +1,75 @@ +"""Add chore history and scheduling tables + +Revision ID: 05bf96a9e18b +Revises: 91d00c100f5b +Create Date: 2025-06-08 00:41:10.516324 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects import postgresql + +# revision identifiers, used by Alembic. +revision: str = '05bf96a9e18b' +down_revision: Union[str, None] = '91d00c100f5b' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + """Upgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + op.create_table('chore_history', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('chore_id', sa.Integer(), nullable=True), + sa.Column('group_id', sa.Integer(), nullable=True), + sa.Column('event_type', sa.Enum('CREATED', 'UPDATED', 'DELETED', 'COMPLETED', 'REOPENED', 'ASSIGNED', 'UNASSIGNED', 'REASSIGNED', 'SCHEDULE_GENERATED', 'DUE_DATE_CHANGED', 'DETAILS_CHANGED', name='chorehistoryeventtypeenum'), nullable=False), + sa.Column('event_data', postgresql.JSONB(astext_type=sa.Text()), nullable=True), + sa.Column('changed_by_user_id', sa.Integer(), nullable=True), + sa.Column('timestamp', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False), + sa.ForeignKeyConstraint(['changed_by_user_id'], ['users.id'], ), + sa.ForeignKeyConstraint(['chore_id'], ['chores.id'], ondelete='CASCADE'), + sa.ForeignKeyConstraint(['group_id'], ['groups.id'], ondelete='CASCADE'), + sa.PrimaryKeyConstraint('id') + ) + op.create_index(op.f('ix_chore_history_chore_id'), 'chore_history', ['chore_id'], unique=False) + op.create_index(op.f('ix_chore_history_group_id'), 'chore_history', ['group_id'], unique=False) + op.create_index(op.f('ix_chore_history_id'), 'chore_history', ['id'], unique=False) + op.create_table('chore_assignment_history', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('assignment_id', sa.Integer(), nullable=False), + sa.Column('event_type', sa.Enum('CREATED', 'UPDATED', 'DELETED', 'COMPLETED', 'REOPENED', 'ASSIGNED', 'UNASSIGNED', 'REASSIGNED', 'SCHEDULE_GENERATED', 'DUE_DATE_CHANGED', 'DETAILS_CHANGED', name='chorehistoryeventtypeenum'), nullable=False), + sa.Column('event_data', postgresql.JSONB(astext_type=sa.Text()), nullable=True), + sa.Column('changed_by_user_id', sa.Integer(), nullable=True), + sa.Column('timestamp', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False), + sa.ForeignKeyConstraint(['assignment_id'], ['chore_assignments.id'], ondelete='CASCADE'), + sa.ForeignKeyConstraint(['changed_by_user_id'], ['users.id'], ), + sa.PrimaryKeyConstraint('id') + ) + op.create_index(op.f('ix_chore_assignment_history_assignment_id'), 'chore_assignment_history', ['assignment_id'], unique=False) + op.create_index(op.f('ix_chore_assignment_history_id'), 'chore_assignment_history', ['id'], unique=False) + op.drop_index('ix_apscheduler_jobs_next_run_time', table_name='apscheduler_jobs') + op.drop_table('apscheduler_jobs') + # ### end Alembic commands ### + + +def downgrade() -> None: + """Downgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + op.create_table('apscheduler_jobs', + sa.Column('id', sa.VARCHAR(length=191), autoincrement=False, nullable=False), + sa.Column('next_run_time', sa.DOUBLE_PRECISION(precision=53), autoincrement=False, nullable=True), + sa.Column('job_state', postgresql.BYTEA(), autoincrement=False, nullable=False), + sa.PrimaryKeyConstraint('id', name='apscheduler_jobs_pkey') + ) + op.create_index('ix_apscheduler_jobs_next_run_time', 'apscheduler_jobs', ['next_run_time'], unique=False) + op.drop_index(op.f('ix_chore_assignment_history_id'), table_name='chore_assignment_history') + op.drop_index(op.f('ix_chore_assignment_history_assignment_id'), table_name='chore_assignment_history') + op.drop_table('chore_assignment_history') + op.drop_index(op.f('ix_chore_history_id'), table_name='chore_history') + op.drop_index(op.f('ix_chore_history_group_id'), table_name='chore_history') + op.drop_index(op.f('ix_chore_history_chore_id'), table_name='chore_history') + op.drop_table('chore_history') + # ### end Alembic commands ### diff --git a/be/app/api/v1/endpoints/chores.py b/be/app/api/v1/endpoints/chores.py index be38736..ba72bcd 100644 --- a/be/app/api/v1/endpoints/chores.py +++ b/be/app/api/v1/endpoints/chores.py @@ -8,8 +8,13 @@ from sqlalchemy.ext.asyncio import AsyncSession from app.database import get_transactional_session, get_session from app.auth import current_active_user from app.models import User as UserModel, Chore as ChoreModel, ChoreTypeEnum -from app.schemas.chore import ChoreCreate, ChoreUpdate, ChorePublic, ChoreAssignmentCreate, ChoreAssignmentUpdate, ChoreAssignmentPublic +from app.schemas.chore import ( + ChoreCreate, ChoreUpdate, ChorePublic, + ChoreAssignmentCreate, ChoreAssignmentUpdate, ChoreAssignmentPublic, + ChoreHistoryPublic, ChoreAssignmentHistoryPublic +) from app.crud import chore as crud_chore +from app.crud import history as crud_history from app.core.exceptions import ChoreNotFoundError, PermissionDeniedError, GroupNotFoundError, DatabaseIntegrityError logger = logging.getLogger(__name__) @@ -450,4 +455,66 @@ async def complete_chore_assignment( raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=e.detail) except DatabaseIntegrityError as e: logger.error(f"DatabaseIntegrityError completing assignment {assignment_id} for {current_user.email}: {e.detail}", exc_info=True) - raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=e.detail) \ No newline at end of file + raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=e.detail) + +# === CHORE HISTORY ENDPOINTS === + +@router.get( + "/{chore_id}/history", + response_model=PyList[ChoreHistoryPublic], + summary="Get Chore History", + tags=["Chores", "History"] +) +async def get_chore_history( + chore_id: int, + db: AsyncSession = Depends(get_session), + current_user: UserModel = Depends(current_active_user), +): + """Retrieves the history of a specific chore.""" + # First, check if user has permission to view the chore itself + chore = await crud_chore.get_chore_by_id(db, chore_id) + if not chore: + raise ChoreNotFoundError(chore_id=chore_id) + + if chore.type == ChoreTypeEnum.personal and chore.created_by_id != current_user.id: + raise PermissionDeniedError("You can only view history for your own personal chores.") + + if chore.type == ChoreTypeEnum.group: + is_member = await crud_chore.is_user_member(db, chore.group_id, current_user.id) + if not is_member: + raise PermissionDeniedError("You must be a member of the group to view this chore's history.") + + logger.info(f"User {current_user.email} getting history for chore {chore_id}") + return await crud_history.get_chore_history(db=db, chore_id=chore_id) + +@router.get( + "/assignments/{assignment_id}/history", + response_model=PyList[ChoreAssignmentHistoryPublic], + summary="Get Chore Assignment History", + tags=["Chore Assignments", "History"] +) +async def get_chore_assignment_history( + assignment_id: int, + db: AsyncSession = Depends(get_session), + current_user: UserModel = Depends(current_active_user), +): + """Retrieves the history of a specific chore assignment.""" + assignment = await crud_chore.get_chore_assignment_by_id(db, assignment_id) + if not assignment: + raise ChoreNotFoundError(assignment_id=assignment_id) + + # Check permission by checking permission on the parent chore + chore = await crud_chore.get_chore_by_id(db, assignment.chore_id) + if not chore: + raise ChoreNotFoundError(chore_id=assignment.chore_id) # Should not happen if assignment exists + + if chore.type == ChoreTypeEnum.personal and chore.created_by_id != current_user.id: + raise PermissionDeniedError("You can only view history for assignments of your own personal chores.") + + if chore.type == ChoreTypeEnum.group: + is_member = await crud_chore.is_user_member(db, chore.group_id, current_user.id) + if not is_member: + raise PermissionDeniedError("You must be a member of the group to view this assignment's history.") + + logger.info(f"User {current_user.email} getting history for assignment {assignment_id}") + return await crud_history.get_assignment_history(db=db, assignment_id=assignment_id) \ No newline at end of file diff --git a/be/app/api/v1/endpoints/groups.py b/be/app/api/v1/endpoints/groups.py index 4aad4b9..64248f2 100644 --- a/be/app/api/v1/endpoints/groups.py +++ b/be/app/api/v1/endpoints/groups.py @@ -8,13 +8,16 @@ from sqlalchemy.ext.asyncio import AsyncSession from app.database import get_transactional_session, get_session from app.auth import current_active_user from app.models import User as UserModel, UserRoleEnum # Import model and enum -from app.schemas.group import GroupCreate, GroupPublic +from app.schemas.group import GroupCreate, GroupPublic, GroupScheduleGenerateRequest from app.schemas.invite import InviteCodePublic from app.schemas.message import Message # For simple responses from app.schemas.list import ListPublic, ListDetail +from app.schemas.chore import ChoreHistoryPublic, ChoreAssignmentPublic from app.crud import group as crud_group from app.crud import invite as crud_invite from app.crud import list as crud_list +from app.crud import history as crud_history +from app.crud import schedule as crud_schedule from app.core.exceptions import ( GroupNotFoundError, GroupPermissionError, @@ -264,4 +267,55 @@ async def read_group_lists( lists = await crud_list.get_lists_for_user(db=db, user_id=current_user.id) group_lists = [list for list in lists if list.group_id == group_id] - return group_lists \ No newline at end of file + return group_lists + +@router.post( + "/{group_id}/chores/generate-schedule", + response_model=List[ChoreAssignmentPublic], + summary="Generate Group Chore Schedule", + tags=["Groups", "Chores"] +) +async def generate_group_chore_schedule( + group_id: int, + schedule_in: GroupScheduleGenerateRequest, + db: AsyncSession = Depends(get_transactional_session), + current_user: UserModel = Depends(current_active_user), +): + """Generates a round-robin chore schedule for a group.""" + logger.info(f"User {current_user.email} generating chore schedule for group {group_id}") + # Permission check: ensure user is a member (or owner/admin if stricter rules are needed) + if not await crud_group.is_user_member(db, group_id, current_user.id): + raise GroupMembershipError(group_id, "generate chore schedule for this group") + + try: + assignments = await crud_schedule.generate_group_chore_schedule( + db=db, + group_id=group_id, + start_date=schedule_in.start_date, + end_date=schedule_in.end_date, + user_id=current_user.id, + member_ids=schedule_in.member_ids, + ) + return assignments + except Exception as e: + logger.error(f"Error generating schedule for group {group_id}: {e}", exc_info=True) + raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=str(e)) + +@router.get( + "/{group_id}/chores/history", + response_model=List[ChoreHistoryPublic], + summary="Get Group Chore History", + tags=["Groups", "Chores", "History"] +) +async def get_group_chore_history( + group_id: int, + db: AsyncSession = Depends(get_session), + current_user: UserModel = Depends(current_active_user), +): + """Retrieves all chore-related history for a specific group.""" + logger.info(f"User {current_user.email} requesting chore history for group {group_id}") + # Permission check + if not await crud_group.is_user_member(db, group_id, current_user.id): + raise GroupMembershipError(group_id, "view chore history for this group") + + return await crud_history.get_group_chore_history(db=db, group_id=group_id) \ No newline at end of file diff --git a/be/app/core/exceptions.py b/be/app/core/exceptions.py index aedd157..c9a2d1f 100644 --- a/be/app/core/exceptions.py +++ b/be/app/core/exceptions.py @@ -332,9 +332,17 @@ class UserOperationError(HTTPException): detail=detail ) +class ChoreOperationError(HTTPException): + """Raised when a chore-related operation fails.""" + def __init__(self, detail: str): + super().__init__( + status_code=status.HTTP_400_BAD_REQUEST, + detail=detail + ) + class ChoreNotFoundError(HTTPException): - """Raised when a chore is not found.""" - def __init__(self, chore_id: int, group_id: Optional[int] = None, detail: Optional[str] = None): + """Raised when a chore or assignment is not found.""" + def __init__(self, chore_id: int = None, assignment_id: int = None, group_id: Optional[int] = None, detail: Optional[str] = None): if detail: error_detail = detail elif group_id is not None: diff --git a/be/app/crud/chore.py b/be/app/crud/chore.py index cbac95e..f8ca622 100644 --- a/be/app/crud/chore.py +++ b/be/app/crud/chore.py @@ -6,10 +6,11 @@ from typing import List, Optional import logging from datetime import date, datetime -from app.models import Chore, Group, User, ChoreAssignment, ChoreFrequencyEnum, ChoreTypeEnum, UserGroup +from app.models import Chore, Group, User, ChoreAssignment, ChoreFrequencyEnum, ChoreTypeEnum, UserGroup, ChoreHistoryEventTypeEnum from app.schemas.chore import ChoreCreate, ChoreUpdate, ChoreAssignmentCreate, ChoreAssignmentUpdate from app.core.chore_utils import calculate_next_due_date from app.crud.group import get_group_by_id, is_user_member +from app.crud.history import create_chore_history_entry, create_assignment_history_entry from app.core.exceptions import ChoreNotFoundError, GroupNotFoundError, PermissionDeniedError, DatabaseIntegrityError logger = logging.getLogger(__name__) @@ -39,7 +40,9 @@ async def get_all_user_chores(db: AsyncSession, user_id: int) -> List[Chore]: personal_chores_query .options( selectinload(Chore.creator), - selectinload(Chore.assignments).selectinload(ChoreAssignment.assigned_user) + selectinload(Chore.assignments).selectinload(ChoreAssignment.assigned_user), + selectinload(Chore.assignments).selectinload(ChoreAssignment.history), + selectinload(Chore.history) ) .order_by(Chore.next_due_date, Chore.name) ) @@ -56,7 +59,9 @@ async def get_all_user_chores(db: AsyncSession, user_id: int) -> List[Chore]: .options( selectinload(Chore.creator), selectinload(Chore.group), - selectinload(Chore.assignments).selectinload(ChoreAssignment.assigned_user) + selectinload(Chore.assignments).selectinload(ChoreAssignment.assigned_user), + selectinload(Chore.assignments).selectinload(ChoreAssignment.history), + selectinload(Chore.history) ) .order_by(Chore.next_due_date, Chore.name) ) @@ -99,6 +104,16 @@ async def create_chore( db.add(db_chore) await db.flush() # Get the ID for the chore + # Log history + await create_chore_history_entry( + db, + chore_id=db_chore.id, + group_id=db_chore.group_id, + changed_by_user_id=user_id, + event_type=ChoreHistoryEventTypeEnum.CREATED, + event_data={"chore_name": db_chore.name} + ) + try: # Load relationships for the response with eager loading result = await db.execute( @@ -107,7 +122,9 @@ async def create_chore( .options( selectinload(Chore.creator), selectinload(Chore.group), - selectinload(Chore.assignments) + selectinload(Chore.assignments).selectinload(ChoreAssignment.assigned_user), + selectinload(Chore.assignments).selectinload(ChoreAssignment.history), + selectinload(Chore.history) ) ) return result.scalar_one() @@ -120,7 +137,13 @@ async def get_chore_by_id(db: AsyncSession, chore_id: int) -> Optional[Chore]: result = await db.execute( select(Chore) .where(Chore.id == chore_id) - .options(selectinload(Chore.creator), selectinload(Chore.group), selectinload(Chore.assignments)) + .options( + selectinload(Chore.creator), + selectinload(Chore.group), + selectinload(Chore.assignments).selectinload(ChoreAssignment.assigned_user), + selectinload(Chore.assignments).selectinload(ChoreAssignment.history), + selectinload(Chore.history) + ) ) return result.scalar_one_or_none() @@ -152,7 +175,9 @@ async def get_personal_chores( ) .options( selectinload(Chore.creator), - selectinload(Chore.assignments).selectinload(ChoreAssignment.assigned_user) + selectinload(Chore.assignments).selectinload(ChoreAssignment.assigned_user), + selectinload(Chore.assignments).selectinload(ChoreAssignment.history), + selectinload(Chore.history) ) .order_by(Chore.next_due_date, Chore.name) ) @@ -175,7 +200,9 @@ async def get_chores_by_group_id( ) .options( selectinload(Chore.creator), - selectinload(Chore.assignments).selectinload(ChoreAssignment.assigned_user) + selectinload(Chore.assignments).selectinload(ChoreAssignment.assigned_user), + selectinload(Chore.assignments).selectinload(ChoreAssignment.history), + selectinload(Chore.history) ) .order_by(Chore.next_due_date, Chore.name) ) @@ -194,6 +221,9 @@ async def update_chore( if not db_chore: raise ChoreNotFoundError(chore_id, group_id) + # Store original state for history + original_data = {field: getattr(db_chore, field) for field in chore_in.model_dump(exclude_unset=True)} + # Check permissions if db_chore.type == ChoreTypeEnum.group: if not group_id: @@ -245,6 +275,23 @@ async def update_chore( if db_chore.frequency == ChoreFrequencyEnum.custom and db_chore.custom_interval_days is None: raise ValueError("custom_interval_days must be set for custom frequency chores.") + # Log history for changes + changes = {} + for field, old_value in original_data.items(): + new_value = getattr(db_chore, field) + if old_value != new_value: + changes[field] = {"old": str(old_value), "new": str(new_value)} + + if changes: + await create_chore_history_entry( + db, + chore_id=chore_id, + group_id=db_chore.group_id, + changed_by_user_id=user_id, + event_type=ChoreHistoryEventTypeEnum.UPDATED, + event_data=changes + ) + try: await db.flush() # Flush changes within the transaction result = await db.execute( @@ -253,7 +300,9 @@ async def update_chore( .options( selectinload(Chore.creator), selectinload(Chore.group), - selectinload(Chore.assignments).selectinload(ChoreAssignment.assigned_user) + selectinload(Chore.assignments).selectinload(ChoreAssignment.assigned_user), + selectinload(Chore.assignments).selectinload(ChoreAssignment.history), + selectinload(Chore.history) ) ) return result.scalar_one() @@ -273,6 +322,16 @@ async def delete_chore( if not db_chore: raise ChoreNotFoundError(chore_id, group_id) + # Log history before deleting + await create_chore_history_entry( + db, + chore_id=chore_id, + group_id=db_chore.group_id, + changed_by_user_id=user_id, + event_type=ChoreHistoryEventTypeEnum.DELETED, + event_data={"chore_name": db_chore.name} + ) + # Check permissions if db_chore.type == ChoreTypeEnum.group: if not group_id: @@ -324,6 +383,15 @@ async def create_chore_assignment( db.add(db_assignment) await db.flush() # Get the ID for the assignment + # Log history + await create_assignment_history_entry( + db, + assignment_id=db_assignment.id, + changed_by_user_id=user_id, + event_type=ChoreHistoryEventTypeEnum.ASSIGNED, + event_data={"assigned_to_user_id": db_assignment.assigned_to_user_id, "due_date": db_assignment.due_date.isoformat()} + ) + try: # Load relationships for the response result = await db.execute( @@ -331,7 +399,8 @@ async def create_chore_assignment( .where(ChoreAssignment.id == db_assignment.id) .options( selectinload(ChoreAssignment.chore).selectinload(Chore.creator), - selectinload(ChoreAssignment.assigned_user) + selectinload(ChoreAssignment.assigned_user), + selectinload(ChoreAssignment.history) ) ) return result.scalar_one() @@ -346,7 +415,8 @@ async def get_chore_assignment_by_id(db: AsyncSession, assignment_id: int) -> Op .where(ChoreAssignment.id == assignment_id) .options( selectinload(ChoreAssignment.chore).selectinload(Chore.creator), - selectinload(ChoreAssignment.assigned_user) + selectinload(ChoreAssignment.assigned_user), + selectinload(ChoreAssignment.history) ) ) return result.scalar_one_or_none() @@ -364,7 +434,8 @@ async def get_user_assignments( query = query.options( selectinload(ChoreAssignment.chore).selectinload(Chore.creator), - selectinload(ChoreAssignment.assigned_user) + selectinload(ChoreAssignment.assigned_user), + selectinload(ChoreAssignment.history) ).order_by(ChoreAssignment.due_date, ChoreAssignment.id) result = await db.execute(query) @@ -393,7 +464,8 @@ async def get_chore_assignments( .where(ChoreAssignment.chore_id == chore_id) .options( selectinload(ChoreAssignment.chore).selectinload(Chore.creator), - selectinload(ChoreAssignment.assigned_user) + selectinload(ChoreAssignment.assigned_user), + selectinload(ChoreAssignment.history) ) .order_by(ChoreAssignment.due_date, ChoreAssignment.id) ) @@ -411,11 +483,10 @@ async def update_chore_assignment( if not db_assignment: raise ChoreNotFoundError(assignment_id=assignment_id) - # Load the chore for permission checking chore = await get_chore_by_id(db, db_assignment.chore_id) if not chore: raise ChoreNotFoundError(chore_id=db_assignment.chore_id) - + # Check permissions - only assignee can complete, but chore managers can reschedule can_manage = False if chore.type == ChoreTypeEnum.personal: @@ -427,19 +498,27 @@ async def update_chore_assignment( update_data = assignment_in.model_dump(exclude_unset=True) + original_assignee = db_assignment.assigned_to_user_id + original_due_date = db_assignment.due_date + # Check specific permissions for different updates if 'is_complete' in update_data and not can_complete: raise PermissionDeniedError(detail="Only the assignee can mark assignments as complete") - if 'due_date' in update_data and not can_manage: - raise PermissionDeniedError(detail="Only chore managers can reschedule assignments") - + if 'due_date' in update_data and update_data['due_date'] != original_due_date: + if not can_manage: + raise PermissionDeniedError(detail="Only chore managers can reschedule assignments") + await create_assignment_history_entry(db, assignment_id=assignment_id, changed_by_user_id=user_id, event_type=ChoreHistoryEventTypeEnum.DUE_DATE_CHANGED, event_data={"old": original_due_date.isoformat(), "new": update_data['due_date'].isoformat()}) + + if 'assigned_to_user_id' in update_data and update_data['assigned_to_user_id'] != original_assignee: + if not can_manage: + raise PermissionDeniedError(detail="Only chore managers can reassign assignments") + await create_assignment_history_entry(db, assignment_id=assignment_id, changed_by_user_id=user_id, event_type=ChoreHistoryEventTypeEnum.REASSIGNED, event_data={"old": original_assignee, "new": update_data['assigned_to_user_id']}) + # Handle completion logic - if 'is_complete' in update_data and update_data['is_complete']: - if not db_assignment.is_complete: # Only if not already complete + if 'is_complete' in update_data: + if update_data['is_complete'] and not db_assignment.is_complete: update_data['completed_at'] = datetime.utcnow() - - # Update parent chore's last_completed_at and recalculate next_due_date chore.last_completed_at = update_data['completed_at'] chore.next_due_date = calculate_next_due_date( current_due_date=chore.next_due_date, @@ -447,24 +526,25 @@ async def update_chore_assignment( custom_interval_days=chore.custom_interval_days, last_completed_date=chore.last_completed_at ) - elif 'is_complete' in update_data and not update_data['is_complete']: - # If marking as incomplete, clear completed_at - update_data['completed_at'] = None + await create_assignment_history_entry(db, assignment_id=assignment_id, changed_by_user_id=user_id, event_type=ChoreHistoryEventTypeEnum.COMPLETED) + elif not update_data['is_complete'] and db_assignment.is_complete: + update_data['completed_at'] = None + await create_assignment_history_entry(db, assignment_id=assignment_id, changed_by_user_id=user_id, event_type=ChoreHistoryEventTypeEnum.REOPENED) # Apply updates for field, value in update_data.items(): setattr(db_assignment, field, value) try: - await db.flush() # Flush changes within the transaction - + await db.flush() # Load relationships for the response result = await db.execute( select(ChoreAssignment) .where(ChoreAssignment.id == db_assignment.id) .options( selectinload(ChoreAssignment.chore).selectinload(Chore.creator), - selectinload(ChoreAssignment.assigned_user) + selectinload(ChoreAssignment.assigned_user), + selectinload(ChoreAssignment.history) ) ) return result.scalar_one() @@ -483,6 +563,15 @@ async def delete_chore_assignment( if not db_assignment: raise ChoreNotFoundError(assignment_id=assignment_id) + # Log history before deleting + await create_assignment_history_entry( + db, + assignment_id=assignment_id, + changed_by_user_id=user_id, + event_type=ChoreHistoryEventTypeEnum.UNASSIGNED, + event_data={"unassigned_user_id": db_assignment.assigned_to_user_id} + ) + # Load the chore for permission checking chore = await get_chore_by_id(db, db_assignment.chore_id) if not chore: diff --git a/be/app/crud/group.py b/be/app/crud/group.py index 535e1f5..054fa08 100644 --- a/be/app/crud/group.py +++ b/be/app/crud/group.py @@ -79,7 +79,8 @@ async def get_user_groups(db: AsyncSession, user_id: int) -> List[GroupModel]: .options( selectinload(GroupModel.member_associations).options( selectinload(UserGroupModel.user) - ) + ), + selectinload(GroupModel.chore_history) # Eager load chore history ) ) return result.scalars().all() @@ -95,7 +96,8 @@ async def get_group_by_id(db: AsyncSession, group_id: int) -> Optional[GroupMode select(GroupModel) .where(GroupModel.id == group_id) .options( - selectinload(GroupModel.member_associations).selectinload(UserGroupModel.user) + selectinload(GroupModel.member_associations).selectinload(UserGroupModel.user), + selectinload(GroupModel.chore_history) # Eager load chore history ) ) return result.scalars().first() diff --git a/be/app/crud/history.py b/be/app/crud/history.py new file mode 100644 index 0000000..e5a8012 --- /dev/null +++ b/be/app/crud/history.py @@ -0,0 +1,83 @@ +# be/app/crud/history.py +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy.future import select +from sqlalchemy.orm import selectinload +from typing import List, Optional, Any, Dict + +from app.models import ChoreHistory, ChoreAssignmentHistory, ChoreHistoryEventTypeEnum, User, Chore, Group +from app.schemas.chore import ChoreHistoryPublic, ChoreAssignmentHistoryPublic + +async def create_chore_history_entry( + db: AsyncSession, + *, + chore_id: Optional[int], + group_id: Optional[int], + changed_by_user_id: Optional[int], + event_type: ChoreHistoryEventTypeEnum, + event_data: Optional[Dict[str, Any]] = None, +) -> ChoreHistory: + """Logs an event in the chore history.""" + history_entry = ChoreHistory( + chore_id=chore_id, + group_id=group_id, + changed_by_user_id=changed_by_user_id, + event_type=event_type, + event_data=event_data or {}, + ) + db.add(history_entry) + await db.flush() + await db.refresh(history_entry) + return history_entry + +async def create_assignment_history_entry( + db: AsyncSession, + *, + assignment_id: int, + changed_by_user_id: int, + event_type: ChoreHistoryEventTypeEnum, + event_data: Optional[Dict[str, Any]] = None, +) -> ChoreAssignmentHistory: + """Logs an event in the chore assignment history.""" + history_entry = ChoreAssignmentHistory( + assignment_id=assignment_id, + changed_by_user_id=changed_by_user_id, + event_type=event_type, + event_data=event_data or {}, + ) + db.add(history_entry) + await db.flush() + await db.refresh(history_entry) + return history_entry + +async def get_chore_history(db: AsyncSession, chore_id: int) -> List[ChoreHistory]: + """Gets all history for a specific chore.""" + result = await db.execute( + select(ChoreHistory) + .where(ChoreHistory.chore_id == chore_id) + .options(selectinload(ChoreHistory.changed_by_user)) + .order_by(ChoreHistory.timestamp.desc()) + ) + return result.scalars().all() + +async def get_assignment_history(db: AsyncSession, assignment_id: int) -> List[ChoreAssignmentHistory]: + """Gets all history for a specific assignment.""" + result = await db.execute( + select(ChoreAssignmentHistory) + .where(ChoreAssignmentHistory.assignment_id == assignment_id) + .options(selectinload(ChoreAssignmentHistory.changed_by_user)) + .order_by(ChoreAssignmentHistory.timestamp.desc()) + ) + return result.scalars().all() + +async def get_group_chore_history(db: AsyncSession, group_id: int) -> List[ChoreHistory]: + """Gets all chore-related history for a group, including chore-specific and group-level events.""" + result = await db.execute( + select(ChoreHistory) + .where(ChoreHistory.group_id == group_id) + .options( + selectinload(ChoreHistory.changed_by_user), + selectinload(ChoreHistory.chore) # Also load chore info if available + ) + .order_by(ChoreHistory.timestamp.desc()) + ) + return result.scalars().all() \ No newline at end of file diff --git a/be/app/crud/schedule.py b/be/app/crud/schedule.py new file mode 100644 index 0000000..a42e0dc --- /dev/null +++ b/be/app/crud/schedule.py @@ -0,0 +1,120 @@ +# be/app/crud/schedule.py +import logging +from datetime import date, timedelta +from typing import List +from itertools import cycle + +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy.future import select + +from app.models import Chore, Group, User, ChoreAssignment, UserGroup, ChoreTypeEnum, ChoreHistoryEventTypeEnum +from app.crud.group import get_group_by_id +from app.crud.history import create_chore_history_entry +from app.core.exceptions import GroupNotFoundError, ChoreOperationError + +logger = logging.getLogger(__name__) + +async def generate_group_chore_schedule( + db: AsyncSession, + *, + group_id: int, + start_date: date, + end_date: date, + user_id: int, # The user initiating the action + member_ids: List[int] = None +) -> List[ChoreAssignment]: + """ + Generates a round-robin chore schedule for all group chores within a date range. + """ + if start_date > end_date: + raise ChoreOperationError("Start date cannot be after end date.") + + group = await get_group_by_id(db, group_id) + if not group: + raise GroupNotFoundError(group_id) + + if not member_ids: + # If no members are specified, use all members from the group + members_result = await db.execute( + select(UserGroup.user_id).where(UserGroup.group_id == group_id) + ) + member_ids = members_result.scalars().all() + + if not member_ids: + raise ChoreOperationError("Cannot generate schedule with no members.") + + # Fetch all chores belonging to this group + chores_result = await db.execute( + select(Chore).where(Chore.group_id == group_id, Chore.type == ChoreTypeEnum.group) + ) + group_chores = chores_result.scalars().all() + if not group_chores: + logger.info(f"No chores found in group {group_id} to generate a schedule for.") + return [] + + member_cycle = cycle(member_ids) + new_assignments = [] + + current_date = start_date + while current_date <= end_date: + for chore in group_chores: + # Check if a chore is due on the current day based on its frequency + # This is a simplified check. A more robust system would use the chore's next_due_date + # and frequency to see if it falls on the current_date. + # For this implementation, we assume we generate assignments for ALL chores on ALL days + # in the range, which might not be desired. + # A better approach is needed here. Let's assume for now we just create assignments for each chore + # on its *next* due date if it falls within the range. + + if start_date <= chore.next_due_date <= end_date: + # Check if an assignment for this chore on this due date already exists + existing_assignment_result = await db.execute( + select(ChoreAssignment.id) + .where(ChoreAssignment.chore_id == chore.id, ChoreAssignment.due_date == chore.next_due_date) + .limit(1) + ) + if existing_assignment_result.scalar_one_or_none(): + logger.info(f"Skipping assignment for chore '{chore.name}' on {chore.next_due_date} as it already exists.") + continue + + assigned_to_user_id = next(member_cycle) + + assignment = ChoreAssignment( + chore_id=chore.id, + assigned_to_user_id=assigned_to_user_id, + due_date=chore.next_due_date, # Assign on the chore's own next_due_date + is_complete=False + ) + db.add(assignment) + new_assignments.append(assignment) + logger.info(f"Created assignment for chore '{chore.name}' to user {assigned_to_user_id} on {chore.next_due_date}") + + current_date += timedelta(days=1) + + if not new_assignments: + logger.info(f"No new assignments were generated for group {group_id} in the specified date range.") + return [] + + # Log a single group-level event for the schedule generation + await create_chore_history_entry( + db, + chore_id=None, # This is a group-level event + group_id=group_id, + changed_by_user_id=user_id, + event_type=ChoreHistoryEventTypeEnum.SCHEDULE_GENERATED, + event_data={ + "start_date": start_date.isoformat(), + "end_date": end_date.isoformat(), + "member_ids": member_ids, + "assignments_created": len(new_assignments) + } + ) + + await db.flush() + + # Refresh assignments to load relationships if needed, although not strictly necessary + # as the objects are already in the session. + for assign in new_assignments: + await db.refresh(assign) + + return new_assignments \ No newline at end of file diff --git a/be/app/models.py b/be/app/models.py index 8c18196..6a4f513 100644 --- a/be/app/models.py +++ b/be/app/models.py @@ -24,6 +24,7 @@ from sqlalchemy import ( Date # Added Date for Chore model ) from sqlalchemy.orm import relationship, backref +from sqlalchemy.dialects.postgresql import JSONB from .database import Base @@ -71,6 +72,20 @@ class ChoreTypeEnum(enum.Enum): personal = "personal" group = "group" +class ChoreHistoryEventTypeEnum(str, enum.Enum): + CREATED = "created" + UPDATED = "updated" + DELETED = "deleted" + COMPLETED = "completed" + REOPENED = "reopened" + ASSIGNED = "assigned" + UNASSIGNED = "unassigned" + REASSIGNED = "reassigned" + SCHEDULE_GENERATED = "schedule_generated" + # Add more specific events as needed + DUE_DATE_CHANGED = "due_date_changed" + DETAILS_CHANGED = "details_changed" + # --- User Model --- class User(Base): __tablename__ = "users" @@ -109,6 +124,11 @@ class User(Base): assigned_chores = relationship("ChoreAssignment", back_populates="assigned_user", cascade="all, delete-orphan") # --- End Relationships for Chores --- + # --- History Relationships --- + chore_history_entries = relationship("ChoreHistory", back_populates="changed_by_user", cascade="all, delete-orphan") + assignment_history_entries = relationship("ChoreAssignmentHistory", back_populates="changed_by_user", cascade="all, delete-orphan") + # --- End History Relationships --- + # --- Group Model --- class Group(Base): @@ -137,6 +157,10 @@ class Group(Base): chores = relationship("Chore", back_populates="group", cascade="all, delete-orphan") # --- End Relationship for Chores --- + # --- History Relationships --- + chore_history = relationship("ChoreHistory", back_populates="group", cascade="all, delete-orphan") + # --- End History Relationships --- + # --- UserGroup Association Model --- class UserGroup(Base): @@ -383,6 +407,7 @@ class Chore(Base): group = relationship("Group", back_populates="chores") creator = relationship("User", back_populates="created_chores") assignments = relationship("ChoreAssignment", back_populates="chore", cascade="all, delete-orphan") + history = relationship("ChoreHistory", back_populates="chore", cascade="all, delete-orphan") # --- ChoreAssignment Model --- @@ -403,6 +428,7 @@ class ChoreAssignment(Base): # --- Relationships --- chore = relationship("Chore", back_populates="assignments") assigned_user = relationship("User", back_populates="assigned_chores") + history = relationship("ChoreAssignmentHistory", back_populates="assignment", cascade="all, delete-orphan") # === NEW: RecurrencePattern Model === @@ -430,3 +456,35 @@ class RecurrencePattern(Base): # === END: RecurrencePattern Model === + +# === NEW: Chore History Models === + +class ChoreHistory(Base): + __tablename__ = "chore_history" + + id = Column(Integer, primary_key=True, index=True) + chore_id = Column(Integer, ForeignKey("chores.id", ondelete="CASCADE"), nullable=True, index=True) + group_id = Column(Integer, ForeignKey("groups.id", ondelete="CASCADE"), nullable=True, index=True) # For group-level events + event_type = Column(SAEnum(ChoreHistoryEventTypeEnum, name="chorehistoryeventtypeenum", create_type=True), nullable=False) + event_data = Column(JSONB, nullable=True) # e.g., {'field': 'name', 'old': 'Old', 'new': 'New'} + changed_by_user_id = Column(Integer, ForeignKey("users.id"), nullable=True) # Nullable if system-generated + timestamp = Column(DateTime(timezone=True), server_default=func.now(), nullable=False) + + # --- Relationships --- + chore = relationship("Chore", back_populates="history") + group = relationship("Group", back_populates="chore_history") + changed_by_user = relationship("User", back_populates="chore_history_entries") + +class ChoreAssignmentHistory(Base): + __tablename__ = "chore_assignment_history" + + id = Column(Integer, primary_key=True, index=True) + assignment_id = Column(Integer, ForeignKey("chore_assignments.id", ondelete="CASCADE"), nullable=False, index=True) + event_type = Column(SAEnum(ChoreHistoryEventTypeEnum, name="chorehistoryeventtypeenum", create_type=True), nullable=False) # Reusing enum + event_data = Column(JSONB, nullable=True) + changed_by_user_id = Column(Integer, ForeignKey("users.id"), nullable=True) + timestamp = Column(DateTime(timezone=True), server_default=func.now(), nullable=False) + + # --- Relationships --- + assignment = relationship("ChoreAssignment", back_populates="history") + changed_by_user = relationship("User", back_populates="assignment_history_entries") diff --git a/be/app/schemas/chore.py b/be/app/schemas/chore.py index 7ba70f1..3605164 100644 --- a/be/app/schemas/chore.py +++ b/be/app/schemas/chore.py @@ -1,13 +1,37 @@ from datetime import date, datetime -from typing import Optional, List +from typing import Optional, List, Any from pydantic import BaseModel, ConfigDict, field_validator # Assuming ChoreFrequencyEnum is imported from models # Adjust the import path if necessary based on your project structure. # e.g., from app.models import ChoreFrequencyEnum -from ..models import ChoreFrequencyEnum, ChoreTypeEnum, User as UserModel # For UserPublic relation +from ..models import ChoreFrequencyEnum, ChoreTypeEnum, User as UserModel, ChoreHistoryEventTypeEnum # For UserPublic relation from .user import UserPublic # For embedding user information +# Forward declaration for circular dependencies +class ChoreAssignmentPublic(BaseModel): + pass + +# History Schemas +class ChoreHistoryPublic(BaseModel): + id: int + event_type: ChoreHistoryEventTypeEnum + event_data: Optional[dict[str, Any]] = None + changed_by_user: Optional[UserPublic] = None + timestamp: datetime + + model_config = ConfigDict(from_attributes=True) + +class ChoreAssignmentHistoryPublic(BaseModel): + id: int + event_type: ChoreHistoryEventTypeEnum + event_data: Optional[dict[str, Any]] = None + changed_by_user: Optional[UserPublic] = None + timestamp: datetime + + model_config = ConfigDict(from_attributes=True) + + # Chore Schemas class ChoreBase(BaseModel): name: str @@ -75,7 +99,8 @@ class ChorePublic(ChoreBase): created_at: datetime updated_at: datetime creator: Optional[UserPublic] = None # Embed creator UserPublic schema - # group: Optional[GroupPublic] = None # Embed GroupPublic schema if needed + assignments: List[ChoreAssignmentPublic] = [] + history: List[ChoreHistoryPublic] = [] model_config = ConfigDict(from_attributes=True) @@ -92,6 +117,7 @@ class ChoreAssignmentUpdate(BaseModel): # Only completion status and perhaps due_date can be updated for an assignment is_complete: Optional[bool] = None due_date: Optional[date] = None # If rescheduling an existing assignment is allowed + assigned_to_user_id: Optional[int] = None # For reassigning the chore class ChoreAssignmentPublic(ChoreAssignmentBase): id: int @@ -100,12 +126,13 @@ class ChoreAssignmentPublic(ChoreAssignmentBase): created_at: datetime updated_at: datetime # Embed ChorePublic and UserPublic for richer responses - chore: Optional[ChorePublic] = None + chore: Optional[ChorePublic] = None assigned_user: Optional[UserPublic] = None + history: List[ChoreAssignmentHistoryPublic] = [] model_config = ConfigDict(from_attributes=True) # To handle potential circular imports if ChorePublic needs GroupPublic and GroupPublic needs ChorePublic # We can update forward refs after all models are defined. -# ChorePublic.model_rebuild() # If using Pydantic v2 and forward refs were used with strings -# ChoreAssignmentPublic.model_rebuild() +ChorePublic.model_rebuild() +ChoreAssignmentPublic.model_rebuild() diff --git a/be/app/schemas/group.py b/be/app/schemas/group.py index 6773e83..ea43806 100644 --- a/be/app/schemas/group.py +++ b/be/app/schemas/group.py @@ -1,14 +1,21 @@ # app/schemas/group.py from pydantic import BaseModel, ConfigDict, computed_field -from datetime import datetime +from datetime import datetime, date from typing import Optional, List from .user import UserPublic # Import UserPublic to represent members +from .chore import ChoreHistoryPublic # Import for history # Properties to receive via API on creation class GroupCreate(BaseModel): name: str +# New schema for generating a schedule +class GroupScheduleGenerateRequest(BaseModel): + start_date: date + end_date: date + member_ids: Optional[List[int]] = None # Optional: if not provided, use all members + # Properties to return to client class GroupPublic(BaseModel): id: int @@ -16,6 +23,7 @@ class GroupPublic(BaseModel): created_by_id: int created_at: datetime member_associations: Optional[List["UserGroupPublic"]] = None + chore_history: Optional[List[ChoreHistoryPublic]] = [] @computed_field @property @@ -39,4 +47,7 @@ class UserGroupPublic(BaseModel): # Properties stored in DB (if needed, often GroupPublic is sufficient) # class GroupInDB(GroupPublic): -# pass \ No newline at end of file +# pass + +# We need to rebuild GroupPublic to resolve the forward reference to UserGroupPublic +GroupPublic.model_rebuild() \ No newline at end of file diff --git a/fe/src/config/api.ts b/fe/src/config/api.ts index 7cddb43..ed3b2b3 100644 --- a/fe/src/config/api.ts +++ b/fe/src/config/api.ts @@ -1,5 +1,6 @@ import { api } from '@/services/api'; -import { API_BASE_URL, API_VERSION, API_ENDPOINTS } from './api-config'; +import { API_BASE_URL, API_VERSION } from './api-config'; +export { API_ENDPOINTS } from './api-config'; // Helper function to get full API URL export const getApiUrl = (endpoint: string): string => { @@ -13,6 +14,4 @@ export const apiClient = { put: (endpoint: string, data = {}, config = {}) => api.put(getApiUrl(endpoint), data, config), patch: (endpoint: string, data = {}, config = {}) => api.patch(getApiUrl(endpoint), data, config), delete: (endpoint: string, config = {}) => api.delete(getApiUrl(endpoint), config), -}; - -export { API_ENDPOINTS }; \ No newline at end of file +}; \ No newline at end of file diff --git a/fe/src/pages/ChoresPage.vue b/fe/src/pages/ChoresPage.vue index 0ec5b6c..15f4e30 100644 --- a/fe/src/pages/ChoresPage.vue +++ b/fe/src/pages/ChoresPage.vue @@ -71,8 +71,8 @@ const loadChores = async () => { return { ...c, current_assignment_id: currentAssignment?.id ?? null, - is_completed: currentAssignment?.is_complete ?? c.is_completed ?? false, - completed_at: currentAssignment?.completed_at ?? c.completed_at ?? null, + is_completed: currentAssignment?.is_complete ?? false, + completed_at: currentAssignment?.completed_at ?? null, updating: false, } }); @@ -401,7 +401,7 @@ const toggleCompletion = async (chore: ChoreWithCompletion) => {
+ }}
@@ -422,7 +422,7 @@ const toggleCompletion = async (chore: ChoreWithCompletion) => {
+ }} @@ -431,7 +431,7 @@ const toggleCompletion = async (chore: ChoreWithCompletion) => { @@ -456,7 +456,7 @@ const toggleCompletion = async (chore: ChoreWithCompletion) => { t('choresPage.deleteConfirm.cancel', 'Cancel') }} + }}
diff --git a/fe/src/pages/GroupDetailPage.vue b/fe/src/pages/GroupDetailPage.vue index 9c704be..8d4aab6 100644 --- a/fe/src/pages/GroupDetailPage.vue +++ b/fe/src/pages/GroupDetailPage.vue @@ -80,15 +80,17 @@
{{ t('groupDetailPage.chores.title') }} - + {{ t('groupDetailPage.chores.generateScheduleButton') }} +
- +
{{ chore.name }} {{ t('groupDetailPage.chores.duePrefix') }} {{ formatDate(chore.next_due_date) - }} + }}
@@ -99,6 +101,20 @@
+ +
+ {{ t('groupDetailPage.activityLog.title') }} +
+ +
+ +

{{ t('groupDetailPage.activityLog.emptyState') }}

+
+
@@ -145,7 +161,10 @@
- {{ split.user?.name || split.user?.email || t('groupDetailPage.expenses.fallbackUserName', { userId: split.user_id }) }} + {{ split.user?.name || split.user?.email || t('groupDetailPage.expenses.fallbackUserName', + { + userId: split.user_id + }) }}
{{ t('groupDetailPage.expenses.owes') }} {{ @@ -177,7 +196,9 @@ {{ t('groupDetailPage.expenses.activityLabel') }} {{ formatCurrency(activity.amount_paid) }} {{ - t('groupDetailPage.expenses.byUser') }} {{ activity.payer?.name || t('groupDetailPage.expenses.activityByUserFallback', { userId: activity.paid_by_user_id }) }} {{ t('groupDetailPage.expenses.onDate') }} {{ new + t('groupDetailPage.expenses.byUser') }} {{ activity.payer?.name || + t('groupDetailPage.expenses.activityByUserFallback', { userId: activity.paid_by_user_id }) }} {{ + t('groupDetailPage.expenses.onDate') }} {{ new Date(activity.paid_at).toLocaleDateString() }} @@ -207,7 +228,10 @@

{{ t('groupDetailPage.settleShareModal.settleAmountFor', { userName: selectedSplitForSettlement?.user?.name - || selectedSplitForSettlement?.user?.email || t('groupDetailPage.expenses.fallbackUserName', { userId: selectedSplitForSettlement?.user_id }) + || selectedSplitForSettlement?.user?.email || t('groupDetailPage.expenses.fallbackUserName', { + userId: + selectedSplitForSettlement?.user_id + }) }) }}

@@ -218,17 +242,64 @@ + + + + +
+ + {{ t('groupDetailPage.choreDetailModal.assignmentsTitle') }} +
+ + +
+ + {{ t('groupDetailPage.choreDetailModal.choreHistoryTitle') }} + +
    +
  • {{ formatHistoryEntry(entry) }}
  • +
+

{{ t('groupDetailPage.choreDetailModal.noHistory') }}

+
+
+ + + + + + + + + + + diff --git a/fe/src/services/choreService.ts b/fe/src/services/choreService.ts index 21e60a0..03bcdca 100644 --- a/fe/src/services/choreService.ts +++ b/fe/src/services/choreService.ts @@ -1,7 +1,8 @@ import { api } from './api' -import type { Chore, ChoreCreate, ChoreUpdate, ChoreType, ChoreAssignment, ChoreAssignmentCreate, ChoreAssignmentUpdate } from '../types/chore' +import type { Chore, ChoreCreate, ChoreUpdate, ChoreType, ChoreAssignment, ChoreAssignmentCreate, ChoreAssignmentUpdate, ChoreHistory } from '../types/chore' import { groupService } from './groupService' import type { Group } from './groupService' +import { apiClient, API_ENDPOINTS } from '@/config/api' export const choreService = { async getAllChores(): Promise { @@ -117,7 +118,7 @@ export const choreService = { // Update assignment async updateAssignment(assignmentId: number, update: ChoreAssignmentUpdate): Promise { - const response = await api.put(`/api/v1/chores/assignments/${assignmentId}`, update) + const response = await apiClient.put(`/api/v1/chores/assignments/${assignmentId}`, update) return response.data }, @@ -180,4 +181,9 @@ export const choreService = { // Renamed original for safety, to be removed await api.delete(`/api/v1/chores/personal/${choreId}`) }, + + async getChoreHistory(choreId: number): Promise { + const response = await apiClient.get(API_ENDPOINTS.CHORES.HISTORY(choreId)) + return response.data + }, } diff --git a/fe/src/services/groupService.ts b/fe/src/services/groupService.ts index fab5b26..15a6041 100644 --- a/fe/src/services/groupService.ts +++ b/fe/src/services/groupService.ts @@ -1,4 +1,6 @@ -import { api } from './api' +import { apiClient, API_ENDPOINTS } from '@/config/api'; +import type { Group } from '@/types/group'; +import type { ChoreHistory } from '@/types/chore'; // Define Group interface matching backend schema export interface Group { @@ -17,13 +19,17 @@ export interface Group { export const groupService = { async getUserGroups(): Promise { - try { - const response = await api.get('/api/v1/groups') - return response.data - } catch (error) { - console.error('Failed to fetch user groups:', error) - throw error - } + const response = await apiClient.get(API_ENDPOINTS.GROUPS.BASE); + return response.data; + }, + + async generateSchedule(groupId: string, data: { start_date: string; end_date: string; member_ids: number[] }): Promise { + await apiClient.post(API_ENDPOINTS.GROUPS.GENERATE_SCHEDULE(groupId), data); + }, + + async getGroupChoreHistory(groupId: string): Promise { + const response = await apiClient.get(API_ENDPOINTS.GROUPS.CHORE_HISTORY(groupId)); + return response.data; }, // Add other group-related service methods here, e.g.: diff --git a/fe/src/stores/auth.ts b/fe/src/stores/auth.ts index 7c6d386..16958b9 100644 --- a/fe/src/stores/auth.ts +++ b/fe/src/stores/auth.ts @@ -4,7 +4,7 @@ import { defineStore } from 'pinia' import { ref, computed } from 'vue' import router from '@/router' -interface AuthState { +export interface AuthState { accessToken: string | null refreshToken: string | null user: { diff --git a/fe/src/types/chore.ts b/fe/src/types/chore.ts index d2e3fcc..dc4081e 100644 --- a/fe/src/types/chore.ts +++ b/fe/src/types/chore.ts @@ -1,7 +1,8 @@ -import type { UserPublic } from './user' +import type { User } from './user' export type ChoreFrequency = 'one_time' | 'daily' | 'weekly' | 'monthly' | 'custom' export type ChoreType = 'personal' | 'group' +export type ChoreHistoryEventType = 'created' | 'updated' | 'deleted' | 'completed' | 'reopened' | 'assigned' | 'unassigned' | 'reassigned' | 'schedule_generated' | 'due_date_changed' | 'details_changed' export interface Chore { id: number @@ -16,14 +17,9 @@ export interface Chore { created_at: string updated_at: string type: ChoreType - creator?: { - id: number - name: string - email: string - } - assignments?: ChoreAssignment[] - is_completed: boolean - completed_at: string | null + creator?: User + assignments: ChoreAssignment[] + history?: ChoreHistory[] } export interface ChoreCreate extends Omit { } @@ -38,11 +34,12 @@ export interface ChoreAssignment { assigned_by_id: number due_date: string is_complete: boolean - completed_at: string | null + completed_at?: string created_at: string updated_at: string chore?: Chore - assigned_user?: UserPublic + assigned_user?: User + history?: ChoreAssignmentHistory[] } export interface ChoreAssignmentCreate { @@ -52,6 +49,23 @@ export interface ChoreAssignmentCreate { } export interface ChoreAssignmentUpdate { - due_date?: string is_complete?: boolean + due_date?: string + assigned_to_user_id?: number +} + +export interface ChoreHistory { + id: number + event_type: ChoreHistoryEventType + event_data?: Record + changed_by_user?: User + timestamp: string +} + +export interface ChoreAssignmentHistory { + id: number + event_type: ChoreHistoryEventType + event_data?: Record + changed_by_user?: User + timestamp: string } diff --git a/fe/src/types/group.ts b/fe/src/types/group.ts new file mode 100644 index 0000000..42902b6 --- /dev/null +++ b/fe/src/types/group.ts @@ -0,0 +1,12 @@ +// fe/src/types/group.ts +import type { AuthState } from '@/stores/auth'; +import type { ChoreHistory } from './chore'; + +export interface Group { + id: number; + name: string; + created_by_id: number; + created_at: string; + members: AuthState['user'][]; + chore_history?: ChoreHistory[]; +} -- 2.45.2 From f20f3c960da88f687d2e8c17b01473ecb57732f7 Mon Sep 17 00:00:00 2001 From: mohamad Date: Sun, 8 Jun 2025 01:32:40 +0200 Subject: [PATCH 5/7] feat: Add language selector and Dutch translations --- fe/src/config/api-config.ts | 15 +++- fe/src/i18n/de.json | 10 +++ fe/src/i18n/en.json | 10 +++ fe/src/i18n/es.json | 10 +++ fe/src/i18n/fr.json | 10 +++ fe/src/i18n/nl.json | 10 +++ fe/src/layouts/MainLayout.vue | 153 ++++++++++++++++++++++++++++++---- fe/src/main.ts | 1 + 8 files changed, 199 insertions(+), 20 deletions(-) diff --git a/fe/src/config/api-config.ts b/fe/src/config/api-config.ts index 2f80e8e..e88ff90 100644 --- a/fe/src/config/api-config.ts +++ b/fe/src/config/api-config.ts @@ -2,7 +2,7 @@ export const API_VERSION = 'v1' // API Base URL -export const API_BASE_URL = (window as any).ENV?.VITE_API_URL || 'https://mitlistbe.mohamad.dev' +export const API_BASE_URL = (window as any).ENV?.VITE_API_URL || 'http://localhost:8000' // API Endpoints export const API_ENDPOINTS = { @@ -33,7 +33,6 @@ export const API_ENDPOINTS = { BASE: '/lists', BY_ID: (id: string) => `/lists/${id}`, STATUS: (id: string) => `/lists/${id}/status`, - STATUSES: '/lists/statuses', ITEMS: (listId: string) => `/lists/${listId}/items`, ITEM: (listId: string, itemId: string) => `/lists/${listId}/items/${itemId}`, EXPENSES: (listId: string) => `/lists/${listId}/expenses`, @@ -62,13 +61,15 @@ export const API_ENDPOINTS = { SETTINGS: (groupId: string) => `/groups/${groupId}/settings`, ROLES: (groupId: string) => `/groups/${groupId}/roles`, ROLE: (groupId: string, roleId: string) => `/groups/${groupId}/roles/${roleId}`, + GENERATE_SCHEDULE: (groupId: string) => `/groups/${groupId}/chores/generate-schedule`, + CHORE_HISTORY: (groupId: string) => `/groups/${groupId}/chores/history`, }, // Invites INVITES: { BASE: '/invites', BY_ID: (id: string) => `/invites/${id}`, - ACCEPT: '/invites/accept', + ACCEPT: (id: string) => `/invites/accept/${id}`, DECLINE: (id: string) => `/invites/decline/${id}`, REVOKE: (id: string) => `/invites/revoke/${id}`, LIST: '/invites', @@ -120,4 +121,12 @@ export const API_ENDPOINTS = { METRICS: '/health/metrics', LOGS: '/health/logs', }, + + CHORES: { + BASE: '/chores', + BY_ID: (id: number) => `/chores/${id}`, + HISTORY: (id: number) => `/chores/${id}/history`, + ASSIGNMENTS: (choreId: number) => `/chores/${choreId}/assignments`, + ASSIGNMENT_BY_ID: (id: number) => `/chores/assignments/${id}`, + }, } diff --git a/fe/src/i18n/de.json b/fe/src/i18n/de.json index 31586fc..a084114 100644 --- a/fe/src/i18n/de.json +++ b/fe/src/i18n/de.json @@ -627,5 +627,15 @@ "sampleTodosHeader": "Beispiel-Todos (aus IndexPage-Daten)", "totalCountLabel": "Gesamtzahl aus Meta:", "noTodos": "Keine Todos zum Anzeigen." + }, + "languageSelector": { + "title": "Sprache", + "languages": { + "en": "English", + "de": "Deutsch", + "nl": "Nederlands", + "fr": "Français", + "es": "Español" + } } } \ No newline at end of file diff --git a/fe/src/i18n/en.json b/fe/src/i18n/en.json index 57abdd0..d81ccf4 100644 --- a/fe/src/i18n/en.json +++ b/fe/src/i18n/en.json @@ -555,5 +555,15 @@ "sampleTodosHeader": "Sample Todos (from IndexPage data)", "totalCountLabel": "Total count from meta:", "noTodos": "No todos to display." + }, + "languageSelector": { + "title": "Language", + "languages": { + "en": "English", + "de": "Deutsch", + "nl": "Nederlands", + "fr": "Français", + "es": "Español" + } } } \ No newline at end of file diff --git a/fe/src/i18n/es.json b/fe/src/i18n/es.json index d89c8d7..2398631 100644 --- a/fe/src/i18n/es.json +++ b/fe/src/i18n/es.json @@ -627,5 +627,15 @@ "sampleTodosHeader": "Tareas de ejemplo (de datos de IndexPage)", "totalCountLabel": "Recuento total de meta:", "noTodos": "No hay tareas para mostrar." + }, + "languageSelector": { + "title": "Idioma", + "languages": { + "en": "English", + "de": "Deutsch", + "nl": "Nederlands", + "fr": "Français", + "es": "Español" + } } } \ No newline at end of file diff --git a/fe/src/i18n/fr.json b/fe/src/i18n/fr.json index cc89b52..7554798 100644 --- a/fe/src/i18n/fr.json +++ b/fe/src/i18n/fr.json @@ -627,5 +627,15 @@ "sampleTodosHeader": "Exemples de tâches (depuis les données IndexPage)", "totalCountLabel": "Nombre total depuis meta :", "noTodos": "Aucune tâche à afficher." + }, + "languageSelector": { + "title": "Langue", + "languages": { + "en": "English", + "de": "Deutsch", + "nl": "Nederlands", + "fr": "Français", + "es": "Español" + } } } \ No newline at end of file diff --git a/fe/src/i18n/nl.json b/fe/src/i18n/nl.json index 4e017bb..ce065d1 100644 --- a/fe/src/i18n/nl.json +++ b/fe/src/i18n/nl.json @@ -627,5 +627,15 @@ "sampleTodosHeader": "Voorbeeldtaken (uit IndexPage-gegevens)", "totalCountLabel": "Totaal aantal uit meta:", "noTodos": "Geen taken om weer te geven." + }, + "languageSelector": { + "title": "Taal", + "languages": { + "en": "English", + "de": "Deutsch", + "nl": "Nederlands", + "fr": "Français", + "es": "Español" + } } } \ No newline at end of file diff --git a/fe/src/layouts/MainLayout.vue b/fe/src/layouts/MainLayout.vue index f5dda86..7a47c33 100644 --- a/fe/src/layouts/MainLayout.vue +++ b/fe/src/layouts/MainLayout.vue @@ -2,17 +2,40 @@
mitlist
-
- -
@@ -53,13 +76,14 @@ @@ -679,4 +894,212 @@ const toggleCompletion = async (chore: ChoreWithCompletion) => { transform: scaleX(1); transform-origin: left; } + +/* New styles for enhanced UX */ +.chore-main-info { + display: flex; + align-items: center; + gap: 0.5rem; + flex-wrap: wrap; +} + +.chore-badges { + display: flex; + gap: 0.25rem; +} + +.badge { + font-size: 0.75rem; + padding: 0.125rem 0.375rem; + border-radius: 0.25rem; + font-weight: 500; + text-transform: uppercase; + letter-spacing: 0.025em; +} + +.badge-group { + background-color: #3b82f6; + color: white; +} + +.badge-overdue { + background-color: #ef4444; + color: white; +} + +.badge-due-today { + background-color: #f59e0b; + color: white; +} + +.chore-description { + font-size: 0.875rem; + color: var(--dark); + opacity: 0.8; + margin-top: 0.25rem; + font-style: italic; +} + +/* Status-based styling */ +.status-overdue { + border-left: 4px solid #ef4444; +} + +.status-due-today { + border-left: 4px solid #f59e0b; +} + +.status-completed { + opacity: 0.7; +} + +/* Modal styles */ +.detail-modal .modal-container, +.history-modal .modal-container { + max-width: 600px; + max-height: 80vh; + overflow-y: auto; +} + +.detail-section { + margin-bottom: 1.5rem; +} + +.detail-section h4 { + margin-bottom: 0.75rem; + font-weight: 600; + color: var(--dark); + border-bottom: 1px solid #e5e7eb; + padding-bottom: 0.25rem; +} + +.detail-grid { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 0.75rem; +} + +.detail-item { + display: flex; + flex-direction: column; + gap: 0.25rem; +} + +.detail-item.full-width { + grid-column: 1 / -1; +} + +.detail-item .label { + font-weight: 600; + font-size: 0.875rem; + color: var(--dark); + opacity: 0.8; +} + +.detail-item .value { + font-size: 0.875rem; + color: var(--dark); +} + +.assignments-list { + display: flex; + flex-direction: column; + gap: 0.75rem; +} + +.assignment-item { + padding: 0.75rem; + border: 1px solid #e5e7eb; + border-radius: 0.5rem; + background-color: #f9fafb; +} + +.assignment-main { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 0.5rem; +} + +.assigned-user { + font-weight: 500; + color: var(--dark); +} + +.assignment-status { + font-size: 0.875rem; + padding: 0.25rem 0.5rem; + border-radius: 0.25rem; + background-color: #fbbf24; + color: white; +} + +.assignment-status.completed { + background-color: #10b981; +} + +.assignment-details { + display: flex; + flex-direction: column; + gap: 0.25rem; + font-size: 0.875rem; + color: var(--dark); + opacity: 0.8; +} + +.history-list { + display: flex; + flex-direction: column; + gap: 0.5rem; +} + +.history-item { + padding: 0.75rem; + border-left: 3px solid #e5e7eb; + background-color: #f9fafb; + border-radius: 0 0.25rem 0.25rem 0; +} + +.history-text { + font-size: 0.875rem; + color: var(--dark); +} + +.history-data { + margin-top: 0.5rem; +} + +.history-data details { + font-size: 0.75rem; +} + +.history-data summary { + cursor: pointer; + color: var(--primary); + font-weight: 500; +} + +.history-data pre { + margin-top: 0.25rem; + padding: 0.5rem; + background-color: #f3f4f6; + border-radius: 0.25rem; + font-size: 0.75rem; + overflow-x: auto; +} + +.loading-spinner { + text-align: center; + padding: 1rem; + color: var(--dark); + opacity: 0.7; +} + +.no-data { + text-align: center; + padding: 1rem; + color: var(--dark); + opacity: 0.7; + font-style: italic; +} -- 2.45.2 From 88c951630891e7ac7c330c64880980f9184459a7 Mon Sep 17 00:00:00 2001 From: mohamad Date: Sun, 8 Jun 2025 02:03:38 +0200 Subject: [PATCH 7/7] feat: Enhance GroupDetailPage with chore assignments and history This update introduces significant improvements to the GroupDetailPage, including: - Added detailed modals for chore assignments and history. - Implemented loading states for assignments and chore history. - Enhanced chore display with status indicators for overdue and due-today. - Improved UI with new styles for chore items and assignment details. These changes enhance user experience by providing more context and information about group chores and their assignments. --- fe/src/assets/valerie-ui.scss | 14 +- fe/src/pages/GroupDetailPage.vue | 1206 +++++++++++++++++++++++------- 2 files changed, 932 insertions(+), 288 deletions(-) diff --git a/fe/src/assets/valerie-ui.scss b/fe/src/assets/valerie-ui.scss index f3d916b..95455cf 100644 --- a/fe/src/assets/valerie-ui.scss +++ b/fe/src/assets/valerie-ui.scss @@ -917,11 +917,13 @@ select.form-input { .modal-backdrop { position: fixed; inset: 0; - background-color: rgba(57, 62, 70, 0.7); + background-color: rgba(57, 62, 70, 0.9); + /* Increased opacity for better visibility */ display: flex; align-items: center; justify-content: center; - z-index: 1000; + z-index: 9999; + /* Increased z-index to ensure it's above other elements */ opacity: 0; visibility: hidden; transition: @@ -941,16 +943,18 @@ select.form-input { background-color: var(--light); border: var(--border); width: 90%; - max-width: 550px; + max-width: 850px; box-shadow: var(--shadow-lg); position: relative; - overflow-y: scroll; - /* Can cause tooltip clipping */ + overflow-y: auto; + /* Changed from scroll to auto */ transform: scale(0.95) translateY(-20px); transition: transform var(--transition-speed) var(--transition-ease-out); max-height: 90vh; display: flex; flex-direction: column; + z-index: 10000; + /* Ensure modal content is above backdrop */ } .modal-container::before { diff --git a/fe/src/pages/GroupDetailPage.vue b/fe/src/pages/GroupDetailPage.vue index 8d4aab6..7d4eb8f 100644 --- a/fe/src/pages/GroupDetailPage.vue +++ b/fe/src/pages/GroupDetailPage.vue @@ -1,300 +1,470 @@ @@ -308,7 +478,7 @@ import ListsPage from './ListsPage.vue'; // Import ListsPage import { useNotificationStore } from '@/stores/notifications'; import { choreService } from '../services/choreService' import type { Chore, ChoreFrequency, ChoreAssignment, ChoreHistory, ChoreAssignmentHistory } from '../types/chore' -import { format } from 'date-fns' +import { format, formatDistanceToNow, parseISO, startOfDay, isEqual, isToday as isTodayDate } from 'date-fns' import type { Expense, ExpenseSplit, SettlementActivityCreate } from '@/types/expense'; import { ExpenseOverallStatusEnum, ExpenseSplitStatusEnum } from '@/types/expense'; import { useAuthStore } from '@/stores/auth'; @@ -326,6 +496,7 @@ import VInput from '@/components/valerie/VInput.vue'; import VFormField from '@/components/valerie/VFormField.vue'; import VIcon from '@/components/valerie/VIcon.vue'; import VModal from '@/components/valerie/VModal.vue'; +import VSelect from '@/components/valerie/VSelect.vue'; import { onClickOutside } from '@vueuse/core' import { groupService } from '../services/groupService'; // New service @@ -425,6 +596,9 @@ const generatingSchedule = ref(false); const groupChoreHistory = ref([]); const groupHistoryLoading = ref(false); +const loadingAssignments = ref(false); +const selectedChoreAssignments = ref([]); + const getApiErrorMessage = (err: unknown, fallbackMessageKey: string): string => { if (err && typeof err === 'object') { if ('response' in err && err.response && typeof err.response === 'object' && 'data' in err.response && err.response.data) { @@ -618,6 +792,30 @@ const formatDate = (date: string) => { return format(new Date(date), 'MMM d, yyyy') } +const getDueDateStatus = (chore: Chore) => { + const today = startOfDay(new Date()); + const dueDate = startOfDay(new Date(chore.next_due_date)); + + if (dueDate < today) return 'overdue'; + if (isEqual(dueDate, today)) return 'due-today'; + return 'upcoming'; +} + +const getChoreStatusInfo = (chore: Chore) => { + const currentAssignment = chore.assignments && chore.assignments.length > 0 ? chore.assignments[0] : null; + const isCompleted = currentAssignment?.is_complete ?? false; + const assignedUser = currentAssignment?.assigned_user; + const dueDateStatus = getDueDateStatus(chore); + + return { + currentAssignment, + isCompleted, + assignedUser, + dueDateStatus, + assignedUserName: assignedUser?.name || assignedUser?.email || 'Unassigned' + }; +} + const formatFrequency = (frequency: ChoreFrequency) => { const options: Record = { one_time: t('choresPage.frequencyOptions.oneTime'), // Reusing existing keys @@ -833,13 +1031,35 @@ const toggleInviteUI = () => { const openChoreDetailModal = async (chore: Chore) => { selectedChore.value = chore; showChoreDetailModal.value = true; + + // Load assignments for this chore + loadingAssignments.value = true; + try { + selectedChoreAssignments.value = await choreService.getChoreAssignments(chore.id); + } catch (error) { + console.error('Failed to load chore assignments:', error); + notificationStore.addNotification({ + message: 'Failed to load chore assignments.', + type: 'error' + }); + } finally { + loadingAssignments.value = false; + } + // Optionally lazy load history if not already loaded with the chore if (!chore.history || chore.history.length === 0) { - const history = await choreService.getChoreHistory(chore.id); - const choreInList = upcomingChores.value.find(c => c.id === chore.id); - if (choreInList) { - choreInList.history = history; - selectedChore.value = choreInList; + try { + const history = await choreService.getChoreHistory(chore.id); + selectedChore.value = { + ...selectedChore.value, + history: history + }; + } catch (error) { + console.error('Failed to load chore history:', error); + notificationStore.addNotification({ + message: 'Failed to load chore history.', + type: 'error' + }); } } }; @@ -900,17 +1120,62 @@ const loadGroupChoreHistory = async () => { const formatHistoryEntry = (entry: ChoreHistory | ChoreAssignmentHistory): string => { const user = entry.changed_by_user?.email || 'System'; - const time = new Date(entry.timestamp).toLocaleString(); + const eventType = entry.event_type.toLowerCase().replace(/_/g, ' '); + + let action = ''; + switch (entry.event_type) { + case 'created': + action = 'created this chore'; + break; + case 'updated': + action = 'updated this chore'; + break; + case 'completed': + action = 'completed the assignment'; + break; + case 'reopened': + action = 'reopened the assignment'; + break; + case 'assigned': + action = 'was assigned to this chore'; + break; + case 'unassigned': + action = 'was unassigned from this chore'; + break; + case 'reassigned': + action = 'was reassigned this chore'; + break; + case 'due_date_changed': + action = 'changed the due date'; + break; + case 'deleted': + action = 'deleted this chore'; + break; + default: + action = eventType; + } + let details = ''; if (entry.event_data) { - details = Object.entries(entry.event_data).map(([key, value]) => { + const changes = Object.entries(entry.event_data).map(([key, value]) => { if (typeof value === 'object' && value !== null && 'old' in value && 'new' in value) { - return `${key} changed from '${value.old}' to '${value.new}'`; + const fieldName = key.replace(/_/g, ' '); + return `${fieldName}: "${value.old}" → "${value.new}"`; } return `${key}: ${JSON.stringify(value)}`; - }).join(', '); + }); + if (changes.length > 0) { + details = ` (${changes.join(', ')})`; + } } - return `${user} ${entry.event_type} on ${time}. Details: ${details}`; + + return `${user} ${action}${details}`; +}; + +const isAssignmentOverdue = (assignment: ChoreAssignment): boolean => { + const dueDate = new Date(assignment.due_date); + const today = startOfDay(new Date()); + return dueDate < today; }; onMounted(() => { @@ -1530,4 +1795,379 @@ onMounted(() => { .neo-settlement-activities li { margin-top: 0.2em; } + +/* Enhanced Chores List Styles */ +.enhanced-chores-list { + display: flex; + flex-direction: column; + gap: 1rem; +} + +.enhanced-chore-item { + background: #fafafa; + border: 2px solid #111; + border-radius: 12px; + padding: 1rem; + cursor: pointer; + transition: all 0.2s ease; + position: relative; +} + +.enhanced-chore-item:hover { + transform: translateY(-2px); + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15); +} + +.enhanced-chore-item.status-overdue { + border-left: 6px solid #ef4444; +} + +.enhanced-chore-item.status-due-today { + border-left: 6px solid #f59e0b; +} + +.enhanced-chore-item.completed { + opacity: 0.8; + background: #f0f9ff; +} + +.chore-main-content { + display: flex; + align-items: flex-start; + gap: 1rem; +} + +.chore-icon-container { + flex-shrink: 0; +} + +.chore-status-indicator { + width: 2.5rem; + height: 2.5rem; + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + font-size: 1.2rem; + background: #e5e7eb; +} + +.chore-status-indicator.overdue { + background: #fee2e2; + color: #dc2626; +} + +.chore-status-indicator.due-today { + background: #fef3c7; + color: #d97706; +} + +.chore-status-indicator.completed { + background: #d1fae5; + color: #059669; +} + +.chore-text-content { + flex: 1; + min-width: 0; +} + +.chore-header { + display: flex; + justify-content: space-between; + align-items: flex-start; + margin-bottom: 0.5rem; + flex-wrap: wrap; + gap: 0.5rem; +} + +.neo-chore-name { + font-weight: 600; + font-size: 1.1rem; + color: #111; +} + +.neo-chore-name.completed { + text-decoration: line-through; + opacity: 0.7; +} + +.chore-badges { + display: flex; + gap: 0.25rem; + flex-wrap: wrap; +} + +.chore-details { + display: flex; + flex-direction: column; + gap: 0.25rem; + font-size: 0.9rem; + color: #666; +} + +.chore-due-info, +.chore-assignment-info { + display: flex; + gap: 0.5rem; +} + +.due-label, +.assignment-label { + font-weight: 600; + color: #374151; +} + +.due-date.overdue { + color: #dc2626; + font-weight: 600; +} + +.due-date.due-today { + color: #d97706; + font-weight: 600; +} + +.today-indicator, +.overdue-indicator { + font-size: 0.8rem; + font-weight: 500; +} + +.chore-description { + margin-top: 0.25rem; + font-style: italic; + color: #6b7280; +} + +.completion-info { + margin-top: 0.25rem; + color: #059669; + font-weight: 500; + font-size: 0.85rem; +} + +.chore-actions { + display: flex; + align-items: center; + gap: 0.5rem; +} + +/* Chore Detail Modal Styles */ +.chore-detail-content { + display: flex; + flex-direction: column; + gap: 1.5rem; +} + +.chore-overview-section { + border-bottom: 1px solid #e5e7eb; + padding-bottom: 1rem; +} + +.chore-status-summary { + margin-bottom: 1rem; +} + +.status-badges { + display: flex; + gap: 0.5rem; + margin-bottom: 1rem; + flex-wrap: wrap; +} + +.chore-meta-info { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); + gap: 0.75rem; +} + +.meta-item { + display: flex; + flex-direction: column; + gap: 0.25rem; +} + +.meta-item .label { + font-weight: 600; + color: #374151; + font-size: 0.9rem; +} + +.meta-item .value { + color: #111; +} + +.meta-item .value.overdue { + color: #dc2626; + font-weight: 600; +} + +.chore-description-full { + margin-top: 1rem; +} + +.chore-description-full p { + color: #374151; + line-height: 1.6; +} + +.assignments-section, +.assignment-history-section, +.chore-history-section { + border-bottom: 1px solid #e5e7eb; + padding-bottom: 1rem; +} + +.assignments-section:last-child, +.assignment-history-section:last-child, +.chore-history-section:last-child { + border-bottom: none; +} + +.assignments-list { + display: flex; + flex-direction: column; + gap: 1rem; +} + +.assignment-card { + background: #f9fafb; + border: 1px solid #e5e7eb; + border-radius: 8px; + padding: 1rem; +} + +.editing-assignment { + display: flex; + flex-direction: column; + gap: 1rem; +} + +.editing-actions { + display: flex; + gap: 0.5rem; +} + +.assignment-info { + display: flex; + flex-direction: column; + gap: 0.75rem; +} + +.assignment-header { + display: flex; + justify-content: space-between; + align-items: flex-start; +} + +.assigned-user-info { + display: flex; + align-items: center; + gap: 0.5rem; +} + +.user-name { + font-weight: 600; + color: #111; +} + +.assignment-actions { + display: flex; + gap: 0.5rem; +} + +.assignment-details { + display: flex; + flex-direction: column; + gap: 0.5rem; +} + +.detail-item { + display: flex; + gap: 0.5rem; +} + +.detail-item .label { + font-weight: 600; + color: #374151; + min-width: 80px; +} + +.detail-item .value { + color: #111; +} + +.no-assignments, +.no-history { + color: #6b7280; + font-style: italic; + text-align: center; + padding: 1rem; +} + +.history-timeline { + display: flex; + flex-direction: column; + gap: 0.75rem; +} + +.assignment-history-header { + font-weight: 600; + color: #374151; + margin-bottom: 0.5rem; + border-bottom: 1px solid #e5e7eb; + padding-bottom: 0.25rem; +} + +.history-entry { + display: flex; + flex-direction: column; + gap: 0.25rem; + padding: 0.75rem; + background: #f9fafb; + border-radius: 6px; + border-left: 3px solid #d1d5db; +} + +.history-timestamp { + font-size: 0.8rem; + color: #6b7280; + font-weight: 500; +} + +.history-event { + color: #374151; +} + +.history-user { + font-size: 0.85rem; + color: #6b7280; + font-style: italic; +} + +@media (max-width: 768px) { + .chore-header { + flex-direction: column; + align-items: flex-start; + } + + .chore-meta-info { + grid-template-columns: 1fr; + } + + .assignment-header { + flex-direction: column; + gap: 0.5rem; + } +} + +.loading-assignments { + display: flex; + align-items: center; + justify-content: center; + gap: 0.5rem; + color: #6b7280; + font-style: italic; +} -- 2.45.2