317 lines
8.5 KiB
Vue
317 lines
8.5 KiB
Vue
<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();
|
||
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: 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>
|