habits.andr33v.ru/app/pages/index.vue

711 lines
13 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="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>