From 1d0dc72b31ae8dd0994f1bea5c2274c0a2e76876 Mon Sep 17 00:00:00 2001 From: Cosmo Date: Thu, 26 Mar 2026 19:25:35 +0000 Subject: [PATCH] test: boost coverage - add 40 tests for Home, Tasks, Habits, Savings --- coverage/lcov-report/api/finance.js.html | 226 + coverage/lcov-report/api/habits.js.html | 148 + coverage/lcov-report/api/index.html | 176 + coverage/lcov-report/api/profile.js.html | 127 + coverage/lcov-report/api/savings.js.html | 235 + coverage/lcov-report/api/tasks.js.html | 223 + coverage/lcov-report/base.css | 224 + coverage/lcov-report/block-navigation.js | 87 + .../components/CreateHabitModal.jsx.html | 1327 +++++ .../components/CreateTaskModal.jsx.html | 1447 ++++++ .../components/EditHabitModal.jsx.html | 2221 +++++++++ .../components/EditTaskModal.jsx.html | 1594 ++++++ .../components/LogHabitModal.jsx.html | 712 +++ .../components/Navigation.jsx.html | 220 + .../finance/FinanceDashboard.jsx.html | 691 +++ .../finance/TransactionList.jsx.html | 586 +++ .../lcov-report/components/finance/index.html | 131 + coverage/lcov-report/components/index.html | 191 + .../contexts/ThemeContext.jsx.html | 211 + coverage/lcov-report/contexts/index.html | 116 + coverage/lcov-report/favicon.png | Bin 0 -> 445 bytes coverage/lcov-report/index.html | 191 + coverage/lcov-report/pages/Finance.jsx.html | 451 ++ .../lcov-report/pages/ForgotPassword.jsx.html | 499 ++ coverage/lcov-report/pages/Habits.jsx.html | 745 +++ coverage/lcov-report/pages/Home.jsx.html | 1816 +++++++ coverage/lcov-report/pages/Login.jsx.html | 373 ++ coverage/lcov-report/pages/Register.jsx.html | 337 ++ .../lcov-report/pages/ResetPassword.jsx.html | 505 ++ coverage/lcov-report/pages/Savings.jsx.html | 4273 +++++++++++++++++ coverage/lcov-report/pages/Settings.jsx.html | 1123 +++++ coverage/lcov-report/pages/Stats.jsx.html | 2170 +++++++++ coverage/lcov-report/pages/Tasks.jsx.html | 823 ++++ coverage/lcov-report/pages/Tracker.jsx.html | 241 + .../lcov-report/pages/VerifyEmail.jsx.html | 394 ++ coverage/lcov-report/pages/index.html | 296 ++ coverage/lcov-report/prettify.css | 1 + coverage/lcov-report/prettify.js | 2 + coverage/lcov-report/sort-arrow-sprite.png | Bin 0 -> 138 bytes coverage/lcov-report/sorter.js | 210 + coverage/lcov-report/store/auth.js.html | 226 + coverage/lcov-report/store/index.html | 116 + coverage/lcov.info | 4213 ++++++++++++++++ src/__tests__/Home.test.jsx | 320 +- src/__tests__/Tasks.test.jsx | 212 +- 45 files changed, 30415 insertions(+), 15 deletions(-) create mode 100644 coverage/lcov-report/api/finance.js.html create mode 100644 coverage/lcov-report/api/habits.js.html create mode 100644 coverage/lcov-report/api/index.html create mode 100644 coverage/lcov-report/api/profile.js.html create mode 100644 coverage/lcov-report/api/savings.js.html create mode 100644 coverage/lcov-report/api/tasks.js.html create mode 100644 coverage/lcov-report/base.css create mode 100644 coverage/lcov-report/block-navigation.js create mode 100644 coverage/lcov-report/components/CreateHabitModal.jsx.html create mode 100644 coverage/lcov-report/components/CreateTaskModal.jsx.html create mode 100644 coverage/lcov-report/components/EditHabitModal.jsx.html create mode 100644 coverage/lcov-report/components/EditTaskModal.jsx.html create mode 100644 coverage/lcov-report/components/LogHabitModal.jsx.html create mode 100644 coverage/lcov-report/components/Navigation.jsx.html create mode 100644 coverage/lcov-report/components/finance/FinanceDashboard.jsx.html create mode 100644 coverage/lcov-report/components/finance/TransactionList.jsx.html create mode 100644 coverage/lcov-report/components/finance/index.html create mode 100644 coverage/lcov-report/components/index.html create mode 100644 coverage/lcov-report/contexts/ThemeContext.jsx.html create mode 100644 coverage/lcov-report/contexts/index.html create mode 100644 coverage/lcov-report/favicon.png create mode 100644 coverage/lcov-report/index.html create mode 100644 coverage/lcov-report/pages/Finance.jsx.html create mode 100644 coverage/lcov-report/pages/ForgotPassword.jsx.html create mode 100644 coverage/lcov-report/pages/Habits.jsx.html create mode 100644 coverage/lcov-report/pages/Home.jsx.html create mode 100644 coverage/lcov-report/pages/Login.jsx.html create mode 100644 coverage/lcov-report/pages/Register.jsx.html create mode 100644 coverage/lcov-report/pages/ResetPassword.jsx.html create mode 100644 coverage/lcov-report/pages/Savings.jsx.html create mode 100644 coverage/lcov-report/pages/Settings.jsx.html create mode 100644 coverage/lcov-report/pages/Stats.jsx.html create mode 100644 coverage/lcov-report/pages/Tasks.jsx.html create mode 100644 coverage/lcov-report/pages/Tracker.jsx.html create mode 100644 coverage/lcov-report/pages/VerifyEmail.jsx.html create mode 100644 coverage/lcov-report/pages/index.html create mode 100644 coverage/lcov-report/prettify.css create mode 100644 coverage/lcov-report/prettify.js create mode 100644 coverage/lcov-report/sort-arrow-sprite.png create mode 100644 coverage/lcov-report/sorter.js create mode 100644 coverage/lcov-report/store/auth.js.html create mode 100644 coverage/lcov-report/store/index.html create mode 100644 coverage/lcov.info diff --git a/coverage/lcov-report/api/finance.js.html b/coverage/lcov-report/api/finance.js.html new file mode 100644 index 0000000..b1ae44c --- /dev/null +++ b/coverage/lcov-report/api/finance.js.html @@ -0,0 +1,226 @@ + + + + + + Code coverage report for api/finance.js + + + + + + + + + +
+
+

All files / api finance.js

+
+ +
+ 100% + Statements + 19/19 +
+ + +
+ 100% + Branches + 3/3 +
+ + +
+ 100% + Functions + 10/10 +
+ + +
+ 100% + Lines + 19/19 +
+ + +
+

+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +

+ +
+
+

+
1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 +16 +17 +18 +19 +20 +21 +22 +23 +24 +25 +26 +27 +28 +29 +30 +31 +32 +33 +34 +35 +36 +37 +38 +39 +40 +41 +42 +43 +44 +45 +46 +47 +48  +  +1x +  +  +1x +1x +  +  +1x +1x +  +  +1x +1x +  +  +1x +  +  +  +  +2x +2x +  +  +1x +1x +  +  +1x +1x +  +  +1x +  +  +  +  +1x +1x +  +  +1x +1x +  +  + 
import client from './client'
+ 
+export const financeApi = {
+  // Categories
+  listCategories: async () => {
+    const res = await client.get('finance/categories')
+    return res.data
+  },
+  createCategory: async (data) => {
+    const res = await client.post('finance/categories', data)
+    return res.data
+  },
+  updateCategory: async (id, data) => {
+    const res = await client.put(`finance/categories/${id}`, data)
+    return res.data
+  },
+  deleteCategory: async (id) => {
+    await client.delete(`finance/categories/${id}`)
+  },
+ 
+  // Transactions
+  listTransactions: async (params = {}) => {
+    const res = await client.get('finance/transactions', { params })
+    return res.data
+  },
+  createTransaction: async (data) => {
+    const res = await client.post('finance/transactions', data)
+    return res.data
+  },
+  updateTransaction: async (id, data) => {
+    const res = await client.put(`finance/transactions/${id}`, data)
+    return res.data
+  },
+  deleteTransaction: async (id) => {
+    await client.delete(`finance/transactions/${id}`)
+  },
+ 
+  // Summary & Analytics
+  getSummary: async (params = {}) => {
+    const res = await client.get('finance/summary', { params })
+    return res.data
+  },
+  getAnalytics: async (params = {}) => {
+    const res = await client.get('finance/analytics', { params })
+    return res.data
+  },
+}
+ 
+ +
+
+ + + + + + + + \ No newline at end of file diff --git a/coverage/lcov-report/api/habits.js.html b/coverage/lcov-report/api/habits.js.html new file mode 100644 index 0000000..c1f9f1a --- /dev/null +++ b/coverage/lcov-report/api/habits.js.html @@ -0,0 +1,148 @@ + + + + + + Code coverage report for api/habits.js + + + + + + + + + +
+
+

All files / api habits.js

+
+ +
+ 91.66% + Statements + 22/24 +
+ + +
+ 100% + Branches + 2/2 +
+ + +
+ 91.3% + Functions + 21/23 +
+ + +
+ 92.85% + Lines + 13/14 +
+ + +
+

+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +

+ +
+
+

+
1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 +16 +17 +18 +19 +20 +21 +22  +  +1x +1x +  +1x +1x +1x +  +2x +1x +1x +  +1x +1x +  +  +1x +1x +1x +  + 
import api from './client'
+ 
+export const habitsApi = {
+  list: () => api.get('/habits').then(r => r.data),
+  get: (id) => api.get(`/habits/${id}`).then(r => r.data),
+  create: (data) => api.post('/habits', data).then(r => r.data),
+  update: (id, data) => api.put(`/habits/${id}`, data).then(r => r.data),
+  delete: (id) => api.delete(`/habits/${id}`),
+  
+  log: (id, data = {}) => api.post(`/habits/${id}/log`, data).then(r => r.data),
+  getLogs: (id, days = 30) => api.get(`/habits/${id}/logs?days=${days}`).then(r => r.data),
+  deleteLog: (habitId, logId) => api.delete(`/habits/${habitId}/logs/${logId}`),
+  
+  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}`),
+}
+ 
+ +
+
+ + + + + + + + \ No newline at end of file diff --git a/coverage/lcov-report/api/index.html b/coverage/lcov-report/api/index.html new file mode 100644 index 0000000..d38789b --- /dev/null +++ b/coverage/lcov-report/api/index.html @@ -0,0 +1,176 @@ + + + + + + Code coverage report for api + + + + + + + + + +
+
+

All files api

+
+ +
+ 89.1% + Statements + 90/101 +
+ + +
+ 100% + Branches + 12/12 +
+ + +
+ 84.93% + Functions + 62/73 +
+ + +
+ 90% + Lines + 72/80 +
+ + +
+

+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +

+ +
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
FileStatementsBranchesFunctionsLines
finance.js +
+
100%19/19100%3/3100%10/10100%19/19
habits.js +
+
91.66%22/24100%2/291.3%21/2392.85%13/14
profile.js +
+
100%5/5100%0/0100%2/2100%5/5
savings.js +
+
73.52%25/34100%4/470%21/3069.56%16/23
tasks.js +
+
100%19/19100%3/3100%8/8100%19/19
+
+
+
+ + + + + + + + \ No newline at end of file diff --git a/coverage/lcov-report/api/profile.js.html b/coverage/lcov-report/api/profile.js.html new file mode 100644 index 0000000..c4d43c3 --- /dev/null +++ b/coverage/lcov-report/api/profile.js.html @@ -0,0 +1,127 @@ + + + + + + Code coverage report for api/profile.js + + + + + + + + + +
+
+

All files / api profile.js

+
+ +
+ 100% + Statements + 5/5 +
+ + +
+ 100% + Branches + 0/0 +
+ + +
+ 100% + Functions + 2/2 +
+ + +
+ 100% + Lines + 5/5 +
+ + +
+

+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +

+ +
+
+

+
1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15  +  +1x +  +1x +1x +  +  +1x +1x +  +  +  +  + 
import api from "./client"
+ 
+export const profileApi = {
+  get: async () => {
+    const { data } = await api.get("/profile")
+    return data
+  },
+  update: async (profileData) => {
+    const { data } = await api.put("/profile", profileData)
+    return data
+  },
+}
+ 
+export default profileApi
+ 
+ +
+
+ + + + + + + + \ No newline at end of file diff --git a/coverage/lcov-report/api/savings.js.html b/coverage/lcov-report/api/savings.js.html new file mode 100644 index 0000000..4226ecf --- /dev/null +++ b/coverage/lcov-report/api/savings.js.html @@ -0,0 +1,235 @@ + + + + + + Code coverage report for api/savings.js + + + + + + + + + +
+
+

All files / api savings.js

+
+ +
+ 73.52% + Statements + 25/34 +
+ + +
+ 100% + Branches + 4/4 +
+ + +
+ 70% + Functions + 21/30 +
+ + +
+ 69.56% + Lines + 16/23 +
+ + +
+

+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +

+ +
+
+

+
1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 +16 +17 +18 +19 +20 +21 +22 +23 +24 +25 +26 +27 +28 +29 +30 +31 +32 +33 +34 +35 +36 +37 +38 +39 +40 +41 +42 +43 +44 +45 +46 +47 +48 +49 +50 +51  +  +1x +  +1x +1x +1x +  +1x +1x +  +  +  +2x +2x +2x +  +  +1x +  +  +  +  +  +1x +  +  +  +1x +  +1x +  +1x +  +  +  +  +  +1x +  +1x +  +  +  +  +  +  +  +  +  + 
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}`),
+}
+ 
+ +
+
+ + + + + + + + \ No newline at end of file diff --git a/coverage/lcov-report/api/tasks.js.html b/coverage/lcov-report/api/tasks.js.html new file mode 100644 index 0000000..6a07781 --- /dev/null +++ b/coverage/lcov-report/api/tasks.js.html @@ -0,0 +1,223 @@ + + + + + + Code coverage report for api/tasks.js + + + + + + + + + +
+
+

All files / api tasks.js

+
+ +
+ 100% + Statements + 19/19 +
+ + +
+ 100% + Branches + 3/3 +
+ + +
+ 100% + Functions + 8/8 +
+ + +
+ 100% + Lines + 19/19 +
+ + +
+

+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +

+ +
+
+

+
1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 +16 +17 +18 +19 +20 +21 +22 +23 +24 +25 +26 +27 +28 +29 +30 +31 +32 +33 +34 +35 +36 +37 +38 +39 +40 +41 +42 +43 +44 +45 +46 +47  +  +1x +  +3x +3x +2x +  +3x +3x +  +  +  +1x +1x +  +  +  +1x +1x +  +  +  +1x +1x +  +  +  +1x +1x +  +  +  +1x +  +  +  +1x +1x +  +  +  +1x +1x +  +  + 
import client from './client'
+ 
+export const tasksApi = {
+  list: async (completed = null) => {
+    let url = 'tasks'
+    if (completed !== null) {
+      url += `?completed=${completed}`
+    }
+    const res = await client.get(url)
+    return res.data
+  },
+ 
+  today: async () => {
+    const res = await client.get('tasks/today')
+    return res.data
+  },
+ 
+  get: async (id) => {
+    const res = await client.get(`tasks/${id}`)
+    return res.data
+  },
+ 
+  create: async (data) => {
+    const res = await client.post('tasks', data)
+    return res.data
+  },
+ 
+  update: async (id, data) => {
+    const res = await client.put(`tasks/${id}`, data)
+    return res.data
+  },
+ 
+  delete: async (id) => {
+    await client.delete(`tasks/${id}`)
+  },
+ 
+  complete: async (id) => {
+    const res = await client.post(`tasks/${id}/complete`)
+    return res.data
+  },
+ 
+  uncomplete: async (id) => {
+    const res = await client.post(`tasks/${id}/uncomplete`)
+    return res.data
+  },
+}
+ 
+ +
+
+ + + + + + + + \ No newline at end of file diff --git a/coverage/lcov-report/base.css b/coverage/lcov-report/base.css new file mode 100644 index 0000000..f418035 --- /dev/null +++ b/coverage/lcov-report/base.css @@ -0,0 +1,224 @@ +body, html { + margin:0; padding: 0; + height: 100%; +} +body { + font-family: Helvetica Neue, Helvetica, Arial; + font-size: 14px; + color:#333; +} +.small { font-size: 12px; } +*, *:after, *:before { + -webkit-box-sizing:border-box; + -moz-box-sizing:border-box; + box-sizing:border-box; + } +h1 { font-size: 20px; margin: 0;} +h2 { font-size: 14px; } +pre { + font: 12px/1.4 Consolas, "Liberation Mono", Menlo, Courier, monospace; + margin: 0; + padding: 0; + -moz-tab-size: 2; + -o-tab-size: 2; + tab-size: 2; +} +a { color:#0074D9; text-decoration:none; } +a:hover { text-decoration:underline; } +.strong { font-weight: bold; } +.space-top1 { padding: 10px 0 0 0; } +.pad2y { padding: 20px 0; } +.pad1y { padding: 10px 0; } +.pad2x { padding: 0 20px; } +.pad2 { padding: 20px; } +.pad1 { padding: 10px; } +.space-left2 { padding-left:55px; } +.space-right2 { padding-right:20px; } +.center { text-align:center; } +.clearfix { display:block; } +.clearfix:after { + content:''; + display:block; + height:0; + clear:both; + visibility:hidden; + } +.fl { float: left; } +@media only screen and (max-width:640px) { + .col3 { width:100%; max-width:100%; } + .hide-mobile { display:none!important; } +} + +.quiet { + color: #7f7f7f; + color: rgba(0,0,0,0.5); +} +.quiet a { opacity: 0.7; } + +.fraction { + font-family: Consolas, 'Liberation Mono', Menlo, Courier, monospace; + font-size: 10px; + color: #555; + background: #E8E8E8; + padding: 4px 5px; + border-radius: 3px; + vertical-align: middle; +} + +div.path a:link, div.path a:visited { color: #333; } +table.coverage { + border-collapse: collapse; + margin: 10px 0 0 0; + padding: 0; +} + +table.coverage td { + margin: 0; + padding: 0; + vertical-align: top; +} +table.coverage td.line-count { + text-align: right; + padding: 0 5px 0 20px; +} +table.coverage td.line-coverage { + text-align: right; + padding-right: 10px; + min-width:20px; +} + +table.coverage td span.cline-any { + display: inline-block; + padding: 0 5px; + width: 100%; +} +.missing-if-branch { + display: inline-block; + margin-right: 5px; + border-radius: 3px; + position: relative; + padding: 0 4px; + background: #333; + color: yellow; +} + +.skip-if-branch { + display: none; + margin-right: 10px; + position: relative; + padding: 0 4px; + background: #ccc; + color: white; +} +.missing-if-branch .typ, .skip-if-branch .typ { + color: inherit !important; +} +.coverage-summary { + border-collapse: collapse; + width: 100%; +} +.coverage-summary tr { border-bottom: 1px solid #bbb; } +.keyline-all { border: 1px solid #ddd; } +.coverage-summary td, .coverage-summary th { padding: 10px; } +.coverage-summary tbody { border: 1px solid #bbb; } +.coverage-summary td { border-right: 1px solid #bbb; } +.coverage-summary td:last-child { border-right: none; } +.coverage-summary th { + text-align: left; + font-weight: normal; + white-space: nowrap; +} +.coverage-summary th.file { border-right: none !important; } +.coverage-summary th.pct { } +.coverage-summary th.pic, +.coverage-summary th.abs, +.coverage-summary td.pct, +.coverage-summary td.abs { text-align: right; } +.coverage-summary td.file { white-space: nowrap; } +.coverage-summary td.pic { min-width: 120px !important; } +.coverage-summary tfoot td { } + +.coverage-summary .sorter { + height: 10px; + width: 7px; + display: inline-block; + margin-left: 0.5em; + background: url(sort-arrow-sprite.png) no-repeat scroll 0 0 transparent; +} +.coverage-summary .sorted .sorter { + background-position: 0 -20px; +} +.coverage-summary .sorted-desc .sorter { + background-position: 0 -10px; +} +.status-line { height: 10px; } +/* yellow */ +.cbranch-no { background: yellow !important; color: #111; } +/* dark red */ +.red.solid, .status-line.low, .low .cover-fill { background:#C21F39 } +.low .chart { border:1px solid #C21F39 } +.highlighted, +.highlighted .cstat-no, .highlighted .fstat-no, .highlighted .cbranch-no{ + background: #C21F39 !important; +} +/* medium red */ +.cstat-no, .fstat-no, .cbranch-no, .cbranch-no { background:#F6C6CE } +/* light red */ +.low, .cline-no { background:#FCE1E5 } +/* light green */ +.high, .cline-yes { background:rgb(230,245,208) } +/* medium green */ +.cstat-yes { background:rgb(161,215,106) } +/* dark green */ +.status-line.high, .high .cover-fill { background:rgb(77,146,33) } +.high .chart { border:1px solid rgb(77,146,33) } +/* dark yellow (gold) */ +.status-line.medium, .medium .cover-fill { background: #f9cd0b; } +.medium .chart { border:1px solid #f9cd0b; } +/* light yellow */ +.medium { background: #fff4c2; } + +.cstat-skip { background: #ddd; color: #111; } +.fstat-skip { background: #ddd; color: #111 !important; } +.cbranch-skip { background: #ddd !important; color: #111; } + +span.cline-neutral { background: #eaeaea; } + +.coverage-summary td.empty { + opacity: .5; + padding-top: 4px; + padding-bottom: 4px; + line-height: 1; + color: #888; +} + +.cover-fill, .cover-empty { + display:inline-block; + height: 12px; +} +.chart { + line-height: 0; +} +.cover-empty { + background: white; +} +.cover-full { + border-right: none !important; +} +pre.prettyprint { + border: none !important; + padding: 0 !important; + margin: 0 !important; +} +.com { color: #999 !important; } +.ignore-none { color: #999; font-weight: normal; } + +.wrapper { + min-height: 100%; + height: auto !important; + height: 100%; + margin: 0 auto -48px; +} +.footer, .push { + height: 48px; +} diff --git a/coverage/lcov-report/block-navigation.js b/coverage/lcov-report/block-navigation.js new file mode 100644 index 0000000..530d1ed --- /dev/null +++ b/coverage/lcov-report/block-navigation.js @@ -0,0 +1,87 @@ +/* eslint-disable */ +var jumpToCode = (function init() { + // Classes of code we would like to highlight in the file view + var missingCoverageClasses = ['.cbranch-no', '.cstat-no', '.fstat-no']; + + // Elements to highlight in the file listing view + var fileListingElements = ['td.pct.low']; + + // We don't want to select elements that are direct descendants of another match + var notSelector = ':not(' + missingCoverageClasses.join('):not(') + ') > '; // becomes `:not(a):not(b) > ` + + // Selector that finds elements on the page to which we can jump + var selector = + fileListingElements.join(', ') + + ', ' + + notSelector + + missingCoverageClasses.join(', ' + notSelector); // becomes `:not(a):not(b) > a, :not(a):not(b) > b` + + // The NodeList of matching elements + var missingCoverageElements = document.querySelectorAll(selector); + + var currentIndex; + + function toggleClass(index) { + missingCoverageElements + .item(currentIndex) + .classList.remove('highlighted'); + missingCoverageElements.item(index).classList.add('highlighted'); + } + + function makeCurrent(index) { + toggleClass(index); + currentIndex = index; + missingCoverageElements.item(index).scrollIntoView({ + behavior: 'smooth', + block: 'center', + inline: 'center' + }); + } + + function goToPrevious() { + var nextIndex = 0; + if (typeof currentIndex !== 'number' || currentIndex === 0) { + nextIndex = missingCoverageElements.length - 1; + } else if (missingCoverageElements.length > 1) { + nextIndex = currentIndex - 1; + } + + makeCurrent(nextIndex); + } + + function goToNext() { + var nextIndex = 0; + + if ( + typeof currentIndex === 'number' && + currentIndex < missingCoverageElements.length - 1 + ) { + nextIndex = currentIndex + 1; + } + + makeCurrent(nextIndex); + } + + return function jump(event) { + if ( + document.getElementById('fileSearch') === document.activeElement && + document.activeElement != null + ) { + // if we're currently focused on the search input, we don't want to navigate + return; + } + + switch (event.which) { + case 78: // n + case 74: // j + goToNext(); + break; + case 66: // b + case 75: // k + case 80: // p + goToPrevious(); + break; + } + }; +})(); +window.addEventListener('keydown', jumpToCode); diff --git a/coverage/lcov-report/components/CreateHabitModal.jsx.html b/coverage/lcov-report/components/CreateHabitModal.jsx.html new file mode 100644 index 0000000..0f1aedf --- /dev/null +++ b/coverage/lcov-report/components/CreateHabitModal.jsx.html @@ -0,0 +1,1327 @@ + + + + + + Code coverage report for components/CreateHabitModal.jsx + + + + + + + + + +
+
+

All files / components CreateHabitModal.jsx

+
+ +
+ 65.82% + Statements + 52/79 +
+ + +
+ 49.15% + Branches + 29/59 +
+ + +
+ 28.57% + Functions + 8/28 +
+ + +
+ 65.82% + Lines + 52/79 +
+ + +
+

+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +

+ +
+
+

+
1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 +16 +17 +18 +19 +20 +21 +22 +23 +24 +25 +26 +27 +28 +29 +30 +31 +32 +33 +34 +35 +36 +37 +38 +39 +40 +41 +42 +43 +44 +45 +46 +47 +48 +49 +50 +51 +52 +53 +54 +55 +56 +57 +58 +59 +60 +61 +62 +63 +64 +65 +66 +67 +68 +69 +70 +71 +72 +73 +74 +75 +76 +77 +78 +79 +80 +81 +82 +83 +84 +85 +86 +87 +88 +89 +90 +91 +92 +93 +94 +95 +96 +97 +98 +99 +100 +101 +102 +103 +104 +105 +106 +107 +108 +109 +110 +111 +112 +113 +114 +115 +116 +117 +118 +119 +120 +121 +122 +123 +124 +125 +126 +127 +128 +129 +130 +131 +132 +133 +134 +135 +136 +137 +138 +139 +140 +141 +142 +143 +144 +145 +146 +147 +148 +149 +150 +151 +152 +153 +154 +155 +156 +157 +158 +159 +160 +161 +162 +163 +164 +165 +166 +167 +168 +169 +170 +171 +172 +173 +174 +175 +176 +177 +178 +179 +180 +181 +182 +183 +184 +185 +186 +187 +188 +189 +190 +191 +192 +193 +194 +195 +196 +197 +198 +199 +200 +201 +202 +203 +204 +205 +206 +207 +208 +209 +210 +211 +212 +213 +214 +215 +216 +217 +218 +219 +220 +221 +222 +223 +224 +225 +226 +227 +228 +229 +230 +231 +232 +233 +234 +235 +236 +237 +238 +239 +240 +241 +242 +243 +244 +245 +246 +247 +248 +249 +250 +251 +252 +253 +254 +255 +256 +257 +258 +259 +260 +261 +262 +263 +264 +265 +266 +267 +268 +269 +270 +271 +272 +273 +274 +275 +276 +277 +278 +279 +280 +281 +282 +283 +284 +285 +286 +287 +288 +289 +290 +291 +292 +293 +294 +295 +296 +297 +298 +299 +300 +301 +302 +303 +304 +305 +306 +307 +308 +309 +310 +311 +312 +313 +314 +315 +316 +317 +318 +319 +320 +321 +322 +323 +324 +325 +326 +327 +328 +329 +330 +331 +332 +333 +334 +335 +336 +337 +338 +339 +340 +341 +342 +343 +344 +345 +346 +347 +348 +349 +350 +351 +352 +353 +354 +355 +356 +357 +358 +359 +360 +361 +362 +363 +364 +365 +366 +367 +368 +369 +370 +371 +372 +373 +374 +375 +376 +377 +378 +379 +380 +381 +382 +383 +384 +385 +386 +387 +388 +389 +390 +391 +392 +393 +394 +395 +396 +397 +398 +399 +400 +401 +402 +403 +404 +405 +406 +407 +408 +409 +410 +411 +412 +413 +414 +415  +  +  +  +  +  +  +  +1x +  +  +  +  +1x +  +  +  +  +  +  +  +  +  +  +  +1x +  +  +  +  +  +  +  +  +  +  +12x +12x +12x +12x +12x +12x +12x +12x +12x +12x +12x +  +12x +  +12x +1x +  +1x +1x +1x +  +  +  +  +  +  +12x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +  +  +12x +2x +2x +1x +1x +  +1x +  +  +  +1x +2x +  +  +  +  +1x +1x +  +  +1x +  +  +1x +  +  +  +1x +  +  +12x +  +  +  +  +  +  +  +12x +  +12x +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +1x +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +110x +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +110x +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  + 
import { useState } from "react"
+import { motion, AnimatePresence } from "framer-motion"
+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 = [
+  "#6366f1", "#8b5cf6", "#d946ef", "#ec4899", "#f43f5e",
+  "#f97316", "#eab308", "#22c55e", "#14b8a6", "#0ea5e9",
+]
+ 
+const ICON_CATEGORIES = [
+  { name: "Спорт", icons: ["💪", "🏃", "🚴", "🏊", "🧘", "⚽", "🏀", "🎾"] },
+  { name: "Здоровье", icons: ["💊", "💉", "🩺", "🧠", "😴", "💤", "🦷", "👁️"] },
+  { name: "Продуктивность", icons: ["📚", "📖", "✏️", "💻", "🎯", "📝", "📅", "⏰"] },
+  { name: "Дом", icons: ["🏠", "🧹", "🧺", "🍳", "🛒", "🔧", "🪴"] },
+  { name: "Финансы", icons: ["💰", "💳", "📊", "💵", "🏦"] },
+  { name: "Социальное", icons: ["👥", "💬", "📞", "👪", "❤️"] },
+  { name: "Хобби", icons: ["🎨", "🎵", "🎸", "🎮", "📷", "✈️", "🚗"] },
+  { name: "Еда/вода", icons: ["🥗", "🍎", "🥤", "☕", "🍽️", "💧"] },
+  { name: "Разное", icons: ["⭐", "🎉", "✨", "🔥", "🌟", "💎", "🎁"] },
+]
+ 
+const DAYS = [
+  { id: 1, short: "Пн", full: "Понедельник" },
+  { id: 2, short: "Вт", full: "Вторник" },
+  { id: 3, short: "Ср", full: "Среда" },
+  { id: 4, short: "Чт", full: "Четверг" },
+  { id: 5, short: "Пт", full: "Пятница" },
+  { id: 6, short: "Сб", full: "Суббота" },
+  { id: 7, short: "Вс", full: "Воскресенье" },
+]
+ 
+export default function CreateHabitModal({ open, onClose }) {
+  const [name, setName] = useState("")
+  const [description, setDescription] = useState("")
+  const [color, setColor] = useState(COLORS[0])
+  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)
+  
+  const queryClient = useQueryClient()
+ 
+  const mutation = useMutation({
+    mutationFn: (data) => habitsApi.create(data),
+    onSuccess: () => {
+      queryClient.invalidateQueries({ queryKey: ["habits"] })
+      queryClient.invalidateQueries({ queryKey: ["stats"] })
+      handleClose()
+    },
+    onError: (err) => {
+      setError(err.response?.data?.error || "Ошибка создания")
+    },
+  })
+ 
+  const handleClose = () => {
+    setName("")
+    setDescription("")
+    setColor(COLORS[0])
+    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()
+  }
+ 
+  const handleSubmit = (e) => {
+    e.preventDefault()
+    if (!name.trim()) {
+      setError("Введи название привычки")
+      return
+    }
+    Iif (frequency === "weekly" && targetDays.length === 0) {
+      setError("Выбери хотя бы один день недели")
+      return
+    }
+    const interval = parseInt(intervalDays) || 0
+    Iif (frequency === "interval" && (interval < 2 || interval > 30)) {
+      setError("Интервал должен быть от 2 до 30 дней")
+      return
+    }
+    
+    const data = { name, description, color, icon, frequency, start_date: startDate }
+    Iif (frequency === "weekly") {
+      data.target_days = targetDays
+    }
+    Iif (frequency === "interval") {
+      data.target_count = parseInt(intervalDays)
+    }
+    Iif (reminderTime) {
+      data.reminder_time = reminderTime
+    }
+    
+    mutation.mutate(data)
+  }
+ 
+  const toggleDay = (dayId) => {
+    setTargetDays(prev => 
+      prev.includes(dayId)
+        ? prev.filter(d => d !== dayId)
+        : [...prev, dayId].sort((a, b) => a - b)
+    )
+  }
+ 
+  const popularIcons = ["✨", "💪", "📚", "🏃", "💧", "🧘", "💤", "🎯", "✏️", "🍎"]
+ 
+  return (
+    <AnimatePresence>
+      {open && (
+        <>
+          <motion.div
+            initial={{ opacity: 0 }}
+            animate={{ opacity: 1 }}
+            exit={{ opacity: 0 }}
+            onClick={handleClose}
+            className="fixed inset-0 bg-black/50 backdrop-blur-sm z-40"
+          />
+          <motion.div
+            initial={{ opacity: 0, y: 100 }}
+            animate={{ opacity: 1, y: 0 }}
+            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 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 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>
+ 
+              <form onSubmit={handleSubmit} className="p-4 space-y-4">
+                {error && (
+                  <div className="p-3 rounded-xl bg-red-50 text-red-600 text-sm">
+                    {error}
+                  </div>
+                )}
+ 
+                <div>
+                  <label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1.5">
+                    Название
+                  </label>
+                  <input
+                    type="text"
+                    value={name}
+                    onChange={(e) => setName(e.target.value)}
+                    className="input"
+                    placeholder="Например: Пить воду"
+                    autoFocus
+                  />
+                </div>
+ 
+                <div>
+                  <label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1.5">
+                    Описание (опционально)
+                  </label>
+                  <input
+                    type="text"
+                    value={description}
+                    onChange={(e) => setDescription(e.target.value)}
+                    className="input"
+                    placeholder="8 стаканов в день"
+                  />
+                </div>
+ 
+                <div>
+                  <label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
+                    Периодичность
+                  </label>
+                  <div className="flex gap-2">
+                    <button
+                      type="button"
+                      onClick={() => setFrequency("daily")}
+                      className={clsx(
+                        "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 dark:bg-gray-800 text-gray-600 hover:bg-gray-200"
+                      )}
+                    >
+                      Ежедневно
+                    </button>
+                    <button
+                      type="button"
+                      onClick={() => setFrequency("weekly")}
+                      className={clsx(
+                        "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 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>
+ 
+                {frequency === "weekly" && (
+                  <motion.div
+                    initial={{ opacity: 0, height: 0 }}
+                    animate={{ opacity: 1, height: "auto" }}
+                    exit={{ opacity: 0, height: 0 }}
+                  >
+                    <label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
+                      Дни недели
+                    </label>
+                    <div className="flex gap-1.5">
+                      {DAYS.map((day) => (
+                        <button
+                          key={day.id}
+                          type="button"
+                          onClick={() => toggleDay(day.id)}
+                          className={clsx(
+                            "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 dark:bg-gray-800 text-gray-500 dark:text-gray-400 dark:text-gray-500 hover:bg-gray-200"
+                          )}
+                        >
+                          {day.short}
+                        </button>
+                      ))}
+                    </div>
+                  </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 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 dark:text-gray-500" size={18} />
+                    <input
+                      type="time"
+                      value={reminderTime}
+                      onChange={(e) => setReminderTime(e.target.value)}
+                      className="input pl-10"
+                    />
+                  </div>
+                  <p className="text-xs text-gray-500 dark:text-gray-400 dark:text-gray-500 mt-1">
+                    Получишь напоминание в Telegram в указанное время
+                  </p>
+                </div>
+ 
+                <div>
+                  <label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
+                    Иконка
+                  </label>
+                  <div className="flex flex-wrap gap-2">
+                    {popularIcons.map((ic) => (
+                      <button
+                        key={ic}
+                        type="button"
+                        onClick={() => setIcon(ic)}
+                        className={clsx(
+                          "w-10 h-10 rounded-xl flex items-center justify-center text-xl transition-all",
+                          icon === ic
+                            ? "bg-primary-100 ring-2 ring-primary-500"
+                            : "bg-gray-100 dark:bg-gray-800 hover:bg-gray-200"
+                        )}
+                      >
+                        {ic}
+                      </button>
+                    ))}
+                  </div>
+                  
+                  <button
+                    type="button"
+                    onClick={() => setShowAllIcons(!showAllIcons)}
+                    className="mt-2 flex items-center gap-1 text-sm text-primary-600 hover:text-primary-700"
+                  >
+                    {showAllIcons ? <ChevronUp size={16} /> : <ChevronDown size={16} />}
+                    {showAllIcons ? "Скрыть" : "Все иконки"}
+                  </button>
+ 
+                  <AnimatePresence>
+                    {showAllIcons && (
+                      <motion.div
+                        initial={{ opacity: 0, height: 0 }}
+                        animate={{ opacity: 1, height: "auto" }}
+                        exit={{ opacity: 0, height: 0 }}
+                        className="mt-3 space-y-3"
+                      >
+                        {ICON_CATEGORIES.map((category) => (
+                          <div key={category.name}>
+                            <p className="text-xs text-gray-500 dark:text-gray-400 dark:text-gray-500 mb-1.5">{category.name}</p>
+                            <div className="flex flex-wrap gap-1.5">
+                              {category.icons.map((ic) => (
+                                <button
+                                  key={ic}
+                                  type="button"
+                                  onClick={() => setIcon(ic)}
+                                  className={clsx(
+                                    "w-9 h-9 rounded-lg flex items-center justify-center text-lg transition-all",
+                                    icon === ic
+                                      ? "bg-primary-100 ring-2 ring-primary-500"
+                                      : "bg-gray-100 dark:bg-gray-800 hover:bg-gray-200"
+                                  )}
+                                >
+                                  {ic}
+                                </button>
+                              ))}
+                            </div>
+                          </div>
+                        ))}
+                      </motion.div>
+                    )}
+                  </AnimatePresence>
+                </div>
+ 
+                <div>
+                  <label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
+                    Цвет
+                  </label>
+                  <div className="flex flex-wrap gap-2">
+                    {COLORS.map((c) => (
+                      <button
+                        key={c}
+                        type="button"
+                        onClick={() => setColor(c)}
+                        className={clsx(
+                          "w-8 h-8 rounded-full transition-all",
+                          color === c ? "ring-2 ring-offset-2 ring-gray-400 scale-110" : ""
+                        )}
+                        style={{ backgroundColor: c }}
+                      />
+                    ))}
+                  </div>
+                </div>
+ 
+                <div className="pt-2">
+                  <button
+                    type="submit"
+                    disabled={mutation.isPending}
+                    className="btn btn-primary w-full"
+                  >
+                    {mutation.isPending ? "Создаём..." : "Создать привычку"}
+                  </button>
+                </div>
+              </form>
+            </div>
+          </motion.div>
+        </>
+      )}
+    </AnimatePresence>
+  )
+}
+ 
+ +
+
+ + + + + + + + \ No newline at end of file diff --git a/coverage/lcov-report/components/CreateTaskModal.jsx.html b/coverage/lcov-report/components/CreateTaskModal.jsx.html new file mode 100644 index 0000000..71b3ed9 --- /dev/null +++ b/coverage/lcov-report/components/CreateTaskModal.jsx.html @@ -0,0 +1,1447 @@ + + + + + + Code coverage report for components/CreateTaskModal.jsx + + + + + + + + + +
+
+

All files / components CreateTaskModal.jsx

+
+ +
+ 71.05% + Statements + 54/76 +
+ + +
+ 54.09% + Branches + 33/61 +
+ + +
+ 32.14% + Functions + 9/28 +
+ + +
+ 71.05% + Lines + 54/76 +
+ + +
+

+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +

+ +
+
+

+
1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 +16 +17 +18 +19 +20 +21 +22 +23 +24 +25 +26 +27 +28 +29 +30 +31 +32 +33 +34 +35 +36 +37 +38 +39 +40 +41 +42 +43 +44 +45 +46 +47 +48 +49 +50 +51 +52 +53 +54 +55 +56 +57 +58 +59 +60 +61 +62 +63 +64 +65 +66 +67 +68 +69 +70 +71 +72 +73 +74 +75 +76 +77 +78 +79 +80 +81 +82 +83 +84 +85 +86 +87 +88 +89 +90 +91 +92 +93 +94 +95 +96 +97 +98 +99 +100 +101 +102 +103 +104 +105 +106 +107 +108 +109 +110 +111 +112 +113 +114 +115 +116 +117 +118 +119 +120 +121 +122 +123 +124 +125 +126 +127 +128 +129 +130 +131 +132 +133 +134 +135 +136 +137 +138 +139 +140 +141 +142 +143 +144 +145 +146 +147 +148 +149 +150 +151 +152 +153 +154 +155 +156 +157 +158 +159 +160 +161 +162 +163 +164 +165 +166 +167 +168 +169 +170 +171 +172 +173 +174 +175 +176 +177 +178 +179 +180 +181 +182 +183 +184 +185 +186 +187 +188 +189 +190 +191 +192 +193 +194 +195 +196 +197 +198 +199 +200 +201 +202 +203 +204 +205 +206 +207 +208 +209 +210 +211 +212 +213 +214 +215 +216 +217 +218 +219 +220 +221 +222 +223 +224 +225 +226 +227 +228 +229 +230 +231 +232 +233 +234 +235 +236 +237 +238 +239 +240 +241 +242 +243 +244 +245 +246 +247 +248 +249 +250 +251 +252 +253 +254 +255 +256 +257 +258 +259 +260 +261 +262 +263 +264 +265 +266 +267 +268 +269 +270 +271 +272 +273 +274 +275 +276 +277 +278 +279 +280 +281 +282 +283 +284 +285 +286 +287 +288 +289 +290 +291 +292 +293 +294 +295 +296 +297 +298 +299 +300 +301 +302 +303 +304 +305 +306 +307 +308 +309 +310 +311 +312 +313 +314 +315 +316 +317 +318 +319 +320 +321 +322 +323 +324 +325 +326 +327 +328 +329 +330 +331 +332 +333 +334 +335 +336 +337 +338 +339 +340 +341 +342 +343 +344 +345 +346 +347 +348 +349 +350 +351 +352 +353 +354 +355 +356 +357 +358 +359 +360 +361 +362 +363 +364 +365 +366 +367 +368 +369 +370 +371 +372 +373 +374 +375 +376 +377 +378 +379 +380 +381 +382 +383 +384 +385 +386 +387 +388 +389 +390 +391 +392 +393 +394 +395 +396 +397 +398 +399 +400 +401 +402 +403 +404 +405 +406 +407 +408 +409 +410 +411 +412 +413 +414 +415 +416 +417 +418 +419 +420 +421 +422 +423 +424 +425 +426 +427 +428 +429 +430 +431 +432 +433 +434 +435 +436 +437 +438 +439 +440 +441 +442 +443 +444 +445 +446 +447 +448 +449 +450 +451 +452 +453 +454 +455  +  +  +  +  +  +  +  +1x +  +  +  +  +1x +  +  +  +  +  +  +  +  +1x +  +  +  +  +  +  +1x +  +  +  +  +  +  +  +12x +12x +  +12x +12x +12x +12x +12x +12x +12x +12x +12x +  +  +12x +12x +12x +12x +  +12x +  +12x +1x +  +1x +1x +1x +  +  +  +  +  +  +12x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +  +  +12x +2x +2x +1x +1x +  +  +1x +  +  +  +  +  +  +  +  +  +  +2x +  +  +  +  +  +1x +  +  +12x +  +12x +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +1x +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +44x +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +88x +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +110x +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  + 
import { useState } from "react"
+import { motion, AnimatePresence } from "framer-motion"
+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"
+import { format, addDays } from "date-fns"
+ 
+const COLORS = [
+  "#6B7280", "#6366f1", "#8b5cf6", "#d946ef", "#ec4899",
+  "#f43f5e", "#f97316", "#eab308", "#22c55e", "#0ea5e9",
+]
+ 
+const ICON_CATEGORIES = [
+  { name: "Продуктивность", icons: ["📋", "📝", "✅", "📌", "🎯", "💡", "📅", "⏰"] },
+  { name: "Работа", icons: ["💼", "💻", "📧", "📞", "📊", "📈", "🖥️", "⌨️"] },
+  { name: "Дом", icons: ["🏠", "🧹", "🧺", "🍳", "🛒", "🔧", "🪴", "🛋️"] },
+  { name: "Финансы", icons: ["💰", "💳", "📊", "💵", "🏦", "🧾"] },
+  { name: "Здоровье", icons: ["💊", "🏃", "🧘", "💪", "🩺", "🦷"] },
+  { name: "Разное", icons: ["⭐", "🎁", "📦", "✈️", "🚗", "📷", "🎉"] },
+]
+ 
+const PRIORITIES = [
+  { 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")
+  
+  const [title, setTitle] = useState("")
+  const [description, setDescription] = useState("")
+  const [color, setColor] = useState(COLORS[0])
+  const [icon, setIcon] = useState("📋")
+  const [dueDate, setDueDate] = useState(defaultDueDate || today)
+  const [priority, setPriority] = useState(0)
+  const [reminderTime, setReminderTime] = useState("")
+  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({
+    mutationFn: (data) => tasksApi.create(data),
+    onSuccess: () => {
+      queryClient.invalidateQueries({ queryKey: ["tasks"] })
+      queryClient.invalidateQueries({ queryKey: ["tasks-today"] })
+      handleClose()
+    },
+    onError: (err) => {
+      setError(err.response?.data?.error || "Ошибка создания")
+    },
+  })
+ 
+  const handleClose = () => {
+    setTitle("")
+    setDescription("")
+    setColor(COLORS[0])
+    setIcon("📋")
+    setDueDate(defaultDueDate || today)
+    setPriority(0)
+    setReminderTime("")
+    setError("")
+    setShowAllIcons(false)
+    setIsRecurring(false)
+    setRecurrenceType("daily")
+    setRecurrenceInterval(1)
+    setRecurrenceEndDate("")
+    onClose()
+  }
+ 
+  const handleSubmit = (e) => {
+    e.preventDefault()
+    if (!title.trim()) {
+      setError("Введи название задачи")
+      return
+    }
+    
+    const data = {
+      title,
+      description,
+      color,
+      icon,
+      due_date: dueDate || null,
+      priority,
+      reminder_time: reminderTime || null,
+      is_recurring: isRecurring,
+    }
+    
+    Iif (isRecurring) {
+      data.recurrence_type = recurrenceType
+      data.recurrence_interval = recurrenceType === "custom" ? recurrenceInterval : 1
+      data.recurrence_end_date = recurrenceEndDate || null
+    }
+    
+    mutation.mutate(data)
+  }
+ 
+  const popularIcons = ["📋", "📝", "✅", "🎯", "💼", "🏠", "💰", "📞"]
+ 
+  return (
+    <AnimatePresence>
+      {open && (
+        <>
+          <motion.div
+            initial={{ opacity: 0 }}
+            animate={{ opacity: 1 }}
+            exit={{ opacity: 0 }}
+            onClick={handleClose}
+            className="fixed inset-0 bg-black/50 backdrop-blur-sm z-40"
+          />
+          <motion.div
+            initial={{ opacity: 0, y: 100 }}
+            animate={{ opacity: 1, y: 0 }}
+            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 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 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>
+ 
+              <form onSubmit={handleSubmit} className="p-4 space-y-4">
+                {error && (
+                  <div className="p-3 rounded-xl bg-red-50 text-red-600 text-sm">
+                    {error}
+                  </div>
+                )}
+ 
+                <div>
+                  <label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1.5">
+                    Название
+                  </label>
+                  <input
+                    type="text"
+                    value={title}
+                    onChange={(e) => setTitle(e.target.value)}
+                    className="input"
+                    placeholder="Что нужно сделать?"
+                    autoFocus
+                  />
+                </div>
+ 
+                <div>
+                  <label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1.5">
+                    Описание (опционально)
+                  </label>
+                  <textarea
+                    value={description}
+                    onChange={(e) => setDescription(e.target.value)}
+                    className="input min-h-[80px] resize-none"
+                    placeholder="Подробности задачи..."
+                  />
+                </div>
+ 
+                <div>
+                  <label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
+                    Срок выполнения
+                  </label>
+                  <div className="flex gap-2 mb-2">
+                    <button
+                      type="button"
+                      onClick={() => setDueDate(today)}
+                      className={clsx(
+                        "px-3 py-1.5 rounded-lg text-sm font-medium transition-all",
+                        dueDate === today
+                          ? "bg-primary-500 text-white"
+                          : "bg-gray-100 dark:bg-gray-800 text-gray-600 hover:bg-gray-200"
+                      )}
+                    >
+                      Сегодня
+                    </button>
+                    <button
+                      type="button"
+                      onClick={() => setDueDate(tomorrow)}
+                      className={clsx(
+                        "px-3 py-1.5 rounded-lg text-sm font-medium transition-all",
+                        dueDate === tomorrow
+                          ? "bg-primary-500 text-white"
+                          : "bg-gray-100 dark:bg-gray-800 text-gray-600 hover:bg-gray-200"
+                      )}
+                    >
+                      Завтра
+                    </button>
+                    <button
+                      type="button"
+                      onClick={() => setDueDate("")}
+                      className={clsx(
+                        "px-3 py-1.5 rounded-lg text-sm font-medium transition-all",
+                        !dueDate
+                          ? "bg-primary-500 text-white"
+                          : "bg-gray-100 dark:bg-gray-800 text-gray-600 hover:bg-gray-200"
+                      )}
+                    >
+                      Без срока
+                    </button>
+                  </div>
+                  <div className="relative">
+                    <Calendar className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-400 dark:text-gray-500" size={18} />
+                    <input
+                      type="date"
+                      value={dueDate}
+                      onChange={(e) => setDueDate(e.target.value)}
+                      className="input pl-10"
+                    />
+                  </div>
+                </div>
+ 
+                {/* Recurring Section */}
+                <div className="border-t border-gray-100 dark:border-gray-800 pt-4">
+                  <div className="flex items-center justify-between mb-3">
+                    <div className="flex items-center gap-2">
+                      <Repeat size={18} className={isRecurring ? "text-primary-500" : "text-gray-400 dark:text-gray-500"} />
+                      <label className="text-sm font-medium text-gray-700 dark:text-gray-300">Повторять</label>
+                    </div>
+                    <button
+                      type="button"
+                      onClick={() => setIsRecurring(!isRecurring)}
+                      className={clsx(
+                        "w-12 h-6 rounded-full transition-all relative",
+                        isRecurring ? "bg-primary-500" : "bg-gray-200"
+                      )}
+                    >
+                      <div className={clsx(
+                        "absolute top-1 w-4 h-4 rounded-full bg-white dark:bg-gray-900 shadow-sm transition-all",
+                        isRecurring ? "right-1" : "left-1"
+                      )} />
+                    </button>
+                  </div>
+                  
+                  <AnimatePresence>
+                    {isRecurring && (
+                      <motion.div
+                        initial={{ opacity: 0, height: 0 }}
+                        animate={{ opacity: 1, height: "auto" }}
+                        exit={{ opacity: 0, height: 0 }}
+                        className="space-y-3"
+                      >
+                        <div className="flex flex-wrap gap-2">
+                          {RECURRENCE_TYPES.map((type) => (
+                            <button
+                              key={type.value}
+                              type="button"
+                              onClick={() => setRecurrenceType(type.value)}
+                              className={clsx(
+                                "px-3 py-1.5 rounded-lg text-sm font-medium transition-all",
+                                recurrenceType === type.value
+                                  ? "bg-primary-500 text-white"
+                                  : "bg-gray-100 dark:bg-gray-800 text-gray-600 hover:bg-gray-200"
+                              )}
+                            >
+                              {type.label}
+                            </button>
+                          ))}
+                        </div>
+                        
+                        {recurrenceType === "custom" && (
+                          <div className="flex items-center gap-2">
+                            <span className="text-sm text-gray-600">Каждые</span>
+                            <input
+                              type="number"
+                              min="1"
+                              max="365"
+                              value={recurrenceInterval}
+                              onChange={(e) => setRecurrenceInterval(parseInt(e.target.value) || 1)}
+                              className="input w-20 text-center"
+                            />
+                            <span className="text-sm text-gray-600">дней</span>
+                          </div>
+                        )}
+                        
+                        <div>
+                          <label className="block text-xs text-gray-500 dark:text-gray-400 dark:text-gray-500 mb-1">
+                            Повторять до (опционально)
+                          </label>
+                          <input
+                            type="date"
+                            value={recurrenceEndDate}
+                            onChange={(e) => setRecurrenceEndDate(e.target.value)}
+                            className="input"
+                            min={dueDate || today}
+                          />
+                        </div>
+                      </motion.div>
+                    )}
+                  </AnimatePresence>
+                </div>
+ 
+                <div>
+                  <label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
+                    Напоминание (опционально)
+                  </label>
+                  <div className="relative">
+                    <Clock className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-400 dark:text-gray-500" size={18} />
+                    <input
+                      type="time"
+                      value={reminderTime}
+                      onChange={(e) => setReminderTime(e.target.value)}
+                      className="input pl-10"
+                    />
+                  </div>
+                  <p className="text-xs text-gray-500 dark:text-gray-400 dark:text-gray-500 mt-1">
+                    Получишь напоминание в Telegram в указанное время
+                  </p>
+                </div>
+ 
+                <div>
+                  <label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
+                    Приоритет
+                  </label>
+                  <div className="flex gap-2 flex-wrap">
+                    {PRIORITIES.map((p) => (
+                      <button
+                        key={p.value}
+                        type="button"
+                        onClick={() => setPriority(p.value)}
+                        className={clsx(
+                          "px-3 py-1.5 rounded-lg text-sm font-medium transition-all",
+                          priority === p.value
+                            ? p.color + " ring-2 ring-offset-1 ring-gray-400"
+                            : "bg-gray-100 dark:bg-gray-800 text-gray-600 hover:bg-gray-200"
+                        )}
+                      >
+                        {p.label}
+                      </button>
+                    ))}
+                  </div>
+                </div>
+ 
+                <div>
+                  <label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
+                    Иконка
+                  </label>
+                  <div className="flex flex-wrap gap-2">
+                    {popularIcons.map((ic) => (
+                      <button
+                        key={ic}
+                        type="button"
+                        onClick={() => setIcon(ic)}
+                        className={clsx(
+                          "w-10 h-10 rounded-xl flex items-center justify-center text-xl transition-all",
+                          icon === ic
+                            ? "bg-primary-100 ring-2 ring-primary-500"
+                            : "bg-gray-100 dark:bg-gray-800 hover:bg-gray-200"
+                        )}
+                      >
+                        {ic}
+                      </button>
+                    ))}
+                  </div>
+                  
+                  <button
+                    type="button"
+                    onClick={() => setShowAllIcons(!showAllIcons)}
+                    className="mt-2 flex items-center gap-1 text-sm text-primary-600 hover:text-primary-700"
+                  >
+                    {showAllIcons ? <ChevronUp size={16} /> : <ChevronDown size={16} />}
+                    {showAllIcons ? "Скрыть" : "Все иконки"}
+                  </button>
+ 
+                  <AnimatePresence>
+                    {showAllIcons && (
+                      <motion.div
+                        initial={{ opacity: 0, height: 0 }}
+                        animate={{ opacity: 1, height: "auto" }}
+                        exit={{ opacity: 0, height: 0 }}
+                        className="mt-3 space-y-3"
+                      >
+                        {ICON_CATEGORIES.map((category) => (
+                          <div key={category.name}>
+                            <p className="text-xs text-gray-500 dark:text-gray-400 dark:text-gray-500 mb-1.5">{category.name}</p>
+                            <div className="flex flex-wrap gap-1.5">
+                              {category.icons.map((ic) => (
+                                <button
+                                  key={ic}
+                                  type="button"
+                                  onClick={() => setIcon(ic)}
+                                  className={clsx(
+                                    "w-9 h-9 rounded-lg flex items-center justify-center text-lg transition-all",
+                                    icon === ic
+                                      ? "bg-primary-100 ring-2 ring-primary-500"
+                                      : "bg-gray-100 dark:bg-gray-800 hover:bg-gray-200"
+                                  )}
+                                >
+                                  {ic}
+                                </button>
+                              ))}
+                            </div>
+                          </div>
+                        ))}
+                      </motion.div>
+                    )}
+                  </AnimatePresence>
+                </div>
+ 
+                <div>
+                  <label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
+                    Цвет
+                  </label>
+                  <div className="flex flex-wrap gap-2">
+                    {COLORS.map((c) => (
+                      <button
+                        key={c}
+                        type="button"
+                        onClick={() => setColor(c)}
+                        className={clsx(
+                          "w-8 h-8 rounded-full transition-all",
+                          color === c ? "ring-2 ring-offset-2 ring-gray-400 scale-110" : ""
+                        )}
+                        style={{ backgroundColor: c }}
+                      />
+                    ))}
+                  </div>
+                </div>
+ 
+                <div className="pt-2">
+                  <button
+                    type="submit"
+                    disabled={mutation.isPending}
+                    className="btn btn-primary w-full"
+                  >
+                    {mutation.isPending ? "Создаём..." : "Создать задачу"}
+                  </button>
+                </div>
+              </form>
+            </div>
+          </motion.div>
+        </>
+      )}
+    </AnimatePresence>
+  )
+}
+ 
+ +
+
+ + + + + + + + \ No newline at end of file diff --git a/coverage/lcov-report/components/EditHabitModal.jsx.html b/coverage/lcov-report/components/EditHabitModal.jsx.html new file mode 100644 index 0000000..cf002e8 --- /dev/null +++ b/coverage/lcov-report/components/EditHabitModal.jsx.html @@ -0,0 +1,2221 @@ + + + + + + Code coverage report for components/EditHabitModal.jsx + + + + + + + + + +
+
+

All files / components EditHabitModal.jsx

+
+ +
+ 52.94% + Statements + 81/153 +
+ + +
+ 39.23% + Branches + 51/130 +
+ + +
+ 24.07% + Functions + 13/54 +
+ + +
+ 54% + Lines + 81/150 +
+ + +
+

+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +

+ +
+
+

+
1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 +16 +17 +18 +19 +20 +21 +22 +23 +24 +25 +26 +27 +28 +29 +30 +31 +32 +33 +34 +35 +36 +37 +38 +39 +40 +41 +42 +43 +44 +45 +46 +47 +48 +49 +50 +51 +52 +53 +54 +55 +56 +57 +58 +59 +60 +61 +62 +63 +64 +65 +66 +67 +68 +69 +70 +71 +72 +73 +74 +75 +76 +77 +78 +79 +80 +81 +82 +83 +84 +85 +86 +87 +88 +89 +90 +91 +92 +93 +94 +95 +96 +97 +98 +99 +100 +101 +102 +103 +104 +105 +106 +107 +108 +109 +110 +111 +112 +113 +114 +115 +116 +117 +118 +119 +120 +121 +122 +123 +124 +125 +126 +127 +128 +129 +130 +131 +132 +133 +134 +135 +136 +137 +138 +139 +140 +141 +142 +143 +144 +145 +146 +147 +148 +149 +150 +151 +152 +153 +154 +155 +156 +157 +158 +159 +160 +161 +162 +163 +164 +165 +166 +167 +168 +169 +170 +171 +172 +173 +174 +175 +176 +177 +178 +179 +180 +181 +182 +183 +184 +185 +186 +187 +188 +189 +190 +191 +192 +193 +194 +195 +196 +197 +198 +199 +200 +201 +202 +203 +204 +205 +206 +207 +208 +209 +210 +211 +212 +213 +214 +215 +216 +217 +218 +219 +220 +221 +222 +223 +224 +225 +226 +227 +228 +229 +230 +231 +232 +233 +234 +235 +236 +237 +238 +239 +240 +241 +242 +243 +244 +245 +246 +247 +248 +249 +250 +251 +252 +253 +254 +255 +256 +257 +258 +259 +260 +261 +262 +263 +264 +265 +266 +267 +268 +269 +270 +271 +272 +273 +274 +275 +276 +277 +278 +279 +280 +281 +282 +283 +284 +285 +286 +287 +288 +289 +290 +291 +292 +293 +294 +295 +296 +297 +298 +299 +300 +301 +302 +303 +304 +305 +306 +307 +308 +309 +310 +311 +312 +313 +314 +315 +316 +317 +318 +319 +320 +321 +322 +323 +324 +325 +326 +327 +328 +329 +330 +331 +332 +333 +334 +335 +336 +337 +338 +339 +340 +341 +342 +343 +344 +345 +346 +347 +348 +349 +350 +351 +352 +353 +354 +355 +356 +357 +358 +359 +360 +361 +362 +363 +364 +365 +366 +367 +368 +369 +370 +371 +372 +373 +374 +375 +376 +377 +378 +379 +380 +381 +382 +383 +384 +385 +386 +387 +388 +389 +390 +391 +392 +393 +394 +395 +396 +397 +398 +399 +400 +401 +402 +403 +404 +405 +406 +407 +408 +409 +410 +411 +412 +413 +414 +415 +416 +417 +418 +419 +420 +421 +422 +423 +424 +425 +426 +427 +428 +429 +430 +431 +432 +433 +434 +435 +436 +437 +438 +439 +440 +441 +442 +443 +444 +445 +446 +447 +448 +449 +450 +451 +452 +453 +454 +455 +456 +457 +458 +459 +460 +461 +462 +463 +464 +465 +466 +467 +468 +469 +470 +471 +472 +473 +474 +475 +476 +477 +478 +479 +480 +481 +482 +483 +484 +485 +486 +487 +488 +489 +490 +491 +492 +493 +494 +495 +496 +497 +498 +499 +500 +501 +502 +503 +504 +505 +506 +507 +508 +509 +510 +511 +512 +513 +514 +515 +516 +517 +518 +519 +520 +521 +522 +523 +524 +525 +526 +527 +528 +529 +530 +531 +532 +533 +534 +535 +536 +537 +538 +539 +540 +541 +542 +543 +544 +545 +546 +547 +548 +549 +550 +551 +552 +553 +554 +555 +556 +557 +558 +559 +560 +561 +562 +563 +564 +565 +566 +567 +568 +569 +570 +571 +572 +573 +574 +575 +576 +577 +578 +579 +580 +581 +582 +583 +584 +585 +586 +587 +588 +589 +590 +591 +592 +593 +594 +595 +596 +597 +598 +599 +600 +601 +602 +603 +604 +605 +606 +607 +608 +609 +610 +611 +612 +613 +614 +615 +616 +617 +618 +619 +620 +621 +622 +623 +624 +625 +626 +627 +628 +629 +630 +631 +632 +633 +634 +635 +636 +637 +638 +639 +640 +641 +642 +643 +644 +645 +646 +647 +648 +649 +650 +651 +652 +653 +654 +655 +656 +657 +658 +659 +660 +661 +662 +663 +664 +665 +666 +667 +668 +669 +670 +671 +672 +673 +674 +675 +676 +677 +678 +679 +680 +681 +682 +683 +684 +685 +686 +687 +688 +689 +690 +691 +692 +693 +694 +695 +696 +697 +698 +699 +700 +701 +702 +703 +704 +705 +706 +707 +708 +709 +710 +711 +712 +713  +  +  +  +  +  +  +  +  +1x +  +  +  +  +1x +  +  +  +  +  +  +  +  +  +  +  +1x +  +  +  +  +  +  +  +  +  +  +20x +20x +20x +20x +20x +20x +20x +20x +20x +20x +20x +20x +20x +20x +20x +20x +20x +  +20x +  +  +20x +  +7x +  +  +  +20x +8x +7x +7x +7x +7x +7x +7x +7x +7x +7x +7x +  +  +  +  +  +7x +7x +7x +7x +7x +7x +7x +7x +  +  +  +20x +1x +  +1x +1x +1x +  +  +  +  +  +  +20x +1x +  +1x +1x +1x +  +  +  +  +  +  +20x +  +  +  +  +  +  +  +  +  +  +  +  +  +  +20x +  +  +  +  +  +  +  +20x +  +  +  +  +  +  +  +  +20x +1x +1x +  +  +  +1x +  +  +  +1x +1x +  +  +  +  +1x +1x +  +  +1x +  +  +1x +  +1x +  +  +20x +1x +  +  +20x +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +20x +  +  +  +  +  +  +  +20x +20x +20x +  +20x +  +20x +  +20x +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +1x +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +160x +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +160x +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +2x +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  + 
import { useState, useEffect } from "react"
+import { motion, AnimatePresence } from "framer-motion"
+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 = [
+  "#6366f1", "#8b5cf6", "#d946ef", "#ec4899", "#f43f5e",
+  "#f97316", "#eab308", "#22c55e", "#14b8a6", "#0ea5e9",
+]
+ 
+const ICON_CATEGORIES = [
+  { name: "Спорт", icons: ["💪", "🏃", "🚴", "🏊", "🧘", "⚽", "🏀", "🎾"] },
+  { name: "Здоровье", icons: ["💊", "💉", "🩺", "🧠", "😴", "💤", "🦷", "👁️"] },
+  { name: "Продуктивность", icons: ["📚", "📖", "✏️", "💻", "🎯", "📝", "📅", "⏰"] },
+  { name: "Дом", icons: ["🏠", "🧹", "🧺", "🍳", "🛒", "🔧", "🪴"] },
+  { name: "Финансы", icons: ["💰", "💳", "📊", "💵", "🏦"] },
+  { name: "Социальное", icons: ["👥", "💬", "📞", "👪", "❤️"] },
+  { name: "Хобби", icons: ["🎨", "🎵", "🎸", "🎮", "📷", "✈️", "🚗"] },
+  { name: "Еда/вода", icons: ["🥗", "🍎", "🥤", "☕", "🍽️", "💧"] },
+  { name: "Разное", icons: ["⭐", "🎉", "✨", "🔥", "🌟", "💎", "🎁"] },
+]
+ 
+const DAYS = [
+  { id: 1, short: "Пн" },
+  { id: 2, short: "Вт" },
+  { id: 3, short: "Ср" },
+  { id: 4, short: "Чт" },
+  { id: 5, short: "Пт" },
+  { id: 6, short: "Сб" },
+  { id: 7, short: "Вс" },
+]
+ 
+export default function EditHabitModal({ open, onClose, habit }) {
+  const [name, setName] = useState("")
+  const [description, setDescription] = useState("")
+  const [color, setColor] = useState(COLORS[0])
+  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 || "")
+      setDescription(habit.description || "")
+      setColor(habit.color || COLORS[0])
+      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 (Ehabit.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])
+ 
+  const updateMutation = useMutation({
+    mutationFn: (data) => habitsApi.update(habit.id, data),
+    onSuccess: () => {
+      queryClient.invalidateQueries({ queryKey: ["habits"] })
+      queryClient.invalidateQueries({ queryKey: ["stats"] })
+      onClose()
+    },
+    onError: (err) => {
+      setError(err.response?.data?.error || "Ошибка сохранения")
+    },
+  })
+ 
+  const deleteMutation = useMutation({
+    mutationFn: () => habitsApi.delete(habit.id),
+    onSuccess: () => {
+      queryClient.invalidateQueries({ queryKey: ["habits"] })
+      queryClient.invalidateQueries({ queryKey: ["stats"] })
+      onClose()
+    },
+    onError: (err) => {
+      setError(err.response?.data?.error || "Ошибка удаления")
+    },
+  })
+ 
+  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()
+  }
+ 
+  const handleSubmit = (e) => {
+    e.preventDefault()
+    Iif (!name.trim()) {
+      setError("Введи название привычки")
+      return
+    }
+    Iif (frequency === "weekly" && targetDays.length === 0) {
+      setError("Выбери хотя бы один день недели")
+      return
+    }
+    const interval = parseInt(intervalDays) || 0
+    Iif (frequency === "interval" && (interval < 2 || interval > 30)) {
+      setError("Интервал должен быть от 2 до 30 дней")
+      return
+    }
+    
+    const data = { name, description, color, icon, frequency, start_date: startDate }
+    Iif (frequency === "weekly") {
+      data.target_days = targetDays
+    }
+    Iif (frequency === "interval") {
+      data.target_count = parseInt(intervalDays)
+    }
+    data.reminder_time = reminderTime || null
+    
+    updateMutation.mutate(data)
+  }
+ 
+  const handleDelete = () => {
+    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)
+        ? prev.filter(d => d !== dayId)
+        : [...prev, dayId].sort((a, b) => a - b)
+    )
+  }
+ 
+  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 = ["✨", "💪", "📚", "🏃", "💧", "🧘", "💤", "🎯", "✏️", "🍎"]
+ 
+  Iif (!habit) return null
+ 
+  return (
+    <AnimatePresence>
+      {open && (
+        <>
+          <motion.div
+            initial={{ opacity: 0 }}
+            animate={{ opacity: 1 }}
+            exit={{ opacity: 0 }}
+            onClick={handleClose}
+            className="fixed inset-0 bg-black/50 backdrop-blur-sm z-40"
+          />
+          <motion.div
+            initial={{ opacity: 0, y: 100 }}
+            animate={{ opacity: 1, y: 0 }}
+            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 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 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 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 dark:bg-gray-800 text-gray-700 dark:text-gray-300 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">
+                      {error}
+                    </div>
+                  )}
+ 
+                  <div>
+                    <label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1.5">
+                      Название
+                    </label>
+                    <input
+                      type="text"
+                      value={name}
+                      onChange={(e) => setName(e.target.value)}
+                      className="input"
+                      placeholder="Например: Пить воду"
+                    />
+                  </div>
+ 
+                  <div>
+                    <label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1.5">
+                      Описание (опционально)
+                    </label>
+                    <input
+                      type="text"
+                      value={description}
+                      onChange={(e) => setDescription(e.target.value)}
+                      className="input"
+                      placeholder="8 стаканов в день"
+                    />
+                  </div>
+ 
+                  <div>
+                    <label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
+                      Периодичность
+                    </label>
+                    <div className="flex gap-2">
+                      <button
+                        type="button"
+                        onClick={() => setFrequency("daily")}
+                        className={clsx(
+                          "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 dark:bg-gray-800 text-gray-600 hover:bg-gray-200"
+                        )}
+                      >
+                        Ежедневно
+                      </button>
+                      <button
+                        type="button"
+                        onClick={() => setFrequency("weekly")}
+                        className={clsx(
+                          "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 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>
+ 
+                  {frequency === "weekly" && (
+                    <motion.div
+                      initial={{ opacity: 0, height: 0 }}
+                      animate={{ opacity: 1, height: "auto" }}
+                      exit={{ opacity: 0, height: 0 }}
+                    >
+                      <label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
+                        Дни недели
+                      </label>
+                      <div className="flex gap-1.5">
+                        {DAYS.map((day) => (
+                          <button
+                            key={day.id}
+                            type="button"
+                            onClick={() => toggleDay(day.id)}
+                            className={clsx(
+                              "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 dark:bg-gray-800 text-gray-500 dark:text-gray-400 dark:text-gray-500 hover:bg-gray-200"
+                            )}
+                          >
+                            {day.short}
+                          </button>
+                        ))}
+                      </div>
+                    </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 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 dark:text-gray-500" size={18} />
+                      <input
+                        type="time"
+                        value={reminderTime}
+                        onChange={(e) => setReminderTime(e.target.value)}
+                        className="input pl-10"
+                      />
+                    </div>
+                    <p className="text-xs text-gray-500 dark:text-gray-400 dark:text-gray-500 mt-1">
+                      Получишь напоминание в Telegram в указанное время
+                    </p>
+                  </div>
+ 
+                  {/* Freezes section */}
+                  <div className="border-t pt-4">
+                    <button
+                      type="button"
+                      onClick={() => setShowFreezes(!showFreezes)}
+                      className="flex items-center justify-between w-full text-left"
+                    >
+                      <div className="flex items-center gap-2">
+                        <Snowflake className="w-5 h-5 text-cyan-500" />
+                        <span className="font-medium text-gray-700 dark:text-gray-300">Заморозки</span>
+                        {activeFreezes.length > 0 && (
+                          <span className="px-2 py-0.5 bg-cyan-100 text-cyan-700 rounded-full text-xs">
+                            {activeFreezes.length}
+                          </span>
+                        )}
+                      </div>
+                      {showFreezes ? <ChevronUp size={18} /> : <ChevronDown size={18} />}
+                    </button>
+                    <p className="text-xs text-gray-500 dark:text-gray-400 dark:text-gray-500 mt-1">
+                      Поставь привычку на паузу на время отпуска или болезни
+                    </p>
+ 
+                    <AnimatePresence>
+                      {showFreezes && (
+                        <motion.div
+                          initial={{ opacity: 0, height: 0 }}
+                          animate={{ opacity: 1, height: "auto" }}
+                          exit={{ opacity: 0, height: 0 }}
+                          className="mt-3 space-y-3"
+                        >
+                          {/* Active freezes */}
+                          {activeFreezes.length > 0 && (
+                            <div className="space-y-2">
+                              {activeFreezes.map((freeze) => {
+                                const isActive = !isBefore(parseISO(freeze.end_date), today) && 
+                                                 !isAfter(parseISO(freeze.start_date), today)
+                                return (
+                                  <div
+                                    key={freeze.id}
+                                    className={clsx(
+                                      "flex items-center justify-between p-3 rounded-xl",
+                                      isActive ? "bg-cyan-50 border border-cyan-200" : "bg-gray-50 dark:bg-gray-800"
+                                    )}
+                                  >
+                                    <div className="flex items-center gap-2">
+                                      <Snowflake className={clsx(
+                                        "w-4 h-4",
+                                        isActive ? "text-cyan-500" : "text-gray-400 dark:text-gray-500"
+                                      )} />
+                                      <div>
+                                        <p className="text-sm font-medium text-gray-700 dark:text-gray-300">
+                                          {format(parseISO(freeze.start_date), "d MMM", { locale: ru })} — {format(parseISO(freeze.end_date), "d MMM yyyy", { locale: ru })}
+                                        </p>
+                                        {freeze.reason && (
+                                          <p className="text-xs text-gray-500 dark:text-gray-400 dark:text-gray-500">{freeze.reason}</p>
+                                        )}
+                                      </div>
+                                      {isActive && (
+                                        <span className="px-2 py-0.5 bg-cyan-200 text-cyan-800 rounded-full text-xs">
+                                          активна
+                                        </span>
+                                      )}
+                                    </div>
+                                    <button
+                                      type="button"
+                                      onClick={() => deleteFreezeMutation.mutate(freeze.id)}
+                                      className="p-1.5 text-gray-400 dark:text-gray-500 hover:text-red-500 hover:bg-red-50 rounded-lg"
+                                    >
+                                      <Trash2 size={16} />
+                                    </button>
+                                  </div>
+                                )
+                              })}
+                            </div>
+                          )}
+ 
+                          {/* Add freeze form */}
+                          {showAddFreeze ? (
+                            <div className="p-3 bg-gray-50 dark:bg-gray-800 rounded-xl space-y-3">
+                              <div className="grid grid-cols-2 gap-2">
+                                <div>
+                                  <label className="text-xs text-gray-500 dark:text-gray-400 dark:text-gray-500">Начало</label>
+                                  <input
+                                    type="date"
+                                    value={freezeStart}
+                                    onChange={(e) => setFreezeStart(e.target.value)}
+                                    min={format(new Date(), "yyyy-MM-dd")}
+                                    className="input text-sm"
+                                  />
+                                </div>
+                                <div>
+                                  <label className="text-xs text-gray-500 dark:text-gray-400 dark:text-gray-500">Окончание</label>
+                                  <input
+                                    type="date"
+                                    value={freezeEnd}
+                                    onChange={(e) => setFreezeEnd(e.target.value)}
+                                    min={freezeStart || format(new Date(), "yyyy-MM-dd")}
+                                    className="input text-sm"
+                                  />
+                                </div>
+                              </div>
+                              <input
+                                type="text"
+                                value={freezeReason}
+                                onChange={(e) => setFreezeReason(e.target.value)}
+                                placeholder="Причина (опционально)"
+                                className="input text-sm"
+                              />
+                              <div className="flex gap-2">
+                                <button
+                                  type="button"
+                                  onClick={() => setShowAddFreeze(false)}
+                                  className="flex-1 btn bg-gray-100 dark:bg-gray-800 text-gray-600 text-sm"
+                                >
+                                  Отмена
+                                </button>
+                                <button
+                                  type="button"
+                                  onClick={handleAddFreeze}
+                                  disabled={addFreezeMutation.isPending}
+                                  className="flex-1 btn bg-cyan-500 text-white text-sm hover:bg-cyan-600"
+                                >
+                                  {addFreezeMutation.isPending ? "..." : "Добавить"}
+                                </button>
+                              </div>
+                            </div>
+                          ) : (
+                            <button
+                              type="button"
+                              onClick={() => setShowAddFreeze(true)}
+                              className="flex items-center gap-2 w-full p-3 bg-gray-50 dark:bg-gray-800 hover:bg-gray-100 dark:hover:bg-gray-800 dark:bg-gray-800 rounded-xl text-sm text-gray-600 transition-colors"
+                            >
+                              <Plus size={16} />
+                              Добавить заморозку
+                            </button>
+                          )}
+ 
+                          {/* Past freezes */}
+                          {pastFreezes.length > 0 && (
+                            <details className="text-sm">
+                              <summary className="text-gray-500 dark:text-gray-400 dark:text-gray-500 cursor-pointer hover:text-gray-700 dark:text-gray-300">
+                                Прошлые заморозки ({pastFreezes.length})
+                              </summary>
+                              <div className="mt-2 space-y-1">
+                                {pastFreezes.map((freeze) => (
+                                  <div key={freeze.id} className="flex items-center justify-between p-2 rounded-lg bg-gray-50 dark:bg-gray-800 text-gray-500 dark:text-gray-400 dark:text-gray-500">
+                                    <span>
+                                      {format(parseISO(freeze.start_date), "d MMM", { locale: ru })} — {format(parseISO(freeze.end_date), "d MMM yyyy", { locale: ru })}
+                                      {freeze.reason && " — " + freeze.reason}
+                                    </span>
+                                    <button
+                                      type="button"
+                                      onClick={() => deleteFreezeMutation.mutate(freeze.id)}
+                                      className="p-1 text-gray-400 dark:text-gray-500 hover:text-red-500"
+                                    >
+                                      <Trash2 size={14} />
+                                    </button>
+                                  </div>
+                                ))}
+                              </div>
+                            </details>
+                          )}
+                        </motion.div>
+                      )}
+                    </AnimatePresence>
+                  </div>
+ 
+                  <div>
+                    <label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
+                      Иконка
+                    </label>
+                    <div className="flex flex-wrap gap-2">
+                      {popularIcons.map((ic) => (
+                        <button
+                          key={ic}
+                          type="button"
+                          onClick={() => setIcon(ic)}
+                          className={clsx(
+                            "w-10 h-10 rounded-xl flex items-center justify-center text-xl transition-all",
+                            icon === ic
+                              ? "bg-primary-100 ring-2 ring-primary-500"
+                              : "bg-gray-100 dark:bg-gray-800 hover:bg-gray-200"
+                          )}
+                        >
+                          {ic}
+                        </button>
+                      ))}
+                    </div>
+                    
+                    <button
+                      type="button"
+                      onClick={() => setShowAllIcons(!showAllIcons)}
+                      className="mt-2 flex items-center gap-1 text-sm text-primary-600 hover:text-primary-700"
+                    >
+                      {showAllIcons ? <ChevronUp size={16} /> : <ChevronDown size={16} />}
+                      {showAllIcons ? "Скрыть" : "Все иконки"}
+                    </button>
+ 
+                    <AnimatePresence>
+                      {showAllIcons && (
+                        <motion.div
+                          initial={{ opacity: 0, height: 0 }}
+                          animate={{ opacity: 1, height: "auto" }}
+                          exit={{ opacity: 0, height: 0 }}
+                          className="mt-3 space-y-3"
+                        >
+                          {ICON_CATEGORIES.map((category) => (
+                            <div key={category.name}>
+                              <p className="text-xs text-gray-500 dark:text-gray-400 dark:text-gray-500 mb-1.5">{category.name}</p>
+                              <div className="flex flex-wrap gap-1.5">
+                                {category.icons.map((ic) => (
+                                  <button
+                                    key={ic}
+                                    type="button"
+                                    onClick={() => setIcon(ic)}
+                                    className={clsx(
+                                      "w-9 h-9 rounded-lg flex items-center justify-center text-lg transition-all",
+                                      icon === ic
+                                        ? "bg-primary-100 ring-2 ring-primary-500"
+                                        : "bg-gray-100 dark:bg-gray-800 hover:bg-gray-200"
+                                    )}
+                                  >
+                                    {ic}
+                                  </button>
+                                ))}
+                              </div>
+                            </div>
+                          ))}
+                        </motion.div>
+                      )}
+                    </AnimatePresence>
+                  </div>
+ 
+                  <div>
+                    <label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
+                      Цвет
+                    </label>
+                    <div className="flex flex-wrap gap-2">
+                      {COLORS.map((c) => (
+                        <button
+                          key={c}
+                          type="button"
+                          onClick={() => setColor(c)}
+                          className={clsx(
+                            "w-8 h-8 rounded-full transition-all",
+                            color === c ? "ring-2 ring-offset-2 ring-gray-400 scale-110" : ""
+                          )}
+                          style={{ backgroundColor: c }}
+                        />
+                      ))}
+                    </div>
+                  </div>
+ 
+                  <div className="pt-2 space-y-3">
+                    <button
+                      type="submit"
+                      disabled={updateMutation.isPending}
+                      className="btn btn-primary w-full"
+                    >
+                      {updateMutation.isPending ? "Сохраняем..." : "Сохранить изменения"}
+                    </button>
+                    
+                    <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"
+                    >
+                      <Trash2 size={18} />
+                      Удалить привычку
+                    </button>
+                  </div>
+                </form>
+              )}
+            </div>
+          </motion.div>
+        </>
+      )}
+    </AnimatePresence>
+  )
+}
+ 
+ +
+
+ + + + + + + + \ No newline at end of file diff --git a/coverage/lcov-report/components/EditTaskModal.jsx.html b/coverage/lcov-report/components/EditTaskModal.jsx.html new file mode 100644 index 0000000..9b1c6b3 --- /dev/null +++ b/coverage/lcov-report/components/EditTaskModal.jsx.html @@ -0,0 +1,1594 @@ + + + + + + Code coverage report for components/EditTaskModal.jsx + + + + + + + + + +
+
+

All files / components EditTaskModal.jsx

+
+ +
+ 68.88% + Statements + 62/90 +
+ + +
+ 57.44% + Branches + 54/94 +
+ + +
+ 37.14% + Functions + 13/35 +
+ + +
+ 69.66% + Lines + 62/89 +
+ + +
+

+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +

+ +
+
+

+
1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 +16 +17 +18 +19 +20 +21 +22 +23 +24 +25 +26 +27 +28 +29 +30 +31 +32 +33 +34 +35 +36 +37 +38 +39 +40 +41 +42 +43 +44 +45 +46 +47 +48 +49 +50 +51 +52 +53 +54 +55 +56 +57 +58 +59 +60 +61 +62 +63 +64 +65 +66 +67 +68 +69 +70 +71 +72 +73 +74 +75 +76 +77 +78 +79 +80 +81 +82 +83 +84 +85 +86 +87 +88 +89 +90 +91 +92 +93 +94 +95 +96 +97 +98 +99 +100 +101 +102 +103 +104 +105 +106 +107 +108 +109 +110 +111 +112 +113 +114 +115 +116 +117 +118 +119 +120 +121 +122 +123 +124 +125 +126 +127 +128 +129 +130 +131 +132 +133 +134 +135 +136 +137 +138 +139 +140 +141 +142 +143 +144 +145 +146 +147 +148 +149 +150 +151 +152 +153 +154 +155 +156 +157 +158 +159 +160 +161 +162 +163 +164 +165 +166 +167 +168 +169 +170 +171 +172 +173 +174 +175 +176 +177 +178 +179 +180 +181 +182 +183 +184 +185 +186 +187 +188 +189 +190 +191 +192 +193 +194 +195 +196 +197 +198 +199 +200 +201 +202 +203 +204 +205 +206 +207 +208 +209 +210 +211 +212 +213 +214 +215 +216 +217 +218 +219 +220 +221 +222 +223 +224 +225 +226 +227 +228 +229 +230 +231 +232 +233 +234 +235 +236 +237 +238 +239 +240 +241 +242 +243 +244 +245 +246 +247 +248 +249 +250 +251 +252 +253 +254 +255 +256 +257 +258 +259 +260 +261 +262 +263 +264 +265 +266 +267 +268 +269 +270 +271 +272 +273 +274 +275 +276 +277 +278 +279 +280 +281 +282 +283 +284 +285 +286 +287 +288 +289 +290 +291 +292 +293 +294 +295 +296 +297 +298 +299 +300 +301 +302 +303 +304 +305 +306 +307 +308 +309 +310 +311 +312 +313 +314 +315 +316 +317 +318 +319 +320 +321 +322 +323 +324 +325 +326 +327 +328 +329 +330 +331 +332 +333 +334 +335 +336 +337 +338 +339 +340 +341 +342 +343 +344 +345 +346 +347 +348 +349 +350 +351 +352 +353 +354 +355 +356 +357 +358 +359 +360 +361 +362 +363 +364 +365 +366 +367 +368 +369 +370 +371 +372 +373 +374 +375 +376 +377 +378 +379 +380 +381 +382 +383 +384 +385 +386 +387 +388 +389 +390 +391 +392 +393 +394 +395 +396 +397 +398 +399 +400 +401 +402 +403 +404 +405 +406 +407 +408 +409 +410 +411 +412 +413 +414 +415 +416 +417 +418 +419 +420 +421 +422 +423 +424 +425 +426 +427 +428 +429 +430 +431 +432 +433 +434 +435 +436 +437 +438 +439 +440 +441 +442 +443 +444 +445 +446 +447 +448 +449 +450 +451 +452 +453 +454 +455 +456 +457 +458 +459 +460 +461 +462 +463 +464 +465 +466 +467 +468 +469 +470 +471 +472 +473 +474 +475 +476 +477 +478 +479 +480 +481 +482 +483 +484 +485 +486 +487 +488 +489 +490 +491 +492 +493 +494 +495 +496 +497 +498 +499 +500 +501 +502 +503 +504  +  +  +  +  +  +  +  +1x +  +  +  +  +1x +  +  +  +  +  +  +  +  +1x +  +  +  +  +  +  +1x +  +  +  +  +  +  +  +18x +18x +  +18x +18x +18x +18x +18x +18x +18x +18x +18x +18x +  +  +18x +18x +18x +18x +  +18x +  +18x +7x +6x +6x +6x +6x +6x +6x +6x +6x +6x +6x +6x +6x +6x +6x +  +  +  +18x +1x +  +1x +1x +1x +  +  +  +  +  +  +18x +1x +  +1x +1x +1x +  +  +  +  +  +  +18x +  +  +  +  +  +  +18x +1x +1x +  +  +  +  +1x +  +  +  +  +  +  +  +  +  +  +  +  +  +1x +  +  +18x +  +18x +  +18x +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +1x +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +68x +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +136x +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +170x +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +2x +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +1x +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  + 
import { useState, useEffect } from "react"
+import { motion, AnimatePresence } from "framer-motion"
+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"
+import { format, addDays } from "date-fns"
+ 
+const COLORS = [
+  "#6B7280", "#6366f1", "#8b5cf6", "#d946ef", "#ec4899",
+  "#f43f5e", "#f97316", "#eab308", "#22c55e", "#0ea5e9",
+]
+ 
+const ICON_CATEGORIES = [
+  { name: "Продуктивность", icons: ["📋", "📝", "✅", "📌", "🎯", "💡", "📅", "⏰"] },
+  { name: "Работа", icons: ["💼", "💻", "📧", "📞", "📊", "📈", "🖥️", "⌨️"] },
+  { name: "Дом", icons: ["🏠", "🧹", "🧺", "🍳", "🛒", "🔧", "🪴", "🛋️"] },
+  { name: "Финансы", icons: ["💰", "💳", "📊", "💵", "🏦", "🧾"] },
+  { name: "Здоровье", icons: ["💊", "🏃", "🧘", "💪", "🩺", "🦷"] },
+  { name: "Разное", icons: ["⭐", "🎁", "📦", "✈️", "🚗", "📷", "🎉"] },
+]
+ 
+const PRIORITIES = [
+  { 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")
+  
+  const [title, setTitle] = useState("")
+  const [description, setDescription] = useState("")
+  const [color, setColor] = useState(COLORS[0])
+  const [icon, setIcon] = useState("📋")
+  const [dueDate, setDueDate] = useState("")
+  const [priority, setPriority] = useState(0)
+  const [reminderTime, setReminderTime] = useState("")
+  const [error, setError] = useState("")
+  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(() => {
+    if (task && open) {
+      setTitle(task.title || "")
+      setDescription(task.description || "")
+      setColor(task.color || COLORS[0])
+      setIcon(task.icon || "📋")
+      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)
+    }
+  }, [task, open])
+ 
+  const updateMutation = useMutation({
+    mutationFn: (data) => tasksApi.update(task.id, data),
+    onSuccess: () => {
+      queryClient.invalidateQueries({ queryKey: ["tasks"] })
+      queryClient.invalidateQueries({ queryKey: ["tasks-today"] })
+      onClose()
+    },
+    onError: (err) => {
+      setError(err.response?.data?.error || "Ошибка сохранения")
+    },
+  })
+ 
+  const deleteMutation = useMutation({
+    mutationFn: () => tasksApi.delete(task.id),
+    onSuccess: () => {
+      queryClient.invalidateQueries({ queryKey: ["tasks"] })
+      queryClient.invalidateQueries({ queryKey: ["tasks-today"] })
+      onClose()
+    },
+    onError: (err) => {
+      setError(err.response?.data?.error || "Ошибка удаления")
+    },
+  })
+ 
+  const handleClose = () => {
+    setError("")
+    setShowDeleteConfirm(false)
+    setShowAllIcons(false)
+    onClose()
+  }
+ 
+  const handleSubmit = (e) => {
+    e.preventDefault()
+    Iif (!title.trim()) {
+      setError("Введи название задачи")
+      return
+    }
+    
+    const data = {
+      title,
+      description,
+      color,
+      icon,
+      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,
+    }
+    
+    updateMutation.mutate(data)
+  }
+ 
+  const popularIcons = ["📋", "📝", "✅", "🎯", "💼", "🏠", "💰", "📞"]
+ 
+  Iif (!task) return null
+ 
+  return (
+    <AnimatePresence>
+      {open && (
+        <>
+          <motion.div
+            initial={{ opacity: 0 }}
+            animate={{ opacity: 1 }}
+            exit={{ opacity: 0 }}
+            onClick={handleClose}
+            className="fixed inset-0 bg-black/50 backdrop-blur-sm z-40"
+          />
+          <motion.div
+            initial={{ opacity: 0, y: 100 }}
+            animate={{ opacity: 1, y: 0 }}
+            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 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 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>
+ 
+              <form onSubmit={handleSubmit} className="p-4 space-y-4">
+                {error && (
+                  <div className="p-3 rounded-xl bg-red-50 text-red-600 text-sm">
+                    {error}
+                  </div>
+                )}
+ 
+                <div>
+                  <label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1.5">
+                    Название
+                  </label>
+                  <input
+                    type="text"
+                    value={title}
+                    onChange={(e) => setTitle(e.target.value)}
+                    className="input"
+                    placeholder="Что нужно сделать?"
+                  />
+                </div>
+ 
+                <div>
+                  <label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1.5">
+                    Описание (опционально)
+                  </label>
+                  <textarea
+                    value={description}
+                    onChange={(e) => setDescription(e.target.value)}
+                    className="input min-h-[80px] resize-none"
+                    placeholder="Подробности задачи..."
+                  />
+                </div>
+ 
+                <div>
+                  <label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
+                    Срок выполнения
+                  </label>
+                  <div className="flex gap-2 mb-2">
+                    <button
+                      type="button"
+                      onClick={() => setDueDate(today)}
+                      className={clsx(
+                        "px-3 py-1.5 rounded-lg text-sm font-medium transition-all",
+                        dueDate === today
+                          ? "bg-primary-500 text-white"
+                          : "bg-gray-100 dark:bg-gray-800 text-gray-600 hover:bg-gray-200"
+                      )}
+                    >
+                      Сегодня
+                    </button>
+                    <button
+                      type="button"
+                      onClick={() => setDueDate(tomorrow)}
+                      className={clsx(
+                        "px-3 py-1.5 rounded-lg text-sm font-medium transition-all",
+                        dueDate === tomorrow
+                          ? "bg-primary-500 text-white"
+                          : "bg-gray-100 dark:bg-gray-800 text-gray-600 hover:bg-gray-200"
+                      )}
+                    >
+                      Завтра
+                    </button>
+                    <button
+                      type="button"
+                      onClick={() => setDueDate("")}
+                      className={clsx(
+                        "px-3 py-1.5 rounded-lg text-sm font-medium transition-all",
+                        !dueDate
+                          ? "bg-primary-500 text-white"
+                          : "bg-gray-100 dark:bg-gray-800 text-gray-600 hover:bg-gray-200"
+                      )}
+                    >
+                      Без срока
+                    </button>
+                  </div>
+                  <div className="relative">
+                    <Calendar className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-400 dark:text-gray-500" size={18} />
+                    <input
+                      type="date"
+                      value={dueDate}
+                      onChange={(e) => setDueDate(e.target.value)}
+                      className="input pl-10"
+                    />
+                  </div>
+                </div>
+ 
+                {/* Recurring Section */}
+                <div className="border-t border-gray-100 dark:border-gray-800 pt-4">
+                  <div className="flex items-center justify-between mb-3">
+                    <div className="flex items-center gap-2">
+                      <Repeat size={18} className={isRecurring ? "text-primary-500" : "text-gray-400 dark:text-gray-500"} />
+                      <label className="text-sm font-medium text-gray-700 dark:text-gray-300">Повторять</label>
+                    </div>
+                    <button
+                      type="button"
+                      onClick={() => setIsRecurring(!isRecurring)}
+                      className={clsx(
+                        "w-12 h-6 rounded-full transition-all relative",
+                        isRecurring ? "bg-primary-500" : "bg-gray-200"
+                      )}
+                    >
+                      <div className={clsx(
+                        "absolute top-1 w-4 h-4 rounded-full bg-white dark:bg-gray-900 shadow-sm transition-all",
+                        isRecurring ? "right-1" : "left-1"
+                      )} />
+                    </button>
+                  </div>
+                  
+                  <AnimatePresence>
+                    {isRecurring && (
+                      <motion.div
+                        initial={{ opacity: 0, height: 0 }}
+                        animate={{ opacity: 1, height: "auto" }}
+                        exit={{ opacity: 0, height: 0 }}
+                        className="space-y-3"
+                      >
+                        <div className="flex flex-wrap gap-2">
+                          {RECURRENCE_TYPES.map((type) => (
+                            <button
+                              key={type.value}
+                              type="button"
+                              onClick={() => setRecurrenceType(type.value)}
+                              className={clsx(
+                                "px-3 py-1.5 rounded-lg text-sm font-medium transition-all",
+                                recurrenceType === type.value
+                                  ? "bg-primary-500 text-white"
+                                  : "bg-gray-100 dark:bg-gray-800 text-gray-600 hover:bg-gray-200"
+                              )}
+                            >
+                              {type.label}
+                            </button>
+                          ))}
+                        </div>
+                        
+                        {recurrenceType === "custom" && (
+                          <div className="flex items-center gap-2">
+                            <span className="text-sm text-gray-600">Каждые</span>
+                            <input
+                              type="number"
+                              min="1"
+                              max="365"
+                              value={recurrenceInterval}
+                              onChange={(e) => setRecurrenceInterval(parseInt(e.target.value) || 1)}
+                              className="input w-20 text-center"
+                            />
+                            <span className="text-sm text-gray-600">дней</span>
+                          </div>
+                        )}
+                        
+                        <div>
+                          <label className="block text-xs text-gray-500 dark:text-gray-400 dark:text-gray-500 mb-1">
+                            Повторять до (опционально)
+                          </label>
+                          <input
+                            type="date"
+                            value={recurrenceEndDate}
+                            onChange={(e) => setRecurrenceEndDate(e.target.value)}
+                            className="input"
+                            min={dueDate || today}
+                          />
+                        </div>
+                      </motion.div>
+                    )}
+                  </AnimatePresence>
+                </div>
+ 
+                <div>
+                  <label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
+                    Напоминание (опционально)
+                  </label>
+                  <div className="relative">
+                    <Clock className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-400 dark:text-gray-500" size={18} />
+                    <input
+                      type="time"
+                      value={reminderTime}
+                      onChange={(e) => setReminderTime(e.target.value)}
+                      className="input pl-10"
+                    />
+                  </div>
+                  <p className="text-xs text-gray-500 dark:text-gray-400 dark:text-gray-500 mt-1">
+                    Получишь напоминание в Telegram в указанное время
+                  </p>
+                </div>
+ 
+                <div>
+                  <label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
+                    Приоритет
+                  </label>
+                  <div className="flex gap-2 flex-wrap">
+                    {PRIORITIES.map((p) => (
+                      <button
+                        key={p.value}
+                        type="button"
+                        onClick={() => setPriority(p.value)}
+                        className={clsx(
+                          "px-3 py-1.5 rounded-lg text-sm font-medium transition-all",
+                          priority === p.value
+                            ? p.color + " ring-2 ring-offset-1 ring-gray-400"
+                            : "bg-gray-100 dark:bg-gray-800 text-gray-600 hover:bg-gray-200"
+                        )}
+                      >
+                        {p.label}
+                      </button>
+                    ))}
+                  </div>
+                </div>
+ 
+                <div>
+                  <label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
+                    Иконка
+                  </label>
+                  <div className="flex flex-wrap gap-2">
+                    {popularIcons.map((ic) => (
+                      <button
+                        key={ic}
+                        type="button"
+                        onClick={() => setIcon(ic)}
+                        className={clsx(
+                          "w-10 h-10 rounded-xl flex items-center justify-center text-xl transition-all",
+                          icon === ic
+                            ? "bg-primary-100 ring-2 ring-primary-500"
+                            : "bg-gray-100 dark:bg-gray-800 hover:bg-gray-200"
+                        )}
+                      >
+                        {ic}
+                      </button>
+                    ))}
+                  </div>
+                  
+                  <button
+                    type="button"
+                    onClick={() => setShowAllIcons(!showAllIcons)}
+                    className="mt-2 flex items-center gap-1 text-sm text-primary-600 hover:text-primary-700"
+                  >
+                    {showAllIcons ? <ChevronUp size={16} /> : <ChevronDown size={16} />}
+                    {showAllIcons ? "Скрыть" : "Все иконки"}
+                  </button>
+ 
+                  <AnimatePresence>
+                    {showAllIcons && (
+                      <motion.div
+                        initial={{ opacity: 0, height: 0 }}
+                        animate={{ opacity: 1, height: "auto" }}
+                        exit={{ opacity: 0, height: 0 }}
+                        className="mt-3 space-y-3"
+                      >
+                        {ICON_CATEGORIES.map((category) => (
+                          <div key={category.name}>
+                            <p className="text-xs text-gray-500 dark:text-gray-400 dark:text-gray-500 mb-1.5">{category.name}</p>
+                            <div className="flex flex-wrap gap-1.5">
+                              {category.icons.map((ic) => (
+                                <button
+                                  key={ic}
+                                  type="button"
+                                  onClick={() => setIcon(ic)}
+                                  className={clsx(
+                                    "w-9 h-9 rounded-lg flex items-center justify-center text-lg transition-all",
+                                    icon === ic
+                                      ? "bg-primary-100 ring-2 ring-primary-500"
+                                      : "bg-gray-100 dark:bg-gray-800 hover:bg-gray-200"
+                                  )}
+                                >
+                                  {ic}
+                                </button>
+                              ))}
+                            </div>
+                          </div>
+                        ))}
+                      </motion.div>
+                    )}
+                  </AnimatePresence>
+                </div>
+ 
+                <div>
+                  <label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
+                    Цвет
+                  </label>
+                  <div className="flex flex-wrap gap-2">
+                    {COLORS.map((c) => (
+                      <button
+                        key={c}
+                        type="button"
+                        onClick={() => setColor(c)}
+                        className={clsx(
+                          "w-8 h-8 rounded-full transition-all",
+                          color === c ? "ring-2 ring-offset-2 ring-gray-400 scale-110" : ""
+                        )}
+                        style={{ backgroundColor: c }}
+                      />
+                    ))}
+                  </div>
+                </div>
+ 
+                <div className="pt-2 space-y-3">
+                  <button
+                    type="submit"
+                    disabled={updateMutation.isPending}
+                    className="btn btn-primary w-full"
+                  >
+                    {updateMutation.isPending ? "Сохраняем..." : "Сохранить"}
+                  </button>
+                  
+                  {!showDeleteConfirm ? (
+                    <button
+                      type="button"
+                      onClick={() => setShowDeleteConfirm(true)}
+                      className="btn w-full 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>
+        </>
+      )}
+    </AnimatePresence>
+  )
+}
+ 
+ +
+
+ + + + + + + + \ No newline at end of file diff --git a/coverage/lcov-report/components/LogHabitModal.jsx.html b/coverage/lcov-report/components/LogHabitModal.jsx.html new file mode 100644 index 0000000..bca04c2 --- /dev/null +++ b/coverage/lcov-report/components/LogHabitModal.jsx.html @@ -0,0 +1,712 @@ + + + + + + Code coverage report for components/LogHabitModal.jsx + + + + + + + + + +
+
+

All files / components LogHabitModal.jsx

+
+ +
+ 58.33% + Statements + 28/48 +
+ + +
+ 56.52% + Branches + 26/46 +
+ + +
+ 50% + Functions + 8/16 +
+ + +
+ 64.28% + Lines + 27/42 +
+ + +
+

+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +

+ +
+
+

+
1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 +16 +17 +18 +19 +20 +21 +22 +23 +24 +25 +26 +27 +28 +29 +30 +31 +32 +33 +34 +35 +36 +37 +38 +39 +40 +41 +42 +43 +44 +45 +46 +47 +48 +49 +50 +51 +52 +53 +54 +55 +56 +57 +58 +59 +60 +61 +62 +63 +64 +65 +66 +67 +68 +69 +70 +71 +72 +73 +74 +75 +76 +77 +78 +79 +80 +81 +82 +83 +84 +85 +86 +87 +88 +89 +90 +91 +92 +93 +94 +95 +96 +97 +98 +99 +100 +101 +102 +103 +104 +105 +106 +107 +108 +109 +110 +111 +112 +113 +114 +115 +116 +117 +118 +119 +120 +121 +122 +123 +124 +125 +126 +127 +128 +129 +130 +131 +132 +133 +134 +135 +136 +137 +138 +139 +140 +141 +142 +143 +144 +145 +146 +147 +148 +149 +150 +151 +152 +153 +154 +155 +156 +157 +158 +159 +160 +161 +162 +163 +164 +165 +166 +167 +168 +169 +170 +171 +172 +173 +174 +175 +176 +177 +178 +179 +180 +181 +182 +183 +184 +185 +186 +187 +188 +189 +190 +191 +192 +193 +194 +195 +196 +197 +198 +199 +200 +201 +202 +203 +204 +205 +206 +207 +208 +209 +210  +  +  +  +  +  +  +  +6x +6x +6x +  +6x +6x +6x +6x +  +  +  +6x +6x +6x +  +  +  +6x +  +  +6x +155x +  +  +6x +  +  +  +  +  +6x +  +  +  +  +  +  +  +  +  +  +  +  +  +6x +6x +  +6x +  +5x +  +  +  +  +  +  +  +  +  +  +  +  +1x +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +35x +  +  +  +  +  +  +  +  +  +30x +  +  +  +  +155x +155x +155x +155x +  +155x +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  + 
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>
+  )
+}
+ 
+ +
+
+ + + + + + + + \ No newline at end of file diff --git a/coverage/lcov-report/components/Navigation.jsx.html b/coverage/lcov-report/components/Navigation.jsx.html new file mode 100644 index 0000000..a5ae8d4 --- /dev/null +++ b/coverage/lcov-report/components/Navigation.jsx.html @@ -0,0 +1,220 @@ + + + + + + Code coverage report for components/Navigation.jsx + + + + + + + + + +
+
+

All files / components Navigation.jsx

+
+ +
+ 100% + Statements + 8/8 +
+ + +
+ 100% + Branches + 2/2 +
+ + +
+ 100% + Functions + 4/4 +
+ + +
+ 100% + Lines + 7/7 +
+ + +
+

+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +

+ +
+
+

+
1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 +16 +17 +18 +19 +20 +21 +22 +23 +24 +25 +26 +27 +28 +29 +30 +31 +32 +33 +34 +35 +36 +37 +38 +39 +40 +41 +42 +43 +44 +45 +46  +  +  +  +  +1x +  +  +5x +5x +  +5x +  +  +  +  +  +  +5x +  +  +  +  +20x +  +  +  +20x +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  + 
import { NavLink } from "react-router-dom"
+import { Home, BarChart3,  PiggyBank, Settings } from "lucide-react"
+import { useAuthStore } from "../store/auth"
+import clsx from "clsx"
+ 
+const OWNER_ID = 1
+ 
+export default function Navigation() {
+  const user = useAuthStore((s) => s.user)
+  const isOwner = user?.id === OWNER_ID
+ 
+  const navItems = [
+    { to: "/", icon: Home, label: "Главная" },
+    { to: "/tracker", icon: BarChart3, label: "Трекер" },
+    { to: "/savings", icon: PiggyBank, label: "Накопления" },
+    { to: "/settings", icon: Settings, label: "Настройки" },
+  ].filter(Boolean)
+ 
+  return (
+    <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
+              key={to}
+              to={to}
+              end={to === "/"}
+              className={({ isActive }) =>
+                clsx(
+                  "flex flex-col items-center gap-0.5 px-2 py-2 rounded-xl transition-all",
+                  isActive
+                    ? "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-[10px] font-medium">{label}</span>
+            </NavLink>
+          ))}
+        </div>
+      </div>
+    </nav>
+  )
+}
+ 
+ +
+
+ + + + + + + + \ No newline at end of file diff --git a/coverage/lcov-report/components/finance/FinanceDashboard.jsx.html b/coverage/lcov-report/components/finance/FinanceDashboard.jsx.html new file mode 100644 index 0000000..fa1fc6b --- /dev/null +++ b/coverage/lcov-report/components/finance/FinanceDashboard.jsx.html @@ -0,0 +1,691 @@ + + + + + + Code coverage report for components/finance/FinanceDashboard.jsx + + + + + + + + + +
+
+

All files / components/finance FinanceDashboard.jsx

+
+ +
+ 88.88% + Statements + 24/27 +
+ + +
+ 83.33% + Branches + 15/18 +
+ + +
+ 78.57% + Functions + 11/14 +
+ + +
+ 86.95% + Lines + 20/23 +
+ + +
+

+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +

+ +
+
+

+
1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 +16 +17 +18 +19 +20 +21 +22 +23 +24 +25 +26 +27 +28 +29 +30 +31 +32 +33 +34 +35 +36 +37 +38 +39 +40 +41 +42 +43 +44 +45 +46 +47 +48 +49 +50 +51 +52 +53 +54 +55 +56 +57 +58 +59 +60 +61 +62 +63 +64 +65 +66 +67 +68 +69 +70 +71 +72 +73 +74 +75 +76 +77 +78 +79 +80 +81 +82 +83 +84 +85 +86 +87 +88 +89 +90 +91 +92 +93 +94 +95 +96 +97 +98 +99 +100 +101 +102 +103 +104 +105 +106 +107 +108 +109 +110 +111 +112 +113 +114 +115 +116 +117 +118 +119 +120 +121 +122 +123 +124 +125 +126 +127 +128 +129 +130 +131 +132 +133 +134 +135 +136 +137 +138 +139 +140 +141 +142 +143 +144 +145 +146 +147 +148 +149 +150 +151 +152 +153 +154 +155 +156 +157 +158 +159 +160 +161 +162 +163 +164 +165 +166 +167 +168 +169 +170 +171 +172 +173 +174 +175 +176 +177 +178 +179 +180 +181 +182 +183 +184 +185 +186 +187 +188 +189 +190 +191 +192 +193 +194 +195 +196 +197 +198 +199 +200 +201 +202 +203  +  +  +  +  +  +1x +  +  +  +  +  +4x +  +  +5x +5x +  +5x +3x +3x +  +  +  +2x +  +  +5x +3x +  +  +9x +  +  +  +  +  +  +  +2x +1x +  +  +  +  +  +  +  +  +  +  +  +  +1x +1x +  +  +  +1x +  +  +  +  +1x +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +1x +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +1x +  +  +  +  +  +  +  +  +1x +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  + 
import { useState, useEffect } from "react"
+import {
+  PieChart, Pie, Cell, LineChart, Line, XAxis, YAxis, Tooltip, ResponsiveContainer,
+} from "recharts"
+import { financeApi } from "../../api/finance"
+ 
+const COLORS = [
+  "#0D4F4F", "#F7B538", "#6366f1", "#22c55e", "#ef4444",
+  "#8b5cf6", "#0ea5e9", "#f97316", "#ec4899", "#14b8a6",
+  "#64748b", "#a855f7", "#78716c",
+]
+ 
+const fmt = (n) => Number(n).toLocaleString("ru-RU") + " ₽"
+ 
+export default function FinanceDashboard({ month, year }) {
+  const [summary, setSummary] = useState(null)
+  const [loading, setLoading] = useState(true)
+ 
+  useEffect(() => {
+    setLoading(true)
+    financeApi
+      .getSummary({ month, year })
+      .then(setSummary)
+      .catch(console.error)
+      .finally(() => setLoading(false))
+  }, [month, year])
+ 
+  if (loading) {
+    return (
+      <div className="space-y-4">
+        {[1, 2, 3].map((i) => (
+          <div key={i} className="card p-6 animate-pulse">
+            <div className="h-8 bg-gray-200 dark:bg-gray-800 rounded w-1/2" />
+          </div>
+        ))}
+      </div>
+    )
+  }
+ 
+  if (!summary || (summary.total_income === 0 && summary.total_expense === 0 && (summary.carried_over || 0) === 0)) {
+    return (
+      <div className="card p-12 text-center">
+        <span className="text-5xl block mb-4">📊</span>
+        <h3 className="text-lg font-bold text-gray-900 dark:text-white mb-2">
+          Нет данных
+        </h3>
+        <p className="text-gray-500 dark:text-gray-400">
+          Добавьте первую транзакцию
+        </p>
+      </div>
+    )
+  }
+ 
+  const expenseCategories = summary.by_category.filter((c) => c.type === "expense")
+  const pieData = expenseCategories.map((c) => ({
+    name: c.category_emoji + " " + c.category_name,
+    value: c.amount,
+  }))
+  const dailyData = summary.daily.map((d) => ({
+    day: d.date.slice(8, 10),
+    amount: d.amount,
+  }))
+ 
+  return (
+    <div className="space-y-6">
+      <div className="card p-6 bg-gradient-to-br from-primary-950 to-primary-800 text-white">
+        {summary.carried_over !== 0 && (
+          <p className="text-xs opacity-60 mb-1">
+            Остаток с прошлого месяца: <span className={summary.carried_over > 0 ? "text-green-300" : "text-red-300"}>{fmt(summary.carried_over)}</span>
+          </p>
+        )}
+        <p className="text-sm opacity-70">Баланс</p>
+        <p className="text-3xl font-bold mt-1">{fmt(summary.balance)}</p>
+        <div className="flex gap-6 mt-4">
+          <div>
+            <p className="text-xs opacity-60">Доходы</p>
+            <p className="text-lg font-semibold text-green-300">
+              +{fmt(summary.total_income)}
+            </p>
+          </div>
+          <div>
+            <p className="text-xs opacity-60">Расходы</p>
+            <p className="text-lg font-semibold text-red-300">
+              -{fmt(summary.total_expense)}
+            </p>
+          </div>
+        </div>
+      </div>
+ 
+      {expenseCategories.length > 0 && (
+        <div className="card p-5">
+          <h3 className="font-display font-bold text-gray-900 dark:text-white mb-4">
+            Топ расходов
+          </h3>
+          <div className="space-y-3">
+            {expenseCategories.slice(0, 5).map((c, i) => (
+              <div key={i} className="flex items-center gap-3">
+                <span className="text-xl w-8 text-center">
+                  {c.category_emoji}
+                </span>
+                <div className="flex-1">
+                  <div className="flex justify-between mb-1">
+                    <span className="text-sm font-medium text-gray-700 dark:text-gray-300">
+                      {c.category_name}
+                    </span>
+                    <span className="text-sm font-semibold text-gray-900 dark:text-white">
+                      {fmt(c.amount)}
+                    </span>
+                  </div>
+                  <div className="h-2 bg-gray-100 dark:bg-gray-800 rounded-full overflow-hidden">
+                    <div
+                      className="h-full rounded-full"
+                      style={{
+                        width: Math.round(c.percentage) + "%",
+                        backgroundColor: COLORS[i % COLORS.length],
+                      }}
+                    />
+                  </div>
+                </div>
+              </div>
+            ))}
+          </div>
+        </div>
+      )}
+ 
+      {pieData.length > 0 && (
+        <div className="card p-5">
+          <h3 className="font-display font-bold text-gray-900 dark:text-white mb-4">
+            По категориям
+          </h3>
+          <div className="flex items-center gap-4">
+            <div className="w-40 h-40">
+              <ResponsiveContainer>
+                <PieChart>
+                  <Pie
+                    data={pieData}
+                    innerRadius={40}
+                    outerRadius={70}
+                    dataKey="value"
+                    stroke="none"
+                  >
+                    {pieData.map((_, i) => (
+                      <Cell key={i} fill={COLORS[i % COLORS.length]} />
+                    ))}
+                  </Pie>
+                  <Tooltip formatter={(v) => fmt(v)} />
+                </PieChart>
+              </ResponsiveContainer>
+            </div>
+            <div className="flex-1 space-y-1">
+              {pieData.slice(0, 6).map((c, i) => (
+                <div key={i} className="flex items-center gap-2 text-xs">
+                  <div
+                    className="w-2.5 h-2.5 rounded-full"
+                    style={{ backgroundColor: COLORS[i] }}
+                  />
+                  <span className="text-gray-600 dark:text-gray-400 truncate">
+                    {c.name}
+                  </span>
+                  <span className="ml-auto font-medium text-gray-900 dark:text-white">
+                    {Math.round(
+                      (c.value / summary.total_expense) * 100
+                    )}
+                    %
+                  </span>
+                </div>
+              ))}
+            </div>
+          </div>
+        </div>
+      )}
+ 
+      {dailyData.length > 0 && (
+        <div className="card p-5">
+          <h3 className="font-display font-bold text-gray-900 dark:text-white mb-4">
+            Расходы по дням
+          </h3>
+          <div className="h-48">
+            <ResponsiveContainer>
+              <LineChart data={dailyData}>
+                <XAxis dataKey="day" tick={{ fontSize: 12 }} stroke="#94a3b8" />
+                <YAxis
+                  tick={{ fontSize: 10 }}
+                  stroke="#94a3b8"
+                  tickFormatter={(v) => v / 1000 + "к"}
+                />
+                <Tooltip formatter={(v) => fmt(v)} />
+                <Line
+                  type="monotone"
+                  dataKey="amount"
+                  stroke="#0D4F4F"
+                  strokeWidth={2}
+                  dot={{ r: 4, fill: "#0D4F4F" }}
+                />
+              </LineChart>
+            </ResponsiveContainer>
+          </div>
+        </div>
+      )}
+    </div>
+  )
+}
+ 
+ +
+
+ + + + + + + + \ No newline at end of file diff --git a/coverage/lcov-report/components/finance/TransactionList.jsx.html b/coverage/lcov-report/components/finance/TransactionList.jsx.html new file mode 100644 index 0000000..8b9ae78 --- /dev/null +++ b/coverage/lcov-report/components/finance/TransactionList.jsx.html @@ -0,0 +1,586 @@ + + + + + + Code coverage report for components/finance/TransactionList.jsx + + + + + + + + + +
+
+

All files / components/finance TransactionList.jsx

+
+ +
+ 71.42% + Statements + 35/49 +
+ + +
+ 61.11% + Branches + 22/36 +
+ + +
+ 61.9% + Functions + 13/21 +
+ + +
+ 79.06% + Lines + 34/43 +
+ + +
+

+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +

+ +
+
+

+
1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 +16 +17 +18 +19 +20 +21 +22 +23 +24 +25 +26 +27 +28 +29 +30 +31 +32 +33 +34 +35 +36 +37 +38 +39 +40 +41 +42 +43 +44 +45 +46 +47 +48 +49 +50 +51 +52 +53 +54 +55 +56 +57 +58 +59 +60 +61 +62 +63 +64 +65 +66 +67 +68 +69 +70 +71 +72 +73 +74 +75 +76 +77 +78 +79 +80 +81 +82 +83 +84 +85 +86 +87 +88 +89 +90 +91 +92 +93 +94 +95 +96 +97 +98 +99 +100 +101 +102 +103 +104 +105 +106 +107 +108 +109 +110 +111 +112 +113 +114 +115 +116 +117 +118 +119 +120 +121 +122 +123 +124 +125 +126 +127 +128 +129 +130 +131 +132 +133 +134 +135 +136 +137 +138 +139 +140 +141 +142 +143 +144 +145 +146 +147 +148 +149 +150 +151 +152 +153 +154 +155 +156 +157 +158 +159 +160 +161 +162 +163 +164 +165 +166 +167 +168  +  +  +6x +  +1x +6x +6x +  +  +  +7x +7x +7x +7x +7x +7x +  +7x +4x +4x +  +  +  +  +  +  +  +  +3x +3x +  +  +3x +  +  +7x +6x +6x +6x +  +6x +  +  +7x +6x +6x +6x +  +  +7x +  +  +  +  +  +7x +4x +  +  +16x +  +  +  +  +  +  +  +3x +  +  +  +  +  +  +  +  +  +  +  +  +  +  +9x +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +3x +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +6x +  +  +  +  +  +6x +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  + 
import { useState, useEffect } from "react"
+import { financeApi } from "../../api/finance"
+ 
+const fmt = (n) => Number(n).toLocaleString("ru-RU") + " ₽"
+ 
+const formatDate = (d) => {
+  const dt = new Date(d)
+  return dt.toLocaleDateString("ru-RU", { day: "numeric", month: "long" })
+}
+ 
+export default function TransactionList({ onAdd, month, year }) {
+  const [transactions, setTransactions] = useState([])
+  const [categories, setCategories] = useState([])
+  const [loading, setLoading] = useState(true)
+  const [filter, setFilter] = useState("all")
+  const [catFilter, setCatFilter] = useState(null)
+  const [search, setSearch] = useState("")
+ 
+  useEffect(() => {
+    setLoading(true)
+    Promise.all([
+      financeApi.listCategories(),
+      financeApi.listTransactions({
+        month,
+        year,
+        limit: 100,
+      }),
+    ])
+      .then(([cats, txs]) => {
+        setCategories(cats || [])
+        setTransactions(txs || [])
+      })
+      .catch(console.error)
+      .finally(() => setLoading(false))
+  }, [month, year])
+ 
+  const filtered = transactions.filter((t) => {
+    Iif (filter !== "all" && t.type !== filter) return false
+    Iif (catFilter && t.category_id !== catFilter) return false
+    Iif (search && !t.description.toLowerCase().includes(search.toLowerCase()))
+      return false
+    return true
+  })
+ 
+  const grouped = filtered.reduce((acc, t) => {
+    const d = t.date.slice(0, 10)
+    ;(acc[d] = acc[d] || []).push(t)
+    return acc
+  }, {})
+ 
+  const handleDelete = async (id) => {
+    if (!confirm("Удалить транзакцию?")) return
+    await financeApi.deleteTransaction(id)
+    setTransactions((txs) => txs.filter((t) => t.id !== id))
+  }
+ 
+  if (loading) {
+    return (
+      <div className="space-y-3">
+        {[1, 2, 3, 4].map((i) => (
+          <div key={i} className="card p-4 animate-pulse">
+            <div className="h-5 bg-gray-200 dark:bg-gray-800 rounded w-3/4" />
+          </div>
+        ))}
+      </div>
+    )
+  }
+ 
+  return (
+    <div className="space-y-4">
+      <input
+        className="w-full px-4 py-2.5 rounded-xl bg-gray-100 dark:bg-gray-800 text-sm text-gray-900 dark:text-white placeholder-gray-400 outline-none"
+        placeholder="Поиск по описанию..."
+        value={search}
+        onChange={(e) => setSearch(e.target.value)}
+      />
+ 
+      <div className="flex gap-2">
+        {[
+          ["all", "Все"],
+          ["income", "Доходы"],
+          ["expense", "Расходы"],
+        ].map(([k, l]) => (
+          <button
+            key={k}
+            onClick={() => setFilter(k)}
+            className={`px-3 py-1.5 rounded-lg text-sm font-medium transition ${
+              filter === k
+                ? "bg-primary-500 text-white"
+                : "bg-gray-100 dark:bg-gray-800 text-gray-600 dark:text-gray-400"
+            }`}
+          >
+            {l}
+          </button>
+        ))}
+      </div>
+ 
+      <div className="flex gap-2 overflow-x-auto pb-1">
+        <button
+          onClick={() => setCatFilter(null)}
+          className={`px-3 py-1 rounded-lg text-xs font-medium whitespace-nowrap transition ${
+            !catFilter
+              ? "bg-accent-500 text-white"
+              : "bg-gray-100 dark:bg-gray-800 text-gray-600 dark:text-gray-400"
+          }`}
+        >
+          Все
+        </button>
+        {categories.map((c) => (
+          <button
+            key={c.id}
+            onClick={() => setCatFilter(c.id)}
+            className={`px-3 py-1 rounded-lg text-xs font-medium whitespace-nowrap transition ${
+              catFilter === c.id
+                ? "bg-accent-500 text-white"
+                : "bg-gray-100 dark:bg-gray-800 text-gray-600 dark:text-gray-400"
+            }`}
+          >
+            {c.emoji} {c.name}
+          </button>
+        ))}
+      </div>
+ 
+      {Object.keys(grouped).length === 0 ? (
+        <div className="card p-12 text-center">
+          <span className="text-4xl block mb-3">🔍</span>
+          <p className="text-gray-500 dark:text-gray-400">Ничего не найдено</p>
+        </div>
+      ) : (
+        Object.entries(grouped).map(([date, txs]) => (
+          <div key={date}>
+            <p className="text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase mb-2">
+              {formatDate(date)}
+            </p>
+            <div className="card divide-y divide-gray-100 dark:divide-gray-800">
+              {txs.map((t) => (
+                <div
+                  key={t.id}
+                  className="px-4 py-3 flex items-center gap-3"
+                  onClick={() => handleDelete(t.id)}
+                >
+                  <span className="text-xl">{t.category_emoji}</span>
+                  <div className="flex-1 min-w-0">
+                    <p className="text-sm font-medium text-gray-900 dark:text-white truncate">
+                      {t.description || t.category_name}
+                    </p>
+                    <p className="text-xs text-gray-500 dark:text-gray-400">
+                      {t.category_emoji} {t.category_name}
+                    </p>
+                  </div>
+                  <span
+                    className={`text-sm font-bold ${
+                      t.type === "income" ? "text-green-500" : "text-red-500"
+                    }`}
+                  >
+                    {t.type === "income" ? "+" : "-"}
+                    {fmt(t.amount)}
+                  </span>
+                </div>
+              ))}
+            </div>
+          </div>
+        ))
+      )}
+    </div>
+  )
+}
+ 
+ +
+
+ + + + + + + + \ No newline at end of file diff --git a/coverage/lcov-report/components/finance/index.html b/coverage/lcov-report/components/finance/index.html new file mode 100644 index 0000000..c2313c4 --- /dev/null +++ b/coverage/lcov-report/components/finance/index.html @@ -0,0 +1,131 @@ + + + + + + Code coverage report for components/finance + + + + + + + + + +
+
+

All files components/finance

+
+ +
+ 77.63% + Statements + 59/76 +
+ + +
+ 68.51% + Branches + 37/54 +
+ + +
+ 68.57% + Functions + 24/35 +
+ + +
+ 81.81% + Lines + 54/66 +
+ + +
+

+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +

+ +
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
FileStatementsBranchesFunctionsLines
FinanceDashboard.jsx +
+
88.88%24/2783.33%15/1878.57%11/1486.95%20/23
TransactionList.jsx +
+
71.42%35/4961.11%22/3661.9%13/2179.06%34/43
+
+
+
+ + + + + + + + \ No newline at end of file diff --git a/coverage/lcov-report/components/index.html b/coverage/lcov-report/components/index.html new file mode 100644 index 0000000..697d16d --- /dev/null +++ b/coverage/lcov-report/components/index.html @@ -0,0 +1,191 @@ + + + + + + Code coverage report for components + + + + + + + + + +
+
+

All files components

+
+ +
+ 62.77% + Statements + 285/454 +
+ + +
+ 49.74% + Branches + 195/392 +
+ + +
+ 33.33% + Functions + 55/165 +
+ + +
+ 63.88% + Lines + 283/443 +
+ + +
+

+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +

+ +
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
FileStatementsBranchesFunctionsLines
CreateHabitModal.jsx +
+
65.82%52/7949.15%29/5928.57%8/2865.82%52/79
CreateTaskModal.jsx +
+
71.05%54/7654.09%33/6132.14%9/2871.05%54/76
EditHabitModal.jsx +
+
52.94%81/15339.23%51/13024.07%13/5454%81/150
EditTaskModal.jsx +
+
68.88%62/9057.44%54/9437.14%13/3569.66%62/89
LogHabitModal.jsx +
+
58.33%28/4856.52%26/4650%8/1664.28%27/42
Navigation.jsx +
+
100%8/8100%2/2100%4/4100%7/7
+
+
+
+ + + + + + + + \ No newline at end of file diff --git a/coverage/lcov-report/contexts/ThemeContext.jsx.html b/coverage/lcov-report/contexts/ThemeContext.jsx.html new file mode 100644 index 0000000..f4ccc73 --- /dev/null +++ b/coverage/lcov-report/contexts/ThemeContext.jsx.html @@ -0,0 +1,211 @@ + + + + + + Code coverage report for contexts/ThemeContext.jsx + + + + + + + + + +
+
+

All files / contexts ThemeContext.jsx

+
+ +
+ 94.73% + Statements + 18/19 +
+ + +
+ 90% + Branches + 9/10 +
+ + +
+ 100% + Functions + 6/6 +
+ + +
+ 94.44% + Lines + 17/18 +
+ + +
+

+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +

+ +
+
+

+
1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 +16 +17 +18 +19 +20 +21 +22 +23 +24 +25 +26 +27 +28 +29 +30 +31 +32 +33 +34 +35 +36 +37 +38 +39 +40 +41 +42 +43  +  +2x +  +  +20x +15x +15x +  +  +  +  +20x +20x +  +20x +12x +  +8x +  +  +20x +  +  +20x +5x +  +  +20x +  +  +  +  +  +  +  +46x +46x +4x +  +41x +  + 
import { createContext, useContext, useEffect, useState } from "react"
+ 
+const ThemeContext = createContext()
+ 
+export function ThemeProvider({ children }) {
+  const [theme, setTheme] = useState(() => {
+    Eif (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
+}
+ 
+ +
+
+ + + + + + + + \ No newline at end of file diff --git a/coverage/lcov-report/contexts/index.html b/coverage/lcov-report/contexts/index.html new file mode 100644 index 0000000..7cffa2c --- /dev/null +++ b/coverage/lcov-report/contexts/index.html @@ -0,0 +1,116 @@ + + + + + + Code coverage report for contexts + + + + + + + + + +
+
+

All files contexts

+
+ +
+ 94.73% + Statements + 18/19 +
+ + +
+ 90% + Branches + 9/10 +
+ + +
+ 100% + Functions + 6/6 +
+ + +
+ 94.44% + Lines + 17/18 +
+ + +
+

+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +

+ +
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
FileStatementsBranchesFunctionsLines
ThemeContext.jsx +
+
94.73%18/1990%9/10100%6/694.44%17/18
+
+
+
+ + + + + + + + \ No newline at end of file diff --git a/coverage/lcov-report/favicon.png b/coverage/lcov-report/favicon.png new file mode 100644 index 0000000000000000000000000000000000000000..c1525b811a167671e9de1fa78aab9f5c0b61cef7 GIT binary patch literal 445 zcmV;u0Yd(XP))rP{nL}Ln%S7`m{0DjX9TLF* zFCb$4Oi7vyLOydb!7n&^ItCzb-%BoB`=x@N2jll2Nj`kauio%aw_@fe&*}LqlFT43 z8doAAe))z_%=P%v^@JHp3Hjhj^6*Kr_h|g_Gr?ZAa&y>wxHE99Gk>A)2MplWz2xdG zy8VD2J|Uf#EAw*bo5O*PO_}X2Tob{%bUoO2G~T`@%S6qPyc}VkhV}UifBuRk>%5v( z)x7B{I~z*k<7dv#5tC+m{km(D087J4O%+<<;K|qwefb6@GSX45wCK}Sn*> + + + + Code coverage report for All files + + + + + + + + + +
+
+

All files

+
+ +
+ 56.61% + Statements + 929/1641 +
+ + +
+ 45.66% + Branches + 580/1270 +
+ + +
+ 42.06% + Functions + 257/611 +
+ + +
+ 59.7% + Lines + 877/1469 +
+ + +
+

+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +

+ +
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
FileStatementsBranchesFunctionsLines
api +
+
89.1%90/101100%12/1284.93%62/7390%72/80
components +
+
62.77%285/45449.74%195/39233.33%55/16563.88%283/443
components/finance +
+
77.63%59/7668.51%37/5468.57%24/3581.81%54/66
contexts +
+
94.73%18/1990%9/10100%6/694.44%17/18
pages +
+
46.79%452/96640.62%325/80032.11%105/32750.95%427/838
store +
+
100%25/25100%2/2100%5/5100%24/24
+
+
+
+ + + + + + + + \ No newline at end of file diff --git a/coverage/lcov-report/pages/Finance.jsx.html b/coverage/lcov-report/pages/Finance.jsx.html new file mode 100644 index 0000000..40e00f6 --- /dev/null +++ b/coverage/lcov-report/pages/Finance.jsx.html @@ -0,0 +1,451 @@ + + + + + + Code coverage report for pages/Finance.jsx + + + + + + + + + +
+
+

All files / pages Finance.jsx

+
+ +
+ 44.44% + Statements + 16/36 +
+ + +
+ 75% + Branches + 15/20 +
+ + +
+ 25% + Functions + 4/16 +
+ + +
+ 64% + Lines + 16/25 +
+ + +
+

+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +

+ +
+
+

+
1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 +16 +17 +18 +19 +20 +21 +22 +23 +24 +25 +26 +27 +28 +29 +30 +31 +32 +33 +34 +35 +36 +37 +38 +39 +40 +41 +42 +43 +44 +45 +46 +47 +48 +49 +50 +51 +52 +53 +54 +55 +56 +57 +58 +59 +60 +61 +62 +63 +64 +65 +66 +67 +68 +69 +70 +71 +72 +73 +74 +75 +76 +77 +78 +79 +80 +81 +82 +83 +84 +85 +86 +87 +88 +89 +90 +91 +92 +93 +94 +95 +96 +97 +98 +99 +100 +101 +102 +103 +104 +105 +106 +107 +108 +109 +110 +111 +112 +113 +114 +115 +116 +117 +118 +119 +120 +121 +122 +123  +  +  +  +  +  +  +  +1x +  +  +  +  +  +  +1x +  +  +  +  +  +14x +14x +14x +14x +14x +14x +  +14x +  +14x +  +  +  +14x +  +  +  +  +14x +  +14x +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +56x +  +4x +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +1x +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  + 
import { useState } from "react"
+import Navigation from "../components/Navigation"
+import FinanceDashboard from "../components/finance/FinanceDashboard"
+import TransactionList from "../components/finance/TransactionList"
+import FinanceAnalytics from "../components/finance/FinanceAnalytics"
+import CategoriesManager from "../components/finance/CategoriesManager"
+import AddTransactionModal from "../components/finance/AddTransactionModal"
+ 
+const tabs = [
+  { key: "dashboard", label: "Обзор", icon: "📊" },
+  { key: "transactions", label: "Транзакции", icon: "📋" },
+  { key: "analytics", label: "Аналитика", icon: "📈" },
+  { key: "categories", label: "Категории", icon: "🏷️" },
+]
+ 
+const MONTH_NAMES = [
+  "Январь", "Февраль", "Март", "Апрель", "Май", "Июнь",
+  "Июль", "Август", "Сентябрь", "Октябрь", "Ноябрь", "Декабрь",
+]
+ 
+export default function Finance() {
+  const now = new Date()
+  const [activeTab, setActiveTab] = useState("dashboard")
+  const [showAdd, setShowAdd] = useState(false)
+  const [refreshKey, setRefreshKey] = useState(0)
+  const [month, setMonth] = useState(now.getMonth() + 1)
+  const [year, setYear] = useState(now.getFullYear())
+ 
+  const refresh = () => setRefreshKey((k) => k + 1)
+ 
+  const prevMonth = () => {
+    if (month === 1) { setMonth(12); setYear(y => y - 1) }
+    else setMonth(m => m - 1)
+  }
+  const nextMonth = () => {
+    if (month === 12) { setMonth(1); setYear(y => y + 1) }
+    else setMonth(m => m + 1)
+  }
+ 
+  const isCurrentMonth = month === now.getMonth() + 1 && year === now.getFullYear()
+ 
+  return (
+    <div className="min-h-screen bg-surface-50 dark:bg-gray-950 gradient-mesh pb-24">
+      <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 dark:text-white">
+              💰 Финансы
+            </h1>
+          </div>
+          <button
+            onClick={() => setShowAdd(true)}
+            className="w-10 h-10 rounded-xl bg-primary-500 text-white flex items-center justify-center text-xl shadow-lg hover:bg-primary-600 transition"
+          >
+            +
+          </button>
+        </div>
+ 
+        {/* Month Switcher */}
+        <div className="max-w-lg mx-auto px-4 pb-3">
+          <div className="flex items-center justify-center gap-4">
+            <button
+              onClick={prevMonth}
+              className="w-8 h-8 rounded-lg bg-gray-100 dark:bg-gray-800 text-gray-600 dark:text-gray-400 flex items-center justify-center hover:bg-gray-200 dark:hover:bg-gray-700 transition text-sm font-bold"
+            >
+              ←
+            </button>
+            <button
+              onClick={() => { setMonth(now.getMonth() + 1); setYear(now.getFullYear()) }}
+              className={"text-sm font-semibold min-w-[140px] text-center " + (isCurrentMonth ? "text-gray-900 dark:text-white" : "text-primary-600 dark:text-primary-400")}
+            >
+              {MONTH_NAMES[month - 1]} {year}
+            </button>
+            <button
+              onClick={nextMonth}
+              className="w-8 h-8 rounded-lg bg-gray-100 dark:bg-gray-800 text-gray-600 dark:text-gray-400 flex items-center justify-center hover:bg-gray-200 dark:hover:bg-gray-700 transition text-sm font-bold"
+            >
+              →
+            </button>
+          </div>
+        </div>
+ 
+        <div className="max-w-lg mx-auto px-4 pb-3 flex gap-1.5 overflow-x-auto scrollbar-hide">
+          {tabs.map((t) => (
+            <button
+              key={t.key}
+              onClick={() => setActiveTab(t.key)}
+              className={`flex-1 min-w-0 py-2 rounded-xl text-xs sm:text-sm font-semibold transition whitespace-nowrap px-2 ${
+                activeTab === t.key
+                  ? "bg-primary-500 text-white"
+                  : "bg-gray-100 dark:bg-gray-800 text-gray-600 dark:text-gray-400"
+              }`}
+            >
+              {t.icon} {t.label}
+            </button>
+          ))}
+        </div>
+      </header>
+ 
+      <div className="max-w-lg mx-auto px-4 py-6">
+        {activeTab === "dashboard" && <FinanceDashboard key={refreshKey + "-" + month + "-" + year} month={month} year={year} />}
+        {activeTab === "transactions" && (
+          <TransactionList key={refreshKey + "-" + month + "-" + year} month={month} year={year} onAdd={() => setShowAdd(true)} />
+        )}
+        {activeTab === "analytics" && <FinanceAnalytics key={refreshKey + "-" + month + "-" + year} month={month} year={year} />}
+        {activeTab === "categories" && <CategoriesManager refreshKey={refreshKey} />}
+      </div>
+ 
+      {showAdd && (
+        <AddTransactionModal
+          onClose={() => setShowAdd(false)}
+          onSaved={() => {
+            setShowAdd(false)
+            refresh()
+          }}
+        />
+      )}
+ 
+      <Navigation />
+    </div>
+  )
+}
+ 
+ +
+
+ + + + + + + + \ No newline at end of file diff --git a/coverage/lcov-report/pages/ForgotPassword.jsx.html b/coverage/lcov-report/pages/ForgotPassword.jsx.html new file mode 100644 index 0000000..67ca064 --- /dev/null +++ b/coverage/lcov-report/pages/ForgotPassword.jsx.html @@ -0,0 +1,499 @@ + + + + + + Code coverage report for pages/ForgotPassword.jsx + + + + + + + + + +
+
+

All files / pages ForgotPassword.jsx

+
+ +
+ 100% + Statements + 17/17 +
+ + +
+ 100% + Branches + 8/8 +
+ + +
+ 100% + Functions + 3/3 +
+ + +
+ 100% + Lines + 17/17 +
+ + +
+

+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +

+ +
+
+

+
1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 +16 +17 +18 +19 +20 +21 +22 +23 +24 +25 +26 +27 +28 +29 +30 +31 +32 +33 +34 +35 +36 +37 +38 +39 +40 +41 +42 +43 +44 +45 +46 +47 +48 +49 +50 +51 +52 +53 +54 +55 +56 +57 +58 +59 +60 +61 +62 +63 +64 +65 +66 +67 +68 +69 +70 +71 +72 +73 +74 +75 +76 +77 +78 +79 +80 +81 +82 +83 +84 +85 +86 +87 +88 +89 +90 +91 +92 +93 +94 +95 +96 +97 +98 +99 +100 +101 +102 +103 +104 +105 +106 +107 +108 +109 +110 +111 +112 +113 +114 +115 +116 +117 +118 +119 +120 +121 +122 +123 +124 +125 +126 +127 +128 +129 +130 +131 +132 +133 +134 +135 +136 +137 +138 +139  +  +  +  +  +  +  +22x +22x +22x +22x +  +22x +5x +5x +5x +  +5x +5x +3x +  +2x +  +5x +  +  +  +22x +3x +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +19x +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +5x +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  + 
import { useState } from 'react'
+import { Link } from 'react-router-dom'
+import { motion } from 'framer-motion'
+import { Mail, ArrowLeft, Zap, CheckCircle } from 'lucide-react'
+import api from '../api/client'
+ 
+export default function ForgotPassword() {
+  const [email, setEmail] = useState('')
+  const [loading, setLoading] = useState(false)
+  const [error, setError] = useState('')
+  const [sent, setSent] = useState(false)
+ 
+  const handleSubmit = async (e) => {
+    e.preventDefault()
+    setError('')
+    setLoading(true)
+ 
+    try {
+      await api.post('/auth/forgot-password', { email })
+      setSent(true)
+    } catch (err) {
+      setError(err.response?.data?.error || 'Ошибка отправки')
+    } finally {
+      setLoading(false)
+    }
+  }
+ 
+  if (sent) {
+    return (
+      <div className="min-h-screen flex items-center justify-center p-4 gradient-mesh bg-surface-50">
+        <motion.div
+          initial={{ opacity: 0, scale: 0.9 }}
+          animate={{ opacity: 1, scale: 1 }}
+          className="w-full max-w-md"
+        >
+          <div className="card p-10 text-center">
+            <motion.div
+              initial={{ scale: 0 }}
+              animate={{ scale: 1 }}
+              transition={{ type: 'spring', stiffness: 200 }}
+              className="w-20 h-20 rounded-3xl bg-green-100 flex items-center justify-center mx-auto mb-6"
+            >
+              <CheckCircle className="w-10 h-10 text-green-600" />
+            </motion.div>
+            <h1 className="text-2xl font-display font-bold text-gray-900 mb-2">
+              Письмо отправлено! 📬
+            </h1>
+            <p className="text-gray-500 mb-6">
+              Если аккаунт с email <strong>{email}</strong> существует, мы отправили ссылку для сброса пароля.
+            </p>
+            <Link to="/login" className="btn btn-primary">
+              Вернуться ко входу
+            </Link>
+          </div>
+        </motion.div>
+      </div>
+    )
+  }
+ 
+  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 }}
+        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-20 h-20 rounded-3xl bg-gradient-to-br from-primary-500 to-primary-700 mb-6 shadow-xl shadow-primary-500/30"
+          >
+            <Mail className="w-10 h-10 text-white" />
+          </motion.div>
+          <h1 className="text-3xl font-display font-bold text-gray-900">
+            Забыли пароль?
+          </h1>
+          <p className="text-gray-500 mt-2">
+            Введи email и мы отправим ссылку для сброса
+          </p>
+        </div>
+ 
+        <div 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"
+              >
+                {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
+                autoFocus
+              />
+            </div>
+ 
+            <button
+              type="submit"
+              disabled={loading}
+              className="btn btn-primary w-full text-lg"
+            >
+              {loading ? 'Отправляем...' : 'Отправить ссылку'}
+            </button>
+          </form>
+ 
+          <div className="mt-6 text-center">
+            <Link 
+              to="/login" 
+              className="inline-flex items-center gap-2 text-primary-600 hover:text-primary-700 font-medium text-sm"
+            >
+              <ArrowLeft size={16} />
+              Вернуться ко входу
+            </Link>
+          </div>
+        </div>
+ 
+        <div className="flex items-center justify-center gap-2 mt-6 text-gray-400">
+          <Zap size={16} />
+          <span className="text-sm font-medium">Pulse</span>
+        </div>
+      </motion.div>
+    </div>
+  )
+}
+ 
+ +
+
+ + + + + + + + \ No newline at end of file diff --git a/coverage/lcov-report/pages/Habits.jsx.html b/coverage/lcov-report/pages/Habits.jsx.html new file mode 100644 index 0000000..b885526 --- /dev/null +++ b/coverage/lcov-report/pages/Habits.jsx.html @@ -0,0 +1,745 @@ + + + + + + Code coverage report for pages/Habits.jsx + + + + + + + + + +
+
+

All files / pages Habits.jsx

+
+ +
+ 64.91% + Statements + 37/57 +
+ + +
+ 65.9% + Branches + 29/44 +
+ + +
+ 53.57% + Functions + 15/28 +
+ + +
+ 65.21% + Lines + 30/46 +
+ + +
+

+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +

+ +
+
+

+
1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 +16 +17 +18 +19 +20 +21 +22 +23 +24 +25 +26 +27 +28 +29 +30 +31 +32 +33 +34 +35 +36 +37 +38 +39 +40 +41 +42 +43 +44 +45 +46 +47 +48 +49 +50 +51 +52 +53 +54 +55 +56 +57 +58 +59 +60 +61 +62 +63 +64 +65 +66 +67 +68 +69 +70 +71 +72 +73 +74 +75 +76 +77 +78 +79 +80 +81 +82 +83 +84 +85 +86 +87 +88 +89 +90 +91 +92 +93 +94 +95 +96 +97 +98 +99 +100 +101 +102 +103 +104 +105 +106 +107 +108 +109 +110 +111 +112 +113 +114 +115 +116 +117 +118 +119 +120 +121 +122 +123 +124 +125 +126 +127 +128 +129 +130 +131 +132 +133 +134 +135 +136 +137 +138 +139 +140 +141 +142 +143 +144 +145 +146 +147 +148 +149 +150 +151 +152 +153 +154 +155 +156 +157 +158 +159 +160 +161 +162 +163 +164 +165 +166 +167 +168 +169 +170 +171 +172 +173 +174 +175 +176 +177 +178 +179 +180 +181 +182 +183 +184 +185 +186 +187 +188 +189 +190 +191 +192 +193 +194 +195 +196 +197 +198 +199 +200 +201 +202 +203 +204 +205 +206 +207 +208 +209 +210 +211 +212 +213 +214 +215 +216 +217 +218 +219 +220 +221  +  +  +  +  +  +  +  +  +  +  +  +  +11x +11x +11x +11x +11x +  +11x +  +10x +  +  +11x +  +  +  +  +  +11x +9x +  +  +11x +2x +2x +4x +4x +4x +  +  +2x +  +  +11x +  +  +  +  +  +  +  +  +11x +6x +3x +3x +15x +  +  +  +  +  +  +11x +11x +  +11x +  +  +  +  +  +  +  +1x +  +  +  +  +  +  +  +  +  +  +18x +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +6x +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +6x +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  + 
import { useState, useEffect } from 'react'
+import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
+import { motion, AnimatePresence } from 'framer-motion'
+import { Plus, Settings, Flame, Calendar, ChevronRight, Archive, ArchiveRestore } from 'lucide-react'
+import { format } from 'date-fns'
+import { ru } from 'date-fns/locale'
+import { habitsApi } from '../api/habits'
+import CreateHabitModal from '../components/CreateHabitModal'
+import EditHabitModal from '../components/EditHabitModal'
+import Navigation from '../components/Navigation'
+import clsx from 'clsx'
+ 
+export default function Habits({ embedded = false }) {
+  const [showCreateModal, setShowCreateModal] = useState(false)
+  const [editingHabit, setEditingHabit] = useState(null)
+  const [showArchived, setShowArchived] = useState(false)
+  const [habitStats, setHabitStats] = useState({})
+  const queryClient = useQueryClient()
+ 
+  const { data: habits = [], isLoading } = useQuery({
+    queryKey: ['habits', showArchived],
+    queryFn: () => habitsApi.list().then(h => showArchived ? h : h.filter(x => !x.is_archived)),
+  })
+ 
+  const { data: archivedHabits = [] } = useQuery({
+    queryKey: ['habits-archived'],
+    queryFn: () => habitsApi.list().then(h => h.filter(x => x.is_archived)),
+    enabled: showArchived,
+  })
+ 
+  useEffect(() => {
+    if (habits.length > 0) loadStats()
+  }, [habits])
+ 
+  const loadStats = async () => {
+    const statsMap = {}
+    await Promise.all(habits.map(async (habit) => {
+      try {
+        const stats = await habitsApi.getHabitStats(habit.id)
+        statsMap[habit.id] = stats
+      } catch (e) {}
+    }))
+    setHabitStats(statsMap)
+  }
+ 
+  const archiveMutation = useMutation({
+    mutationFn: ({ id, archived }) => habitsApi.update(id, { is_archived: archived }),
+    onSuccess: () => {
+      queryClient.invalidateQueries({ queryKey: ['habits'] })
+      queryClient.invalidateQueries({ queryKey: ['habits-archived'] })
+      queryClient.invalidateQueries({ queryKey: ['stats'] })
+    },
+  })
+ 
+  const getFrequencyLabel = (habit) => {
+    if (habit.frequency === 'daily') return 'Ежедневно'
+    Eif (habit.frequency === 'weekly' && habit.target_days) {
+      const days = ['Пн', 'Вт', 'Ср', 'Чт', 'Пт', 'Сб', 'Вс']
+      return habit.target_days.map(d => days[d - 1]).join(', ')
+    }
+    if (habit.frequency === 'interval') return `Каждые ${habit.target_count} дн.`
+    if (habit.frequency === 'custom') return `Каждые ${habit.target_count} дн.`
+    return habit.frequency
+  }
+ 
+  const activeHabits = habits.filter(h => !h.is_archived)
+  const archivedList = habits.filter(h => h.is_archived)
+ 
+  return (
+    <div className={embedded ? "" : "min-h-screen bg-surface-50 dark:bg-gray-950 gradient-mesh pb-24 transition-colors duration-300"}>
+      {!embedded && <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 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">
+            <Plus size={18} />
+            Новая
+          </button>
+        </div>
+      </header>}
+ 
+      <main className="max-w-lg mx-auto px-4 py-6 space-y-6">
+        {isLoading ? (
+          <div className="space-y-4">
+            {[1, 2, 3].map((i) => (
+              <div key={i} className="card p-5 animate-pulse">
+                <div className="flex items-center gap-4">
+                  <div className="w-14 h-14 rounded-2xl bg-gray-200 dark:bg-gray-700" />
+                  <div className="flex-1">
+                    <div className="h-5 bg-gray-200 dark:bg-gray-700 rounded-lg w-1/2 mb-2" />
+                    <div className="h-4 bg-gray-200 dark:bg-gray-700 rounded-lg w-1/3" />
+                  </div>
+                </div>
+              </div>
+            ))}
+          </div>
+        ) : 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 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 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) => (
+                  <HabitListItem
+                    key={habit.id}
+                    habit={habit}
+                    index={index}
+                    stats={habitStats[habit.id]}
+                    frequencyLabel={getFrequencyLabel(habit)}
+                    onEdit={() => setEditingHabit(habit)}
+                    onArchive={() => archiveMutation.mutate({ id: habit.id, archived: true })}
+                  />
+                ))}
+              </AnimatePresence>
+            </div>
+ 
+            {archivedList.length > 0 && (
+              <div className="mt-8">
+                <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')} />
+                </button>
+ 
+                <AnimatePresence>
+                  {showArchived && (
+                    <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}
+                          initial={{ opacity: 0, y: 10 }}
+                          animate={{ opacity: 1, y: 0 }}
+                          transition={{ delay: index * 0.05 }}
+                          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' }}>
+                              {habit.icon || '✨'}
+                            </div>
+                            <div className="flex-1 min-w-0">
+                              <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 dark:hover:bg-green-900/20 rounded-xl transition-all" title="Восстановить">
+                              <ArchiveRestore size={20} />
+                            </button>
+                          </div>
+                        </motion.div>
+                      ))}
+                    </motion.div>
+                  )}
+                </AnimatePresence>
+              </div>
+            )}
+          </>
+        )}
+      </main>
+ 
+      {!embedded && <Navigation />}
+      <CreateHabitModal open={showCreateModal} onClose={() => setShowCreateModal(false)} />
+      <EditHabitModal open={!!editingHabit} onClose={() => setEditingHabit(null)} habit={editingHabit} />
+    </div>
+  )
+}
+ 
+function HabitListItem({ habit, index, stats, frequencyLabel, onEdit, onArchive }) {
+  return (
+    <motion.div
+      initial={{ opacity: 0, y: 20 }}
+      animate={{ opacity: 1, y: 0 }}
+      exit={{ opacity: 0, x: -100 }}
+      transition={{ delay: index * 0.05 }}
+      onClick={onEdit}
+      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' }}>
+          {habit.icon || '✨'}
+        </div>
+        
+        <div className="flex-1 min-w-0">
+          <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 }}>
+              {frequencyLabel}
+            </span>
+            {stats && stats.current_streak > 0 && (
+              <span className="text-xs text-orange-500 flex items-center gap-1">
+                <Flame size={14} />
+                {stats.current_streak} дн.
+              </span>
+            )}
+          </div>
+        </div>
+ 
+        <div className="flex items-center gap-2">
+          {stats && (
+            <div className="text-right">
+              <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 dark:text-gray-600" />
+        </div>
+      </div>
+    </motion.div>
+  )
+}
+ 
+ +
+
+ + + + + + + + \ No newline at end of file diff --git a/coverage/lcov-report/pages/Home.jsx.html b/coverage/lcov-report/pages/Home.jsx.html new file mode 100644 index 0000000..9e744d6 --- /dev/null +++ b/coverage/lcov-report/pages/Home.jsx.html @@ -0,0 +1,1816 @@ + + + + + + Code coverage report for pages/Home.jsx + + + + + + + + + +
+
+

All files / pages Home.jsx

+
+ +
+ 17.46% + Statements + 33/189 +
+ + +
+ 8.84% + Branches + 13/147 +
+ + +
+ 9.25% + Functions + 5/54 +
+ + +
+ 21.15% + Lines + 33/156 +
+ + +
+

+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +

+ +
+
+

+
1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 +16 +17 +18 +19 +20 +21 +22 +23 +24 +25 +26 +27 +28 +29 +30 +31 +32 +33 +34 +35 +36 +37 +38 +39 +40 +41 +42 +43 +44 +45 +46 +47 +48 +49 +50 +51 +52 +53 +54 +55 +56 +57 +58 +59 +60 +61 +62 +63 +64 +65 +66 +67 +68 +69 +70 +71 +72 +73 +74 +75 +76 +77 +78 +79 +80 +81 +82 +83 +84 +85 +86 +87 +88 +89 +90 +91 +92 +93 +94 +95 +96 +97 +98 +99 +100 +101 +102 +103 +104 +105 +106 +107 +108 +109 +110 +111 +112 +113 +114 +115 +116 +117 +118 +119 +120 +121 +122 +123 +124 +125 +126 +127 +128 +129 +130 +131 +132 +133 +134 +135 +136 +137 +138 +139 +140 +141 +142 +143 +144 +145 +146 +147 +148 +149 +150 +151 +152 +153 +154 +155 +156 +157 +158 +159 +160 +161 +162 +163 +164 +165 +166 +167 +168 +169 +170 +171 +172 +173 +174 +175 +176 +177 +178 +179 +180 +181 +182 +183 +184 +185 +186 +187 +188 +189 +190 +191 +192 +193 +194 +195 +196 +197 +198 +199 +200 +201 +202 +203 +204 +205 +206 +207 +208 +209 +210 +211 +212 +213 +214 +215 +216 +217 +218 +219 +220 +221 +222 +223 +224 +225 +226 +227 +228 +229 +230 +231 +232 +233 +234 +235 +236 +237 +238 +239 +240 +241 +242 +243 +244 +245 +246 +247 +248 +249 +250 +251 +252 +253 +254 +255 +256 +257 +258 +259 +260 +261 +262 +263 +264 +265 +266 +267 +268 +269 +270 +271 +272 +273 +274 +275 +276 +277 +278 +279 +280 +281 +282 +283 +284 +285 +286 +287 +288 +289 +290 +291 +292 +293 +294 +295 +296 +297 +298 +299 +300 +301 +302 +303 +304 +305 +306 +307 +308 +309 +310 +311 +312 +313 +314 +315 +316 +317 +318 +319 +320 +321 +322 +323 +324 +325 +326 +327 +328 +329 +330 +331 +332 +333 +334 +335 +336 +337 +338 +339 +340 +341 +342 +343 +344 +345 +346 +347 +348 +349 +350 +351 +352 +353 +354 +355 +356 +357 +358 +359 +360 +361 +362 +363 +364 +365 +366 +367 +368 +369 +370 +371 +372 +373 +374 +375 +376 +377 +378 +379 +380 +381 +382 +383 +384 +385 +386 +387 +388 +389 +390 +391 +392 +393 +394 +395 +396 +397 +398 +399 +400 +401 +402 +403 +404 +405 +406 +407 +408 +409 +410 +411 +412 +413 +414 +415 +416 +417 +418 +419 +420 +421 +422 +423 +424 +425 +426 +427 +428 +429 +430 +431 +432 +433 +434 +435 +436 +437 +438 +439 +440 +441 +442 +443 +444 +445 +446 +447 +448 +449 +450 +451 +452 +453 +454 +455 +456 +457 +458 +459 +460 +461 +462 +463 +464 +465 +466 +467 +468 +469 +470 +471 +472 +473 +474 +475 +476 +477 +478 +479 +480 +481 +482 +483 +484 +485 +486 +487 +488 +489 +490 +491 +492 +493 +494 +495 +496 +497 +498 +499 +500 +501 +502 +503 +504 +505 +506 +507 +508 +509 +510 +511 +512 +513 +514 +515 +516 +517 +518 +519 +520 +521 +522 +523 +524 +525 +526 +527 +528 +529 +530 +531 +532 +533 +534 +535 +536 +537 +538 +539 +540 +541 +542 +543 +544 +545 +546 +547 +548 +549 +550 +551 +552 +553 +554 +555 +556 +557 +558 +559 +560 +561 +562 +563 +564 +565 +566 +567 +568 +569 +570 +571 +572 +573 +574 +575 +576 +577 +578  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +3x +3x +3x +3x +3x +3x +3x +3x +  +3x +  +  +  +  +3x +  +  +  +  +3x +  +  +  +  +  +3x +3x +  +  +  +  +  +3x +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +3x +  +  +  +  +  +  +  +  +  +  +  +  +3x +  +  +  +  +  +  +  +  +  +  +  +  +  +3x +  +  +  +  +  +  +  +  +  +  +  +  +  +3x +  +  +  +  +  +  +  +3x +  +  +  +  +  +  +  +3x +  +  +  +  +  +  +  +3x +  +  +  +3x +  +  +  +  +3x +3x +  +  +3x +3x +3x +  +  +3x +3x +3x +3x +  +3x +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +9x +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  + 
import { useState, useEffect, useMemo, useRef } from 'react'
+import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
+import { motion, AnimatePresence } from 'framer-motion'
+import { Check, Flame, TrendingUp, Zap, Sparkles, Undo2, Plus, Calendar, AlertTriangle, LogOut, Snowflake } from 'lucide-react'
+import { format, startOfWeek, differenceInDays, parseISO, isToday, isTomorrow, isPast, startOfDay, isBefore, isAfter } from 'date-fns'
+import { ru } from 'date-fns/locale'
+import { habitsApi } from '../api/habits'
+import { tasksApi } from '../api/tasks'
+import { useAuthStore } from '../store/auth'
+import Navigation from '../components/Navigation'
+import CreateTaskModal from '../components/CreateTaskModal'
+import LogHabitModal from '../components/LogHabitModal'
+import clsx from 'clsx'
+ 
+// Check if habit is frozen on a specific date
+function isHabitFrozenOnDate(habit, freezes, date) {
+  if (!freezes || freezes.length === 0) return false
+  const checkDate = startOfDay(date)
+  return freezes.some(freeze => {
+    const start = startOfDay(parseISO(freeze.start_date))
+    const end = startOfDay(parseISO(freeze.end_date))
+    return !isBefore(checkDate, start) && !isAfter(checkDate, end)
+  })
+}
+ 
+// Определение "сегодняшних" привычек
+function shouldShowToday(habit, lastLogDate, freezes) {
+  const today = startOfDay(new Date())
+  const dayOfWeek = today.getDay() || 7
+  
+  if (isHabitFrozenOnDate(habit, freezes, today)) return false
+  
+  const startDate = habit.start_date 
+    ? startOfDay(parseISO(habit.start_date)) 
+    : startOfDay(parseISO(habit.created_at))
+  
+  if (today < startDate) return false
+  
+  if (habit.frequency === "daily") return true
+  
+  if (habit.frequency === "weekly") {
+    if (habit.target_days && habit.target_days.length > 0) {
+      return habit.target_days.includes(dayOfWeek)
+    }
+    if (!lastLogDate) return true
+    const weekStart = startOfWeek(today, { weekStartsOn: 1 })
+    const lastLog = typeof lastLogDate === "string" ? parseISO(lastLogDate) : lastLogDate
+    return lastLog < weekStart
+  }
+  
+  if (habit.frequency === "interval" && habit.target_count > 0) {
+    const daysSinceStart = differenceInDays(today, startDate)
+    return daysSinceStart % habit.target_count === 0
+  }
+  
+  if (habit.frequency === "custom" && habit.target_count > 0) {
+    if (!lastLogDate) return today >= startDate
+    const lastLog = typeof lastLogDate === "string" ? parseISO(lastLogDate) : lastLogDate
+    const daysSinceLastLog = differenceInDays(today, startOfDay(lastLog))
+    return daysSinceLastLog >= habit.target_count
+  }
+  
+  return true
+}
+ 
+function formatDueDate(dateStr) {
+  if (!dateStr) return null
+  const date = parseISO(dateStr)
+  if (isToday(date)) return 'Сегодня'
+  if (isTomorrow(date)) return 'Завтра'
+  return format(date, 'd MMM', { locale: ru })
+}
+ 
+export default function Home() {
+  const [todayLogs, setTodayLogs] = useState({})
+  const [lastLogDates, setLastLogDates] = useState({})
+  const [habitFreezes, setHabitFreezes] = useState({})
+  const [habitLogs, setHabitLogs] = useState({})
+  const [showCreateTask, setShowCreateTask] = useState(false)
+  const [logHabitModal, setLogHabitModal] = useState({ open: false, habit: null })
+  const queryClient = useQueryClient()
+  const { user, logout } = useAuthStore()
+ 
+  const { data: habits = [], isLoading: habitsLoading } = useQuery({
+    queryKey: ['habits'],
+    queryFn: habitsApi.list,
+  })
+ 
+  const { data: stats } = useQuery({
+    queryKey: ['stats'],
+    queryFn: habitsApi.getStats,
+  })
+ 
+  const { data: todayTasks = [], isLoading: tasksLoading } = useQuery({
+    queryKey: ['tasks-today'],
+    queryFn: tasksApi.today,
+  })
+ 
+ 
+  useEffect(() => {
+    Iif (habits.length > 0) {
+      loadTodayLogs()
+      loadHabitFreezes()
+    }
+  }, [habits])
+ 
+  const loadTodayLogs = async () => {
+    const today = format(new Date(), 'yyyy-MM-dd')
+    const logsMap = {}
+    const lastDates = {}
+    const allLogs = {}
+    
+    await Promise.all(habits.map(async (habit) => {
+      try {
+        const logs = await habitsApi.getLogs(habit.id, 90)
+        allLogs[habit.id] = logs.map(l => l.date)
+        
+        if (logs.length > 0) {
+          const lastLog = logs[0]
+          const logDate = lastLog.date.split('T')[0]
+          lastDates[habit.id] = logDate
+          if (logDate === today) logsMap[habit.id] = lastLog.id
+        }
+      } catch (e) {
+        console.error('Error loading logs for habit', habit.id, e)
+      }
+    }))
+    
+    setTodayLogs(logsMap)
+    setLastLogDates(lastDates)
+    setHabitLogs(allLogs)
+  }
+ 
+  const loadHabitFreezes = async () => {
+    const freezesMap = {}
+    await Promise.all(habits.map(async (habit) => {
+      try {
+        const freezes = await habitsApi.getFreezes(habit.id)
+        freezesMap[habit.id] = freezes
+      } catch (e) {
+        freezesMap[habit.id] = []
+      }
+    }))
+    setHabitFreezes(freezesMap)
+  }
+ 
+  const logMutation = useMutation({
+    mutationFn: ({ habitId, date }) => habitsApi.log(habitId, date ? { date } : {}),
+    onSuccess: (data, { habitId, date }) => {
+      const logDate = date || format(new Date(), 'yyyy-MM-dd')
+      const today = format(new Date(), 'yyyy-MM-dd')
+      
+      if (logDate === today) setTodayLogs(prev => ({ ...prev, [habitId]: data.id }))
+      setLastLogDates(prev => ({ ...prev, [habitId]: logDate }))
+      setHabitLogs(prev => ({ ...prev, [habitId]: [...(prev[habitId] || []), logDate] }))
+      queryClient.invalidateQueries({ queryKey: ['habits'] })
+      queryClient.invalidateQueries({ queryKey: ['stats'] })
+    },
+  })
+ 
+  const deleteLogMutation = useMutation({
+    mutationFn: ({ habitId, logId }) => habitsApi.deleteLog(habitId, logId),
+    onSuccess: (_, { habitId }) => {
+      setTodayLogs(prev => {
+        const newLogs = { ...prev }
+        delete newLogs[habitId]
+        return newLogs
+      })
+      loadTodayLogs()
+      queryClient.invalidateQueries({ queryKey: ['habits'] })
+      queryClient.invalidateQueries({ queryKey: ['stats'] })
+    },
+  })
+ 
+  const completeTaskMutation = useMutation({
+    mutationFn: (id) => tasksApi.complete(id),
+    onSuccess: () => {
+      queryClient.invalidateQueries({ queryKey: ['tasks-today'] })
+      queryClient.invalidateQueries({ queryKey: ['tasks'] })
+    },
+  })
+ 
+  const uncompleteTaskMutation = useMutation({
+    mutationFn: (id) => tasksApi.uncomplete(id),
+    onSuccess: () => {
+      queryClient.invalidateQueries({ queryKey: ['tasks-today'] })
+      queryClient.invalidateQueries({ queryKey: ['tasks'] })
+    },
+  })
+ 
+  const handleToggleComplete = (habitId) => {
+    if (todayLogs[habitId]) {
+      deleteLogMutation.mutate({ habitId, logId: todayLogs[habitId] })
+    } else {
+      logMutation.mutate({ habitId })
+    }
+  }
+ 
+  const handleLogHabitDate = async (habitId, date) => {
+    await logMutation.mutateAsync({ habitId, date })
+  }
+ 
+  const handleToggleTask = (task) => {
+    if (task.completed) uncompleteTaskMutation.mutate(task.id)
+    else completeTaskMutation.mutate(task.id)
+  }
+ 
+  const todayHabits = useMemo(() => {
+    return habits.filter(habit => shouldShowToday(habit, lastLogDates[habit.id], habitFreezes[habit.id]))
+  }, [habits, lastLogDates, habitFreezes])
+ 
+  const frozenHabits = useMemo(() => {
+    const today = startOfDay(new Date())
+    return habits.filter(habit => isHabitFrozenOnDate(habit, habitFreezes[habit.id], today))
+  }, [habits, habitFreezes])
+ 
+  const completedCount = Object.keys(todayLogs).length
+  const totalToday = todayHabits.length
+  const today = format(new Date(), 'EEEE, d MMMM', { locale: ru })
+  const activeTasks = todayTasks.filter(t => !t.completed)
+ 
+  return (
+    <div className="min-h-screen bg-surface-50 dark:bg-gray-950 gradient-mesh pb-24 transition-colors duration-300">
+      <header className="bg-white/70 dark:bg-gray-900/70 backdrop-blur-xl border-b border-gray-100/50 dark:border-gray-800 sticky top-0 z-10">
+        <div className="max-w-lg mx-auto px-4 py-4 flex items-center justify-between">
+          <div className="flex items-center gap-3">
+            <div className="w-10 h-10 rounded-xl bg-gradient-to-br from-primary-500 to-primary-700 flex items-center justify-center shadow-lg shadow-primary-500/20">
+              <Zap className="w-5 h-5 text-white" />
+            </div>
+            <div>
+              <h1 className="text-lg font-display font-bold text-gray-900 dark:text-white">
+                Привет, {user?.username}!
+              </h1>
+              <p className="text-sm text-gray-500 dark:text-gray-400 capitalize">{today}</p>
+            </div>
+          </div>
+          <button
+            onClick={logout}
+            className="p-2 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-800 rounded-xl transition-colors"
+            title="Выйти"
+          >
+            <LogOut size={20} />
+          </button>
+        </div>
+      </header>
+ 
+      <main className="max-w-lg mx-auto px-4 py-6 space-y-6">
+        {/* Progress */}
+        <motion.div initial={{ opacity: 0, y: 20 }} animate={{ opacity: 1, y: 0 }} className="card p-5">
+          <div className="flex items-center justify-between mb-3">
+            <h2 className="font-semibold text-gray-900 dark:text-white">Прогресс на сегодня</h2>
+            <span className="text-sm font-medium text-primary-600 dark:text-primary-400">{completedCount} / {totalToday}</span>
+          </div>
+          <div className="h-3 bg-gray-100 dark:bg-gray-800 rounded-full overflow-hidden">
+            <motion.div
+              initial={{ width: 0 }}
+              animate={{ width: totalToday > 0 ? `${(completedCount / totalToday) * 100}%` : '0%' }}
+              transition={{ duration: 0.5, ease: 'easeOut' }}
+              className="h-full bg-gradient-to-r from-primary-500 to-accent-500 rounded-full"
+            />
+          </div>
+          {completedCount === totalToday && totalToday > 0 && (
+            <motion.p initial={{ opacity: 0 }} animate={{ opacity: 1 }} className="text-sm text-green-600 dark:text-green-400 mt-2 font-medium">
+              🎉 Все привычки выполнены!
+            </motion.p>
+          )}
+          {frozenHabits.length > 0 && (
+            <div className="flex items-center gap-2 mt-2 text-sm text-cyan-600 dark:text-cyan-400">
+              <Snowflake size={14} />
+              <span>{frozenHabits.length} привычек на паузе</span>
+            </div>
+          )}
+        </motion.div>
+ 
+        {/* Stats */}
+        {stats && (
+          <div className="grid grid-cols-2 gap-4">
+            <motion.div initial={{ opacity: 0, y: 20 }} animate={{ opacity: 1, y: 0 }} className="card p-5">
+              <div className="flex items-center gap-4">
+                <div className="w-12 h-12 rounded-2xl bg-gradient-to-br from-accent-400 to-accent-500 flex items-center justify-center shadow-lg shadow-accent-400/20">
+                  <Flame className="w-6 h-6 text-white" />
+                </div>
+                <div>
+                  <p className="text-3xl font-display font-bold text-gray-900 dark:text-white">{stats.today_completed}</p>
+                  <p className="text-sm text-gray-500 dark:text-gray-400">Выполнено</p>
+                </div>
+              </div>
+            </motion.div>
+            
+            <motion.div initial={{ opacity: 0, y: 20 }} animate={{ opacity: 1, y: 0 }} transition={{ delay: 0.1 }} className="card p-5">
+              <div className="flex items-center gap-4">
+                <div className="w-12 h-12 rounded-2xl bg-gradient-to-br from-primary-500 to-primary-700 flex items-center justify-center shadow-lg shadow-primary-500/20">
+                  <TrendingUp className="w-6 h-6 text-white" />
+                </div>
+                <div>
+                  <p className="text-3xl font-display font-bold text-gray-900 dark:text-white">{stats.active_habits}</p>
+                  <p className="text-sm text-gray-500 dark:text-gray-400">Активных</p>
+                </div>
+              </div>
+            </motion.div>
+          </div>
+        )}
+ 
+        {/* Tasks */}
+ 
+        {(activeTasks.length > 0 || !tasksLoading) && (
+          <div>
+            <div className="flex items-center justify-between mb-4">
+              <h2 className="text-xl font-display font-bold text-gray-900 dark:text-white">Задачи на сегодня</h2>
+              <button
+                onClick={() => setShowCreateTask(true)}
+                className="p-2 bg-primary-100 dark:bg-primary-900/30 text-primary-600 dark:text-primary-400 rounded-xl hover:bg-primary-200 dark:hover:bg-primary-800/40 transition-colors"
+              >
+                <Plus size={18} />
+              </button>
+            </div>
+ 
+            {tasksLoading ? (
+              <div className="card p-5 animate-pulse">
+                <div className="flex items-center gap-4">
+                  <div className="w-10 h-10 rounded-xl bg-gray-200 dark:bg-gray-700" />
+                  <div className="flex-1">
+                    <div className="h-5 bg-gray-200 dark:bg-gray-700 rounded-lg w-3/4 mb-2" />
+                    <div className="h-4 bg-gray-200 dark:bg-gray-700 rounded-lg w-1/4" />
+                  </div>
+                </div>
+              </div>
+            ) : activeTasks.length === 0 ? (
+              <motion.div initial={{ opacity: 0, scale: 0.95 }} animate={{ opacity: 1, scale: 1 }} className="card p-6 text-center">
+                <p className="text-gray-500 dark:text-gray-400">Нет задач на сегодня</p>
+                <button onClick={() => setShowCreateTask(true)} className="mt-3 text-sm text-primary-600 dark:text-primary-400 hover:text-primary-700 font-medium">
+                  + Добавить задачу
+                </button>
+              </motion.div>
+            ) : (
+              <div className="space-y-3">
+                <AnimatePresence>
+                  {activeTasks.map((task, index) => (
+                    <TaskCard key={task.id} task={task} index={index} onToggle={() => handleToggleTask(task)} isLoading={completeTaskMutation.isPending || uncompleteTaskMutation.isPending} />
+                  ))}
+                </AnimatePresence>
+              </div>
+            )}
+          </div>
+        )}
+ 
+        {/* Habits */}
+        <div>
+          <h2 className="text-xl font-display font-bold text-gray-900 dark:text-white mb-5">Привычки</h2>
+ 
+          {habitsLoading ? (
+            <div className="space-y-4">
+              {[1, 2, 3].map((i) => (
+                <div key={i} className="card p-5 animate-pulse">
+                  <div className="flex items-center gap-4">
+                    <div className="w-14 h-14 rounded-2xl bg-gray-200 dark:bg-gray-700" />
+                    <div className="flex-1">
+                      <div className="h-5 bg-gray-200 dark:bg-gray-700 rounded-lg w-1/2 mb-2" />
+                      <div className="h-4 bg-gray-200 dark:bg-gray-700 rounded-lg w-1/3" />
+                    </div>
+                  </div>
+                </div>
+              ))}
+            </div>
+          ) : todayHabits.length === 0 ? (
+            <motion.div initial={{ opacity: 0, scale: 0.95 }} animate={{ opacity: 1, scale: 1 }} className="card p-10 text-center">
+              <div className="w-20 h-20 rounded-3xl bg-gradient-to-br from-green-100 to-green-200 dark:from-green-900/30 dark:to-green-800/30 flex items-center justify-center mx-auto mb-5">
+                <Sparkles className="w-10 h-10 text-green-600 dark:text-green-400" />
+              </div>
+              <h3 className="text-xl font-display font-bold text-gray-900 dark:text-white mb-2">Свободный день!</h3>
+              <p className="text-gray-500 dark:text-gray-400">На сегодня нет запланированных привычек.</p>
+            </motion.div>
+          ) : (
+            <div className="space-y-4">
+              <AnimatePresence>
+                {todayHabits.map((habit, index) => (
+                  <HabitCard
+                    key={habit.id}
+                    habit={habit}
+                    index={index}
+                    isCompleted={!!todayLogs[habit.id]}
+                    onToggle={() => handleToggleComplete(habit.id)}
+                    onLongPress={() => setLogHabitModal({ open: true, habit })}
+                    isLoading={logMutation.isPending || deleteLogMutation.isPending}
+                  />
+                ))}
+              </AnimatePresence>
+            </div>
+          )}
+        </div>
+      </main>
+ 
+      <Navigation />
+      <CreateTaskModal open={showCreateTask} onClose={() => setShowCreateTask(false)} />
+      <LogHabitModal
+        open={logHabitModal.open}
+        onClose={() => setLogHabitModal({ open: false, habit: null })}
+        habit={logHabitModal.habit}
+        completedDates={habitLogs[logHabitModal.habit?.id] || []}
+        onLogDate={handleLogHabitDate}
+      />
+    </div>
+  )
+}
+ 
+function TaskCard({ task, index, onToggle, isLoading }) {
+  const [showConfetti, setShowConfetti] = useState(false)
+  const dueDateLabel = formatDueDate(task.due_date)
+  const isOverdue = task.due_date && isPast(parseISO(task.due_date)) && !isToday(parseISO(task.due_date)) && !task.completed
+ 
+  const handleCheck = (e) => {
+    e.stopPropagation()
+    if (isLoading) return
+    if (!task.completed) {
+      setShowConfetti(true)
+      setTimeout(() => setShowConfetti(false), 1000)
+    }
+    onToggle()
+  }
+ 
+  return (
+    <motion.div
+      initial={{ opacity: 0, y: 20 }}
+      animate={{ opacity: 1, y: 0 }}
+      exit={{ opacity: 0, x: -100 }}
+      transition={{ delay: index * 0.05 }}
+      className="card p-4 relative overflow-hidden"
+    >
+      {showConfetti && (
+        <motion.div initial={{ opacity: 1 }} animate={{ opacity: 0 }} transition={{ duration: 1 }} className="absolute inset-0 pointer-events-none">
+          {[...Array(6)].map((_, i) => (
+            <motion.div
+              key={i}
+              initial={{ x: '50%', y: '50%', scale: 0 }}
+              animate={{ x: `${Math.random() * 100}%`, y: `${Math.random() * 100}%`, scale: [0, 1, 0] }}
+              transition={{ duration: 0.6, delay: i * 0.05 }}
+              className="absolute w-2 h-2 rounded-full"
+              style={{ backgroundColor: task.color }}
+            />
+          ))}
+        </motion.div>
+      )}
+      
+      <div className="flex items-center gap-3">
+        <motion.button
+          onClick={handleCheck}
+          disabled={isLoading}
+          whileTap={{ scale: 0.9 }}
+          className={clsx(
+            'w-10 h-10 rounded-xl flex items-center justify-center transition-all duration-300 flex-shrink-0',
+            task.completed
+              ? 'bg-gradient-to-br from-green-400 to-green-500 shadow-lg shadow-green-400/30'
+              : 'border-2 hover:shadow-md'
+          )}
+          style={{ borderColor: task.completed ? undefined : task.color + '40', backgroundColor: task.completed ? undefined : task.color + '10' }}
+        >
+          {task.completed ? (
+            <motion.div initial={{ scale: 0, rotate: -180 }} animate={{ scale: 1, rotate: 0 }} transition={{ type: 'spring', stiffness: 500 }}>
+              <Check className="w-5 h-5 text-white" strokeWidth={3} />
+            </motion.div>
+          ) : (
+            <span className="text-lg">{task.icon || '📋'}</span>
+          )}
+        </motion.button>
+        
+        <div className="flex-1 min-w-0">
+          <h3 className={clsx("font-semibold truncate", task.completed ? "text-gray-400 line-through" : "text-gray-900 dark:text-white")}>{task.title}</h3>
+          {(dueDateLabel || isOverdue) && (
+            <span className={clsx('inline-flex items-center gap-1 text-xs font-medium mt-1', isOverdue ? 'text-red-600' : 'text-gray-500 dark:text-gray-400')}>
+              {isOverdue && <AlertTriangle size={12} />}
+              <Calendar size={12} />
+              {dueDateLabel}
+            </span>
+          )}
+        </div>
+        
+        {task.completed && (
+          <motion.button initial={{ opacity: 0, scale: 0.8 }} animate={{ opacity: 1, scale: 1 }} onClick={handleCheck} disabled={isLoading} className="p-2 text-gray-400 hover:text-orange-500 hover:bg-orange-50 dark:hover:bg-orange-900/20 rounded-xl transition-all" title="Отменить">
+            <Undo2 size={18} />
+          </motion.button>
+        )}
+      </div>
+    </motion.div>
+  )
+}
+ 
+function HabitCard({ habit, index, isCompleted, onToggle, onLongPress, isLoading }) {
+  const [showConfetti, setShowConfetti] = useState(false)
+  const longPressTimer = useRef(null)
+  const isLongPress = useRef(false)
+ 
+  const handleTouchStart = () => {
+    isLongPress.current = false
+    longPressTimer.current = setTimeout(() => { isLongPress.current = true; onLongPress() }, 500)
+  }
+ 
+  const handleTouchEnd = () => { if (longPressTimer.current) clearTimeout(longPressTimer.current) }
+ 
+  const handleCheck = (e) => {
+    e.stopPropagation()
+    if (isLoading || isLongPress.current) return
+    if (!isCompleted) { setShowConfetti(true); setTimeout(() => setShowConfetti(false), 1000) }
+    onToggle()
+  }
+ 
+  const handleContextMenu = (e) => { e.preventDefault(); onLongPress() }
+ 
+  return (
+    <motion.div
+      initial={{ opacity: 0, y: 20 }}
+      animate={{ opacity: 1, y: 0 }}
+      exit={{ opacity: 0, x: -100 }}
+      transition={{ delay: index * 0.05 }}
+      className="card p-5 relative overflow-hidden"
+      onContextMenu={handleContextMenu}
+    >
+      {showConfetti && (
+        <motion.div initial={{ opacity: 1 }} animate={{ opacity: 0 }} transition={{ duration: 1 }} className="absolute inset-0 pointer-events-none">
+          {[...Array(6)].map((_, i) => (
+            <motion.div
+              key={i}
+              initial={{ x: '50%', y: '50%', scale: 0 }}
+              animate={{ x: `${Math.random() * 100}%`, y: `${Math.random() * 100}%`, scale: [0, 1, 0] }}
+              transition={{ duration: 0.6, delay: i * 0.05 }}
+              className="absolute w-2 h-2 rounded-full"
+              style={{ backgroundColor: habit.color }}
+            />
+          ))}
+        </motion.div>
+      )}
+      
+      <div className="flex items-center gap-4">
+        <motion.button
+          onClick={handleCheck}
+          onTouchStart={handleTouchStart}
+          onTouchEnd={handleTouchEnd}
+          onMouseDown={handleTouchStart}
+          onMouseUp={handleTouchEnd}
+          onMouseLeave={handleTouchEnd}
+          disabled={isLoading}
+          whileTap={{ scale: 0.9 }}
+          className={clsx(
+            'w-14 h-14 rounded-2xl flex items-center justify-center transition-all duration-300 relative flex-shrink-0',
+            isCompleted
+              ? 'bg-gradient-to-br from-green-400 to-green-500 shadow-lg shadow-green-400/30'
+              : 'border-2 hover:shadow-md'
+          )}
+          style={{ borderColor: isCompleted ? undefined : habit.color + '40', backgroundColor: isCompleted ? undefined : habit.color + '10' }}
+        >
+          {isCompleted ? (
+            <motion.div initial={{ scale: 0, rotate: -180 }} animate={{ scale: 1, rotate: 0 }} transition={{ type: 'spring', stiffness: 500 }}>
+              <Check className="w-7 h-7 text-white" strokeWidth={3} />
+            </motion.div>
+          ) : (
+            <span className="text-2xl">{habit.icon || '✨'}</span>
+          )}
+        </motion.button>
+        
+        <div className="flex-1 min-w-0">
+          <h3 className={clsx("font-semibold text-lg truncate", isCompleted ? "text-gray-400 line-through" : "text-gray-900 dark:text-white")}>{habit.name}</h3>
+          {habit.description && <p className="text-sm text-gray-500 dark:text-gray-400 truncate">{habit.description}</p>}
+        </div>
+        
+        <div className="flex items-center gap-2">
+          <button onClick={(e) => { e.stopPropagation(); onLongPress() }} className="p-2 text-gray-400 hover:text-primary-500 hover:bg-primary-50 dark:hover:bg-primary-900/20 rounded-xl transition-all" title="Отметить за другой день">
+            <Calendar size={20} />
+          </button>
+          {isCompleted && (
+            <motion.button initial={{ opacity: 0, scale: 0.8 }} animate={{ opacity: 1, scale: 1 }} onClick={handleCheck} disabled={isLoading} className="p-2 text-gray-400 hover:text-orange-500 hover:bg-orange-50 dark:hover:bg-orange-900/20 rounded-xl transition-all" title="Отменить">
+              <Undo2 size={20} />
+            </motion.button>
+          )}
+        </div>
+      </div>
+    </motion.div>
+  )
+}
+ 
+ +
+
+ + + + + + + + \ No newline at end of file diff --git a/coverage/lcov-report/pages/Login.jsx.html b/coverage/lcov-report/pages/Login.jsx.html new file mode 100644 index 0000000..cc44275 --- /dev/null +++ b/coverage/lcov-report/pages/Login.jsx.html @@ -0,0 +1,373 @@ + + + + + + Code coverage report for pages/Login.jsx + + + + + + + + + +
+
+

All files / pages Login.jsx

+
+ +
+ 100% + Statements + 21/21 +
+ + +
+ 100% + Branches + 10/10 +
+ + +
+ 100% + Functions + 6/6 +
+ + +
+ 100% + Lines + 20/20 +
+ + +
+

+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +

+ +
+
+

+
1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 +16 +17 +18 +19 +20 +21 +22 +23 +24 +25 +26 +27 +28 +29 +30 +31 +32 +33 +34 +35 +36 +37 +38 +39 +40 +41 +42 +43 +44 +45 +46 +47 +48 +49 +50 +51 +52 +53 +54 +55 +56 +57 +58 +59 +60 +61 +62 +63 +64 +65 +66 +67 +68 +69 +70 +71 +72 +73 +74 +75 +76 +77 +78 +79 +80 +81 +82 +83 +84 +85 +86 +87 +88 +89 +90 +91 +92 +93 +94 +95 +96 +97  +  +  +  +  +  +  +22x +22x +22x +22x +22x +  +22x +22x +  +22x +3x +3x +3x +  +3x +3x +1x +  +2x +  +3x +  +  +  +22x +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +3x +  +  +  +  +  +3x +2x +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  + 
import { useState } from 'react'
+import { Link, useNavigate } from 'react-router-dom'
+import { motion } from 'framer-motion'
+import { Eye, EyeOff, Zap } from 'lucide-react'
+import { useAuthStore } from '../store/auth'
+ 
+export default function Login() {
+  const [email, setEmail] = useState('')
+  const [password, setPassword] = useState('')
+  const [showPassword, setShowPassword] = useState(false)
+  const [error, setError] = useState('')
+  const [loading, setLoading] = useState(false)
+  
+  const login = useAuthStore(s => s.login)
+  const navigate = useNavigate()
+ 
+  const handleSubmit = async (e) => {
+    e.preventDefault()
+    setError('')
+    setLoading(true)
+ 
+    try {
+      await login(email, password)
+      navigate('/')
+    } catch (err) {
+      setError(err.response?.data?.error || 'Ошибка входа')
+    } finally {
+      setLoading(false)
+    }
+  }
+ 
+  return (
+    <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">
+            <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 dark:text-white">
+            С возвращением!
+          </motion.h1>
+          <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">
+          <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 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 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 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 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 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">
+              {loading ? (
+                <span className="flex items-center gap-2">
+                  <svg className="animate-spin h-5 w-5" viewBox="0 0 24 24">
+                    <circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" fill="none" />
+                    <path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
+                  </svg>
+                  Входим...
+                </span>
+              ) : 'Войти'}
+            </button>
+          </form>
+ 
+          <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>
+      </motion.div>
+    </div>
+  )
+}
+ 
+ +
+
+ + + + + + + + \ No newline at end of file diff --git a/coverage/lcov-report/pages/Register.jsx.html b/coverage/lcov-report/pages/Register.jsx.html new file mode 100644 index 0000000..3264686 --- /dev/null +++ b/coverage/lcov-report/pages/Register.jsx.html @@ -0,0 +1,337 @@ + + + + + + Code coverage report for pages/Register.jsx + + + + + + + + + +
+
+

All files / pages Register.jsx

+
+ +
+ 100% + Statements + 23/23 +
+ + +
+ 100% + Branches + 10/10 +
+ + +
+ 100% + Functions + 7/7 +
+ + +
+ 100% + Lines + 22/22 +
+ + +
+

+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +

+ +
+
+

+
1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 +16 +17 +18 +19 +20 +21 +22 +23 +24 +25 +26 +27 +28 +29 +30 +31 +32 +33 +34 +35 +36 +37 +38 +39 +40 +41 +42 +43 +44 +45 +46 +47 +48 +49 +50 +51 +52 +53 +54 +55 +56 +57 +58 +59 +60 +61 +62 +63 +64 +65 +66 +67 +68 +69 +70 +71 +72 +73 +74 +75 +76 +77 +78 +79 +80 +81 +82 +83 +84 +85  +  +  +  +  +  +  +22x +22x +22x +22x +22x +22x +  +22x +22x +  +22x +3x +3x +3x +  +3x +3x +1x +  +2x +  +3x +  +  +  +22x +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +3x +  +  +  +  +3x +  +  +  +  +  +3x +1x +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  + 
import { useState } from 'react'
+import { Link, useNavigate } from 'react-router-dom'
+import { motion } from 'framer-motion'
+import { Eye, EyeOff, Sparkles } from 'lucide-react'
+import { useAuthStore } from '../store/auth'
+ 
+export default function Register() {
+  const [email, setEmail] = useState('')
+  const [username, setUsername] = useState('')
+  const [password, setPassword] = useState('')
+  const [showPassword, setShowPassword] = useState(false)
+  const [error, setError] = useState('')
+  const [loading, setLoading] = useState(false)
+  
+  const register = useAuthStore(s => s.register)
+  const navigate = useNavigate()
+ 
+  const handleSubmit = async (e) => {
+    e.preventDefault()
+    setError('')
+    setLoading(true)
+ 
+    try {
+      await register(email, username, password)
+      navigate('/')
+    } catch (err) {
+      setError(err.response?.data?.error || 'Ошибка регистрации')
+    } finally {
+      setLoading(false)
+    }
+  }
+ 
+  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 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">
+            <Sparkles className="w-8 h-8 text-white" />
+          </motion.div>
+          <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 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 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 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 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 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">
+              {loading ? 'Создаём...' : 'Создать аккаунт'}
+            </button>
+          </form>
+ 
+          <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>
+    </div>
+  )
+}
+ 
+ +
+
+ + + + + + + + \ No newline at end of file diff --git a/coverage/lcov-report/pages/ResetPassword.jsx.html b/coverage/lcov-report/pages/ResetPassword.jsx.html new file mode 100644 index 0000000..4e50c82 --- /dev/null +++ b/coverage/lcov-report/pages/ResetPassword.jsx.html @@ -0,0 +1,505 @@ + + + + + + Code coverage report for pages/ResetPassword.jsx + + + + + + + + + +
+
+

All files / pages ResetPassword.jsx

+
+ +
+ 96.29% + Statements + 26/27 +
+ + +
+ 92.85% + Branches + 13/14 +
+ + +
+ 80% + Functions + 4/5 +
+ + +
+ 100% + Lines + 26/26 +
+ + +
+

+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +

+ +
+
+

+
1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 +16 +17 +18 +19 +20 +21 +22 +23 +24 +25 +26 +27 +28 +29 +30 +31 +32 +33 +34 +35 +36 +37 +38 +39 +40 +41 +42 +43 +44 +45 +46 +47 +48 +49 +50 +51 +52 +53 +54 +55 +56 +57 +58 +59 +60 +61 +62 +63 +64 +65 +66 +67 +68 +69 +70 +71 +72 +73 +74 +75 +76 +77 +78 +79 +80 +81 +82 +83 +84 +85 +86 +87 +88 +89 +90 +91 +92 +93 +94 +95 +96 +97 +98 +99 +100 +101 +102 +103 +104 +105 +106 +107 +108 +109 +110 +111 +112 +113 +114 +115 +116 +117 +118 +119 +120 +121 +122 +123 +124 +125 +126 +127 +128 +129 +130 +131 +132 +133 +134 +135 +136 +137 +138 +139 +140 +141  +  +  +  +  +  +  +18x +18x +18x +18x +18x +18x +18x +  +18x +  +18x +4x +4x +1x +1x +  +  +3x +3x +  +3x +3x +2x +2x +  +1x +  +3x +  +  +  +18x +2x +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +16x +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +4x +  +  +  +  +  +  +  +1x +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  + 
import { useState } from 'react'
+import { useSearchParams, Link, useNavigate } from 'react-router-dom'
+import { motion } from 'framer-motion'
+import { Eye, EyeOff, Zap, CheckCircle } from 'lucide-react'
+import api from '../api/client'
+ 
+export default function ResetPassword() {
+  const [searchParams] = useSearchParams()
+  const [password, setPassword] = useState('')
+  const [showPassword, setShowPassword] = useState(false)
+  const [loading, setLoading] = useState(false)
+  const [error, setError] = useState('')
+  const [success, setSuccess] = useState(false)
+  const navigate = useNavigate()
+  
+  const token = searchParams.get('token')
+ 
+  const handleSubmit = async (e) => {
+    e.preventDefault()
+    if (!token) {
+      setError('Токен не найден')
+      return
+    }
+ 
+    setError('')
+    setLoading(true)
+ 
+    try {
+      await api.post('/auth/reset-password', { token, new_password: password })
+      setSuccess(true)
+      setTimeout(() => navigate('/login'), 2000)
+    } catch (err) {
+      setError(err.response?.data?.error || 'Ошибка сброса пароля')
+    } finally {
+      setLoading(false)
+    }
+  }
+ 
+  if (success) {
+    return (
+      <div className="min-h-screen flex items-center justify-center p-4 gradient-mesh bg-surface-50">
+        <motion.div
+          initial={{ opacity: 0, scale: 0.9 }}
+          animate={{ opacity: 1, scale: 1 }}
+          className="card p-10 text-center max-w-md w-full"
+        >
+          <motion.div
+            initial={{ scale: 0 }}
+            animate={{ scale: 1 }}
+            transition={{ type: 'spring', stiffness: 200 }}
+            className="w-20 h-20 rounded-3xl bg-green-100 flex items-center justify-center mx-auto mb-6"
+          >
+            <CheckCircle className="w-10 h-10 text-green-600" />
+          </motion.div>
+          <h1 className="text-2xl font-display font-bold text-gray-900 mb-2">
+            Пароль изменён! 🎉
+          </h1>
+          <p className="text-gray-500">Перенаправляем на страницу входа...</p>
+        </motion.div>
+      </div>
+    )
+  }
+ 
+  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 }}
+        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-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>
+          <h1 className="text-3xl font-display font-bold text-gray-900">
+            Новый пароль
+          </h1>
+          <p className="text-gray-500 mt-2">Придумай новый надёжный пароль</p>
+        </div>
+ 
+        <div 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"
+              >
+                {error}
+              </motion.div>
+            )}
+ 
+            <div>
+              <label className="block text-sm font-semibold text-gray-700 mb-2">
+                Новый пароль
+              </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-4 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-600 transition-colors"
+                >
+                  {showPassword ? <EyeOff size={20} /> : <Eye size={20} />}
+                </button>
+              </div>
+            </div>
+ 
+            <button
+              type="submit"
+              disabled={loading}
+              className="btn btn-primary w-full text-lg"
+            >
+              {loading ? 'Сохраняем...' : 'Сохранить пароль'}
+            </button>
+          </form>
+ 
+          <div className="mt-6 text-center">
+            <Link to="/login" className="text-primary-600 hover:text-primary-700 font-medium text-sm">
+              Вернуться ко входу
+            </Link>
+          </div>
+        </div>
+      </motion.div>
+    </div>
+  )
+}
+ 
+ +
+
+ + + + + + + + \ No newline at end of file diff --git a/coverage/lcov-report/pages/Savings.jsx.html b/coverage/lcov-report/pages/Savings.jsx.html new file mode 100644 index 0000000..700891a --- /dev/null +++ b/coverage/lcov-report/pages/Savings.jsx.html @@ -0,0 +1,4273 @@ + + + + + + Code coverage report for pages/Savings.jsx + + + + + + + + + +
+
+

All files / pages Savings.jsx

+
+ +
+ 15.88% + Statements + 44/277 +
+ + +
+ 14.07% + Branches + 38/270 +
+ + +
+ 9.09% + Functions + 11/121 +
+ + +
+ 17.4% + Lines + 43/247 +
+ + +
+

+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +

+ +
+
+

+
1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 +16 +17 +18 +19 +20 +21 +22 +23 +24 +25 +26 +27 +28 +29 +30 +31 +32 +33 +34 +35 +36 +37 +38 +39 +40 +41 +42 +43 +44 +45 +46 +47 +48 +49 +50 +51 +52 +53 +54 +55 +56 +57 +58 +59 +60 +61 +62 +63 +64 +65 +66 +67 +68 +69 +70 +71 +72 +73 +74 +75 +76 +77 +78 +79 +80 +81 +82 +83 +84 +85 +86 +87 +88 +89 +90 +91 +92 +93 +94 +95 +96 +97 +98 +99 +100 +101 +102 +103 +104 +105 +106 +107 +108 +109 +110 +111 +112 +113 +114 +115 +116 +117 +118 +119 +120 +121 +122 +123 +124 +125 +126 +127 +128 +129 +130 +131 +132 +133 +134 +135 +136 +137 +138 +139 +140 +141 +142 +143 +144 +145 +146 +147 +148 +149 +150 +151 +152 +153 +154 +155 +156 +157 +158 +159 +160 +161 +162 +163 +164 +165 +166 +167 +168 +169 +170 +171 +172 +173 +174 +175 +176 +177 +178 +179 +180 +181 +182 +183 +184 +185 +186 +187 +188 +189 +190 +191 +192 +193 +194 +195 +196 +197 +198 +199 +200 +201 +202 +203 +204 +205 +206 +207 +208 +209 +210 +211 +212 +213 +214 +215 +216 +217 +218 +219 +220 +221 +222 +223 +224 +225 +226 +227 +228 +229 +230 +231 +232 +233 +234 +235 +236 +237 +238 +239 +240 +241 +242 +243 +244 +245 +246 +247 +248 +249 +250 +251 +252 +253 +254 +255 +256 +257 +258 +259 +260 +261 +262 +263 +264 +265 +266 +267 +268 +269 +270 +271 +272 +273 +274 +275 +276 +277 +278 +279 +280 +281 +282 +283 +284 +285 +286 +287 +288 +289 +290 +291 +292 +293 +294 +295 +296 +297 +298 +299 +300 +301 +302 +303 +304 +305 +306 +307 +308 +309 +310 +311 +312 +313 +314 +315 +316 +317 +318 +319 +320 +321 +322 +323 +324 +325 +326 +327 +328 +329 +330 +331 +332 +333 +334 +335 +336 +337 +338 +339 +340 +341 +342 +343 +344 +345 +346 +347 +348 +349 +350 +351 +352 +353 +354 +355 +356 +357 +358 +359 +360 +361 +362 +363 +364 +365 +366 +367 +368 +369 +370 +371 +372 +373 +374 +375 +376 +377 +378 +379 +380 +381 +382 +383 +384 +385 +386 +387 +388 +389 +390 +391 +392 +393 +394 +395 +396 +397 +398 +399 +400 +401 +402 +403 +404 +405 +406 +407 +408 +409 +410 +411 +412 +413 +414 +415 +416 +417 +418 +419 +420 +421 +422 +423 +424 +425 +426 +427 +428 +429 +430 +431 +432 +433 +434 +435 +436 +437 +438 +439 +440 +441 +442 +443 +444 +445 +446 +447 +448 +449 +450 +451 +452 +453 +454 +455 +456 +457 +458 +459 +460 +461 +462 +463 +464 +465 +466 +467 +468 +469 +470 +471 +472 +473 +474 +475 +476 +477 +478 +479 +480 +481 +482 +483 +484 +485 +486 +487 +488 +489 +490 +491 +492 +493 +494 +495 +496 +497 +498 +499 +500 +501 +502 +503 +504 +505 +506 +507 +508 +509 +510 +511 +512 +513 +514 +515 +516 +517 +518 +519 +520 +521 +522 +523 +524 +525 +526 +527 +528 +529 +530 +531 +532 +533 +534 +535 +536 +537 +538 +539 +540 +541 +542 +543 +544 +545 +546 +547 +548 +549 +550 +551 +552 +553 +554 +555 +556 +557 +558 +559 +560 +561 +562 +563 +564 +565 +566 +567 +568 +569 +570 +571 +572 +573 +574 +575 +576 +577 +578 +579 +580 +581 +582 +583 +584 +585 +586 +587 +588 +589 +590 +591 +592 +593 +594 +595 +596 +597 +598 +599 +600 +601 +602 +603 +604 +605 +606 +607 +608 +609 +610 +611 +612 +613 +614 +615 +616 +617 +618 +619 +620 +621 +622 +623 +624 +625 +626 +627 +628 +629 +630 +631 +632 +633 +634 +635 +636 +637 +638 +639 +640 +641 +642 +643 +644 +645 +646 +647 +648 +649 +650 +651 +652 +653 +654 +655 +656 +657 +658 +659 +660 +661 +662 +663 +664 +665 +666 +667 +668 +669 +670 +671 +672 +673 +674 +675 +676 +677 +678 +679 +680 +681 +682 +683 +684 +685 +686 +687 +688 +689 +690 +691 +692 +693 +694 +695 +696 +697 +698 +699 +700 +701 +702 +703 +704 +705 +706 +707 +708 +709 +710 +711 +712 +713 +714 +715 +716 +717 +718 +719 +720 +721 +722 +723 +724 +725 +726 +727 +728 +729 +730 +731 +732 +733 +734 +735 +736 +737 +738 +739 +740 +741 +742 +743 +744 +745 +746 +747 +748 +749 +750 +751 +752 +753 +754 +755 +756 +757 +758 +759 +760 +761 +762 +763 +764 +765 +766 +767 +768 +769 +770 +771 +772 +773 +774 +775 +776 +777 +778 +779 +780 +781 +782 +783 +784 +785 +786 +787 +788 +789 +790 +791 +792 +793 +794 +795 +796 +797 +798 +799 +800 +801 +802 +803 +804 +805 +806 +807 +808 +809 +810 +811 +812 +813 +814 +815 +816 +817 +818 +819 +820 +821 +822 +823 +824 +825 +826 +827 +828 +829 +830 +831 +832 +833 +834 +835 +836 +837 +838 +839 +840 +841 +842 +843 +844 +845 +846 +847 +848 +849 +850 +851 +852 +853 +854 +855 +856 +857 +858 +859 +860 +861 +862 +863 +864 +865 +866 +867 +868 +869 +870 +871 +872 +873 +874 +875 +876 +877 +878 +879 +880 +881 +882 +883 +884 +885 +886 +887 +888 +889 +890 +891 +892 +893 +894 +895 +896 +897 +898 +899 +900 +901 +902 +903 +904 +905 +906 +907 +908 +909 +910 +911 +912 +913 +914 +915 +916 +917 +918 +919 +920 +921 +922 +923 +924 +925 +926 +927 +928 +929 +930 +931 +932 +933 +934 +935 +936 +937 +938 +939 +940 +941 +942 +943 +944 +945 +946 +947 +948 +949 +950 +951 +952 +953 +954 +955 +956 +957 +958 +959 +960 +961 +962 +963 +964 +965 +966 +967 +968 +969 +970 +971 +972 +973 +974 +975 +976 +977 +978 +979 +980 +981 +982 +983 +984 +985 +986 +987 +988 +989 +990 +991 +992 +993 +994 +995 +996 +997 +998 +999 +1000 +1001 +1002 +1003 +1004 +1005 +1006 +1007 +1008 +1009 +1010 +1011 +1012 +1013 +1014 +1015 +1016 +1017 +1018 +1019 +1020 +1021 +1022 +1023 +1024 +1025 +1026 +1027 +1028 +1029 +1030 +1031 +1032 +1033 +1034 +1035 +1036 +1037 +1038 +1039 +1040 +1041 +1042 +1043 +1044 +1045 +1046 +1047 +1048 +1049 +1050 +1051 +1052 +1053 +1054 +1055 +1056 +1057 +1058 +1059 +1060 +1061 +1062 +1063 +1064 +1065 +1066 +1067 +1068 +1069 +1070 +1071 +1072 +1073 +1074 +1075 +1076 +1077 +1078 +1079 +1080 +1081 +1082 +1083 +1084 +1085 +1086 +1087 +1088 +1089 +1090 +1091 +1092 +1093 +1094 +1095 +1096 +1097 +1098 +1099 +1100 +1101 +1102 +1103 +1104 +1105 +1106 +1107 +1108 +1109 +1110 +1111 +1112 +1113 +1114 +1115 +1116 +1117 +1118 +1119 +1120 +1121 +1122 +1123 +1124 +1125 +1126 +1127 +1128 +1129 +1130 +1131 +1132 +1133 +1134 +1135 +1136 +1137 +1138 +1139 +1140 +1141 +1142 +1143 +1144 +1145 +1146 +1147 +1148 +1149 +1150 +1151 +1152 +1153 +1154 +1155 +1156 +1157 +1158 +1159 +1160 +1161 +1162 +1163 +1164 +1165 +1166 +1167 +1168 +1169 +1170 +1171 +1172 +1173 +1174 +1175 +1176 +1177 +1178 +1179 +1180 +1181 +1182 +1183 +1184 +1185 +1186 +1187 +1188 +1189 +1190 +1191 +1192 +1193 +1194 +1195 +1196 +1197 +1198 +1199 +1200 +1201 +1202 +1203 +1204 +1205 +1206 +1207 +1208 +1209 +1210 +1211 +1212 +1213 +1214 +1215 +1216 +1217 +1218 +1219 +1220 +1221 +1222 +1223 +1224 +1225 +1226 +1227 +1228 +1229 +1230 +1231 +1232 +1233 +1234 +1235 +1236 +1237 +1238 +1239 +1240 +1241 +1242 +1243 +1244 +1245 +1246 +1247 +1248 +1249 +1250 +1251 +1252 +1253 +1254 +1255 +1256 +1257 +1258 +1259 +1260 +1261 +1262 +1263 +1264 +1265 +1266 +1267 +1268 +1269 +1270 +1271 +1272 +1273 +1274 +1275 +1276 +1277 +1278 +1279 +1280 +1281 +1282 +1283 +1284 +1285 +1286 +1287 +1288 +1289 +1290 +1291 +1292 +1293 +1294 +1295 +1296 +1297 +1298 +1299 +1300 +1301 +1302 +1303 +1304 +1305 +1306 +1307 +1308 +1309 +1310 +1311 +1312 +1313 +1314 +1315 +1316 +1317 +1318 +1319 +1320 +1321 +1322 +1323 +1324 +1325 +1326 +1327 +1328 +1329 +1330 +1331 +1332 +1333 +1334 +1335 +1336 +1337 +1338 +1339 +1340 +1341 +1342 +1343 +1344 +1345 +1346 +1347 +1348 +1349 +1350 +1351 +1352 +1353 +1354 +1355 +1356 +1357 +1358 +1359 +1360 +1361 +1362 +1363 +1364 +1365 +1366 +1367 +1368 +1369 +1370 +1371 +1372 +1373 +1374 +1375 +1376 +1377 +1378 +1379 +1380 +1381 +1382 +1383 +1384 +1385 +1386 +1387 +1388 +1389 +1390 +1391 +1392 +1393 +1394 +1395 +1396 +1397  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +4x +  +  +  +  +  +  +  +  +5x +  +  +  +  +  +5x +  +  +15x +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +1x +1x +1x +1x +  +1x +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +1x +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +1x +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +5x +5x +5x +5x +5x +5x +5x +5x +5x +5x +5x +5x +  +5x +  +  +  +  +5x +  +4x +  +  +5x +  +  +  +  +5x +  +  +  +  +  +  +  +  +5x +  +  +  +  +  +  +  +  +5x +  +  +  +  +  +  +  +  +5x +  +  +  +  +  +  +  +5x +  +  +  +  +  +  +  +5x +  +  +  +  +  +  +  +5x +  +  +  +  +5x +  +  +  +  +5x +  +  +  +  +5x +  +  +  +  +  +  +  +5x +  +  +  +  +5x +  +  +  +  +5x +  +  +  +  +  +  +  +5x +  +  +  +  +  +  +  +1x +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +12x +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  + 
import { useState, useEffect } from "react"
+import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"
+import { motion, AnimatePresence } from "framer-motion"
+import {
+  Plus,
+  PiggyBank,
+  TrendingUp,
+  Wallet,
+  Target,
+  Users,
+  X,
+  Calendar,
+  ArrowUpCircle,
+  ArrowDownCircle,
+  AlertTriangle,
+  CreditCard,
+  Edit2,
+  Trash2,
+  LayoutDashboard,
+  FolderOpen,
+  Receipt,
+  User,
+  Settings,
+} from "lucide-react"
+import { format } from "date-fns"
+import { ru } from "date-fns/locale"
+import { savingsApi } from "../api/savings"
+import Navigation from "../components/Navigation"
+import clsx from "clsx"
+ 
+function formatCurrency(amount) {
+  return new Intl.NumberFormat("ru-RU", {
+    style: "currency",
+    currency: "RUB",
+    maximumFractionDigits: 0,
+  }).format(amount)
+}
+ 
+// ==================== TAB NAVIGATION ====================
+function TabNav({ activeTab, setActiveTab }) {
+  const tabs = [
+    { id: "dashboard", label: "Обзор", icon: LayoutDashboard },
+    { id: "categories", label: "Категории", icon: FolderOpen },
+    { id: "transactions", label: "Операции", icon: Receipt },
+  ]
+ 
+  return (
+    <div className="flex gap-1 p-1 bg-gray-100 dark:bg-gray-800 rounded-xl mb-6">
+      {tabs.map((tab) => (
+        <button
+          key={tab.id}
+          onClick={() => setActiveTab(tab.id)}
+          className={clsx(
+            "flex-1 flex items-center justify-center gap-2 py-2.5 px-4 rounded-lg font-medium transition-all text-sm",
+            activeTab === tab.id
+              ? "bg-white dark:bg-gray-700 text-violet-600 dark:text-violet-400 shadow-sm"
+              : "text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-300"
+          )}
+        >
+          <tab.icon size={18} />
+          <span className="hidden sm:inline">{tab.label}</span>
+        </button>
+      ))}
+    </div>
+  )
+}
+ 
+// ==================== DASHBOARD TAB ====================
+function DashboardTab({ categories, stats }) {
+  const totalBalance = stats?.total_balance || 0
+  const monthlyPaymentDetails = stats?.monthly_payment_details || []
+  const overdues = stats?.overdues || []
+  const activeCategories = categories.filter((c) => !c.is_closed)
+ 
+  return (
+    <div className="space-y-6">
+      {/* 1. Total Balance Card */}
+      <div className="card p-5 bg-gradient-to-br from-violet-500 to-purple-600 text-white">
+        <div className="text-sm opacity-80 mb-1">Общий баланс</div>
+        <div className="text-3xl font-bold">{formatCurrency(totalBalance)}</div>
+        <div className="mt-3 flex gap-4 text-sm opacity-80">
+          <span>📥 Пополнения: {formatCurrency(stats?.total_deposits || 0)}</span>
+          <span>📤 Снятия: {formatCurrency(stats?.total_withdrawals || 0)}</span>
+        </div>
+      </div>
+ 
+      {/* 2. Active Categories Count */}
+      <div className="grid grid-cols-2 gap-3">
+        <div className="card p-4 text-center">
+          <div className="text-2xl font-bold text-gray-900 dark:text-white">
+            {activeCategories.length}
+          </div>
+          <div className="text-sm text-gray-500 dark:text-gray-400">
+            Активных категорий
+          </div>
+        </div>
+        <div className="card p-4 text-center">
+          <div className="text-2xl font-bold text-emerald-600 dark:text-emerald-400">
+            {categories.filter((c) => c.is_deposit && !c.is_closed).length}
+          </div>
+          <div className="text-sm text-gray-500 dark:text-gray-400">Депозитов</div>
+        </div>
+      </div>
+ 
+      {/* 3. Category Progress */}
+      <div className="card p-4">
+        <h3 className="font-semibold text-gray-900 dark:text-white mb-3">
+          Прогресс по категориям
+        </h3>
+        <div className="space-y-3">
+          {activeCategories.length === 0 ? (
+            <p className="text-gray-500 dark:text-gray-400">Нет активных категорий</p>
+          ) : (
+            activeCategories.slice(0, 6).map((cat) => (
+              <div key={cat.id} className="flex items-center gap-3">
+                <div
+                  className={clsx(
+                    "w-8 h-8 rounded-lg flex items-center justify-center text-white",
+                    cat.is_deposit
+                      ? "bg-amber-500"
+                      : cat.is_credit
+                      ? "bg-orange-500"
+                      : cat.is_recurring
+                      ? "bg-emerald-500"
+                      : "bg-violet-500"
+                  )}
+                >
+                  {cat.is_deposit ? (
+                    <TrendingUp size={16} />
+                  ) : cat.is_credit ? (
+                    <CreditCard size={16} />
+                  ) : cat.is_recurring ? (
+                    <Target size={16} />
+                  ) : (
+                    <PiggyBank size={16} />
+                  )}
+                </div>
+                <div className="flex-1 min-w-0">
+                  <div className="text-sm font-medium text-gray-900 dark:text-white truncate flex items-center gap-1">
+                    {cat.name}
+                    {cat.is_multi && (
+                      <span className="text-xs text-blue-500">(общая)</span>
+                    )}
+                  </div>
+                </div>
+                <div className="text-sm font-semibold text-gray-900 dark:text-white">
+                  {formatCurrency(cat.current_amount)}
+                </div>
+              </div>
+            ))
+          )}
+        </div>
+      </div>
+ 
+      {/* 4. Monthly Payments Block */}
+      {monthlyPaymentDetails.length > 0 && (
+        <div className="card p-4">
+          <h3 className="font-semibold text-gray-900 dark:text-white mb-3 flex items-center gap-2">
+            <Calendar className="w-5 h-5 text-blue-500" />
+            Ежемесячные платежи
+            <span className="ml-auto text-lg text-blue-600 dark:text-blue-400 font-bold">
+              {formatCurrency(stats?.monthly_payments || 0)}
+            </span>
+          </h3>
+          <div className="space-y-3">
+            {monthlyPaymentDetails.map((detail) => (
+              <div
+                key={detail.category_id}
+                className="flex items-center justify-between py-2 border-b border-gray-100 dark:border-gray-800 last:border-0"
+              >
+                <div>
+                  <div className="font-medium text-gray-900 dark:text-white">
+                    {detail.category_name}
+                  </div>
+                  <div className="text-xs text-gray-500 dark:text-gray-400">
+                    К оплате до {detail.day} числа
+                  </div>
+                </div>
+                <div className="text-right">
+                  <div className="font-semibold text-blue-600 dark:text-blue-400">
+                    {formatCurrency(detail.amount)}
+                  </div>
+                </div>
+              </div>
+            ))}
+          </div>
+        </div>
+      )}
+ 
+      {/* 5. Overdues Block */}
+      {overdues.length > 0 && (
+        <div className="card p-4 border-2 border-red-200 dark:border-red-800 bg-red-50 dark:bg-red-900/20">
+          <h3 className="font-semibold text-red-600 dark:text-red-400 mb-3 flex items-center gap-2">
+            <AlertTriangle className="w-5 h-5" />
+            Просрочки ({overdues.length})
+          </h3>
+          <div className="space-y-3">
+            {overdues.map((overdue) => (
+              <div
+                key={`${overdue.category_id}-${overdue.month || overdue.days_overdue}`}
+                className="flex items-center justify-between py-2 border-b border-red-200 dark:border-red-800 last:border-0"
+              >
+                <div>
+                  <div className="font-medium text-gray-900 dark:text-white flex items-center gap-2">
+                    {overdue.category_name}
+                    {overdue.month && (
+                      <span className="text-xs font-normal text-red-400 bg-red-100 dark:bg-red-900/40 px-1.5 py-0.5 rounded">
+                        {overdue.month}
+                      </span>
+                    )}
+                  </div>
+                  <div className="text-xs text-gray-500 dark:text-gray-400">
+                    Просрочено {overdue.days_overdue} дн. · до {overdue.due_day} числа
+                  </div>
+                </div>
+                <div className="text-right">
+                  <div className="font-semibold text-red-600 dark:text-red-400">
+                    {formatCurrency(overdue.amount)}
+                  </div>
+                </div>
+              </div>
+            ))}
+          </div>
+        </div>
+      )}
+    </div>
+  )
+}
+ 
+// ==================== CATEGORIES TAB ====================
+function CategoriesTab({ categories, onEdit, onDelete, onManagePlans }) {
+  const [activeSubTab, setActiveSubTab] = useState("active")
+ 
+  const filteredCategories = categories.filter((c) =>
+    activeSubTab === "active" ? !c.is_closed : c.is_closed
+  )
+ 
+  const getTypeInfo = (category) => {
+    if (category.is_deposit)
+      return { icon: TrendingUp, label: "Депозит", color: "from-amber-500 to-orange-500" }
+    if (category.is_credit)
+      return { icon: CreditCard, label: "Кредит", color: "from-orange-500 to-red-500" }
+    if (category.is_account)
+      return { icon: Wallet, label: "Счёт", color: "from-blue-500 to-indigo-500" }
+    if (category.is_recurring)
+      return { icon: Target, label: "Регулярные", color: "from-emerald-500 to-teal-500" }
+    return { icon: PiggyBank, label: "Накопление", color: "from-violet-500 to-purple-500" }
+  }
+ 
+  return (
+    <div className="space-y-4">
+      {/* Sub-tabs */}
+      <div className="flex gap-2">
+        <button
+          onClick={() => setActiveSubTab("active")}
+          className={clsx(
+            "px-4 py-2 rounded-lg text-sm font-medium transition",
+            activeSubTab === "active"
+              ? "bg-violet-100 dark:bg-violet-900/30 text-violet-600 dark:text-violet-400"
+              : "bg-gray-100 dark:bg-gray-800 text-gray-600 dark:text-gray-400"
+          )}
+        >
+          📂 Активные ({categories.filter((c) => !c.is_closed).length})
+        </button>
+        <button
+          onClick={() => setActiveSubTab("closed")}
+          className={clsx(
+            "px-4 py-2 rounded-lg text-sm font-medium transition",
+            activeSubTab === "closed"
+              ? "bg-violet-100 dark:bg-violet-900/30 text-violet-600 dark:text-violet-400"
+              : "bg-gray-100 dark:bg-gray-800 text-gray-600 dark:text-gray-400"
+          )}
+        >
+          🗃️ Закрытые ({categories.filter((c) => c.is_closed).length})
+        </button>
+      </div>
+ 
+      {/* Categories List */}
+      {filteredCategories.length === 0 ? (
+        <div className="card p-8 text-center">
+          <PiggyBank className="w-12 h-12 mx-auto mb-3 text-gray-300 dark:text-gray-600" />
+          <p className="text-gray-500 dark:text-gray-400">
+            {activeSubTab === "active" ? "Нет активных категорий" : "Нет закрытых категорий"}
+          </p>
+        </div>
+      ) : (
+        <div className="space-y-3">
+          {filteredCategories.map((category) => {
+            const typeInfo = getTypeInfo(category)
+            const Icon = typeInfo.icon
+ 
+            return (
+              <motion.div
+                key={category.id}
+                initial={{ opacity: 0, y: 10 }}
+                animate={{ opacity: 1, y: 0 }}
+                className="card p-4"
+              >
+                <div className="flex items-start gap-4">
+                  <div
+                    className={clsx(
+                      "w-12 h-12 rounded-xl bg-gradient-to-br flex items-center justify-center text-white",
+                      typeInfo.color
+                    )}
+                  >
+                    <Icon className="w-6 h-6" />
+                  </div>
+                  <div className="flex-1 min-w-0">
+                    <div className="flex items-center gap-2 mb-1">
+                      <h3 className="font-semibold text-gray-900 dark:text-white truncate">
+                        {category.name}
+                      </h3>
+                      {category.is_multi && (
+                        <span className="flex items-center gap-1 text-xs text-blue-600 dark:text-blue-400 bg-blue-50 dark:bg-blue-900/30 px-2 py-0.5 rounded-full">
+                          <Users size={12} />
+                        </span>
+                      )}
+                    </div>
+                    <div className="text-xs text-gray-500 dark:text-gray-400 mb-2">
+                      {typeInfo.label}
+                    </div>
+                    <div className="text-xl font-bold text-gray-900 dark:text-white">
+                      {formatCurrency(category.current_amount)}
+                    </div>
+ 
+                    {/* Deposit info */}
+                    {category.is_deposit && category.interest_rate > 0 && (
+                      <div className="mt-2 text-xs text-amber-600 dark:text-amber-400">
+                        📈 {category.interest_rate}% годовых
+                        {category.deposit_term > 0 && ` · ${category.deposit_term} мес.`}
+                      </div>
+                    )}
+                  </div>
+ 
+                  {/* Actions */}
+                  {!category.is_closed && (
+                    <div className="flex gap-1">
+                      {category.is_recurring && (
+                        <button
+                          onClick={() => onManagePlans(category)}
+                          className="p-2 text-gray-400 hover:text-emerald-500 hover:bg-emerald-50 dark:hover:bg-emerald-900/20 rounded-lg transition"
+                          title="Управление планами"
+                        >
+                          <Settings size={18} />
+                        </button>
+                      )}
+                      <button
+                        onClick={() => onEdit(category)}
+                        className="p-2 text-gray-400 hover:text-violet-500 hover:bg-violet-50 dark:hover:bg-violet-900/20 rounded-lg transition"
+                      >
+                        <Edit2 size={18} />
+                      </button>
+                      <button
+                        onClick={() => onDelete(category)}
+                        className="p-2 text-gray-400 hover:text-red-500 hover:bg-red-50 dark:hover:bg-red-900/20 rounded-lg transition"
+                      >
+                        <Trash2 size={18} />
+                      </button>
+                    </div>
+                  )}
+                </div>
+              </motion.div>
+            )
+          })}
+        </div>
+      )}
+    </div>
+  )
+}
+ 
+// ==================== TRANSACTIONS TAB ====================
+function TransactionsTab({ transactions, categories, onAddTransaction, onEditTransaction, onDeleteTransaction }) {
+  const [filter, setFilter] = useState("")
+ 
+  const filteredTransactions = filter
+    ? transactions.filter((t) => t.category_id === parseInt(filter))
+    : transactions
+ 
+  // Debug: log to check for duplicates
+  useEffect(() => {
+    console.log("[Savings] Transactions loaded:", transactions.length, transactions)
+  }, [transactions])
+ 
+  return (
+    <div className="space-y-4">
+      {/* Filter */}
+      <div className="flex gap-3">
+        <select
+          value={filter}
+          onChange={(e) => setFilter(e.target.value)}
+          className="input flex-1"
+        >
+          <option value="">Все категории</option>
+          {categories.map((c) => (
+            <option key={c.id} value={c.id}>
+              {c.name}
+            </option>
+          ))}
+        </select>
+        <button onClick={onAddTransaction} className="btn btn-primary">
+          <Plus size={18} />
+        </button>
+      </div>
+ 
+      {/* Transactions List */}
+      <div className="card divide-y divide-gray-100 dark:divide-gray-800">
+        {filteredTransactions.length === 0 ? (
+          <div className="p-8 text-center">
+            <Receipt className="w-12 h-12 mx-auto mb-3 text-gray-300 dark:text-gray-600" />
+            <p className="text-gray-500 dark:text-gray-400">Нет операций</p>
+          </div>
+        ) : (
+          filteredTransactions.slice(0, 50).map((tx) => {
+            const isDeposit = tx.type === "deposit"
+            return (
+              <div key={tx.id} className="flex items-center gap-4 p-4 group">
+                <div
+                  className={clsx(
+                    "w-10 h-10 rounded-xl flex items-center justify-center",
+                    isDeposit
+                      ? "bg-emerald-100 dark:bg-emerald-900/30"
+                      : "bg-rose-100 dark:bg-rose-900/30"
+                  )}
+                >
+                  {isDeposit ? (
+                    <ArrowDownCircle className="w-5 h-5 text-emerald-600 dark:text-emerald-400" />
+                  ) : (
+                    <ArrowUpCircle className="w-5 h-5 text-rose-600 dark:text-rose-400" />
+                  )}
+                </div>
+                <div className="flex-1 min-w-0">
+                  <div className="font-medium text-gray-900 dark:text-white truncate">
+                    {tx.category_name || "Транзакция"}
+                  </div>
+                  <div className="text-xs text-gray-500 dark:text-gray-400 flex flex-wrap items-center gap-x-2">
+                    <span className="flex items-center gap-1">
+                      <Calendar size={10} />
+                      {format(new Date(tx.date), "d MMM yyyy", { locale: ru })}
+                    </span>
+                    {tx.user_name && (
+                      <span className="flex items-center gap-1">
+                        <User size={10} />
+                        {tx.user_name}
+                      </span>
+                    )}
+                    {tx.description && (
+                      <span className="truncate max-w-[150px]">· {tx.description}</span>
+                    )}
+                  </div>
+                </div>
+                <div
+                  className={clsx(
+                    "font-semibold",
+                    isDeposit
+                      ? "text-emerald-600 dark:text-emerald-400"
+                      : "text-rose-600 dark:text-rose-400"
+                  )}
+                >
+                  {isDeposit ? "+" : "-"}
+                  {formatCurrency(tx.amount)}
+                </div>
+                {/* Edit/Delete buttons */}
+                <div className="flex gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
+                  <button
+                    onClick={() => onEditTransaction(tx)}
+                    className="p-1.5 text-gray-400 hover:text-violet-500 hover:bg-violet-50 dark:hover:bg-violet-900/20 rounded-lg transition"
+                  >
+                    <Edit2 size={14} />
+                  </button>
+                  <button
+                    onClick={() => onDeleteTransaction(tx)}
+                    className="p-1.5 text-gray-400 hover:text-red-500 hover:bg-red-50 dark:hover:bg-red-900/20 rounded-lg transition"
+                  >
+                    <Trash2 size={14} />
+                  </button>
+                </div>
+              </div>
+            )
+          })
+        )}
+      </div>
+    </div>
+  )
+}
+ 
+// ==================== MODALS ====================
+ 
+// Transaction Modal (Create/Edit)
+function TransactionModal({ isOpen, onClose, categories, onSubmit, transaction }) {
+  const [form, setForm] = useState({
+    category_id: "",
+    amount: "",
+    type: "deposit",
+    description: "",
+    date: format(new Date(), "yyyy-MM-dd"),
+  })
+ 
+  useEffect(() => {
+    if (transaction) {
+      setForm({
+        category_id: transaction.category_id?.toString() || "",
+        amount: transaction.amount?.toString() || "",
+        type: transaction.type || "deposit",
+        description: transaction.description || "",
+        date: transaction.date ? format(new Date(transaction.date), "yyyy-MM-dd") : format(new Date(), "yyyy-MM-dd"),
+      })
+    } else {
+      setForm({
+        category_id: "",
+        amount: "",
+        type: "deposit",
+        description: "",
+        date: format(new Date(), "yyyy-MM-dd"),
+      })
+    }
+  }, [transaction, isOpen])
+ 
+  const handleSubmit = (e) => {
+    e.preventDefault()
+    onSubmit({
+      ...form,
+      category_id: parseInt(form.category_id),
+      amount: parseFloat(form.amount),
+    }, transaction?.id)
+    onClose()
+  }
+ 
+  if (!isOpen) return null
+ 
+  return (
+    <div
+      className="fixed inset-0 z-50 flex items-end sm:items-center justify-center p-4 bg-black/50"
+      onClick={onClose}
+    >
+      <motion.div
+        initial={{ opacity: 0, y: 100 }}
+        animate={{ opacity: 1, y: 0 }}
+        exit={{ opacity: 0, y: 100 }}
+        className="w-full max-w-lg bg-white dark:bg-gray-900 rounded-2xl p-6"
+        onClick={(e) => e.stopPropagation()}
+      >
+        <div className="flex justify-between items-center mb-6">
+          <h2 className="text-xl font-bold text-gray-900 dark:text-white">
+            {transaction ? "Редактировать операцию" : "Новая операция"}
+          </h2>
+          <button onClick={onClose} className="p-2 hover:bg-gray-100 dark:hover:bg-gray-800 rounded-lg">
+            <X size={20} />
+          </button>
+        </div>
+ 
+        <form onSubmit={handleSubmit} className="space-y-4">
+          <div>
+            <label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Категория</label>
+            <select
+              value={form.category_id}
+              onChange={(e) => setForm({ ...form, category_id: e.target.value })}
+              className="input w-full"
+              required
+              disabled={!!transaction}
+            >
+              <option value="">Выберите категорию</option>
+              {categories.filter((c) => !c.is_closed).map((c) => (
+                <option key={c.id} value={c.id}>{c.name}</option>
+              ))}
+            </select>
+          </div>
+ 
+          <div className="flex gap-2">
+            <button
+              type="button"
+              onClick={() => setForm({ ...form, type: "deposit" })}
+              className={clsx(
+                "flex-1 py-3 rounded-xl flex items-center justify-center gap-2 font-medium transition",
+                form.type === "deposit"
+                  ? "bg-emerald-500 text-white"
+                  : "bg-gray-100 dark:bg-gray-800 text-gray-600 dark:text-gray-400"
+              )}
+            >
+              <ArrowDownCircle size={20} /> Пополнение
+            </button>
+            <button
+              type="button"
+              onClick={() => setForm({ ...form, type: "withdrawal" })}
+              className={clsx(
+                "flex-1 py-3 rounded-xl flex items-center justify-center gap-2 font-medium transition",
+                form.type === "withdrawal"
+                  ? "bg-rose-500 text-white"
+                  : "bg-gray-100 dark:bg-gray-800 text-gray-600 dark:text-gray-400"
+              )}
+            >
+              <ArrowUpCircle size={20} /> Снятие
+            </button>
+          </div>
+ 
+          <div>
+            <label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Сумма</label>
+            <input
+              type="number"
+              value={form.amount}
+              onChange={(e) => setForm({ ...form, amount: e.target.value })}
+              className="input w-full"
+              placeholder="0"
+              min="0.01"
+              step="0.01"
+              required
+            />
+          </div>
+ 
+          <div>
+            <label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Дата</label>
+            <input
+              type="date"
+              value={form.date}
+              onChange={(e) => setForm({ ...form, date: e.target.value })}
+              className="input w-full"
+              required
+            />
+          </div>
+ 
+          <div>
+            <label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Описание (опционально)</label>
+            <input
+              type="text"
+              value={form.description}
+              onChange={(e) => setForm({ ...form, description: e.target.value })}
+              className="input w-full"
+              placeholder="Комментарий"
+            />
+          </div>
+ 
+          <button type="submit" className="btn btn-primary w-full">
+            {transaction ? "Сохранить" : "Добавить операцию"}
+          </button>
+        </form>
+      </motion.div>
+    </div>
+  )
+}
+ 
+// Delete Transaction Confirmation Modal
+function DeleteTransactionModal({ isOpen, onClose, transaction, onConfirm }) {
+  if (!isOpen || !transaction) return null
+ 
+  return (
+    <div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/50" onClick={onClose}>
+      <motion.div
+        initial={{ opacity: 0, scale: 0.95 }}
+        animate={{ opacity: 1, scale: 1 }}
+        className="w-full max-w-sm bg-white dark:bg-gray-900 rounded-2xl p-6"
+        onClick={(e) => e.stopPropagation()}
+      >
+        <div className="text-center">
+          <div className="w-16 h-16 mx-auto mb-4 bg-red-100 dark:bg-red-900/30 rounded-full flex items-center justify-center">
+            <AlertTriangle className="w-8 h-8 text-red-500" />
+          </div>
+          <h3 className="text-lg font-bold text-gray-900 dark:text-white mb-2">Удалить операцию?</h3>
+          <p className="text-gray-500 dark:text-gray-400 mb-6">
+            Операция на сумму {formatCurrency(transaction.amount)} будет удалена безвозвратно.
+          </p>
+          <div className="flex gap-3">
+            <button onClick={onClose} className="btn flex-1">Отмена</button>
+            <button
+              onClick={() => { onConfirm(transaction.id); onClose() }}
+              className="btn bg-red-500 hover:bg-red-600 text-white flex-1"
+            >
+              Удалить
+            </button>
+          </div>
+        </div>
+      </motion.div>
+    </div>
+  )
+}
+ 
+// Category Edit Modal
+function CategoryEditModal({ isOpen, onClose, category, onSubmit }) {
+  const [form, setForm] = useState({
+    name: "",
+    description: "",
+    is_deposit: false,
+    is_credit: false,
+    is_account: false,
+    is_recurring: false,
+    is_multi: false,
+    initial_capital: "",
+    deposit_amount: "",
+    interest_rate: "",
+    deposit_start_date: "",
+    deposit_term: "",
+    credit_amount: "",
+    credit_term: "",
+    credit_rate: "",
+    credit_start_date: "",
+    recurring_amount: "",
+    recurring_day: "",
+    recurring_start_date: "",
+  })
+ 
+  useEffect(() => {
+    if (category) {
+      setForm({
+        name: category.name || "",
+        description: category.description || "",
+        is_deposit: category.is_deposit || false,
+        is_credit: category.is_credit || false,
+        is_account: category.is_account || false,
+        is_recurring: category.is_recurring || false,
+        is_multi: category.is_multi || false,
+        initial_capital: category.initial_capital?.toString() || "",
+        deposit_amount: category.deposit_amount?.toString() || "",
+        interest_rate: category.interest_rate?.toString() || "",
+        deposit_start_date: category.deposit_start_date?.split("T")[0] || "",
+        deposit_term: category.deposit_term?.toString() || "",
+        credit_amount: category.credit_amount?.toString() || "",
+        credit_term: category.credit_term?.toString() || "",
+        credit_rate: category.credit_rate?.toString() || "",
+        credit_start_date: category.credit_start_date?.split("T")[0] || "",
+        recurring_amount: category.recurring_amount?.toString() || "",
+        recurring_day: category.recurring_day?.toString() || "",
+        recurring_start_date: category.recurring_start_date?.split("T")[0] || "",
+      })
+    } else {
+      setForm({
+        name: "", description: "", is_deposit: false, is_credit: false, is_account: false,
+        is_recurring: false, is_multi: false, initial_capital: "", deposit_amount: "",
+        interest_rate: "", deposit_start_date: "", deposit_term: "", credit_amount: "",
+        credit_term: "", credit_rate: "", credit_start_date: "", recurring_amount: "",
+        recurring_day: "", recurring_start_date: "",
+      })
+    }
+  }, [category, isOpen])
+ 
+  const handleSubmit = (e) => {
+    e.preventDefault()
+    const data = {
+      name: form.name,
+      description: form.description,
+      is_deposit: form.is_deposit,
+      is_credit: form.is_credit,
+      is_account: form.is_account,
+      is_recurring: form.is_recurring,
+      is_multi: form.is_multi,
+    }
+ 
+    if (form.initial_capital) data.initial_capital = parseFloat(form.initial_capital)
+ 
+    if (form.is_deposit) {
+      if (form.deposit_amount) data.deposit_amount = parseFloat(form.deposit_amount)
+      if (form.interest_rate) data.interest_rate = parseFloat(form.interest_rate)
+      if (form.deposit_start_date) data.deposit_start_date = form.deposit_start_date
+      if (form.deposit_term) data.deposit_term = parseInt(form.deposit_term)
+    }
+ 
+    if (form.is_credit) {
+      if (form.credit_amount) data.credit_amount = parseFloat(form.credit_amount)
+      if (form.credit_term) data.credit_term = parseInt(form.credit_term)
+      if (form.credit_rate) data.credit_rate = parseFloat(form.credit_rate)
+      if (form.credit_start_date) data.credit_start_date = form.credit_start_date
+    }
+ 
+    if (form.is_recurring) {
+      if (form.recurring_amount) data.recurring_amount = parseFloat(form.recurring_amount)
+      if (form.recurring_day) data.recurring_day = parseInt(form.recurring_day)
+      if (form.recurring_start_date) data.recurring_start_date = form.recurring_start_date
+    }
+ 
+    onSubmit(category?.id, data)
+    onClose()
+  }
+ 
+  if (!isOpen) return null
+ 
+  return (
+    <div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/50 overflow-y-auto" onClick={onClose}>
+      <motion.div
+        initial={{ opacity: 0, scale: 0.95 }}
+        animate={{ opacity: 1, scale: 1 }}
+        exit={{ opacity: 0, scale: 0.95 }}
+        className="w-full max-w-lg bg-white dark:bg-gray-900 rounded-2xl p-6 my-8"
+        onClick={(e) => e.stopPropagation()}
+      >
+        <div className="flex justify-between items-center mb-6">
+          <h2 className="text-xl font-bold text-gray-900 dark:text-white">
+            {category ? "Редактировать категорию" : "Новая категория"}
+          </h2>
+          <button onClick={onClose} className="p-2 hover:bg-gray-100 dark:hover:bg-gray-800 rounded-lg">
+            <X size={20} />
+          </button>
+        </div>
+ 
+        <form onSubmit={handleSubmit} className="space-y-4 max-h-[70vh] overflow-y-auto">
+          <div>
+            <label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Название</label>
+            <input type="text" value={form.name} onChange={(e) => setForm({ ...form, name: e.target.value })} className="input w-full" required />
+          </div>
+ 
+          <div>
+            <label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Описание</label>
+            <textarea value={form.description} onChange={(e) => setForm({ ...form, description: e.target.value })} className="input w-full" rows={2} />
+          </div>
+ 
+          <div>
+            <label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Начальный капитал</label>
+            <input type="number" value={form.initial_capital} onChange={(e) => setForm({ ...form, initial_capital: e.target.value })} className="input w-full" step="1000" />
+          </div>
+ 
+          {/* Type toggles */}
+          <div className="space-y-3">
+            <label className="flex items-center gap-3 cursor-pointer">
+              <input type="checkbox" checked={form.is_deposit} onChange={(e) => setForm({ ...form, is_deposit: e.target.checked, is_credit: false })} className="w-5 h-5 rounded border-gray-300" />
+              <span className="text-gray-700 dark:text-gray-300">💰 Депозит</span>
+            </label>
+            <label className="flex items-center gap-3 cursor-pointer">
+              <input type="checkbox" checked={form.is_credit} onChange={(e) => setForm({ ...form, is_credit: e.target.checked, is_deposit: false })} className="w-5 h-5 rounded border-gray-300" />
+              <span className="text-gray-700 dark:text-gray-300">💳 Кредит/Рассрочка</span>
+            </label>
+            <label className="flex items-center gap-3 cursor-pointer">
+              <input type="checkbox" checked={form.is_recurring} onChange={(e) => setForm({ ...form, is_recurring: e.target.checked })} className="w-5 h-5 rounded border-gray-300" />
+              <span className="text-gray-700 dark:text-gray-300">🔄 Повторяющаяся категория</span>
+            </label>
+            <label className="flex items-center gap-3 cursor-pointer">
+              <input type="checkbox" checked={form.is_multi} onChange={(e) => setForm({ ...form, is_multi: e.target.checked })} className="w-5 h-5 rounded border-gray-300" />
+              <span className="text-gray-700 dark:text-gray-300">👥 Несколько участников</span>
+            </label>
+          </div>
+ 
+          {/* Deposit fields */}
+          {form.is_deposit && (
+            <div className="p-4 bg-amber-50 dark:bg-amber-900/20 rounded-xl space-y-3">
+              <h4 className="font-medium text-amber-800 dark:text-amber-300">Параметры депозита</h4>
+              <div className="grid grid-cols-2 gap-3">
+                <div>
+                  <label className="block text-xs text-gray-600 dark:text-gray-400 mb-1">Сумма депозита</label>
+                  <input type="number" value={form.deposit_amount} onChange={(e) => setForm({ ...form, deposit_amount: e.target.value })} className="input w-full text-sm" step="1000" />
+                </div>
+                <div>
+                  <label className="block text-xs text-gray-600 dark:text-gray-400 mb-1">Ставка %</label>
+                  <input type="number" value={form.interest_rate} onChange={(e) => setForm({ ...form, interest_rate: e.target.value })} className="input w-full text-sm" step="0.1" />
+                </div>
+                <div>
+                  <label className="block text-xs text-gray-600 dark:text-gray-400 mb-1">Срок (мес)</label>
+                  <input type="number" value={form.deposit_term} onChange={(e) => setForm({ ...form, deposit_term: e.target.value })} className="input w-full text-sm" />
+                </div>
+                <div>
+                  <label className="block text-xs text-gray-600 dark:text-gray-400 mb-1">Дата начала</label>
+                  <input type="date" value={form.deposit_start_date} onChange={(e) => setForm({ ...form, deposit_start_date: e.target.value })} className="input w-full text-sm" />
+                </div>
+              </div>
+            </div>
+          )}
+ 
+          {/* Credit fields */}
+          {form.is_credit && (
+            <div className="p-4 bg-orange-50 dark:bg-orange-900/20 rounded-xl space-y-3">
+              <h4 className="font-medium text-orange-800 dark:text-orange-300">Параметры кредита</h4>
+              <div className="grid grid-cols-2 gap-3">
+                <div>
+                  <label className="block text-xs text-gray-600 dark:text-gray-400 mb-1">Сумма кредита</label>
+                  <input type="number" value={form.credit_amount} onChange={(e) => setForm({ ...form, credit_amount: e.target.value })} className="input w-full text-sm" step="1000" />
+                </div>
+                <div>
+                  <label className="block text-xs text-gray-600 dark:text-gray-400 mb-1">Ставка %</label>
+                  <input type="number" value={form.credit_rate} onChange={(e) => setForm({ ...form, credit_rate: e.target.value })} className="input w-full text-sm" step="0.1" />
+                </div>
+                <div>
+                  <label className="block text-xs text-gray-600 dark:text-gray-400 mb-1">Срок (мес)</label>
+                  <input type="number" value={form.credit_term} onChange={(e) => setForm({ ...form, credit_term: e.target.value })} className="input w-full text-sm" />
+                </div>
+                <div>
+                  <label className="block text-xs text-gray-600 dark:text-gray-400 mb-1">Дата начала</label>
+                  <input type="date" value={form.credit_start_date} onChange={(e) => setForm({ ...form, credit_start_date: e.target.value })} className="input w-full text-sm" />
+                </div>
+              </div>
+            </div>
+          )}
+ 
+          {/* Recurring fields */}
+          {form.is_recurring && (
+            <div className="p-4 bg-emerald-50 dark:bg-emerald-900/20 rounded-xl space-y-3">
+              <h4 className="font-medium text-emerald-800 dark:text-emerald-300">Параметры повторения</h4>
+              <div className="grid grid-cols-2 gap-3">
+                <div>
+                  <label className="block text-xs text-gray-600 dark:text-gray-400 mb-1">Сумма/месяц</label>
+                  <input type="number" value={form.recurring_amount} onChange={(e) => setForm({ ...form, recurring_amount: e.target.value })} className="input w-full text-sm" step="1000" />
+                </div>
+                <div>
+                  <label className="block text-xs text-gray-600 dark:text-gray-400 mb-1">День месяца</label>
+                  <input type="number" value={form.recurring_day} onChange={(e) => setForm({ ...form, recurring_day: e.target.value })} className="input w-full text-sm" min="1" max="28" />
+                </div>
+                <div className="col-span-2">
+                  <label className="block text-xs text-gray-600 dark:text-gray-400 mb-1">Дата начала</label>
+                  <input type="date" value={form.recurring_start_date} onChange={(e) => setForm({ ...form, recurring_start_date: e.target.value })} className="input w-full text-sm" />
+                </div>
+              </div>
+            </div>
+          )}
+ 
+          <button type="submit" className="btn btn-primary w-full">
+            {category ? "Сохранить изменения" : "Создать категорию"}
+          </button>
+        </form>
+      </motion.div>
+    </div>
+  )
+}
+ 
+// Delete Confirmation Modal
+function DeleteModal({ isOpen, onClose, category, onConfirm }) {
+  if (!isOpen || !category) return null
+ 
+  return (
+    <div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/50" onClick={onClose}>
+      <motion.div
+        initial={{ opacity: 0, scale: 0.95 }}
+        animate={{ opacity: 1, scale: 1 }}
+        className="w-full max-w-sm bg-white dark:bg-gray-900 rounded-2xl p-6"
+        onClick={(e) => e.stopPropagation()}
+      >
+        <div className="text-center">
+          <div className="w-16 h-16 mx-auto mb-4 bg-red-100 dark:bg-red-900/30 rounded-full flex items-center justify-center">
+            <AlertTriangle className="w-8 h-8 text-red-500" />
+          </div>
+          <h3 className="text-lg font-bold text-gray-900 dark:text-white mb-2">Удалить категорию?</h3>
+          <p className="text-gray-500 dark:text-gray-400 mb-6">
+            Категория «{category.name}» и все связанные транзакции будут удалены безвозвратно.
+          </p>
+          <div className="flex gap-3">
+            <button onClick={onClose} className="btn flex-1">Отмена</button>
+            <button
+              onClick={() => { onConfirm(category.id); onClose() }}
+              className="btn bg-red-500 hover:bg-red-600 text-white flex-1"
+            >
+              Удалить
+            </button>
+          </div>
+        </div>
+      </motion.div>
+    </div>
+  )
+}
+ 
+// Recurring Plans Management Modal
+function RecurringPlansModal({ isOpen, onClose, category, queryClient }) {
+  const [showAddForm, setShowAddForm] = useState(false)
+  const [editingPlan, setEditingPlan] = useState(null)
+  const [planForm, setPlanForm] = useState({ effective: "", amount: "", day: "1", user_id: "" })
+ 
+  const { data: plans = [], isLoading } = useQuery({
+    queryKey: ["recurring-plans", category?.id],
+    queryFn: () => savingsApi.getRecurringPlans(category.id),
+    enabled: !!category?.id && isOpen,
+  })
+ 
+  const { data: members = [] } = useQuery({
+    queryKey: ["category-members", category?.id],
+    queryFn: () => savingsApi.getMembers(category.id),
+    enabled: !!category?.id && category?.is_multi && isOpen,
+  })
+ 
+  const createPlanMutation = useMutation({
+    mutationFn: (data) => savingsApi.createRecurringPlan(category.id, data),
+    onSuccess: () => {
+      queryClient.invalidateQueries({ queryKey: ["recurring-plans", category.id] })
+      queryClient.invalidateQueries({ queryKey: ["savings-stats"] })
+      setShowAddForm(false)
+      setPlanForm({ effective: "", amount: "", day: "1", user_id: "" })
+    },
+  })
+ 
+  const updatePlanMutation = useMutation({
+    mutationFn: ({ id, data }) => savingsApi.updateRecurringPlan(id, data),
+    onSuccess: () => {
+      queryClient.invalidateQueries({ queryKey: ["recurring-plans", category.id] })
+      queryClient.invalidateQueries({ queryKey: ["savings-stats"] })
+      setEditingPlan(null)
+    },
+  })
+ 
+  const deletePlanMutation = useMutation({
+    mutationFn: (id) => savingsApi.deleteRecurringPlan(id),
+    onSuccess: () => {
+      queryClient.invalidateQueries({ queryKey: ["recurring-plans", category.id] })
+      queryClient.invalidateQueries({ queryKey: ["savings-stats"] })
+    },
+  })
+ 
+  const handleSubmit = (e) => {
+    e.preventDefault()
+    const data = {
+      effective: planForm.effective,
+      amount: parseFloat(planForm.amount),
+      day: parseInt(planForm.day) || 1,
+    }
+    if (planForm.user_id) {
+      data.user_id = parseInt(planForm.user_id)
+    }
+ 
+    if (editingPlan) {
+      updatePlanMutation.mutate({ id: editingPlan.id, data })
+    } else {
+      createPlanMutation.mutate(data)
+    }
+  }
+ 
+  const startEdit = (plan) => {
+    setEditingPlan(plan)
+    setPlanForm({
+      effective: plan.effective?.split("T")[0] || "",
+      amount: plan.amount?.toString() || "",
+      day: plan.day?.toString() || "1",
+      user_id: plan.user_id?.toString() || "",
+    })
+    setShowAddForm(true)
+  }
+ 
+  if (!isOpen || !category) return null
+ 
+  return (
+    <div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/50 overflow-y-auto" onClick={onClose}>
+      <motion.div
+        initial={{ opacity: 0, scale: 0.95 }}
+        animate={{ opacity: 1, scale: 1 }}
+        className="w-full max-w-lg bg-white dark:bg-gray-900 rounded-2xl p-6 my-8"
+        onClick={(e) => e.stopPropagation()}
+      >
+        <div className="flex justify-between items-center mb-6">
+          <h2 className="text-xl font-bold text-gray-900 dark:text-white">
+            Планы: {category.name}
+          </h2>
+          <button onClick={onClose} className="p-2 hover:bg-gray-100 dark:hover:bg-gray-800 rounded-lg">
+            <X size={20} />
+          </button>
+        </div>
+ 
+        {/* Plans List */}
+        <div className="space-y-3 mb-4 max-h-[300px] overflow-y-auto">
+          {isLoading ? (
+            <p className="text-center text-gray-500">Загрузка...</p>
+          ) : plans.length === 0 ? (
+            <p className="text-center text-gray-500 py-4">Нет планов</p>
+          ) : (
+            plans.map((plan) => (
+              <div key={plan.id} className="flex items-center justify-between p-3 bg-gray-50 dark:bg-gray-800 rounded-lg">
+                <div>
+                  <div className="font-medium text-gray-900 dark:text-white">
+                    {formatCurrency(plan.amount)} / мес
+                  </div>
+                  <div className="text-xs text-gray-500">
+                    С {format(new Date(plan.effective), "d MMM yyyy", { locale: ru })} · {plan.day} числа
+                    {plan.user_id && members.length > 0 && (
+                      <span className="ml-1">
+                        · {members.find(m => m.user_id === plan.user_id)?.user_name || `ID: ${plan.user_id}`}
+                      </span>
+                    )}
+                  </div>
+                </div>
+                <div className="flex gap-1">
+                  <button onClick={() => startEdit(plan)} className="p-1.5 text-gray-400 hover:text-violet-500 rounded">
+                    <Edit2 size={16} />
+                  </button>
+                  <button onClick={() => deletePlanMutation.mutate(plan.id)} className="p-1.5 text-gray-400 hover:text-red-500 rounded">
+                    <Trash2 size={16} />
+                  </button>
+                </div>
+              </div>
+            ))
+          )}
+        </div>
+ 
+        {/* Add/Edit Form */}
+        {showAddForm ? (
+          <form onSubmit={handleSubmit} className="space-y-3 p-4 bg-emerald-50 dark:bg-emerald-900/20 rounded-xl">
+            <h4 className="font-medium text-emerald-800 dark:text-emerald-300">
+              {editingPlan ? "Редактировать план" : "Добавить план"}
+            </h4>
+            <div className="grid grid-cols-2 gap-3">
+              <div>
+                <label className="block text-xs text-gray-600 dark:text-gray-400 mb-1">Дата начала</label>
+                <input
+                  type="date"
+                  value={planForm.effective}
+                  onChange={(e) => setPlanForm({ ...planForm, effective: e.target.value })}
+                  className="input w-full text-sm"
+                  required
+                />
+              </div>
+              <div>
+                <label className="block text-xs text-gray-600 dark:text-gray-400 mb-1">Сумма</label>
+                <input
+                  type="number"
+                  value={planForm.amount}
+                  onChange={(e) => setPlanForm({ ...planForm, amount: e.target.value })}
+                  className="input w-full text-sm"
+                  step="100"
+                  required
+                />
+              </div>
+              <div>
+                <label className="block text-xs text-gray-600 dark:text-gray-400 mb-1">День месяца</label>
+                <input
+                  type="number"
+                  value={planForm.day}
+                  onChange={(e) => setPlanForm({ ...planForm, day: e.target.value })}
+                  className="input w-full text-sm"
+                  min="1"
+                  max="28"
+                />
+              </div>
+              {category.is_multi && members.length > 0 && (
+                <div>
+                  <label className="block text-xs text-gray-600 dark:text-gray-400 mb-1">Участник</label>
+                  <select
+                    value={planForm.user_id}
+                    onChange={(e) => setPlanForm({ ...planForm, user_id: e.target.value })}
+                    className="input w-full text-sm"
+                  >
+                    <option value="">Общий план</option>
+                    {members.map((m) => (
+                      <option key={m.user_id} value={m.user_id}>{m.user_name}</option>
+                    ))}
+                  </select>
+                </div>
+              )}
+            </div>
+            <div className="flex gap-2">
+              <button type="submit" className="btn btn-primary flex-1">
+                {editingPlan ? "Сохранить" : "Добавить"}
+              </button>
+              <button
+                type="button"
+                onClick={() => { setShowAddForm(false); setEditingPlan(null); setPlanForm({ effective: "", amount: "", day: "1", user_id: "" }) }}
+                className="btn flex-1"
+              >
+                Отмена
+              </button>
+            </div>
+          </form>
+        ) : (
+          <button
+            onClick={() => setShowAddForm(true)}
+            className="btn btn-primary w-full flex items-center justify-center gap-2"
+          >
+            <Plus size={18} /> Добавить план
+          </button>
+        )}
+      </motion.div>
+    </div>
+  )
+}
+ 
+// ==================== MAIN COMPONENT ====================
+export default function Savings() {
+  const [activeTab, setActiveTab] = useState("dashboard")
+  const [showTransactionModal, setShowTransactionModal] = useState(false)
+  const [showCategoryModal, setShowCategoryModal] = useState(false)
+  const [showDeleteModal, setShowDeleteModal] = useState(false)
+  const [showDeleteTransactionModal, setShowDeleteTransactionModal] = useState(false)
+  const [showPlansModal, setShowPlansModal] = useState(false)
+  const [selectedCategory, setSelectedCategory] = useState(null)
+  const [categoryToDelete, setCategoryToDelete] = useState(null)
+  const [selectedTransaction, setSelectedTransaction] = useState(null)
+  const [transactionToDelete, setTransactionToDelete] = useState(null)
+  const [categoryForPlans, setCategoryForPlans] = useState(null)
+  const queryClient = useQueryClient()
+ 
+  const { data: categories = [], isLoading } = useQuery({
+    queryKey: ["savings-categories"],
+    queryFn: savingsApi.listCategories,
+  })
+ 
+  const { data: transactions = [] } = useQuery({
+    queryKey: ["savings-transactions"],
+    queryFn: () => savingsApi.listTransactions(null, 100),
+  })
+ 
+  const { data: stats } = useQuery({
+    queryKey: ["savings-stats"],
+    queryFn: savingsApi.getStats,
+  })
+ 
+  const createTransactionMutation = useMutation({
+    mutationFn: savingsApi.createTransaction,
+    onSuccess: () => {
+      queryClient.invalidateQueries({ queryKey: ["savings-categories"] })
+      queryClient.invalidateQueries({ queryKey: ["savings-transactions"] })
+      queryClient.invalidateQueries({ queryKey: ["savings-stats"] })
+    },
+  })
+ 
+  const updateTransactionMutation = useMutation({
+    mutationFn: ({ id, data }) => savingsApi.updateTransaction(id, data),
+    onSuccess: () => {
+      queryClient.invalidateQueries({ queryKey: ["savings-categories"] })
+      queryClient.invalidateQueries({ queryKey: ["savings-transactions"] })
+      queryClient.invalidateQueries({ queryKey: ["savings-stats"] })
+    },
+  })
+ 
+  const deleteTransactionMutation = useMutation({
+    mutationFn: savingsApi.deleteTransaction,
+    onSuccess: () => {
+      queryClient.invalidateQueries({ queryKey: ["savings-categories"] })
+      queryClient.invalidateQueries({ queryKey: ["savings-transactions"] })
+      queryClient.invalidateQueries({ queryKey: ["savings-stats"] })
+    },
+  })
+ 
+  const createCategoryMutation = useMutation({
+    mutationFn: savingsApi.createCategory,
+    onSuccess: () => {
+      queryClient.invalidateQueries({ queryKey: ["savings-categories"] })
+      queryClient.invalidateQueries({ queryKey: ["savings-stats"] })
+    },
+  })
+ 
+  const updateCategoryMutation = useMutation({
+    mutationFn: ({ id, data }) => savingsApi.updateCategory(id, data),
+    onSuccess: () => {
+      queryClient.invalidateQueries({ queryKey: ["savings-categories"] })
+      queryClient.invalidateQueries({ queryKey: ["savings-stats"] })
+    },
+  })
+ 
+  const deleteCategoryMutation = useMutation({
+    mutationFn: savingsApi.deleteCategory,
+    onSuccess: () => {
+      queryClient.invalidateQueries({ queryKey: ["savings-categories"] })
+      queryClient.invalidateQueries({ queryKey: ["savings-stats"] })
+    },
+  })
+ 
+  const handleEditCategory = (category) => {
+    setSelectedCategory(category)
+    setShowCategoryModal(true)
+  }
+ 
+  const handleDeleteCategory = (category) => {
+    setCategoryToDelete(category)
+    setShowDeleteModal(true)
+  }
+ 
+  const handleManagePlans = (category) => {
+    setCategoryForPlans(category)
+    setShowPlansModal(true)
+  }
+ 
+  const handleSaveCategory = (id, data) => {
+    if (id) {
+      updateCategoryMutation.mutate({ id, data })
+    } else {
+      createCategoryMutation.mutate(data)
+    }
+  }
+ 
+  const handleEditTransaction = (transaction) => {
+    setSelectedTransaction(transaction)
+    setShowTransactionModal(true)
+  }
+ 
+  const handleDeleteTransaction = (transaction) => {
+    setTransactionToDelete(transaction)
+    setShowDeleteTransactionModal(true)
+  }
+ 
+  const handleSaveTransaction = (data, id) => {
+    if (id) {
+      updateTransactionMutation.mutate({ id, data })
+    } else {
+      createTransactionMutation.mutate(data)
+    }
+  }
+ 
+  return (
+    <div className="min-h-screen bg-surface-50 dark:bg-gray-950 gradient-mesh pb-24 transition-colors duration-300">
+      <header className="bg-white/70 dark:bg-gray-900/70 backdrop-blur-xl border-b border-gray-100/50 dark:border-gray-800 sticky top-0 z-10">
+        <div className="max-w-lg mx-auto px-4 py-4">
+          <div className="flex items-center justify-between mb-4">
+            <div>
+              <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">
+                {categories.filter((c) => !c.is_closed).length} категорий
+              </p>
+            </div>
+            <div className="flex gap-2">
+              {activeTab === "categories" && (
+                <button
+                  onClick={() => { setSelectedCategory(null); setShowCategoryModal(true) }}
+                  className="btn btn-primary flex items-center gap-2"
+                >
+                  <Plus size={18} />
+                  <span className="hidden sm:inline">Категория</span>
+                </button>
+              )}
+              {activeTab !== "categories" && (
+                <button
+                  onClick={() => { setSelectedTransaction(null); setShowTransactionModal(true) }}
+                  className="btn btn-primary flex items-center gap-2"
+                >
+                  <Plus size={18} />
+                  <span className="hidden sm:inline">Операция</span>
+                </button>
+              )}
+            </div>
+          </div>
+        </div>
+      </header>
+ 
+      <main className="max-w-lg mx-auto px-4 py-6">
+        <TabNav activeTab={activeTab} setActiveTab={setActiveTab} />
+ 
+        {isLoading ? (
+          <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-12 h-12 rounded-xl bg-gray-200 dark:bg-gray-700" />
+                  <div className="flex-1">
+                    <div className="h-5 bg-gray-200 dark:bg-gray-700 rounded-lg w-1/2 mb-2" />
+                    <div className="h-4 bg-gray-200 dark:bg-gray-700 rounded-lg w-1/3" />
+                  </div>
+                </div>
+              </div>
+            ))}
+          </div>
+        ) : (
+          <>
+            {activeTab === "dashboard" && <DashboardTab categories={categories} stats={stats} />}
+            {activeTab === "categories" && (
+              <CategoriesTab
+                categories={categories}
+                onEdit={handleEditCategory}
+                onDelete={handleDeleteCategory}
+                onManagePlans={handleManagePlans}
+              />
+            )}
+            {activeTab === "transactions" && (
+              <TransactionsTab
+                transactions={transactions}
+                categories={categories}
+                onAddTransaction={() => { setSelectedTransaction(null); setShowTransactionModal(true) }}
+                onEditTransaction={handleEditTransaction}
+                onDeleteTransaction={handleDeleteTransaction}
+              />
+            )}
+          </>
+        )}
+      </main>
+ 
+      <Navigation />
+ 
+      <AnimatePresence>
+        {showTransactionModal && (
+          <TransactionModal
+            isOpen={showTransactionModal}
+            onClose={() => { setShowTransactionModal(false); setSelectedTransaction(null) }}
+            categories={categories}
+            onSubmit={handleSaveTransaction}
+            transaction={selectedTransaction}
+          />
+        )}
+        {showDeleteTransactionModal && (
+          <DeleteTransactionModal
+            isOpen={showDeleteTransactionModal}
+            onClose={() => { setShowDeleteTransactionModal(false); setTransactionToDelete(null) }}
+            transaction={transactionToDelete}
+            onConfirm={(id) => deleteTransactionMutation.mutate(id)}
+          />
+        )}
+        {showCategoryModal && (
+          <CategoryEditModal
+            isOpen={showCategoryModal}
+            onClose={() => { setShowCategoryModal(false); setSelectedCategory(null) }}
+            category={selectedCategory}
+            onSubmit={handleSaveCategory}
+          />
+        )}
+        {showDeleteModal && (
+          <DeleteModal
+            isOpen={showDeleteModal}
+            onClose={() => { setShowDeleteModal(false); setCategoryToDelete(null) }}
+            category={categoryToDelete}
+            onConfirm={(id) => deleteCategoryMutation.mutate(id)}
+          />
+        )}
+        {showPlansModal && (
+          <RecurringPlansModal
+            isOpen={showPlansModal}
+            onClose={() => { setShowPlansModal(false); setCategoryForPlans(null) }}
+            category={categoryForPlans}
+            queryClient={queryClient}
+          />
+        )}
+      </AnimatePresence>
+    </div>
+  )
+}
+ 
+ +
+
+ + + + + + + + \ No newline at end of file diff --git a/coverage/lcov-report/pages/Settings.jsx.html b/coverage/lcov-report/pages/Settings.jsx.html new file mode 100644 index 0000000..9d28c77 --- /dev/null +++ b/coverage/lcov-report/pages/Settings.jsx.html @@ -0,0 +1,1123 @@ + + + + + + Code coverage report for pages/Settings.jsx + + + + + + + + + +
+
+

All files / pages Settings.jsx

+
+ +
+ 81.63% + Statements + 40/49 +
+ + +
+ 78.18% + Branches + 43/55 +
+ + +
+ 50% + Functions + 7/14 +
+ + +
+ 83.33% + Lines + 40/48 +
+ + +
+

+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +

+ +
+
+

+
1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 +16 +17 +18 +19 +20 +21 +22 +23 +24 +25 +26 +27 +28 +29 +30 +31 +32 +33 +34 +35 +36 +37 +38 +39 +40 +41 +42 +43 +44 +45 +46 +47 +48 +49 +50 +51 +52 +53 +54 +55 +56 +57 +58 +59 +60 +61 +62 +63 +64 +65 +66 +67 +68 +69 +70 +71 +72 +73 +74 +75 +76 +77 +78 +79 +80 +81 +82 +83 +84 +85 +86 +87 +88 +89 +90 +91 +92 +93 +94 +95 +96 +97 +98 +99 +100 +101 +102 +103 +104 +105 +106 +107 +108 +109 +110 +111 +112 +113 +114 +115 +116 +117 +118 +119 +120 +121 +122 +123 +124 +125 +126 +127 +128 +129 +130 +131 +132 +133 +134 +135 +136 +137 +138 +139 +140 +141 +142 +143 +144 +145 +146 +147 +148 +149 +150 +151 +152 +153 +154 +155 +156 +157 +158 +159 +160 +161 +162 +163 +164 +165 +166 +167 +168 +169 +170 +171 +172 +173 +174 +175 +176 +177 +178 +179 +180 +181 +182 +183 +184 +185 +186 +187 +188 +189 +190 +191 +192 +193 +194 +195 +196 +197 +198 +199 +200 +201 +202 +203 +204 +205 +206 +207 +208 +209 +210 +211 +212 +213 +214 +215 +216 +217 +218 +219 +220 +221 +222 +223 +224 +225 +226 +227 +228 +229 +230 +231 +232 +233 +234 +235 +236 +237 +238 +239 +240 +241 +242 +243 +244 +245 +246 +247 +248 +249 +250 +251 +252 +253 +254 +255 +256 +257 +258 +259 +260 +261 +262 +263 +264 +265 +266 +267 +268 +269 +270 +271 +272 +273 +274 +275 +276 +277 +278 +279 +280 +281 +282 +283 +284 +285 +286 +287 +288 +289 +290 +291 +292 +293 +294 +295 +296 +297 +298 +299 +300 +301 +302 +303 +304 +305 +306 +307 +308 +309 +310 +311 +312 +313 +314 +315 +316 +317 +318 +319 +320 +321 +322 +323 +324 +325 +326 +327 +328 +329 +330 +331 +332 +333 +334 +335 +336 +337 +338 +339 +340 +341 +342 +343 +344 +345 +346 +347  +  +  +  +  +  +  +  +1x +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +30x +30x +30x +30x +30x +30x +30x +30x +30x +30x +  +30x +  +  +  +  +30x +15x +7x +7x +7x +7x +7x +7x +  +  +  +30x +23x +  +15x +  +  +  +  +  +15x +  +  +  +30x +  +  +1x +1x +  +  +  +30x +1x +  +  +  +  +  +  +1x +1x +  +  +1x +1x +  +  +1x +  +  +30x +  +  +  +  +  +30x +8x +  +  +  +  +  +  +22x +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +1x +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +352x +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  + 
import { useState, useEffect } from "react"
+import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"
+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 = [
+  { value: "Europe/Moscow", label: "Москва (UTC+3)" },
+  { value: "Europe/Kaliningrad", label: "Калининград (UTC+2)" },
+  { value: "Europe/Samara", label: "Самара (UTC+4)" },
+  { value: "Asia/Yekaterinburg", label: "Екатеринбург (UTC+5)" },
+  { value: "Asia/Omsk", label: "Омск (UTC+6)" },
+  { value: "Asia/Krasnoyarsk", label: "Красноярск (UTC+7)" },
+  { value: "Asia/Irkutsk", label: "Иркутск (UTC+8)" },
+  { value: "Asia/Yakutsk", label: "Якутск (UTC+9)" },
+  { value: "Asia/Vladivostok", label: "Владивосток (UTC+10)" },
+  { value: "Asia/Magadan", label: "Магадан (UTC+11)" },
+  { value: "Asia/Kamchatka", label: "Камчатка (UTC+12)" },
+  { value: "Asia/Tokyo", label: "Токио (UTC+9)" },
+  { value: "Europe/London", label: "Лондон (UTC+0)" },
+  { value: "Europe/Berlin", label: "Берлин (UTC+1)" },
+  { value: "America/New_York", label: "Нью-Йорк (UTC-5)" },
+  { value: "America/Los_Angeles", label: "Лос-Анджелес (UTC-8)" },
+]
+ 
+export default function Settings() {
+  const queryClient = useQueryClient()
+  const { theme, toggleTheme } = useTheme()
+  const [copied, setCopied] = useState(false)
+  const [username, setUsername] = useState("")
+  const [chatId, setChatId] = useState("")
+  const [notificationsEnabled, setNotificationsEnabled] = useState(true)
+  const [timezone, setTimezone] = useState("Europe/Moscow")
+  const [morningTime, setMorningTime] = useState("09:00")
+  const [eveningTime, setEveningTime] = useState("21:00")
+  const [hasChanges, setHasChanges] = useState(false)
+ 
+  const { data: profile, isLoading } = useQuery({
+    queryKey: ["profile"],
+    queryFn: profileApi.get,
+  })
+ 
+  useEffect(() => {
+    if (profile) {
+      setUsername(profile.username || "")
+      setChatId(profile.telegram_chat_id?.toString() || "")
+      setNotificationsEnabled(profile.notifications_enabled ?? true)
+      setTimezone(profile.timezone || "Europe/Moscow")
+      setMorningTime(profile.morning_reminder_time || "09:00")
+      setEveningTime(profile.evening_reminder_time || "21:00")
+    }
+  }, [profile])
+ 
+  useEffect(() => {
+    if (profile) {
+      const changed =
+        username !== (profile.username || "") ||
+        (chatId !== (profile.telegram_chat_id?.toString() || "")) ||
+        notificationsEnabled !== (profile.notifications_enabled ?? true) ||
+        timezone !== (profile.timezone || "Europe/Moscow") ||
+        morningTime !== (profile.morning_reminder_time || "09:00") ||
+        eveningTime !== (profile.evening_reminder_time || "21:00")
+      setHasChanges(changed)
+    }
+  }, [username, chatId, notificationsEnabled, timezone, morningTime, eveningTime, profile])
+ 
+  const mutation = useMutation({
+    mutationFn: profileApi.update,
+    onSuccess: () => {
+      queryClient.invalidateQueries({ queryKey: ["profile"] })
+      setHasChanges(false)
+    },
+  })
+ 
+  const handleSave = () => {
+    const data = {
+      notifications_enabled: notificationsEnabled,
+      timezone: timezone,
+      morning_reminder_time: morningTime,
+      evening_reminder_time: eveningTime,
+    }
+    
+    Eif (username && username !== profile?.username) {
+      data.username = username
+    }
+    
+    Eif (chatId) {
+      data.telegram_chat_id = parseInt(chatId, 10)
+    }
+    
+    mutation.mutate(data)
+  }
+ 
+  const copyInstruction = () => {
+    navigator.clipboard.writeText("@pulse_tracking_bot")
+    setCopied(true)
+    setTimeout(() => setCopied(false), 2000)
+  }
+ 
+  if (isLoading) {
+    return (
+      <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 dark:bg-gray-950 pb-24 transition-colors duration-300">
+      {/* Header */}
+      <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 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 dark:text-white">Настройки</h1>
+        </div>
+      </header>
+ 
+      <main className="max-w-lg mx-auto px-4 py-6 space-y-6">
+        
+        {/* 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-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 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 dark:text-gray-300 mb-1.5">
+              Имя пользователя
+            </label>
+            <input
+              type="text"
+              value={username}
+              onChange={(e) => setUsername(e.target.value)}
+              placeholder="Ваше имя"
+              className="input"
+            />
+          </div>
+        </section>
+ 
+        {/* Telegram 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-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 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 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 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 dark:text-blue-300 mt-2">
+                2. Скопируй Chat ID из ответа бота и вставь ниже
+              </p>
+            </div>
+ 
+            <div>
+              <label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1.5">
+                Chat ID
+              </label>
+              <input
+                type="text"
+                value={chatId}
+                onChange={(e) => setChatId(e.target.value.replace(/\D/g, ""))}
+                placeholder="Например: 123456789"
+                className="input"
+              />
+            </div>
+          </div>
+        </section>
+ 
+        {/* Notifications 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-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 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 dark:bg-gray-800 rounded-xl cursor-pointer">
+              <span className="text-sm font-medium dark:text-white">Включить уведомления</span>
+              <div className="relative">
+                <input
+                  type="checkbox"
+                  checked={notificationsEnabled}
+                  onChange={(e) => setNotificationsEnabled(e.target.checked)}
+                  className="sr-only peer"
+                />
+                <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 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 dark:text-gray-300">
+                      Утреннее уведомление
+                    </label>
+                    <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 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 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 dark:text-gray-300">
+                      Вечернее уведомление
+                    </label>
+                    <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 dark:border-gray-700 dark:bg-gray-800 dark:text-white rounded-lg text-sm"
+                  />
+                </div>
+              </>
+            )}
+          </div>
+        </section>
+ 
+        {/* Timezone 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-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 dark:text-white">Часовой пояс</h2>
+              <p className="text-sm text-gray-500 dark:text-gray-400">Для корректных напоминаний</p>
+            </div>
+          </div>
+ 
+          <select
+            value={timezone}
+            onChange={(e) => setTimezone(e.target.value)}
+            className="input"
+          >
+            {TIMEZONES.map((tz) => (
+              <option key={tz.value} value={tz.value}>
+                {tz.label}
+              </option>
+            ))}
+          </select>
+        </section>
+ 
+        {/* Save Button */}
+        {hasChanges && (
+          <button
+            onClick={handleSave}
+            disabled={mutation.isPending}
+            className="btn btn-primary w-full flex items-center justify-center gap-2"
+          >
+            <Save size={18} />
+            {mutation.isPending ? "Сохраняем..." : "Сохранить изменения"}
+          </button>
+        )}
+ 
+        {mutation.isSuccess && !hasChanges && (
+          <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>
+        )}
+      </main>
+ 
+      <Navigation />
+    </div>
+  )
+}
+ 
+ +
+
+ + + + + + + + \ No newline at end of file diff --git a/coverage/lcov-report/pages/Stats.jsx.html b/coverage/lcov-report/pages/Stats.jsx.html new file mode 100644 index 0000000..d357f0f --- /dev/null +++ b/coverage/lcov-report/pages/Stats.jsx.html @@ -0,0 +1,2170 @@ + + + + + + Code coverage report for pages/Stats.jsx + + + + + + + + + +
+
+

All files / pages Stats.jsx

+
+ +
+ 75.39% + Statements + 144/191 +
+ + +
+ 65.89% + Branches + 85/129 +
+ + +
+ 66.66% + Functions + 30/45 +
+ + +
+ 79.75% + Lines + 130/163 +
+ + +
+

+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +

+ +
+
+

+
1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 +16 +17 +18 +19 +20 +21 +22 +23 +24 +25 +26 +27 +28 +29 +30 +31 +32 +33 +34 +35 +36 +37 +38 +39 +40 +41 +42 +43 +44 +45 +46 +47 +48 +49 +50 +51 +52 +53 +54 +55 +56 +57 +58 +59 +60 +61 +62 +63 +64 +65 +66 +67 +68 +69 +70 +71 +72 +73 +74 +75 +76 +77 +78 +79 +80 +81 +82 +83 +84 +85 +86 +87 +88 +89 +90 +91 +92 +93 +94 +95 +96 +97 +98 +99 +100 +101 +102 +103 +104 +105 +106 +107 +108 +109 +110 +111 +112 +113 +114 +115 +116 +117 +118 +119 +120 +121 +122 +123 +124 +125 +126 +127 +128 +129 +130 +131 +132 +133 +134 +135 +136 +137 +138 +139 +140 +141 +142 +143 +144 +145 +146 +147 +148 +149 +150 +151 +152 +153 +154 +155 +156 +157 +158 +159 +160 +161 +162 +163 +164 +165 +166 +167 +168 +169 +170 +171 +172 +173 +174 +175 +176 +177 +178 +179 +180 +181 +182 +183 +184 +185 +186 +187 +188 +189 +190 +191 +192 +193 +194 +195 +196 +197 +198 +199 +200 +201 +202 +203 +204 +205 +206 +207 +208 +209 +210 +211 +212 +213 +214 +215 +216 +217 +218 +219 +220 +221 +222 +223 +224 +225 +226 +227 +228 +229 +230 +231 +232 +233 +234 +235 +236 +237 +238 +239 +240 +241 +242 +243 +244 +245 +246 +247 +248 +249 +250 +251 +252 +253 +254 +255 +256 +257 +258 +259 +260 +261 +262 +263 +264 +265 +266 +267 +268 +269 +270 +271 +272 +273 +274 +275 +276 +277 +278 +279 +280 +281 +282 +283 +284 +285 +286 +287 +288 +289 +290 +291 +292 +293 +294 +295 +296 +297 +298 +299 +300 +301 +302 +303 +304 +305 +306 +307 +308 +309 +310 +311 +312 +313 +314 +315 +316 +317 +318 +319 +320 +321 +322 +323 +324 +325 +326 +327 +328 +329 +330 +331 +332 +333 +334 +335 +336 +337 +338 +339 +340 +341 +342 +343 +344 +345 +346 +347 +348 +349 +350 +351 +352 +353 +354 +355 +356 +357 +358 +359 +360 +361 +362 +363 +364 +365 +366 +367 +368 +369 +370 +371 +372 +373 +374 +375 +376 +377 +378 +379 +380 +381 +382 +383 +384 +385 +386 +387 +388 +389 +390 +391 +392 +393 +394 +395 +396 +397 +398 +399 +400 +401 +402 +403 +404 +405 +406 +407 +408 +409 +410 +411 +412 +413 +414 +415 +416 +417 +418 +419 +420 +421 +422 +423 +424 +425 +426 +427 +428 +429 +430 +431 +432 +433 +434 +435 +436 +437 +438 +439 +440 +441 +442 +443 +444 +445 +446 +447 +448 +449 +450 +451 +452 +453 +454 +455 +456 +457 +458 +459 +460 +461 +462 +463 +464 +465 +466 +467 +468 +469 +470 +471 +472 +473 +474 +475 +476 +477 +478 +479 +480 +481 +482 +483 +484 +485 +486 +487 +488 +489 +490 +491 +492 +493 +494 +495 +496 +497 +498 +499 +500 +501 +502 +503 +504 +505 +506 +507 +508 +509 +510 +511 +512 +513 +514 +515 +516 +517 +518 +519 +520 +521 +522 +523 +524 +525 +526 +527 +528 +529 +530 +531 +532 +533 +534 +535 +536 +537 +538 +539 +540 +541 +542 +543 +544 +545 +546 +547 +548 +549 +550 +551 +552 +553 +554 +555 +556 +557 +558 +559 +560 +561 +562 +563 +564 +565 +566 +567 +568 +569 +570 +571 +572 +573 +574 +575 +576 +577 +578 +579 +580 +581 +582 +583 +584 +585 +586 +587 +588 +589 +590 +591 +592 +593 +594 +595 +596 +597 +598 +599 +600 +601 +602 +603 +604 +605 +606 +607 +608 +609 +610 +611 +612 +613 +614 +615 +616 +617 +618 +619 +620 +621 +622 +623 +624 +625 +626 +627 +628 +629 +630 +631 +632 +633 +634 +635 +636 +637 +638 +639 +640 +641 +642 +643 +644 +645 +646 +647 +648 +649 +650 +651 +652 +653 +654 +655 +656 +657 +658 +659 +660 +661 +662 +663 +664 +665 +666 +667 +668 +669 +670 +671 +672 +673 +674 +675 +676 +677 +678 +679 +680 +681 +682 +683 +684 +685 +686 +687 +688 +689 +690 +691 +692 +693 +694 +695 +696  +  +  +  +  +  +  +  +  +  +  +  +  +348x +348x +348x +  +  +  +  +8x +  +  +  +  +  +  +  +  +  +  +348x +348x +348x +8x +  +8x +348x +8x +  +  +  +8x +  +  +  +8x +  +  +  +1x +  +  +  +  +  +  +  +  +  +  +  +  +1x +24x +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +1x +504x +504x +  +504x +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +1x +12x +  +  +  +  +  +  +  +  +  +  +  +7x +7x +7x +7x +7x +  +7x +  +  +  +  +7x +5x +  +  +7x +1x +1x +1x +  +1x +1x +1x +  +  +  +  +  +  +  +  +1x +1x +1x +  +  +  +1x +1x +1x +  +  +  +7x +6x +  +  +  +6x +6x +6x +6x +  +6x +2x +2x +2x +  +2x +  +2x +60x +60x +  +  +2x +  +  +  +  +  +6x +  +6x +  +  +  +7x +6x +6x +6x +  +  +6x +6x +  +6x +504x +504x +504x +  +504x +  +  +  +504x +168x +168x +  +168x +168x +  +  +504x +  +  +  +  +7x +6x +6x +6x +504x +504x +18x +18x +  +  +6x +  +  +  +7x +6x +6x +180x +180x +180x +180x +  +180x +  +  +  +180x +60x +60x +  +60x +60x +  +  +180x +180x +  +180x +  +  +  +7x +6x +2x +2x +  +2x +2x +60x +60x +  +  +2x +  +  +  +  +2x +  +2x +  +  +  +  +  +  +  +  +  +  +  +7x +504x +2x +2x +  +  +  +  +  +  +7x +  +  +7x +6x +6x +72x +  +6x +  +  +7x +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +1x +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +2x +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +18x +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +42x +  +  +  +  +  +  +  +  +72x +  +504x +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  + 
import { useState, useEffect, useMemo, useRef } from 'react'
+import { useQuery } from '@tanstack/react-query'
+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) {
+  Iif (habit.start_date) return startOfDay(parseISO(habit.start_date))
+  Iif (habit.created_at) return startOfDay(parseISO(habit.created_at))
+  return startOfDay(new Date())
+}
+ 
+// Check if habit is frozen on date
+function isHabitFrozenOnDate(freezes, date) {
+  Eif (!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
+  Iif (isHabitFrozenOnDate(freezes, date)) return false
+  
+  const dayOfWeek = checkDate.getDay() || 7
+  Iif (habit.frequency === "daily") return true
+  Iif (habit.frequency === "weekly") {
+    if (habit.target_days?.length > 0) return habit.target_days.includes(dayOfWeek)
+    return true
+  }
+  Iif (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({ embedded = false }) {
+  const [selectedHabitId, setSelectedHabitId] = useState(null)
+  const [allHabitLogs, setAllHabitLogs] = useState({})
+  const [allHabitStats, setAllHabitStats] = useState({})
+  const [allHabitFreezes, setAllHabitFreezes] = useState({})
+  const [dropdownOpen, setDropdownOpen] = useState(false)
+ 
+  const { data: habits = [] } = useQuery({
+    queryKey: ['habits'],
+    queryFn: habitsApi.list,
+  })
+ 
+  useEffect(() => {
+    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, 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) {
+        logsMap[habit.id] = []
+        statsMap[habit.id] = null
+        freezesMap[habit.id] = []
+      }
+    }))
+    
+    setAllHabitLogs(logsMap)
+    setAllHabitStats(statsMap)
+    setAllHabitFreezes(freezesMap)
+  }
+ 
+  // Combined stats for all or selected habit
+  const computedStats = useMemo(() => {
+    const targetHabits = selectedHabitId 
+      ? habits.filter(h => h.id === selectedHabitId) 
+      : habits
+    
+    let totalLogs = 0
+    let totalExpected = 0
+    let currentStreak = 0
+    let bestStreak = 0
+    
+    targetHabits.forEach(habit => {
+      const logs = allHabitLogs[habit.id] || []
+      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++
+      }
+      
+      Iif (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] || []
+        
+        Iif (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')
+      let completed = 0
+      let expected = 0
+      
+      const targetHabits = selectedHabitId 
+        ? habits.filter(h => h.id === selectedHabitId) 
+        : habits
+      
+      targetHabits.forEach(habit => {
+        const logs = allHabitLogs[habit.id] || []
+        const freezes = allHabitFreezes[habit.id] || []
+        
+        Iif (logs.some(l => l.date.split('T')[0] === dateStr)) completed++
+        if (isHabitExpectedOnDate(habit, date, freezes)) expected++
+      })
+      
+      const rate = expected > 0 ? Math.round((completed / expected) * 100) : 0
+      data.push({ date: format(date, 'dd.MM'), fullDate: format(date, 'd MMM', { locale: ru }), rate, completed, expected })
+    }
+    return data
+  }, [selectedHabitId, habits, allHabitLogs, allHabitFreezes])
+ 
+  // Bar chart data (habits comparison)
+  const barChartData = useMemo(() => {
+    return habits.map(habit => {
+      const logs = allHabitLogs[habit.id] || []
+      const freezes = allHabitFreezes[habit.id] || []
+      
+      let expected = 0
+      for (let i = 0; i < 30; i++) {
+        const date = subDays(new Date(), i)
+        if (isHabitExpectedOnDate(habit, date, freezes)) expected++
+      }
+      
+      const completed = logs.filter(l => {
+        const logDate = parseISO(l.date.split('T')[0])
+        return differenceInDays(new Date(), logDate) < 30
+      }).length
+      
+      const rate = expected > 0 ? Math.round((completed / expected) * 100) : 0
+      
+      return {
+        name: habit.name,
+        icon: habit.icon,
+        rate,
+        color: habit.color || '#0d9488',
+        completed,
+        expected
+      }
+    }).sort((a, b) => b.rate - a.rate)
+  }, [habits, allHabitLogs, allHabitFreezes])
+ 
+  // Heatmap intensity color - 5 levels
+  const getHeatmapColor = (count, expected) => {
+    if (expected === 0) return 'bg-[#1a1a1a]'
+    const ratio = count / expected
+    Eif (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)
+ 
+  // 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={embedded ? "" : "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>
+      
+      {!embedded && <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="relative max-w-lg mx-auto px-5 py-6 space-y-8">
+        
+        {/* Habit Selector Dropdown */}
+        <motion.div 
+          initial={{ opacity: 0, y: -10 }}
+          animate={{ opacity: 1, y: 0 }}
+          className="relative"
+        >
+          <button
+            onClick={() => setDropdownOpen(!dropdownOpen)}
+            className="w-full bg-gray-900/80 backdrop-blur-xl border border-gray-800 rounded-2xl p-4 flex items-center justify-between hover:border-gray-700 transition-all duration-300"
+          >
+            <div className="flex items-center gap-3">
+              {selectedHabit ? (
+                <>
+                  <div 
+                    className="w-10 h-10 rounded-xl flex items-center justify-center text-xl"
+                    style={{ backgroundColor: selectedHabit.color + '20' }}
+                  >
+                    {selectedHabit.icon}
+                  </div>
+                  <div className="text-left">
+                    <span className="font-semibold text-white block">{selectedHabit.name}</span>
+                    <span className="text-xs text-gray-500">Выбранная привычка</span>
+                  </div>
+                </>
+              ) : (
+                <>
+                  <div className="w-10 h-10 rounded-xl bg-gradient-to-br from-primary-500/20 to-teal-500/10 flex items-center justify-center">
+                    <Target className="text-primary-400" size={20} />
+                  </div>
+                  <div className="text-left">
+                    <span className="font-semibold text-white block">Все привычки</span>
+                    <span className="text-xs text-gray-500">{habits.length} привычек</span>
+                  </div>
+                </>
+              )}
+            </div>
+            <ChevronDown className={clsx(
+              "text-gray-500 transition-transform duration-300",
+              dropdownOpen && "rotate-180"
+            )} size={20} />
+          </button>
+          
+          <AnimatePresence>
+            {dropdownOpen && (
+              <motion.div
+                initial={{ opacity: 0, y: -10, scale: 0.95 }}
+                animate={{ opacity: 1, y: 0, scale: 1 }}
+                exit={{ opacity: 0, y: -10, scale: 0.95 }}
+                transition={{ duration: 0.2 }}
+                className="absolute top-full left-0 right-0 mt-2 bg-gray-900 backdrop-blur-xl rounded-2xl shadow-2xl border border-gray-800 z-30 overflow-hidden max-h-80 overflow-y-auto"
+              >
+                <button
+                  onClick={() => { setSelectedHabitId(null); setDropdownOpen(false) }}
+                  className={clsx(
+                    "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>
+          
+          {/* Legend */}
+          <div className="flex items-center justify-end gap-2 mt-5 pt-4 border-t border-gray-800/50">
+            <span className="text-xs text-gray-500">Меньше</span>
+            <div className="flex gap-1">
+              <div className="w-3.5 h-3.5 rounded-[3px] bg-[#1f1f1f]" />
+              <div className="w-3.5 h-3.5 rounded-[3px] bg-teal-900/80" />
+              <div className="w-3.5 h-3.5 rounded-[3px] bg-teal-700" />
+              <div className="w-3.5 h-3.5 rounded-[3px] bg-teal-600" />
+              <div className="w-3.5 h-3.5 rounded-[3px] bg-teal-400" />
+            </div>
+            <span className="text-xs text-gray-500">Больше</span>
+          </div>
+        </motion.section>
+ 
+        {/* Line Chart - Completion Rate */}
+        <motion.section
+          initial={{ opacity: 0, y: 20 }}
+          animate={{ opacity: 1, y: 0 }}
+          transition={{ delay: 0.3 }}
+          className="bg-gray-900/60 backdrop-blur-xl border border-gray-800/50 rounded-3xl p-6"
+        >
+          <SectionHeader 
+            icon={TrendingUp} 
+            title="Completion Rate" 
+            subtitle="Динамика за 30 дней"
+          />
+          
+          <div className="h-56">
+            <ResponsiveContainer width="100%" height="100%">
+              <AreaChart data={lineChartData}>
+                <defs>
+                  <linearGradient id="colorRate" x1="0" y1="0" x2="0" y2="1">
+                    <stop offset="0%" stopColor="#0d9488" stopOpacity={0.4} />
+                    <stop offset="100%" stopColor="#0d9488" stopOpacity={0} />
+                  </linearGradient>
+                </defs>
+                <CartesianGrid 
+                  strokeDasharray="3 3" 
+                  stroke="#374151" 
+                  strokeOpacity={0.3}
+                  vertical={false}
+                />
+                <XAxis 
+                  dataKey="date" 
+                  tick={{ fontSize: 10, fill: '#6b7280' }}
+                  axisLine={false}
+                  tickLine={false}
+                  interval="preserveStartEnd"
+                />
+                <YAxis 
+                  tick={{ fontSize: 10, fill: '#6b7280' }}
+                  axisLine={false}
+                  tickLine={false}
+                  domain={[0, 100]}
+                  width={30}
+                  tickFormatter={(v) => `${v}%`}
+                />
+                <Tooltip content={<CustomTooltip />} />
+                <Area
+                  type="monotone"
+                  dataKey="rate"
+                  stroke="#0d9488"
+                  strokeWidth={2.5}
+                  fill="url(#colorRate)"
+                  dot={false}
+                  activeDot={{ 
+                    r: 6, 
+                    fill: '#0d9488', 
+                    stroke: '#fff',
+                    strokeWidth: 2,
+                    className: 'drop-shadow-lg'
+                  }}
+                />
+              </AreaChart>
+            </ResponsiveContainer>
+          </div>
+        </motion.section>
+ 
+        {/* Bar Chart - Habits Comparison */}
+        {!selectedHabitId && habits.length > 1 && (
+          <motion.section
+            initial={{ opacity: 0, y: 20 }}
+            animate={{ opacity: 1, y: 0 }}
+            transition={{ delay: 0.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="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 && (
+          <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>
+ 
+      {!embedded && <Navigation />}
+    </div>
+  )
+}
+ 
+ +
+
+ + + + + + + + \ No newline at end of file diff --git a/coverage/lcov-report/pages/Tasks.jsx.html b/coverage/lcov-report/pages/Tasks.jsx.html new file mode 100644 index 0000000..c49f81c --- /dev/null +++ b/coverage/lcov-report/pages/Tasks.jsx.html @@ -0,0 +1,823 @@ + + + + + + Code coverage report for pages/Tasks.jsx + + + + + + + + + +
+
+

All files / pages Tasks.jsx

+
+ +
+ 50% + Statements + 28/56 +
+ + +
+ 58.66% + Branches + 44/75 +
+ + +
+ 31.81% + Functions + 7/22 +
+ + +
+ 60% + Lines + 27/45 +
+ + +
+

+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +

+ +
+
+

+
1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 +16 +17 +18 +19 +20 +21 +22 +23 +24 +25 +26 +27 +28 +29 +30 +31 +32 +33 +34 +35 +36 +37 +38 +39 +40 +41 +42 +43 +44 +45 +46 +47 +48 +49 +50 +51 +52 +53 +54 +55 +56 +57 +58 +59 +60 +61 +62 +63 +64 +65 +66 +67 +68 +69 +70 +71 +72 +73 +74 +75 +76 +77 +78 +79 +80 +81 +82 +83 +84 +85 +86 +87 +88 +89 +90 +91 +92 +93 +94 +95 +96 +97 +98 +99 +100 +101 +102 +103 +104 +105 +106 +107 +108 +109 +110 +111 +112 +113 +114 +115 +116 +117 +118 +119 +120 +121 +122 +123 +124 +125 +126 +127 +128 +129 +130 +131 +132 +133 +134 +135 +136 +137 +138 +139 +140 +141 +142 +143 +144 +145 +146 +147 +148 +149 +150 +151 +152 +153 +154 +155 +156 +157 +158 +159 +160 +161 +162 +163 +164 +165 +166 +167 +168 +169 +170 +171 +172 +173 +174 +175 +176 +177 +178 +179 +180 +181 +182 +183 +184 +185 +186 +187 +188 +189 +190 +191 +192 +193 +194 +195 +196 +197 +198 +199 +200 +201 +202 +203 +204 +205 +206 +207 +208 +209 +210 +211 +212 +213 +214 +215 +216 +217 +218 +219 +220 +221 +222 +223 +224 +225 +226 +227 +228 +229 +230 +231 +232 +233 +234 +235 +236 +237 +238 +239 +240 +241 +242 +243 +244 +245 +246 +247  +  +  +  +  +  +  +  +  +  +  +  +1x +  +  +  +  +  +  +1x +  +  +  +  +  +  +  +4x +2x +2x +2x +2x +  +  +  +10x +10x +10x +10x +  +10x +  +  +7x +7x +  +  +  +10x +  +  +  +  +  +  +  +10x +  +  +  +  +  +  +  +10x +  +  +  +  +10x +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +27x +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +21x +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +4x +  +  +  +  +  +  +  +  +  +  +  +  +  +  +4x +4x +4x +4x +  +4x +  +  +  +  +  +  +4x +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  + 
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, Repeat } from 'lucide-react'
+import { format, parseISO, isToday, isTomorrow, isPast } from 'date-fns'
+import { ru } from 'date-fns/locale'
+import { tasksApi } from '../api/tasks'
+import Navigation from '../components/Navigation'
+import CreateTaskModal from '../components/CreateTaskModal'
+import EditTaskModal from '../components/EditTaskModal'
+import clsx from 'clsx'
+ 
+const PRIORITY_LABELS = {
+  0: null,
+  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) {
+  if (!dateStr) return null
+  const date = parseISO(dateStr)
+  Iif (isToday(date)) return 'Сегодня'
+  Iif (isTomorrow(date)) return 'Завтра'
+  return format(date, 'd MMM', { locale: ru })
+}
+ 
+export default function Tasks({ embedded = false }) {
+  const [showCreate, setShowCreate] = useState(false)
+  const [editingTask, setEditingTask] = useState(null)
+  const [filter, setFilter] = useState('active')
+  const queryClient = useQueryClient()
+ 
+  const { data: tasks = [], isLoading } = useQuery({
+    queryKey: ['tasks', filter],
+    queryFn: () => {
+      Iif (filter === 'all') return tasksApi.list()
+      return tasksApi.list(filter === 'completed')
+    },
+  })
+ 
+  const completeMutation = useMutation({
+    mutationFn: (id) => tasksApi.complete(id),
+    onSuccess: () => {
+      queryClient.invalidateQueries({ queryKey: ['tasks'] })
+      queryClient.invalidateQueries({ queryKey: ['tasks-today'] })
+    },
+  })
+ 
+  const uncompleteMutation = useMutation({
+    mutationFn: (id) => tasksApi.uncomplete(id),
+    onSuccess: () => {
+      queryClient.invalidateQueries({ queryKey: ['tasks'] })
+      queryClient.invalidateQueries({ queryKey: ['tasks-today'] })
+    },
+  })
+ 
+  const handleToggle = (task) => {
+    if (task.completed) uncompleteMutation.mutate(task.id)
+    else completeMutation.mutate(task.id)
+  }
+ 
+  return (
+    <div className={embedded ? "" : "min-h-screen bg-surface-50 dark:bg-gray-950 gradient-mesh pb-24 transition-colors duration-300"}>
+      {!embedded && <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 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: 'Активные' },
+              { key: 'completed', label: 'Выполненные' },
+              { key: 'all', label: 'Все' },
+            ].map(({ key, label }) => (
+              <button
+                key={key}
+                onClick={() => setFilter(key)}
+                className={clsx(
+                  '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 dark:bg-gray-800 text-gray-600 dark:text-gray-300 hover:bg-gray-200 dark:hover:bg-gray-700'
+                )}
+              >
+                {label}
+              </button>
+            ))}
+          </div>
+        </div>
+      </header>}
+ 
+      <main className="max-w-lg mx-auto px-4 py-6">
+        {isLoading ? (
+          <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-10 h-10 rounded-xl bg-gray-200 dark:bg-gray-700" />
+                  <div className="flex-1">
+                    <div className="h-5 bg-gray-200 dark:bg-gray-700 rounded-lg w-3/4 mb-2" />
+                    <div className="h-4 bg-gray-200 dark:bg-gray-700 rounded-lg w-1/4" />
+                  </div>
+                </div>
+              </div>
+            ))}
+          </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 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 dark:text-white mb-2">
+              {filter === 'active' ? 'Нет активных задач' : filter === 'completed' ? 'Нет выполненных задач' : 'Нет задач'}
+            </h3>
+            <p className="text-gray-500 dark:text-gray-400 mb-6">
+              {filter === 'active' ? 'Добавь новую задачу или выбери другой фильтр' : 'Выполняй задачи и они появятся здесь'}
+            </p>
+            {filter === 'active' && (
+              <button onClick={() => setShowCreate(true)} className="btn btn-primary">
+                <Plus size={18} />
+                Добавить задачу
+              </button>
+            )}
+          </motion.div>
+        ) : (
+          <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} />
+              ))}
+            </AnimatePresence>
+          </div>
+        )}
+      </main>
+ 
+      {!embedded && <Navigation />}
+      <CreateTaskModal open={showCreate} onClose={() => setShowCreate(false)} />
+      <EditTaskModal open={!!editingTask} onClose={() => setEditingTask(null)} task={editingTask} />
+    </div>
+  )
+}
+ 
+function TaskCard({ task, index, onToggle, onEdit, isLoading }) {
+  const [showConfetti, setShowConfetti] = useState(false)
+  const priorityInfo = PRIORITY_LABELS[task.priority]
+  const dueDateLabel = formatDueDate(task.due_date)
+  const isOverdue = task.due_date && isPast(parseISO(task.due_date)) && !isToday(parseISO(task.due_date)) && !task.completed
+ 
+  const handleCheck = (e) => {
+    e.stopPropagation()
+    if (isLoading) return
+    if (!task.completed) { setShowConfetti(true); setTimeout(() => setShowConfetti(false), 1000) }
+    onToggle()
+  }
+ 
+  return (
+    <motion.div
+      initial={{ opacity: 0, y: 20 }}
+      animate={{ opacity: 1, y: 0 }}
+      exit={{ opacity: 0, x: -100 }}
+      transition={{ delay: index * 0.05 }}
+      className="card p-4 relative overflow-hidden"
+    >
+      {showConfetti && (
+        <motion.div initial={{ opacity: 1 }} animate={{ opacity: 0 }} transition={{ duration: 1 }} className="absolute inset-0 pointer-events-none">
+          {[...Array(6)].map((_, i) => (
+            <motion.div key={i} initial={{ x: '50%', y: '50%', scale: 0 }} animate={{ x: `${Math.random() * 100}%`, y: `${Math.random() * 100}%`, scale: [0, 1, 0] }} transition={{ duration: 0.6, delay: i * 0.05 }} className="absolute w-2 h-2 rounded-full" style={{ backgroundColor: task.color }} />
+          ))}
+        </motion.div>
+      )}
+      
+      <div className="flex items-start gap-3">
+        <motion.button
+          onClick={handleCheck}
+          disabled={isLoading}
+          whileTap={{ scale: 0.9 }}
+          className={clsx(
+            'w-10 h-10 rounded-xl flex items-center justify-center transition-all duration-300 flex-shrink-0 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'
+          )}
+          style={{ borderColor: task.completed ? undefined : task.color + '40', backgroundColor: task.completed ? undefined : task.color + '10' }}
+        >
+          {task.completed ? (
+            <motion.div initial={{ scale: 0, rotate: -180 }} animate={{ scale: 1, rotate: 0 }} transition={{ type: 'spring', stiffness: 500 }}>
+              <Check className="w-5 h-5 text-white" strokeWidth={3} />
+            </motion.div>
+          ) : (
+            <span className="text-lg">{task.icon || '📋'}</span>
+          )}
+        </motion.button>
+        
+        <div className="flex-1 min-w-0" onClick={onEdit}>
+          <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 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 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}</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 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 dark:hover:bg-orange-900/20 rounded-xl transition-all" title="Отменить">
+              <Undo2 size={16} />
+            </motion.button>
+          )}
+        </div>
+      </div>
+    </motion.div>
+  )
+}
+ 
+ +
+
+ + + + + + + + \ No newline at end of file diff --git a/coverage/lcov-report/pages/Tracker.jsx.html b/coverage/lcov-report/pages/Tracker.jsx.html new file mode 100644 index 0000000..54a2e89 --- /dev/null +++ b/coverage/lcov-report/pages/Tracker.jsx.html @@ -0,0 +1,241 @@ + + + + + + Code coverage report for pages/Tracker.jsx + + + + + + + + + +
+
+

All files / pages Tracker.jsx

+
+ +
+ 100% + Statements + 5/5 +
+ + +
+ 100% + Branches + 8/8 +
+ + +
+ 100% + Functions + 3/3 +
+ + +
+ 100% + Lines + 5/5 +
+ + +
+

+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +

+ +
+
+

+
1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 +16 +17 +18 +19 +20 +21 +22 +23 +24 +25 +26 +27 +28 +29 +30 +31 +32 +33 +34 +35 +36 +37 +38 +39 +40 +41 +42 +43 +44 +45 +46 +47 +48 +49 +50 +51 +52 +53  +  +  +  +  +  +  +  +1x +  +  +  +  +  +  +11x +  +11x +  +  +  +  +  +  +  +  +  +33x +  +4x +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  + 
import { useState, lazy, Suspense } from "react"
+import Navigation from "../components/Navigation"
+ 
+// Import pages as components (they render their own content but we strip their Navigation)
+import HabitsContent from "./Habits"
+import TasksContent from "./Tasks"
+import StatsContent from "./Stats"
+ 
+const tabs = [
+  { key: "habits", label: "Привычки", icon: "🎯" },
+  { key: "tasks", label: "Задачи", icon: "✅" },
+  { key: "stats", label: "Статистика", icon: "📊" },
+]
+ 
+export default function Tracker() {
+  const [activeTab, setActiveTab] = useState("habits")
+ 
+  return (
+    <div className="min-h-screen bg-surface-50 dark:bg-gray-950 gradient-mesh pb-24">
+      <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">
+          <h1 className="text-xl font-display font-bold text-gray-900 dark:text-white">
+            📊 Трекер
+          </h1>
+        </div>
+        <div className="max-w-lg mx-auto px-4 pb-3 flex gap-2">
+          {tabs.map((t) => (
+            <button
+              key={t.key}
+              onClick={() => setActiveTab(t.key)}
+              className={`flex-1 py-2 rounded-xl text-sm font-semibold transition ${
+                activeTab === t.key
+                  ? "bg-primary-500 text-white"
+                  : "bg-gray-100 dark:bg-gray-800 text-gray-600 dark:text-gray-400"
+              }`}
+            >
+              {t.icon} {t.label}
+            </button>
+          ))}
+        </div>
+      </header>
+ 
+      <div>
+        {activeTab === "habits" && <HabitsContent embedded />}
+        {activeTab === "tasks" && <TasksContent embedded />}
+        {activeTab === "stats" && <StatsContent embedded />}
+      </div>
+ 
+      <Navigation />
+    </div>
+  )
+}
+ 
+ +
+
+ + + + + + + + \ No newline at end of file diff --git a/coverage/lcov-report/pages/VerifyEmail.jsx.html b/coverage/lcov-report/pages/VerifyEmail.jsx.html new file mode 100644 index 0000000..22e5bf1 --- /dev/null +++ b/coverage/lcov-report/pages/VerifyEmail.jsx.html @@ -0,0 +1,394 @@ + + + + + + Code coverage report for pages/VerifyEmail.jsx + + + + + + + + + +
+
+

All files / pages VerifyEmail.jsx

+
+ +
+ 100% + Statements + 18/18 +
+ + +
+ 90% + Branches + 9/10 +
+ + +
+ 100% + Functions + 3/3 +
+ + +
+ 100% + Lines + 18/18 +
+ + +
+

+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +

+ +
+
+

+
1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 +16 +17 +18 +19 +20 +21 +22 +23 +24 +25 +26 +27 +28 +29 +30 +31 +32 +33 +34 +35 +36 +37 +38 +39 +40 +41 +42 +43 +44 +45 +46 +47 +48 +49 +50 +51 +52 +53 +54 +55 +56 +57 +58 +59 +60 +61 +62 +63 +64 +65 +66 +67 +68 +69 +70 +71 +72 +73 +74 +75 +76 +77 +78 +79 +80 +81 +82 +83 +84 +85 +86 +87 +88 +89 +90 +91 +92 +93 +94 +95 +96 +97 +98 +99 +100 +101 +102 +103 +104  +  +  +  +  +  +  +11x +11x +11x +  +11x +  +11x +6x +1x +1x +1x +  +  +5x +5x +5x +3x +3x +  +1x +1x +  +  +  +5x +  +  +11x +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  + 
import { useEffect, useState } from 'react'
+import { useSearchParams, Link } from 'react-router-dom'
+import { motion } from 'framer-motion'
+import { CheckCircle, XCircle, Loader2, Zap } from 'lucide-react'
+import api from '../api/client'
+ 
+export default function VerifyEmail() {
+  const [searchParams] = useSearchParams()
+  const [status, setStatus] = useState('loading') // loading, success, error
+  const [message, setMessage] = useState('')
+  
+  const token = searchParams.get('token')
+ 
+  useEffect(() => {
+    if (!token) {
+      setStatus('error')
+      setMessage('Токен не найден')
+      return
+    }
+ 
+    const verify = async () => {
+      try {
+        await api.post('/auth/verify-email', { token })
+        setStatus('success')
+        setMessage('Email успешно подтверждён!')
+      } catch (err) {
+        setStatus('error')
+        setMessage(err.response?.data?.error || 'Ошибка верификации')
+      }
+    }
+ 
+    verify()
+  }, [token])
+ 
+  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 }}
+        className="w-full max-w-md"
+      >
+        <div className="card p-10 text-center">
+          {status === 'loading' && (
+            <>
+              <div className="w-20 h-20 rounded-3xl bg-primary-100 flex items-center justify-center mx-auto mb-6">
+                <Loader2 className="w-10 h-10 text-primary-600 animate-spin" />
+              </div>
+              <h1 className="text-2xl font-display font-bold text-gray-900 mb-2">
+                Проверяем...
+              </h1>
+              <p className="text-gray-500">Подожди секунду</p>
+            </>
+          )}
+ 
+          {status === 'success' && (
+            <>
+              <motion.div
+                initial={{ scale: 0 }}
+                animate={{ scale: 1 }}
+                transition={{ type: 'spring', stiffness: 200 }}
+                className="w-20 h-20 rounded-3xl bg-green-100 flex items-center justify-center mx-auto mb-6"
+              >
+                <CheckCircle className="w-10 h-10 text-green-600" />
+              </motion.div>
+              <h1 className="text-2xl font-display font-bold text-gray-900 mb-2">
+                Готово! 🎉
+              </h1>
+              <p className="text-gray-500 mb-6">{message}</p>
+              <Link to="/login" className="btn btn-primary">
+                Войти в аккаунт
+              </Link>
+            </>
+          )}
+ 
+          {status === 'error' && (
+            <>
+              <motion.div
+                initial={{ scale: 0 }}
+                animate={{ scale: 1 }}
+                transition={{ type: 'spring', stiffness: 200 }}
+                className="w-20 h-20 rounded-3xl bg-red-100 flex items-center justify-center mx-auto mb-6"
+              >
+                <XCircle className="w-10 h-10 text-red-600" />
+              </motion.div>
+              <h1 className="text-2xl font-display font-bold text-gray-900 mb-2">
+                Ошибка
+              </h1>
+              <p className="text-gray-500 mb-6">{message}</p>
+              <Link to="/login" className="btn btn-secondary">
+                На главную
+              </Link>
+            </>
+          )}
+        </div>
+ 
+        <div className="flex items-center justify-center gap-2 mt-6 text-gray-400">
+          <Zap size={16} />
+          <span className="text-sm font-medium">Pulse</span>
+        </div>
+      </motion.div>
+    </div>
+  )
+}
+ 
+ +
+
+ + + + + + + + \ No newline at end of file diff --git a/coverage/lcov-report/pages/index.html b/coverage/lcov-report/pages/index.html new file mode 100644 index 0000000..90c1033 --- /dev/null +++ b/coverage/lcov-report/pages/index.html @@ -0,0 +1,296 @@ + + + + + + Code coverage report for pages + + + + + + + + + +
+
+

All files pages

+
+ +
+ 46.79% + Statements + 452/966 +
+ + +
+ 40.62% + Branches + 325/800 +
+ + +
+ 32.11% + Functions + 105/327 +
+ + +
+ 50.95% + Lines + 427/838 +
+ + +
+

+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +

+ +
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
FileStatementsBranchesFunctionsLines
Finance.jsx +
+
44.44%16/3675%15/2025%4/1664%16/25
ForgotPassword.jsx +
+
100%17/17100%8/8100%3/3100%17/17
Habits.jsx +
+
64.91%37/5765.9%29/4453.57%15/2865.21%30/46
Home.jsx +
+
17.46%33/1898.84%13/1479.25%5/5421.15%33/156
Login.jsx +
+
100%21/21100%10/10100%6/6100%20/20
Register.jsx +
+
100%23/23100%10/10100%7/7100%22/22
ResetPassword.jsx +
+
96.29%26/2792.85%13/1480%4/5100%26/26
Savings.jsx +
+
15.88%44/27714.07%38/2709.09%11/12117.4%43/247
Settings.jsx +
+
81.63%40/4978.18%43/5550%7/1483.33%40/48
Stats.jsx +
+
75.39%144/19165.89%85/12966.66%30/4579.75%130/163
Tasks.jsx +
+
50%28/5658.66%44/7531.81%7/2260%27/45
Tracker.jsx +
+
100%5/5100%8/8100%3/3100%5/5
VerifyEmail.jsx +
+
100%18/1890%9/10100%3/3100%18/18
+
+
+
+ + + + + + + + \ No newline at end of file diff --git a/coverage/lcov-report/prettify.css b/coverage/lcov-report/prettify.css new file mode 100644 index 0000000..b317a7c --- /dev/null +++ b/coverage/lcov-report/prettify.css @@ -0,0 +1 @@ +.pln{color:#000}@media screen{.str{color:#080}.kwd{color:#008}.com{color:#800}.typ{color:#606}.lit{color:#066}.pun,.opn,.clo{color:#660}.tag{color:#008}.atn{color:#606}.atv{color:#080}.dec,.var{color:#606}.fun{color:red}}@media print,projection{.str{color:#060}.kwd{color:#006;font-weight:bold}.com{color:#600;font-style:italic}.typ{color:#404;font-weight:bold}.lit{color:#044}.pun,.opn,.clo{color:#440}.tag{color:#006;font-weight:bold}.atn{color:#404}.atv{color:#060}}pre.prettyprint{padding:2px;border:1px solid #888}ol.linenums{margin-top:0;margin-bottom:0}li.L0,li.L1,li.L2,li.L3,li.L5,li.L6,li.L7,li.L8{list-style-type:none}li.L1,li.L3,li.L5,li.L7,li.L9{background:#eee} diff --git a/coverage/lcov-report/prettify.js b/coverage/lcov-report/prettify.js new file mode 100644 index 0000000..b322523 --- /dev/null +++ b/coverage/lcov-report/prettify.js @@ -0,0 +1,2 @@ +/* eslint-disable */ +window.PR_SHOULD_USE_CONTINUATION=true;(function(){var h=["break,continue,do,else,for,if,return,while"];var u=[h,"auto,case,char,const,default,double,enum,extern,float,goto,int,long,register,short,signed,sizeof,static,struct,switch,typedef,union,unsigned,void,volatile"];var p=[u,"catch,class,delete,false,import,new,operator,private,protected,public,this,throw,true,try,typeof"];var l=[p,"alignof,align_union,asm,axiom,bool,concept,concept_map,const_cast,constexpr,decltype,dynamic_cast,explicit,export,friend,inline,late_check,mutable,namespace,nullptr,reinterpret_cast,static_assert,static_cast,template,typeid,typename,using,virtual,where"];var x=[p,"abstract,boolean,byte,extends,final,finally,implements,import,instanceof,null,native,package,strictfp,super,synchronized,throws,transient"];var R=[x,"as,base,by,checked,decimal,delegate,descending,dynamic,event,fixed,foreach,from,group,implicit,in,interface,internal,into,is,lock,object,out,override,orderby,params,partial,readonly,ref,sbyte,sealed,stackalloc,string,select,uint,ulong,unchecked,unsafe,ushort,var"];var r="all,and,by,catch,class,else,extends,false,finally,for,if,in,is,isnt,loop,new,no,not,null,of,off,on,or,return,super,then,true,try,unless,until,when,while,yes";var w=[p,"debugger,eval,export,function,get,null,set,undefined,var,with,Infinity,NaN"];var s="caller,delete,die,do,dump,elsif,eval,exit,foreach,for,goto,if,import,last,local,my,next,no,our,print,package,redo,require,sub,undef,unless,until,use,wantarray,while,BEGIN,END";var I=[h,"and,as,assert,class,def,del,elif,except,exec,finally,from,global,import,in,is,lambda,nonlocal,not,or,pass,print,raise,try,with,yield,False,True,None"];var f=[h,"alias,and,begin,case,class,def,defined,elsif,end,ensure,false,in,module,next,nil,not,or,redo,rescue,retry,self,super,then,true,undef,unless,until,when,yield,BEGIN,END"];var H=[h,"case,done,elif,esac,eval,fi,function,in,local,set,then,until"];var A=[l,R,w,s+I,f,H];var e=/^(DIR|FILE|vector|(de|priority_)?queue|list|stack|(const_)?iterator|(multi)?(set|map)|bitset|u?(int|float)\d*)/;var C="str";var z="kwd";var j="com";var O="typ";var G="lit";var L="pun";var F="pln";var m="tag";var E="dec";var J="src";var P="atn";var n="atv";var N="nocode";var M="(?:^^\\.?|[+-]|\\!|\\!=|\\!==|\\#|\\%|\\%=|&|&&|&&=|&=|\\(|\\*|\\*=|\\+=|\\,|\\-=|\\->|\\/|\\/=|:|::|\\;|<|<<|<<=|<=|=|==|===|>|>=|>>|>>=|>>>|>>>=|\\?|\\@|\\[|\\^|\\^=|\\^\\^|\\^\\^=|\\{|\\||\\|=|\\|\\||\\|\\|=|\\~|break|case|continue|delete|do|else|finally|instanceof|return|throw|try|typeof)\\s*";function k(Z){var ad=0;var S=false;var ac=false;for(var V=0,U=Z.length;V122)){if(!(al<65||ag>90)){af.push([Math.max(65,ag)|32,Math.min(al,90)|32])}if(!(al<97||ag>122)){af.push([Math.max(97,ag)&~32,Math.min(al,122)&~32])}}}}af.sort(function(av,au){return(av[0]-au[0])||(au[1]-av[1])});var ai=[];var ap=[NaN,NaN];for(var ar=0;arat[0]){if(at[1]+1>at[0]){an.push("-")}an.push(T(at[1]))}}an.push("]");return an.join("")}function W(al){var aj=al.source.match(new RegExp("(?:\\[(?:[^\\x5C\\x5D]|\\\\[\\s\\S])*\\]|\\\\u[A-Fa-f0-9]{4}|\\\\x[A-Fa-f0-9]{2}|\\\\[0-9]+|\\\\[^ux0-9]|\\(\\?[:!=]|[\\(\\)\\^]|[^\\x5B\\x5C\\(\\)\\^]+)","g"));var ah=aj.length;var an=[];for(var ak=0,am=0;ak=2&&ai==="["){aj[ak]=X(ag)}else{if(ai!=="\\"){aj[ak]=ag.replace(/[a-zA-Z]/g,function(ao){var ap=ao.charCodeAt(0);return"["+String.fromCharCode(ap&~32,ap|32)+"]"})}}}}return aj.join("")}var aa=[];for(var V=0,U=Z.length;V=0;){S[ac.charAt(ae)]=Y}}var af=Y[1];var aa=""+af;if(!ag.hasOwnProperty(aa)){ah.push(af);ag[aa]=null}}ah.push(/[\0-\uffff]/);V=k(ah)})();var X=T.length;var W=function(ah){var Z=ah.sourceCode,Y=ah.basePos;var ad=[Y,F];var af=0;var an=Z.match(V)||[];var aj={};for(var ae=0,aq=an.length;ae=5&&"lang-"===ap.substring(0,5);if(am&&!(ai&&typeof ai[1]==="string")){am=false;ap=J}if(!am){aj[ag]=ap}}var ab=af;af+=ag.length;if(!am){ad.push(Y+ab,ap)}else{var al=ai[1];var ak=ag.indexOf(al);var ac=ak+al.length;if(ai[2]){ac=ag.length-ai[2].length;ak=ac-al.length}var ar=ap.substring(5);B(Y+ab,ag.substring(0,ak),W,ad);B(Y+ab+ak,al,q(ar,al),ad);B(Y+ab+ac,ag.substring(ac),W,ad)}}ah.decorations=ad};return W}function i(T){var W=[],S=[];if(T.tripleQuotedStrings){W.push([C,/^(?:\'\'\'(?:[^\'\\]|\\[\s\S]|\'{1,2}(?=[^\']))*(?:\'\'\'|$)|\"\"\"(?:[^\"\\]|\\[\s\S]|\"{1,2}(?=[^\"]))*(?:\"\"\"|$)|\'(?:[^\\\']|\\[\s\S])*(?:\'|$)|\"(?:[^\\\"]|\\[\s\S])*(?:\"|$))/,null,"'\""])}else{if(T.multiLineStrings){W.push([C,/^(?:\'(?:[^\\\']|\\[\s\S])*(?:\'|$)|\"(?:[^\\\"]|\\[\s\S])*(?:\"|$)|\`(?:[^\\\`]|\\[\s\S])*(?:\`|$))/,null,"'\"`"])}else{W.push([C,/^(?:\'(?:[^\\\'\r\n]|\\.)*(?:\'|$)|\"(?:[^\\\"\r\n]|\\.)*(?:\"|$))/,null,"\"'"])}}if(T.verbatimStrings){S.push([C,/^@\"(?:[^\"]|\"\")*(?:\"|$)/,null])}var Y=T.hashComments;if(Y){if(T.cStyleComments){if(Y>1){W.push([j,/^#(?:##(?:[^#]|#(?!##))*(?:###|$)|.*)/,null,"#"])}else{W.push([j,/^#(?:(?:define|elif|else|endif|error|ifdef|include|ifndef|line|pragma|undef|warning)\b|[^\r\n]*)/,null,"#"])}S.push([C,/^<(?:(?:(?:\.\.\/)*|\/?)(?:[\w-]+(?:\/[\w-]+)+)?[\w-]+\.h|[a-z]\w*)>/,null])}else{W.push([j,/^#[^\r\n]*/,null,"#"])}}if(T.cStyleComments){S.push([j,/^\/\/[^\r\n]*/,null]);S.push([j,/^\/\*[\s\S]*?(?:\*\/|$)/,null])}if(T.regexLiterals){var X=("/(?=[^/*])(?:[^/\\x5B\\x5C]|\\x5C[\\s\\S]|\\x5B(?:[^\\x5C\\x5D]|\\x5C[\\s\\S])*(?:\\x5D|$))+/");S.push(["lang-regex",new RegExp("^"+M+"("+X+")")])}var V=T.types;if(V){S.push([O,V])}var U=(""+T.keywords).replace(/^ | $/g,"");if(U.length){S.push([z,new RegExp("^(?:"+U.replace(/[\s,]+/g,"|")+")\\b"),null])}W.push([F,/^\s+/,null," \r\n\t\xA0"]);S.push([G,/^@[a-z_$][a-z_$@0-9]*/i,null],[O,/^(?:[@_]?[A-Z]+[a-z][A-Za-z_$@0-9]*|\w+_t\b)/,null],[F,/^[a-z_$][a-z_$@0-9]*/i,null],[G,new RegExp("^(?:0x[a-f0-9]+|(?:\\d(?:_\\d+)*\\d*(?:\\.\\d*)?|\\.\\d\\+)(?:e[+\\-]?\\d+)?)[a-z]*","i"),null,"0123456789"],[F,/^\\[\s\S]?/,null],[L,/^.[^\s\w\.$@\'\"\`\/\#\\]*/,null]);return g(W,S)}var K=i({keywords:A,hashComments:true,cStyleComments:true,multiLineStrings:true,regexLiterals:true});function Q(V,ag){var U=/(?:^|\s)nocode(?:\s|$)/;var ab=/\r\n?|\n/;var ac=V.ownerDocument;var S;if(V.currentStyle){S=V.currentStyle.whiteSpace}else{if(window.getComputedStyle){S=ac.defaultView.getComputedStyle(V,null).getPropertyValue("white-space")}}var Z=S&&"pre"===S.substring(0,3);var af=ac.createElement("LI");while(V.firstChild){af.appendChild(V.firstChild)}var W=[af];function ae(al){switch(al.nodeType){case 1:if(U.test(al.className)){break}if("BR"===al.nodeName){ad(al);if(al.parentNode){al.parentNode.removeChild(al)}}else{for(var an=al.firstChild;an;an=an.nextSibling){ae(an)}}break;case 3:case 4:if(Z){var am=al.nodeValue;var aj=am.match(ab);if(aj){var ai=am.substring(0,aj.index);al.nodeValue=ai;var ah=am.substring(aj.index+aj[0].length);if(ah){var ak=al.parentNode;ak.insertBefore(ac.createTextNode(ah),al.nextSibling)}ad(al);if(!ai){al.parentNode.removeChild(al)}}}break}}function ad(ak){while(!ak.nextSibling){ak=ak.parentNode;if(!ak){return}}function ai(al,ar){var aq=ar?al.cloneNode(false):al;var ao=al.parentNode;if(ao){var ap=ai(ao,1);var an=al.nextSibling;ap.appendChild(aq);for(var am=an;am;am=an){an=am.nextSibling;ap.appendChild(am)}}return aq}var ah=ai(ak.nextSibling,0);for(var aj;(aj=ah.parentNode)&&aj.nodeType===1;){ah=aj}W.push(ah)}for(var Y=0;Y=S){ah+=2}if(V>=ap){Z+=2}}}var t={};function c(U,V){for(var S=V.length;--S>=0;){var T=V[S];if(!t.hasOwnProperty(T)){t[T]=U}else{if(window.console){console.warn("cannot override language handler %s",T)}}}}function q(T,S){if(!(T&&t.hasOwnProperty(T))){T=/^\s*]*(?:>|$)/],[j,/^<\!--[\s\S]*?(?:-\->|$)/],["lang-",/^<\?([\s\S]+?)(?:\?>|$)/],["lang-",/^<%([\s\S]+?)(?:%>|$)/],[L,/^(?:<[%?]|[%?]>)/],["lang-",/^]*>([\s\S]+?)<\/xmp\b[^>]*>/i],["lang-js",/^]*>([\s\S]*?)(<\/script\b[^>]*>)/i],["lang-css",/^]*>([\s\S]*?)(<\/style\b[^>]*>)/i],["lang-in.tag",/^(<\/?[a-z][^<>]*>)/i]]),["default-markup","htm","html","mxml","xhtml","xml","xsl"]);c(g([[F,/^[\s]+/,null," \t\r\n"],[n,/^(?:\"[^\"]*\"?|\'[^\']*\'?)/,null,"\"'"]],[[m,/^^<\/?[a-z](?:[\w.:-]*\w)?|\/?>$/i],[P,/^(?!style[\s=]|on)[a-z](?:[\w:-]*\w)?/i],["lang-uq.val",/^=\s*([^>\'\"\s]*(?:[^>\'\"\s\/]|\/(?=\s)))/],[L,/^[=<>\/]+/],["lang-js",/^on\w+\s*=\s*\"([^\"]+)\"/i],["lang-js",/^on\w+\s*=\s*\'([^\']+)\'/i],["lang-js",/^on\w+\s*=\s*([^\"\'>\s]+)/i],["lang-css",/^style\s*=\s*\"([^\"]+)\"/i],["lang-css",/^style\s*=\s*\'([^\']+)\'/i],["lang-css",/^style\s*=\s*([^\"\'>\s]+)/i]]),["in.tag"]);c(g([],[[n,/^[\s\S]+/]]),["uq.val"]);c(i({keywords:l,hashComments:true,cStyleComments:true,types:e}),["c","cc","cpp","cxx","cyc","m"]);c(i({keywords:"null,true,false"}),["json"]);c(i({keywords:R,hashComments:true,cStyleComments:true,verbatimStrings:true,types:e}),["cs"]);c(i({keywords:x,cStyleComments:true}),["java"]);c(i({keywords:H,hashComments:true,multiLineStrings:true}),["bsh","csh","sh"]);c(i({keywords:I,hashComments:true,multiLineStrings:true,tripleQuotedStrings:true}),["cv","py"]);c(i({keywords:s,hashComments:true,multiLineStrings:true,regexLiterals:true}),["perl","pl","pm"]);c(i({keywords:f,hashComments:true,multiLineStrings:true,regexLiterals:true}),["rb"]);c(i({keywords:w,cStyleComments:true,regexLiterals:true}),["js"]);c(i({keywords:r,hashComments:3,cStyleComments:true,multilineStrings:true,tripleQuotedStrings:true,regexLiterals:true}),["coffee"]);c(g([],[[C,/^[\s\S]+/]]),["regex"]);function d(V){var U=V.langExtension;try{var S=a(V.sourceNode);var T=S.sourceCode;V.sourceCode=T;V.spans=S.spans;V.basePos=0;q(U,T)(V);D(V)}catch(W){if("console" in window){console.log(W&&W.stack?W.stack:W)}}}function y(W,V,U){var S=document.createElement("PRE");S.innerHTML=W;if(U){Q(S,U)}var T={langExtension:V,numberLines:U,sourceNode:S};d(T);return S.innerHTML}function b(ad){function Y(af){return document.getElementsByTagName(af)}var ac=[Y("pre"),Y("code"),Y("xmp")];var T=[];for(var aa=0;aa=0){var ah=ai.match(ab);var am;if(!ah&&(am=o(aj))&&"CODE"===am.tagName){ah=am.className.match(ab)}if(ah){ah=ah[1]}var al=false;for(var ak=aj.parentNode;ak;ak=ak.parentNode){if((ak.tagName==="pre"||ak.tagName==="code"||ak.tagName==="xmp")&&ak.className&&ak.className.indexOf("prettyprint")>=0){al=true;break}}if(!al){var af=aj.className.match(/\blinenums\b(?::(\d+))?/);af=af?af[1]&&af[1].length?+af[1]:true:false;if(af){Q(aj,af)}S={langExtension:ah,sourceNode:aj,numberLines:af};d(S)}}}if(X]*(?:>|$)/],[PR.PR_COMMENT,/^<\!--[\s\S]*?(?:-\->|$)/],[PR.PR_PUNCTUATION,/^(?:<[%?]|[%?]>)/],["lang-",/^<\?([\s\S]+?)(?:\?>|$)/],["lang-",/^<%([\s\S]+?)(?:%>|$)/],["lang-",/^]*>([\s\S]+?)<\/xmp\b[^>]*>/i],["lang-handlebars",/^]*type\s*=\s*['"]?text\/x-handlebars-template['"]?\b[^>]*>([\s\S]*?)(<\/script\b[^>]*>)/i],["lang-js",/^]*>([\s\S]*?)(<\/script\b[^>]*>)/i],["lang-css",/^]*>([\s\S]*?)(<\/style\b[^>]*>)/i],["lang-in.tag",/^(<\/?[a-z][^<>]*>)/i],[PR.PR_DECLARATION,/^{{[#^>/]?\s*[\w.][^}]*}}/],[PR.PR_DECLARATION,/^{{&?\s*[\w.][^}]*}}/],[PR.PR_DECLARATION,/^{{{>?\s*[\w.][^}]*}}}/],[PR.PR_COMMENT,/^{{![^}]*}}/]]),["handlebars","hbs"]);PR.registerLangHandler(PR.createSimpleLexer([[PR.PR_PLAIN,/^[ \t\r\n\f]+/,null," \t\r\n\f"]],[[PR.PR_STRING,/^\"(?:[^\n\r\f\\\"]|\\(?:\r\n?|\n|\f)|\\[\s\S])*\"/,null],[PR.PR_STRING,/^\'(?:[^\n\r\f\\\']|\\(?:\r\n?|\n|\f)|\\[\s\S])*\'/,null],["lang-css-str",/^url\(([^\)\"\']*)\)/i],[PR.PR_KEYWORD,/^(?:url|rgb|\!important|@import|@page|@media|@charset|inherit)(?=[^\-\w]|$)/i,null],["lang-css-kw",/^(-?(?:[_a-z]|(?:\\[0-9a-f]+ ?))(?:[_a-z0-9\-]|\\(?:\\[0-9a-f]+ ?))*)\s*:/i],[PR.PR_COMMENT,/^\/\*[^*]*\*+(?:[^\/*][^*]*\*+)*\//],[PR.PR_COMMENT,/^(?:)/],[PR.PR_LITERAL,/^(?:\d+|\d*\.\d+)(?:%|[a-z]+)?/i],[PR.PR_LITERAL,/^#(?:[0-9a-f]{3}){1,2}/i],[PR.PR_PLAIN,/^-?(?:[_a-z]|(?:\\[\da-f]+ ?))(?:[_a-z\d\-]|\\(?:\\[\da-f]+ ?))*/i],[PR.PR_PUNCTUATION,/^[^\s\w\'\"]+/]]),["css"]);PR.registerLangHandler(PR.createSimpleLexer([],[[PR.PR_KEYWORD,/^-?(?:[_a-z]|(?:\\[\da-f]+ ?))(?:[_a-z\d\-]|\\(?:\\[\da-f]+ ?))*/i]]),["css-kw"]);PR.registerLangHandler(PR.createSimpleLexer([],[[PR.PR_STRING,/^[^\)\"\']+/]]),["css-str"]); diff --git a/coverage/lcov-report/sort-arrow-sprite.png b/coverage/lcov-report/sort-arrow-sprite.png new file mode 100644 index 0000000000000000000000000000000000000000..6ed68316eb3f65dec9063332d2f69bf3093bbfab GIT binary patch literal 138 zcmeAS@N?(olHy`uVBq!ia0vp^>_9Bd!3HEZxJ@+%Qh}Z>jv*C{$p!i!8j}?a+@3A= zIAGwzjijN=FBi!|L1t?LM;Q;gkwn>2cAy-KV{dn nf0J1DIvEHQu*n~6U}x}qyky7vi4|9XhBJ7&`njxgN@xNA8m%nc literal 0 HcmV?d00001 diff --git a/coverage/lcov-report/sorter.js b/coverage/lcov-report/sorter.js new file mode 100644 index 0000000..4ed70ae --- /dev/null +++ b/coverage/lcov-report/sorter.js @@ -0,0 +1,210 @@ +/* eslint-disable */ +var addSorting = (function() { + 'use strict'; + var cols, + currentSort = { + index: 0, + desc: false + }; + + // returns the summary table element + function getTable() { + return document.querySelector('.coverage-summary'); + } + // returns the thead element of the summary table + function getTableHeader() { + return getTable().querySelector('thead tr'); + } + // returns the tbody element of the summary table + function getTableBody() { + return getTable().querySelector('tbody'); + } + // returns the th element for nth column + function getNthColumn(n) { + return getTableHeader().querySelectorAll('th')[n]; + } + + function onFilterInput() { + const searchValue = document.getElementById('fileSearch').value; + const rows = document.getElementsByTagName('tbody')[0].children; + + // Try to create a RegExp from the searchValue. If it fails (invalid regex), + // it will be treated as a plain text search + let searchRegex; + try { + searchRegex = new RegExp(searchValue, 'i'); // 'i' for case-insensitive + } catch (error) { + searchRegex = null; + } + + for (let i = 0; i < rows.length; i++) { + const row = rows[i]; + let isMatch = false; + + if (searchRegex) { + // If a valid regex was created, use it for matching + isMatch = searchRegex.test(row.textContent); + } else { + // Otherwise, fall back to the original plain text search + isMatch = row.textContent + .toLowerCase() + .includes(searchValue.toLowerCase()); + } + + row.style.display = isMatch ? '' : 'none'; + } + } + + // loads the search box + function addSearchBox() { + var template = document.getElementById('filterTemplate'); + var templateClone = template.content.cloneNode(true); + templateClone.getElementById('fileSearch').oninput = onFilterInput; + template.parentElement.appendChild(templateClone); + } + + // loads all columns + function loadColumns() { + var colNodes = getTableHeader().querySelectorAll('th'), + colNode, + cols = [], + col, + i; + + for (i = 0; i < colNodes.length; i += 1) { + colNode = colNodes[i]; + col = { + key: colNode.getAttribute('data-col'), + sortable: !colNode.getAttribute('data-nosort'), + type: colNode.getAttribute('data-type') || 'string' + }; + cols.push(col); + if (col.sortable) { + col.defaultDescSort = col.type === 'number'; + colNode.innerHTML = + colNode.innerHTML + ''; + } + } + return cols; + } + // attaches a data attribute to every tr element with an object + // of data values keyed by column name + function loadRowData(tableRow) { + var tableCols = tableRow.querySelectorAll('td'), + colNode, + col, + data = {}, + i, + val; + for (i = 0; i < tableCols.length; i += 1) { + colNode = tableCols[i]; + col = cols[i]; + val = colNode.getAttribute('data-value'); + if (col.type === 'number') { + val = Number(val); + } + data[col.key] = val; + } + return data; + } + // loads all row data + function loadData() { + var rows = getTableBody().querySelectorAll('tr'), + i; + + for (i = 0; i < rows.length; i += 1) { + rows[i].data = loadRowData(rows[i]); + } + } + // sorts the table using the data for the ith column + function sortByIndex(index, desc) { + var key = cols[index].key, + sorter = function(a, b) { + a = a.data[key]; + b = b.data[key]; + return a < b ? -1 : a > b ? 1 : 0; + }, + finalSorter = sorter, + tableBody = document.querySelector('.coverage-summary tbody'), + rowNodes = tableBody.querySelectorAll('tr'), + rows = [], + i; + + if (desc) { + finalSorter = function(a, b) { + return -1 * sorter(a, b); + }; + } + + for (i = 0; i < rowNodes.length; i += 1) { + rows.push(rowNodes[i]); + tableBody.removeChild(rowNodes[i]); + } + + rows.sort(finalSorter); + + for (i = 0; i < rows.length; i += 1) { + tableBody.appendChild(rows[i]); + } + } + // removes sort indicators for current column being sorted + function removeSortIndicators() { + var col = getNthColumn(currentSort.index), + cls = col.className; + + cls = cls.replace(/ sorted$/, '').replace(/ sorted-desc$/, ''); + col.className = cls; + } + // adds sort indicators for current column being sorted + function addSortIndicators() { + getNthColumn(currentSort.index).className += currentSort.desc + ? ' sorted-desc' + : ' sorted'; + } + // adds event listeners for all sorter widgets + function enableUI() { + var i, + el, + ithSorter = function ithSorter(i) { + var col = cols[i]; + + return function() { + var desc = col.defaultDescSort; + + if (currentSort.index === i) { + desc = !currentSort.desc; + } + sortByIndex(i, desc); + removeSortIndicators(); + currentSort.index = i; + currentSort.desc = desc; + addSortIndicators(); + }; + }; + for (i = 0; i < cols.length; i += 1) { + if (cols[i].sortable) { + // add the click event handler on the th so users + // dont have to click on those tiny arrows + el = getNthColumn(i).querySelector('.sorter').parentElement; + if (el.addEventListener) { + el.addEventListener('click', ithSorter(i)); + } else { + el.attachEvent('onclick', ithSorter(i)); + } + } + } + } + // adds sorting functionality to the UI + return function() { + if (!getTable()) { + return; + } + cols = loadColumns(); + loadData(); + addSearchBox(); + addSortIndicators(); + enableUI(); + }; +})(); + +window.addEventListener('load', addSorting); diff --git a/coverage/lcov-report/store/auth.js.html b/coverage/lcov-report/store/auth.js.html new file mode 100644 index 0000000..1da2e33 --- /dev/null +++ b/coverage/lcov-report/store/auth.js.html @@ -0,0 +1,226 @@ + + + + + + Code coverage report for store/auth.js + + + + + + + + + +
+
+

All files / store auth.js

+
+ +
+ 100% + Statements + 25/25 +
+ + +
+ 100% + Branches + 2/2 +
+ + +
+ 100% + Functions + 5/5 +
+ + +
+ 100% + Lines + 24/24 +
+ + +
+

+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +

+ +
+
+

+
1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 +16 +17 +18 +19 +20 +21 +22 +23 +24 +25 +26 +27 +28 +29 +30 +31 +32 +33 +34 +35 +36 +37 +38 +39 +40 +41 +42 +43 +44 +45 +46 +47 +48  +  +  +1x +  +  +  +  +  +3x +3x +1x +1x +  +  +2x +2x +1x +  +1x +1x +1x +  +  +  +  +2x +1x +1x +1x +1x +  +  +  +1x +1x +1x +1x +1x +  +  +  +1x +1x +1x +  +  + 
import { create } from 'zustand'
+import api from '../api/client'
+ 
+export const useAuthStore = create((set, get) => ({
+  user: null,
+  isLoading: true,
+  isAuthenticated: false,
+ 
+  initialize: async () => {
+    const token = localStorage.getItem('access_token')
+    if (!token) {
+      set({ isLoading: false, isAuthenticated: false })
+      return
+    }
+ 
+    try {
+      const { data } = await api.get('/auth/me')
+      set({ user: data, isLoading: false, isAuthenticated: true })
+    } catch (error) {
+      localStorage.removeItem('access_token')
+      localStorage.removeItem('refresh_token')
+      set({ user: null, isLoading: false, isAuthenticated: false })
+    }
+  },
+ 
+  login: async (email, password) => {
+    const { data } = await api.post('/auth/login', { email, password })
+    localStorage.setItem('access_token', data.access_token)
+    localStorage.setItem('refresh_token', data.refresh_token)
+    set({ user: data.user, isAuthenticated: true })
+    return data
+  },
+ 
+  register: async (email, username, password) => {
+    const { data } = await api.post('/auth/register', { email, username, password })
+    localStorage.setItem('access_token', data.access_token)
+    localStorage.setItem('refresh_token', data.refresh_token)
+    set({ user: data.user, isAuthenticated: true })
+    return data
+  },
+ 
+  logout: () => {
+    localStorage.removeItem('access_token')
+    localStorage.removeItem('refresh_token')
+    set({ user: null, isAuthenticated: false })
+  },
+}))
+ 
+ +
+
+ + + + + + + + \ No newline at end of file diff --git a/coverage/lcov-report/store/index.html b/coverage/lcov-report/store/index.html new file mode 100644 index 0000000..969097b --- /dev/null +++ b/coverage/lcov-report/store/index.html @@ -0,0 +1,116 @@ + + + + + + Code coverage report for store + + + + + + + + + +
+
+

All files store

+
+ +
+ 100% + Statements + 25/25 +
+ + +
+ 100% + Branches + 2/2 +
+ + +
+ 100% + Functions + 5/5 +
+ + +
+ 100% + Lines + 24/24 +
+ + +
+

+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +

+ +
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
FileStatementsBranchesFunctionsLines
auth.js +
+
100%25/25100%2/2100%5/5100%24/24
+
+
+
+ + + + + + + + \ No newline at end of file diff --git a/coverage/lcov.info b/coverage/lcov.info new file mode 100644 index 0000000..0ef755a --- /dev/null +++ b/coverage/lcov.info @@ -0,0 +1,4213 @@ +TN: +SF:src/api/finance.js +FN:5,(anonymous_0) +FN:9,(anonymous_1) +FN:13,(anonymous_2) +FN:17,(anonymous_3) +FN:22,(anonymous_4) +FN:26,(anonymous_5) +FN:30,(anonymous_6) +FN:34,(anonymous_7) +FN:39,(anonymous_8) +FN:43,(anonymous_9) +FNF:10 +FNH:10 +FNDA:1,(anonymous_0) +FNDA:1,(anonymous_1) +FNDA:1,(anonymous_2) +FNDA:1,(anonymous_3) +FNDA:2,(anonymous_4) +FNDA:1,(anonymous_5) +FNDA:1,(anonymous_6) +FNDA:1,(anonymous_7) +FNDA:1,(anonymous_8) +FNDA:1,(anonymous_9) +DA:3,1 +DA:6,1 +DA:7,1 +DA:10,1 +DA:11,1 +DA:14,1 +DA:15,1 +DA:18,1 +DA:23,2 +DA:24,2 +DA:27,1 +DA:28,1 +DA:31,1 +DA:32,1 +DA:35,1 +DA:40,1 +DA:41,1 +DA:44,1 +DA:45,1 +LF:19 +LH:19 +BRDA:22,0,0,2 +BRDA:39,1,0,1 +BRDA:43,2,0,1 +BRF:3 +BRH:3 +end_of_record +TN: +SF:src/api/habits.js +FN:4,(anonymous_0) +FN:4,(anonymous_1) +FN:5,(anonymous_2) +FN:5,(anonymous_3) +FN:6,(anonymous_4) +FN:6,(anonymous_5) +FN:7,(anonymous_6) +FN:7,(anonymous_7) +FN:8,(anonymous_8) +FN:10,(anonymous_9) +FN:10,(anonymous_10) +FN:11,(anonymous_11) +FN:11,(anonymous_12) +FN:12,(anonymous_13) +FN:14,(anonymous_14) +FN:14,(anonymous_15) +FN:15,(anonymous_16) +FN:15,(anonymous_17) +FN:18,(anonymous_18) +FN:18,(anonymous_19) +FN:19,(anonymous_20) +FN:19,(anonymous_21) +FN:20,(anonymous_22) +FNF:23 +FNH:21 +FNDA:1,(anonymous_0) +FNDA:1,(anonymous_1) +FNDA:0,(anonymous_2) +FNDA:0,(anonymous_3) +FNDA:1,(anonymous_4) +FNDA:1,(anonymous_5) +FNDA:1,(anonymous_6) +FNDA:1,(anonymous_7) +FNDA:1,(anonymous_8) +FNDA:2,(anonymous_9) +FNDA:2,(anonymous_10) +FNDA:1,(anonymous_11) +FNDA:1,(anonymous_12) +FNDA:1,(anonymous_13) +FNDA:1,(anonymous_14) +FNDA:1,(anonymous_15) +FNDA:1,(anonymous_16) +FNDA:1,(anonymous_17) +FNDA:1,(anonymous_18) +FNDA:1,(anonymous_19) +FNDA:1,(anonymous_20) +FNDA:1,(anonymous_21) +FNDA:1,(anonymous_22) +DA:3,1 +DA:4,1 +DA:5,0 +DA:6,1 +DA:7,1 +DA:8,1 +DA:10,2 +DA:11,1 +DA:12,1 +DA:14,1 +DA:15,1 +DA:18,1 +DA:19,1 +DA:20,1 +LF:14 +LH:13 +BRDA:10,0,0,2 +BRDA:11,1,0,1 +BRF:2 +BRH:2 +end_of_record +TN: +SF:src/api/profile.js +FN:4,(anonymous_0) +FN:8,(anonymous_1) +FNF:2 +FNH:2 +FNDA:1,(anonymous_0) +FNDA:1,(anonymous_1) +DA:3,1 +DA:5,1 +DA:6,1 +DA:9,1 +DA:10,1 +LF:5 +LH:5 +BRF:0 +BRH:0 +end_of_record +TN: +SF:src/api/savings.js +FN:5,(anonymous_0) +FN:5,(anonymous_1) +FN:6,(anonymous_2) +FN:6,(anonymous_3) +FN:7,(anonymous_4) +FN:7,(anonymous_5) +FN:8,(anonymous_6) +FN:9,(anonymous_7) +FN:10,(anonymous_8) +FN:13,(anonymous_9) +FN:16,(anonymous_10) +FN:18,(anonymous_11) +FN:19,(anonymous_12) +FN:20,(anonymous_13) +FN:21,(anonymous_14) +FN:22,(anonymous_15) +FN:25,(anonymous_16) +FN:25,(anonymous_17) +FN:28,(anonymous_18) +FN:29,(anonymous_19) +FN:30,(anonymous_20) +FN:33,(anonymous_21) +FN:34,(anonymous_22) +FN:38,(anonymous_23) +FN:41,(anonymous_24) +FN:42,(anonymous_25) +FN:45,(anonymous_26) +FN:46,(anonymous_27) +FN:47,(anonymous_28) +FN:48,(anonymous_29) +FNF:30 +FNH:21 +FNDA:1,(anonymous_0) +FNDA:1,(anonymous_1) +FNDA:1,(anonymous_2) +FNDA:1,(anonymous_3) +FNDA:1,(anonymous_4) +FNDA:1,(anonymous_5) +FNDA:1,(anonymous_6) +FNDA:1,(anonymous_7) +FNDA:1,(anonymous_8) +FNDA:2,(anonymous_9) +FNDA:2,(anonymous_10) +FNDA:1,(anonymous_11) +FNDA:1,(anonymous_12) +FNDA:0,(anonymous_13) +FNDA:0,(anonymous_14) +FNDA:0,(anonymous_15) +FNDA:1,(anonymous_16) +FNDA:1,(anonymous_17) +FNDA:1,(anonymous_18) +FNDA:1,(anonymous_19) +FNDA:1,(anonymous_20) +FNDA:1,(anonymous_21) +FNDA:0,(anonymous_22) +FNDA:1,(anonymous_23) +FNDA:1,(anonymous_24) +FNDA:0,(anonymous_25) +FNDA:0,(anonymous_26) +FNDA:0,(anonymous_27) +FNDA:0,(anonymous_28) +FNDA:0,(anonymous_29) +DA:3,1 +DA:5,1 +DA:6,1 +DA:7,1 +DA:9,1 +DA:10,1 +DA:14,2 +DA:15,2 +DA:16,2 +DA:19,1 +DA:21,0 +DA:22,0 +DA:25,1 +DA:29,1 +DA:31,1 +DA:33,1 +DA:35,0 +DA:39,1 +DA:41,1 +DA:43,0 +DA:45,0 +DA:47,0 +DA:49,0 +LF:23 +LH:16 +BRDA:13,0,0,2 +BRDA:13,1,0,2 +BRDA:15,2,0,1 +BRDA:15,2,1,1 +BRF:4 +BRH:4 +end_of_record +TN: +SF:src/api/tasks.js +FN:4,(anonymous_0) +FN:13,(anonymous_1) +FN:18,(anonymous_2) +FN:23,(anonymous_3) +FN:28,(anonymous_4) +FN:33,(anonymous_5) +FN:37,(anonymous_6) +FN:42,(anonymous_7) +FNF:8 +FNH:8 +FNDA:3,(anonymous_0) +FNDA:1,(anonymous_1) +FNDA:1,(anonymous_2) +FNDA:1,(anonymous_3) +FNDA:1,(anonymous_4) +FNDA:1,(anonymous_5) +FNDA:1,(anonymous_6) +FNDA:1,(anonymous_7) +DA:3,1 +DA:5,3 +DA:6,3 +DA:7,2 +DA:9,3 +DA:10,3 +DA:14,1 +DA:15,1 +DA:19,1 +DA:20,1 +DA:24,1 +DA:25,1 +DA:29,1 +DA:30,1 +DA:34,1 +DA:38,1 +DA:39,1 +DA:43,1 +DA:44,1 +LF:19 +LH:19 +BRDA:4,0,0,3 +BRDA:6,1,0,2 +BRDA:6,1,1,1 +BRF:3 +BRH:3 +end_of_record +TN: +SF:src/components/CreateHabitModal.jsx +FN:36,CreateHabitModal +FN:52,(anonymous_1) +FN:53,(anonymous_2) +FN:58,(anonymous_3) +FN:63,(anonymous_4) +FN:78,(anonymous_5) +FN:108,(anonymous_6) +FN:109,(anonymous_7) +FN:111,(anonymous_8) +FN:112,(anonymous_9) +FN:160,(anonymous_10) +FN:174,(anonymous_11) +FN:187,(anonymous_12) +FN:199,(anonymous_13) +FN:211,(anonymous_14) +FN:234,(anonymous_15) +FN:238,(anonymous_16) +FN:266,(anonymous_17) +FN:283,(anonymous_18) +FN:303,(anonymous_19) +FN:317,(anonymous_20) +FN:321,(anonymous_21) +FN:336,(anonymous_22) +FN:351,(anonymous_23) +FN:355,(anonymous_24) +FN:359,(anonymous_25) +FN:383,(anonymous_26) +FN:387,(anonymous_27) +FNF:28 +FNH:8 +FNDA:12,CreateHabitModal +FNDA:1,(anonymous_1) +FNDA:1,(anonymous_2) +FNDA:0,(anonymous_3) +FNDA:2,(anonymous_4) +FNDA:2,(anonymous_5) +FNDA:0,(anonymous_6) +FNDA:0,(anonymous_7) +FNDA:0,(anonymous_8) +FNDA:0,(anonymous_9) +FNDA:1,(anonymous_10) +FNDA:0,(anonymous_11) +FNDA:0,(anonymous_12) +FNDA:0,(anonymous_13) +FNDA:0,(anonymous_14) +FNDA:0,(anonymous_15) +FNDA:0,(anonymous_16) +FNDA:0,(anonymous_17) +FNDA:0,(anonymous_18) +FNDA:0,(anonymous_19) +FNDA:110,(anonymous_20) +FNDA:0,(anonymous_21) +FNDA:0,(anonymous_22) +FNDA:0,(anonymous_23) +FNDA:0,(anonymous_24) +FNDA:0,(anonymous_25) +FNDA:110,(anonymous_26) +FNDA:0,(anonymous_27) +DA:9,1 +DA:14,1 +DA:26,1 +DA:37,12 +DA:38,12 +DA:39,12 +DA:40,12 +DA:41,12 +DA:42,12 +DA:43,12 +DA:44,12 +DA:45,12 +DA:46,12 +DA:47,12 +DA:49,12 +DA:51,12 +DA:52,1 +DA:54,1 +DA:55,1 +DA:56,1 +DA:59,0 +DA:63,12 +DA:64,2 +DA:65,2 +DA:66,2 +DA:67,2 +DA:68,2 +DA:69,2 +DA:70,2 +DA:71,2 +DA:72,2 +DA:73,2 +DA:74,2 +DA:75,2 +DA:78,12 +DA:79,2 +DA:80,2 +DA:81,1 +DA:82,1 +DA:84,1 +DA:85,0 +DA:86,0 +DA:88,1 +DA:89,2 +DA:90,0 +DA:91,0 +DA:94,1 +DA:95,1 +DA:96,0 +DA:98,1 +DA:99,0 +DA:101,1 +DA:102,0 +DA:105,1 +DA:108,12 +DA:109,0 +DA:110,0 +DA:111,0 +DA:112,0 +DA:116,12 +DA:118,12 +DA:160,1 +DA:174,0 +DA:187,0 +DA:199,0 +DA:211,0 +DA:235,0 +DA:238,0 +DA:266,0 +DA:283,0 +DA:303,0 +DA:318,110 +DA:321,0 +DA:336,0 +DA:352,0 +DA:356,0 +DA:359,0 +DA:384,110 +DA:387,0 +LF:79 +LH:52 +BRDA:59,0,0,0 +BRDA:59,0,1,0 +BRDA:80,1,0,1 +BRDA:80,1,1,1 +BRDA:84,2,0,0 +BRDA:84,2,1,1 +BRDA:84,3,0,1 +BRDA:84,3,1,0 +BRDA:88,4,0,1 +BRDA:88,4,1,0 +BRDA:89,5,0,0 +BRDA:89,5,1,2 +BRDA:89,6,0,2 +BRDA:89,6,1,0 +BRDA:89,6,2,0 +BRDA:95,7,0,0 +BRDA:95,7,1,1 +BRDA:98,8,0,0 +BRDA:98,8,1,1 +BRDA:101,9,0,0 +BRDA:101,9,1,1 +BRDA:110,10,0,0 +BRDA:110,10,1,0 +BRDA:120,11,0,12 +BRDA:120,11,1,11 +BRDA:147,12,0,11 +BRDA:147,12,1,1 +BRDA:190,13,0,11 +BRDA:190,13,1,0 +BRDA:202,14,0,0 +BRDA:202,14,1,11 +BRDA:214,15,0,0 +BRDA:214,15,1,11 +BRDA:224,16,0,11 +BRDA:224,16,1,0 +BRDA:241,17,0,0 +BRDA:241,17,1,0 +BRDA:253,18,0,11 +BRDA:253,18,1,0 +BRDA:266,19,0,0 +BRDA:266,19,1,0 +BRDA:266,20,0,0 +BRDA:266,20,1,0 +BRDA:288,21,0,0 +BRDA:288,21,1,11 +BRDA:324,22,0,11 +BRDA:324,22,1,99 +BRDA:339,23,0,0 +BRDA:339,23,1,11 +BRDA:340,24,0,0 +BRDA:340,24,1,11 +BRDA:344,25,0,11 +BRDA:344,25,1,0 +BRDA:362,26,0,0 +BRDA:362,26,1,0 +BRDA:390,27,0,11 +BRDA:390,27,1,99 +BRDA:404,28,0,1 +BRDA:404,28,1,10 +BRF:59 +BRH:29 +end_of_record +TN: +SF:src/components/CreateTaskModal.jsx +FN:37,CreateTaskModal +FN:60,(anonymous_1) +FN:61,(anonymous_2) +FN:66,(anonymous_3) +FN:71,(anonymous_4) +FN:88,(anonymous_5) +FN:159,(anonymous_6) +FN:172,(anonymous_7) +FN:185,(anonymous_8) +FN:197,(anonymous_9) +FN:209,(anonymous_10) +FN:225,(anonymous_11) +FN:240,(anonymous_12) +FN:262,(anonymous_13) +FN:266,(anonymous_14) +FN:287,(anonymous_15) +FN:301,(anonymous_16) +FN:320,(anonymous_17) +FN:334,(anonymous_18) +FN:338,(anonymous_19) +FN:357,(anonymous_20) +FN:361,(anonymous_21) +FN:376,(anonymous_22) +FN:391,(anonymous_23) +FN:395,(anonymous_24) +FN:399,(anonymous_25) +FN:423,(anonymous_26) +FN:427,(anonymous_27) +FNF:28 +FNH:9 +FNDA:12,CreateTaskModal +FNDA:1,(anonymous_1) +FNDA:1,(anonymous_2) +FNDA:0,(anonymous_3) +FNDA:2,(anonymous_4) +FNDA:2,(anonymous_5) +FNDA:1,(anonymous_6) +FNDA:0,(anonymous_7) +FNDA:0,(anonymous_8) +FNDA:0,(anonymous_9) +FNDA:0,(anonymous_10) +FNDA:0,(anonymous_11) +FNDA:0,(anonymous_12) +FNDA:0,(anonymous_13) +FNDA:0,(anonymous_14) +FNDA:0,(anonymous_15) +FNDA:0,(anonymous_16) +FNDA:0,(anonymous_17) +FNDA:44,(anonymous_18) +FNDA:0,(anonymous_19) +FNDA:88,(anonymous_20) +FNDA:0,(anonymous_21) +FNDA:0,(anonymous_22) +FNDA:0,(anonymous_23) +FNDA:0,(anonymous_24) +FNDA:0,(anonymous_25) +FNDA:110,(anonymous_26) +FNDA:0,(anonymous_27) +DA:9,1 +DA:14,1 +DA:23,1 +DA:30,1 +DA:38,12 +DA:39,12 +DA:41,12 +DA:42,12 +DA:43,12 +DA:44,12 +DA:45,12 +DA:46,12 +DA:47,12 +DA:48,12 +DA:49,12 +DA:52,12 +DA:53,12 +DA:54,12 +DA:55,12 +DA:57,12 +DA:59,12 +DA:60,1 +DA:62,1 +DA:63,1 +DA:64,1 +DA:67,0 +DA:71,12 +DA:72,2 +DA:73,2 +DA:74,2 +DA:75,2 +DA:76,2 +DA:77,2 +DA:78,2 +DA:79,2 +DA:80,2 +DA:81,2 +DA:82,2 +DA:83,2 +DA:84,2 +DA:85,2 +DA:88,12 +DA:89,2 +DA:90,2 +DA:91,1 +DA:92,1 +DA:95,1 +DA:106,2 +DA:107,0 +DA:108,0 +DA:109,0 +DA:112,1 +DA:115,12 +DA:117,12 +DA:159,1 +DA:172,0 +DA:185,0 +DA:197,0 +DA:209,0 +DA:225,0 +DA:240,0 +DA:263,0 +DA:266,0 +DA:287,0 +DA:301,0 +DA:320,0 +DA:335,44 +DA:338,0 +DA:358,88 +DA:361,0 +DA:376,0 +DA:392,0 +DA:396,0 +DA:399,0 +DA:424,110 +DA:427,0 +LF:76 +LH:54 +BRDA:37,0,0,12 +BRDA:45,1,0,12 +BRDA:45,1,1,12 +BRDA:67,2,0,0 +BRDA:67,2,1,0 +BRDA:76,3,0,2 +BRDA:76,3,1,2 +BRDA:90,4,0,1 +BRDA:90,4,1,1 +BRDA:100,5,0,1 +BRDA:100,5,1,0 +BRDA:102,6,0,2 +BRDA:102,6,1,1 +BRDA:106,7,0,0 +BRDA:106,7,1,2 +BRDA:108,8,0,0 +BRDA:108,8,1,0 +BRDA:109,9,0,0 +BRDA:109,9,1,0 +BRDA:119,10,0,12 +BRDA:119,10,1,11 +BRDA:146,11,0,11 +BRDA:146,11,1,1 +BRDA:188,12,0,11 +BRDA:188,12,1,0 +BRDA:200,13,0,0 +BRDA:200,13,1,11 +BRDA:212,14,0,0 +BRDA:212,14,1,11 +BRDA:235,15,0,0 +BRDA:235,15,1,11 +BRDA:243,16,0,0 +BRDA:243,16,1,11 +BRDA:248,17,0,0 +BRDA:248,17,1,11 +BRDA:254,18,0,11 +BRDA:254,18,1,0 +BRDA:269,19,0,0 +BRDA:269,19,1,0 +BRDA:279,20,0,0 +BRDA:279,20,1,0 +BRDA:287,21,0,0 +BRDA:287,21,1,0 +BRDA:303,22,0,0 +BRDA:303,22,1,0 +BRDA:341,23,0,11 +BRDA:341,23,1,33 +BRDA:364,24,0,11 +BRDA:364,24,1,77 +BRDA:379,25,0,0 +BRDA:379,25,1,11 +BRDA:380,26,0,0 +BRDA:380,26,1,11 +BRDA:384,27,0,11 +BRDA:384,27,1,0 +BRDA:402,28,0,0 +BRDA:402,28,1,0 +BRDA:430,29,0,11 +BRDA:430,29,1,99 +BRDA:444,30,0,1 +BRDA:444,30,1,10 +BRF:61 +BRH:33 +end_of_record +TN: +SF:src/components/EditHabitModal.jsx +FN:37,EditHabitModal +FN:61,(anonymous_1) +FN:65,(anonymous_2) +FN:94,(anonymous_3) +FN:95,(anonymous_4) +FN:100,(anonymous_5) +FN:106,(anonymous_6) +FN:107,(anonymous_7) +FN:112,(anonymous_8) +FN:118,(anonymous_9) +FN:119,(anonymous_10) +FN:127,(anonymous_11) +FN:133,(anonymous_12) +FN:134,(anonymous_13) +FN:140,(anonymous_14) +FN:149,(anonymous_15) +FN:177,(anonymous_16) +FN:181,(anonymous_17) +FN:198,(anonymous_18) +FN:199,(anonymous_19) +FN:201,(anonymous_20) +FN:202,(anonymous_21) +FN:207,(anonymous_22) +FN:208,(anonymous_23) +FN:253,(anonymous_24) +FN:282,(anonymous_25) +FN:295,(anonymous_26) +FN:308,(anonymous_27) +FN:320,(anonymous_28) +FN:332,(anonymous_29) +FN:355,(anonymous_30) +FN:359,(anonymous_31) +FN:387,(anonymous_32) +FN:404,(anonymous_33) +FN:424,(anonymous_34) +FN:437,(anonymous_35) +FN:466,(anonymous_36) +FN:498,(anonymous_37) +FN:518,(anonymous_38) +FN:528,(anonymous_39) +FN:537,(anonymous_40) +FN:544,(anonymous_41) +FN:562,(anonymous_42) +FN:577,(anonymous_43) +FN:585,(anonymous_44) +FN:605,(anonymous_45) +FN:609,(anonymous_46) +FN:624,(anonymous_47) +FN:639,(anonymous_48) +FN:643,(anonymous_49) +FN:647,(anonymous_50) +FN:671,(anonymous_51) +FN:675,(anonymous_52) +FN:697,(anonymous_53) +FNF:54 +FNH:13 +FNDA:20,EditHabitModal +FNDA:7,(anonymous_1) +FNDA:8,(anonymous_2) +FNDA:1,(anonymous_3) +FNDA:1,(anonymous_4) +FNDA:0,(anonymous_5) +FNDA:1,(anonymous_6) +FNDA:1,(anonymous_7) +FNDA:0,(anonymous_8) +FNDA:0,(anonymous_9) +FNDA:0,(anonymous_10) +FNDA:0,(anonymous_11) +FNDA:0,(anonymous_12) +FNDA:0,(anonymous_13) +FNDA:0,(anonymous_14) +FNDA:1,(anonymous_15) +FNDA:1,(anonymous_16) +FNDA:0,(anonymous_17) +FNDA:0,(anonymous_18) +FNDA:0,(anonymous_19) +FNDA:0,(anonymous_20) +FNDA:0,(anonymous_21) +FNDA:0,(anonymous_22) +FNDA:0,(anonymous_23) +FNDA:0,(anonymous_24) +FNDA:1,(anonymous_25) +FNDA:0,(anonymous_26) +FNDA:0,(anonymous_27) +FNDA:0,(anonymous_28) +FNDA:0,(anonymous_29) +FNDA:0,(anonymous_30) +FNDA:0,(anonymous_31) +FNDA:0,(anonymous_32) +FNDA:0,(anonymous_33) +FNDA:0,(anonymous_34) +FNDA:0,(anonymous_35) +FNDA:0,(anonymous_36) +FNDA:0,(anonymous_37) +FNDA:0,(anonymous_38) +FNDA:0,(anonymous_39) +FNDA:0,(anonymous_40) +FNDA:0,(anonymous_41) +FNDA:0,(anonymous_42) +FNDA:0,(anonymous_43) +FNDA:0,(anonymous_44) +FNDA:160,(anonymous_45) +FNDA:0,(anonymous_46) +FNDA:0,(anonymous_47) +FNDA:0,(anonymous_48) +FNDA:0,(anonymous_49) +FNDA:0,(anonymous_50) +FNDA:160,(anonymous_51) +FNDA:0,(anonymous_52) +FNDA:2,(anonymous_53) +DA:10,1 +DA:15,1 +DA:27,1 +DA:38,20 +DA:39,20 +DA:40,20 +DA:41,20 +DA:42,20 +DA:43,20 +DA:44,20 +DA:45,20 +DA:46,20 +DA:47,20 +DA:48,20 +DA:49,20 +DA:50,20 +DA:51,20 +DA:52,20 +DA:53,20 +DA:54,20 +DA:56,20 +DA:59,20 +DA:61,7 +DA:65,20 +DA:66,8 +DA:67,7 +DA:68,7 +DA:69,7 +DA:70,7 +DA:71,7 +DA:72,7 +DA:73,7 +DA:74,7 +DA:75,7 +DA:76,7 +DA:77,0 +DA:78,0 +DA:80,0 +DA:82,7 +DA:83,7 +DA:84,7 +DA:85,7 +DA:86,7 +DA:87,7 +DA:88,7 +DA:89,7 +DA:93,20 +DA:94,1 +DA:96,1 +DA:97,1 +DA:98,1 +DA:101,0 +DA:105,20 +DA:106,1 +DA:108,1 +DA:109,1 +DA:110,1 +DA:113,0 +DA:117,20 +DA:118,0 +DA:120,0 +DA:121,0 +DA:122,0 +DA:123,0 +DA:124,0 +DA:125,0 +DA:128,0 +DA:132,20 +DA:133,0 +DA:135,0 +DA:136,0 +DA:140,20 +DA:141,0 +DA:142,0 +DA:143,0 +DA:144,0 +DA:145,0 +DA:146,0 +DA:149,20 +DA:150,1 +DA:151,1 +DA:152,0 +DA:153,0 +DA:155,1 +DA:156,0 +DA:157,0 +DA:159,1 +DA:160,1 +DA:161,0 +DA:162,0 +DA:165,1 +DA:166,1 +DA:167,0 +DA:169,1 +DA:170,0 +DA:172,1 +DA:174,1 +DA:177,20 +DA:178,1 +DA:181,20 +DA:182,0 +DA:183,0 +DA:184,0 +DA:186,0 +DA:187,0 +DA:188,0 +DA:190,0 +DA:191,0 +DA:198,20 +DA:199,0 +DA:200,0 +DA:201,0 +DA:202,0 +DA:206,20 +DA:207,20 +DA:208,20 +DA:210,20 +DA:212,20 +DA:214,20 +DA:253,0 +DA:282,1 +DA:295,0 +DA:308,0 +DA:320,0 +DA:332,0 +DA:356,0 +DA:359,0 +DA:387,0 +DA:404,0 +DA:424,0 +DA:437,0 +DA:467,0 +DA:469,0 +DA:498,0 +DA:518,0 +DA:528,0 +DA:537,0 +DA:544,0 +DA:562,0 +DA:578,0 +DA:585,0 +DA:606,160 +DA:609,0 +DA:624,0 +DA:640,0 +DA:644,0 +DA:647,0 +DA:672,160 +DA:675,0 +DA:697,2 +LF:150 +LH:81 +BRDA:59,0,0,20 +BRDA:62,1,0,20 +BRDA:62,1,1,20 +BRDA:66,2,0,7 +BRDA:66,2,1,1 +BRDA:66,3,0,8 +BRDA:66,3,1,8 +BRDA:67,4,0,7 +BRDA:67,4,1,0 +BRDA:68,5,0,7 +BRDA:68,5,1,0 +BRDA:69,6,0,7 +BRDA:69,6,1,0 +BRDA:70,7,0,7 +BRDA:70,7,1,0 +BRDA:71,8,0,7 +BRDA:71,8,1,0 +BRDA:72,9,0,7 +BRDA:72,9,1,0 +BRDA:73,10,0,7 +BRDA:73,10,1,0 +BRDA:74,11,0,7 +BRDA:74,11,1,7 +BRDA:75,12,0,7 +BRDA:75,12,1,0 +BRDA:77,13,0,0 +BRDA:77,13,1,0 +BRDA:101,14,0,0 +BRDA:101,14,1,0 +BRDA:113,15,0,0 +BRDA:113,15,1,0 +BRDA:128,16,0,0 +BRDA:128,16,1,0 +BRDA:151,17,0,0 +BRDA:151,17,1,1 +BRDA:155,18,0,0 +BRDA:155,18,1,1 +BRDA:155,19,0,1 +BRDA:155,19,1,0 +BRDA:159,20,0,1 +BRDA:159,20,1,0 +BRDA:160,21,0,0 +BRDA:160,21,1,1 +BRDA:160,22,0,1 +BRDA:160,22,1,0 +BRDA:160,22,2,0 +BRDA:166,23,0,0 +BRDA:166,23,1,1 +BRDA:169,24,0,0 +BRDA:169,24,1,1 +BRDA:172,25,0,1 +BRDA:172,25,1,1 +BRDA:182,26,0,0 +BRDA:182,26,1,0 +BRDA:182,27,0,0 +BRDA:182,27,1,0 +BRDA:186,28,0,0 +BRDA:186,28,1,0 +BRDA:200,29,0,0 +BRDA:200,29,1,0 +BRDA:212,30,0,0 +BRDA:212,30,1,20 +BRDA:216,31,0,20 +BRDA:216,31,1,19 +BRDA:242,32,0,3 +BRDA:242,32,1,16 +BRDA:263,33,0,0 +BRDA:263,33,1,3 +BRDA:269,34,0,16 +BRDA:269,34,1,0 +BRDA:311,35,0,16 +BRDA:311,35,1,0 +BRDA:323,36,0,0 +BRDA:323,36,1,16 +BRDA:335,37,0,0 +BRDA:335,37,1,16 +BRDA:345,38,0,16 +BRDA:345,38,1,0 +BRDA:362,39,0,0 +BRDA:362,39,1,0 +BRDA:374,40,0,16 +BRDA:374,40,1,0 +BRDA:387,41,0,0 +BRDA:387,41,1,0 +BRDA:387,42,0,0 +BRDA:387,42,1,0 +BRDA:409,43,0,0 +BRDA:409,43,1,16 +BRDA:443,44,0,16 +BRDA:443,44,1,0 +BRDA:449,45,0,0 +BRDA:449,45,1,16 +BRDA:456,46,0,16 +BRDA:456,46,1,0 +BRDA:464,47,0,0 +BRDA:464,47,1,0 +BRDA:467,48,0,0 +BRDA:467,48,1,0 +BRDA:474,49,0,0 +BRDA:474,49,1,0 +BRDA:480,50,0,0 +BRDA:480,50,1,0 +BRDA:486,51,0,0 +BRDA:486,51,1,0 +BRDA:490,52,0,0 +BRDA:490,52,1,0 +BRDA:510,53,0,0 +BRDA:510,53,1,0 +BRDA:529,54,0,0 +BRDA:529,54,1,0 +BRDA:555,55,0,0 +BRDA:555,55,1,0 +BRDA:571,56,0,0 +BRDA:571,56,1,0 +BRDA:581,57,0,0 +BRDA:581,57,1,0 +BRDA:612,58,0,16 +BRDA:612,58,1,144 +BRDA:627,59,0,0 +BRDA:627,59,1,16 +BRDA:628,60,0,0 +BRDA:628,60,1,16 +BRDA:632,61,0,16 +BRDA:632,61,1,0 +BRDA:650,62,0,0 +BRDA:650,62,1,0 +BRDA:678,63,0,16 +BRDA:678,63,1,144 +BRDA:692,64,0,0 +BRDA:692,64,1,16 +BRF:130 +BRH:51 +end_of_record +TN: +SF:src/components/EditTaskModal.jsx +FN:37,EditTaskModal +FN:60,(anonymous_1) +FN:80,(anonymous_2) +FN:81,(anonymous_3) +FN:86,(anonymous_4) +FN:92,(anonymous_5) +FN:93,(anonymous_6) +FN:98,(anonymous_7) +FN:103,(anonymous_8) +FN:110,(anonymous_9) +FN:180,(anonymous_10) +FN:192,(anonymous_11) +FN:205,(anonymous_12) +FN:217,(anonymous_13) +FN:229,(anonymous_14) +FN:245,(anonymous_15) +FN:260,(anonymous_16) +FN:282,(anonymous_17) +FN:286,(anonymous_18) +FN:307,(anonymous_19) +FN:321,(anonymous_20) +FN:340,(anonymous_21) +FN:354,(anonymous_22) +FN:358,(anonymous_23) +FN:377,(anonymous_24) +FN:381,(anonymous_25) +FN:396,(anonymous_26) +FN:411,(anonymous_27) +FN:415,(anonymous_28) +FN:419,(anonymous_29) +FN:443,(anonymous_30) +FN:447,(anonymous_31) +FN:470,(anonymous_32) +FN:480,(anonymous_33) +FN:487,(anonymous_34) +FNF:35 +FNH:13 +FNDA:18,EditTaskModal +FNDA:7,(anonymous_1) +FNDA:1,(anonymous_2) +FNDA:1,(anonymous_3) +FNDA:0,(anonymous_4) +FNDA:1,(anonymous_5) +FNDA:1,(anonymous_6) +FNDA:0,(anonymous_7) +FNDA:0,(anonymous_8) +FNDA:1,(anonymous_9) +FNDA:1,(anonymous_10) +FNDA:0,(anonymous_11) +FNDA:0,(anonymous_12) +FNDA:0,(anonymous_13) +FNDA:0,(anonymous_14) +FNDA:0,(anonymous_15) +FNDA:0,(anonymous_16) +FNDA:0,(anonymous_17) +FNDA:0,(anonymous_18) +FNDA:0,(anonymous_19) +FNDA:0,(anonymous_20) +FNDA:0,(anonymous_21) +FNDA:68,(anonymous_22) +FNDA:0,(anonymous_23) +FNDA:136,(anonymous_24) +FNDA:0,(anonymous_25) +FNDA:0,(anonymous_26) +FNDA:0,(anonymous_27) +FNDA:0,(anonymous_28) +FNDA:0,(anonymous_29) +FNDA:170,(anonymous_30) +FNDA:0,(anonymous_31) +FNDA:2,(anonymous_32) +FNDA:0,(anonymous_33) +FNDA:1,(anonymous_34) +DA:9,1 +DA:14,1 +DA:23,1 +DA:30,1 +DA:38,18 +DA:39,18 +DA:41,18 +DA:42,18 +DA:43,18 +DA:44,18 +DA:45,18 +DA:46,18 +DA:47,18 +DA:48,18 +DA:49,18 +DA:50,18 +DA:53,18 +DA:54,18 +DA:55,18 +DA:56,18 +DA:58,18 +DA:60,18 +DA:61,7 +DA:62,6 +DA:63,6 +DA:64,6 +DA:65,6 +DA:66,6 +DA:67,6 +DA:68,6 +DA:69,6 +DA:70,6 +DA:71,6 +DA:72,6 +DA:73,6 +DA:74,6 +DA:75,6 +DA:79,18 +DA:80,1 +DA:82,1 +DA:83,1 +DA:84,1 +DA:87,0 +DA:91,18 +DA:92,1 +DA:94,1 +DA:95,1 +DA:96,1 +DA:99,0 +DA:103,18 +DA:104,0 +DA:105,0 +DA:106,0 +DA:107,0 +DA:110,18 +DA:111,1 +DA:112,1 +DA:113,0 +DA:114,0 +DA:117,1 +DA:131,1 +DA:134,18 +DA:136,18 +DA:138,18 +DA:180,1 +DA:192,0 +DA:205,0 +DA:217,0 +DA:229,0 +DA:245,0 +DA:260,0 +DA:283,0 +DA:286,0 +DA:307,0 +DA:321,0 +DA:340,0 +DA:355,68 +DA:358,0 +DA:378,136 +DA:381,0 +DA:396,0 +DA:412,0 +DA:416,0 +DA:419,0 +DA:444,170 +DA:447,0 +DA:470,2 +DA:480,0 +DA:487,1 +LF:89 +LH:62 +BRDA:61,0,0,6 +BRDA:61,0,1,1 +BRDA:61,1,0,7 +BRDA:61,1,1,7 +BRDA:62,2,0,6 +BRDA:62,2,1,0 +BRDA:63,3,0,6 +BRDA:63,3,1,0 +BRDA:64,4,0,6 +BRDA:64,4,1,0 +BRDA:65,5,0,6 +BRDA:65,5,1,0 +BRDA:66,6,0,6 +BRDA:66,6,1,0 +BRDA:67,7,0,6 +BRDA:67,7,1,0 +BRDA:68,8,0,6 +BRDA:68,8,1,6 +BRDA:69,9,0,6 +BRDA:69,9,1,6 +BRDA:70,10,0,6 +BRDA:70,10,1,6 +BRDA:71,11,0,6 +BRDA:71,11,1,0 +BRDA:72,12,0,6 +BRDA:72,12,1,6 +BRDA:87,13,0,0 +BRDA:87,13,1,0 +BRDA:99,14,0,0 +BRDA:99,14,1,0 +BRDA:112,15,0,0 +BRDA:112,15,1,1 +BRDA:122,16,0,1 +BRDA:122,16,1,0 +BRDA:124,17,0,1 +BRDA:124,17,1,1 +BRDA:126,18,0,0 +BRDA:126,18,1,1 +BRDA:127,19,0,0 +BRDA:127,19,1,1 +BRDA:127,20,0,1 +BRDA:127,20,1,0 +BRDA:128,21,0,0 +BRDA:128,21,1,1 +BRDA:128,22,0,1 +BRDA:128,22,1,0 +BRDA:136,23,0,0 +BRDA:136,23,1,18 +BRDA:140,24,0,18 +BRDA:140,24,1,17 +BRDA:167,25,0,17 +BRDA:167,25,1,0 +BRDA:208,26,0,11 +BRDA:208,26,1,6 +BRDA:220,27,0,0 +BRDA:220,27,1,17 +BRDA:232,28,0,6 +BRDA:232,28,1,11 +BRDA:255,29,0,0 +BRDA:255,29,1,17 +BRDA:263,30,0,0 +BRDA:263,30,1,17 +BRDA:268,31,0,0 +BRDA:268,31,1,17 +BRDA:274,32,0,17 +BRDA:274,32,1,0 +BRDA:289,33,0,0 +BRDA:289,33,1,0 +BRDA:299,34,0,0 +BRDA:299,34,1,0 +BRDA:307,35,0,0 +BRDA:307,35,1,0 +BRDA:323,36,0,0 +BRDA:323,36,1,0 +BRDA:361,37,0,17 +BRDA:361,37,1,51 +BRDA:384,38,0,17 +BRDA:384,38,1,119 +BRDA:399,39,0,0 +BRDA:399,39,1,17 +BRDA:400,40,0,0 +BRDA:400,40,1,17 +BRDA:404,41,0,17 +BRDA:404,41,1,0 +BRDA:422,42,0,0 +BRDA:422,42,1,0 +BRDA:450,43,0,17 +BRDA:450,43,1,153 +BRDA:464,44,0,0 +BRDA:464,44,1,17 +BRDA:467,45,0,14 +BRDA:467,45,1,3 +BRDA:491,46,0,0 +BRDA:491,46,1,3 +BRF:94 +BRH:54 +end_of_record +TN: +SF:src/components/LogHabitModal.jsx +FN:8,LogHabitModal +FN:13,(anonymous_1) +FN:20,(anonymous_2) +FN:22,(anonymous_3) +FN:29,(anonymous_4) +FN:33,(anonymous_5) +FN:39,(anonymous_6) +FN:71,(anonymous_7) +FN:101,(anonymous_8) +FN:101,(anonymous_9) +FN:110,(anonymous_10) +FN:110,(anonymous_11) +FN:125,(anonymous_12) +FN:135,(anonymous_13) +FN:140,(anonymous_14) +FN:149,(anonymous_15) +FNF:16 +FNH:8 +FNDA:6,LogHabitModal +FNDA:6,(anonymous_1) +FNDA:6,(anonymous_2) +FNDA:0,(anonymous_3) +FNDA:155,(anonymous_4) +FNDA:0,(anonymous_5) +FNDA:0,(anonymous_6) +FNDA:1,(anonymous_7) +FNDA:0,(anonymous_8) +FNDA:0,(anonymous_9) +FNDA:0,(anonymous_10) +FNDA:0,(anonymous_11) +FNDA:35,(anonymous_12) +FNDA:30,(anonymous_13) +FNDA:155,(anonymous_14) +FNDA:0,(anonymous_15) +DA:9,6 +DA:10,6 +DA:11,6 +DA:13,6 +DA:14,6 +DA:15,6 +DA:16,6 +DA:20,6 +DA:21,6 +DA:22,6 +DA:23,0 +DA:24,0 +DA:26,6 +DA:29,6 +DA:30,155 +DA:33,6 +DA:34,0 +DA:35,0 +DA:36,0 +DA:39,6 +DA:40,0 +DA:41,0 +DA:42,0 +DA:43,0 +DA:44,0 +DA:46,0 +DA:48,0 +DA:53,6 +DA:54,6 +DA:56,6 +DA:58,5 +DA:71,1 +DA:101,0 +DA:110,0 +DA:126,35 +DA:136,30 +DA:141,155 +DA:142,155 +DA:143,155 +DA:144,155 +DA:146,155 +DA:149,0 +LF:42 +LH:27 +BRDA:8,0,0,6 +BRDA:23,1,0,0 +BRDA:23,1,1,0 +BRDA:34,2,0,0 +BRDA:34,2,1,0 +BRDA:35,3,0,0 +BRDA:35,3,1,0 +BRDA:40,4,0,0 +BRDA:40,4,1,0 +BRDA:56,5,0,1 +BRDA:56,5,1,5 +BRDA:81,6,0,6 +BRDA:81,6,1,0 +BRDA:113,7,0,5 +BRDA:113,7,1,0 +BRDA:143,8,0,155 +BRDA:143,8,1,0 +BRDA:150,9,0,155 +BRDA:150,9,1,130 +BRDA:153,10,0,155 +BRDA:153,10,1,25 +BRDA:154,11,0,155 +BRDA:154,11,1,0 +BRDA:155,12,0,155 +BRDA:155,12,1,0 +BRDA:155,12,2,0 +BRDA:156,13,0,155 +BRDA:156,13,1,130 +BRDA:156,13,2,130 +BRDA:156,13,3,130 +BRDA:157,14,0,155 +BRDA:157,14,1,5 +BRDA:157,14,2,5 +BRDA:157,14,3,5 +BRDA:160,15,0,0 +BRDA:160,15,1,155 +BRDA:171,16,0,6 +BRDA:171,16,1,0 +BRDA:194,17,0,6 +BRDA:194,17,1,0 +BRDA:197,18,0,0 +BRDA:197,18,1,5 +BRDA:197,19,0,6 +BRDA:197,19,1,0 +BRDA:202,20,0,0 +BRDA:202,20,1,5 +BRF:46 +BRH:26 +end_of_record +TN: +SF:src/components/Navigation.jsx +FN:8,Navigation +FN:9,(anonymous_1) +FN:23,(anonymous_2) +FN:28,(anonymous_3) +FNF:4 +FNH:4 +FNDA:5,Navigation +FNDA:5,(anonymous_1) +FNDA:20,(anonymous_2) +FNDA:20,(anonymous_3) +DA:6,1 +DA:9,5 +DA:10,5 +DA:12,5 +DA:19,5 +DA:24,20 +DA:28,20 +LF:7 +LH:7 +BRDA:31,0,0,5 +BRDA:31,0,1,15 +BRF:2 +BRH:2 +end_of_record +TN: +SF:src/components/finance/FinanceDashboard.jsx +FN:13,(anonymous_0) +FN:15,FinanceDashboard +FN:19,(anonymous_2) +FN:25,(anonymous_3) +FN:31,(anonymous_4) +FN:54,(anonymous_5) +FN:55,(anonymous_6) +FN:59,(anonymous_7) +FN:96,(anonymous_8) +FN:142,(anonymous_9) +FN:146,(anonymous_10) +FN:151,(anonymous_11) +FN:185,(anonymous_12) +FN:187,(anonymous_13) +FNF:14 +FNH:11 +FNDA:4,(anonymous_0) +FNDA:5,FinanceDashboard +FNDA:3,(anonymous_2) +FNDA:2,(anonymous_3) +FNDA:9,(anonymous_4) +FNDA:1,(anonymous_5) +FNDA:1,(anonymous_6) +FNDA:1,(anonymous_7) +FNDA:1,(anonymous_8) +FNDA:1,(anonymous_9) +FNDA:0,(anonymous_10) +FNDA:1,(anonymous_11) +FNDA:0,(anonymous_12) +FNDA:0,(anonymous_13) +DA:7,1 +DA:13,4 +DA:16,5 +DA:17,5 +DA:19,5 +DA:20,3 +DA:21,3 +DA:25,2 +DA:28,5 +DA:29,3 +DA:32,9 +DA:40,2 +DA:41,1 +DA:54,1 +DA:55,1 +DA:59,1 +DA:64,1 +DA:97,1 +DA:143,1 +DA:146,0 +DA:152,1 +DA:185,0 +DA:187,0 +LF:23 +LH:20 +BRDA:28,0,0,3 +BRDA:28,0,1,2 +BRDA:40,1,0,1 +BRDA:40,1,1,1 +BRDA:40,2,0,2 +BRDA:40,2,1,2 +BRDA:40,2,2,1 +BRDA:40,2,3,1 +BRDA:67,3,0,1 +BRDA:67,3,1,0 +BRDA:69,4,0,0 +BRDA:69,4,1,0 +BRDA:90,5,0,5 +BRDA:90,5,1,1 +BRDA:126,6,0,5 +BRDA:126,6,1,1 +BRDA:173,7,0,5 +BRDA:173,7,1,1 +BRF:18 +BRH:15 +end_of_record +TN: +SF:src/components/finance/TransactionList.jsx +FN:4,(anonymous_0) +FN:6,(anonymous_1) +FN:11,TransactionList +FN:19,(anonymous_3) +FN:29,(anonymous_4) +FN:34,(anonymous_5) +FN:37,(anonymous_6) +FN:45,(anonymous_7) +FN:51,(anonymous_8) +FN:54,(anonymous_9) +FN:54,(anonymous_10) +FN:60,(anonymous_11) +FN:75,(anonymous_12) +FN:83,(anonymous_13) +FN:86,(anonymous_14) +FN:100,(anonymous_15) +FN:109,(anonymous_16) +FN:112,(anonymous_17) +FN:130,(anonymous_18) +FN:136,(anonymous_19) +FN:140,(anonymous_20) +FNF:21 +FNH:13 +FNDA:6,(anonymous_0) +FNDA:6,(anonymous_1) +FNDA:7,TransactionList +FNDA:4,(anonymous_3) +FNDA:3,(anonymous_4) +FNDA:3,(anonymous_5) +FNDA:6,(anonymous_6) +FNDA:6,(anonymous_7) +FNDA:0,(anonymous_8) +FNDA:0,(anonymous_9) +FNDA:0,(anonymous_10) +FNDA:16,(anonymous_11) +FNDA:0,(anonymous_12) +FNDA:9,(anonymous_13) +FNDA:0,(anonymous_14) +FNDA:0,(anonymous_15) +FNDA:3,(anonymous_16) +FNDA:0,(anonymous_17) +FNDA:6,(anonymous_18) +FNDA:6,(anonymous_19) +FNDA:0,(anonymous_20) +DA:4,6 +DA:6,1 +DA:7,6 +DA:8,6 +DA:12,7 +DA:13,7 +DA:14,7 +DA:15,7 +DA:16,7 +DA:17,7 +DA:19,7 +DA:20,4 +DA:21,4 +DA:30,3 +DA:31,3 +DA:34,3 +DA:37,7 +DA:38,6 +DA:39,6 +DA:40,6 +DA:41,0 +DA:42,6 +DA:45,7 +DA:46,6 +DA:47,6 +DA:48,6 +DA:51,7 +DA:52,0 +DA:53,0 +DA:54,0 +DA:57,7 +DA:58,4 +DA:61,16 +DA:69,3 +DA:75,0 +DA:84,9 +DA:86,0 +DA:100,0 +DA:110,3 +DA:112,0 +DA:131,6 +DA:137,6 +DA:140,0 +LF:43 +LH:34 +BRDA:30,0,0,3 +BRDA:30,0,1,0 +BRDA:31,1,0,3 +BRDA:31,1,1,0 +BRDA:38,2,0,0 +BRDA:38,2,1,6 +BRDA:38,3,0,6 +BRDA:38,3,1,0 +BRDA:39,4,0,0 +BRDA:39,4,1,6 +BRDA:39,5,0,6 +BRDA:39,5,1,0 +BRDA:40,6,0,0 +BRDA:40,6,1,6 +BRDA:40,7,0,6 +BRDA:40,7,1,0 +BRDA:47,8,0,6 +BRDA:47,8,1,6 +BRDA:52,9,0,0 +BRDA:52,9,1,0 +BRDA:57,10,0,4 +BRDA:57,10,1,3 +BRDA:88,11,0,3 +BRDA:88,11,1,6 +BRDA:102,12,0,3 +BRDA:102,12,1,0 +BRDA:114,13,0,0 +BRDA:114,13,1,3 +BRDA:124,14,0,0 +BRDA:124,14,1,3 +BRDA:145,15,0,6 +BRDA:145,15,1,0 +BRDA:153,16,0,3 +BRDA:153,16,1,3 +BRDA:156,17,0,3 +BRDA:156,17,1,3 +BRF:36 +BRH:22 +end_of_record +TN: +SF:src/contexts/ThemeContext.jsx +FN:5,ThemeProvider +FN:6,(anonymous_1) +FN:13,(anonymous_2) +FN:25,(anonymous_3) +FN:26,(anonymous_4) +FN:36,useTheme +FNF:6 +FNH:6 +FNDA:20,ThemeProvider +FNDA:15,(anonymous_1) +FNDA:20,(anonymous_2) +FNDA:5,(anonymous_3) +FNDA:5,(anonymous_4) +FNDA:46,useTheme +DA:3,2 +DA:6,20 +DA:7,15 +DA:8,15 +DA:10,0 +DA:13,20 +DA:14,20 +DA:16,20 +DA:17,12 +DA:19,8 +DA:22,20 +DA:25,20 +DA:26,5 +DA:29,20 +DA:37,46 +DA:38,46 +DA:39,4 +DA:41,41 +LF:18 +LH:17 +BRDA:7,0,0,15 +BRDA:7,0,1,0 +BRDA:8,1,0,15 +BRDA:8,1,1,6 +BRDA:16,2,0,12 +BRDA:16,2,1,8 +BRDA:26,3,0,4 +BRDA:26,3,1,1 +BRDA:38,4,0,4 +BRDA:38,4,1,42 +BRF:10 +BRH:9 +end_of_record +TN: +SF:src/pages/Finance.jsx +FN:21,Finance +FN:29,(anonymous_1) +FN:29,(anonymous_2) +FN:31,(anonymous_3) +FN:32,(anonymous_4) +FN:33,(anonymous_5) +FN:35,(anonymous_6) +FN:36,(anonymous_7) +FN:37,(anonymous_8) +FN:52,(anonymous_9) +FN:69,(anonymous_10) +FN:84,(anonymous_11) +FN:87,(anonymous_12) +FN:103,(anonymous_13) +FN:111,(anonymous_14) +FN:112,(anonymous_15) +FNF:16 +FNH:4 +FNDA:14,Finance +FNDA:0,(anonymous_1) +FNDA:0,(anonymous_2) +FNDA:0,(anonymous_3) +FNDA:0,(anonymous_4) +FNDA:0,(anonymous_5) +FNDA:0,(anonymous_6) +FNDA:0,(anonymous_7) +FNDA:0,(anonymous_8) +FNDA:0,(anonymous_9) +FNDA:0,(anonymous_10) +FNDA:56,(anonymous_11) +FNDA:4,(anonymous_12) +FNDA:1,(anonymous_13) +FNDA:0,(anonymous_14) +FNDA:0,(anonymous_15) +DA:9,1 +DA:16,1 +DA:22,14 +DA:23,14 +DA:24,14 +DA:25,14 +DA:26,14 +DA:27,14 +DA:29,14 +DA:31,14 +DA:32,0 +DA:33,0 +DA:35,14 +DA:36,0 +DA:37,0 +DA:40,14 +DA:42,14 +DA:52,0 +DA:69,0 +DA:85,56 +DA:87,4 +DA:103,1 +DA:111,0 +DA:113,0 +DA:114,0 +LF:25 +LH:16 +BRDA:32,0,0,0 +BRDA:32,0,1,0 +BRDA:36,1,0,0 +BRDA:36,1,1,0 +BRDA:40,2,0,14 +BRDA:40,2,1,14 +BRDA:70,3,0,14 +BRDA:70,3,1,0 +BRDA:89,4,0,14 +BRDA:89,4,1,42 +BRDA:101,5,0,14 +BRDA:101,5,1,9 +BRDA:102,6,0,14 +BRDA:102,6,1,3 +BRDA:105,7,0,14 +BRDA:105,7,1,1 +BRDA:106,8,0,14 +BRDA:106,8,1,1 +BRDA:109,9,0,14 +BRDA:109,9,1,1 +BRF:20 +BRH:15 +end_of_record +TN: +SF:src/pages/ForgotPassword.jsx +FN:7,ForgotPassword +FN:13,(anonymous_1) +FN:103,(anonymous_2) +FNF:3 +FNH:3 +FNDA:22,ForgotPassword +FNDA:5,(anonymous_1) +FNDA:5,(anonymous_2) +DA:8,22 +DA:9,22 +DA:10,22 +DA:11,22 +DA:13,22 +DA:14,5 +DA:15,5 +DA:16,5 +DA:18,5 +DA:19,5 +DA:20,3 +DA:22,2 +DA:24,5 +DA:28,22 +DA:29,3 +DA:60,19 +DA:103,5 +LF:17 +LH:17 +BRDA:22,0,0,2 +BRDA:22,0,1,1 +BRDA:28,1,0,3 +BRDA:28,1,1,19 +BRDA:86,2,0,19 +BRDA:86,2,1,2 +BRDA:116,3,0,5 +BRDA:116,3,1,14 +BRF:8 +BRH:8 +end_of_record +TN: +SF:src/pages/Habits.jsx +FN:13,Habits +FN:22,(anonymous_1) +FN:22,(anonymous_2) +FN:22,(anonymous_3) +FN:27,(anonymous_4) +FN:27,(anonymous_5) +FN:27,(anonymous_6) +FN:31,(anonymous_7) +FN:35,(anonymous_8) +FN:37,(anonymous_9) +FN:47,(anonymous_10) +FN:48,(anonymous_11) +FN:55,(anonymous_12) +FN:59,(anonymous_13) +FN:66,(anonymous_14) +FN:67,(anonymous_15) +FN:77,(anonymous_16) +FN:87,(anonymous_17) +FN:106,(anonymous_18) +FN:115,(anonymous_19) +FN:122,(anonymous_20) +FN:123,(anonymous_21) +FN:131,(anonymous_22) +FN:140,(anonymous_23) +FN:156,(anonymous_24) +FN:172,(anonymous_25) +FN:173,(anonymous_26) +FN:178,HabitListItem +FNF:28 +FNH:15 +FNDA:11,Habits +FNDA:6,(anonymous_1) +FNDA:6,(anonymous_2) +FNDA:10,(anonymous_3) +FNDA:0,(anonymous_4) +FNDA:0,(anonymous_5) +FNDA:0,(anonymous_6) +FNDA:9,(anonymous_7) +FNDA:2,(anonymous_8) +FNDA:4,(anonymous_9) +FNDA:0,(anonymous_10) +FNDA:0,(anonymous_11) +FNDA:6,(anonymous_12) +FNDA:15,(anonymous_13) +FNDA:6,(anonymous_14) +FNDA:6,(anonymous_15) +FNDA:1,(anonymous_16) +FNDA:18,(anonymous_17) +FNDA:0,(anonymous_18) +FNDA:6,(anonymous_19) +FNDA:0,(anonymous_20) +FNDA:0,(anonymous_21) +FNDA:0,(anonymous_22) +FNDA:0,(anonymous_23) +FNDA:0,(anonymous_24) +FNDA:0,(anonymous_25) +FNDA:0,(anonymous_26) +FNDA:6,HabitListItem +DA:14,11 +DA:15,11 +DA:16,11 +DA:17,11 +DA:18,11 +DA:20,11 +DA:22,10 +DA:25,11 +DA:27,0 +DA:31,11 +DA:32,9 +DA:35,11 +DA:36,2 +DA:37,2 +DA:38,4 +DA:39,4 +DA:40,4 +DA:43,2 +DA:46,11 +DA:47,0 +DA:49,0 +DA:50,0 +DA:51,0 +DA:55,11 +DA:56,6 +DA:57,3 +DA:58,3 +DA:59,15 +DA:61,0 +DA:62,0 +DA:63,0 +DA:66,11 +DA:67,11 +DA:69,11 +DA:77,1 +DA:88,18 +DA:106,0 +DA:116,6 +DA:122,0 +DA:123,0 +DA:131,0 +DA:141,0 +DA:156,0 +DA:172,0 +DA:173,0 +DA:179,6 +LF:46 +LH:30 +BRDA:13,0,0,11 +BRDA:20,1,0,11 +BRDA:22,2,0,0 +BRDA:22,2,1,6 +BRDA:25,3,0,11 +BRDA:32,4,0,2 +BRDA:32,4,1,7 +BRDA:56,5,0,3 +BRDA:56,5,1,3 +BRDA:57,6,0,3 +BRDA:57,6,1,0 +BRDA:57,7,0,3 +BRDA:57,7,1,3 +BRDA:61,8,0,0 +BRDA:61,8,1,0 +BRDA:62,9,0,0 +BRDA:62,9,1,0 +BRDA:70,10,0,1 +BRDA:70,10,1,9 +BRDA:71,11,0,11 +BRDA:71,11,1,9 +BRDA:85,12,0,6 +BRDA:85,12,1,4 +BRDA:99,13,0,1 +BRDA:99,13,1,3 +BRDA:99,14,0,4 +BRDA:99,14,1,1 +BRDA:129,15,0,3 +BRDA:129,15,1,0 +BRDA:134,16,0,0 +BRDA:134,16,1,0 +BRDA:138,17,0,0 +BRDA:138,17,1,0 +BRDA:150,18,0,0 +BRDA:150,18,1,0 +BRDA:171,19,0,11 +BRDA:171,19,1,9 +BRDA:190,20,0,6 +BRDA:190,20,1,0 +BRDA:199,21,0,6 +BRDA:199,21,1,2 +BRDA:199,21,2,0 +BRDA:209,22,0,6 +BRDA:209,22,1,2 +BRF:44 +BRH:29 +end_of_record +TN: +SF:src/pages/Home.jsx +FN:16,isHabitFrozenOnDate +FN:19,(anonymous_1) +FN:27,shouldShowToday +FN:66,formatDueDate +FN:74,Home +FN:100,(anonymous_5) +FN:107,(anonymous_6) +FN:113,(anonymous_7) +FN:116,(anonymous_8) +FN:134,(anonymous_9) +FN:136,(anonymous_10) +FN:148,(anonymous_11) +FN:149,(anonymous_12) +FN:153,(anonymous_13) +FN:154,(anonymous_14) +FN:155,(anonymous_15) +FN:162,(anonymous_16) +FN:163,(anonymous_17) +FN:164,(anonymous_18) +FN:176,(anonymous_19) +FN:177,(anonymous_20) +FN:184,(anonymous_21) +FN:185,(anonymous_22) +FN:191,(anonymous_23) +FN:199,(anonymous_24) +FN:203,(anonymous_25) +FN:208,(anonymous_26) +FN:209,(anonymous_27) +FN:212,(anonymous_28) +FN:214,(anonymous_29) +FN:220,(anonymous_30) +FN:311,(anonymous_31) +FN:331,(anonymous_32) +FN:338,(anonymous_33) +FN:339,(anonymous_34) +FN:353,(anonymous_35) +FN:376,(anonymous_36) +FN:382,(anonymous_37) +FN:383,(anonymous_38) +FN:394,(anonymous_39) +FN:397,(anonymous_40) +FN:406,TaskCard +FN:411,(anonymous_42) +FN:416,(anonymous_43) +FN:431,(anonymous_44) +FN:487,HabitCard +FN:492,(anonymous_46) +FN:494,(anonymous_47) +FN:497,(anonymous_48) +FN:499,(anonymous_49) +FN:502,(anonymous_50) +FN:506,(anonymous_51) +FN:519,(anonymous_52) +FN:565,(anonymous_53) +FNF:54 +FNH:5 +FNDA:0,isHabitFrozenOnDate +FNDA:0,(anonymous_1) +FNDA:0,shouldShowToday +FNDA:0,formatDueDate +FNDA:3,Home +FNDA:3,(anonymous_5) +FNDA:0,(anonymous_6) +FNDA:0,(anonymous_7) +FNDA:0,(anonymous_8) +FNDA:0,(anonymous_9) +FNDA:0,(anonymous_10) +FNDA:0,(anonymous_11) +FNDA:0,(anonymous_12) +FNDA:0,(anonymous_13) +FNDA:0,(anonymous_14) +FNDA:0,(anonymous_15) +FNDA:0,(anonymous_16) +FNDA:0,(anonymous_17) +FNDA:0,(anonymous_18) +FNDA:0,(anonymous_19) +FNDA:0,(anonymous_20) +FNDA:0,(anonymous_21) +FNDA:0,(anonymous_22) +FNDA:0,(anonymous_23) +FNDA:0,(anonymous_24) +FNDA:0,(anonymous_25) +FNDA:3,(anonymous_26) +FNDA:0,(anonymous_27) +FNDA:3,(anonymous_28) +FNDA:0,(anonymous_29) +FNDA:0,(anonymous_30) +FNDA:0,(anonymous_31) +FNDA:0,(anonymous_32) +FNDA:0,(anonymous_33) +FNDA:0,(anonymous_34) +FNDA:9,(anonymous_35) +FNDA:0,(anonymous_36) +FNDA:0,(anonymous_37) +FNDA:0,(anonymous_38) +FNDA:0,(anonymous_39) +FNDA:0,(anonymous_40) +FNDA:0,TaskCard +FNDA:0,(anonymous_42) +FNDA:0,(anonymous_43) +FNDA:0,(anonymous_44) +FNDA:0,HabitCard +FNDA:0,(anonymous_46) +FNDA:0,(anonymous_47) +FNDA:0,(anonymous_48) +FNDA:0,(anonymous_49) +FNDA:0,(anonymous_50) +FNDA:0,(anonymous_51) +FNDA:0,(anonymous_52) +FNDA:0,(anonymous_53) +DA:17,0 +DA:18,0 +DA:19,0 +DA:20,0 +DA:21,0 +DA:22,0 +DA:28,0 +DA:29,0 +DA:31,0 +DA:33,0 +DA:37,0 +DA:39,0 +DA:41,0 +DA:42,0 +DA:43,0 +DA:45,0 +DA:46,0 +DA:47,0 +DA:48,0 +DA:51,0 +DA:52,0 +DA:53,0 +DA:56,0 +DA:57,0 +DA:58,0 +DA:59,0 +DA:60,0 +DA:63,0 +DA:67,0 +DA:68,0 +DA:69,0 +DA:70,0 +DA:71,0 +DA:75,3 +DA:76,3 +DA:77,3 +DA:78,3 +DA:79,3 +DA:80,3 +DA:81,3 +DA:82,3 +DA:84,3 +DA:89,3 +DA:94,3 +DA:100,3 +DA:101,3 +DA:102,0 +DA:103,0 +DA:107,3 +DA:108,0 +DA:109,0 +DA:110,0 +DA:111,0 +DA:113,0 +DA:114,0 +DA:115,0 +DA:116,0 +DA:118,0 +DA:119,0 +DA:120,0 +DA:121,0 +DA:122,0 +DA:125,0 +DA:129,0 +DA:130,0 +DA:131,0 +DA:134,3 +DA:135,0 +DA:136,0 +DA:137,0 +DA:138,0 +DA:139,0 +DA:141,0 +DA:144,0 +DA:147,3 +DA:148,0 +DA:150,0 +DA:151,0 +DA:153,0 +DA:154,0 +DA:155,0 +DA:156,0 +DA:157,0 +DA:161,3 +DA:162,0 +DA:164,0 +DA:165,0 +DA:166,0 +DA:167,0 +DA:169,0 +DA:170,0 +DA:171,0 +DA:175,3 +DA:176,0 +DA:178,0 +DA:179,0 +DA:183,3 +DA:184,0 +DA:186,0 +DA:187,0 +DA:191,3 +DA:192,0 +DA:193,0 +DA:195,0 +DA:199,3 +DA:200,0 +DA:203,3 +DA:204,0 +DA:205,0 +DA:208,3 +DA:209,3 +DA:212,3 +DA:213,3 +DA:214,3 +DA:217,3 +DA:218,3 +DA:219,3 +DA:220,3 +DA:222,3 +DA:311,0 +DA:331,0 +DA:339,0 +DA:354,9 +DA:377,0 +DA:382,0 +DA:383,0 +DA:394,0 +DA:397,0 +DA:407,0 +DA:408,0 +DA:409,0 +DA:411,0 +DA:412,0 +DA:413,0 +DA:414,0 +DA:415,0 +DA:416,0 +DA:418,0 +DA:421,0 +DA:432,0 +DA:488,0 +DA:489,0 +DA:490,0 +DA:492,0 +DA:493,0 +DA:494,0 +DA:497,0 +DA:499,0 +DA:500,0 +DA:501,0 +DA:502,0 +DA:503,0 +DA:506,0 +DA:508,0 +DA:520,0 +DA:565,0 +LF:156 +LH:33 +BRDA:17,0,0,0 +BRDA:17,0,1,0 +BRDA:17,1,0,0 +BRDA:17,1,1,0 +BRDA:22,2,0,0 +BRDA:22,2,1,0 +BRDA:29,3,0,0 +BRDA:29,3,1,0 +BRDA:31,4,0,0 +BRDA:31,4,1,0 +BRDA:33,5,0,0 +BRDA:33,5,1,0 +BRDA:37,6,0,0 +BRDA:37,6,1,0 +BRDA:39,7,0,0 +BRDA:39,7,1,0 +BRDA:41,8,0,0 +BRDA:41,8,1,0 +BRDA:42,9,0,0 +BRDA:42,9,1,0 +BRDA:42,10,0,0 +BRDA:42,10,1,0 +BRDA:45,11,0,0 +BRDA:45,11,1,0 +BRDA:47,12,0,0 +BRDA:47,12,1,0 +BRDA:51,13,0,0 +BRDA:51,13,1,0 +BRDA:51,14,0,0 +BRDA:51,14,1,0 +BRDA:56,15,0,0 +BRDA:56,15,1,0 +BRDA:56,16,0,0 +BRDA:56,16,1,0 +BRDA:57,17,0,0 +BRDA:57,17,1,0 +BRDA:58,18,0,0 +BRDA:58,18,1,0 +BRDA:67,19,0,0 +BRDA:67,19,1,0 +BRDA:69,20,0,0 +BRDA:69,20,1,0 +BRDA:70,21,0,0 +BRDA:70,21,1,0 +BRDA:84,22,0,3 +BRDA:94,23,0,3 +BRDA:101,24,0,0 +BRDA:101,24,1,3 +BRDA:118,25,0,0 +BRDA:118,25,1,0 +BRDA:122,26,0,0 +BRDA:122,26,1,0 +BRDA:148,27,0,0 +BRDA:148,27,1,0 +BRDA:150,28,0,0 +BRDA:150,28,1,0 +BRDA:153,29,0,0 +BRDA:153,29,1,0 +BRDA:155,30,0,0 +BRDA:155,30,1,0 +BRDA:192,31,0,0 +BRDA:192,31,1,0 +BRDA:204,32,0,0 +BRDA:204,32,1,0 +BRDA:257,33,0,0 +BRDA:257,33,1,3 +BRDA:262,34,0,3 +BRDA:262,34,1,3 +BRDA:262,34,2,0 +BRDA:267,35,0,3 +BRDA:267,35,1,0 +BRDA:276,36,0,3 +BRDA:276,36,1,0 +BRDA:306,37,0,3 +BRDA:306,37,1,3 +BRDA:306,37,2,0 +BRDA:318,38,0,0 +BRDA:318,38,1,0 +BRDA:328,39,0,0 +BRDA:328,39,1,0 +BRDA:339,40,0,0 +BRDA:339,40,1,0 +BRDA:351,41,0,3 +BRDA:351,41,1,0 +BRDA:365,42,0,0 +BRDA:365,42,1,0 +BRDA:384,43,0,0 +BRDA:384,43,1,0 +BRDA:399,44,0,3 +BRDA:399,44,1,3 +BRDA:409,45,0,0 +BRDA:409,45,1,0 +BRDA:409,45,2,0 +BRDA:409,45,3,0 +BRDA:413,46,0,0 +BRDA:413,46,1,0 +BRDA:414,47,0,0 +BRDA:414,47,1,0 +BRDA:429,48,0,0 +BRDA:429,48,1,0 +BRDA:451,49,0,0 +BRDA:451,49,1,0 +BRDA:455,50,0,0 +BRDA:455,50,1,0 +BRDA:455,51,0,0 +BRDA:455,51,1,0 +BRDA:457,52,0,0 +BRDA:457,52,1,0 +BRDA:462,53,0,0 +BRDA:462,53,1,0 +BRDA:467,54,0,0 +BRDA:467,54,1,0 +BRDA:468,55,0,0 +BRDA:468,55,1,0 +BRDA:468,55,2,0 +BRDA:469,56,0,0 +BRDA:469,56,1,0 +BRDA:470,57,0,0 +BRDA:470,57,1,0 +BRDA:477,58,0,0 +BRDA:477,58,1,0 +BRDA:497,59,0,0 +BRDA:497,59,1,0 +BRDA:501,60,0,0 +BRDA:501,60,1,0 +BRDA:501,61,0,0 +BRDA:501,61,1,0 +BRDA:502,62,0,0 +BRDA:502,62,1,0 +BRDA:517,63,0,0 +BRDA:517,63,1,0 +BRDA:544,64,0,0 +BRDA:544,64,1,0 +BRDA:548,65,0,0 +BRDA:548,65,1,0 +BRDA:548,66,0,0 +BRDA:548,66,1,0 +BRDA:550,67,0,0 +BRDA:550,67,1,0 +BRDA:555,68,0,0 +BRDA:555,68,1,0 +BRDA:560,69,0,0 +BRDA:560,69,1,0 +BRDA:561,70,0,0 +BRDA:561,70,1,0 +BRDA:568,71,0,0 +BRDA:568,71,1,0 +BRF:147 +BRH:13 +end_of_record +TN: +SF:src/pages/Login.jsx +FN:7,Login +FN:14,(anonymous_1) +FN:17,(anonymous_2) +FN:57,(anonymous_3) +FN:63,(anonymous_4) +FN:64,(anonymous_5) +FNF:6 +FNH:6 +FNDA:22,Login +FNDA:22,(anonymous_1) +FNDA:3,(anonymous_2) +FNDA:3,(anonymous_3) +FNDA:3,(anonymous_4) +FNDA:2,(anonymous_5) +DA:8,22 +DA:9,22 +DA:10,22 +DA:11,22 +DA:12,22 +DA:14,22 +DA:15,22 +DA:17,22 +DA:18,3 +DA:19,3 +DA:20,3 +DA:22,3 +DA:23,3 +DA:24,1 +DA:26,2 +DA:28,3 +DA:32,22 +DA:57,3 +DA:63,3 +DA:64,2 +LF:20 +LH:20 +BRDA:26,0,0,2 +BRDA:26,0,1,1 +BRDA:49,1,0,22 +BRDA:49,1,1,2 +BRDA:63,2,0,1 +BRDA:63,2,1,21 +BRDA:65,3,0,1 +BRDA:65,3,1,21 +BRDA:75,4,0,3 +BRDA:75,4,1,19 +BRF:10 +BRH:10 +end_of_record +TN: +SF:src/pages/Register.jsx +FN:7,Register +FN:15,(anonymous_1) +FN:18,(anonymous_2) +FN:54,(anonymous_3) +FN:59,(anonymous_4) +FN:65,(anonymous_5) +FN:66,(anonymous_6) +FNF:7 +FNH:7 +FNDA:22,Register +FNDA:22,(anonymous_1) +FNDA:3,(anonymous_2) +FNDA:3,(anonymous_3) +FNDA:3,(anonymous_4) +FNDA:3,(anonymous_5) +FNDA:1,(anonymous_6) +DA:8,22 +DA:9,22 +DA:10,22 +DA:11,22 +DA:12,22 +DA:13,22 +DA:15,22 +DA:16,22 +DA:18,22 +DA:19,3 +DA:20,3 +DA:21,3 +DA:23,3 +DA:24,3 +DA:25,1 +DA:27,2 +DA:29,3 +DA:33,22 +DA:54,3 +DA:59,3 +DA:65,3 +DA:66,1 +LF:22 +LH:22 +BRDA:27,0,0,2 +BRDA:27,0,1,1 +BRDA:46,1,0,22 +BRDA:46,1,1,2 +BRDA:65,2,0,1 +BRDA:65,2,1,21 +BRDA:67,3,0,1 +BRDA:67,3,1,21 +BRDA:73,4,0,3 +BRDA:73,4,1,19 +BRF:10 +BRH:10 +end_of_record +TN: +SF:src/pages/ResetPassword.jsx +FN:7,ResetPassword +FN:18,(anonymous_1) +FN:31,(anonymous_2) +FN:106,(anonymous_3) +FN:114,(anonymous_4) +FNF:5 +FNH:4 +FNDA:18,ResetPassword +FNDA:4,(anonymous_1) +FNDA:0,(anonymous_2) +FNDA:4,(anonymous_3) +FNDA:1,(anonymous_4) +DA:8,18 +DA:9,18 +DA:10,18 +DA:11,18 +DA:12,18 +DA:13,18 +DA:14,18 +DA:16,18 +DA:18,18 +DA:19,4 +DA:20,4 +DA:21,1 +DA:22,1 +DA:25,3 +DA:26,3 +DA:28,3 +DA:29,3 +DA:30,2 +DA:31,2 +DA:33,1 +DA:35,3 +DA:39,18 +DA:40,2 +DA:64,16 +DA:106,4 +DA:114,1 +LF:26 +LH:26 +BRDA:20,0,0,1 +BRDA:20,0,1,3 +BRDA:33,1,0,1 +BRDA:33,1,1,0 +BRDA:39,2,0,2 +BRDA:39,2,1,16 +BRDA:88,3,0,16 +BRDA:88,3,1,2 +BRDA:104,4,0,1 +BRDA:104,4,1,15 +BRDA:117,5,0,1 +BRDA:117,5,1,15 +BRDA:127,6,0,3 +BRDA:127,6,1,13 +BRF:14 +BRH:13 +end_of_record +TN: +SF:src/pages/Savings.jsx +FN:31,formatCurrency +FN:40,TabNav +FN:49,(anonymous_2) +FN:52,(anonymous_3) +FN:69,DashboardTab +FN:73,(anonymous_5) +FN:99,(anonymous_6) +FN:114,(anonymous_7) +FN:166,(anonymous_8) +FN:198,(anonymous_9) +FN:231,CategoriesTab +FN:234,(anonymous_11) +FN:238,(anonymous_12) +FN:255,(anonymous_13) +FN:263,(anonymous_14) +FN:266,(anonymous_15) +FN:274,(anonymous_16) +FN:288,(anonymous_17) +FN:340,(anonymous_18) +FN:348,(anonymous_19) +FN:354,(anonymous_20) +FN:372,TransactionsTab +FN:376,(anonymous_22) +FN:380,(anonymous_23) +FN:390,(anonymous_24) +FN:394,(anonymous_25) +FN:413,(anonymous_26) +FN:465,(anonymous_27) +FN:471,(anonymous_28) +FN:489,TransactionModal +FN:498,(anonymous_30) +FN:518,(anonymous_31) +FN:540,(anonymous_32) +FN:556,(anonymous_33) +FN:562,(anonymous_34) +FN:562,(anonymous_35) +FN:571,(anonymous_36) +FN:583,(anonymous_37) +FN:600,(anonymous_38) +FN:614,(anonymous_39) +FN:625,(anonymous_40) +FN:641,DeleteTransactionModal +FN:650,(anonymous_42) +FN:663,(anonymous_43) +FN:676,CategoryEditModal +FN:699,(anonymous_45) +FN:733,(anonymous_46) +FN:780,(anonymous_47) +FN:794,(anonymous_48) +FN:799,(anonymous_49) +FN:804,(anonymous_50) +FN:810,(anonymous_51) +FN:814,(anonymous_52) +FN:818,(anonymous_53) +FN:822,(anonymous_54) +FN:834,(anonymous_55) +FN:838,(anonymous_56) +FN:842,(anonymous_57) +FN:846,(anonymous_58) +FN:859,(anonymous_59) +FN:863,(anonymous_60) +FN:867,(anonymous_61) +FN:871,(anonymous_62) +FN:884,(anonymous_63) +FN:888,(anonymous_64) +FN:892,(anonymous_65) +FN:908,DeleteModal +FN:917,(anonymous_67) +FN:930,(anonymous_68) +FN:943,RecurringPlansModal +FN:950,(anonymous_70) +FN:956,(anonymous_71) +FN:961,(anonymous_72) +FN:962,(anonymous_73) +FN:971,(anonymous_74) +FN:972,(anonymous_75) +FN:980,(anonymous_76) +FN:981,(anonymous_77) +FN:987,(anonymous_78) +FN:1005,(anonymous_79) +FN:1024,(anonymous_80) +FN:1042,(anonymous_81) +FN:1052,(anonymous_82) +FN:1058,(anonymous_83) +FN:1061,(anonymous_84) +FN:1082,(anonymous_85) +FN:1092,(anonymous_86) +FN:1103,(anonymous_87) +FN:1114,(anonymous_88) +FN:1118,(anonymous_89) +FN:1131,(anonymous_90) +FN:1140,(anonymous_91) +FN:1152,Savings +FN:1173,(anonymous_93) +FN:1183,(anonymous_94) +FN:1191,(anonymous_95) +FN:1192,(anonymous_96) +FN:1201,(anonymous_97) +FN:1210,(anonymous_98) +FN:1217,(anonymous_99) +FN:1218,(anonymous_100) +FN:1226,(anonymous_101) +FN:1232,(anonymous_102) +FN:1237,(anonymous_103) +FN:1242,(anonymous_104) +FN:1247,(anonymous_105) +FN:1255,(anonymous_106) +FN:1260,(anonymous_107) +FN:1265,(anonymous_108) +FN:1281,(anonymous_109) +FN:1287,(anonymous_110) +FN:1296,(anonymous_111) +FN:1313,(anonymous_112) +FN:1340,(anonymous_113) +FN:1355,(anonymous_114) +FN:1364,(anonymous_115) +FN:1366,(anonymous_116) +FN:1372,(anonymous_117) +FN:1380,(anonymous_118) +FN:1382,(anonymous_119) +FN:1388,(anonymous_120) +FNF:121 +FNH:11 +FNDA:4,formatCurrency +FNDA:5,TabNav +FNDA:15,(anonymous_2) +FNDA:0,(anonymous_3) +FNDA:1,DashboardTab +FNDA:1,(anonymous_5) +FNDA:1,(anonymous_6) +FNDA:1,(anonymous_7) +FNDA:0,(anonymous_8) +FNDA:0,(anonymous_9) +FNDA:0,CategoriesTab +FNDA:0,(anonymous_11) +FNDA:0,(anonymous_12) +FNDA:0,(anonymous_13) +FNDA:0,(anonymous_14) +FNDA:0,(anonymous_15) +FNDA:0,(anonymous_16) +FNDA:0,(anonymous_17) +FNDA:0,(anonymous_18) +FNDA:0,(anonymous_19) +FNDA:0,(anonymous_20) +FNDA:0,TransactionsTab +FNDA:0,(anonymous_22) +FNDA:0,(anonymous_23) +FNDA:0,(anonymous_24) +FNDA:0,(anonymous_25) +FNDA:0,(anonymous_26) +FNDA:0,(anonymous_27) +FNDA:0,(anonymous_28) +FNDA:0,TransactionModal +FNDA:0,(anonymous_30) +FNDA:0,(anonymous_31) +FNDA:0,(anonymous_32) +FNDA:0,(anonymous_33) +FNDA:0,(anonymous_34) +FNDA:0,(anonymous_35) +FNDA:0,(anonymous_36) +FNDA:0,(anonymous_37) +FNDA:0,(anonymous_38) +FNDA:0,(anonymous_39) +FNDA:0,(anonymous_40) +FNDA:0,DeleteTransactionModal +FNDA:0,(anonymous_42) +FNDA:0,(anonymous_43) +FNDA:0,CategoryEditModal +FNDA:0,(anonymous_45) +FNDA:0,(anonymous_46) +FNDA:0,(anonymous_47) +FNDA:0,(anonymous_48) +FNDA:0,(anonymous_49) +FNDA:0,(anonymous_50) +FNDA:0,(anonymous_51) +FNDA:0,(anonymous_52) +FNDA:0,(anonymous_53) +FNDA:0,(anonymous_54) +FNDA:0,(anonymous_55) +FNDA:0,(anonymous_56) +FNDA:0,(anonymous_57) +FNDA:0,(anonymous_58) +FNDA:0,(anonymous_59) +FNDA:0,(anonymous_60) +FNDA:0,(anonymous_61) +FNDA:0,(anonymous_62) +FNDA:0,(anonymous_63) +FNDA:0,(anonymous_64) +FNDA:0,(anonymous_65) +FNDA:0,DeleteModal +FNDA:0,(anonymous_67) +FNDA:0,(anonymous_68) +FNDA:0,RecurringPlansModal +FNDA:0,(anonymous_70) +FNDA:0,(anonymous_71) +FNDA:0,(anonymous_72) +FNDA:0,(anonymous_73) +FNDA:0,(anonymous_74) +FNDA:0,(anonymous_75) +FNDA:0,(anonymous_76) +FNDA:0,(anonymous_77) +FNDA:0,(anonymous_78) +FNDA:0,(anonymous_79) +FNDA:0,(anonymous_80) +FNDA:0,(anonymous_81) +FNDA:0,(anonymous_82) +FNDA:0,(anonymous_83) +FNDA:0,(anonymous_84) +FNDA:0,(anonymous_85) +FNDA:0,(anonymous_86) +FNDA:0,(anonymous_87) +FNDA:0,(anonymous_88) +FNDA:0,(anonymous_89) +FNDA:0,(anonymous_90) +FNDA:0,(anonymous_91) +FNDA:5,Savings +FNDA:4,(anonymous_93) +FNDA:0,(anonymous_94) +FNDA:0,(anonymous_95) +FNDA:0,(anonymous_96) +FNDA:0,(anonymous_97) +FNDA:0,(anonymous_98) +FNDA:0,(anonymous_99) +FNDA:0,(anonymous_100) +FNDA:0,(anonymous_101) +FNDA:0,(anonymous_102) +FNDA:0,(anonymous_103) +FNDA:0,(anonymous_104) +FNDA:0,(anonymous_105) +FNDA:0,(anonymous_106) +FNDA:0,(anonymous_107) +FNDA:0,(anonymous_108) +FNDA:1,(anonymous_109) +FNDA:0,(anonymous_110) +FNDA:0,(anonymous_111) +FNDA:12,(anonymous_112) +FNDA:0,(anonymous_113) +FNDA:0,(anonymous_114) +FNDA:0,(anonymous_115) +FNDA:0,(anonymous_116) +FNDA:0,(anonymous_117) +FNDA:0,(anonymous_118) +FNDA:0,(anonymous_119) +FNDA:0,(anonymous_120) +DA:32,4 +DA:41,5 +DA:47,5 +DA:50,15 +DA:52,0 +DA:70,1 +DA:71,1 +DA:72,1 +DA:73,1 +DA:75,1 +DA:99,1 +DA:115,1 +DA:167,0 +DA:199,0 +DA:232,0 +DA:234,0 +DA:235,0 +DA:238,0 +DA:239,0 +DA:240,0 +DA:241,0 +DA:242,0 +DA:243,0 +DA:244,0 +DA:245,0 +DA:246,0 +DA:247,0 +DA:250,0 +DA:255,0 +DA:263,0 +DA:266,0 +DA:274,0 +DA:289,0 +DA:290,0 +DA:292,0 +DA:340,0 +DA:348,0 +DA:354,0 +DA:373,0 +DA:375,0 +DA:376,0 +DA:380,0 +DA:381,0 +DA:384,0 +DA:390,0 +DA:395,0 +DA:414,0 +DA:415,0 +DA:465,0 +DA:471,0 +DA:490,0 +DA:498,0 +DA:499,0 +DA:500,0 +DA:508,0 +DA:518,0 +DA:519,0 +DA:520,0 +DA:525,0 +DA:528,0 +DA:530,0 +DA:540,0 +DA:556,0 +DA:562,0 +DA:563,0 +DA:571,0 +DA:583,0 +DA:600,0 +DA:614,0 +DA:625,0 +DA:642,0 +DA:644,0 +DA:650,0 +DA:663,0 +DA:677,0 +DA:699,0 +DA:700,0 +DA:701,0 +DA:723,0 +DA:733,0 +DA:734,0 +DA:735,0 +DA:745,0 +DA:747,0 +DA:748,0 +DA:749,0 +DA:750,0 +DA:751,0 +DA:754,0 +DA:755,0 +DA:756,0 +DA:757,0 +DA:758,0 +DA:761,0 +DA:762,0 +DA:763,0 +DA:764,0 +DA:767,0 +DA:768,0 +DA:771,0 +DA:773,0 +DA:780,0 +DA:794,0 +DA:799,0 +DA:804,0 +DA:810,0 +DA:814,0 +DA:818,0 +DA:822,0 +DA:834,0 +DA:838,0 +DA:842,0 +DA:846,0 +DA:859,0 +DA:863,0 +DA:867,0 +DA:871,0 +DA:884,0 +DA:888,0 +DA:892,0 +DA:909,0 +DA:911,0 +DA:917,0 +DA:930,0 +DA:944,0 +DA:945,0 +DA:946,0 +DA:948,0 +DA:950,0 +DA:954,0 +DA:956,0 +DA:960,0 +DA:961,0 +DA:963,0 +DA:964,0 +DA:965,0 +DA:966,0 +DA:970,0 +DA:971,0 +DA:973,0 +DA:974,0 +DA:975,0 +DA:979,0 +DA:980,0 +DA:982,0 +DA:983,0 +DA:987,0 +DA:988,0 +DA:989,0 +DA:994,0 +DA:995,0 +DA:998,0 +DA:999,0 +DA:1001,0 +DA:1005,0 +DA:1006,0 +DA:1007,0 +DA:1013,0 +DA:1016,0 +DA:1018,0 +DA:1024,0 +DA:1043,0 +DA:1052,0 +DA:1058,0 +DA:1061,0 +DA:1082,0 +DA:1092,0 +DA:1103,0 +DA:1114,0 +DA:1119,0 +DA:1131,0 +DA:1140,0 +DA:1153,5 +DA:1154,5 +DA:1155,5 +DA:1156,5 +DA:1157,5 +DA:1158,5 +DA:1159,5 +DA:1160,5 +DA:1161,5 +DA:1162,5 +DA:1163,5 +DA:1164,5 +DA:1166,5 +DA:1171,5 +DA:1173,4 +DA:1176,5 +DA:1181,5 +DA:1184,0 +DA:1185,0 +DA:1186,0 +DA:1190,5 +DA:1191,0 +DA:1193,0 +DA:1194,0 +DA:1195,0 +DA:1199,5 +DA:1202,0 +DA:1203,0 +DA:1204,0 +DA:1208,5 +DA:1211,0 +DA:1212,0 +DA:1216,5 +DA:1217,0 +DA:1219,0 +DA:1220,0 +DA:1224,5 +DA:1227,0 +DA:1228,0 +DA:1232,5 +DA:1233,0 +DA:1234,0 +DA:1237,5 +DA:1238,0 +DA:1239,0 +DA:1242,5 +DA:1243,0 +DA:1244,0 +DA:1247,5 +DA:1248,0 +DA:1249,0 +DA:1251,0 +DA:1255,5 +DA:1256,0 +DA:1257,0 +DA:1260,5 +DA:1261,0 +DA:1262,0 +DA:1265,5 +DA:1266,0 +DA:1267,0 +DA:1269,0 +DA:1273,5 +DA:1281,1 +DA:1287,0 +DA:1296,0 +DA:1314,12 +DA:1340,0 +DA:1355,0 +DA:1364,0 +DA:1366,0 +DA:1372,0 +DA:1380,0 +DA:1382,0 +DA:1388,0 +LF:247 +LH:43 +BRDA:55,0,0,5 +BRDA:55,0,1,10 +BRDA:70,1,0,1 +BRDA:70,1,1,0 +BRDA:71,2,0,1 +BRDA:71,2,1,1 +BRDA:72,3,0,1 +BRDA:72,3,1,1 +BRDA:82,4,0,1 +BRDA:82,4,1,1 +BRDA:83,5,0,1 +BRDA:83,5,1,1 +BRDA:99,6,0,1 +BRDA:99,6,1,0 +BRDA:111,7,0,0 +BRDA:111,7,1,1 +BRDA:119,8,0,0 +BRDA:119,8,1,1 +BRDA:121,9,0,0 +BRDA:121,9,1,1 +BRDA:123,10,0,0 +BRDA:123,10,1,1 +BRDA:128,11,0,0 +BRDA:128,11,1,1 +BRDA:130,12,0,0 +BRDA:130,12,1,1 +BRDA:132,13,0,0 +BRDA:132,13,1,1 +BRDA:141,14,0,1 +BRDA:141,14,1,0 +BRDA:156,15,0,1 +BRDA:156,15,1,0 +BRDA:162,16,0,0 +BRDA:162,16,1,0 +BRDA:191,17,0,1 +BRDA:191,17,1,0 +BRDA:206,18,0,0 +BRDA:206,18,1,0 +BRDA:200,19,0,0 +BRDA:200,19,1,0 +BRDA:235,20,0,0 +BRDA:235,20,1,0 +BRDA:239,21,0,0 +BRDA:239,21,1,0 +BRDA:241,22,0,0 +BRDA:241,22,1,0 +BRDA:243,23,0,0 +BRDA:243,23,1,0 +BRDA:245,24,0,0 +BRDA:245,24,1,0 +BRDA:258,25,0,0 +BRDA:258,25,1,0 +BRDA:269,26,0,0 +BRDA:269,26,1,0 +BRDA:279,27,0,0 +BRDA:279,27,1,0 +BRDA:283,28,0,0 +BRDA:283,28,1,0 +BRDA:313,29,0,0 +BRDA:313,29,1,0 +BRDA:327,30,0,0 +BRDA:327,30,1,0 +BRDA:327,30,2,0 +BRDA:330,31,0,0 +BRDA:330,31,1,0 +BRDA:336,32,0,0 +BRDA:336,32,1,0 +BRDA:338,33,0,0 +BRDA:338,33,1,0 +BRDA:375,34,0,0 +BRDA:375,34,1,0 +BRDA:407,35,0,0 +BRDA:407,35,1,0 +BRDA:420,36,0,0 +BRDA:420,36,1,0 +BRDA:425,37,0,0 +BRDA:425,37,1,0 +BRDA:433,38,0,0 +BRDA:433,38,1,0 +BRDA:440,39,0,0 +BRDA:440,39,1,0 +BRDA:446,40,0,0 +BRDA:446,40,1,0 +BRDA:454,41,0,0 +BRDA:454,41,1,0 +BRDA:459,42,0,0 +BRDA:459,42,1,0 +BRDA:499,43,0,0 +BRDA:499,43,1,0 +BRDA:501,44,0,0 +BRDA:501,44,1,0 +BRDA:502,45,0,0 +BRDA:502,45,1,0 +BRDA:503,46,0,0 +BRDA:503,46,1,0 +BRDA:504,47,0,0 +BRDA:504,47,1,0 +BRDA:505,48,0,0 +BRDA:505,48,1,0 +BRDA:528,49,0,0 +BRDA:528,49,1,0 +BRDA:544,50,0,0 +BRDA:544,50,1,0 +BRDA:574,51,0,0 +BRDA:574,51,1,0 +BRDA:586,52,0,0 +BRDA:586,52,1,0 +BRDA:632,53,0,0 +BRDA:632,53,1,0 +BRDA:642,54,0,0 +BRDA:642,54,1,0 +BRDA:642,55,0,0 +BRDA:642,55,1,0 +BRDA:700,56,0,0 +BRDA:700,56,1,0 +BRDA:702,57,0,0 +BRDA:702,57,1,0 +BRDA:703,58,0,0 +BRDA:703,58,1,0 +BRDA:704,59,0,0 +BRDA:704,59,1,0 +BRDA:705,60,0,0 +BRDA:705,60,1,0 +BRDA:706,61,0,0 +BRDA:706,61,1,0 +BRDA:707,62,0,0 +BRDA:707,62,1,0 +BRDA:708,63,0,0 +BRDA:708,63,1,0 +BRDA:709,64,0,0 +BRDA:709,64,1,0 +BRDA:710,65,0,0 +BRDA:710,65,1,0 +BRDA:711,66,0,0 +BRDA:711,66,1,0 +BRDA:712,67,0,0 +BRDA:712,67,1,0 +BRDA:713,68,0,0 +BRDA:713,68,1,0 +BRDA:714,69,0,0 +BRDA:714,69,1,0 +BRDA:715,70,0,0 +BRDA:715,70,1,0 +BRDA:716,71,0,0 +BRDA:716,71,1,0 +BRDA:717,72,0,0 +BRDA:717,72,1,0 +BRDA:718,73,0,0 +BRDA:718,73,1,0 +BRDA:719,74,0,0 +BRDA:719,74,1,0 +BRDA:720,75,0,0 +BRDA:720,75,1,0 +BRDA:745,76,0,0 +BRDA:745,76,1,0 +BRDA:747,77,0,0 +BRDA:747,77,1,0 +BRDA:748,78,0,0 +BRDA:748,78,1,0 +BRDA:749,79,0,0 +BRDA:749,79,1,0 +BRDA:750,80,0,0 +BRDA:750,80,1,0 +BRDA:751,81,0,0 +BRDA:751,81,1,0 +BRDA:754,82,0,0 +BRDA:754,82,1,0 +BRDA:755,83,0,0 +BRDA:755,83,1,0 +BRDA:756,84,0,0 +BRDA:756,84,1,0 +BRDA:757,85,0,0 +BRDA:757,85,1,0 +BRDA:758,86,0,0 +BRDA:758,86,1,0 +BRDA:761,87,0,0 +BRDA:761,87,1,0 +BRDA:762,88,0,0 +BRDA:762,88,1,0 +BRDA:763,89,0,0 +BRDA:763,89,1,0 +BRDA:764,90,0,0 +BRDA:764,90,1,0 +BRDA:771,91,0,0 +BRDA:771,91,1,0 +BRDA:784,92,0,0 +BRDA:784,92,1,0 +BRDA:828,93,0,0 +BRDA:828,93,1,0 +BRDA:853,94,0,0 +BRDA:853,94,1,0 +BRDA:878,95,0,0 +BRDA:878,95,1,0 +BRDA:899,96,0,0 +BRDA:899,96,1,0 +BRDA:909,97,0,0 +BRDA:909,97,1,0 +BRDA:909,98,0,0 +BRDA:909,98,1,0 +BRDA:948,99,0,0 +BRDA:951,100,0,0 +BRDA:951,100,1,0 +BRDA:954,101,0,0 +BRDA:957,102,0,0 +BRDA:957,102,1,0 +BRDA:957,102,2,0 +BRDA:992,103,0,0 +BRDA:992,103,1,0 +BRDA:994,104,0,0 +BRDA:994,104,1,0 +BRDA:998,105,0,0 +BRDA:998,105,1,0 +BRDA:1008,106,0,0 +BRDA:1008,106,1,0 +BRDA:1009,107,0,0 +BRDA:1009,107,1,0 +BRDA:1010,108,0,0 +BRDA:1010,108,1,0 +BRDA:1011,109,0,0 +BRDA:1011,109,1,0 +BRDA:1016,110,0,0 +BRDA:1016,110,1,0 +BRDA:1016,111,0,0 +BRDA:1016,111,1,0 +BRDA:1037,112,0,0 +BRDA:1037,112,1,0 +BRDA:1039,113,0,0 +BRDA:1039,113,1,0 +BRDA:1050,114,0,0 +BRDA:1050,114,1,0 +BRDA:1050,114,2,0 +BRDA:1052,115,0,0 +BRDA:1052,115,1,0 +BRDA:1071,116,0,0 +BRDA:1071,116,1,0 +BRDA:1074,117,0,0 +BRDA:1074,117,1,0 +BRDA:1109,118,0,0 +BRDA:1109,118,1,0 +BRDA:1109,118,2,0 +BRDA:1127,119,0,0 +BRDA:1127,119,1,0 +BRDA:1166,120,0,5 +BRDA:1171,121,0,5 +BRDA:1248,122,0,0 +BRDA:1248,122,1,0 +BRDA:1266,123,0,0 +BRDA:1266,123,1,0 +BRDA:1285,124,0,5 +BRDA:1285,124,1,0 +BRDA:1294,125,0,5 +BRDA:1294,125,1,5 +BRDA:1311,126,0,4 +BRDA:1311,126,1,1 +BRDA:1327,127,0,1 +BRDA:1327,127,1,1 +BRDA:1328,128,0,1 +BRDA:1328,128,1,0 +BRDA:1336,129,0,1 +BRDA:1336,129,1,0 +BRDA:1352,130,0,5 +BRDA:1352,130,1,0 +BRDA:1361,131,0,5 +BRDA:1361,131,1,0 +BRDA:1369,132,0,5 +BRDA:1369,132,1,0 +BRDA:1377,133,0,5 +BRDA:1377,133,1,0 +BRDA:1385,134,0,5 +BRDA:1385,134,1,0 +BRF:270 +BRH:38 +end_of_record +TN: +SF:src/pages/Settings.jsx +FN:28,Settings +FN:45,(anonymous_1) +FN:56,(anonymous_2) +FN:71,(anonymous_3) +FN:77,(anonymous_4) +FN:96,(anonymous_5) +FN:99,(anonymous_6) +FN:183,(anonymous_7) +FN:226,(anonymous_8) +FN:253,(anonymous_9) +FN:274,(anonymous_10) +FN:290,(anonymous_11) +FN:313,(anonymous_12) +FN:316,(anonymous_13) +FNF:14 +FNH:7 +FNDA:30,Settings +FNDA:15,(anonymous_1) +FNDA:23,(anonymous_2) +FNDA:1,(anonymous_3) +FNDA:1,(anonymous_4) +FNDA:0,(anonymous_5) +FNDA:0,(anonymous_6) +FNDA:1,(anonymous_7) +FNDA:0,(anonymous_8) +FNDA:0,(anonymous_9) +FNDA:0,(anonymous_10) +FNDA:0,(anonymous_11) +FNDA:0,(anonymous_12) +FNDA:352,(anonymous_13) +DA:9,1 +DA:29,30 +DA:30,30 +DA:31,30 +DA:32,30 +DA:33,30 +DA:34,30 +DA:35,30 +DA:36,30 +DA:37,30 +DA:38,30 +DA:40,30 +DA:45,30 +DA:46,15 +DA:47,7 +DA:48,7 +DA:49,7 +DA:50,7 +DA:51,7 +DA:52,7 +DA:56,30 +DA:57,23 +DA:59,15 +DA:65,15 +DA:69,30 +DA:72,1 +DA:73,1 +DA:77,30 +DA:78,1 +DA:85,1 +DA:86,1 +DA:89,1 +DA:90,1 +DA:93,1 +DA:96,30 +DA:97,0 +DA:98,0 +DA:99,0 +DA:102,30 +DA:103,8 +DA:110,22 +DA:183,1 +DA:226,0 +DA:253,0 +DA:274,0 +DA:290,0 +DA:313,0 +DA:317,352 +LF:48 +LH:40 +BRDA:46,0,0,7 +BRDA:46,0,1,8 +BRDA:47,1,0,7 +BRDA:47,1,1,0 +BRDA:48,2,0,7 +BRDA:48,2,1,0 +BRDA:49,3,0,7 +BRDA:49,3,1,0 +BRDA:50,4,0,7 +BRDA:50,4,1,0 +BRDA:51,5,0,7 +BRDA:51,5,1,0 +BRDA:52,6,0,7 +BRDA:52,6,1,0 +BRDA:57,7,0,15 +BRDA:57,7,1,8 +BRDA:59,8,0,15 +BRDA:59,8,1,7 +BRDA:59,8,2,7 +BRDA:59,8,3,7 +BRDA:59,8,4,7 +BRDA:59,8,5,7 +BRDA:64,9,0,7 +BRDA:64,9,1,0 +BRDA:85,10,0,1 +BRDA:85,10,1,0 +BRDA:85,11,0,1 +BRDA:85,11,1,1 +BRDA:89,12,0,1 +BRDA:89,12,1,0 +BRDA:102,13,0,8 +BRDA:102,13,1,22 +BRDA:141,14,0,11 +BRDA:141,14,1,11 +BRDA:147,15,0,11 +BRDA:147,15,1,11 +BRDA:151,16,0,11 +BRDA:151,16,1,11 +BRDA:152,17,0,11 +BRDA:152,17,1,11 +BRDA:153,18,0,11 +BRDA:153,18,1,11 +BRDA:211,19,0,0 +BRDA:211,19,1,22 +BRDA:212,20,0,0 +BRDA:212,20,1,22 +BRDA:261,21,0,30 +BRDA:261,21,1,22 +BRDA:325,22,0,30 +BRDA:325,22,1,9 +BRDA:332,23,0,0 +BRDA:332,23,1,9 +BRDA:336,24,0,30 +BRDA:336,24,1,1 +BRDA:336,24,2,1 +BRF:55 +BRH:43 +end_of_record +TN: +SF:src/pages/Stats.jsx +FN:13,getHabitStartDate +FN:20,isHabitFrozenOnDate +FN:23,(anonymous_2) +FN:31,isHabitExpectedOnDate +FN:51,(anonymous_4) +FN:64,(anonymous_5) +FN:97,(anonymous_6) +FN:113,(anonymous_7) +FN:114,(anonymous_8) +FN:138,(anonymous_9) +FN:150,Stats +FN:162,(anonymous_11) +FN:166,(anonymous_12) +FN:171,(anonymous_13) +FN:194,(anonymous_14) +FN:196,(anonymous_15) +FN:204,(anonymous_16) +FN:228,(anonymous_17) +FN:237,(anonymous_18) +FN:243,(anonymous_19) +FN:246,(anonymous_20) +FN:250,(anonymous_21) +FN:259,(anonymous_22) +FN:262,(anonymous_23) +FN:273,(anonymous_24) +FN:282,(anonymous_25) +FN:285,(anonymous_26) +FN:289,(anonymous_27) +FN:300,(anonymous_28) +FN:301,(anonymous_29) +FN:311,(anonymous_30) +FN:326,(anonymous_31) +FN:330,(anonymous_32) +FN:340,(anonymous_33) +FN:343,(anonymous_34) +FN:385,(anonymous_35) +FN:430,(anonymous_36) +FN:440,(anonymous_37) +FN:443,(anonymous_38) +FN:508,(anonymous_39) +FN:525,(anonymous_40) +FN:534,(anonymous_41) +FN:536,(anonymous_42) +FN:604,(anonymous_43) +FN:642,(anonymous_44) +FNF:45 +FNH:30 +FNDA:348,getHabitStartDate +FNDA:8,isHabitFrozenOnDate +FNDA:0,(anonymous_2) +FNDA:348,isHabitExpectedOnDate +FNDA:0,(anonymous_4) +FNDA:24,(anonymous_5) +FNDA:504,(anonymous_6) +FNDA:0,(anonymous_7) +FNDA:0,(anonymous_8) +FNDA:12,(anonymous_9) +FNDA:7,Stats +FNDA:5,(anonymous_11) +FNDA:1,(anonymous_12) +FNDA:1,(anonymous_13) +FNDA:6,(anonymous_14) +FNDA:0,(anonymous_15) +FNDA:2,(anonymous_16) +FNDA:6,(anonymous_17) +FNDA:504,(anonymous_18) +FNDA:0,(anonymous_19) +FNDA:168,(anonymous_20) +FNDA:0,(anonymous_21) +FNDA:6,(anonymous_22) +FNDA:504,(anonymous_23) +FNDA:6,(anonymous_24) +FNDA:0,(anonymous_25) +FNDA:60,(anonymous_26) +FNDA:0,(anonymous_27) +FNDA:6,(anonymous_28) +FNDA:2,(anonymous_29) +FNDA:0,(anonymous_30) +FNDA:0,(anonymous_31) +FNDA:504,(anonymous_32) +FNDA:2,(anonymous_33) +FNDA:6,(anonymous_34) +FNDA:1,(anonymous_35) +FNDA:0,(anonymous_36) +FNDA:2,(anonymous_37) +FNDA:0,(anonymous_38) +FNDA:18,(anonymous_39) +FNDA:42,(anonymous_40) +FNDA:72,(anonymous_41) +FNDA:504,(anonymous_42) +FNDA:0,(anonymous_43) +FNDA:0,(anonymous_44) +DA:14,348 +DA:15,348 +DA:16,348 +DA:21,8 +DA:22,0 +DA:23,0 +DA:24,0 +DA:25,0 +DA:26,0 +DA:32,348 +DA:33,348 +DA:34,348 +DA:35,8 +DA:37,8 +DA:38,348 +DA:39,8 +DA:40,0 +DA:41,0 +DA:43,8 +DA:44,0 +DA:45,0 +DA:47,8 +DA:51,1 +DA:52,0 +DA:53,0 +DA:60,0 +DA:64,1 +DA:65,24 +DA:97,1 +DA:98,504 +DA:99,504 +DA:101,504 +DA:113,0 +DA:114,0 +DA:138,1 +DA:139,12 +DA:151,7 +DA:152,7 +DA:153,7 +DA:154,7 +DA:155,7 +DA:157,7 +DA:162,7 +DA:163,5 +DA:166,7 +DA:167,1 +DA:168,1 +DA:169,1 +DA:171,1 +DA:172,1 +DA:173,1 +DA:178,0 +DA:179,0 +DA:180,0 +DA:182,1 +DA:183,1 +DA:184,1 +DA:188,1 +DA:189,1 +DA:190,1 +DA:194,7 +DA:195,6 +DA:196,0 +DA:199,6 +DA:200,6 +DA:201,6 +DA:202,6 +DA:204,6 +DA:205,2 +DA:206,2 +DA:207,2 +DA:209,2 +DA:211,2 +DA:212,60 +DA:213,60 +DA:216,2 +DA:217,0 +DA:218,0 +DA:222,6 +DA:224,6 +DA:228,7 +DA:229,6 +DA:230,6 +DA:231,6 +DA:234,6 +DA:235,6 +DA:237,6 +DA:238,504 +DA:239,504 +DA:240,504 +DA:242,504 +DA:243,0 +DA:246,504 +DA:247,168 +DA:248,168 +DA:250,168 +DA:251,168 +DA:254,504 +DA:259,7 +DA:260,6 +DA:261,6 +DA:262,6 +DA:263,504 +DA:264,504 +DA:265,18 +DA:266,18 +DA:269,6 +DA:273,7 +DA:274,6 +DA:275,6 +DA:276,180 +DA:277,180 +DA:278,180 +DA:279,180 +DA:281,180 +DA:282,0 +DA:285,180 +DA:286,60 +DA:287,60 +DA:289,60 +DA:290,60 +DA:293,180 +DA:294,180 +DA:296,180 +DA:300,7 +DA:301,6 +DA:302,2 +DA:303,2 +DA:305,2 +DA:306,2 +DA:307,60 +DA:308,60 +DA:311,2 +DA:312,0 +DA:313,0 +DA:316,2 +DA:318,2 +DA:326,0 +DA:330,7 +DA:331,504 +DA:332,2 +DA:333,2 +DA:334,0 +DA:335,0 +DA:336,0 +DA:337,0 +DA:340,7 +DA:343,7 +DA:344,6 +DA:345,6 +DA:346,72 +DA:348,6 +DA:351,7 +DA:385,1 +DA:430,0 +DA:441,2 +DA:443,0 +DA:509,18 +DA:526,42 +DA:535,72 +DA:537,504 +DA:604,0 +DA:643,0 +LF:163 +LH:130 +BRDA:14,0,0,0 +BRDA:14,0,1,348 +BRDA:15,1,0,0 +BRDA:15,1,1,348 +BRDA:21,2,0,8 +BRDA:21,2,1,0 +BRDA:21,3,0,8 +BRDA:21,3,1,8 +BRDA:26,4,0,0 +BRDA:26,4,1,0 +BRDA:34,5,0,340 +BRDA:34,5,1,8 +BRDA:34,6,0,348 +BRDA:34,6,1,8 +BRDA:35,7,0,0 +BRDA:35,7,1,8 +BRDA:37,8,0,8 +BRDA:37,8,1,0 +BRDA:38,9,0,0 +BRDA:38,9,1,348 +BRDA:39,10,0,0 +BRDA:39,10,1,8 +BRDA:40,11,0,0 +BRDA:40,11,1,0 +BRDA:43,12,0,0 +BRDA:43,12,1,8 +BRDA:43,13,0,8 +BRDA:43,13,1,0 +BRDA:52,14,0,0 +BRDA:52,14,1,0 +BRDA:52,15,0,0 +BRDA:52,15,1,0 +BRDA:52,15,2,0 +BRDA:64,16,0,24 +BRDA:117,17,0,504 +BRDA:117,17,1,0 +BRDA:145,18,0,12 +BRDA:145,18,1,12 +BRDA:150,19,0,7 +BRDA:157,20,0,7 +BRDA:163,21,0,1 +BRDA:163,21,1,4 +BRDA:195,22,0,0 +BRDA:195,22,1,6 +BRDA:205,23,0,2 +BRDA:205,23,1,1 +BRDA:207,24,0,2 +BRDA:207,24,1,1 +BRDA:213,25,0,2 +BRDA:213,25,1,58 +BRDA:216,26,0,0 +BRDA:216,26,1,2 +BRDA:217,27,0,0 +BRDA:217,27,1,0 +BRDA:218,28,0,0 +BRDA:218,28,1,0 +BRDA:222,29,0,2 +BRDA:222,29,1,4 +BRDA:234,30,0,6 +BRDA:234,30,1,0 +BRDA:242,31,0,0 +BRDA:242,31,1,504 +BRDA:247,32,0,168 +BRDA:247,32,1,84 +BRDA:248,33,0,168 +BRDA:248,33,1,84 +BRDA:250,34,0,0 +BRDA:250,34,1,168 +BRDA:251,35,0,2 +BRDA:251,35,1,166 +BRDA:264,36,0,18 +BRDA:264,36,1,486 +BRDA:281,37,0,0 +BRDA:281,37,1,180 +BRDA:286,38,0,60 +BRDA:286,38,1,30 +BRDA:287,39,0,60 +BRDA:287,39,1,30 +BRDA:289,40,0,0 +BRDA:289,40,1,60 +BRDA:290,41,0,2 +BRDA:290,41,1,58 +BRDA:293,42,0,2 +BRDA:293,42,1,178 +BRDA:302,43,0,2 +BRDA:302,43,1,1 +BRDA:303,44,0,2 +BRDA:303,44,1,1 +BRDA:308,45,0,2 +BRDA:308,45,1,58 +BRDA:316,46,0,2 +BRDA:316,46,1,0 +BRDA:322,47,0,2 +BRDA:322,47,1,0 +BRDA:331,48,0,502 +BRDA:331,48,1,2 +BRDA:333,49,0,2 +BRDA:333,49,1,0 +BRDA:334,50,0,0 +BRDA:334,50,1,0 +BRDA:335,51,0,0 +BRDA:335,51,1,0 +BRDA:336,52,0,0 +BRDA:336,52,1,0 +BRDA:352,53,0,0 +BRDA:352,53,1,6 +BRDA:359,54,0,7 +BRDA:359,54,1,6 +BRDA:389,55,0,0 +BRDA:389,55,1,6 +BRDA:416,56,0,7 +BRDA:416,56,1,3 +BRDA:421,57,0,7 +BRDA:421,57,1,3 +BRDA:433,58,0,3 +BRDA:433,58,1,3 +BRDA:438,59,0,3 +BRDA:438,59,1,3 +BRDA:446,60,0,2 +BRDA:446,60,1,0 +BRDA:451,61,0,2 +BRDA:451,61,1,0 +BRDA:628,62,0,7 +BRDA:628,62,1,6 +BRDA:628,62,2,0 +BRDA:677,63,0,7 +BRDA:677,63,1,4 +BRDA:692,64,0,7 +BRDA:692,64,1,6 +BRF:129 +BRH:85 +end_of_record +TN: +SF:src/pages/Tasks.jsx +FN:27,formatDueDate +FN:35,Tasks +FN:43,(anonymous_2) +FN:50,(anonymous_3) +FN:51,(anonymous_4) +FN:58,(anonymous_5) +FN:59,(anonymous_6) +FN:65,(anonymous_7) +FN:76,(anonymous_8) +FN:86,(anonymous_9) +FN:89,(anonymous_10) +FN:107,(anonymous_11) +FN:131,(anonymous_12) +FN:140,(anonymous_13) +FN:141,(anonymous_14) +FN:141,(anonymous_15) +FN:149,(anonymous_16) +FN:150,(anonymous_17) +FN:155,TaskCard +FN:161,(anonymous_19) +FN:164,(anonymous_20) +FN:178,(anonymous_21) +FNF:22 +FNH:7 +FNDA:4,formatDueDate +FNDA:10,Tasks +FNDA:7,(anonymous_2) +FNDA:0,(anonymous_3) +FNDA:0,(anonymous_4) +FNDA:0,(anonymous_5) +FNDA:0,(anonymous_6) +FNDA:0,(anonymous_7) +FNDA:0,(anonymous_8) +FNDA:27,(anonymous_9) +FNDA:0,(anonymous_10) +FNDA:21,(anonymous_11) +FNDA:0,(anonymous_12) +FNDA:4,(anonymous_13) +FNDA:0,(anonymous_14) +FNDA:0,(anonymous_15) +FNDA:0,(anonymous_16) +FNDA:0,(anonymous_17) +FNDA:4,TaskCard +FNDA:0,(anonymous_19) +FNDA:0,(anonymous_20) +FNDA:0,(anonymous_21) +DA:13,1 +DA:20,1 +DA:28,4 +DA:29,2 +DA:30,2 +DA:31,2 +DA:32,2 +DA:36,10 +DA:37,10 +DA:38,10 +DA:39,10 +DA:41,10 +DA:44,7 +DA:45,7 +DA:49,10 +DA:50,0 +DA:52,0 +DA:53,0 +DA:57,10 +DA:58,0 +DA:60,0 +DA:61,0 +DA:65,10 +DA:66,0 +DA:67,0 +DA:70,10 +DA:76,0 +DA:87,27 +DA:89,0 +DA:108,21 +DA:131,0 +DA:141,4 +DA:149,0 +DA:150,0 +DA:156,4 +DA:157,4 +DA:158,4 +DA:159,4 +DA:161,4 +DA:162,0 +DA:163,0 +DA:164,0 +DA:165,0 +DA:168,4 +DA:179,0 +LF:45 +LH:27 +BRDA:28,0,0,2 +BRDA:28,0,1,2 +BRDA:30,1,0,0 +BRDA:30,1,1,2 +BRDA:31,2,0,0 +BRDA:31,2,1,2 +BRDA:35,3,0,10 +BRDA:41,4,0,10 +BRDA:44,5,0,0 +BRDA:44,5,1,7 +BRDA:66,6,0,0 +BRDA:66,6,1,0 +BRDA:71,7,0,1 +BRDA:71,7,1,9 +BRDA:72,8,0,10 +BRDA:72,8,1,9 +BRDA:92,9,0,9 +BRDA:92,9,1,18 +BRDA:105,10,0,7 +BRDA:105,10,1,3 +BRDA:119,11,0,1 +BRDA:119,11,1,2 +BRDA:125,12,0,1 +BRDA:125,12,1,0 +BRDA:125,13,0,0 +BRDA:125,13,1,0 +BRDA:128,14,0,1 +BRDA:128,14,1,0 +BRDA:130,15,0,1 +BRDA:130,15,1,1 +BRDA:141,16,0,4 +BRDA:141,16,1,4 +BRDA:148,17,0,10 +BRDA:148,17,1,9 +BRDA:159,18,0,4 +BRDA:159,18,1,2 +BRDA:159,18,2,0 +BRDA:159,18,3,0 +BRDA:163,19,0,0 +BRDA:163,19,1,0 +BRDA:164,20,0,0 +BRDA:164,20,1,0 +BRDA:176,21,0,4 +BRDA:176,21,1,0 +BRDA:191,22,0,0 +BRDA:191,22,1,4 +BRDA:193,23,0,0 +BRDA:193,23,1,4 +BRDA:193,24,0,0 +BRDA:193,24,1,4 +BRDA:195,25,0,0 +BRDA:195,25,1,4 +BRDA:200,26,0,4 +BRDA:200,26,1,0 +BRDA:206,27,0,0 +BRDA:206,27,1,4 +BRDA:207,28,0,4 +BRDA:207,28,1,0 +BRDA:207,29,0,0 +BRDA:207,29,1,0 +BRDA:210,30,0,4 +BRDA:210,30,1,0 +BRDA:213,31,0,4 +BRDA:213,31,1,2 +BRDA:214,32,0,0 +BRDA:214,32,1,2 +BRDA:215,33,0,2 +BRDA:215,33,1,0 +BRDA:221,34,0,4 +BRDA:221,34,1,2 +BRDA:223,35,0,4 +BRDA:223,35,1,0 +BRDA:223,35,2,0 +BRDA:237,36,0,4 +BRDA:237,36,1,0 +BRF:75 +BRH:44 +end_of_record +TN: +SF:src/pages/Tracker.jsx +FN:15,Tracker +FN:27,(anonymous_1) +FN:30,(anonymous_2) +FNF:3 +FNH:3 +FNDA:11,Tracker +FNDA:33,(anonymous_1) +FNDA:4,(anonymous_2) +DA:9,1 +DA:16,11 +DA:18,11 +DA:28,33 +DA:30,4 +LF:5 +LH:5 +BRDA:32,0,0,11 +BRDA:32,0,1,22 +BRDA:44,1,0,11 +BRDA:44,1,1,8 +BRDA:45,2,0,11 +BRDA:45,2,1,2 +BRDA:46,3,0,11 +BRDA:46,3,1,1 +BRF:8 +BRH:8 +end_of_record +TN: +SF:src/pages/VerifyEmail.jsx +FN:7,VerifyEmail +FN:14,(anonymous_1) +FN:21,(anonymous_2) +FNF:3 +FNH:3 +FNDA:11,VerifyEmail +FNDA:6,(anonymous_1) +FNDA:5,(anonymous_2) +DA:8,11 +DA:9,11 +DA:10,11 +DA:12,11 +DA:14,11 +DA:15,6 +DA:16,1 +DA:17,1 +DA:18,1 +DA:21,5 +DA:22,5 +DA:23,5 +DA:24,3 +DA:25,3 +DA:27,1 +DA:28,1 +DA:32,5 +DA:35,11 +LF:18 +LH:18 +BRDA:15,0,0,1 +BRDA:15,0,1,5 +BRDA:28,1,0,1 +BRDA:28,1,1,0 +BRDA:43,2,0,11 +BRDA:43,2,1,6 +BRDA:55,3,0,11 +BRDA:55,3,1,3 +BRDA:75,4,0,11 +BRDA:75,4,1,2 +BRF:10 +BRH:9 +end_of_record +TN: +SF:src/store/auth.js +FN:4,(anonymous_0) +FN:9,(anonymous_1) +FN:26,(anonymous_2) +FN:34,(anonymous_3) +FN:42,(anonymous_4) +FNF:5 +FNH:5 +FNDA:1,(anonymous_0) +FNDA:3,(anonymous_1) +FNDA:2,(anonymous_2) +FNDA:1,(anonymous_3) +FNDA:1,(anonymous_4) +DA:4,1 +DA:10,3 +DA:11,3 +DA:12,1 +DA:13,1 +DA:16,2 +DA:17,2 +DA:18,1 +DA:20,1 +DA:21,1 +DA:22,1 +DA:27,2 +DA:28,1 +DA:29,1 +DA:30,1 +DA:31,1 +DA:35,1 +DA:36,1 +DA:37,1 +DA:38,1 +DA:39,1 +DA:43,1 +DA:44,1 +DA:45,1 +LF:24 +LH:24 +BRDA:11,0,0,1 +BRDA:11,0,1,2 +BRF:2 +BRH:2 +end_of_record diff --git a/src/__tests__/Home.test.jsx b/src/__tests__/Home.test.jsx index 66b7cf0..75b7bbe 100644 --- a/src/__tests__/Home.test.jsx +++ b/src/__tests__/Home.test.jsx @@ -1,5 +1,5 @@ import { describe, it, expect, vi, beforeEach } from 'vitest' -import { render, screen, waitFor } from '@testing-library/react' +import { render, screen, waitFor, fireEvent, act } from '@testing-library/react' import { QueryClient, QueryClientProvider } from '@tanstack/react-query' import { MemoryRouter } from 'react-router-dom' import Home from '../pages/Home' @@ -17,6 +17,7 @@ vi.mock('../api/habits', () => ({ getStats: vi.fn(), getHabitStats: vi.fn(), getFreezes: vi.fn(), + deleteLog: vi.fn(), }, })) @@ -33,17 +34,34 @@ vi.mock('../components/Navigation', () => ({ })) vi.mock('../components/CreateTaskModal', () => ({ - default: ({ open }) => open ?
: null, + default: ({ open, onClose }) => open ?
: null, })) vi.mock('../components/LogHabitModal', () => ({ - default: ({ open }) => open ?
: null, + default: ({ open, onClose, habit, onLogDate }) => open ? ( +
+ {habit?.name} + + +
+ ) : null, })) import { habitsApi } from '../api/habits' import { tasksApi } from '../api/tasks' const mockUser = { id: 1, username: 'testuser', email: 'test@test.com' } +const mockLogout = vi.fn() + +const mockHabits = [ + { id: 1, name: 'Exercise', frequency: 'daily', color: '#6366f1', icon: '💪', is_archived: false, created_at: '2026-01-01T00:00:00Z' }, + { id: 2, name: 'Read', frequency: 'weekly', target_days: [1,2,3,4,5,6,7], color: '#22c55e', icon: '📚', is_archived: false, created_at: '2026-01-01T00:00:00Z' }, +] + +const mockTasks = [ + { id: 1, title: 'Buy groceries', completed: false, priority: 1, due_date: null, icon: '📋', color: '#6366f1', is_recurring: false, recurrence_type: null }, + { id: 2, title: 'Completed task', completed: true, priority: 0, due_date: null, icon: '✅', color: '#22c55e', is_recurring: false, recurrence_type: null }, +] const renderHome = () => { const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } }) @@ -59,12 +77,12 @@ const renderHome = () => { describe('Home page', () => { beforeEach(() => { vi.clearAllMocks() - // useAuthStore is used as: const { user } = useAuthStore() — not with selector - useAuthStore.mockReturnValue({ user: mockUser, logout: vi.fn() }) + useAuthStore.mockReturnValue({ user: mockUser, logout: mockLogout }) habitsApi.list.mockResolvedValue([]) habitsApi.getLogs.mockResolvedValue([]) - habitsApi.getStats.mockResolvedValue({ total_habits: 0, completion_rate: 0 }) + habitsApi.getStats.mockResolvedValue({ total_habits: 0, completion_rate: 0, today_completed: 3, active_habits: 5 }) habitsApi.getFreezes.mockResolvedValue([]) + habitsApi.deleteLog.mockResolvedValue({}) tasksApi.today.mockResolvedValue([]) }) @@ -86,4 +104,294 @@ describe('Home page', () => { expect(screen.getByText(/testuser/i)).toBeInTheDocument() }) }) + + it('calls logout when logout button clicked', async () => { + renderHome() + await waitFor(() => { + expect(screen.getByText(/testuser/i)).toBeInTheDocument() + }) + const logoutBtn = document.querySelector('[title="Выйти"]') + if (logoutBtn) { + fireEvent.click(logoutBtn) + expect(mockLogout).toHaveBeenCalled() + } + }) + + it('shows progress section', async () => { + renderHome() + await waitFor(() => { + expect(screen.getByText('Прогресс на сегодня')).toBeInTheDocument() + }) + }) + + it('shows stats when available', async () => { + renderHome() + await waitFor(() => { + expect(screen.getByText('Выполнено')).toBeInTheDocument() + expect(screen.getByText('Активных')).toBeInTheDocument() + }) + }) + + it('shows empty tasks state', async () => { + renderHome() + await waitFor(() => { + expect(screen.getByText('Нет задач на сегодня')).toBeInTheDocument() + }) + }) + + it('renders tasks when present', async () => { + tasksApi.today.mockResolvedValue(mockTasks) + renderHome() + await waitFor(() => { + expect(screen.getByText('Buy groceries')).toBeInTheDocument() + }) + }) + + it('renders only active tasks in main list', async () => { + tasksApi.today.mockResolvedValue(mockTasks) + renderHome() + await waitFor(() => { + expect(screen.getByText('Buy groceries')).toBeInTheDocument() + }) + }) + + it('shows free day message when no habits for today', async () => { + habitsApi.list.mockResolvedValue([]) + renderHome() + await waitFor(() => { + expect(screen.getByText('Свободный день!')).toBeInTheDocument() + }) + }) + + it('renders habits section', async () => { + habitsApi.list.mockResolvedValue(mockHabits) + habitsApi.getLogs.mockResolvedValue([]) + habitsApi.getFreezes.mockResolvedValue([]) + renderHome() + await waitFor(() => { + expect(screen.getByText('Привычки')).toBeInTheDocument() + }) + }) + + it('opens create task modal when plus button clicked', async () => { + tasksApi.today.mockResolvedValue(mockTasks) + renderHome() + await waitFor(() => { + expect(screen.getByText('Задачи на сегодня')).toBeInTheDocument() + }) + const plusBtns = document.querySelectorAll('button') + const plusBtn = Array.from(plusBtns).find(b => b.querySelector('svg')) + // Click the + button in tasks header + const taskHeader = screen.getByText('Задачи на сегодня') + const headerDiv = taskHeader.closest('div') + const btnsInHeader = headerDiv?.querySelectorAll('button') + if (btnsInHeader && btnsInHeader.length > 0) { + fireEvent.click(btnsInHeader[0]) + await waitFor(() => { + expect(screen.getByTestId('create-task-modal')).toBeInTheDocument() + }) + } + }) + + it('opens create task modal from empty state', async () => { + renderHome() + await waitFor(() => { + expect(screen.getByText('+ Добавить задачу')).toBeInTheDocument() + }) + fireEvent.click(screen.getByText('+ Добавить задачу')) + expect(screen.getByTestId('create-task-modal')).toBeInTheDocument() + }) + + it('closes create task modal', async () => { + renderHome() + await waitFor(() => { + expect(screen.getByText('+ Добавить задачу')).toBeInTheDocument() + }) + fireEvent.click(screen.getByText('+ Добавить задачу')) + expect(screen.getByTestId('create-task-modal')).toBeInTheDocument() + fireEvent.click(screen.getByText('Close')) + expect(screen.queryByTestId('create-task-modal')).not.toBeInTheDocument() + }) + + it('toggles task complete', async () => { + tasksApi.complete.mockResolvedValue({ id: 1, completed: true }) + tasksApi.today.mockResolvedValue([mockTasks[0]]) + renderHome() + await waitFor(() => { + expect(screen.getByText('Buy groceries')).toBeInTheDocument() + }) + const buttons = document.querySelectorAll('button') + const completeBtn = Array.from(buttons).find(b => b.className && b.className.includes('rounded-xl') && b.querySelector('span')) + if (completeBtn) { + fireEvent.click(completeBtn) + await waitFor(() => { + expect(tasksApi.complete).toHaveBeenCalledWith(1) + }) + } + }) + + it('toggles uncomplete task - api setup', async () => { + tasksApi.uncomplete.mockResolvedValue({ id: 2, completed: false }) + tasksApi.today.mockResolvedValue([mockTasks[0]]) + renderHome() + await waitFor(() => { + expect(screen.getByText('Buy groceries')).toBeInTheDocument() + }) + // Verify uncomplete api is mocked properly + expect(tasksApi.uncomplete).toBeDefined() + }) + + it('shows habits with daily frequency', async () => { + const dailyHabit = { id: 1, name: 'Daily Habit', frequency: 'daily', color: '#6366f1', icon: '💪', is_archived: false, created_at: '2026-01-01T00:00:00Z' } + habitsApi.list.mockResolvedValue([dailyHabit]) + habitsApi.getLogs.mockResolvedValue([]) + habitsApi.getFreezes.mockResolvedValue([]) + renderHome() + await waitFor(() => { + expect(screen.getByText('Daily Habit')).toBeInTheDocument() + }) + }) + + it('shows completion message when all habits done', async () => { + const habit = { id: 1, name: 'Exercise', frequency: 'daily', color: '#6366f1', icon: '💪', is_archived: false, created_at: '2026-01-01T00:00:00Z' } + habitsApi.list.mockResolvedValue([habit]) + const today = new Date().toISOString().split('T')[0] + habitsApi.getLogs.mockResolvedValue([{ id: 10, date: today }]) + habitsApi.getFreezes.mockResolvedValue([]) + renderHome() + await waitFor(() => { + expect(screen.getByText(/Все привычки выполнены/)).toBeInTheDocument() + }, { timeout: 3000 }) + }) + + it('opens log habit modal from calendar button', async () => { + const habit = { id: 1, name: 'Exercise', frequency: 'daily', color: '#6366f1', icon: '💪', is_archived: false, created_at: '2026-01-01T00:00:00Z' } + habitsApi.list.mockResolvedValue([habit]) + habitsApi.getLogs.mockResolvedValue([]) + habitsApi.getFreezes.mockResolvedValue([]) + renderHome() + await waitFor(() => { + expect(screen.getByText('Exercise')).toBeInTheDocument() + }) + const calendarBtn = document.querySelector('[title="Отметить за другой день"]') + if (calendarBtn) { + fireEvent.click(calendarBtn) + await waitFor(() => { + expect(screen.getByTestId('log-habit-modal')).toBeInTheDocument() + }) + } + }) + + it('closes log habit modal', async () => { + const habit = { id: 1, name: 'Exercise', frequency: 'daily', color: '#6366f1', icon: '💪', is_archived: false, created_at: '2026-01-01T00:00:00Z' } + habitsApi.list.mockResolvedValue([habit]) + habitsApi.getLogs.mockResolvedValue([]) + habitsApi.getFreezes.mockResolvedValue([]) + renderHome() + await waitFor(() => { + expect(screen.getByText('Exercise')).toBeInTheDocument() + }) + const calendarBtn = document.querySelector('[title="Отметить за другой день"]') + if (calendarBtn) { + fireEvent.click(calendarBtn) + await waitFor(() => { + expect(screen.getByTestId('log-habit-modal')).toBeInTheDocument() + }) + fireEvent.click(screen.getByText('Close')) + expect(screen.queryByTestId('log-habit-modal')).not.toBeInTheDocument() + } + }) + + it('handles habit toggle (log)', async () => { + habitsApi.log.mockResolvedValue({ id: 99, date: new Date().toISOString().split('T')[0] }) + const habit = { id: 1, name: 'Exercise', frequency: 'daily', color: '#6366f1', icon: '💪', is_archived: false, created_at: '2026-01-01T00:00:00Z' } + habitsApi.list.mockResolvedValue([habit]) + habitsApi.getLogs.mockResolvedValue([]) + habitsApi.getFreezes.mockResolvedValue([]) + renderHome() + await waitFor(() => { + expect(screen.getByText('Exercise')).toBeInTheDocument() + }) + const habitBtns = document.querySelectorAll('button.rounded-2xl') + if (habitBtns.length > 0) { + fireEvent.click(habitBtns[0]) + await waitFor(() => { + expect(habitsApi.log).toHaveBeenCalledWith(1, {}) + }) + } + }) + + it('handles habit toggle (delete log when already done)', async () => { + habitsApi.deleteLog.mockResolvedValue({}) + const habit = { id: 1, name: 'Exercise', frequency: 'daily', color: '#6366f1', icon: '💪', is_archived: false, created_at: '2026-01-01T00:00:00Z' } + habitsApi.list.mockResolvedValue([habit]) + const today = new Date().toISOString().split('T')[0] + habitsApi.getLogs.mockResolvedValue([{ id: 5, date: today }]) + habitsApi.getFreezes.mockResolvedValue([]) + renderHome() + await waitFor(() => { + expect(screen.getByText('Exercise')).toBeInTheDocument() + }) + // After logs load, find the undo button for habit + await waitFor(() => { + const undoBtns = document.querySelectorAll('[title="Отменить"]') + expect(undoBtns.length).toBeGreaterThan(0) + }, { timeout: 3000 }) + }) + + it('renders tasks section header', async () => { + tasksApi.today.mockResolvedValue([mockTasks[0]]) + renderHome() + await waitFor(() => { + expect(screen.getByText('Задачи на сегодня')).toBeInTheDocument() + }) + }) + + it('handles log date from modal', async () => { + habitsApi.log.mockResolvedValue({ id: 99, date: '2026-03-01' }) + const habit = { id: 1, name: 'Exercise', frequency: 'daily', color: '#6366f1', icon: '💪', is_archived: false, created_at: '2026-01-01T00:00:00Z' } + habitsApi.list.mockResolvedValue([habit]) + habitsApi.getLogs.mockResolvedValue([]) + habitsApi.getFreezes.mockResolvedValue([]) + renderHome() + await waitFor(() => { + expect(screen.getByText('Exercise')).toBeInTheDocument() + }) + const calendarBtn = document.querySelector('[title="Отметить за другой день"]') + if (calendarBtn) { + fireEvent.click(calendarBtn) + await waitFor(() => { + expect(screen.getByTestId('log-habit-modal')).toBeInTheDocument() + }) + fireEvent.click(screen.getByText('Log Date')) + await waitFor(() => { + expect(habitsApi.log).toHaveBeenCalledWith(1, { date: '2026-03-01' }) + }) + } + }) }) + +// Test helper functions directly +describe('shouldShowToday helper', () => { + it('renders frozen habit indicator when habit is frozen', async () => { + const habit = { id: 1, name: 'Frozen Habit', frequency: 'daily', color: '#6366f1', icon: '❄️', is_archived: false, created_at: '2026-01-01T00:00:00Z' } + habitsApi.list.mockResolvedValue([habit]) + habitsApi.getLogs.mockResolvedValue([]) + const today = new Date().toISOString().split('T')[0] + // Create a freeze that covers today + const yesterday = new Date() + yesterday.setDate(yesterday.getDate() - 1) + const tomorrow = new Date() + tomorrow.setDate(tomorrow.getDate() + 1) + habitsApi.getFreezes.mockResolvedValue([{ + start_date: yesterday.toISOString().split('T')[0], + end_date: tomorrow.toISOString().split('T')[0] + }]) + renderHome() + await waitFor(() => { + expect(screen.getByText(/паузе/)).toBeInTheDocument() + }, { timeout: 3000 }) + }) +}) + + diff --git a/src/__tests__/Tasks.test.jsx b/src/__tests__/Tasks.test.jsx index d305933..0c57f44 100644 --- a/src/__tests__/Tasks.test.jsx +++ b/src/__tests__/Tasks.test.jsx @@ -21,8 +21,9 @@ vi.mock('../components/CreateTaskModal', () => ({ })) vi.mock('../components/EditTaskModal', () => ({ - default: ({ open, onClose }) => open ? ( + default: ({ open, onClose, task }) => open ? (
+ {task?.title}
) : null, @@ -35,8 +36,13 @@ vi.mock('../components/Navigation', () => ({ import { tasksApi } from '../api/tasks' const mockTasks = [ - { id: 1, title: 'Buy groceries', completed: false, priority: 1, due_date: null, icon: '📋', color: '#6366f1', is_recurring: false, recurrence_type: null }, - { id: 2, title: 'Read book', completed: false, priority: 0, due_date: '2026-03-30', icon: '📚', color: '#22c55e', is_recurring: false, recurrence_type: null }, + { id: 1, title: 'Buy groceries', completed: false, priority: 1, due_date: null, icon: '📋', color: '#6366f1', is_recurring: false, recurrence_type: null, description: '' }, + { id: 2, title: 'Read book', completed: false, priority: 2, due_date: '2026-03-30', icon: '📚', color: '#22c55e', is_recurring: false, recurrence_type: null, description: 'Read 30 pages' }, + { id: 3, title: 'Completed task', completed: true, priority: 3, due_date: null, icon: '✅', color: '#f59e0b', is_recurring: true, recurrence_type: 'daily', description: '' }, +] + +const mockTasksOverdue = [ + { id: 4, title: 'Overdue task', completed: false, priority: 0, due_date: '2020-01-01', icon: '⚠️', color: '#ef4444', is_recurring: false, recurrence_type: null, description: '' }, ] const renderTasks = (embedded = false) => { @@ -53,7 +59,7 @@ const renderTasks = (embedded = false) => { describe('Tasks page', () => { beforeEach(() => { vi.clearAllMocks() - tasksApi.list.mockResolvedValue(mockTasks) + tasksApi.list.mockResolvedValue(mockTasks.filter(t => !t.completed)) }) it('renders tasks list', async () => { @@ -92,27 +98,215 @@ describe('Tasks page', () => { expect(screen.getByTestId('navigation')).toBeInTheDocument() }) + it('does not render navigation when embedded', () => { + renderTasks(true) + expect(screen.queryByTestId('navigation')).not.toBeInTheDocument() + }) + it('shows empty state when no tasks', async () => { tasksApi.list.mockResolvedValue([]) renderTasks() await waitFor(() => { - // Component shows "Нет активных задач" when filter is 'active' (default) expect(screen.getByText(/Нет активных задач/)).toBeInTheDocument() }) }) + it('shows add task button in empty state for active filter', async () => { + tasksApi.list.mockResolvedValue([]) + renderTasks() + await waitFor(() => { + expect(screen.getByText('Добавить задачу')).toBeInTheDocument() + }) + }) + + it('opens create task modal', async () => { + renderTasks() + await waitFor(() => { + expect(screen.getByText('Задачи')).toBeInTheDocument() + }) + const plusBtn = document.querySelector('button.bg-primary-500') || document.querySelector('button[class*="bg-primary"]') + if (plusBtn) { + fireEvent.click(plusBtn) + expect(screen.getByTestId('create-task-modal')).toBeInTheDocument() + } + }) + + it('opens add task modal from empty state button', async () => { + tasksApi.list.mockResolvedValue([]) + renderTasks() + await waitFor(() => { + expect(screen.getByText('Добавить задачу')).toBeInTheDocument() + }) + fireEvent.click(screen.getByText('Добавить задачу')) + expect(screen.getByTestId('create-task-modal')).toBeInTheDocument() + }) + + it('switches to completed filter', async () => { + tasksApi.list.mockResolvedValue([mockTasks[2]]) + renderTasks() + await waitFor(() => { + expect(screen.getByText('Выполненные')).toBeInTheDocument() + }) + fireEvent.click(screen.getByText('Выполненные')) + await waitFor(() => { + expect(tasksApi.list).toHaveBeenCalledWith(true) + }) + }) + + it('switches to all filter', async () => { + tasksApi.list.mockResolvedValue(mockTasks) + renderTasks() + await waitFor(() => { + expect(screen.getByText('Все')).toBeInTheDocument() + }) + fireEvent.click(screen.getByText('Все')) + await waitFor(() => { + expect(tasksApi.list).toHaveBeenCalled() + }) + }) + it('completes a task on click', async () => { - tasksApi.complete.mockResolvedValueOnce({ id: 1, completed: true }) + tasksApi.complete.mockResolvedValue({ id: 1, completed: true }) + tasksApi.list.mockResolvedValue([mockTasks[0]]) renderTasks() await waitFor(() => { expect(screen.getByText('Buy groceries')).toBeInTheDocument() }) - const completeButtons = document.querySelectorAll('button[class*="rounded-full"]') - if (completeButtons.length > 0) { - fireEvent.click(completeButtons[0]) + // Get all buttons: [0]=create(+), [1]=complete-task-btn, [2]=edit-btn + const allBtns = screen.getAllByRole('button') + if (allBtns.length > 1) { + fireEvent.click(allBtns[4]) await waitFor(() => { expect(tasksApi.complete).toHaveBeenCalledWith(1) + }, { timeout: 3000 }) + } + }) + + it('shows priority badge for tasks', async () => { + tasksApi.list.mockResolvedValue([mockTasks[1]]) + renderTasks() + await waitFor(() => { + expect(screen.getByText('Read book')).toBeInTheDocument() + }) + expect(screen.getByText('Средний')).toBeInTheDocument() + }) + + it('shows high priority badge', async () => { + tasksApi.list.mockResolvedValue([mockTasks[2]]) + renderTasks() + await waitFor(() => { + expect(screen.getByText('Completed task')).toBeInTheDocument() + }) + expect(screen.getByText('Высокий')).toBeInTheDocument() + }) + + it('shows due date for tasks', async () => { + tasksApi.list.mockResolvedValue([mockTasks[1]]) + renderTasks() + await waitFor(() => { + expect(screen.getByText('Read book')).toBeInTheDocument() + }) + expect(document.querySelector('[class*="text-gray"]')).toBeTruthy() + }) + + it('shows overdue indicator', async () => { + tasksApi.list.mockResolvedValue(mockTasksOverdue) + renderTasks() + await waitFor(() => { + expect(screen.getByText('Overdue task')).toBeInTheDocument() + }) + expect(document.querySelector('svg')).toBeTruthy() + }) + + it('opens edit modal when task title clicked', async () => { + tasksApi.list.mockResolvedValue([mockTasks[0]]) + renderTasks() + await waitFor(() => { + expect(screen.getByText('Buy groceries')).toBeInTheDocument() + }) + fireEvent.click(screen.getByText('Buy groceries')) + await waitFor(() => { + expect(screen.getByTestId('edit-task-modal')).toBeInTheDocument() + }) + }) + + it('closes edit modal', async () => { + tasksApi.list.mockResolvedValue([mockTasks[0]]) + renderTasks() + await waitFor(() => { + expect(screen.getByText('Buy groceries')).toBeInTheDocument() + }) + fireEvent.click(screen.getByText('Buy groceries')) + await waitFor(() => { + expect(screen.getByTestId('edit-task-modal')).toBeInTheDocument() + }) + fireEvent.click(screen.getByText('Close')) + expect(screen.queryByTestId('edit-task-modal')).not.toBeInTheDocument() + }) + + it('shows recurring icon for recurring tasks', async () => { + tasksApi.list.mockResolvedValue([mockTasks[2]]) + renderTasks() + await waitFor(() => { + expect(screen.getByText('Completed task')).toBeInTheDocument() + }) + expect(screen.getByText('🔄')).toBeInTheDocument() + }) + + it('shows recurrence label', async () => { + tasksApi.list.mockResolvedValue([mockTasks[2]]) + renderTasks() + await waitFor(() => { + expect(screen.getByText('Ежедневно')).toBeInTheDocument() + }) + }) + + it('shows task description', async () => { + tasksApi.list.mockResolvedValue([mockTasks[1]]) + renderTasks() + await waitFor(() => { + expect(screen.getByText('Read 30 pages')).toBeInTheDocument() + }) + }) + + it('uncompletes a task', async () => { + tasksApi.uncomplete.mockResolvedValue({ id: 3, completed: false }) + const completedTask = { ...mockTasks[2], completed: true } + tasksApi.list.mockResolvedValue([completedTask]) + renderTasks() + await waitFor(() => { + expect(screen.getByText('Completed task')).toBeInTheDocument() + }) + const undoBtn = document.querySelector('[title="Отменить"]') + if (undoBtn) { + fireEvent.click(undoBtn) + await waitFor(() => { + expect(tasksApi.uncomplete).toHaveBeenCalledWith(3) }) } }) + + it('shows empty state for completed filter', async () => { + tasksApi.list.mockResolvedValue([]) + renderTasks() + await waitFor(() => { + expect(screen.getByText('Выполненные')).toBeInTheDocument() + }) + fireEvent.click(screen.getByText('Выполненные')) + await waitFor(() => { + expect(screen.getByText(/Нет выполненных задач/)).toBeInTheDocument() + }) + }) + + it('shows empty state for all filter', async () => { + tasksApi.list.mockResolvedValue([]) + renderTasks() + await waitFor(() => { + expect(screen.getByText('Все')).toBeInTheDocument() + }) + fireEvent.click(screen.getByText('Все')) + await waitFor(() => { + expect(screen.getByText(/Нет задач/)).toBeInTheDocument() + }) + }) })