Initial commit: Pulse web app

This commit is contained in:
Cosmo
2026-02-06 11:19:55 +00:00
commit 199887e552
31 changed files with 4314 additions and 0 deletions

610
src/pages/Home.jsx Normal file
View File

@@ -0,0 +1,610 @@
import { useState, useEffect, useMemo } from 'react'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { motion, AnimatePresence } from 'framer-motion'
import { Check, Flame, TrendingUp, Zap, Sparkles, Undo2, Plus, Calendar, AlertTriangle, LogOut } from 'lucide-react'
import { format, startOfWeek, differenceInDays, parseISO, isToday, isTomorrow, isPast } from 'date-fns'
import { ru } from 'date-fns/locale'
import { habitsApi } from '../api/habits'
import { tasksApi } from '../api/tasks'
import { useAuthStore } from '../store/auth'
import Navigation from '../components/Navigation'
import CreateTaskModal from '../components/CreateTaskModal'
import clsx from 'clsx'
// Определение "сегодняшних" привычек
function shouldShowToday(habit, lastLogDate) {
const today = new Date()
const dayOfWeek = today.getDay() || 7 // 1=Пн, 7=Вс (JS: 0=Вс -> 7)
if (habit.frequency === 'daily') {
return true
}
if (habit.frequency === 'weekly') {
// Проверяем, выбран ли сегодняшний день
if (habit.target_days && habit.target_days.includes(dayOfWeek)) {
return true
}
// Если не выполнялась на этой неделе
if (!lastLogDate) return true
const weekStart = startOfWeek(today, { weekStartsOn: 1 })
const lastLog = typeof lastLogDate === 'string' ? parseISO(lastLogDate) : lastLogDate
if (lastLog < weekStart) {
return true // Не выполнялась на этой неделе
}
return false
}
// custom (every_n_days) - показывать если прошло N+ дней
if (habit.frequency === 'custom' && habit.target_count > 0) {
if (!lastLogDate) return true
const lastLog = typeof lastLogDate === 'string' ? parseISO(lastLogDate) : lastLogDate
const daysSinceLastLog = differenceInDays(today, lastLog)
return daysSinceLastLog >= habit.target_count
}
return true
}
function formatDueDate(dateStr) {
if (!dateStr) return null
const date = parseISO(dateStr)
if (isToday(date)) return 'Сегодня'
if (isTomorrow(date)) return 'Завтра'
return format(date, 'd MMM', { locale: ru })
}
export default function Home() {
const [todayLogs, setTodayLogs] = useState({})
const [lastLogDates, setLastLogDates] = useState({})
const [showCreateTask, setShowCreateTask] = useState(false)
const queryClient = useQueryClient()
const { user, logout } = useAuthStore()
const { data: habits = [], isLoading: habitsLoading } = useQuery({
queryKey: ['habits'],
queryFn: habitsApi.list,
})
const { data: stats } = useQuery({
queryKey: ['stats'],
queryFn: habitsApi.getStats,
})
const { data: todayTasks = [], isLoading: tasksLoading } = useQuery({
queryKey: ['tasks-today'],
queryFn: tasksApi.today,
})
useEffect(() => {
if (habits.length > 0) {
loadTodayLogs()
}
}, [habits])
const loadTodayLogs = async () => {
const today = format(new Date(), 'yyyy-MM-dd')
const logsMap = {}
const lastDates = {}
await Promise.all(habits.map(async (habit) => {
try {
const logs = await habitsApi.getLogs(habit.id, 30)
// Находим последний лог
if (logs.length > 0) {
const lastLog = logs[0]
const logDate = lastLog.date.split('T')[0]
lastDates[habit.id] = logDate
if (logDate === today) {
logsMap[habit.id] = lastLog.id
}
}
} catch (e) {
console.error('Error loading logs for habit', habit.id, e)
}
}))
setTodayLogs(logsMap)
setLastLogDates(lastDates)
}
const logMutation = useMutation({
mutationFn: (habitId) => habitsApi.log(habitId),
onSuccess: (data, habitId) => {
const today = format(new Date(), 'yyyy-MM-dd')
setTodayLogs(prev => ({ ...prev, [habitId]: data.id }))
setLastLogDates(prev => ({ ...prev, [habitId]: today }))
queryClient.invalidateQueries({ queryKey: ['habits'] })
queryClient.invalidateQueries({ queryKey: ['stats'] })
},
})
const deleteLogMutation = useMutation({
mutationFn: ({ habitId, logId }) => habitsApi.deleteLog(habitId, logId),
onSuccess: (_, { habitId }) => {
setTodayLogs(prev => {
const newLogs = { ...prev }
delete newLogs[habitId]
return newLogs
})
// Перезагружаем логи для обновления lastLogDate
loadTodayLogs()
queryClient.invalidateQueries({ queryKey: ['habits'] })
queryClient.invalidateQueries({ queryKey: ['stats'] })
},
})
const completeTaskMutation = useMutation({
mutationFn: (id) => tasksApi.complete(id),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['tasks-today'] })
queryClient.invalidateQueries({ queryKey: ['tasks'] })
},
})
const uncompleteTaskMutation = useMutation({
mutationFn: (id) => tasksApi.uncomplete(id),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['tasks-today'] })
queryClient.invalidateQueries({ queryKey: ['tasks'] })
},
})
const handleToggleComplete = (habitId) => {
if (todayLogs[habitId]) {
deleteLogMutation.mutate({ habitId, logId: todayLogs[habitId] })
} else {
logMutation.mutate(habitId)
}
}
const handleToggleTask = (task) => {
if (task.completed) {
uncompleteTaskMutation.mutate(task.id)
} else {
completeTaskMutation.mutate(task.id)
}
}
// Фильтруем привычки для сегодня
const todayHabits = useMemo(() => {
return habits.filter(habit =>
shouldShowToday(habit, lastLogDates[habit.id])
)
}, [habits, lastLogDates])
const completedCount = Object.keys(todayLogs).length
const totalToday = todayHabits.length
const today = format(new Date(), 'EEEE, d MMMM', { locale: ru })
// Активные задачи (не выполненные)
const activeTasks = todayTasks.filter(t => !t.completed)
return (
<div className="min-h-screen bg-surface-50 gradient-mesh pb-24">
<header className="bg-white/70 backdrop-blur-xl border-b border-gray-100/50 sticky top-0 z-10">
<div className="max-w-lg mx-auto px-4 py-4 flex items-center justify-between">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-xl bg-gradient-to-br from-primary-500 to-primary-700 flex items-center justify-center shadow-lg shadow-primary-500/20">
<Zap className="w-5 h-5 text-white" />
</div>
<div>
<h1 className="text-lg font-display font-bold text-gray-900">
Привет, {user?.username}!
</h1>
<p className="text-sm text-gray-500 capitalize">{today}</p>
</div>
</div>
<button
onClick={logout}
className="p-2 text-gray-400 hover:text-gray-600 hover:bg-gray-100 rounded-xl transition-colors"
title="Выйти"
>
<LogOut size={20} />
</button>
</div>
</header>
<main className="max-w-lg mx-auto px-4 py-6 space-y-6">
{/* Прогресс на сегодня */}
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
className="card p-5"
>
<div className="flex items-center justify-between mb-3">
<h2 className="font-semibold text-gray-900">Прогресс на сегодня</h2>
<span className="text-sm font-medium text-primary-600">
{completedCount} / {totalToday}
</span>
</div>
<div className="h-3 bg-gray-100 rounded-full overflow-hidden">
<motion.div
initial={{ width: 0 }}
animate={{ width: totalToday > 0 ? `${(completedCount / totalToday) * 100}%` : '0%' }}
transition={{ duration: 0.5, ease: 'easeOut' }}
className="h-full bg-gradient-to-r from-primary-500 to-accent-500 rounded-full"
/>
</div>
{completedCount === totalToday && totalToday > 0 && (
<motion.p
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
className="text-sm text-green-600 mt-2 font-medium"
>
🎉 Все привычки выполнены!
</motion.p>
)}
</motion.div>
{/* Статистика */}
{stats && (
<div className="grid grid-cols-2 gap-4">
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
className="card p-5"
>
<div className="flex items-center gap-4">
<div className="w-12 h-12 rounded-2xl bg-gradient-to-br from-accent-400 to-accent-500 flex items-center justify-center shadow-lg shadow-accent-400/20">
<Flame className="w-6 h-6 text-white" />
</div>
<div>
<p className="text-3xl font-display font-bold text-gray-900">{stats.today_completed}</p>
<p className="text-sm text-gray-500">Выполнено</p>
</div>
</div>
</motion.div>
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.1 }}
className="card p-5"
>
<div className="flex items-center gap-4">
<div className="w-12 h-12 rounded-2xl bg-gradient-to-br from-primary-500 to-primary-700 flex items-center justify-center shadow-lg shadow-primary-500/20">
<TrendingUp className="w-6 h-6 text-white" />
</div>
<div>
<p className="text-3xl font-display font-bold text-gray-900">{stats.active_habits}</p>
<p className="text-sm text-gray-500">Активных</p>
</div>
</div>
</motion.div>
</div>
)}
{/* Задачи на сегодня */}
{(activeTasks.length > 0 || !tasksLoading) && (
<div>
<div className="flex items-center justify-between mb-4">
<h2 className="text-xl font-display font-bold text-gray-900">Задачи на сегодня</h2>
<button
onClick={() => setShowCreateTask(true)}
className="p-2 bg-primary-100 text-primary-600 rounded-xl hover:bg-primary-200 transition-colors"
>
<Plus size={18} />
</button>
</div>
{tasksLoading ? (
<div className="card p-5 animate-pulse">
<div className="flex items-center gap-4">
<div className="w-10 h-10 rounded-xl bg-gray-200" />
<div className="flex-1">
<div className="h-5 bg-gray-200 rounded-lg w-3/4 mb-2" />
<div className="h-4 bg-gray-200 rounded-lg w-1/4" />
</div>
</div>
</div>
) : activeTasks.length === 0 ? (
<motion.div
initial={{ opacity: 0, scale: 0.95 }}
animate={{ opacity: 1, scale: 1 }}
className="card p-6 text-center"
>
<p className="text-gray-500">Нет задач на сегодня</p>
<button
onClick={() => setShowCreateTask(true)}
className="mt-3 text-sm text-primary-600 hover:text-primary-700 font-medium"
>
+ Добавить задачу
</button>
</motion.div>
) : (
<div className="space-y-3">
<AnimatePresence>
{activeTasks.map((task, index) => (
<TaskCard
key={task.id}
task={task}
index={index}
onToggle={() => handleToggleTask(task)}
isLoading={completeTaskMutation.isPending || uncompleteTaskMutation.isPending}
/>
))}
</AnimatePresence>
</div>
)}
</div>
)}
{/* Привычки на сегодня */}
<div>
<h2 className="text-xl font-display font-bold text-gray-900 mb-5">Привычки</h2>
{habitsLoading ? (
<div className="space-y-4">
{[1, 2, 3].map((i) => (
<div key={i} className="card p-5 animate-pulse">
<div className="flex items-center gap-4">
<div className="w-14 h-14 rounded-2xl bg-gray-200" />
<div className="flex-1">
<div className="h-5 bg-gray-200 rounded-lg w-1/2 mb-2" />
<div className="h-4 bg-gray-200 rounded-lg w-1/3" />
</div>
</div>
</div>
))}
</div>
) : todayHabits.length === 0 ? (
<motion.div
initial={{ opacity: 0, scale: 0.95 }}
animate={{ opacity: 1, scale: 1 }}
className="card p-10 text-center"
>
<div className="w-20 h-20 rounded-3xl bg-gradient-to-br from-green-100 to-green-200 flex items-center justify-center mx-auto mb-5">
<Sparkles className="w-10 h-10 text-green-600" />
</div>
<h3 className="text-xl font-display font-bold text-gray-900 mb-2">Свободный день!</h3>
<p className="text-gray-500">На сегодня нет запланированных привычек. Отдохни или добавь новую во вкладке "Привычки".</p>
</motion.div>
) : (
<div className="space-y-4">
<AnimatePresence>
{todayHabits.map((habit, index) => (
<HabitCard
key={habit.id}
habit={habit}
index={index}
isCompleted={!!todayLogs[habit.id]}
onToggle={() => handleToggleComplete(habit.id)}
isLoading={logMutation.isPending || deleteLogMutation.isPending}
/>
))}
</AnimatePresence>
</div>
)}
</div>
</main>
<Navigation />
<CreateTaskModal
open={showCreateTask}
onClose={() => setShowCreateTask(false)}
/>
</div>
)
}
function TaskCard({ task, index, onToggle, isLoading }) {
const [showConfetti, setShowConfetti] = useState(false)
const dueDateLabel = formatDueDate(task.due_date)
const isOverdue = task.due_date && isPast(parseISO(task.due_date)) && !isToday(parseISO(task.due_date)) && !task.completed
const handleCheck = (e) => {
e.stopPropagation()
if (isLoading) return
if (!task.completed) {
setShowConfetti(true)
setTimeout(() => setShowConfetti(false), 1000)
}
onToggle()
}
return (
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, x: -100 }}
transition={{ delay: index * 0.05 }}
className="card p-4 relative overflow-hidden"
>
{showConfetti && (
<motion.div
initial={{ opacity: 1 }}
animate={{ opacity: 0 }}
transition={{ duration: 1 }}
className="absolute inset-0 pointer-events-none"
>
{[...Array(6)].map((_, i) => (
<motion.div
key={i}
initial={{ x: '50%', y: '50%', scale: 0 }}
animate={{
x: `${Math.random() * 100}%`,
y: `${Math.random() * 100}%`,
scale: [0, 1, 0]
}}
transition={{ duration: 0.6, delay: i * 0.05 }}
className="absolute w-2 h-2 rounded-full"
style={{ backgroundColor: task.color }}
/>
))}
</motion.div>
)}
<div className="flex items-center gap-3">
<motion.button
onClick={handleCheck}
disabled={isLoading}
whileTap={{ scale: 0.9 }}
className={clsx(
'w-10 h-10 rounded-xl flex items-center justify-center transition-all duration-300 flex-shrink-0',
task.completed
? 'bg-gradient-to-br from-green-400 to-green-500 shadow-lg shadow-green-400/30'
: 'border-2 hover:shadow-md'
)}
style={{
borderColor: task.completed ? undefined : task.color + '40',
backgroundColor: task.completed ? undefined : task.color + '10'
}}
>
{task.completed ? (
<motion.div
initial={{ scale: 0, rotate: -180 }}
animate={{ scale: 1, rotate: 0 }}
transition={{ type: 'spring', stiffness: 500 }}
>
<Check className="w-5 h-5 text-white" strokeWidth={3} />
</motion.div>
) : (
<span className="text-lg">{task.icon || '📋'}</span>
)}
</motion.button>
<div className="flex-1 min-w-0">
<h3 className={clsx(
"font-semibold truncate",
task.completed ? "text-gray-400 line-through" : "text-gray-900"
)}>{task.title}</h3>
{(dueDateLabel || isOverdue) && (
<span className={clsx(
'inline-flex items-center gap-1 text-xs font-medium mt-1',
isOverdue ? 'text-red-600' : 'text-gray-500'
)}>
{isOverdue && <AlertTriangle size={12} />}
<Calendar size={12} />
{dueDateLabel}
</span>
)}
</div>
{task.completed && (
<motion.button
initial={{ opacity: 0, scale: 0.8 }}
animate={{ opacity: 1, scale: 1 }}
onClick={handleCheck}
disabled={isLoading}
className="p-2 text-gray-400 hover:text-orange-500 hover:bg-orange-50 rounded-xl transition-all"
title="Отменить"
>
<Undo2 size={18} />
</motion.button>
)}
</div>
</motion.div>
)
}
function HabitCard({ habit, index, isCompleted, onToggle, isLoading }) {
const [showConfetti, setShowConfetti] = useState(false)
const handleCheck = (e) => {
e.stopPropagation()
if (isLoading) return
if (!isCompleted) {
setShowConfetti(true)
setTimeout(() => setShowConfetti(false), 1000)
}
onToggle()
}
return (
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, x: -100 }}
transition={{ delay: index * 0.05 }}
className="card p-5 relative overflow-hidden"
>
{showConfetti && (
<motion.div
initial={{ opacity: 1 }}
animate={{ opacity: 0 }}
transition={{ duration: 1 }}
className="absolute inset-0 pointer-events-none"
>
{[...Array(6)].map((_, i) => (
<motion.div
key={i}
initial={{ x: '50%', y: '50%', scale: 0 }}
animate={{
x: `${Math.random() * 100}%`,
y: `${Math.random() * 100}%`,
scale: [0, 1, 0]
}}
transition={{ duration: 0.6, delay: i * 0.05 }}
className="absolute w-2 h-2 rounded-full"
style={{ backgroundColor: habit.color }}
/>
))}
</motion.div>
)}
<div className="flex items-center gap-4">
<motion.button
onClick={handleCheck}
disabled={isLoading}
whileTap={{ scale: 0.9 }}
className={clsx(
'w-14 h-14 rounded-2xl flex items-center justify-center transition-all duration-300 relative flex-shrink-0',
isCompleted
? 'bg-gradient-to-br from-green-400 to-green-500 shadow-lg shadow-green-400/30'
: 'border-2 hover:shadow-md'
)}
style={{
borderColor: isCompleted ? undefined : habit.color + '40',
backgroundColor: isCompleted ? undefined : habit.color + '10'
}}
>
{isCompleted ? (
<motion.div
initial={{ scale: 0, rotate: -180 }}
animate={{ scale: 1, rotate: 0 }}
transition={{ type: 'spring', stiffness: 500 }}
>
<Check className="w-7 h-7 text-white" strokeWidth={3} />
</motion.div>
) : (
<span className="text-2xl">{habit.icon || '✨'}</span>
)}
</motion.button>
<div className="flex-1 min-w-0">
<h3 className={clsx(
"font-semibold text-lg truncate",
isCompleted ? "text-gray-400 line-through" : "text-gray-900"
)}>{habit.name}</h3>
{habit.description && (
<p className="text-sm text-gray-500 truncate">{habit.description}</p>
)}
</div>
{isCompleted && (
<motion.button
initial={{ opacity: 0, scale: 0.8 }}
animate={{ opacity: 1, scale: 1 }}
onClick={handleCheck}
disabled={isLoading}
className="p-2 text-gray-400 hover:text-orange-500 hover:bg-orange-50 rounded-xl transition-all"
title="Отменить"
>
<Undo2 size={20} />
</motion.button>
)}
</div>
</motion.div>
)
}