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

324 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="dashboard">
<div v-if="isAuthenticated && user">
<h2>My Habits for {{ user.nickname }}</h2>
<div v-if="loading" class="loading-message">
<p>Loading habits...</p>
</div>
<div v-else-if="processedHabits.length > 0" class="habits-list">
<div v-for="habit in processedHabits" :key="habit.id" class="habit-card">
<div class="habit-details">
<div class="habit-header">
<div class="habit-title-area">
<span class="habit-name">{{ habit.name }}</span>
<span class="habit-schedule">{{ formatDaysOfWeek(habit.daysOfWeek) }}</span>
</div>
<button
v-if="isActionableToday(habit) && !isCompleteToday(habit)"
@click="completeHabit(habit.id)"
:disabled="completing === habit.id"
class="complete-btn"
>
{{ completing === habit.id ? '...' : 'Complete' }}
</button>
<span v-else-if="isCompleteToday(habit)" class="completed-text">✅ Done!</span>
</div>
<div class="calendar-grid">
<div
v-for="day in calendarDays"
:key="day.getTime()"
:class="['calendar-cell', getDayStatus(habit, day), { 'is-scheduled': isScheduledDay(habit, day) }]"
>
<span class="date-label">{{ formatDate(day) }}</span>
</div>
</div>
</div>
</div>
</div>
<div v-else class="empty-state">
<p>You haven't created any habits yet.</p>
<NuxtLink to="/habits">Manage Habits</NuxtLink>
</div>
</div>
<div v-else>
<p>Loading session...</p>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted, watch } from 'vue';
// --- Type Definitions ---
interface Habit {
id: number;
name: string;
daysOfWeek: number[]; // Backend: 0 = Monday, 6 = Sunday
completions: { id: number; date: string }[];
createdAt: string;
}
// Internal type for easier lookup
interface HabitWithCompletionSet extends Habit {
completionDates: Set<number>; // Set of timestamps for O(1) lookup
createdAtTimestamp: number;
}
// --- Composables ---
const { user, isAuthenticated } = useAuth();
const api = useApi();
// --- State ---
const allHabits = ref<Habit[]>([]);
const loading = ref(true);
const completing = ref<number | null>(null);
// --- Helpers: Date Normalization & Formatting ---
/**
* Normalizes a date to the start of the day in UTC.
*/
const normalizeDateUTC = (date: Date): Date => {
return new Date(Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate()));
};
/**
* Normalizes a JS Date's getDay() result (0=Sun) to the backend's format (0=Mon).
* @param jsDay The result of date.getUTCDay()
* @returns A number where 0 = Monday, ..., 6 = Sunday.
*/
const normalizeJsDay = (jsDay: number): number => {
return jsDay === 0 ? 6 : jsDay - 1;
};
const today = normalizeDateUTC(new Date());
const formatDate = (date: Date): string => {
return date.toLocaleDateString('ru-RU', { day: 'numeric', month: 'short' });
};
// --- Computed Properties ---
const processedHabits = computed((): HabitWithCompletionSet[] => {
return allHabits.value
.map(habit => ({
...habit,
completionDates: new Set(habit.completions.map(c => normalizeDateUTC(new Date(c.date)).getTime())),
createdAtTimestamp: normalizeDateUTC(new Date(habit.createdAt)).getTime(),
}));
});
const calendarDays = computed(() => {
const dates = [];
// JS day is 0=Sun, so we normalize it to backend's 0=Mon format.
const todayBackendDay = normalizeJsDay(today.getUTCDay());
// Start date is the Monday of the previous week.
const startDate = normalizeDateUTC(new Date(today));
startDate.setUTCDate(startDate.getUTCDate() - todayBackendDay - 7);
for (let i = 0; i < 14; i++) {
const d = new Date(startDate);
d.setUTCDate(d.getUTCDate() + i);
dates.push(d);
}
return dates;
});
// --- Methods: Status and Actions ---
const dayLabels = ['Пн', 'Вт', 'Ср', 'Чт', 'Пт', 'Сб', 'Вс'];
const formatDaysOfWeek = (days: number[]): string => {
if (days.length === 7) {
return 'Каждый день';
}
return [...days]
.sort((a, b) => a - b) // Sort numerically (0=Mon, 1=Tue, etc.)
.map(dayIndex => dayLabels[dayIndex])
.join(', ');
};
const isScheduledDay = (habit: HabitWithCompletionSet, date: Date): boolean => {
const backendDay = normalizeJsDay(normalizeDateUTC(date).getUTCDay());
return habit.daysOfWeek.includes(backendDay);
};
const isActionableToday = (habit: HabitWithCompletionSet): boolean => {
const todayBackendDay = normalizeJsDay(today.getUTCDay());
return habit.daysOfWeek.includes(todayBackendDay);
};
const getDayStatus = (habit: HabitWithCompletionSet, date: Date): string => {
const normalizedDate = normalizeDateUTC(date);
const normalizedTimestamp = normalizedDate.getTime();
if (normalizedTimestamp > today.getTime()) {
return 'NEUTRAL';
}
if (normalizedTimestamp < habit.createdAtTimestamp) {
return 'NEUTRAL';
}
if (!isScheduledDay(habit, date)) {
return 'NEUTRAL';
}
if (habit.completionDates.has(normalizedTimestamp)) {
return 'COMPLETED';
}
return 'MISSED';
};
const isCompleteToday = (habit: HabitWithCompletionSet) => {
return habit.completionDates.has(today.getTime());
};
const fetchHabits = async () => {
loading.value = true;
try {
allHabits.value = await api<Habit[]>('/habits');
} catch (error) {
console.error("Failed to fetch habits:", error);
allHabits.value = [];
} finally {
loading.value = false;
}
};
const completeHabit = async (habitId: number) => {
completing.value = habitId;
try {
await api(`/habits/${habitId}/complete`, { method: 'POST' });
await fetchHabits(); // Re-fetch the list to update UI
} catch (error) {
console.error(`Failed to complete habit ${habitId}:`, error);
} finally {
completing.value = null;
}
};
// --- Lifecycle ---
onMounted(() => {
if (isAuthenticated.value) {
fetchHabits();
}
watch(isAuthenticated, (isAuth) => {
if (isAuth && allHabits.value.length === 0) {
fetchHabits();
}
});
});
</script>
<style scoped>
.dashboard {
max-width: 600px;
margin: 0 auto;
text-align: center;
}
.habits-list {
display: flex;
flex-direction: column;
gap: 20px;
margin-top: 20px;
}
.habit-card {
padding: 15px;
background-color: #fff;
border-radius: 8px;
box-shadow: 0 2px 4px rgba(0,0,0,0.05);
text-align: left;
}
.habit-details {
width: 100%;
}
.habit-header {
display: flex;
justify-content: space-between;
align-items: flex-start; /* Align items to the top */
margin-bottom: 15px;
gap: 10px; /* Add gap between title area and button */
}
.habit-title-area {
display: flex;
flex-direction: column;
gap: 4px;
}
.habit-name {
font-size: 1.2em;
font-weight: bold;
}
.habit-schedule {
font-size: 0.85em;
color: #666;
font-style: italic;
}
.complete-btn {
padding: 8px 12px;
border: none;
border-radius: 5px;
background-color: #88c0d0;
color: #2e3440;
cursor: pointer;
flex-shrink: 0;
}
.completed-text {
color: #a3be8c;
font-weight: bold;
flex-shrink: 0;
}
.empty-state {
margin-top: 40px;
color: #666;
}
.empty-state a {
margin-top: 10px;
display: inline-block;
}
/* Calendar Grid Styles */
.calendar-grid {
display: grid;
grid-template-columns: repeat(7, 1fr);
gap: 5px; /* Slightly more gap */
}
.calendar-cell {
width: 100%;
aspect-ratio: 1 / 1;
border-radius: 4px;
display: flex;
justify-content: center;
align-items: center;
border: 1px solid transparent; /* Default transparent border */
}
.calendar-cell.is-scheduled {
border-color: #d8dee9; /* Neutral border for scheduled days */
}
.date-label {
font-size: 0.75em;
color: #4c566a;
}
.calendar-cell.COMPLETED .date-label,
.calendar-cell.MISSED .date-label {
color: white;
}
.calendar-cell.NEUTRAL {
background-color: #eceff4; /* Gray */
}
.calendar-cell.COMPLETED {
background-color: #a3be8c; /* Green */
border-color: #a3be8c;
}
.calendar-cell.MISSED {
background-color: #bf616a; /* Red */
border-color: #bf616a;
}
</style>