├── .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 | 
28 |
29 | ### Monthly Analytics
30 | 
31 |
32 | ### Yearly Analytics
33 | 
34 |
35 | ### All Habits View
36 | 
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 |
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 |
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 |
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 |
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 |
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 |
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 |
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 |
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 |
517 |
518 |
519 |
520 |
521 | {/* ==================================================================================== */}
522 | {/* RIGHT COLUMN */}
523 | {/* ==================================================================================== */}
524 |
525 |
526 | {/* Repeatability Settings */}
527 |
528 |
531 |
532 |
533 |
535 | setIsRepeatDropdownOpen(!isRepeatDropdownOpen)
536 | }
537 | className="w-full h-10 bg-secondary border-default items-center justify-start text-default placeholder-muted focus:outline-none focus:ring-2 focus:ring-accent focus:border-transparent transition-all duration-200"
538 | >
539 |
540 | {
541 | repeatOptions.find((opt) => opt.value === repeatability)
542 | ?.label
543 | }
544 |
545 |
549 |
550 |
551 | {isRepeatDropdownOpen && (
552 |
553 | {repeatOptions.map((option) => (
554 |
557 | handleRepeatabilityChange(option.value)
558 | }
559 | className="w-full mt-2 text-left px-4 py-3 text-default hover:bg-active-hover hover:text-accent transition-colors duration-150"
560 | >
561 | {option.label}
562 |
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 |
598 | repeatability === "weekdays"
599 | ? toggleDay(key)
600 | : setSelectedDays([key])
601 | }
602 | className={`px-3 py-2 text-sm font-medium transition-all duration-300 transform hover:scale-105 ${selectedDays.includes(key)
603 | ? "bg-blue-500 text-on-accent shadow-lg"
604 | : "bg-secondary text-muted hover:bg-hover"
605 | }`}
606 | >
607 | {label}
608 |
609 | ))}
610 |
611 | )}
612 |
613 |
614 | {/* Reminders Section */}
615 |
616 |
619 |
620 |
621 | This feature is coming soon
622 |
623 |
624 |
625 | {/* Date Pickers */}
626 |
627 |
628 |
631 |
637 |
638 |
639 |
640 |
641 | {/* ======================================================================================== */}
642 | {/* ACTION BUTTONS */}
643 | {/* ======================================================================================== */}
644 |
645 |
646 |
650 | Cancel
651 |
652 |
657 | Save
658 |
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 | handleColorSelect(color)}
682 | className={`w-12 h-12 rounded-m transition-transform duration-200 hover:scale-110 ${selectedColor === color
683 | ? "ring-2 ring-accent ring-offset-2 ring-offset-primary"
684 | : ""
685 | }`}
686 | style={{ backgroundColor: color }}
687 | />
688 | ))}
689 |
690 |
691 |
694 |
695 | setCustomColor(e.target.value)}
699 | className="w-12 h-10 rounded-m border-none cursor-pointer"
700 | />
701 |
705 | Select
706 |
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 | handleIconSelect(name)}
732 | className={`w-12 h-12 rounded-m flex items-center justify-center transition-all duration-200 hover:scale-110 ${selectedIcon === name
733 | ? "ring-2 ring-accent ring-offset-2 ring-offset-primary btn-accent"
734 | : "bg-secondary hover:bg-hover"
735 | }`}
736 | >
737 |
745 |
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 |
97 |
98 |
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 |
166 |
167 |
168 |
169 |
170 | Are you sure you want to delete{" "}
171 |
172 | {task.name}
173 |
174 | ? This action cannot be undone.
175 |
176 |
177 |
181 | Cancel
182 |
183 |
187 | Delete
188 |
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 | taskDone(task.id)}
19 | className={`
20 | w-14 h-14 rounded-full flex items-center justify-center
21 | transition-all duration-200 ease-out active:scale-95
22 | border-2 bg-white shadow-sm hover:shadow-md
23 | `}
24 | style={{
25 | backgroundColor: task.color,
26 | borderColor: task.color,
27 | }}
28 | >
29 |
30 |
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 |
68 |
69 |
incrementHabit(task.id)}
71 | className={`
72 | absolute inset-0 w-14 h-14 rounded-full flex flex-col items-center justify-center
73 | transition-all duration-200 ease-out active:scale-95 hover:shadow-md
74 | `}
75 | style={{ backgroundColor: `${task.color}15` }}
76 | >
77 |
81 | {task.counterValue || 0}
82 |
83 |
84 |
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 |
123 |
124 |
toggleTimer(task.id)}
126 | className={`
127 | absolute inset-0 w-14 h-14 rounded-full flex items-center justify-center
128 | transition-all duration-200 ease-out active:scale-95 hover:shadow-md
129 | `}
130 | style={{
131 | backgroundColor: task.isTimerRunning
132 | ? `${task.color}25`
133 | : `${task.color}15`,
134 | }}
135 | >
136 | {task.isTimerRunning ? (
137 |
138 | ) : (
139 |
145 | )}
146 |
147 |
148 | {(task.timerElapsed || 0) > 0 && !task.isTimerRunning && (
149 |
resetTimer(task.id)}
151 | className="absolute -top-1 -right-1 w-4.5 h-4.5 rounded-full flex items-center justify-center
152 | bg-gray-100 hover:bg-gray-200 active:bg-gray-300 shadow-sm transition-all duration-200 ease-out active:scale-90"
153 | >
154 |
155 |
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 |
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 |
43 |
44 | Add new task
45 |
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 |
--------------------------------------------------------------------------------