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

303 lines
8.2 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">
<h3>{{ habit.name }}</h3>
<p class="habit-schedule">{{ getScheduleText(habit) }}</p>
</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>
<!-- 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 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();
const todayNormalized = new Date();
todayNormalized.setHours(0, 0, 0, 0);
const exploding = computed(() => props.explodingHabitId === props.habit.id);
// --- History Grid Logic (copied from index.vue) ---
const last14Days = computed(() => {
const dates = [];
const today = new Date();
const todayDay = today.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
// (appDayOfWeek) gets us to this week's Monday. +7 gets us to last week's Monday.
const daysToSubtract = appDayOfWeek + 7;
const startDate = new Date();
startDate.setDate(today.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 isSameDay = (d1, d2) => {
d1 = new Date(d1);
d2 = new Date(d2);
return d1.getFullYear() === d2.getFullYear() &&
d1.getMonth() === d2.getMonth() &&
d1.getDate() === d2.getDate();
};
const isCompleted = (habit, date) => {
if (!habit || !habit.completions) return false;
return habit.completions.some(c => isSameDay(c.date, date));
};
const getCellClasses = (habit, day) => {
const classes = {};
const dayNormalized = new Date(day);
dayNormalized.setHours(0, 0, 0, 0);
const habitCreatedAt = new Date(habit.createdAt);
habitCreatedAt.setHours(0, 0, 0, 0);
if (dayNormalized > todayNormalized) {
classes['future-day'] = true;
}
if (isSameDay(dayNormalized, todayNormalized)) {
classes['today-highlight'] = true;
}
const dayOfWeek = (dayNormalized.getDay() === 0) ? 6 : dayNormalized.getDay() - 1; // Mon=0, Sun=6
const isScheduled = habit.daysOfWeek.includes(dayOfWeek);
if (isScheduled) {
classes['scheduled-day'] = true;
}
if (isCompleted(habit, dayNormalized)) {
classes['completed'] = true;
return classes;
}
if (dayNormalized < todayNormalized && isScheduled && dayNormalized >= habitCreatedAt) {
classes['missed-day'] = true;
}
return classes;
};
const isScheduledForToday = (habit) => {
const todayDay = today.getDay(); // Sunday is 0
const appDayOfWeek = (todayDay === 0) ? 6 : todayDay - 1;
return habit.daysOfWeek.includes(appDayOfWeek);
}
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 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: space-between;
align-items: center;
margin-bottom: 20px;
}
.habit-details {
text-align: left;
}
.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;
}
/* 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; }
</style>