mitlist/fe/src/pages/ChoresPage.vue
mohamad f49e15c05c
Some checks failed
Deploy to Production, build images and push to Gitea Registry / build_and_push (pull_request) Failing after 1m24s
feat: Introduce FastAPI and Vue.js guidelines, enhance API structure, and add caching support
This commit adds new guidelines for FastAPI and Vue.js development, emphasizing best practices for component structure, API performance, and data handling. It also introduces caching mechanisms using Redis for improved performance and updates the API structure to streamline authentication and user management. Additionally, new endpoints for categories and time entries are implemented, enhancing the overall functionality of the application.
2025-06-09 21:02:51 +02:00

1191 lines
37 KiB
Vue

<script setup lang="ts">
import { ref, onMounted, computed } 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, ChoreAssignmentUpdate, ChoreAssignment, ChoreHistory, ChoreWithCompletion } from '../types/chore'
import { groupService } from '../services/groupService'
import { useStorage } from '@vueuse/core'
import ChoreItem from '@/components/ChoreItem.vue';
import { useTimeEntryStore, type TimeEntry } from '../stores/timeEntryStore';
import { storeToRefs } from 'pinia';
import { useAuthStore } from '@/stores/auth';
const { t } = useI18n()
const props = defineProps<{ groupId?: number | string }>();
// 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;
}
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 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,
}
const choreForm = ref({ ...initialChoreFormState })
const isLoading = ref(true)
const authStore = useAuthStore();
const { isGuest } = storeToRefs(authStore);
const timeEntryStore = useTimeEntryStore();
const { timeEntries, loading: timeEntryLoading, error: timeEntryError } = storeToRefs(timeEntryStore);
const activeTimer = computed(() => {
for (const assignmentId in timeEntries.value) {
const entry = timeEntries.value[assignmentId].find(te => !te.end_time);
if (entry) return entry;
}
return null;
});
const loadChores = async () => {
const now = Date.now();
if (cachedChores.value && cachedChores.value.length > 0 && (now - cachedTimestamp.value) < CACHE_DURATION) {
chores.value = cachedChores.value;
isLoading.value = false;
} else {
isLoading.value = true;
}
try {
const fetchedChores = await choreService.getAllChores()
const mappedChores = fetchedChores.map(c => {
const currentAssignment = c.assignments && c.assignments.length > 0 ? c.assignments[0] : null;
return {
...c,
current_assignment_id: currentAssignment?.id ?? null,
is_completed: currentAssignment?.is_complete ?? false,
completed_at: currentAssignment?.completed_at ?? null,
assigned_user_name: currentAssignment?.assigned_user?.name || currentAssignment?.assigned_user?.email || 'Unknown',
completed_by_name: currentAssignment?.assigned_user?.name || currentAssignment?.assigned_user?.email || 'Unknown',
updating: false,
}
});
chores.value = mappedChores;
cachedChores.value = mappedChores;
cachedTimestamp.value = Date.now()
} catch (error) {
console.error(t('choresPage.consoleErrors.loadFailed'), error)
notificationStore.addNotification({ message: t('choresPage.notifications.loadFailed', 'Failed to load chores.'), type: 'error' })
} finally {
isLoading.value = false
}
}
const loadGroups = async () => {
try {
groups.value = await groupService.getUserGroups();
} catch (error) {
console.error(t('choresPage.consoleErrors.loadGroupsFailed'), error);
notificationStore.addNotification({ message: t('choresPage.notifications.loadGroupsFailed', 'Failed to load groups.'), type: 'error' });
}
}
const loadTimeEntries = async () => {
chores.value.forEach(chore => {
if (chore.current_assignment_id) {
timeEntryStore.fetchTimeEntriesForAssignment(chore.current_assignment_id);
}
});
};
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
}
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,
}
showChoreModal.value = true
}
const handleFormSubmit = async () => {
try {
let createdChore;
if (isEditing.value && selectedChore.value) {
const updateData: ChoreUpdate = { ...choreForm.value };
createdChore = await choreService.updateChore(selectedChore.value.id, updateData);
notificationStore.addNotification({ message: t('choresPage.notifications.updateSuccess', 'Chore updated successfully!'), type: 'success' });
} else {
const createData = { ...choreForm.value };
createdChore = await choreService.createChore(createData as ChoreCreate);
// Create an assignment for the new chore
if (createdChore) {
try {
await choreService.createAssignment({
chore_id: createdChore.id,
assigned_to_user_id: createdChore.created_by_id,
due_date: createdChore.next_due_date
});
} catch (assignmentError) {
console.error(t('choresPage.consoleErrors.createAssignmentForNewChoreFailed'), assignmentError);
// Continue anyway since the chore was created
}
}
notificationStore.addNotification({ message: t('choresPage.notifications.createSuccess', 'Chore created successfully!'), type: 'success' });
}
showChoreModal.value = false;
await loadChores();
} catch (error) {
console.error(t('choresPage.consoleErrors.saveFailed'), error);
notificationStore.addNotification({ message: t('choresPage.notifications.saveFailed', 'Failed to save the chore.'), type: 'error' });
}
}
const confirmDelete = (chore: ChoreWithCompletion) => {
selectedChore.value = chore
showDeleteDialog.value = true
}
const deleteChore = async () => {
if (!selectedChore.value) return
try {
await choreService.deleteChore(selectedChore.value.id, selectedChore.value.type, selectedChore.value.group_id ?? undefined)
notificationStore.addNotification({ message: t('choresPage.notifications.deleteSuccess', 'Chore deleted successfully.'), type: 'success' })
showDeleteDialog.value = false
await loadChores()
} catch (error) {
console.error(t('choresPage.consoleErrors.deleteFailed'), error)
notificationStore.addNotification({ message: t('choresPage.notifications.deleteFailed', 'Failed to delete chore.'), type: 'error' })
}
}
const toggleCompletion = async (chore: ChoreWithCompletion) => {
if (chore.current_assignment_id === null) {
// If no assignment exists, create one
try {
const assignment = await choreService.createAssignment({
chore_id: chore.id,
assigned_to_user_id: chore.created_by_id,
due_date: chore.next_due_date
});
chore.current_assignment_id = assignment.id;
} catch (error) {
console.error(t('choresPage.consoleErrors.createAssignmentFailed'), error);
notificationStore.addNotification({
message: t('choresPage.notifications.createAssignmentFailed', 'Failed to create assignment for chore.'),
type: 'error'
});
return;
}
}
const originalCompleted = chore.is_completed;
chore.updating = true;
const newCompletedStatus = !chore.is_completed;
chore.is_completed = newCompletedStatus;
try {
if (newCompletedStatus) {
await choreService.completeAssignment(chore.current_assignment_id);
} else {
const assignmentUpdate: ChoreAssignmentUpdate = { is_complete: false };
await choreService.updateAssignment(chore.current_assignment_id, assignmentUpdate);
}
notificationStore.addNotification({
message: newCompletedStatus ? t('choresPage.notifications.completed', 'Chore marked as complete!') : t('choresPage.notifications.uncompleted', 'Chore marked as incomplete.'),
type: 'success'
});
await loadChores();
} catch (error) {
console.error(t('choresPage.consoleErrors.updateCompletionStatusFailed'), error);
notificationStore.addNotification({ message: t('choresPage.notifications.updateFailed', 'Failed to update chore status.'), type: 'error' });
chore.is_completed = originalCompleted;
} finally {
chore.updating = false;
}
};
const openChoreDetailModal = async (chore: ChoreWithCompletion) => {
selectedChore.value = chore;
showChoreDetailModal.value = true;
// 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;
}
};
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.current_assignment_id) {
await timeEntryStore.startTimeEntry(chore.current_assignment_id);
}
};
const stopTimer = async (chore: ChoreWithCompletion, timeEntryId: number) => {
if (chore.current_assignment_id) {
await timeEntryStore.stopTimeEntry(chore.current_assignment_id, timeEntryId);
}
};
</script>
<template>
<div class="container">
<div v-if="isGuest" class="guest-banner">
<p>
You are using a guest account.
<router-link to="/auth/signup">Sign up</router-link>
to save your data permanently.
</p>
</div>
<header v-if="!props.groupId" class="flex justify-between items-center">
<h1 style="margin-block-start: 0;">{{ t('choresPage.title') }}</h1>
<button class="btn btn-primary" @click="openCreateChoreModal">
{{ t('choresPage.addChore', '+') }}
</button>
</header>
<div v-if="isLoading" class="flex justify-center mt-4">
<div class="spinner-dots">
<span></span>
<span></span>
<span></span>
</div>
</div>
<div v-else-if="groupedChores.length === 0" class="empty-state-card">
<h3>{{ t('choresPage.empty.title', 'No Chores Yet') }}</h3>
<p>{{ t('choresPage.empty.message', 'Get started by adding your first chore!') }}</p>
<button class="btn btn-primary" @click="openCreateChoreModal">
{{ t('choresPage.addFirstChore', 'Add First Chore') }}
</button>
</div>
<div v-else class="schedule-list">
<div v-for="group in groupedChores" :key="group.date.toISOString()" class="schedule-group">
<h2 class="date-header">{{ formatDateHeader(group.date) }}</h2>
<div class="neo-item-list-container">
<ul class="neo-item-list">
<ChoreItem v-for="chore in group.chores" :key="chore.id" :chore="chore"
:time-entries="chore.current_assignment_id ? timeEntries[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" />
</ul>
</div>
</div>
</div>
<!-- Chore Form Modal -->
<div v-if="showChoreModal" class="modal-backdrop open" @click.self="showChoreModal = false">
<div class="modal-container">
<form @submit.prevent="handleFormSubmit">
<div class="modal-header">
<h3>{{ isEditing ? t('choresPage.editChore', 'Edit Chore') : t('choresPage.createChore', 'Create Chore') }}
</h3>
<button type="button" @click="showChoreModal = false" class="close-button">
&times;
</button>
</div>
<div class="modal-body">
<div class="form-group">
<label class="form-label" for="chore-name">{{ t('choresPage.form.name', 'Name') }}</label>
<input id="chore-name" type="text" v-model="choreForm.name" class="form-input" required>
</div>
<div class="form-group">
<label class="form-label" for="chore-desc">{{ t('choresPage.form.description', 'Description') }}</label>
<textarea id="chore-desc" v-model="choreForm.description" class="form-input"></textarea>
</div>
<div class="form-group">
<label class="form-label" for="chore-date">{{ t('choresPage.form.dueDate', 'Due Date') }}</label>
<input id="chore-date" type="date" v-model="choreForm.next_due_date" class="form-input" required>
</div>
<div class="form-group">
<label class="form-label">{{ t('choresPage.form.frequency', 'Frequency') }}</label>
<div class="radio-group">
<label v-for="option in frequencyOptions" :key="option.value" class="radio-label">
<input type="radio" v-model="choreForm.frequency" :value="option.value">
<span class="checkmark radio-mark"></span>
<span>{{ option.label }}</span>
</label>
</div>
</div>
<div v-if="choreForm.frequency === 'custom'" class="form-group">
<label class="form-label" for="chore-interval">{{ t('choresPage.form.interval', 'Interval (days)')
}}</label>
<input id="chore-interval" type="number" v-model.number="choreForm.custom_interval_days"
class="form-input" :placeholder="t('choresPage.form.intervalPlaceholder')" min="1">
</div>
<div class="form-group">
<label class="form-label">{{ t('choresPage.form.type', 'Type') }}</label>
<div class="radio-group">
<label class="radio-label">
<input type="radio" v-model="choreForm.type" value="personal">
<span class="checkmark radio-mark"></span>
<span>{{ t('choresPage.form.personal', 'Personal') }}</span>
</label>
<label class="radio-label">
<input type="radio" v-model="choreForm.type" value="group">
<span class="checkmark radio-mark"></span>
<span>{{ t('choresPage.form.group', 'Group') }}</span>
</label>
</div>
</div>
<div v-if="choreForm.type === 'group'" class="form-group">
<label class="form-label" for="chore-group">{{ t('choresPage.form.assignGroup', 'Assign to Group')
}}</label>
<select id="chore-group" v-model="choreForm.group_id" class="form-input">
<option v-for="group in groups" :key="group.id" :value="group.id">{{ group.name }}</option>
</select>
</div>
<div class="form-group">
<label class="form-label" for="parent-chore">{{ t('choresPage.form.parentChore', 'Parent Chore')
}}</label>
<select id="parent-chore" v-model="choreForm.parent_chore_id" class="form-input">
<option :value="null">None</option>
<option v-for="parent in availableParentChores" :key="parent.id" :value="parent.id">
{{ parent.name }}
</option>
</select>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-neutral" @click="showChoreModal = false">{{
t('choresPage.form.cancel', 'Cancel')
}}</button>
<button type="submit" class="btn btn-primary">{{ isEditing ? t('choresPage.form.save', 'Save Changes') :
t('choresPage.form.create', 'Create') }}</button>
</div>
</form>
</div>
</div>
<!-- Delete Confirmation Dialog -->
<div v-if="showDeleteDialog" class="modal-backdrop open" @click.self="showDeleteDialog = false">
<div class="modal-container confirm-modal">
<div class="modal-header">
<h3>{{ t('choresPage.deleteConfirm.title', 'Confirm Deletion') }}</h3>
<button type="button" @click="showDeleteDialog = false" class="close-button">
&times;
</button>
</div>
<div class="modal-body">
<p>{{ t('choresPage.deleteConfirm.message', 'Really want to delete? This action cannot be undone.') }}</p>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-neutral" @click="showDeleteDialog = false">{{
t('choresPage.deleteConfirm.cancel', 'Cancel') }}</button>
<button type="button" class="btn btn-danger" @click="deleteChore">{{
t('choresPage.deleteConfirm.delete', 'Delete')
}}</button>
</div>
</div>
</div>
<!-- Chore Detail Modal -->
<div v-if="showChoreDetailModal" class="modal-backdrop open" @click.self="showChoreDetailModal = false">
<div class="modal-container detail-modal">
<div class="modal-header">
<h3>{{ selectedChore?.name }}</h3>
<button type="button" @click="showChoreDetailModal = false" class="close-button">
&times;
</button>
</div>
<div class="modal-body" v-if="selectedChore">
<div class="detail-section">
<h4>Details</h4>
<div class="detail-grid">
<div class="detail-item">
<span class="label">Type:</span>
<span class="value">{{ selectedChore.type === 'group' ? 'Group' : 'Personal' }}</span>
</div>
<div class="detail-item">
<span class="label">Created by:</span>
<span class="value">{{ selectedChore.creator?.name || selectedChore.creator?.email || 'Unknown'
}}</span>
</div>
<div class="detail-item">
<span class="label">Due date:</span>
<span class="value">{{ format(new Date(selectedChore.next_due_date), 'PPP') }}</span>
</div>
<div class="detail-item">
<span class="label">Frequency:</span>
<span class="value">
{{selectedChore?.frequency === 'custom' && selectedChore?.custom_interval_days
? `Every ${selectedChore.custom_interval_days} days`
: frequencyOptions.find(f => f.value === selectedChore?.frequency)?.label || selectedChore?.frequency
}}
</span>
</div>
<div v-if="selectedChore.description" class="detail-item full-width">
<span class="label">Description:</span>
<span class="value">{{ selectedChore.description }}</span>
</div>
</div>
</div>
<div class="detail-section">
<h4>Assignments</h4>
<div v-if="loadingAssignments" class="loading-spinner">Loading...</div>
<div v-else-if="selectedChoreAssignments.length === 0" class="no-data">
No assignments found for this chore.
</div>
<div v-else class="assignments-list">
<div v-for="assignment in selectedChoreAssignments" :key="assignment.id" class="assignment-item">
<div class="assignment-main">
<span class="assigned-user">{{ assignment.assigned_user?.name || assignment.assigned_user?.email
}}</span>
<span class="assignment-status" :class="{ completed: assignment.is_complete }">
{{ assignment.is_complete ? '✅ Completed' : '⏳ Pending' }}
</span>
</div>
<div class="assignment-details">
<span>Due: {{ format(new Date(assignment.due_date), 'PPP') }}</span>
<span v-if="assignment.completed_at">
Completed: {{ format(new Date(assignment.completed_at), 'PPP') }}
</span>
</div>
</div>
</div>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-neutral" @click="showChoreDetailModal = false">Close</button>
</div>
</div>
</div>
<!-- History Modal -->
<div v-if="showHistoryModal" class="modal-backdrop open" @click.self="showHistoryModal = false">
<div class="modal-container history-modal">
<div class="modal-header">
<h3>History: {{ selectedChore?.name }}</h3>
<button type="button" @click="showHistoryModal = false" class="close-button">
&times;
</button>
</div>
<div class="modal-body">
<div v-if="loadingHistory" class="loading-spinner">Loading history...</div>
<div v-else-if="selectedChoreHistory.length === 0" class="no-data">
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-content">
<span class="history-text">{{ formatHistoryEntry(entry) }}</span>
<div v-if="entry.event_data && Object.keys(entry.event_data).length > 0" class="history-data">
<details>
<summary>Details</summary>
<pre>{{ JSON.stringify(entry.event_data, null, 2) }}</pre>
</details>
</div>
</div>
</div>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-neutral" @click="showHistoryModal = false">Close</button>
</div>
</div>
</div>
</div>
</template>
<style scoped lang="scss">
.guest-banner {
background-color: #fffbeb;
color: #92400e;
padding: 1rem;
border-radius: 0.5rem;
margin-bottom: 1.5rem;
border: 1px solid #fBBF24;
text-align: center;
}
.guest-banner p {
margin: 0;
}
.guest-banner a {
color: #92400e;
text-decoration: underline;
font-weight: bold;
}
.schedule-group {
margin-bottom: 2rem;
position: relative;
}
.date-header {
font-size: clamp(1rem, 4vw, 1.2rem);
font-weight: bold;
color: var(--dark);
text-transform: none;
letter-spacing: normal;
margin-bottom: 1rem;
padding-bottom: 0.5rem;
border-bottom: 2px solid var(--dark);
position: sticky;
top: 0;
background-color: var(--light);
z-index: 10;
&::after {
display: none; // Hides the default h-tag underline from valerie-ui
}
}
.item-time {
font-size: 0.9rem;
color: var(--dark);
opacity: 0.7;
margin-left: 1rem;
}
.neo-item-list-container {
border: 3px solid #111;
border-radius: 18px;
background: var(--light);
box-shadow: 6px 6px 0 #111;
overflow: hidden;
}
/* Status-based styling */
.schedule-group:has(.status-overdue) .neo-item-list-container {
box-shadow: 6px 6px 0 #c72d2d;
}
.schedule-group:has(.status-due-today) .neo-item-list-container {
box-shadow: 6px 6px 0 #b37814;
}
.status-completed {
opacity: 0.7;
}
/* Neo-style list items from ListDetailPage */
.neo-item-list {
list-style: none;
padding: 0.5rem 1rem;
margin: 0;
}
.neo-list-item {
display: flex;
align-items: center;
padding: 0.75rem 0;
border-bottom: 1px solid rgba(0, 0, 0, 0.08);
position: relative;
transition: background-color 0.2s ease;
}
.neo-list-item:hover {
background-color: #f8f8f8;
}
.neo-list-item:last-child {
border-bottom: none;
}
.neo-item-content {
display: flex;
justify-content: space-between;
align-items: center;
width: 100%;
gap: 0.5rem;
}
.neo-item-actions {
display: flex;
gap: 0.25rem;
opacity: 0;
transition: opacity 0.2s ease;
margin-left: auto;
.btn {
margin-left: 0.25rem;
}
}
.neo-list-item:hover .neo-item-actions {
opacity: 1;
}
/* Custom Checkbox Styles from ListDetailPage */
.neo-checkbox-label {
display: grid;
grid-template-columns: auto 1fr;
align-items: center;
gap: 0.8em;
cursor: pointer;
position: relative;
width: 100%;
font-weight: 500;
color: #414856;
transition: color 0.3s ease;
margin-bottom: 0;
}
.neo-checkbox-label input[type="checkbox"] {
appearance: none;
-webkit-appearance: none;
-moz-appearance: none;
position: relative;
height: 20px;
width: 20px;
outline: none;
border: 2px solid #b8c1d1;
margin: 0;
cursor: pointer;
background: transparent;
border-radius: 6px;
display: grid;
align-items: center;
justify-content: center;
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
}
.neo-checkbox-label input[type="checkbox"]:hover {
border-color: var(--secondary);
transform: scale(1.05);
}
.neo-checkbox-label input[type="checkbox"]::before,
.neo-checkbox-label input[type="checkbox"]::after {
content: none;
}
.neo-checkbox-label input[type="checkbox"]::after {
content: "";
position: absolute;
opacity: 0;
left: 5px;
top: 1px;
width: 6px;
height: 12px;
border: solid var(--primary);
border-width: 0 3px 3px 0;
transform: rotate(45deg) scale(0);
transition: all 0.2s cubic-bezier(0.18, 0.89, 0.32, 1.28);
transition-property: transform, opacity;
}
.neo-checkbox-label input[type="checkbox"]:checked {
border-color: var(--primary);
}
.neo-checkbox-label input[type="checkbox"]:checked::after {
opacity: 1;
transform: rotate(45deg) scale(1);
}
.checkbox-content {
display: flex;
align-items: center;
gap: 0.5rem;
width: 100%;
}
.checkbox-text-span {
position: relative;
transition: color 0.4s ease, opacity 0.4s ease;
width: fit-content;
font-weight: 500;
color: var(--dark);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
/* Animated strikethrough line */
.checkbox-text-span::before {
content: '';
position: absolute;
top: 50%;
left: -0.1em;
right: -0.1em;
height: 2px;
background: var(--dark);
transform: scaleX(0);
transform-origin: right;
transition: transform 0.4s cubic-bezier(0.77, 0, .18, 1);
}
.neo-checkbox-label input[type="checkbox"]:checked~.checkbox-content .checkbox-text-span {
color: var(--dark);
opacity: 0.6;
}
.neo-checkbox-label input[type="checkbox"]:checked~.checkbox-content .checkbox-text-span::before {
transform: scaleX(1);
transform-origin: left;
transition: transform 0.4s cubic-bezier(0.77, 0, .18, 1) 0.1s;
}
.neo-completed-static {
color: var(--dark);
opacity: 0.6;
position: relative;
}
.neo-completed-static::before {
content: '';
position: absolute;
top: 50%;
left: -0.1em;
right: -0.1em;
height: 2px;
background: var(--dark);
transform: scaleX(1);
transform-origin: left;
}
/* New styles for enhanced UX */
.chore-main-info {
display: flex;
align-items: center;
gap: 0.5rem;
flex-wrap: wrap;
}
.chore-badges {
display: flex;
gap: 0.25rem;
}
.badge {
font-size: 0.75rem;
padding: 0.125rem 0.375rem;
border-radius: 0.25rem;
font-weight: 500;
text-transform: uppercase;
letter-spacing: 0.025em;
}
.badge-group {
background-color: #3b82f6;
color: white;
}
.badge-overdue {
background-color: #ef4444;
color: white;
}
.badge-due-today {
background-color: #f59e0b;
color: white;
}
.chore-description {
font-size: 0.875rem;
color: var(--dark);
opacity: 0.8;
margin-top: 0.25rem;
font-style: italic;
}
/* Modal styles */
.detail-modal .modal-container,
.history-modal .modal-container {
max-width: 600px;
max-height: 80vh;
overflow-y: auto;
}
.detail-section {
margin-bottom: 1.5rem;
}
.detail-section h4 {
margin-bottom: 0.75rem;
font-weight: 600;
color: var(--dark);
border-bottom: 1px solid #e5e7eb;
padding-bottom: 0.25rem;
}
.detail-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 0.75rem;
}
.detail-item {
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.detail-item.full-width {
grid-column: 1 / -1;
}
.detail-item .label {
font-weight: 600;
font-size: 0.875rem;
color: var(--dark);
opacity: 0.8;
}
.detail-item .value {
font-size: 0.875rem;
color: var(--dark);
}
.assignments-list {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.assignment-item {
padding: 0.75rem;
border: 1px solid #e5e7eb;
border-radius: 0.5rem;
background-color: #f9fafb;
}
.assignment-main {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 0.5rem;
}
.assigned-user {
font-weight: 500;
color: var(--dark);
}
.assignment-status {
font-size: 0.875rem;
padding: 0.25rem 0.5rem;
border-radius: 0.25rem;
background-color: #fbbf24;
color: white;
}
.assignment-status.completed {
background-color: #10b981;
}
.assignment-details {
display: flex;
flex-direction: column;
gap: 0.25rem;
font-size: 0.875rem;
color: var(--dark);
opacity: 0.8;
}
.history-list {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.history-item {
padding: 0.75rem;
border-left: 3px solid #e5e7eb;
background-color: #f9fafb;
border-radius: 0 0.25rem 0.25rem 0;
}
.history-text {
font-size: 0.875rem;
color: var(--dark);
}
.history-data {
margin-top: 0.5rem;
}
.history-data details {
font-size: 0.75rem;
}
.history-data summary {
cursor: pointer;
color: var(--primary);
font-weight: 500;
}
.history-data pre {
margin-top: 0.25rem;
padding: 0.5rem;
background-color: #f3f4f6;
border-radius: 0.25rem;
font-size: 0.75rem;
overflow-x: auto;
}
.loading-spinner {
text-align: center;
padding: 1rem;
color: var(--dark);
opacity: 0.7;
}
.no-data {
text-align: center;
padding: 1rem;
color: var(--dark);
opacity: 0.7;
font-style: italic;
}
</style>