├── .editorconfig ├── .eslintignore ├── .eslintrc ├── .github └── workflows │ └── release.yml ├── .gitignore ├── .npmrc ├── CHANGELOG.md ├── LICENSE ├── README.md ├── docs ├── screenshot-all-habits.png ├── screenshot-dashboard.png ├── screenshot-monthly-overview.png └── screenshot-yearly-overview.png ├── esbuild.config.mjs ├── main.css ├── manifest.json ├── package-lock.json ├── package.json ├── src ├── app │ └── App.tsx ├── assets │ └── sounds │ │ └── beep.wav ├── features │ ├── dashboard │ │ └── DashboardView.tsx │ ├── expenses │ │ └── ExpensesView.tsx │ └── habits │ │ ├── HabitsView.tsx │ │ ├── components │ │ ├── AnalyticsGrid.tsx │ │ ├── AnalyticsTabs.tsx │ │ ├── Calendar.tsx │ │ ├── EmptyState.tsx │ │ ├── Form │ │ │ ├── Modal.tsx │ │ │ ├── datePicker.tsx │ │ │ ├── icons.tsx │ │ │ └── iconsmap.tsx │ │ ├── HabitForm.tsx │ │ ├── HabitInfo.tsx │ │ ├── TaskButton.tsx │ │ ├── TaskCard.tsx │ │ ├── TaskList.tsx │ │ └── index.ts │ │ ├── controller │ │ ├── habitController.ts │ │ └── index.ts │ │ ├── hooks │ │ ├── index.ts │ │ ├── useAnalytics.ts │ │ ├── useCalendar.ts │ │ ├── useDashboard.ts │ │ └── useHabits.ts │ │ ├── store │ │ ├── actions │ │ │ ├── amountAction.ts │ │ │ ├── coreHabitAction.ts │ │ │ ├── index.ts │ │ │ ├── taskAction.ts │ │ │ └── timeAction.ts │ │ ├── fetcher │ │ │ └── fetchHabitAndStats.ts │ │ ├── helpers │ │ │ ├── index.ts │ │ │ └── recompute.ts │ │ ├── index.ts │ │ ├── slices │ │ │ ├── habitActionsSlice.ts │ │ │ ├── habitStateSlice.ts │ │ │ └── index.ts │ │ └── zustandStore.ts │ │ ├── types │ │ ├── ActionStateTypes.ts │ │ ├── analyticsTypes.ts │ │ ├── backgroundTypes.ts │ │ ├── dashboardType.ts │ │ ├── habitTypes.ts │ │ └── index.ts │ │ └── utils │ │ ├── alarm │ │ └── sound.ts │ │ ├── analytics │ │ ├── compeletion.ts │ │ ├── generateAnalyticsData.ts │ │ ├── index.ts │ │ ├── montlyData.ts │ │ ├── streak.ts │ │ └── yearlyData.ts │ │ ├── calendar │ │ ├── generateCalendar.ts │ │ ├── getCalendarFromHabits.ts │ │ └── initialDate.ts │ │ ├── dashboard │ │ └── generateDashboardData.ts │ │ ├── datetime │ │ └── dateUtils.ts │ │ ├── index.ts │ │ ├── store │ │ ├── createEntry.ts │ │ ├── isForToday.ts │ │ ├── isHabitCompeleted.ts │ │ └── storage.ts │ │ └── uiux │ │ ├── index.ts │ │ └── validDate.ts ├── input.css ├── main.ts └── views │ └── ZenboardView.tsx ├── tailwind.config.js ├── tsconfig.json ├── version-bump.mjs └── versions.json /.editorconfig: -------------------------------------------------------------------------------- 1 | # top-most EditorConfig file 2 | root = true 3 | 4 | [*] 5 | charset = utf-8 6 | end_of_line = lf 7 | insert_final_newline = true 8 | indent_style = tab 9 | indent_size = 4 10 | tab_width = 4 11 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | 3 | main.js 4 | -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "root": true, 3 | "parser": "@typescript-eslint/parser", 4 | "env": { "node": true }, 5 | "plugins": [ 6 | "@typescript-eslint" 7 | ], 8 | "extends": [ 9 | "eslint:recommended", 10 | "plugin:@typescript-eslint/eslint-recommended", 11 | "plugin:@typescript-eslint/recommended" 12 | ], 13 | "parserOptions": { 14 | "sourceType": "module" 15 | }, 16 | "rules": { 17 | "no-unused-vars": "off", 18 | "@typescript-eslint/no-unused-vars": ["error", { "args": "none" }], 19 | "@typescript-eslint/ban-ts-comment": "off", 20 | "no-prototype-builtins": "off", 21 | "@typescript-eslint/no-empty-function": "off" 22 | } 23 | } -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release Obsidian Plugin 2 | on: 3 | push: 4 | tags: 5 | - "*" 6 | jobs: 7 | release: 8 | runs-on: ubuntu-latest 9 | steps: 10 | - uses: actions/checkout@v4 11 | - name: Setup Node 12 | uses: actions/setup-node@v4 13 | with: 14 | node-version: "20" 15 | - name: Install dependencies 16 | run: npm install 17 | - name: Build plugin 18 | run: npm run build 19 | - name: Create release package 20 | run: | 21 | # Verify required files exist 22 | [ -f main.js ] || { echo "main.js not found"; exit 1; } 23 | [ -f manifest.json ] || { echo "manifest.json not found"; exit 1; } 24 | [ -f styles.css ] || { echo "styles.css not found"; exit 1; } 25 | 26 | # Create directory and copy files 27 | PLUGIN_NAME="${{ github.event.repository.name }}" 28 | mkdir -p "${PLUGIN_NAME}" 29 | cp main.js manifest.json styles.css "${PLUGIN_NAME}/" 30 | 31 | # Create zip file 32 | zip -r "${PLUGIN_NAME}.zip" "${PLUGIN_NAME}" 33 | 34 | # Verify zip was created 35 | [ -f "${PLUGIN_NAME}.zip" ] || { echo "Zip file creation failed"; exit 1; } 36 | 37 | # List files for debugging 38 | echo "Files created:" 39 | ls -la *.zip 40 | - name: Create Release 41 | uses: softprops/action-gh-release@v2 42 | with: 43 | files: | 44 | main.js 45 | manifest.json 46 | styles.css 47 | ${{ github.event.repository.name }}.zip 48 | generate_release_notes: true 49 | env: 50 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 51 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # vscode 2 | .vscode 3 | 4 | # Intellij 5 | *.iml 6 | .idea 7 | 8 | # npm 9 | node_modules 10 | 11 | # Don't include the compiled main.js file in the repo. 12 | # They should be uploaded to GitHub releases instead. 13 | main.js 14 | 15 | # Exclude sourcemaps 16 | *.map 17 | 18 | # obsidian 19 | data.json 20 | 21 | # Exclude macOS Finder (System Explorer) View States 22 | .DS_Store 23 | 24 | styles.css 25 | -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | tag-version-prefix="" -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | 2 | # Changelog 3 | 4 | ## [1.0.0] - 2025-08-15 5 | ### Added 6 | - Initial release of **Zenboard** habit tracker plugin for Obsidian. 7 | - Track habits with daily, weekly, or custom intervals. 8 | - Visual dashboard with charts and streak counters. 9 | - Quick logging from ribbon icon, dashboard, or command palette. 10 | - Vault-based storage for all habit data. 11 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2025 Shad Abdullah 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. 22 | 23 | 24 | ## License 25 | [MIT](LICENSE) © 2025 Shad Abdullah 26 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Habit Tracker – Zenboard 2 | 3 | Track, visualize, and maintain your daily habits directly in Obsidian. Build streaks, stay consistent, and keep everything in your vault. 4 | 5 | ## Features 6 | 7 | - 📅 **Flexible habit types** – daily, weekly, or custom intervals 8 | - 📊 **Progress tracking** – streak counters and visual charts 9 | - ⚡ **Quick logging** – via ribbon icon, dashboard, or command palette 10 | - 🗂 **Vault-first storage** – all data is saved locally in your vault 11 | - 🌗 **Obsidian theme support** 12 | 13 | ## How to Use 14 | 15 | 1. Install and enable **Habit Tracker – Zenboard** from 16 | **Settings → Community plugins → Browse**. 17 | 2. Open the dashboard via the ribbon icon or: 18 | - Command Palette → `Open zenboard` 19 | 3. Add your first habit with **+ Add Habit**. 20 | 4. Log progress daily and watch your streak grow. 21 | 22 | > **Tip:** You can pin the dashboard as a side pane for quick access. 23 | 24 | ## Screenshots 25 | 26 | ### Dashboard 27 | ![Zenboard Dashboard](docs/screenshot-dashboard.png) 28 | 29 | ### Monthly Analytics 30 | ![Zenboard Monthly Overview](docs/screenshot-monthly-overview.png) 31 | 32 | ### Yearly Analytics 33 | ![Zenboard Yearly Overview](docs/screenshot-yearly-overview.png) 34 | 35 | ### All Habits View 36 | ![Zenboard All Habits ](docs/screenshot-all-habits.png) 37 | 38 | 39 | 40 | ## For Developers 41 | 42 | If you want to contribute or customize the plugin: 43 | 44 | ```bash 45 | # Clone the repository into your vault’s plugins folder 46 | git clone https://github.com/shadabdullah/obsidian-zenboard .obsidian/plugins/zenboard 47 | 48 | cd .obsidian/plugins/zenboard 49 | npm install 50 | 51 | # Development: watch JS + Tailwind CSS for live updates 52 | npm run dev:watch 53 | 54 | # Build once for production (generates JS + CSS) 55 | npm run build 56 | 57 | # Clean build: remove old files and rebuild everything 58 | npm run clean-build 59 | -------------------------------------------------------------------------------- /docs/screenshot-all-habits.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Shadabdullah/obsidian-zenboard/854d73fa41fe0a8cbfa00e6aef6a735bf4d2db6e/docs/screenshot-all-habits.png -------------------------------------------------------------------------------- /docs/screenshot-dashboard.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Shadabdullah/obsidian-zenboard/854d73fa41fe0a8cbfa00e6aef6a735bf4d2db6e/docs/screenshot-dashboard.png -------------------------------------------------------------------------------- /docs/screenshot-monthly-overview.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Shadabdullah/obsidian-zenboard/854d73fa41fe0a8cbfa00e6aef6a735bf4d2db6e/docs/screenshot-monthly-overview.png -------------------------------------------------------------------------------- /docs/screenshot-yearly-overview.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Shadabdullah/obsidian-zenboard/854d73fa41fe0a8cbfa00e6aef6a735bf4d2db6e/docs/screenshot-yearly-overview.png -------------------------------------------------------------------------------- /esbuild.config.mjs: -------------------------------------------------------------------------------- 1 | import esbuild from "esbuild"; 2 | import process from "process"; 3 | import builtins from "builtin-modules"; 4 | 5 | const banner = 6 | `/* 7 | THIS IS A GENERATED/BUNDLED FILE BY ESBUILD 8 | if you want to view the source, please visit the github repository of this plugin 9 | */ 10 | `; 11 | 12 | const prod = (process.argv[2] === "production"); 13 | 14 | const context = await esbuild.context({ 15 | banner: { 16 | js: banner, 17 | }, 18 | entryPoints: ["src/main.ts"], 19 | bundle: true, 20 | external: [ 21 | "obsidian", 22 | "electron", 23 | "@codemirror/autocomplete", 24 | "@codemirror/collab", 25 | "@codemirror/commands", 26 | "@codemirror/language", 27 | "@codemirror/lint", 28 | "@codemirror/search", 29 | "@codemirror/state", 30 | "@codemirror/view", 31 | "@lezer/common", 32 | "@lezer/highlight", 33 | "@lezer/lr", 34 | ...builtins], 35 | jsx: "automatic", 36 | jsxImportSource: "react", 37 | format: "cjs", 38 | target: "es2018", 39 | logLevel: "info", 40 | sourcemap: prod ? false : "inline", 41 | treeShaking: true, 42 | outfile: "main.js", 43 | minify: prod, 44 | }); 45 | 46 | if (prod) { 47 | await context.rebuild(); 48 | process.exit(0); 49 | } else { 50 | await context.watch(); 51 | } 52 | -------------------------------------------------------------------------------- /manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "id": "zenboard", 3 | "name": "ZenBoard", 4 | "version": "1.0.0", 5 | "minAppVersion": "1.6.5", 6 | "description": "A simple habit tracker to help you build and maintain daily routines.", 7 | "author": "Shad Abdullah", 8 | "authorUrl": "https://github.com/shadabdullah/zenboard", 9 | "isDesktopOnly": true 10 | } 11 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "zenboard-plugin", 3 | "version": "1.0.0", 4 | "description": "🧠 ZenBoard – A powerful habit planner plugin for Obsidian", 5 | "main": "main.js", 6 | "scripts": { 7 | "tw": "tailwindcss -i ./src/input.css -o ./styles.css", 8 | "dev": "node esbuild.config.mjs", 9 | "dev:watch": "concurrently \"npm run tw -- --watch\" \"npm run dev\"", 10 | "build": "npm run tw && tsc --noEmit --skipLibCheck && node esbuild.config.mjs production", 11 | "clean-build": "rm -f main.js styles.css && npm run build", 12 | "version": "node version-bump.mjs && git add manifest.json versions.json" 13 | }, 14 | "keywords": [ 15 | "obsidian", 16 | "plugin", 17 | "productivity", 18 | "tasks", 19 | "calendar", 20 | "habits", 21 | "react" 22 | ], 23 | "author": "ShadAbdullah", 24 | "license": "MIT", 25 | "devDependencies": { 26 | "@types/node": "^16.11.6", 27 | "@types/react": "^18.3.23", 28 | "@types/react-dom": "^18.3.7", 29 | "@types/styled-jsx": "^2.2.9", 30 | "@typescript-eslint/eslint-plugin": "5.29.0", 31 | "@typescript-eslint/parser": "5.29.0", 32 | "builtin-modules": "3.3.0", 33 | "concurrently": "^9.2.0", 34 | "esbuild": "^0.17.3", 35 | "obsidian": "latest", 36 | "tailwindcss": "^3.4.1", 37 | "tslib": "2.4.0", 38 | "typescript": "4.7.4" 39 | }, 40 | "dependencies": { 41 | "clsx": "^2.1.1", 42 | "framer-motion": "^12.23.12", 43 | "lucide-react": "^0.525.0", 44 | "react": "^18.3.1", 45 | "react-dom": "^18.3.1", 46 | "styled-jsx": "^5.1.7", 47 | "zustand": "^5.0.7" 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /src/app/App.tsx: -------------------------------------------------------------------------------- 1 | import DashboardView from "../features/dashboard/DashboardView"; 2 | 3 | const userSettings = { 4 | habitTracker: true, 5 | expenseManager: true, 6 | }; 7 | 8 | export default function App() { 9 | return ( 10 |
11 | 12 |
13 | ); 14 | } 15 | 16 | 17 | -------------------------------------------------------------------------------- /src/assets/sounds/beep.wav: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Shadabdullah/obsidian-zenboard/854d73fa41fe0a8cbfa00e6aef6a735bf4d2db6e/src/assets/sounds/beep.wav -------------------------------------------------------------------------------- /src/features/dashboard/DashboardView.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState, useEffect } from "react"; 2 | import { 3 | CheckCircle, 4 | Wallet, 5 | Settings 6 | } from "lucide-react"; 7 | import ExpensesView from "../expenses/ExpensesView"; 8 | import HabitsView from "../habits/HabitsView"; 9 | 10 | type EnabledFeatures = { 11 | habitTracker: boolean; 12 | expenseManager: boolean; 13 | }; 14 | 15 | type Props = { 16 | enabledFeatures: EnabledFeatures; 17 | }; 18 | 19 | export default function DashboardView({ enabledFeatures }: Props) { 20 | const [currentTime, setCurrentTime] = useState(new Date()); 21 | const [activeTab, setActiveTab] = useState(""); 22 | 23 | useEffect(() => { 24 | const timer = setInterval(() => setCurrentTime(new Date()), 1000); 25 | return () => clearInterval(timer); 26 | }, []); 27 | 28 | useEffect(() => { 29 | // Set first available tab as active 30 | if (enabledFeatures.habitTracker) setActiveTab("habits"); 31 | else if (enabledFeatures.expenseManager) setActiveTab("expenses"); 32 | }, [enabledFeatures]); 33 | 34 | const tabs = [ 35 | { 36 | id: "habits", 37 | label: "Habit Tracker", 38 | enabled: enabledFeatures.habitTracker, 39 | icon: CheckCircle, 40 | }, 41 | { 42 | id: "expenses", 43 | label: "Expense Manager", 44 | enabled: enabledFeatures.expenseManager, 45 | icon: Wallet, 46 | }, 47 | ]; 48 | 49 | const renderTabContent = () => { 50 | switch (activeTab) { 51 | case "habits": 52 | return ; 53 | case "expenses": 54 | return ; 55 | default: 56 | return
No content available.
; 57 | } 58 | }; 59 | 60 | return ( 61 |
62 | {/* Header */} 63 |
64 |
65 |
66 |

67 | Zenboard 68 |

69 |
70 | {/*
*/} 71 | {/* */} 75 | {/*
*/} 76 |
77 |
78 | 79 | {/* Tab Navigation */} 80 | 104 | 105 | {/* Main Content */} 106 |
107 |
108 | {renderTabContent()} 109 |
110 |
111 |
112 | ); 113 | } 114 | -------------------------------------------------------------------------------- /src/features/expenses/ExpensesView.tsx: -------------------------------------------------------------------------------- 1 | import { DollarSign, Clock, Sparkles } from 'lucide-react'; 2 | 3 | const ExpensesView = () => { 4 | return ( 5 |
6 | {/* Background decoration */} 7 |
8 |
9 | 10 |
11 | 12 | {/* Main content */} 13 |
14 |
15 | 16 |
17 |

Expense Manager

18 |

Track spending, manage your budget

19 |
20 | 21 | Coming Soon 22 |
23 |
24 |
25 | ); 26 | }; 27 | 28 | export default ExpensesView; 29 | -------------------------------------------------------------------------------- /src/features/habits/HabitsView.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState } from "react"; 2 | import { 3 | Flame, 4 | Plus, 5 | BarChart3, 6 | ArrowLeft, 7 | Grid, 8 | } from "lucide-react"; 9 | import { useAnalytics, useCalendar } from "@habits/hooks"; 10 | import { 11 | TaskCard, 12 | HabitInfoCard, 13 | Calendar, 14 | AnalyticsGrid, 15 | HabitForm, 16 | EmptyState, 17 | } from "@habits/components"; 18 | 19 | import { getTodayDate } from "@habits/utils"; 20 | 21 | // Below here import one by one 22 | import { useHabits, useDashboard } from "@habits/hooks"; 23 | 24 | const HabitView: React.FC = () => { 25 | 26 | const [currentView, setCurrentView] = useState< 27 | "habits" | "analytics" | "all-habits" 28 | >("habits"); 29 | const [analyticsTab, setAnalyticsTab] = useState<"monthly" | "overall">( 30 | "monthly", 31 | ); 32 | const [isModalOpen, setIsModalOpen] = useState(false); 33 | 34 | const { loading, completedHabits, pendingHabits, allHabits, allStats } = 35 | useHabits(); 36 | const { analyticsData } = useAnalytics(); 37 | 38 | const { dashboardData } = useDashboard(); 39 | 40 | const { 41 | calendarData, 42 | selectedDate, 43 | scrollContainerRef, 44 | scrollCalendar, 45 | selectDate, 46 | scrollToToday, 47 | scrollToStart, 48 | scrollToEnd, 49 | } = useCalendar(); 50 | 51 | if (loading) return
Loading...
; 52 | 53 | return ( 54 |
55 | {/* Status Bar Spacer */} 56 |
57 | 58 |
59 | {/* Header */} 60 |
61 |
62 |
63 |
64 | {(currentView === "analytics" || 65 | currentView === "all-habits") && ( 66 | 72 | )} 73 |
74 |
75 |

76 | ZenBoard 77 |

78 | {/* App Icon Badge */} 79 |
80 | Z 81 |
82 |
83 |

84 | {currentView === "analytics" 85 | ? "Track your progress" 86 | : currentView === "all-habits" 87 | ? "Manage all your habits" 88 | : `${selectedDate.toLocaleDateString("en-US", { 89 | weekday: "long", 90 | month: "long", 91 | day: "numeric", 92 | year: "numeric", 93 | })}`} 94 |

95 |
96 |
97 |
98 | 99 | 100 | {/* Streak Indicator */} 101 |
102 |
103 | 104 |
105 |
106 | Streak 107 |
108 |
109 | {dashboardData?.maxStreak ?? "—"} 110 |
111 |
112 |
113 |
114 | 115 | {/* Analytics Toggle */} 116 | 129 |
130 |
131 |
132 |
133 | 134 | {/* Content */} 135 | {currentView === "habits" ? ( 136 | <> 137 | {/* Calendar */} 138 |
139 | 149 |
150 | 151 | {/* Action Buttons */} 152 |
153 |
154 | 161 | 162 | 169 |
170 |
171 | 172 | {/* Habit Form Modal */} 173 | setIsModalOpen(false)} 176 | /> 177 | 178 | {/* Tasks Grid */} 179 |
180 | {/* Active Tasks */} 181 |
182 |
183 |

184 | Active 185 |

186 |
187 | {pendingHabits.length} 188 |
189 |
190 | 191 |
192 | {pendingHabits.length > 0 ? ( 193 | pendingHabits.map((task) => { 194 | const progress = 195 | allStats?.[task.id]?.[getTodayDate()]?.v || 0; 196 | return ( 197 | 203 | ); 204 | }) 205 | ) : ( 206 | 207 | )} 208 |
209 |
210 | 211 | {/* Completed Tasks */} 212 |
213 |
214 |

215 | Completed 216 |

217 |
218 | {completedHabits.length} 219 |
220 |
221 | 222 |
223 | {completedHabits.length > 0 ? ( 224 |
225 | {completedHabits.map((task) => ( 226 | 232 | ))} 233 |
234 | ) : ( 235 | 236 | )} 237 |
238 |
239 |
240 | 241 | ) : currentView === "all-habits" ? ( 242 | /* All Habits View */ 243 |
244 | {/* Header */} 245 |
246 |
247 |

248 | All Habits 249 |

250 |

251 | Manage and view details of all your habits 252 |

253 |
254 |
255 | {allHabits?.length || 0} habits 256 |
257 |
258 | 259 | {/* All Habits Grid */} 260 |
261 | {allHabits && allHabits.length > 0 ? ( 262 |
263 | {allHabits.map((habit) => ( 264 | 265 | ))} 266 |
267 | ) : ( 268 | { 271 | setCurrentView("habits"); 272 | setIsModalOpen(true); 273 | }} 274 | /> 275 | )} 276 |
277 |
278 | ) : ( 279 | /* Analytics View */ 280 |
281 | {/* Tab Selector */} 282 |
283 | 292 | 301 |
302 | 303 | {/* Analytics Content */} 304 |
305 | 310 |
311 |
312 | )} 313 |
314 |
315 | ); 316 | }; 317 | 318 | export default HabitView; 319 | -------------------------------------------------------------------------------- /src/features/habits/components/AnalyticsGrid.tsx: -------------------------------------------------------------------------------- 1 | import React, { useMemo, useCallback } from "react"; 2 | import { AnalyticsData, ProcessedHabitData, StoredHabit } from "@habits/types"; 3 | import HabitIcon from "./Form/iconsmap"; 4 | import { Calendar } from "lucide-react"; 5 | import { EmptyState } from "@habits/components"; 6 | 7 | interface AnalyticsGridProps { 8 | habits: StoredHabit[]; 9 | analyticsData: AnalyticsData | null; 10 | viewType: "monthly" | "overall"; 11 | } 12 | 13 | const AnalyticsGrid: React.FC = ({ 14 | habits, 15 | analyticsData, 16 | viewType, 17 | }) => { 18 | 19 | const getMonthName = useCallback((monthIndex: number) => { 20 | const months = [ 21 | "Jan", "Feb", "Mar", "Apr", "May", "Jun", 22 | "Jul", "Aug", "Sep", "Oct", "Nov", "Dec", 23 | ]; 24 | return months[monthIndex] || "Unknown"; 25 | }, []); 26 | 27 | 28 | 29 | 30 | // Memoize date calculations to avoid recalculating on every render 31 | const dateInfo = useMemo(() => { 32 | const currentDate = new Date(); 33 | const year = currentDate.getFullYear(); 34 | const month = currentDate.getMonth(); 35 | const monthName = getMonthName(month); 36 | const daysInMonth = new Date(year, month + 1, 0).getDate(); 37 | const firstDayOfMonth = new Date(year, month, 1).getDay(); 38 | 39 | return { year, month, monthName, daysInMonth, firstDayOfMonth }; 40 | }, []); // Empty dependency array since we want current date info 41 | 42 | const getProgressRing = useCallback((percentage: number, habit: StoredHabit) => { 43 | const circumference = 2 * Math.PI * 16; 44 | const strokeDasharray = `${(percentage / 100) * circumference} ${circumference}`; 45 | 46 | return ( 47 |
48 | 49 | 56 | 66 | 67 |
68 | 69 | {percentage}% 70 | 71 |
72 |
73 | ); 74 | }, []); 75 | 76 | // Memoize calendar generation to avoid recalculating 77 | const generateCalendarDays = useCallback((monthlyData: number[][], daysInMonth: number, firstDayOfMonth: number) => { 78 | const calendarDays = []; 79 | 80 | // Add empty cells for days before month starts 81 | for (let i = 0; i < firstDayOfMonth; i++) { 82 | calendarDays.push(null); 83 | } 84 | 85 | // Add actual days of the month using the 2D array structure 86 | let dayCounter = 0; 87 | for (let week = 0; week < monthlyData.length; week++) { 88 | for (let day = 0; day < monthlyData[week].length; day++) { 89 | if (monthlyData[week][day] !== -1) { 90 | calendarDays.push(monthlyData[week][day]); 91 | dayCounter++; 92 | if (dayCounter >= daysInMonth) break; 93 | } 94 | } 95 | if (dayCounter >= daysInMonth) break; 96 | } 97 | 98 | // Fill remaining days if needed 99 | while (calendarDays.length < firstDayOfMonth + daysInMonth) { 100 | calendarDays.push(0); 101 | } 102 | 103 | return calendarDays; 104 | }, []); 105 | 106 | const renderMonthlyHeatmap = useCallback((processedData: ProcessedHabitData) => { 107 | const { 108 | habit, 109 | dataPeriod, 110 | completedDays, 111 | totalDays, 112 | percentage, 113 | currentStreak, 114 | longestStreak, 115 | } = processedData; 116 | 117 | // Get the monthly data from dataPeriod 118 | const monthlyData = "monthlyData" in dataPeriod ? dataPeriod.monthlyData : []; 119 | 120 | 121 | const { year, month, monthName, daysInMonth, firstDayOfMonth } = dateInfo; 122 | 123 | // Use memoized calendar generation 124 | const calendarDays = generateCalendarDays(monthlyData, daysInMonth, firstDayOfMonth); 125 | 126 | 127 | return ( 128 | <> 129 | {/* Header section */} 130 |
131 |
135 | 136 | 137 | 138 |
139 | 140 |
141 |

142 | {habit.name} 143 |

144 | 145 |
146 | 147 | 151 | 152 | 153 | {monthName} {year} - {completedDays} of {totalDays}{" "} 154 | {totalDays === 1 ? "day" : "days"} 155 | 156 |
157 |
158 | 159 |
160 | {getProgressRing(percentage, habit)} 161 |
162 |
163 | 164 | {/* Monthly calendar heatmap */} 165 |
166 |
167 | {/* Day labels */} 168 |
169 | {["S", "M", "T", "W", "T", "F", "S"].map((day, index) => ( 170 |
174 | {day} 175 |
176 | ))} 177 |
178 | 179 | {/* Calendar grid */} 180 |
181 | {calendarDays.map((day, index) => { 182 | const dayNumber = 183 | day !== null && index >= firstDayOfMonth 184 | ? index - firstDayOfMonth + 1 185 | : null; 186 | 187 | return ( 188 |
222 | {dayNumber && {dayNumber}} 223 |
224 | ); 225 | })} 226 |
227 |
228 |
229 | 230 | {/* Stats footer */} 231 |
232 | {/* Enhanced Streak Section */} 233 |
234 |
241 |
242 |
246 |
247 | 251 | {currentStreak} 252 | 253 | 254 | days 255 | 256 |
257 |
258 |
259 | 260 | Current 261 | 262 |
263 | 264 | {/* Tooltip */} 265 |
266 |
267 | Active streak 268 |
269 |
270 |
271 | 272 | {/* Max Streak */} 273 |
274 |
275 |
276 |
277 |
278 |
279 |
280 | 281 | {longestStreak} 282 | 283 | 284 | days 285 | 286 |
287 |
288 |
289 | 290 | Best 291 | 292 |
293 | 294 | {/* Tooltip */} 295 |
296 |
297 | Personal record 298 |
299 |
300 |
301 |
302 | 303 | ); 304 | }, [dateInfo, getProgressRing, generateCalendarDays]); 305 | 306 | const renderYearlyHeatmap = useCallback((processedData: ProcessedHabitData) => { 307 | const { 308 | habit, 309 | dataPeriod, 310 | completedDays, 311 | totalDays, 312 | percentage, 313 | currentStreak, 314 | longestStreak, 315 | } = processedData; 316 | 317 | // Get the overall data from dataPeriod 318 | const overallData = "overallData" in dataPeriod ? dataPeriod.overallData : []; 319 | const { year, month, monthName } = dateInfo; 320 | 321 | return ( 322 | <> 323 | {/* Header section - Improved layout for better name visibility */} 324 |
325 | {/* Top row - Habit name with icon */} 326 |
327 | {/* Habit Icon */} 328 |
332 | 333 | 334 | 335 |
336 | 337 | {/* Habit Name - Now has more space */} 338 |
339 |

340 | {habit.name} 341 |

342 |
343 | 344 | {/* Progress Ring - Moved to top right */} 345 |
346 |
347 | 351 | {/* Background circle */} 352 | 359 | {/* Progress circle */} 360 | 373 | 374 |
375 | 379 | {percentage}% 380 | 381 |
382 |
383 | 384 | {/* Progress ring glow effect */} 385 |
389 |
390 |
391 | 392 | {/* Bottom row - Streak information */} 393 |
394 | {/* Year and completion stats */} 395 |
396 | 397 | 401 | 402 | 403 | 404 | 405 | {monthName} {year} - {completedDays} of {totalDays}{" "} 406 | {totalDays === 1 ? "day" : "days"} 407 | 408 |
409 | 410 | {/* Enhanced Streak Section */} 411 |
412 | {/* Current Streak */} 413 |
414 |
421 |
422 |
426 |
427 | 431 | {currentStreak} 432 | 433 | 434 | days 435 | 436 |
437 |
438 |
439 | 440 | Current 441 | 442 |
443 | 444 | {/* Tooltip */} 445 |
446 |
447 | Active streak 448 |
449 |
450 |
451 | 452 | {/* Max Streak */} 453 |
454 |
455 |
456 |
457 |
458 |
459 |
460 | 461 | {longestStreak} 462 | 463 | 464 | days 465 | 466 |
467 |
468 |
469 | 470 | Best 471 | 472 |
473 | 474 | {/* Tooltip */} 475 |
476 |
477 | Personal record 478 |
479 |
480 |
481 |
482 |
483 |
484 | 485 | {/* Compact yearly heatmap - single continuous grid */} 486 |
487 |
488 | {/* Main grid - using flexbox to create compact rows */} 489 |
490 | {overallData.map((day, dayIndex) => ( 491 |
522 | ))} 523 |
524 | 525 | {/* Legend */} 526 |
527 |
528 |
529 | Incomplete 530 |
531 | 532 |
533 |
537 | Completed 538 |
539 | 540 |
541 |
545 | Skipped 546 |
547 | 548 |
549 |
550 | Skipped 551 |
552 |
553 |
554 |
555 | 556 |
557 |
564 |
565 | 566 | ); 567 | }, [dateInfo, getProgressRing]); 568 | 569 | // Memoize the hasHabitsWithData calculation 570 | const hasHabitsWithData = useMemo(() => { 571 | return Array.isArray(habits) && habits.some((habit) => { 572 | if (!habit || typeof habit.id === "undefined") return false; 573 | const processedData = analyticsData?.[habit.id]?.[viewType] ?? []; 574 | return processedData !== undefined && processedData !== null; 575 | }); 576 | }, [habits, analyticsData, viewType]); 577 | 578 | // If no habits or no valid data, show placeholder 579 | if (!Array.isArray(habits) || habits.length === 0 || !hasHabitsWithData) { 580 | return ; 581 | } 582 | 583 | return ( 584 |
590 | {habits?.map((habit) => { 591 | const processedData: ProcessedHabitData | null = 592 | analyticsData?.[habit.id]?.[viewType] ?? null; 593 | if (!processedData) return null; 594 | 595 | return ( 596 |
604 | {/* Subtle gradient overlay */} 605 |
606 | 607 | {viewType === "monthly" 608 | ? renderMonthlyHeatmap(processedData) 609 | : renderYearlyHeatmap(processedData)} 610 | 611 | {/* iOS-style card indicator */} 612 |
613 |
614 | ); 615 | })} 616 |
617 | ); 618 | }; 619 | 620 | export default AnalyticsGrid; 621 | -------------------------------------------------------------------------------- /src/features/habits/components/AnalyticsTabs.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | interface AnalyticsTabsProps { 4 | activeTab: 'monthly' | 'overall'; 5 | onTabChange: (tab: 'monthly' | 'overall') => void; 6 | } 7 | 8 | const AnalyticsTabs: React.FC = ({ activeTab, onTabChange }) => { 9 | return ( 10 |
11 | 21 | 31 |
32 | ); 33 | }; 34 | 35 | export default AnalyticsTabs; 36 | -------------------------------------------------------------------------------- /src/features/habits/components/Calendar.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { ChevronLeft, ChevronRight } from "lucide-react"; 3 | import { DayData } from "@habits/types"; 4 | 5 | interface CalendarProps { 6 | calendarData: DayData[]; 7 | selectedDate: Date; 8 | onSelectDate: (date: Date) => void; 9 | onScroll: (direction: "left" | "right") => void; 10 | scrollToToday: () => void; 11 | scrollToStart: () => void; 12 | scrollToEnd: () => void; 13 | scrollRef: React.RefObject; 14 | } 15 | 16 | const Calendar: React.FC = ({ 17 | calendarData, 18 | selectedDate, 19 | onSelectDate, 20 | onScroll, 21 | scrollRef, 22 | scrollToStart, 23 | scrollToEnd, 24 | scrollToToday, 25 | }) => { 26 | const calculateProgress = (total: number, completed: number) => { 27 | if (total === 0) return 0; 28 | return Math.round((completed / total) * 100); 29 | }; 30 | 31 | return ( 32 |
33 | {/* Header section */} 34 |
35 |

36 | Calendar 37 |

38 | 39 | {/* Navigation controls - iOS style */} 40 |
41 | 47 | 53 | 59 | 65 | 71 |
72 |
73 | 74 | {/* Calendar container */} 75 |
76 | {/* Subtle fade edges */} 77 |
79 |
81 | 82 |
} 84 | className="flex gap-4-3 pb-4 px-2 scrollbar-hide relative" 85 | style={{ 86 | overflowX: "auto", 87 | overflowY: "visible", 88 | position: "relative", 89 | scrollbarWidth: "none", 90 | msOverflowStyle: "none", 91 | WebkitOverflowScrolling: "touch", 92 | }} 93 | > 94 | {calendarData.map((day, index) => { 95 | const isSelected = 96 | selectedDate.toDateString() === day.date.toDateString(); 97 | const isToday = day.isToday; 98 | const progress = calculateProgress( 99 | day.taskCount, 100 | day.completedTasks, 101 | ); 102 | const isComplete = progress === 100; 103 | 104 | const dayName = day.date 105 | .toLocaleDateString("en-US", { weekday: "short" }) 106 | .toUpperCase(); 107 | 108 | // Check if day is in the future 109 | const isFuture = day.date > new Date(); 110 | const isPast = day.date < new Date() && !isToday; 111 | 112 | // iOS-style minimal design with Obsidian theme support 113 | const getCardStyles = () => { 114 | if (isComplete) { 115 | return "bg-blue-500 border border-blue-400 shadow-lg"; 116 | } 117 | if (isSelected) { 118 | return "bg-blue-50 border border-blue-200 shadow-md"; 119 | } 120 | if (isToday) { 121 | return "bg-secondary border-default shadow-md"; 122 | } 123 | return "bg-primary border-default shadow-sm"; 124 | }; 125 | 126 | const getTextColor = () => { 127 | if (isComplete) { 128 | return "text-white"; 129 | } 130 | if (isSelected) { 131 | return "text-blue-600"; 132 | } 133 | if (isToday) { 134 | return "text-default"; 135 | } 136 | if (isFuture) { 137 | return "text-faint"; 138 | } 139 | return "text-muted"; 140 | }; 141 | 142 | const getDateColor = () => { 143 | if (isComplete) { 144 | return "text-white"; 145 | } 146 | if (isSelected) { 147 | return "text-blue-600"; 148 | } 149 | if (isToday) { 150 | return "text-default"; 151 | } 152 | if (isFuture) { 153 | return "text-faint"; 154 | } 155 | return "text-default"; 156 | }; 157 | 158 | const getButtonBg = () => { 159 | if (isComplete) { 160 | return "bg-white/20"; 161 | } 162 | if (isSelected) { 163 | return "bg-primary"; 164 | } 165 | if (isToday) { 166 | return "bg-primary"; 167 | } 168 | return "bg-secondary"; 169 | }; 170 | 171 | return ( 172 |
173 |
181 | {/* Day name */} 182 |
185 | {dayName} 186 |
187 | 188 | {/* Date with progress ring */} 189 | 304 | 305 | {/* No progress text needed anymore since we show it in the circle */} 306 |
307 |
308 | ); 309 | })} 310 |
311 |
312 | 313 | {/* Simple scroll indicator */} 314 |
315 |
316 |
318 |
319 |
320 |
321 | ); 322 | }; 323 | 324 | export default Calendar; 325 | -------------------------------------------------------------------------------- /src/features/habits/components/EmptyState.tsx: -------------------------------------------------------------------------------- 1 | import { CheckCircle, Circle, BarChart, Grid, Plus } from "lucide-react"; 2 | import { motion } from "framer-motion"; 3 | import clsx from "clsx"; 4 | 5 | type EmptyStateProps = { 6 | type: "active" | "completed" | "all" | "stats"; 7 | onActionClick?: () => void; 8 | }; 9 | 10 | const EmptyState = ({ type, onActionClick }: EmptyStateProps) => { 11 | const config = { 12 | active: { 13 | icon: , 14 | title: "No habits for today. See you tomorrow!", 15 | description: "Add habits to start tracking your progress.", 16 | color: "blue", 17 | }, 18 | completed: { 19 | icon: , 20 | title: "Nothing completed yet", 21 | description: "Complete tasks to see them here", 22 | color: "green", 23 | }, 24 | all: { 25 | icon: , 26 | title: "No habits yet", 27 | description: "Start building your routine by adding your first habit", 28 | color: "blue", 29 | }, 30 | stats: { 31 | icon: , 32 | title: "No Stats Available", 33 | description: "Add habits and make some progress to see here", 34 | color: "blue", 35 | }, 36 | }; 37 | 38 | const current = config[type]; 39 | 40 | return ( 41 | 47 | 53 | {current.icon} 54 | 55 | 56 | 61 |

62 | {current.title} 63 |

64 | 65 |

66 | {current.description} 67 |

68 | 69 | {type === "all" && ( 70 | 83 | 84 | Add First Habit 85 | 86 | )} 87 |
88 |
89 | ); 90 | }; 91 | 92 | export default EmptyState; 93 | -------------------------------------------------------------------------------- /src/features/habits/components/Form/Modal.tsx: -------------------------------------------------------------------------------- 1 | interface ModalProps { 2 | title: string; 3 | description: string; 4 | onClose: () => void; 5 | } 6 | 7 | const Modal: React.FC = ({ title, description, onClose }) => ( 8 |
9 |
10 |

11 | {title} 12 |

13 |

14 | {description} 15 |

16 |
17 | 23 |
24 |
25 |
26 | ); 27 | 28 | export default Modal; 29 | -------------------------------------------------------------------------------- /src/features/habits/components/Form/datePicker.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState, useRef } from "react"; 2 | import { Calendar } from "lucide-react"; 3 | 4 | interface DatePickerProps { 5 | startDate?: Date; 6 | endDate?: Date | null; 7 | onStartDateChange: (date: Date) => void; 8 | onEndDateChange: (date: Date | null) => void; 9 | } 10 | 11 | const DatePicker: React.FC = ({ 12 | startDate = new Date(), 13 | endDate = null, 14 | onStartDateChange, 15 | onEndDateChange, 16 | }) => { 17 | const [isOpen, setIsOpen] = useState(false); 18 | const [tempStartDate, setTempStartDate] = useState(startDate); 19 | const [tempEndDate, setTempEndDate] = useState(endDate); 20 | const [hasEndDate, setHasEndDate] = useState(endDate !== null); 21 | 22 | const startDateInputRef = useRef(null); 23 | const endDateInputRef = useRef(null); 24 | 25 | // Date formatting utilities 26 | const formatDisplayDate = (date: Date) => 27 | new Intl.DateTimeFormat("en-US", { 28 | weekday: "short", 29 | month: "short", 30 | day: "numeric", 31 | year: "numeric", 32 | }).format(date); 33 | 34 | const formatInputDate = (date: Date) => date.toISOString().split("T")[0]; 35 | const getTodayString = () => formatInputDate(new Date()); 36 | 37 | // Date handlers 38 | const handleStartDateChange = (e: React.ChangeEvent) => { 39 | const newDate = new Date(e.target.value); 40 | setTempStartDate(newDate); 41 | if (tempEndDate && tempEndDate < newDate) { 42 | setTempEndDate(null); 43 | } 44 | }; 45 | 46 | const handleEndDateChange = (e: React.ChangeEvent) => { 47 | setTempEndDate(new Date(e.target.value)); 48 | }; 49 | 50 | const toggleEndDate = (enabled: boolean) => { 51 | setHasEndDate(enabled); 52 | if (!enabled) { 53 | setTempEndDate(null); 54 | } else if (!tempEndDate) { 55 | const tomorrow = new Date(tempStartDate); 56 | tomorrow.setDate(tomorrow.getDate() + 1); 57 | setTempEndDate(tomorrow); 58 | } 59 | }; 60 | 61 | const handleSave = () => { 62 | onStartDateChange(tempStartDate); 63 | onEndDateChange(hasEndDate ? tempEndDate : null); 64 | setIsOpen(false); 65 | }; 66 | 67 | const handleCancel = () => { 68 | setTempStartDate(startDate); 69 | setTempEndDate(endDate); 70 | setHasEndDate(endDate !== null); 71 | setIsOpen(false); 72 | }; 73 | 74 | const getMinEndDate = () => { 75 | const minDate = new Date(tempStartDate); 76 | minDate.setDate(minDate.getDate() + 1); 77 | return formatInputDate(minDate); 78 | }; 79 | 80 | const triggerDatePicker = (ref: React.RefObject) => { 81 | (ref.current as HTMLInputElement & { showPicker: () => void }).showPicker(); 82 | }; 83 | 84 | return ( 85 |
86 | {/* Trigger Button */} 87 | 102 | 103 | {/* Modal */} 104 | {isOpen && ( 105 |
e.target === e.currentTarget && handleCancel()} 108 | > 109 |
110 | {/* Modal Header */} 111 |
112 | 118 |

Select Duration

119 | 125 |
126 | 127 | {/* Modal Content */} 128 |
129 | {/* Start Date Picker */} 130 |
131 | 134 |
135 | 143 | 150 |
151 |
152 | 153 | {/* End Date Picker */} 154 |
155 |
156 | 159 | 168 |
169 | 170 | {hasEndDate ? ( 171 |
172 | 180 | 187 |
188 | ) : ( 189 |
190 |
191 |
192 | Never 193 |
194 |
195 | This habit continues indefinitely 196 |
197 |
198 | )} 199 |
200 | 201 | {/* Preview Section */} 202 |
203 |
204 |
205 |
Preview
206 |
207 |
208 |
209 | From: 210 | 211 | {formatDisplayDate(tempStartDate)} 212 | 213 |
214 |
215 | Until: 216 | 217 | {hasEndDate && tempEndDate 218 | ? formatDisplayDate(tempEndDate) 219 | : "∞ Never"} 220 | 221 |
222 |
223 |
224 |
225 |
226 |
227 | )} 228 |
229 | ); 230 | }; 231 | 232 | export default DatePicker; 233 | -------------------------------------------------------------------------------- /src/features/habits/components/Form/icons.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | // Health & Fitness 3 | Activity, 4 | Heart, 5 | HeartHandshake, 6 | Weight, 7 | Timer, 8 | Zap, 9 | Dumbbell, 10 | 11 | // Food & Drink 12 | Coffee, 13 | Apple, 14 | Cake, 15 | Droplets, 16 | 17 | // Education & Learning 18 | Book, 19 | BookOpen, 20 | Edit, 21 | FileText, 22 | GraduationCap, 23 | PenTool, 24 | 25 | // Productivity 26 | CheckSquare, 27 | Square, 28 | CheckCircle, 29 | Calendar, 30 | Clock, 31 | AlarmClock, 32 | 33 | // Wellness & Mindfulness 34 | Smile, 35 | Laugh, 36 | Moon, 37 | Sun, 38 | Brain, 39 | Bike, 40 | Waves, 41 | 42 | // Finance 43 | DollarSign, 44 | Plus, 45 | Coins, 46 | Wallet, 47 | 48 | // Technology 49 | Smartphone, 50 | Laptop, 51 | Gamepad2, 52 | Headphones, 53 | 54 | // Daily Activities 55 | Home, 56 | Car, 57 | Paintbrush, 58 | ShoppingBag, 59 | Camera, 60 | Music, 61 | 62 | // Nature & Environment 63 | Flower, 64 | Cloud, 65 | 66 | // Goals & Achievement 67 | Trophy, 68 | Medal, 69 | Star, 70 | Award, 71 | Target, 72 | 73 | // Communication 74 | Phone, 75 | MessageSquare, 76 | Users, 77 | UserPlus, 78 | 79 | // Creative 80 | Palette, 81 | Music2, 82 | Video, 83 | Mic, 84 | } from "lucide-react"; 85 | 86 | // Habit icons array with categories 87 | const habitIcons = [ 88 | // Health & Fitness 89 | { name: "Activity", icon: Activity, category: "Health & Fitness" }, 90 | { name: "Heart", icon: Heart, category: "Health & Fitness" }, 91 | { 92 | name: "HeartHandshake", 93 | icon: HeartHandshake, 94 | category: "Health & Fitness", 95 | }, 96 | { name: "Weight", icon: Weight, category: "Health & Fitness" }, 97 | { name: "Timer", icon: Timer, category: "Health & Fitness" }, 98 | { name: "Zap", icon: Zap, category: "Health & Fitness" }, 99 | { name: "Dumbbell", icon: Dumbbell, category: "Health & Fitness" }, 100 | { name: "Bike", icon: Bike, category: "Health & Fitness" }, 101 | { name: "Waves", icon: Waves, category: "Health & Fitness" }, 102 | 103 | // Food & Drink 104 | { name: "Coffee", icon: Coffee, category: "Food & Drink" }, 105 | { name: "Apple", icon: Apple, category: "Food & Drink" }, 106 | { name: "Cake", icon: Cake, category: "Food & Drink" }, 107 | { name: "Droplets", icon: Droplets, category: "Food & Drink" }, 108 | 109 | // Education & Learning 110 | { name: "Book", icon: Book, category: "Education & Learning" }, 111 | { name: "BookOpen", icon: BookOpen, category: "Education & Learning" }, 112 | { name: "Edit", icon: Edit, category: "Education & Learning" }, 113 | { name: "FileText", icon: FileText, category: "Education & Learning" }, 114 | { 115 | name: "GraduationCap", 116 | icon: GraduationCap, 117 | category: "Education & Learning", 118 | }, 119 | { name: "PenTool", icon: PenTool, category: "Education & Learning" }, 120 | 121 | // Productivity 122 | { name: "CheckSquare", icon: CheckSquare, category: "Productivity" }, 123 | { name: "Square", icon: Square, category: "Productivity" }, 124 | { name: "CheckCircle", icon: CheckCircle, category: "Productivity" }, 125 | { name: "Calendar", icon: Calendar, category: "Productivity" }, 126 | { name: "Clock", icon: Clock, category: "Productivity" }, 127 | { name: "AlarmClock", icon: AlarmClock, category: "Productivity" }, 128 | { name: "Target", icon: Target, category: "Productivity" }, 129 | 130 | // Wellness & Mindfulness 131 | { name: "Smile", icon: Smile, category: "Wellness & Mindfulness" }, 132 | { name: "Laugh", icon: Laugh, category: "Wellness & Mindfulness" }, 133 | { name: "Moon", icon: Moon, category: "Wellness & Mindfulness" }, 134 | { name: "Sun", icon: Sun, category: "Wellness & Mindfulness" }, 135 | { name: "Brain", icon: Brain, category: "Wellness & Mindfulness" }, 136 | 137 | // Finance 138 | { name: "DollarSign", icon: DollarSign, category: "Finance" }, 139 | { name: "Plus", icon: Plus, category: "Finance" }, 140 | { name: "Coins", icon: Coins, category: "Finance" }, 141 | { name: "Wallet", icon: Wallet, category: "Finance" }, 142 | 143 | // Technology 144 | { name: "Smartphone", icon: Smartphone, category: "Technology" }, 145 | { name: "Laptop", icon: Laptop, category: "Technology" }, 146 | { name: "Gamepad2", icon: Gamepad2, category: "Technology" }, 147 | { name: "Headphones", icon: Headphones, category: "Technology" }, 148 | 149 | // Daily Activities 150 | { name: "Home", icon: Home, category: "Daily Activities" }, 151 | { name: "Car", icon: Car, category: "Daily Activities" }, 152 | { name: "Paintbrush", icon: Paintbrush, category: "Daily Activities" }, 153 | { name: "ShoppingBag", icon: ShoppingBag, category: "Daily Activities" }, 154 | { name: "Camera", icon: Camera, category: "Daily Activities" }, 155 | { name: "Music", icon: Music, category: "Daily Activities" }, 156 | 157 | // Goals & Achievement 158 | { name: "Trophy", icon: Trophy, category: "Goals & Achievement" }, 159 | { name: "Medal", icon: Medal, category: "Goals & Achievement" }, 160 | { name: "Star", icon: Star, category: "Goals & Achievement" }, 161 | { name: "Award", icon: Award, category: "Goals & Achievement" }, 162 | 163 | // Communication 164 | { name: "Phone", icon: Phone, category: "Communication" }, 165 | { name: "MessageSquare", icon: MessageSquare, category: "Communication" }, 166 | { name: "Users", icon: Users, category: "Communication" }, 167 | { name: "UserPlus", icon: UserPlus, category: "Communication" }, 168 | 169 | // Creative 170 | { name: "Palette", icon: Palette, category: "Creative" }, 171 | { name: "Music2", icon: Music2, category: "Creative" }, 172 | { name: "Video", icon: Video, category: "Creative" }, 173 | { name: "Mic", icon: Mic, category: "Creative" }, 174 | 175 | // Nature & Environment 176 | { name: "Flower", icon: Flower, category: "Nature & Environment" }, 177 | { name: "Cloud", icon: Cloud, category: "Nature & Environment" }, 178 | ]; 179 | 180 | export default habitIcons; 181 | -------------------------------------------------------------------------------- /src/features/habits/components/Form/iconsmap.tsx: -------------------------------------------------------------------------------- 1 | 2 | import React, { useMemo } from 'react'; 3 | import habitIcons from './icons'; 4 | import { Smile } from 'lucide-react'; // Fallback icon 5 | 6 | type HabitIconProps = { 7 | name: string; 8 | size?: number; 9 | className?: string; 10 | }; 11 | 12 | const HabitIcon: React.FC = ({ 13 | name, 14 | size = 20, 15 | className = 'inline-block', 16 | }) => { 17 | // Memoized icon map 18 | const iconMap = useMemo( 19 | () => Object.fromEntries(habitIcons.map(({ name, icon }) => [name, icon])), 20 | [] 21 | ); 22 | 23 | const Icon = iconMap[name] || Smile; // fallback to Smile icon 24 | 25 | return ; 26 | }; 27 | 28 | export default HabitIcon; 29 | 30 | -------------------------------------------------------------------------------- /src/features/habits/components/HabitForm.tsx: -------------------------------------------------------------------------------- 1 | import { HabitData } from "@habits/types"; 2 | import { formatLocalDate } from "@habits/utils"; 3 | import React, { useState } from "react"; 4 | import { 5 | X, 6 | Clock, 7 | CheckCircle, 8 | BarChart3, 9 | Bell, 10 | ChevronDown, 11 | Plus, 12 | Minus, 13 | Droplets, 14 | } from "lucide-react"; 15 | 16 | import { HabitStore } from "@habits/store"; 17 | import habitIcons from "./Form/icons"; 18 | import DatePicker from "./Form/datePicker"; 19 | import Modal from "./Form/Modal"; 20 | 21 | // ================================================================================================ 22 | // INTERFACES 23 | // ================================================================================================ 24 | 25 | interface HabitFormModalProps { 26 | isOpen: boolean; 27 | onClose: () => void; 28 | } 29 | 30 | // ================================================================================================ 31 | // MAIN COMPONENT 32 | // ================================================================================================ 33 | 34 | const HabitForm: React.FC = ({ isOpen, onClose }) => { 35 | // ============================================================================================== 36 | // STATE INITIALIZATION 37 | // ============================================================================================== 38 | 39 | const [modal, setModal] = useState({ 40 | title: "", 41 | description: "", 42 | isOpen: false, 43 | }); 44 | 45 | // Habit form data 46 | const [habitName, setHabitName] = useState(""); 47 | const [trackingType, setTrackingType] = useState<"task" | "amount" | "time">( 48 | "time", 49 | ); 50 | const [timeValue, setTimeValue] = useState(15); 51 | const [targetCount, setTargetCount] = useState(1); 52 | const [selectedDays, setSelectedDays] = useState([ 53 | "MO", 54 | "TU", 55 | "WE", 56 | "TH", 57 | "FR", 58 | "SA", 59 | "SU", 60 | ]); 61 | const [repeatability, setRepeatability] = useState("weekdays"); 62 | const [repeatInterval, setRepeatInterval] = useState(1); 63 | const [selectedColor, setSelectedColor] = useState("#6200ee"); // default accent color 64 | const [selectedIcon, setSelectedIcon] = useState("Droplets"); 65 | 66 | 67 | // UI state 68 | const [isRepeatDropdownOpen, setIsRepeatDropdownOpen] = useState(false); 69 | const [showColorPicker, setShowColorPicker] = useState(false); 70 | const [showIconPicker, setShowIconPicker] = useState(false); 71 | const [customColor, setCustomColor] = useState("#6200ee"); 72 | 73 | // ============================================================================================== 74 | // CONSTANTS 75 | // ============================================================================================== 76 | 77 | const days = [ 78 | { key: "MO", label: "Mon", full: "Monday" }, 79 | { key: "TU", label: "Tue", full: "Tuesday" }, 80 | { key: "WE", label: "Wed", full: "Wednesday" }, 81 | { key: "TH", label: "Thu", full: "Thursday" }, 82 | { key: "FR", label: "Fri", full: "Friday" }, 83 | { key: "SA", label: "Sat", full: "Saturday" }, 84 | { key: "SU", label: "Sun", full: "Sunday" }, 85 | ]; 86 | 87 | // Using Obsidian theme-compatible colors 88 | const defaultColors = [ 89 | "#1E1E2F", // Deep blue-gray 90 | "#2E8B57", // Vibrant green 91 | "#FF8C42", // Warm orange 92 | "#E63946", // Strong red 93 | "#9D4EDD", // Bright purple 94 | "#F72585", // Hot pink 95 | "#4DD0E1", // Aqua / cyan 96 | "#6B4226", // Brownish dark 97 | "#3C3F41", // Neutral gray 98 | "#5C5470", // Muted purple-gray 99 | "#0077B6", // Interactive blue 100 | "#F1FAEE", // Accent text (light for contrast) 101 | ]; 102 | const repeatOptions = [ 103 | { value: "weekdays", label: "Selected week days" }, 104 | { value: "daily", label: "Every day" }, 105 | { value: "every_few_days", label: "Every few days" }, 106 | { value: "weekly", label: "Weekly" }, 107 | { value: "monthly", label: "Monthly" }, 108 | ]; 109 | 110 | const trackingTypes = [ 111 | { type: "task" as const, icon: CheckCircle, label: "Task" }, 112 | { type: "amount" as const, icon: BarChart3, label: "Amount" }, 113 | { type: "time" as const, icon: Clock, label: "Time" }, 114 | ]; 115 | 116 | // ============================================================================================== 117 | // EVENT HANDLERS 118 | // ============================================================================================== 119 | // === Component State ============================================================================= 120 | const [startDate, setStartDate] = useState(new Date()); 121 | const [endDate, setEndDate] = useState(null); 122 | 123 | // === Event Handlers ============================================================================== 124 | 125 | const toggleDay = (day: string) => { 126 | setSelectedDays((prev) => 127 | prev.includes(day) ? prev.filter((d) => d !== day) : [...prev, day], 128 | ); 129 | }; 130 | 131 | const handleRepeatabilityChange = (value: string) => { 132 | setRepeatability(value); 133 | setIsRepeatDropdownOpen(false); 134 | 135 | // Set default days based on selection 136 | if (value === "weekdays") { 137 | setSelectedDays(["MO", "TU", "WE", "TH", "FR"]); 138 | } else if (value === "daily") { 139 | setSelectedDays(["MO", "TU", "WE", "TH", "FR", "SA", "SU"]); 140 | } else if (value === "weekly") { 141 | setSelectedDays([ 142 | new Date().toLocaleDateString("en", { weekday: "short" }).charAt(0), 143 | ]); 144 | } else if (value === "every_few_days") { 145 | setSelectedDays([]); // ✅ clear selected days 146 | } else if (value === "monthly") { 147 | setSelectedDays([]); // ✅ clear selected days 148 | setRepeatInterval(1); // ✅ reset interval to 1 149 | } 150 | }; 151 | 152 | const handleColorSelect = (color: string) => { 153 | setSelectedColor(color); 154 | setShowColorPicker(false); 155 | }; 156 | 157 | const handleCustomColorSelect = () => { 158 | setSelectedColor(customColor); 159 | setShowColorPicker(false); 160 | }; 161 | 162 | const handleIconSelect = (iconName: string) => { 163 | setSelectedIcon(iconName); 164 | setShowIconPicker(false); 165 | }; 166 | 167 | const getSelectedIcon = () => { 168 | const iconData = habitIcons.find((h) => h.name === selectedIcon); 169 | return iconData ? iconData.icon : Droplets; 170 | }; 171 | 172 | const handleIncrement = ( 173 | setValue: (value: number) => void, 174 | currentValue: number, 175 | ) => { 176 | setValue(Math.max(1, currentValue + 1)); 177 | }; 178 | 179 | const handleDecrement = ( 180 | setValue: (value: number) => void, 181 | currentValue: number, 182 | ) => { 183 | setValue(Math.max(1, currentValue - 1)); 184 | }; 185 | 186 | const handleInputChange = ( 187 | setValue: (value: number) => void, 188 | value: string, 189 | ) => { 190 | const parsed = parseInt(value); 191 | if (value === "") { 192 | setValue(1); 193 | } else if (!isNaN(parsed) && parsed >= 1) { 194 | setValue(parsed); 195 | } 196 | }; 197 | 198 | // ============================================================================================== 199 | // UTILITY FUNCTIONS 200 | // ============================================================================================== 201 | 202 | const resetForm = () => { 203 | setHabitName(""); 204 | setTrackingType("time"); 205 | setTimeValue(15); 206 | setTargetCount(1); 207 | setSelectedDays(["MO", "TU", "WE", "TH", "FR", "SA", "SU"]); 208 | setRepeatability("weekdays"); 209 | setRepeatInterval(1); 210 | setSelectedColor("var(--color-accent)"); 211 | setSelectedIcon("Droplets"); 212 | setStartDate(new Date()); 213 | setEndDate(null); 214 | setShowColorPicker(false); 215 | setShowIconPicker(false); 216 | setIsRepeatDropdownOpen(false); 217 | }; 218 | 219 | const handleSave = async () => { 220 | if (!habitName.trim()) return; 221 | 222 | const formatedDate = formatLocalDate(startDate, endDate); 223 | const formattedStartDate: string = formatedDate.formattedStart; 224 | const formattedEndDate: string = formatedDate.formattedEnd; 225 | 226 | const habitData: HabitData = 227 | trackingType === "time" 228 | ? { 229 | trackingType: "time", 230 | name: habitName, 231 | timeValue: timeValue * 60, 232 | timerElapsed: 0, 233 | isTimerRunning: false, 234 | selectedDays, 235 | repeatability, 236 | repeatInterval, 237 | startDate: formattedStartDate, 238 | endDate: formattedEndDate || "Never", 239 | color: selectedColor, 240 | icon: selectedIcon, 241 | } 242 | : trackingType === "amount" 243 | ? { 244 | trackingType: "amount", 245 | name: habitName, 246 | targetCount, 247 | counterValue: 0, 248 | selectedDays, 249 | repeatability, 250 | repeatInterval, 251 | startDate: formattedStartDate, 252 | endDate: formattedEndDate || "Never", 253 | color: selectedColor, 254 | icon: selectedIcon, 255 | } 256 | : { 257 | trackingType: "task", 258 | name: habitName, 259 | selectedDays, 260 | repeatability, 261 | repeatInterval, 262 | startDate: formattedStartDate, 263 | endDate: formattedEndDate || "Never", 264 | color: selectedColor, 265 | icon: selectedIcon, 266 | }; 267 | 268 | try { 269 | const { addHabit } = HabitStore.getState(); 270 | await addHabit(habitData); 271 | resetForm(); 272 | onClose(); 273 | } catch (error) { 274 | setModal({ 275 | title: "Error", 276 | description: "Something went wrong while saving the habit.", 277 | isOpen: true, 278 | }); 279 | console.error("Error saving habit:", error); 280 | } 281 | }; 282 | 283 | // ============================================================================================== 284 | // RENDER CONDITIONS 285 | // ============================================================================================== 286 | 287 | if (!isOpen) return null; 288 | 289 | // ============================================================================================== 290 | // MAIN RENDER 291 | // ============================================================================================== 292 | 293 | return ( 294 |
295 | {/* Backdrop Overlay */} 296 |
300 | 301 | {/* Main Modal Container */} 302 |
303 | {/* ======================================================================================== */} 304 | {/* MODAL HEADER */} 305 | {/* ======================================================================================== */} 306 | 307 |
308 |

309 | New Habit 310 |

311 | 317 |
318 | 319 | {/* ======================================================================================== */} 320 | {/* MODAL CONTENT */} 321 | {/* ======================================================================================== */} 322 | 323 |
324 |
325 | {/* ==================================================================================== */} 326 | {/* LEFT COLUMN */} 327 | {/* ==================================================================================== */} 328 | 329 |
330 | {/* Habit Name Input */} 331 |
332 | 335 | setHabitName(e.target.value)} 339 | className="w-full h-10 bg-secondary border-default rounded-m px-4 py-3 text-default placeholder-muted focus:outline-none focus:ring-2 focus:ring-accent focus:border-transparent transition-all duration-200" 340 | placeholder="Enter habit name" 341 | /> 342 |
343 | 344 | {/* Tracking Type Selection */} 345 |
346 | 349 | 350 |
351 | {trackingTypes.map(({ type, icon: Icon, label }) => ( 352 | 363 | ))} 364 |
365 | 366 | {/* Time Value Input */} 367 | {trackingType === "time" && ( 368 |
369 | 375 | 376 |
377 | 381 |
382 | 386 | handleInputChange(setTimeValue, e.target.value) 387 | } 388 | className="bg-transparent text-default text-center text-2xl font-bold focus:outline-none w-16 border-b-2 border-transparent focus:border-accent transition-all duration-300" 389 | placeholder="15" 390 | min="1" 391 | /> 392 | 393 | min 394 | 395 |
396 |
397 | 398 | 404 |
405 | )} 406 | 407 | {/* Amount Value Input */} 408 | {trackingType === "amount" && ( 409 |
410 | 418 | 419 |
420 | 424 |
425 | 429 | handleInputChange(setTargetCount, e.target.value) 430 | } 431 | className="bg-transparent text-default text-center text-2xl font-bold focus:outline-none w-16 border-b-2 border-transparent focus:border-accent transition-all duration-300" 432 | placeholder="1" 433 | min="1" 434 | /> 435 | 436 | times 437 | 438 |
439 |
440 | 441 | 449 |
450 | )} 451 |
452 | 453 | {/* Color & Icon Selection */} 454 |
455 | 458 | 459 | {/* Icon Selection */} 460 |
461 |
462 | {habitIcons.slice(0, 7).map(({ name, icon: Icon }) => ( 463 | 480 | ))} 481 | 487 |
488 |
489 | 490 | {/* Color Selection */} 491 |
492 |
496 | {React.createElement(getSelectedIcon(), { size: 20 })} 497 |
498 |
499 | {defaultColors.slice(0, 6).map((color, index) => ( 500 | 516 |
517 |
518 |
519 |
520 | 521 | {/* ==================================================================================== */} 522 | {/* RIGHT COLUMN */} 523 | {/* ==================================================================================== */} 524 | 525 |
526 | {/* Repeatability Settings */} 527 |
528 | 531 | 532 |
533 | 550 | 551 | {isRepeatDropdownOpen && ( 552 |
553 | {repeatOptions.map((option) => ( 554 | 563 | ))} 564 |
565 | )} 566 |
567 | 568 | {/* Every Few Days Interval */} 569 | {repeatability === "every_few_days" && ( 570 |
571 | 572 | Every 573 | 574 | 578 | setRepeatInterval(parseInt(e.target.value) || 1) 579 | } 580 | className="bg-transparent text-default text-center text-lg font-medium focus:outline-none w-16" 581 | min="1" 582 | max="30" 583 | /> 584 | 585 | days 586 | 587 |
588 | )} 589 | 590 | {/* Days Selection */} 591 | {(repeatability === "weekdays" || 592 | repeatability === "weekly") && ( 593 |
594 | {days.map(({ key, label }) => ( 595 | 609 | ))} 610 |
611 | )} 612 |
613 | 614 | {/* Reminders Section */} 615 |
616 | 619 | 623 |
624 | 625 | {/* Date Pickers */} 626 |
627 | 628 | 631 | 637 |
638 |
639 |
640 | 641 | {/* ======================================================================================== */} 642 | {/* ACTION BUTTONS */} 643 | {/* ======================================================================================== */} 644 | 645 |
646 | 652 | 659 |
660 |
661 |
662 | 663 | {/* ============================================================================================== */} 664 | {/* COLOR PICKER MODAL */} 665 | {/* ============================================================================================== */} 666 | 667 | {showColorPicker && ( 668 |
669 |
setShowColorPicker(false)} 672 | /> 673 |
674 |

675 | Choose Color 676 |

677 |
678 | {defaultColors.map((color, index) => ( 679 |
690 |
691 | 694 |
695 | setCustomColor(e.target.value)} 699 | className="w-12 h-10 rounded-m border-none cursor-pointer" 700 | /> 701 | 707 |
708 |
709 |
710 |
711 | )} 712 | 713 | {/* ============================================================================================== */} 714 | {/* ICON PICKER MODAL */} 715 | {/* ============================================================================================== */} 716 | 717 | {showIconPicker && ( 718 |
719 |
setShowIconPicker(false)} 722 | /> 723 |
724 |

725 | Choose Icon 726 |

727 |
728 | {habitIcons.map(({ name, icon: Icon }) => ( 729 | 746 | ))} 747 |
748 |
749 |
750 | )} 751 | 752 | {/* ============================================================================================== */} 753 | {/* ERROR MODAL */} 754 | {/* ============================================================================================== */} 755 | 756 | {modal.isOpen && ( 757 | setModal((prev) => ({ ...prev, isOpen: false }))} 761 | /> 762 | )} 763 |
764 | ); 765 | }; 766 | 767 | // ================================================================================================ 768 | // EXPORT 769 | // ================================================================================================ 770 | 771 | export default HabitForm; 772 | -------------------------------------------------------------------------------- /src/features/habits/components/HabitInfo.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState } from "react"; 2 | import { StoredHabit } from "@habits/types"; 3 | import { CheckCircle, Trash2, Calendar, Target, Timer, X } from "lucide-react"; 4 | import HabitIcon from "./Form/iconsmap"; 5 | import { HabitStore } from "@habits/store"; 6 | 7 | interface HabitInfoCardProps { 8 | task: StoredHabit; 9 | } 10 | 11 | export function HabitInfoCard({ task }: HabitInfoCardProps) { 12 | const { deleteHabit } = HabitStore(); 13 | const [showDeleteModal, setShowDeleteModal] = useState(false); 14 | 15 | const getHabitStats = () => { 16 | const stats = []; 17 | 18 | if (task.trackingType === "amount") { 19 | stats.push({ 20 | icon: , 21 | value: `${task.targetCount || 0} ${task.counterValue || "times"}`, 22 | }); 23 | } 24 | 25 | if (task.trackingType === "time") { 26 | stats.push({ 27 | icon: , 28 | value: formatTime(task.timeValue || 0), 29 | }); 30 | } 31 | 32 | const getRepeatLabel = (task: StoredHabit): string => { 33 | if (task.selectedDays.length > 0) { 34 | return `${task.selectedDays.length}x/week`; 35 | } 36 | 37 | const interval = task.repeatInterval ?? 1; 38 | 39 | if (task.repeatability === "every_few_days") { 40 | return `Every ${interval}d`; 41 | } 42 | 43 | return `Every ${interval}/mo`; 44 | }; 45 | stats.push({ 46 | icon: , 47 | value: getRepeatLabel(task), 48 | }); 49 | 50 | return stats; 51 | }; 52 | 53 | const formatTime = (seconds: number): string => { 54 | const mins = Math.floor(seconds / 60); 55 | const secs = seconds % 60; 56 | return `${mins}:${secs.toString().padStart(2, "0")}`; 57 | }; 58 | 59 | const handleDeleteClick = () => setShowDeleteModal(true); 60 | const handleConfirmDelete = () => { 61 | deleteHabit(task.id); 62 | setShowDeleteModal(false); 63 | }; 64 | const handleCancelDelete = () => setShowDeleteModal(false); 65 | 66 | return ( 67 |
68 | {/* Icon + Title Row */} 69 |
70 |
71 |
78 | 82 |
83 |
84 | 85 | {task.name} 86 | 87 | 88 | {task.trackingType} habit 89 | 90 |
91 |
92 | 99 |
100 | 101 | {/* Active Days */} 102 |
103 | {task.selectedDays.map((day, index) => ( 104 | 108 | {day.slice(0, 2)} 109 | 110 | ))} 111 |
112 | 113 | {/* Stats */} 114 |
115 | {getHabitStats() 116 | .slice(0, 2) 117 | .map((stat, index) => ( 118 |
122 |
123 | {stat.icon} 124 |
125 |
126 |
127 | {stat.value} 128 |
129 | {/*
*/} 130 | {/* {label} */} 131 | {/*
*/} 132 |
133 |
134 | ))} 135 |
136 | 137 | {/* Dates */} 138 |
139 |
140 | 141 | {task.startDate || "Not set"} 142 |
143 |
144 | 145 | {task.endDate || "Ongoing"} 146 |
147 |
148 | 149 | {/* Delete Modal */} 150 | {showDeleteModal && ( 151 |
152 |
153 |
154 |
155 |
156 | 157 |
158 |

159 | Delete Habit 160 |

161 |
162 | 168 |
169 |
170 | Are you sure you want to delete{" "} 171 | 172 | {task.name} 173 | 174 | ? This action cannot be undone. 175 |
176 |
177 | 183 | 189 |
190 |
191 |
192 | )} 193 |
194 | ); 195 | } 196 | -------------------------------------------------------------------------------- /src/features/habits/components/TaskButton.tsx: -------------------------------------------------------------------------------- 1 | import { StoredHabit } from "../types/habitTypes"; 2 | import React from "react"; 3 | import { Play, Plus, Pause, RotateCcw, CheckCircle } from "lucide-react"; 4 | import { HabitStore } from "@habits/store"; 5 | 6 | interface TaskButtonProps { 7 | task: StoredHabit; 8 | progress: number; 9 | } 10 | 11 | const TaskButton: React.FC = ({ task, progress }) => { 12 | const { taskDone, incrementHabit, resetTimer, toggleTimer } = HabitStore(); 13 | 14 | // === Task-based (boolean) === 15 | if (task.trackingType === "task") { 16 | return ( 17 | 31 | ); 32 | } 33 | 34 | // === Amount-based (counter) === 35 | if (task.trackingType === "amount") { 36 | const progresz = Math.min( 37 | ((progress || 0) / (task.targetCount || 1)) * 100, 38 | 100, 39 | ); 40 | const radius = 22; 41 | const circumference = 2 * Math.PI * radius; 42 | const strokeDashoffset = circumference - (progresz / 100) * circumference; 43 | 44 | return ( 45 |
46 | 47 | 55 | 67 | 68 | 69 | 85 |
86 | ); 87 | } 88 | 89 | // === Time-based (timer) === 90 | if (task.trackingType === "time") { 91 | const progresz = Math.min( 92 | ((progress || 0) / (task.timeValue || 1)) * 100, 93 | 100, 94 | ); 95 | const radius = 22; 96 | const circumference = 2 * Math.PI * radius; 97 | const strokeDashoffset = circumference - (progresz / 100) * circumference; 98 | 99 | return ( 100 |
101 | 102 | 110 | 122 | 123 | 124 | 147 | 148 | {(task.timerElapsed || 0) > 0 && !task.isTimerRunning && ( 149 | 156 | )} 157 |
158 | ); 159 | } 160 | 161 | return null; 162 | }; 163 | 164 | export default TaskButton; 165 | -------------------------------------------------------------------------------- /src/features/habits/components/TaskCard.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { StoredHabit } from "@habits/types"; 3 | import { TaskButton } from "@habits/components"; 4 | import HabitIcon from "./Form/iconsmap"; 5 | import { formatTime } from "@habits/utils"; 6 | 7 | interface TaskCardProps { 8 | task: StoredHabit; 9 | isCompleted: boolean; 10 | progress: number; 11 | } 12 | 13 | const TaskCard: React.FC = ({ task, isCompleted, progress }) => { 14 | // Helper function to get progress info 15 | const getProgressInfo = () => { 16 | if (task.trackingType === "amount") { 17 | return ( 18 | <> 19 | 20 | {progress} 21 | {/* //{task.counterValue || 0} */} 22 | 23 | / 24 | 25 | {task.targetCount || 0} 26 | 27 | 28 | ); 29 | } 30 | if (task.trackingType === "time") { 31 | return ( 32 | <> 33 | 34 | {formatTime(progress || 0)} 35 | 36 | 37 | 38 | {formatTime(task.timeValue || 0)} 39 | 40 | 41 | ); 42 | } 43 | return null; 44 | }; 45 | 46 | // Check if habit is time-based and has 5 seconds or less remaining 47 | const shouldBlink = () => { 48 | if (task.trackingType !== "time" || isCompleted) return false; 49 | const remaining = (task.timeValue || 0) - (progress || 0); 50 | return remaining <= 5 && remaining > 0; 51 | }; 52 | 53 | return ( 54 |
71 | {/* Custom CSS for blinking animation */} 72 | 88 | 89 | {/* Subtle top highlight - iOS characteristic */} 90 |
91 | 92 |
93 |
94 |
95 | {/* Icon with iOS-style design */} 96 |
107 | 108 | 109 | 110 |
111 | 112 | {/* Content */} 113 |
114 |
115 |

123 | {task.name} 124 |

125 | 126 | {/* Progress indicator 127 | {!task.isCompleted && getProgress() && ( 128 | 129 | {getProgress()} 130 | 131 | )} 132 | */} 133 |
134 | 135 | {/* Days indicator */} 136 |
137 | {task.selectedDays.map((day, index) => ( 138 | 142 | {day} 143 | 144 | ))} 145 |
146 | 147 | {/* Progress bar for amount and time tracking */} 148 | {(task.trackingType === "amount" || 149 | task.trackingType === "time") && ( 150 |
151 |
152 |
162 |
163 |
164 | )} 165 |
166 |
167 | 168 | {/* Action buttons */} 169 |
170 | {/* Progress info for amount and time types */} 171 | {!isCompleted && getProgressInfo() && ( 172 |
182 | {getProgressInfo()} 183 |
184 | )} 185 | {!isCompleted && ( 186 | 187 | )} 188 |
189 |
190 |
191 | 192 | {/* Bottom separator - iOS style */} 193 |
194 |
195 | ); 196 | }; 197 | 198 | export default TaskCard; 199 | -------------------------------------------------------------------------------- /src/features/habits/components/TaskList.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { Plus } from "lucide-react"; 3 | import { StoredHabit } from "@habits/types"; 4 | import { TaskCard } from "@habits/components"; 5 | 6 | interface TaskListProps { 7 | title: string; 8 | tasks: StoredHabit[]; 9 | badgeText: string; 10 | badgeColor: string; 11 | isCompleted?: boolean; 12 | showAddButton?: boolean; 13 | emptyStateIcon?: string; 14 | emptyStateTitle?: string; 15 | emptyStateDescription?: string; 16 | } 17 | 18 | const TaskList: React.FC = ({ 19 | title, 20 | tasks, 21 | badgeText, 22 | badgeColor, 23 | isCompleted = false, 24 | showAddButton = false, 25 | emptyStateIcon = "🎉", 26 | emptyStateTitle = "No completed tasks yet", 27 | emptyStateDescription = "Complete tasks to see them here", 28 | }) => { 29 | return ( 30 |
31 |
32 |

{title}

33 | 34 | {badgeText} 35 | 36 |
37 |
38 | {tasks.map((task) => ( 39 | 40 | ))} 41 | {showAddButton && ( 42 | 46 | )} 47 | {tasks.length === 0 && !showAddButton && ( 48 |
49 |
50 | {emptyStateIcon} 51 |
52 |

{emptyStateTitle}

53 |

{emptyStateDescription}

54 |
55 | )} 56 |
57 |
58 | ); 59 | }; 60 | 61 | export default TaskList; 62 | -------------------------------------------------------------------------------- /src/features/habits/components/index.ts: -------------------------------------------------------------------------------- 1 | // src/zenboard/features/habits/components/index.ts 2 | 3 | export { default as AnalyticsGrid } from "./AnalyticsGrid"; 4 | export { default as AnalyticsTabs } from "./AnalyticsTabs"; 5 | export { default as Calendar } from "./Calendar"; 6 | export { default as HabitForm } from "./HabitForm"; 7 | export * from "./HabitInfo"; 8 | export { default as TaskCard } from "./TaskCard"; 9 | export { default as TaskButton } from "./TaskButton"; 10 | export { default as TaskList } from "./TaskList"; 11 | export { default as EmptyState } from "./EmptyState"; 12 | -------------------------------------------------------------------------------- /src/features/habits/controller/habitController.ts: -------------------------------------------------------------------------------- 1 | // src/controllers/habitController.ts 2 | //import {} from '../types/habitTypes'; 3 | import { 4 | createInitialStatEntry, 5 | getFromStorage, 6 | setToStorage, 7 | } from "@habits/utils"; 8 | 9 | import { 10 | HabitData, 11 | StoredHabit, 12 | HabitStats, 13 | HabitStatEntry, 14 | } from "@habits/types"; 15 | 16 | const HABITS_KEY = "habits"; 17 | const STATS_KEY = "stats"; 18 | 19 | export const getAllHabits = async (): Promise => { 20 | const habits = await getFromStorage(HABITS_KEY); 21 | return habits || []; 22 | }; 23 | 24 | export const saveHabit = async ( 25 | habit: HabitData, 26 | ): Promise<{ 27 | habit: StoredHabit; 28 | stat: { [date: string]: HabitStatEntry }; 29 | }> => { 30 | const habits = await getAllHabits(); 31 | 32 | const newHabit: StoredHabit = { 33 | ...habit, 34 | id: crypto.randomUUID(), 35 | createdAt: new Date().toISOString(), 36 | }; 37 | 38 | const cleanedHabit = Object.fromEntries( 39 | Object.entries(newHabit).filter(([_, v]) => v !== undefined), 40 | ); 41 | 42 | await setToStorage(HABITS_KEY, [...habits, cleanedHabit]); 43 | 44 | // Initialize stats 45 | const stats = (await getFromStorage(STATS_KEY)) || {}; 46 | const entry = createInitialStatEntry(newHabit); 47 | 48 | stats[newHabit.id] = { 49 | [newHabit.startDate]: entry, 50 | }; 51 | 52 | await setToStorage(STATS_KEY, stats); 53 | 54 | return { 55 | habit: newHabit, 56 | stat: { 57 | [newHabit.startDate]: entry, 58 | }, 59 | }; 60 | }; 61 | 62 | export const updateHabit = async ( 63 | id: string, 64 | updatedData: HabitData, 65 | ): Promise => { 66 | const habits = await getAllHabits(); 67 | const updated = habits.map((h) => 68 | h.id === id ? { ...h, ...updatedData } : h, 69 | ); 70 | await setToStorage(HABITS_KEY, updated); 71 | }; 72 | 73 | export const deleteHabitStats = async (habitId: string): Promise => { 74 | const stats = await getFromStorage(STATS_KEY); 75 | if (!stats || !stats[habitId]) return; 76 | 77 | delete stats[habitId]; 78 | 79 | await setToStorage(STATS_KEY, stats); 80 | }; 81 | 82 | export const deleteHabitFromStorage = async (id: string): Promise => { 83 | const habits = await getAllHabits(); 84 | const filtered = habits.filter((h) => h.id !== id); 85 | await setToStorage(HABITS_KEY, filtered); 86 | 87 | await deleteHabitStats(id); 88 | }; 89 | 90 | export async function updateHabitStat( 91 | habitId: string, 92 | date: string, 93 | entry: HabitStatEntry, 94 | ) { 95 | const stats = (await getFromStorage(STATS_KEY)) || {}; 96 | const habitStats = stats[habitId] || {}; 97 | habitStats[date] = entry; 98 | stats[habitId] = habitStats; 99 | await setToStorage(STATS_KEY, stats); 100 | } 101 | 102 | export const getAllStats = async (): Promise => { 103 | const stats = await getFromStorage(STATS_KEY); 104 | return stats || {}; 105 | }; 106 | -------------------------------------------------------------------------------- /src/features/habits/controller/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./habitController"; 2 | -------------------------------------------------------------------------------- /src/features/habits/hooks/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./useCalendar"; 2 | export * from "./useHabits"; 3 | export * from "./useAnalytics"; 4 | export * from "./useDashboard"; 5 | -------------------------------------------------------------------------------- /src/features/habits/hooks/useAnalytics.ts: -------------------------------------------------------------------------------- 1 | import { useState, useEffect } from "react"; 2 | import { HabitStore, fetchHabitsAndStats } from "@habits/store"; 3 | import { AnalyticsData } from "@habits/types"; 4 | import { generateAnalyticsData } from "@habits/utils"; 5 | 6 | export const useAnalytics = () => { 7 | const { loading, allHabits, allStats, shouldUpdate } = HabitStore(); 8 | const [analyticsData, setAnalyticsData] = useState(null); 9 | 10 | // Fetch data once on mount 11 | useEffect(() => { 12 | fetchHabitsAndStats(); 13 | }, []); 14 | 15 | // Recalculate analytics when shouldUpdate changes 16 | useEffect(() => { 17 | if (!allHabits?.length || !allStats) return; 18 | 19 | const analytics = generateAnalyticsData(allHabits, allStats); 20 | setAnalyticsData(analytics); 21 | }, [shouldUpdate]); // Back to just shouldUpdate dependency 22 | 23 | return { analyticsData, loading }; 24 | }; 25 | 26 | export default useAnalytics; 27 | -------------------------------------------------------------------------------- /src/features/habits/hooks/useCalendar.ts: -------------------------------------------------------------------------------- 1 | import { useState, useEffect, useRef, useCallback, useMemo } from "react"; 2 | import { useHabits } from "@habits/hooks"; 3 | import { getCalendarFromHabits } from "@habits/utils"; 4 | 5 | export const useCalendar = () => { 6 | const scrollContainerRef = useRef(null); 7 | const [selectedDate, setSelectedDate] = useState(new Date()); 8 | const [currentMonthInView, setCurrentMonthInView] = useState(""); 9 | 10 | // Cache for performance 11 | const dayWidthCache = useRef(null); 12 | const scrollTimeoutRef = useRef(null); 13 | 14 | const { allHabits, allStats, shouldUpdate } = useHabits(); 15 | 16 | // Memoize calendar data calculation - only recalculate when shouldUpdate changes 17 | const calendarData = useMemo(() => { 18 | if (!allHabits?.length || !allStats) return []; 19 | return getCalendarFromHabits(allHabits, allStats); 20 | }, [shouldUpdate, allHabits?.length, allStats]); // Only depend on shouldUpdate and data existence 21 | 22 | // Memoized day width calculation with caching 23 | const getDayWidth = useCallback(() => { 24 | // Return cached value if available 25 | if (dayWidthCache.current !== null) { 26 | return dayWidthCache.current; 27 | } 28 | 29 | if (scrollContainerRef.current?.firstElementChild) { 30 | const firstDay = scrollContainerRef.current.firstElementChild as HTMLElement; 31 | const computedStyle = window.getComputedStyle(firstDay); 32 | const width = firstDay.offsetWidth; 33 | const marginRight = parseInt(computedStyle.marginRight) || 0; 34 | const gap = 12; // gap-3 = 12px from Tailwind 35 | 36 | // Cache the calculated width 37 | dayWidthCache.current = width + gap; 38 | return dayWidthCache.current; 39 | } 40 | return 92; // Fallback 41 | }, []); 42 | 43 | // Clear cache when calendar data changes 44 | useEffect(() => { 45 | dayWidthCache.current = null; 46 | }, [calendarData.length]); 47 | 48 | // Optimized scroll functions with useCallback 49 | const scrollToToday = useCallback((animated = true) => { 50 | if (!scrollContainerRef.current || calendarData.length === 0) return; 51 | 52 | const todayIndex = calendarData.findIndex((day) => day.isToday); 53 | if (todayIndex === -1) return; 54 | 55 | requestAnimationFrame(() => { 56 | if (!scrollContainerRef.current) return; 57 | 58 | const dayWidth = getDayWidth(); 59 | const containerWidth = scrollContainerRef.current.offsetWidth; 60 | const scrollPosition = todayIndex * dayWidth - containerWidth / 2 + dayWidth / 2; 61 | 62 | scrollContainerRef.current.scrollTo({ 63 | left: Math.max(0, scrollPosition), 64 | behavior: animated ? "smooth" : "auto", 65 | }); 66 | }); 67 | }, [calendarData, getDayWidth]); 68 | 69 | const scrollToStart = useCallback(() => { 70 | if (!scrollContainerRef.current) return; 71 | 72 | const startTime = performance.now(); 73 | const startScrollLeft = scrollContainerRef.current.scrollLeft; 74 | const duration = Math.min(800, startScrollLeft / 2); 75 | 76 | const animateScroll = (currentTime: number) => { 77 | const elapsed = currentTime - startTime; 78 | const progress = Math.min(elapsed / duration, 1); 79 | const easeOut = 1 - Math.pow(1 - progress, 3); 80 | const currentScrollLeft = startScrollLeft * (1 - easeOut); 81 | 82 | if (scrollContainerRef.current) { 83 | scrollContainerRef.current.scrollLeft = currentScrollLeft; 84 | } 85 | 86 | if (progress < 1) { 87 | requestAnimationFrame(animateScroll); 88 | } 89 | }; 90 | 91 | requestAnimationFrame(animateScroll); 92 | }, []); 93 | 94 | const scrollToEnd = useCallback(() => { 95 | if (!scrollContainerRef.current || calendarData.length === 0) return; 96 | 97 | requestAnimationFrame(() => { 98 | if (!scrollContainerRef.current) return; 99 | 100 | const dayWidth = getDayWidth(); 101 | const totalWidth = calendarData.length * dayWidth; 102 | const containerWidth = scrollContainerRef.current.offsetWidth; 103 | const maxScrollLeft = Math.max(0, totalWidth - containerWidth); 104 | const startTime = performance.now(); 105 | const startScrollLeft = scrollContainerRef.current.scrollLeft; 106 | const distance = maxScrollLeft - startScrollLeft; 107 | const duration = Math.min(800, Math.abs(distance) / 2); 108 | 109 | const animateScroll = (currentTime: number) => { 110 | const elapsed = currentTime - startTime; 111 | const progress = Math.min(elapsed / duration, 1); 112 | const easeOut = 1 - Math.pow(1 - progress, 3); 113 | const currentScrollLeft = startScrollLeft + distance * easeOut; 114 | 115 | if (scrollContainerRef.current) { 116 | scrollContainerRef.current.scrollLeft = currentScrollLeft; 117 | } 118 | 119 | if (progress < 1) { 120 | requestAnimationFrame(animateScroll); 121 | } 122 | }; 123 | 124 | requestAnimationFrame(animateScroll); 125 | }); 126 | }, [calendarData.length, getDayWidth]); 127 | 128 | const scrollCalendar = useCallback((direction: "left" | "right") => { 129 | if (!scrollContainerRef.current) return; 130 | 131 | const dayWidth = getDayWidth(); 132 | const scrollAmount = dayWidth * 5; 133 | const currentScrollLeft = scrollContainerRef.current.scrollLeft; 134 | const newScrollLeft = direction === "right" 135 | ? currentScrollLeft + scrollAmount 136 | : currentScrollLeft - scrollAmount; 137 | 138 | scrollContainerRef.current.scrollTo({ 139 | left: Math.max(0, newScrollLeft), 140 | behavior: "smooth", 141 | }); 142 | }, [getDayWidth]); 143 | 144 | const selectDate = useCallback((date: Date) => { 145 | setSelectedDate(date); 146 | }, []); 147 | 148 | // Optimized scroll handler with throttling 149 | const handleScroll = useCallback(() => { 150 | if (!scrollContainerRef.current || calendarData.length === 0) return; 151 | 152 | // Clear existing timeout 153 | if (scrollTimeoutRef.current) { 154 | clearTimeout(scrollTimeoutRef.current); 155 | } 156 | 157 | // Throttle scroll updates 158 | scrollTimeoutRef.current = setTimeout(() => { 159 | if (!scrollContainerRef.current) return; 160 | 161 | const scrollLeft = scrollContainerRef.current.scrollLeft; 162 | const dayWidth = getDayWidth(); 163 | const visibleDayIndex = Math.round(scrollLeft / dayWidth); 164 | const safeIndex = Math.max(0, Math.min(visibleDayIndex, calendarData.length - 1)); 165 | 166 | if (calendarData[safeIndex]) { 167 | const visibleDate = calendarData[safeIndex].date; 168 | const monthYear = visibleDate.toLocaleDateString("en-US", { 169 | month: "long", 170 | year: "numeric", 171 | }); 172 | setCurrentMonthInView(monthYear); 173 | } 174 | }, 16); // ~60fps throttling 175 | }, [calendarData, getDayWidth]); 176 | 177 | // Auto-scroll to today when calendar data changes 178 | useEffect(() => { 179 | if (calendarData.length === 0) return; 180 | 181 | const timeoutId = setTimeout(() => { 182 | scrollToToday(false); 183 | }, 100); 184 | 185 | return () => clearTimeout(timeoutId); 186 | }, [calendarData.length, scrollToToday]); // Only depend on length, not entire array 187 | 188 | // Setup scroll listener 189 | useEffect(() => { 190 | const scrollContainer = scrollContainerRef.current; 191 | if (!scrollContainer) return; 192 | 193 | scrollContainer.addEventListener("scroll", handleScroll, { passive: true }); 194 | 195 | // Initial call to set month 196 | const initialTimeout = setTimeout(handleScroll, 150); 197 | 198 | return () => { 199 | scrollContainer.removeEventListener("scroll", handleScroll); 200 | clearTimeout(initialTimeout); 201 | if (scrollTimeoutRef.current) { 202 | clearTimeout(scrollTimeoutRef.current); 203 | } 204 | }; 205 | }, [handleScroll]); 206 | 207 | return { 208 | calendarData, 209 | selectedDate, 210 | currentMonthInView, 211 | scrollContainerRef, 212 | scrollCalendar, 213 | selectDate, 214 | scrollToToday, 215 | scrollToStart, 216 | scrollToEnd, 217 | }; 218 | }; 219 | 220 | export default useCalendar; 221 | -------------------------------------------------------------------------------- /src/features/habits/hooks/useDashboard.ts: -------------------------------------------------------------------------------- 1 | import { useState, useEffect } from "react"; 2 | import { DashboardData } from "@habits/types"; 3 | import { generateDashboardData } from "@habits/utils"; 4 | import useAnalytics from "./useAnalytics"; 5 | 6 | export const useDashboard = () => { 7 | const { loading, analyticsData } = useAnalytics(); 8 | const [dashboardData, setDashboardData] = useState( 9 | null, 10 | ); 11 | 12 | useEffect(() => { 13 | if (!analyticsData) return; 14 | const dashboard = generateDashboardData(analyticsData); 15 | setDashboardData(dashboard); 16 | }, [analyticsData]); 17 | 18 | return { dashboardData, loading }; 19 | }; 20 | -------------------------------------------------------------------------------- /src/features/habits/hooks/useHabits.ts: -------------------------------------------------------------------------------- 1 | // src/hooks/useHabits.ts 2 | import { HabitStore, fetchHabitsAndStats } from "@habits/store"; 3 | import { useEffect } from "react"; 4 | 5 | export const useHabits = () => { 6 | const { 7 | shouldUpdate, 8 | loading, 9 | todayHabits, 10 | completedHabits, 11 | pendingHabits, 12 | allStats, 13 | allHabits, 14 | } = HabitStore(); 15 | 16 | useEffect(() => { 17 | fetchHabitsAndStats(); // now calling the external action 18 | }, []); 19 | 20 | return { 21 | shouldUpdate, 22 | loading, 23 | todayHabits, 24 | completedHabits, 25 | pendingHabits, 26 | allStats, 27 | allHabits, 28 | }; 29 | }; 30 | -------------------------------------------------------------------------------- /src/features/habits/store/actions/amountAction.ts: -------------------------------------------------------------------------------- 1 | // store/habit/actions/amountAction.ts 2 | import { StateCreator } from "zustand"; 3 | import { getTodayDate } from "@habits/utils"; 4 | import { updateHabitStat } from "@habits/controller"; 5 | import { 6 | HabitStatEntry, 7 | HabitActionSlice, 8 | HabitStateSlice, 9 | AmountActionSlice, 10 | } from "@habits/types"; 11 | 12 | // Define the interface for amount actions 13 | 14 | export const createAmountAction: StateCreator< 15 | HabitStateSlice & HabitActionSlice, 16 | [], 17 | [], 18 | AmountActionSlice // Specify what this slice returns 19 | > = (set, get) => ({ 20 | incrementHabit: async (taskId: string) => { 21 | const today = getTodayDate(); 22 | const { allHabits, allStats, shouldUpdate } = get(); 23 | 24 | 25 | 26 | let completedNow = false; 27 | 28 | // 1. Update allHabits with type safety 29 | const updatedHabits = allHabits.map((h) => { 30 | if (h.id === taskId) { 31 | // Type guard to check if habit has counterValue property 32 | if ("counterValue" in h) { 33 | 34 | const newValue = (h.counterValue || 0) + 1; 35 | 36 | // ✅ Check completion right here 37 | if (h.trackingType === "amount" && newValue >= h.targetCount) { 38 | completedNow = true; 39 | } 40 | 41 | return { ...h, counterValue: newValue }; 42 | } 43 | // If it doesn't have counterValue, add it 44 | return { ...h, counterValue: 1 } as typeof h & { counterValue: number }; 45 | } 46 | return h; 47 | }); 48 | 49 | set({ allHabits: updatedHabits }); 50 | 51 | // 2. Get previous stat value 52 | const prevEntry = allStats?.[taskId]?.[today]; 53 | const prevValue = typeof prevEntry?.v === "number" ? prevEntry.v : 0; 54 | 55 | // 3. Create new stat entry 56 | const updatedStatEntry: HabitStatEntry = { 57 | t: "amount", 58 | v: prevValue + 1, 59 | }; 60 | 61 | // 4. Update stats locally 62 | const updatedStats = { 63 | ...allStats, 64 | [taskId]: { 65 | ...(allStats[taskId] || {}), 66 | [today]: updatedStatEntry, 67 | }, 68 | }; 69 | 70 | set({ allStats: updatedStats }); 71 | 72 | if (completedNow) { 73 | 74 | get().setShouldUpdate(get().shouldUpdate + 1); 75 | } 76 | // 5. Recompute derived state 77 | get().recomputeDerivedState(); 78 | 79 | // 6. Persist to storage 80 | try { 81 | await updateHabitStat(taskId, today, updatedStatEntry); 82 | } catch (error) { 83 | console.error("Failed to update habit stat:", error); 84 | // Optionally revert the local changes on error 85 | } 86 | }, 87 | }); 88 | -------------------------------------------------------------------------------- /src/features/habits/store/actions/coreHabitAction.ts: -------------------------------------------------------------------------------- 1 | // store/habit/actions/amountAction.ts 2 | import { StateCreator } from "zustand"; 3 | import { 4 | HabitStateSlice, 5 | HabitActionSlice, 6 | HabitData, 7 | CoreHabitActionSlice, 8 | } from "@habits/types"; 9 | 10 | import { saveHabit, deleteHabitFromStorage } from "@habits/controller"; 11 | 12 | export const createCoreHabitAction: StateCreator< 13 | HabitStateSlice & HabitActionSlice, 14 | [], 15 | [], 16 | CoreHabitActionSlice 17 | > = (set, get) => ({ 18 | addHabit: async (habitData: HabitData): Promise => { 19 | const { allHabits, allStats } = get(); 20 | const originalHabits = allHabits; 21 | 22 | try { 23 | const { habit: newHabit, stat: newStat } = await saveHabit(habitData); 24 | 25 | set({ 26 | allHabits: [...allHabits, newHabit], 27 | allStats: { 28 | ...allStats, 29 | [newHabit.id]: newStat, // only update one habit's stat 30 | }, 31 | }); 32 | 33 | get().recomputeDerivedState(); 34 | } catch (error) { 35 | console.error("Failed to add habit:", error); 36 | // Rollback on error 37 | set({ allHabits: originalHabits }); 38 | get().recomputeDerivedState(); 39 | throw error; 40 | } 41 | }, 42 | 43 | deleteHabit: async (id: string): Promise => { 44 | const { allHabits, allStats } = get(); 45 | const originalHabits = allHabits; 46 | const originalStats = allStats; 47 | 48 | try { 49 | // Update local state first 50 | const updatedHabits = allHabits.filter((habit) => habit.id !== id); 51 | const updatedStats = { ...allStats }; 52 | delete updatedStats[id]; 53 | 54 | set({ allHabits: updatedHabits, allStats: updatedStats }); 55 | get().recomputeDerivedState(); 56 | 57 | // Then delete from storage 58 | await deleteHabitFromStorage(id); 59 | } catch (error) { 60 | console.error("Failed to delete habit:", error); 61 | // Rollback on error 62 | set({ allHabits: originalHabits, allStats: originalStats }); 63 | get().recomputeDerivedState(); 64 | throw error; 65 | } 66 | }, 67 | }); 68 | 69 | // Export the type for use in other files 70 | export type { CoreHabitActionSlice }; 71 | -------------------------------------------------------------------------------- /src/features/habits/store/actions/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./amountAction"; 2 | export * from "./coreHabitAction"; 3 | export * from "./taskAction"; 4 | export * from "./timeAction"; 5 | -------------------------------------------------------------------------------- /src/features/habits/store/actions/taskAction.ts: -------------------------------------------------------------------------------- 1 | import { StateCreator } from "zustand"; 2 | import { getTodayDate } from "@habits/utils"; 3 | import { updateHabitStat } from "@habits/controller"; 4 | import { 5 | HabitStatEntry, 6 | HabitStateSlice, 7 | HabitActionSlice, 8 | TaskActionSlice, 9 | } from "@habits/types"; 10 | 11 | // Alternative with rollback on error 12 | export const createTaskAction: StateCreator< 13 | HabitStateSlice & HabitActionSlice, 14 | [], 15 | [], 16 | TaskActionSlice 17 | > = (set, get) => ({ 18 | taskDone: async (id: string): Promise => { 19 | const today = getTodayDate(); 20 | const { allStats } = get(); 21 | const originalStats = allStats; 22 | 23 | try { 24 | // Check if already done 25 | const alreadyDone = allStats?.[id]?.[today]?.v === 1; 26 | if (alreadyDone) return; 27 | 28 | // Create new stat entry 29 | const entry: HabitStatEntry = { t: "task", v: 1 }; 30 | 31 | // Update stats locally 32 | const updatedStats = { 33 | ...allStats, 34 | [id]: { 35 | ...(allStats[id] || {}), 36 | [today]: entry, 37 | }, 38 | }; 39 | 40 | 41 | get().setShouldUpdate(get().shouldUpdate + 1); 42 | set({ allStats: updatedStats }); 43 | get().recomputeDerivedState(); 44 | 45 | // Persist to storage 46 | await updateHabitStat(id, today, entry); 47 | } catch (error) { 48 | console.error("Failed to mark task as done:", error); 49 | // Rollback on error 50 | set({ allStats: originalStats }); 51 | get().recomputeDerivedState(); 52 | throw error; 53 | } 54 | }, 55 | }); 56 | 57 | // Export the type for use in other files 58 | export type { TaskActionSlice }; 59 | -------------------------------------------------------------------------------- /src/features/habits/store/actions/timeAction.ts: -------------------------------------------------------------------------------- 1 | // store/habit/actions/timeAction.ts 2 | import { StateCreator } from "zustand"; 3 | import { 4 | HabitActionSlice, 5 | HabitStateSlice, 6 | TimeActionSlice, 7 | HabitStatEntry, 8 | } from "@habits/types"; 9 | 10 | import { updateHabitStat } from "@habits/controller"; 11 | import { getTodayDate } from "@habits/utils"; 12 | 13 | // Global maps 14 | const timerIntervals = new Map(); 15 | const lastSyncedTimestamps = new Map(); 16 | 17 | export const createTimeAction: StateCreator< 18 | HabitStateSlice & HabitActionSlice, 19 | [], 20 | [], 21 | TimeActionSlice 22 | > = (set, get) => ({ 23 | // Initialize timer elapsed from saved stats (call this after loading habits/stats) 24 | initializeTimerElapsed: async (taskId: string) => { 25 | const { allStats, setAllHabits } = get(); 26 | const today = getTodayDate(); 27 | const savedElapsed = allStats?.[taskId]?.[today]?.v || 0; 28 | 29 | if (typeof savedElapsed === "number" && savedElapsed > 0) { 30 | setAllHabits((prev) => 31 | prev.map((h) => 32 | h.id === taskId && h.trackingType === "time" 33 | ? { ...h, timerElapsed: savedElapsed } 34 | : h, 35 | ), 36 | ); 37 | } 38 | }, 39 | 40 | toggleTimer: async (taskId: string) => { 41 | const { 42 | setAllStats, 43 | todayHabits, 44 | setAllHabits, 45 | recomputeDerivedState, 46 | allStats, 47 | } = get(); 48 | 49 | const habit = todayHabits.find((h) => h.id === taskId); 50 | if (!habit || habit.trackingType !== "time") return; 51 | 52 | const isRunning = habit.isTimerRunning; 53 | const today = getTodayDate(); 54 | 55 | if (isRunning) { 56 | // ⏸ Pause Timer 57 | const runningInterval = timerIntervals.get(taskId); 58 | if (runningInterval) { 59 | clearInterval(runningInterval); 60 | timerIntervals.delete(taskId); 61 | } 62 | 63 | // Get current habit state for final sync 64 | const currentState = get(); 65 | const habitToPause = currentState.todayHabits.find( 66 | (h) => h.id === taskId, 67 | ); 68 | 69 | if (habitToPause?.trackingType === "time") { 70 | const newElapsed = habitToPause.timerElapsed || 0; 71 | 72 | // Sync final time to database 73 | if (newElapsed > 0) { 74 | const updatedStat: HabitStatEntry = { t: "time", v: newElapsed }; 75 | updateHabitStat(taskId, today, updatedStat); 76 | lastSyncedTimestamps.set(taskId, Date.now()); 77 | } 78 | } 79 | 80 | // Update habit state to paused 81 | setAllHabits((prev) => 82 | prev.map((h) => 83 | h.id === taskId ? { ...h, isTimerRunning: false } : h, 84 | ), 85 | ); 86 | } else { 87 | // ▶️ Start Timer 88 | // First, get the last saved elapsed time from stats 89 | const savedElapsed = allStats?.[taskId]?.[today]?.v || 0; 90 | const currentElapsed = habit.timerElapsed || 0; 91 | 92 | // If the saved time is greater than current, sync the habit state first 93 | if (typeof savedElapsed === "number" && savedElapsed > currentElapsed) { 94 | setAllHabits((prev) => 95 | prev.map((h) => 96 | h.id === taskId ? { ...h, timerElapsed: savedElapsed } : h, 97 | ), 98 | ); 99 | } 100 | 101 | const interval = setInterval(() => { 102 | const currentState = get(); 103 | const currentHabit = currentState.allHabits.find( 104 | (h) => h.id === taskId, 105 | ); 106 | 107 | if ( 108 | !currentHabit || 109 | currentHabit.trackingType !== "time" || 110 | !currentHabit.isTimerRunning 111 | ) { 112 | // Timer was stopped externally, clean up 113 | const runningInterval = timerIntervals.get(taskId); 114 | if (runningInterval) { 115 | clearInterval(runningInterval); 116 | timerIntervals.delete(taskId); 117 | } 118 | return; 119 | } 120 | 121 | const newElapsed = (currentHabit.timerElapsed || 0) + 1; 122 | const targetTime = currentHabit.timeValue || 0; 123 | const shouldStop = targetTime > 0 && newElapsed >= targetTime; 124 | const now = Date.now(); 125 | const lastSynced = lastSyncedTimestamps.get(taskId) || 0; 126 | 127 | // Update habit state first 128 | setAllHabits((prev) => 129 | prev.map((h) => { 130 | if (h.id !== taskId || h.trackingType !== "time") return h; 131 | return { 132 | ...h, 133 | timerElapsed: newElapsed, 134 | isTimerRunning: !shouldStop, 135 | }; 136 | }), 137 | ); 138 | 139 | // Update local stats state 140 | const updatedStat: HabitStatEntry = { 141 | t: "time", 142 | v: newElapsed, 143 | }; 144 | 145 | setAllStats((prev) => ({ 146 | ...prev, 147 | [taskId]: { 148 | ...(prev[taskId] || {}), 149 | [today]: updatedStat, 150 | }, 151 | })); 152 | 153 | // Sync to database periodically or when stopping 154 | if (shouldStop || now - lastSynced >= 15000) { 155 | updateHabitStat(taskId, today, updatedStat); 156 | lastSyncedTimestamps.set(taskId, now); 157 | } 158 | 159 | // Stop timer if target reached 160 | if (shouldStop) { 161 | const runningInterval = timerIntervals.get(taskId); 162 | if (runningInterval) { 163 | clearInterval(runningInterval); 164 | timerIntervals.delete(taskId); 165 | } 166 | } 167 | }, 1000); 168 | 169 | // Store interval and start timer 170 | timerIntervals.set(taskId, interval); 171 | 172 | setAllHabits((prev) => 173 | prev.map((h) => (h.id === taskId ? { ...h, isTimerRunning: true } : h)), 174 | ); 175 | } 176 | 177 | recomputeDerivedState(); 178 | }, 179 | 180 | resetTimer: async (taskId: string) => { 181 | const { setAllStats, setAllHabits, recomputeDerivedState } = get(); 182 | const today = getTodayDate(); 183 | 184 | // Clear any running interval 185 | const runningInterval = timerIntervals.get(taskId); 186 | if (runningInterval) { 187 | clearInterval(runningInterval); 188 | timerIntervals.delete(taskId); 189 | } 190 | 191 | // Remove last synced timestamp 192 | lastSyncedTimestamps.delete(taskId); 193 | 194 | // Reset local habit state 195 | setAllHabits((prev) => 196 | prev.map((h) => 197 | h.id === taskId 198 | ? { 199 | ...h, 200 | isTimerRunning: false, 201 | timerElapsed: 0, 202 | } 203 | : h, 204 | ), 205 | ); 206 | 207 | // Reset local stats state 208 | const updatedStat: HabitStatEntry = { 209 | t: "time", 210 | v: 0, 211 | }; 212 | 213 | setAllStats((prev) => ({ 214 | ...prev, 215 | [taskId]: { 216 | ...(prev[taskId] || {}), 217 | [today]: updatedStat, 218 | }, 219 | })); 220 | 221 | // Sync reset to database 222 | updateHabitStat(taskId, today, updatedStat); 223 | 224 | recomputeDerivedState(); 225 | }, 226 | }); 227 | 228 | // Cleanup on logout/unload 229 | export const clearAllTimers = () => { 230 | for (const interval of timerIntervals.values()) { 231 | clearInterval(interval); 232 | } 233 | timerIntervals.clear(); 234 | lastSyncedTimestamps.clear(); 235 | }; 236 | 237 | // Export types - you'll need to add initializeTimerElapsed to your TimeActionSlice type 238 | export type { TimeActionSlice }; 239 | -------------------------------------------------------------------------------- /src/features/habits/store/fetcher/fetchHabitAndStats.ts: -------------------------------------------------------------------------------- 1 | import { getAllHabits, getAllStats } from "@habits/controller"; 2 | import { HabitStore } from "@habits/store"; 3 | 4 | export const fetchHabitsAndStats = async () => { 5 | const set = HabitStore.getState(); 6 | 7 | if (set.hasFetched) return; 8 | 9 | set.setLoading(true); 10 | set.setHasFetched(true); 11 | 12 | try { 13 | const [habits, stats] = await Promise.all([getAllHabits(), getAllStats()]); 14 | set.setAllHabits(habits); 15 | set.setAllStats(stats); 16 | set.setShouldUpdate(set.shouldUpdate + 1); 17 | } catch (e) { 18 | console.error("Error in fetchHabitsAndStats", e); 19 | } finally { 20 | set.setLoading(false); 21 | } 22 | }; 23 | -------------------------------------------------------------------------------- /src/features/habits/store/helpers/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./recompute"; 2 | -------------------------------------------------------------------------------- /src/features/habits/store/helpers/recompute.ts: -------------------------------------------------------------------------------- 1 | import { 2 | StoredHabit, 3 | HabitStats, 4 | HabitStateSlice, 5 | HabitStatEntry, 6 | } from "@habits/types"; 7 | import { 8 | getTodayDate, 9 | isHabitScheduledForToday, 10 | createInitialStatEntry, 11 | } from "@habits/utils"; 12 | 13 | import { updateHabitStat } from "@habits/controller"; 14 | 15 | function isHabitCompleted(habit: StoredHabit, stat?: HabitStatEntry): boolean { 16 | if (!stat) return false; 17 | 18 | switch (stat.t) { 19 | case "task": 20 | return stat.v === 1; 21 | case "amount": 22 | return habit.trackingType === "amount" && stat.v >= habit.targetCount; 23 | case "time": 24 | return habit.trackingType === "time" && stat.v >= habit.timeValue; 25 | default: 26 | return false; 27 | } 28 | } 29 | 30 | export const recomputeDerivedState = ( 31 | allHabits: StoredHabit[], 32 | allStats: HabitStats, 33 | set: (partial: Partial) => void, 34 | ) => { 35 | const today = getTodayDate(); 36 | const todayHabits = allHabits.filter((h) => 37 | isHabitScheduledForToday(h, today), 38 | ); 39 | const completed: StoredHabit[] = []; 40 | const pending: StoredHabit[] = []; 41 | 42 | if (!Array.isArray(allHabits) || allHabits.length === 0) { 43 | return; 44 | } 45 | 46 | if ( 47 | !allStats || 48 | typeof allStats !== "object" || 49 | Object.keys(allStats).length === 0 50 | ) { 51 | return; 52 | } 53 | 54 | todayHabits.forEach((habit) => { 55 | let statEntry = allStats?.[habit.id]?.[today]; 56 | 57 | // 🟡 If statEntry is undefined, initialize it 58 | if (!statEntry) { 59 | const defaultEntry = createInitialStatEntry(habit); 60 | statEntry = defaultEntry; 61 | 62 | if (!allStats[habit.id]) { 63 | allStats[habit.id] = {}; 64 | } 65 | 66 | // ✅ Update local allStats object 67 | allStats[habit.id][today] = defaultEntry; 68 | // ✅ Save to DB (optional: debounce or throttle outside) 69 | updateHabitStat(habit.id, today, defaultEntry); // or createHabitStat() 70 | } 71 | 72 | const isCompleted = isHabitCompleted(habit, statEntry); 73 | 74 | (isCompleted ? completed : pending).push(habit); 75 | }); 76 | 77 | 78 | set({ 79 | todayHabits: todayHabits, 80 | completedHabits: completed, 81 | pendingHabits: pending, 82 | }); 83 | }; 84 | -------------------------------------------------------------------------------- /src/features/habits/store/index.ts: -------------------------------------------------------------------------------- 1 | // Re-export everything for external usage 2 | export * from "./slices"; 3 | export * from "./helpers"; 4 | export * from "./fetcher/fetchHabitAndStats"; 5 | export * from "./zustandStore"; 6 | -------------------------------------------------------------------------------- /src/features/habits/store/slices/habitActionsSlice.ts: -------------------------------------------------------------------------------- 1 | import { StateCreator } from "zustand"; 2 | import { 3 | createAmountAction, 4 | createTaskAction, 5 | createCoreHabitAction, 6 | createTimeAction, 7 | } from "@habits/store/actions"; 8 | import { recomputeDerivedState } from "../helpers/recompute"; 9 | import { HabitActionSlice, HabitStateSlice } from "@habits/types"; 10 | 11 | export const createHabitActionSlice: StateCreator< 12 | HabitStateSlice & HabitActionSlice, 13 | [], 14 | [], 15 | HabitActionSlice 16 | > = (set, get, store) => { 17 | // Get all the individual action slices 18 | const amountActions = createAmountAction(set, get, store); 19 | const taskActions = createTaskAction(set, get, store); 20 | const timeActions = createTimeAction(set, get, store); 21 | const coreActions = createCoreHabitAction(set, get, store); 22 | 23 | return { 24 | // Add the central recomputeDerivedState method 25 | recomputeDerivedState: () => { 26 | const { allHabits, allStats } = get(); 27 | recomputeDerivedState(allHabits, allStats, set); 28 | }, 29 | 30 | // Spread all the action creators 31 | ...amountActions, 32 | ...taskActions, 33 | ...timeActions, 34 | ...coreActions, 35 | }; 36 | }; 37 | -------------------------------------------------------------------------------- /src/features/habits/store/slices/habitStateSlice.ts: -------------------------------------------------------------------------------- 1 | import { StateCreator } from "zustand"; 2 | import { HabitStateSlice, HabitActionSlice } from "@habits/types"; 3 | 4 | export const createHabitStateSlice: StateCreator< 5 | HabitStateSlice & HabitActionSlice, 6 | [], 7 | [], 8 | HabitStateSlice 9 | > = (set, get) => ({ 10 | loading: true, 11 | hasFetched: false, 12 | shouldUpdate: 0, 13 | allHabits: [], 14 | allStats: {}, 15 | todayHabits: [], 16 | completedHabits: [], 17 | pendingHabits: [], 18 | setLoading: (loading) => set({ loading }), 19 | setHasFetched: (val) => set({ hasFetched: val }), 20 | setShouldUpdate: (value) => set({ shouldUpdate: value }), 21 | setAllHabits: (habits) => { 22 | set((state) => ({ 23 | allHabits: 24 | typeof habits === "function" ? habits(state.allHabits) : habits, 25 | })); 26 | // This will work now because the full store includes HabitActionSlice 27 | get().recomputeDerivedState(); 28 | }, 29 | setAllStats: (stats) => { 30 | set((state) => ({ 31 | allStats: typeof stats === "function" ? stats(state.allStats) : stats, 32 | })); 33 | get().recomputeDerivedState(); 34 | }, 35 | }); 36 | -------------------------------------------------------------------------------- /src/features/habits/store/slices/index.ts: -------------------------------------------------------------------------------- 1 | 2 | export * from "./habitStateSlice"; 3 | export * from "./habitActionsSlice"; 4 | -------------------------------------------------------------------------------- /src/features/habits/store/zustandStore.ts: -------------------------------------------------------------------------------- 1 | // store/habitStore.ts 2 | import { create } from "zustand"; 3 | import { HabitActionSlice, HabitStateSlice } from "@habits/types"; 4 | import { createHabitActionSlice, createHabitStateSlice } from "@habits/store"; 5 | 6 | export const HabitStore = create()( 7 | (...args) => ({ 8 | ...createHabitStateSlice(...args), 9 | ...createHabitActionSlice(...args), 10 | }), 11 | ); 12 | -------------------------------------------------------------------------------- /src/features/habits/types/ActionStateTypes.ts: -------------------------------------------------------------------------------- 1 | import { StoredHabit, HabitStats, HabitData } from "@habits/types"; 2 | 3 | export type HabitStateSlice = { 4 | loading: boolean; 5 | hasFetched: boolean; 6 | shouldUpdate: number; 7 | allHabits: StoredHabit[]; 8 | allStats: HabitStats; 9 | todayHabits: StoredHabit[]; 10 | completedHabits: StoredHabit[]; 11 | pendingHabits: StoredHabit[]; 12 | setLoading: (loading: boolean) => void; 13 | setHasFetched: (value: boolean) => void; 14 | setShouldUpdate: (value: number) => void; 15 | setAllHabits: ( 16 | habits: StoredHabit[] | ((prev: StoredHabit[]) => StoredHabit[]), 17 | ) => void; 18 | setAllStats: (stats: HabitStats | ((prev: HabitStats) => HabitStats)) => void; 19 | }; 20 | 21 | // store/habit/actions/types.ts 22 | export type HabitActionSlice = { 23 | recomputeDerivedState: () => void; 24 | incrementHabit: (id: string) => Promise; 25 | taskDone: (id: string) => Promise; 26 | deleteHabit: (id: string) => Promise; 27 | addHabit: (habit: HabitData) => Promise; 28 | //timerStart: (id: string) => void; 29 | // Add more as needed 30 | // 31 | resetTimer: (taskId: string) => void; 32 | toggleTimer: (taskId: string) => void; 33 | }; 34 | 35 | export interface CoreHabitActionSlice { 36 | addHabit: (habitData: HabitData) => Promise; 37 | deleteHabit: (id: string) => Promise; 38 | } 39 | 40 | export interface TaskActionSlice { 41 | taskDone: (id: string) => Promise; 42 | } 43 | 44 | export interface AmountActionSlice { 45 | incrementHabit: (taskId: string) => Promise; 46 | } 47 | 48 | export interface TimeActionSlice { 49 | initializeTimerElapsed: (taskId: string) => Promise; 50 | resetTimer: (taskId: string) => Promise; 51 | toggleTimer: (taskId: string) => Promise; 52 | } 53 | -------------------------------------------------------------------------------- /src/features/habits/types/analyticsTypes.ts: -------------------------------------------------------------------------------- 1 | import { StoredHabit } from "@habits/types"; 2 | 3 | // Types of calendar data 4 | 5 | export interface DayData { 6 | date: Date; 7 | taskCount: number; 8 | isToday: boolean; 9 | completedTasks: number; 10 | } 11 | 12 | // Data types for analytics 13 | 14 | export interface monthlyData { 15 | monthlyData: number[][]; 16 | } 17 | 18 | export interface overallData { 19 | overallData: number[]; 20 | } 21 | 22 | export interface ProcessedHabitData { 23 | habit: StoredHabit; 24 | dataPeriod: monthlyData | overallData; 25 | completedDays: number; 26 | totalDays: number; 27 | percentage: number; 28 | currentStreak: number; 29 | longestStreak: number; 30 | } 31 | 32 | export interface AnalyticsData { 33 | [habitId: string]: { 34 | monthly: ProcessedHabitData; 35 | overall: ProcessedHabitData; 36 | }; 37 | } 38 | -------------------------------------------------------------------------------- /src/features/habits/types/backgroundTypes.ts: -------------------------------------------------------------------------------- 1 | 2 | export type TimerMessage = 3 | | { type: "START_TIMER"; taskId: string; pausedElapsed?: number; targetTime?: number } 4 | | { type: "STOP_TIMER"; taskId: string } 5 | | { type: "RESET_TIMER"; taskId: string }; 6 | -------------------------------------------------------------------------------- /src/features/habits/types/dashboardType.ts: -------------------------------------------------------------------------------- 1 | export interface DashboardData { 2 | maxStreak: number; 3 | } 4 | -------------------------------------------------------------------------------- /src/features/habits/types/habitTypes.ts: -------------------------------------------------------------------------------- 1 | // src/types/habitTypes.ts 2 | 3 | // Shared base fields 4 | interface BaseHabitData { 5 | name: string; 6 | selectedDays: string[]; 7 | repeatability: string; 8 | repeatInterval?: number; 9 | startDate: string; 10 | endDate: string; 11 | color: string; 12 | icon?: string; 13 | } 14 | 15 | // Time-based habit 16 | export interface TimeHabit extends BaseHabitData { 17 | trackingType: "time"; 18 | timeValue: number; 19 | timerElapsed: number; 20 | isTimerRunning: boolean; 21 | } 22 | 23 | // Amount-based habit 24 | export interface AmountHabit extends BaseHabitData { 25 | trackingType: "amount"; 26 | targetCount: number; 27 | counterValue: number; 28 | } 29 | 30 | // Task-based habit (optional for now) 31 | export interface TaskHabit extends BaseHabitData { 32 | trackingType: "task"; 33 | } 34 | 35 | // Final discriminated union 36 | export type HabitData = TimeHabit | AmountHabit | TaskHabit; 37 | 38 | // Stored version (with ID and timestamp) 39 | export type StoredHabit = HabitData & { 40 | id: string; 41 | createdAt: string; 42 | }; 43 | 44 | export type HabitStatEntry = 45 | | { t: "task"; v: 0 | 1 } 46 | | { t: "time"; v: number } 47 | | { t: "amount"; v: number }; 48 | 49 | export type HabitStats = { 50 | [habitId: string]: { 51 | [date: string]: HabitStatEntry; 52 | }; 53 | }; 54 | -------------------------------------------------------------------------------- /src/features/habits/types/index.ts: -------------------------------------------------------------------------------- 1 | // src/zenboard/features/habits/types/index.ts 2 | export * from "./ActionStateTypes"; 3 | export * from "./analyticsTypes"; 4 | export * from "./habitTypes"; 5 | export * from "./dashboardType"; 6 | export * from "./backgroundTypes"; 7 | -------------------------------------------------------------------------------- /src/features/habits/utils/alarm/sound.ts: -------------------------------------------------------------------------------- 1 | // import beep from "@assets/sounds/beep.wav"; 2 | 3 | export const playBeep = () => { 4 | // const audio = new Audio(beep); 5 | // audio.play().catch((e) => { 6 | // console.warn("Audio playback failed:", e); 7 | // }); 8 | }; 9 | -------------------------------------------------------------------------------- /src/features/habits/utils/analytics/compeletion.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Calculates completion statistics from data array 3 | */ 4 | export const calculateCompletionStats = ( 5 | data: number[], 6 | ): { 7 | completedDays: number; 8 | totalDays: number; 9 | percentage: number; 10 | } => { 11 | const flatData = data.flat(); 12 | const completedDays = flatData.filter((day) => day === 1).length; 13 | const totalDays = flatData.filter( 14 | (day) => day !== -1 && day !== -2 && day !== -3, 15 | ).length; 16 | const percentage = 17 | totalDays > 0 ? Math.floor((completedDays / totalDays) * 100) : 0; 18 | 19 | return { completedDays, totalDays, percentage }; 20 | }; 21 | -------------------------------------------------------------------------------- /src/features/habits/utils/analytics/generateAnalyticsData.ts: -------------------------------------------------------------------------------- 1 | import { HabitStats, AnalyticsData } from "@habits/types"; 2 | import { StoredHabit } from "@habits/types"; 3 | import { 4 | generateMonthlyRawData, 5 | generateYearlyRawData, 6 | calculateStreaks, 7 | calculateCompletionStats, 8 | } from "@habits/utils/analytics"; 9 | 10 | /** 11 | * Main function to generate analytics data for all habits 12 | */ 13 | export const generateAnalyticsData = ( 14 | habits: StoredHabit[], 15 | stats: HabitStats, 16 | ): AnalyticsData => { 17 | const data: AnalyticsData = {}; 18 | 19 | habits.forEach((habit) => { 20 | const monthlyRawData = generateMonthlyRawData(habit, stats); 21 | const overallRawData = generateYearlyRawData(habit, stats); 22 | 23 | // Calculate stats for monthly data 24 | const monthlyFlatData = monthlyRawData.flat(); 25 | const monthlyStats = calculateCompletionStats(monthlyFlatData); 26 | const monthlyStreaks = calculateStreaks(monthlyFlatData); 27 | 28 | // Calculate stats for overall data 29 | const overallStats = calculateCompletionStats(overallRawData); 30 | const overallStreaks = calculateStreaks(overallRawData); 31 | 32 | data[habit.id] = { 33 | monthly: { 34 | habit, 35 | dataPeriod: { monthlyData: monthlyRawData }, 36 | completedDays: monthlyStats.completedDays, 37 | totalDays: monthlyStats.totalDays, 38 | percentage: monthlyStats.percentage, 39 | currentStreak: monthlyStreaks.current, 40 | longestStreak: monthlyStreaks.longest, 41 | }, 42 | overall: { 43 | habit, 44 | dataPeriod: { overallData: overallRawData }, 45 | completedDays: overallStats.completedDays, 46 | totalDays: overallStats.totalDays, 47 | percentage: overallStats.percentage, 48 | currentStreak: overallStreaks.current, 49 | longestStreak: overallStreaks.longest, 50 | }, 51 | }; 52 | }); 53 | 54 | return data; 55 | }; 56 | -------------------------------------------------------------------------------- /src/features/habits/utils/analytics/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./compeletion"; 2 | export * from "./streak"; 3 | export * from "./montlyData"; 4 | export * from "./yearlyData"; 5 | -------------------------------------------------------------------------------- /src/features/habits/utils/analytics/montlyData.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Generates monthly calendar data for current month 3 | */ 4 | import { StoredHabit, HabitStats } from "@habits/types"; 5 | import { 6 | formatToYYMMDD, 7 | isHabitScheduledForToday, 8 | isHabitCompleted, 9 | isStrictlyGreaterDateYYMMDD, 10 | getTodayDate, 11 | } from "@habits/utils"; 12 | /** 13 | * Generates monthly calendar data for current month 14 | */ 15 | export const generateMonthlyRawData = ( 16 | habit: StoredHabit, 17 | stats: HabitStats, 18 | ): number[][] => { 19 | const currentDate = new Date(); 20 | const year = currentDate.getFullYear(); 21 | const month = currentDate.getMonth(); 22 | const daysInMonth = new Date(year, month + 1, 0).getDate(); 23 | const firstDayOfMonth = new Date(year, month, 1).getDay(); 24 | 25 | const monthlyData: number[][] = []; 26 | let dayIndex = 0; 27 | 28 | // Generate 6 weeks to cover any month layout 29 | for (let week = 0; week < 6; week++) { 30 | const weekData: number[] = []; 31 | 32 | for (let day = 0; day < 7; day++) { 33 | if (week === 0 && day < firstDayOfMonth) { 34 | // Empty cells before month starts 35 | weekData.push(-1); 36 | } else if (dayIndex >= daysInMonth) { 37 | // Empty cells after month ends 38 | weekData.push(-1); 39 | } else { 40 | // Actual day - use real data if available 41 | const actualDay = dayIndex + 1; 42 | const dateKey = formatToYYMMDD(new Date(year, month, actualDay)); 43 | 44 | 45 | 46 | const isSchedule = isHabitScheduledForToday(habit, dateKey); 47 | 48 | if (isSchedule) { 49 | const habitStats = stats?.[habit.id]; 50 | const statEntry = habitStats?.[dateKey]; 51 | 52 | if (statEntry) { 53 | weekData.push(isHabitCompleted(habit, statEntry)); 54 | } else { 55 | // No data for this date - treat as not completed 56 | const isFuture = isStrictlyGreaterDateYYMMDD( 57 | dateKey, 58 | getTodayDate(), 59 | ); 60 | 61 | if (isFuture) { 62 | weekData.push(-3); 63 | } else { 64 | weekData.push(0); 65 | } 66 | } 67 | } else { 68 | weekData.push(-2); 69 | } 70 | 71 | dayIndex++; 72 | } 73 | } 74 | monthlyData.push(weekData); 75 | 76 | // Break if we've covered all days in month 77 | if (dayIndex >= daysInMonth) break; 78 | } 79 | 80 | return monthlyData; 81 | }; 82 | -------------------------------------------------------------------------------- /src/features/habits/utils/analytics/streak.ts: -------------------------------------------------------------------------------- 1 | export const calculateStreaks = ( 2 | completionData: number[], 3 | ): { current: number; longest: number } => { 4 | let currentStreak = 0; 5 | let longestStreak = 0; 6 | let tempStreak = 0; 7 | 8 | // Calculate current streak from the end 9 | for (let i = completionData.length - 1; i >= 0; i--) { 10 | const day = completionData[i]; 11 | 12 | if (day === 1) { 13 | currentStreak++; 14 | } else if (day === 0) { 15 | break; // streak ends on first missed habit 16 | } else if (day < 0) { 17 | continue; // ignore -1, -2, -3 18 | } 19 | } 20 | 21 | // Calculate longest streak overall 22 | for (const day of completionData) { 23 | if (day === 1) { 24 | tempStreak++; 25 | longestStreak = Math.max(longestStreak, tempStreak); 26 | } else if (day === 0) { 27 | tempStreak = 0; 28 | } else if (day < 0) { 29 | // ignore 30 | continue; 31 | } 32 | } 33 | 34 | return { current: currentStreak, longest: longestStreak }; 35 | }; 36 | -------------------------------------------------------------------------------- /src/features/habits/utils/analytics/yearlyData.ts: -------------------------------------------------------------------------------- 1 | import { StoredHabit, HabitStats } from "@habits/types"; 2 | import { 3 | formatToYYMMDD, 4 | isHabitCompleted, 5 | isHabitScheduledForToday, 6 | } from "@habits/utils"; 7 | 8 | /** 9 | * Generates yearly data starting from habit.startDate with minimum 100 grids for nice analytics display 10 | */ 11 | export const generateYearlyRawData = ( 12 | habit: StoredHabit, 13 | stats?: HabitStats, 14 | ): number[] => { 15 | const yearData: number[] = []; 16 | const currentDate = new Date(); 17 | const habitStartDate = new Date(habit.startDate); 18 | const habitStats = stats?.[habit.id]; 19 | 20 | // Calculate the number of days from habit start date to current date 21 | const daysSinceStart = Math.floor( 22 | (currentDate.getTime() - habitStartDate.getTime()) / (1000 * 60 * 60 * 24), 23 | ); 24 | 25 | // Ensure we show at least 100 grids for better visual appeal 26 | // But cap at 365 days maximum 27 | const minGrids = 100; 28 | const maxGrids = 365; 29 | const actualDays = daysSinceStart + 1; // +1 to include the start date 30 | 31 | const totalGrids = Math.min(Math.max(actualDays, minGrids), maxGrids); 32 | 33 | for (let day = 0; day < totalGrids; day++) { 34 | const date = new Date(habitStartDate); 35 | date.setDate(habitStartDate.getDate() + day); 36 | 37 | const dateKey = formatToYYMMDD(date); 38 | const statEntry = habitStats?.[dateKey]; 39 | const isSchedule = isHabitScheduledForToday(habit, dateKey); 40 | 41 | if (date > currentDate) { 42 | // Future dates - show as empty/no data (-1) 43 | yearData.push(-1); 44 | } else { 45 | if (isSchedule) { 46 | if (statEntry) { 47 | // Past/current dates with data 48 | yearData.push(isHabitCompleted(habit, statEntry)); 49 | } else { 50 | // Past/current dates without data - treat as not completed 51 | yearData.push(0); 52 | } 53 | } else { 54 | yearData.push(-2); 55 | } 56 | } 57 | } 58 | 59 | return yearData; 60 | }; 61 | -------------------------------------------------------------------------------- /src/features/habits/utils/calendar/generateCalendar.ts: -------------------------------------------------------------------------------- 1 | import { DayData, HabitStats, StoredHabit } from "@habits/types"; 2 | import { isHabitScheduledForToday, formatToYYMMDD } from "@habits/utils"; 3 | 4 | export const generateCalendarData = ( 5 | startDate: Date, 6 | allHabits: StoredHabit[], 7 | allStats: HabitStats, 8 | ): DayData[] => { 9 | const today = new Date(); 10 | const rangeEnd = new Date(); 11 | rangeEnd.setFullYear(today.getFullYear() + 1); 12 | 13 | const days: DayData[] = []; 14 | const currentDate = new Date(startDate); 15 | 16 | while (currentDate <= rangeEnd) { 17 | const formattedDate = formatToYYMMDD(currentDate); 18 | 19 | let taskCount = 0; 20 | let completedTasks = 0; 21 | 22 | for (const habit of allHabits) { 23 | const isScheduled = isHabitScheduledForToday(habit, formattedDate); 24 | 25 | if (!isScheduled) continue; 26 | 27 | taskCount++; 28 | 29 | const statEntry = allStats?.[habit.id]?.[formattedDate]; 30 | 31 | if (!statEntry) continue; 32 | 33 | let isCompleted = false; 34 | 35 | switch (habit.trackingType) { 36 | case "task": 37 | isCompleted = statEntry.v >= 1; 38 | break; 39 | case "amount": 40 | isCompleted = statEntry.v >= habit.targetCount; 41 | break; 42 | case "time": 43 | isCompleted = statEntry.v >= habit.timeValue; 44 | break; 45 | } 46 | 47 | if (isCompleted) { 48 | completedTasks++; 49 | } 50 | } 51 | 52 | days.push({ 53 | date: new Date(currentDate), 54 | taskCount, 55 | completedTasks, 56 | isToday: currentDate.toDateString() === today.toDateString(), 57 | }); 58 | 59 | currentDate.setDate(currentDate.getDate() + 1); 60 | } 61 | 62 | return days; 63 | }; 64 | -------------------------------------------------------------------------------- /src/features/habits/utils/calendar/getCalendarFromHabits.ts: -------------------------------------------------------------------------------- 1 | import { generateCalendarData, getEarliestHabitStartDate } from "@habits/utils"; 2 | import { DayData, HabitStats, StoredHabit } from "@habits/types"; 3 | 4 | export function getCalendarFromHabits( 5 | habits: StoredHabit[], 6 | stats: HabitStats, 7 | ): DayData[] { 8 | const startDate = getEarliestHabitStartDate(habits); 9 | return generateCalendarData(startDate, habits, stats); 10 | } 11 | -------------------------------------------------------------------------------- /src/features/habits/utils/calendar/initialDate.ts: -------------------------------------------------------------------------------- 1 | import { StoredHabit } from "@habits/types"; 2 | 3 | export function getEarliestHabitStartDate(habits: StoredHabit[]): Date { 4 | if (habits.length === 0) return new Date(); // fallback to today 5 | 6 | const dates = habits 7 | .map((h) => new Date(h.startDate)) 8 | .filter((d) => !isNaN(d.getTime())); // filter out invalid dates 9 | 10 | return new Date(Math.min(...dates.map((d) => d.getTime()))); 11 | } 12 | -------------------------------------------------------------------------------- /src/features/habits/utils/dashboard/generateDashboardData.ts: -------------------------------------------------------------------------------- 1 | import { AnalyticsData, DashboardData } from "@habits/types"; 2 | 3 | export const generateDashboardData = ( 4 | analyticsData: AnalyticsData, 5 | ): DashboardData => { 6 | const data: DashboardData = { maxStreak: 0 }; 7 | 8 | Object.values(analyticsData).forEach((habitData) => { 9 | const monthlyStreak = habitData.monthly?.currentStreak || 0; 10 | const overallStreak = habitData.overall?.currentStreak || 0; 11 | 12 | const bestStreak = Math.max(monthlyStreak, overallStreak); 13 | 14 | if (bestStreak > data.maxStreak) { 15 | data.maxStreak = bestStreak; 16 | } 17 | }); 18 | 19 | return data; 20 | }; 21 | -------------------------------------------------------------------------------- /src/features/habits/utils/datetime/dateUtils.ts: -------------------------------------------------------------------------------- 1 | // src/utils/dateUtils.ts 2 | export const getTodayDate = (): string => { 3 | return new Date().toISOString().split("T")[0]; // "2025-07-20" 4 | }; 5 | 6 | // export const getDayOfWeek = (): string => { 7 | // const day = new Date().toLocaleDateString("en-US", { weekday: "short" }); // e.g. "Sun" 8 | // return day.slice(0, 2).toUpperCase(); // "SU" 9 | // }; 10 | 11 | 12 | 13 | export const getDayOfWeek = (date?: Date | string): string => { 14 | let targetDate: Date; 15 | 16 | if (!date) { 17 | targetDate = new Date(); // Use current date if no parameter 18 | } else if (typeof date === 'string') { 19 | targetDate = new Date(date); 20 | } else { 21 | targetDate = date; 22 | } 23 | 24 | // Validate the date 25 | if (isNaN(targetDate.getTime())) { 26 | throw new Error('Invalid date provided to getDayOfWeek'); 27 | } 28 | 29 | // Return 3-letter day abbreviation (Sun, Mon, Tue, etc.) 30 | return targetDate.toLocaleDateString("en-US", { weekday: "short" }).slice(0,2).toUpperCase(); 31 | }; 32 | 33 | 34 | 35 | 36 | 37 | 38 | // utils/timeUtils.ts 39 | export function formatTime(seconds: number): string { 40 | const mins = Math.floor(seconds / 60); 41 | const secs = seconds % 60; 42 | return `${mins.toString().padStart(2, "0")}:${secs.toString().padStart(2, "0")}`; 43 | } 44 | 45 | export const formatToYYMMDD = (date: Date | null): string => { 46 | if (!date) return "Never"; 47 | const year = date.getFullYear(); 48 | const month = `${date.getMonth() + 1}`.padStart(2, "0"); 49 | const day = `${date.getDate()}`.padStart(2, "0"); 50 | return `${year}-${month}-${day}`; 51 | }; 52 | 53 | export const formatLocalDate = ( 54 | startDate: string | Date, 55 | endDate: string | Date | null, 56 | ): { formattedStart: string; formattedEnd: string } => { 57 | const parse = (input: string | Date | null): Date | null => { 58 | if (!input) return null; 59 | if (typeof input === "string") { 60 | if (input.toLowerCase() === "never") return null; 61 | const parsed = new Date(input); 62 | return isNaN(parsed.getTime()) ? null : parsed; 63 | } 64 | return input instanceof Date && !isNaN(input.getTime()) ? input : null; 65 | }; 66 | 67 | const parsedStart = parse(startDate); 68 | const parsedEnd = parse(endDate); 69 | 70 | return { 71 | formattedStart: formatToYYMMDD(parsedStart), 72 | formattedEnd: formatToYYMMDD(parsedEnd), 73 | }; 74 | }; 75 | 76 | export function isStrictlyGreaterDateYYMMDD(a: string, b: string): boolean { 77 | const parse = (str: string) => { 78 | const [year, month, day] = str.split("-").map(Number); 79 | return new Date(year, month - 1, day); // month is 0-based 80 | }; 81 | 82 | const dateA = parse(a); 83 | const dateB = parse(b); 84 | 85 | return dateA.getTime() > dateB.getTime(); 86 | } 87 | -------------------------------------------------------------------------------- /src/features/habits/utils/index.ts: -------------------------------------------------------------------------------- 1 | // src/zenboard/features/habits/types/index.ts 2 | 3 | export * from "./store/createEntry"; 4 | export * from "./datetime/dateUtils"; 5 | export * from "./store/storage"; 6 | export * from "./store/isForToday"; 7 | export * from "./store/isHabitCompeleted"; 8 | 9 | export * from "./calendar/generateCalendar"; 10 | export * from "./calendar/initialDate"; 11 | export * from "./calendar/getCalendarFromHabits"; 12 | export * from "./analytics/generateAnalyticsData"; 13 | export * from "./dashboard/generateDashboardData"; 14 | 15 | export * from "./alarm/sound"; 16 | 17 | -------------------------------------------------------------------------------- /src/features/habits/utils/store/createEntry.ts: -------------------------------------------------------------------------------- 1 | import { HabitData } from "@habits/types"; // Adjust the path if needed 2 | 3 | export function createInitialStatEntry(habit: HabitData) { 4 | switch (habit.trackingType) { 5 | case "task": 6 | return { t: "task", v: 0 } as const; 7 | case "time": 8 | return { t: "time", v: 0 } as const; 9 | case "amount": 10 | return { t: "amount", v: 0 } as const; 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /src/features/habits/utils/store/isForToday.ts: -------------------------------------------------------------------------------- 1 | import { StoredHabit } from "@habits/types"; 2 | import { getDayOfWeek } from "@habits/utils"; 3 | 4 | // Check if today's date is within the habit's active range 5 | const isInValidDateRange = ( 6 | startDate: string, 7 | forDate: string, 8 | endDate?: string, 9 | ): boolean => { 10 | const today = new Date(forDate); 11 | const start = new Date(startDate); 12 | 13 | if (isNaN(start.getTime())) return false; // invalid start date 14 | 15 | if (endDate?.toLowerCase() === "never") return today >= start; 16 | if (!endDate) return today >= start; 17 | 18 | const end = new Date(endDate); 19 | if (isNaN(end.getTime())) return false; // invalid end date 20 | 21 | return today >= start && today <= end; 22 | }; 23 | 24 | // Calculate days since start date (inclusive - start date = 0 days) 25 | const calculateDaysSinceStart = (start: string, current: string): number => { 26 | const startDate = new Date(start); 27 | const currentDate = new Date(current); 28 | 29 | if (isNaN(startDate.getTime()) || isNaN(currentDate.getTime())) return -1; 30 | 31 | const diffTime = currentDate.getTime() - startDate.getTime(); 32 | return Math.floor(diffTime / (1000 * 60 * 60 * 24)); 33 | }; 34 | 35 | export const isHabitScheduledForToday = ( 36 | habit: StoredHabit, 37 | dateStr: string, 38 | ): boolean => { 39 | // Check if date is in valid range first 40 | const isInRange = isInValidDateRange( 41 | habit.startDate, 42 | dateStr, 43 | habit.endDate, 44 | ); 45 | if (!isInRange) return false; 46 | 47 | // Get day of week for the target date (not current date) 48 | const dayOfWeek = getDayOfWeek(dateStr); // Fixed: pass dateStr instead of using current date 49 | 50 | // Handle different repeatability types 51 | switch (habit.repeatability) { 52 | case "daily": 53 | return true; 54 | 55 | case "weekly": 56 | // Check if today's day of week is in selectedDays 57 | return habit.selectedDays?.includes(dayOfWeek) ?? false; 58 | 59 | case "every_few_days": { 60 | const interval = habit.repeatInterval ?? 1; 61 | if (interval <= 0) return false; 62 | 63 | const daysSinceStart = calculateDaysSinceStart(habit.startDate, dateStr); 64 | if (daysSinceStart < 0) return false; // Invalid dates 65 | 66 | return daysSinceStart % interval === 0; 67 | } 68 | 69 | case "monthly": { 70 | const interval = habit.repeatInterval ?? 1; 71 | if (interval <= 0) return false; 72 | 73 | const startDate = new Date(habit.startDate); 74 | const targetDate = new Date(dateStr); 75 | 76 | if (isNaN(startDate.getTime()) || isNaN(targetDate.getTime())) return false; 77 | 78 | // Check if it's the same day of month 79 | const sameDayOfMonth = targetDate.getDate() === startDate.getDate(); 80 | if (!sameDayOfMonth) return false; 81 | 82 | // Calculate month difference 83 | const monthDiff = 84 | targetDate.getFullYear() * 12 + 85 | targetDate.getMonth() - 86 | (startDate.getFullYear() * 12 + startDate.getMonth()); 87 | 88 | return monthDiff >= 0 && monthDiff % interval === 0; 89 | } 90 | 91 | default: 92 | // For custom schedules or other types, check selectedDays 93 | return habit.selectedDays?.includes(dayOfWeek) ?? false; 94 | } 95 | }; 96 | -------------------------------------------------------------------------------- /src/features/habits/utils/store/isHabitCompeleted.ts: -------------------------------------------------------------------------------- 1 | import {} from "@habits/utils"; 2 | 3 | import { StoredHabit, HabitStatEntry } from "@habits/types"; 4 | 5 | /** 6 | * Determines if a habit is completed based on its tracking type and stats 7 | */ 8 | export const isHabitCompleted = ( 9 | habit: StoredHabit, 10 | statEntry: HabitStatEntry, 11 | ): number => { 12 | if (!statEntry) return 0; 13 | 14 | switch (habit.trackingType) { 15 | case "task": 16 | return statEntry.t === "task" ? statEntry.v : 0; 17 | 18 | case "time": 19 | if (statEntry.t === "time" && "timeValue" in habit) { 20 | return statEntry.v >= habit.timeValue ? 1 : 0; 21 | } 22 | return 0; 23 | 24 | case "amount": 25 | if (statEntry.t === "amount" && "targetCount" in habit) { 26 | return statEntry.v >= habit.targetCount ? 1 : 0; 27 | } 28 | return 0; 29 | 30 | default: 31 | return 0; 32 | } 33 | }; 34 | -------------------------------------------------------------------------------- /src/features/habits/utils/store/storage.ts: -------------------------------------------------------------------------------- 1 | import type ZenBoardPlugin from "@src/main"; 2 | 3 | let cache: Record = {}; 4 | let isLoaded = false; 5 | let pluginInstance: ZenBoardPlugin | null = null; 6 | 7 | /** 8 | * Must be called in onload() before using get/set 9 | */ 10 | export const initStorage = async (plugin: ZenBoardPlugin) => { 11 | if (isLoaded) { 12 | console.warn("Zenboard storage already initialized — skipping."); 13 | return; 14 | } 15 | 16 | try { 17 | cache = (await plugin.loadData()) ?? {}; 18 | isLoaded = true; 19 | pluginInstance = plugin; 20 | } catch (err) { 21 | console.error("Failed to initialize Zenboard storage:", err); 22 | cache = {}; // fallback to empty cache 23 | isLoaded = true; // prevent endless init loop 24 | pluginInstance = plugin; 25 | } 26 | }; 27 | 28 | export const getFromStorage = async (key: string): Promise => { 29 | if (!isLoaded) { 30 | throw new Error("Storage not initialized. Call initStorage() in onload() first."); 31 | } 32 | return cache[key] ?? null; 33 | }; 34 | 35 | export const setToStorage = async (key: string, value: T): Promise => { 36 | if (!isLoaded) { 37 | throw new Error("Storage not initialized. Call initStorage() in onload() first."); 38 | } 39 | 40 | cache[key] = value; 41 | 42 | if (!pluginInstance) { 43 | console.warn("No plugin instance found — data change not persisted."); 44 | return; 45 | } 46 | 47 | try { 48 | await pluginInstance.saveData(cache); 49 | } catch (err) { 50 | console.error("Failed to save Zenboard storage:", err); 51 | } 52 | }; 53 | -------------------------------------------------------------------------------- /src/features/habits/utils/uiux/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./validDate"; 2 | -------------------------------------------------------------------------------- /src/features/habits/utils/uiux/validDate.ts: -------------------------------------------------------------------------------- 1 | export const parseLocalDate = (dateStr: string): Date => { 2 | const [year, month, day] = dateStr.split("-").map(Number); 3 | return new Date(year, month - 1, day); 4 | }; 5 | 6 | export const isValidDateRange = (startStr: string, endStr: string): boolean => { 7 | const start = parseLocalDate(startStr); 8 | const end = parseLocalDate(endStr); 9 | return end >= start; 10 | }; 11 | -------------------------------------------------------------------------------- /src/input.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; 4 | 5 | /* ===== 🎨 Backgrounds ===== */ 6 | .bg-primary { 7 | background-color: var(--background-primary); 8 | } 9 | 10 | .bg-secondary { 11 | background-color: var(--background-secondary); 12 | } 13 | 14 | .bg-hover { 15 | background-color: var(--background-modifier-hover); 16 | } 17 | 18 | .bg-active-hover { 19 | background-color: var(--background-modifier-active-hover); 20 | } 21 | 22 | /* ===== 🪟 Borders ===== */ 23 | .border-default { 24 | border: 1px solid var(--background-modifier-border); 25 | } 26 | 27 | .border-hover { 28 | border-color: var(--background-modifier-border-hover); 29 | } 30 | 31 | .border-focus { 32 | border-color: var(--background-modifier-border-focus); 33 | } 34 | 35 | /* ===== 🖋 Text Colors ===== */ 36 | .text-default { 37 | color: var(--text-normal); 38 | } 39 | 40 | .text-muted { 41 | color: var(--text-muted); 42 | } 43 | 44 | .text-faint { 45 | color: var(--text-faint); 46 | } 47 | 48 | .text-accent { 49 | color: var(--text-accent); 50 | } 51 | 52 | .text-accent-hover:hover { 53 | color: var(--text-accent-hover); 54 | } 55 | 56 | .text-on-accent { 57 | color: var(--text-on-accent); 58 | } 59 | 60 | /* ===== 🧩 Interactive ===== */ 61 | .btn-base { 62 | background-color: var(--interactive-normal); 63 | color: var(--text-on-accent); 64 | } 65 | 66 | .btn-base:hover { 67 | background-color: var(--interactive-hover); 68 | } 69 | 70 | .btn-accent { 71 | background-color: var(--interactive-accent); 72 | color: var(--text-on-accent); 73 | } 74 | 75 | .btn-accent:hover { 76 | background-color: var(--interactive-accent-hover); 77 | } 78 | 79 | /* ===== 📏 Spacing ===== */ 80 | .px-4-1 { 81 | padding-left: var(--size-4-1); 82 | padding-right: var(--size-4-1); 83 | } 84 | 85 | .py-4-2 { 86 | padding-top: var(--size-4-2); 87 | padding-bottom: var(--size-4-2); 88 | } 89 | 90 | .gap-4-3 { 91 | gap: var(--size-4-3); 92 | } 93 | 94 | .m-4-4 { 95 | margin: var(--size-4-4); 96 | } 97 | 98 | /* ===== 🌀 Border Radius ===== */ 99 | .rounded-s { 100 | border-radius: var(--radius-s); 101 | } 102 | 103 | .rounded-m { 104 | border-radius: var(--radius-m); 105 | } 106 | 107 | .rounded-l { 108 | border-radius: var(--radius-l); 109 | } 110 | -------------------------------------------------------------------------------- /src/main.ts: -------------------------------------------------------------------------------- 1 | import { Plugin } from 'obsidian'; 2 | import { initStorage } from '@features/habits/utils'; 3 | import { ZenboardDashboardView, VIEW_TYPE_ZENBOARD } from './views/ZenboardView'; 4 | import { clearAllTimers } from '@habits/store//actions'; 5 | 6 | export default class ZenboardPlugin extends Plugin { 7 | async onload() { 8 | // Initialize storage 9 | await initStorage(this); 10 | 11 | // Register dashboard view 12 | this.registerView( 13 | VIEW_TYPE_ZENBOARD, 14 | (leaf) => new ZenboardDashboardView(leaf) 15 | ); 16 | 17 | // Ribbon icon to open React pane 18 | const ribbonIconEl = this.addRibbonIcon( 19 | 'target', 20 | 'Zenboard: Open dashboard', 21 | async () => { 22 | await this.activateDashboardView(); 23 | } 24 | ); 25 | ribbonIconEl.addClass('zenboard-ribbon-icon'); 26 | 27 | // Add command to open dashboard 28 | this.addCommand({ 29 | id: 'open-dashboard', 30 | name: 'Open Zenboard', 31 | callback: async () => { 32 | await this.activateDashboardView(); 33 | } 34 | }); 35 | } 36 | 37 | async activateDashboardView() { 38 | // Check if view already exists 39 | const existingLeaves = this.app.workspace.getLeavesOfType(VIEW_TYPE_ZENBOARD); 40 | 41 | if (existingLeaves.length > 0) { 42 | // If view exists, just activate it 43 | this.app.workspace.revealLeaf(existingLeaves[0]); 44 | return; 45 | } 46 | 47 | // Create new leaf if none exists 48 | const leaf = this.app.workspace.getLeaf(false); 49 | if (!leaf) { 50 | return; 51 | } 52 | 53 | try { 54 | await leaf.setViewState({ 55 | type: VIEW_TYPE_ZENBOARD, 56 | active: true, 57 | }); 58 | } catch (error) { 59 | console.error('Failed to activate Zenboard view:', error); 60 | } 61 | } 62 | 63 | onunload() { 64 | // Clear Timers 65 | clearAllTimers() 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /src/views/ZenboardView.tsx: -------------------------------------------------------------------------------- 1 | import { ItemView, WorkspaceLeaf } from "obsidian"; 2 | import * as ReactDOM from "react-dom/client"; 3 | import App from "../app/App"; 4 | 5 | export const VIEW_TYPE_ZENBOARD = "zenboard-view"; 6 | 7 | export class ZenboardDashboardView extends ItemView { 8 | private root: ReactDOM.Root; 9 | 10 | constructor(leaf: WorkspaceLeaf) { 11 | super(leaf); 12 | } 13 | 14 | getViewType(): string { 15 | return VIEW_TYPE_ZENBOARD; 16 | } 17 | 18 | getDisplayText(): string { 19 | return "Zenboard"; 20 | } 21 | 22 | getIcon(): string { 23 | return "layout-dashboard"; 24 | } 25 | 26 | async onOpen() { 27 | 28 | const container = this.containerEl.children[1]; 29 | if (!container) { 30 | return; 31 | } 32 | this.root = ReactDOM.createRoot(container); 33 | this.root.render(); 34 | } 35 | 36 | async onClose() { 37 | if (this.root) { 38 | this.root.unmount(); 39 | } 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /tailwind.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('tailwindcss').Config} */ 2 | module.exports = { 3 | content: ["./src/**/*.{js,ts,jsx,tsx}"], // Scan these files for classes 4 | important: '#zenboard-container', // Ensures styles work in Obsidian's shadow DOM 5 | corePlugins: { 6 | preflight: false, // Disable default CSS reset (avoids Obsidian conflicts) 7 | }, 8 | theme: { 9 | extend: {}, 10 | }, 11 | plugins: [], 12 | } 13 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "baseUrl": ".", 4 | "paths": { 5 | // App level 6 | "@app/*": [ 7 | "src/app/*" 8 | ], 9 | "@views/*": [ 10 | "src/views/*" 11 | ], 12 | "@context/*": [ 13 | "src/context/*" 14 | ], 15 | // Features 16 | "@features/*": [ 17 | "src/features/*" 18 | ], 19 | // Settings feature 20 | "@settings/*": [ 21 | "src/features/settings/*" 22 | ], 23 | "@settings/components/*": [ 24 | "src/features/settings/views/components/*" 25 | ], 26 | "@settings/types/*": [ 27 | "src/features/settings/types/*" 28 | ], 29 | "@settings/hooks/*": [ 30 | "src/features/settings/hooks/*" 31 | ], 32 | // Habits feature 33 | "@habits/*": [ 34 | "src/features/habits/*" 35 | ], 36 | "@habits/components/*": [ 37 | "src/features/habits/components/*" 38 | ], 39 | "@habits/hooks/*": [ 40 | "src/features/habits/hooks/*" 41 | ], 42 | "@habits/store/*": [ 43 | "src/features/habits/store/*" 44 | ], 45 | "@habits/types/*": [ 46 | "src/features/habits/types/*" 47 | ], 48 | "@habits/utils/*": [ 49 | "src/features/habits/utils/*" 50 | ], 51 | "@habits/controller/*": [ 52 | "src/features/habits/controller/*" 53 | ], 54 | // Shared 55 | "@shared/*": [ 56 | "src/shared/*" 57 | ], 58 | "@components/*": [ 59 | "src/shared/components/*" 60 | ], 61 | "@hooks/*": [ 62 | "src/shared/hooks/*" 63 | ], 64 | "@utils/*": [ 65 | "src/shared/utils/*" 66 | ], 67 | // Root level 68 | "@src/*": [ 69 | "src/*" 70 | ], 71 | "@assets/*": [ 72 | "src/assets/*" 73 | ], 74 | "@locales/*": [ 75 | "src/locales/*" 76 | ], 77 | "@pages/*": [ 78 | "src/pages/*" 79 | ] 80 | }, 81 | "inlineSourceMap": true, 82 | "inlineSources": true, 83 | "module": "ESNext", 84 | "target": "ES6", 85 | "allowJs": true, 86 | "noImplicitAny": true, 87 | "moduleResolution": "node", 88 | "importHelpers": true, 89 | "isolatedModules": true, 90 | "strictNullChecks": true, 91 | "lib": [ 92 | "DOM", 93 | "ES2020" 94 | ], 95 | "jsx": "react-jsx", 96 | "allowSyntheticDefaultImports": true 97 | }, 98 | "include": [ 99 | "src/**/*.ts", 100 | "src/**/*.tsx" 101 | ] 102 | } 103 | -------------------------------------------------------------------------------- /version-bump.mjs: -------------------------------------------------------------------------------- 1 | import { readFileSync, writeFileSync } from "fs"; 2 | 3 | const targetVersion = process.env.npm_package_version; 4 | 5 | // read minAppVersion from manifest.json and bump version to target version 6 | let manifest = JSON.parse(readFileSync("manifest.json", "utf8")); 7 | const { minAppVersion } = manifest; 8 | manifest.version = targetVersion; 9 | writeFileSync("manifest.json", JSON.stringify(manifest, null, "\t")); 10 | 11 | // update versions.json with target version and minAppVersion from manifest.json 12 | let versions = JSON.parse(readFileSync("versions.json", "utf8")); 13 | versions[targetVersion] = minAppVersion; 14 | writeFileSync("versions.json", JSON.stringify(versions, null, "\t")); 15 | -------------------------------------------------------------------------------- /versions.json: -------------------------------------------------------------------------------- 1 | { 2 | "1.0.0": "1.6.5" 3 | } 4 | --------------------------------------------------------------------------------