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

31
.gitea/workflows/ci.yml Normal file
View File

@@ -0,0 +1,31 @@
name: CI
on:
push:
branches: [dev]
jobs:
ci:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '20'
- name: Install dependencies
run: npm ci
- name: Lint
run: npx eslint src/ --ext .js,.jsx,.ts,.tsx --max-warnings 0 || true
- name: Test
run: npx vitest run --reporter=verbose
- name: Build
run: npm run build
- name: Deploy to dev
run: |
echo "Build successful - dev deploy would happen via docker"

View File

@@ -0,0 +1,25 @@
name: Deploy Production
on:
push:
branches: [main]
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '20'
- name: Install dependencies
run: npm ci
- name: Build
run: npm run build
- name: Deploy to production
run: |
echo "Production deploy would happen via docker"

5915
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -6,7 +6,9 @@
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
"preview": "vite preview",
"storybook": "storybook dev -p 6006",
"build-storybook": "storybook build"
},
"dependencies": {
"react": "^18.2.0",
@@ -18,7 +20,8 @@
"date-fns": "^3.3.1",
"lucide-react": "^0.312.0",
"clsx": "^2.1.0",
"framer-motion": "^11.0.3"
"framer-motion": "^11.0.3",
"recharts": "^2.12.0"
},
"devDependencies": {
"@types/react": "^18.2.48",
@@ -27,6 +30,12 @@
"autoprefixer": "^10.4.17",
"postcss": "^8.4.33",
"tailwindcss": "^3.4.1",
"vite": "^5.0.12"
"vite": "^5.0.12",
"@storybook/react": "^8.5.0",
"@storybook/react-vite": "^8.5.0",
"@storybook/addon-essentials": "^8.5.0",
"@storybook/addon-themes": "^8.5.0",
"@storybook/blocks": "^8.5.0",
"storybook": "^8.5.0"
}
}

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-sm font-medium text-gray-700 mb-2">
<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" 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>
<label className="block text-sm font-medium text-gray-700 mb-2">
<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 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,
})
is_recurring: isRecurring,
recurrence_type: isRecurring ? recurrenceType : null,
recurrence_interval: isRecurring && recurrenceType === "custom" ? recurrenceInterval : 1,
recurrence_end_date: isRecurring && recurrenceEndDate ? recurrenceEndDate : null,
}
const handleDelete = () => {
deleteMutation.mutate()
updateMutation.mutate(data)
}
const popularIcons = ["📋", "📝", "✅", "🎯", "💼", "🏠", "💰", "📞"]
@@ -133,43 +152,17 @@ 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>
</div>
</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">
@@ -178,7 +171,7 @@ export default function EditTaskModal({ open, onClose, task }) {
)}
<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
@@ -191,7 +184,7 @@ export default function EditTaskModal({ open, onClose, task }) {
</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
@@ -203,7 +196,7 @@ export default function EditTaskModal({ open, onClose, task }) {
</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">
@@ -214,7 +207,7 @@ export default function EditTaskModal({ open, onClose, task }) {
"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"
)}
>
Сегодня
@@ -226,7 +219,7 @@ export default function EditTaskModal({ open, onClose, task }) {
"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"
)}
>
Завтра
@@ -238,14 +231,14 @@ export default function EditTaskModal({ open, onClose, task }) {
"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}
@@ -255,12 +248,92 @@ export default function EditTaskModal({ open, onClose, task }) {
</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-sm font-medium text-gray-700 mb-2">
<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" 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}
@@ -268,13 +341,13 @@ export default function EditTaskModal({ open, onClose, task }) {
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">
@@ -287,7 +360,7 @@ export default function EditTaskModal({ open, onClose, task }) {
"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}
@@ -297,7 +370,7 @@ export default function EditTaskModal({ open, onClose, task }) {
</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">
@@ -310,7 +383,7 @@ export default function EditTaskModal({ open, onClose, task }) {
"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}
@@ -337,7 +410,7 @@ export default function EditTaskModal({ open, onClose, task }) {
>
{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
@@ -348,7 +421,7 @@ export default function EditTaskModal({ open, onClose, task }) {
"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}
@@ -363,7 +436,7 @@ export default function EditTaskModal({ open, onClose, task }) {
</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">
@@ -388,20 +461,39 @@ export default function EditTaskModal({ open, onClose, task }) {
disabled={updateMutation.isPending}
className="btn btn-primary w-full"
>
{updateMutation.isPending ? "Сохраняем..." : "Сохранить изменения"}
{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 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}>
<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
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 (habit.frequency === 'weekly') {
// Проверяем, выбран ли сегодняшний день
if (habit.target_days && habit.target_days.includes(dayOfWeek)) {
return true
}
// Если не выполнялась на этой неделе
if (!lastLogDate) return true
const weekStart = startOfWeek(today, { weekStartsOn: 1 })
const lastLog = typeof lastLogDate === 'string' ? parseISO(lastLogDate) : lastLogDate
if (lastLog < weekStart) {
return true // Не выполнялась на этой неделе
const lastLog = typeof lastLogDate === "string" ? parseISO(lastLogDate) : lastLogDate
return lastLog < weekStart
}
return false
if (habit.frequency === "interval" && habit.target_count > 0) {
const daysSinceStart = differenceInDays(today, startDate)
return daysSinceStart % habit.target_count === 0
}
// 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 === "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,28 +555,21 @@ 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>
<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 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={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 })
// Combined stats for all or selected habit
const computedStats = useMemo(() => {
const targetHabits = selectedHabitId
? habits.filter(h => h.id === selectedHabitId)
: habits
const startDayOfWeek = monthStart.getDay()
const paddingDays = startDayOfWeek === 0 ? 6 : startDayOfWeek - 1
let totalLogs = 0
let totalExpected = 0
let currentStreak = 0
let bestStreak = 0
// Получить привычки, выполненные в конкретный день
const getCompletedHabitsForDate = (date) => {
targetHabits.forEach(habit => {
const logs = allHabitLogs[habit.id] || []
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])
// 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++
})
return { date: day, dateStr, count, expected }
})
}, [selectedHabitId, habits, allHabitLogs, allHabitFreezes])
// 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])
// 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')
return habits.filter(habit => {
let completed = 0
let expected = 0
const targetHabits = selectedHabitId
? habits.filter(h => h.id === selectedHabitId)
: habits
targetHabits.forEach(habit => {
const logs = allHabitLogs[habit.id] || []
return logs.some(log => log.date.split('T')[0] === dateStr)
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 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)
})
stats[habit.id] = monthLogs.length
})
return stats
}, [habits, allHabitLogs, currentMonth])
const completed = logs.filter(l => {
const logDate = parseISO(l.date.split('T')[0])
return differenceInDays(new Date(), logDate) < 30
}).length
// Общий % выполнения за месяц
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])
const rate = expected > 0 ? Math.round((completed / expected) * 100) : 0
const prevMonth = () => setCurrentMonth(subMonths(currentMonth, 1))
const nextMonth = () => setCurrentMonth(addMonths(currentMonth, 1))
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 && (
{/* Habit Selector Dropdown */}
<motion.div
initial={{ opacity: 0, y: 20 }}
initial={{ opacity: 0, y: -10 }}
animate={{ opacity: 1, y: 0 }}
className="card p-4"
className="relative"
>
<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,
}}
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="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 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.1 }}
className="card p-5"
>
<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()}
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'
)}
>
{/* Число дня */}
<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>
</motion.div>
{/* Monthly Summary - карточки для каждой привычки */}
{habits.length > 0 && (
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.15 }}
className="card p-5"
>
<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>
<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>
</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="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>
<h3 className="text-lg font-semibold text-gray-900">{selectedHabit.name}</h3>
<p className="text-sm text-gray-500">Детальная статистика</p>
<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(
"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"
)}
>
<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>
{/* 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>
{/* 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>
{/* 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>
{/* Детальные данные */}
<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>
{/* 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>
<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>
</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.4 }}
className="bg-gray-900/60 backdrop-blur-xl border border-gray-800/50 rounded-3xl p-6"
>
<SectionHeader
icon={BarChart3}
title="По привычкам"
subtitle="Рейтинг за 30 дней"
/>
<div className="space-y-3">
{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="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 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.section>
)}
{habits.length === 0 && (
<div className="card p-10 text-center">
<p className="text-gray-500">Создайте привычки, чтобы видеть статистику</p>
<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>
)}

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:svgjs="http://svgjs.com/svgjs" xmlns:xlink="http://www.w3.org/1999/xlink" width="164" height="164" version="1.1"><svg xmlns="http://www.w3.org/2000/svg" width="164" height="164" fill="none" viewBox="0 0 164 164"><path fill="#FF4785" d="M22.467 147.762 17.5 15.402a8.062 8.062 0 0 1 7.553-8.35L137.637.016a8.061 8.061 0 0 1 8.565 8.047v144.23a8.063 8.063 0 0 1-8.424 8.054l-107.615-4.833a8.062 8.062 0 0 1-7.695-7.752Z"/><path fill="#fff" fill-rule="evenodd" d="m128.785.57-15.495.968-.755 18.172a1.203 1.203 0 0 0 1.928 1.008l7.06-5.354 5.962 4.697a1.202 1.202 0 0 0 1.946-.987L128.785.569Zm-12.059 60.856c-2.836 2.203-23.965 3.707-23.965.57.447-11.969-4.912-12.494-7.889-12.494-2.828 0-7.59.855-7.59 7.267 0 6.534 6.96 10.223 15.13 14.553 11.607 6.15 25.654 13.594 25.654 32.326 0 17.953-14.588 27.871-33.194 27.871-19.201 0-35.981-7.769-34.086-34.702.744-3.163 25.156-2.411 25.156 0-.298 11.114 2.232 14.383 8.633 14.383 4.912 0 7.144-2.708 7.144-7.267 0-6.9-7.252-10.973-15.595-15.657C64.827 81.933 51.53 74.468 51.53 57.34c0-17.098 11.76-28.497 32.747-28.497 20.988 0 32.449 11.224 32.449 32.584Z" clip-rule="evenodd"/></svg><style>@media (prefers-color-scheme:light){:root{filter:none}}</style></svg>

After

Width:  |  Height:  |  Size: 1.2 KiB

173
storybook-static/index.html Normal file
View File

@@ -0,0 +1,173 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<title>@storybook/core - Storybook</title>
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1" />
<link rel="icon" type="image/svg+xml" href="./favicon.svg" />
<style>
@font-face {
font-family: 'Nunito Sans';
font-style: normal;
font-weight: 400;
font-display: swap;
src: url('./sb-common-assets/nunito-sans-regular.woff2') format('woff2');
}
@font-face {
font-family: 'Nunito Sans';
font-style: italic;
font-weight: 400;
font-display: swap;
src: url('./sb-common-assets/nunito-sans-italic.woff2') format('woff2');
}
@font-face {
font-family: 'Nunito Sans';
font-style: normal;
font-weight: 700;
font-display: swap;
src: url('./sb-common-assets/nunito-sans-bold.woff2') format('woff2');
}
@font-face {
font-family: 'Nunito Sans';
font-style: italic;
font-weight: 700;
font-display: swap;
src: url('./sb-common-assets/nunito-sans-bold-italic.woff2') format('woff2');
}
</style>
<link href="./sb-manager/runtime.js" rel="modulepreload" />
<link href="./sb-addons/storybook-core-core-server-presets-0/common-manager-bundle.js" rel="modulepreload" />
<link href="./sb-addons/essentials-controls-1/manager-bundle.js" rel="modulepreload" />
<link href="./sb-addons/essentials-actions-2/manager-bundle.js" rel="modulepreload" />
<link href="./sb-addons/essentials-docs-3/manager-bundle.js" rel="modulepreload" />
<link href="./sb-addons/essentials-backgrounds-4/manager-bundle.js" rel="modulepreload" />
<link href="./sb-addons/essentials-viewport-5/manager-bundle.js" rel="modulepreload" />
<link href="./sb-addons/essentials-toolbars-6/manager-bundle.js" rel="modulepreload" />
<link href="./sb-addons/essentials-measure-7/manager-bundle.js" rel="modulepreload" />
<link href="./sb-addons/essentials-outline-8/manager-bundle.js" rel="modulepreload" />
<link href="./sb-addons/themes-9/manager-bundle.js" rel="modulepreload" />
<style>
#storybook-root[hidden] {
display: none !important;
}
</style>
</head>
<body>
<div id="root"></div>
<script>
window['FEATURES'] = {
"argTypeTargetsV7": true,
"legacyDecoratorFileOrder": false,
"disallowImplicitActionsInRenderV8": true
};
window['REFS'] = {};
window['LOGLEVEL'] = "info";
window['DOCS_OPTIONS'] = {
"defaultName": "Docs",
"autodocs": "tag"
};
window['CONFIG_TYPE'] = "PRODUCTION";
window['TAGS_OPTIONS'] = {
"dev-only": {
"excludeFromDocsStories": true
},
"docs-only": {
"excludeFromSidebar": true
},
"test-only": {
"excludeFromSidebar": true,
"excludeFromDocsStories": true
}
};
window['STORYBOOK_RENDERER'] = "react";
window['STORYBOOK_BUILDER'] = "@storybook/builder-vite";
window['STORYBOOK_FRAMEWORK'] = "@storybook/react-vite";
</script>
<script type="module">
import './sb-manager/globals-runtime.js';
import './sb-addons/storybook-core-core-server-presets-0/common-manager-bundle.js';
import './sb-addons/essentials-controls-1/manager-bundle.js';
import './sb-addons/essentials-actions-2/manager-bundle.js';
import './sb-addons/essentials-docs-3/manager-bundle.js';
import './sb-addons/essentials-backgrounds-4/manager-bundle.js';
import './sb-addons/essentials-viewport-5/manager-bundle.js';
import './sb-addons/essentials-toolbars-6/manager-bundle.js';
import './sb-addons/essentials-measure-7/manager-bundle.js';
import './sb-addons/essentials-outline-8/manager-bundle.js';
import './sb-addons/themes-9/manager-bundle.js';
import './sb-manager/runtime.js';
</script>
</body>
</html>

View File

@@ -0,0 +1 @@
{"v":5,"entries":{"ui-button--primary":{"type":"story","id":"ui-button--primary","name":"Primary","title":"UI/Button","importPath":"./src/stories/Buttons.stories.jsx","tags":["dev","test"]},"ui-button--secondary":{"type":"story","id":"ui-button--secondary","name":"Secondary","title":"UI/Button","importPath":"./src/stories/Buttons.stories.jsx","tags":["dev","test"]},"ui-button--accent":{"type":"story","id":"ui-button--accent","name":"Accent","title":"UI/Button","importPath":"./src/stories/Buttons.stories.jsx","tags":["dev","test"]},"ui-button--disabled":{"type":"story","id":"ui-button--disabled","name":"Disabled","title":"UI/Button","importPath":"./src/stories/Buttons.stories.jsx","tags":["dev","test"]},"ui-button--all-variants":{"type":"story","id":"ui-button--all-variants","name":"All Variants","title":"UI/Button","importPath":"./src/stories/Buttons.stories.jsx","tags":["dev","test"]},"ui-cards--default-card":{"type":"story","id":"ui-cards--default-card","name":"Default Card","title":"UI/Cards","importPath":"./src/stories/Cards.stories.jsx","tags":["dev","test"]},"ui-cards--glass-card":{"type":"story","id":"ui-cards--glass-card","name":"Glass Card","title":"UI/Cards","importPath":"./src/stories/Cards.stories.jsx","tags":["dev","test"]},"ui-cards--habit-not-completed":{"type":"story","id":"ui-cards--habit-not-completed","name":"Habit Not Completed","title":"UI/Cards","importPath":"./src/stories/Cards.stories.jsx","tags":["dev","test"]},"ui-cards--habit-completed":{"type":"story","id":"ui-cards--habit-completed","name":"Habit Completed","title":"UI/Cards","importPath":"./src/stories/Cards.stories.jsx","tags":["dev","test"]},"ui-cards--task-normal":{"type":"story","id":"ui-cards--task-normal","name":"Task Normal","title":"UI/Cards","importPath":"./src/stories/Cards.stories.jsx","tags":["dev","test"]},"ui-cards--task-high-priority":{"type":"story","id":"ui-cards--task-high-priority","name":"Task High Priority","title":"UI/Cards","importPath":"./src/stories/Cards.stories.jsx","tags":["dev","test"]},"ui-cards--task-done":{"type":"story","id":"ui-cards--task-done","name":"Task Done","title":"UI/Cards","importPath":"./src/stories/Cards.stories.jsx","tags":["dev","test"]},"components-navigation--default":{"type":"story","id":"components-navigation--default","name":"Default","title":"Components/Navigation","importPath":"./src/stories/Navigation.stories.jsx","componentPath":"./src/components/Navigation.jsx","tags":["dev","test"]},"components-navigation--on-habits-page":{"type":"story","id":"components-navigation--on-habits-page","name":"On Habits Page","title":"Components/Navigation","importPath":"./src/stories/Navigation.stories.jsx","componentPath":"./src/components/Navigation.jsx","tags":["dev","test"]}}}

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@@ -0,0 +1 @@
{"generatedAt":1772285649405,"userSince":1772285647831,"hasCustomBabel":false,"hasCustomWebpack":false,"hasStaticDirs":false,"hasStorybookEslint":false,"refCount":0,"testPackages":{},"hasRouterPackage":true,"packageManager":{"type":"npm","agent":"npm"},"preview":{"usesGlobals":false},"framework":{"name":"@storybook/react-vite","options":{}},"builder":"@storybook/builder-vite","renderer":"@storybook/react","storybookVersion":"8.6.17","storybookVersionSpecifier":"^8.5.0","language":"javascript","storybookPackages":{"@storybook/react":{"version":"8.6.17"},"@storybook/react-vite":{"version":"8.6.17"},"@storybook/blocks":{"version":"8.6.14"},"storybook":{"version":"8.6.17"}},"addons":{"@storybook/addon-essentials":{"version":"8.6.14"},"@storybook/addon-themes":{"version":"8.6.17"}}}

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,3 @@
try{
(()=>{var T=__STORYBOOK_API__,{ActiveTabs:h,Consumer:g,ManagerContext:f,Provider:v,RequestResponseError:A,addons:n,combineParameters:x,controlOrMetaKey:P,controlOrMetaSymbol:k,eventMatchesShortcut:M,eventToShortcut:R,experimental_MockUniversalStore:C,experimental_UniversalStore:U,experimental_requestResponse:w,experimental_useUniversalStore:B,isMacLike:E,isShortcutTaken:I,keyToSymbol:K,merge:N,mockChannel:G,optionOrAltSymbol:L,shortcutMatchesShortcut:Y,shortcutToHumanString:q,types:D,useAddonState:F,useArgTypes:H,useArgs:j,useChannel:V,useGlobalTypes:z,useGlobals:J,useParameter:Q,useSharedState:W,useStoryPrepared:X,useStorybookApi:Z,useStorybookState:$}=__STORYBOOK_API__;var S=(()=>{let e;return typeof window<"u"?e=window:typeof globalThis<"u"?e=globalThis:typeof window<"u"?e=window:typeof self<"u"?e=self:e={},e})(),c="tag-filters",p="static-filter";n.register(c,e=>{let u=Object.entries(S.TAGS_OPTIONS??{}).reduce((t,r)=>{let[o,i]=r;return i.excludeFromSidebar&&(t[o]=!0),t},{});e.experimental_setFilter(p,t=>{let r=t.tags??[];return(r.includes("dev")||t.type==="docs")&&r.filter(o=>u[o]).length===0})});})();
}catch(e){ console.error("[Storybook] One of your manager-entries failed: " + import.meta.url, e); }

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:svgjs="http://svgjs.com/svgjs" xmlns:xlink="http://www.w3.org/1999/xlink" width="164" height="164" version="1.1"><svg xmlns="http://www.w3.org/2000/svg" width="164" height="164" fill="none" viewBox="0 0 164 164"><path fill="#FF4785" d="M22.467 147.762 17.5 15.402a8.062 8.062 0 0 1 7.553-8.35L137.637.016a8.061 8.061 0 0 1 8.565 8.047v144.23a8.063 8.063 0 0 1-8.424 8.054l-107.615-4.833a8.062 8.062 0 0 1-7.695-7.752Z"/><path fill="#fff" fill-rule="evenodd" d="m128.785.57-15.495.968-.755 18.172a1.203 1.203 0 0 0 1.928 1.008l7.06-5.354 5.962 4.697a1.202 1.202 0 0 0 1.946-.987L128.785.569Zm-12.059 60.856c-2.836 2.203-23.965 3.707-23.965.57.447-11.969-4.912-12.494-7.889-12.494-2.828 0-7.59.855-7.59 7.267 0 6.534 6.96 10.223 15.13 14.553 11.607 6.15 25.654 13.594 25.654 32.326 0 17.953-14.588 27.871-33.194 27.871-19.201 0-35.981-7.769-34.086-34.702.744-3.163 25.156-2.411 25.156 0-.298 11.114 2.232 14.383 8.633 14.383 4.912 0 7.144-2.708 7.144-7.267 0-6.9-7.252-10.973-15.595-15.657C64.827 81.933 51.53 74.468 51.53 57.34c0-17.098 11.76-28.497 32.747-28.497 20.988 0 32.449 11.224 32.449 32.584Z" clip-rule="evenodd"/></svg><style>@media (prefers-color-scheme:light){:root{filter:none}}</style></svg>

After

Width:  |  Height:  |  Size: 1.2 KiB

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,48 @@
import ESM_COMPAT_Module from "node:module";
import { fileURLToPath as ESM_COMPAT_fileURLToPath } from 'node:url';
import { dirname as ESM_COMPAT_dirname } from 'node:path';
const __filename = ESM_COMPAT_fileURLToPath(import.meta.url);
const __dirname = ESM_COMPAT_dirname(__filename);
const require = ESM_COMPAT_Module.createRequire(import.meta.url);
// src/manager/globals/globals.ts
var _ = {
react: "__REACT__",
"react-dom": "__REACT_DOM__",
"react-dom/client": "__REACT_DOM_CLIENT__",
"@storybook/icons": "__STORYBOOK_ICONS__",
"storybook/internal/manager-api": "__STORYBOOK_API__",
"@storybook/manager-api": "__STORYBOOK_API__",
"@storybook/core/manager-api": "__STORYBOOK_API__",
"storybook/internal/components": "__STORYBOOK_COMPONENTS__",
"@storybook/components": "__STORYBOOK_COMPONENTS__",
"@storybook/core/components": "__STORYBOOK_COMPONENTS__",
"storybook/internal/channels": "__STORYBOOK_CHANNELS__",
"@storybook/channels": "__STORYBOOK_CHANNELS__",
"@storybook/core/channels": "__STORYBOOK_CHANNELS__",
"storybook/internal/core-errors": "__STORYBOOK_CORE_EVENTS__",
"@storybook/core-events": "__STORYBOOK_CORE_EVENTS__",
"@storybook/core/core-events": "__STORYBOOK_CORE_EVENTS__",
"storybook/internal/manager-errors": "__STORYBOOK_CORE_EVENTS_MANAGER_ERRORS__",
"@storybook/core-events/manager-errors": "__STORYBOOK_CORE_EVENTS_MANAGER_ERRORS__",
"@storybook/core/manager-errors": "__STORYBOOK_CORE_EVENTS_MANAGER_ERRORS__",
"storybook/internal/router": "__STORYBOOK_ROUTER__",
"@storybook/router": "__STORYBOOK_ROUTER__",
"@storybook/core/router": "__STORYBOOK_ROUTER__",
"storybook/internal/theming": "__STORYBOOK_THEMING__",
"@storybook/theming": "__STORYBOOK_THEMING__",
"@storybook/core/theming": "__STORYBOOK_THEMING__",
"storybook/internal/theming/create": "__STORYBOOK_THEMING_CREATE__",
"@storybook/theming/create": "__STORYBOOK_THEMING_CREATE__",
"@storybook/core/theming/create": "__STORYBOOK_THEMING_CREATE__",
"storybook/internal/client-logger": "__STORYBOOK_CLIENT_LOGGER__",
"@storybook/client-logger": "__STORYBOOK_CLIENT_LOGGER__",
"@storybook/core/client-logger": "__STORYBOOK_CLIENT_LOGGER__",
"storybook/internal/types": "__STORYBOOK_TYPES__",
"@storybook/types": "__STORYBOOK_TYPES__",
"@storybook/core/types": "__STORYBOOK_TYPES__"
}, o = Object.keys(_);
export {
o as globalPackages,
_ as globalsNameReferenceMap
};

File diff suppressed because it is too large Load Diff

View File

@@ -4,6 +4,7 @@ export default {
'./index.html',
'./src/**/*.{js,ts,jsx,tsx}',
],
darkMode: 'class',
theme: {
extend: {
colors: {