mitlist/fe/src/pages/ChoresPage.vue
google-labs-jules[bot] 198222c3ff 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.
2025-06-07 20:40:49 +00:00

683 lines
22 KiB
Vue

<script setup lang="ts">
import { ref, onMounted, computed } from 'vue'
import { useI18n } from 'vue-i18n'
import { format, startOfDay, isEqual, isToday as isTodayDate } from 'date-fns'
import { choreService } from '../services/choreService'
import { useNotificationStore } from '../stores/notifications'
import type { Chore, ChoreCreate, ChoreUpdate, ChoreFrequency, ChoreAssignmentUpdate } from '../types/chore'
import { groupService } from '../services/groupService'
import { useStorage } from '@vueuse/core'
const { t } = useI18n()
// Types
interface ChoreWithCompletion extends Chore {
current_assignment_id: number | null;
is_completed: boolean;
completed_at: string | null;
updating: boolean;
}
interface ChoreFormData {
name: string;
description: string;
frequency: ChoreFrequency;
custom_interval_days: number | undefined;
next_due_date: string;
type: 'personal' | 'group';
group_id: number | undefined;
}
const notificationStore = useNotificationStore()
// State
const chores = ref<ChoreWithCompletion[]>([])
const groups = ref<{ id: number, name: string }[]>([])
const showChoreModal = ref(false)
const showDeleteDialog = ref(false)
const isEditing = ref(false)
const selectedChore = ref<ChoreWithCompletion | null>(null)
const cachedChores = useStorage<ChoreWithCompletion[]>('cached-chores-v2', [])
const cachedTimestamp = useStorage<number>('cached-chores-timestamp-v2', 0)
const CACHE_DURATION = 5 * 60 * 1000; // 5 minutes
const initialChoreFormState: ChoreFormData = {
name: '',
description: '',
frequency: 'daily',
custom_interval_days: undefined,
next_due_date: format(new Date(), 'yyyy-MM-dd'),
type: 'personal',
group_id: undefined,
}
const choreForm = ref({ ...initialChoreFormState })
const isLoading = ref(true)
const loadChores = async () => {
const now = Date.now();
if (cachedChores.value && cachedChores.value.length > 0 && (now - cachedTimestamp.value) < CACHE_DURATION) {
chores.value = cachedChores.value;
isLoading.value = false;
} else {
isLoading.value = true;
}
try {
const fetchedChores = await choreService.getAllChores()
const mappedChores = fetchedChores.map(c => {
const currentAssignment = c.assignments && c.assignments.length > 0 ? c.assignments[0] : null;
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,
updating: false,
}
});
chores.value = mappedChores;
cachedChores.value = mappedChores;
cachedTimestamp.value = Date.now()
} catch (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
}
}
const loadGroups = async () => {
try {
groups.value = await groupService.getUserGroups();
} catch (error) {
console.error(t('choresPage.consoleErrors.loadGroupsFailed'), error);
notificationStore.addNotification({ message: t('choresPage.notifications.loadGroupsFailed', 'Failed to load groups.'), type: 'error' });
}
}
onMounted(() => {
loadChores()
loadGroups()
})
const frequencyOptions = computed(() => [
{ label: t('choresPage.frequencyOptions.oneTime'), value: 'one_time' as ChoreFrequency },
{ label: t('choresPage.frequencyOptions.daily'), value: 'daily' as ChoreFrequency },
{ label: t('choresPage.frequencyOptions.weekly'), value: 'weekly' as ChoreFrequency },
{ label: t('choresPage.frequencyOptions.monthly'), value: 'monthly' as ChoreFrequency },
{ label: t('choresPage.frequencyOptions.custom'), value: 'custom' as ChoreFrequency }
]);
const getChoreSubtext = (chore: ChoreWithCompletion): string => {
if (chore.is_completed && chore.completed_at) {
const completedDate = new Date(chore.completed_at);
if (isTodayDate(completedDate)) {
return t('choresPage.completedToday');
}
return t('choresPage.completedOn', { date: format(completedDate, 'd MMM') });
}
const parts: string[] = [];
if (chore.frequency && chore.frequency !== 'one_time') {
const freqOption = frequencyOptions.value.find(f => f.value === chore.frequency);
if (freqOption) {
if (chore.frequency === 'custom' && chore.custom_interval_days) {
parts.push(t('choresPage.frequency.customInterval', { n: chore.custom_interval_days }));
} else {
parts.push(freqOption.label);
}
}
}
if (chore.type === 'group' && chore.group_id) {
const group = groups.value.find(g => g.id === chore.group_id);
if (group) {
parts.push(group.name);
}
}
return parts.join(' · ');
};
const groupedChores = computed(() => {
if (!chores.value) return []
const choresByDate = chores.value.reduce((acc, chore) => {
const dueDate = format(startOfDay(new Date(chore.next_due_date)), 'yyyy-MM-dd')
if (!acc[dueDate]) {
acc[dueDate] = []
}
acc[dueDate].push(chore)
return acc
}, {} as Record<string, ChoreWithCompletion[]>)
return Object.keys(choresByDate)
.sort((a, b) => new Date(a).getTime() - new Date(b).getTime())
.map(dateStr => {
// Create a new Date object and ensure it's interpreted as local time, not UTC
const dateParts = dateStr.split('-').map(Number);
const date = new Date(dateParts[0], dateParts[1] - 1, dateParts[2]);
return {
date,
chores: choresByDate[dateStr]
.sort((a, b) => a.name.localeCompare(b.name))
.map(chore => ({
...chore,
subtext: getChoreSubtext(chore)
}))
}
});
})
const formatDateHeader = (date: Date) => {
const today = startOfDay(new Date())
const itemDate = startOfDay(date)
if (isEqual(itemDate, today)) {
return `${t('choresPage.today', 'Today')}, ${format(itemDate, 'eee, d MMM')}`
}
return format(itemDate, 'eee, d MMM')
}
const resetChoreForm = () => {
choreForm.value = { ...initialChoreFormState, next_due_date: format(new Date(), 'yyyy-MM-dd') };
isEditing.value = false
selectedChore.value = null
}
const openCreateChoreModal = () => {
resetChoreForm()
showChoreModal.value = true
}
const openEditChoreModal = (chore: ChoreWithCompletion) => {
isEditing.value = true
selectedChore.value = chore
choreForm.value = {
name: chore.name,
description: chore.description || '',
frequency: chore.frequency,
custom_interval_days: chore.custom_interval_days ?? undefined,
next_due_date: chore.next_due_date,
type: chore.type,
group_id: chore.group_id ?? undefined,
}
showChoreModal.value = true
}
const handleFormSubmit = async () => {
try {
let createdChore;
if (isEditing.value && selectedChore.value) {
const updateData: ChoreUpdate = { ...choreForm.value };
createdChore = await choreService.updateChore(selectedChore.value.id, updateData);
notificationStore.addNotification({ message: t('choresPage.notifications.updateSuccess', 'Chore updated successfully!'), type: 'success' });
} else {
const createData = { ...choreForm.value };
createdChore = await choreService.createChore(createData as ChoreCreate);
// Create an assignment for the new chore
if (createdChore) {
try {
await choreService.createAssignment({
chore_id: createdChore.id,
assigned_to_user_id: createdChore.created_by_id,
due_date: createdChore.next_due_date
});
} catch (assignmentError) {
console.error(t('choresPage.consoleErrors.createAssignmentForNewChoreFailed'), assignmentError);
// Continue anyway since the chore was created
}
}
notificationStore.addNotification({ message: t('choresPage.notifications.createSuccess', 'Chore created successfully!'), type: 'success' });
}
showChoreModal.value = false;
await loadChores();
} catch (error) {
console.error(t('choresPage.consoleErrors.saveFailed'), error);
notificationStore.addNotification({ message: t('choresPage.notifications.saveFailed', 'Failed to save the chore.'), type: 'error' });
}
}
const confirmDelete = (chore: ChoreWithCompletion) => {
selectedChore.value = chore
showDeleteDialog.value = true
}
const deleteChore = async () => {
if (!selectedChore.value) return
try {
await choreService.deleteChore(selectedChore.value.id, selectedChore.value.type, selectedChore.value.group_id ?? undefined)
notificationStore.addNotification({ message: t('choresPage.notifications.deleteSuccess', 'Chore deleted successfully.'), type: 'success' })
showDeleteDialog.value = false
await loadChores()
} catch (error) {
console.error(t('choresPage.consoleErrors.deleteFailed'), error)
notificationStore.addNotification({ message: t('choresPage.notifications.deleteFailed', 'Failed to delete chore.'), type: 'error' })
}
}
const toggleCompletion = async (chore: ChoreWithCompletion) => {
if (chore.current_assignment_id === null) {
// If no assignment exists, create one
try {
const assignment = await choreService.createAssignment({
chore_id: chore.id,
assigned_to_user_id: chore.created_by_id,
due_date: chore.next_due_date
});
chore.current_assignment_id = assignment.id;
} catch (error) {
console.error(t('choresPage.consoleErrors.createAssignmentFailed'), error);
notificationStore.addNotification({
message: t('choresPage.notifications.createAssignmentFailed', 'Failed to create assignment for chore.'),
type: 'error'
});
return;
}
}
const originalCompleted = chore.is_completed;
chore.updating = true;
const newCompletedStatus = !chore.is_completed;
chore.is_completed = newCompletedStatus;
try {
if (newCompletedStatus) {
await choreService.completeAssignment(chore.current_assignment_id);
} else {
const assignmentUpdate: ChoreAssignmentUpdate = { is_complete: false };
await choreService.updateAssignment(chore.current_assignment_id, assignmentUpdate);
}
notificationStore.addNotification({
message: newCompletedStatus ? t('choresPage.notifications.completed', 'Chore marked as complete!') : t('choresPage.notifications.uncompleted', 'Chore marked as incomplete.'),
type: 'success'
});
await loadChores();
} catch (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 {
chore.updating = false;
}
};
</script>
<template>
<div class="container">
<header class="flex justify-between items-center">
<h1 style="margin-block-start: 0;">{{ t('choresPage.title') }}</h1>
<button class="btn btn-primary" @click="openCreateChoreModal">
{{ t('choresPage.addChore', '+') }}
</button>
</header>
<div v-if="isLoading" class="flex justify-center mt-4">
<div class="spinner-dots">
<span></span>
<span></span>
<span></span>
</div>
</div>
<div v-else-if="groupedChores.length === 0" class="empty-state-card">
<h3>{{ t('choresPage.empty.title', 'No Chores Yet') }}</h3>
<p>{{ t('choresPage.empty.message', 'Get started by adding your first chore!') }}</p>
<button class="btn btn-primary" @click="openCreateChoreModal">
{{ t('choresPage.addFirstChore', 'Add First Chore') }}
</button>
</div>
<div v-else class="schedule-list">
<div v-for="group in groupedChores" :key="group.date.toISOString()" class="schedule-group">
<h2 class="date-header">{{ formatDateHeader(group.date) }}</h2>
<div class="neo-item-list-container">
<ul class="neo-item-list">
<li v-for="chore in group.chores" :key="chore.id" class="neo-list-item">
<div class="neo-item-content">
<label class="neo-checkbox-label">
<input type="checkbox" :checked="chore.is_completed" @change="toggleCompletion(chore)">
<div class="checkbox-content">
<span class="checkbox-text-span"
:class="{ 'neo-completed-static': chore.is_completed && !chore.updating }">
{{ chore.name }}
</span>
<span v-if="chore.subtext" class="item-time">{{ chore.subtext }}</span>
</div>
</label>
<div class="neo-item-actions">
<button class="btn btn-sm btn-neutral" @click="openEditChoreModal(chore)">
{{ t('choresPage.edit', 'Edit') }}
</button>
<button class="btn btn-sm btn-danger" @click="confirmDelete(chore)">
{{ t('choresPage.delete', 'Delete') }}
</button>
</div>
</div>
</li>
</ul>
</div>
</div>
</div>
<!-- Chore Form Modal -->
<div v-if="showChoreModal" class="modal-backdrop open" @click.self="showChoreModal = false">
<div class="modal-container">
<form @submit.prevent="handleFormSubmit">
<div class="modal-header">
<h3>{{ isEditing ? t('choresPage.editChore', 'Edit Chore') : t('choresPage.createChore', 'Create Chore') }}
</h3>
<button type="button" @click="showChoreModal = false" class="close-button">
&times;
</button>
</div>
<div class="modal-body">
<div class="form-group">
<label class="form-label" for="chore-name">{{ t('choresPage.form.name', 'Name') }}</label>
<input id="chore-name" type="text" v-model="choreForm.name" class="form-input" required>
</div>
<div class="form-group">
<label class="form-label" for="chore-desc">{{ t('choresPage.form.description', 'Description') }}</label>
<textarea id="chore-desc" v-model="choreForm.description" class="form-input"></textarea>
</div>
<div class="form-group">
<label class="form-label" for="chore-date">{{ t('choresPage.form.dueDate', 'Due Date') }}</label>
<input id="chore-date" type="date" v-model="choreForm.next_due_date" class="form-input" required>
</div>
<div class="form-group">
<label class="form-label">{{ t('choresPage.form.frequency', 'Frequency') }}</label>
<div class="radio-group">
<label v-for="option in frequencyOptions" :key="option.value" class="radio-label">
<input type="radio" v-model="choreForm.frequency" :value="option.value">
<span class="checkmark radio-mark"></span>
<span>{{ option.label }}</span>
</label>
</div>
</div>
<div v-if="choreForm.frequency === 'custom'" class="form-group">
<label class="form-label" for="chore-interval">{{ t('choresPage.form.interval', 'Interval (days)')
}}</label>
<input id="chore-interval" type="number" v-model.number="choreForm.custom_interval_days"
class="form-input" :placeholder="t('choresPage.form.intervalPlaceholder')" min="1">
</div>
<div class="form-group">
<label class="form-label">{{ t('choresPage.form.type', 'Type') }}</label>
<div class="radio-group">
<label class="radio-label">
<input type="radio" v-model="choreForm.type" value="personal">
<span class="checkmark radio-mark"></span>
<span>{{ t('choresPage.form.personal', 'Personal') }}</span>
</label>
<label class="radio-label">
<input type="radio" v-model="choreForm.type" value="group">
<span class="checkmark radio-mark"></span>
<span>{{ t('choresPage.form.group', 'Group') }}</span>
</label>
</div>
</div>
<div v-if="choreForm.type === 'group'" class="form-group">
<label class="form-label" for="chore-group">{{ t('choresPage.form.assignGroup', 'Assign to Group')
}}</label>
<select id="chore-group" v-model="choreForm.group_id" class="form-input">
<option v-for="group in groups" :key="group.id" :value="group.id">{{ group.name }}</option>
</select>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-neutral" @click="showChoreModal = false">{{
t('choresPage.form.cancel', 'Cancel')
}}</button>
<button type="submit" class="btn btn-primary">{{ isEditing ? t('choresPage.form.save', 'Save Changes') :
t('choresPage.form.create', 'Create') }}</button>
</div>
</form>
</div>
</div>
<!-- Delete Confirmation Dialog -->
<div v-if="showDeleteDialog" class="modal-backdrop open" @click.self="showDeleteDialog = false">
<div class="modal-container confirm-modal">
<div class="modal-header">
<h3>{{ t('choresPage.deleteConfirm.title', 'Confirm Deletion') }}</h3>
<button type="button" @click="showDeleteDialog = false" class="close-button">
&times;
</button>
</div>
<div class="modal-body">
<p>{{ t('choresPage.deleteConfirm.message', 'Really want to delete? This action cannot be undone.') }}</p>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-neutral" @click="showDeleteDialog = false">{{
t('choresPage.deleteConfirm.cancel', 'Cancel') }}</button>
<button type="button" class="btn btn-danger" @click="deleteChore">{{
t('choresPage.deleteConfirm.delete', 'Delete')
}}</button>
</div>
</div>
</div>
</div>
</template>
<style scoped lang="scss">
.schedule-group {
margin-bottom: 2rem;
position: relative;
}
.date-header {
font-size: clamp(1rem, 4vw, 1.2rem);
font-weight: bold;
color: var(--dark);
text-transform: none;
letter-spacing: normal;
margin-bottom: 1rem;
padding-bottom: 0.5rem;
border-bottom: 2px solid var(--dark);
position: sticky;
top: 0;
background-color: var(--light);
z-index: 10;
&::after {
display: none; // Hides the default h-tag underline from valerie-ui
}
}
.item-time {
font-size: 0.9rem;
color: var(--dark);
opacity: 0.7;
margin-left: 1rem;
}
.neo-item-list-container {
border: 3px solid #111;
border-radius: 18px;
background: var(--light);
box-shadow: 6px 6px 0 #111;
overflow: hidden;
}
/* Neo-style list items from ListDetailPage */
.neo-item-list {
list-style: none;
padding: 0.5rem 1rem;
margin: 0;
}
.neo-list-item {
display: flex;
align-items: center;
padding: 0.75rem 0;
border-bottom: 1px solid rgba(0, 0, 0, 0.08);
position: relative;
transition: background-color 0.2s ease;
}
.neo-list-item:hover {
background-color: #f8f8f8;
}
.neo-list-item:last-child {
border-bottom: none;
}
.neo-item-content {
display: flex;
justify-content: space-between;
align-items: center;
width: 100%;
gap: 0.5rem;
}
.neo-item-actions {
display: flex;
gap: 0.25rem;
opacity: 0;
transition: opacity 0.2s ease;
margin-left: auto;
.btn {
margin-left: 0.25rem;
}
}
.neo-list-item:hover .neo-item-actions {
opacity: 1;
}
/* Custom Checkbox Styles from ListDetailPage */
.neo-checkbox-label {
display: grid;
grid-template-columns: auto 1fr;
align-items: center;
gap: 0.8em;
cursor: pointer;
position: relative;
width: 100%;
font-weight: 500;
color: #414856;
transition: color 0.3s ease;
margin-bottom: 0;
}
.neo-checkbox-label input[type="checkbox"] {
appearance: none;
-webkit-appearance: none;
-moz-appearance: none;
position: relative;
height: 20px;
width: 20px;
outline: none;
border: 2px solid #b8c1d1;
margin: 0;
cursor: pointer;
background: transparent;
border-radius: 6px;
display: grid;
align-items: center;
justify-content: center;
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
}
.neo-checkbox-label input[type="checkbox"]:hover {
border-color: var(--secondary);
transform: scale(1.05);
}
.neo-checkbox-label input[type="checkbox"]::before,
.neo-checkbox-label input[type="checkbox"]::after {
content: none;
}
.neo-checkbox-label input[type="checkbox"]::after {
content: "";
position: absolute;
opacity: 0;
left: 5px;
top: 1px;
width: 6px;
height: 12px;
border: solid var(--primary);
border-width: 0 3px 3px 0;
transform: rotate(45deg) scale(0);
transition: all 0.2s cubic-bezier(0.18, 0.89, 0.32, 1.28);
transition-property: transform, opacity;
}
.neo-checkbox-label input[type="checkbox"]:checked {
border-color: var(--primary);
}
.neo-checkbox-label input[type="checkbox"]:checked::after {
opacity: 1;
transform: rotate(45deg) scale(1);
}
.checkbox-content {
display: flex;
align-items: center;
gap: 0.5rem;
width: 100%;
}
.checkbox-text-span {
position: relative;
transition: color 0.4s ease, opacity 0.4s ease;
width: fit-content;
font-weight: 500;
color: var(--dark);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
/* Animated strikethrough line */
.checkbox-text-span::before {
content: '';
position: absolute;
top: 50%;
left: -0.1em;
right: -0.1em;
height: 2px;
background: var(--dark);
transform: scaleX(0);
transform-origin: right;
transition: transform 0.4s cubic-bezier(0.77, 0, .18, 1);
}
.neo-checkbox-label input[type="checkbox"]:checked~.checkbox-content .checkbox-text-span {
color: var(--dark);
opacity: 0.6;
}
.neo-checkbox-label input[type="checkbox"]:checked~.checkbox-content .checkbox-text-span::before {
transform: scaleX(1);
transform-origin: left;
transition: transform 0.4s cubic-bezier(0.77, 0, .18, 1) 0.1s;
}
.neo-completed-static {
color: var(--dark);
opacity: 0.6;
position: relative;
}
.neo-completed-static::before {
content: '';
position: absolute;
top: 50%;
left: -0.1em;
right: -0.1em;
height: 2px;
background: var(--dark);
transform: scaleX(1);
transform-origin: left;
}
</style>