ci: add Gitea Actions workflows and placeholder tests
Some checks failed
Deploy Production / deploy (push) Failing after 1m45s
CI / ci (push) Failing after 30s

This commit is contained in:
Cosmo
2026-03-01 00:04:14 +00:00
parent b7ce5ab1fb
commit ec6993de98
53 changed files with 65421 additions and 1263 deletions

View File

@@ -6,6 +6,7 @@ import Register from "./pages/Register"
import Home from "./pages/Home"
import Habits from "./pages/Habits"
import Tasks from "./pages/Tasks"
import Savings from "./pages/Savings"
import VerifyEmail from "./pages/VerifyEmail"
import ResetPassword from "./pages/ResetPassword"
import ForgotPassword from "./pages/ForgotPassword"
@@ -107,6 +108,14 @@ export default function App() {
</ProtectedRoute>
}
/>
<Route
path="/savings"
element={
<ProtectedRoute>
<Savings />
</ProtectedRoute>
}
/>
<Route
path="/stats"
element={

View File

@@ -0,0 +1,7 @@
import { describe, it, expect } from 'vitest';
describe('App', () => {
it('should pass basic test', () => {
expect(1 + 1).toBe(2);
});
});

View File

@@ -13,4 +13,9 @@ export const habitsApi = {
getStats: () => api.get('/habits/stats').then(r => r.data),
getHabitStats: (id) => api.get(`/habits/${id}/stats`).then(r => r.data),
// Freezes
getFreezes: (habitId) => api.get(`/habits/${habitId}/freezes`).then(r => r.data),
addFreeze: (habitId, data) => api.post(`/habits/${habitId}/freezes`, data).then(r => r.data),
deleteFreeze: (habitId, freezeId) => api.delete(`/habits/${habitId}/freezes/${freezeId}`),
}

50
src/api/savings.js Normal file
View File

@@ -0,0 +1,50 @@
import api from "./client"
export const savingsApi = {
// Categories
listCategories: () => api.get("/savings/categories").then((r) => r.data),
getCategory: (id) => api.get(`/savings/categories/${id}`).then((r) => r.data),
createCategory: (data) => api.post("/savings/categories", data).then((r) => r.data),
updateCategory: (id, data) =>
api.put(`/savings/categories/${id}`, data).then((r) => r.data),
deleteCategory: (id) => api.delete(`/savings/categories/${id}`),
// Transactions
listTransactions: (categoryId, limit = 100, offset = 0) => {
let url = `/savings/transactions?limit=${limit}&offset=${offset}`
if (categoryId) url += `&category_id=${categoryId}`
return api.get(url).then((r) => r.data)
},
createTransaction: (data) =>
api.post("/savings/transactions", data).then((r) => r.data),
updateTransaction: (id, data) =>
api.put(`/savings/transactions/${id}`, data).then((r) => r.data),
deleteTransaction: (id) => api.delete(`/savings/transactions/${id}`),
// Stats
getStats: () => api.get("/savings/stats").then((r) => r.data),
// Members
getMembers: (categoryId) =>
api.get(`/savings/categories/${categoryId}/members`).then((r) => r.data),
addMember: (categoryId, userId) =>
api
.post(`/savings/categories/${categoryId}/members`, { user_id: userId })
.then((r) => r.data),
removeMember: (categoryId, userId) =>
api.delete(`/savings/categories/${categoryId}/members/${userId}`),
// Recurring Plans
getRecurringPlans: (categoryId) =>
api
.get(`/savings/categories/${categoryId}/recurring-plans`)
.then((r) => r.data),
createRecurringPlan: (categoryId, data) =>
api
.post(`/savings/categories/${categoryId}/recurring-plans`, data)
.then((r) => r.data),
updateRecurringPlan: (planId, data) =>
api.put(`/savings/recurring-plans/${planId}`, data).then((r) => r.data),
deleteRecurringPlan: (planId) =>
api.delete(`/savings/recurring-plans/${planId}`),
}

View File

@@ -1,8 +1,9 @@
import { useState } from "react"
import { motion, AnimatePresence } from "framer-motion"
import { X, ChevronDown, ChevronUp, Clock } from "lucide-react"
import { X, ChevronDown, ChevronUp, Clock, Calendar } from "lucide-react"
import { useMutation, useQueryClient } from "@tanstack/react-query"
import { habitsApi } from "../api/habits"
import { format } from "date-fns"
import clsx from "clsx"
const COLORS = [
@@ -16,7 +17,7 @@ const ICON_CATEGORIES = [
{ name: "Продуктивность", icons: ["📚", "📖", "✏️", "💻", "🎯", "📝", "📅", "⏰"] },
{ name: "Дом", icons: ["🏠", "🧹", "🧺", "🍳", "🛒", "🔧", "🪴"] },
{ name: "Финансы", icons: ["💰", "💳", "📊", "💵", "🏦"] },
{ name: "Социальное", icons: ["👥", "💬", "📞", "👨👩👧👦", "❤️"] },
{ name: "Социальное", icons: ["👥", "💬", "📞", "👪", "❤️"] },
{ name: "Хобби", icons: ["🎨", "🎵", "🎸", "🎮", "📷", "✈️", "🚗"] },
{ name: "Еда/вода", icons: ["🥗", "🍎", "🥤", "☕", "🍽️", "💧"] },
{ name: "Разное", icons: ["⭐", "🎉", "✨", "🔥", "🌟", "💎", "🎁"] },
@@ -39,7 +40,9 @@ export default function CreateHabitModal({ open, onClose }) {
const [icon, setIcon] = useState("✨")
const [frequency, setFrequency] = useState("daily")
const [targetDays, setTargetDays] = useState([1, 2, 3, 4, 5, 6, 7])
const [intervalDays, setIntervalDays] = useState(2)
const [reminderTime, setReminderTime] = useState("")
const [startDate, setStartDate] = useState(format(new Date(), "yyyy-MM-dd"))
const [error, setError] = useState("")
const [showAllIcons, setShowAllIcons] = useState(false)
@@ -64,7 +67,9 @@ export default function CreateHabitModal({ open, onClose }) {
setIcon("✨")
setFrequency("daily")
setTargetDays([1, 2, 3, 4, 5, 6, 7])
setIntervalDays(2)
setReminderTime("")
setStartDate(format(new Date(), "yyyy-MM-dd"))
setError("")
setShowAllIcons(false)
onClose()
@@ -80,11 +85,19 @@ export default function CreateHabitModal({ open, onClose }) {
setError("Выбери хотя бы один день недели")
return
}
const interval = parseInt(intervalDays) || 0
if (frequency === "interval" && (interval < 2 || interval > 30)) {
setError("Интервал должен быть от 2 до 30 дней")
return
}
const data = { name, description, color, icon, frequency }
const data = { name, description, color, icon, frequency, start_date: startDate }
if (frequency === "weekly") {
data.target_days = targetDays
}
if (frequency === "interval") {
data.target_count = parseInt(intervalDays)
}
if (reminderTime) {
data.reminder_time = reminderTime
}
@@ -119,12 +132,12 @@ export default function CreateHabitModal({ open, onClose }) {
exit={{ opacity: 0, y: 100 }}
className="fixed bottom-0 left-0 right-0 z-50 p-4 sm:p-0 sm:flex sm:items-center sm:justify-center sm:inset-0"
>
<div className="bg-white rounded-2xl sm:rounded-2xl w-full max-w-md max-h-[85vh] overflow-auto">
<div className="sticky top-0 bg-white border-b border-gray-100 px-4 py-3 flex items-center justify-between">
<div className="bg-white dark:bg-gray-900 rounded-2xl sm:rounded-2xl w-full max-w-md max-h-[85vh] overflow-auto">
<div className="sticky top-0 bg-white dark:bg-gray-900 border-b border-gray-100 dark:border-gray-800 px-4 py-3 flex items-center justify-between">
<h2 className="text-lg font-semibold">Новая привычка</h2>
<button
onClick={handleClose}
className="p-2 text-gray-400 hover:text-gray-600 rounded-xl hover:bg-gray-100"
className="p-2 text-gray-400 dark:text-gray-500 hover:text-gray-600 rounded-xl hover:bg-gray-100 dark:hover:bg-gray-800 dark:bg-gray-800"
>
<X size={20} />
</button>
@@ -138,7 +151,7 @@ export default function CreateHabitModal({ open, onClose }) {
)}
<div>
<label className="block text-sm font-medium text-gray-700 mb-1.5">
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1.5">
Название
</label>
<input
@@ -152,7 +165,7 @@ export default function CreateHabitModal({ open, onClose }) {
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1.5">
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1.5">
Описание (опционально)
</label>
<input
@@ -165,7 +178,7 @@ export default function CreateHabitModal({ open, onClose }) {
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Периодичность
</label>
<div className="flex gap-2">
@@ -173,10 +186,10 @@ export default function CreateHabitModal({ open, onClose }) {
type="button"
onClick={() => setFrequency("daily")}
className={clsx(
"flex-1 py-2.5 px-4 rounded-xl font-medium text-sm transition-all",
"flex-1 py-2.5 px-3 rounded-xl font-medium text-sm transition-all",
frequency === "daily"
? "bg-primary-500 text-white shadow-lg shadow-primary-500/30"
: "bg-gray-100 text-gray-600 hover:bg-gray-200"
: "bg-gray-100 dark:bg-gray-800 text-gray-600 hover:bg-gray-200"
)}
>
Ежедневно
@@ -185,13 +198,25 @@ export default function CreateHabitModal({ open, onClose }) {
type="button"
onClick={() => setFrequency("weekly")}
className={clsx(
"flex-1 py-2.5 px-4 rounded-xl font-medium text-sm transition-all",
"flex-1 py-2.5 px-3 rounded-xl font-medium text-sm transition-all",
frequency === "weekly"
? "bg-primary-500 text-white shadow-lg shadow-primary-500/30"
: "bg-gray-100 text-gray-600 hover:bg-gray-200"
: "bg-gray-100 dark:bg-gray-800 text-gray-600 hover:bg-gray-200"
)}
>
По дням недели
По дням
</button>
<button
type="button"
onClick={() => setFrequency("interval")}
className={clsx(
"flex-1 py-2.5 px-3 rounded-xl font-medium text-sm transition-all",
frequency === "interval"
? "bg-primary-500 text-white shadow-lg shadow-primary-500/30"
: "bg-gray-100 dark:bg-gray-800 text-gray-600 hover:bg-gray-200"
)}
>
Интервал
</button>
</div>
</div>
@@ -202,7 +227,7 @@ export default function CreateHabitModal({ open, onClose }) {
animate={{ opacity: 1, height: "auto" }}
exit={{ opacity: 0, height: 0 }}
>
<label className="block text-sm font-medium text-gray-700 mb-2">
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Дни недели
</label>
<div className="flex gap-1.5">
@@ -215,7 +240,7 @@ export default function CreateHabitModal({ open, onClose }) {
"flex-1 py-2 rounded-lg font-medium text-sm transition-all",
targetDays.includes(day.id)
? "bg-primary-500 text-white shadow-md"
: "bg-gray-100 text-gray-500 hover:bg-gray-200"
: "bg-gray-100 dark:bg-gray-800 text-gray-500 dark:text-gray-400 dark:text-gray-500 hover:bg-gray-200"
)}
>
{day.short}
@@ -225,12 +250,53 @@ export default function CreateHabitModal({ open, onClose }) {
</motion.div>
)}
{frequency === "interval" && (
<motion.div
initial={{ opacity: 0, height: 0 }}
animate={{ opacity: 1, height: "auto" }}
exit={{ opacity: 0, height: 0 }}
>
<div className="flex items-center gap-3">
<span className="text-gray-500 dark:text-gray-400 dark:text-gray-500">Каждые</span>
<input
type="number"
min="2"
max="30"
value={intervalDays}
onChange={(e) => setIntervalDays(e.target.value === "" ? "" : parseInt(e.target.value) || "")}
className="input w-20 text-center"
/>
<span className="text-gray-500 dark:text-gray-400 dark:text-gray-500">дней</span>
</div>
</motion.div>
)}
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Дата начала
</label>
<div className="relative">
<Calendar className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-400 dark:text-gray-500" size={18} />
<input
type="date"
value={startDate}
onChange={(e) => setStartDate(e.target.value)}
className="input pl-10"
/>
</div>
<p className="text-xs text-gray-500 dark:text-gray-400 dark:text-gray-500 mt-1">
{frequency === "interval"
? "Интервал считается от этой даты"
: "Привычка появится начиная с этой даты"}
</p>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Напоминание (опционально)
</label>
<div className="relative">
<Clock className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-400" size={18} />
<Clock className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-400 dark:text-gray-500" size={18} />
<input
type="time"
value={reminderTime}
@@ -238,13 +304,13 @@ export default function CreateHabitModal({ open, onClose }) {
className="input pl-10"
/>
</div>
<p className="text-xs text-gray-500 mt-1">
<p className="text-xs text-gray-500 dark:text-gray-400 dark:text-gray-500 mt-1">
Получишь напоминание в Telegram в указанное время
</p>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Иконка
</label>
<div className="flex flex-wrap gap-2">
@@ -257,7 +323,7 @@ export default function CreateHabitModal({ open, onClose }) {
"w-10 h-10 rounded-xl flex items-center justify-center text-xl transition-all",
icon === ic
? "bg-primary-100 ring-2 ring-primary-500"
: "bg-gray-100 hover:bg-gray-200"
: "bg-gray-100 dark:bg-gray-800 hover:bg-gray-200"
)}
>
{ic}
@@ -284,7 +350,7 @@ export default function CreateHabitModal({ open, onClose }) {
>
{ICON_CATEGORIES.map((category) => (
<div key={category.name}>
<p className="text-xs text-gray-500 mb-1.5">{category.name}</p>
<p className="text-xs text-gray-500 dark:text-gray-400 dark:text-gray-500 mb-1.5">{category.name}</p>
<div className="flex flex-wrap gap-1.5">
{category.icons.map((ic) => (
<button
@@ -295,7 +361,7 @@ export default function CreateHabitModal({ open, onClose }) {
"w-9 h-9 rounded-lg flex items-center justify-center text-lg transition-all",
icon === ic
? "bg-primary-100 ring-2 ring-primary-500"
: "bg-gray-100 hover:bg-gray-200"
: "bg-gray-100 dark:bg-gray-800 hover:bg-gray-200"
)}
>
{ic}
@@ -310,7 +376,7 @@ export default function CreateHabitModal({ open, onClose }) {
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Цвет
</label>
<div className="flex flex-wrap gap-2">

View File

@@ -1,6 +1,6 @@
import { useState } from "react"
import { motion, AnimatePresence } from "framer-motion"
import { X, ChevronDown, ChevronUp, Calendar, Clock } from "lucide-react"
import { X, ChevronDown, ChevronUp, Calendar, Clock, Repeat } from "lucide-react"
import { useMutation, useQueryClient } from "@tanstack/react-query"
import { tasksApi } from "../api/tasks"
import clsx from "clsx"
@@ -21,12 +21,19 @@ const ICON_CATEGORIES = [
]
const PRIORITIES = [
{ value: 0, label: "Без приоритета", color: "bg-gray-100 text-gray-600" },
{ value: 0, label: "Без приоритета", color: "bg-gray-100 dark:bg-gray-800 text-gray-600" },
{ value: 1, label: "Низкий", color: "bg-blue-100 text-blue-700" },
{ value: 2, label: "Средний", color: "bg-yellow-100 text-yellow-700" },
{ value: 3, label: "Высокий", color: "bg-red-100 text-red-700" },
]
const RECURRENCE_TYPES = [
{ value: "daily", label: "Ежедневно" },
{ value: "weekly", label: "Еженедельно" },
{ value: "monthly", label: "Ежемесячно" },
{ value: "custom", label: "Каждые N дней" },
]
export default function CreateTaskModal({ open, onClose, defaultDueDate = null }) {
const today = format(new Date(), "yyyy-MM-dd")
const tomorrow = format(addDays(new Date(), 1), "yyyy-MM-dd")
@@ -41,6 +48,12 @@ export default function CreateTaskModal({ open, onClose, defaultDueDate = null }
const [error, setError] = useState("")
const [showAllIcons, setShowAllIcons] = useState(false)
// Recurring state
const [isRecurring, setIsRecurring] = useState(false)
const [recurrenceType, setRecurrenceType] = useState("daily")
const [recurrenceInterval, setRecurrenceInterval] = useState(1)
const [recurrenceEndDate, setRecurrenceEndDate] = useState("")
const queryClient = useQueryClient()
const mutation = useMutation({
@@ -65,6 +78,10 @@ export default function CreateTaskModal({ open, onClose, defaultDueDate = null }
setReminderTime("")
setError("")
setShowAllIcons(false)
setIsRecurring(false)
setRecurrenceType("daily")
setRecurrenceInterval(1)
setRecurrenceEndDate("")
onClose()
}
@@ -75,7 +92,7 @@ export default function CreateTaskModal({ open, onClose, defaultDueDate = null }
return
}
mutation.mutate({
const data = {
title,
description,
color,
@@ -83,7 +100,16 @@ export default function CreateTaskModal({ open, onClose, defaultDueDate = null }
due_date: dueDate || null,
priority,
reminder_time: reminderTime || null,
})
is_recurring: isRecurring,
}
if (isRecurring) {
data.recurrence_type = recurrenceType
data.recurrence_interval = recurrenceType === "custom" ? recurrenceInterval : 1
data.recurrence_end_date = recurrenceEndDate || null
}
mutation.mutate(data)
}
const popularIcons = ["📋", "📝", "✅", "🎯", "💼", "🏠", "💰", "📞"]
@@ -105,12 +131,12 @@ export default function CreateTaskModal({ open, onClose, defaultDueDate = null }
exit={{ opacity: 0, y: 100 }}
className="fixed bottom-0 left-0 right-0 z-50 p-4 sm:p-0 sm:flex sm:items-center sm:justify-center sm:inset-0"
>
<div className="bg-white rounded-2xl sm:rounded-2xl w-full max-w-md max-h-[85vh] overflow-auto">
<div className="sticky top-0 bg-white border-b border-gray-100 px-4 py-3 flex items-center justify-between">
<div className="bg-white dark:bg-gray-900 rounded-2xl sm:rounded-2xl w-full max-w-md max-h-[85vh] overflow-auto">
<div className="sticky top-0 bg-white dark:bg-gray-900 border-b border-gray-100 dark:border-gray-800 px-4 py-3 flex items-center justify-between">
<h2 className="text-lg font-semibold">Новая задача</h2>
<button
onClick={handleClose}
className="p-2 text-gray-400 hover:text-gray-600 rounded-xl hover:bg-gray-100"
className="p-2 text-gray-400 dark:text-gray-500 hover:text-gray-600 rounded-xl hover:bg-gray-100 dark:hover:bg-gray-800 dark:bg-gray-800"
>
<X size={20} />
</button>
@@ -124,7 +150,7 @@ export default function CreateTaskModal({ open, onClose, defaultDueDate = null }
)}
<div>
<label className="block text-sm font-medium text-gray-700 mb-1.5">
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1.5">
Название
</label>
<input
@@ -138,7 +164,7 @@ export default function CreateTaskModal({ open, onClose, defaultDueDate = null }
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1.5">
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1.5">
Описание (опционально)
</label>
<textarea
@@ -150,7 +176,7 @@ export default function CreateTaskModal({ open, onClose, defaultDueDate = null }
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Срок выполнения
</label>
<div className="flex gap-2 mb-2">
@@ -161,7 +187,7 @@ export default function CreateTaskModal({ open, onClose, defaultDueDate = null }
"px-3 py-1.5 rounded-lg text-sm font-medium transition-all",
dueDate === today
? "bg-primary-500 text-white"
: "bg-gray-100 text-gray-600 hover:bg-gray-200"
: "bg-gray-100 dark:bg-gray-800 text-gray-600 hover:bg-gray-200"
)}
>
Сегодня
@@ -173,7 +199,7 @@ export default function CreateTaskModal({ open, onClose, defaultDueDate = null }
"px-3 py-1.5 rounded-lg text-sm font-medium transition-all",
dueDate === tomorrow
? "bg-primary-500 text-white"
: "bg-gray-100 text-gray-600 hover:bg-gray-200"
: "bg-gray-100 dark:bg-gray-800 text-gray-600 hover:bg-gray-200"
)}
>
Завтра
@@ -185,14 +211,14 @@ export default function CreateTaskModal({ open, onClose, defaultDueDate = null }
"px-3 py-1.5 rounded-lg text-sm font-medium transition-all",
!dueDate
? "bg-primary-500 text-white"
: "bg-gray-100 text-gray-600 hover:bg-gray-200"
: "bg-gray-100 dark:bg-gray-800 text-gray-600 hover:bg-gray-200"
)}
>
Без срока
</button>
</div>
<div className="relative">
<Calendar className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-400" size={18} />
<Calendar className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-400 dark:text-gray-500" size={18} />
<input
type="date"
value={dueDate}
@@ -202,12 +228,92 @@ export default function CreateTaskModal({ open, onClose, defaultDueDate = null }
</div>
</div>
{/* Recurring Section */}
<div className="border-t border-gray-100 dark:border-gray-800 pt-4">
<div className="flex items-center justify-between mb-3">
<div className="flex items-center gap-2">
<Repeat size={18} className={isRecurring ? "text-primary-500" : "text-gray-400 dark:text-gray-500"} />
<label className="text-sm font-medium text-gray-700 dark:text-gray-300">Повторять</label>
</div>
<button
type="button"
onClick={() => setIsRecurring(!isRecurring)}
className={clsx(
"w-12 h-6 rounded-full transition-all relative",
isRecurring ? "bg-primary-500" : "bg-gray-200"
)}
>
<div className={clsx(
"absolute top-1 w-4 h-4 rounded-full bg-white dark:bg-gray-900 shadow-sm transition-all",
isRecurring ? "right-1" : "left-1"
)} />
</button>
</div>
<AnimatePresence>
{isRecurring && (
<motion.div
initial={{ opacity: 0, height: 0 }}
animate={{ opacity: 1, height: "auto" }}
exit={{ opacity: 0, height: 0 }}
className="space-y-3"
>
<div className="flex flex-wrap gap-2">
{RECURRENCE_TYPES.map((type) => (
<button
key={type.value}
type="button"
onClick={() => setRecurrenceType(type.value)}
className={clsx(
"px-3 py-1.5 rounded-lg text-sm font-medium transition-all",
recurrenceType === type.value
? "bg-primary-500 text-white"
: "bg-gray-100 dark:bg-gray-800 text-gray-600 hover:bg-gray-200"
)}
>
{type.label}
</button>
))}
</div>
{recurrenceType === "custom" && (
<div className="flex items-center gap-2">
<span className="text-sm text-gray-600">Каждые</span>
<input
type="number"
min="1"
max="365"
value={recurrenceInterval}
onChange={(e) => setRecurrenceInterval(parseInt(e.target.value) || 1)}
className="input w-20 text-center"
/>
<span className="text-sm text-gray-600">дней</span>
</div>
)}
<div>
<label className="block text-xs text-gray-500 dark:text-gray-400 dark:text-gray-500 mb-1">
Повторять до (опционально)
</label>
<input
type="date"
value={recurrenceEndDate}
onChange={(e) => setRecurrenceEndDate(e.target.value)}
className="input"
min={dueDate || today}
/>
</div>
</motion.div>
)}
</AnimatePresence>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Напоминание (опционально)
</label>
<div className="relative">
<Clock className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-400" size={18} />
<Clock className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-400 dark:text-gray-500" size={18} />
<input
type="time"
value={reminderTime}
@@ -215,13 +321,13 @@ export default function CreateTaskModal({ open, onClose, defaultDueDate = null }
className="input pl-10"
/>
</div>
<p className="text-xs text-gray-500 mt-1">
<p className="text-xs text-gray-500 dark:text-gray-400 dark:text-gray-500 mt-1">
Получишь напоминание в Telegram в указанное время
</p>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Приоритет
</label>
<div className="flex gap-2 flex-wrap">
@@ -234,7 +340,7 @@ export default function CreateTaskModal({ open, onClose, defaultDueDate = null }
"px-3 py-1.5 rounded-lg text-sm font-medium transition-all",
priority === p.value
? p.color + " ring-2 ring-offset-1 ring-gray-400"
: "bg-gray-100 text-gray-600 hover:bg-gray-200"
: "bg-gray-100 dark:bg-gray-800 text-gray-600 hover:bg-gray-200"
)}
>
{p.label}
@@ -244,7 +350,7 @@ export default function CreateTaskModal({ open, onClose, defaultDueDate = null }
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Иконка
</label>
<div className="flex flex-wrap gap-2">
@@ -257,7 +363,7 @@ export default function CreateTaskModal({ open, onClose, defaultDueDate = null }
"w-10 h-10 rounded-xl flex items-center justify-center text-xl transition-all",
icon === ic
? "bg-primary-100 ring-2 ring-primary-500"
: "bg-gray-100 hover:bg-gray-200"
: "bg-gray-100 dark:bg-gray-800 hover:bg-gray-200"
)}
>
{ic}
@@ -284,7 +390,7 @@ export default function CreateTaskModal({ open, onClose, defaultDueDate = null }
>
{ICON_CATEGORIES.map((category) => (
<div key={category.name}>
<p className="text-xs text-gray-500 mb-1.5">{category.name}</p>
<p className="text-xs text-gray-500 dark:text-gray-400 dark:text-gray-500 mb-1.5">{category.name}</p>
<div className="flex flex-wrap gap-1.5">
{category.icons.map((ic) => (
<button
@@ -295,7 +401,7 @@ export default function CreateTaskModal({ open, onClose, defaultDueDate = null }
"w-9 h-9 rounded-lg flex items-center justify-center text-lg transition-all",
icon === ic
? "bg-primary-100 ring-2 ring-primary-500"
: "bg-gray-100 hover:bg-gray-200"
: "bg-gray-100 dark:bg-gray-800 hover:bg-gray-200"
)}
>
{ic}
@@ -310,7 +416,7 @@ export default function CreateTaskModal({ open, onClose, defaultDueDate = null }
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Цвет
</label>
<div className="flex flex-wrap gap-2">

View File

@@ -1,8 +1,10 @@
import { useState, useEffect } from "react"
import { motion, AnimatePresence } from "framer-motion"
import { X, Trash2, ChevronDown, ChevronUp, Clock } from "lucide-react"
import { useMutation, useQueryClient } from "@tanstack/react-query"
import { X, Trash2, ChevronDown, ChevronUp, Clock, Calendar, Snowflake, Plus } from "lucide-react"
import { useMutation, useQueryClient, useQuery } from "@tanstack/react-query"
import { habitsApi } from "../api/habits"
import { format, parseISO, isBefore, isAfter, startOfDay } from "date-fns"
import { ru } from "date-fns/locale"
import clsx from "clsx"
const COLORS = [
@@ -16,7 +18,7 @@ const ICON_CATEGORIES = [
{ name: "Продуктивность", icons: ["📚", "📖", "✏️", "💻", "🎯", "📝", "📅", "⏰"] },
{ name: "Дом", icons: ["🏠", "🧹", "🧺", "🍳", "🛒", "🔧", "🪴"] },
{ name: "Финансы", icons: ["💰", "💳", "📊", "💵", "🏦"] },
{ name: "Социальное", icons: ["👥", "💬", "📞", "👨👩👧👦", "❤️"] },
{ name: "Социальное", icons: ["👥", "💬", "📞", "👪", "❤️"] },
{ name: "Хобби", icons: ["🎨", "🎵", "🎸", "🎮", "📷", "✈️", "🚗"] },
{ name: "Еда/вода", icons: ["🥗", "🍎", "🥤", "☕", "🍽️", "💧"] },
{ name: "Разное", icons: ["⭐", "🎉", "✨", "🔥", "🌟", "💎", "🎁"] },
@@ -39,13 +41,27 @@ export default function EditHabitModal({ open, onClose, habit }) {
const [icon, setIcon] = useState("✨")
const [frequency, setFrequency] = useState("daily")
const [targetDays, setTargetDays] = useState([1, 2, 3, 4, 5, 6, 7])
const [intervalDays, setIntervalDays] = useState(3)
const [reminderTime, setReminderTime] = useState("")
const [startDate, setStartDate] = useState("")
const [error, setError] = useState("")
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false)
const [showAllIcons, setShowAllIcons] = useState(false)
const [showFreezes, setShowFreezes] = useState(false)
const [showAddFreeze, setShowAddFreeze] = useState(false)
const [freezeStart, setFreezeStart] = useState("")
const [freezeEnd, setFreezeEnd] = useState("")
const [freezeReason, setFreezeReason] = useState("")
const queryClient = useQueryClient()
// Load freezes for this habit
const { data: freezes = [], refetch: refetchFreezes } = useQuery({
queryKey: ['habit-freezes', habit?.id],
queryFn: () => habitsApi.getFreezes(habit.id),
enabled: !!habit?.id && open,
})
useEffect(() => {
if (habit && open) {
setName(habit.name || "")
@@ -54,10 +70,23 @@ export default function EditHabitModal({ open, onClose, habit }) {
setIcon(habit.icon || "✨")
setFrequency(habit.frequency || "daily")
setTargetDays(habit.target_days || [1, 2, 3, 4, 5, 6, 7])
setIntervalDays(habit.target_count || 3)
setReminderTime(habit.reminder_time || "")
if (habit.start_date) {
setStartDate(habit.start_date)
} else if (habit.created_at) {
setStartDate(format(parseISO(habit.created_at), "yyyy-MM-dd"))
} else {
setStartDate(format(new Date(), "yyyy-MM-dd"))
}
setError("")
setShowDeleteConfirm(false)
setShowAllIcons(false)
setShowFreezes(false)
setShowAddFreeze(false)
setFreezeStart("")
setFreezeEnd("")
setFreezeReason("")
}
}, [habit, open])
@@ -85,10 +114,35 @@ export default function EditHabitModal({ open, onClose, habit }) {
},
})
const addFreezeMutation = useMutation({
mutationFn: (data) => habitsApi.addFreeze(habit.id, data),
onSuccess: () => {
refetchFreezes()
queryClient.invalidateQueries({ queryKey: ["habits"] })
setShowAddFreeze(false)
setFreezeStart("")
setFreezeEnd("")
setFreezeReason("")
},
onError: (err) => {
setError(err.response?.data?.error || "Ошибка создания заморозки")
},
})
const deleteFreezeMutation = useMutation({
mutationFn: (freezeId) => habitsApi.deleteFreeze(habit.id, freezeId),
onSuccess: () => {
refetchFreezes()
queryClient.invalidateQueries({ queryKey: ["habits"] })
},
})
const handleClose = () => {
setError("")
setShowDeleteConfirm(false)
setShowAllIcons(false)
setShowFreezes(false)
setShowAddFreeze(false)
onClose()
}
@@ -102,11 +156,19 @@ export default function EditHabitModal({ open, onClose, habit }) {
setError("Выбери хотя бы один день недели")
return
}
const interval = parseInt(intervalDays) || 0
if (frequency === "interval" && (interval < 2 || interval > 30)) {
setError("Интервал должен быть от 2 до 30 дней")
return
}
const data = { name, description, color, icon, frequency }
const data = { name, description, color, icon, frequency, start_date: startDate }
if (frequency === "weekly") {
data.target_days = targetDays
}
if (frequency === "interval") {
data.target_count = parseInt(intervalDays)
}
data.reminder_time = reminderTime || null
updateMutation.mutate(data)
@@ -116,6 +178,23 @@ export default function EditHabitModal({ open, onClose, habit }) {
deleteMutation.mutate()
}
const handleAddFreeze = () => {
if (!freezeStart || !freezeEnd) {
setError("Укажи даты начала и окончания заморозки")
return
}
if (isBefore(parseISO(freezeEnd), parseISO(freezeStart))) {
setError("Дата окончания должна быть после даты начала")
return
}
setError("")
addFreezeMutation.mutate({
start_date: freezeStart,
end_date: freezeEnd,
reason: freezeReason,
})
}
const toggleDay = (dayId) => {
setTargetDays(prev =>
prev.includes(dayId)
@@ -124,6 +203,10 @@ export default function EditHabitModal({ open, onClose, habit }) {
)
}
const today = startOfDay(new Date())
const activeFreezes = freezes.filter(f => !isBefore(parseISO(f.end_date), today))
const pastFreezes = freezes.filter(f => isBefore(parseISO(f.end_date), today))
const popularIcons = ["✨", "💪", "📚", "🏃", "💧", "🧘", "💤", "🎯", "✏️", "🍎"]
if (!habit) return null
@@ -145,12 +228,12 @@ export default function EditHabitModal({ open, onClose, habit }) {
exit={{ opacity: 0, y: 100 }}
className="fixed bottom-0 left-0 right-0 z-50 p-4 sm:p-0 sm:flex sm:items-center sm:justify-center sm:inset-0"
>
<div className="bg-white rounded-2xl sm:rounded-2xl w-full max-w-md max-h-[85vh] overflow-auto">
<div className="sticky top-0 bg-white border-b border-gray-100 px-4 py-3 flex items-center justify-between">
<div className="bg-white dark:bg-gray-900 rounded-2xl sm:rounded-2xl w-full max-w-md max-h-[85vh] overflow-auto">
<div className="sticky top-0 bg-white dark:bg-gray-900 border-b border-gray-100 dark:border-gray-800 px-4 py-3 flex items-center justify-between z-10">
<h2 className="text-lg font-semibold">Редактировать привычку</h2>
<button
onClick={handleClose}
className="p-2 text-gray-400 hover:text-gray-600 rounded-xl hover:bg-gray-100"
className="p-2 text-gray-400 dark:text-gray-500 hover:text-gray-600 rounded-xl hover:bg-gray-100 dark:hover:bg-gray-800 dark:bg-gray-800"
>
<X size={20} />
</button>
@@ -161,14 +244,14 @@ export default function EditHabitModal({ open, onClose, habit }) {
<div className="w-16 h-16 rounded-full bg-red-100 flex items-center justify-center mx-auto mb-4">
<Trash2 className="w-8 h-8 text-red-500" />
</div>
<h3 className="text-lg font-semibold text-gray-900 mb-2">Удалить привычку?</h3>
<p className="text-gray-500 mb-6">
<h3 className="text-lg font-semibold text-gray-900 dark:text-white mb-2">Удалить привычку?</h3>
<p className="text-gray-500 dark:text-gray-400 dark:text-gray-500 mb-6">
Привычка "{habit.name}" и вся её история будут удалены безвозвратно.
</p>
<div className="flex gap-3">
<button
onClick={() => setShowDeleteConfirm(false)}
className="flex-1 btn bg-gray-100 text-gray-700 hover:bg-gray-200"
className="flex-1 btn bg-gray-100 dark:bg-gray-800 text-gray-700 dark:text-gray-300 hover:bg-gray-200"
>
Отмена
</button>
@@ -190,7 +273,7 @@ export default function EditHabitModal({ open, onClose, habit }) {
)}
<div>
<label className="block text-sm font-medium text-gray-700 mb-1.5">
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1.5">
Название
</label>
<input
@@ -203,7 +286,7 @@ export default function EditHabitModal({ open, onClose, habit }) {
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1.5">
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1.5">
Описание (опционально)
</label>
<input
@@ -216,7 +299,7 @@ export default function EditHabitModal({ open, onClose, habit }) {
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Периодичность
</label>
<div className="flex gap-2">
@@ -224,10 +307,10 @@ export default function EditHabitModal({ open, onClose, habit }) {
type="button"
onClick={() => setFrequency("daily")}
className={clsx(
"flex-1 py-2.5 px-4 rounded-xl font-medium text-sm transition-all",
"flex-1 py-2.5 px-3 rounded-xl font-medium text-sm transition-all",
frequency === "daily"
? "bg-primary-500 text-white shadow-lg shadow-primary-500/30"
: "bg-gray-100 text-gray-600 hover:bg-gray-200"
: "bg-gray-100 dark:bg-gray-800 text-gray-600 hover:bg-gray-200"
)}
>
Ежедневно
@@ -236,13 +319,25 @@ export default function EditHabitModal({ open, onClose, habit }) {
type="button"
onClick={() => setFrequency("weekly")}
className={clsx(
"flex-1 py-2.5 px-4 rounded-xl font-medium text-sm transition-all",
"flex-1 py-2.5 px-3 rounded-xl font-medium text-sm transition-all",
frequency === "weekly"
? "bg-primary-500 text-white shadow-lg shadow-primary-500/30"
: "bg-gray-100 text-gray-600 hover:bg-gray-200"
: "bg-gray-100 dark:bg-gray-800 text-gray-600 hover:bg-gray-200"
)}
>
По дням недели
По дням
</button>
<button
type="button"
onClick={() => setFrequency("interval")}
className={clsx(
"flex-1 py-2.5 px-3 rounded-xl font-medium text-sm transition-all",
frequency === "interval"
? "bg-primary-500 text-white shadow-lg shadow-primary-500/30"
: "bg-gray-100 dark:bg-gray-800 text-gray-600 hover:bg-gray-200"
)}
>
Интервал
</button>
</div>
</div>
@@ -253,7 +348,7 @@ export default function EditHabitModal({ open, onClose, habit }) {
animate={{ opacity: 1, height: "auto" }}
exit={{ opacity: 0, height: 0 }}
>
<label className="block text-sm font-medium text-gray-700 mb-2">
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Дни недели
</label>
<div className="flex gap-1.5">
@@ -266,7 +361,7 @@ export default function EditHabitModal({ open, onClose, habit }) {
"flex-1 py-2 rounded-lg font-medium text-sm transition-all",
targetDays.includes(day.id)
? "bg-primary-500 text-white shadow-md"
: "bg-gray-100 text-gray-500 hover:bg-gray-200"
: "bg-gray-100 dark:bg-gray-800 text-gray-500 dark:text-gray-400 dark:text-gray-500 hover:bg-gray-200"
)}
>
{day.short}
@@ -276,12 +371,53 @@ export default function EditHabitModal({ open, onClose, habit }) {
</motion.div>
)}
{frequency === "interval" && (
<motion.div
initial={{ opacity: 0, height: 0 }}
animate={{ opacity: 1, height: "auto" }}
exit={{ opacity: 0, height: 0 }}
>
<div className="flex items-center gap-3">
<span className="text-gray-500 dark:text-gray-400 dark:text-gray-500">Каждые</span>
<input
type="number"
min="2"
max="30"
value={intervalDays}
onChange={(e) => setIntervalDays(e.target.value === "" ? "" : parseInt(e.target.value) || "")}
className="input w-20 text-center"
/>
<span className="text-gray-500 dark:text-gray-400 dark:text-gray-500">дней</span>
</div>
</motion.div>
)}
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Дата начала
</label>
<div className="relative">
<Calendar className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-400 dark:text-gray-500" size={18} />
<input
type="date"
value={startDate}
onChange={(e) => setStartDate(e.target.value)}
className="input pl-10"
/>
</div>
<p className="text-xs text-gray-500 dark:text-gray-400 dark:text-gray-500 mt-1">
{frequency === "interval"
? "Интервал считается от этой даты"
: "Привычка появится начиная с этой даты"}
</p>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Напоминание (опционально)
</label>
<div className="relative">
<Clock className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-400" size={18} />
<Clock className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-400 dark:text-gray-500" size={18} />
<input
type="time"
value={reminderTime}
@@ -289,13 +425,180 @@ export default function EditHabitModal({ open, onClose, habit }) {
className="input pl-10"
/>
</div>
<p className="text-xs text-gray-500 mt-1">
<p className="text-xs text-gray-500 dark:text-gray-400 dark:text-gray-500 mt-1">
Получишь напоминание в Telegram в указанное время
</p>
</div>
{/* Freezes section */}
<div className="border-t pt-4">
<button
type="button"
onClick={() => setShowFreezes(!showFreezes)}
className="flex items-center justify-between w-full text-left"
>
<div className="flex items-center gap-2">
<Snowflake className="w-5 h-5 text-cyan-500" />
<span className="font-medium text-gray-700 dark:text-gray-300">Заморозки</span>
{activeFreezes.length > 0 && (
<span className="px-2 py-0.5 bg-cyan-100 text-cyan-700 rounded-full text-xs">
{activeFreezes.length}
</span>
)}
</div>
{showFreezes ? <ChevronUp size={18} /> : <ChevronDown size={18} />}
</button>
<p className="text-xs text-gray-500 dark:text-gray-400 dark:text-gray-500 mt-1">
Поставь привычку на паузу на время отпуска или болезни
</p>
<AnimatePresence>
{showFreezes && (
<motion.div
initial={{ opacity: 0, height: 0 }}
animate={{ opacity: 1, height: "auto" }}
exit={{ opacity: 0, height: 0 }}
className="mt-3 space-y-3"
>
{/* Active freezes */}
{activeFreezes.length > 0 && (
<div className="space-y-2">
{activeFreezes.map((freeze) => {
const isActive = !isBefore(parseISO(freeze.end_date), today) &&
!isAfter(parseISO(freeze.start_date), today)
return (
<div
key={freeze.id}
className={clsx(
"flex items-center justify-between p-3 rounded-xl",
isActive ? "bg-cyan-50 border border-cyan-200" : "bg-gray-50 dark:bg-gray-800"
)}
>
<div className="flex items-center gap-2">
<Snowflake className={clsx(
"w-4 h-4",
isActive ? "text-cyan-500" : "text-gray-400 dark:text-gray-500"
)} />
<div>
<p className="text-sm font-medium text-gray-700 dark:text-gray-300">
{format(parseISO(freeze.start_date), "d MMM", { locale: ru })} {format(parseISO(freeze.end_date), "d MMM yyyy", { locale: ru })}
</p>
{freeze.reason && (
<p className="text-xs text-gray-500 dark:text-gray-400 dark:text-gray-500">{freeze.reason}</p>
)}
</div>
{isActive && (
<span className="px-2 py-0.5 bg-cyan-200 text-cyan-800 rounded-full text-xs">
активна
</span>
)}
</div>
<button
type="button"
onClick={() => deleteFreezeMutation.mutate(freeze.id)}
className="p-1.5 text-gray-400 dark:text-gray-500 hover:text-red-500 hover:bg-red-50 rounded-lg"
>
<Trash2 size={16} />
</button>
</div>
)
})}
</div>
)}
{/* Add freeze form */}
{showAddFreeze ? (
<div className="p-3 bg-gray-50 dark:bg-gray-800 rounded-xl space-y-3">
<div className="grid grid-cols-2 gap-2">
<div>
<label className="text-xs text-gray-500 dark:text-gray-400 dark:text-gray-500">Начало</label>
<input
type="date"
value={freezeStart}
onChange={(e) => setFreezeStart(e.target.value)}
min={format(new Date(), "yyyy-MM-dd")}
className="input text-sm"
/>
</div>
<div>
<label className="text-xs text-gray-500 dark:text-gray-400 dark:text-gray-500">Окончание</label>
<input
type="date"
value={freezeEnd}
onChange={(e) => setFreezeEnd(e.target.value)}
min={freezeStart || format(new Date(), "yyyy-MM-dd")}
className="input text-sm"
/>
</div>
</div>
<input
type="text"
value={freezeReason}
onChange={(e) => setFreezeReason(e.target.value)}
placeholder="Причина (опционально)"
className="input text-sm"
/>
<div className="flex gap-2">
<button
type="button"
onClick={() => setShowAddFreeze(false)}
className="flex-1 btn bg-gray-100 dark:bg-gray-800 text-gray-600 text-sm"
>
Отмена
</button>
<button
type="button"
onClick={handleAddFreeze}
disabled={addFreezeMutation.isPending}
className="flex-1 btn bg-cyan-500 text-white text-sm hover:bg-cyan-600"
>
{addFreezeMutation.isPending ? "..." : "Добавить"}
</button>
</div>
</div>
) : (
<button
type="button"
onClick={() => setShowAddFreeze(true)}
className="flex items-center gap-2 w-full p-3 bg-gray-50 dark:bg-gray-800 hover:bg-gray-100 dark:hover:bg-gray-800 dark:bg-gray-800 rounded-xl text-sm text-gray-600 transition-colors"
>
<Plus size={16} />
Добавить заморозку
</button>
)}
{/* Past freezes */}
{pastFreezes.length > 0 && (
<details className="text-sm">
<summary className="text-gray-500 dark:text-gray-400 dark:text-gray-500 cursor-pointer hover:text-gray-700 dark:text-gray-300">
Прошлые заморозки ({pastFreezes.length})
</summary>
<div className="mt-2 space-y-1">
{pastFreezes.map((freeze) => (
<div key={freeze.id} className="flex items-center justify-between p-2 rounded-lg bg-gray-50 dark:bg-gray-800 text-gray-500 dark:text-gray-400 dark:text-gray-500">
<span>
{format(parseISO(freeze.start_date), "d MMM", { locale: ru })} {format(parseISO(freeze.end_date), "d MMM yyyy", { locale: ru })}
{freeze.reason && " — " + freeze.reason}
</span>
<button
type="button"
onClick={() => deleteFreezeMutation.mutate(freeze.id)}
className="p-1 text-gray-400 dark:text-gray-500 hover:text-red-500"
>
<Trash2 size={14} />
</button>
</div>
))}
</div>
</details>
)}
</motion.div>
)}
</AnimatePresence>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Иконка
</label>
<div className="flex flex-wrap gap-2">
@@ -308,7 +611,7 @@ export default function EditHabitModal({ open, onClose, habit }) {
"w-10 h-10 rounded-xl flex items-center justify-center text-xl transition-all",
icon === ic
? "bg-primary-100 ring-2 ring-primary-500"
: "bg-gray-100 hover:bg-gray-200"
: "bg-gray-100 dark:bg-gray-800 hover:bg-gray-200"
)}
>
{ic}
@@ -335,7 +638,7 @@ export default function EditHabitModal({ open, onClose, habit }) {
>
{ICON_CATEGORIES.map((category) => (
<div key={category.name}>
<p className="text-xs text-gray-500 mb-1.5">{category.name}</p>
<p className="text-xs text-gray-500 dark:text-gray-400 dark:text-gray-500 mb-1.5">{category.name}</p>
<div className="flex flex-wrap gap-1.5">
{category.icons.map((ic) => (
<button
@@ -346,7 +649,7 @@ export default function EditHabitModal({ open, onClose, habit }) {
"w-9 h-9 rounded-lg flex items-center justify-center text-lg transition-all",
icon === ic
? "bg-primary-100 ring-2 ring-primary-500"
: "bg-gray-100 hover:bg-gray-200"
: "bg-gray-100 dark:bg-gray-800 hover:bg-gray-200"
)}
>
{ic}
@@ -361,7 +664,7 @@ export default function EditHabitModal({ open, onClose, habit }) {
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Цвет
</label>
<div className="flex flex-wrap gap-2">

View File

@@ -1,6 +1,6 @@
import { useState, useEffect } from "react"
import { motion, AnimatePresence } from "framer-motion"
import { X, Trash2, ChevronDown, ChevronUp, Calendar, Clock } from "lucide-react"
import { X, Trash2, ChevronDown, ChevronUp, Calendar, Clock, Repeat } from "lucide-react"
import { useMutation, useQueryClient } from "@tanstack/react-query"
import { tasksApi } from "../api/tasks"
import clsx from "clsx"
@@ -21,12 +21,19 @@ const ICON_CATEGORIES = [
]
const PRIORITIES = [
{ value: 0, label: "Без приоритета", color: "bg-gray-100 text-gray-600" },
{ value: 0, label: "Без приоритета", color: "bg-gray-100 dark:bg-gray-800 text-gray-600" },
{ value: 1, label: "Низкий", color: "bg-blue-100 text-blue-700" },
{ value: 2, label: "Средний", color: "bg-yellow-100 text-yellow-700" },
{ value: 3, label: "Высокий", color: "bg-red-100 text-red-700" },
]
const RECURRENCE_TYPES = [
{ value: "daily", label: "Ежедневно" },
{ value: "weekly", label: "Еженедельно" },
{ value: "monthly", label: "Ежемесячно" },
{ value: "custom", label: "Каждые N дней" },
]
export default function EditTaskModal({ open, onClose, task }) {
const today = format(new Date(), "yyyy-MM-dd")
const tomorrow = format(addDays(new Date(), 1), "yyyy-MM-dd")
@@ -42,6 +49,12 @@ export default function EditTaskModal({ open, onClose, task }) {
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false)
const [showAllIcons, setShowAllIcons] = useState(false)
// Recurring state
const [isRecurring, setIsRecurring] = useState(false)
const [recurrenceType, setRecurrenceType] = useState("daily")
const [recurrenceInterval, setRecurrenceInterval] = useState(1)
const [recurrenceEndDate, setRecurrenceEndDate] = useState("")
const queryClient = useQueryClient()
useEffect(() => {
@@ -53,6 +66,10 @@ export default function EditTaskModal({ open, onClose, task }) {
setDueDate(task.due_date || "")
setPriority(task.priority || 0)
setReminderTime(task.reminder_time || "")
setIsRecurring(task.is_recurring || false)
setRecurrenceType(task.recurrence_type || "daily")
setRecurrenceInterval(task.recurrence_interval || 1)
setRecurrenceEndDate(task.recurrence_end_date || "")
setError("")
setShowDeleteConfirm(false)
setShowAllIcons(false)
@@ -97,7 +114,7 @@ export default function EditTaskModal({ open, onClose, task }) {
return
}
updateMutation.mutate({
const data = {
title,
description,
color,
@@ -105,11 +122,13 @@ export default function EditTaskModal({ open, onClose, task }) {
due_date: dueDate || null,
priority,
reminder_time: reminderTime || null,
})
}
const handleDelete = () => {
deleteMutation.mutate()
is_recurring: isRecurring,
recurrence_type: isRecurring ? recurrenceType : null,
recurrence_interval: isRecurring && recurrenceType === "custom" ? recurrenceInterval : 1,
recurrence_end_date: isRecurring && recurrenceEndDate ? recurrenceEndDate : null,
}
updateMutation.mutate(data)
}
const popularIcons = ["📋", "📝", "✅", "🎯", "💼", "🏠", "💰", "📞"]
@@ -133,275 +152,348 @@ export default function EditTaskModal({ open, onClose, task }) {
exit={{ opacity: 0, y: 100 }}
className="fixed bottom-0 left-0 right-0 z-50 p-4 sm:p-0 sm:flex sm:items-center sm:justify-center sm:inset-0"
>
<div className="bg-white rounded-2xl sm:rounded-2xl w-full max-w-md max-h-[85vh] overflow-auto">
<div className="sticky top-0 bg-white border-b border-gray-100 px-4 py-3 flex items-center justify-between">
<div className="bg-white dark:bg-gray-900 rounded-2xl sm:rounded-2xl w-full max-w-md max-h-[85vh] overflow-auto">
<div className="sticky top-0 bg-white dark:bg-gray-900 border-b border-gray-100 dark:border-gray-800 px-4 py-3 flex items-center justify-between">
<h2 className="text-lg font-semibold">Редактировать задачу</h2>
<button
onClick={handleClose}
className="p-2 text-gray-400 hover:text-gray-600 rounded-xl hover:bg-gray-100"
className="p-2 text-gray-400 dark:text-gray-500 hover:text-gray-600 rounded-xl hover:bg-gray-100 dark:hover:bg-gray-800 dark:bg-gray-800"
>
<X size={20} />
</button>
</div>
{showDeleteConfirm ? (
<div className="p-6 text-center">
<div className="w-16 h-16 rounded-full bg-red-100 flex items-center justify-center mx-auto mb-4">
<Trash2 className="w-8 h-8 text-red-500" />
</div>
<h3 className="text-lg font-semibold text-gray-900 mb-2">Удалить задачу?</h3>
<p className="text-gray-500 mb-6">
Задача "{task.title}" будет удалена безвозвратно.
</p>
<div className="flex gap-3">
<button
onClick={() => setShowDeleteConfirm(false)}
className="flex-1 btn bg-gray-100 text-gray-700 hover:bg-gray-200"
>
Отмена
</button>
<button
onClick={handleDelete}
disabled={deleteMutation.isPending}
className="flex-1 btn bg-red-500 text-white hover:bg-red-600"
>
{deleteMutation.isPending ? "Удаляем..." : "Удалить"}
</button>
<form onSubmit={handleSubmit} className="p-4 space-y-4">
{error && (
<div className="p-3 rounded-xl bg-red-50 text-red-600 text-sm">
{error}
</div>
)}
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1.5">
Название
</label>
<input
type="text"
value={title}
onChange={(e) => setTitle(e.target.value)}
className="input"
placeholder="Что нужно сделать?"
/>
</div>
) : (
<form onSubmit={handleSubmit} className="p-4 space-y-4">
{error && (
<div className="p-3 rounded-xl bg-red-50 text-red-600 text-sm">
{error}
</div>
)}
<div>
<label className="block text-sm font-medium text-gray-700 mb-1.5">
Название
</label>
<input
type="text"
value={title}
onChange={(e) => setTitle(e.target.value)}
className="input"
placeholder="Что нужно сделать?"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1.5">
Описание (опционально)
</label>
<textarea
value={description}
onChange={(e) => setDescription(e.target.value)}
className="input min-h-[80px] resize-none"
placeholder="Подробности задачи..."
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1.5">
Описание (опционально)
</label>
<textarea
value={description}
onChange={(e) => setDescription(e.target.value)}
className="input min-h-[80px] resize-none"
placeholder="Подробности задачи..."
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Срок выполнения
</label>
<div className="flex gap-2 mb-2">
<button
type="button"
onClick={() => setDueDate(today)}
className={clsx(
"px-3 py-1.5 rounded-lg text-sm font-medium transition-all",
dueDate === today
? "bg-primary-500 text-white"
: "bg-gray-100 text-gray-600 hover:bg-gray-200"
)}
>
Сегодня
</button>
<button
type="button"
onClick={() => setDueDate(tomorrow)}
className={clsx(
"px-3 py-1.5 rounded-lg text-sm font-medium transition-all",
dueDate === tomorrow
? "bg-primary-500 text-white"
: "bg-gray-100 text-gray-600 hover:bg-gray-200"
)}
>
Завтра
</button>
<button
type="button"
onClick={() => setDueDate("")}
className={clsx(
"px-3 py-1.5 rounded-lg text-sm font-medium transition-all",
!dueDate
? "bg-primary-500 text-white"
: "bg-gray-100 text-gray-600 hover:bg-gray-200"
)}
>
Без срока
</button>
</div>
<div className="relative">
<Calendar className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-400" size={18} />
<input
type="date"
value={dueDate}
onChange={(e) => setDueDate(e.target.value)}
className="input pl-10"
/>
</div>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Напоминание (опционально)
</label>
<div className="relative">
<Clock className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-400" size={18} />
<input
type="time"
value={reminderTime}
onChange={(e) => setReminderTime(e.target.value)}
className="input pl-10"
/>
</div>
<p className="text-xs text-gray-500 mt-1">
Получишь напоминание в Telegram в указанное время
</p>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Приоритет
</label>
<div className="flex gap-2 flex-wrap">
{PRIORITIES.map((p) => (
<button
key={p.value}
type="button"
onClick={() => setPriority(p.value)}
className={clsx(
"px-3 py-1.5 rounded-lg text-sm font-medium transition-all",
priority === p.value
? p.color + " ring-2 ring-offset-1 ring-gray-400"
: "bg-gray-100 text-gray-600 hover:bg-gray-200"
)}
>
{p.label}
</button>
))}
</div>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Иконка
</label>
<div className="flex flex-wrap gap-2">
{popularIcons.map((ic) => (
<button
key={ic}
type="button"
onClick={() => setIcon(ic)}
className={clsx(
"w-10 h-10 rounded-xl flex items-center justify-center text-xl transition-all",
icon === ic
? "bg-primary-100 ring-2 ring-primary-500"
: "bg-gray-100 hover:bg-gray-200"
)}
>
{ic}
</button>
))}
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Срок выполнения
</label>
<div className="flex gap-2 mb-2">
<button
type="button"
onClick={() => setShowAllIcons(!showAllIcons)}
className="mt-2 flex items-center gap-1 text-sm text-primary-600 hover:text-primary-700"
>
{showAllIcons ? <ChevronUp size={16} /> : <ChevronDown size={16} />}
{showAllIcons ? "Скрыть" : "Все иконки"}
</button>
<AnimatePresence>
{showAllIcons && (
<motion.div
initial={{ opacity: 0, height: 0 }}
animate={{ opacity: 1, height: "auto" }}
exit={{ opacity: 0, height: 0 }}
className="mt-3 space-y-3"
>
{ICON_CATEGORIES.map((category) => (
<div key={category.name}>
<p className="text-xs text-gray-500 mb-1.5">{category.name}</p>
<div className="flex flex-wrap gap-1.5">
{category.icons.map((ic) => (
<button
key={ic}
type="button"
onClick={() => setIcon(ic)}
className={clsx(
"w-9 h-9 rounded-lg flex items-center justify-center text-lg transition-all",
icon === ic
? "bg-primary-100 ring-2 ring-primary-500"
: "bg-gray-100 hover:bg-gray-200"
)}
>
{ic}
</button>
))}
</div>
</div>
))}
</motion.div>
onClick={() => setDueDate(today)}
className={clsx(
"px-3 py-1.5 rounded-lg text-sm font-medium transition-all",
dueDate === today
? "bg-primary-500 text-white"
: "bg-gray-100 dark:bg-gray-800 text-gray-600 hover:bg-gray-200"
)}
</AnimatePresence>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Цвет
</label>
<div className="flex flex-wrap gap-2">
{COLORS.map((c) => (
<button
key={c}
type="button"
onClick={() => setColor(c)}
className={clsx(
"w-8 h-8 rounded-full transition-all",
color === c ? "ring-2 ring-offset-2 ring-gray-400 scale-110" : ""
)}
style={{ backgroundColor: c }}
/>
))}
</div>
</div>
<div className="pt-2 space-y-3">
<button
type="submit"
disabled={updateMutation.isPending}
className="btn btn-primary w-full"
>
{updateMutation.isPending ? "Сохраняем..." : "Сохранить изменения"}
Сегодня
</button>
<button
type="button"
onClick={() => setDueDate(tomorrow)}
className={clsx(
"px-3 py-1.5 rounded-lg text-sm font-medium transition-all",
dueDate === tomorrow
? "bg-primary-500 text-white"
: "bg-gray-100 dark:bg-gray-800 text-gray-600 hover:bg-gray-200"
)}
>
Завтра
</button>
<button
type="button"
onClick={() => setDueDate("")}
className={clsx(
"px-3 py-1.5 rounded-lg text-sm font-medium transition-all",
!dueDate
? "bg-primary-500 text-white"
: "bg-gray-100 dark:bg-gray-800 text-gray-600 hover:bg-gray-200"
)}
>
Без срока
</button>
</div>
<div className="relative">
<Calendar className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-400 dark:text-gray-500" size={18} />
<input
type="date"
value={dueDate}
onChange={(e) => setDueDate(e.target.value)}
className="input pl-10"
/>
</div>
</div>
{/* Recurring Section */}
<div className="border-t border-gray-100 dark:border-gray-800 pt-4">
<div className="flex items-center justify-between mb-3">
<div className="flex items-center gap-2">
<Repeat size={18} className={isRecurring ? "text-primary-500" : "text-gray-400 dark:text-gray-500"} />
<label className="text-sm font-medium text-gray-700 dark:text-gray-300">Повторять</label>
</div>
<button
type="button"
onClick={() => setIsRecurring(!isRecurring)}
className={clsx(
"w-12 h-6 rounded-full transition-all relative",
isRecurring ? "bg-primary-500" : "bg-gray-200"
)}
>
<div className={clsx(
"absolute top-1 w-4 h-4 rounded-full bg-white dark:bg-gray-900 shadow-sm transition-all",
isRecurring ? "right-1" : "left-1"
)} />
</button>
</div>
<AnimatePresence>
{isRecurring && (
<motion.div
initial={{ opacity: 0, height: 0 }}
animate={{ opacity: 1, height: "auto" }}
exit={{ opacity: 0, height: 0 }}
className="space-y-3"
>
<div className="flex flex-wrap gap-2">
{RECURRENCE_TYPES.map((type) => (
<button
key={type.value}
type="button"
onClick={() => setRecurrenceType(type.value)}
className={clsx(
"px-3 py-1.5 rounded-lg text-sm font-medium transition-all",
recurrenceType === type.value
? "bg-primary-500 text-white"
: "bg-gray-100 dark:bg-gray-800 text-gray-600 hover:bg-gray-200"
)}
>
{type.label}
</button>
))}
</div>
{recurrenceType === "custom" && (
<div className="flex items-center gap-2">
<span className="text-sm text-gray-600">Каждые</span>
<input
type="number"
min="1"
max="365"
value={recurrenceInterval}
onChange={(e) => setRecurrenceInterval(parseInt(e.target.value) || 1)}
className="input w-20 text-center"
/>
<span className="text-sm text-gray-600">дней</span>
</div>
)}
<div>
<label className="block text-xs text-gray-500 dark:text-gray-400 dark:text-gray-500 mb-1">
Повторять до (опционально)
</label>
<input
type="date"
value={recurrenceEndDate}
onChange={(e) => setRecurrenceEndDate(e.target.value)}
className="input"
min={dueDate || today}
/>
</div>
</motion.div>
)}
</AnimatePresence>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Напоминание (опционально)
</label>
<div className="relative">
<Clock className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-400 dark:text-gray-500" size={18} />
<input
type="time"
value={reminderTime}
onChange={(e) => setReminderTime(e.target.value)}
className="input pl-10"
/>
</div>
<p className="text-xs text-gray-500 dark:text-gray-400 dark:text-gray-500 mt-1">
Получишь напоминание в Telegram в указанное время
</p>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Приоритет
</label>
<div className="flex gap-2 flex-wrap">
{PRIORITIES.map((p) => (
<button
key={p.value}
type="button"
onClick={() => setPriority(p.value)}
className={clsx(
"px-3 py-1.5 rounded-lg text-sm font-medium transition-all",
priority === p.value
? p.color + " ring-2 ring-offset-1 ring-gray-400"
: "bg-gray-100 dark:bg-gray-800 text-gray-600 hover:bg-gray-200"
)}
>
{p.label}
</button>
))}
</div>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Иконка
</label>
<div className="flex flex-wrap gap-2">
{popularIcons.map((ic) => (
<button
key={ic}
type="button"
onClick={() => setIcon(ic)}
className={clsx(
"w-10 h-10 rounded-xl flex items-center justify-center text-xl transition-all",
icon === ic
? "bg-primary-100 ring-2 ring-primary-500"
: "bg-gray-100 dark:bg-gray-800 hover:bg-gray-200"
)}
>
{ic}
</button>
))}
</div>
<button
type="button"
onClick={() => setShowAllIcons(!showAllIcons)}
className="mt-2 flex items-center gap-1 text-sm text-primary-600 hover:text-primary-700"
>
{showAllIcons ? <ChevronUp size={16} /> : <ChevronDown size={16} />}
{showAllIcons ? "Скрыть" : "Все иконки"}
</button>
<AnimatePresence>
{showAllIcons && (
<motion.div
initial={{ opacity: 0, height: 0 }}
animate={{ opacity: 1, height: "auto" }}
exit={{ opacity: 0, height: 0 }}
className="mt-3 space-y-3"
>
{ICON_CATEGORIES.map((category) => (
<div key={category.name}>
<p className="text-xs text-gray-500 dark:text-gray-400 dark:text-gray-500 mb-1.5">{category.name}</p>
<div className="flex flex-wrap gap-1.5">
{category.icons.map((ic) => (
<button
key={ic}
type="button"
onClick={() => setIcon(ic)}
className={clsx(
"w-9 h-9 rounded-lg flex items-center justify-center text-lg transition-all",
icon === ic
? "bg-primary-100 ring-2 ring-primary-500"
: "bg-gray-100 dark:bg-gray-800 hover:bg-gray-200"
)}
>
{ic}
</button>
))}
</div>
</div>
))}
</motion.div>
)}
</AnimatePresence>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Цвет
</label>
<div className="flex flex-wrap gap-2">
{COLORS.map((c) => (
<button
key={c}
type="button"
onClick={() => setColor(c)}
className={clsx(
"w-8 h-8 rounded-full transition-all",
color === c ? "ring-2 ring-offset-2 ring-gray-400 scale-110" : ""
)}
style={{ backgroundColor: c }}
/>
))}
</div>
</div>
<div className="pt-2 space-y-3">
<button
type="submit"
disabled={updateMutation.isPending}
className="btn btn-primary w-full"
>
{updateMutation.isPending ? "Сохраняем..." : "Сохранить"}
</button>
{!showDeleteConfirm ? (
<button
type="button"
onClick={() => setShowDeleteConfirm(true)}
className="btn w-full bg-red-50 text-red-600 hover:bg-red-100 flex items-center justify-center gap-2"
className="btn w-full flex items-center justify-center gap-2 text-red-600 hover:bg-red-50"
>
<Trash2 size={18} />
Удалить задачу
</button>
</div>
</form>
)}
) : (
<div className="flex gap-2">
<button
type="button"
onClick={() => setShowDeleteConfirm(false)}
className="btn flex-1"
>
Отмена
</button>
<button
type="button"
onClick={() => deleteMutation.mutate()}
disabled={deleteMutation.isPending}
className="btn flex-1 bg-red-500 text-white hover:bg-red-600"
>
{deleteMutation.isPending ? "Удаляем..." : "Да, удалить"}
</button>
</div>
)}
</div>
</form>
</div>
</motion.div>
</>

View File

@@ -0,0 +1,209 @@
import { useState, useMemo } from 'react'
import { motion, AnimatePresence } from 'framer-motion'
import { X, ChevronLeft, ChevronRight, Check } from 'lucide-react'
import { format, startOfMonth, endOfMonth, eachDayOfInterval, isSameDay, isFuture, startOfDay, subMonths, addMonths, isToday } from 'date-fns'
import { ru } from 'date-fns/locale'
import clsx from 'clsx'
export default function LogHabitModal({ open, onClose, habit, completedDates = [], onLogDate }) {
const [currentMonth, setCurrentMonth] = useState(new Date())
const [selectedDate, setSelectedDate] = useState(null)
const [isLogging, setIsLogging] = useState(false)
const days = useMemo(() => {
const start = startOfMonth(currentMonth)
const end = endOfMonth(currentMonth)
return eachDayOfInterval({ start, end })
}, [currentMonth])
// Convert completedDates to a Set for faster lookup
const completedSet = useMemo(() => {
const set = new Set()
completedDates.forEach(d => {
const dateStr = typeof d === 'string' ? d.split('T')[0] : format(d, 'yyyy-MM-dd')
set.add(dateStr)
})
return set
}, [completedDates])
const isDateCompleted = (date) => {
return completedSet.has(format(date, 'yyyy-MM-dd'))
}
const handleDateClick = (date) => {
if (isFuture(startOfDay(date))) return
if (isDateCompleted(date)) return
setSelectedDate(date)
}
const handleConfirm = async () => {
if (!selectedDate) return
setIsLogging(true)
try {
await onLogDate(habit.id, format(selectedDate, 'yyyy-MM-dd'))
onClose()
} catch (error) {
console.error('Failed to log habit:', error)
} finally {
setIsLogging(false)
}
}
// Get first day of week offset
const firstDayOfMonth = startOfMonth(currentMonth)
const startOffset = (firstDayOfMonth.getDay() + 6) % 7 // Monday = 0
if (!open) return null
return (
<AnimatePresence>
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="fixed inset-0 bg-black/50 backdrop-blur-sm z-50 flex items-center justify-center p-4"
onClick={onClose}
>
<motion.div
initial={{ opacity: 0, scale: 0.95, y: 20 }}
animate={{ opacity: 1, scale: 1, y: 0 }}
exit={{ opacity: 0, scale: 0.95, y: 20 }}
onClick={e => e.stopPropagation()}
className="bg-white dark:bg-gray-900 rounded-3xl shadow-2xl w-full max-w-sm overflow-hidden"
>
{/* Header */}
<div className="p-5 border-b border-gray-100 dark:border-gray-800 flex items-center justify-between">
<div className="flex items-center gap-3">
<div
className="w-10 h-10 rounded-xl flex items-center justify-center text-xl"
style={{ backgroundColor: habit?.color + '20' }}
>
{habit?.icon || '✨'}
</div>
<div>
<h2 className="text-lg font-display font-bold text-gray-900 dark:text-white">Отметить привычку</h2>
<p className="text-sm text-gray-500 dark:text-gray-400 dark:text-gray-500">{habit?.name}</p>
</div>
</div>
<button
onClick={onClose}
className="p-2 text-gray-400 dark:text-gray-500 hover:text-gray-600 hover:bg-gray-100 dark:hover:bg-gray-800 dark:bg-gray-800 rounded-xl transition-colors"
>
<X size={20} />
</button>
</div>
{/* Calendar */}
<div className="p-5">
{/* Month navigation */}
<div className="flex items-center justify-between mb-4">
<button
onClick={() => setCurrentMonth(m => subMonths(m, 1))}
className="p-2 text-gray-400 dark:text-gray-500 hover:text-gray-600 hover:bg-gray-100 dark:hover:bg-gray-800 dark:bg-gray-800 rounded-xl transition-colors"
>
<ChevronLeft size={20} />
</button>
<span className="font-semibold text-gray-900 dark:text-white capitalize">
{format(currentMonth, 'LLLL yyyy', { locale: ru })}
</span>
<button
onClick={() => setCurrentMonth(m => addMonths(m, 1))}
disabled={isSameDay(startOfMonth(currentMonth), startOfMonth(new Date()))}
className={clsx(
"p-2 rounded-xl transition-colors",
isSameDay(startOfMonth(currentMonth), startOfMonth(new Date()))
? "text-gray-200 cursor-not-allowed"
: "text-gray-400 dark:text-gray-500 hover:text-gray-600 hover:bg-gray-100 dark:hover:bg-gray-800 dark:bg-gray-800"
)}
>
<ChevronRight size={20} />
</button>
</div>
{/* Weekday headers */}
<div className="grid grid-cols-7 gap-1 mb-2">
{['Пн', 'Вт', 'Ср', 'Чт', 'Пт', 'Сб', 'Вс'].map(day => (
<div key={day} className="text-center text-xs font-medium text-gray-400 dark:text-gray-500 py-2">
{day}
</div>
))}
</div>
{/* Calendar grid */}
<div className="grid grid-cols-7 gap-1">
{/* Empty cells for offset */}
{Array.from({ length: startOffset }).map((_, i) => (
<div key={`offset-${i}`} className="aspect-square" />
))}
{/* Days */}
{days.map(day => {
const completed = isDateCompleted(day)
const future = isFuture(startOfDay(day))
const selected = selectedDate && isSameDay(day, selectedDate)
const today = isToday(day)
return (
<button
key={day.toISOString()}
onClick={() => handleDateClick(day)}
disabled={future || completed}
className={clsx(
"aspect-square rounded-xl flex items-center justify-center text-sm font-medium transition-all",
future && "text-gray-200 cursor-not-allowed",
completed && "bg-green-100 text-green-600 cursor-default",
selected && !completed && "bg-primary-500 text-white shadow-lg shadow-primary-500/30",
!future && !completed && !selected && "text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-800 dark:bg-gray-800",
today && !selected && !completed && "ring-2 ring-primary-200"
)}
>
{completed ? (
<Check size={16} className="text-green-600" />
) : (
format(day, 'd')
)}
</button>
)
})}
</div>
{/* Selected date info */}
{selectedDate && (
<motion.div
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
className="mt-4 p-3 bg-primary-50 rounded-xl text-center"
>
<p className="text-sm text-primary-700">
Выбрано: <span className="font-semibold">{format(selectedDate, 'd MMMM yyyy', { locale: ru })}</span>
</p>
</motion.div>
)}
</div>
{/* Actions */}
<div className="p-5 pt-0 flex gap-3">
<button
onClick={onClose}
className="flex-1 py-3 px-4 rounded-xl font-semibold text-gray-700 dark:text-gray-300 bg-gray-100 dark:bg-gray-800 hover:bg-gray-200 transition-colors"
>
Отмена
</button>
<button
onClick={handleConfirm}
disabled={!selectedDate || isLogging}
className={clsx(
"flex-1 py-3 px-4 rounded-xl font-semibold text-white transition-all",
selectedDate && !isLogging
? "bg-primary-500 hover:bg-primary-600 shadow-lg shadow-primary-500/30"
: "bg-gray-300 cursor-not-allowed"
)}
>
{isLogging ? 'Сохранение...' : 'Отметить'}
</button>
</div>
</motion.div>
</motion.div>
</AnimatePresence>
)
}

View File

@@ -1,5 +1,5 @@
import { NavLink } from "react-router-dom"
import { Home, ListChecks, CheckSquare, BarChart3, Settings } from "lucide-react"
import { Home, ListChecks, CheckSquare, BarChart3, PiggyBank, Settings } from "lucide-react"
import clsx from "clsx"
export default function Navigation() {
@@ -8,12 +8,13 @@ export default function Navigation() {
{ to: "/habits", icon: ListChecks, label: "Привычки" },
{ to: "/tasks", icon: CheckSquare, label: "Задачи" },
{ to: "/stats", icon: BarChart3, label: "Статистика" },
{ to: "/savings", icon: PiggyBank, label: "Накопления" },
{ to: "/settings", icon: Settings, label: "Настройки" },
]
return (
<nav className="fixed bottom-0 left-0 right-0 bg-white/80 backdrop-blur-xl border-t border-gray-100 z-50">
<div className="max-w-lg mx-auto px-4">
<nav className="fixed bottom-0 left-0 right-0 bg-white/80 dark:bg-gray-900/80 backdrop-blur-xl border-t border-gray-100 dark:border-gray-800 z-50 transition-colors duration-300">
<div className="max-w-lg mx-auto px-2">
<div className="flex items-center justify-around py-2">
{navItems.map(({ to, icon: Icon, label }) => (
<NavLink
@@ -21,15 +22,15 @@ export default function Navigation() {
to={to}
className={({ isActive }) =>
clsx(
"flex flex-col items-center gap-1 px-2 py-2 rounded-xl transition-all",
"flex flex-col items-center gap-0.5 px-1.5 py-1.5 rounded-xl transition-all",
isActive
? "text-primary-600 bg-primary-50"
: "text-gray-400 hover:text-gray-600"
? "text-primary-600 dark:text-primary-400 bg-primary-50 dark:bg-primary-900/30"
: "text-gray-400 dark:text-gray-500 hover:text-gray-600 dark:hover:text-gray-300"
)
}
>
<Icon size={20} />
<span className="text-xs font-medium">{label}</span>
<Icon size={18} />
<span className="text-[10px] font-medium">{label}</span>
</NavLink>
))}
</div>

View File

@@ -0,0 +1,42 @@
import { createContext, useContext, useEffect, useState } from "react"
const ThemeContext = createContext()
export function ThemeProvider({ children }) {
const [theme, setTheme] = useState(() => {
if (typeof window !== "undefined") {
return localStorage.getItem("theme") || "dark"
}
return "dark"
})
useEffect(() => {
const root = window.document.documentElement
if (theme === "dark") {
root.classList.add("dark")
} else {
root.classList.remove("dark")
}
localStorage.setItem("theme", theme)
}, [theme])
const toggleTheme = () => {
setTheme(prev => prev === "dark" ? "light" : "dark")
}
return (
<ThemeContext.Provider value={{ theme, setTheme, toggleTheme }}>
{children}
</ThemeContext.Provider>
)
}
export function useTheme() {
const context = useContext(ThemeContext)
if (!context) {
throw new Error("useTheme must be used within ThemeProvider")
}
return context
}

View File

@@ -11,7 +11,9 @@
body {
@apply bg-surface-50 text-gray-900 antialiased;
@apply dark:bg-gray-950 dark:text-gray-100;
font-feature-settings: 'cv02', 'cv03', 'cv04', 'cv11';
transition: background-color 0.3s ease, color 0.3s ease;
}
}
@@ -28,12 +30,16 @@
@apply hover:from-primary-700 hover:to-primary-800;
@apply focus:ring-primary-500;
@apply shadow-lg shadow-primary-500/25;
@apply dark:from-primary-500 dark:to-primary-600;
@apply dark:hover:from-primary-600 dark:hover:to-primary-700;
}
.btn-secondary {
@apply bg-white text-gray-700 border border-gray-200;
@apply hover:bg-gray-50 hover:border-gray-300;
@apply focus:ring-gray-500;
@apply dark:bg-gray-800 dark:text-gray-200 dark:border-gray-700;
@apply dark:hover:bg-gray-700 dark:hover:border-gray-600;
}
.btn-accent {
@@ -47,16 +53,21 @@
@apply w-full px-4 py-3.5 rounded-2xl border border-gray-200 bg-white;
@apply focus:outline-none focus:ring-2 focus:ring-primary-500/20 focus:border-primary-500;
@apply placeholder:text-gray-400 transition-all duration-200;
@apply dark:bg-gray-900 dark:border-gray-700 dark:text-gray-100;
@apply dark:placeholder:text-gray-500 dark:focus:border-primary-400;
}
.card {
@apply bg-white rounded-3xl shadow-sm border border-gray-100/50;
@apply hover:shadow-md transition-shadow duration-300;
@apply hover:shadow-md transition-all duration-300;
@apply dark:bg-gray-900 dark:border-gray-800 dark:shadow-none;
@apply dark:hover:bg-gray-800;
}
.card-glass {
@apply bg-white/70 backdrop-blur-xl rounded-3xl;
@apply border border-white/20 shadow-lg;
@apply dark:bg-gray-900/70 dark:border-gray-700/50;
}
.gradient-mesh {
@@ -65,6 +76,13 @@
radial-gradient(at 80% 0%, rgba(247, 181, 56, 0.08) 0px, transparent 50%),
radial-gradient(at 0% 50%, rgba(20, 184, 166, 0.05) 0px, transparent 50%);
}
.dark .gradient-mesh {
background:
radial-gradient(at 40% 20%, rgba(20, 184, 166, 0.15) 0px, transparent 50%),
radial-gradient(at 80% 0%, rgba(247, 181, 56, 0.1) 0px, transparent 50%),
radial-gradient(at 0% 50%, rgba(20, 184, 166, 0.08) 0px, transparent 50%);
}
}
/* Custom scrollbar */
@@ -78,8 +96,10 @@
::-webkit-scrollbar-thumb {
@apply bg-gray-300 rounded-full;
@apply dark:bg-gray-700;
}
::-webkit-scrollbar-thumb:hover {
@apply bg-gray-400;
@apply dark:bg-gray-600;
}

View File

@@ -2,6 +2,7 @@ import React from 'react'
import ReactDOM from 'react-dom/client'
import { BrowserRouter } from 'react-router-dom'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { ThemeProvider } from './contexts/ThemeContext'
import App from './App'
import './index.css'
@@ -17,9 +18,11 @@ const queryClient = new QueryClient({
ReactDOM.createRoot(document.getElementById('root')).render(
<React.StrictMode>
<QueryClientProvider client={queryClient}>
<BrowserRouter>
<App />
</BrowserRouter>
<ThemeProvider>
<BrowserRouter>
<App />
</BrowserRouter>
</ThemeProvider>
</QueryClientProvider>
</React.StrictMode>,
)

View File

@@ -28,11 +28,8 @@ export default function Habits() {
enabled: showArchived,
})
// Загружаем статистику для каждой привычки
useEffect(() => {
if (habits.length > 0) {
loadStats()
}
if (habits.length > 0) loadStats()
}, [habits])
const loadStats = async () => {
@@ -41,9 +38,7 @@ export default function Habits() {
try {
const stats = await habitsApi.getHabitStats(habit.id)
statsMap[habit.id] = stats
} catch (e) {
console.error('Error loading stats for habit', habit.id, e)
}
} catch (e) {}
}))
setHabitStats(statsMap)
}
@@ -63,9 +58,8 @@ export default function Habits() {
const days = ['Пн', 'Вт', 'Ср', 'Чт', 'Пт', 'Сб', 'Вс']
return habit.target_days.map(d => days[d - 1]).join(', ')
}
if (habit.frequency === 'custom') {
return `Каждые ${habit.target_count} дн.`
}
if (habit.frequency === 'interval') return `Каждые ${habit.target_count} дн.`
if (habit.frequency === 'custom') return `Каждые ${habit.target_count} дн.`
return habit.frequency
}
@@ -73,17 +67,14 @@ export default function Habits() {
const archivedList = habits.filter(h => h.is_archived)
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>
<h1 className="text-xl font-display font-bold text-gray-900">Мои привычки</h1>
<p className="text-sm text-gray-500">{activeHabits.length} активных</p>
<h1 className="text-xl font-display font-bold text-gray-900 dark:text-white">Мои привычки</h1>
<p className="text-sm text-gray-500 dark:text-gray-400">{activeHabits.length} активных</p>
</div>
<button
onClick={() => setShowCreateModal(true)}
className="btn btn-primary flex items-center gap-2"
>
<button onClick={() => setShowCreateModal(true)} className="btn btn-primary flex items-center gap-2">
<Plus size={18} />
Новая
</button>
@@ -96,37 +87,29 @@ export default function Habits() {
{[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>
) : activeHabits.length === 0 && !showArchived ? (
<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-primary-100 to-accent-100 flex items-center justify-center mx-auto mb-5">
<Plus className="w-10 h-10 text-primary-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-primary-100 to-accent-100 dark:from-primary-900/30 dark:to-accent-900/30 flex items-center justify-center mx-auto mb-5">
<Plus className="w-10 h-10 text-primary-600 dark:text-primary-400" />
</div>
<h3 className="text-xl font-display font-bold text-gray-900 mb-2">Нет привычек</h3>
<p className="text-gray-500 mb-6">Создай свою первую привычку!</p>
<button
onClick={() => setShowCreateModal(true)}
className="btn btn-primary"
>
<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 mb-6">Создай свою первую привычку!</p>
<button onClick={() => setShowCreateModal(true)} className="btn btn-primary">
<Plus size={20} className="mr-2" />
Создать привычку
</button>
</motion.div>
) : (
<>
{/* Активные привычки */}
<div className="space-y-3">
<AnimatePresence>
{activeHabits.map((habit, index) => (
@@ -143,32 +126,17 @@ export default function Habits() {
</AnimatePresence>
</div>
{/* Архивные привычки */}
{archivedList.length > 0 && (
<div className="mt-8">
<button
onClick={() => setShowArchived(!showArchived)}
className="flex items-center gap-2 text-gray-500 hover:text-gray-700 mb-4"
>
<button onClick={() => setShowArchived(!showArchived)} className="flex items-center gap-2 text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-300 mb-4">
<Archive size={18} />
<span className="font-medium">Архив ({archivedList.length})</span>
<ChevronRight
size={18}
className={clsx(
'transition-transform',
showArchived && 'rotate-90'
)}
/>
<ChevronRight size={18} className={clsx('transition-transform', showArchived && 'rotate-90')} />
</button>
<AnimatePresence>
{showArchived && (
<motion.div
initial={{ opacity: 0, height: 0 }}
animate={{ opacity: 1, height: 'auto' }}
exit={{ opacity: 0, height: 0 }}
className="space-y-3"
>
<motion.div initial={{ opacity: 0, height: 0 }} animate={{ opacity: 1, height: 'auto' }} exit={{ opacity: 0, height: 0 }} className="space-y-3">
{archivedList.map((habit, index) => (
<motion.div
key={habit.id}
@@ -178,21 +146,14 @@ export default function Habits() {
className="card p-4 opacity-60"
>
<div className="flex items-center gap-4">
<div
className="w-12 h-12 rounded-xl flex items-center justify-center text-xl"
style={{ backgroundColor: habit.color + '20' }}
>
<div className="w-12 h-12 rounded-xl flex items-center justify-center text-xl" style={{ backgroundColor: habit.color + '20' }}>
{habit.icon || '✨'}
</div>
<div className="flex-1 min-w-0">
<h3 className="font-semibold text-gray-600 truncate">{habit.name}</h3>
<p className="text-sm text-gray-400">{getFrequencyLabel(habit)}</p>
<h3 className="font-semibold text-gray-600 dark:text-gray-400 truncate">{habit.name}</h3>
<p className="text-sm text-gray-400 dark:text-gray-500">{getFrequencyLabel(habit)}</p>
</div>
<button
onClick={() => archiveMutation.mutate({ id: habit.id, archived: false })}
className="p-2 text-gray-400 hover:text-green-500 hover:bg-green-50 rounded-xl transition-all"
title="Восстановить"
>
<button onClick={() => archiveMutation.mutate({ id: habit.id, archived: false })} className="p-2 text-gray-400 hover:text-green-500 hover:bg-green-50 dark:hover:bg-green-900/20 rounded-xl transition-all" title="Восстановить">
<ArchiveRestore size={20} />
</button>
</div>
@@ -208,17 +169,8 @@ export default function Habits() {
</main>
<Navigation />
<CreateHabitModal
open={showCreateModal}
onClose={() => setShowCreateModal(false)}
/>
<EditHabitModal
open={!!editingHabit}
onClose={() => setEditingHabit(null)}
habit={editingHabit}
/>
<CreateHabitModal open={showCreateModal} onClose={() => setShowCreateModal(false)} />
<EditHabitModal open={!!editingHabit} onClose={() => setEditingHabit(null)} habit={editingHabit} />
</div>
)
}
@@ -234,20 +186,14 @@ function HabitListItem({ habit, index, stats, frequencyLabel, onEdit, onArchive
className="card p-4 cursor-pointer hover:shadow-lg transition-all"
>
<div className="flex items-center gap-4">
<div
className="w-14 h-14 rounded-2xl flex items-center justify-center text-2xl flex-shrink-0"
style={{ backgroundColor: habit.color + '15' }}
>
<div className="w-14 h-14 rounded-2xl flex items-center justify-center text-2xl flex-shrink-0" style={{ backgroundColor: habit.color + '15' }}>
{habit.icon || '✨'}
</div>
<div className="flex-1 min-w-0">
<h3 className="font-semibold text-gray-900 truncate">{habit.name}</h3>
<h3 className="font-semibold text-gray-900 dark:text-white truncate">{habit.name}</h3>
<div className="flex items-center gap-3 mt-1">
<span
className="text-xs font-medium px-2 py-0.5 rounded-full"
style={{ backgroundColor: habit.color + '15', color: habit.color }}
>
<span className="text-xs font-medium px-2 py-0.5 rounded-full" style={{ backgroundColor: habit.color + '15', color: habit.color }}>
{frequencyLabel}
</span>
{stats && stats.current_streak > 0 && (
@@ -262,11 +208,11 @@ function HabitListItem({ habit, index, stats, frequencyLabel, onEdit, onArchive
<div className="flex items-center gap-2">
{stats && (
<div className="text-right">
<p className="text-sm font-semibold text-gray-900">{stats.this_month}</p>
<p className="text-xs text-gray-400">в месяц</p>
<p className="text-sm font-semibold text-gray-900 dark:text-white">{stats.this_month}</p>
<p className="text-xs text-gray-400 dark:text-gray-500">в месяц</p>
</div>
)}
<ChevronRight size={20} className="text-gray-300" />
<ChevronRight size={20} className="text-gray-300 dark:text-gray-600" />
</div>
</div>
</motion.div>

View File

@@ -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>
)

View File

@@ -30,108 +30,48 @@ export default function Login() {
}
return (
<div className="min-h-screen flex items-center justify-center p-4 gradient-mesh bg-surface-50">
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.5 }}
className="w-full max-w-md"
>
<div className="min-h-screen flex items-center justify-center p-4 gradient-mesh bg-surface-50 dark:bg-gray-950 transition-colors duration-300">
<motion.div initial={{ opacity: 0, y: 20 }} animate={{ opacity: 1, y: 0 }} transition={{ duration: 0.5 }} className="w-full max-w-md">
<div className="text-center mb-8">
<motion.div
initial={{ scale: 0, rotate: -180 }}
animate={{ scale: 1, rotate: 0 }}
transition={{ type: 'spring', delay: 0.1, stiffness: 200 }}
className="inline-flex items-center justify-center w-20 h-20 rounded-3xl bg-gradient-to-br from-primary-500 to-primary-700 mb-6 shadow-xl shadow-primary-500/30"
>
<motion.div initial={{ scale: 0, rotate: -180 }} animate={{ scale: 1, rotate: 0 }} transition={{ type: 'spring', delay: 0.1, stiffness: 200 }} className="inline-flex items-center justify-center w-20 h-20 rounded-3xl bg-gradient-to-br from-primary-500 to-primary-700 mb-6 shadow-xl shadow-primary-500/30">
<Zap className="w-10 h-10 text-white" />
</motion.div>
<motion.h1
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ delay: 0.2 }}
className="text-3xl font-display font-bold text-gray-900"
>
<motion.h1 initial={{ opacity: 0 }} animate={{ opacity: 1 }} transition={{ delay: 0.2 }} className="text-3xl font-display font-bold text-gray-900 dark:text-white">
С возвращением!
</motion.h1>
<motion.p
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ delay: 0.3 }}
className="text-gray-500 mt-2"
>
<motion.p initial={{ opacity: 0 }} animate={{ opacity: 1 }} transition={{ delay: 0.3 }} className="text-gray-500 dark:text-gray-400 mt-2">
Войди, чтобы продолжить
</motion.p>
</div>
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.2 }}
className="card p-8"
>
<motion.div initial={{ opacity: 0, y: 20 }} animate={{ opacity: 1, y: 0 }} transition={{ delay: 0.2 }} className="card p-8">
<form onSubmit={handleSubmit} className="space-y-5">
{error && (
<motion.div
initial={{ opacity: 0, height: 0 }}
animate={{ opacity: 1, height: 'auto' }}
className="p-4 rounded-2xl bg-red-50 text-red-600 text-sm font-medium"
>
<motion.div initial={{ opacity: 0, height: 0 }} animate={{ opacity: 1, height: 'auto' }} className="p-4 rounded-2xl bg-red-50 dark:bg-red-900/20 text-red-600 dark:text-red-400 text-sm font-medium">
{error}
</motion.div>
)}
<div>
<label className="block text-sm font-semibold text-gray-700 mb-2">
Email
</label>
<input
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
className="input"
placeholder="your@email.com"
required
/>
<label className="block text-sm font-semibold text-gray-700 dark:text-gray-300 mb-2">Email</label>
<input type="email" value={email} onChange={(e) => setEmail(e.target.value)} className="input" placeholder="your@email.com" required />
</div>
<div>
<label className="block text-sm font-semibold text-gray-700 mb-2">
Пароль
</label>
<label className="block text-sm font-semibold text-gray-700 dark:text-gray-300 mb-2">Пароль</label>
<div className="relative">
<input
type={showPassword ? 'text' : 'password'}
value={password}
onChange={(e) => setPassword(e.target.value)}
className="input pr-12"
placeholder="••••••••"
required
/>
<button
type="button"
onClick={() => setShowPassword(!showPassword)}
className="absolute right-4 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-600 transition-colors"
>
<input type={showPassword ? 'text' : 'password'} value={password} onChange={(e) => setPassword(e.target.value)} className="input pr-12" placeholder="••••••••" required />
<button type="button" onClick={() => setShowPassword(!showPassword)} className="absolute right-4 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 transition-colors">
{showPassword ? <EyeOff size={20} /> : <Eye size={20} />}
</button>
</div>
</div>
<div className="flex justify-end">
<Link
to="/forgot-password"
className="text-sm text-primary-600 hover:text-primary-700 font-medium"
>
Забыли пароль?
</Link>
<Link to="/forgot-password" className="text-sm text-primary-600 dark:text-primary-400 hover:text-primary-700 font-medium">Забыли пароль?</Link>
</div>
<button
type="submit"
disabled={loading}
className="btn btn-primary w-full text-lg"
>
<button type="submit" disabled={loading} className="btn btn-primary w-full text-lg">
{loading ? (
<span className="flex items-center gap-2">
<svg className="animate-spin h-5 w-5" viewBox="0 0 24 24">
@@ -144,12 +84,9 @@ export default function Login() {
</button>
</form>
<div className="mt-8 pt-6 border-t border-gray-100 text-center">
<p className="text-gray-500">
Нет аккаунта?{' '}
<Link to="/register" className="text-primary-600 hover:text-primary-700 font-semibold">
Зарегистрируйся
</Link>
<div className="mt-8 pt-6 border-t border-gray-100 dark:border-gray-800 text-center">
<p className="text-gray-500 dark:text-gray-400">
Нет аккаунта?{' '}<Link to="/register" className="text-primary-600 dark:text-primary-400 hover:text-primary-700 font-semibold">Зарегистрируйся</Link>
</p>
</div>
</motion.div>

View File

@@ -31,103 +31,51 @@ export default function Register() {
}
return (
<div className="min-h-screen flex items-center justify-center p-4 bg-gradient-to-br from-primary-50 via-white to-accent-50">
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
className="w-full max-w-md"
>
<div className="min-h-screen flex items-center justify-center p-4 bg-gradient-to-br from-primary-50 via-white to-accent-50 dark:from-gray-950 dark:via-gray-900 dark:to-gray-950 transition-colors duration-300">
<motion.div initial={{ opacity: 0, y: 20 }} animate={{ opacity: 1, y: 0 }} className="w-full max-w-md">
<div className="text-center mb-8">
<motion.div
initial={{ scale: 0 }}
animate={{ scale: 1 }}
transition={{ type: 'spring', delay: 0.1 }}
className="inline-flex items-center justify-center w-16 h-16 rounded-2xl bg-gradient-to-br from-primary-500 to-accent-500 mb-4"
>
<motion.div initial={{ scale: 0 }} animate={{ scale: 1 }} transition={{ type: 'spring', delay: 0.1 }} className="inline-flex items-center justify-center w-16 h-16 rounded-2xl bg-gradient-to-br from-primary-500 to-accent-500 mb-4">
<Sparkles className="w-8 h-8 text-white" />
</motion.div>
<h1 className="text-2xl font-bold text-gray-900">Создай аккаунт</h1>
<p className="text-gray-500 mt-1">Начни отслеживать свои привычки</p>
<h1 className="text-2xl font-bold text-gray-900 dark:text-white">Создай аккаунт</h1>
<p className="text-gray-500 dark:text-gray-400 mt-1">Начни отслеживать свои привычки</p>
</div>
<div className="card p-6">
<form onSubmit={handleSubmit} className="space-y-4">
{error && (
<motion.div
initial={{ opacity: 0, height: 0 }}
animate={{ opacity: 1, height: 'auto' }}
className="p-3 rounded-xl bg-red-50 text-red-600 text-sm"
>
<motion.div initial={{ opacity: 0, height: 0 }} animate={{ opacity: 1, height: 'auto' }} className="p-3 rounded-xl bg-red-50 dark:bg-red-900/20 text-red-600 dark:text-red-400 text-sm">
{error}
</motion.div>
)}
<div>
<label className="block text-sm font-medium text-gray-700 mb-1.5">
Как тебя зовут?
</label>
<input
type="text"
value={username}
onChange={(e) => setUsername(e.target.value)}
className="input"
placeholder="Имя"
required
/>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1.5">Как тебя зовут?</label>
<input type="text" value={username} onChange={(e) => setUsername(e.target.value)} className="input" placeholder="Имя" required />
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1.5">
Email
</label>
<input
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
className="input"
placeholder="your@email.com"
required
/>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1.5">Email</label>
<input type="email" value={email} onChange={(e) => setEmail(e.target.value)} className="input" placeholder="your@email.com" required />
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1.5">
Пароль
</label>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1.5">Пароль</label>
<div className="relative">
<input
type={showPassword ? 'text' : 'password'}
value={password}
onChange={(e) => setPassword(e.target.value)}
className="input pr-12"
placeholder="Минимум 8 символов"
minLength={8}
required
/>
<button
type="button"
onClick={() => setShowPassword(!showPassword)}
className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-600"
>
<input type={showPassword ? 'text' : 'password'} value={password} onChange={(e) => setPassword(e.target.value)} className="input pr-12" placeholder="Минимум 8 символов" minLength={8} required />
<button type="button" onClick={() => setShowPassword(!showPassword)} className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300">
{showPassword ? <EyeOff size={20} /> : <Eye size={20} />}
</button>
</div>
</div>
<button
type="submit"
disabled={loading}
className="btn btn-primary w-full"
>
<button type="submit" disabled={loading} className="btn btn-primary w-full">
{loading ? 'Создаём...' : 'Создать аккаунт'}
</button>
</form>
<p className="text-center text-sm text-gray-500 mt-6">
Уже есть аккаунт?{' '}
<Link to="/login" className="text-primary-600 hover:text-primary-700 font-medium">
Войти
</Link>
<p className="text-center text-sm text-gray-500 dark:text-gray-400 mt-6">
Уже есть аккаунт?{' '}<Link to="/login" className="text-primary-600 dark:text-primary-400 hover:text-primary-700 font-medium">Войти</Link>
</p>
</div>
</motion.div>

1396
src/pages/Savings.jsx Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -1,8 +1,9 @@
import { useState, useEffect } from "react"
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"
import { ArrowLeft, Bell, MessageCircle, Globe, Save, Copy, Check, User, Sun, Moon } from "lucide-react"
import { ArrowLeft, Bell, MessageCircle, Globe, Save, Copy, Check, User, Sun, Moon, Palette } from "lucide-react"
import { Link } from "react-router-dom"
import { profileApi } from "../api/profile"
import { useTheme } from "../contexts/ThemeContext"
import Navigation from "../components/Navigation"
const TIMEZONES = [
@@ -26,6 +27,7 @@ const TIMEZONES = [
export default function Settings() {
const queryClient = useQueryClient()
const { theme, toggleTheme } = useTheme()
const [copied, setCopied] = useState(false)
const [username, setUsername] = useState("")
const [chatId, setChatId] = useState("")
@@ -99,39 +101,80 @@ export default function Settings() {
if (isLoading) {
return (
<div className="min-h-screen bg-surface-50 flex items-center justify-center">
<div className="min-h-screen bg-surface-50 dark:bg-gray-950 flex items-center justify-center">
<div className="w-10 h-10 border-4 border-primary-500 border-t-transparent rounded-full animate-spin" />
</div>
)
}
return (
<div className="min-h-screen bg-surface-50 pb-24">
<div className="min-h-screen bg-surface-50 dark:bg-gray-950 pb-24 transition-colors duration-300">
{/* Header */}
<header className="bg-white/80 backdrop-blur-xl sticky top-0 z-40 border-b border-gray-100">
<header className="bg-white/80 dark:bg-gray-900/80 backdrop-blur-xl sticky top-0 z-40 border-b border-gray-100 dark:border-gray-800">
<div className="max-w-lg mx-auto px-4 py-4 flex items-center gap-3">
<Link to="/" className="p-2 -ml-2 text-gray-600 hover:text-gray-900 rounded-xl hover:bg-gray-100">
<Link to="/" className="p-2 -ml-2 text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white rounded-xl hover:bg-gray-100 dark:hover:bg-gray-800">
<ArrowLeft size={20} />
</Link>
<h1 className="text-xl font-bold">Настройки</h1>
<h1 className="text-xl font-bold dark:text-white">Настройки</h1>
</div>
</header>
<main className="max-w-lg mx-auto px-4 py-6 space-y-6">
{/* Profile Section */}
<section className="bg-white rounded-2xl p-4 shadow-sm">
{/* Theme Section */}
<section className="bg-white dark:bg-gray-900 rounded-2xl p-4 shadow-sm dark:shadow-none border border-transparent dark:border-gray-800">
<div className="flex items-center gap-3 mb-4">
<div className="w-10 h-10 rounded-xl bg-green-100 flex items-center justify-center">
<User className="text-green-600" size={20} />
<div className="w-10 h-10 rounded-xl bg-violet-100 dark:bg-violet-900/30 flex items-center justify-center">
<Palette className="text-violet-600 dark:text-violet-400" size={20} />
</div>
<div>
<h2 className="font-semibold">Профиль</h2>
<p className="text-sm text-gray-500">Основная информация</p>
<h2 className="font-semibold dark:text-white">Оформление</h2>
<p className="text-sm text-gray-500 dark:text-gray-400">Выбери тему приложения</p>
</div>
</div>
<button
onClick={toggleTheme}
className="w-full flex items-center justify-between p-4 bg-gray-50 dark:bg-gray-800 rounded-xl transition-all hover:bg-gray-100 dark:hover:bg-gray-700"
>
<div className="flex items-center gap-3">
{theme === "dark" ? (
<Moon className="text-primary-500" size={22} />
) : (
<Sun className="text-amber-500" size={22} />
)}
<span className="font-medium text-gray-900 dark:text-white">
{theme === "dark" ? "Тёмная тема" : "Светлая тема"}
</span>
</div>
<div className="relative">
<div className={`w-14 h-8 rounded-full transition-colors duration-300 ${theme === "dark" ? "bg-primary-500" : "bg-gray-300"}`}>
<div className={`absolute top-1 w-6 h-6 bg-white rounded-full shadow-md transition-transform duration-300 flex items-center justify-center ${theme === "dark" ? "translate-x-7" : "translate-x-1"}`}>
{theme === "dark" ? (
<Moon className="text-primary-600" size={14} />
) : (
<Sun className="text-amber-500" size={14} />
)}
</div>
</div>
</div>
</button>
</section>
{/* Profile Section */}
<section className="bg-white dark:bg-gray-900 rounded-2xl p-4 shadow-sm dark:shadow-none border border-transparent dark:border-gray-800">
<div className="flex items-center gap-3 mb-4">
<div className="w-10 h-10 rounded-xl bg-green-100 dark:bg-green-900/30 flex items-center justify-center">
<User className="text-green-600 dark:text-green-400" size={20} />
</div>
<div>
<h2 className="font-semibold dark:text-white">Профиль</h2>
<p className="text-sm text-gray-500 dark:text-gray-400">Основная информация</p>
</div>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1.5">
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1.5">
Имя пользователя
</label>
<input
@@ -145,36 +188,36 @@ export default function Settings() {
</section>
{/* Telegram Section */}
<section className="bg-white rounded-2xl p-4 shadow-sm">
<section className="bg-white dark:bg-gray-900 rounded-2xl p-4 shadow-sm dark:shadow-none border border-transparent dark:border-gray-800">
<div className="flex items-center gap-3 mb-4">
<div className="w-10 h-10 rounded-xl bg-blue-100 flex items-center justify-center">
<MessageCircle className="text-blue-600" size={20} />
<div className="w-10 h-10 rounded-xl bg-blue-100 dark:bg-blue-900/30 flex items-center justify-center">
<MessageCircle className="text-blue-600 dark:text-blue-400" size={20} />
</div>
<div>
<h2 className="font-semibold">Telegram</h2>
<p className="text-sm text-gray-500">Получай уведомления в Telegram</p>
<h2 className="font-semibold dark:text-white">Telegram</h2>
<p className="text-sm text-gray-500 dark:text-gray-400">Получай уведомления в Telegram</p>
</div>
</div>
<div className="space-y-4">
<div className="p-3 bg-blue-50 rounded-xl">
<p className="text-sm text-blue-800 mb-2">
1. Напиши <code className="bg-blue-100 px-1 rounded">/start</code> боту в Telegram
<div className="p-3 bg-blue-50 dark:bg-blue-900/20 rounded-xl">
<p className="text-sm text-blue-800 dark:text-blue-300 mb-2">
1. Напиши <code className="bg-blue-100 dark:bg-blue-800 px-1 rounded">/start</code> боту в Telegram
</p>
<button
onClick={copyInstruction}
className="flex items-center gap-2 text-sm font-medium text-blue-600 hover:text-blue-700"
className="flex items-center gap-2 text-sm font-medium text-blue-600 dark:text-blue-400 hover:text-blue-700 dark:hover:text-blue-300"
>
{copied ? <Check size={16} /> : <Copy size={16} />}
{copied ? "Скопировано!" : "@pulse_tracking_bot"}
</button>
<p className="text-sm text-blue-800 mt-2">
<p className="text-sm text-blue-800 dark:text-blue-300 mt-2">
2. Скопируй Chat ID из ответа бота и вставь ниже
</p>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1.5">
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1.5">
Chat ID
</label>
<input
@@ -189,20 +232,20 @@ export default function Settings() {
</section>
{/* Notifications Section */}
<section className="bg-white rounded-2xl p-4 shadow-sm">
<section className="bg-white dark:bg-gray-900 rounded-2xl p-4 shadow-sm dark:shadow-none border border-transparent dark:border-gray-800">
<div className="flex items-center gap-3 mb-4">
<div className="w-10 h-10 rounded-xl bg-orange-100 flex items-center justify-center">
<Bell className="text-orange-600" size={20} />
<div className="w-10 h-10 rounded-xl bg-orange-100 dark:bg-orange-900/30 flex items-center justify-center">
<Bell className="text-orange-600 dark:text-orange-400" size={20} />
</div>
<div>
<h2 className="font-semibold">Уведомления</h2>
<p className="text-sm text-gray-500">Настрой ежедневные уведомления</p>
<h2 className="font-semibold dark:text-white">Уведомления</h2>
<p className="text-sm text-gray-500 dark:text-gray-400">Настрой ежедневные уведомления</p>
</div>
</div>
<div className="space-y-4">
<label className="flex items-center justify-between p-3 bg-gray-50 rounded-xl cursor-pointer">
<span className="text-sm font-medium">Включить уведомления</span>
<label className="flex items-center justify-between p-3 bg-gray-50 dark:bg-gray-800 rounded-xl cursor-pointer">
<span className="text-sm font-medium dark:text-white">Включить уведомления</span>
<div className="relative">
<input
type="checkbox"
@@ -210,42 +253,42 @@ export default function Settings() {
onChange={(e) => setNotificationsEnabled(e.target.checked)}
className="sr-only peer"
/>
<div className="w-11 h-6 bg-gray-300 rounded-full peer peer-checked:bg-primary-500 transition-colors"></div>
<div className="w-11 h-6 bg-gray-300 dark:bg-gray-600 rounded-full peer peer-checked:bg-primary-500 transition-colors"></div>
<div className="absolute left-1 top-1 w-4 h-4 bg-white rounded-full peer-checked:translate-x-5 transition-transform"></div>
</div>
</label>
{notificationsEnabled && (
<>
<div className="flex items-center gap-3 p-3 bg-yellow-50 rounded-xl">
<Sun className="text-yellow-600" size={20} />
<div className="flex items-center gap-3 p-3 bg-yellow-50 dark:bg-yellow-900/20 rounded-xl">
<Sun className="text-yellow-600 dark:text-yellow-400" size={20} />
<div className="flex-1">
<label className="block text-sm font-medium text-gray-700">
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300">
Утреннее уведомление
</label>
<p className="text-xs text-gray-500">Задачи и привычки на сегодня</p>
<p className="text-xs text-gray-500 dark:text-gray-400">Задачи и привычки на сегодня</p>
</div>
<input
type="time"
value={morningTime}
onChange={(e) => setMorningTime(e.target.value)}
className="px-3 py-1.5 border border-gray-200 rounded-lg text-sm"
className="px-3 py-1.5 border border-gray-200 dark:border-gray-700 dark:bg-gray-800 dark:text-white rounded-lg text-sm"
/>
</div>
<div className="flex items-center gap-3 p-3 bg-indigo-50 rounded-xl">
<Moon className="text-indigo-600" size={20} />
<div className="flex items-center gap-3 p-3 bg-indigo-50 dark:bg-indigo-900/20 rounded-xl">
<Moon className="text-indigo-600 dark:text-indigo-400" size={20} />
<div className="flex-1">
<label className="block text-sm font-medium text-gray-700">
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300">
Вечернее уведомление
</label>
<p className="text-xs text-gray-500">Итоги дня: выполнено / осталось</p>
<p className="text-xs text-gray-500 dark:text-gray-400">Итоги дня: выполнено / осталось</p>
</div>
<input
type="time"
value={eveningTime}
onChange={(e) => setEveningTime(e.target.value)}
className="px-3 py-1.5 border border-gray-200 rounded-lg text-sm"
className="px-3 py-1.5 border border-gray-200 dark:border-gray-700 dark:bg-gray-800 dark:text-white rounded-lg text-sm"
/>
</div>
</>
@@ -254,14 +297,14 @@ export default function Settings() {
</section>
{/* Timezone Section */}
<section className="bg-white rounded-2xl p-4 shadow-sm">
<section className="bg-white dark:bg-gray-900 rounded-2xl p-4 shadow-sm dark:shadow-none border border-transparent dark:border-gray-800">
<div className="flex items-center gap-3 mb-4">
<div className="w-10 h-10 rounded-xl bg-purple-100 flex items-center justify-center">
<Globe className="text-purple-600" size={20} />
<div className="w-10 h-10 rounded-xl bg-purple-100 dark:bg-purple-900/30 flex items-center justify-center">
<Globe className="text-purple-600 dark:text-purple-400" size={20} />
</div>
<div>
<h2 className="font-semibold">Часовой пояс</h2>
<p className="text-sm text-gray-500">Для корректных напоминаний</p>
<h2 className="font-semibold dark:text-white">Часовой пояс</h2>
<p className="text-sm text-gray-500 dark:text-gray-400">Для корректных напоминаний</p>
</div>
</div>
@@ -291,7 +334,7 @@ export default function Settings() {
)}
{mutation.isSuccess && !hasChanges && (
<div className="p-3 rounded-xl bg-green-50 text-green-700 text-sm text-center">
<div className="p-3 rounded-xl bg-green-50 dark:bg-green-900/20 text-green-700 dark:text-green-400 text-sm text-center">
Настройки сохранены
</div>
)}

View File

@@ -1,374 +1,691 @@
import { useState, useEffect, useMemo } from 'react'
import { useState, useEffect, useMemo, useRef } from 'react'
import { useQuery } from '@tanstack/react-query'
import { motion } from 'framer-motion'
import { ChevronLeft, ChevronRight, Flame, Target, TrendingUp, BarChart3 } from 'lucide-react'
import { format, startOfMonth, endOfMonth, eachDayOfInterval, isToday, subMonths, addMonths, parseISO, isSameMonth } from 'date-fns'
import { motion, AnimatePresence } from 'framer-motion'
import { ChevronDown, Flame, Trophy, CheckCircle2, TrendingUp, BarChart3, Calendar, Sparkles, Target, Zap } from 'lucide-react'
import { format, subDays, parseISO, startOfDay, differenceInDays, isBefore, isAfter, eachDayOfInterval, startOfMonth, getDay, addDays } from 'date-fns'
import { ru } from 'date-fns/locale'
import { LineChart, Line, BarChart, Bar, XAxis, YAxis, Tooltip, ResponsiveContainer, Cell, Area, AreaChart, CartesianGrid } from 'recharts'
import { habitsApi } from '../api/habits'
import Navigation from '../components/Navigation'
import clsx from 'clsx'
// Получить дату начала привычки
function getHabitStartDate(habit) {
if (habit.start_date) return startOfDay(parseISO(habit.start_date))
if (habit.created_at) return startOfDay(parseISO(habit.created_at))
return startOfDay(new Date())
}
// Check if habit is frozen on date
function isHabitFrozenOnDate(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 isHabitExpectedOnDate(habit, date, freezes) {
const checkDate = startOfDay(date)
const startDate = getHabitStartDate(habit)
if (checkDate < startDate || checkDate > startOfDay(new Date())) return false
if (isHabitFrozenOnDate(freezes, date)) return false
const dayOfWeek = checkDate.getDay() || 7
if (habit.frequency === "daily") return true
if (habit.frequency === "weekly") {
if (habit.target_days?.length > 0) return habit.target_days.includes(dayOfWeek)
return true
}
if (habit.frequency === "interval" && habit.target_count > 0) {
const daysSinceStart = differenceInDays(checkDate, startDate)
return daysSinceStart % habit.target_count === 0
}
return true
}
// Custom Tooltip Component
const CustomTooltip = ({ active, payload, label }) => {
if (active && payload && payload.length) {
return (
<div className="bg-gray-900/95 backdrop-blur-sm px-4 py-3 rounded-xl shadow-2xl border border-gray-700/50">
<p className="text-gray-400 text-xs mb-1">{label}</p>
<p className="text-white font-bold text-lg">{payload[0].value}%</p>
</div>
)
}
return null
}
// Stat Card Component
const StatCard = ({ icon, value, label, emoji, color, delay = 0 }) => (
<motion.div
initial={{ opacity: 0, y: 20, scale: 0.95 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
transition={{ delay, duration: 0.4, ease: "easeOut" }}
className="relative overflow-hidden group"
>
<div className="absolute inset-0 bg-gradient-to-br from-white/80 to-white/40 dark:from-gray-800/80 dark:to-gray-900/40 backdrop-blur-xl rounded-2xl" />
<div className={clsx(
"absolute inset-0 opacity-[0.03] group-hover:opacity-[0.06] transition-opacity duration-500",
`bg-gradient-to-br ${color}`
)} />
<div className="relative p-5 flex items-center gap-4">
<div className={clsx(
"w-14 h-14 rounded-2xl flex items-center justify-center text-2xl",
"bg-gradient-to-br shadow-lg",
color
)}>
{emoji}
</div>
<div className="flex-1">
<p className="text-4xl font-bold text-gray-900 dark:text-white tracking-tight">
{value}
</p>
<p className="text-sm text-gray-500 dark:text-gray-400 mt-0.5">{label}</p>
</div>
</div>
<div className="absolute bottom-0 left-0 right-0 h-1 bg-gradient-to-r opacity-50 rounded-b-2xl"
style={{ background: `linear-gradient(to right, var(--tw-gradient-stops))` }} />
</motion.div>
)
// Heatmap Cell Component
const HeatmapCell = ({ day, getColor, index }) => {
const [showTooltip, setShowTooltip] = useState(false)
const cellRef = useRef(null)
return (
<div className="relative" ref={cellRef}>
<motion.div
initial={{ scale: 0, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
transition={{ delay: index * 0.003, duration: 0.2 }}
className={clsx(
"w-full aspect-square rounded-[4px] cursor-pointer transition-all duration-200",
"hover:ring-2 hover:ring-primary-400 hover:ring-offset-2 hover:ring-offset-gray-900",
"hover:scale-110 hover:z-10",
getColor(day.count, day.expected)
)}
onMouseEnter={() => setShowTooltip(true)}
onMouseLeave={() => setShowTooltip(false)}
/>
<AnimatePresence>
{showTooltip && (
<motion.div
initial={{ opacity: 0, y: 5, scale: 0.9 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
exit={{ opacity: 0, y: 5, scale: 0.9 }}
className="absolute bottom-full left-1/2 -translate-x-1/2 mb-2 z-50 pointer-events-none"
>
<div className="bg-gray-900 text-white px-3 py-2 rounded-lg shadow-xl text-xs whitespace-nowrap">
<p className="font-medium">{format(day.date, 'd MMMM', { locale: ru })}</p>
<p className="text-primary-400 mt-0.5">
{day.count}/{day.expected} выполнено
</p>
</div>
</motion.div>
)}
</AnimatePresence>
</div>
)
}
// Section Header Component
const SectionHeader = ({ icon: Icon, title, subtitle }) => (
<div className="flex items-center gap-3 mb-5">
<div className="w-10 h-10 rounded-xl bg-gradient-to-br from-primary-500/20 to-primary-600/10 flex items-center justify-center">
<Icon className="text-primary-500" size={20} />
</div>
<div>
<h3 className="text-lg font-bold text-gray-900 dark:text-white">{title}</h3>
{subtitle && <p className="text-xs text-gray-500 dark:text-gray-400">{subtitle}</p>}
</div>
</div>
)
export default function Stats() {
const [currentMonth, setCurrentMonth] = useState(new Date())
const [selectedHabitId, setSelectedHabitId] = useState(null)
const [allHabitLogs, setAllHabitLogs] = useState({})
const [allHabitStats, setAllHabitStats] = useState({})
const [selectedHabitId, setSelectedHabitId] = useState(null)
const [allHabitFreezes, setAllHabitFreezes] = useState({})
const [dropdownOpen, setDropdownOpen] = useState(false)
const { data: habits = [] } = useQuery({
queryKey: ['habits'],
queryFn: habitsApi.list,
})
// Загрузка логов и статистики для всех привычек
useEffect(() => {
if (habits.length > 0) {
loadAllHabitsData()
}
if (habits.length > 0) loadAllHabitsData()
}, [habits])
const loadAllHabitsData = async () => {
const logsMap = {}
const statsMap = {}
const freezesMap = {}
await Promise.all(habits.map(async (habit) => {
try {
const [logs, stats] = await Promise.all([
const [logs, stats, freezes] = await Promise.all([
habitsApi.getLogs(habit.id, 90),
habitsApi.getHabitStats(habit.id),
habitsApi.getFreezes(habit.id),
])
logsMap[habit.id] = logs
statsMap[habit.id] = stats
freezesMap[habit.id] = freezes
} catch (e) {
console.error(`Error loading data for habit ${habit.id}:`, e)
logsMap[habit.id] = []
statsMap[habit.id] = null
freezesMap[habit.id] = []
}
}))
setAllHabitLogs(logsMap)
setAllHabitStats(statsMap)
setAllHabitFreezes(freezesMap)
}
const monthStart = startOfMonth(currentMonth)
const monthEnd = endOfMonth(currentMonth)
const daysInMonth = eachDayOfInterval({ start: monthStart, end: monthEnd })
const startDayOfWeek = monthStart.getDay()
const paddingDays = startDayOfWeek === 0 ? 6 : startDayOfWeek - 1
// Получить привычки, выполненные в конкретный день
const getCompletedHabitsForDate = (date) => {
const dateStr = format(date, 'yyyy-MM-dd')
return habits.filter(habit => {
// Combined stats for all or selected habit
const computedStats = useMemo(() => {
const targetHabits = selectedHabitId
? habits.filter(h => h.id === selectedHabitId)
: habits
let totalLogs = 0
let totalExpected = 0
let currentStreak = 0
let bestStreak = 0
targetHabits.forEach(habit => {
const logs = allHabitLogs[habit.id] || []
return logs.some(log => log.date.split('T')[0] === dateStr)
const stats = allHabitStats[habit.id]
const freezes = allHabitFreezes[habit.id] || []
totalLogs += logs.length
for (let i = 0; i < 30; i++) {
const date = subDays(new Date(), i)
if (isHabitExpectedOnDate(habit, date, freezes)) totalExpected++
}
if (stats) {
currentStreak = Math.max(currentStreak, stats.current_streak || 0)
bestStreak = Math.max(bestStreak, stats.longest_streak || 0)
}
})
}
const rate = totalExpected > 0 ? Math.round((totalLogs / totalExpected) * 100) : 0
return { totalLogs, currentStreak, bestStreak, rate }
}, [selectedHabitId, habits, allHabitLogs, allHabitStats, allHabitFreezes])
// Подсчёт выполнений за текущий месяц для каждой привычки
const monthlyStats = useMemo(() => {
const stats = {}
habits.forEach(habit => {
const logs = allHabitLogs[habit.id] || []
const monthLogs = logs.filter(log => {
const logDate = parseISO(log.date.split('T')[0])
return isSameMonth(logDate, currentMonth)
// Heatmap data (12 weeks = 84 days)
const heatmapData = useMemo(() => {
const today = startOfDay(new Date())
const startDate = subDays(today, 83)
const days = eachDayOfInterval({ start: startDate, end: today })
// Align to week start (Monday)
const firstDayOfWeek = getDay(startDate) || 7
const paddingDays = firstDayOfWeek - 1
return days.map(day => {
const dateStr = format(day, 'yyyy-MM-dd')
let count = 0
let expected = 0
const targetHabits = selectedHabitId
? habits.filter(h => h.id === selectedHabitId)
: habits
targetHabits.forEach(habit => {
const logs = allHabitLogs[habit.id] || []
const freezes = allHabitFreezes[habit.id] || []
if (logs.some(l => l.date.split('T')[0] === dateStr)) count++
if (isHabitExpectedOnDate(habit, day, freezes)) expected++
})
stats[habit.id] = monthLogs.length
return { date: day, dateStr, count, expected }
})
return stats
}, [habits, allHabitLogs, currentMonth])
}, [selectedHabitId, habits, allHabitLogs, allHabitFreezes])
// Общий % выполнения за месяц
const overallMonthlyPercent = useMemo(() => {
if (habits.length === 0) return 0
const totalPossible = habits.length * daysInMonth.length
const totalCompleted = Object.values(monthlyStats).reduce((sum, val) => sum + val, 0)
return Math.round((totalCompleted / totalPossible) * 100)
}, [habits, monthlyStats, daysInMonth])
// Get unique months for heatmap labels
const heatmapMonths = useMemo(() => {
const months = []
let currentMonth = null
heatmapData.forEach((day, index) => {
const month = format(day.date, 'MMM', { locale: ru })
if (month !== currentMonth) {
months.push({ month, index: Math.floor(index / 7) })
currentMonth = month
}
})
return months
}, [heatmapData])
const prevMonth = () => setCurrentMonth(subMonths(currentMonth, 1))
const nextMonth = () => setCurrentMonth(addMonths(currentMonth, 1))
// Line chart data (30 days completion rate)
const lineChartData = useMemo(() => {
const data = []
for (let i = 29; i >= 0; i--) {
const date = subDays(new Date(), i)
const dateStr = format(date, 'yyyy-MM-dd')
let completed = 0
let expected = 0
const targetHabits = selectedHabitId
? habits.filter(h => h.id === selectedHabitId)
: habits
targetHabits.forEach(habit => {
const logs = allHabitLogs[habit.id] || []
const freezes = allHabitFreezes[habit.id] || []
if (logs.some(l => l.date.split('T')[0] === dateStr)) completed++
if (isHabitExpectedOnDate(habit, date, freezes)) expected++
})
const rate = expected > 0 ? Math.round((completed / expected) * 100) : 0
data.push({ date: format(date, 'dd.MM'), fullDate: format(date, 'd MMM', { locale: ru }), rate, completed, expected })
}
return data
}, [selectedHabitId, habits, allHabitLogs, allHabitFreezes])
// Bar chart data (habits comparison)
const barChartData = useMemo(() => {
return habits.map(habit => {
const logs = allHabitLogs[habit.id] || []
const freezes = allHabitFreezes[habit.id] || []
let expected = 0
for (let i = 0; i < 30; i++) {
const date = subDays(new Date(), i)
if (isHabitExpectedOnDate(habit, date, freezes)) expected++
}
const completed = logs.filter(l => {
const logDate = parseISO(l.date.split('T')[0])
return differenceInDays(new Date(), logDate) < 30
}).length
const rate = expected > 0 ? Math.round((completed / expected) * 100) : 0
return {
name: habit.name,
icon: habit.icon,
rate,
color: habit.color || '#0d9488',
completed,
expected
}
}).sort((a, b) => b.rate - a.rate)
}, [habits, allHabitLogs, allHabitFreezes])
// Heatmap intensity color - 5 levels
const getHeatmapColor = (count, expected) => {
if (expected === 0) return 'bg-[#1a1a1a]'
const ratio = count / expected
if (ratio === 0) return 'bg-[#1f1f1f] dark:bg-[#1a1a1a]'
if (ratio < 0.4) return 'bg-teal-900/80'
if (ratio < 0.7) return 'bg-teal-700'
if (ratio < 1) return 'bg-teal-600'
return 'bg-teal-400 shadow-sm shadow-teal-400/30'
}
const selectedHabit = habits.find(h => h.id === selectedHabitId)
const selectedStats = selectedHabitId ? allHabitStats[selectedHabitId] : null
// Group heatmap by weeks (columns)
const heatmapWeeks = useMemo(() => {
const weeks = []
for (let i = 0; i < heatmapData.length; i += 7) {
weeks.push(heatmapData.slice(i, i + 7))
}
return weeks
}, [heatmapData])
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">
<h1 className="text-xl font-display font-bold text-gray-900">Статистика</h1>
<p className="text-sm text-gray-500">Общий прогресс по привычкам</p>
<div className="min-h-screen bg-gray-950 pb-24 transition-colors duration-300">
{/* Gradient Background */}
<div className="fixed inset-0 pointer-events-none">
<div className="absolute top-0 left-1/4 w-96 h-96 bg-primary-500/10 rounded-full blur-3xl" />
<div className="absolute bottom-1/4 right-0 w-80 h-80 bg-teal-500/5 rounded-full blur-3xl" />
</div>
<header className="relative bg-gray-900/50 backdrop-blur-xl border-b border-gray-800/50 sticky top-0 z-20">
<div className="max-w-lg mx-auto px-5 py-5">
<div className="flex items-center gap-3">
<div className="w-11 h-11 rounded-2xl bg-gradient-to-br from-primary-500 to-teal-600 flex items-center justify-center shadow-lg shadow-primary-500/25">
<BarChart3 className="text-white" size={22} />
</div>
<div>
<h1 className="text-xl font-bold text-white flex items-center gap-2">
Статистика
<Sparkles className="text-primary-400" size={16} />
</h1>
<p className="text-sm text-gray-400">Отслеживай свой прогресс</p>
</div>
</div>
</div>
</header>
<main className="max-w-lg mx-auto px-4 py-6 space-y-6">
<main className="relative max-w-lg mx-auto px-5 py-6 space-y-8">
{/* Легенда с привычками */}
{habits.length > 0 && (
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
className="card p-4"
>
<div className="flex items-center justify-between mb-3">
<h3 className="text-sm font-semibold text-gray-700">Привычки</h3>
<div className="flex items-center gap-2">
<span className="text-xs text-gray-500">Общий прогресс:</span>
<span className="text-sm font-bold text-primary-600">{overallMonthlyPercent}%</span>
</div>
</div>
<div className="flex flex-wrap gap-2">
{habits.map((habit) => (
<button
key={habit.id}
onClick={() => setSelectedHabitId(selectedHabitId === habit.id ? null : habit.id)}
className={clsx(
'flex items-center gap-1.5 px-3 py-1.5 rounded-full text-sm transition-all',
selectedHabitId === habit.id
? 'ring-2 ring-offset-1'
: 'hover:bg-gray-100'
)}
style={{
backgroundColor: selectedHabitId === habit.id ? habit.color + '20' : undefined,
ringColor: habit.color,
}}
>
<div
className="w-3 h-3 rounded-full flex-shrink-0"
style={{ backgroundColor: habit.color }}
/>
<span className="text-gray-700">{habit.icon}</span>
<span className="text-gray-600 font-medium">{habit.name}</span>
</button>
))}
</div>
</motion.div>
)}
{/* Календарь */}
<motion.div
initial={{ opacity: 0, y: 20 }}
{/* Habit Selector Dropdown */}
<motion.div
initial={{ opacity: 0, y: -10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.1 }}
className="card p-5"
className="relative"
>
<div className="flex items-center justify-between mb-4">
<button
onClick={prevMonth}
className="p-2 text-gray-400 hover:text-gray-600 rounded-lg hover:bg-gray-100 transition-all"
>
<ChevronLeft size={20} />
</button>
<h3 className="text-lg font-semibold text-gray-900 capitalize">
{format(currentMonth, 'LLLL yyyy', { locale: ru })}
</h3>
<button
onClick={nextMonth}
className="p-2 text-gray-400 hover:text-gray-600 rounded-lg hover:bg-gray-100 transition-all"
>
<ChevronRight size={20} />
</button>
</div>
{/* Дни недели */}
<div className="grid grid-cols-7 gap-1 mb-2">
{['Пн', 'Вт', 'Ср', 'Чт', 'Пт', 'Сб', 'Вс'].map((day) => (
<div key={day} className="text-center text-xs font-medium text-gray-400 py-2">
{day}
</div>
))}
</div>
{/* Дни месяца */}
<div className="grid grid-cols-7 gap-1">
{[...Array(paddingDays)].map((_, i) => (
<div key={'pad' + i} className="aspect-square" />
))}
{daysInMonth.map((day) => {
const completedHabits = getCompletedHabitsForDate(day)
const today = isToday(day)
const future = day > new Date()
const completedCount = completedHabits.length
const totalCount = habits.length
return (
<div
key={day.toISOString()}
<button
onClick={() => setDropdownOpen(!dropdownOpen)}
className="w-full bg-gray-900/80 backdrop-blur-xl border border-gray-800 rounded-2xl p-4 flex items-center justify-between hover:border-gray-700 transition-all duration-300"
>
<div className="flex items-center gap-3">
{selectedHabit ? (
<>
<div
className="w-10 h-10 rounded-xl flex items-center justify-center text-xl"
style={{ backgroundColor: selectedHabit.color + '20' }}
>
{selectedHabit.icon}
</div>
<div className="text-left">
<span className="font-semibold text-white block">{selectedHabit.name}</span>
<span className="text-xs text-gray-500">Выбранная привычка</span>
</div>
</>
) : (
<>
<div className="w-10 h-10 rounded-xl bg-gradient-to-br from-primary-500/20 to-teal-500/10 flex items-center justify-center">
<Target className="text-primary-400" size={20} />
</div>
<div className="text-left">
<span className="font-semibold text-white block">Все привычки</span>
<span className="text-xs text-gray-500">{habits.length} привычек</span>
</div>
</>
)}
</div>
<ChevronDown className={clsx(
"text-gray-500 transition-transform duration-300",
dropdownOpen && "rotate-180"
)} size={20} />
</button>
<AnimatePresence>
{dropdownOpen && (
<motion.div
initial={{ opacity: 0, y: -10, scale: 0.95 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
exit={{ opacity: 0, y: -10, scale: 0.95 }}
transition={{ duration: 0.2 }}
className="absolute top-full left-0 right-0 mt-2 bg-gray-900 backdrop-blur-xl rounded-2xl shadow-2xl border border-gray-800 z-30 overflow-hidden max-h-80 overflow-y-auto"
>
<button
onClick={() => { setSelectedHabitId(null); setDropdownOpen(false) }}
className={clsx(
'aspect-square flex flex-col items-center justify-center rounded-lg relative p-1',
today && 'ring-2 ring-primary-500 bg-primary-50',
future && 'opacity-40',
!today && completedCount > 0 && 'bg-gray-50'
"w-full p-4 flex items-center gap-3 hover:bg-gray-800 transition-colors border-b border-gray-800/50",
!selectedHabitId && "bg-primary-500/10"
)}
>
{/* Число дня */}
<span className={clsx(
'text-sm font-medium',
today ? 'text-primary-600' : completedCount > 0 ? 'text-gray-900' : 'text-gray-400'
)}>
{format(day, 'd')}
</span>
{/* Цветные точки выполненных привычек */}
{completedCount > 0 && (
<div className="flex flex-wrap gap-0.5 justify-center mt-0.5">
{completedHabits.slice(0, 4).map((habit) => (
<motion.div
key={habit.id}
initial={{ scale: 0 }}
animate={{ scale: 1 }}
className="w-1.5 h-1.5 rounded-full"
style={{ backgroundColor: habit.color }}
/>
))}
{completedHabits.length > 4 && (
<span className="text-[8px] text-gray-400">+{completedHabits.length - 4}</span>
)}
</div>
)}
{/* X/Y в углу */}
{totalCount > 0 && !future && (
<span className={clsx(
'absolute bottom-0.5 right-0.5 text-[8px] font-medium',
completedCount === totalCount ? 'text-green-500' :
completedCount > 0 ? 'text-gray-400' : 'text-gray-300'
)}>
{completedCount}/{totalCount}
</span>
)}
</div>
)
})}
</div>
<Target className="text-primary-400" size={20} />
<span className="font-medium text-white">Все привычки</span>
{!selectedHabitId && <div className="ml-auto w-2 h-2 rounded-full bg-primary-400" />}
</button>
{habits.map(habit => (
<button
key={habit.id}
onClick={() => { setSelectedHabitId(habit.id); setDropdownOpen(false) }}
className={clsx(
"w-full p-4 flex items-center gap-3 hover:bg-gray-800 transition-colors",
selectedHabitId === habit.id && "bg-primary-500/10"
)}
>
<span className="text-xl">{habit.icon}</span>
<span className="font-medium text-white">{habit.name}</span>
{selectedHabitId === habit.id && <div className="ml-auto w-2 h-2 rounded-full bg-primary-400" />}
</button>
))}
</motion.div>
)}
</AnimatePresence>
</motion.div>
{/* Monthly Summary - карточки для каждой привычки */}
{habits.length > 0 && (
<motion.div
{/* Stats Cards */}
<section>
<div className="grid grid-cols-2 gap-4">
<StatCard
emoji="🔥"
value={computedStats.currentStreak}
label="Текущий streak"
color="from-orange-500/20 to-red-500/10"
delay={0}
/>
<StatCard
emoji="🏆"
value={computedStats.bestStreak}
label="Лучший streak"
color="from-yellow-500/20 to-amber-500/10"
delay={0.1}
/>
<StatCard
emoji="✅"
value={computedStats.totalLogs}
label="Всего выполнено"
color="from-green-500/20 to-emerald-500/10"
delay={0.2}
/>
<StatCard
emoji="📈"
value={`${computedStats.rate}%`}
label="Completion rate"
color="from-primary-500/20 to-teal-500/10"
delay={0.3}
/>
</div>
</section>
{/* Heatmap Calendar */}
<motion.section
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.2 }}
className="bg-gray-900/60 backdrop-blur-xl border border-gray-800/50 rounded-3xl p-6"
>
<SectionHeader
icon={Calendar}
title="Активность"
subtitle="Последние 12 недель"
/>
{/* Month labels */}
<div className="flex mb-2 ml-8">
{heatmapMonths.map((m, i) => (
<div
key={i}
className="text-[10px] text-gray-500 capitalize"
style={{
position: 'absolute',
left: `${32 + m.index * 18}px`
}}
>
{m.month}
</div>
))}
</div>
<div className="flex gap-1 mt-6">
{/* Day labels */}
<div className="flex flex-col gap-[3px] pr-2">
{['Пн', '', 'Ср', '', 'Пт', '', 'Вс'].map((d, i) => (
<div key={i} className="h-[14px] text-[10px] text-gray-500 flex items-center">
{d}
</div>
))}
</div>
{/* Heatmap grid */}
<div className="flex gap-[3px] flex-1">
{heatmapWeeks.map((week, weekIndex) => (
<div key={weekIndex} className="flex flex-col gap-[3px] flex-1">
{week.map((day, dayIndex) => (
<HeatmapCell
key={day.dateStr}
day={day}
getColor={getHeatmapColor}
index={weekIndex * 7 + dayIndex}
/>
))}
</div>
))}
</div>
</div>
{/* Legend */}
<div className="flex items-center justify-end gap-2 mt-5 pt-4 border-t border-gray-800/50">
<span className="text-xs text-gray-500">Меньше</span>
<div className="flex gap-1">
<div className="w-3.5 h-3.5 rounded-[3px] bg-[#1f1f1f]" />
<div className="w-3.5 h-3.5 rounded-[3px] bg-teal-900/80" />
<div className="w-3.5 h-3.5 rounded-[3px] bg-teal-700" />
<div className="w-3.5 h-3.5 rounded-[3px] bg-teal-600" />
<div className="w-3.5 h-3.5 rounded-[3px] bg-teal-400" />
</div>
<span className="text-xs text-gray-500">Больше</span>
</div>
</motion.section>
{/* Line Chart - Completion Rate */}
<motion.section
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.3 }}
className="bg-gray-900/60 backdrop-blur-xl border border-gray-800/50 rounded-3xl p-6"
>
<SectionHeader
icon={TrendingUp}
title="Completion Rate"
subtitle="Динамика за 30 дней"
/>
<div className="h-56">
<ResponsiveContainer width="100%" height="100%">
<AreaChart data={lineChartData}>
<defs>
<linearGradient id="colorRate" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stopColor="#0d9488" stopOpacity={0.4} />
<stop offset="100%" stopColor="#0d9488" stopOpacity={0} />
</linearGradient>
</defs>
<CartesianGrid
strokeDasharray="3 3"
stroke="#374151"
strokeOpacity={0.3}
vertical={false}
/>
<XAxis
dataKey="date"
tick={{ fontSize: 10, fill: '#6b7280' }}
axisLine={false}
tickLine={false}
interval="preserveStartEnd"
/>
<YAxis
tick={{ fontSize: 10, fill: '#6b7280' }}
axisLine={false}
tickLine={false}
domain={[0, 100]}
width={30}
tickFormatter={(v) => `${v}%`}
/>
<Tooltip content={<CustomTooltip />} />
<Area
type="monotone"
dataKey="rate"
stroke="#0d9488"
strokeWidth={2.5}
fill="url(#colorRate)"
dot={false}
activeDot={{
r: 6,
fill: '#0d9488',
stroke: '#fff',
strokeWidth: 2,
className: 'drop-shadow-lg'
}}
/>
</AreaChart>
</ResponsiveContainer>
</div>
</motion.section>
{/* Bar Chart - Habits Comparison */}
{!selectedHabitId && habits.length > 1 && (
<motion.section
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.15 }}
className="card p-5"
transition={{ delay: 0.4 }}
className="bg-gray-900/60 backdrop-blur-xl border border-gray-800/50 rounded-3xl p-6"
>
<h3 className="text-lg font-semibold text-gray-900 mb-4 flex items-center gap-2">
<BarChart3 className="w-5 h-5 text-primary-500" />
Итоги месяца
</h3>
<SectionHeader
icon={BarChart3}
title="По привычкам"
subtitle="Рейтинг за 30 дней"
/>
<div className="space-y-3">
{habits.map((habit) => {
const completed = monthlyStats[habit.id] || 0
const total = daysInMonth.length
const percent = Math.round((completed / total) * 100)
return (
<div
key={habit.id}
className="p-3 rounded-xl bg-gray-50 hover:bg-gray-100 transition-all cursor-pointer"
onClick={() => setSelectedHabitId(selectedHabitId === habit.id ? null : habit.id)}
>
<div className="flex items-center justify-between mb-2">
<div className="flex items-center gap-2">
<div
className="w-3 h-3 rounded-full"
style={{ backgroundColor: habit.color }}
/>
<span className="text-lg">{habit.icon}</span>
<span className="font-medium text-gray-900">{habit.name}</span>
</div>
<span className="text-sm font-bold text-gray-700">{completed}/{total}</span>
</div>
{/* Progress bar */}
<div className="h-2 bg-gray-200 rounded-full overflow-hidden">
<motion.div
initial={{ width: 0 }}
animate={{ width: `${percent}%` }}
transition={{ duration: 0.5, ease: 'easeOut' }}
className="h-full rounded-full"
style={{ backgroundColor: habit.color }}
/>
</div>
<div className="text-right mt-1">
<span className="text-xs text-gray-500">{percent}%</span>
</div>
{barChartData.map((habit, index) => (
<motion.div
key={habit.name}
initial={{ opacity: 0, x: -20 }}
animate={{ opacity: 1, x: 0 }}
transition={{ delay: 0.1 * index }}
className="relative"
>
<div className="flex items-center gap-3 mb-2">
<span className="text-lg">{habit.icon}</span>
<span className="text-sm font-medium text-white flex-1 truncate">{habit.name}</span>
<span className="text-sm font-bold text-primary-400">{habit.rate}%</span>
</div>
)
})}
<div className="h-2 bg-gray-800 rounded-full overflow-hidden">
<motion.div
initial={{ width: 0 }}
animate={{ width: `${habit.rate}%` }}
transition={{ delay: 0.2 + 0.1 * index, duration: 0.8, ease: "easeOut" }}
className="h-full rounded-full"
style={{
backgroundColor: habit.color,
boxShadow: `0 0 10px ${habit.color}50`
}}
/>
</div>
<div className="flex justify-between mt-1">
<span className="text-[10px] text-gray-500">{habit.completed} выполнено</span>
<span className="text-[10px] text-gray-500">{habit.expected} ожидалось</span>
</div>
</motion.div>
))}
</div>
</motion.div>
)}
{/* Детальная статистика для выбранной привычки */}
{selectedHabit && selectedStats && (
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
className="card p-5"
>
<div className="flex items-center gap-3 mb-4">
<div
className="w-10 h-10 rounded-xl flex items-center justify-center text-xl"
style={{ backgroundColor: selectedHabit.color + '20' }}
>
{selectedHabit.icon}
</div>
<div>
<h3 className="text-lg font-semibold text-gray-900">{selectedHabit.name}</h3>
<p className="text-sm text-gray-500">Детальная статистика</p>
</div>
</div>
{/* Streak карточки */}
<div className="grid grid-cols-3 gap-3 mb-4">
<div className="text-center p-3 bg-gradient-to-br from-accent-50 to-accent-100 rounded-xl">
<Flame className="w-5 h-5 text-accent-500 mx-auto mb-1" />
<p className="text-xl font-bold text-gray-900">{selectedStats.current_streak}</p>
<p className="text-xs text-gray-500">Текущий</p>
</div>
<div className="text-center p-3 bg-gradient-to-br from-primary-50 to-primary-100 rounded-xl">
<TrendingUp className="w-5 h-5 text-primary-500 mx-auto mb-1" />
<p className="text-xl font-bold text-gray-900">{selectedStats.longest_streak}</p>
<p className="text-xs text-gray-500">Лучший</p>
</div>
<div className="text-center p-3 bg-gradient-to-br from-green-50 to-green-100 rounded-xl">
<Target className="w-5 h-5 text-green-500 mx-auto mb-1" />
<p className="text-xl font-bold text-gray-900">{Math.round(selectedStats.completion_pct || 0)}%</p>
<p className="text-xs text-gray-500">Выполнение</p>
</div>
</div>
{/* Детальные данные */}
<div className="space-y-2">
<div className="flex justify-between items-center py-2 border-b border-gray-100">
<span className="text-gray-600">Всего выполнений</span>
<span className="font-semibold text-gray-900">{selectedStats.total_logs || 0}</span>
</div>
<div className="flex justify-between items-center py-2 border-b border-gray-100">
<span className="text-gray-600">За эту неделю</span>
<span className="font-semibold text-gray-900">{selectedStats.this_week || 0}</span>
</div>
<div className="flex justify-between items-center py-2 border-b border-gray-100">
<span className="text-gray-600">За этот месяц</span>
<span className="font-semibold text-gray-900">{selectedStats.this_month || 0}</span>
</div>
<div className="flex justify-between items-center py-2">
<span className="text-gray-600">Дата создания</span>
<span className="font-semibold text-gray-900">
{format(parseISO(selectedHabit.created_at), 'd MMM yyyy', { locale: ru })}
</span>
</div>
</div>
</motion.div>
</motion.section>
)}
{habits.length === 0 && (
<div className="card p-10 text-center">
<p className="text-gray-500">Создайте привычки, чтобы видеть статистику</p>
</div>
<motion.div
initial={{ opacity: 0, scale: 0.95 }}
animate={{ opacity: 1, scale: 1 }}
className="bg-gray-900/60 backdrop-blur-xl border border-gray-800/50 rounded-3xl p-12 text-center"
>
<div className="w-16 h-16 rounded-2xl bg-gray-800 flex items-center justify-center mx-auto mb-4">
<Zap className="text-gray-600" size={32} />
</div>
<p className="text-gray-400 font-medium">Создайте привычки,</p>
<p className="text-gray-500 text-sm">чтобы видеть статистику</p>
</motion.div>
)}
</main>

View File

@@ -1,7 +1,7 @@
import { useState } from 'react'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { motion, AnimatePresence } from 'framer-motion'
import { Plus, Check, Circle, Calendar, AlertTriangle, Undo2, Edit2 } from 'lucide-react'
import { Plus, Check, Circle, Calendar, AlertTriangle, Undo2, Edit2, Repeat } from 'lucide-react'
import { format, parseISO, isToday, isTomorrow, isPast } from 'date-fns'
import { ru } from 'date-fns/locale'
import { tasksApi } from '../api/tasks'
@@ -12,9 +12,16 @@ import clsx from 'clsx'
const PRIORITY_LABELS = {
0: null,
1: { label: 'Низкий', class: 'bg-blue-100 text-blue-700' },
2: { label: 'Средний', class: 'bg-yellow-100 text-yellow-700' },
3: { label: 'Высокий', class: 'bg-red-100 text-red-700' },
1: { label: 'Низкий', class: 'bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400' },
2: { label: 'Средний', class: 'bg-yellow-100 text-yellow-700 dark:bg-yellow-900/30 dark:text-yellow-400' },
3: { label: 'Высокий', class: 'bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400' },
}
const RECURRENCE_LABELS = {
daily: 'Ежедневно',
weekly: 'Еженедельно',
monthly: 'Ежемесячно',
custom: 'Повтор',
}
function formatDueDate(dateStr) {
@@ -28,7 +35,7 @@ function formatDueDate(dateStr) {
export default function Tasks() {
const [showCreate, setShowCreate] = useState(false)
const [editingTask, setEditingTask] = useState(null)
const [filter, setFilter] = useState('active') // all, active, completed
const [filter, setFilter] = useState('active')
const queryClient = useQueryClient()
const { data: tasks = [], isLoading } = useQuery({
@@ -56,31 +63,21 @@ export default function Tasks() {
})
const handleToggle = (task) => {
if (task.completed) {
uncompleteMutation.mutate(task.id)
} else {
completeMutation.mutate(task.id)
}
if (task.completed) uncompleteMutation.mutate(task.id)
else completeMutation.mutate(task.id)
}
const activeTasks = tasks.filter(t => !t.completed)
const completedTasks = tasks.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">
<div className="flex items-center justify-between">
<h1 className="text-xl font-display font-bold text-gray-900">Задачи</h1>
<button
onClick={() => setShowCreate(true)}
className="p-2 bg-primary-500 text-white rounded-xl hover:bg-primary-600 transition-colors shadow-lg shadow-primary-500/30"
>
<h1 className="text-xl font-display font-bold text-gray-900 dark:text-white">Задачи</h1>
<button onClick={() => setShowCreate(true)} className="p-2 bg-primary-500 text-white rounded-xl hover:bg-primary-600 transition-colors shadow-lg shadow-primary-500/30">
<Plus size={22} />
</button>
</div>
{/* Фильтры */}
<div className="flex gap-2 mt-4">
{[
{ key: 'active', label: 'Активные' },
@@ -94,7 +91,7 @@ export default function Tasks() {
'px-4 py-2 rounded-xl text-sm font-medium transition-all',
filter === key
? 'bg-primary-500 text-white shadow-lg shadow-primary-500/30'
: 'bg-gray-100 text-gray-600 hover:bg-gray-200'
: 'bg-gray-100 dark:bg-gray-800 text-gray-600 dark:text-gray-300 hover:bg-gray-200 dark:hover:bg-gray-700'
)}
>
{label}
@@ -110,35 +107,28 @@ export default function Tasks() {
{[1, 2, 3].map((i) => (
<div key={i} 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>
))}
</div>
) : tasks.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-primary-100 to-primary-200 flex items-center justify-center mx-auto mb-5">
<Check className="w-10 h-10 text-primary-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-primary-100 to-primary-200 dark:from-primary-900/30 dark:to-primary-800/30 flex items-center justify-center mx-auto mb-5">
<Check className="w-10 h-10 text-primary-600 dark:text-primary-400" />
</div>
<h3 className="text-xl font-display font-bold text-gray-900 mb-2">
<h3 className="text-xl font-display font-bold text-gray-900 dark:text-white mb-2">
{filter === 'active' ? 'Нет активных задач' : filter === 'completed' ? 'Нет выполненных задач' : 'Нет задач'}
</h3>
<p className="text-gray-500 mb-6">
<p className="text-gray-500 dark:text-gray-400 mb-6">
{filter === 'active' ? 'Добавь новую задачу или выбери другой фильтр' : 'Выполняй задачи и они появятся здесь'}
</p>
{filter === 'active' && (
<button
onClick={() => setShowCreate(true)}
className="btn btn-primary"
>
<button onClick={() => setShowCreate(true)} className="btn btn-primary">
<Plus size={18} />
Добавить задачу
</button>
@@ -148,14 +138,7 @@ export default function Tasks() {
<div className="space-y-4">
<AnimatePresence>
{tasks.map((task, index) => (
<TaskCard
key={task.id}
task={task}
index={index}
onToggle={() => handleToggle(task)}
onEdit={() => setEditingTask(task)}
isLoading={completeMutation.isPending || uncompleteMutation.isPending}
/>
<TaskCard key={task.id} task={task} index={index} onToggle={() => handleToggle(task)} onEdit={() => setEditingTask(task)} isLoading={completeMutation.isPending || uncompleteMutation.isPending} />
))}
</AnimatePresence>
</div>
@@ -163,17 +146,8 @@ export default function Tasks() {
</main>
<Navigation />
<CreateTaskModal
open={showCreate}
onClose={() => setShowCreate(false)}
/>
<EditTaskModal
open={!!editingTask}
onClose={() => setEditingTask(null)}
task={editingTask}
/>
<CreateTaskModal open={showCreate} onClose={() => setShowCreate(false)} />
<EditTaskModal open={!!editingTask} onClose={() => setEditingTask(null)} task={editingTask} />
</div>
)
}
@@ -187,10 +161,7 @@ function TaskCard({ task, index, onToggle, onEdit, isLoading }) {
const handleCheck = (e) => {
e.stopPropagation()
if (isLoading) return
if (!task.completed) {
setShowConfetti(true)
setTimeout(() => setShowConfetti(false), 1000)
}
if (!task.completed) { setShowConfetti(true); setTimeout(() => setShowConfetti(false), 1000) }
onToggle()
}
@@ -203,25 +174,9 @@ function TaskCard({ task, index, onToggle, onEdit, 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]
}}
transition={{ duration: 0.6, delay: i * 0.05 }}
className="absolute w-2 h-2 rounded-full"
style={{ backgroundColor: task.color }}
/>
<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>
)}
@@ -233,21 +188,12 @@ function TaskCard({ task, index, onToggle, onEdit, 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 mt-0.5',
task.completed
? 'bg-gradient-to-br from-green-400 to-green-500 shadow-lg shadow-green-400/30'
: 'border-2 hover:shadow-md'
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'
}}
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>
) : (
@@ -256,57 +202,40 @@ function TaskCard({ task, index, onToggle, onEdit, isLoading }) {
</motion.button>
<div className="flex-1 min-w-0" onClick={onEdit}>
<h3 className={clsx(
"font-semibold truncate cursor-pointer hover:text-primary-600",
task.completed ? "text-gray-400 line-through" : "text-gray-900"
)}>{task.title}</h3>
<div className="flex items-center gap-2">
<h3 className={clsx("font-semibold truncate cursor-pointer hover:text-primary-600 dark:hover:text-primary-400", task.completed ? "text-gray-400 line-through" : "text-gray-900 dark:text-white")}>{task.title}</h3>
{task.is_recurring && <span className="text-sm" title={RECURRENCE_LABELS[task.recurrence_type] || 'Повторяется'}>🔄</span>}
</div>
{task.description && (
<p className="text-sm text-gray-500 truncate mt-0.5">{task.description}</p>
)}
{task.description && <p className="text-sm text-gray-500 dark:text-gray-400 truncate mt-0.5">{task.description}</p>}
<div className="flex items-center gap-2 mt-2 flex-wrap">
{dueDateLabel && (
<span className={clsx(
'inline-flex items-center gap-1 px-2 py-0.5 rounded-md text-xs font-medium',
isOverdue
? 'bg-red-100 text-red-700'
: 'bg-gray-100 text-gray-600'
)}>
<span className={clsx('inline-flex items-center gap-1 px-2 py-0.5 rounded-md text-xs font-medium', isOverdue ? 'bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400' : 'bg-gray-100 text-gray-600 dark:bg-gray-800 dark:text-gray-400')}>
{isOverdue && <AlertTriangle size={12} />}
<Calendar size={12} />
{dueDateLabel}
</span>
)}
{priorityInfo && (
<span className={clsx(
'px-2 py-0.5 rounded-md text-xs font-medium',
priorityInfo.class
)}>
{priorityInfo.label}
{priorityInfo && <span className={clsx('px-2 py-0.5 rounded-md text-xs font-medium', priorityInfo.class)}>{priorityInfo.label}</span>}
{task.is_recurring && task.recurrence_type && (
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-md text-xs font-medium bg-purple-100 text-purple-700 dark:bg-purple-900/30 dark:text-purple-400">
<Repeat size={12} />
{RECURRENCE_LABELS[task.recurrence_type]}
</span>
)}
</div>
</div>
<div className="flex items-center gap-1">
<button
onClick={onEdit}
className="p-2 text-gray-400 hover:text-primary-500 hover:bg-primary-50 rounded-xl transition-all"
>
<button onClick={onEdit} className="p-2 text-gray-400 hover:text-primary-500 hover:bg-primary-50 dark:hover:bg-primary-900/20 rounded-xl transition-all">
<Edit2 size={16} />
</button>
{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={16} />
</motion.button>
)}