mitlist/fe/src/pages/ChoresPage.vue
mohamad 66daa19cd5
Some checks failed
Deploy to Production, build images and push to Gitea Registry / build_and_push (pull_request) Failing after 1m17s
feat: Implement WebSocket enhancements and introduce new components for settlements
This commit includes several key updates and new features:

- Enhanced WebSocket functionality across various components, improving real-time communication and user experience.
- Introduced new components for managing settlements, including `SettlementCard.vue`, `SettlementForm.vue`, and `SuggestedSettlementsCard.vue`, to streamline financial interactions.
- Updated existing components and services to support the new settlement features, ensuring consistency and improved performance.
- Added advanced performance optimizations to enhance loading times and responsiveness throughout the application.

These changes aim to provide a more robust and user-friendly experience in managing financial settlements and real-time interactions.
2025-06-30 01:07:10 +02:00

1618 lines
47 KiB
Vue

<script setup lang="ts">
import { ref, onMounted, computed, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { format, startOfDay, isEqual, isToday as isTodayDate, formatDistanceToNow, parseISO } from 'date-fns'
import { choreService } from '../services/choreService'
import { useNotificationStore } from '../stores/notifications'
import type { Chore, ChoreCreate, ChoreUpdate, ChoreFrequency, ChoreAssignment, ChoreHistory, ChoreWithCompletion } from '../types/chore'
import { groupService } from '../services/groupService'
import { useStorage } from '@vueuse/core'
import ChoreItem from '@/components/ChoreItem.vue';
import { useChoreStore } from '@/stores/choreStore';
import { useAuthStore } from '@/stores/auth';
import type { UserPublic } from '@/types/user';
import BaseIcon from '@/components/BaseIcon.vue';
import ChoreDetailSheet from '@/components/ChoreDetailSheet.vue';
import Dialog from '@/components/ui/Dialog.vue';
import { Listbox, ListboxButton, ListboxOption, ListboxOptions } from '@headlessui/vue';
import { TransitionExpand } from '@/components/ui';
import { Button } from '@/components/ui';
import QuickChoreAdd from '@/components/QuickChoreAdd.vue';
import { storeToRefs } from 'pinia'
const { t } = useI18n()
const props = defineProps<{ groupId?: number | string }>();
// Form state
const showAdvancedOptions = ref(false)
// Types
// ChoreWithCompletion is now imported from ../types/chore
interface ChoreFormData {
name: string;
description: string;
frequency: ChoreFrequency;
custom_interval_days: number | undefined;
next_due_date: string;
type: 'personal' | 'group';
group_id: number | undefined;
parent_chore_id?: number | null;
assigned_to_user_id?: number | null;
}
const notificationStore = useNotificationStore()
// State
const chores = ref<ChoreWithCompletion[]>([])
const groups = ref<{ id: number, name: string }[]>([])
const showChoreModal = ref(false)
const showDeleteDialog = ref(false)
const showChoreDetailModal = ref(false)
const showHistoryModal = ref(false)
const isEditing = ref(false)
const selectedChore = ref<ChoreWithCompletion | null>(null)
const selectedChoreHistory = ref<ChoreHistory[]>([])
const selectedChoreAssignments = ref<ChoreAssignment[]>([])
const loadingHistory = ref(false)
const loadingAssignments = ref(false)
const groupMembers = ref<UserPublic[]>([])
const loadingMembers = ref(false)
const choreFormGroupMembers = ref<UserPublic[]>([])
const loadingChoreFormMembers = 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 initialChoreFormState: ChoreFormData = {
name: '',
description: '',
frequency: 'daily',
custom_interval_days: undefined,
next_due_date: format(new Date(), 'yyyy-MM-dd'),
type: 'personal',
group_id: undefined,
parent_chore_id: null,
assigned_to_user_id: null,
}
const choreForm = ref({ ...initialChoreFormState })
const isLoading = ref(true)
const authStore = useAuthStore();
const { isGuest, user } = storeToRefs(authStore);
const choreStore = useChoreStore();
const activeTimer = computed(() => {
return choreStore.activeTimerEntry;
});
const loadChores = async () => {
const now = Date.now();
if (cachedChores.value && cachedChores.value.length > 0 && (now - cachedTimestamp.value) < CACHE_DURATION) {
chores.value = cachedChores.value;
isLoading.value = false;
} else {
isLoading.value = true;
}
try {
const fetchedChores = await choreService.getAllChores()
const currentUserId = user.value?.id ? Number(user.value.id) : null;
const mappedChores = fetchedChores.map(c => {
// Prefer the assignment that belongs to the current user, otherwise fallback to the first assignment
const userAssignment = c.assignments?.find(a => a.assigned_to_user_id === currentUserId) ?? null;
const displayAssignment = userAssignment ?? (c.assignments?.[0] ?? null);
return {
...c,
current_assignment_id: userAssignment?.id ?? null,
is_completed: userAssignment?.is_complete ?? false,
completed_at: userAssignment?.completed_at ?? null,
assigned_user_name:
displayAssignment?.assigned_user?.name ||
displayAssignment?.assigned_user?.email ||
'Unknown',
completed_by_name:
displayAssignment?.assigned_user?.name ||
displayAssignment?.assigned_user?.email ||
'Unknown',
updating: false,
} as ChoreWithCompletion;
});
chores.value = mappedChores;
cachedChores.value = mappedChores;
cachedTimestamp.value = Date.now()
} catch (error) {
console.error(t('choresPage.consoleErrors.loadFailed'), error)
notificationStore.addNotification({ message: t('choresPage.notifications.loadFailed', 'Failed to load chores.'), type: 'error' })
} finally {
isLoading.value = false
}
}
const loadGroups = async () => {
try {
groups.value = await groupService.getUserGroups();
} catch (error) {
console.error(t('choresPage.consoleErrors.loadGroupsFailed'), error);
notificationStore.addNotification({ message: t('choresPage.notifications.loadGroupsFailed', 'Failed to load groups.'), type: 'error' });
}
}
const loadTimeEntries = async () => {
chores.value.forEach(chore => {
if (chore.current_assignment_id) {
choreStore.fetchTimeEntries(chore.current_assignment_id);
}
});
};
// Watch for type changes to clear group_id when switching to personal
watch(() => choreForm.value.type, (newType) => {
if (newType === 'personal') {
choreForm.value.group_id = undefined
}
})
// Fetch group members when a group is selected in the form
watch(() => choreForm.value.group_id, async (newGroupId) => {
if (newGroupId && choreForm.value.type === 'group') {
loadingChoreFormMembers.value = true;
try {
choreFormGroupMembers.value = await groupService.getGroupMembers(newGroupId);
} catch (error) {
console.error('Failed to load group members for form:', error);
choreFormGroupMembers.value = [];
} finally {
loadingChoreFormMembers.value = false;
}
} else {
choreFormGroupMembers.value = [];
}
});
// Reload chores once the user information becomes available (e.g., after login refresh)
watch(user, (newUser, oldUser) => {
if (newUser && !oldUser) {
loadChores().then(loadTimeEntries);
}
});
onMounted(() => {
loadChores().then(loadTimeEntries);
loadGroups()
})
const frequencyOptions = computed(() => [
{ label: t('choresPage.frequencyOptions.oneTime'), value: 'one_time' as ChoreFrequency },
{ label: t('choresPage.frequencyOptions.daily'), value: 'daily' as ChoreFrequency },
{ label: t('choresPage.frequencyOptions.weekly'), value: 'weekly' as ChoreFrequency },
{ label: t('choresPage.frequencyOptions.monthly'), value: 'monthly' as ChoreFrequency },
{ label: t('choresPage.frequencyOptions.custom'), value: 'custom' as ChoreFrequency }
]);
const getChoreSubtext = (chore: ChoreWithCompletion): string => {
if (chore.is_completed && chore.completed_at) {
const completedDate = new Date(chore.completed_at);
if (isTodayDate(completedDate)) {
return t('choresPage.completedToday') + (chore.completed_by_name ? ` by ${chore.completed_by_name}` : '');
}
const timeAgo = formatDistanceToNow(completedDate, { addSuffix: true });
return `Completed ${timeAgo}` + (chore.completed_by_name ? ` by ${chore.completed_by_name}` : '');
}
const parts: string[] = [];
// Show who it's assigned to if there's an assignment
if (chore.current_assignment_id && chore.assigned_user_name) {
parts.push(`Assigned to ${chore.assigned_user_name}`);
}
// Show creator info for group chores
if (chore.type === 'group' && chore.creator) {
parts.push(`Created by ${chore.creator.name || chore.creator.email}`);
}
if (chore.frequency && chore.frequency !== 'one_time') {
const freqOption = frequencyOptions.value.find(f => f.value === chore.frequency);
if (freqOption) {
if (chore.frequency === 'custom' && chore.custom_interval_days) {
parts.push(t('choresPage.frequency.customInterval', { n: chore.custom_interval_days }));
} else {
parts.push(freqOption.label);
}
}
}
if (chore.type === 'group' && chore.group_id) {
const group = groups.value.find(g => g.id === chore.group_id);
if (group) {
parts.push(group.name);
}
}
return parts.join(' · ');
};
const filteredChores = computed(() => {
if (props.groupId) {
return chores.value.filter(
c => c.type === 'group' && String(c.group_id) === String(props.groupId)
);
}
return chores.value;
});
const availableParentChores = computed(() => {
return chores.value.filter(c => {
// A chore cannot be its own parent
if (isEditing.value && selectedChore.value && c.id === selectedChore.value.id) {
return false;
}
// A chore that is already a subtask cannot be a parent
if (c.parent_chore_id) {
return false;
}
// If a group is selected, only show chores from that group or personal chores
if (choreForm.value.group_id) {
return c.group_id === choreForm.value.group_id || c.type === 'personal';
}
// If no group is selected, only show personal chores that are not in a group
return c.type === 'personal' && !c.group_id;
});
});
const groupedChores = computed(() => {
if (!filteredChores.value) return [];
const choreMap = new Map<number, ChoreWithCompletion>();
filteredChores.value.forEach(chore => {
choreMap.set(chore.id, { ...chore, child_chores: [] });
});
const rootChores: ChoreWithCompletion[] = [];
choreMap.forEach(chore => {
if (chore.parent_chore_id && choreMap.has(chore.parent_chore_id)) {
choreMap.get(chore.parent_chore_id)?.child_chores?.push(chore);
} else {
rootChores.push(chore);
}
});
const choresByDate = rootChores.reduce((acc, chore) => {
const dueDate = format(startOfDay(new Date(chore.next_due_date)), 'yyyy-MM-dd');
if (!acc[dueDate]) {
acc[dueDate] = [];
}
acc[dueDate].push(chore);
return acc;
}, {} as Record<string, ChoreWithCompletion[]>);
return Object.keys(choresByDate)
.sort((a, b) => new Date(a).getTime() - new Date(b).getTime())
.map(dateStr => {
const dateParts = dateStr.split('-').map(Number);
const date = new Date(dateParts[0], dateParts[1] - 1, dateParts[2]);
return {
date,
chores: choresByDate[dateStr]
.sort((a, b) => a.name.localeCompare(b.name))
.map(chore => ({
...chore,
subtext: getChoreSubtext(chore)
}))
};
});
});
const formatDateHeader = (date: Date) => {
const today = startOfDay(new Date())
const itemDate = startOfDay(date)
if (isEqual(itemDate, today)) {
return `${t('choresPage.today', 'Today')}, ${format(itemDate, 'eee, d MMM')}`
}
return format(itemDate, 'eee, d MMM')
}
const resetChoreForm = () => {
choreForm.value = { ...initialChoreFormState, next_due_date: format(new Date(), 'yyyy-MM-dd') };
isEditing.value = false
selectedChore.value = null
showAdvancedOptions.value = false // Reset advanced options
}
const openCreateChoreModal = () => {
resetChoreForm()
if (props.groupId) {
choreForm.value.type = 'group';
choreForm.value.group_id = typeof props.groupId === 'string' ? parseInt(props.groupId) : props.groupId;
}
showChoreModal.value = true
}
const openEditChoreModal = (chore: ChoreWithCompletion) => {
isEditing.value = true
selectedChore.value = chore
choreForm.value = {
name: chore.name,
description: chore.description || '',
frequency: chore.frequency,
custom_interval_days: chore.custom_interval_days ?? undefined,
next_due_date: chore.next_due_date,
type: chore.type,
group_id: chore.group_id ?? undefined,
parent_chore_id: chore.parent_chore_id,
assigned_to_user_id: chore.assigned_to_user_id,
}
showChoreModal.value = true
}
const handleFormSubmit = async () => {
try {
let createdChore;
if (isEditing.value && selectedChore.value) {
const updateData: ChoreUpdate = { ...choreForm.value };
// Ensure group_id is properly set based on type
if (updateData.type === 'personal') {
updateData.group_id = undefined;
}
createdChore = await choreService.updateChore(selectedChore.value.id, updateData, selectedChore.value);
notificationStore.addNotification({ message: t('choresPage.notifications.updateSuccess', 'Chore updated successfully!'), type: 'success' });
} else {
const createData = { ...choreForm.value };
// Ensure group_id is properly set based on type
if (createData.type === 'personal') {
createData.group_id = undefined;
}
createdChore = await choreService.createChore(createData as ChoreCreate);
// Create an assignment for the new chore
if (createdChore) {
try {
await choreService.createAssignment({
chore_id: createdChore.id,
assigned_to_user_id: createdChore.created_by_id,
due_date: createdChore.next_due_date
});
} catch (assignmentError) {
console.error(t('choresPage.consoleErrors.createAssignmentForNewChoreFailed'), assignmentError);
// Continue anyway since the chore was created
}
}
notificationStore.addNotification({ message: t('choresPage.notifications.createSuccess', 'Chore created successfully!'), type: 'success' });
}
showChoreModal.value = false;
await loadChores();
} catch (error) {
console.error(t('choresPage.consoleErrors.saveFailed'), error);
notificationStore.addNotification({ message: t('choresPage.notifications.saveFailed', 'Failed to save the chore.'), type: 'error' });
}
}
const confirmDelete = (chore: ChoreWithCompletion) => {
selectedChore.value = chore
showDeleteDialog.value = true
}
const deleteChore = async () => {
if (!selectedChore.value) return
try {
await choreService.deleteChore(selectedChore.value.id, selectedChore.value.type, selectedChore.value.group_id ?? undefined)
notificationStore.addNotification({ message: t('choresPage.notifications.deleteSuccess', 'Chore deleted successfully.'), type: 'success' })
showDeleteDialog.value = false
await loadChores()
} catch (error) {
console.error(t('choresPage.consoleErrors.deleteFailed'), error)
notificationStore.addNotification({ message: t('choresPage.notifications.deleteFailed', 'Failed to delete chore.'), type: 'error' })
}
}
const toggleCompletion = async (chore: ChoreWithCompletion) => {
const assignment = chore.current_assignment_id
? chore.assignments?.find(a => a.id === chore.current_assignment_id) || null
: null
try {
await choreStore.toggleCompletion(chore, assignment as any)
await loadChores()
notificationStore.addNotification({
message: t('choresPage.notifications.updateSuccess', 'Chore status updated.'),
type: 'success',
})
} catch (e) {
notificationStore.addNotification({
message: t('choresPage.notifications.updateFailed', 'Failed to update chore status.'),
type: 'error',
})
}
}
const openChoreDetailModal = async (chore: ChoreWithCompletion) => {
selectedChore.value = chore;
showChoreDetailModal.value = true;
groupMembers.value = []; // Reset
// Load assignments for this chore
loadingAssignments.value = true;
try {
selectedChoreAssignments.value = await choreService.getChoreAssignments(chore.id);
} catch (error) {
console.error('Failed to load chore assignments:', error);
notificationStore.addNotification({
message: 'Failed to load chore assignments.',
type: 'error'
});
} finally {
loadingAssignments.value = false;
}
// If it's a group chore, load members
if (chore.type === 'group' && chore.group_id) {
loadingMembers.value = true;
try {
groupMembers.value = await groupService.getGroupMembers(chore.group_id);
} catch (error) {
console.error('Failed to load group members:', error);
notificationStore.addNotification({
message: 'Failed to load group members.',
type: 'error'
});
} finally {
loadingMembers.value = false;
}
}
};
const openHistoryModal = async (chore: ChoreWithCompletion) => {
selectedChore.value = chore;
showHistoryModal.value = true;
// Load history for this chore
loadingHistory.value = true;
try {
selectedChoreHistory.value = await choreService.getChoreHistory(chore.id);
} catch (error) {
console.error('Failed to load chore history:', error);
notificationStore.addNotification({
message: 'Failed to load chore history.',
type: 'error'
});
} finally {
loadingHistory.value = false;
}
};
const formatHistoryEntry = (entry: ChoreHistory) => {
const timestamp = format(parseISO(entry.timestamp), 'MMM d, h:mm a');
const user = entry.changed_by_user?.name || entry.changed_by_user?.email || 'System';
switch (entry.event_type) {
case 'created':
return `${timestamp} - ${user} created this chore`;
case 'updated':
return `${timestamp} - ${user} updated this chore`;
case 'deleted':
return `${timestamp} - ${user} deleted this chore`;
case 'assigned':
return `${timestamp} - ${user} assigned this chore`;
case 'completed':
return `${timestamp} - ${user} completed this chore`;
case 'reopened':
return `${timestamp} - ${user} reopened this chore`;
default:
return `${timestamp} - ${user} performed action: ${entry.event_type}`;
}
};
const getDueDateStatus = (chore: ChoreWithCompletion) => {
if (chore.is_completed) return 'completed';
const today = startOfDay(new Date());
const dueDate = startOfDay(new Date(chore.next_due_date));
if (dueDate < today) return 'overdue';
if (isEqual(dueDate, today)) return 'due-today';
return 'upcoming';
};
const startTimer = async (chore: ChoreWithCompletion) => {
if (chore.is_completed || !chore.current_assignment_id) return;
await choreStore.startTimer(chore.current_assignment_id);
};
const stopTimer = async (chore: ChoreWithCompletion, timeEntryId: number) => {
if (!chore.current_assignment_id) return;
await choreStore.stopTimer(chore.current_assignment_id, timeEntryId);
};
const handleAssignChore = async (userId: number) => {
if (!selectedChore.value) return;
try {
await choreService.createAssignment({
chore_id: selectedChore.value.id,
assigned_to_user_id: userId,
due_date: selectedChore.value.next_due_date
});
notificationStore.addNotification({ message: 'Chore assigned successfully!', type: 'success' });
// Refresh assignments
selectedChoreAssignments.value = await choreService.getChoreAssignments(selectedChore.value.id);
await loadChores(); // Also reload all chores to update main list
} catch (error) {
console.error('Failed to assign chore:', error);
notificationStore.addNotification({ message: 'Failed to assign chore.', type: 'error' });
}
};
const handleUnassignChore = async (assignmentId: number) => {
if (!selectedChore.value) return;
try {
await choreService.deleteAssignment(assignmentId);
notificationStore.addNotification({ message: 'Chore unassigned successfully!', type: 'success' });
selectedChoreAssignments.value = await choreService.getChoreAssignments(selectedChore.value.id);
await loadChores();
} catch (error) {
console.error('Failed to unassign chore:', error);
notificationStore.addNotification({ message: 'Failed to unassign chore.', type: 'error' });
}
}
const isUserAssigned = (userId: number) => {
return selectedChoreAssignments.value.some(a => a.assigned_to_user_id === userId);
};
const getAssignmentIdForUser = (userId: number): number | null => {
const assignment = selectedChoreAssignments.value.find(a => a.assigned_to_user_id === userId);
return assignment ? assignment.id : null;
};
</script>
<template>
<div class="chores-page">
<!-- Guest Banner -->
<Transition name="slide-down">
<div v-if="isGuest" class="guest-banner">
<div class="guest-banner-content">
<span class="material-icons">info</span>
<p>
You're using a guest account.
<router-link to="/auth/signup" class="guest-link">Sign up</router-link>
to save your data permanently.
</p>
</div>
</div>
</Transition>
<!-- Page Header - Only show if not embedded in group -->
<header v-if="!props.groupId" class="page-header">
<div class="header-content">
<h1 class="page-title">
{{ t('choresPage.title', 'Chores') }}
</h1>
<Button @click="openCreateChoreModal" class="add-chore-btn"
:class="{ 'btn-pulse': groupedChores.length === 0 }">
<BaseIcon name="heroicons:plus-20-solid" class="w-5 h-5" />
<span class="btn-text">{{ t('choresPage.addChore', 'Add Chore') }}</span>
</Button>
</div>
</header>
<!-- Quick Add Section -->
<section v-if="!props.groupId" class="quick-add-section">
<QuickChoreAdd />
</section>
<!-- Loading State -->
<div v-if="isLoading" class="loading-container">
<div class="skeleton-chores">
<div class="skeleton-header"></div>
<div class="skeleton-list">
<div v-for="i in 3" :key="i" class="skeleton-chore-item">
<div class="skeleton-avatar"></div>
<div class="skeleton-content">
<div class="skeleton-line"></div>
<div class="skeleton-line short"></div>
</div>
</div>
</div>
</div>
</div>
<!-- Empty State -->
<div v-else-if="groupedChores.length === 0" class="empty-state">
<div class="empty-illustration">
<span class="material-icons">task_alt</span>
</div>
<h3 class="empty-title">{{ t('choresPage.empty.title', 'No Chores Yet') }}</h3>
<p class="empty-subtitle">{{ t('choresPage.empty.message', 'Get started by adding your first chore!') }}</p>
<Button @click="openCreateChoreModal" class="empty-cta">
<BaseIcon name="heroicons:plus-20-solid" class="w-5 h-5 mr-2" />
{{ t('choresPage.addFirstChore', 'Add First Chore') }}
</Button>
</div>
<!-- Chores List -->
<section v-else class="chores-section">
<div v-for="group in groupedChores" :key="group.date.toISOString()" class="date-group">
<div class="date-header">
<h2 class="date-title">{{ formatDateHeader(group.date) }}</h2>
<span class="chore-count">{{ group.chores.length }}</span>
</div>
<div class="chores-grid">
<ChoreItem v-for="chore in group.chores" :key="chore.id" :chore="chore"
:time-entries="chore.current_assignment_id ? choreStore.timeEntriesByAssignment[chore.current_assignment_id] || [] : []"
:active-timer="activeTimer" @toggle-completion="toggleCompletion" @edit="openEditChoreModal"
@delete="confirmDelete" @open-details="openChoreDetailModal" @open-history="openHistoryModal"
@start-timer="startTimer" @stop-timer="stopTimer" class="chore-card" />
</div>
</div>
</section>
<!-- Simplified Chore Form Modal -->
<Dialog v-model="showChoreModal" class="chore-modal">
<div class="modal-header">
<h3 class="modal-title">
{{ isEditing ? t('choresPage.editChore', 'Edit Chore') : t('choresPage.createChore', 'Create Chore') }}
</h3>
<button type="button" @click="showChoreModal = false" class="modal-close" aria-label="Close">
<BaseIcon name="heroicons:x-mark-20-solid" class="w-5 h-5" />
</button>
</div>
<form @submit.prevent="handleFormSubmit" class="chore-form">
<!-- Essential Fields First -->
<div class="form-section">
<div class="form-field">
<label for="chore-name" class="form-label required">{{ t('choresPage.form.name', 'Name') }}</label>
<input id="chore-name" type="text" v-model="choreForm.name" required class="form-input"
:placeholder="t('choresPage.form.namePlaceholder', 'e.g., Take out trash')" autocomplete="off" />
</div>
<div class="form-field">
<label for="chore-date" class="form-label required">{{ t('choresPage.form.dueDate', 'Due Date') }}</label>
<input id="chore-date" type="date" v-model="choreForm.next_due_date" required class="form-input" />
</div>
</div>
<!-- Progressive Disclosure - Advanced Options -->
<div class="form-section">
<button type="button" class="form-toggle" @click="showAdvancedOptions = !showAdvancedOptions"
:aria-expanded="showAdvancedOptions">
<span>{{ t('choresPage.form.advancedOptions', 'Advanced Options') }}</span>
<BaseIcon :name="showAdvancedOptions ? 'heroicons:chevron-up-20-solid' : 'heroicons:chevron-down-20-solid'"
class="w-5 h-5" />
</button>
<Transition name="expand">
<div v-if="showAdvancedOptions" class="advanced-options">
<!-- Description -->
<div class="form-field">
<label for="chore-desc" class="form-label">{{ t('choresPage.form.description', 'Description') }}</label>
<textarea id="chore-desc" v-model="choreForm.description" class="form-textarea"
:placeholder="t('choresPage.form.descriptionPlaceholder', 'Optional details or instructions')"
rows="3" />
</div>
<!-- Frequency -->
<div class="form-field">
<label class="form-label">{{ t('choresPage.form.frequency', 'Frequency') }}</label>
<Listbox v-model="choreForm.frequency">
<div class="listbox-container">
<ListboxButton class="listbox-button">
<span class="listbox-value">
{{frequencyOptions.find(f => f.value === choreForm.frequency)?.label}}
</span>
<BaseIcon name="heroicons:chevron-up-down-20-solid" class="listbox-icon" />
</ListboxButton>
<TransitionExpand>
<ListboxOptions class="listbox-options">
<ListboxOption v-for="option in frequencyOptions" :key="option.value" :value="option.value"
v-slot="{ active, selected }" class="listbox-option">
<span :class="{ 'font-semibold': selected }">{{ option.label }}</span>
<span v-if="selected" class="listbox-check">
<BaseIcon name="heroicons:check-20-solid" class="w-5 h-5" />
</span>
</ListboxOption>
</ListboxOptions>
</TransitionExpand>
</div>
</Listbox>
</div>
<!-- Custom Interval (only if custom frequency) -->
<Transition name="expand">
<div v-if="choreForm.frequency === 'custom'" class="form-field">
<label for="chore-interval" class="form-label">
{{ t('choresPage.form.interval', 'Interval (days)') }}
</label>
<input id="chore-interval" type="number" v-model.number="choreForm.custom_interval_days" min="1"
class="form-input" placeholder="7" />
</div>
</Transition>
<!-- Type Selector (simplified) -->
<div class="form-field">
<label class="form-label">{{ t('choresPage.form.type', 'Type') }}</label>
<div class="radio-group">
<label class="radio-option">
<input type="radio" v-model="choreForm.type" value="personal" class="radio-input" />
<span class="radio-label">Personal</span>
</label>
<label class="radio-option">
<input type="radio" v-model="choreForm.type" value="group" class="radio-input" />
<span class="radio-label">Group</span>
</label>
</div>
</div>
<!-- Group Selection (only if group type) -->
<Transition name="expand">
<div v-if="choreForm.type === 'group'" class="form-field">
<label class="form-label">{{ t('choresPage.form.assignGroup', 'Group') }}</label>
<Listbox v-model="choreForm.group_id">
<div class="listbox-container">
<ListboxButton class="listbox-button">
<span class="listbox-value">
{{groups.find(g => g.id === choreForm.group_id)?.name || 'Select Group'}}
</span>
<BaseIcon name="heroicons:chevron-up-down-20-solid" class="listbox-icon" />
</ListboxButton>
<TransitionExpand>
<ListboxOptions class="listbox-options">
<ListboxOption v-for="group in groups" :key="group.id" :value="group.id"
v-slot="{ active, selected }" class="listbox-option">
<span :class="{ 'font-semibold': selected }">{{ group.name }}</span>
<span v-if="selected" class="listbox-check">
<BaseIcon name="heroicons:check-20-solid" class="w-5 h-5" />
</span>
</ListboxOption>
</ListboxOptions>
</TransitionExpand>
</div>
</Listbox>
</div>
</Transition>
<!-- Assignment (only for group chores) -->
<Transition name="expand">
<div v-if="choreForm.type === 'group' && choreForm.group_id" class="form-field">
<label class="form-label">{{ t('choresPage.form.assignTo', 'Assign To') }}</label>
<div v-if="loadingChoreFormMembers" class="loading-text">
Loading members...
</div>
<div v-else>
<Listbox v-model="choreForm.assigned_to_user_id">
<div class="listbox-container">
<ListboxButton class="listbox-button">
<span class="listbox-value">
{{choreForm.assigned_to_user_id
? (choreFormGroupMembers.find(m => m.id === choreForm.assigned_to_user_id)?.name ||
'Unknown')
: "Anyone can take this"
}}
</span>
<BaseIcon name="heroicons:chevron-up-down-20-solid" class="listbox-icon" />
</ListboxButton>
<TransitionExpand>
<ListboxOptions class="listbox-options">
<ListboxOption :value="null" v-slot="{ active, selected }" class="listbox-option">
<span :class="{ 'font-semibold': selected }">Anyone can take this</span>
<span v-if="selected" class="listbox-check">
<BaseIcon name="heroicons:check-20-solid" class="w-5 h-5" />
</span>
</ListboxOption>
<ListboxOption v-for="member in choreFormGroupMembers" :key="member.id" :value="member.id"
v-slot="{ active, selected }" class="listbox-option">
<span :class="{ 'font-semibold': selected }">{{ member.name || member.email }}</span>
<span v-if="selected" class="listbox-check">
<BaseIcon name="heroicons:check-20-solid" class="w-5 h-5" />
</span>
</ListboxOption>
</ListboxOptions>
</TransitionExpand>
</div>
</Listbox>
</div>
</div>
</Transition>
<!-- Parent Chore (Subtask functionality) -->
<div v-if="availableParentChores.length > 0" class="form-field">
<label class="form-label">{{ t('choresPage.form.parentChore', 'Make this a subtask of') }}</label>
<Listbox v-model="choreForm.parent_chore_id">
<div class="listbox-container">
<ListboxButton class="listbox-button">
<span class="listbox-value">
{{choreForm.parent_chore_id
? (availableParentChores.find(p => p.id === choreForm.parent_chore_id)?.name)
: 'None - This is a main task'
}}
</span>
<BaseIcon name="heroicons:chevron-up-down-20-solid" class="listbox-icon" />
</ListboxButton>
<TransitionExpand>
<ListboxOptions class="listbox-options">
<ListboxOption :value="null" v-slot="{ active, selected }" class="listbox-option">
<span :class="{ 'font-semibold': selected }">None - This is a main task</span>
<span v-if="selected" class="listbox-check">
<BaseIcon name="heroicons:check-20-solid" class="w-5 h-5" />
</span>
</ListboxOption>
<ListboxOption v-for="parent in availableParentChores" :key="parent.id" :value="parent.id"
v-slot="{ active, selected }" class="listbox-option">
<span :class="{ 'font-semibold': selected }">{{ parent.name }}</span>
<span v-if="selected" class="listbox-check">
<BaseIcon name="heroicons:check-20-solid" class="w-5 h-5" />
</span>
</ListboxOption>
</ListboxOptions>
</TransitionExpand>
</div>
</Listbox>
</div>
</div>
</Transition>
</div>
<!-- Form Actions -->
<div class="form-actions">
<Button variant="ghost" color="neutral" @click="showChoreModal = false">
{{ t('choresPage.form.cancel', 'Cancel') }}
</Button>
<Button type="submit" :disabled="!choreForm.name.trim()">
{{ isEditing ? t('choresPage.form.save', 'Save Changes') : t('choresPage.form.create', 'Create Chore') }}
</Button>
</div>
</form>
</Dialog>
<!-- Simplified Delete Confirmation -->
<Dialog v-model="showDeleteDialog" class="delete-modal">
<div class="delete-content">
<div class="delete-icon">
<span class="material-icons">warning</span>
</div>
<h3 class="delete-title">{{ t('choresPage.deleteConfirm.title', 'Delete Chore?') }}</h3>
<p class="delete-message">
{{ t('choresPage.deleteConfirm.message', 'This action cannot be undone.') }}
</p>
<div class="delete-actions">
<Button variant="ghost" color="neutral" @click="showDeleteDialog = false">
{{ t('choresPage.deleteConfirm.cancel', 'Cancel') }}
</Button>
<Button color="error" @click="deleteChore">
{{ t('choresPage.deleteConfirm.delete', 'Delete') }}
</Button>
</div>
</div>
</Dialog>
<!-- Chore Detail Sheet -->
<ChoreDetailSheet v-model="showChoreDetailModal" :chore="selectedChore" />
<!-- History Modal (Simplified) -->
<Dialog v-model="showHistoryModal" class="history-modal">
<div class="modal-header">
<h3 class="modal-title">History: {{ selectedChore?.name }}</h3>
<button type="button" @click="showHistoryModal = false" class="modal-close" aria-label="Close">
<BaseIcon name="heroicons:x-mark-20-solid" class="w-5 h-5" />
</button>
</div>
<div class="history-content">
<div v-if="loadingHistory" class="loading-text">Loading history...</div>
<div v-else-if="!selectedChoreHistory.length" class="empty-history">
No history found for this chore.
</div>
<div v-else class="history-list">
<div v-for="entry in selectedChoreHistory" :key="entry.id" class="history-item">
<div class="history-main">{{ formatHistoryEntry(entry) }}</div>
<div class="history-time">{{ format(new Date(entry.timestamp), 'PPpp') }}</div>
</div>
</div>
</div>
</Dialog>
</div>
</template>
<style scoped lang="scss">
@import url('https://fonts.googleapis.com/icon?family=Material+Icons');
.chores-page {
min-height: 100vh;
background: linear-gradient(135deg, #f8f9fa 0%, #e9ecef 100%);
padding: 1rem;
@media (min-width: 768px) {
padding: 1.5rem 2rem;
}
}
/* Guest Banner */
.guest-banner {
background: linear-gradient(135deg, #fef3c7, #fcd34d);
border: 1px solid #f59e0b;
border-radius: 12px;
padding: 1rem;
margin-bottom: 1.5rem;
box-shadow: 0 2px 8px rgba(245, 158, 11, 0.1);
}
.guest-banner-content {
display: flex;
align-items: center;
gap: 0.75rem;
.material-icons {
color: #d97706;
font-size: 20px;
}
p {
margin: 0;
font-size: 0.9rem;
color: #92400e;
}
}
.guest-link {
color: #d97706;
font-weight: 600;
text-decoration: underline;
&:hover {
color: #92400e;
}
}
/* Page Header */
.page-header {
margin-bottom: 2rem;
}
.header-content {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
flex-wrap: wrap;
}
.page-title {
font-size: 2rem;
font-weight: 700;
color: #1f2937;
margin: 0;
@media (min-width: 768px) {
font-size: 2.5rem;
}
}
.add-chore-btn {
display: flex;
align-items: center;
gap: 0.5rem;
transition: all 0.2s ease;
&.btn-pulse {
animation: pulse 2s infinite;
}
.btn-text {
@media (max-width: 640px) {
display: none;
}
}
}
@keyframes pulse {
0%,
100% {
transform: scale(1);
}
50% {
transform: scale(1.05);
}
}
/* Quick Add Section */
.quick-add-section {
margin-bottom: 2rem;
}
/* Loading States */
.loading-container {
padding: 2rem 0;
}
.skeleton-chores {
display: flex;
flex-direction: column;
gap: 1.5rem;
}
.skeleton-header {
height: 2rem;
width: 200px;
background: linear-gradient(90deg, #f0f0f0 25%, #e0e0e0 50%, #f0f0f0 75%);
background-size: 200% 100%;
animation: skeleton-shimmer 1.5s infinite;
border-radius: 8px;
}
.skeleton-list {
display: flex;
flex-direction: column;
gap: 1rem;
}
.skeleton-chore-item {
display: flex;
align-items: center;
gap: 1rem;
padding: 1rem;
background: white;
border-radius: 12px;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
}
.skeleton-avatar {
width: 48px;
height: 48px;
border-radius: 50%;
background: linear-gradient(90deg, #f0f0f0 25%, #e0e0e0 50%, #f0f0f0 75%);
background-size: 200% 100%;
animation: skeleton-shimmer 1.5s infinite;
flex-shrink: 0;
}
.skeleton-content {
flex-grow: 1;
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.skeleton-line {
height: 1rem;
background: linear-gradient(90deg, #f0f0f0 25%, #e0e0e0 50%, #f0f0f0 75%);
background-size: 200% 100%;
animation: skeleton-shimmer 1.5s infinite;
border-radius: 4px;
&.short {
width: 60%;
}
}
@keyframes skeleton-shimmer {
0% {
background-position: 200% 0;
}
100% {
background-position: -200% 0;
}
}
/* Empty State */
.empty-state {
text-align: center;
padding: 4rem 2rem;
max-width: 400px;
margin: 0 auto;
}
.empty-illustration {
margin-bottom: 1.5rem;
.material-icons {
font-size: 4rem;
color: #d1d5db;
}
}
.empty-title {
font-size: 1.5rem;
font-weight: 600;
color: #374151;
margin: 0 0 0.5rem 0;
}
.empty-subtitle {
color: #6b7280;
margin: 0 0 2rem 0;
line-height: 1.5;
}
.empty-cta {
margin: 0 auto;
}
/* Chores Section */
.chores-section {
display: flex;
flex-direction: column;
gap: 2rem;
}
.date-group {
background: white;
border-radius: 16px;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
overflow: hidden;
}
.date-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 1rem 1.5rem;
background: linear-gradient(135deg, #f8f9fa, #e9ecef);
border-bottom: 1px solid #e5e7eb;
}
.date-title {
font-size: 1.125rem;
font-weight: 600;
color: #374151;
margin: 0;
}
.chore-count {
background: #3b82f6;
color: white;
font-size: 0.75rem;
font-weight: 600;
padding: 0.25rem 0.5rem;
border-radius: 12px;
min-width: 1.5rem;
text-align: center;
}
.chores-grid {
padding: 1rem;
display: grid;
gap: 0.75rem;
@media (min-width: 768px) {
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
padding: 1.5rem;
gap: 1rem;
}
}
.chore-card {
transition: all 0.2s ease;
&:hover {
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
}
}
/* Modal Styles */
.chore-modal,
.delete-modal,
.history-modal {
.modal-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 1.5rem;
padding-bottom: 1rem;
border-bottom: 1px solid #e5e7eb;
}
.modal-title {
font-size: 1.25rem;
font-weight: 600;
color: #111827;
margin: 0;
}
.modal-close {
display: flex;
align-items: center;
justify-content: center;
width: 2rem;
height: 2rem;
border: none;
background: none;
color: #6b7280;
border-radius: 6px;
cursor: pointer;
transition: all 0.2s ease;
&:hover {
background: #f3f4f6;
color: #374151;
}
}
}
/* Form Styles */
.chore-form {
display: flex;
flex-direction: column;
gap: 1.5rem;
}
.form-section {
display: flex;
flex-direction: column;
gap: 1rem;
}
.form-field {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.form-label {
font-size: 0.9rem;
font-weight: 500;
color: #374151;
&.required::after {
content: ' *';
color: #ef4444;
}
}
.form-input,
.form-textarea {
padding: 0.75rem;
border: 1px solid #d1d5db;
border-radius: 8px;
font-size: 0.9rem;
transition: all 0.2s ease;
background: white;
&:focus {
outline: none;
border-color: #3b82f6;
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1);
}
&::placeholder {
color: #9ca3af;
}
}
.form-textarea {
resize: vertical;
min-height: 80px;
}
/* Progressive Disclosure Toggle */
.form-toggle {
display: flex;
align-items: center;
justify-content: space-between;
width: 100%;
padding: 0.75rem 1rem;
background: #f8f9fa;
border: 1px solid #e5e7eb;
border-radius: 8px;
cursor: pointer;
transition: all 0.2s ease;
font-size: 0.9rem;
font-weight: 500;
color: #374151;
&:hover {
background: #f3f4f6;
border-color: #d1d5db;
}
&[aria-expanded="true"] {
background: #eff6ff;
border-color: #3b82f6;
color: #1d4ed8;
}
}
.advanced-options {
padding-top: 1rem;
display: flex;
flex-direction: column;
gap: 1rem;
}
/* Radio Group */
.radio-group {
display: flex;
gap: 1rem;
flex-wrap: wrap;
}
.radio-option {
display: flex;
align-items: center;
gap: 0.5rem;
cursor: pointer;
}
.radio-input {
width: 1rem;
height: 1rem;
accent-color: #3b82f6;
}
.radio-label {
font-size: 0.9rem;
color: #374151;
}
/* Listbox Styles */
.listbox-container {
position: relative;
}
.listbox-button {
display: flex;
align-items: center;
justify-content: space-between;
width: 100%;
padding: 0.75rem;
background: white;
border: 1px solid #d1d5db;
border-radius: 8px;
cursor: pointer;
transition: all 0.2s ease;
&:hover {
border-color: #9ca3af;
}
&:focus {
outline: none;
border-color: #3b82f6;
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1);
}
}
.listbox-value {
font-size: 0.9rem;
color: #374151;
text-align: left;
}
.listbox-icon {
width: 1.25rem;
height: 1.25rem;
color: #6b7280;
flex-shrink: 0;
}
.listbox-options {
position: absolute;
top: calc(100% + 4px);
left: 0;
right: 0;
background: white;
border: 1px solid #d1d5db;
border-radius: 8px;
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1);
z-index: 50;
max-height: 200px;
overflow-y: auto;
}
.listbox-option {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.75rem;
cursor: pointer;
transition: background-color 0.2s ease;
font-size: 0.9rem;
&:hover {
background: #f8f9fa;
}
&:first-child {
border-top-left-radius: 8px;
border-top-right-radius: 8px;
}
&:last-child {
border-bottom-left-radius: 8px;
border-bottom-right-radius: 8px;
}
}
.listbox-check {
color: #3b82f6;
width: 1.25rem;
height: 1.25rem;
}
.loading-text {
color: #6b7280;
font-size: 0.9rem;
text-align: center;
padding: 1rem;
}
/* Form Actions */
.form-actions {
display: flex;
justify-content: flex-end;
gap: 0.75rem;
padding-top: 1rem;
border-top: 1px solid #e5e7eb;
@media (max-width: 640px) {
flex-direction: column-reverse;
button {
width: 100%;
justify-content: center;
}
}
}
/* Delete Modal */
.delete-content {
text-align: center;
padding: 1rem 0;
}
.delete-icon {
margin-bottom: 1rem;
.material-icons {
font-size: 3rem;
color: #ef4444;
}
}
.delete-title {
font-size: 1.25rem;
font-weight: 600;
color: #111827;
margin: 0 0 0.5rem 0;
}
.delete-message {
color: #6b7280;
margin: 0 0 2rem 0;
}
.delete-actions {
display: flex;
justify-content: center;
gap: 0.75rem;
@media (max-width: 640px) {
flex-direction: column-reverse;
button {
width: 100%;
justify-content: center;
}
}
}
/* History Modal */
.history-content {
max-height: 60vh;
overflow-y: auto;
}
.empty-history {
text-align: center;
color: #6b7280;
padding: 2rem;
}
.history-list {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.history-item {
padding: 0.75rem;
background: #f8f9fa;
border-radius: 8px;
border-left: 3px solid #3b82f6;
}
.history-main {
font-size: 0.9rem;
color: #374151;
margin-bottom: 0.25rem;
}
.history-time {
font-size: 0.75rem;
color: #6b7280;
}
/* Transitions */
.slide-down-enter-active,
.slide-down-leave-active {
transition: all 0.3s ease;
}
.slide-down-enter-from,
.slide-down-leave-to {
opacity: 0;
transform: translateY(-10px);
}
.expand-enter-active,
.expand-leave-active {
transition: all 0.3s ease;
overflow: hidden;
}
.expand-enter-from,
.expand-leave-to {
opacity: 0;
max-height: 0;
padding-top: 0;
}
.expand-enter-to,
.expand-leave-from {
opacity: 1;
max-height: 500px;
padding-top: 1rem;
}
/* Dark mode support */
@media (prefers-color-scheme: dark) {
.chores-page {
background: linear-gradient(135deg, #1f2937 0%, #111827 100%);
}
.date-group,
.form-input,
.form-textarea,
.listbox-button,
.listbox-options {
background: #374151;
border-color: #4b5563;
color: #f9fafb;
}
.page-title,
.date-title,
.modal-title,
.form-label {
color: #f9fafb;
}
.skeleton-header,
.skeleton-line,
.skeleton-avatar {
background: linear-gradient(90deg, #374151 25%, #4b5563 50%, #374151 75%);
background-size: 200% 100%;
}
}
/* Accessibility */
@media (prefers-reduced-motion: reduce) {
* {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
}
}
</style>