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

494 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="streak-section">
<div class="streak-card" :class="{ 'active-streak': user.dailyStreak === 1 }">
<h2>x1</h2>
<p>Базовые</p>
</div>
<div class="streak-card" :class="{ 'active-streak': user.dailyStreak === 2 }">
<h2>x2</h2>
<p>Двойные</p>
</div>
<div class="streak-card" :class="{ 'active-streak': user.dailyStreak >= 3 }">
<h2>x3</h2>
<p>Тройные</p>
</div>
</div>
<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>
<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 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);
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(', ');
};
// --- 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 {
const response = await api(`/api/habits/${habitId}/complete`, { method: 'POST' });
if (updateUser && response) {
updateUser({
coins: response.updatedCoins,
exp: response.updatedExp,
});
}
const habit = habits.value.find(h => h.id === habitId);
if (habit) {
habit.completions.push({
id: Math.random(),
habitId: habitId,
date: new Date().toISOString(),
});
}
explodingHabitId.value = habitId;
setTimeout(() => {
explodingHabitId.value = null;
}, 1000);
} catch (err) {
alert(err.data?.message || 'Failed to complete habit.');
} finally {
isSubmittingHabit.value = false;
}
};
</script>
<style scoped>
.home-page {
padding: 40px;
text-align: center;
}
.streak-section {
display: flex;
justify-content: center;
gap: 10px; /* Reduced gap */
margin-top: 20px; /* Added margin-top to separate from paragraph */
margin-bottom: 30px; /* Slightly reduced margin-bottom */
}
.streak-card {
background: #f8f9fa;
border: 2px solid #e9ecef;
border-radius: 10px; /* Slightly reduced border-radius */
padding: 10px 15px; /* Reduced padding */
width: 100px; /* Reduced width */
transition: all 0.3s ease;
font-size: 0.9em; /* Reduced base font size */
}
.streak-card h2 {
margin: 0 0 5px 0; /* Reduced margin */
font-size: 1.8em; /* Reduced font size */
color: #adb5bd;
}
.streak-card p {
margin: 0;
font-size: 0.8em; /* Reduced font size */
color: #6c757d;
}
.active-streak {
border-color: #81a1c1;
background-color: #eceff4;
box-shadow: 0 4px 8px rgba(0,0,0,0.1); /* Reduced shadow */
transform: translateY(-3px); /* Reduced transform */
}
.active-streak h2 {
color: #4c566a;
}
.active-streak p {
color: #3b4252;
font-weight: bold;
}
.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 {
aspect-ratio: 1 / 1;
border: 1px solid #e2e8f0;
border-radius: 4px;
display: flex;
justify-content: center;
align-items: center;
background-color: #f8fafc;
}
.day-cell.completed {
background-color: #4ade80;
color: white;
border-color: #4ade80;
}
.day-cell.missed-day {
background-color: #feecf0;
}
.day-cell.scheduled-day {
border-width: 2px;
border-color: #81a1c1;
}
.future-day .day-label {
color: #adb5bd;
}
.day-cell.today-highlight .day-label {
text-decoration: underline;
font-weight: bold;
}
.day-label {
font-size: 0.9em;
}
.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;
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;
}
.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;
}
/* 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; }
.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>