habits.andr33v.ru/app/components/HabitCard.vue

309 lines
8.3 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<template>
<div class="habit-card">
<div class="habit-header">
<div class="habit-details" style="flex-grow: 1;">
<h3>{{ habit.name }}</h3>
<p class="habit-schedule">{{ getScheduleText(habit) }}</p>
</div>
</div>
<!-- Calendar / History Grid (only for full user dashboard, not onboarding) -->
<div v-if="showHistoryGrid" class="history-grid">
<div v-for="day in last14Days" :key="day.toISOString()" class="day-cell" :class="getCellClasses(habit, day)">
<span class="day-label">{{ formatDayLabel(day) }}</span>
</div>
</div>
<div class="habit-action">
<div v-if="isScheduledForToday(habit) || forceShowAction">
<span v-if="isCompleted(habit, today)" class="completed-text">Выполнено!</span>
<button v-else @click="emitComplete" :disabled="isSubmittingHabit" class="btn btn-primary btn-sm">
Выполнить
</button>
</div>
</div>
<div v-if="exploding" class="confetti-container">
<div v-for="i in 15" :key="i" class="confetti-particle"></div>
</div>
</div>
</template>
<script setup>
import { computed, ref } from 'vue';
const props = defineProps({
habit: {
type: Object,
required: true,
},
isSubmittingHabit: {
type: Boolean,
default: false,
},
explodingHabitId: {
type: Number,
default: null,
},
showHistoryGrid: {
type: Boolean,
default: true, // Default to true for dashboard, false for onboarding
},
forceShowAction: {
type: Boolean,
default: false,
},
});
const emit = defineEmits(['complete']);
const today = new Date().toISOString().slice(0, 10); // "YYYY-MM-DD"
const isCompleted = (habit, date) => {
if (!habit || !habit.completions) return false;
// Ensure date is in "YYYY-MM-DD" string format for comparison
const comparisonDate = (typeof date === 'string')
? date
: new Date(date).toISOString().slice(0, 10);
return habit.completions.some(c => c.date === comparisonDate);
};
const getScheduleText = (habit) => {
if (habit.daysOfWeek.length === 7) {
return 'каждый день';
}
const dayMap = { 0: 'Пн', 1: 'Вт', 2: 'Ср', 3: 'Чт', 4: 'Пт', 5: 'Сб', 6: 'Вс' };
return habit.daysOfWeek.sort().map(dayIndex => dayMap[dayIndex]).join(', ');
};
const exploding = computed(() => props.explodingHabitId === props.habit.id);
// --- History Grid Logic ---
const last14Days = computed(() => {
const dates = [];
const todayDate = new Date();
const todayDay = todayDate.getDay(); // 0 for Sunday, 1 for Monday, etc.
// Adjust so that Monday is 0 and Sunday is 6 for application's convention
const appDayOfWeek = (todayDay === 0) ? 6 : todayDay - 1;
// Calculate days to subtract to get to the Monday of LAST week
const daysToSubtract = appDayOfWeek + 7;
const startDate = new Date();
startDate.setDate(todayDate.getDate() - daysToSubtract);
for (let i = 0; i < 14; i++) {
const date = new Date(startDate);
date.setDate(startDate.getDate() + i);
dates.push(date);
}
return dates;
});
const formatDayLabel = (date) => {
const formatted = new Intl.DateTimeFormat('ru-RU', { day: 'numeric', month: 'short' }).format(date);
return formatted.replace(' г.', '');
};
const getCellClasses = (habit, day) => {
const classes = {};
const dayString = new Date(day).toISOString().slice(0, 10);
const habitCreatedAt = new Date(habit.createdAt).toISOString().slice(0, 10);
if (dayString > today) {
classes['future-day'] = true;
}
if (dayString === today) {
classes['today-highlight'] = true;
}
const dayOfWeek = (new Date(day).getDay() === 0) ? 6 : new Date(day).getDay() - 1; // Mon=0, Sun=6
const isScheduled = habit.daysOfWeek.includes(dayOfWeek);
if (isScheduled) {
classes['scheduled-day'] = true;
}
if (isCompleted(habit, dayString)) {
classes['completed'] = true;
return classes;
}
if (dayString < today && isScheduled && dayString >= habitCreatedAt) {
classes['missed-day'] = true;
}
return classes;
};
const isScheduledForToday = (habit) => {
const todayDay = new Date().getDay(); // Sunday is 0
const appDayOfWeek = (todayDay === 0) ? 6 : todayDay - 1;
return habit.daysOfWeek.includes(appDayOfWeek);
}
const emitComplete = () => {
emit('complete', props.habit.id);
};
</script>
<style scoped>
.habit-card {
background: var(--container-bg-color);
border: 1px solid var(--border-color);
border-radius: 8px;
padding: 24px;
margin: 24px auto;
max-width: 800px;
box-shadow: 0 2px 4px rgba(0,0,0,0.05);
position: relative;
overflow: hidden;
}
.habit-header {
display: flex;
justify-content: flex-start; /* Adjust as needed */
align-items: center;
}
.habit-details {
text-align: left;
flex-grow: 1; /* Allow details to take available space */
}
.habit-action {
margin-top: 20px; /* Add some space above the action button */
text-align: center; /* Center the button */
}
.habit-details h3 {
margin: 0;
font-size: 1.3rem;
}
.habit-schedule {
margin: 5px 0 0 0;
font-size: 0.9em;
color: var(--text-color-light);
}
.completed-text {
font-weight: bold;
color: #16a34a; /* A nice green */
}
.history-grid {
display: grid;
grid-template-columns: repeat(7, 1fr);
gap: 6px;
margin: 20px 0;
}
.day-cell {
aspect-ratio: 1 / 1;
border: 1px solid var(--border-color);
border-radius: 4px;
display: flex;
justify-content: center;
align-items: center;
background-color: #f8fafc;
position: relative;
}
.day-cell.completed {
background-color: #4ade80;
color: white;
border-color: #4ade80;
}
.day-cell.missed-day {
background-color: #fee2e2;
}
.day-cell.scheduled-day {
border-width: 2px;
border-color: var(--primary-color);
}
.future-day .day-label {
color: #cbd5e1;
}
.day-cell.today-highlight::after {
content: '';
position: absolute;
bottom: 4px;
left: 50%;
transform: translateX(-50%);
width: 6px;
height: 6px;
border-radius: 50%;
background-color: var(--primary-color);
}
.day-label {
font-size: 0.85em;
line-height: 0.8;
}
/* Confetti Animation */
.confetti-container {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
pointer-events: none;
overflow: hidden;
z-index: 10;
}
.confetti-particle {
position: absolute;
left: 50%;
bottom: 0;
width: 10px;
height: 10px;
border-radius: 50%;
animation: confetti-fall 1s ease-out forwards;
}
@keyframes confetti-fall {
from {
transform: translateY(0) translateX(0);
opacity: 1;
}
to {
transform: translateY(-200px) translateX(var(--x-end)) rotate(360deg);
opacity: 0;
}
}
/* Particle Colors & random-ish trajectories */
.confetti-particle:nth-child(1) { background-color: #d88e8e; --x-end: -150px; animation-delay: 0s; }
.confetti-particle:nth-child(2) { background-color: #a3be8c; --x-end: 150px; animation-delay: 0.1s; }
.confetti-particle:nth-child(3) { background-color: #ebcb8b; --x-end: 100px; animation-delay: 0.05s; }
.confetti-particle:nth-child(4) { background-color: #81a1c1; --x-end: -100px; animation-delay: 0.2s; }
.confetti-particle:nth-child(5) { background-color: #b48ead; --x-end: 50px; animation-delay: 0.15s; }
.confetti-particle:nth-child(6) { background-color: #d88e8e; --x-end: -50px; animation-delay: 0.3s; }
.confetti-particle:nth-child(7) { background-color: #a3be8c; --x-end: -80px; animation-delay: 0.25s; }
.confetti-particle:nth-child(8) { background-color: #ebcb8b; --x-end: 80px; animation-delay: 0.4s; }
.confetti-particle:nth-child(9) { background-color: #81a1c1; --x-end: 120px; animation-delay: 0.35s; }
.confetti-particle:nth-child(10) { background-color: #b48ead; --x-end: -120px; animation-delay: 0.45s; }
.confetti-particle:nth-child(11) { background-color: #d88e8e; --x-end: -180px; animation-delay: 0.08s; }
.confetto-particle:nth-child(12) { background-color: #a3be8c; --x-end: 180px; animation-delay: 0.12s; }
.confetti-particle:nth-child(13) { background-color: #ebcb8b; --x-end: 40px; animation-delay: 0.18s; }
.confetti-particle:nth-child(14) { background-color: #81a1c1; --x-end: -40px; animation-delay: 0.22s; }
.confetti-particle:nth-child(15) { background-color: #b48ead; --x-end: 0px; animation-delay: 0.28s; }
/* Responsive Styles for the action button */
@media (max-width: 768px) {
.habit-action button {
width: 100%;
}
}
</style>