Files
pulse-web/src/pages/Home.jsx
Cosmo ec6993de98
Some checks failed
Deploy Production / deploy (push) Failing after 1m45s
CI / ci (push) Failing after 30s
ci: add Gitea Actions workflows and placeholder tests
2026-03-01 00:04:14 +00:00

576 lines
24 KiB
JavaScript
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.
import { useState, useEffect, useMemo, useRef } 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, Snowflake } from 'lucide-react'
import { format, startOfWeek, differenceInDays, parseISO, isToday, isTomorrow, isPast, startOfDay, isBefore, isAfter } 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 LogHabitModal from '../components/LogHabitModal'
import clsx from 'clsx'
// Check if habit is frozen on a specific date
function isHabitFrozenOnDate(habit, freezes, date) {
if (!freezes || freezes.length === 0) return false
const checkDate = startOfDay(date)
return freezes.some(freeze => {
const start = startOfDay(parseISO(freeze.start_date))
const end = startOfDay(parseISO(freeze.end_date))
return !isBefore(checkDate, start) && !isAfter(checkDate, end)
})
}
// Определение "сегодняшних" привычек
function shouldShowToday(habit, lastLogDate, freezes) {
const today = startOfDay(new Date())
const dayOfWeek = today.getDay() || 7
if (isHabitFrozenOnDate(habit, freezes, today)) return false
const startDate = habit.start_date
? startOfDay(parseISO(habit.start_date))
: startOfDay(parseISO(habit.created_at))
if (today < startDate) return false
if (habit.frequency === "daily") return true
if (habit.frequency === "weekly") {
if (habit.target_days && habit.target_days.length > 0) {
return habit.target_days.includes(dayOfWeek)
}
if (!lastLogDate) return true
const weekStart = startOfWeek(today, { weekStartsOn: 1 })
const lastLog = typeof lastLogDate === "string" ? parseISO(lastLogDate) : lastLogDate
return lastLog < weekStart
}
if (habit.frequency === "interval" && habit.target_count > 0) {
const daysSinceStart = differenceInDays(today, startDate)
return daysSinceStart % habit.target_count === 0
}
if (habit.frequency === "custom" && habit.target_count > 0) {
if (!lastLogDate) return today >= startDate
const lastLog = typeof lastLogDate === "string" ? parseISO(lastLogDate) : lastLogDate
const daysSinceLastLog = differenceInDays(today, startOfDay(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 [habitFreezes, setHabitFreezes] = useState({})
const [habitLogs, setHabitLogs] = useState({})
const [showCreateTask, setShowCreateTask] = useState(false)
const [logHabitModal, setLogHabitModal] = useState({ open: false, habit: null })
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()
loadHabitFreezes()
}
}, [habits])
const loadTodayLogs = async () => {
const today = format(new Date(), 'yyyy-MM-dd')
const logsMap = {}
const lastDates = {}
const allLogs = {}
await Promise.all(habits.map(async (habit) => {
try {
const logs = await habitsApi.getLogs(habit.id, 90)
allLogs[habit.id] = logs.map(l => l.date)
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)
setHabitLogs(allLogs)
}
const loadHabitFreezes = async () => {
const freezesMap = {}
await Promise.all(habits.map(async (habit) => {
try {
const freezes = await habitsApi.getFreezes(habit.id)
freezesMap[habit.id] = freezes
} catch (e) {
freezesMap[habit.id] = []
}
}))
setHabitFreezes(freezesMap)
}
const logMutation = useMutation({
mutationFn: ({ habitId, date }) => habitsApi.log(habitId, date ? { date } : {}),
onSuccess: (data, { habitId, date }) => {
const logDate = date || format(new Date(), 'yyyy-MM-dd')
const today = format(new Date(), 'yyyy-MM-dd')
if (logDate === today) setTodayLogs(prev => ({ ...prev, [habitId]: data.id }))
setLastLogDates(prev => ({ ...prev, [habitId]: logDate }))
setHabitLogs(prev => ({ ...prev, [habitId]: [...(prev[habitId] || []), logDate] }))
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
})
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 handleLogHabitDate = async (habitId, date) => {
await logMutation.mutateAsync({ habitId, date })
}
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], habitFreezes[habit.id]))
}, [habits, lastLogDates, habitFreezes])
const frozenHabits = useMemo(() => {
const today = startOfDay(new Date())
return habits.filter(habit => isHabitFrozenOnDate(habit, habitFreezes[habit.id], today))
}, [habits, habitFreezes])
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 dark:bg-gray-950 gradient-mesh pb-24 transition-colors duration-300">
<header className="bg-white/70 dark:bg-gray-900/70 backdrop-blur-xl border-b border-gray-100/50 dark:border-gray-800 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 dark:text-white">
Привет, {user?.username}!
</h1>
<p className="text-sm text-gray-500 dark:text-gray-400 capitalize">{today}</p>
</div>
</div>
<button
onClick={logout}
className="p-2 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-800 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">
{/* Progress */}
<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 dark:text-white">Прогресс на сегодня</h2>
<span className="text-sm font-medium text-primary-600 dark:text-primary-400">{completedCount} / {totalToday}</span>
</div>
<div className="h-3 bg-gray-100 dark:bg-gray-800 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 dark:text-green-400 mt-2 font-medium">
🎉 Все привычки выполнены!
</motion.p>
)}
{frozenHabits.length > 0 && (
<div className="flex items-center gap-2 mt-2 text-sm text-cyan-600 dark:text-cyan-400">
<Snowflake size={14} />
<span>{frozenHabits.length} привычек на паузе</span>
</div>
)}
</motion.div>
{/* Stats */}
{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 dark:text-white">{stats.today_completed}</p>
<p className="text-sm text-gray-500 dark:text-gray-400">Выполнено</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 dark:text-white">{stats.active_habits}</p>
<p className="text-sm text-gray-500 dark:text-gray-400">Активных</p>
</div>
</div>
</motion.div>
</div>
)}
{/* Tasks */}
{(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 dark:text-white">Задачи на сегодня</h2>
<button
onClick={() => setShowCreateTask(true)}
className="p-2 bg-primary-100 dark:bg-primary-900/30 text-primary-600 dark:text-primary-400 rounded-xl hover:bg-primary-200 dark:hover:bg-primary-800/40 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 dark:bg-gray-700" />
<div className="flex-1">
<div className="h-5 bg-gray-200 dark:bg-gray-700 rounded-lg w-3/4 mb-2" />
<div className="h-4 bg-gray-200 dark:bg-gray-700 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 dark:text-gray-400">Нет задач на сегодня</p>
<button onClick={() => setShowCreateTask(true)} className="mt-3 text-sm text-primary-600 dark:text-primary-400 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>
)}
{/* Habits */}
<div>
<h2 className="text-xl font-display font-bold text-gray-900 dark:text-white 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 dark:bg-gray-700" />
<div className="flex-1">
<div className="h-5 bg-gray-200 dark:bg-gray-700 rounded-lg w-1/2 mb-2" />
<div className="h-4 bg-gray-200 dark:bg-gray-700 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 dark:from-green-900/30 dark:to-green-800/30 flex items-center justify-center mx-auto mb-5">
<Sparkles className="w-10 h-10 text-green-600 dark:text-green-400" />
</div>
<h3 className="text-xl font-display font-bold text-gray-900 dark:text-white mb-2">Свободный день!</h3>
<p className="text-gray-500 dark:text-gray-400">На сегодня нет запланированных привычек.</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)}
onLongPress={() => setLogHabitModal({ open: true, habit })}
isLoading={logMutation.isPending || deleteLogMutation.isPending}
/>
))}
</AnimatePresence>
</div>
)}
</div>
</main>
<Navigation />
<CreateTaskModal open={showCreateTask} onClose={() => setShowCreateTask(false)} />
<LogHabitModal
open={logHabitModal.open}
onClose={() => setLogHabitModal({ open: false, habit: null })}
habit={logHabitModal.habit}
completedDates={habitLogs[logHabitModal.habit?.id] || []}
onLogDate={handleLogHabitDate}
/>
</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 dark:text-white")}>{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 dark:text-gray-400')}>
{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 dark:hover:bg-orange-900/20 rounded-xl transition-all" title="Отменить">
<Undo2 size={18} />
</motion.button>
)}
</div>
</motion.div>
)
}
function HabitCard({ habit, index, isCompleted, onToggle, onLongPress, isLoading }) {
const [showConfetti, setShowConfetti] = useState(false)
const longPressTimer = useRef(null)
const isLongPress = useRef(false)
const handleTouchStart = () => {
isLongPress.current = false
longPressTimer.current = setTimeout(() => { isLongPress.current = true; onLongPress() }, 500)
}
const handleTouchEnd = () => { if (longPressTimer.current) clearTimeout(longPressTimer.current) }
const handleCheck = (e) => {
e.stopPropagation()
if (isLoading || isLongPress.current) return
if (!isCompleted) { setShowConfetti(true); setTimeout(() => setShowConfetti(false), 1000) }
onToggle()
}
const handleContextMenu = (e) => { e.preventDefault(); onLongPress() }
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"
onContextMenu={handleContextMenu}
>
{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}
onTouchStart={handleTouchStart}
onTouchEnd={handleTouchEnd}
onMouseDown={handleTouchStart}
onMouseUp={handleTouchEnd}
onMouseLeave={handleTouchEnd}
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 dark:text-white")}>{habit.name}</h3>
{habit.description && <p className="text-sm text-gray-500 dark:text-gray-400 truncate">{habit.description}</p>}
</div>
<div className="flex items-center gap-2">
<button onClick={(e) => { e.stopPropagation(); onLongPress() }} className="p-2 text-gray-400 hover:text-primary-500 hover:bg-primary-50 dark:hover:bg-primary-900/20 rounded-xl transition-all" title="Отметить за другой день">
<Calendar size={20} />
</button>
{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 dark:hover:bg-orange-900/20 rounded-xl transition-all" title="Отменить">
<Undo2 size={20} />
</motion.button>
)}
</div>
</div>
</motion.div>
)
}