![google-labs-jules[bot]](/assets/img/avatar_default.png)
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.
2465 lines
64 KiB
Vue
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> |