
- Added ARIA roles and attributes to buttons and modals for improved accessibility. - Updated chore types and properties in the Chore interface to allow for null values. - Refactored chore loading and filtering logic to handle edge cases and improve performance. - Enhanced calendar and list views with better user feedback for empty states and loading indicators. - Improved styling for mobile responsiveness and dark mode support. These changes aim to enhance user experience, accessibility, and maintainability of the ChoresPage component.
2464 lines
62 KiB
Vue
2464 lines
62 KiB
Vue
<template>
|
|
<main class="neo-container page-padding">
|
|
<div class="neo-list-header">
|
|
<div class="header-left">
|
|
<h1 class="neo-title">Chores</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>
|
|
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>
|
|
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>
|
|
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>
|
|
All Pending
|
|
</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>
|
|
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="Calendar View">
|
|
<span class="material-icons">calendar_month</span>
|
|
<span class="btn-text hide-text-on-mobile">Calendar</span>
|
|
</button>
|
|
<button class="neo-toggle-btn" :class="{ active: viewMode === 'list' }" @click="viewMode = 'list'"
|
|
:disabled="isLoading" :aria-pressed="viewMode === 'list'" aria-label="List View">
|
|
<span class="material-icons">view_list</span>
|
|
<span class="btn-text hide-text-on-mobile">List</span>
|
|
</button>
|
|
</div>
|
|
<button class="neo-action-button" @click="openCreateChoreModal(null)" :disabled="isLoading"
|
|
aria-label="New Chore">
|
|
<span class="material-icons">add</span>
|
|
<span class="btn-text hide-text-on-mobile-sm">New Chore</span>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Loading State -->
|
|
<div v-if="isLoading" class="neo-loading-state">
|
|
<div class="loading-spinner"></div>
|
|
<p>Loading chores...</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="Previous month">
|
|
<span class="material-icons">chevron_left</span>
|
|
</button>
|
|
<h2>{{ currentMonthYear }}</h2>
|
|
<button class="btn btn-icon" @click="nextMonth" aria-label="Next month">
|
|
<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="day in weekDays" :key="day" class="weekday">{{ day }}</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="Add chore to this day">
|
|
<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>No chores to display for this period.</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' ? 'Personal' : getGroupName(chore.group_id) || 'Group' }}
|
|
</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>
|
|
Completed: {{ 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="Mark as Done">
|
|
<span class="material-icons">check_circle</span> Done
|
|
</button>
|
|
<button v-else class="btn btn-warning btn-sm btn-undo" @click="toggleChoreCompletion(chore)"
|
|
title="Mark as Not Done">
|
|
<span class="material-icons">undo</span> Undo
|
|
</button>
|
|
<button class="btn btn-icon" @click="openEditChoreModal(chore)" title="Edit" aria-label="Edit chore">
|
|
<span class="material-icons">edit</span>
|
|
<span class="btn-text hide-text-on-mobile">Edit</span>
|
|
</button>
|
|
<button class="btn btn-icon btn-danger-icon" @click="confirmDeleteChore(chore)" title="Delete"
|
|
aria-label="Delete chore">
|
|
<span class="material-icons">delete</span>
|
|
<span class="btn-text hide-text-on-mobile">Delete</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>No chores in this view. Well done!</p>
|
|
<button v-if="activeView !== 'all'" class="btn btn-sm btn-outline" @click="activeView = 'all'">View All
|
|
Pending</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 ? 'Edit Chore' : 'New Chore'
|
|
}}</h3>
|
|
<button class="btn btn-icon" @click="showChoreModal = false" aria-label="Close modal">
|
|
<span class="material-icons">close</span>
|
|
</button>
|
|
</div>
|
|
<form @submit.prevent="onSubmit" class="modal-form">
|
|
<div class="form-group">
|
|
<label for="name">Name</label>
|
|
<input id="name" v-model="choreForm.name" type="text" class="form-input" placeholder="Enter chore name"
|
|
required />
|
|
</div>
|
|
|
|
<div class="form-group">
|
|
<label>Type</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>
|
|
Personal
|
|
</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>
|
|
Group
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<div v-if="choreForm.type === 'group'" class="form-group">
|
|
<label for="group">Group</label>
|
|
<select id="group" v-model="choreForm.group_id" class="form-input" required>
|
|
<option :value="undefined" disabled>Select a group</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">Description</label>
|
|
<textarea id="description" v-model="choreForm.description" class="form-input" rows="3"
|
|
placeholder="Add a description (optional)"></textarea>
|
|
</div>
|
|
|
|
<div class="form-row">
|
|
<div class="form-group">
|
|
<label for="frequency">Frequency</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">Interval (days)</label>
|
|
<input id="interval" v-model.number="choreForm.custom_interval_days" type="number" class="form-input"
|
|
min="1" placeholder="e.g. 3" required />
|
|
</div>
|
|
</div>
|
|
|
|
<div class="form-group">
|
|
<label for="dueDate">Due Date</label>
|
|
<div class="quick-due-dates">
|
|
<button type="button" class="btn btn-sm btn-outline" @click="setQuickDueDate('today')">Today</button>
|
|
<button type="button" class="btn btn-sm btn-outline"
|
|
@click="setQuickDueDate('tomorrow')">Tomorrow</button>
|
|
<button type="button" class="btn btn-sm btn-outline" @click="setQuickDueDate('next_week')">Next
|
|
Week</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">Cancel</button>
|
|
<button type="submit" class="btn btn-primary">Save</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">Delete Chore</h3>
|
|
<button class="btn btn-icon" @click="showDeleteDialog = false" aria-label="Close modal">
|
|
<span class="material-icons">close</span>
|
|
</button>
|
|
</div>
|
|
<div class="modal-body">
|
|
<p>Are you sure you want to delete this chore? This action cannot be undone.</p>
|
|
</div>
|
|
<div class="modal-footer">
|
|
<button class="btn btn-neutral" @click="showDeleteDialog = false">Cancel</button>
|
|
<button class="btn btn-danger" @click="deleteChore">Delete</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">Keyboard Shortcuts</h3>
|
|
<button class="btn btn-icon" @click="showShortcutsModal = false" aria-label="Close modal">
|
|
<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">New Chore</div>
|
|
</div>
|
|
<div class="shortcut-item">
|
|
<div class="shortcut-keys">
|
|
<kbd>Ctrl/Cmd</kbd> + <kbd>/</kbd>
|
|
</div>
|
|
<div class="shortcut-description">Toggle View</div>
|
|
</div>
|
|
<div class="shortcut-item">
|
|
<div class="shortcut-keys">
|
|
<kbd>Ctrl/Cmd</kbd> + <kbd>?</kbd>
|
|
</div>
|
|
<div class="shortcut-description">Show/Hide Shortcuts</div>
|
|
</div>
|
|
<div class="shortcut-item">
|
|
<div class="shortcut-keys">
|
|
<kbd>Esc</kbd>
|
|
</div>
|
|
<div class="shortcut-description">Close Modal</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</main>
|
|
</template>
|
|
|
|
<script setup lang="ts">
|
|
import { ref, onMounted, computed, onUnmounted, watch, onBeforeUnmount } from 'vue'
|
|
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'
|
|
|
|
// 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: { label: string; value: ChoreFrequency }[] = [
|
|
{ label: 'One Time', value: 'one_time' },
|
|
{ label: 'Daily', value: 'daily' },
|
|
{ label: 'Weekly', value: 'weekly' },
|
|
{ label: 'Monthly', value: 'monthly' },
|
|
{ label: 'Custom', value: 'custom' }
|
|
];
|
|
|
|
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: 'Failed to load chores', 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 weekDays = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']
|
|
// For smaller screens, you might use: const weekDays = ['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 = `Chore '${choreForm.value.name}' updated successfully`
|
|
} else {
|
|
await choreService.createChore(choreDataSubmit as ChoreCreate)
|
|
notificationMessage = `Chore '${choreForm.value.name}' created successfully`
|
|
}
|
|
|
|
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: `Failed to ${isEditing.value ? 'update' : 'create'} chore`, 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: `Chore '${selectedChore.value.name}' deleted successfully`, type: 'success' })
|
|
await loadChores()
|
|
} catch (error) {
|
|
console.error('Failed to delete chore:', error);
|
|
notificationStore.addNotification({ message: 'Failed to delete chore', 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 'No due date';
|
|
try {
|
|
const dueDate = startOfDay(new Date(dateString.replace(/-/g, '/')));
|
|
if (isEqual(dueDate, today.value)) return 'Due Today';
|
|
|
|
const tomorrow = addDays(today.value, 1);
|
|
if (isEqual(dueDate, tomorrow)) return 'Due Tomorrow';
|
|
|
|
if (isBefore(dueDate, today.value)) return `Overdue: ${formatDate(dateString)}`;
|
|
|
|
return `Due ${formatDate(dateString)}`;
|
|
} catch {
|
|
return 'Invalid date'
|
|
}
|
|
}
|
|
|
|
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: `${choreToToggle.name} marked as ${choreToToggle.is_completed ? 'done' : 'not done'}.`,
|
|
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: 'Failed to update chore status.', 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: 'Chore name is required.', type: 'error' }); return false;
|
|
}
|
|
if (choreForm.value.type === 'group' && !choreForm.value.group_id) {
|
|
notificationStore.addNotification({ message: 'Please select a group for group chores.', type: 'error' }); return false;
|
|
}
|
|
if (choreForm.value.frequency === 'custom' && (!choreForm.value.custom_interval_days || choreForm.value.custom_interval_days < 1)) {
|
|
notificationStore.addNotification({ message: 'Custom interval must be at least 1 day.', type: 'error' }); return false;
|
|
}
|
|
if (!choreForm.value.next_due_date) {
|
|
notificationStore.addNotification({ message: 'Due date is required.', type: 'error' }); return false;
|
|
}
|
|
try {
|
|
new Date(choreForm.value.next_due_date.replace(/-/g, '/')); // check if valid date
|
|
} catch {
|
|
notificationStore.addNotification({ message: 'Invalid due date format.', 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('You have unsaved changes in the chore form. Are you sure you want to leave?')
|
|
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> |