Compare commits

...

210 Commits

Author SHA1 Message Date
f64fc274a7 Adds Playwright E2E and Vitest test infrastructure
Some checks failed
Lint / Lint and Check (push) Failing after 48s
Integrates Playwright for end-to-end browser testing with automated web server setup, example smoke tests, and CI-compatible configuration. Introduces Vitest, Testing Library, and related utilities for fast component and unit testing.

Updates scripts, development dependencies, and lockfile to support both test suites. Establishes unified testing commands for local and CI workflows, laying groundwork for comprehensive automated UI and integration coverage.
2025-11-24 22:43:46 +01:00
65f1fcb7bb Adds habit edit, archive, and undo log features
Enables users to update or archive habits and to undo the latest habit log.
Adds PATCH/DELETE API endpoints for habit edit and soft deletion.
Introduces UI dialogs and controls for editing and archiving habits,
as well as for undoing the most recent log entry directly from the dashboard.
Improves log statistics handling by ordering and simplifies last log detection.
2025-11-24 22:12:25 +01:00
55950e9473 Plan 2025-11-24 22:02:40 +01:00
9b9cdfbfab chore(deps): update pnpm to v10.23.0
All checks were successful
renovate/stability-days Updates have met minimum release age requirement
Lint / Lint and Check (push) Successful in 41s
2025-11-23 15:01:34 +00:00
c1270381e0 chore(deps): update actions/checkout action to v6
All checks were successful
Lint / Lint and Check (push) Successful in 42s
2025-11-22 09:42:10 +01:00
e5b645e6ab fix(deps): update dependency lucide-react to ^0.554.0
All checks were successful
renovate/stability-days Updates have met minimum release age requirement
Lint / Lint and Check (push) Successful in 37s
2025-11-22 04:03:19 +00:00
d719b11f77 chore(deps): update dependency typescript-eslint to v8.47.0
All checks were successful
renovate/stability-days Updates have met minimum release age requirement
Lint / Lint and Check (push) Successful in 42s
2025-11-22 03:04:02 +00:00
9861af1e1c chore(deps): update dependency @types/react to v19.2.6
All checks were successful
renovate/stability-days Updates have met minimum release age requirement
Lint / Lint and Check (push) Successful in 41s
2025-11-22 02:04:41 +00:00
854a7b97d6 chore(deps): update dependency @tanstack/react-query to v5.90.10
All checks were successful
renovate/stability-days Updates have met minimum release age requirement
Lint / Lint and Check (push) Successful in 42s
2025-11-22 01:04:59 +00:00
af97e8bccd chore(deps): update actions/checkout digest to 93cb6ef
All checks were successful
Lint / Lint and Check (push) Successful in 37s
2025-11-22 00:03:51 +00:00
640e25ce12 chore(deps): update dependency drizzle-kit to v0.31.7
All checks were successful
renovate/stability-days Updates have met minimum release age requirement
Lint / Lint and Check (push) Successful in 32s
2025-11-16 21:02:11 +00:00
55a0729628 try db lazy loading for coolify build
All checks were successful
Lint / Lint and Check (push) Successful in 45s
2025-11-15 19:24:32 +01:00
5bb861d881 env config
All checks were successful
Lint / Lint and Check (push) Successful in 41s
2025-11-15 17:23:10 +01:00
2f6b0ed098 remove dotenv
All checks were successful
Lint / Lint and Check (push) Successful in 41s
2025-11-15 17:00:33 +01:00
b630a0ca33 linter issues
All checks were successful
Lint / Lint and Check (push) Successful in 40s
2025-11-15 16:08:05 +01:00
2089e5d01d fix eslint & update packages 2025-11-15 16:03:45 +01:00
a18ae4f3df next 16 2025-11-15 15:39:31 +01:00
d03ed2b4d5 chore(deps): update pnpm to v10.22.0
All checks were successful
renovate/stability-days Updates have met minimum release age requirement
Lint / Lint and Check (push) Successful in 30s
2025-11-15 14:03:25 +00:00
f69c73392e chore(deps): update dependency @tanstack/react-query to v5.90.8
All checks were successful
renovate/stability-days Updates have met minimum release age requirement
Lint / Lint and Check (push) Successful in 29s
2025-11-15 13:02:59 +00:00
a2550f370e chore(deps): update pnpm to v10.21.0
All checks were successful
renovate/stability-days Updates have met minimum release age requirement
Lint / Lint and Check (push) Successful in 37s
2025-11-15 08:03:42 +00:00
0272f71822 chore(deps): update dependency tailwind-merge to v3.4.0
All checks were successful
renovate/stability-days Updates have met minimum release age requirement
Lint / Lint and Check (push) Successful in 30s
2025-11-15 07:04:10 +00:00
1278c134a9 chore(deps): update dependency @types/react-dom to v19.2.3
All checks were successful
renovate/stability-days Updates have met minimum release age requirement
Lint / Lint and Check (push) Successful in 30s
2025-11-15 06:05:27 +00:00
dc00bddfc0 chore(deps): update typescript-eslint monorepo to v8.46.4
All checks were successful
renovate/stability-days Updates have met minimum release age requirement
Lint / Lint and Check (push) Successful in 31s
2025-11-15 04:04:34 +00:00
7d500a04cd chore(deps): update dependency turbo to v2.6.1
All checks were successful
renovate/stability-days Updates have met minimum release age requirement
Lint / Lint and Check (push) Successful in 31s
2025-11-15 03:05:47 +00:00
91fc73e57b chore(deps): update dependency next-plausible to v3.12.5
All checks were successful
renovate/stability-days Updates have met minimum release age requirement
Lint / Lint and Check (push) Successful in 27s
2025-11-15 02:05:41 +00:00
f016bfedd6 chore(deps): update dependency @types/react to v19.2.3
All checks were successful
renovate/stability-days Updates have met minimum release age requirement
Lint / Lint and Check (push) Successful in 51s
2025-11-15 01:05:57 +00:00
8d752d681d chore(deps): update dependency @types/node to v24.10.1
All checks were successful
renovate/stability-days Updates have met minimum release age requirement
Lint / Lint and Check (push) Successful in 26s
2025-11-15 00:05:20 +00:00
78305bcc9b fix(deps): update dependency lucide-react to ^0.553.0
All checks were successful
Lint / Lint and Check (push) Successful in 30s
2025-11-08 06:03:44 +00:00
41be40e21f chore(deps): update eslint monorepo to v9.39.1
All checks were successful
Lint / Lint and Check (push) Successful in 27s
2025-11-08 05:04:26 +00:00
7c15a1b2d4 chore(deps): update dependency @types/node to v24.10.0
All checks were successful
Lint / Lint and Check (push) Successful in 27s
2025-11-08 04:04:43 +00:00
d8df994b8c chore(deps): update typescript-eslint monorepo to v8.46.3
All checks were successful
Lint / Lint and Check (push) Successful in 27s
2025-11-08 03:18:04 +00:00
090b59bc4a chore(deps): update tailwindcss monorepo to v4.1.17
All checks were successful
Lint / Lint and Check (push) Successful in 28s
2025-11-08 02:07:03 +00:00
f987a6d271 chore(deps): update radix-ui-primitives monorepo
All checks were successful
Lint / Lint and Check (push) Successful in 49s
2025-11-08 01:07:15 +00:00
6500befb13 chore(deps): update dependency @tanstack/react-query to v5.90.7
All checks were successful
Lint / Lint and Check (push) Successful in 27s
2025-11-08 00:05:41 +00:00
786c74282d chore(deps): update dependency node to v24
All checks were successful
Lint / Lint and Check (pull_request) Successful in 26s
Lint / Lint and Check (push) Successful in 37s
2025-11-01 16:37:19 +01:00
5116630d8f chore(deps): update actions/setup-node action to v6 (#18)
All checks were successful
Lint / Lint and Check (push) Successful in 29s
This PR contains the following updates:

| Package | Type | Update | Change |
|---|---|---|---|
| [actions/setup-node](https://github.com/actions/setup-node) | action | major | `v5` -> `v6` |

---

### Release Notes

<details>
<summary>actions/setup-node (actions/setup-node)</summary>

### [`v6`](https://github.com/actions/setup-node/compare/v5...v6)

[Compare Source](https://github.com/actions/setup-node/compare/v5...v6)

</details>

---

### Configuration

📅 **Schedule**: Branch creation - "every weekend" (UTC), Automerge - At any time (no schedule defined).

🚦 **Automerge**: Disabled by config. Please merge this manually once you are satisfied.

♻ **Rebasing**: Whenever PR becomes conflicted, or you tick the rebase/retry checkbox.

🔕 **Ignore**: Close this PR and you won't be reminded about this update again.

---

 - [ ] <!-- rebase-check -->If you want to rebase/retry this PR, check this box

---

This PR has been generated by [Renovate Bot](https://github.com/renovatebot/renovate).
<!--renovate-debug:eyJjcmVhdGVkSW5WZXIiOiI0MS4xMjIuMyIsInVwZGF0ZWRJblZlciI6IjQxLjEyMi4zIiwidGFyZ2V0QnJhbmNoIjoibWFpbiIsImxhYmVscyI6W119-->

Reviewed-on: #18
Co-authored-by: Renovate Bot <renovatebot@schulze.network>
Co-committed-by: Renovate Bot <renovatebot@schulze.network>
2025-11-01 16:36:53 +01:00
f3511063a4 Revert "chore(deps): update eslint monorepo to v9.39.0 (#21)"
All checks were successful
Lint / Lint and Check (push) Successful in 38s
This reverts commit 51401e1e80.
2025-11-01 16:33:36 +01:00
167d3e1e84 chore(deps): update dependency @tanstack/react-query to v5.90.6 (#23)
Some checks failed
Lint / Lint and Check (push) Failing after 22s
Co-authored-by: Renovate Bot <renovatebot@schulze.network>
Co-committed-by: Renovate Bot <renovatebot@schulze.network>
2025-11-01 16:25:03 +01:00
70c978f199 fix(deps): update dependency lucide-react to ^0.552.0 (#22)
Some checks failed
Lint / Lint and Check (push) Failing after 22s
Co-authored-by: Renovate Bot <renovatebot@schulze.network>
Co-committed-by: Renovate Bot <renovatebot@schulze.network>
2025-11-01 08:21:43 +01:00
51401e1e80 chore(deps): update eslint monorepo to v9.39.0 (#21)
Some checks failed
Lint / Lint and Check (push) Failing after 23s
Co-authored-by: Renovate Bot <renovatebot@schulze.network>
Co-committed-by: Renovate Bot <renovatebot@schulze.network>
2025-11-01 07:03:47 +01:00
8ec8716300 chore(deps): update pnpm to v10.20.0
Some checks failed
Lint / Lint and Check (push) Has been cancelled
2025-11-01 05:04:18 +00:00
ed16749fc9 chore(deps): update dependency turbo to v2.6.0
All checks were successful
Lint / Lint and Check (push) Successful in 28s
2025-11-01 04:04:40 +00:00
29a995deb9 chore(deps): update dependency drizzle-kit to v0.31.6
All checks were successful
Lint / Lint and Check (push) Successful in 29s
2025-11-01 03:05:26 +00:00
4db001a1fb chore(deps): update dependency cssnano to v7.1.2
All checks were successful
Lint / Lint and Check (push) Successful in 28s
2025-11-01 02:06:26 +00:00
020645a1f8 chore(deps): update dependency @types/pg to v8.15.6
All checks were successful
Lint / Lint and Check (push) Successful in 56s
2025-11-01 01:06:49 +00:00
5d61249bfc chore(deps): update dependency @types/node to v22.18.13
All checks were successful
Lint / Lint and Check (push) Successful in 28s
2025-11-01 00:06:13 +00:00
41777d6215 fix(deps): update dependency lucide-react to ^0.548.0
All checks were successful
Lint / Lint and Check (push) Successful in 32s
2025-10-25 05:03:56 +00:00
f6c6caa67d chore(deps): update pnpm to v10.19.0
All checks were successful
Lint / Lint and Check (push) Successful in 34s
2025-10-25 04:04:29 +00:00
853e882198 chore(deps): update typescript-eslint monorepo to v8.46.2
All checks were successful
Lint / Lint and Check (push) Successful in 28s
2025-10-25 03:05:14 +00:00
74ef49821e chore(deps): update tailwindcss monorepo to v4.1.16
All checks were successful
Lint / Lint and Check (push) Successful in 30s
2025-10-25 02:05:37 +00:00
36f0fbd87d chore(deps): update dependency drizzle-orm to v0.44.7
All checks were successful
Lint / Lint and Check (push) Successful in 30s
2025-10-25 01:05:51 +00:00
1ae54a78eb chore(deps): update dependency @types/node to v22.18.12
All checks were successful
Lint / Lint and Check (push) Successful in 33s
2025-10-25 00:05:26 +00:00
088afc042e fix(deps): update dependency lucide-react to ^0.546.0
All checks were successful
Lint / Lint and Check (push) Successful in 28s
2025-10-18 08:02:23 +00:00
d2e7715a33 chore(deps): update eslint monorepo to v9.38.0
All checks were successful
Lint / Lint and Check (push) Successful in 33s
2025-10-18 07:02:43 +00:00
44df200838 chore(deps): update dependency prettier-plugin-tailwindcss to v0.7.1
All checks were successful
Lint / Lint and Check (push) Successful in 36s
2025-10-18 06:03:28 +00:00
3919f4dc6c fix(deps): update nextjs monorepo to v15.5.6
All checks were successful
Lint / Lint and Check (push) Successful in 28s
2025-10-18 05:03:47 +00:00
0ce02d2f00 chore(deps): update typescript-eslint monorepo to v8.46.1
All checks were successful
Lint / Lint and Check (push) Successful in 32s
2025-10-18 04:04:43 +00:00
0c7816c8fd chore(deps): update pnpm to v10.18.3
All checks were successful
Lint / Lint and Check (push) Successful in 26s
2025-10-18 03:04:49 +00:00
a9173b6589 chore(deps): update dependency @types/react-dom to v19.2.2
All checks were successful
Lint / Lint and Check (push) Successful in 27s
2025-10-18 02:05:30 +00:00
0d9a063ad4 chore(deps): update dependency @types/node to v22.18.11
All checks were successful
Lint / Lint and Check (push) Successful in 26s
2025-10-18 01:06:24 +00:00
68fe96f838 chore(deps): update dependency @tanstack/react-query to v5.90.5
All checks were successful
Lint / Lint and Check (push) Successful in 27s
2025-10-18 00:05:20 +00:00
9ef3e83307 chore(deps): update dependency @types/node to v22.18.10
All checks were successful
Lint / Lint and Check (push) Successful in 28s
2025-10-11 15:02:51 +00:00
c9e14aeecd fix(deps): update dependency lucide-react to ^0.545.0
All checks were successful
Lint / Lint and Check (push) Successful in 28s
2025-10-11 05:05:23 +00:00
608f6ae1af chore(deps): update typescript-eslint monorepo to v8.46.0
All checks were successful
Lint / Lint and Check (push) Successful in 28s
2025-10-11 04:05:55 +00:00
3ec58d99a7 chore(deps): update react monorepo
All checks were successful
Lint / Lint and Check (push) Successful in 27s
2025-10-11 03:06:29 +00:00
200d4bde02 chore(deps): update pnpm to v10.18.2
All checks were successful
Lint / Lint and Check (push) Successful in 27s
2025-10-11 02:06:27 +00:00
24260b0f72 chore(deps): update dependency @types/node to v22.18.9
All checks were successful
Lint / Lint and Check (push) Successful in 27s
2025-10-11 01:07:13 +00:00
30ed468ef3 chore(deps): update pnpm/action-setup digest to 41ff726
All checks were successful
Lint / Lint and Check (push) Successful in 26s
2025-10-11 00:05:36 +00:00
2982995ce9 fix(deps): update react monorepo to v19.2.0
All checks were successful
Lint / Lint and Check (push) Successful in 33s
2025-10-04 08:03:22 +00:00
11071f3150 chore(deps): update typescript-eslint monorepo to v8.45.0
All checks were successful
Lint / Lint and Check (push) Successful in 33s
2025-10-04 07:04:09 +00:00
41f1ee4d0c chore(deps): update pnpm to v10.18.0
All checks were successful
Lint / Lint and Check (push) Successful in 32s
2025-10-04 06:04:13 +00:00
a0bfab2404 chore(deps): update eslint monorepo to v9.37.0
All checks were successful
Lint / Lint and Check (push) Successful in 31s
2025-10-04 05:04:46 +00:00
593d845969 chore(deps): update tailwindcss monorepo to v4.1.14
All checks were successful
Lint / Lint and Check (push) Successful in 33s
2025-10-04 04:05:20 +00:00
b76730c927 chore(deps): update dependency typescript to v5.9.3
All checks were successful
Lint / Lint and Check (push) Successful in 27s
2025-10-04 03:05:52 +00:00
0f8cc9dfe6 chore(deps): update dependency drizzle-orm to v0.44.6
All checks were successful
Lint / Lint and Check (push) Successful in 27s
2025-10-04 02:06:21 +00:00
2e06f6cdc1 chore(deps): update dependency dotenv to v17.2.3
All checks were successful
Lint / Lint and Check (push) Successful in 27s
2025-10-04 01:06:39 +00:00
b5dd538a36 chore(deps): update dependency @types/node to v22.18.8
All checks were successful
Lint / Lint and Check (push) Successful in 28s
2025-10-04 00:05:41 +00:00
5e25753311 chore(deps): update dependency @types/react to v19.1.15
All checks were successful
Lint / Lint and Check (push) Successful in 34s
2025-09-28 12:02:06 +00:00
ad1bf1425b chore(deps): update dependency @tanstack/react-query to v5.90.2
All checks were successful
Lint / Lint and Check (push) Successful in 30s
2025-09-27 07:02:15 +00:00
1fa7ad48e8 fix(deps): update nextjs monorepo to v15.5.4
All checks were successful
Lint / Lint and Check (push) Successful in 34s
2025-09-27 06:02:16 +00:00
18213aa2ec chore(deps): update typescript-eslint monorepo to v8.44.1
All checks were successful
Lint / Lint and Check (push) Successful in 30s
2025-09-27 05:02:55 +00:00
87e9f7df79 chore(deps): update pnpm to v10.17.1
All checks were successful
Lint / Lint and Check (push) Successful in 28s
2025-09-27 04:03:26 +00:00
3867fa5b3c chore(deps): update dependency turbo to v2.5.8
All checks were successful
Lint / Lint and Check (push) Successful in 28s
2025-09-27 03:04:21 +00:00
2411f4d862 chore(deps): update dependency nanoid to v5.1.6
All checks were successful
Lint / Lint and Check (push) Successful in 27s
2025-09-27 02:04:20 +00:00
2dea8867f5 chore(deps): update dependency drizzle-kit to v0.31.5
All checks were successful
Lint / Lint and Check (push) Successful in 30s
2025-09-27 01:05:07 +00:00
6139cb4b1d chore(deps): update dependency @types/react to v19.1.14
All checks were successful
Lint / Lint and Check (push) Successful in 27s
2025-09-27 00:04:13 +00:00
f687242557 chore(deps): update actions/checkout action to v5 (#16)
All checks were successful
Lint / Lint and Check (push) Successful in 32s
This PR contains the following updates:

| Package | Type | Update | Change |
|---|---|---|---|
| [actions/checkout](https://github.com/actions/checkout) | action | major | `v4` -> `v5` |

---

### Release Notes

<details>
<summary>actions/checkout (actions/checkout)</summary>

### [`v5`](https://github.com/actions/checkout/compare/v4...v5)

[Compare Source](https://github.com/actions/checkout/compare/v4...v5)

</details>

---

### Configuration

📅 **Schedule**: Branch creation - "every weekend" (UTC), Automerge - At any time (no schedule defined).

🚦 **Automerge**: Disabled by config. Please merge this manually once you are satisfied.

♻ **Rebasing**: Whenever PR becomes conflicted, or you tick the rebase/retry checkbox.

🔕 **Ignore**: Close this PR and you won't be reminded about this update again.

---

 - [ ] <!-- rebase-check -->If you want to rebase/retry this PR, check this box

---

This PR has been generated by [Renovate Bot](https://github.com/renovatebot/renovate).
<!--renovate-debug:eyJjcmVhdGVkSW5WZXIiOiI0MS41MS4xIiwidXBkYXRlZEluVmVyIjoiNDEuNTEuMSIsInRhcmdldEJyYW5jaCI6Im1haW4iLCJsYWJlbHMiOltdfQ==-->

Reviewed-on: #16
Co-authored-by: Renovate Bot <renovatebot@schulze.network>
Co-committed-by: Renovate Bot <renovatebot@schulze.network>
2025-09-21 22:45:52 +02:00
9259c74101 chore(deps): update actions/setup-node action to v5
All checks were successful
Lint / Lint and Check (push) Successful in 33s
2025-09-21 21:46:19 +02:00
66768abc3b fix(deps): update dependency postcss-preset-env to v10.4.0
All checks were successful
Lint / Lint and Check (push) Successful in 31s
2025-09-21 15:01:56 +00:00
abe7a4af7e fix(deps): update dependency @tanstack/react-query to v5.89.0
All checks were successful
Lint / Lint and Check (push) Successful in 30s
2025-09-20 04:03:19 +00:00
1c78d18327 chore(deps): update typescript-eslint monorepo to v8.44.0
All checks were successful
Lint / Lint and Check (push) Successful in 31s
2025-09-20 03:03:56 +00:00
e9dbbe51bd chore(deps): update pnpm to v10.17.0
All checks were successful
Lint / Lint and Check (push) Successful in 27s
2025-09-20 02:04:19 +00:00
58afc26526 chore(deps): update eslint monorepo to v9.36.0
All checks were successful
Lint / Lint and Check (push) Successful in 33s
2025-09-20 01:04:51 +00:00
83dc1de53c chore(deps): update dependency @types/node to v22.18.6
All checks were successful
Lint / Lint and Check (push) Successful in 38s
2025-09-20 00:04:43 +00:00
d176451787 chore(deps): update pnpm to v10.16.1
All checks were successful
Lint / Lint and Check (push) Successful in 29s
2025-09-13 18:01:53 +00:00
ba560199cc fix(deps): update dependency lucide-react to ^0.544.0
All checks were successful
Lint / Lint and Check (push) Successful in 29s
2025-09-13 06:03:00 +00:00
a1bfc8cf21 chore(deps): update typescript-eslint monorepo to v8.43.0
All checks were successful
Lint / Lint and Check (push) Successful in 36s
2025-09-13 05:03:35 +00:00
e77f7686cf chore(deps): update pnpm to v10.16.0
All checks were successful
Lint / Lint and Check (push) Successful in 33s
2025-09-13 04:04:11 +00:00
eaf30ad45d fix(deps): update nextjs monorepo to v15.5.3
All checks were successful
Lint / Lint and Check (push) Successful in 30s
2025-09-13 03:04:40 +00:00
ed6790ae0f fix(deps): update dependency @tanstack/react-query to v5.87.4
All checks were successful
Lint / Lint and Check (push) Successful in 30s
2025-09-13 02:04:57 +00:00
6944a240b2 chore(deps): update dependency @types/node to v22.18.3
All checks were successful
Lint / Lint and Check (push) Successful in 28s
2025-09-13 01:06:03 +00:00
9787a58d58 chore(deps): update dependency @types/react to v19.1.13
All checks were successful
Lint / Lint and Check (push) Successful in 27s
2025-09-13 00:04:39 +00:00
5bc2b7fa28 fix(deps): update dependency @tanstack/react-query to v5.87.1
All checks were successful
Lint / Lint and Check (push) Successful in 37s
2025-09-06 06:01:59 +00:00
b792b75ea1 chore(deps): update typescript-eslint monorepo to v8.42.0
All checks were successful
Lint / Lint and Check (push) Successful in 35s
2025-09-06 05:02:18 +00:00
87fc331e9b chore(deps): update eslint monorepo to v9.35.0
All checks were successful
Lint / Lint and Check (push) Successful in 26s
2025-09-06 04:03:12 +00:00
ca7164e52b fix(deps): update dependency dotenv to v17.2.2
All checks were successful
Lint / Lint and Check (push) Successful in 29s
2025-09-06 03:03:35 +00:00
e277d53b52 chore(deps): update tailwindcss monorepo to v4.1.13
All checks were successful
Lint / Lint and Check (push) Successful in 32s
2025-09-06 02:03:53 +00:00
87d4d5dde4 chore(deps): update pnpm to v10.15.1
All checks were successful
Lint / Lint and Check (push) Successful in 36s
2025-09-06 01:04:31 +00:00
4f78e6cfcd chore(deps): update dependency @types/node to v22.18.1
All checks were successful
Lint / Lint and Check (push) Successful in 54s
2025-09-06 00:04:05 +00:00
a8c78d6a42 fix(deps): update dependency @tanstack/react-query to v5.85.6
All checks were successful
Lint / Lint and Check (push) Successful in 32s
2025-08-30 14:01:56 +00:00
114cba348a fix(deps): update dependency lucide-react to ^0.542.0
All checks were successful
Lint / Lint and Check (push) Successful in 32s
2025-08-30 06:02:03 +00:00
501a784da9 chore(deps): update typescript-eslint monorepo to v8.41.0
All checks were successful
Lint / Lint and Check (push) Successful in 34s
2025-08-30 05:02:37 +00:00
6552451f39 chore(deps): update dependency @types/node to v22.18.0
All checks were successful
Lint / Lint and Check (push) Successful in 26s
2025-08-30 04:03:13 +00:00
8deb7d13e9 fix(deps): update nextjs monorepo to v15.5.2
All checks were successful
Lint / Lint and Check (push) Successful in 28s
2025-08-30 03:04:08 +00:00
2de8e45e92 fix(deps): update dependency postcss-preset-env to v10.3.1
All checks were successful
Lint / Lint and Check (push) Successful in 30s
2025-08-30 02:04:38 +00:00
18d6cf77e5 fix(deps): update dependency drizzle-orm to v0.44.5
All checks were successful
Lint / Lint and Check (push) Successful in 30s
2025-08-30 01:05:32 +00:00
30b28a7cb6 chore(deps): update react monorepo
All checks were successful
Lint / Lint and Check (push) Successful in 30s
2025-08-30 00:04:12 +00:00
e6b4bae90d fix(deps): update nextjs monorepo to v15.5.0
All checks were successful
Lint / Lint and Check (push) Successful in 29s
2025-08-23 08:02:59 +00:00
295c884804 fix(deps): update dependency postcss-preset-env to v10.3.0
All checks were successful
Lint / Lint and Check (push) Successful in 29s
2025-08-23 07:02:48 +00:00
270b21dbf0 fix(deps): update dependency lucide-react to ^0.541.0
All checks were successful
Lint / Lint and Check (push) Successful in 28s
2025-08-23 06:03:27 +00:00
91cd975e51 chore(deps): update typescript-eslint monorepo to v8.40.0
All checks were successful
Lint / Lint and Check (push) Successful in 29s
2025-08-23 05:03:20 +00:00
bedf8f7eab chore(deps): update pnpm to v10.15.0
All checks were successful
Lint / Lint and Check (push) Successful in 28s
2025-08-23 04:03:54 +00:00
b24096ff80 chore(deps): update eslint monorepo to v9.34.0
All checks were successful
Lint / Lint and Check (push) Successful in 29s
2025-08-23 03:04:10 +00:00
7e9754bca4 fix(deps): update dependency cssnano to v7.1.1
All checks were successful
Lint / Lint and Check (push) Successful in 28s
2025-08-23 02:04:50 +00:00
d1495d29e9 fix(deps): update dependency @tanstack/react-query to v5.85.5
All checks were successful
Lint / Lint and Check (push) Successful in 28s
2025-08-23 01:05:52 +00:00
c69b164c23 chore(deps): update dependency @types/react to v19.1.11
All checks were successful
Lint / Lint and Check (push) Successful in 49s
2025-08-23 00:04:25 +00:00
26443ef169 fix(deps): update dependency @tanstack/react-query to v5.85.3
All checks were successful
Lint / Lint and Check (push) Successful in 52s
2025-08-16 07:05:07 +00:00
dcfd830afe fix(deps): update radix-ui-primitives monorepo
All checks were successful
Lint / Lint and Check (push) Successful in 58s
2025-08-16 06:05:41 +00:00
6145d4549a chore(deps): update typescript-eslint monorepo to v8.39.1
All checks were successful
Lint / Lint and Check (push) Successful in 1m3s
2025-08-16 05:07:26 +00:00
9c99914c36 chore(deps): update tailwindcss monorepo to v4.1.12
All checks were successful
Lint / Lint and Check (push) Successful in 59s
2025-08-16 04:07:38 +00:00
988535d452 chore(deps): update dependency turbo to v2.5.6
All checks were successful
Lint / Lint and Check (push) Successful in 54s
2025-08-16 03:08:16 +00:00
2ece96f3ca chore(deps): update dependency @types/react to v19.1.10
All checks were successful
Lint / Lint and Check (push) Successful in 50s
2025-08-16 02:13:24 +00:00
a0bdda25bc chore(deps): update dependency @types/node to v22.17.2
All checks were successful
Lint / Lint and Check (push) Successful in 49s
2025-08-16 01:11:45 +00:00
778626c3a2 chore(deps): update actions/checkout digest to 08eba0b
All checks were successful
Lint / Lint and Check (push) Successful in 49s
2025-08-16 00:08:16 +00:00
af3e2ceb6b fix(deps): update dependency lucide-react to ^0.539.0
All checks were successful
Lint / Lint and Check (push) Successful in 45s
2025-08-09 05:05:43 +00:00
1cfcd31fff chore(deps): update typescript-eslint monorepo to v8.39.0
All checks were successful
Lint / Lint and Check (push) Successful in 42s
2025-08-09 04:06:46 +00:00
9276e0f037 chore(deps): update eslint monorepo to v9.33.0
All checks were successful
Lint / Lint and Check (push) Successful in 44s
2025-08-09 03:08:05 +00:00
c4bf5f1a35 fix(deps): update nextjs monorepo to v15.4.6
All checks were successful
Lint / Lint and Check (push) Successful in 47s
2025-08-09 02:12:16 +00:00
d480175dd0 fix(deps): update dependency @tanstack/react-query to v5.84.2
All checks were successful
Lint / Lint and Check (push) Successful in 45s
2025-08-09 01:09:58 +00:00
e62cfaa0d4 chore(deps): update dependency @types/node to v22.17.1
All checks were successful
Lint / Lint and Check (push) Successful in 1m40s
2025-08-09 00:08:06 +00:00
095f2c65be fix(deps): update dependency lucide-react to ^0.536.0
All checks were successful
Lint / Lint and Check (push) Successful in 46s
2025-08-02 08:06:24 +00:00
60ce3892d6 fix(deps): update dependency @tanstack/react-query to v5.84.1
All checks were successful
Lint / Lint and Check (push) Successful in 44s
2025-08-02 07:08:37 +00:00
9dff61cbbc chore(deps): update pnpm to v10.14.0
All checks were successful
Lint / Lint and Check (push) Successful in 53s
2025-08-02 06:09:14 +00:00
3c7eeb108b chore(deps): update dependency typescript to v5.9.2
All checks were successful
Lint / Lint and Check (push) Successful in 47s
2025-08-02 05:09:10 +00:00
af7690e66d chore(deps): update dependency @types/node to v22.17.0
All checks were successful
Lint / Lint and Check (push) Successful in 45s
2025-08-02 04:09:51 +00:00
64ef558818 fix(deps): update react monorepo
All checks were successful
Lint / Lint and Check (push) Successful in 54s
2025-08-02 03:13:13 +00:00
6bd6192dd0 fix(deps): update nextjs monorepo to v15.4.5
All checks were successful
Lint / Lint and Check (push) Successful in 54s
2025-08-02 02:20:46 +00:00
43de660f1b fix(deps): update dependency drizzle-orm to v0.44.4
All checks were successful
Lint / Lint and Check (push) Successful in 44s
2025-08-02 01:12:36 +00:00
4bb1943244 chore(deps): update dependency @types/pg to v8.15.5
All checks were successful
Lint / Lint and Check (push) Successful in 43s
2025-08-02 00:09:12 +00:00
0888cca45b fix(deps): update dependency lucide-react to ^0.526.0
All checks were successful
Lint / Lint and Check (push) Successful in 54s
2025-07-26 20:04:14 +00:00
5314c98508 chore(deps): update typescript-eslint monorepo to v8.38.0
All checks were successful
Lint / Lint and Check (push) Successful in 52s
2025-07-26 03:07:57 +00:00
484047a7bd chore(deps): update eslint monorepo to v9.32.0
All checks were successful
Lint / Lint and Check (push) Successful in 47s
2025-07-26 02:12:19 +00:00
80d95065e8 fix(deps): update nextjs monorepo to v15.4.4
All checks were successful
Lint / Lint and Check (push) Successful in 45s
2025-07-26 01:10:21 +00:00
5386dad5c9 fix(deps): update dependency dotenv to v17.2.1
All checks were successful
Lint / Lint and Check (push) Successful in 45s
2025-07-26 00:07:16 +00:00
4e63d0ac42 fix(deps): update nextjs monorepo to v15.4.2
All checks were successful
Lint / Lint and Check (push) Successful in 44s
2025-07-19 03:06:17 +00:00
485e75dc93 chore(deps): update dependency turbo to v2.5.5
All checks were successful
Lint / Lint and Check (push) Successful in 52s
2025-07-19 02:10:23 +00:00
49c8389d9d chore(deps): update dependency @types/node to v22.16.5
All checks were successful
Lint / Lint and Check (push) Successful in 44s
2025-07-19 01:09:05 +00:00
92a27f544b chore(deps): pin dependencies
All checks were successful
Lint / Lint and Check (push) Successful in 1m4s
2025-07-19 00:06:53 +00:00
b02f5e6364 linter fixes, env fixes
All checks were successful
Lint / Lint and Check (push) Successful in 51s
2025-07-15 23:15:32 +02:00
f5fff9c52b fix not able to install on nixos 2025-07-15 21:59:57 +02:00
5d9eb9217e linter config, icons, fix stats
Some checks failed
Lint / Lint and Check (push) Failing after 47s
2025-07-15 19:34:28 +02:00
d69d9fc129 db 2025-07-15 19:02:08 +02:00
253111f741 initial webapp 2025-07-15 18:58:21 +02:00
835b73552b upgrade nextjs
All checks were successful
Lint / Lint and Check (push) Successful in 44s
2025-07-15 16:34:55 +02:00
b1e8fb3353 use pnpm 2025-07-15 16:32:44 +02:00
20c0bf2770 chore(deps): update sibiraj-s/action-eslint action to v4 (#15)
All checks were successful
Lint / Lint (push) Successful in 32s
This PR contains the following updates:

| Package | Type | Update | Change |
|---|---|---|---|
| [sibiraj-s/action-eslint](https://github.com/sibiraj-s/action-eslint) | action | major | `v3.0.1` -> `v4.0.1` |

---

### Release Notes

<details>
<summary>sibiraj-s/action-eslint (sibiraj-s/action-eslint)</summary>

### [`v4.0.1`](https://github.com/sibiraj-s/action-eslint/blob/HEAD/CHANGELOG.md#v401-2025-07-08)

[Compare Source](https://github.com/sibiraj-s/action-eslint/compare/v4.0.0...v4.0.1)

##### Bug Fixes

-   fix usage of logger ([502aee5](https://github.com/sibiraj-s/action-eslint/commit/502aee5))
-   fix GitHub rest API usage ([6ab9fd68](https://github.com/sibiraj-s/action-eslint/commit/6ab9fd68))

### [`v4.0.0`](https://github.com/sibiraj-s/action-eslint/blob/HEAD/CHANGELOG.md#v400-2025-07-08)

[Compare Source](https://github.com/sibiraj-s/action-eslint/compare/v3.0.1...v4.0.0)

##### Breaking Changes

-   The action is now ESM ([d9543bd](https://github.com/sibiraj-s/action-eslint/commit/d9543bd))
-   Action runtime updated to Node.js 20 ([5580b04](https://github.com/sibiraj-s/action-eslint/commit/5580b04))
-   Drop `use-npx` option, by default it uses `npx` for `npm` and `pnpm dlx` for `pnpm` ([f7b0bf2](https://github.com/sibiraj-s/action-eslint/commit/f7b0bf2))

##### Features

-   Add pnpm support ([f7b0bf2](https://github.com/sibiraj-s/action-eslint/commit/f7b0bf2))

##### Bug Fixes

-   Fix exec working directory ([548a14a](https://github.com/sibiraj-s/action-eslint/commit/548a14a))

</details>

---

### Configuration

📅 **Schedule**: Branch creation - "every weekend" (UTC), Automerge - At any time (no schedule defined).

🚦 **Automerge**: Disabled by config. Please merge this manually once you are satisfied.

♻ **Rebasing**: Whenever PR becomes conflicted, or you tick the rebase/retry checkbox.

🔕 **Ignore**: Close this PR and you won't be reminded about this update again.

---

 - [ ] <!-- rebase-check -->If you want to rebase/retry this PR, check this box

---

This PR has been generated by [Renovate Bot](https://github.com/renovatebot/renovate).
<!--renovate-debug:eyJjcmVhdGVkSW5WZXIiOiI0MC40Ni4wIiwidXBkYXRlZEluVmVyIjoiNDAuNDYuMCIsInRhcmdldEJyYW5jaCI6Im1haW4iLCJsYWJlbHMiOltdfQ==-->

Reviewed-on: #15
Co-authored-by: Renovate Bot <renovatebot@schulze.network>
Co-committed-by: Renovate Bot <renovatebot@schulze.network>
2025-07-12 11:38:37 +02:00
07475f7cb1 chore(deps): update dependency @types/node to v22.16.3
All checks were successful
Lint / Lint (push) Successful in 1m0s
2025-07-12 00:04:59 +00:00
1f77670b1e chore(deps): update dependency @types/node to v22.16.0
All checks were successful
Lint / Lint (push) Successful in 28s
2025-07-05 01:06:51 +00:00
7108ce3e8f fix(deps): update nextjs monorepo to v15.3.5
All checks were successful
Lint / Lint (push) Successful in 29s
2025-07-05 00:04:56 +00:00
f28341781e chore(deps): update dependency @types/node to v22.15.34
All checks were successful
Lint / Lint (push) Successful in 32s
2025-06-28 08:03:35 +00:00
1d3bf08a2c fix(deps): update dependency lucide-react to ^0.525.0
All checks were successful
Lint / Lint (push) Successful in 29s
2025-06-28 03:05:42 +00:00
e155256b65 fix(deps): update dependency postcss-preset-env to v10.2.4
All checks were successful
Lint / Lint (push) Successful in 28s
2025-06-28 02:09:55 +00:00
71cffc17d6 chore(deps): update tailwindcss monorepo to v4.1.11
All checks were successful
Lint / Lint (push) Successful in 27s
2025-06-28 01:06:48 +00:00
922009dffe chore(deps): update dependency @types/node to v22.15.33
All checks were successful
Lint / Lint (push) Successful in 31s
2025-06-28 00:05:01 +00:00
6665a8b735 fix(deps): update dependency lucide-react to ^0.522.0
All checks were successful
Lint / Lint (push) Successful in 29s
2025-06-21 07:04:03 +00:00
9fe6e0d243 fix(deps): update dependency lucide-react to ^0.519.0
All checks were successful
Lint / Lint (push) Successful in 29s
2025-06-21 03:06:10 +00:00
e888cef0c1 fix(deps): update nextjs monorepo to v15.3.4
All checks were successful
Lint / Lint (push) Successful in 28s
2025-06-21 02:09:21 +00:00
192f2503e4 chore(deps): update dependency postcss to v8.5.6
All checks were successful
Lint / Lint (push) Successful in 29s
2025-06-21 01:07:27 +00:00
a534a04082 chore(deps): update dependency @types/node to v22.15.32
All checks were successful
Lint / Lint (push) Successful in 28s
2025-06-21 00:05:36 +00:00
f7cac220a8 fix(deps): update dependency lucide-react to ^0.515.0
All checks were successful
Lint / Lint (push) Successful in 29s
2025-06-14 07:06:03 +00:00
087859edb8 fix(deps): update dependency tailwind-merge to v3.3.1
All checks were successful
Lint / Lint (push) Successful in 30s
2025-06-14 06:06:27 +00:00
84a4cd6293 fix(deps): update dependency postcss-preset-env to v10.2.3
All checks were successful
Lint / Lint (push) Successful in 29s
2025-06-14 05:06:35 +00:00
01f2a30ca0 chore(deps): update tailwindcss monorepo to v4.1.10
All checks were successful
Lint / Lint (push) Successful in 31s
2025-06-14 04:06:50 +00:00
c05a4b40b6 chore(deps): update npm to v11.4.2
All checks were successful
Lint / Lint (push) Successful in 31s
2025-06-14 03:07:04 +00:00
65f3833ad4 chore(deps): update dependency postcss to v8.5.5
All checks were successful
Lint / Lint (push) Successful in 27s
2025-06-14 02:10:15 +00:00
8d7780e46d chore(deps): update dependency @types/react to v19.1.8
All checks were successful
Lint / Lint (push) Successful in 28s
2025-06-14 01:08:05 +00:00
b0b69a4352 chore(deps): update dependency @types/node to v22.15.31
All checks were successful
Lint / Lint (push) Successful in 28s
2025-06-14 00:06:04 +00:00
0346f0c792 fix(deps): update dependency lucide-react to ^0.513.0
All checks were successful
Lint / Lint (push) Successful in 27s
2025-06-07 03:05:49 +00:00
b6b40a0b50 fix(deps): update dependency postcss-preset-env to v10.2.1
All checks were successful
Lint / Lint (push) Successful in 26s
2025-06-07 02:08:42 +00:00
17071995dd chore(deps): update dependency @types/react-dom to v19.1.6
All checks were successful
Lint / Lint (push) Successful in 26s
2025-06-07 01:07:01 +00:00
4c9e984ab1 chore(deps): update dependency @types/node to v22.15.30
All checks were successful
Lint / Lint (push) Successful in 28s
2025-06-07 00:05:12 +00:00
ec9b659b7b fix(deps): update dependency postcss-preset-env to v10.2.0
All checks were successful
Lint / Lint (push) Successful in 31s
2025-05-31 06:06:35 +00:00
48e03d3775 fix(deps): update nextjs monorepo to v15.3.3 2025-05-31 05:06:33 +00:00
51bf896d37 chore(deps): update tailwindcss monorepo to v4.1.8 2025-05-31 04:06:31 +00:00
789e389bcd chore(deps): update dependency turbo to v2.5.4 2025-05-31 03:06:28 +00:00
7ac4a5261b chore(deps): update dependency postcss to v8.5.4 2025-05-31 02:08:17 +00:00
8540764a99 chore(deps): update dependency @types/react to v19.1.6 2025-05-31 01:08:25 +00:00
8ab4c37676 chore(deps): update dependency @types/node to v22.15.29 2025-05-31 00:05:45 +00:00
9d6214ca8a chore(deps): update npm to v11.4.1 2025-05-24 02:05:07 +00:00
a4151c5736 chore(deps): update dependency @types/react to v19.1.5 2025-05-24 01:06:20 +00:00
762dd0a445 chore(deps): update dependency @types/node to v22.15.21 2025-05-24 00:04:26 +00:00
82a6a1ba96 remove sharp 2025-05-22 16:14:02 +02:00
776c9e5547 fix(deps): update dependency lucide-react to ^0.511.0 2025-05-17 04:05:14 +00:00
90e809c272 chore(deps): update npm to v11.4.0 2025-05-17 03:05:53 +00:00
6c02bd8dc6 chore(deps): update tailwindcss monorepo to v4.1.7 2025-05-17 02:07:36 +00:00
745bb0d7f8 chore(deps): update react monorepo 2025-05-17 01:07:30 +00:00
e927ab6a15 chore(deps): update dependency @types/node to v22.15.18 2025-05-17 00:05:15 +00:00
df0c893137 fix(deps): update dependency tailwind-merge to v3.3.0 2025-05-11 21:02:39 +00:00
2697e763c2 fix(deps): update dependency tailwind-merge to v3.2.0 2025-05-10 20:02:23 +00:00
ef969d2b6c chore(deps): pin dependencies (#14)
This PR contains the following updates:

| Package | Type | Update | Change |
|---|---|---|---|
| [@tailwindcss/postcss](https://tailwindcss.com) ([source](https://github.com/tailwindlabs/tailwindcss/tree/HEAD/packages/@tailwindcss-postcss)) | devDependencies | pin | [`^4.1.6` -> `4.1.6`](https://renovatebot.com/diffs/npm/@tailwindcss%2fpostcss/4.1.6/4.1.6) |
| [tailwindcss](https://tailwindcss.com) ([source](https://github.com/tailwindlabs/tailwindcss/tree/HEAD/packages/tailwindcss)) | devDependencies | pin | [`^4.1.6` -> `4.1.6`](https://renovatebot.com/diffs/npm/tailwindcss/4.1.6/4.1.6) |

Add the preset `:preserveSemverRanges` to your config if you don't want to pin your dependencies.

---

### Configuration

📅 **Schedule**: Branch creation - "every weekend" (UTC), Automerge - At any time (no schedule defined).

🚦 **Automerge**: Disabled because a matching PR was automerged previously.

♻ **Rebasing**: Whenever PR is behind base branch, or you tick the rebase/retry checkbox.

🔕 **Ignore**: Close this PR and you won't be reminded about these updates again.

---

 - [ ] <!-- rebase-check -->If you want to rebase/retry this PR, check this box

---

This PR has been generated by [Renovate Bot](https://github.com/renovatebot/renovate).
<!--renovate-debug:eyJjcmVhdGVkSW5WZXIiOiI0MC4xMS4wIiwidXBkYXRlZEluVmVyIjoiNDAuMTEuMCIsInRhcmdldEJyYW5jaCI6Im1haW4iLCJsYWJlbHMiOltdfQ==-->

Reviewed-on: #14
Co-authored-by: Renovate Bot <renovatebot@schulze.network>
Co-committed-by: Renovate Bot <renovatebot@schulze.network>
2025-05-10 21:05:14 +02:00
49 changed files with 11964 additions and 8720 deletions

20
.cursorrules Normal file
View File

@@ -0,0 +1,20 @@
# Project Rules
## Testing Policy
- **Mandatory Tests**: All new features and bug fixes must be accompanied by tests.
- **Unit/Integration Tests**: Use **Vitest** for testing utilities, hooks, and components.
- **E2E Tests**: Use **Playwright** for critical user flows (auth, core features).
- **Coverage**: Aim for high coverage on business logic and critical paths.
## Tech Stack
- **Framework**: Next.js 15 (App Router)
- **Language**: TypeScript
- **Styling**: Tailwind CSS
- **Database**: PostgreSQL with Drizzle ORM
- **State Management**: React Query
## Code Style
- **Functional Components**: Use arrow functions for components.
- **Types**: strict TypeScript usage (avoid `any`).
- **Imports**: Use absolute imports (`@/...`).

View File

@@ -1,3 +0,0 @@
{
"extends": "next/core-web-vitals"
}

View File

@@ -4,24 +4,28 @@ on:
pull_request:
push:
branches:
- "**" # matches every branch
- '**' # matches every branch
jobs:
eslint:
name: Lint
lint_and_check:
name: Lint and Check
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
- name: Checkout code
uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
- name: Install pnpm
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4
- name: Setup Node.js
uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6
with:
node-version: 20
cache: "npm"
- run: npm i
- uses: sibiraj-s/action-eslint@bcf41bb9abce43cdbad51ab9b3da2eddaa17eab3 # v3.0.1
with:
eslint-args: "--ignore-path=.gitignore --quiet"
extensions: "js,jsx,ts,tsx"
annotations: true
all-files: true
node-version: 24
cache: 'pnpm'
- name: Install dependencies
run: pnpm install
- name: Run check
run: pnpm run lint

4
.gitignore vendored
View File

@@ -36,3 +36,7 @@ yarn-error.log*
next-env.d.ts
.turbo/
playwright-report/
test-results/

6
.prettierrc Normal file
View File

@@ -0,0 +1,6 @@
{
"tabWidth": 2,
"singleQuote": true,
"printWidth": 105,
"plugins": ["prettier-plugin-tailwindcss"]
}

210
README.md
View File

@@ -1,4 +1,210 @@
# trackevery-day
# 📅 Track Every Day
https://trackevery.day/
Track anything, every day.
A simple, privacy-focused habit tracking web app. Track anything, every day.
## 🎯 Vision & Goal
**Goal**: To provide the most frictionless, privacy-respecting tool for users to build consistency in their lives without the barrier of complex sign-ups or data tracking concerns.
**Vision**: A world where self-improvement is accessible to everyone without trading their privacy for it. `trackevery-day` aims to become the standard for "unaccounted" personal tracking, eventually expanding into a broader minimalist "life logger" platform.
## 💼 Business Model
This project operates on a sustainable Open Source model:
1. **Core Product (Free & Open Source)**: The full application is available for free. Users can self-host or use the public instance.
2. **Supporter Tier (Future)**: Optional premium features for power users who want to support development:
- Advanced Data Analysis & Trends
- Encrypted Cloud Backups
- API Access for integrations
3. **Donations**: Community support via GitHub Sponsors / Ko-fi to cover hosting costs.
---
## 🗺️ Roadmap & Tasks
We are building this out in phases. Below is the breakdown of problems into small, actionable tasks.
### Phase 1: Core Refinement (Current Focus)
_Goal: Polish the existing functionality to be feature-complete._
- [ ] **Habit Management**
- [ ] Add "Edit Habit" functionality (rename, change type/color).
- [ ] Add "Delete/Archive Habit" functionality (UI implementation).
- [ ] Implement "Undo Log" (remove accidental logs).
- [ ] **Visualization**
- [ ] Add a "Contribution Graph" (GitHub style) heatmap for each habit.
- [ ] Add a simple line chart for "Frequency over Time".
- [ ] **UX Improvements**
- [ ] specific mobile-responsive tweaks for the dashboard grid.
- [ ] Add a "Settings" page to manage the token (regenerate, view).
### Phase 2: Data Sovereignty
_Goal: Ensure users truly own their data._
- [ ] **Export/Import**
- [ ] Create JSON export handler.
- [ ] Create CSV export handler (for spreadsheet analysis).
- [ ] Build a "Restore from Backup" feature (JSON import).
- [ ] **Local-First Enhancements**
- [ ] Cache habit data in `localStorage` for faster load times.
- [ ] Implement offline queuing for logs when network is unavailable.
### Phase 3: Engagement & Growth
_Goal: Help users stay consistent._
- [ ] **PWA Implementation**
- [ ] Add `manifest.json` and service workers.
- [ ] Enable "Add to Home Screen" prompt.
- [ ] **Gamification (Subtle)**
- [ ] Visual rewards for hitting streaks (confetti, badges).
- [ ] "Levels" based on total consistency score.
- [ ] **Notifications**
- [ ] Browser-based push notifications for reminders (optional).
### Phase 4: Advanced Features (Supporter Tier)
_Goal: Power features for data nerds._
- [ ] **Public Profile** (Optional public shareable link for specific habits).
- [ ] **API Access** (Generate API keys to log via curl/scripts).
- [ ] **Webhooks** (Trigger events when a habit is logged).
---
## ✨ Features
- **Token-based authentication** - No email or password required
- **Privacy-first** - Your data is tied to a unique token
- **Simple interface** - Click to log, see stats instantly
- **Habit types** - Track positive, neutral, or negative habits
- **Real-time statistics** - See averages, streaks, and time since last log
- **Cross-device sync** - Use your token to access data anywhere
## 🚀 Getting Started
### Prerequisites
- Node.js 18+
- PostgreSQL database (local or hosted)
- pnpm (or npm/yarn)
### Setup
1. Clone the repository:
```bash
git clone https://git.schulze.network/schulze/trackevery-day.git
cd trackevery-day
```
2. Install dependencies:
```bash
pnpm install
```
3. Set up environment variables:
```bash
# Create a .env.local file with:
POSTGRES_URL="your-postgres-connection-string"
```
4. Set up the database:
```bash
# Generate migrations
pnpm db:generate
# Push schema to database
pnpm db:push
# Or run migrations
pnpm db:migrate
```
5. Run the development server:
```bash
pnpm dev
```
Open [http://localhost:3000](http://localhost:3000) to start tracking!
## 🏗️ Tech Stack
- **Next.js 15** - React framework with App Router
- **Drizzle ORM** - Type-safe database queries
- **PostgreSQL** - Database (works with Vercel Postgres, Neon, Supabase, etc.)
- **React Query** - Data fetching and caching
- **Tailwind CSS** - Styling
- **TypeScript** - Type safety
## 📱 How It Works
1. **First Visit**: A unique token is generated (e.g., `happy-blue-cat-1234`)
2. **Save Your Token**: This is your key to access your data
3. **Track Habits**: Click habit cards to log executions
4. **View Stats**: See real-time statistics and progress
5. **Access Anywhere**: Use your token to login from any device
## 🔒 Privacy
- No personal information required
- No email or password needed
- Your token is your only identifier
- Data is only accessible with your token
## 📝 Database Schema
```sql
-- Users table
users (
id SERIAL PRIMARY KEY,
token TEXT UNIQUE NOT NULL,
created_at TIMESTAMP DEFAULT NOW()
)
-- Habits table
habits (
id SERIAL PRIMARY KEY,
user_id INTEGER REFERENCES users(id),
name TEXT NOT NULL,
type TEXT CHECK (type IN ('positive', 'neutral', 'negative')),
is_archived BOOLEAN DEFAULT FALSE,
created_at TIMESTAMP DEFAULT NOW()
)
-- Habit logs table
habit_logs (
id SERIAL PRIMARY KEY,
habit_id INTEGER REFERENCES habits(id),
logged_at TIMESTAMP DEFAULT NOW(),
note TEXT
)
```
## 🛠️ Development
```bash
# Run development server
pnpm dev
# Type checking
pnpm check
# Database management
pnpm db:studio # Open Drizzle Studio
pnpm db:generate # Generate migrations
pnpm db:push # Push schema changes
```
## 📄 License
GPL-3.0 License - see LICENSE file for details

115
app/api/auth/route.ts Normal file
View File

@@ -0,0 +1,115 @@
import { NextRequest, NextResponse } from 'next/server';
import { db, users } from '@/lib/db';
import { generateMemorableToken, isValidToken } from '@/lib/auth/tokens';
import { setTokenCookie, getTokenCookie } from '@/lib/auth/cookies';
import { eq } from 'drizzle-orm';
export async function GET() {
try {
// Check if user already has a token
const existingToken = await getTokenCookie();
if (existingToken) {
// Verify token exists in database
const userRows = await db.select().from(users).where(eq(users.token, existingToken));
if (userRows.length > 0) {
const user = userRows[0];
return NextResponse.json({
authenticated: true,
token: existingToken,
userId: user.id,
});
}
}
return NextResponse.json({ authenticated: false });
} catch (error) {
console.error('Auth check error:', error);
return NextResponse.json({ authenticated: false }, { status: 500 });
}
}
export async function POST(request: NextRequest) {
try {
const body = (await request.json()) as { action: string; token?: string };
const { action, token } = body;
if (action === 'create') {
// Generate new token and create user
const newToken = generateMemorableToken();
const newUserRows = await db
.insert(users)
.values({
token: newToken,
})
.returning();
if (newUserRows.length === 0) {
throw new Error('Failed to create user');
}
const newUser = newUserRows[0];
await setTokenCookie(newToken);
return NextResponse.json({
success: true,
token: newToken,
userId: newUser.id,
});
}
if (action === 'login' && token) {
// Validate token format
if (!isValidToken(token)) {
return NextResponse.json(
{
success: false,
error: 'Invalid token format',
},
{ status: 400 },
);
}
// Check if token exists
const userRows = await db.select().from(users).where(eq(users.token, token));
if (userRows.length === 0) {
return NextResponse.json(
{
success: false,
error: 'Token not found',
},
{ status: 404 },
);
}
const user = userRows[0];
await setTokenCookie(token);
return NextResponse.json({
success: true,
token,
userId: user.id,
});
}
return NextResponse.json(
{
success: false,
error: 'Invalid action',
},
{ status: 400 },
);
} catch (error) {
console.error('Auth error:', error);
return NextResponse.json(
{
success: false,
error: 'Internal server error',
},
{ status: 500 },
);
}
}

View File

@@ -0,0 +1,106 @@
import { NextRequest, NextResponse } from 'next/server';
import { db, habits, users, habitLogs } from '@/lib/db';
import { getTokenCookie } from '@/lib/auth/cookies';
import { eq, and, desc } from 'drizzle-orm';
async function getUserFromToken() {
const token = await getTokenCookie();
if (!token) return null;
const userRows = await db.select().from(users).where(eq(users.token, token));
return userRows.length > 0 ? userRows[0] : null;
}
export async function POST(request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
try {
const { id } = await params;
const habitId = parseInt(id);
if (isNaN(habitId)) {
return NextResponse.json({ error: 'Invalid habit ID' }, { status: 400 });
}
const user = await getUserFromToken();
if (!user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
// Verify habit belongs to user
const habitRows = await db
.select()
.from(habits)
.where(and(eq(habits.id, habitId), eq(habits.userId, user.id)));
if (habitRows.length === 0) {
return NextResponse.json({ error: 'Habit not found' }, { status: 404 });
}
const body = (await request.json()) as { note?: string };
const { note } = body;
// Create log entry
const logRows = await db
.insert(habitLogs)
.values({
habitId,
note,
})
.returning();
if (logRows.length === 0) {
throw new Error('Failed to create log entry');
}
const log = logRows[0];
return NextResponse.json({ log });
} catch (error) {
console.error('Log habit error:', error);
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
}
}
export async function DELETE(request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
try {
const { id } = await params;
const habitId = parseInt(id);
if (isNaN(habitId)) {
return NextResponse.json({ error: 'Invalid habit ID' }, { status: 400 });
}
const user = await getUserFromToken();
if (!user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
// Verify habit belongs to user
const habitRows = await db
.select()
.from(habits)
.where(and(eq(habits.id, habitId), eq(habits.userId, user.id)));
if (habitRows.length === 0) {
return NextResponse.json({ error: 'Habit not found' }, { status: 404 });
}
// Find latest log
const latestLog = await db
.select()
.from(habitLogs)
.where(eq(habitLogs.habitId, habitId))
.orderBy(desc(habitLogs.loggedAt))
.limit(1);
if (latestLog.length === 0) {
return NextResponse.json({ error: 'No logs to undo' }, { status: 404 });
}
// Delete latest log
await db.delete(habitLogs).where(eq(habitLogs.id, latestLog[0].id));
return NextResponse.json({ success: true });
} catch (error) {
console.error('Undo log error:', error);
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
}
}

View File

@@ -0,0 +1,117 @@
import { NextRequest, NextResponse } from 'next/server';
import { db, habits, users } from '@/lib/db';
import { getTokenCookie } from '@/lib/auth/cookies';
import { eq, and } from 'drizzle-orm';
async function getUserFromToken() {
const token = await getTokenCookie();
if (!token) return null;
const userRows = await db.select().from(users).where(eq(users.token, token));
return userRows.length > 0 ? userRows[0] : null;
}
export async function PATCH(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const { id } = await params;
const habitId = parseInt(id);
if (isNaN(habitId)) {
return NextResponse.json({ error: 'Invalid habit ID' }, { status: 400 });
}
const user = await getUserFromToken();
if (!user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
// Verify habit belongs to user
const habitRows = await db
.select()
.from(habits)
.where(and(eq(habits.id, habitId), eq(habits.userId, user.id)));
if (habitRows.length === 0) {
return NextResponse.json({ error: 'Habit not found' }, { status: 404 });
}
const body = (await request.json()) as {
name?: string;
type?: string;
color?: string;
icon?: string;
targetFrequency?: { value: number; period: 'day' | 'week' | 'month' };
};
const { name, type, color, icon, targetFrequency } = body;
// Validate type if provided
if (type && !['positive', 'neutral', 'negative'].includes(type)) {
return NextResponse.json(
{ error: 'Type must be one of: positive, neutral, negative' },
{ status: 400 }
);
}
const updatedHabitRows = await db
.update(habits)
.set({
...(name && { name }),
...(type && { type: type as 'positive' | 'neutral' | 'negative' }),
...(color && { color }),
...(icon && { icon }),
...(targetFrequency && { targetFrequency }),
})
.where(and(eq(habits.id, habitId), eq(habits.userId, user.id)))
.returning();
return NextResponse.json({ habit: updatedHabitRows[0] });
} catch (error) {
console.error('Update habit error:', error);
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
}
}
export async function DELETE(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const { id } = await params;
const habitId = parseInt(id);
if (isNaN(habitId)) {
return NextResponse.json({ error: 'Invalid habit ID' }, { status: 400 });
}
const user = await getUserFromToken();
if (!user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
// Verify habit belongs to user
const habitRows = await db
.select()
.from(habits)
.where(and(eq(habits.id, habitId), eq(habits.userId, user.id)));
if (habitRows.length === 0) {
return NextResponse.json({ error: 'Habit not found' }, { status: 404 });
}
// Soft delete (archive)
await db
.update(habits)
.set({ isArchived: true, archivedAt: new Date() })
.where(and(eq(habits.id, habitId), eq(habits.userId, user.id)));
return NextResponse.json({ success: true });
} catch (error) {
console.error('Delete habit error:', error);
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
}
}

132
app/api/habits/route.ts Normal file
View File

@@ -0,0 +1,132 @@
import { NextRequest, NextResponse } from 'next/server';
import { db, habits, users, habitLogs } from '@/lib/db';
import { getTokenCookie } from '@/lib/auth/cookies';
import { eq, and, desc } from 'drizzle-orm';
async function getUserFromToken() {
const token = await getTokenCookie();
if (!token) return null;
const userRows = await db.select().from(users).where(eq(users.token, token));
return userRows.length > 0 ? userRows[0] : null;
}
export async function GET() {
try {
const user = await getUserFromToken();
if (!user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
// Get current timestamp for date calculations
const now = new Date();
const sevenDaysAgo = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000);
const thirtyDaysAgo = new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000);
// First get all habits
const userHabitsBase = await db
.select()
.from(habits)
.where(and(eq(habits.userId, user.id), eq(habits.isArchived, false)))
.orderBy(desc(habits.createdAt));
// Then get aggregated log data for each habit
const habitsWithStats = await Promise.all(
userHabitsBase.map(async (habit) => {
// Get all logs for this habit, ordered by date desc
const logs = await db
.select({
id: habitLogs.id,
loggedAt: habitLogs.loggedAt,
})
.from(habitLogs)
.where(eq(habitLogs.habitId, habit.id))
.orderBy(desc(habitLogs.loggedAt));
// Calculate statistics
const totalLogs = logs.length;
const logsLastWeek = logs.filter((log) => log.loggedAt >= sevenDaysAgo).length;
const logsLastMonth = logs.filter((log) => log.loggedAt >= thirtyDaysAgo).length;
const lastLoggedAt = logs.length > 0 ? logs[0].loggedAt : null;
return {
id: habit.id,
name: habit.name,
type: habit.type,
targetFrequency: habit.targetFrequency,
color: habit.color,
icon: habit.icon,
createdAt: habit.createdAt,
lastLoggedAt,
totalLogs,
logsLastWeek,
logsLastMonth,
};
}),
);
return NextResponse.json({ habits: habitsWithStats });
} catch (error) {
console.error('Get habits error:', error);
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
}
}
export async function POST(request: NextRequest) {
try {
const user = await getUserFromToken();
if (!user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const body = (await request.json()) as {
name: string;
type: string;
targetFrequency?: { value: number; period: 'day' | 'week' | 'month' };
color?: string;
icon?: string;
};
const { name, type, targetFrequency, color, icon } = body;
if (!name || !type) {
return NextResponse.json(
{
error: 'Name and type are required',
},
{ status: 400 },
);
}
// Validate type is one of the allowed enum values
if (!['positive', 'neutral', 'negative'].includes(type)) {
return NextResponse.json(
{
error: 'Type must be one of: positive, neutral, negative',
},
{ status: 400 },
);
}
const newHabitRows = await db
.insert(habits)
.values({
userId: user.id,
name,
type: type as 'positive' | 'neutral' | 'negative',
targetFrequency,
color,
icon,
})
.returning();
if (newHabitRows.length === 0) {
throw new Error('Failed to create habit');
}
const newHabit = newHabitRows[0];
return NextResponse.json({ habit: newHabit });
} catch (error) {
console.error('Create habit error:', error);
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
}
}

120
app/dashboard/page.test.tsx Normal file
View File

@@ -0,0 +1,120 @@
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import { describe, it, expect, vi, beforeEach } from 'vitest';
import Dashboard from './page';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
// Mock next/navigation
vi.mock('next/navigation', () => ({
useRouter: () => ({
push: vi.fn(),
}),
}));
// Mock ResizeObserver
global.ResizeObserver = class ResizeObserver {
observe() {}
unobserve() {}
disconnect() {}
};
// Mock fetch
global.fetch = vi.fn();
const createTestQueryClient = () => new QueryClient({
defaultOptions: {
queries: {
retry: false,
},
},
});
describe('Dashboard', () => {
beforeEach(() => {
vi.clearAllMocks();
// Mock Auth Response
(global.fetch as any).mockImplementation((url: string) => {
if (url === '/api/auth') {
return Promise.resolve({
json: () => Promise.resolve({ authenticated: true, token: 'test-token' }),
});
}
if (url === '/api/habits') {
return Promise.resolve({
ok: true,
json: () => Promise.resolve({
habits: [
{
id: 1,
name: 'Test Habit',
type: 'neutral',
totalLogs: 5,
logsLastWeek: 2,
logsLastMonth: 5,
createdAt: new Date().toISOString(),
lastLoggedAt: new Date().toISOString(),
}
]
}),
});
}
return Promise.resolve({ ok: true, json: () => Promise.resolve({}) });
});
});
it('renders habits correctly', async () => {
render(
<QueryClientProvider client={createTestQueryClient()}>
<Dashboard />
</QueryClientProvider>
);
await waitFor(() => {
expect(screen.getByText('Test Habit')).toBeInTheDocument();
});
});
it('opens edit dialog when edit button is clicked', async () => {
render(
<QueryClientProvider client={createTestQueryClient()}>
<Dashboard />
</QueryClientProvider>
);
await waitFor(() => {
expect(screen.getByText('Test Habit')).toBeInTheDocument();
});
const editBtn = screen.getByTestId('edit-habit-1');
fireEvent.click(editBtn);
await waitFor(() => {
expect(screen.getByRole('dialog')).toBeInTheDocument();
expect(screen.getByText('Edit Habit')).toBeInTheDocument();
expect(screen.getByDisplayValue('Test Habit')).toBeInTheDocument();
});
});
it('opens archive confirmation when archive button is clicked', async () => {
render(
<QueryClientProvider client={createTestQueryClient()}>
<Dashboard />
</QueryClientProvider>
);
await waitFor(() => {
expect(screen.getByText('Test Habit')).toBeInTheDocument();
});
// Open Edit Dialog
fireEvent.click(screen.getByTestId('edit-habit-1'));
await waitFor(() => screen.getByRole('dialog'));
// Click Archive
fireEvent.click(screen.getByText('Archive Habit'));
await waitFor(() => {
expect(screen.getByText('Confirm Delete')).toBeInTheDocument();
});
});
});

658
app/dashboard/page.tsx Normal file
View File

@@ -0,0 +1,658 @@
'use client';
import { useEffect, useState } from 'react';
import { useRouter } from 'next/navigation';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { formatDistanceToNow } from 'date-fns';
import {
Plus,
TrendingUp,
TrendingDown,
Activity,
Clock,
Calendar,
Target,
Copy,
Check,
Trophy,
HeartCrack,
MoreVertical,
Trash2,
Save,
RotateCcw,
} from 'lucide-react';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from '@/components/ui/dialog';
import { Badge } from '@/components/ui/badge';
import { Alert, AlertDescription } from '@/components/ui/alert';
import { Separator } from '@/components/ui/separator';
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
interface AuthData {
authenticated: boolean;
token?: string;
userId?: string;
}
interface Habit {
id: number;
name: string;
type: 'positive' | 'neutral' | 'negative';
lastLoggedAt: string | null;
totalLogs: number;
logsLastWeek: number;
logsLastMonth: number;
createdAt: string;
}
interface HabitsResponse {
habits: Habit[];
}
interface LogResponse {
log: {
id: number;
habitId: number;
loggedAt: string;
note?: string;
};
}
interface HabitResponse {
habit: Habit;
}
export default function Dashboard() {
const router = useRouter();
const queryClient = useQueryClient();
// State
const [showNewHabitDialog, setShowNewHabitDialog] = useState(false);
const [newHabitName, setNewHabitName] = useState('');
const [newHabitType, setNewHabitType] = useState<'positive' | 'neutral' | 'negative'>('neutral');
const [editingHabit, setEditingHabit] = useState<Habit | null>(null);
const [editHabitName, setEditHabitName] = useState('');
const [editHabitType, setEditHabitType] = useState<'positive' | 'neutral' | 'negative'>('neutral');
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
const [copiedToken, setCopiedToken] = useState(false);
const [currentTime, setCurrentTime] = useState(() => Date.now());
// Check authentication
const { data: authData, isLoading: authLoading } = useQuery<AuthData>({
queryKey: ['auth'],
queryFn: async (): Promise<AuthData> => {
const res = await fetch('/api/auth');
const data = (await res.json()) as AuthData;
if (!data.authenticated) {
router.push('/');
}
return data;
},
});
// Update current time periodically
useEffect(() => {
const interval = setInterval(() => {
setCurrentTime(Date.now());
}, 60000);
return () => clearInterval(interval);
}, []);
// Fetch habits
const { data: habitsData, isLoading: habitsLoading } = useQuery<HabitsResponse>({
queryKey: ['habits'],
queryFn: async (): Promise<HabitsResponse> => {
const res = await fetch('/api/habits');
if (!res.ok) throw new Error('Failed to fetch habits');
return res.json() as Promise<HabitsResponse>;
},
enabled: !!authData?.authenticated,
});
// Log habit mutation
const logHabitMutation = useMutation<LogResponse, Error, number>({
mutationFn: async (habitId: number): Promise<LogResponse> => {
const res = await fetch(`/api/habits/${String(habitId)}/log`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({}),
});
if (!res.ok) throw new Error('Failed to log habit');
return res.json() as Promise<LogResponse>;
},
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: ['habits'] });
},
});
// Undo log mutation
const undoLogMutation = useMutation<void, Error, number>({
mutationFn: async (habitId: number): Promise<void> => {
const res = await fetch(`/api/habits/${habitId}/log`, {
method: 'DELETE',
});
if (!res.ok) throw new Error('Failed to undo log');
},
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: ['habits'] });
},
});
// Create habit mutation
const createHabitMutation = useMutation<HabitResponse, Error, { name: string; type: string }>({
mutationFn: async (data: { name: string; type: string }): Promise<HabitResponse> => {
const res = await fetch('/api/habits', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data),
});
if (!res.ok) throw new Error('Failed to create habit');
return res.json() as Promise<HabitResponse>;
},
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: ['habits'] });
setShowNewHabitDialog(false);
setNewHabitName('');
setNewHabitType('neutral');
},
});
// Update habit mutation
const updateHabitMutation = useMutation<HabitResponse, Error, { id: number; name: string; type: string }>({
mutationFn: async ({ id, name, type }): Promise<HabitResponse> => {
const res = await fetch(`/api/habits/${id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name, type }),
});
if (!res.ok) throw new Error('Failed to update habit');
return res.json() as Promise<HabitResponse>;
},
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: ['habits'] });
setEditingHabit(null);
},
});
// Delete habit mutation
const deleteHabitMutation = useMutation<void, Error, number>({
mutationFn: async (id: number): Promise<void> => {
const res = await fetch(`/api/habits/${id}`, {
method: 'DELETE',
});
if (!res.ok) throw new Error('Failed to delete habit');
},
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: ['habits'] });
setEditingHabit(null);
setShowDeleteConfirm(false);
},
});
const handleCreateHabit = () => {
if (newHabitName.trim()) {
createHabitMutation.mutate({
name: newHabitName.trim(),
type: newHabitType,
});
}
};
const handleUpdateHabit = () => {
if (editingHabit && editHabitName.trim()) {
updateHabitMutation.mutate({
id: editingHabit.id,
name: editHabitName.trim(),
type: editHabitType,
});
}
};
const handleDeleteHabit = () => {
if (editingHabit) {
deleteHabitMutation.mutate(editingHabit.id);
}
};
const openEditDialog = (e: React.MouseEvent, habit: Habit) => {
e.stopPropagation();
setEditingHabit(habit);
setEditHabitName(habit.name);
setEditHabitType(habit.type);
setShowDeleteConfirm(false);
};
const copyToken = () => {
if (authData?.token) {
void navigator.clipboard.writeText(authData.token);
setCopiedToken(true);
setTimeout(() => {
setCopiedToken(false);
}, 2000);
}
};
const getHabitCardClass = (type: string) => {
switch (type) {
case 'positive':
return 'border-emerald-600 bg-emerald-950/50 hover:bg-emerald-900/50 hover:border-emerald-500';
case 'negative':
return 'border-red-600 bg-red-950/50 hover:bg-red-900/50 hover:border-red-500';
default:
return 'border-zinc-700 bg-zinc-950/50 hover:bg-zinc-900/50 hover:border-zinc-600';
}
};
const getHabitIcon = (type: string) => {
switch (type) {
case 'positive':
return <Trophy className="h-5 w-5 text-emerald-500" />;
case 'negative':
return <HeartCrack className="h-5 w-5 text-red-500" />;
default:
return <Activity className="h-5 w-5 text-zinc-500" />;
}
};
const getHabitBadgeVariant = (type: string): 'default' | 'secondary' | 'destructive' | 'outline' => {
switch (type) {
case 'positive':
return 'default';
case 'negative':
return 'destructive';
default:
return 'secondary';
}
};
const getAverageFrequency = (habit: Habit) => {
const daysSinceCreation = Math.max(
1,
Math.floor((currentTime - new Date(habit.createdAt).getTime()) / (1000 * 60 * 60 * 24)),
);
if (daysSinceCreation <= 7) {
const avg = habit.totalLogs / daysSinceCreation;
return `${avg.toFixed(1)}/day`;
} else if (daysSinceCreation <= 30) {
const weeks = daysSinceCreation / 7;
const avg = habit.totalLogs / weeks;
return `${avg.toFixed(1)}/week`;
} else {
const months = daysSinceCreation / 30;
const avg = habit.totalLogs / months;
return `${avg.toFixed(1)}/month`;
}
};
if (authLoading || habitsLoading) {
return (
<div className="flex min-h-screen items-center justify-center bg-black">
<div className="text-zinc-400">Loading...</div>
</div>
);
}
const habits = habitsData?.habits ?? [];
return (
<div className="min-h-screen bg-black">
<div className="mx-auto max-w-7xl p-4 md:p-8">
{/* Header */}
<div className="mb-8">
<div className="mb-6 flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<div>
<h1 className="mb-2 text-4xl font-bold text-white">Track Every Day</h1>
<p className="text-zinc-400">Build better habits, one day at a time.</p>
</div>
<Dialog open={showNewHabitDialog} onOpenChange={setShowNewHabitDialog}>
<DialogTrigger asChild>
<Button size="lg" className="bg-emerald-600 hover:bg-emerald-700">
<Plus className="mr-2 h-5 w-5" />
New Habit
</Button>
</DialogTrigger>
<DialogContent className="border-zinc-800 bg-zinc-950">
<DialogHeader>
<DialogTitle>Create New Habit</DialogTitle>
<DialogDescription>
Add a new habit to track. Choose whether it&apos;s something you want to do more,
less, or just monitor.
</DialogDescription>
</DialogHeader>
<div className="grid gap-4 py-4">
<div className="grid gap-2">
<Label htmlFor="name">Habit Name</Label>
<Input
id="name"
placeholder="e.g., Exercise, Read, Meditate..."
value={newHabitName}
onChange={(e) => {
setNewHabitName(e.target.value);
}}
className="border-zinc-800 bg-zinc-900"
/>
</div>
<div className="grid gap-2">
<Label htmlFor="type">Habit Type</Label>
<Select
value={newHabitType}
onValueChange={(value: 'positive' | 'neutral' | 'negative') => {
setNewHabitType(value);
}}
>
<SelectTrigger className="border-zinc-800 bg-zinc-900">
<SelectValue />
</SelectTrigger>
<SelectContent className="border-zinc-800 bg-zinc-900">
<SelectItem value="positive">
<div className="flex items-center gap-2">
<TrendingUp className="h-4 w-4 text-emerald-500" />
<span>Positive - Something to do more</span>
</div>
</SelectItem>
<SelectItem value="neutral">
<div className="flex items-center gap-2">
<Activity className="h-4 w-4 text-zinc-500" />
<span>Neutral - Just tracking</span>
</div>
</SelectItem>
<SelectItem value="negative">
<div className="flex items-center gap-2">
<TrendingDown className="h-4 w-4 text-red-500" />
<span>Negative - Something to reduce</span>
</div>
</SelectItem>
</SelectContent>
</Select>
</div>
</div>
<DialogFooter>
<Button
variant="outline"
onClick={() => {
setShowNewHabitDialog(false);
}}
>
Cancel
</Button>
<Button
onClick={handleCreateHabit}
disabled={!newHabitName.trim() || createHabitMutation.isPending}
className="bg-emerald-600 hover:bg-emerald-700"
>
{createHabitMutation.isPending ? 'Creating...' : 'Create Habit'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
{/* Token Alert */}
{authData?.token && (
<Alert className="border-zinc-800 bg-zinc-950">
<AlertDescription className="flex items-center justify-between">
<div className="space-y-1">
<p className="text-sm text-zinc-400">Your access token:</p>
<code className="rounded bg-zinc-900 px-2 py-1 font-mono text-sm text-white">
{authData.token}
</code>
</div>
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Button variant="outline" size="sm" onClick={copyToken} className="ml-4">
{copiedToken ? <Check className="h-4 w-4" /> : <Copy className="h-4 w-4" />}
</Button>
</TooltipTrigger>
<TooltipContent>
<p>{copiedToken ? 'Copied!' : 'Copy token'}</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
</AlertDescription>
</Alert>
)}
</div>
{/* Habits Grid */}
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
{habits.map((habit: Habit) => (
<Card
key={habit.id}
className={`group relative transform cursor-pointer transition-all duration-200 hover:scale-[1.02] ${getHabitCardClass(
habit.type,
)} ${logHabitMutation.isPending ? 'opacity-75' : ''}`}
onClick={() => {
logHabitMutation.mutate(habit.id);
}}
>
<CardHeader className="pb-3">
<div className="flex items-start justify-between">
<CardTitle className="text-lg">{habit.name}</CardTitle>
<div className="flex items-center gap-2">
{getHabitIcon(habit.type)}
<Button
variant="ghost"
size="icon"
className="h-8 w-8 opacity-0 transition-opacity group-hover:opacity-100 hover:bg-black/20"
onClick={(e) => openEditDialog(e, habit)}
data-testid={`edit-habit-${habit.id}`}
>
<MoreVertical className="h-4 w-4" />
</Button>
</div>
</div>
</CardHeader>
<CardContent>
<div className="space-y-3">
{/* Last logged */}
<div className="flex items-center justify-between text-sm">
<div className="flex items-center gap-2">
<Clock className="h-4 w-4 text-zinc-500" />
<span className="text-zinc-400">
{habit.lastLoggedAt
? formatDistanceToNow(new Date(habit.lastLoggedAt), { addSuffix: true })
: 'Never logged'}
</span>
</div>
{habit.lastLoggedAt && (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-6 w-6 text-zinc-500 hover:text-red-400 hover:bg-red-950/30"
onClick={(e) => {
e.stopPropagation();
undoLogMutation.mutate(habit.id);
}}
>
<RotateCcw className="h-3 w-3" />
</Button>
</TooltipTrigger>
<TooltipContent>
<p>Undo last log</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
)}
</div>
<Separator className="bg-zinc-800" />
{/* Stats */}
<div className="grid grid-cols-2 gap-2">
<div className="text-center">
<p className="text-2xl font-bold">{habit.totalLogs}</p>
<p className="text-xs text-zinc-500">Total</p>
</div>
<div className="text-center">
<p className="text-2xl font-bold">{habit.logsLastWeek}</p>
<p className="text-xs text-zinc-500">This week</p>
</div>
</div>
{/* Average frequency badge */}
<div className="flex justify-center pt-2">
<Badge variant={getHabitBadgeVariant(habit.type)} className="font-normal">
<Target className="mr-1 h-3 w-3" />
{getAverageFrequency(habit)}
</Badge>
</div>
{/* Motivational message */}
{habit.type === 'positive' && habit.totalLogs > 0 && (
<p className="pt-2 text-center text-xs text-emerald-400">
Keep up the great work! 💪
</p>
)}
{habit.type === 'negative' && habit.lastLoggedAt && (
<p className="pt-2 text-center text-xs text-red-400">
Stay mindful, you&apos;ve got this! 🎯
</p>
)}
</div>
</CardContent>
</Card>
))}
{/* Empty state */}
{habits.length === 0 && (
<Card className="border-dashed border-zinc-800 bg-zinc-950/50 md:col-span-2 lg:col-span-3 xl:col-span-4">
<CardContent className="flex flex-col items-center justify-center py-12">
<Calendar className="mb-4 h-12 w-12 text-zinc-600" />
<h3 className="mb-2 text-lg font-semibold">No habits yet</h3>
<p className="mb-4 text-sm text-zinc-500">Start building better habits today</p>
<Button
onClick={() => {
setShowNewHabitDialog(true);
}}
className="bg-emerald-600 hover:bg-emerald-700"
>
<Plus className="mr-2 h-4 w-4" />
Create Your First Habit
</Button>
</CardContent>
</Card>
)}
</div>
{/* Edit Habit Dialog */}
<Dialog open={!!editingHabit} onOpenChange={(open) => !open && setEditingHabit(null)}>
<DialogContent className="border-zinc-800 bg-zinc-950">
<DialogHeader>
<DialogTitle>Edit Habit</DialogTitle>
<DialogDescription>
Modify your habit details or archive it if you no longer want to track it.
</DialogDescription>
</DialogHeader>
<div className="grid gap-4 py-4">
<div className="grid gap-2">
<Label htmlFor="edit-name">Habit Name</Label>
<Input
id="edit-name"
value={editHabitName}
onChange={(e) => setEditHabitName(e.target.value)}
className="border-zinc-800 bg-zinc-900"
/>
</div>
<div className="grid gap-2">
<Label htmlFor="edit-type">Habit Type</Label>
<Select
value={editHabitType}
onValueChange={(value: 'positive' | 'neutral' | 'negative') => {
setEditHabitType(value);
}}
>
<SelectTrigger className="border-zinc-800 bg-zinc-900">
<SelectValue />
</SelectTrigger>
<SelectContent className="border-zinc-800 bg-zinc-900">
<SelectItem value="positive">
<div className="flex items-center gap-2">
<TrendingUp className="h-4 w-4 text-emerald-500" />
<span>Positive - Something to do more</span>
</div>
</SelectItem>
<SelectItem value="neutral">
<div className="flex items-center gap-2">
<Activity className="h-4 w-4 text-zinc-500" />
<span>Neutral - Just tracking</span>
</div>
</SelectItem>
<SelectItem value="negative">
<div className="flex items-center gap-2">
<TrendingDown className="h-4 w-4 text-red-500" />
<span>Negative - Something to reduce</span>
</div>
</SelectItem>
</SelectContent>
</Select>
</div>
</div>
<DialogFooter className="flex-col items-stretch gap-2 sm:flex-row sm:justify-between">
<div className="flex flex-1 justify-start">
{showDeleteConfirm ? (
<div className="flex items-center gap-2">
<Button
variant="destructive"
onClick={handleDeleteHabit}
disabled={deleteHabitMutation.isPending}
>
{deleteHabitMutation.isPending ? 'Deleting...' : 'Confirm Delete'}
</Button>
<Button variant="ghost" onClick={() => setShowDeleteConfirm(false)}>
Cancel
</Button>
</div>
) : (
<Button
variant="outline"
className="border-red-900 text-red-500 hover:bg-red-950 hover:text-red-400"
onClick={() => setShowDeleteConfirm(true)}
>
<Trash2 className="mr-2 h-4 w-4" />
Archive Habit
</Button>
)}
</div>
<div className="flex items-center gap-2 justify-end">
<Button
variant="outline"
onClick={() => setEditingHabit(null)}
>
Cancel
</Button>
<Button
onClick={handleUpdateHabit}
disabled={!editHabitName.trim() || updateHabitMutation.isPending}
className="bg-emerald-600 hover:bg-emerald-700"
>
<Save className="mr-2 h-4 w-4" />
{updateHabitMutation.isPending ? 'Saving...' : 'Save Changes'}
</Button>
</div>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
</div>
);
}

View File

@@ -1,4 +1,4 @@
@import 'tailwindcss';
@import "tailwindcss";
@custom-variant dark (&:is(.dark *));
@@ -6,10 +6,7 @@
--font-sans: var(--font-sans);
--background-image-gradient-radial: radial-gradient(var(--tw-gradient-stops));
--background-image-gradient-conic: conic-gradient(
from 180deg at 50% 50%,
var(--tw-gradient-stops)
);
--background-image-gradient-conic: conic-gradient(from 180deg at 50% 50%, var(--tw-gradient-stops));
--radius-lg: var(--radius);
--radius-md: calc(var(--radius) - 2px);
@@ -90,41 +87,14 @@
@layer base {
:root {
--background: 0 0% 100%;
--foreground: 240 10% 3.9%;
--card: 0 0% 100%;
--card-foreground: 240 10% 3.9%;
--popover: 0 0% 100%;
--popover-foreground: 240 10% 3.9%;
--primary: 240 5.9% 10%;
--primary-foreground: 0 0% 98%;
--secondary: 240 4.8% 95.9%;
--secondary-foreground: 240 5.9% 10%;
--muted: 240 4.8% 95.9%;
--muted-foreground: 240 3.8% 46.1%;
--accent: 240 4.8% 95.9%;
--accent-foreground: 240 5.9% 10%;
--destructive: 0 84.2% 60.2%;
--destructive-foreground: 0 0% 98%;
--border: 240 5.9% 90%;
--input: 240 5.9% 90%;
--ring: 240 10% 3.9%;
--chart-1: 12 76% 61%;
--chart-2: 173 58% 39%;
--chart-3: 197 37% 24%;
--chart-4: 43 74% 66%;
--chart-5: 27 87% 67%;
--radius: 0.5rem;
}
.dark {
--background: 240 10% 3.9%;
--background: 0 0% 0%;
--foreground: 0 0% 98%;
--card: 240 10% 3.9%;
--card-foreground: 0 0% 98%;
--popover: 240 10% 3.9%;
--popover-foreground: 0 0% 98%;
--primary: 0 0% 98%;
--primary-foreground: 240 5.9% 10%;
--primary: 142.1 76.2% 36.3%;
--primary-foreground: 355.7 100% 97.3%;
--secondary: 240 3.7% 15.9%;
--secondary-foreground: 0 0% 98%;
--muted: 240 3.7% 15.9%;
@@ -135,12 +105,13 @@
--destructive-foreground: 0 0% 98%;
--border: 240 3.7% 15.9%;
--input: 240 3.7% 15.9%;
--ring: 240 4.9% 83.9%;
--ring: 142.1 76.2% 36.3%;
--chart-1: 220 70% 50%;
--chart-2: 160 60% 45%;
--chart-3: 30 80% 55%;
--chart-4: 280 65% 60%;
--chart-5: 340 75% 55%;
--radius: 0.5rem;
}
}
@@ -148,6 +119,9 @@
* {
@apply border-border;
}
html {
@apply bg-black;
}
body {
@apply bg-background text-foreground;
}

View File

@@ -1,22 +1,23 @@
import type { Metadata, Viewport } from "next";
import { Inter } from "next/font/google";
import PlausibleProvider from "next-plausible";
import "./globals.css";
import { cn } from "@/lib/utils";
import type { Metadata, Viewport } from 'next';
import { Inter } from 'next/font/google';
import PlausibleProvider from 'next-plausible';
import './globals.css';
import { cn } from '@/lib/utils';
import { Providers } from './providers';
const inter = Inter({ subsets: ["latin"], variable: "--font-sans" });
const inter = Inter({ subsets: ['latin'], variable: '--font-sans' });
export const viewport: Viewport = {
colorScheme: "dark",
colorScheme: 'dark',
themeColor: [
//{ media: "(prefers-color-scheme: light)", color: "#f5f5f5" },
//{ media: "(prefers-color-scheme: dark)", color: "#171717" },
{ color: "#052e16" },
{ color: '#052e16' },
],
};
export const metadata: Metadata = {
title: "Track Every Day!",
description: "A web app for tracking habits, activities and vices.",
title: 'Track Every Day!',
description: 'A web app for tracking habits, activities and vices.',
};
export default function RootLayout({
@@ -35,13 +36,8 @@ export default function RootLayout({
trackOutboundLinks={true}
/>
</head>
<body
className={cn(
"min-h-screen bg-background font-sans antialiased",
inter.variable
)}
>
{children}
<body className={cn('bg-background min-h-screen font-sans antialiased', inter.variable)}>
<Providers>{children}</Providers>
</body>
</html>
);

View File

@@ -1,5 +1,14 @@
import { redirect } from "next/navigation";
import { cookies } from 'next/headers';
import { redirect } from 'next/navigation';
export default function Home() {
redirect("/welcome");
export default async function Home() {
const cookieStore = await cookies();
const token = cookieStore.get('habit-tracker-token');
// If user has a token, redirect to dashboard, otherwise to welcome
if (token?.value) {
redirect('/dashboard');
} else {
redirect('/welcome');
}
}

21
app/providers.tsx Normal file
View File

@@ -0,0 +1,21 @@
'use client';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { useState } from 'react';
import type { ReactNode } from 'react';
export function Providers({ children }: { children: ReactNode }) {
const [queryClient] = useState(
() =>
new QueryClient({
defaultOptions: {
queries: {
staleTime: 60 * 1000, // 1 minute
refetchOnWindowFocus: false,
},
},
}),
);
return <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>;
}

View File

@@ -3,9 +3,5 @@ export default function Layout({
}: Readonly<{
children: React.ReactNode;
}>) {
return (
<div className="flex flex-col h-screen w-screen block bg-emerald-950 text-neutral-300">
<div className="m-4 md:my-16 md:mx-auto max-w-96">{children}</div>
</div>
);
return <div className="flex min-h-screen items-center justify-center bg-black p-4">{children}</div>;
}

View File

@@ -1,12 +1,219 @@
export default function Home() {
'use client';
import { useState } from 'react';
import { useRouter } from 'next/navigation';
import { useMutation } from '@tanstack/react-query';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Alert, AlertDescription } from '@/components/ui/alert';
import { Separator } from '@/components/ui/separator';
import { LogIn, Shield, Zap, ArrowLeft, Activity, CalendarCheck } from 'lucide-react';
interface AuthResponse {
success?: boolean;
token?: string;
userId?: string;
error?: string;
}
export default function Welcome() {
const router = useRouter();
const [showTokenInput, setShowTokenInput] = useState(false);
const [tokenInput, setTokenInput] = useState('');
const [error, setError] = useState('');
const createAccountMutation = useMutation({
mutationFn: async (): Promise<AuthResponse> => {
const res = await fetch('/api/auth', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ action: 'create' }),
});
if (!res.ok) throw new Error('Failed to create account');
return res.json() as Promise<AuthResponse>;
},
onSuccess: () => {
router.push('/dashboard');
},
onError: () => {
setError('Failed to create account. Please try again.');
},
});
const loginMutation = useMutation({
mutationFn: async (token: string): Promise<AuthResponse> => {
const res = await fetch('/api/auth', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ action: 'login', token }),
});
if (!res.ok) {
const data = (await res.json()) as AuthResponse;
throw new Error(data.error ?? 'Failed to login');
}
return res.json() as Promise<AuthResponse>;
},
onSuccess: () => {
router.push('/dashboard');
},
onError: (error: Error) => {
setError(error.message || 'Failed to login. Please check your token.');
},
});
const handleTokenLogin = () => {
if (tokenInput.trim()) {
setError('');
loginMutation.mutate(tokenInput.trim());
}
};
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'Enter') {
handleTokenLogin();
}
};
return (
<div className="shadow-xl rounded-lg w-full border px-6 py-12 bg-emerald-900 border-emerald-700 ">
<div className="flex flex-col">
<span className="text-4xl font-bold">📅 Track Every Day</span>
<span className="mt-4 text-center">
A web app for logging your habits, vices and activities.
</span>
<Card className="w-full max-w-md border-zinc-800 bg-zinc-950">
<CardHeader className="space-y-2 text-center">
<div className="mx-auto mb-4 flex h-16 w-16 items-center justify-center rounded-2xl bg-emerald-950">
<span className="text-3xl">📅</span>
</div>
<CardTitle className="text-3xl font-bold">Track Every Day</CardTitle>
<CardDescription className="text-base">
Build better habits, one day at a time. No email or password required.
</CardDescription>
</CardHeader>
<CardContent>
{!showTokenInput ? (
<div className="space-y-6">
{/* Features */}
<div className="grid gap-3 text-sm">
<div className="flex items-center gap-3">
<div className="flex h-8 w-8 flex-shrink-0 items-center justify-center rounded-lg bg-emerald-950">
<Shield className="h-4 w-4 text-emerald-500" />
</div>
<p className="text-zinc-400">Privacy-first: No personal data required</p>
</div>
<div className="flex items-center gap-3">
<div className="flex h-8 w-8 flex-shrink-0 items-center justify-center rounded-lg bg-emerald-950">
<Zap className="h-4 w-4 text-emerald-500" />
</div>
<p className="text-zinc-400">Instant access with a unique token</p>
</div>
<div className="flex items-center gap-3">
<div className="flex h-8 w-8 flex-shrink-0 items-center justify-center rounded-lg bg-emerald-950">
<Activity className="h-4 w-4 text-emerald-500" />
</div>
<p className="text-zinc-400">Track positive, neutral, or negative habits</p>
</div>
</div>
<Separator className="bg-zinc-800" />
{/* Actions */}
<div className="space-y-3">
<Button
onClick={() => {
createAccountMutation.mutate();
}}
disabled={createAccountMutation.isPending}
className="w-full bg-emerald-600 text-white hover:bg-emerald-700"
size="lg"
>
{createAccountMutation.isPending ? (
<>Creating your account...</>
) : (
<>
<CalendarCheck className="mr-2 h-4 w-4" />
Start Tracking Now
</>
)}
</Button>
<div className="relative">
<div className="absolute inset-0 flex items-center">
<Separator className="w-full bg-zinc-800" />
</div>
<div className="relative flex justify-center text-xs uppercase">
<span className="bg-zinc-950 px-2 text-zinc-500">or</span>
</div>
</div>
<Button
variant="outline"
onClick={() => {
setShowTokenInput(true);
}}
className="w-full border-zinc-800 hover:bg-zinc-900"
size="lg"
>
<LogIn className="mr-2 h-4 w-4" />I Have a Token
</Button>
</div>
</div>
) : (
<div className="space-y-4">
<Button
variant="ghost"
onClick={() => {
setShowTokenInput(false);
setTokenInput('');
setError('');
}}
className="mb-2 -ml-2 text-zinc-500 hover:text-white"
size="sm"
>
<ArrowLeft className="mr-1 h-4 w-4" />
Back
</Button>
<div className="space-y-2">
<Label htmlFor="token">Access Token</Label>
<Input
id="token"
type="text"
placeholder="e.g., happy-blue-cat-1234"
value={tokenInput}
onChange={(e) => {
setTokenInput(e.target.value);
}}
onKeyDown={handleKeyDown}
className="border-zinc-800 bg-zinc-900 placeholder:text-zinc-600"
autoFocus
/>
<p className="text-xs text-zinc-500">
Enter the token you saved from your previous session
</p>
</div>
<Button
onClick={handleTokenLogin}
disabled={loginMutation.isPending || !tokenInput.trim()}
className="w-full bg-emerald-600 hover:bg-emerald-700"
size="lg"
>
{loginMutation.isPending ? 'Logging in...' : 'Access My Habits'}
</Button>
</div>
)}
{error && (
<Alert className="mt-4 border-red-900 bg-red-950">
<AlertDescription className="text-sm text-red-400">{error}</AlertDescription>
</Alert>
)}
<div className="mt-6 border-t border-zinc-800 pt-6">
<p className="text-center text-xs text-zinc-500">
Your habits are tied to a unique token. Save it to access your data across devices. No
account creation or personal information required.
</p>
</div>
</CardContent>
</Card>
);
}

60
components/ui/alert.tsx Normal file
View File

@@ -0,0 +1,60 @@
import * as React from 'react';
import { cva, type VariantProps } from 'class-variance-authority';
import { cn } from '@/lib/utils';
const alertVariants = cva(
'relative w-full rounded-lg border px-4 py-3 text-sm grid has-[>svg]:grid-cols-[calc(var(--spacing)*4)_1fr] grid-cols-[0_1fr] has-[>svg]:gap-x-3 gap-y-0.5 items-start [&>svg]:size-4 [&>svg]:translate-y-0.5 [&>svg]:text-current',
{
variants: {
variant: {
default: 'bg-card text-card-foreground',
destructive:
'text-destructive bg-card [&>svg]:text-current *:data-[slot=alert-description]:text-destructive/90',
},
},
defaultVariants: {
variant: 'default',
},
},
);
function Alert({
className,
variant,
...props
}: React.ComponentProps<'div'> & VariantProps<typeof alertVariants>) {
return (
<div
data-slot="alert"
role="alert"
className={cn(alertVariants({ variant }), className)}
{...props}
/>
);
}
function AlertTitle({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot="alert-title"
className={cn('col-start-2 line-clamp-1 min-h-4 font-medium tracking-tight', className)}
{...props}
/>
);
}
function AlertDescription({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot="alert-description"
className={cn(
'text-muted-foreground col-start-2 grid justify-items-start gap-1 text-sm [&_p]:leading-relaxed',
className,
)}
{...props}
/>
);
}
export { Alert, AlertTitle, AlertDescription };

37
components/ui/badge.tsx Normal file
View File

@@ -0,0 +1,37 @@
import * as React from 'react';
import { Slot } from '@radix-ui/react-slot';
import { cva, type VariantProps } from 'class-variance-authority';
import { cn } from '@/lib/utils';
const badgeVariants = cva(
'inline-flex items-center justify-center rounded-md border px-2 py-0.5 text-xs font-medium w-fit whitespace-nowrap shrink-0 [&>svg]:size-3 gap-1 [&>svg]:pointer-events-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive transition-[color,box-shadow] overflow-hidden',
{
variants: {
variant: {
default: 'border-transparent bg-primary text-primary-foreground [a&]:hover:bg-primary/90',
secondary:
'border-transparent bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90',
destructive:
'border-transparent bg-destructive text-white [a&]:hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60',
outline: 'text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground',
},
},
defaultVariants: {
variant: 'default',
},
},
);
function Badge({
className,
variant,
asChild = false,
...props
}: React.ComponentProps<'span'> & VariantProps<typeof badgeVariants> & { asChild?: boolean }) {
const Comp = asChild ? Slot : 'span';
return <Comp data-slot="badge" className={cn(badgeVariants({ variant }), className)} {...props} />;
}
export { Badge, badgeVariants };

52
components/ui/button.tsx Normal file
View File

@@ -0,0 +1,52 @@
import * as React from 'react';
import { Slot } from '@radix-ui/react-slot';
import { cva, type VariantProps } from 'class-variance-authority';
import { cn } from '@/lib/utils';
const buttonVariants = cva(
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
{
variants: {
variant: {
default: 'bg-primary text-primary-foreground shadow-xs hover:bg-primary/90',
destructive:
'bg-destructive text-white shadow-xs hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60',
outline:
'border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50',
secondary: 'bg-secondary text-secondary-foreground shadow-xs hover:bg-secondary/80',
ghost: 'hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50',
link: 'text-primary underline-offset-4 hover:underline',
},
size: {
default: 'h-9 px-4 py-2 has-[>svg]:px-3',
sm: 'h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5',
lg: 'h-10 rounded-md px-6 has-[>svg]:px-4',
icon: 'size-9',
},
},
defaultVariants: {
variant: 'default',
size: 'default',
},
},
);
function Button({
className,
variant,
size,
asChild = false,
...props
}: React.ComponentProps<'button'> &
VariantProps<typeof buttonVariants> & {
asChild?: boolean;
}) {
const Comp = asChild ? Slot : 'button';
return (
<Comp data-slot="button" className={cn(buttonVariants({ variant, size, className }))} {...props} />
);
}
export { Button, buttonVariants };

71
components/ui/card.tsx Normal file
View File

@@ -0,0 +1,71 @@
import * as React from 'react';
import { cn } from '@/lib/utils';
function Card({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot="card"
className={cn(
'bg-card text-card-foreground flex flex-col gap-6 rounded-xl border py-6 shadow-sm',
className,
)}
{...props}
/>
);
}
function CardHeader({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot="card-header"
className={cn(
'@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start gap-1.5 px-6 has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6',
className,
)}
{...props}
/>
);
}
function CardTitle({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div data-slot="card-title" className={cn('leading-none font-semibold', className)} {...props} />
);
}
function CardDescription({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot="card-description"
className={cn('text-muted-foreground text-sm', className)}
{...props}
/>
);
}
function CardAction({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot="card-action"
className={cn('col-start-2 row-span-2 row-start-1 self-start justify-self-end', className)}
{...props}
/>
);
}
function CardContent({ className, ...props }: React.ComponentProps<'div'>) {
return <div data-slot="card-content" className={cn('px-6', className)} {...props} />;
}
function CardFooter({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot="card-footer"
className={cn('flex items-center px-6 [.border-t]:pt-6', className)}
{...props}
/>
);
}
export { Card, CardHeader, CardFooter, CardTitle, CardAction, CardDescription, CardContent };

126
components/ui/dialog.tsx Normal file
View File

@@ -0,0 +1,126 @@
'use client';
import * as React from 'react';
import * as DialogPrimitive from '@radix-ui/react-dialog';
import { XIcon } from 'lucide-react';
import { cn } from '@/lib/utils';
function Dialog({ ...props }: React.ComponentProps<typeof DialogPrimitive.Root>) {
return <DialogPrimitive.Root data-slot="dialog" {...props} />;
}
function DialogTrigger({ ...props }: React.ComponentProps<typeof DialogPrimitive.Trigger>) {
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />;
}
function DialogPortal({ ...props }: React.ComponentProps<typeof DialogPrimitive.Portal>) {
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />;
}
function DialogClose({ ...props }: React.ComponentProps<typeof DialogPrimitive.Close>) {
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />;
}
function DialogOverlay({ className, ...props }: React.ComponentProps<typeof DialogPrimitive.Overlay>) {
return (
<DialogPrimitive.Overlay
data-slot="dialog-overlay"
className={cn(
'data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50',
className,
)}
{...props}
/>
);
}
function DialogContent({
className,
children,
showCloseButton = true,
...props
}: React.ComponentProps<typeof DialogPrimitive.Content> & {
showCloseButton?: boolean;
}) {
return (
<DialogPortal data-slot="dialog-portal">
<DialogOverlay />
<DialogPrimitive.Content
data-slot="dialog-content"
className={cn(
'bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-lg',
className,
)}
{...props}
>
{children}
{showCloseButton && (
<DialogPrimitive.Close
data-slot="dialog-close"
className="ring-offset-background focus:ring-ring data-[state=open]:bg-accent data-[state=open]:text-muted-foreground absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4"
>
<XIcon />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
)}
</DialogPrimitive.Content>
</DialogPortal>
);
}
function DialogHeader({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot="dialog-header"
className={cn('flex flex-col gap-2 text-center sm:text-left', className)}
{...props}
/>
);
}
function DialogFooter({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot="dialog-footer"
className={cn('flex flex-col-reverse gap-2 sm:flex-row sm:justify-end', className)}
{...props}
/>
);
}
function DialogTitle({ className, ...props }: React.ComponentProps<typeof DialogPrimitive.Title>) {
return (
<DialogPrimitive.Title
data-slot="dialog-title"
className={cn('text-lg leading-none font-semibold', className)}
{...props}
/>
);
}
function DialogDescription({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Description>) {
return (
<DialogPrimitive.Description
data-slot="dialog-description"
className={cn('text-muted-foreground text-sm', className)}
{...props}
/>
);
}
export {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogOverlay,
DialogPortal,
DialogTitle,
DialogTrigger,
};

21
components/ui/input.tsx Normal file
View File

@@ -0,0 +1,21 @@
import * as React from 'react';
import { cn } from '@/lib/utils';
function Input({ className, type, ...props }: React.ComponentProps<'input'>) {
return (
<input
type={type}
data-slot="input"
className={cn(
'file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input flex h-9 w-full min-w-0 rounded-md border bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm',
'focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]',
'aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive',
className,
)}
{...props}
/>
);
}
export { Input };

21
components/ui/label.tsx Normal file
View File

@@ -0,0 +1,21 @@
'use client';
import * as React from 'react';
import * as LabelPrimitive from '@radix-ui/react-label';
import { cn } from '@/lib/utils';
function Label({ className, ...props }: React.ComponentProps<typeof LabelPrimitive.Root>) {
return (
<LabelPrimitive.Root
data-slot="label"
className={cn(
'flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50',
className,
)}
{...props}
/>
);
}
export { Label };

170
components/ui/select.tsx Normal file
View File

@@ -0,0 +1,170 @@
'use client';
import * as React from 'react';
import * as SelectPrimitive from '@radix-ui/react-select';
import { CheckIcon, ChevronDownIcon, ChevronUpIcon } from 'lucide-react';
import { cn } from '@/lib/utils';
function Select({ ...props }: React.ComponentProps<typeof SelectPrimitive.Root>) {
return <SelectPrimitive.Root data-slot="select" {...props} />;
}
function SelectGroup({ ...props }: React.ComponentProps<typeof SelectPrimitive.Group>) {
return <SelectPrimitive.Group data-slot="select-group" {...props} />;
}
function SelectValue({ ...props }: React.ComponentProps<typeof SelectPrimitive.Value>) {
return <SelectPrimitive.Value data-slot="select-value" {...props} />;
}
function SelectTrigger({
className,
size = 'default',
children,
...props
}: React.ComponentProps<typeof SelectPrimitive.Trigger> & {
size?: 'sm' | 'default';
}) {
return (
<SelectPrimitive.Trigger
data-slot="select-trigger"
data-size={size}
className={cn(
"border-input data-[placeholder]:text-muted-foreground [&_svg:not([class*='text-'])]:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 dark:hover:bg-input/50 flex w-fit items-center justify-between gap-2 rounded-md border bg-transparent px-3 py-2 text-sm whitespace-nowrap shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 data-[size=default]:h-9 data-[size=sm]:h-8 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className,
)}
{...props}
>
{children}
<SelectPrimitive.Icon asChild>
<ChevronDownIcon className="size-4 opacity-50" />
</SelectPrimitive.Icon>
</SelectPrimitive.Trigger>
);
}
function SelectContent({
className,
children,
position = 'popper',
...props
}: React.ComponentProps<typeof SelectPrimitive.Content>) {
return (
<SelectPrimitive.Portal>
<SelectPrimitive.Content
data-slot="select-content"
className={cn(
'bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 relative z-50 max-h-(--radix-select-content-available-height) min-w-[8rem] origin-(--radix-select-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border shadow-md',
position === 'popper' &&
'data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1',
className,
)}
position={position}
{...props}
>
<SelectScrollUpButton />
<SelectPrimitive.Viewport
className={cn(
'p-1',
position === 'popper' &&
'h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)] scroll-my-1',
)}
>
{children}
</SelectPrimitive.Viewport>
<SelectScrollDownButton />
</SelectPrimitive.Content>
</SelectPrimitive.Portal>
);
}
function SelectLabel({ className, ...props }: React.ComponentProps<typeof SelectPrimitive.Label>) {
return (
<SelectPrimitive.Label
data-slot="select-label"
className={cn('text-muted-foreground px-2 py-1.5 text-xs', className)}
{...props}
/>
);
}
function SelectItem({
className,
children,
...props
}: React.ComponentProps<typeof SelectPrimitive.Item>) {
return (
<SelectPrimitive.Item
data-slot="select-item"
className={cn(
"focus:bg-accent focus:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground relative flex w-full cursor-default items-center gap-2 rounded-sm py-1.5 pr-8 pl-2 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2",
className,
)}
{...props}
>
<span className="absolute right-2 flex size-3.5 items-center justify-center">
<SelectPrimitive.ItemIndicator>
<CheckIcon className="size-4" />
</SelectPrimitive.ItemIndicator>
</span>
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
</SelectPrimitive.Item>
);
}
function SelectSeparator({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.Separator>) {
return (
<SelectPrimitive.Separator
data-slot="select-separator"
className={cn('bg-border pointer-events-none -mx-1 my-1 h-px', className)}
{...props}
/>
);
}
function SelectScrollUpButton({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.ScrollUpButton>) {
return (
<SelectPrimitive.ScrollUpButton
data-slot="select-scroll-up-button"
className={cn('flex cursor-default items-center justify-center py-1', className)}
{...props}
>
<ChevronUpIcon className="size-4" />
</SelectPrimitive.ScrollUpButton>
);
}
function SelectScrollDownButton({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.ScrollDownButton>) {
return (
<SelectPrimitive.ScrollDownButton
data-slot="select-scroll-down-button"
className={cn('flex cursor-default items-center justify-center py-1', className)}
{...props}
>
<ChevronDownIcon className="size-4" />
</SelectPrimitive.ScrollDownButton>
);
}
export {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectLabel,
SelectScrollDownButton,
SelectScrollUpButton,
SelectSeparator,
SelectTrigger,
SelectValue,
};

View File

@@ -0,0 +1,28 @@
'use client';
import * as React from 'react';
import * as SeparatorPrimitive from '@radix-ui/react-separator';
import { cn } from '@/lib/utils';
function Separator({
className,
orientation = 'horizontal',
decorative = true,
...props
}: React.ComponentProps<typeof SeparatorPrimitive.Root>) {
return (
<SeparatorPrimitive.Root
data-slot="separator"
decorative={decorative}
orientation={orientation}
className={cn(
'bg-border shrink-0 data-[orientation=horizontal]:h-px data-[orientation=horizontal]:w-full data-[orientation=vertical]:h-full data-[orientation=vertical]:w-px',
className,
)}
{...props}
/>
);
}
export { Separator };

53
components/ui/tooltip.tsx Normal file
View File

@@ -0,0 +1,53 @@
'use client';
import * as React from 'react';
import * as TooltipPrimitive from '@radix-ui/react-tooltip';
import { cn } from '@/lib/utils';
function TooltipProvider({
delayDuration = 0,
...props
}: React.ComponentProps<typeof TooltipPrimitive.Provider>) {
return (
<TooltipPrimitive.Provider data-slot="tooltip-provider" delayDuration={delayDuration} {...props} />
);
}
function Tooltip({ ...props }: React.ComponentProps<typeof TooltipPrimitive.Root>) {
return (
<TooltipProvider>
<TooltipPrimitive.Root data-slot="tooltip" {...props} />
</TooltipProvider>
);
}
function TooltipTrigger({ ...props }: React.ComponentProps<typeof TooltipPrimitive.Trigger>) {
return <TooltipPrimitive.Trigger data-slot="tooltip-trigger" {...props} />;
}
function TooltipContent({
className,
sideOffset = 0,
children,
...props
}: React.ComponentProps<typeof TooltipPrimitive.Content>) {
return (
<TooltipPrimitive.Portal>
<TooltipPrimitive.Content
data-slot="tooltip-content"
sideOffset={sideOffset}
className={cn(
'bg-primary text-primary-foreground animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-fit origin-(--radix-tooltip-content-transform-origin) rounded-md px-3 py-1.5 text-xs text-balance',
className,
)}
{...props}
>
{children}
<TooltipPrimitive.Arrow className="bg-primary fill-primary z-50 size-2.5 translate-y-[calc(-50%_-_2px)] rotate-45 rounded-[2px]" />
</TooltipPrimitive.Content>
</TooltipPrimitive.Portal>
);
}
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider };

16
drizzle.config.ts Normal file
View File

@@ -0,0 +1,16 @@
import { defineConfig } from 'drizzle-kit';
import '@/lib/env-config';
const DATABASE_URL = process.env.POSTGRES_URL;
if (!DATABASE_URL) {
throw new Error('POSTGRES_URL environment variable is required');
}
export default defineConfig({
schema: './lib/db/schema.ts',
dialect: 'postgresql',
dbCredentials: {
url: DATABASE_URL,
},
out: './drizzle',
});

View File

@@ -0,0 +1,32 @@
CREATE TABLE "habit_logs" (
"id" serial PRIMARY KEY NOT NULL,
"habit_id" integer NOT NULL,
"logged_at" timestamp DEFAULT now() NOT NULL,
"note" text
);
--> statement-breakpoint
CREATE TABLE "habits" (
"id" serial PRIMARY KEY NOT NULL,
"user_id" integer NOT NULL,
"name" text NOT NULL,
"type" text DEFAULT 'neutral' NOT NULL,
"target_frequency" jsonb,
"color" text,
"icon" text,
"is_archived" boolean DEFAULT false NOT NULL,
"created_at" timestamp DEFAULT now() NOT NULL,
"archived_at" timestamp
);
--> statement-breakpoint
CREATE TABLE "users" (
"id" serial PRIMARY KEY NOT NULL,
"token" text NOT NULL,
"created_at" timestamp DEFAULT now() NOT NULL,
CONSTRAINT "users_token_unique" UNIQUE("token")
);
--> statement-breakpoint
ALTER TABLE "habit_logs" ADD CONSTRAINT "habit_logs_habit_id_habits_id_fk" FOREIGN KEY ("habit_id") REFERENCES "public"."habits"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "habits" ADD CONSTRAINT "habits_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
CREATE INDEX "habit_logs_habit_id_idx" ON "habit_logs" USING btree ("habit_id");--> statement-breakpoint
CREATE INDEX "habit_logs_logged_at_idx" ON "habit_logs" USING btree ("logged_at");--> statement-breakpoint
CREATE INDEX "habits_user_id_idx" ON "habits" USING btree ("user_id");

View File

@@ -0,0 +1,248 @@
{
"id": "1b2a9dc5-79f6-4172-ba68-f1b9c0e67708",
"prevId": "00000000-0000-0000-0000-000000000000",
"version": "7",
"dialect": "postgresql",
"tables": {
"public.habit_logs": {
"name": "habit_logs",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "serial",
"primaryKey": true,
"notNull": true
},
"habit_id": {
"name": "habit_id",
"type": "integer",
"primaryKey": false,
"notNull": true
},
"logged_at": {
"name": "logged_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"note": {
"name": "note",
"type": "text",
"primaryKey": false,
"notNull": false
}
},
"indexes": {
"habit_logs_habit_id_idx": {
"name": "habit_logs_habit_id_idx",
"columns": [
{
"expression": "habit_id",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
},
"habit_logs_logged_at_idx": {
"name": "habit_logs_logged_at_idx",
"columns": [
{
"expression": "logged_at",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
}
},
"foreignKeys": {
"habit_logs_habit_id_habits_id_fk": {
"name": "habit_logs_habit_id_habits_id_fk",
"tableFrom": "habit_logs",
"tableTo": "habits",
"columnsFrom": [
"habit_id"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.habits": {
"name": "habits",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "serial",
"primaryKey": true,
"notNull": true
},
"user_id": {
"name": "user_id",
"type": "integer",
"primaryKey": false,
"notNull": true
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true
},
"type": {
"name": "type",
"type": "text",
"primaryKey": false,
"notNull": true,
"default": "'neutral'"
},
"target_frequency": {
"name": "target_frequency",
"type": "jsonb",
"primaryKey": false,
"notNull": false
},
"color": {
"name": "color",
"type": "text",
"primaryKey": false,
"notNull": false
},
"icon": {
"name": "icon",
"type": "text",
"primaryKey": false,
"notNull": false
},
"is_archived": {
"name": "is_archived",
"type": "boolean",
"primaryKey": false,
"notNull": true,
"default": false
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"archived_at": {
"name": "archived_at",
"type": "timestamp",
"primaryKey": false,
"notNull": false
}
},
"indexes": {
"habits_user_id_idx": {
"name": "habits_user_id_idx",
"columns": [
{
"expression": "user_id",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
}
},
"foreignKeys": {
"habits_user_id_users_id_fk": {
"name": "habits_user_id_users_id_fk",
"tableFrom": "habits",
"tableTo": "users",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.users": {
"name": "users",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "serial",
"primaryKey": true,
"notNull": true
},
"token": {
"name": "token",
"type": "text",
"primaryKey": false,
"notNull": true
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {
"users_token_unique": {
"name": "users_token_unique",
"nullsNotDistinct": false,
"columns": [
"token"
]
}
},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
}
},
"enums": {},
"schemas": {},
"sequences": {},
"roles": {},
"policies": {},
"views": {},
"_meta": {
"columns": {},
"schemas": {},
"tables": {}
}
}

View File

@@ -0,0 +1,13 @@
{
"version": "7",
"dialect": "postgresql",
"entries": [
{
"idx": 0,
"version": "7",
"when": 1752596363693,
"tag": "0000_lazy_prism",
"breakpoints": true
}
]
}

38
eslint.config.js Normal file
View File

@@ -0,0 +1,38 @@
// @ts-check
import { defineConfig, globalIgnores } from 'eslint/config';
import nextVitals from 'eslint-config-next/core-web-vitals';
import nextTs from 'eslint-config-next/typescript';
import tseslint from 'typescript-eslint';
const eslintConfig = defineConfig([
// Next.js core-web-vitals and TypeScript configs
...nextVitals,
...nextTs,
// Add strict TypeScript rules on top
...tseslint.configs.strictTypeChecked,
...tseslint.configs.stylisticTypeChecked,
// Configure TypeScript parser options
{
files: ['**/*.{ts,tsx}'],
languageOptions: {
parserOptions: {
projectService: true,
tsconfigRootDir: import.meta.dirname,
},
},
},
// Override default ignores of eslint-config-next
globalIgnores([
// Default ignores of eslint-config-next:
'.next/**',
'out/**',
'build/**',
'next-env.d.ts',
// Additional ignores:
'*.mjs',
'tailwind.config.ts',
'eslint.config.js',
]),
]);
export default eslintConfig;

27
lib/auth/cookies.ts Normal file
View File

@@ -0,0 +1,27 @@
import { cookies } from 'next/headers';
import '@/lib/env-config';
const TOKEN_COOKIE_NAME = 'habit-tracker-token';
const TOKEN_COOKIE_OPTIONS = {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'lax' as const,
maxAge: 60 * 60 * 24 * 365, // 1 year
path: '/',
};
export async function setTokenCookie(token: string) {
const cookieStore = await cookies();
cookieStore.set(TOKEN_COOKIE_NAME, token, TOKEN_COOKIE_OPTIONS);
}
export async function getTokenCookie(): Promise<string | undefined> {
const cookieStore = await cookies();
const cookie = cookieStore.get(TOKEN_COOKIE_NAME);
return cookie?.value;
}
export async function deleteTokenCookie() {
const cookieStore = await cookies();
cookieStore.delete(TOKEN_COOKIE_NAME);
}

167
lib/auth/tokens.ts Normal file
View File

@@ -0,0 +1,167 @@
import { customAlphabet } from 'nanoid';
// Word lists for generating memorable tokens
const adjectives = [
'quick',
'lazy',
'happy',
'brave',
'bright',
'calm',
'clever',
'eager',
'gentle',
'kind',
'lively',
'proud',
'silly',
'witty',
'bold',
'cool',
'fair',
'fine',
'glad',
'good',
'neat',
'nice',
'rare',
'safe',
'warm',
'wise',
'fresh',
'clean',
'clear',
'crisp',
'sweet',
'smooth',
];
const colors = [
'red',
'blue',
'green',
'yellow',
'purple',
'orange',
'pink',
'black',
'white',
'gray',
'brown',
'cyan',
'lime',
'navy',
'teal',
'gold',
'silver',
'coral',
'salmon',
'indigo',
'violet',
'crimson',
'azure',
'jade',
];
const animals = [
'cat',
'dog',
'bird',
'fish',
'bear',
'lion',
'wolf',
'fox',
'deer',
'owl',
'hawk',
'duck',
'goat',
'seal',
'crab',
'moth',
'bee',
'ant',
'bat',
'cow',
'pig',
'hen',
'ram',
'rat',
'eel',
'cod',
'jay',
'yak',
'ox',
'pug',
'doe',
'hog',
];
const nouns = [
'moon',
'star',
'cloud',
'river',
'mountain',
'ocean',
'forest',
'desert',
'island',
'valley',
'meadow',
'garden',
'bridge',
'castle',
'tower',
'light',
'shadow',
'dream',
'hope',
'wish',
'song',
'dance',
'smile',
'laugh',
'gift',
'pearl',
'jewel',
'crown',
'shield',
'sword',
'arrow',
'bow',
];
// Generate a 4-digit number suffix for uniqueness
const generateNumber = customAlphabet('0123456789', 4);
function getRandomElement<T>(array: T[]): T {
return array[Math.floor(Math.random() * array.length)];
}
export function generateMemorableToken(): string {
const parts = [
getRandomElement(adjectives),
getRandomElement(colors),
getRandomElement(animals),
generateNumber(),
];
return parts.join('-');
}
export function generateShortToken(): string {
const parts = [getRandomElement(colors), getRandomElement(nouns), generateNumber()];
return parts.join('-');
}
// Validate token format
export function isValidToken(token: string): boolean {
// Check if token matches our format (word-word-word-4digits or word-word-4digits)
const longFormat = /^[a-z]+-[a-z]+-[a-z]+-\d{4}$/;
const shortFormat = /^[a-z]+-[a-z]+-\d{4}$/;
return longFormat.test(token) || shortFormat.test(token);
}

28
lib/db/index.ts Normal file
View File

@@ -0,0 +1,28 @@
import { drizzle } from 'drizzle-orm/node-postgres';
import type { NodePgDatabase } from 'drizzle-orm/node-postgres';
import * as schema from './schema';
import '@/lib/env-config';
let _db: NodePgDatabase<typeof schema> | null = null;
function getDb() {
if (!_db) {
const DATABASE_URL = process.env.POSTGRES_URL;
if (!DATABASE_URL) {
throw new Error('POSTGRES_URL environment variable is required');
}
_db = drizzle(DATABASE_URL, { schema });
}
return _db;
}
export const db = new Proxy({} as NodePgDatabase<typeof schema>, {
get(target, prop) {
const database = getDb();
const value = database[prop as keyof typeof database];
return typeof value === 'function' ? value.bind(database) : value;
},
});
// Re-export schema types for convenience
export * from './schema';

76
lib/db/schema.ts Normal file
View File

@@ -0,0 +1,76 @@
import { pgTable, serial, text, timestamp, integer, jsonb, boolean, index } from 'drizzle-orm/pg-core';
import { relations } from 'drizzle-orm';
export const users = pgTable('users', {
id: serial('id').primaryKey(),
token: text('token').notNull().unique(),
createdAt: timestamp('created_at').defaultNow().notNull(),
});
export const habits = pgTable(
'habits',
{
id: serial('id').primaryKey(),
userId: integer('user_id')
.references(() => users.id)
.notNull(),
name: text('name').notNull(),
type: text('type', { enum: ['positive', 'neutral', 'negative'] })
.notNull()
.default('neutral'),
targetFrequency: jsonb('target_frequency').$type<{
value: number;
period: 'day' | 'week' | 'month';
}>(),
color: text('color'),
icon: text('icon'),
isArchived: boolean('is_archived').default(false).notNull(),
createdAt: timestamp('created_at').defaultNow().notNull(),
archivedAt: timestamp('archived_at'),
},
(table) => [index('habits_user_id_idx').on(table.userId)],
);
export const habitLogs = pgTable(
'habit_logs',
{
id: serial('id').primaryKey(),
habitId: integer('habit_id')
.references(() => habits.id)
.notNull(),
loggedAt: timestamp('logged_at').defaultNow().notNull(),
note: text('note'),
},
(table) => [
index('habit_logs_habit_id_idx').on(table.habitId),
index('habit_logs_logged_at_idx').on(table.loggedAt),
],
);
// Relations
export const usersRelations = relations(users, ({ many }) => ({
habits: many(habits),
}));
export const habitsRelations = relations(habits, ({ one, many }) => ({
user: one(users, {
fields: [habits.userId],
references: [users.id],
}),
logs: many(habitLogs),
}));
export const habitLogsRelations = relations(habitLogs, ({ one }) => ({
habit: one(habits, {
fields: [habitLogs.habitId],
references: [habits.id],
}),
}));
// Types
export type User = typeof users.$inferSelect;
export type NewUser = typeof users.$inferInsert;
export type Habit = typeof habits.$inferSelect;
export type NewHabit = typeof habits.$inferInsert;
export type HabitLog = typeof habitLogs.$inferSelect;
export type NewHabitLog = typeof habitLogs.$inferInsert;

4
lib/env-config.ts Normal file
View File

@@ -0,0 +1,4 @@
import { loadEnvConfig } from '@next/env';
const projectDir = process.cwd();
loadEnvConfig(projectDir);

View File

@@ -1,6 +1,6 @@
import { clsx, type ClassValue } from "clsx"
import { twMerge } from "tailwind-merge"
import { clsx, type ClassValue } from 'clsx';
import { twMerge } from 'tailwind-merge';
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
return twMerge(clsx(inputs));
}

8596
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -2,38 +2,79 @@
"name": "trackeveryday",
"version": "0.1.0",
"private": true,
"type": "module",
"scripts": {
"dev": "next dev --turbopack",
"build": "next build",
"start": "next start",
"lint": "next lint && npx tsc --noEmit"
"lint": "next typegen && eslint . && npx tsc --noEmit",
"test": "vitest",
"test:e2e": "playwright test",
"test:coverage": "vitest run --coverage",
"db:generate": "drizzle-kit generate",
"db:migrate": "drizzle-kit migrate",
"db:push": "drizzle-kit push",
"db:studio": "drizzle-kit studio",
"format:check": "prettier --check \"**/*.{ts,tsx,js,jsx,mdx}\" --cache",
"format:write": "prettier --write \"**/*.{ts,tsx,js,jsx,mdx}\" --cache"
},
"dependencies": {
"class-variance-authority": "^0.7.0",
"@next/env": "^16.0.3",
"@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-label": "^2.1.8",
"@radix-ui/react-select": "^2.2.6",
"@radix-ui/react-separator": "^1.1.8",
"@radix-ui/react-slot": "^1.2.4",
"@radix-ui/react-tooltip": "^1.2.8",
"@tanstack/react-query": "^5.90.9",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"cssnano": "^7.0.1",
"lucide-react": "^0.509.0",
"next": "15.3.2",
"next-plausible": "^3.12.0",
"cssnano": "^7.1.2",
"date-fns": "^4.1.0",
"drizzle-orm": "^0.44.7",
"lucide-react": "^0.554.0",
"nanoid": "^5.1.6",
"next": "16.0.3",
"next-plausible": "^3.12.5",
"pg": "^8.16.3",
"pg-native": "^3.5.2",
"postcss-flexbugs-fixes": "^5.0.2",
"postcss-preset-env": "^10.0.0",
"react": "19.1.0",
"react-dom": "19.1.0",
"sharp": "^0.34.0",
"tailwind-merge": "^3.0.0",
"postcss-preset-env": "^10.4.0",
"react": "19.2.0",
"react-dom": "19.2.0",
"tailwind-merge": "^3.4.0",
"tailwindcss-animate": "^1.0.7"
},
"devDependencies": {
"@tailwindcss/postcss": "^4.1.6",
"@types/node": "22.15.17",
"@types/react": "19.1.3",
"@types/react-dom": "19.1.3",
"eslint": "8.57.1",
"eslint-config-next": "15.3.2",
"postcss": "8.5.3",
"tailwindcss": "^4.1.6",
"turbo": "2.5.3",
"typescript": "5.8.3"
"@playwright/test": "^1.56.1",
"@tailwindcss/postcss": "4.1.17",
"@testing-library/dom": "^10.4.1",
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.0",
"@types/node": "24.10.1",
"@types/pg": "8.15.6",
"@types/react": "19.2.6",
"@types/react-dom": "19.2.3",
"@vitejs/plugin-react": "^5.1.1",
"drizzle-kit": "0.31.7",
"eslint": "9.39.1",
"eslint-config-next": "16.0.3",
"eslint-config-prettier": "^10.1.8",
"jsdom": "^27.2.0",
"postcss": "8.5.6",
"prettier": "3.6.2",
"prettier-plugin-tailwindcss": "0.7.1",
"tailwindcss": "4.1.17",
"turbo": "2.6.1",
"typescript": "5.9.3",
"typescript-eslint": "8.47.0",
"vitest": "^4.0.13"
},
"packageManager": "npm@11.3.0"
"packageManager": "pnpm@10.23.0",
"pnpm": {
"overrides": {
"@types/react": "19.2.6",
"@types/react-dom": "19.2.3"
}
}
}

26
playwright.config.ts Normal file
View File

@@ -0,0 +1,26 @@
import { defineConfig, devices } from '@playwright/test';
export default defineConfig({
testDir: './tests/e2e',
fullyParallel: true,
forbidOnly: !!process.env.CI,
retries: process.env.CI ? 2 : 0,
workers: process.env.CI ? 1 : undefined,
reporter: 'list',
use: {
baseURL: 'http://localhost:3000',
trace: 'on-first-retry',
},
projects: [
{
name: 'chromium',
use: { ...devices['Desktop Chrome'] },
},
],
webServer: {
command: 'pnpm dev',
url: 'http://localhost:3000',
reuseExistingServer: !process.env.CI,
},
});

8735
pnpm-lock.yaml generated Normal file

File diff suppressed because it is too large Load Diff

3
pnpm-workspace.yaml Normal file
View File

@@ -0,0 +1,3 @@
onlyBuiltDependencies:
- '@tailwindcss/oxide'
- sharp

24
proxy.ts Normal file
View File

@@ -0,0 +1,24 @@
import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';
export function proxy(request: NextRequest) {
const token = request.cookies.get('habit-tracker-token');
const isAuthPage = request.nextUrl.pathname === '/welcome';
const isDashboard = request.nextUrl.pathname === '/dashboard';
// If trying to access dashboard without token, redirect to welcome
if (isDashboard && !token) {
return NextResponse.redirect(new URL('/welcome', request.url));
}
// If trying to access welcome page with token, redirect to dashboard
if (isAuthPage && token) {
return NextResponse.redirect(new URL('/dashboard', request.url));
}
return NextResponse.next();
}
export const config = {
matcher: ['/dashboard', '/welcome'],
};

14
tests/e2e/smoke.spec.ts Normal file
View File

@@ -0,0 +1,14 @@
import { test, expect } from '@playwright/test';
test('landing page loads and has create account button', async ({ page }) => {
await page.goto('/');
await expect(page.getByRole('heading', { name: 'Track Every Day' })).toBeVisible();
await expect(page.getByRole('button', { name: 'Start Tracking Now' })).toBeVisible();
});
test('can navigate to login input', async ({ page }) => {
await page.goto('/');
await page.getByRole('button', { name: 'I Have a Token' }).click();
await expect(page.getByLabel('Access Token')).toBeVisible();
});

View File

@@ -1,7 +1,7 @@
{
"compilerOptions": {
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"allowJs": false,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
@@ -10,18 +10,20 @@
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "preserve",
"jsx": "react-jsx",
"incremental": true,
"plugins": [
{
"name": "next"
}
],
"baseUrl": ".",
"paths": {
"@/*": ["./*"]
},
"target": "ES2022"
"target": "ES2022",
"forceConsistentCasingInFileNames": true,
"verbatimModuleSyntax": true,
"noUncheckedIndexedAccess": false,
"exactOptionalPropertyTypes": false,
"noImplicitReturns": false,
"plugins": [{ "name": "next" }]
},
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts", ".next/dev/types/**/*.ts"],
"exclude": ["node_modules"]
}

17
vitest.config.ts Normal file
View File

@@ -0,0 +1,17 @@
import { defineConfig } from 'vitest/config';
import react from '@vitejs/plugin-react';
import path from 'path';
export default defineConfig({
plugins: [react()],
test: {
environment: 'jsdom',
globals: true,
setupFiles: ['./vitest.setup.ts'],
alias: {
'@': path.resolve(__dirname, './'),
},
// Exclude E2E tests from Vitest
exclude: ['**/node_modules/**', '**/dist/**', '**/e2e/**', '**/tests/e2e/**'],
},
});

2
vitest.setup.ts Normal file
View File

@@ -0,0 +1,2 @@
import '@testing-library/jest-dom';