711 lines
13 KiB
Vue
711 lines
13 KiB
Vue
<template>
|
||
<div class="home-page">
|
||
<div v-if="isAuthenticated && user" class="dashboard-content">
|
||
<h1>Ваши цели на сегодня</h1>
|
||
<p>Цели обновляются раз в сутки. Бонусы за выполнение целей усиливаются, если посещать страницу ежедневно!</p>
|
||
|
||
<div class="habits-section">
|
||
<div v-if="habitsPending">Loading habits...</div>
|
||
<div v-else-if="habitsError">Could not load habits.</div>
|
||
<div v-else-if="habits && habits.length > 0">
|
||
<div v-for="habit in habits" :key="habit.id" 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)">
|
||
<span v-if="isCompleted(habit, today)" class="completed-text">Выполнено!</span>
|
||
<button v-else @click="completeHabit(habit.id, $event)" :disabled="isSubmittingHabit">
|
||
Выполнить
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div 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="explodingHabitId === habit.id" class="confetti-container">
|
||
<div v-for="i in 15" :key="i" class="confetti-particle"></div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div v-else>
|
||
<p>You have no habits yet. Go to the <NuxtLink to="/habits">My Habits</NuxtLink> page to create one.</p>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="links">
|
||
<NuxtLink to="/habits" class="button">Manage Habits</NuxtLink>
|
||
<NuxtLink to="/village" class="button">My Village</NuxtLink>
|
||
<NuxtLink to="/leaderboard" class="button">Leaderboard</NuxtLink>
|
||
</div>
|
||
</div>
|
||
<div v-else class="welcome-content">
|
||
<h1>Добро пожаловать в SmurfHabits!</h1>
|
||
<p>Отслеживайте свои привычки и развивайте свою деревню.</p>
|
||
<div class="auth-buttons">
|
||
<NuxtLink to="/login" class="button primary">Войти</NuxtLink>
|
||
<NuxtLink to="/register" class="button secondary">Зарегистрироваться</NuxtLink>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</template>
|
||
|
||
<script setup>
|
||
|
||
import { ref, computed } from 'vue';
|
||
|
||
|
||
|
||
const { user, isAuthenticated, updateUser } = useAuth();
|
||
|
||
const api = useApi();
|
||
|
||
|
||
|
||
// --- Habits Data ---
|
||
|
||
const { data: habits, pending: habitsPending, error: habitsError, refresh: refreshHabits } = await useFetch('/api/habits', {
|
||
|
||
lazy: true,
|
||
|
||
server: false,
|
||
|
||
});
|
||
|
||
|
||
|
||
// --- Date Logic & Helpers ---
|
||
|
||
const today = new Date();
|
||
|
||
const todayNormalized = new Date();
|
||
|
||
todayNormalized.setHours(0, 0, 0, 0);
|
||
|
||
|
||
|
||
const russianDayMap = { 0: 'Вс', 1: 'Пн', 2: 'Вт', 3: 'Ср', 4: 'Чт', 5: 'Пт', 6: 'Сб' };
|
||
|
||
|
||
|
||
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 1 and Sunday is 7
|
||
|
||
const dayOfWeek = todayDay === 0 ? 7 : todayDay;
|
||
|
||
|
||
|
||
// Calculate days to subtract to get to the Monday of LAST week
|
||
|
||
// (dayOfWeek - 1) gets us to this week's Monday. +7 gets us to last week's Monday.
|
||
|
||
const daysToSubtract = (dayOfWeek - 1) + 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) => {
|
||
|
||
// Use Intl for robust localization. 'янв' needs a specific format.
|
||
|
||
const formatted = new Intl.DateTimeFormat('ru-RU', { day: 'numeric', month: 'short' }).format(date);
|
||
|
||
// The format might include a " г.", remove it.
|
||
|
||
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);
|
||
|
||
|
||
|
||
// Is the day in the future?
|
||
|
||
|
||
|
||
if (dayNormalized > todayNormalized) {
|
||
|
||
|
||
|
||
classes['future-day'] = true;
|
||
|
||
|
||
|
||
}
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
// Is the day today?
|
||
|
||
|
||
|
||
if (isSameDay(dayNormalized, todayNormalized)) {
|
||
|
||
|
||
|
||
classes['today-highlight'] = true;
|
||
|
||
|
||
|
||
}
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
// Is the habit scheduled for this day?
|
||
|
||
|
||
|
||
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;
|
||
|
||
}
|
||
|
||
|
||
|
||
// Is the habit completed on this day?
|
||
|
||
if (isCompleted(habit, dayNormalized)) {
|
||
|
||
classes['completed'] = true;
|
||
|
||
return classes; // Completion state overrides all others
|
||
|
||
}
|
||
|
||
|
||
|
||
// Is it a missed day?
|
||
|
||
if (dayNormalized < todayNormalized && isScheduled && dayNormalized >= habitCreatedAt) {
|
||
|
||
classes['missed-day'] = true;
|
||
|
||
}
|
||
|
||
|
||
|
||
return classes;
|
||
|
||
};
|
||
|
||
|
||
|
||
const isScheduledForToday = (habit) => {
|
||
|
||
const todayDay = today.getDay(); // Sunday is 0
|
||
|
||
// Convert JS day (Sun=0) to the application's convention (Mon=0, Sun=6)
|
||
|
||
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(', ');
|
||
|
||
};
|
||
|
||
|
||
|
||
|
||
|
||
// --- Actions & UI State ---
|
||
|
||
const isSubmittingHabit = ref(false);
|
||
|
||
const explodingHabitId = ref(null);
|
||
|
||
|
||
|
||
const completeHabit = async (habitId, event) => {
|
||
|
||
|
||
|
||
if (event) {
|
||
|
||
|
||
|
||
event.preventDefault();
|
||
|
||
|
||
|
||
}
|
||
|
||
|
||
|
||
if (isSubmittingHabit.value) return;
|
||
|
||
|
||
|
||
isSubmittingHabit.value = true;
|
||
|
||
|
||
|
||
try {
|
||
|
||
|
||
|
||
// The API returns the updated user stats and reward info
|
||
|
||
|
||
|
||
const response = await api(`/api/habits/${habitId}/complete`, { method: 'POST' });
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
// Update the global user state for coins and exp
|
||
|
||
|
||
|
||
if (updateUser && response) {
|
||
|
||
|
||
|
||
updateUser({
|
||
|
||
|
||
|
||
coins: response.updatedCoins,
|
||
|
||
|
||
|
||
exp: response.updatedExp,
|
||
|
||
|
||
|
||
});
|
||
|
||
|
||
|
||
}
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
// Optimistically update the local habit state to show completion
|
||
|
||
|
||
|
||
const habit = habits.value.find(h => h.id === habitId);
|
||
|
||
|
||
|
||
if (habit) {
|
||
|
||
|
||
|
||
habit.completions.push({
|
||
|
||
|
||
|
||
id: Math.random(), // Temporary ID for the key
|
||
|
||
|
||
|
||
habitId: habitId,
|
||
|
||
|
||
|
||
date: new Date().toISOString(),
|
||
|
||
|
||
|
||
});
|
||
|
||
|
||
|
||
}
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
// Trigger confetti
|
||
|
||
|
||
|
||
explodingHabitId.value = habitId;
|
||
|
||
|
||
|
||
setTimeout(() => {
|
||
|
||
|
||
|
||
explodingHabitId.value = null;
|
||
|
||
|
||
|
||
}, 1000); // Animation duration
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
} catch (err) {
|
||
|
||
|
||
|
||
alert(err.data?.message || 'Failed to complete habit.');
|
||
|
||
|
||
|
||
} finally {
|
||
|
||
|
||
|
||
isSubmittingHabit.value = false;
|
||
|
||
|
||
|
||
}
|
||
|
||
|
||
|
||
};
|
||
|
||
</script>
|
||
|
||
<style scoped>
|
||
.home-page {
|
||
padding: 40px;
|
||
text-align: center;
|
||
}
|
||
|
||
.habits-section {
|
||
margin-top: 40px;
|
||
margin-bottom: 40px;
|
||
}
|
||
|
||
.habit-card {
|
||
background: #fff;
|
||
border-radius: 8px;
|
||
padding: 20px;
|
||
margin: 20px auto;
|
||
max-width: 800px;
|
||
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
|
||
}
|
||
|
||
.history-grid {
|
||
display: grid;
|
||
grid-template-columns: repeat(7, 1fr);
|
||
gap: 5px;
|
||
margin: 20px 0;
|
||
}
|
||
|
||
.day-cell {
|
||
/* Removed fixed width/height for responsiveness */
|
||
aspect-ratio: 1 / 1; /* Keep cells square */
|
||
border: 1px solid #e2e8f0;
|
||
border-radius: 4px;
|
||
display: flex;
|
||
justify-content: center;
|
||
align-items: center;
|
||
background-color: #f8fafc;
|
||
}
|
||
|
||
.day-cell.completed {
|
||
background-color: #4ade80; /* Green for completed */
|
||
color: white;
|
||
border-color: #4ade80;
|
||
}
|
||
|
||
.day-cell.missed-day {
|
||
background-color: #feecf0; /* Light red for missed */
|
||
}
|
||
|
||
.day-cell.scheduled-day {
|
||
border-width: 2px;
|
||
border-color: #81a1c1; /* Blueish accent for scheduled */
|
||
}
|
||
|
||
.future-day .day-label {
|
||
color: #adb5bd; /* Muted color for future days */
|
||
}
|
||
|
||
.day-cell.today-highlight .day-label {
|
||
text-decoration: underline;
|
||
font-weight: bold;
|
||
}
|
||
|
||
.day-label {
|
||
font-size: 0.9em; /* Make font size relative to parent for responsiveness */
|
||
}
|
||
|
||
.welcome-content {
|
||
margin-top: 50px;
|
||
}
|
||
|
||
.welcome-content h1 {
|
||
font-size: 2.5em;
|
||
margin-bottom: 20px;
|
||
color: #333;
|
||
}
|
||
|
||
.welcome-content p {
|
||
font-size: 1.2em;
|
||
color: #555;
|
||
margin-bottom: 40px;
|
||
}
|
||
|
||
.auth-buttons {
|
||
display: flex;
|
||
justify-content: center;
|
||
gap: 20px;
|
||
}
|
||
|
||
.button {
|
||
display: inline-block;
|
||
padding: 12px 25px;
|
||
border-radius: 8px;
|
||
text-decoration: none;
|
||
font-weight: bold;
|
||
font-size: 1.1em;
|
||
transition: background-color 0.3s ease;
|
||
border: 1px solid transparent;
|
||
}
|
||
|
||
.button.primary {
|
||
background-color: #007bff;
|
||
color: white;
|
||
}
|
||
|
||
.button.primary:hover {
|
||
background-color: #0056b3;
|
||
}
|
||
|
||
.button.secondary {
|
||
background-color: #6c757d;
|
||
color: white;
|
||
}
|
||
|
||
.button.secondary:hover {
|
||
background-color: #5a6268;
|
||
}
|
||
|
||
.links {
|
||
display: flex;
|
||
justify-content: center;
|
||
gap: 20px;
|
||
margin: 40px 0;
|
||
}
|
||
.links a.button {
|
||
background-color: #e9ecef;
|
||
color: #333;
|
||
}
|
||
.links a.button:hover {
|
||
background-color: #dee2e6;
|
||
}
|
||
|
||
|
||
/* New Habit Card Styles */
|
||
.habit-card {
|
||
position: relative; /* For confetti positioning */
|
||
overflow: hidden; /* Hide confetti that flies out of bounds */
|
||
}
|
||
|
||
.habit-header {
|
||
display: flex;
|
||
justify-content: space-between;
|
||
align-items: center;
|
||
margin-bottom: 20px;
|
||
}
|
||
|
||
.habit-details {
|
||
text-align: left;
|
||
}
|
||
|
||
.habit-details h3 {
|
||
margin: 0;
|
||
}
|
||
|
||
.habit-schedule {
|
||
margin: 5px 0 0 0;
|
||
font-size: 0.9em;
|
||
color: #6c757d;
|
||
}
|
||
|
||
.habit-action button {
|
||
padding: 8px 16px;
|
||
background-color: #5e81ac;
|
||
color: white;
|
||
border: none;
|
||
border-radius: 4px;
|
||
cursor: pointer;
|
||
transition: background-color 0.2s;
|
||
}
|
||
|
||
.habit-action button:hover {
|
||
background-color: #4c566a;
|
||
}
|
||
|
||
.completed-text {
|
||
font-weight: bold;
|
||
color: #28a745; /* Joyful green */
|
||
}
|
||
|
||
/* Confetti Animation */
|
||
.confetti-container {
|
||
position: absolute;
|
||
top: 0;
|
||
left: 0;
|
||
width: 100%;
|
||
height: 100%;
|
||
pointer-events: none;
|
||
overflow: hidden;
|
||
}
|
||
|
||
.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; }
|
||
.confetti-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> |