mitlist/fe/src/pages/ChoresPage.vue
google-labs-jules[bot] 5c9ba3f38c feat: Internationalize AuthCallback, Chores, ErrorNotFound, GroupDetail pages
This commit introduces internationalization for several pages:
- AuthCallbackPage.vue
- ChoresPage.vue (a comprehensive page with many elements)
- ErrorNotFound.vue
- GroupDetailPage.vue (including sub-sections for members, invites, chores summary, and expenses summary)

Key changes:
- Integrated `useI18n` in each listed page to handle translatable strings.
- Replaced hardcoded text in templates and relevant script sections (notifications, dynamic messages, fallbacks, etc.) with `t('key')` calls.
- Added new translation keys, organized under page-specific namespaces (e.g., `authCallbackPage`, `choresPage`, `errorNotFoundPage`, `groupDetailPage`), to `fe/src/i18n/en.json`.
- Added corresponding keys with placeholder translations (prefixed with DE:, FR:, ES:) to `fe/src/i18n/de.json`, `fe/src/i18n/fr.json`, and `fe/src/i18n/es.json`.
- Reused existing translation keys (e.g., for chore frequency options) where applicable.
2025-06-02 00:19:26 +02:00

2465 lines
64 KiB
Vue

<template>
<main class="neo-container page-padding">
<div class="neo-list-header">
<div class="header-left">
<h1 class="neo-title">{{ t('choresPage.title') }}</h1>
<div class="view-tabs" role="tablist">
<button class="neo-tab-btn" :class="{ active: activeView === 'overdue' }" @click="activeView = 'overdue'"
:disabled="isLoading" role="tab" :aria-selected="activeView === 'overdue'">
<span class="material-icons">warning</span>
{{ t('choresPage.tabs.overdue') }}
<span v-if="counts.overdue > 0" class="neo-tab-count">{{ counts.overdue }}</span>
</button>
<button class="neo-tab-btn" :class="{ active: activeView === 'today' }" @click="activeView = 'today'"
:disabled="isLoading" role="tab" :aria-selected="activeView === 'today'">
<span class="material-icons">today</span>
{{ t('choresPage.tabs.today') }}
<span v-if="counts.today > 0" class="neo-tab-count">{{ counts.today }}</span>
</button>
<button class="neo-tab-btn" :class="{ active: activeView === 'upcoming' }" @click="activeView = 'upcoming'"
:disabled="isLoading" role="tab" :aria-selected="activeView === 'upcoming'">
<span class="material-icons">upcoming</span>
{{ t('choresPage.tabs.upcoming') }}
</button>
<button class="neo-tab-btn" :class="{ active: activeView === 'all' }" @click="activeView = 'all'"
:disabled="isLoading" role="tab" :aria-selected="activeView === 'all'">
<span class="material-icons">list</span>
{{ t('choresPage.tabs.allPending') }}
</button>
<button class="neo-tab-btn" :class="{ active: activeView === 'completed' }" @click="activeView = 'completed'"
:disabled="isLoading" role="tab" :aria-selected="activeView === 'completed'">
<span class="material-icons">check_circle</span>
{{ t('choresPage.tabs.completed') }}
</button>
</div>
</div>
<div class="header-right">
<div class="neo-view-toggle">
<button class="neo-toggle-btn" :class="{ active: viewMode === 'calendar' }" @click="viewMode = 'calendar'"
:disabled="isLoading" :aria-pressed="viewMode === 'calendar'" :aria-label="t('choresPage.viewToggle.calendarLabel')">
<span class="material-icons">calendar_month</span>
<span class="btn-text hide-text-on-mobile">{{ t('choresPage.viewToggle.calendarText') }}</span>
</button>
<button class="neo-toggle-btn" :class="{ active: viewMode === 'list' }" @click="viewMode = 'list'"
:disabled="isLoading" :aria-pressed="viewMode === 'list'" :aria-label="t('choresPage.viewToggle.listLabel')">
<span class="material-icons">view_list</span>
<span class="btn-text hide-text-on-mobile">{{ t('choresPage.viewToggle.listText') }}</span>
</button>
</div>
<button class="neo-action-button" @click="openCreateChoreModal(null)" :disabled="isLoading"
:aria-label="t('choresPage.newChoreButtonLabel')">
<span class="material-icons">add</span>
<span class="btn-text hide-text-on-mobile-sm">{{ t('choresPage.newChoreButtonText') }}</span>
</button>
</div>
</div>
<!-- Loading State -->
<div v-if="isLoading" class="neo-loading-state">
<div class="loading-spinner"></div>
<p>{{ t('choresPage.loadingState.loadingChores') }}</p>
</div>
<!-- Calendar View -->
<div v-else-if="viewMode === 'calendar'" class="calendar-view">
<div class="calendar-header">
<button class="btn btn-icon" @click="previousMonth" :aria-label="t('choresPage.calendar.prevMonthLabel')">
<span class="material-icons">chevron_left</span>
</button>
<h2>{{ currentMonthYear }}</h2>
<button class="btn btn-icon" @click="nextMonth" :aria-label="t('choresPage.calendar.nextMonthLabel')">
<span class="material-icons">chevron_right</span>
</button>
</div>
<div v-if="calendarDays.length > 0">
<div class="calendar-grid">
<div class="calendar-weekdays">
<div v-for="dayNameKey in weekDayKeys" :key="dayNameKey" class="weekday">{{ t(dayNameKey) }}</div>
</div>
<div class="calendar-days">
<div v-for="(day, index) in calendarDays" :key="index" class="calendar-day" :class="{
'other-month': !day.isCurrentMonth,
'today': day.isToday,
'has-chores': day.chores.length > 0
}" tabindex="0" @keydown.enter="openCreateChoreModal(null, day.date)"
@keydown.space="openCreateChoreModal(null, day.date)">
<div class="day-header">
<span class="day-number">{{ day.date.getDate() }}</span>
<button v-if="!day.isOtherMonth" class="add-chore-indicator"
@click.stop="openCreateChoreModal(null, day.date)" :aria-label="t('choresPage.calendar.addChoreToDayLabel')">
<span class="material-icons">add_circle_outline</span>
</button>
</div>
<div class="day-chores">
<div v-for="chore in day.chores" :key="chore.id" class="calendar-chore-item"
:class="{ 'is-completed': chore.is_completed }" @click="openEditChoreModal(chore)" tabindex="0"
@keydown.enter="openEditChoreModal(chore)" @keydown.space="openEditChoreModal(chore)">
<div class="chore-status" :class="getStatusClass(chore)"></div>
<span class="chore-name">{{ chore.name }}</span>
</div>
<div v-if="day.chores.length === 0 && day.isCurrentMonth" class="no-chores-in-day">
<!-- Optionally, a subtle indicator for no chores -->
</div>
</div>
</div>
</div>
</div>
</div>
<div v-else class="empty-state">
<span class="material-icons empty-icon">calendar_today</span>
<p>{{ t('choresPage.calendar.emptyState') }}</p>
</div>
</div>
<!-- List View -->
<div v-else class="chores-list-container">
<transition-group name="chore-list" tag="div" class="chores-grid">
<div v-for="chore in filteredChores" :key="chore.id" class="chore-card"
:class="{ 'is-overdue': !chore.is_completed && isOverdue(chore), 'is-completed': chore.is_completed }">
<div class="chore-status" :class="getStatusClass(chore)"></div>
<div class="chore-content">
<div class="chore-main-info">
<div class="chore-header">
<h3>{{ chore.name }}</h3>
<div class="chore-tags">
<span class="chore-type-tag" :class="chore.type">
{{ chore.type === 'personal' ? t('choresPage.listView.choreTypePersonal') : getGroupName(chore.group_id) || t('choresPage.listView.choreTypeGroupFallback') }}
</span>
<span v-if="!chore.is_completed" class="chore-frequency-tag" :class="chore.frequency">
{{ formatFrequency(chore.frequency) }}
</span>
</div>
</div>
<div class="chore-meta">
<div v-if="!chore.is_completed" class="chore-due-date" :class="getDueDateClass(chore)">
<span class="material-icons">schedule</span>
{{ formatDueDate(chore.next_due_date) }}
</div>
<div v-else class="chore-completed-date">
<span class="material-icons">check_circle_outline</span>
{{ t('choresPage.listView.completedDatePrefix') }} {{ formatDate(chore.completed_at || chore.next_due_date) }}
</div>
<div v-if="chore.description" class="chore-description">
{{ chore.description }}
</div>
</div>
</div>
<div class="chore-card-actions">
<button v-if="!chore.is_completed" class="btn btn-success btn-sm btn-complete"
@click="toggleChoreCompletion(chore)" :title="t('choresPage.listView.actions.doneTitle')">
<span class="material-icons">check_circle</span> {{ t('choresPage.listView.actions.doneText') }}
</button>
<button v-else class="btn btn-warning btn-sm btn-undo" @click="toggleChoreCompletion(chore)"
:title="t('choresPage.listView.actions.undoTitle')">
<span class="material-icons">undo</span> {{ t('choresPage.listView.actions.undoText') }}
</button>
<button class="btn btn-icon" @click="openEditChoreModal(chore)" :title="t('choresPage.listView.actions.editTitle')" :aria-label="t('choresPage.listView.actions.editLabel')">
<span class="material-icons">edit</span>
<span class="btn-text hide-text-on-mobile">{{ t('choresPage.listView.actions.editText') }}</span>
</button>
<button class="btn btn-icon btn-danger-icon" @click="confirmDeleteChore(chore)" :title="t('choresPage.listView.actions.deleteTitle')"
:aria-label="t('choresPage.listView.actions.deleteLabel')">
<span class="material-icons">delete</span>
<span class="btn-text hide-text-on-mobile">{{ t('choresPage.listView.actions.deleteText') }}</span>
</button>
</div>
</div>
</div>
</transition-group>
<div v-if="!isLoading && filteredChores.length === 0" class="empty-state">
<span class="material-icons empty-icon"> Rtask_alt</span>
<p>{{ t('choresPage.listView.emptyState.message') }}</p>
<button v-if="activeView !== 'all'" class="btn btn-sm btn-outline" @click="activeView = 'all'">{{ t('choresPage.listView.emptyState.viewAllButton') }}</button>
</div>
</div>
<!-- Create/Edit Chore Modal -->
<div v-if="showChoreModal" class="modal-backdrop open" @click.self="showChoreModal = false" role="dialog"
aria-modal="true" :aria-labelledby="isEditing ? 'editChoreModalTitle' : 'newChoreModalTitle'">
<div class="modal-container">
<div class="modal-header">
<h3 :id="isEditing ? 'editChoreModalTitle' : 'newChoreModalTitle'">{{ isEditing ? t('choresPage.choreModal.editTitle') : t('choresPage.choreModal.newTitle')
}}</h3>
<button class="btn btn-icon" @click="showChoreModal = false" :aria-label="t('choresPage.choreModal.closeButtonLabel')">
<span class="material-icons">close</span>
</button>
</div>
<form @submit.prevent="onSubmit" class="modal-form">
<div class="form-group">
<label for="name">{{ t('choresPage.choreModal.nameLabel') }}</label>
<input id="name" v-model="choreForm.name" type="text" class="form-input" :placeholder="t('choresPage.choreModal.namePlaceholder')"
required />
</div>
<div class="form-group">
<label>{{ t('choresPage.choreModal.typeLabel') }}</label>
<div class="type-selector">
<button type="button" class="type-btn" :class="{ active: choreForm.type === 'personal' }"
@click="choreForm.type = 'personal'; choreForm.group_id = undefined"
:aria-pressed="choreForm.type === 'personal' ? 'true' : 'false'">
<span class="material-icons">person</span>
{{ t('choresPage.choreModal.typePersonal') }}
</button>
<button type="button" class="type-btn" :class="{ active: choreForm.type === 'group' }"
@click="choreForm.type = 'group'" :aria-pressed="choreForm.type === 'group' ? 'true' : 'false'">
<span class="material-icons">group</span>
{{ t('choresPage.choreModal.typeGroup') }}
</button>
</div>
</div>
<div v-if="choreForm.type === 'group'" class="form-group">
<label for="group">{{ t('choresPage.choreModal.groupLabel') }}</label>
<select id="group" v-model="choreForm.group_id" class="form-input" required>
<option :value="undefined" disabled>{{ t('choresPage.choreModal.groupSelectDefault') }}</option>
<option v-for="group in groups" :key="group.id" :value="group.id">
{{ group.name }}
</option>
</select>
</div>
<div class="form-group">
<label for="description">{{ t('choresPage.choreModal.descriptionLabel') }}</label>
<textarea id="description" v-model="choreForm.description" class="form-input" rows="3"
:placeholder="t('choresPage.choreModal.descriptionPlaceholder')"></textarea>
</div>
<div class="form-row">
<div class="form-group">
<label for="frequency">{{ t('choresPage.choreModal.frequencyLabel') }}</label>
<select id="frequency" v-model="choreForm.frequency" class="form-input" required>
<option v-for="option in frequencyOptions" :key="option.value" :value="option.value">
{{ option.label }}
</option>
</select>
</div>
<div v-if="choreForm.frequency === 'custom'" class="form-group">
<label for="interval">{{ t('choresPage.choreModal.intervalLabel') }}</label>
<input id="interval" v-model.number="choreForm.custom_interval_days" type="number" class="form-input"
min="1" :placeholder="t('choresPage.choreModal.intervalPlaceholder')" required />
</div>
</div>
<div class="form-group">
<label for="dueDate">{{ t('choresPage.choreModal.dueDateLabel') }}</label>
<div class="quick-due-dates">
<button type="button" class="btn btn-sm btn-outline" @click="setQuickDueDate('today')">{{ t('choresPage.choreModal.quickDueDateToday') }}</button>
<button type="button" class="btn btn-sm btn-outline"
@click="setQuickDueDate('tomorrow')">{{ t('choresPage.choreModal.quickDueDateTomorrow') }}</button>
<button type="button" class="btn btn-sm btn-outline" @click="setQuickDueDate('next_week')">{{ t('choresPage.choreModal.quickDueDateNextWeek') }}</button>
</div>
<input id="dueDate" v-model="choreForm.next_due_date" type="date" class="form-input" required />
</div>
<div class="modal-footer">
<button type="button" class="btn btn-neutral" @click="showChoreModal = false">{{ t('choresPage.choreModal.cancelButton') }}</button>
<button type="submit" class="btn btn-primary">{{ t('choresPage.choreModal.saveButton') }}</button>
</div>
</form>
</div>
</div>
<!-- Delete Confirmation Dialog -->
<div v-if="showDeleteDialog" class="modal-backdrop open" @click.self="showDeleteDialog = false" role="dialog"
aria-modal="true" aria-labelledby="deleteDialogTitle">
<div class="modal-container delete-confirm">
<div class="modal-header">
<h3 id="deleteDialogTitle">{{ t('choresPage.deleteDialog.title') }}</h3>
<button class="btn btn-icon" @click="showDeleteDialog = false" :aria-label="t('choresPage.choreModal.closeButtonLabel')">
<span class="material-icons">close</span>
</button>
</div>
<div class="modal-body">
<p>{{ t('choresPage.deleteDialog.confirmationText') }}</p>
</div>
<div class="modal-footer">
<button class="btn btn-neutral" @click="showDeleteDialog = false">{{ t('choresPage.choreModal.cancelButton') }}</button>
<button class="btn btn-danger" @click="deleteChore">{{ t('choresPage.deleteDialog.deleteButton') }}</button>
</div>
</div>
</div>
<!-- Keyboard Shortcuts Modal -->
<div v-if="showShortcutsModal" class="modal-backdrop open" @click.self="showShortcutsModal = false" role="dialog"
aria-modal="true" aria-labelledby="shortcutsModalTitle">
<div class="modal-container shortcuts-modal">
<div class="modal-header">
<h3 id="shortcutsModalTitle">{{ t('choresPage.shortcutsModal.title') }}</h3>
<button class="btn btn-icon" @click="showShortcutsModal = false" :aria-label="t('choresPage.choreModal.closeButtonLabel')">
<span class="material-icons">close</span>
</button>
</div>
<div class="modal-body">
<div class="shortcuts-list">
<div class="shortcut-item">
<div class="shortcut-keys">
<kbd>Ctrl/Cmd</kbd> + <kbd>N</kbd>
</div>
<div class="shortcut-description">{{ t('choresPage.shortcutsModal.descNewChore') }}</div>
</div>
<div class="shortcut-item">
<div class="shortcut-keys">
<kbd>Ctrl/Cmd</kbd> + <kbd>/</kbd>
</div>
<div class="shortcut-description">{{ t('choresPage.shortcutsModal.descToggleView') }}</div>
</div>
<div class="shortcut-item">
<div class="shortcut-keys">
<kbd>Ctrl/Cmd</kbd> + <kbd>?</kbd>
</div>
<div class="shortcut-description">{{ t('choresPage.shortcutsModal.descToggleShortcuts') }}</div>
</div>
<div class="shortcut-item">
<div class="shortcut-keys">
<kbd>Esc</kbd>
</div>
<div class="shortcut-description">{{ t('choresPage.shortcutsModal.descCloseModal') }}</div>
</div>
</div>
</div>
</div>
</div>
</main>
</template>
<script setup lang="ts">
import { ref, onMounted, computed, onUnmounted, watch, onBeforeUnmount } from 'vue'
import { useI18n } from 'vue-i18n'
import { format, startOfDay, addDays, addWeeks, isBefore, isEqual, startOfMonth, endOfMonth, eachDayOfInterval, isSameMonth, isToday as isTodayDate } from 'date-fns'
import { choreService } from '../services/choreService'
import { useNotificationStore } from '../stores/notifications'
import type { Chore, ChoreCreate, ChoreUpdate, ChoreFrequency } from '../types/chore'
import { useRoute } from 'vue-router'
import { groupService } from '../services/groupService'
import { useStorage } from '@vueuse/core'
const { t } = useI18n()
// Types
interface ChoreWithCompletion extends Chore {
is_completed: boolean;
completed_at: string | null;
}
interface ChoreCreateWithCompletion extends Omit<ChoreCreate, 'created_by_id' | 'created_at' | 'updated_at'> {
is_completed: boolean;
completed_at: string | null;
}
const notificationStore = useNotificationStore()
const route = useRoute()
// 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 showShortcutsModal = ref(false)
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 loadCachedData = () => {
const now = Date.now();
if (cachedChores.value && cachedChores.value.length > 0 && (now - cachedTimestamp.value) < CACHE_DURATION) {
chores.value = cachedChores.value;
}
};
const choreForm = ref<ChoreCreateWithCompletion>({
name: '',
description: '',
frequency: 'daily',
custom_interval_days: undefined,
next_due_date: format(new Date(), 'yyyy-MM-dd'),
type: 'personal',
group_id: undefined,
is_completed: false,
completed_at: null,
})
const getGroupName = (groupId?: number | null): string | undefined => {
if (!groupId) return undefined;
return groups.value.find(g => g.id === groupId)?.name;
};
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 isLoading = ref(true)
const loadChores = async () => {
isLoading.value = true
try {
const fetchedChores = await choreService.getAllChores()
chores.value = fetchedChores.map(c => ({
...c,
is_completed: c.is_completed || false,
completed_at: c.completed_at || null,
}))
cachedChores.value = chores.value
cachedTimestamp.value = Date.now()
} catch (error) {
console.error('Failed to load all chores:', error)
notificationStore.addNotification({ message: t('choresPage.notifications.loadFailed'), type: 'error' })
if (!cachedChores.value || cachedChores.value.length === 0) chores.value = []
} finally {
isLoading.value = false
}
}
const viewMode = ref<'calendar' | 'list'>('calendar')
const currentDate = ref(new Date())
const weekDayKeys = ['choresPage.calendar.weekdays.sun', 'choresPage.calendar.weekdays.mon', 'choresPage.calendar.weekdays.tue', 'choresPage.calendar.weekdays.wed', 'choresPage.calendar.weekdays.thu', 'choresPage.calendar.weekdays.fri', 'choresPage.calendar.weekdays.sat']
// For smaller screens, you might use: const weekDayKeys = ['S', 'M', 'T', 'W', 'T', 'F', 'S'];
const currentMonthYear = computed(() => {
return format(currentDate.value, 'MMMM yyyy')
})
const calendarDays = computed(() => {
if (isLoading.value) return []; // Don't compute if chores are not loaded
const start = startOfMonth(currentDate.value)
const end = endOfMonth(currentDate.value)
const firstDayOfMonth = start.getDay()
const startDate = addDays(start, -firstDayOfMonth)
const lastDayOfMonth = end.getDay()
const endDate = addDays(end, 6 - lastDayOfMonth)
const days = eachDayOfInterval({ start: startDate, end: endDate })
return days.map(date => {
const dayChores = chores.value.filter(chore => {
if (chore.is_completed && activeView.value !== 'completed') return false; // Don't show completed in calendar unless completed view is active (edge case)
if (!chore.next_due_date) return false;
try {
const choreDate = startOfDay(new Date(chore.next_due_date.replace(/-/g, '/'))); // More robust date parsing
return isEqual(choreDate, startOfDay(date))
} catch (e) {
console.warn("Invalid date for chore:", chore.name, chore.next_due_date);
return false;
}
})
return {
date,
isCurrentMonth: isSameMonth(date, currentDate.value),
isToday: isTodayDate(date),
isOtherMonth: !isSameMonth(date, currentDate.value),
chores: dayChores
}
})
})
const previousMonth = () => {
currentDate.value = addDays(startOfMonth(currentDate.value), -1)
}
const nextMonth = () => {
currentDate.value = addDays(endOfMonth(currentDate.value), 1)
}
const openCreateChoreModal = (groupId: number | null, date?: Date) => {
isEditing.value = false
selectedChore.value = null
choreForm.value = {
name: '',
description: '',
frequency: 'daily',
custom_interval_days: undefined,
next_due_date: date ? format(startOfDay(date), 'yyyy-MM-dd') : format(new Date(), 'yyyy-MM-dd'),
type: groupId ? 'group' : 'personal',
group_id: groupId ?? undefined,
is_completed: false,
completed_at: null,
}
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,
next_due_date: chore.next_due_date ? format(new Date(chore.next_due_date.replace(/-/g, '/')), 'yyyy-MM-dd') : format(new Date(), 'yyyy-MM-dd'),
type: chore.type,
group_id: chore.group_id,
is_completed: chore.is_completed,
completed_at: chore.completed_at,
}
showChoreModal.value = true
}
const onSubmit = async () => {
if (!validateForm()) return
isLoading.value = true;
try {
const choreDataSubmit: Partial<ChoreCreateWithCompletion> = { ...choreForm.value }
if (choreDataSubmit.frequency !== 'custom') {
choreDataSubmit.custom_interval_days = undefined
}
if (choreDataSubmit.next_due_date) {
choreDataSubmit.next_due_date = format(new Date(choreDataSubmit.next_due_date.replace(/-/g, '/')), 'yyyy-MM-dd');
}
let notificationMessage = ''
if (isEditing.value && selectedChore.value) {
await choreService.updateChore(selectedChore.value.id, choreDataSubmit as ChoreUpdate)
notificationMessage = t('choresPage.notifications.updateSuccess', { name: choreForm.value.name })
} else {
await choreService.createChore(choreDataSubmit as ChoreCreate)
notificationMessage = t('choresPage.notifications.createSuccess', { name: choreForm.value.name })
}
notificationStore.addNotification({ message: notificationMessage, type: 'success' })
showChoreModal.value = false
hasUnsavedChanges.value = false;
await loadChores()
} catch (error) {
console.error('Failed to save chore:', error)
notificationStore.addNotification({ message: t(isEditing.value ? 'choresPage.notifications.updateFailed' : 'choresPage.notifications.createFailed'), type: 'error' })
} finally {
isLoading.value = false;
}
}
const confirmDeleteChore = (chore: ChoreWithCompletion) => {
selectedChore.value = chore
showDeleteDialog.value = true
}
const deleteChore = async () => {
if (!selectedChore.value) return;
isLoading.value = true;
try {
await choreService.deleteChore(
selectedChore.value.id,
selectedChore.value.type,
selectedChore.value.group_id ?? undefined
);
showDeleteDialog.value = false;
notificationStore.addNotification({ message: t('choresPage.notifications.deleteSuccess', { name: selectedChore.value.name }), type: 'success' })
await loadChores()
} catch (error) {
console.error('Failed to delete chore:', error);
notificationStore.addNotification({ message: t('choresPage.notifications.deleteFailed'), type: 'error' })
} finally {
isLoading.value = false;
selectedChore.value = null;
}
}
const formatDate = (dateString: string | null): string => {
if (!dateString) return 'N/A';
try {
return format(new Date(dateString.replace(/-/g, '/')), 'MMM d, yyyy');
} catch (e) {
return 'Invalid Date';
}
}
const formatFrequency = (frequency?: ChoreFrequency) => {
if (!frequency) return '';
const option = frequencyOptions.find(opt => opt.value === frequency)
return option ? option.label : frequency.charAt(0).toUpperCase() + frequency.slice(1);
}
const loadGroups = async () => {
try {
groups.value = await groupService.getUserGroups();
} catch (error) {
console.error('Failed to load groups:', error);
// notificationStore.addNotification({ message: 'Failed to load groups', type: 'error' }); // Optional: can be noisy
}
};
const activeView = ref('today')
const today = computed(() => startOfDay(new Date()));
const isOverdue = (chore: ChoreWithCompletion) => {
if (chore.is_completed || !chore.next_due_date) return false;
try {
const dueDate = startOfDay(new Date(chore.next_due_date.replace(/-/g, '/')));
return isBefore(dueDate, today.value);
} catch { return false; }
};
const filteredChores = computed(() => {
let result: ChoreWithCompletion[];
const pendingChores = chores.value
.filter(c => !c.is_completed)
.sort((a, b) => {
try { return new Date(a.next_due_date.replace(/-/g, '/')).getTime() - new Date(b.next_due_date.replace(/-/g, '/')).getTime(); }
catch { return 0; }
});
const completedChoresList = chores.value
.filter(c => c.is_completed)
.sort((a, b) => {
try { return new Date(b.completed_at || 0).getTime() - new Date(a.completed_at || 0).getTime(); }
catch { return 0; }
});
switch (activeView.value) {
case 'overdue':
result = pendingChores.filter(chore => isOverdue(chore));
break;
case 'today':
result = pendingChores.filter(chore => {
if (!chore.next_due_date) return false;
try {
const dueDate = startOfDay(new Date(chore.next_due_date.replace(/-/g, '/')));
return isEqual(dueDate, today.value);
} catch { return false; }
});
break;
case 'upcoming':
result = pendingChores.filter(chore => {
if (!chore.next_due_date) return false;
try {
const dueDate = startOfDay(new Date(chore.next_due_date.replace(/-/g, '/')));
return isBefore(today.value, dueDate);
} catch { return false; }
});
break;
case 'completed':
result = completedChoresList;
break;
default: // 'all' (all pending)
result = pendingChores;
}
return result;
});
const counts = computed(() => {
const pending = chores.value.filter(c => !c.is_completed);
return {
overdue: pending.filter(chore => isOverdue(chore)).length,
today: pending.filter(chore => {
if (!chore.next_due_date) return false;
try {
const dueDate = startOfDay(new Date(chore.next_due_date.replace(/-/g, '/')));
return isEqual(dueDate, today.value);
} catch { return false; }
}).length,
};
});
const getStatusClass = (chore: ChoreWithCompletion) => {
if (chore.is_completed) return 'completed';
if (isOverdue(chore)) return 'overdue';
if (!chore.next_due_date) return 'upcoming'; // Default if no due date
try {
const dueDate = startOfDay(new Date(chore.next_due_date.replace(/-/g, '/')));
if (isEqual(dueDate, today.value)) return 'today';
} catch { /* fall through */ }
return 'upcoming';
}
const getDueDateClass = (chore: ChoreWithCompletion) => {
if (chore.is_completed) return '';
if (isOverdue(chore)) return 'overdue-text'; // Use a different class for text color if needed
if (!chore.next_due_date) return '';
try {
const dueDate = startOfDay(new Date(chore.next_due_date.replace(/-/g, '/')));
if (isEqual(dueDate, today.value)) return 'today-text';
} catch { /* fall through */ }
return 'upcoming-text';
}
const formatDueDate = (dateString: string | null) => {
if (!dateString) return t('choresPage.formatters.noDueDate');
try {
const dueDate = startOfDay(new Date(dateString.replace(/-/g, '/')));
if (isEqual(dueDate, today.value)) return t('choresPage.formatters.dueToday');
const tomorrow = addDays(today.value, 1);
if (isEqual(dueDate, tomorrow)) return t('choresPage.formatters.dueTomorrow');
if (isBefore(dueDate, today.value)) return t('choresPage.formatters.overdueFull', { date: formatDate(dateString) });
return t('choresPage.formatters.dueFull', { date: formatDate(dateString) });
} catch {
return t('choresPage.formatters.invalidDate')
}
}
const toggleChoreCompletion = async (choreToToggle: ChoreWithCompletion) => {
const originalStatus = choreToToggle.is_completed;
const originalCompletedAt = choreToToggle.completed_at;
choreToToggle.is_completed = !choreToToggle.is_completed;
choreToToggle.completed_at = choreToToggle.is_completed ? new Date().toISOString() : null;
// Optimistic update locally
const index = chores.value.findIndex(c => c.id === choreToToggle.id);
if (index !== -1) {
chores.value.splice(index, 1, { ...choreToToggle });
}
cachedChores.value = [...chores.value];
try {
await choreService.updateChore(choreToToggle.id, {
is_completed: choreToToggle.is_completed,
completed_at: choreToToggle.completed_at,
} as ChoreUpdate);
notificationStore.addNotification({
message: t(choreToToggle.is_completed ? 'choresPage.notifications.markedDone' : 'choresPage.notifications.markedNotDone', { name: choreToToggle.name }),
type: choreToToggle.is_completed ? 'success' : 'info'
});
} catch (error) {
console.error("Failed to update chore completion status:", error);
// Revert optimistic update on error
choreToToggle.is_completed = originalStatus;
choreToToggle.completed_at = originalCompletedAt;
if (index !== -1) chores.value.splice(index, 1, { ...choreToToggle });
cachedChores.value = [...chores.value];
notificationStore.addNotification({ message: t('choresPage.notifications.statusUpdateFailed'), type: 'error' });
}
};
const setQuickDueDate = (when: 'today' | 'tomorrow' | 'next_week') => {
let date = startOfDay(new Date());
if (when === 'tomorrow') date = addDays(date, 1);
if (when === 'next_week') date = addWeeks(date, 1);
choreForm.value.next_due_date = format(date, 'yyyy-MM-dd');
};
const handleKeyPress = (event: KeyboardEvent) => {
const target = event.target as HTMLElement;
if (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.tagName === 'SELECT') {
return;
}
if ((event.ctrlKey || event.metaKey) && event.key === 'n') {
event.preventDefault();
if (!showChoreModal.value) openCreateChoreModal(null);
}
if ((event.ctrlKey || event.metaKey) && event.key === '/') {
event.preventDefault();
viewMode.value = viewMode.value === 'calendar' ? 'list' : 'calendar';
}
if ((event.ctrlKey || event.metaKey) && (event.key === '?' || event.key === '-')) { // Often ? is Shift + /
event.preventDefault();
showShortcutsModal.value = !showShortcutsModal.value;
}
if (event.key === 'Escape') {
if (showChoreModal.value) showChoreModal.value = false;
else if (showDeleteDialog.value) showDeleteDialog.value = false;
else if (showShortcutsModal.value) showShortcutsModal.value = false;
}
};
onMounted(() => {
window.addEventListener('keydown', handleKeyPress)
loadCachedData() // Load from cache first for perceived speed
Promise.all([loadChores(), loadGroups()]); // Then fetch fresh data
})
onUnmounted(() => {
window.removeEventListener('keydown', handleKeyPress)
})
const validateForm = () => {
if (!choreForm.value.name.trim()) {
notificationStore.addNotification({ message: t('choresPage.validation.nameRequired'), type: 'error' }); return false;
}
if (choreForm.value.type === 'group' && !choreForm.value.group_id) {
notificationStore.addNotification({ message: t('choresPage.validation.groupRequired'), type: 'error' }); return false;
}
if (choreForm.value.frequency === 'custom' && (!choreForm.value.custom_interval_days || choreForm.value.custom_interval_days < 1)) {
notificationStore.addNotification({ message: t('choresPage.validation.intervalRequired'), type: 'error' }); return false;
}
if (!choreForm.value.next_due_date) {
notificationStore.addNotification({ message: t('choresPage.validation.dueDateRequired'), type: 'error' }); return false;
}
try {
new Date(choreForm.value.next_due_date.replace(/-/g, '/')); // check if valid date
} catch {
notificationStore.addNotification({ message: t('choresPage.validation.invalidDueDate'), type: 'error' }); return false;
}
return true;
}
// Auto-save functionality (commented out as the previous implementation was risky)
// A more robust auto-save would save to localStorage/drafts, not directly submit.
/*
let autoSaveTimeout: number | null = null
const autoSaveForm = () => {
if (autoSaveTimeout) {
clearTimeout(autoSaveTimeout)
}
autoSaveTimeout = window.setTimeout(() => {
// This logic should be revised. Auto-submitting a form is usually not desired.
// Consider saving to a local draft instead.
// if (isEditing.value && selectedChore.value) {
// console.log("Attempting auto-save (currently disabled logic)");
// // onSubmit() // This is risky; onSubmit usually makes an API call
// }
}, 3000) // Auto-save after 3 seconds of inactivity
}
watch(choreForm, () => {
if (isEditing.value) {
hasUnsavedChanges.value = true; // Mark changes for onBeforeUnload prompt
// autoSaveForm() // Trigger auto-save logic if re-enabled
}
}, { deep: true })
*/
const hasUnsavedChanges = ref(false)
watch(choreForm, (newValue, oldValue) => {
// Set hasUnsavedChanges only if the modal is open and it's not the initial population
if (showChoreModal.value && oldValue.name !== '') { // Basic check to avoid triggering on modal open
hasUnsavedChanges.value = true;
}
}, { deep: true });
watch(showChoreModal, (isOpen) => {
if (!isOpen) {
hasUnsavedChanges.value = false; // Reset when modal is closed
} else {
// When modal opens for a new chore, or for editing,
// we don't consider it "unsaved" until the user makes a change.
// The watch on choreForm handles detecting actual changes.
// Resetting here might be too early if form is pre-filled.
}
});
onBeforeUnmount(() => {
if (hasUnsavedChanges.value && showChoreModal.value) { // Only prompt if modal is open with changes
const confirmLeave = window.confirm(t('choresPage.unsavedChangesConfirmation'))
if (!confirmLeave) {
return false // This typically doesn't prevent component unmount in Vue 3 composition API directly
// but is a common pattern. For SPA, router guards are better.
}
}
})
</script>
<style>
/* Modal styles - (global, as before) */
.modal-backdrop {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.6);
/* Slightly darker backdrop */
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
padding: 1rem;
/* Add padding for small screens so modal doesn't touch edges */
}
.modal-container {
background: white;
border-radius: 12px;
border: 3px solid #111;
box-shadow: 6px 6px 0 #111;
width: 100%;
/* Let padding on backdrop control spacing */
max-width: 600px;
max-height: 90vh;
overflow-y: auto;
display: flex;
/* Added for flex column layout */
flex-direction: column;
/* Added */
}
.modal-header {
padding: 1rem 1.5rem;
border-bottom: 2px solid #111;
display: flex;
justify-content: space-between;
align-items: center;
flex-shrink: 0;
/* Prevent shrinking */
}
.modal-header h3 {
margin: 0;
font-size: 1.5rem;
font-weight: 700;
}
.modal-body {
padding: 1.5rem;
overflow-y: auto;
/* Allow body to scroll if content overflows */
flex-grow: 1;
/* Allow body to take available space */
}
.modal-footer {
padding: 1rem 1.5rem;
border-top: 2px solid #111;
display: flex;
justify-content: flex-end;
gap: 1rem;
flex-shrink: 0;
/* Prevent shrinking */
}
/* Form styles (global, as before) */
.modal-form {
/* padding: 1.5rem; No longer needed if modal-body has padding */
}
.form-group {
margin-bottom: 1.5rem;
}
.form-row {
display: flex;
flex-wrap: wrap;
/* Allow wrapping on small screens */
gap: 1rem;
margin-bottom: 1.5rem;
}
.form-row .form-group {
flex: 1 1 200px;
/* Allow items to grow and shrink, base size 200px */
margin-bottom: 0;
}
.form-group label {
display: block;
margin-bottom: 0.5rem;
font-weight: 600;
color: #333;
}
.form-input {
width: 100%;
padding: 0.75rem;
border: 2px solid #111;
border-radius: 8px;
font-size: 1rem;
transition: all 0.2s ease;
background: white;
box-sizing: border-box;
/* Ensure padding and border are inside width */
}
.form-input:focus {
outline: none;
border-color: #007bff;
box-shadow: 0 0 0 3px rgba(0, 123, 255, 0.2);
/* Softer focus */
}
.form-input::placeholder {
color: #999;
}
/* Type selector styles */
.type-selector {
display: flex;
gap: 1rem;
margin-bottom: 1rem;
/* Was form-group, consistent */
}
.type-btn {
flex: 1;
padding: 0.75rem;
border: 2px solid #111;
border-radius: 8px;
background: white;
font-weight: 600;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
transition: all 0.2s ease;
min-width: 120px;
/* Ensure buttons don't get too small */
}
.type-btn:hover {
background: #f8f9fa;
}
.type-btn.active {
background: #111;
color: white;
}
/* Quick due dates styles */
.quick-due-dates {
display: flex;
flex-wrap: wrap;
/* Allow wrapping */
gap: 0.5rem;
margin-bottom: 0.75rem;
/* Increased margin */
}
.btn-outline {
padding: 0.5rem 1rem;
border: 2px solid #111;
border-radius: 6px;
background: white;
font-weight: 600;
cursor: pointer;
transition: all 0.2s ease;
font-size: 0.9rem;
/* Slightly smaller for tertiary actions */
}
.btn-outline:hover {
background: #f8f9fa;
}
/* Button styles (global, as before) */
.btn {
padding: 0.75rem 1.5rem;
border-radius: 8px;
font-weight: 600;
cursor: pointer;
transition: all 0.2s ease;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
border: 2px solid transparent;
/* Base border */
}
.btn-primary {
background: #111;
color: white;
border-color: #111;
}
.btn-primary:hover {
background: #333;
border-color: #333;
}
.btn-neutral {
background: white;
color: #333;
border-color: #111;
}
.btn-neutral:hover {
background: #f8f9fa;
}
.btn-icon {
padding: 0.5rem;
border: none;
background: transparent;
color: #666;
cursor: pointer;
border-radius: 50%;
/* Make icon buttons round for better touch target */
transition: all 0.2s ease;
line-height: 1;
/* Ensure icon is centered */
}
.btn-icon .material-icons {
font-size: 1.5rem;
/* Slightly larger icons */
}
.btn-icon:hover {
color: #333;
background-color: rgba(0, 0, 0, 0.05);
/* Subtle hover background */
}
.btn-danger {
background-color: #dc3545;
color: white;
border-color: #dc3545;
}
.btn-danger:hover {
background-color: #c82333;
border-color: #bd2130;
}
.btn-danger-icon {
/* For icon buttons that are dangerous */
color: #dc3545;
}
.btn-danger-icon:hover {
color: #c82333;
background-color: rgba(220, 53, 69, 0.1);
}
/* Dark mode support for modals and forms (as before, with minor tweaks if needed) */
@media (prefers-color-scheme: dark) {
.modal-container {
background: #1e1e1e;
/* Slightly different dark */
border-color: #f0f0f0;
/* Lighter border for dark */
box-shadow: 6px 6px 0 #f0f0f0;
}
.modal-header,
.modal-footer {
border-color: #444;
/* Darker separator */
}
.modal-header h3 {
color: #f0f0f0;
}
.form-group label {
color: #ccc;
/* Lighter label */
}
.form-input {
background: #2a2a2a;
border-color: #555;
/* Mid-tone border */
color: #f0f0f0;
}
.form-input:focus {
border-color: #0095ff;
/* Brighter focus for dark */
box-shadow: 0 0 0 3px rgba(0, 149, 255, 0.25);
}
.form-input::placeholder {
color: #777;
}
.type-btn {
background: #2a2a2a;
border-color: #555;
color: #ccc;
}
.type-btn:hover {
background: #333;
}
.type-btn.active {
background: #f0f0f0;
color: #111;
border-color: #f0f0f0;
}
.btn-outline {
background: #2a2a2a;
border-color: #555;
color: #ccc;
}
.btn-outline:hover {
background: #333;
}
.btn-neutral {
background: #2a2a2a;
color: #ccc;
border-color: #555;
}
.btn-neutral:hover {
background: #333;
}
.btn-primary {
/* Ensure primary button is distinct in dark mode */
background: #007bff;
color: white;
border-color: #007bff;
}
.btn-primary:hover {
background: #0056b3;
border-color: #0056b3;
}
.btn-icon {
color: #999;
}
.btn-icon:hover {
color: #f0f0f0;
background-color: rgba(255, 255, 255, 0.08);
}
.btn-danger {
background-color: #d9534f;
border-color: #d9534f;
}
.btn-danger:hover {
background-color: #c9302c;
border-color: #c9302c;
}
.btn-danger-icon {
color: #d9534f;
}
.btn-danger-icon:hover {
color: #c9302c;
background-color: rgba(217, 83, 79, 0.1);
}
}
</style>
<style scoped>
/* Utility classes */
.hide-text-on-mobile {
display: inline;
}
.hide-text-on-mobile-sm {
display: inline;
}
@media (max-width: 480px) {
/* Stricter breakpoint for hiding text */
.hide-text-on-mobile {
display: none;
}
}
@media (max-width: 380px) {
/* Even Stricter breakpoint for New Chore */
.hide-text-on-mobile-sm {
display: none;
}
}
/* Base container styles (as before) */
.neo-container {
padding: 1rem;
max-width: 1200px;
margin: 0 auto;
}
.page-padding {
/* This class seems redundant if neo-container is always used */
/* padding: 1rem; max-width: 1200px; margin: 0 auto; */
}
/* Header styles */
.neo-list-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
/* Align items to the start of the cross axis */
flex-wrap: wrap;
/* Allow wrapping for very narrow screens if necessary */
gap: 1.5rem;
/* Adjusted gap */
margin-bottom: 2rem;
padding: 1.5rem;
background: white;
border-radius: 18px;
border: 3px solid #111;
box-shadow: 6px 6px 0 #111;
}
.header-left {
flex: 1;
min-width: 280px;
/* Ensure it has some minimum width before wrapping */
}
.header-right {
display: flex;
align-items: center;
gap: 1rem;
flex-shrink: 0;
}
.neo-title {
font-size: 2.5rem;
font-weight: 900;
margin: 0 0 1rem 0;
line-height: 1.2;
color: #111;
}
/* Tab styles */
.view-tabs {
display: flex;
gap: 0.5rem;
flex-wrap: wrap;
/* Allow tabs to wrap if they don't fit */
/* Removed overflow-x: auto; flex-wrap is usually preferred if space allows items to stack */
/* If horizontal scrolling is desired over wrapping:
overflow-x: auto;
white-space: nowrap;
padding-bottom: 8px; // for scrollbar visibility
flex-wrap: nowrap;
*/
margin-bottom: 0.5rem;
/* if wrapped, needs some margin */
}
.neo-tab-btn {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.6rem 1rem;
border: 2px solid #111;
background: #f5f5f5;
color: #333;
font-weight: 600;
font-size: 0.9rem;
border-radius: 8px;
cursor: pointer;
transition: all 0.2s ease;
box-shadow: 2px 2px 0 #111;
}
.neo-tab-btn .material-icons {
font-size: 1.1rem;
}
.neo-tab-btn:hover:not(:disabled) {
transform: translateY(-1px);
box-shadow: 3px 3px 0 #111;
}
.neo-tab-btn:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.neo-tab-btn.active {
background: #111;
color: white;
box-shadow: 3px 3px 0 #111;
/* Ensure active shadow is consistent */
}
.neo-tab-count {
background: rgba(255, 255, 255, 0.2);
color: white;
/* Assuming active tab has dark background */
padding: 0.1em 0.5em;
border-radius: 1rem;
font-size: 0.8rem;
font-weight: 600;
min-width: 1.5rem;
/* Ensure it's circular even for 1 digit */
text-align: center;
line-height: normal;
/* Adjust if text is not centered vertically */
}
.neo-tab-btn:not(.active) .neo-tab-count {
background: #e0e0e0;
color: #333;
}
/* View toggle styles */
.neo-view-toggle {
display: flex;
gap: 0.25rem;
/* Reduced gap for tighter packing */
background: #f5f5f5;
padding: 0.25rem;
border-radius: 8px;
border: 2px solid #111;
}
.neo-toggle-btn {
border: none;
background: transparent;
color: #333;
padding: 0.5rem 0.75rem;
/* Adjusted padding */
font-weight: 600;
border-radius: 6px;
cursor: pointer;
transition: all 0.2s ease;
display: flex;
align-items: center;
gap: 0.5rem;
}
.neo-toggle-btn .material-icons {
font-size: 1.2rem;
}
.neo-toggle-btn:hover:not(.active):not(:disabled) {
background: #e9ecef;
}
.neo-toggle-btn:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.neo-toggle-btn.active {
background: #111;
color: white;
}
/* Action button styles */
.neo-action-button {
background: #fff;
border: 3px solid #111;
border-radius: 8px;
padding: 0.6rem 1rem;
font-weight: 700;
cursor: pointer;
display: flex;
align-items: center;
gap: 0.5rem;
box-shadow: 3px 3px 0 #111;
transition: transform 0.1s ease-in-out, box-shadow 0.1s ease-in-out;
}
.neo-action-button .material-icons {
font-size: 1.2rem;
}
.neo-action-button:hover:not(:disabled) {
transform: translateY(-2px);
box-shadow: 3px 5px 0 #111;
}
.neo-action-button:active:not(:disabled) {
transform: translateY(0);
box-shadow: 2px 2px 0 #111;
}
.neo-action-button:disabled {
opacity: 0.6;
cursor: not-allowed;
transform: none;
box-shadow: 3px 3px 0 #111;
}
/* Loading state styles (as before) */
.neo-loading-state {
text-align: center;
padding: 3rem 1rem;
margin: 2rem 0;
border: 3px solid #111;
border-radius: 18px;
background: #fff;
box-shadow: 6px 6px 0 #111;
}
.loading-spinner {
/* Basic spinner */
border: 4px solid #f3f3f3;
border-top: 4px solid #111;
border-radius: 50%;
width: 40px;
height: 40px;
animation: spin 1s linear infinite;
margin: 0 auto 1rem;
}
@keyframes spin {
0% {
transform: rotate(0deg);
}
100% {
transform: rotate(360deg);
}
}
/* Empty State Styles */
.empty-state {
text-align: center;
padding: 2rem 1rem;
margin: 2rem auto;
/* Centered if inside a flex/grid container */
border: 2px dashed #ccc;
border-radius: 12px;
background: #f9f9f9;
color: #555;
max-width: 500px;
}
.empty-state .empty-icon {
font-size: 3rem;
margin-bottom: 0.5rem;
color: #aaa;
}
.empty-state p {
font-size: 1.1rem;
margin-bottom: 1rem;
}
/* Responsive styles for header */
@media (max-width: 768px) {
.neo-list-header {
flex-direction: column;
gap: 1.5rem;
/* Consistent gap */
padding: 1rem;
}
.header-left {
width: 100%;
/* Take full width when stacked */
}
.header-right {
width: 100%;
justify-content: space-between;
/* Spread items */
}
.neo-title {
font-size: 2rem;
/* Slightly smaller title on mobile */
}
.view-tabs {
/* If choosing scroll over wrap for tabs: */
/* overflow-x: auto; white-space: nowrap; padding-bottom: 8px; flex-wrap: nowrap; */
/* -webkit-overflow-scrolling: touch; scrollbar-width: thin; */
}
/* .view-tabs::-webkit-scrollbar { display: block; height: 5px; } */
/* .view-tabs::-webkit-scrollbar-thumb { background: #ccc; border-radius: 3px;} */
.neo-tab-btn {
font-size: 0.85rem;
/* Slightly smaller tab text */
padding: 0.5rem 0.75rem;
}
.neo-view-toggle {
/* flex: 1; Ensure it can take space if New Chore btn shrinks more */
}
.neo-toggle-btn {
padding: 0.5rem 0.6rem;
}
/* More compact toggle buttons */
.neo-action-button {
/* flex-grow: 1; /* Allows it to grow if space available */
/* justify-content: center; /* Center content if text is hidden */
padding: 0.6rem 0.8rem;
/* More compact */
}
}
/* Dark mode support (as before) */
@media (prefers-color-scheme: dark) {
.neo-list-header {
background: #1a1a1a;
border-color: #fff;
box-shadow: 6px 6px 0 #fff;
}
.neo-title {
color: #fff;
}
.neo-tab-btn {
background: #2a2a2a;
border-color: #fff;
color: #fff;
box-shadow: 2px 2px 0 #fff;
}
.neo-tab-btn:hover:not(:disabled) {
box-shadow: 3px 3px 0 #fff;
}
.neo-tab-btn.active {
background: #fff;
color: #111;
}
.neo-tab-btn:not(.active) .neo-tab-count {
background: #333;
color: #fff;
}
.neo-view-toggle {
background: #2a2a2a;
border-color: #fff;
}
.neo-toggle-btn {
color: #fff;
}
.neo-toggle-btn:hover:not(.active):not(:disabled) {
background: #333;
}
.neo-toggle-btn.active {
background: #fff;
color: #111;
}
.neo-action-button {
background: #1a1a1a;
border-color: #fff;
color: #fff;
box-shadow: 3px 3px 0 #fff;
}
.neo-action-button:hover:not(:disabled) {
box-shadow: 3px 5px 0 #fff;
background: #2c2c2c;
}
.empty-state {
background: #222;
border-color: #444;
color: #aaa;
}
.empty-state .empty-icon {
color: #555;
}
.loading-spinner {
border-top-color: #fff;
}
}
/* Calendar View Styles */
.calendar-view {
background: white;
border-radius: 18px;
border: 3px solid #111;
box-shadow: 6px 6px 0 #111;
padding: 1.5rem;
margin-bottom: 2rem;
}
.calendar-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 1.5rem;
}
.calendar-header h2 {
font-size: 1.5rem;
font-weight: 700;
margin: 0;
text-align: center;
flex-grow: 1;
}
.calendar-header .btn-icon {
flex-shrink: 0;
}
.calendar-grid {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.calendar-weekdays {
display: grid;
grid-template-columns: repeat(7, 1fr);
gap: 0.25rem;
/* Smaller gap for weekdays */
margin-bottom: 0.5rem;
}
.weekday {
text-align: center;
font-weight: 600;
color: #666;
padding: 0.5rem 0.25rem;
font-size: 0.85rem;
}
.calendar-days {
display: grid;
grid-template-columns: repeat(7, 1fr);
gap: 0.5rem;
}
.calendar-day {
/* aspect-ratio: 1; /* Can make cells too large or small. Consider min-height. */
min-height: 100px;
/* Ensure a minimum height for usability */
border: 2px solid #111;
border-radius: 12px;
padding: 0.5rem;
background: #f5f5f5;
display: flex;
flex-direction: column;
gap: 0.25rem;
/* Reduced gap inside day cell */
transition: all 0.2s ease;
position: relative;
/* For positioning add button */
overflow: hidden;
/* Prevent content overflow */
}
.calendar-day:focus-within {
/* Highlight when day or its content is focused */
border-color: #007bff;
box-shadow: 0 0 0 2px rgba(0, 123, 255, .25), 3px 3px 0 #111;
}
.calendar-day:hover:not(:focus-within) {
transform: translateY(-2px);
box-shadow: 3px 3px 0 #111;
}
.calendar-day.other-month {
opacity: 0.6;
background: #eee;
}
.calendar-day.today {
background: #fff0b3;
border-color: #ffc107;
}
/* Highlight today */
.calendar-day.has-chores:not(.today) {
background: #e9f5ff;
}
/* Subtle bg for days with chores */
.day-header {
display: flex;
justify-content: space-between;
align-items: center;
}
.day-number {
font-weight: 600;
font-size: 1rem;
}
.add-chore-indicator {
/* visibility: hidden; Replaced with subtle always visible */
opacity: 0.6;
padding: 0.1rem;
background: transparent;
border: none;
color: #007bff;
cursor: pointer;
border-radius: 50%;
transition: all 0.2s ease;
line-height: 1;
}
.add-chore-indicator .material-icons {
font-size: 1.3rem;
}
.calendar-day:hover .add-chore-indicator,
.calendar-day:focus-within .add-chore-indicator,
/* Show on focus as well */
.add-chore-indicator:focus {
opacity: 1;
color: #0056b3;
transform: scale(1.1);
}
.add-chore-indicator:hover {
color: #f57c00;
/* Distinct hover for the button itself */
}
.day-chores {
flex: 1;
overflow-y: auto;
display: flex;
flex-direction: column;
gap: 0.25rem;
scrollbar-width: thin;
/* For Firefox */
}
.day-chores::-webkit-scrollbar {
width: 5px;
}
.day-chores::-webkit-scrollbar-thumb {
background: #ccc;
border-radius: 3px;
}
.calendar-chore-item {
display: flex;
align-items: center;
gap: 0.3rem;
/* Smaller gap */
padding: 0.2rem 0.4rem;
/* Compact padding */
background: white;
border: 1px solid #ddd;
border-radius: 6px;
cursor: pointer;
transition: all 0.2s ease;
font-size: 0.8rem;
/* Smaller font for chore items */
line-height: 1.3;
}
.calendar-chore-item:hover {
background: #f0f8ff;
transform: translateX(2px);
}
.calendar-chore-item.is-completed {
opacity: 0.6;
text-decoration: line-through;
}
.calendar-chore-item .chore-name {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
flex-grow: 1;
}
.chore-status {
width: 8px;
height: 8px;
border-radius: 50%;
flex-shrink: 0;
}
.chore-status.overdue {
background: #dc3545;
}
.chore-status.today {
background: #007bff;
}
.chore-status.upcoming {
background: #28a745;
}
.chore-status.completed {
background: #6c757d;
}
@media (max-width: 768px) {
.calendar-view {
padding: 1rem;
}
.calendar-header h2 {
font-size: 1.25rem;
}
.calendar-days {
gap: 0.25rem;
}
.calendar-day {
min-height: 80px;
padding: 0.3rem;
}
.day-number {
font-size: 0.9rem;
}
.add-chore-indicator .material-icons {
font-size: 1.1rem;
}
.calendar-chore-item {
font-size: 0.75rem;
padding: 0.15rem 0.3rem;
}
.weekday {
font-size: 0.75rem;
padding: 0.3rem 0.1rem;
}
}
@media (max-width: 480px) {
.weekday {
font-size: 0.6rem;
/* Abbreviate to S, M, T... if needed */
}
.calendar-day {
min-height: 70px;
}
.day-chores {
gap: 0.15rem;
}
}
/* Dark Mode Calendar */
@media (prefers-color-scheme: dark) {
.calendar-view {
background: #1a1a1a;
border-color: #fff;
box-shadow: 6px 6px 0 #fff;
}
.calendar-header h2 {
color: #fff;
}
.weekday {
color: #aaa;
}
.calendar-day {
background: #2a2a2a;
border-color: #555;
}
.calendar-day:hover:not(:focus-within) {
box-shadow: 3px 3px 0 #fff;
}
.calendar-day.other-month {
background: #222;
opacity: 0.5;
}
.calendar-day.today {
background: #4a3a00;
border-color: #ffc107;
}
.calendar-day.has-chores:not(.today) {
background: #1c2a3e;
}
.day-number {
color: #f0f0f0;
}
.add-chore-indicator {
color: #0095ff;
}
.add-chore-indicator:hover,
.calendar-day:hover .add-chore-indicator,
.add-chore-indicator:focus {
color: #66c0ff;
}
.calendar-chore-item {
background: #333;
border-color: #444;
color: #ccc;
}
.calendar-chore-item:hover {
background: #3a4a5a;
}
.calendar-chore-item.is-completed {
opacity: 0.5;
}
.chore-status.overdue {
background: #d9534f;
}
.chore-status.today {
background: #0095ff;
}
.chore-status.upcoming {
background: #5cb85c;
}
.chore-status.completed {
background: #777;
}
.day-chores::-webkit-scrollbar-thumb {
background: #555;
}
.calendar-day:focus-within {
border-color: #0095ff;
box-shadow: 0 0 0 2px rgba(0, 149, 255, .25), 3px 3px 0 #fff;
}
}
/* List View Styles */
.chores-list-container {}
/* Wrapper for list and empty state */
.chores-grid {
display: flex;
flex-direction: column;
gap: 1rem;
/* Increased gap */
margin-bottom: 2rem;
width: 100%;
}
.chore-card {
background: white;
border-radius: 12px;
border: 2px solid #111;
box-shadow: 3px 3px 0 #111;
padding: 1.25rem 1.5rem;
/* Slightly more padding */
display: flex;
gap: 1rem;
transition: all 0.2s ease;
align-items: flex-start;
/* Align status dot with top of content */
}
.chore-card:hover {
transform: translateY(-1px);
box-shadow: 4px 4px 0 #111;
}
.chore-card.is-overdue {
border-left: 5px solid #dc3545;
/* Use left border for overdue emphasis */
/* border-color: #dc3545; // This changes all borders */
}
.chore-card.is-completed {
opacity: 0.7;
background: #f9f9f9;
/* Slightly different bg for completed */
}
.chore-card.is-completed .chore-header h3 {
text-decoration: line-through;
color: #555;
}
.chore-status {
margin-top: 0.25rem;
/* Align with first line of text */
}
.chore-content {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
/* Stack main info and actions vertically */
gap: 0.75rem;
}
.chore-main-info {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
/* Header and meta stacked */
gap: 0.5rem;
}
.chore-header {
display: flex;
justify-content: space-between;
/* Push tags to the right */
align-items: flex-start;
/* Align items at the start */
gap: 1rem;
width: 100%;
}
.chore-header h3 {
margin: 0;
font-size: 1.2rem;
/* Slightly larger chore name */
font-weight: 600;
/* white-space: nowrap; // Removed to allow wrapping */
/* overflow: hidden; text-overflow: ellipsis; // Removed */
word-break: break-word;
/* Allow long names to wrap */
flex-grow: 1;
}
.chore-tags {
display: flex;
gap: 0.5rem;
flex-wrap: wrap;
/* Allow tags to wrap */
flex-shrink: 0;
/* Prevent tags from shrinking too much */
margin-top: 2px;
/* Align better with h3 */
}
.chore-type-tag,
.chore-frequency-tag {
padding: 0.2rem 0.6rem;
border-radius: 1rem;
font-size: 0.75rem;
/* Smaller tags */
font-weight: 600;
white-space: nowrap;
border: 1px solid transparent;
/* For consistency */
}
.chore-type-tag.personal {
background: #e3f2fd;
color: #1976d2;
border-color: #b3e5fc;
}
.chore-type-tag.group {
background: #f3e5f5;
color: #7b1fa2;
border-color: #e1bee7;
}
.chore-frequency-tag {
background: #e8f5e9;
color: #2e7d32;
border-color: #c8e6c9;
}
.chore-meta {
display: flex;
flex-direction: column;
/* Stack meta items */
gap: 0.5rem;
font-size: 0.9rem;
/* Slightly larger meta text */
color: #555;
}
.chore-meta>div {
display: flex;
align-items: center;
gap: 0.4rem;
}
/* For icon and text alignment */
.chore-meta .material-icons {
font-size: 1.1rem;
color: #777;
}
.chore-due-date.overdue-text,
.chore-due-date .overdue {
/* For overdue text specifically */
color: #dc3545;
font-weight: 500;
}
.chore-due-date.today-text {
color: #007bff;
font-weight: 500;
}
.chore-description {
font-size: 0.9rem;
color: #666;
line-height: 1.4;
/* white-space: nowrap; overflow: hidden; text-overflow: ellipsis; max-width: 200px; // Removed for better readability, allow wrapping */
}
.chore-card-actions {
display: flex;
flex-wrap: wrap;
/* Allow actions to wrap */
gap: 0.5rem;
align-items: center;
/* Align items vertically */
/* margin-left: auto; // No longer needed with flex-column parent */
align-self: flex-end;
/* Align actions to the right */
}
.chore-card-actions .btn {
font-size: 0.85rem;
padding: 0.4rem 0.8rem;
}
.chore-card-actions .btn-icon .material-icons {
font-size: 1.2rem;
}
/* Ensure icon size is consistent */
.chore-card-actions .btn-icon .btn-text {
font-size: 0.85rem;
}
/* Match text size if shown */
/* Dark Mode Support for List View */
@media (prefers-color-scheme: dark) {
.chore-card {
background: #1a1a1a;
border-color: #fff;
box-shadow: 3px 3px 0 #fff;
}
.chore-card:hover {
box-shadow: 4px 4px 0 #fff;
background: #202020;
}
.chore-card.is-completed {
background: #222;
opacity: 0.6;
}
.chore-card.is-completed .chore-header h3 {
color: #888;
}
.chore-header h3 {
color: #f0f0f0;
}
.chore-meta {
color: #aaa;
}
.chore-meta .material-icons {
color: #888;
}
.chore-description {
color: #bbb;
}
.chore-type-tag.personal {
background: #1c2a3e;
color: #82cfff;
border-color: #2a587f;
}
.chore-type-tag.group {
background: #301c30;
color: #e1a9f0;
border-color: #5c2a5c;
}
.chore-frequency-tag {
background: #1c301d;
color: #9fec9f;
border-color: #2a5c2b;
}
.chore-due-date.overdue-text {
color: #ff8a80;
}
.chore-due-date.today-text {
color: #80d8ff;
}
}
/* Responsive Styles for List View */
@media (max-width: 768px) {
.chores-grid {
padding: 0;
gap: 0.75rem;
}
.chore-card {
padding: 1rem;
}
.chore-header h3 {
font-size: 1.1rem;
}
.chore-tags {
justify-content: flex-start;
}
/* Align tags to start on mobile */
.chore-card-actions {
align-self: stretch;
justify-content: flex-end;
}
/* Full width for actions row, content to right */
}
@media (max-width: 480px) {
/* On very small screens, hide text for Edit/Delete buttons in list view */
.chore-card-actions .btn-icon .hide-text-on-mobile {
display: none;
}
.chore-card-actions .btn-icon {
/* Ensure padding is just for icon */
padding: 0.5rem;
}
.chore-header {
flex-direction: column;
align-items: flex-start;
gap: 0.25rem;
}
.chore-header h3 {
font-size: 1rem;
}
}
/* Transition Animations (as before) */
.chore-list-enter-active,
.chore-list-leave-active {
transition: all 0.3s ease;
}
.chore-list-enter-from,
.chore-list-leave-to {
opacity: 0;
transform: translateY(20px) scale(0.95);
/* Slightly different animation */
}
.chore-list-move {
transition: transform 0.3s ease;
}
/* Keyboard Shortcuts Modal Styles (as before) */
.shortcuts-modal {
max-width: 500px;
}
.shortcuts-list {
display: flex;
flex-direction: column;
gap: 1rem;
}
.shortcut-item {
display: flex;
align-items: center;
gap: 1rem;
/* Reduced gap */
padding: 0.75rem;
background: #f5f5f5;
border-radius: 8px;
border: 2px solid #111;
}
.shortcut-keys {
display: flex;
gap: 0.5rem;
align-items: center;
min-width: 110px;
/* Adjusted min-width */
}
.shortcut-description {
font-weight: 500;
color: #333;
font-size: 0.9rem;
}
kbd {
background: white;
border: 2px solid #111;
border-radius: 4px;
padding: 0.2rem 0.5rem;
font-family: monospace;
font-size: 0.9rem;
box-shadow: 1px 1px 0 #111;
/* Softer shadow for kbd */
color: #111;
}
/* Dark Mode Support for Shortcuts Modal (as before) */
@media (prefers-color-scheme: dark) {
.shortcut-item {
background: #2a2a2a;
border-color: #fff;
}
.shortcut-description {
color: #fff;
}
kbd {
background: #1a1a1a;
border-color: #fff;
box-shadow: 1px 1px 0 #fff;
color: #fff;
}
}
/* Remove old keyboard shortcuts help styles (as before) */
.keyboard-shortcuts-help,
.shortcuts-tooltip {
display: none;
}
</style>