ci: add Gitea Actions workflows and placeholder tests
This commit is contained in:
@@ -1,51 +1,62 @@
|
||||
import { useState, useEffect, useMemo } from 'react'
|
||||
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 } from 'lucide-react'
|
||||
import { format, startOfWeek, differenceInDays, parseISO, isToday, isTomorrow, isPast } from 'date-fns'
|
||||
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) {
|
||||
const today = new Date()
|
||||
const dayOfWeek = today.getDay() || 7 // 1=Пн, 7=Вс (JS: 0=Вс -> 7)
|
||||
function shouldShowToday(habit, lastLogDate, freezes) {
|
||||
const today = startOfDay(new Date())
|
||||
const dayOfWeek = today.getDay() || 7
|
||||
|
||||
if (habit.frequency === 'daily') {
|
||||
return true
|
||||
}
|
||||
if (isHabitFrozenOnDate(habit, freezes, today)) return false
|
||||
|
||||
if (habit.frequency === 'weekly') {
|
||||
// Проверяем, выбран ли сегодняшний день
|
||||
if (habit.target_days && habit.target_days.includes(dayOfWeek)) {
|
||||
return true
|
||||
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
|
||||
|
||||
if (lastLog < weekStart) {
|
||||
return true // Не выполнялась на этой неделе
|
||||
}
|
||||
|
||||
return false
|
||||
const lastLog = typeof lastLogDate === "string" ? parseISO(lastLogDate) : lastLogDate
|
||||
return lastLog < weekStart
|
||||
}
|
||||
|
||||
// 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)
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
@@ -63,7 +74,10 @@ function formatDueDate(dateStr) {
|
||||
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()
|
||||
|
||||
@@ -85,6 +99,7 @@ export default function Home() {
|
||||
useEffect(() => {
|
||||
if (habits.length > 0) {
|
||||
loadTodayLogs()
|
||||
loadHabitFreezes()
|
||||
}
|
||||
}, [habits])
|
||||
|
||||
@@ -92,20 +107,18 @@ export default function Home() {
|
||||
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, 30)
|
||||
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
|
||||
}
|
||||
if (logDate === today) logsMap[habit.id] = lastLog.id
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Error loading logs for habit', habit.id, e)
|
||||
@@ -114,14 +127,31 @@ export default function Home() {
|
||||
|
||||
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) => habitsApi.log(habitId),
|
||||
onSuccess: (data, habitId) => {
|
||||
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')
|
||||
setTodayLogs(prev => ({ ...prev, [habitId]: data.id }))
|
||||
setLastLogDates(prev => ({ ...prev, [habitId]: today }))
|
||||
|
||||
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'] })
|
||||
},
|
||||
@@ -135,7 +165,6 @@ export default function Home() {
|
||||
delete newLogs[habitId]
|
||||
return newLogs
|
||||
})
|
||||
// Перезагружаем логи для обновления lastLogDate
|
||||
loadTodayLogs()
|
||||
queryClient.invalidateQueries({ queryKey: ['habits'] })
|
||||
queryClient.invalidateQueries({ queryKey: ['stats'] })
|
||||
@@ -162,50 +191,51 @@ export default function Home() {
|
||||
if (todayLogs[habitId]) {
|
||||
deleteLogMutation.mutate({ habitId, logId: todayLogs[habitId] })
|
||||
} else {
|
||||
logMutation.mutate(habitId)
|
||||
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)
|
||||
}
|
||||
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])
|
||||
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 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="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">
|
||||
<h1 className="text-lg font-display font-bold text-gray-900 dark:text-white">
|
||||
Привет, {user?.username}!
|
||||
</h1>
|
||||
<p className="text-sm text-gray-500 capitalize">{today}</p>
|
||||
<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 hover:bg-gray-100 rounded-xl transition-colors"
|
||||
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} />
|
||||
@@ -214,19 +244,13 @@ export default function Home() {
|
||||
</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"
|
||||
>
|
||||
{/* 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">Прогресс на сегодня</h2>
|
||||
<span className="text-sm font-medium text-primary-600">
|
||||
{completedCount} / {totalToday}
|
||||
</span>
|
||||
<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 rounded-full overflow-hidden">
|
||||
<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%' }}
|
||||
@@ -235,62 +259,55 @@ export default function Home() {
|
||||
/>
|
||||
</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 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"
|
||||
>
|
||||
<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>
|
||||
<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"
|
||||
>
|
||||
<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>
|
||||
<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">Задачи на сегодня</h2>
|
||||
<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 text-primary-600 rounded-xl hover:bg-primary-200 transition-colors"
|
||||
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>
|
||||
@@ -299,24 +316,17 @@ export default function Home() {
|
||||
{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="w-10 h-10 rounded-xl bg-gray-200 dark:bg-gray-700" />
|
||||
<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 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">Нет задач на сегодня</p>
|
||||
<button
|
||||
onClick={() => setShowCreateTask(true)}
|
||||
className="mt-3 text-sm text-primary-600 hover:text-primary-700 font-medium"
|
||||
>
|
||||
<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>
|
||||
@@ -324,13 +334,7 @@ export default function Home() {
|
||||
<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}
|
||||
/>
|
||||
<TaskCard key={task.id} task={task} index={index} onToggle={() => handleToggleTask(task)} isLoading={completeTaskMutation.isPending || uncompleteTaskMutation.isPending} />
|
||||
))}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
@@ -338,35 +342,31 @@ export default function Home() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Привычки на сегодня */}
|
||||
{/* Habits */}
|
||||
<div>
|
||||
<h2 className="text-xl font-display font-bold text-gray-900 mb-5">Привычки</h2>
|
||||
<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" />
|
||||
<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 rounded-lg w-1/2 mb-2" />
|
||||
<div className="h-4 bg-gray-200 rounded-lg w-1/3" />
|
||||
<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 flex items-center justify-center mx-auto mb-5">
|
||||
<Sparkles className="w-10 h-10 text-green-600" />
|
||||
<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 mb-2">Свободный день!</h3>
|
||||
<p className="text-gray-500">На сегодня нет запланированных привычек. Отдохни или добавь новую во вкладке "Привычки".</p>
|
||||
<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">
|
||||
@@ -378,6 +378,7 @@ export default function Home() {
|
||||
index={index}
|
||||
isCompleted={!!todayLogs[habit.id]}
|
||||
onToggle={() => handleToggleComplete(habit.id)}
|
||||
onLongPress={() => setLogHabitModal({ open: true, habit })}
|
||||
isLoading={logMutation.isPending || deleteLogMutation.isPending}
|
||||
/>
|
||||
))}
|
||||
@@ -388,10 +389,13 @@ export default function Home() {
|
||||
</main>
|
||||
|
||||
<Navigation />
|
||||
|
||||
<CreateTaskModal
|
||||
open={showCreateTask}
|
||||
onClose={() => setShowCreateTask(false)}
|
||||
<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>
|
||||
)
|
||||
@@ -421,21 +425,12 @@ function TaskCard({ task, index, onToggle, isLoading }) {
|
||||
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"
|
||||
>
|
||||
<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]
|
||||
}}
|
||||
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 }}
|
||||
@@ -455,17 +450,10 @@ function TaskCard({ task, index, onToggle, isLoading }) {
|
||||
? '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'
|
||||
}}
|
||||
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 }}
|
||||
>
|
||||
<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>
|
||||
) : (
|
||||
@@ -474,16 +462,9 @@ function TaskCard({ task, index, onToggle, isLoading }) {
|
||||
</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>
|
||||
|
||||
<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'
|
||||
)}>
|
||||
<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}
|
||||
@@ -492,14 +473,7 @@ function TaskCard({ task, index, onToggle, isLoading }) {
|
||||
</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="Отменить"
|
||||
>
|
||||
<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>
|
||||
)}
|
||||
@@ -508,19 +482,27 @@ function TaskCard({ task, index, onToggle, isLoading }) {
|
||||
)
|
||||
}
|
||||
|
||||
function HabitCard({ habit, index, isCompleted, onToggle, isLoading }) {
|
||||
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) return
|
||||
if (!isCompleted) {
|
||||
setShowConfetti(true)
|
||||
setTimeout(() => setShowConfetti(false), 1000)
|
||||
}
|
||||
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 }}
|
||||
@@ -528,23 +510,15 @@ function HabitCard({ habit, index, isCompleted, onToggle, isLoading }) {
|
||||
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"
|
||||
>
|
||||
<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]
|
||||
}}
|
||||
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 }}
|
||||
@@ -556,6 +530,11 @@ function HabitCard({ habit, index, isCompleted, onToggle, isLoading }) {
|
||||
<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(
|
||||
@@ -564,17 +543,10 @@ function HabitCard({ habit, index, isCompleted, onToggle, isLoading }) {
|
||||
? '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'
|
||||
}}
|
||||
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 }}
|
||||
>
|
||||
<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>
|
||||
) : (
|
||||
@@ -583,27 +555,20 @@ function HabitCard({ habit, index, isCompleted, onToggle, isLoading }) {
|
||||
</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>
|
||||
)}
|
||||
<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>
|
||||
|
||||
{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 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>
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user