Compare commits

...

95 Commits

Author SHA1 Message Date
bb2e4be41b Merge Tag 'v0.2.30' 2025-12-04 23:21:16 +01:00
dohsimpson
b01c5dcd6a feat: update Next.js to v15.5.7 to address CVE-2025-55182 2025-12-03 12:48:53 -05:00
3f9cd87c4d fix: added missing i18n en entries 2025-09-02 22:52:25 +02:00
083fae020a fix: imports 2025-09-02 22:47:24 +02:00
38da61c6c2 Merge Tag 'v0.2.29' 2025-09-02 22:46:45 +02:00
0689a5827f fix: replace return null with empty tags 2025-09-02 22:35:36 +02:00
ab0c5e3e99 Merge Tag 'v0.2.28' 2025-09-02 22:31:39 +02:00
3cc8543067 Merge Tag 'v0.2.27' 2025-09-02 22:29:16 +02:00
06aa27af63 Merge Tag 'v0.2.26' 2025-09-02 22:28:48 +02:00
c5bacb719c Merge Tag 'v0.2.25' 2025-09-02 22:28:15 +02:00
3ae2a3cb79 Merge Tag 'v0.2.24' 2025-09-02 22:27:46 +02:00
Doh
3e6b4b75ec feat: freehand drawing capability and card layout improvements and v0.2.29 release (#180) 2025-08-21 23:04:50 -04:00
Doh
31700c9a45 feat: server permission checking and v0.2.28 release (#178) 2025-08-20 17:27:50 -04:00
Doh
e05b982307 feat: mobile navigation text centering and v0.2.27 release (#177) 2025-08-20 17:14:32 -04:00
Doh
ee2821b2bf feat: optimize Docker build performance and add version validation (#176) 2025-08-20 10:04:23 -04:00
Doh
8fb7cd1810 Add project documentation and translation guide (#174) 2025-08-20 09:26:29 -04:00
1b17d6b50a fix: add TS types 2025-08-17 19:49:11 +02:00
Doh
a6f5bf1baa Update README.md
updated tasktrove banner
2025-08-17 09:40:10 -04:00
Doh
8dda60b9b1 Add Korean translation (#169) 2025-08-14 14:56:09 -04:00
8269f3adad fix: refactored code & removed unused parts 2025-08-09 18:57:04 +02:00
Doh
ad2504dc7f Update README.md 2025-07-11 22:12:00 -04:00
4cadf4cea7 fix: added upstream differences to README 2025-07-10 00:45:45 +02:00
06e802f2f5 fix: resolved mobile display errors 2025-06-17 23:45:55 +02:00
6c0b196de2 fix: only display 'show all' if there are more than 4 entries 2025-06-17 23:20:39 +02:00
0f073760ee fix: unify NavDisplays 2025-06-17 22:30:18 +02:00
55c2e3577d Merge Tag v0.2.23 2025-06-13 21:59:16 +02:00
043201217f Merge Tag v0.2.22 2025-06-13 21:57:27 +02:00
4e11f17729 Merge Tag v0.2.21 2025-06-13 21:52:24 +02:00
faa6f4cb76 Merge Tag v0.2.20 2025-06-13 21:43:44 +02:00
84d6321153 Merge Tag v0.2.19 2025-06-13 21:26:19 +02:00
1af98fb233 Merge Tag v0.2.18 2025-06-13 21:22:11 +02:00
Doh
8d2bfaf62c update PWA icon, fix floating number balance (#159) 2025-06-04 18:40:48 -04:00
9046d40a7a Merge Tag v0.2.17.0 2025-06-04 16:08:11 +02:00
be0a5c48b3 Merge Tag v0.2.16.0 2025-06-04 16:02:37 +02:00
Doh
98b5d5eebb Added logo to README 2025-05-31 10:53:04 -04:00
Doh
276e8a8a7b refresh stale data (#156) 2025-05-30 18:04:03 -04:00
dohsimpson
1967d154ed bump version 2025-05-29 08:58:46 -04:00
Doh
9e0ae1e0da Fix emojipicker (#152) 2025-05-29 08:46:08 -04:00
dohsimpson
5ae659469b fix infinite render 2025-05-28 17:43:28 -04:00
dohsimpson
6ef4aacfb8 fix coin balance 2025-05-28 17:17:13 -04:00
dohsimpson
95203426a3 fix modal and invalid frequency 2025-05-27 02:42:13 -04:00
Doh
b673d54ede Added improved loading screen (#148) 2025-05-26 08:42:00 -04:00
Doh
42c8d14d6d fix emoji picker and about modal (#146) 2025-05-25 20:33:08 -04:00
Doh
3ac311c3fd add cover image in README 2025-05-25 20:27:46 -04:00
Doh
1a286a99f4 feat: Move delete account button to user edit modal (#144)
Co-authored-by: google-labs-jules[bot] <161369871+google-labs-jules[bot]@users.noreply.github.com>
2025-05-25 17:30:41 -04:00
3d78a00c66 Merge Tag v0.2.15.0 2025-05-25 17:46:20 +02:00
9c2e3f7dec Merge Tag v0.2.14 2025-05-25 17:41:03 +02:00
Doh
82f45343ae max coin limit (#140)
Co-authored-by: google-labs-jules[bot] <161369871+google-labs-jules[bot]@users.noreply.github.com>
2025-05-22 22:05:49 -04:00
Doh
a3d2b1ef96 support delete user (#139)
Co-authored-by: google-labs-jules[bot] <161369871+google-labs-jules[bot]@users.noreply.github.com>
2025-05-22 21:24:30 -04:00
e93b1c1c57 fix: resolved linting errors 2025-05-21 15:05:12 +02:00
92d1462010 Merge Tag v0.2.13 2025-05-21 14:57:45 +02:00
Doh
ac71c94d53 fix responsive (#135) 2025-05-20 22:12:31 -04:00
eff14f3772 Merge Tag v0.2.12 2025-05-19 12:56:21 +02:00
Doh
91ffe46863 Added i18n support (#129) 2025-05-18 09:00:48 -04:00
d9fa0426ce fix: removed viewType from browser Settings Atom, converted to using path to identify pages 2025-05-18 02:03:54 +02:00
49a0ea8804 fix: refactored habit / task page 2025-05-18 01:34:28 +02:00
9bf24db477 fix: remove empty file 2025-05-18 01:16:39 +02:00
8530f703d9 fix: unified display of header 2025-05-18 01:16:17 +02:00
1a447e00bf fix: adapted notes to reflect fork 2025-05-17 17:27:26 +02:00
ac116e8322 feat: highlight selected navigation item 2025-05-17 17:16:09 +02:00
8c7a7a63d0 fix: refactored code on wishlist page 2025-05-17 16:46:54 +02:00
7c7d0e2f32 fix: switched docker-compose.yaml image remote 2025-05-17 16:29:54 +02:00
e908f1edec fix: resolved linting problems 2025-05-14 11:01:05 +02:00
8e6ddf0b9f fix: removed other build workflows 2025-05-14 10:38:06 +02:00
c5a8f403ef feat: added ghcr release workflow 2025-05-14 10:35:28 +02:00
33d36d0600 fix: refactored error display in add habit modal & disables button if
invalid
2025-05-12 18:11:25 +02:00
942356eaed fix: resolved navigator undefined error 2025-05-12 18:11:03 +02:00
e4a52657af fix: refactored error display in add habit modal & disables button if invalid 2025-05-12 18:00:04 +02:00
dbd0d0c7b7 fix: added missing dependency lodash 2025-05-12 17:05:16 +02:00
Doh
95197e216c Update README.md 2025-05-10 19:55:03 -04:00
Doh
660005d857 Show overdue tasks and improved context menu (#110) 2025-05-10 15:51:39 -04:00
Doh
2408ed84bd performance optimization via atoms (#108) 2025-04-20 12:14:51 -04:00
Doh
dda8b522e3 Added auto-backups feature (#107) 2025-04-17 23:18:37 -04:00
Doh
909bfa7c6f Added notification for admin user (#106) 2025-04-13 22:01:07 -04:00
dohsimpson
e53e2f649a fix build 2025-04-10 17:03:07 -04:00
dohsimpson
a42c0324c5 fix build 2025-04-10 16:56:11 -04:00
Doh
685cb80321 add support for habit pinning (#105) 2025-04-10 16:47:59 -04:00
Doh
f1e3ee5747 support interval habit frequency (#104) 2025-04-10 15:33:33 -04:00
dohsimpson
d31982bf29 [SECURITY] patched cve-2025-29927 2025-03-25 10:47:03 -04:00
Doh
9052c9f37a per-user coins data for admin (#82)
* admin user can see per-user coins data

* fixes

* fix
2025-02-28 17:07:44 -05:00
dohsimpson
a615a45c39 fix demo bugs 2025-02-26 18:51:13 -05:00
dohsimpson
dea2b30c3b fix completion badge 2025-02-21 18:16:15 -05:00
Doh
ea0203dc86 added iOS padding (#69) 2025-02-19 20:00:17 -05:00
dohsimpson
b7933ea040 fix build error 2025-02-18 23:59:41 -05:00
Doh
8ac2ec053d Multiuser support (#60) 2025-02-18 23:43:23 -05:00
dohsimpson
363b31e934 fix mobile responsive layout for habit and task list 2025-01-27 22:41:27 -05:00
dohsimpson
7065d5694b fix redeem link 2025-01-27 18:43:15 -05:00
dohsimpson
b62cf77ba8 redeem link + completing task + play sound 2025-01-27 18:24:53 -05:00
dohsimpson
c66e28162c dark mode 2025-01-25 13:03:07 -05:00
Doh
6fe10d9fa5 support archiving habit and wishlist + wishlist redeem count (#49) 2025-01-24 20:41:26 -05:00
dohsimpson
d3502e284d added support for tasks 2025-01-22 17:59:59 -05:00
dohsimpson
3b33719e1a fix completed habits map 2025-01-21 22:28:51 -05:00
dohsimpson
9d804dba1e added start day of week settings 2025-01-21 10:36:41 -05:00
dohsimpson
2bcbabccc1 enable completing past habit 2025-01-18 19:02:17 -05:00
Doh
7ca1744168 action to auto cut github release (#43) 2025-01-17 16:17:57 -05:00
120 changed files with 14820 additions and 2014 deletions

View File

@@ -7,3 +7,17 @@ Dockerfile
node_modules
npm-debug.log
data
CLAUDE.md
docs/
Budfile
PLAN.md
/backups/
/data.bak/
/coverage/
*.md
!README.md
!CHANGELOG.md
tags
tsconfig.tsbuildinfo
.env.local
.env.*.local

View File

@@ -1,68 +0,0 @@
name: Docker Build and Publish
on:
push:
branches:
- main
- github-actions
jobs:
build-and-push:
runs-on: ubuntu-latest
outputs:
EXISTS: ${{ steps.check-version.outputs.EXISTS }}
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Set up Node.js
uses: actions/setup-node@v4
with:
node-version: "20"
- name: Get version from package.json
id: package-version
run: echo "VERSION=$(node -p "require('./package.json').version")" >> $GITHUB_OUTPUT
- name: Login to DockerHub
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Check if version exists
id: check-version
run: |
if docker pull dohsimpson/habittrove:v${{ steps.package-version.outputs.VERSION }} 2>/dev/null; then
echo "EXISTS=true" >> $GITHUB_OUTPUT
else
echo "EXISTS=false" >> $GITHUB_OUTPUT
fi
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Build and push Docker images
uses: docker/build-push-action@v5
with:
context: .
platforms: linux/amd64,linux/arm64
push: true
tags: |
${{ steps.check-version.outputs.EXISTS == 'false' && format('dohsimpson/habittrove:v{0}', steps.package-version.outputs.VERSION) || '' }}
${{ steps.check-version.outputs.EXISTS == 'false' && 'dohsimpson/habittrove:latest' || '' }}
dohsimpson/habittrove:dev
deploy-demo:
runs-on: ubuntu-latest
needs: build-and-push
# demo tracks the latest tag
if: needs.build-and-push.outputs.EXISTS == 'false'
steps:
- uses: actions/checkout@v4
- uses: actions-hub/kubectl@master
env:
KUBE_CONFIG: ${{ secrets.KUBE_CONFIG }}
with:
args: rollout restart -n ${{ secrets.KUBE_NAMESPACE }} deploy/${{ secrets.KUBE_DEPLOYMENT }}

40
.github/workflows/release.yml vendored Normal file
View File

@@ -0,0 +1,40 @@
name: Create and publish a Docker image to Github Container Registry
on:
push:
tags:
- v*
env:
REGISTRY: ghcr.io
IMAGE_NAME: ${{ github.repository }}
jobs:
create-and-publish:
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Log in to the Container registry
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Extract metadata (tags, labels) for Docker
id: meta
uses: docker/metadata-action@v5
with:
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Build and push Docker image
uses: docker/build-push-action@v5
with:
context: .
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}

View File

@@ -1,25 +0,0 @@
name: Unit Tests
on:
pull_request:
branches:
- main
jobs:
test:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Set up Bun
uses: oven-sh/setup-bun@v1
with:
bun-version: latest
- name: Install dependencies
run: bun install
- name: Run unit tests
run: bun test

7
.gitignore vendored
View File

@@ -41,6 +41,11 @@ yarn-error.log*
next-env.d.ts
# customize
data/*
/data/*
/data.*/*
Budfile
certificates
/backups/*
CLAUDE.md
CHANGELOG.md.tmp

12
.husky/pre-commit Normal file → Executable file
View File

@@ -1,7 +1,11 @@
if git diff --cached --name-only --diff-filter=d | xargs grep -n '🪚'; then
echo "Error: Found debug marker 🪚 in these files:"
git diff --cached --name-only --diff-filter=d | xargs grep -n '🪚' | awk -F: '{print " " $1 ":" $2}'
#!/bin/sh
# Check that package.json version exists in CHANGELOG.md
VERSION=$(node -p "require('./package.json').version")
if ! grep -q "## Version $VERSION" CHANGELOG.md; then
echo "❌ Error: Version $VERSION from package.json not found in CHANGELOG.md"
echo "Please add an entry for version $VERSION in CHANGELOG.md"
exit 1
fi
echo "✅ Version $VERSION found in CHANGELOG.md"
npm run typecheck && npm run test
npm run typecheck && npm run lint && npm run test

6
.vscode/settings.json vendored Normal file
View File

@@ -0,0 +1,6 @@
{
"i18n-ally.localesPaths": [
"i18n",
"messages"
]
}

View File

@@ -1,5 +1,331 @@
# Changelog
## Version 0.2.30
### Fixed
* Security: Updated Next.js from 15.2.3 to 15.5.7 to address CVE-2025-55182 (https://github.com/vercel/next.js/security/advisories/GHSA-9qr9-h5gf-34mp)
## Version 0.2.29
### Added
* ✏️ Freehand drawing capability for habits and wishlist items
### Fixed
* Wishlist and Habit card layout - time and rewards sections now stay at bottom regardless of description length
* Wishlist card user avatars now appear on same row as title for consistency with habit cards
## Version 0.2.28
### Added
* Server permission checking system to validate data directory access on startup
* Permission error display with troubleshooting guidance and recheck functionality
## Version 0.2.27
### Fixed
* Mobile navigation text centering and sizing for multi-word translations
## Version 0.2.26
### Improved
* Docker build performance optimization with cache mounts
## Version 0.2.25
### Added
* 🌍 Added Catalan language support (Català)
### Fixed
* Translation files consistency: Added missing UserForm keys to English and Korean translations
## Version 0.2.24
### Added
* 🌍 Added Korean language support (한국어)
## Version 0.2.23
### Fixed
* floating number coin balance (#155)
* disable freshness check if browser does not support web crypto (#161)
### Improved
* use transparent background PWA icon with correct text (#103)
* display icon in logo
## Version 0.2.22
### Added
* auto check data freshness on interval (#138)
* warn about out-of-sync data
## Version 0.2.21
### Fixed
* emoji picker overlay issue (#150)
## Version 0.2.20
### Fixed
* coin balance shows correct value for selected user in coin management view (#151)
### Improved
* refactor code to remove client-helpers hook
## Version 0.2.19
### Fixed
* settings button not working
* fixed delete dialog modal blocks page interaction (#149)
* disable submit button when frequency is invaid
## Version 0.2.18
### Improved
* nicer loading UI (#147)
* header and navigation code refactor
## Version 0.2.17
### Fixed
* fix emoji selector (#142)
* fix about modal (#145)
## Version 0.2.16
### Improved
* move delete user button to user form
* disable deleting user on demo instance
## Version 0.2.15
### Improved
* max coins set to 9999, to prevent js large number precision issue (#137)
## Version 0.2.14
### Added
* support deleting user (#93)
## Version 0.2.13
### Fixed
* fix responsive design on mobile (#134)
* fix translation (#132)
* fix latest docker tag auto build (#131)
## Version 0.2.12
### Added
* 🌍 Added multi-language support! Users can now select their preferred language in settings.
* Supported languages: English, Español (Spanish), Deutsch (German), Français (French), Русский (Russian), 简体中文 (Simplified Chinese) and 日本語 (Japanese).
## Version 0.2.11
### Added
* support searching and sorting in habit list
### Improved
* Show overdue tasks in daily overview
* Context menu option for tasks changed from "Move to Today" to "Move to Tomorrow"
* More context menu items in daily overview
* code refactor for context menu and daily overview item section
## Version 0.2.10
### Improved
* performance optimization: faster load time for large data set
## Version 0.2.9
### Added
* Auto backup feature: Automatically backs up data
* Backup rotation: Keeps the last 7 daily backups
* Setting to enable/disable auto backup.
## Version 0.2.8
### Added
* notification for admin users on shared habit / wishlist completion (#92)
## Version 0.2.7
### Added
* visual pin indicators for pinned habits/tasks
* pin/unpin options in context menus
* support click and right-click context menu in dailyoverview
## Version 0.2.6
### Added
* support weekly / monthly intervals for recurring frequency (#99)
* show error when frequency is unsupported (#56)
* add task / habit button in habit view
### Fixed
* make user select modal scrollable
## Version 0.2.5
### Changed
* bumped Nextjs version (cve-2025-29927)
## Version 0.2.4
### Added
* admin can select user to view coins for that user
### Fixed
* fix disable password in demo instance (#74)
## Version 0.2.3
### Fixed
* gracefully handle invalid rrule (#76)
* fix long habit name overflow in daily (#75)
* disable password in demo instance (#74)
## Version 0.2.2
### Changed
* persist "show all" settings in browser (#72)
### Fixed
* nav bar spacing
* completion count badge
## Version 0.2.1
### Changed
* Added bottom padding for nav bar on iOS devices (#63)
## Version 0.2.0
### Added
* Multi-user support with permissions system
* Sharing habits and wishlist items with other users
* show both tasks and habits in dashboard (#58)
* show tasks in completion streak (#57)
### BREAKING CHANGE
* PLEASE BACK UP `data/` DIRECTORY BEFORE UPGRADE.
* Requires AUTH_SECRET environment variable for user authentication. Generate a secure secret with: `openssl rand -base64 32`
* Previous coin balance will be hidden. If this is undesirable, consider using manual adjustment to adjust coin balance after upgrade.
## Version 0.1.30
### Fixed
- fix responsive layout on mobile for habits and wishlist when has archived items
## Version 0.1.29
### Fixed
- actually working redeem link for wishlist items (#52)
## Version 0.1.28
### Added
- redeem link for wishlist items (#52)
- sound effect for habit / task completion (#53)
### Fixed
- fail habit create or edit if frequency is not set (#54)
- archive task when completed (#50)
## Version 0.1.27
### Added
- dark mode toggle (#48)
- notification badge for tasks (#51)
## Version 0.1.26
### Added
- archiving habits and wishlists (#44)
- wishlist item now supports redeem count (#36)
### Fixed
- pomodoro skip should update label
## Version 0.1.25
### Added
- added support for tasks (#41)
## Version 0.1.24
### Fixed
- completed habits atom should not store partially completed habits (#46)
## Version 0.1.23
### Added
- settings to adjust week start day for calendar (#45)
## Version 0.1.22
### Added
- start pomodoro from habit view
- complete past habit in calendar view (#32)
## Version 0.1.21
### Added
- auto cut github release for new version
## Version 0.1.20
### Changed

100
CLAUDE.md Normal file
View File

@@ -0,0 +1,100 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Project Overview
HabitTrove is a gamified habit tracking PWA built with Next.js 15, TypeScript, and Jotai state management. Users earn coins for completing habits and can redeem them for rewards. Features multi-user support with admin capabilities and shared ownership of habits/wishlist items.
## Essential Commands
### Development
- `npm run dev` - Start development server with Turbopack
- `npm run setup:dev` - Full setup: installs bun, dependencies, runs typecheck and lint
- `npm install --force` - Install dependencies (force flag required)
### Quality Assurance (Run these before committing)
- `npm run typecheck` - TypeScript type checking (required)
- `npm run lint` - ESLint code linting (required)
- `npm test` - Run tests with Bun
- `npm run build` - Build production version
### Docker Deployment
- `npm run docker-build` - Build Docker image locally
- `docker compose up -d` - Run with docker-compose (recommended)
- Requires `AUTH_SECRET` environment variable: `openssl rand -base64 32`
## Version Management
### Creating a New Version
1. Update version in `package.json`
2. Update `CHANGELOG.md` with new version and changes (follow existing patterns in the file, keep entries concise - ideally 1 line per change)
3. Run `npm run typecheck && npm run lint` to ensure code quality
4. Commit changes: `git add . && git commit -m "feat: description"`
* Follow Conventional Commits Standard: `<type>[scope]: <description>` (e.g., `feat(auth): add OAuth integration`, `fix: resolve memory leak in task loader`).
## Architecture Overview
### State Management (Jotai)
- **Central atoms**: `habitsAtom`, `coinsAtom`, `wishlistAtom`, `usersAtom` in `lib/atoms.ts`
- **Derived atoms**: Computed values like `dailyHabitsAtom`, `coinsBalanceAtom`
- **Business logic hooks**: `useHabits`, `useCoins`, `useWishlist` in `/hooks`
### Data Models & Ownership
- **Individual ownership**: `CoinTransaction` has single `userId`
- **Shared ownership**: `Habit` and `WishlistItemType` have `userIds: string[]` array
- **Admin features**: Admin users can view/manage any user's data via dropdown selectors
- **Data persistence**: JSON files in `/data` directory with automatic `/backups`
### Key Components Structure
- **Feature components**: `HabitList`, `CoinsManager`, `WishlistManager` - main page components
- **Modal components**: `AddEditHabitModal`, `AddEditWishlistItemModal`, `UserSelectModal`
- **UI components**: `/components/ui` - shadcn/ui based components
### Authentication & Users
- NextAuth.js v5 with multi-user support
- User permissions: regular users vs admin users
- Admin dropdown patterns: Similar implementation across Habits/Wishlist pages (reference CoinsManager for pattern)
### Internationalization
- `next-intl` with messages in `/messages/*.json`
- Supported languages: English, Spanish, German, French, Russian, Chinese, Japanese
## Code Patterns
### Component Structure
```typescript
// Standard component pattern:
export default function ComponentName() {
const [data, setData] = useAtom(dataAtom)
const { businessLogicFunction } = useCustomHook()
// Component logic
}
```
### Hook Patterns
- Custom hooks accept options: `useHabits({ selectedUser?: string })`
- Return destructured functions and computed values
- Handle both individual and shared ownership models
### Shared Ownership Pattern
```typescript
// Filtering for shared ownership:
const userItems = allItems.filter(item =>
item.userIds && item.userIds.includes(targetUserId)
)
```
### Admin Dropdown Pattern
Reference `CoinsManager.tsx:107-119` for admin user selection implementation. Similar pattern should be applied to Habits and Wishlist pages.
## Data Safety
- Always backup `/data` before major changes
- Test with existing data files to prevent data loss
- Validate user permissions for all data operations
- Handle migration scripts carefully (see PLAN.md for shared ownership migration)
## Performance Considerations
- State updates use immutable patterns
- Large dataset filtering happens at hook level
- Derived atoms prevent unnecessary re-renders

View File

@@ -1,56 +1,52 @@
# syntax=docker.io/docker/dockerfile:1
FROM --platform=$BUILDPLATFORM node:18-alpine AS base
# Use build platform for base images to avoid emulation
FROM --platform=$BUILDPLATFORM node:22-alpine AS base
# Install dependencies only when needed
FROM --platform=$BUILDPLATFORM base AS deps
# Check https://github.com/nodejs/docker-node/tree/b4117f9333da4138b03a546ec926ef50a31506c3#nodealpine to understand why libc6-compat might be needed.
FROM base AS deps
RUN apk add --no-cache libc6-compat
WORKDIR /app
# Install dependencies based on the preferred package manager
# Use cache mounts for npm cache
COPY package.json yarn.lock* package-lock.json* pnpm-lock.yaml* .npmrc* ./
RUN \
RUN --mount=type=cache,target=/root/.npm \
if [ -f yarn.lock ]; then yarn --frozen-lockfile; \
# use --force flag until all deps supports react19
elif [ -f package-lock.json ]; then npm ci --force; \
elif [ -f pnpm-lock.yaml ]; then corepack enable pnpm && pnpm i --frozen-lockfile; \
else echo "Lockfile not found." && exit 1; \
fi
# Rebuild the source code only when needed
FROM --platform=$BUILDPLATFORM base AS builder
FROM base AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
ENV NEXT_TELEMETRY_DISABLED=1
RUN \
# Use cache mount for Next.js cache
RUN --mount=type=cache,target=/app/.next/cache \
if [ -f yarn.lock ]; then yarn run build; \
elif [ -f package-lock.json ]; then npm run build; \
elif [ -f pnpm-lock.yaml ]; then corepack enable pnpm && pnpm run build; \
else echo "Lockfile not found." && exit 1; \
fi
# Production image, copy all the files and run next
FROM base AS runner
# Production image - use target platform
FROM node:22-alpine AS runner
WORKDIR /app
ENV NODE_ENV=production
ENV NEXT_TELEMETRY_DISABLED=1
RUN addgroup --system --gid 1001 nodejs
RUN adduser --system --uid 1001 nextjs
# Create data directory and set permissions
RUN mkdir -p /app/data && chown nextjs:nodejs /app/data
RUN addgroup --system --gid 1001 nodejs && \
adduser --system --uid 1001 nextjs && \
mkdir -p /app/data /app/backups && \
chown nextjs:nodejs /app/data /app/backups
COPY --from=builder /app/public ./public
COPY --from=builder /app/CHANGELOG.md ./
# Automatically leverage output traces to reduce image size
# https://nextjs.org/docs/advanced-features/output-file-tracing
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
@@ -61,6 +57,6 @@ EXPOSE 3000
ENV PORT=3000
ENV HOSTNAME="0.0.0.0"
VOLUME ["/app/data"]
VOLUME ["/app/data", "/app/backups"]
CMD ["node", "server.js"]

View File

@@ -1,12 +1,22 @@
# HabitTrove
# <img align="left" width="50" height="50" src="https://github.com/user-attachments/assets/99dcf223-3680-4b3a-8050-d9788f051682" /> HabitTrove
HabitTrove is a gamified habit tracking application that helps you build and maintain positive habits by rewarding you with coins, which you can use to exchange for rewards.
> **⚠️ Important:** HabitTrove is currently in beta. Please regularly backup your `data/` directory to prevent any potential data loss.
## Differences to Upstream
## Try the Demo
I generally try to keep the `main` branch up to date with upstream features, merging tagged versions and mapping them to `<upstream-version>.0`.
Want to try HabitTrove before installing? Visit the public [demo instance](https://habittrove.app.enting.org) to experience all features without any setup required. (do not store personal info. Data on the demo instance is reset daily)
In this version I've taken steps to ensure a smoother experience and decreased the chance of the program bricking itself. This doesn't mean that it's completely stable, but I've fixed the most glaring bugs I encountered.
Differences (as of writing) are:
- resolved linting problems so you can actually commit things
- added missing dependency
- refactored adding habit modal to cause less errors
- resolved undefined error
- replaced dockerhub release flow with github
- miscellaneous refactorings
- split habits & tasks page into two different pages
- only display "show all" if there are more than 4 entries
## Features
@@ -14,18 +24,18 @@ Want to try HabitTrove before installing? Visit the public [demo instance](https
- 🏆 Earn coins for completing habits
- 💰 Create a wishlist of rewards to redeem with earned coins
- 📊 View your habit completion streaks and statistics
- ✏️ Add freehand drawings to habits and wishlist items for visual reminders
- 📅 Calendar heatmap to visualize your progress (WIP)
- 🌙 Dark mode support (WIP)
- 📲 Progressive Web App (PWA) support (Planned)
- 🌍 Multi-language support (English, Español, Català, Deutsch, Français, Русский, 简体中文, 한국어, 日本語)
- 🌙 Dark mode support
- 📲 Progressive Web App (PWA) support
- 💾 Automatic daily backups with rotation
## Usage
1. **Creating Habits**: Click the "Add Habit" button to create a new habit. Set a name, description, and coin reward.
2. **Tracking Habits**: Mark habits as complete on your dashboard. Each completion earns you the specified coins.
3. **Wishlist**: Add rewards to your wishlist that you can redeem with earned coins.
4. **Statistics**: View your progress through the heatmap and streak counters.
## Docker Deployment
@@ -39,18 +49,30 @@ The easiest way to run HabitTrove is using our pre-built Docker images from Dock
1. First, prepare the data directory with correct permissions:
```bash
mkdir -p data
chown -R 1001:1001 data # Required for the nextjs user in container
mkdir -p data backups
chown -R 1001:1001 data backups # Required for the nextjs user in container
```
2. Then run using either method:
```bash
# Generate a secure authentication secret
export AUTH_SECRET=$(openssl rand -base64 32)
echo $AUTH_SECRET
# Using docker-compose (recommended)
## Update the AUTH_SECRET environment variable in docker-compose.yaml
nano docker-compose.yaml
## Start the container
docker compose up -d
# Or using docker run directly
docker run -d -p 3000:3000 -v ./data:/app/data dohsimpson/habittrove
docker run -d \
-p 3000:3000 \
-v ./data:/app/data \
-v ./backups:/app/backups \ # Add this line to map the backups directory
-e AUTH_SECRET=$AUTH_SECRET \
ghcr.io/manindark/habittrove
```
Available image tags:
@@ -62,9 +84,11 @@ Available image tags:
Choose your tag based on needs:
- Use `latest` for general production use
- Use version tags (e.g., `v0.1.4`) for reproducible deployments
- Use version tags (e.g., `v0.2.9`) for reproducible deployments
- Use `dev` for testing new features
**Note on Volumes:** The application stores user data in `/app/data` and backups in `/app/backups` inside the container. The examples above map `./data` and `./backups` from your host machine to these container directories. Ensure these host directories exist and have the correct permissions (`chown -R 1001:1001 data backups`).
### Building Locally
If you want to build the image locally (useful for development):
@@ -95,7 +119,7 @@ To contribute to HabitTrove, you'll need to set up a development environment. He
1. Clone the repository and navigate to the project directory:
```bash
git clone https://github.com/dohsimpson/habittrove.git
git clone https://github.com/ManInDark/HabitTrove.git
cd habittrove
```
@@ -148,7 +172,7 @@ Run these commands regularly during development to catch issues early.
## Contributing
We welcome feature requests and bug reports! Please [open an issue](https://github.com/dohsimpson/habittrove/issues/new). We do not accept pull request at the moment.
We welcome feature requests and bug reports! Please [open an issue](https://github.com/ManInDark/habittrove/issues/new).
## License

View File

@@ -1,20 +1,37 @@
'use server'
import fs from 'fs/promises'
import path from 'path'
import { getCurrentUser, saltAndHashPassword, verifyPassword } from "@/lib/server-helpers";
import {
HabitsData,
CoinsData,
CoinTransaction,
TransactionType,
WishlistItemType,
WishlistData,
Settings,
DataType,
DATA_DEFAULTS,
getDefaultSettings
} from '@/lib/types'
import { d2t, getNow } from '@/lib/utils';
DataType,
getDefaultCoinsData,
getDefaultHabitsData,
getDefaultSettings,
getDefaultUsersData,
getDefaultWishlistData,
HabitsData,
Permission,
ServerSettings,
Settings,
TransactionType,
User,
UserData,
WishlistData,
WishlistItemType
} from '@/lib/types';
import { d2t, generateCryptoHash, getNow, prepareDataForHashing } from '@/lib/utils';
import { signInSchema } from '@/lib/zod';
import fs from 'fs/promises';
import _ from 'lodash';
import path from 'path';
type ResourceType = 'habit' | 'wishlist' | 'coins'
type ActionType = 'write' | 'interact'
function getDefaultData<T>(type: DataType): T {
return DATA_DEFAULTS[type]() as T;
@@ -29,6 +46,27 @@ async function ensureDataDir() {
}
}
// --- Backup Debug Action ---
export async function triggerManualBackup(): Promise<{ success: boolean; message: string }> {
// Optional: Add extra permission check if needed for debug actions
// const user = await getCurrentUser();
// if (!user?.isAdmin) {
// return { success: false, message: "Permission denied." };
// }
console.log("Manual backup trigger requested...");
try {
// Import runBackup locally to avoid potential circular dependencies if moved
const { runBackup } = await import('@/lib/backup');
await runBackup();
console.log("Manual backup trigger completed successfully.");
return { success: true, message: "Backup process completed successfully." };
} catch (error) {
console.error("Manual backup trigger failed:", error);
return { success: false, message: `Backup failed: ${error instanceof Error ? error.message : 'Unknown error'}` };
}
}
async function loadData<T>(type: DataType): Promise<T> {
try {
await ensureDataDir()
@@ -38,14 +76,14 @@ async function loadData<T>(type: DataType): Promise<T> {
await fs.access(filePath)
} catch {
// File doesn't exist, create it with default data
const initialData = getDefaultData(type)
const initialData = getDefaultData<T>(type)
await fs.writeFile(filePath, JSON.stringify(initialData, null, 2))
return initialData as T
}
// File exists, read and return its contents
const data = await fs.readFile(filePath, 'utf8')
const jsonData = JSON.parse(data)
const jsonData = JSON.parse(data) as T
return jsonData
} catch (error) {
console.error(`Error loading ${type} data:`, error)
@@ -55,6 +93,9 @@ async function loadData<T>(type: DataType): Promise<T> {
async function saveData<T>(type: DataType, data: T): Promise<void> {
try {
const user = await getCurrentUser()
if (!user) throw new Error('User not authenticated')
await ensureDataDir()
const filePath = path.join(process.cwd(), 'data', `${type}.json`)
const saveData = data
@@ -64,9 +105,44 @@ async function saveData<T>(type: DataType, data: T): Promise<void> {
}
}
/**
* Calculates the server's global freshness token based on all core data files.
* This is an expensive operation as it reads all data files.
*/
async function calculateServerFreshnessToken(): Promise<string | null> {
try {
const [settings, habits, coins, wishlist, users] = await Promise.all([
loadSettings(),
loadHabitsData(),
loadCoinsData(),
loadWishlistData(),
loadUsersData()
]);
const dataString = prepareDataForHashing(
settings,
habits,
coins,
wishlist,
users
);
return generateCryptoHash(dataString);
} catch (error) {
console.error("Error calculating server freshness token:", error);
throw error;
}
}
// Wishlist specific functions
export async function loadWishlistData(): Promise<WishlistData> {
return loadData<WishlistData>('wishlist')
const user = await getCurrentUser()
if (!user) return getDefaultWishlistData<WishlistData>()
const data = await loadData<WishlistData>('wishlist')
return {
...data,
items: data.items.filter(x => user.isAdmin || x.userIds?.includes(user.id))
}
}
export async function loadWishlistItems(): Promise<WishlistItemType[]> {
@@ -74,39 +150,112 @@ export async function loadWishlistItems(): Promise<WishlistItemType[]> {
return data.items
}
export async function saveWishlistItems(items: WishlistItemType[]): Promise<void> {
return saveData('wishlist', { items })
export async function saveWishlistItems(data: WishlistData): Promise<void> {
const user = await getCurrentUser()
data.items = data.items.map(wishlist => ({
...wishlist,
userIds: wishlist.userIds || (user ? [user.id] : undefined)
}))
if (!user?.isAdmin) {
const existingData = await loadData<WishlistData>('wishlist')
existingData.items = existingData.items.filter(x => user?.id && !x.userIds?.includes(user?.id))
data.items = [
...existingData.items,
...data.items
]
}
return saveData('wishlist', data)
}
// Habits specific functions
export async function loadHabitsData(): Promise<HabitsData> {
return loadData<HabitsData>('habits')
const user = await getCurrentUser()
if (!user) return getDefaultHabitsData<HabitsData>()
const data = await loadData<HabitsData>('habits')
return {
habits: data.habits.filter(x => user.isAdmin || x.userIds?.includes(user.id))
}
}
export async function saveHabitsData(data: HabitsData): Promise<void> {
return saveData('habits', data)
const user = await getCurrentUser()
// Create clone of input data
const newData = _.cloneDeep(data)
// Map habits with user IDs
newData.habits = newData.habits.map(habit => ({
...habit,
userIds: habit.userIds || (user ? [user.id] : undefined)
}))
if (!user?.isAdmin) {
const existingData = await loadHabitsData();
const existingHabits = existingData.habits.filter(x => user?.id && !x.userIds?.includes(user?.id))
newData.habits = [
...existingHabits,
...newData.habits
]
}
return saveData('habits', newData)
}
// Coins specific functions
export async function loadCoinsData(): Promise<CoinsData> {
try {
return await loadData<CoinsData>('coins')
const user = await getCurrentUser()
if (!user) return getDefaultCoinsData<CoinsData>()
const data = await loadData<CoinsData>('coins')
return {
...data,
transactions: user.isAdmin ? data.transactions : data.transactions.filter(x => x.userId === user.id)
}
} catch {
return { balance: 0, transactions: [] }
return getDefaultCoinsData<CoinsData>()
}
}
export async function saveCoinsData(data: CoinsData): Promise<void> {
return saveData('coins', data)
const user = await getCurrentUser()
// Create clones of the data
const newData = _.cloneDeep(data)
newData.transactions = newData.transactions.map(transaction => ({
...transaction,
userId: transaction.userId || user?.id
}))
if (!user?.isAdmin) {
const existingData = await loadData<CoinsData>('coins')
const existingTransactions = existingData.transactions.filter(x => user?.id && x.userId !== user.id)
newData.transactions = [
...newData.transactions,
...existingTransactions
]
}
return saveData('coins', newData)
}
export async function addCoins(
amount: number,
description: string,
type: TransactionType = 'MANUAL_ADJUSTMENT',
relatedItemId?: string,
export async function addCoins({
amount,
description,
type = 'MANUAL_ADJUSTMENT',
relatedItemId,
note,
userId,
}: {
amount: number
description: string
type?: TransactionType
relatedItemId?: string
note?: string
): Promise<CoinsData> {
userId?: string
}): Promise<CoinsData> {
const currentUser = await getCurrentUser()
const data = await loadCoinsData()
const newTransaction: CoinTransaction = {
id: crypto.randomUUID(),
@@ -115,7 +264,8 @@ export async function addCoins(
description,
timestamp: d2t({ dateTime: getNow({}) }),
...(relatedItemId && { relatedItemId }),
...(note && note.trim() !== '' && { note })
...(note && note.trim() !== '' && { note }),
userId: userId || currentUser?.id
}
const newData: CoinsData = {
@@ -128,9 +278,11 @@ export async function addCoins(
}
export async function loadSettings(): Promise<Settings> {
const defaultSettings = getDefaultSettings()
const defaultSettings = getDefaultSettings<Settings>()
try {
const user = await getCurrentUser()
if (!user) return defaultSettings
const data = await loadData<Settings>('settings')
return { ...defaultSettings, ...data }
} catch {
@@ -142,13 +294,22 @@ export async function saveSettings(settings: Settings): Promise<void> {
return saveData('settings', settings)
}
export async function removeCoins(
amount: number,
description: string,
type: TransactionType = 'MANUAL_ADJUSTMENT',
relatedItemId?: string,
export async function removeCoins({
amount,
description,
type = 'MANUAL_ADJUSTMENT',
relatedItemId,
note,
userId,
}: {
amount: number
description: string
type?: TransactionType
relatedItemId?: string
note?: string
): Promise<CoinsData> {
userId?: string
}): Promise<CoinsData> {
const currentUser = await getCurrentUser()
const data = await loadCoinsData()
const newTransaction: CoinTransaction = {
id: crypto.randomUUID(),
@@ -157,7 +318,8 @@ export async function removeCoins(
description,
timestamp: d2t({ dateTime: getNow({}) }),
...(relatedItemId && { relatedItemId }),
...(note && note.trim() !== '' && { note })
...(note && note.trim() !== '' && { note }),
userId: userId || currentUser?.id
}
const newData: CoinsData = {
@@ -169,10 +331,10 @@ export async function removeCoins(
return newData
}
export async function uploadAvatar(formData: FormData) {
export async function uploadAvatar(formData: FormData): Promise<string> {
const file = formData.get('avatar') as File
if (!file) throw new Error('No file provided')
if (file.size > 5 * 1024 * 1024) { // 5MB
throw new Error('File size must be less than 5MB')
}
@@ -190,18 +352,7 @@ export async function uploadAvatar(formData: FormData) {
const buffer = await file.arrayBuffer()
await fs.writeFile(filePath, Buffer.from(buffer))
// Update settings with new avatar path
const settings = await loadSettings()
const newSettings = {
...settings,
profile: {
...settings.profile,
avatarPath: `/data/avatars/${filename}`
}
}
await saveSettings(newSettings)
return newSettings;
return `/data/avatars/${filename}`
}
export async function getChangelog(): Promise<string> {
@@ -213,3 +364,259 @@ export async function getChangelog(): Promise<string> {
return '# Changelog\n\nNo changelog available.'
}
}
// user logic
export async function loadUsersData(): Promise<UserData> {
try {
return await loadData<UserData>('auth')
} catch {
return getDefaultUsersData<UserData>()
}
}
export async function saveUsersData(data: UserData): Promise<void> {
return saveData('auth', data)
}
export async function getUser(username: string, plainTextPassword?: string): Promise<User | null> {
const data = await loadUsersData()
const user = data.users.find(user => user.username === username)
if (!user) return null
// Verify the plaintext password against the stored salt:hash
const isValidPassword = verifyPassword(plainTextPassword, user.password)
if (!isValidPassword) return null
return user
}
export async function createUser(formData: FormData): Promise<User> {
const username = formData.get('username') as string;
let password = formData.get('password') as string | undefined;
const avatarPath = formData.get('avatarPath') as string;
const permissions = formData.get('permissions') ?
JSON.parse(formData.get('permissions') as string) as Permission[] :
undefined;
if (password === null) password = undefined
// Validate username and password against schema
await signInSchema.parseAsync({ username, password });
const data = await loadUsersData();
// Check if username already exists
if (data.users.some(user => user.username === username)) {
throw new Error('Username already exists');
}
const hashedPassword = password ? saltAndHashPassword(password) : undefined;
const newUser: User = {
id: crypto.randomUUID(),
username,
password: hashedPassword,
permissions,
isAdmin: false,
lastNotificationReadTimestamp: undefined,
...(avatarPath && { avatarPath })
};
const newData: UserData = {
users: [...data.users, newUser]
};
await saveUsersData(newData);
return newUser;
}
export async function updateUser(userId: string, updates: Partial<Omit<User, 'id' | 'password'>>): Promise<User> {
const data = await loadUsersData()
const userIndex = data.users.findIndex(user => user.id === userId)
if (userIndex === -1) {
throw new Error('User not found')
}
// If username is being updated, check for duplicates
if (updates.username) {
const isDuplicate = data.users.some(
user => user.username === updates.username && user.id !== userId
)
if (isDuplicate) {
throw new Error('Username already exists')
}
}
const updatedUser = {
...data.users[userIndex],
...updates
}
const newData: UserData = {
users: [
...data.users.slice(0, userIndex),
updatedUser,
...data.users.slice(userIndex + 1)
]
}
await saveUsersData(newData)
return updatedUser
}
export async function updateUserPassword(userId: string, newPassword?: string): Promise<void> {
const data = await loadUsersData()
const userIndex = data.users.findIndex(user => user.id === userId)
if (userIndex === -1) {
throw new Error('User not found')
}
const hashedPassword = newPassword ? saltAndHashPassword(newPassword) : ''
const updatedUser = {
...data.users[userIndex],
password: hashedPassword
}
const newData: UserData = {
users: [
...data.users.slice(0, userIndex),
updatedUser,
...data.users.slice(userIndex + 1)
]
}
await saveUsersData(newData)
}
export async function deleteUser(userId: string): Promise<void> {
// Load all necessary data
const wishlistData = await loadData<WishlistData>('wishlist')
const habitsData = await loadData<HabitsData>('habits')
const coinsData = await loadData<CoinsData>('coins')
const authData = await loadUsersData()
// Process Wishlist Data
const updatedWishlistItems = wishlistData.items.reduce((acc, item) => {
if (item.userIds?.includes(userId)) {
if (item.userIds.length === 1) {
// Remove item if this is the only user
return acc
} else {
// Remove userId from item's userIds
acc.push({
...item,
userIds: item.userIds.filter(id => id !== userId)
})
}
} else {
// Keep item as is
acc.push(item)
}
return acc
}, [] as WishlistItemType[])
wishlistData.items = updatedWishlistItems
await saveData('wishlist', wishlistData)
// Process Habits Data
const updatedHabits = habitsData.habits.reduce((acc, habit) => {
if (habit.userIds?.includes(userId)) {
if (habit.userIds.length === 1) {
// Remove habit if this is the only user
return acc
} else {
// Remove userId from habit's userIds
acc.push({
...habit,
userIds: habit.userIds.filter(id => id !== userId)
})
}
} else {
// Keep habit as is
acc.push(habit)
}
return acc
}, [] as HabitsData['habits'])
habitsData.habits = updatedHabits
await saveData('habits', habitsData)
// Process Coins Data
coinsData.transactions = coinsData.transactions.filter(
transaction => transaction.userId !== userId
)
// Recalculate balance
coinsData.balance = coinsData.transactions.reduce(
(sum, transaction) => sum + transaction.amount,
0
)
await saveData('coins', coinsData)
// Delete User from Auth Data
const userIndex = authData.users.findIndex(user => user.id === userId)
if (userIndex === -1) {
throw new Error('User not found')
}
authData.users = [
...authData.users.slice(0, userIndex),
...authData.users.slice(userIndex + 1)
]
await saveUsersData(authData)
}
export async function updateLastNotificationReadTimestamp(userId: string, timestamp: string): Promise<void> {
const data = await loadUsersData()
const userIndex = data.users.findIndex(user => user.id === userId)
if (userIndex === -1) {
throw new Error('User not found for updating notification timestamp')
}
const updatedUser = {
...data.users[userIndex],
lastNotificationReadTimestamp: timestamp
}
const newData: UserData = {
users: [
...data.users.slice(0, userIndex),
updatedUser,
...data.users.slice(userIndex + 1)
]
}
await saveUsersData(newData)
}
export async function loadServerSettings(): Promise<ServerSettings> {
return {
isDemo: !!process.env.DEMO,
}
}
/**
* Checks if the client's data is fresh by comparing its token with the server's token.
* @param clientToken The freshness token calculated by the client.
* @returns A promise that resolves to an object { isFresh: boolean }.
*/
export async function checkDataFreshness(clientToken: string): Promise<{ isFresh: boolean }> {
try {
const serverToken = await calculateServerFreshnessToken();
const isFresh = clientToken === serverToken;
if (!isFresh) {
console.log(`Data freshness check: Stale. Client token: ${clientToken}, Server token: ${serverToken}`);
}
return { isFresh };
} catch (error) {
console.error("Error in checkDataFreshness:", error);
// If server fails to determine its token, assume client might be stale to be safe,
// or handle error reporting differently.
return { isFresh: false };
}
}

27
app/actions/user.ts Normal file
View File

@@ -0,0 +1,27 @@
"use server"
import { signIn as signInNextAuth, signOut as signOutNextAuth } from '@/auth';
export async function signIn(username: string, password: string) {
try {
const result = await signInNextAuth("credentials", {
username,
password,
redirect: false, // This needs to be passed as an option, not as form data
});
return result;
} catch (error) {
throw new Error("Invalid credentials");
}
}
export async function signOut() {
try {
const result = await signOutNextAuth({
redirect: false,
})
} catch (error) {
throw new Error("Failed to sign out");
}
}

View File

@@ -0,0 +1,2 @@
import { handlers } from "@/auth"
export const { GET, POST } = handlers

View File

@@ -0,0 +1,50 @@
import { NextResponse } from 'next/server'
import { auth } from '@/auth'
import { deleteUser } from '@/app/actions/data'
import { getCurrentUser } from '@/lib/server-helpers'
export async function POST(req: Request) {
try {
const session = await auth()
if (!session?.user?.id) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const currentUserId = session.user.id
const currentUser = await getCurrentUser()
if (!currentUser) {
// This case should ideally not happen if session.user.id exists,
// but as a safeguard:
return NextResponse.json({ error: 'Unauthorized: User not found in system' }, { status: 401 })
}
let userIdToDelete: string
try {
const body = await req.json()
userIdToDelete = body.userId
} catch (error) {
return NextResponse.json({ error: 'Invalid request body: Could not parse JSON.' }, { status: 400 })
}
if (!userIdToDelete) {
return NextResponse.json({ error: 'Bad Request: userId is required' }, { status: 400 })
}
// Security Check: Users can only delete their own account unless they are an admin.
if (!currentUser.isAdmin && userIdToDelete !== currentUserId) {
return NextResponse.json({ error: 'Forbidden: You do not have permission to delete this user.' }, { status: 403 })
}
await deleteUser(userIdToDelete)
return NextResponse.json({ message: 'User deleted successfully' }, { status: 200 })
} catch (error) {
console.error('Error deleting user:', error)
if (error instanceof Error && error.message === 'User not found') {
return NextResponse.json({ error: 'User not found' }, { status: 404 })
}
return NextResponse.json({ error: 'Internal Server Error' }, { status: 500 })
}
}

View File

@@ -1,9 +1,10 @@
import Layout from '@/components/Layout'
import HabitCalendar from '@/components/HabitCalendar'
export default function CalendarPage() {
return (
<HabitCalendar />
<div className="flex flex-col">
<HabitCalendar />
</div>
)
}

View File

@@ -1,4 +1,3 @@
import Layout from '@/components/Layout'
import CoinsManager from '@/components/CoinsManager'
export default function CoinsPage() {

60
app/debug/backup/page.tsx Normal file
View File

@@ -0,0 +1,60 @@
'use client'
import { useState } from 'react';
import { Button } from '@/components/ui/button';
import { triggerManualBackup } from '@/app/actions/data'; // Import the server action
import { Loader2 } from 'lucide-react'; // For loading indicator
export default function DebugBackupPage() {
const [isLoading, setIsLoading] = useState(false);
const [statusMessage, setStatusMessage] = useState('');
const [isError, setIsError] = useState(false);
const handleBackupClick = async () => {
setIsLoading(true);
setStatusMessage('Starting backup...');
setIsError(false);
try {
const result = await triggerManualBackup();
setStatusMessage(result.message);
setIsError(!result.success);
} catch (error) {
console.error("Error calling triggerManualBackup action:", error);
setStatusMessage(`Client-side error: ${error instanceof Error ? error.message : 'Unknown error'}`);
setIsError(true);
} finally {
setIsLoading(false);
}
};
return (
<div className="p-4">
<h1 className="text-xl font-bold mb-4">Debug Backup</h1>
<div className="bg-gray-100 dark:bg-gray-800 p-4 rounded space-y-4">
<p className="text-muted-foreground">
Click the button below to manually trigger the data backup process.
Check the server console logs for detailed output. Backups are stored in the `/backups` directory.
</p>
<Button
onClick={handleBackupClick}
disabled={isLoading}
>
{isLoading ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Running Backup...
</>
) : (
'Run Manual Backup Now'
)}
</Button>
{statusMessage && (
<div className={`mt-4 p-3 rounded ${isError ? 'bg-red-100 dark:bg-red-900 text-red-700 dark:text-red-200' : 'bg-green-100 dark:bg-green-900 text-green-700 dark:text-green-200'}`}>
{statusMessage}
</div>
)}
</div>
</div>
);
}

69
app/debug/habits/page.tsx Normal file
View File

@@ -0,0 +1,69 @@
'use client'
import { habitsAtom, settingsAtom } from "@/lib/atoms";
import { Habit } from "@/lib/types";
import { useAtom } from "jotai";
import { DateTime } from "luxon";
type CompletionCache = {
[dateKey: string]: { // dateKey format: "YYYY-MM-DD"
[habitId: string]: number // number of completions on that date
}
}
export default function DebugPage() {
const [habits] = useAtom(habitsAtom);
const [settings] = useAtom(settingsAtom);
function buildCompletionCache(habits: Habit[], timezone: string): CompletionCache {
const cache: CompletionCache = {};
habits.forEach(habit => {
habit.completions.forEach(utcTimestamp => {
// Convert UTC timestamp to local date string in specified timezone
const localDate = DateTime
.fromISO(utcTimestamp)
.setZone(timezone)
.toFormat('yyyy-MM-dd');
if (!cache[localDate]) {
cache[localDate] = {};
}
// Increment completion count for this habit on this date
cache[localDate][habit.id] = (cache[localDate][habit.id] || 0) + 1;
});
});
return cache;
}
function getCompletedHabitsForDate(
habits: Habit[],
date: DateTime,
timezone: string,
completionCache: CompletionCache
): Habit[] {
const dateKey = date.setZone(timezone).toFormat('yyyy-MM-dd');
const dateCompletions = completionCache[dateKey] || {};
return habits.filter(habit => {
const completionsNeeded = habit.targetCompletions || 1;
const completionsAchieved = dateCompletions[habit.id] || 0;
return completionsAchieved >= completionsNeeded;
});
}
const habitCache = buildCompletionCache(habits.habits, settings.system.timezone);
return (
<div className="p-4">
<h1 className="text-xl font-bold mb-4">Debug Page</h1>
<div className="bg-gray-100 p-4 rounded break-all">
</div>
</div>
);
}

10
app/debug/layout.tsx Normal file
View File

@@ -0,0 +1,10 @@
import { ReactNode } from "react";
export default function Debug({children}: {children: ReactNode}) {
if (process.env.NODE_ENV !== 'development') return <></>
return (
<div className="debug">
{children}
</div>
)
}

16
app/debug/user/page.tsx Normal file
View File

@@ -0,0 +1,16 @@
import { saltAndHashPassword } from "@/lib/server-helpers";
export default function DebugPage() {
const password = 'admin';
const hashedPassword = saltAndHashPassword(password);
return (
<div className="p-4">
<h1 className="text-xl font-bold mb-4">Debug Page</h1>
<div className="bg-gray-100 p-4 rounded break-all">
<p><strong>Password:</strong> {password}</p>
<p><strong>Hashed Password:</strong> {hashedPassword}</p>
</div>
</div>
);
}

View File

@@ -1,9 +1,10 @@
import Layout from '@/components/Layout'
import HabitList from '@/components/HabitList'
export default function HabitsPage() {
return (
<HabitList />
<div className="flex flex-col">
<HabitList isTasksView={false} />
</div>
)
}

View File

@@ -1,27 +1,23 @@
import './globals.css'
import { Inter } from 'next/font/google'
import { DM_Sans } from 'next/font/google'
import { JotaiProvider } from '@/components/jotai-providers'
import { Suspense } from 'react'
import { JotaiHydrate } from '@/components/jotai-hydrate'
import { loadSettings, loadHabitsData, loadCoinsData, loadWishlistData } from './actions/data'
import { JotaiProvider } from '@/components/jotai-providers'
import Layout from '@/components/Layout'
import LoadingSpinner from '@/components/LoadingSpinner'
import { ThemeProvider } from "@/components/theme-provider"
import { Toaster } from '@/components/ui/toaster'
// Inter (clean, modern, excellent readability)
// const inter = Inter({
// subsets: ['latin'],
// weight: ['400', '500', '600', '700']
// })
import { SessionProvider } from 'next-auth/react'
import { NextIntlClientProvider } from 'next-intl'
import { getLocale, getMessages } from 'next-intl/server'
import { DM_Sans } from 'next/font/google'
import { Suspense } from 'react'
import { loadCoinsData, loadHabitsData, loadServerSettings, loadSettings, loadUsersData, loadWishlistData } from './actions/data'
import './globals.css'
// Clean and contemporary
const dmSans = DM_Sans({
const activeFont = DM_Sans({
subsets: ['latin'],
weight: ['400', '500', '600', '700']
})
const activeFont = dmSans
export const metadata = {
title: 'HabitTrove',
description: 'Track your habits and get rewarded',
@@ -34,15 +30,23 @@ export default async function RootLayout({
}: {
children: React.ReactNode
}) {
const [initialSettings, initialHabits, initialCoins, initialWishlist] = await Promise.all([
const locale = await getLocale();
// Providing all messages to the client
// side is the easiest way to get started
const messages = await getMessages();
const [initialSettings, initialHabits, initialCoins, initialWishlist, initialUsers, initialServerSettings] = await Promise.all([
loadSettings(),
loadHabitsData(),
loadCoinsData(),
loadWishlistData()
loadWishlistData(),
loadUsersData(),
loadServerSettings(),
])
return (
<html lang="en">
// set suppressHydrationWarning to true to prevent hydration errors when using ThemeProvider (https://ui.shadcn.com/docs/dark-mode/next)
<html lang={locale} suppressHydrationWarning>
<body className={activeFont.className}>
<script
dangerouslySetInnerHTML={{
@@ -62,18 +66,31 @@ export default async function RootLayout({
}}
/>
<JotaiProvider>
<Suspense fallback="loading">
<Suspense fallback={<LoadingSpinner />}>
<JotaiHydrate
initialValues={{
settings: initialSettings,
habits: initialHabits,
coins: initialCoins,
wishlist: initialWishlist
wishlist: initialWishlist,
users: initialUsers,
serverSettings: initialServerSettings,
}}
>
<Layout>
{children}
</Layout>
<NextIntlClientProvider locale={locale} messages={messages}>
<ThemeProvider
attribute="class"
defaultTheme="system"
enableSystem
disableTransitionOnChange
>
<SessionProvider>
<Layout>
{children}
</Layout>
</SessionProvider>
</ThemeProvider>
</NextIntlClientProvider>
</JotaiHydrate>
</Suspense>
</JotaiProvider>

View File

@@ -1,42 +1,62 @@
'use client'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Switch } from '@/components/ui/switch'
import { Label } from '@/components/ui/label'
import { DynamicTimeNoSSR } from '@/components/DynamicTimeNoSSR'
import { useAtom } from 'jotai'
import { settingsAtom } from '@/lib/atoms'
import { Settings } from '@/lib/types'
import { saveSettings, uploadAvatar } from '../actions/data'
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'
import { Button } from '@/components/ui/button'
import { User } from 'lucide-react'
import { DynamicTimeNoSSR } from '@/components/DynamicTimeNoSSR';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Label } from '@/components/ui/label';
import { Switch } from '@/components/ui/switch';
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { toast } from '@/hooks/use-toast';
import { serverSettingsAtom, settingsAtom } from '@/lib/atoms';
import { Settings, WeekDay } from '@/lib/types';
import { useAtom } from 'jotai';
import { Info } from 'lucide-react'; // Import Info icon
import { useTranslations } from 'next-intl';
import { saveSettings } from '../actions/data';
import { useSession } from 'next-auth/react'; // signOut removed
import { useRouter } from 'next/navigation';
// AlertDialog components and useState removed
// Trash2 icon removed
export default function SettingsPage() {
const [settings, setSettings] = useAtom(settingsAtom)
const t = useTranslations('SettingsPage');
// tWarning removed
const [settings, setSettings] = useAtom(settingsAtom);
const [serverSettings] = useAtom(serverSettingsAtom);
const { data: session } = useSession();
const router = useRouter();
// showConfirmDialog and isDeleting states removed
const updateSettings = async (newSettings: Settings) => {
await saveSettings(newSettings)
setSettings(newSettings)
}
// handleDeleteAccount function removed
if (!settings) return null
if (!settings) return <></>
return (
<>
<div className="container mx-auto px-4 py-8">
<h1 className="text-3xl font-bold mb-6">Settings</h1>
<div>
<h1 className="text-3xl font-bold mb-6">{t('title')}</h1>
<Card className="mb-6">
<CardHeader>
<CardTitle>UI Settings</CardTitle>
<CardTitle>{t('uiSettingsTitle')}</CardTitle>
</CardHeader>
<CardContent className="space-y-6">
<div className="flex items-center justify-between">
<div className="space-y-0.5">
<Label htmlFor="number-formatting">Number Formatting</Label>
<Label htmlFor="number-formatting">{t('numberFormattingLabel')}</Label>
<div className="text-sm text-muted-foreground">
Format large numbers (e.g., 1K, 1M, 1B)
{t('numberFormattingDescription')}
</div>
</div>
<Switch
@@ -53,9 +73,9 @@ export default function SettingsPage() {
<div className="flex items-center justify-between">
<div className="space-y-0.5">
<Label htmlFor="number-grouping">Number Grouping</Label>
<Label htmlFor="number-grouping">{t('numberGroupingLabel')}</Label>
<div className="text-sm text-muted-foreground">
Use thousand separators (e.g., 1,000 vs 1000)
{t('numberGroupingDescription')}
</div>
</div>
<Switch
@@ -74,14 +94,14 @@ export default function SettingsPage() {
<Card className="mb-6">
<CardHeader>
<CardTitle>System Settings</CardTitle>
<CardTitle>{t('systemSettingsTitle')}</CardTitle>
</CardHeader>
<CardContent className="space-y-6">
<div className="flex items-center justify-between">
<div className="space-y-0.5">
<Label htmlFor="timezone">Timezone</Label>
<Label htmlFor="timezone">{t('timezoneLabel')}</Label>
<div className="text-sm text-muted-foreground">
Select your timezone for accurate date tracking
{t('timezoneDescription')}
</div>
</div>
<div className="flex flex-col items-end gap-2">
@@ -94,7 +114,7 @@ export default function SettingsPage() {
system: { ...settings.system, timezone: e.target.value }
})
}
className="w-[200px] rounded-md border border-input bg-background px-3 py-2"
className="w-[110px] xs:w-[200px] rounded-md border border-input bg-background px-3 py-2 mb-4"
>
{Intl.supportedValuesOf('timeZone').map((tz) => (
<option key={tz} value={tz}>
@@ -105,63 +125,126 @@ export default function SettingsPage() {
<DynamicTimeNoSSR />
</div>
</div>
</CardContent>
</Card>
<Card className="mb-6">
<CardHeader>
<CardTitle>Profile Settings</CardTitle>
</CardHeader>
<CardContent className="space-y-6">
<div className="flex items-center justify-between">
<div className="space-y-0.5">
<Label htmlFor="avatar">Avatar</Label>
<Label htmlFor="weekStartDay">{t('weekStartDayLabel')}</Label>
<div className="text-sm text-muted-foreground">
Customize your profile picture
{t('weekStartDayDescription')}
</div>
</div>
<div className="flex items-center gap-4">
<Avatar className="h-16 w-16">
<AvatarImage src={settings.profile?.avatarPath ? `/api/avatars/${settings.profile.avatarPath.split('/').pop()}` : '/avatars/default.png'} />
<AvatarFallback>
<User className="h-8 w-8" />
</AvatarFallback>
</Avatar>
<form action={async (formData: FormData) => {
const newSettings = await uploadAvatar(formData)
setSettings(newSettings)
}}>
<input
type="file"
id="avatar"
name="avatar"
accept="image/png, image/jpeg"
className="hidden"
onChange={(e) => {
const file = e.target.files?.[0]
if (file) {
if (file.size > 5 * 1024 * 1024) { // 5MB
alert('File size must be less than 5MB')
e.target.value = ''
return
}
const form = e.target.form
if (form) form.requestSubmit()
}
}}
/>
<Button
type="button"
variant="outline"
onClick={() => document.getElementById('avatar')?.click()}
>
Change
</Button>
</form>
<div className="flex flex-col items-end gap-2">
<select
id="weekStartDay"
value={settings.system.weekStartDay}
onChange={(e) =>
updateSettings({
...settings,
system: { ...settings.system, weekStartDay: Number(e.target.value) as WeekDay }
})
}
className="w-[110px] xs:w-[200px] rounded-md border border-input bg-background px-3 py-2"
>
{([
['sunday', 0],
['monday', 1],
['tuesday', 2],
['wednesday', 3],
['thursday', 4],
['friday', 5],
['saturday', 6]
] as Array<["sunday" | "monday" | "tuesday" | "wednesday" | "thursday" | "friday" | "saturday", WeekDay]>).map(([dayName, dayNumber]) => (
<option key={dayNumber} value={dayNumber}>
{t(`weekdays.${dayName}`)}
</option>
))}
</select>
</div>
</div>
{/* Add this section for Auto Backup */}
<div className="flex items-center justify-between">
<div className="space-y-0.5">
<div className="flex items-center gap-1.5">
<Label htmlFor="auto-backup">{t('autoBackupLabel')}</Label>
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Info className="h-4 w-4 text-muted-foreground cursor-help" />
</TooltipTrigger>
<TooltipContent side="top" align="start">
<p className="max-w-xs text-sm">
{t('autoBackupTooltip')}
</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
<div className="text-sm text-muted-foreground">
{t('autoBackupDescription')}
</div>
</div>
<Switch
id="auto-backup"
checked={settings.system.autoBackupEnabled}
onCheckedChange={(checked) =>
updateSettings({
...settings,
system: { ...settings.system, autoBackupEnabled: checked }
})
}
/>
</div>
{/* End of Auto Backup section */}
<div className="flex items-center justify-between">
<div className="space-y-0.5">
<div className="flex items-center gap-1.5">
<Label htmlFor="language-select">{t('languageLabel')}</Label>
</div>
<div className="text-sm text-muted-foreground">
{t('languageDescription')}
</div>
{serverSettings.isDemo && (
<div className="text-sm text-red-500">
{t('languageDisabledInDemoTooltip')}
</div>
)}
</div>
<select
id="language-select"
value={settings.system.language}
disabled={serverSettings.isDemo}
onChange={(e) => {
updateSettings({
...settings,
system: { ...settings.system, language: e.target.value }
});
toast({
title: t('languageChangedTitle'),
description: t('languageChangedDescription'),
variant: 'default',
});
}}
className={`w-[110px] xs:w-[200px] rounded-md border border-input bg-background px-3 py-2 ${serverSettings.isDemo ? 'cursor-not-allowed opacity-50' : ''}`}
>
{/* Add more languages as needed */}
<option value="en">English</option>
<option value="es">Español</option>
<option value="ca">Català</option>
<option value="de">Deutsch</option>
<option value="fr">Français</option>
<option value="ru">Русский</option>
<option value="zh"></option>
<option value="ko"></option>
<option value="ja"></option>
</select>
</div>
</CardContent>
</Card>
</div>
{/* Danger Zone Card Removed */}
</div >
</>
)
}

10
app/tasks/page.tsx Normal file
View File

@@ -0,0 +1,10 @@
import HabitList from '@/components/HabitList'
export default function TasksPage() {
return (
<div className="flex flex-col">
<HabitList isTasksView={true} />
</div>
)
}

View File

@@ -1,4 +1,3 @@
import Layout from '@/components/Layout'
import WishlistManager from '@/components/WishlistManager'
export default function WishlistPage() {

44
auth.ts Normal file
View File

@@ -0,0 +1,44 @@
import NextAuth from "next-auth"
import Credentials from "next-auth/providers/credentials"
import { getUser } from "./app/actions/data"
import { signInSchema } from "./lib/zod"
import { SafeUser, SessionUser } from "./lib/types"
export const { handlers, signIn, signOut, auth } = NextAuth({
trustHost: true,
providers: [
Credentials({
credentials: {
username: {},
password: {},
},
authorize: async (credentials) => {
const { username, password } = await signInSchema.parseAsync(credentials)
// Pass the plaintext password to getUser for verification
const user = await getUser(username, password)
if (!user) {
throw new Error("Invalid credentials.")
}
const safeUser: SessionUser = { id: user.id }
return safeUser
},
}),
],
callbacks: {
jwt: async ({ token, user }) => {
if (user) {
token.id = (user as SessionUser).id
}
return token
},
session: async ({ session, token }) => {
if (session?.user) {
session.user.id = token.id as string
}
return session
}
}
})

View File

@@ -5,24 +5,25 @@ import { Button } from "./ui/button"
import { Star, History } from "lucide-react"
import packageJson from '../package.json'
import { DialogTitle } from "@radix-ui/react-dialog"
import { useTranslations } from "next-intl"
import { Logo } from "./Logo"
import ChangelogModal from "./ChangelogModal"
import { useState } from "react"
interface AboutModalProps {
isOpen: boolean
onClose: () => void
}
export default function AboutModal({ isOpen, onClose }: AboutModalProps) {
export default function AboutModal({ onClose }: AboutModalProps) {
const t = useTranslations('AboutModal')
const version = packageJson.version
const [changelogOpen, setChangelogOpen] = useState(false)
return (
<Dialog open={isOpen} onOpenChange={onClose}>
<Dialog open={true} onOpenChange={onClose}>
<DialogContent className="max-w-sm">
<DialogHeader>
<DialogTitle aria-label="about"></DialogTitle>
<DialogTitle aria-label={t('dialogArisLabel')}></DialogTitle>
</DialogHeader>
<div className="space-y-6 text-center py-4">
<div>
@@ -40,14 +41,14 @@ export default function AboutModal({ isOpen, onClose }: AboutModalProps) {
onClick={() => setChangelogOpen(true)}
>
<History className="w-3 h-3 mr-1" />
Changelog
{t('changelogButton')}
</Button>
</div>
</div>
<div className="space-y-4">
<div className="text-sm">
Created with by{' '}
{t('createdByPrefix')}{' '}
<a
href="https://github.com/dohsimpson"
target="_blank"
@@ -56,17 +57,19 @@ export default function AboutModal({ isOpen, onClose }: AboutModalProps) {
>
@dohsimpson
</a>
<br/>
Fork by <a href="https://github.com/ManInDark" target="_blank" rel="noopener noreferrer" className="font-medium hover:underline">@ManInDark</a>
</div>
<div className="flex justify-center">
<a
href="https://github.com/dohsimpson/habittrove"
href="https://github.com/ManInDark/HabitTrove"
target="_blank"
rel="noopener noreferrer"
>
<Button variant="outline" size="sm">
<Star className="w-4 h-4 mr-2" />
Star on GitHub
{t('starOnGitHubButton')}
</Button>
</a>
</div>

View File

@@ -1,38 +1,81 @@
'use client'
import { useState, useEffect } from 'react'
import { RRule, RRuleSet, rrulestr } from 'rrule'
import { useAtom } from 'jotai'
import { settingsAtom } from '@/lib/atoms'
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '@/components/ui/dialog'
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'
import { Button } from '@/components/ui/button'
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Textarea } from '@/components/ui/textarea'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'
import { Info, SmilePlus } from 'lucide-react'
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
import data from '@emoji-mart/data'
import Picker from '@emoji-mart/react'
import { Textarea } from '@/components/ui/textarea'
import { currentUserAtom, settingsAtom, usersAtom } from '@/lib/atoms'
import { INITIAL_DUE, INITIAL_RECURRENCE_RULE, MAX_COIN_LIMIT, QUICK_DATES } from '@/lib/constants'
import { Habit } from '@/lib/types'
import { parseNaturalLanguageRRule, parseRRule, serializeRRule } from '@/lib/utils'
import { INITIAL_RECURRENCE_RULE } from '@/lib/constants'
import { convertHumanReadableFrequencyToMachineReadable, convertMachineReadableFrequencyToHumanReadable, d2t, serializeRRule } from '@/lib/utils'
import { useAtom } from 'jotai'
import { Brush, Zap } from 'lucide-react'
import { DateTime } from 'luxon'
import { useTranslations } from 'next-intl'
import { useState } from 'react'
import { RRule } from 'rrule'
import DrawingDisplay from './DrawingDisplay'
import DrawingModal from './DrawingModal'
import EmojiPickerButton from './EmojiPickerButton'
import ModalOverlay from './ModalOverlay'; // Import the new component
interface AddEditHabitModalProps {
onClose: () => void
onSave: (habit: Omit<Habit, 'id'>) => Promise<void>
habit?: Habit | null
isTask: boolean
}
export default function AddEditHabitModal({ onClose, onSave, habit }: AddEditHabitModalProps) {
export default function AddEditHabitModal({ onClose, onSave, habit, isTask }: AddEditHabitModalProps) {
const t = useTranslations('AddEditHabitModal');
const [settings] = useAtom(settingsAtom)
const [name, setName] = useState(habit?.name || '')
const [description, setDescription] = useState(habit?.description || '')
const [coinReward, setCoinReward] = useState(habit?.coinReward || 1)
const [targetCompletions, setTargetCompletions] = useState(habit?.targetCompletions || 1)
const origRuleText = parseRRule(habit?.frequency || INITIAL_RECURRENCE_RULE).toText()
const [ruleText, setRuleText] = useState<string>(origRuleText)
const isRecurRule = !isTask
// Initialize ruleText with the actual frequency string or default, not the display text
const initialRuleText = habit?.frequency ? convertMachineReadableFrequencyToHumanReadable({
frequency: habit.frequency,
isRecurRule,
timezone: settings.system.timezone
}) : (isRecurRule ? INITIAL_RECURRENCE_RULE : INITIAL_DUE);
const [ruleText, setRuleText] = useState<string>(initialRuleText)
const [currentUser] = useAtom(currentUserAtom)
const [isQuickDatesOpen, setIsQuickDatesOpen] = useState(false)
const [selectedUserIds, setSelectedUserIds] = useState<string[]>((habit?.userIds || []).filter(id => id !== currentUser?.id))
const [usersData] = useAtom(usersAtom)
const users = usersData.users
const [drawing, setDrawing] = useState<string>(habit?.drawing || '')
const [isDrawingModalOpen, setIsDrawingModalOpen] = useState(false)
function getFrequencyUpdate() {
if (ruleText === initialRuleText && habit?.frequency) {
// If text hasn't changed and original frequency exists, return it
return habit.frequency;
}
const parsedResult = convertHumanReadableFrequencyToMachineReadable({
text: ruleText,
timezone: settings.system.timezone,
isRecurring: isRecurRule
});
if (parsedResult.result) {
return isRecurRule
? serializeRRule(parsedResult.result as RRule)
: d2t({
dateTime: parsedResult.result as DateTime,
timezone: settings.system.timezone
});
} else {
return 'invalid';
}
}
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
@@ -42,183 +85,287 @@ export default function AddEditHabitModal({ onClose, onSave, habit }: AddEditHab
coinReward,
targetCompletions: targetCompletions > 1 ? targetCompletions : undefined,
completions: habit?.completions || [],
frequency: habit ? (
origRuleText === ruleText ? habit.frequency : serializeRRule(parseNaturalLanguageRRule(ruleText))
) : serializeRRule(parseNaturalLanguageRRule(ruleText)),
frequency: getFrequencyUpdate(),
userIds: selectedUserIds.length > 0 ? selectedUserIds.concat(currentUser?.id || []) : (currentUser && [currentUser.id]),
drawing: drawing && drawing !== '[]' ? drawing : undefined
})
}
const { result, message: errorMessage } = convertHumanReadableFrequencyToMachineReadable({ text: ruleText, timezone: settings.system.timezone, isRecurring: isRecurRule });
return (
<Dialog open={true} onOpenChange={onClose}>
<DialogContent>
<DialogHeader>
<DialogTitle>{habit ? 'Edit Habit' : 'Add New Habit'}</DialogTitle>
</DialogHeader>
<form onSubmit={handleSubmit}>
<div className="grid gap-4 py-4">
<div className="grid grid-cols-4 items-center gap-4">
<Label htmlFor="name" className="text-right">
Name
</Label>
<div className='flex col-span-3 gap-2'>
<Input
id="name"
value={name}
onChange={(e) => setName(e.target.value)}
required
<>
<ModalOverlay />
<Dialog open={true} onOpenChange={(open) => {
if (!open && !isDrawingModalOpen) {
onClose()
}
}} modal={false}>
<DialogContent> {/* DialogContent from shadcn/ui is typically z-50, ModalOverlay is z-40 */}
<DialogHeader>
<DialogTitle>
{habit
? t(isTask ? 'editTaskTitle' : 'editHabitTitle')
: t(isTask ? 'addNewTaskTitle' : 'addNewHabitTitle')}
</DialogTitle>
</DialogHeader>
<form onSubmit={handleSubmit}>
<div className="grid gap-4 py-4">
<div className="grid grid-cols-4 items-center gap-4">
<Label htmlFor="name" className="text-right">
{t('nameLabel')}
</Label>
<div className='flex col-span-3 gap-2'>
<Input
id="name"
value={name}
onChange={(e) => setName(e.target.value)}
required
/>
<EmojiPickerButton
inputIdToFocus="name"
onEmojiSelect={(emoji) => {
setName(prev => {
const space = prev.length > 0 && !prev.endsWith(' ') ? ' ' : '';
return `${prev}${space}${emoji}`;
})
}}
/>
</div>
</div>
<div className="grid grid-cols-4 items-center gap-4">
<Label htmlFor="description" className="text-right">
{t('descriptionLabel')}
</Label>
<Textarea
id="description"
value={description}
onChange={(e) => setDescription(e.target.value)}
className="col-span-3"
/>
<Popover>
<PopoverTrigger asChild>
</div>
<div className="grid grid-cols-4 items-center gap-4">
<Label htmlFor="recurrence" className="text-right">
{t('whenLabel')}
</Label>
{/* date input (task) */}
<div className="col-span-3 space-y-2">
<div className="flex gap-2">
<Input
id="recurrence"
value={ruleText}
onChange={(e) => setRuleText(e.target.value)}
required
/>
{isTask && (
<Popover open={isQuickDatesOpen} onOpenChange={setIsQuickDatesOpen}>
<PopoverTrigger asChild>
<Button
type="button"
variant="ghost"
size="icon"
className="h-8 w-8"
>
<Zap className="h-4 w-4" />
</Button>
</PopoverTrigger>
<PopoverContent className="p-3 w-[280px] max-h-[40vh] overflow-y-auto" align="start">
<div className="space-y-1">
<div className="grid grid-cols-2 gap-2">
{QUICK_DATES.map((date) => (
<Button
key={date.value}
variant="outline"
className="justify-start h-9 px-3 hover:bg-primary hover:text-primary-foreground transition-colors"
onClick={() => {
setRuleText(date.value);
setIsQuickDatesOpen(false);
}}
>
{date.label}
</Button>
))}
</div>
</div>
</PopoverContent>
</Popover>
)}
</div>
</div>
{/* rrule input (habit) */}
<div className="col-start-2 col-span-3 text-sm">
<span className={errorMessage ? 'text-destructive' : 'text-muted-foreground'}>
{errorMessage ? errorMessage : convertMachineReadableFrequencyToHumanReadable({ frequency: result, isRecurRule, timezone: settings.system.timezone })}
</span>
</div>
</div>
<div className="grid grid-cols-4 items-center gap-4">
<div className="flex items-center gap-2 justify-end">
<Label htmlFor="targetCompletions">
{t('completeLabel')}
</Label>
</div>
<div className="col-span-3">
<div className="flex items-center gap-4">
<div className="flex items-center border rounded-lg overflow-hidden">
<button
type="button"
onClick={() => setTargetCompletions(prev => Math.max(1, prev - 1))}
className="px-3 py-2 bg-gray-100 hover:bg-gray-200 dark:bg-gray-800 dark:hover:bg-gray-700 transition-colors"
>
-
</button>
<Input
id="targetCompletions"
type="number"
value={targetCompletions}
onChange={(e) => {
const value = parseInt(e.target.value)
setTargetCompletions(isNaN(value) ? 1 : Math.max(1, value))
}}
min={1}
max={10}
className="w-20 text-center border-0 [appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none"
/>
<button
type="button"
onClick={() => setTargetCompletions(prev => Math.min(10, prev + 1))}
className="px-3 py-2 bg-gray-100 hover:bg-gray-200 dark:bg-gray-800 dark:hover:bg-gray-700 transition-colors"
>
+
</button>
</div>
<span className="text-sm text-muted-foreground">
{t('timesSuffix')}
</span>
</div>
</div>
</div>
<div className="grid grid-cols-4 items-center gap-4">
<div className="flex items-center gap-2 justify-end">
<Label htmlFor="coinReward">
{t('rewardLabel')}
</Label>
</div>
<div className="col-span-3">
<div className="flex items-center gap-4">
<div className="flex items-center border rounded-lg overflow-hidden">
<button
type="button"
onClick={() => setCoinReward(prev => Math.max(0, prev - 1))}
className="px-3 py-2 bg-gray-100 hover:bg-gray-200 dark:bg-gray-800 dark:hover:bg-gray-700 transition-colors"
>
-
</button>
<Input
id="coinReward"
type="number"
value={coinReward}
onChange={(e) => {
const value = parseInt(e.target.value === "" ? "0" : e.target.value)
setCoinReward(Math.min(value, MAX_COIN_LIMIT))
}}
min={0}
max={MAX_COIN_LIMIT}
required
className="w-20 text-center border-0 [appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none"
/>
<button
type="button"
onClick={() => setCoinReward(prev => Math.min(prev + 1, MAX_COIN_LIMIT))}
className="px-3 py-2 bg-gray-100 hover:bg-gray-200 dark:bg-gray-800 dark:hover:bg-gray-700 transition-colors"
>
+
</button>
</div>
<span className="text-sm text-muted-foreground">
{t('coinsSuffix')}
</span>
</div>
</div>
</div>
<div className="grid grid-cols-4 items-center gap-4">
<Label className="text-right">
{t('drawingLabel')}
</Label>
<div className="col-span-3">
<div className="flex gap-4 items-center">
<Button
type="button"
variant="ghost"
size="icon"
className="h-8 w-8"
variant="outline"
onClick={(e) => {
e.preventDefault()
e.stopPropagation()
setIsDrawingModalOpen(true)
}}
className="flex-1 justify-start"
>
<SmilePlus className="h-8 w-8" />
<Brush className="h-4 w-4 mr-2" />
{drawing ? t('editDrawing') : t('addDrawing')}
</Button>
</PopoverTrigger>
<PopoverContent className="w-[300px] p-0">
<Picker
data={data}
onEmojiSelect={(emoji: { native: string }) => {
setName(prev => {
// Add space before emoji if there isn't one already
const space = prev.length > 0 && !prev.endsWith(' ') ? ' ' : '';
return `${prev}${space}${emoji.native}`;
})
// Focus back on input after selection
const input = document.getElementById('name') as HTMLInputElement
input?.focus()
}}
/>
</PopoverContent>
</Popover>
</div>
</div>
<div className="grid grid-cols-4 items-center gap-4">
<Label htmlFor="description" className="text-right">
Description
</Label>
<Textarea
id="description"
value={description}
onChange={(e) => setDescription(e.target.value)}
className="col-span-3"
/>
</div>
<div className="grid grid-cols-4 items-center gap-4">
<Label htmlFor="recurrence" className="text-right">
Frequency
</Label>
<div className="col-span-3 space-y-2">
<Input
id="recurrence"
value={ruleText}
onChange={(e) => setRuleText(e.target.value)}
// placeholder="e.g. 'every weekday' or 'every 2 weeks on Monday, Wednesday'"
/>
</div>
<div className="col-start-2 col-span-3 text-sm text-muted-foreground">
<span>
{(() => {
try {
return parseNaturalLanguageRRule(ruleText).toText()
} catch (e: unknown) {
return `Invalid rule: ${e instanceof Error ? e.message : 'Invalid recurrence rule'}`
}
})()}
</span>
</div>
</div>
<div className="grid grid-cols-4 items-center gap-4">
<div className="flex items-center gap-2 justify-end">
<Label htmlFor="targetCompletions">
Repetitions
</Label>
</div>
<div className="col-span-3">
<div className="flex items-center gap-4">
<div className="flex items-center border rounded-lg overflow-hidden">
<button
type="button"
onClick={() => setTargetCompletions(prev => Math.max(1, prev - 1))}
className="px-3 py-2 bg-gray-100 hover:bg-gray-200 dark:bg-gray-800 dark:hover:bg-gray-700 transition-colors"
>
-
</button>
<Input
id="targetCompletions"
type="number"
value={targetCompletions}
onChange={(e) => {
const value = parseInt(e.target.value)
setTargetCompletions(isNaN(value) ? 1 : Math.max(1, value))
}}
min={1}
max={10}
className="w-20 text-center border-0 [appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none"
/>
<button
type="button"
onClick={() => setTargetCompletions(prev => Math.min(10, prev + 1))}
className="px-3 py-2 bg-gray-100 hover:bg-gray-200 dark:bg-gray-800 dark:hover:bg-gray-700 transition-colors"
>
+
</button>
{drawing && (
<div className="flex-shrink-0">
<DrawingDisplay
drawingData={drawing}
width={80}
height={53}
className=""
/>
</div>
)}
</div>
<span className="text-sm text-muted-foreground">
times per occurrence
</span>
</div>
</div>
</div>
<div className="grid grid-cols-4 items-center gap-4">
<div className="flex items-center gap-2 justify-end">
<Label htmlFor="coinReward">
Coin Reward
</Label>
</div>
<div className="col-span-3">
<div className="flex items-center gap-4">
<div className="flex items-center border rounded-lg overflow-hidden">
<button
type="button"
onClick={() => setCoinReward(prev => Math.max(0, prev - 1))}
className="px-3 py-2 bg-gray-100 hover:bg-gray-200 dark:bg-gray-800 dark:hover:bg-gray-700 transition-colors"
>
-
</button>
<Input
id="coinReward"
type="number"
value={coinReward}
onChange={(e) => setCoinReward(parseInt(e.target.value === "" ? "0" : e.target.value))}
min={0}
required
className="w-20 text-center border-0 [appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none"
/>
<button
type="button"
onClick={() => setCoinReward(prev => prev + 1)}
className="px-3 py-2 bg-gray-100 hover:bg-gray-200 dark:bg-gray-800 dark:hover:bg-gray-700 transition-colors"
>
+
</button>
{users && users.length > 1 && (
<div className="grid grid-cols-4 items-center gap-4">
<div className="flex items-center justify-end gap-2">
<Label htmlFor="sharing-toggle">{t('shareLabel')}</Label>
</div>
<div className="col-span-3">
<div className="flex flex-wrap gap-2">
{users.filter((u) => u.id !== currentUser?.id).map(user => (
<Avatar
key={user.id}
className={`h-8 w-8 border-2 cursor-pointer
${selectedUserIds.includes(user.id)
? 'border-primary'
: 'border-muted'
}`}
title={user.username}
onClick={() => {
setSelectedUserIds(prev =>
prev.includes(user.id)
? prev.filter(id => id !== user.id)
: [...prev, user.id]
)
}}
>
<AvatarImage src={user?.avatarPath && `/api/avatars/${user.avatarPath.split('/').pop()}` || ""} />
<AvatarFallback>{user.username[0]}</AvatarFallback>
</Avatar>
))}
</div>
</div>
<span className="text-sm text-muted-foreground">
coins per completion
</span>
</div>
</div>
)}
</div>
</div>
<DialogFooter>
<Button type="submit">{habit ? 'Save Changes' : 'Add Habit'}</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
<DialogFooter>
<Button type="submit" disabled={!!errorMessage}>
{habit
? t('saveChangesButton')
: t(isTask ? 'addTaskButton' : 'addHabitButton')}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
<DrawingModal
isOpen={isDrawingModalOpen}
onClose={() => setIsDrawingModalOpen(false)}
onSave={(drawingData) => setDrawing(drawingData)}
initialDrawing={drawing}
title={name}
/>
</>
)
}

View File

@@ -1,121 +1,363 @@
import { useState, useEffect } from 'react'
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '@/components/ui/dialog'
import { Button } from '@/components/ui/button'
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Textarea } from '@/components/ui/textarea'
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
import { SmilePlus } from 'lucide-react'
import data from '@emoji-mart/data'
import Picker from '@emoji-mart/react'
import { currentUserAtom, usersAtom } from '@/lib/atoms'
import { MAX_COIN_LIMIT } from '@/lib/constants'
import { WishlistItemType } from '@/lib/types'
import { useAtom } from 'jotai'
import { Brush } from 'lucide-react'
import { useTranslations } from 'next-intl'
import { useEffect, useState } from 'react'
import DrawingDisplay from './DrawingDisplay'
import DrawingModal from './DrawingModal'
import EmojiPickerButton from './EmojiPickerButton'
import ModalOverlay from './ModalOverlay'
import { Avatar, AvatarFallback, AvatarImage } from './ui/avatar'
interface AddEditWishlistItemModalProps {
isOpen: boolean
onClose: () => void
onSave: (item: Omit<WishlistItemType, 'id'>) => void
item?: WishlistItemType | null
setIsOpen: (isOpen: boolean) => void
editingItem: WishlistItemType | null
setEditingItem: (item: WishlistItemType | null) => void
addWishlistItem: (item: Omit<WishlistItemType, 'id'>) => void
editWishlistItem: (item: WishlistItemType) => void
}
export default function AddEditWishlistItemModal({ isOpen, onClose, onSave, item }: AddEditWishlistItemModalProps) {
const [name, setName] = useState('')
const [description, setDescription] = useState('')
const [coinCost, setCoinCost] = useState(1)
export default function AddEditWishlistItemModal({
setIsOpen,
editingItem,
setEditingItem,
addWishlistItem,
editWishlistItem
}: AddEditWishlistItemModalProps) {
const t = useTranslations('AddEditWishlistItemModal')
const [name, setName] = useState(editingItem?.name || '')
const [description, setDescription] = useState(editingItem?.description || '')
const [coinCost, setCoinCost] = useState(editingItem?.coinCost || 1)
const [targetCompletions, setTargetCompletions] = useState<number | undefined>(editingItem?.targetCompletions)
const [link, setLink] = useState(editingItem?.link || '')
const [currentUser] = useAtom(currentUserAtom)
const [selectedUserIds, setSelectedUserIds] = useState<string[]>((editingItem?.userIds || []).filter(id => id !== currentUser?.id))
const [errors, setErrors] = useState<{ [key: string]: string }>({})
const [usersData] = useAtom(usersAtom)
const [drawing, setDrawing] = useState<string>(editingItem?.drawing || '')
const [isDrawingModalOpen, setIsDrawingModalOpen] = useState(false)
useEffect(() => {
if (item) {
setName(item.name)
setDescription(item.description)
setCoinCost(item.coinCost)
if (editingItem) {
setName(editingItem.name)
setDescription(editingItem.description)
setCoinCost(editingItem.coinCost)
setTargetCompletions(editingItem.targetCompletions)
setLink(editingItem.link || '')
setDrawing(editingItem.drawing || '')
} else {
setName('')
setDescription('')
setCoinCost(1)
setTargetCompletions(undefined)
setLink('')
setDrawing('')
}
}, [item])
setErrors({})
}, [editingItem])
const handleSubmit = (e: React.FormEvent) => {
const validate = () => {
const newErrors: { [key: string]: string } = {}
if (!name.trim()) {
newErrors.name = t('errorNameRequired')
}
if (coinCost < 1) {
newErrors.coinCost = t('errorCoinCostMin')
} else if (coinCost > MAX_COIN_LIMIT) {
newErrors.coinCost = t('errorCoinCostMax', { max: MAX_COIN_LIMIT })
}
if (targetCompletions !== undefined && targetCompletions < 1) {
newErrors.targetCompletions = t('errorTargetCompletionsMin')
}
if (link && !isValidUrl(link)) {
newErrors.link = t('errorInvalidUrl')
}
setErrors(newErrors)
return Object.keys(newErrors).length === 0
}
const isValidUrl = (url: string) => {
try {
new URL(url)
return true
} catch {
return false
}
}
const handleClose = () => {
setIsOpen(false)
setEditingItem(null)
}
const handleSave = (e: React.FormEvent) => {
e.preventDefault()
onSave({ name, description, coinCost })
if (!validate()) return
const itemData = {
name,
description,
coinCost,
targetCompletions: targetCompletions || undefined,
link: link.trim() || undefined,
userIds: selectedUserIds.length > 0 ? selectedUserIds.concat(currentUser?.id || []) : (currentUser && [currentUser.id]),
drawing: drawing && drawing !== '[]' ? drawing : undefined
}
if (editingItem) {
editWishlistItem({ ...itemData, id: editingItem.id })
} else {
addWishlistItem(itemData)
}
setIsOpen(false)
setEditingItem(null)
}
return (
<Dialog open={isOpen} onOpenChange={onClose}>
<DialogContent>
<DialogHeader>
<DialogTitle>{item ? 'Edit Reward' : 'Add New Reward'}</DialogTitle>
</DialogHeader>
<form onSubmit={handleSubmit}>
<div className="grid gap-4 py-4">
<div className="grid grid-cols-4 items-center gap-4">
<Label htmlFor="name" className="text-right">
Name
</Label>
<div className="col-span-3 flex gap-2">
<Input
id="name"
value={name}
onChange={(e) => setName(e.target.value)}
className="flex-1"
required
<>
<ModalOverlay />
<Dialog open={true} onOpenChange={(open) => {
if (!open && !isDrawingModalOpen) {
handleClose()
}
}} modal={false}>
<DialogContent> {/* DialogContent from shadcn/ui is typically z-50, ModalOverlay is z-40 */}
<DialogHeader>
<DialogTitle>{editingItem ? t('editTitle') : t('addTitle')}</DialogTitle>
</DialogHeader>
<form onSubmit={handleSave}>
<div className="grid gap-4 py-4">
<div className="grid grid-cols-4 items-center gap-4">
<Label htmlFor="name" className="text-right">
{t('nameLabel')}
</Label>
<div className="col-span-3 flex gap-2">
<Input
id="name"
value={name}
onChange={(e) => setName(e.target.value)}
className="flex-1"
required
/>
<EmojiPickerButton
inputIdToFocus="name"
onEmojiSelect={(emoji) => {
setName(prev => {
const space = prev.length > 0 && !prev.endsWith(' ') ? ' ' : '';
return `${prev}${space}${emoji}`;
})
}}
/>
</div>
</div>
<div className="grid grid-cols-4 items-center gap-4">
<Label htmlFor="description" className="text-right">
{t('descriptionLabel')}
</Label>
<Textarea
id="description"
value={description}
onChange={(e) => setDescription(e.target.value)}
className="col-span-3"
/>
<Popover>
<PopoverTrigger asChild>
</div>
<div className="grid grid-cols-4 items-center gap-4">
<div className="flex items-center gap-2 justify-end">
<Label htmlFor="coinReward">
{t('costLabel')}
</Label>
</div>
<div className="col-span-3">
<div className="flex items-center gap-4">
<div className="flex items-center border rounded-lg overflow-hidden">
<button
type="button"
onClick={() => setCoinCost(prev => Math.max(0, prev - 1))}
className="px-3 py-2 bg-gray-100 hover:bg-gray-200 dark:bg-gray-800 dark:hover:bg-gray-700 transition-colors"
>
-
</button>
<Input
id="coinReward"
type="number"
value={coinCost}
onChange={(e) => {
const value = parseInt(e.target.value === "" ? "0" : e.target.value)
setCoinCost(Math.min(value, MAX_COIN_LIMIT))
}}
min={0}
max={MAX_COIN_LIMIT}
required
className="w-20 text-center border-0 [appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none"
/>
<button
type="button"
onClick={() => setCoinCost(prev => Math.min(prev + 1, MAX_COIN_LIMIT))}
className="px-3 py-2 bg-gray-100 hover:bg-gray-200 dark:bg-gray-800 dark:hover:bg-gray-700 transition-colors"
>
+
</button>
</div>
<span className="text-sm text-muted-foreground">
{t('coinsSuffix')}
</span>
</div>
</div>
</div>
<div className="grid grid-cols-4 items-center gap-4">
<div className="flex items-center gap-2 justify-end">
<Label htmlFor="targetCompletions">
{t('redeemableLabel')}
</Label>
</div>
<div className="col-span-3">
<div className="flex items-center gap-4">
<div className="flex items-center border rounded-lg overflow-hidden">
<button
type="button"
onClick={() => setTargetCompletions(prev => prev !== undefined && prev > 1 ? prev - 1 : undefined)}
className="px-3 py-2 bg-gray-100 hover:bg-gray-200 dark:bg-gray-800 dark:hover:bg-gray-700 transition-colors"
>
-
</button>
<Input
id="targetCompletions"
type="number"
value={targetCompletions || ''}
onChange={(e) => {
const value = e.target.value
setTargetCompletions(value && value !== "0" ? parseInt(value) : undefined)
}}
min={0}
placeholder="∞"
className="w-20 text-center border-0 [appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none"
/>
<button
type="button"
onClick={() => setTargetCompletions(prev => Math.min(10, (prev || 0) + 1))}
className="px-3 py-2 bg-gray-100 hover:bg-gray-200 dark:bg-gray-800 dark:hover:bg-gray-700 transition-colors"
>
+
</button>
</div>
<span className="text-sm text-muted-foreground">
{t('timesSuffix')}
</span>
</div>
{errors.targetCompletions && (
<div className="text-sm text-red-500">
{errors.targetCompletions}
</div>
)}
</div>
</div>
<div className="grid grid-cols-4 items-center gap-4">
<Label htmlFor="link" className="text-right">
{t('linkLabel')}
</Label>
<div className="col-span-3">
<Input
id="link"
type="url"
placeholder="https://..."
value={link}
onChange={(e) => setLink(e.target.value)}
className="col-span-3"
/>
{errors.link && (
<div className="text-sm text-red-500">
{errors.link}
</div>
)}
</div>
</div>
<div className="grid grid-cols-4 items-center gap-4">
<Label className="text-right">
{t('drawingLabel')}
</Label>
<div className="col-span-3">
<div className="flex gap-4 items-center">
<Button
type="button"
variant="ghost"
size="icon"
className="h-8 w-8"
>
<SmilePlus className="h-4 w-4" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-[300px] p-0">
<Picker
data={data}
onEmojiSelect={(emoji: { native: string }) => {
setName(prev => `${prev}${emoji.native}`)
// Focus back on input after selection
const input = document.getElementById('name') as HTMLInputElement
input?.focus()
variant="outline"
onClick={(e) => {
e.preventDefault()
e.stopPropagation()
setIsDrawingModalOpen(true)
}}
/>
</PopoverContent>
</Popover>
className="flex-1 justify-start"
>
<Brush className="h-4 w-4 mr-2" />
{drawing ? t('editDrawing') : t('addDrawing')}
</Button>
{drawing && (
<div className="flex-shrink-0">
<DrawingDisplay
drawingData={drawing}
width={80}
height={53}
className=""
/>
</div>
)}
</div>
</div>
</div>
{usersData.users && usersData.users.length > 1 && (
<div className="grid grid-cols-4 items-center gap-4">
<div className="flex items-center justify-end gap-2">
<Label htmlFor="sharing-toggle">{t('shareLabel')}</Label>
</div>
<div className="col-span-3">
<div className="flex flex-wrap gap-2">
{usersData.users.filter((u) => u.id !== currentUser?.id).map(user => (
<Avatar
key={user.id}
className={`h-8 w-8 border-2 cursor-pointer
${selectedUserIds.includes(user.id)
? 'border-primary'
: 'border-muted'
}`}
title={user.username}
onClick={() => {
setSelectedUserIds(prev =>
prev.includes(user.id)
? prev.filter(id => id !== user.id)
: [...prev, user.id]
)
}}
>
<AvatarImage src={user?.avatarPath && `/api/avatars/${user.avatarPath.split('/').pop()}` || ""} />
<AvatarFallback>{user.username[0]}</AvatarFallback>
</Avatar>
))}
</div>
</div>
</div>
)}
</div>
<div className="grid grid-cols-4 items-center gap-4">
<Label htmlFor="description" className="text-right">
Description
</Label>
<Textarea
id="description"
value={description}
onChange={(e) => setDescription(e.target.value)}
className="col-span-3"
/>
</div>
<div className="grid grid-cols-4 items-center gap-4">
<Label htmlFor="coinCost" className="text-right">
Coin Cost
</Label>
<Input
id="coinCost"
type="number"
value={coinCost}
onChange={(e) => setCoinCost(parseInt(e.target.value === "" ? "0" : e.target.value))}
className="col-span-3"
min={1}
required
/>
</div>
</div>
<DialogFooter>
<Button type="submit">{item ? 'Save Changes' : 'Add Reward'}</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
<DialogFooter>
<Button type="submit">{editingItem ? t('saveButton') : t('addButton')}</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
<DrawingModal
isOpen={isDrawingModalOpen}
onClose={() => setIsDrawingModalOpen(false)}
onSave={(drawingData) => setDrawing(drawingData)}
initialDrawing={drawing}
title={name}
/>
</>
)
}

View File

@@ -1,19 +1,97 @@
'use client'
import { ReactNode } from 'react'
import { useAtom } from 'jotai'
import { pomodoroAtom } from '@/lib/atoms'
import PomodoroTimer from './PomodoroTimer'
import { checkDataFreshness as checkServerDataFreshness } from '@/app/actions/data';
import { aboutOpenAtom, clientFreshnessTokenAtom, currentUserIdAtom, pomodoroAtom, userSelectAtom } from '@/lib/atoms';
import { useAtom, useAtomValue, useSetAtom } from 'jotai';
import { useSession } from 'next-auth/react';
import { ReactNode, Suspense, useCallback, useEffect, useState } from 'react';
import AboutModal from './AboutModal';
import LoadingSpinner from './LoadingSpinner';
import PomodoroTimer from './PomodoroTimer';
import RefreshBanner from './RefreshBanner';
import UserSelectModal from './UserSelectModal';
export default function ClientWrapper({ children }: { children: ReactNode }) {
function ClientWrapperContent({ children }: { children: ReactNode }) {
const [pomo] = useAtom(pomodoroAtom)
const [userSelect, setUserSelect] = useAtom(userSelectAtom)
const [aboutOpen, setAboutOpen] = useAtom(aboutOpenAtom)
const setCurrentUserIdAtom = useSetAtom(currentUserIdAtom)
const { data: session, status } = useSession()
const currentUserId = session?.user.id
const [showRefreshBanner, setShowRefreshBanner] = useState(false);
// clientFreshnessTokenAtom is async, useAtomValue will suspend until it's resolved.
// Suspense boundary is in app/layout.tsx or could be added here if needed more locally.
const clientToken = useAtomValue(clientFreshnessTokenAtom);
useEffect(() => {
if (status === 'loading') return
if (!currentUserId && !userSelect) {
setUserSelect(true)
}
}, [currentUserId, status, userSelect, setUserSelect])
useEffect(() => {
setCurrentUserIdAtom(currentUserId)
}, [currentUserId, setCurrentUserIdAtom])
const performFreshnessCheck = useCallback(async () => {
if (!clientToken || status !== 'authenticated') return;
try {
const result = await checkServerDataFreshness(clientToken);
if (!result.isFresh) {
setShowRefreshBanner(true);
}
} catch (error) {
console.error("Failed to check data freshness with server:", error);
}
}, [clientToken, status]);
useEffect(() => {
// Interval for polling data freshness
if (clientToken && !showRefreshBanner && status === 'authenticated') {
const intervalId = setInterval(() => {
performFreshnessCheck();
}, 30000); // Check every 30 seconds
return () => clearInterval(intervalId);
}
}, [clientToken, performFreshnessCheck, showRefreshBanner, status]);
const handleRefresh = () => {
setShowRefreshBanner(false);
window.location.reload();
};
return (
<>
{children}
{pomo.show && (
<PomodoroTimer />
)}
{pomo.show && <PomodoroTimer />}
{userSelect && <UserSelectModal onClose={() => setUserSelect(false)} />}
{aboutOpen && <AboutModal onClose={() => setAboutOpen(false)} />}
{showRefreshBanner && <RefreshBanner onRefresh={handleRefresh} />}
</>
)
);
}
export default function ClientWrapper({ children }: { children: ReactNode }) {
const [isMounted, setIsMounted] = useState(false);
// block client-side hydration until mounted (this is crucial to wait for all jotai atoms to load),
// to prevent SSR hydration errors in the children components
useEffect(() => {
setIsMounted(true);
}, []);
if (!isMounted) {
return <LoadingSpinner />;
}
return (
<Suspense fallback={<LoadingSpinner />}>
<ClientWrapperContent>{children}</ClientWrapperContent>
</Suspense>
);
}

View File

@@ -2,17 +2,19 @@ import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Coins } from 'lucide-react'
import { FormattedNumber } from '@/components/FormattedNumber'
import { useAtom } from 'jotai'
import { useTranslations } from 'next-intl'
import { settingsAtom } from '@/lib/atoms'
import dynamic from 'next/dynamic'
const TodayEarnedCoins = dynamic(() => import('./TodayEarnedCoins'), { ssr: false })
export default function CoinBalance({ coinBalance }: { coinBalance: number }) {
const t = useTranslations('CoinBalance');
const [settings] = useAtom(settingsAtom)
return (
<Card>
<CardHeader>
<CardTitle>Coin Balance</CardTitle>
<CardTitle>{t('coinBalanceTitle')}</CardTitle>
</CardHeader>
<CardContent>
<div className="flex items-center justify-center">

View File

@@ -1,20 +1,29 @@
'use client'
import { useState } from 'react'
import { t2d, d2s, getNow, isSameDate } from '@/lib/utils'
import { Button } from '@/components/ui/button'
import { FormattedNumber } from '@/components/FormattedNumber'
import { History, Pencil } from 'lucide-react'
import EmptyState from './EmptyState'
import { Input } from '@/components/ui/input'
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'
import { Button } from '@/components/ui/button'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { settingsAtom } from '@/lib/atoms'
import Link from 'next/link'
import { useAtom } from 'jotai'
import { Input } from '@/components/ui/input'
import { useCoins } from '@/hooks/useCoins'
import { currentUserAtom, settingsAtom, usersAtom } from '@/lib/atoms'
import { MAX_COIN_LIMIT } from '@/lib/constants'
import { TransactionType } from '@/lib/types'
import { d2s, t2d } from '@/lib/utils'
import { useAtom } from 'jotai'
import { History } from 'lucide-react'
import { useTranslations } from 'next-intl'
import Link from 'next/link'
import { useSearchParams } from 'next/navigation'; // Import useSearchParams
import { useEffect, useRef, useState } from 'react'; // Import useEffect, useRef
import EmptyState from './EmptyState'
import { TransactionNoteEditor } from './TransactionNoteEditor'
export default function CoinsManager() {
const t = useTranslations('CoinsManager')
const [currentUser] = useAtom(currentUserAtom)
const [selectedUser, setSelectedUser] = useState<string>()
const {
add,
remove,
@@ -26,14 +35,40 @@ export default function CoinsManager() {
totalSpent,
coinsSpentToday,
transactionsToday
} = useCoins()
} = useCoins({ selectedUser })
const [settings] = useAtom(settingsAtom)
const [usersData] = useAtom(usersAtom)
const DEFAULT_AMOUNT = '0'
const [amount, setAmount] = useState(DEFAULT_AMOUNT)
const [pageSize, setPageSize] = useState(50)
const [currentPage, setCurrentPage] = useState(1)
const [note, setNote] = useState('')
const searchParams = useSearchParams()
const highlightId = searchParams.get('highlight')
const userIdFromQuery = searchParams.get('user') // Get user ID from query
const transactionRefs = useRef<Record<string, HTMLDivElement | null>>({});
const PAGE_ENTRY_COUNTS = [10, 50, 100, 500];
// Effect to set selected user from query param if admin
useEffect(() => {
if (currentUser?.isAdmin && userIdFromQuery && userIdFromQuery !== selectedUser) {
// Check if the user ID from query exists in usersData
if (usersData.users.some(u => u.id === userIdFromQuery)) {
setSelectedUser(userIdFromQuery);
}
}
// Only run when userIdFromQuery or currentUser changes, avoid re-running on selectedUser change within this effect
}, [userIdFromQuery, currentUser, usersData.users, selectedUser]);
// Effect to scroll to highlighted transaction
useEffect(() => {
if (highlightId && transactionRefs.current[highlightId]) {
transactionRefs.current[highlightId]?.scrollIntoView({
behavior: 'smooth',
block: 'center',
});
}
}, [highlightId, transactions]); // Re-run if highlightId or transactions change
const handleSaveNote = async (transactionId: string, note: string) => {
await updateNote(transactionId, note)
@@ -56,9 +91,35 @@ export default function CoinsManager() {
}
}
const getTransactionTypeLabel = (type: TransactionType) => {
switch (type) {
case 'HABIT_COMPLETION': return t('transactionTypeHabitCompletion');
case 'TASK_COMPLETION': return t('transactionTypeTaskCompletion');
case 'HABIT_UNDO': return t('transactionTypeHabitUndo');
case 'TASK_UNDO': return t('transactionTypeTaskUndo');
case 'WISH_REDEMPTION': return t('transactionTypeWishRedemption');
case 'MANUAL_ADJUSTMENT': return t('transactionTypeManualAdjustment');
}
}
return (
<div className="container mx-auto px-4 py-8">
<h1 className="text-3xl font-bold mb-6">Coins Management</h1>
<div>
<div className="flex items-center justify-between mb-6">
<h1 className="text-xl xs:text-3xl font-bold mr-6">{t('title')}</h1>
{currentUser?.isAdmin && (
<select
className="border rounded p-2"
value={selectedUser}
onChange={(e) => setSelectedUser(e.target.value)}
>
{usersData.users.map(user => (
<option key={user.id} value={user.id}>
{user.username}
</option>
))}
</select>
)}
</div>
<div className="grid gap-6 md:grid-cols-2">
<Card>
@@ -66,8 +127,8 @@ export default function CoinsManager() {
<CardTitle className="flex items-center gap-2">
<span className="text-2xl animate-bounce hover:animate-none cursor-default">💰</span>
<div>
<div className="text-sm font-normal text-muted-foreground">Current Balance</div>
<div className="text-3xl font-bold"><FormattedNumber amount={balance} settings={settings} /> coins</div>
<div className="text-sm font-normal text-muted-foreground">{t('currentBalanceLabel')}</div>
<div className="text-3xl font-bold"><FormattedNumber amount={balance} settings={settings} /> {t('coinsSuffix')}</div>
</div>
</CardTitle>
</CardHeader>
@@ -79,7 +140,11 @@ export default function CoinsManager() {
variant="outline"
size="icon"
className="h-10 w-10 text-lg"
onClick={() => setAmount(prev => (Number(prev) - 1).toString())}
onClick={() => setAmount(prev => {
const current = Number(prev);
const next = current - 1;
return (Math.abs(next) > MAX_COIN_LIMIT ? (next < 0 ? -MAX_COIN_LIMIT : MAX_COIN_LIMIT) : next).toString();
})}
>
-
</Button>
@@ -87,7 +152,22 @@ export default function CoinsManager() {
<Input
type="number"
value={amount}
onChange={(e) => setAmount(e.target.value)}
onChange={(e) => {
const rawValue = e.target.value;
if (rawValue === '' || rawValue === '-') {
setAmount(rawValue);
return;
}
let numericValue = Number(rawValue); // Changed const to let
if (isNaN(numericValue)) return; // Or handle error
if (Math.abs(numericValue) > MAX_COIN_LIMIT) {
numericValue = numericValue < 0 ? -MAX_COIN_LIMIT : MAX_COIN_LIMIT;
}
setAmount(numericValue.toString());
}}
min={-MAX_COIN_LIMIT}
max={MAX_COIN_LIMIT}
className="text-center text-xl font-medium h-12"
/>
<div className="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground">
@@ -98,7 +178,11 @@ export default function CoinsManager() {
variant="outline"
size="icon"
className="h-10 w-10 text-lg"
onClick={() => setAmount(prev => (Number(prev) + 1).toString())}
onClick={() => setAmount(prev => {
const current = Number(prev);
const next = current + 1;
return (Math.abs(next) > MAX_COIN_LIMIT ? (next < 0 ? -MAX_COIN_LIMIT : MAX_COIN_LIMIT) : next).toString();
})}
>
+
</Button>
@@ -112,7 +196,7 @@ export default function CoinsManager() {
variant="default"
>
<div className="flex items-center gap-2">
{Number(amount) >= 0 ? 'Add Coins' : 'Remove Coins'}
{Number(amount) >= 0 ? t('addCoinsButton') : t('removeCoinsButton')}
</div>
</Button>
</div>
@@ -124,27 +208,27 @@ export default function CoinsManager() {
<Card>
<CardHeader>
<CardTitle>Statistics</CardTitle>
<CardTitle>{t('statisticsTitle')}</CardTitle>
</CardHeader>
<CardContent>
<div className="grid grid-cols-1 xl:grid-cols-3 gap-4">
{/* Top Row - Totals */}
<div className="p-4 rounded-lg bg-green-100 dark:bg-green-900">
<div className="text-sm text-green-800 dark:text-green-100 mb-1">Total Earned</div>
<div className="text-sm text-green-800 dark:text-green-100 mb-1">{t('totalEarnedLabel')}</div>
<div className="text-2xl font-bold text-green-900 dark:text-green-50">
<FormattedNumber amount={totalEarned} settings={settings} /> 🪙
</div>
</div>
<div className="p-4 rounded-lg bg-red-100 dark:bg-red-900">
<div className="text-sm text-red-800 dark:text-red-100 mb-1">Total Spent</div>
<div className="text-sm text-red-800 dark:text-red-100 mb-1">{t('totalSpentLabel')}</div>
<div className="text-2xl font-bold text-red-900 dark:text-red-50">
<FormattedNumber amount={totalSpent} settings={settings} /> 💸
</div>
</div>
<div className="p-4 rounded-lg bg-pink-100 dark:bg-pink-900">
<div className="text-sm text-pink-800 dark:text-pink-100 mb-1">Total Transactions</div>
<div className="text-sm text-pink-800 dark:text-pink-100 mb-1">{t('totalTransactionsLabel')}</div>
<div className="text-2xl font-bold text-pink-900 dark:text-pink-50">
{transactions.length} 📈
</div>
@@ -152,21 +236,21 @@ export default function CoinsManager() {
{/* Bottom Row - Today */}
<div className="p-4 rounded-lg bg-blue-100 dark:bg-blue-900">
<div className="text-sm text-blue-800 dark:text-blue-100 mb-1">Today's Earned</div>
<div className="text-sm text-blue-800 dark:text-blue-100 mb-1">{t('todaysEarnedLabel')}</div>
<div className="text-2xl font-bold text-blue-900 dark:text-blue-50">
<FormattedNumber amount={coinsEarnedToday} settings={settings} /> 🪙
</div>
</div>
<div className="p-4 rounded-lg bg-purple-100 dark:bg-purple-900">
<div className="text-sm text-purple-800 dark:text-purple-100 mb-1">Today's Spent</div>
<div className="text-sm text-purple-800 dark:text-purple-100 mb-1">{t('todaysSpentLabel')}</div>
<div className="text-2xl font-bold text-purple-900 dark:text-purple-50">
<FormattedNumber amount={coinsSpentToday} settings={settings} /> 💸
</div>
</div>
<div className="p-4 rounded-lg bg-orange-100 dark:bg-orange-900">
<div className="text-sm text-orange-800 dark:text-orange-100 mb-1">Today's Transactions</div>
<div className="text-sm text-orange-800 dark:text-orange-100 mb-1">{t('todaysTransactionsLabel')}</div>
<div className="text-2xl font-bold text-orange-900 dark:text-orange-50">
{transactionsToday} 📊
</div>
@@ -177,13 +261,13 @@ export default function CoinsManager() {
<Card className="md:col-span-2">
<CardHeader>
<CardTitle>Transaction History</CardTitle>
<CardTitle>{t('transactionHistoryTitle')}</CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-4">
<div className="flex justify-between items-center mb-4">
<div className="flex items-center gap-2">
<span className="text-sm text-muted-foreground">Show:</span>
<span className="text-sm text-muted-foreground">{t('showLabel')}</span>
<select
className="border rounded p-1"
value={pageSize}
@@ -192,22 +276,20 @@ export default function CoinsManager() {
setCurrentPage(1) // Reset to first page when changing page size
}}
>
<option value={50}>50</option>
<option value={100}>100</option>
<option value={500}>500</option>
{PAGE_ENTRY_COUNTS.map(n => <option key={n} value={n}>{n}</option>)}
</select>
<span className="text-sm text-muted-foreground">entries</span>
<span className="text-sm text-muted-foreground">{t('entriesSuffix')}</span>
</div>
<div className="text-sm text-muted-foreground">
Showing {Math.min((currentPage - 1) * pageSize + 1, transactions.length)} to {Math.min(currentPage * pageSize, transactions.length)} of {transactions.length} entries
{t('showingEntries', { from: Math.min((currentPage - 1) * pageSize + 1, transactions.length), to: Math.min(currentPage * pageSize, transactions.length), total: transactions.length })}
</div>
</div>
{transactions.length === 0 ? (
<EmptyState
icon={History}
title="No transactions yet"
description="Your transaction history will appear here once you start earning or spending coins"
title={t('noTransactionsTitle')}
description={t('noTransactionsDescription')}
/>
) : (
<>
@@ -229,13 +311,17 @@ export default function CoinsManager() {
}
}
const isHighlighted = transaction.id === highlightId;
const transactionUser = usersData.users.find(u => u.id === transaction.userId);
return (
<div
key={transaction.id}
className="flex justify-between items-center p-3 border rounded hover:bg-gray-50 dark:hover:bg-gray-800 transition-colors"
ref={(el) => { transactionRefs.current[transaction.id] = el; }} // Assign ref correctly
className={`flex justify-between items-center p-3 border rounded hover:bg-gray-50 dark:hover:bg-gray-800 transition-colors ${isHighlighted ? 'ring-2 ring-blue-500 bg-blue-50 dark:bg-blue-900/30' : '' // Apply highlight styles
}`}
>
<div className="space-y-1">
<div className="flex items-center gap-2">
<div className="space-y-1 flex-grow mr-4"> {/* Added flex-grow and margin */}
<div className="flex items-center gap-2 flex-wrap"> {/* Added flex-wrap */}
{transaction.relatedItemId ? (
<Link
href={`${transaction.type === 'WISH_REDEMPTION' ? '/wishlist' : '/habits'}?highlight=${transaction.relatedItemId}`}
@@ -250,8 +336,20 @@ export default function CoinsManager() {
<span
className={`text-xs px-2 py-1 rounded-full ${getBadgeStyles()}`}
>
{transaction.type.split('_').join(' ')}
{getTransactionTypeLabel(transaction.type as TransactionType)}
</span>
{transaction.userId && currentUser?.isAdmin && (
<Avatar className="h-6 w-6">
<AvatarImage
src={transactionUser?.avatarPath ?
`/api/avatars/${transactionUser?.avatarPath?.split('/').pop()}` : undefined}
alt={transactionUser?.username}
/>
<AvatarFallback>
{transactionUser?.username?.[0] || '?'}
</AvatarFallback>
</Avatar>
)}
</div>
<p className="text-sm text-gray-500">
{d2s({ dateTime: t2d({ timestamp: transaction.timestamp, timezone: settings.system.timezone }), timezone: settings.system.timezone })}
@@ -263,14 +361,16 @@ export default function CoinsManager() {
onDelete={handleDeleteNote}
/>
</div>
<span
className={`font-mono ${transaction.amount >= 0
? 'text-green-600 dark:text-green-400'
: 'text-red-600 dark:text-red-400'
}`}
>
{transaction.amount >= 0 ? '+' : ''}{transaction.amount}
</span>
<div className="flex-shrink-0 text-right"> {/* Ensure amount stays on the right */}
<span
className={`font-mono ${transaction.amount >= 0
? 'text-green-600 dark:text-green-400'
: 'text-red-600 dark:text-red-400'
}`}
>
{transaction.amount >= 0 ? '+' : ''}{transaction.amount}
</span>
</div>
</div>
)
})}
@@ -294,9 +394,9 @@ export default function CoinsManager() {
</Button>
<div className="flex items-center gap-1 px-4 py-2 rounded-md bg-muted">
<span className="text-sm font-medium">Page</span>
<span className="text-sm font-medium">{t('pageLabel')}</span>
<span className="text-sm font-bold">{currentPage}</span>
<span className="text-sm font-medium">of</span>
<span className="text-sm font-medium">{t('ofLabel')}</span>
<span className="text-sm font-bold">{Math.ceil(transactions.length / pageSize)}</span>
</div>
<Button

View File

@@ -0,0 +1,35 @@
import { Badge } from "@/components/ui/badge"
import { completedHabitsMapAtom, habitsByDateFamily, settingsAtom } from '@/lib/atoms'
import { getTodayInTimezone } from '@/lib/utils'
import { useAtom } from 'jotai'
import { useTranslations } from 'next-intl'
interface CompletionCountBadgeProps {
type: 'habits' | 'tasks'
date?: string
}
export default function CompletionCountBadge({
type,
date
}: CompletionCountBadgeProps) {
const t = useTranslations('CompletionCountBadge');
const [settings] = useAtom(settingsAtom)
const [completedHabitsMap] = useAtom(completedHabitsMapAtom)
const targetDate = date || getTodayInTimezone(settings.system.timezone)
const [dueHabits] = useAtom(habitsByDateFamily(targetDate))
const completedCount = completedHabitsMap.get(targetDate)?.filter(h =>
type === 'tasks' ? h.isTask : !h.isTask
).length || 0
const totalCount = dueHabits.filter(h =>
type === 'tasks' ? h.isTask : !h.isTask
).length
return (
<Badge variant="secondary">
{t('countCompleted', { completedCount, totalCount })}
</Badge>
)
}

View File

@@ -6,6 +6,7 @@ import {
DialogFooter,
} from '@/components/ui/dialog'
import { Button } from '@/components/ui/button'
import { useTranslations } from 'next-intl'
interface ConfirmDialogProps {
isOpen: boolean
@@ -23,9 +24,13 @@ export default function ConfirmDialog({
onConfirm,
title,
message,
confirmText = "Confirm",
cancelText = "Cancel"
confirmText,
cancelText,
}: ConfirmDialogProps) {
const t = useTranslations('ConfirmDialog');
const finalConfirmText = confirmText || t('confirmButton');
const finalCancelText = cancelText || t('cancelButton');
return (
<Dialog open={isOpen} onOpenChange={onClose}>
<DialogContent>
@@ -37,10 +42,10 @@ export default function ConfirmDialog({
</div>
<DialogFooter>
<Button variant="outline" onClick={onClose}>
{cancelText}
{finalCancelText}
</Button>
<Button variant="destructive" onClick={onConfirm}>
{confirmText}
{finalConfirmText}
</Button>
</DialogFooter>
</DialogContent>

View File

@@ -1,23 +1,34 @@
import { Circle, Coins, ArrowRight, CircleCheck, ChevronDown, ChevronUp, Timer } from 'lucide-react'
import { Badge } from '@/components/ui/badge'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import {
ContextMenu,
ContextMenuContent,
ContextMenuItem,
ContextMenuTrigger,
ContextMenuTrigger
} from "@/components/ui/context-menu"
import { cn, isHabitDueToday, getHabitFreq } from '@/lib/utils'
import Link from 'next/link'
import { useState, useEffect } from 'react'
import { useAtom } from 'jotai'
import { pomodoroAtom, settingsAtom } from '@/lib/atoms'
import { getTodayInTimezone, isSameDate, t2d, d2t, getNow, getCompletedHabitsForDate, getCompletionsForDate } from '@/lib/utils'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
import { Progress } from '@/components/ui/progress'
import { WishlistItemType } from '@/lib/types'
import { Habit } from '@/lib/types'
import Linkify from './linkify'
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip"
import { useHabits } from '@/hooks/useHabits'
import { browserSettingsAtom, completedHabitsMapAtom, hasTasksAtom, pomodoroAtom, settingsAtom } from '@/lib/atoms'
import { DESKTOP_DISPLAY_ITEM_COUNT } from '@/lib/constants'
import { Habit, WishlistItemType } from '@/lib/types'
import { cn, d2t, getNow, getTodayInTimezone, isHabitDue, isSameDate, isTaskOverdue, t2d } from '@/lib/utils'
import { useAtom } from 'jotai'
import { AlertTriangle, ArrowRight, ChevronDown, ChevronUp, Circle, CircleCheck, Coins, Pin, Plus } from 'lucide-react'; // Removed unused icons
import { useTranslations } from 'next-intl'
import Link from 'next/link'
import { useState } from 'react'
import AddEditHabitModal from './AddEditHabitModal'
import CompletionCountBadge from './CompletionCountBadge'
import ConfirmDialog from './ConfirmDialog'
import { HabitContextMenuItems } from './HabitContextMenuItems'
import Linkify from './linkify'
import { Button } from './ui/button'
import DrawingDisplay from './DrawingDisplay'
interface UpcomingItemsProps {
habits: Habit[]
@@ -25,29 +36,354 @@ interface UpcomingItemsProps {
coinBalance: number
}
interface ItemSectionProps {
title: string;
items: Habit[];
emptyMessage: string;
isTask: boolean;
viewLink: string;
addNewItem: () => void;
}
const ItemSection = ({
title,
items,
emptyMessage,
isTask,
viewLink,
addNewItem,
}: ItemSectionProps) => {
const t = useTranslations('DailyOverview');
const { completeHabit, undoComplete, saveHabit, deleteHabit, archiveHabit, habitFreqMap } = useHabits();
const [_, setPomo] = useAtom(pomodoroAtom);
const [browserSettings, setBrowserSettings] = useAtom(browserSettingsAtom);
const [settings] = useAtom(settingsAtom);
const [completedHabitsMap] = useAtom(completedHabitsMapAtom);
const today = getTodayInTimezone(settings.system.timezone);
const currentTodayCompletions = completedHabitsMap.get(today) || [];
const currentBadgeType = isTask ? 'tasks' : 'habits';
const currentExpanded = isTask ? browserSettings.expandedTasks : browserSettings.expandedHabits;
const setCurrentExpanded = (value: boolean) => {
setBrowserSettings(prev => ({
...prev,
[isTask ? 'expandedTasks' : 'expandedHabits']: value
}));
};
const [isConfirmDeleteDialogOpen, setIsConfirmDeleteDialogOpen] = useState(false);
const [habitToDelete, setHabitToDelete] = useState<Habit | null>(null);
const [habitToEdit, setHabitToEdit] = useState<Habit | null>(null);
const handleDeleteClick = (habit: Habit) => {
setHabitToDelete(habit);
setIsConfirmDeleteDialogOpen(true);
};
const confirmDelete = async () => {
if (habitToDelete) {
await deleteHabit(habitToDelete.id);
setHabitToDelete(null);
setIsConfirmDeleteDialogOpen(false);
}
};
const handleEditClick = (habit: Habit) => {
setHabitToEdit(habit);
};
if (items.length === 0) {
return (
<div>
<div className="flex items-center justify-between mb-2">
<h3 className="font-semibold">{title}</h3>
<Button
variant="ghost"
size="sm"
className="h-7 w-7 rounded-full hover:bg-primary/10 hover:text-primary"
onClick={addNewItem}
>
<Plus className="h-4 w-4" />
<span className="sr-only">{t(isTask ? 'addTaskButtonLabel' : 'addHabitButtonLabel')}</span>
</Button>
</div>
<div className="text-center text-muted-foreground text-sm py-4">
{emptyMessage}
</div>
</div>
);
}
return (
<div>
<div className="flex items-center justify-between mb-2">
<div className="flex items-center gap-2">
<h3 className="font-semibold">{title}</h3>
</div>
<div className="flex items-center gap-2">
<CompletionCountBadge type={currentBadgeType} />
<Button
variant="ghost"
size="sm"
className="h-7 w-7 rounded-full hover:bg-primary/10 hover:text-primary"
onClick={addNewItem}
>
<Plus className="h-4 w-4" />
<span className="sr-only">{t(isTask ? 'addTaskButtonLabel' : 'addHabitButtonLabel')}</span>
</Button>
</div>
</div>
<ul className={`grid gap-2 transition-all duration-300 ease-in-out ${currentExpanded ? 'max-h-none' : 'max-h-[200px]'} overflow-hidden`}>
{items
.sort((a, b) => {
// First by pinned status
if (a.pinned !== b.pinned) {
return a.pinned ? -1 : 1;
}
// Then by completion status
const aCompleted = currentTodayCompletions.includes(a);
const bCompleted = currentTodayCompletions.includes(b);
if (aCompleted !== bCompleted) {
return aCompleted ? 1 : -1;
}
// Then by frequency (daily first)
const aFreq = habitFreqMap.get(a.id) || 'daily';
const bFreq = habitFreqMap.get(b.id) || 'daily';
const freqOrder = ['daily', 'weekly', 'monthly', 'yearly'];
if (freqOrder.indexOf(aFreq) !== freqOrder.indexOf(bFreq)) {
return freqOrder.indexOf(aFreq) - freqOrder.indexOf(bFreq);
}
// Then by coin reward (higher first)
if (a.coinReward !== b.coinReward) {
return b.coinReward - a.coinReward;
}
// Finally by target completions (higher first)
const aTarget = a.targetCompletions || 1;
const bTarget = b.targetCompletions || 1;
return bTarget - aTarget;
})
.slice(0, currentExpanded ? undefined : DESKTOP_DISPLAY_ITEM_COUNT)
.map((habit) => {
const completionsToday = habit.completions.filter(completion =>
isSameDate(t2d({ timestamp: completion, timezone: settings.system.timezone }), t2d({ timestamp: d2t({ dateTime: getNow({ timezone: settings.system.timezone }) }), timezone: settings.system.timezone }))
).length
const target = habit.targetCompletions || 1
const isCompleted = completionsToday >= target || (isTask && habit.archived)
return (
<li
className={`flex items-center justify-between text-sm p-2 rounded-md
${isCompleted ? 'bg-secondary/50' : 'bg-secondary/20'}`}
key={habit.id}
>
<span className="flex items-center gap-2 flex-1 min-w-0">
<ContextMenu>
<ContextMenuTrigger asChild>
<div className="flex items-center gap-2 cursor-pointer flex-1 min-w-0">
<div className="flex-shrink-0">
<button
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
if (isCompleted) {
undoComplete(habit);
} else {
completeHabit(habit);
}
}}
className="relative hover:opacity-70 transition-opacity w-4 h-4"
>
{isCompleted ? (
<CircleCheck className="h-4 w-4 text-green-500" />
) : (
<div className="relative h-4 w-4">
<Circle className="absolute h-4 w-4 text-muted-foreground" />
<div
className="absolute h-4 w-4 rounded-full overflow-hidden"
style={{
background: `conic-gradient(
currentColor ${(completionsToday / target) * 360}deg,
transparent ${(completionsToday / target) * 360}deg 360deg
)`,
mask: 'radial-gradient(transparent 50%, black 51%)',
WebkitMask: 'radial-gradient(transparent 50%, black 51%)'
}}
/>
</div>
)}
</button>
</div>
<span className="flex items-center gap-1">
{habit.pinned && (
<Pin className="h-4 w-4 text-yellow-500" />
)}
<Link
href={`/habits?highlight=${habit.id}`}
className="flex items-center gap-1 hover:text-primary transition-colors"
>
{isTask && isTaskOverdue(habit, settings.system.timezone) && !isCompleted && (
<TooltipProvider>
<Tooltip delayDuration={0}>
<TooltipTrigger asChild>
{/* The AlertTriangle itself doesn't need hover styles if the parent Link handles it */}
<AlertTriangle className="h-4 w-4 flex-shrink-0 text-red-600 dark:text-red-500" />
</TooltipTrigger>
<TooltipContent>
<p>{t('overdueTooltip')}</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
)}
<span
className={cn(
isCompleted ? 'line-through' : '',
'break-all' // Text specific styles
)}
>
{habit.name}
</span>
</Link>
{habit.drawing && (
<div className="ml-2 pr-2">
<DrawingDisplay
drawingData={habit.drawing}
width={40}
height={26}
className="border-0"
/>
</div>
)}
</span>
</div>
</ContextMenuTrigger>
<ContextMenuContent className="w-64">
<HabitContextMenuItems
habit={habit}
onEditRequest={() => handleEditClick(habit)}
onDeleteRequest={() => handleDeleteClick(habit)}
context="daily-overview"
/>
</ContextMenuContent>
</ContextMenu>
</span>
<span className="flex items-center gap-2 text-xs text-muted-foreground flex-shrink-0">
{habit.targetCompletions && (
<span className="bg-secondary px-1.5 py-0.5 rounded-full">
{completionsToday}/{target}
</span>
)}
{habitFreqMap.get(habit.id) !== 'daily' && (
<Badge variant="outline" className="text-xs">
{habitFreqMap.get(habit.id)}
</Badge>
)}
<span className="flex items-center">
<Coins className={cn(
"h-3 w-3 mr-1 transition-all",
isCompleted
? "text-yellow-500 drop-shadow-[0_0_2px_rgba(234,179,8,0.3)]"
: "text-gray-400"
)} />
<span className={cn(
"transition-all",
isCompleted
? "text-yellow-500 font-medium"
: "text-gray-400"
)}>
{habit.coinReward}
</span>
</span>
</span>
</li>
)
})}
</ul>
<div className="flex items-center justify-between">
<button
onClick={() => setCurrentExpanded(!currentExpanded)}
className="text-sm text-muted-foreground hover:text-primary flex items-center gap-1"
>
{items.length > DESKTOP_DISPLAY_ITEM_COUNT && (currentExpanded ? (
<>
{t('showLessButton')}
<ChevronUp className="h-3 w-3" />
</>
) : (
<>
{t('showAllButton')}
<ChevronDown className="h-3 w-3" />
</>
))}
</button>
<Link
href={viewLink}
className="text-sm text-muted-foreground hover:text-primary flex items-center gap-1"
>
View
<ArrowRight className="h-3 w-3" />
</Link>
</div>
{habitToDelete && (
<ConfirmDialog
isOpen={isConfirmDeleteDialogOpen}
onClose={() => setIsConfirmDeleteDialogOpen(false)}
onConfirm={confirmDelete}
title={`Delete ${isTask ? 'Task' : 'Habit'}`}
message={`Are you sure you want to delete "${habitToDelete.name}"? This action cannot be undone.`}
confirmText="Delete"
/>
)}
{habitToEdit && (
<AddEditHabitModal
onClose={() => setHabitToEdit(null)}
onSave={async (updatedHabit) => {
await saveHabit({ ...habitToEdit, ...updatedHabit });
setHabitToEdit(null);
}}
habit={habitToEdit}
isTask={habitToEdit.isTask || false}
/>
)}
</div>
);
};
export default function DailyOverview({
habits,
wishlistItems,
coinBalance,
}: UpcomingItemsProps) {
const t = useTranslations('DailyOverview');
const { completeHabit, undoComplete } = useHabits()
const [settings] = useAtom(settingsAtom)
const [dailyHabits, setDailyHabits] = useState<Habit[]>([])
const [completedHabitsMap] = useAtom(completedHabitsMapAtom)
const [browserSettings, setBrowserSettings] = useAtom(browserSettingsAtom)
const today = getTodayInTimezone(settings.system.timezone)
const todayCompletions = getCompletedHabitsForDate({
habits,
date: getNow({ timezone: settings.system.timezone }),
timezone: settings.system.timezone
})
const todayCompletions = completedHabitsMap.get(today) || []
const { saveHabit } = useHabits()
useEffect(() => {
// Filter habits that are due today based on their recurrence rule
const filteredHabits = habits.filter(habit => isHabitDueToday(habit, settings.system.timezone))
setDailyHabits(filteredHabits)
}, [habits])
const timezone = settings.system.timezone
const todayDateObj = getNow({ timezone })
const dailyTasks = habits.filter(habit =>
habit.isTask &&
!habit.archived &&
(isHabitDue({ habit, timezone, date: todayDateObj }) || isTaskOverdue(habit, timezone))
)
const dailyHabits = habits.filter(habit =>
!habit.isTask &&
!habit.archived &&
isHabitDue({ habit, timezone, date: todayDateObj })
)
// Get all wishlist items sorted by redeemable status (non-redeemable first) then by coin cost
// Filter out archived wishlist items
const sortedWishlistItems = wishlistItems
.filter(item => !item.archived)
.sort((a, b) => {
const aRedeemable = a.coinCost <= coinBalance
const bRedeemable = b.coinCost <= coinBalance
@@ -61,204 +397,66 @@ export default function DailyOverview({
return a.coinCost - b.coinCost
})
const [expandedHabits, setExpandedHabits] = useState(false)
const [expandedWishlist, setExpandedWishlist] = useState(false)
const [_, setPomo] = useAtom(pomodoroAtom)
const [hasTasks] = useAtom(hasTasksAtom)
const [, setPomo] = useAtom(pomodoroAtom)
const [modalConfig, setModalConfig] = useState<{
isOpen: boolean,
isTask: boolean
}>({
isOpen: false,
isTask: false
});
return (
<>
<Card>
<CardHeader>
<CardTitle>Today's Overview</CardTitle>
<CardTitle>{t('todaysOverviewTitle')}</CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-4">
<div>
<div className="flex items-center justify-between mb-2">
<h3 className="font-semibold">Daily Habits</h3>
<Badge variant="secondary">
{dailyHabits.filter(habit => {
const completions = getCompletionsForDate({
habit,
date: today,
timezone: settings.system.timezone
});
return completions >= (habit.targetCompletions || 1);
}).length}/{dailyHabits.length} Completed
</Badge>
</div>
<ul className={`grid gap-2 transition-all duration-300 ease-in-out ${expandedHabits ? 'max-h-none' : 'max-h-[200px]'} overflow-hidden`}>
{dailyHabits
.sort((a, b) => {
// First by completion status
const aCompleted = todayCompletions.includes(a);
const bCompleted = todayCompletions.includes(b);
if (aCompleted !== bCompleted) {
return aCompleted ? 1 : -1;
}
<div className="space-y-6">
{/* Tasks Section */}
{hasTasks && (
<ItemSection
title={t('dailyTasksTitle')}
items={dailyTasks}
emptyMessage={t('noTasksDueTodayMessage')}
isTask={true}
viewLink="/habits?view=tasks"
addNewItem={() => setModalConfig({ isOpen: true, isTask: true })}
/>
)}
// Then by frequency (daily first)
const aFreq = getHabitFreq(a);
const bFreq = getHabitFreq(b);
const freqOrder = ['daily', 'weekly', 'monthly', 'yearly'];
if (freqOrder.indexOf(aFreq) !== freqOrder.indexOf(bFreq)) {
return freqOrder.indexOf(aFreq) - freqOrder.indexOf(bFreq);
}
// Then by coin reward (higher first)
if (a.coinReward !== b.coinReward) {
return b.coinReward - a.coinReward;
}
// Finally by target completions (higher first)
const aTarget = a.targetCompletions || 1;
const bTarget = b.targetCompletions || 1;
return bTarget - aTarget;
})
.slice(0, expandedHabits ? undefined : 5)
.map((habit) => {
const completionsToday = habit.completions.filter(completion =>
isSameDate(t2d({ timestamp: completion, timezone: settings.system.timezone }), t2d({ timestamp: d2t({ dateTime: getNow({ timezone: settings.system.timezone }) }), timezone: settings.system.timezone }))
).length
const target = habit.targetCompletions || 1
const isCompleted = completionsToday >= target
return (
<li
className={`flex items-center justify-between text-sm p-2 rounded-md
${isCompleted ? 'bg-secondary/50' : 'bg-secondary/20'}`}
key={habit.id}
>
<span className="flex items-center gap-2">
<ContextMenu>
<ContextMenuTrigger asChild>
<div className="flex-none">
<button
onClick={(e) => {
e.preventDefault();
if (isCompleted) {
undoComplete(habit);
} else {
completeHabit(habit);
}
}}
className="relative hover:opacity-70 transition-opacity w-4 h-4"
>
{isCompleted ? (
<CircleCheck className="h-4 w-4 text-green-500" />
) : (
<div className="relative h-4 w-4">
<Circle className="absolute h-4 w-4 text-muted-foreground" />
<div
className="absolute h-4 w-4 rounded-full overflow-hidden"
style={{
background: `conic-gradient(
currentColor ${(completionsToday / target) * 360}deg,
transparent ${(completionsToday / target) * 360}deg 360deg
)`,
mask: 'radial-gradient(transparent 50%, black 51%)',
WebkitMask: 'radial-gradient(transparent 50%, black 51%)'
}}
/>
</div>
)}
</button>
</div>
</ContextMenuTrigger>
<span className={isCompleted ? 'line-through' : ''}>
<Linkify>
{habit.name}
</Linkify>
</span>
<ContextMenuContent className="w-64">
<ContextMenuItem onClick={() => {
setPomo((prev) => ({
...prev,
show: true,
selectedHabitId: habit.id
}))
}}>
<Timer className="mr-2 h-4 w-4" />
<span>Start Pomodoro</span>
</ContextMenuItem>
</ContextMenuContent>
</ContextMenu>
</span>
<span className="flex items-center gap-2 text-xs text-muted-foreground">
{habit.targetCompletions && (
<span className="bg-secondary px-1.5 py-0.5 rounded-full">
{completionsToday}/{target}
</span>
)}
{getHabitFreq(habit) !== 'daily' && (
<Badge variant="outline" className="text-xs">
{getHabitFreq(habit)}
</Badge>
)}
<span className="flex items-center">
<Coins className={cn(
"h-3 w-3 mr-1 transition-all",
isCompleted
? "text-yellow-500 drop-shadow-[0_0_2px_rgba(234,179,8,0.3)]"
: "text-gray-400"
)} />
<span className={cn(
"transition-all",
isCompleted
? "text-yellow-500 font-medium"
: "text-gray-400"
)}>
{habit.coinReward}
</span>
</span>
</span>
</li>
)
})}
</ul>
<div className="flex items-center justify-between">
<button
onClick={() => setExpandedHabits(!expandedHabits)}
className="text-sm text-muted-foreground hover:text-primary flex items-center gap-1"
>
{expandedHabits ? (
<>
Show less
<ChevronUp className="h-3 w-3" />
</>
) : (
<>
Show all
<ChevronDown className="h-3 w-3" />
</>
)}
</button>
<Link
href="/habits"
className="text-sm text-muted-foreground hover:text-primary flex items-center gap-1"
>
View
<ArrowRight className="h-3 w-3" />
</Link>
</div>
</div>
{/* Habits Section */}
<ItemSection
title={t('dailyHabitsTitle')}
items={dailyHabits}
emptyMessage={t('noHabitsDueTodayMessage')}
isTask={false}
viewLink="/habits"
addNewItem={() => setModalConfig({ isOpen: true, isTask: false })}
/>
<div className="space-y-2">
<div className="flex items-center justify-between mb-2">
<h3 className="font-semibold">Wishlist Goals</h3>
<h3 className="font-semibold">{t('wishlistGoalsTitle')}</h3>
<Badge variant="secondary">
{wishlistItems.filter(item => item.coinCost <= coinBalance).length}/{wishlistItems.length} Redeemable
{t('redeemableBadgeLabel', {
count: wishlistItems.filter(item => item.coinCost <= coinBalance).length,
total: wishlistItems.length
})}
</Badge>
</div>
<div>
<div className={`space-y-3 transition-all duration-300 ease-in-out ${expandedWishlist ? 'max-h-none' : 'max-h-[200px]'} overflow-hidden`}>
<div className={`space-y-3 transition-all duration-300 ease-in-out ${browserSettings.expandedWishlist ? 'max-h-none' : 'max-h-[200px]'} overflow-hidden`}>
{sortedWishlistItems.length === 0 ? (
<div className="text-center text-muted-foreground text-sm py-4">
No wishlist items yet. Add some goals to work towards!
{t('noWishlistItemsMessage')}
</div>
) : (
<>
{sortedWishlistItems
.slice(0, expandedWishlist ? undefined : 5)
.slice(0, browserSettings.expandedWishlist ? undefined : DESKTOP_DISPLAY_ITEM_COUNT)
.map((item) => {
const isRedeemable = item.coinCost <= coinBalance
return (
@@ -271,9 +469,19 @@ export default function DailyOverview({
)}
>
<div className="flex items-center justify-between mb-2">
<span className="text-sm">
<Linkify>{item.name}</Linkify>
</span>
<div className="flex items-center gap-2">
<span className="text-sm">
<Linkify>{item.name}</Linkify>
</span>
{item.drawing && (
<DrawingDisplay
drawingData={item.drawing}
width={40}
height={26}
className="border-0"
/>
)}
</div>
<span className="text-xs flex items-center">
<Coins className={cn(
"h-3 w-3 mr-1 transition-all",
@@ -300,8 +508,8 @@ export default function DailyOverview({
/>
<p className="text-xs text-muted-foreground mt-2">
{isRedeemable
? "Ready to redeem!"
: `${item.coinCost - coinBalance} coins to go`
? t('readyToRedeemMessage')
: t('coinsToGoMessage', { amount: item.coinCost - coinBalance })
}
</p>
</Link>
@@ -312,26 +520,26 @@ export default function DailyOverview({
</div>
<div className="flex items-center justify-between">
<button
onClick={() => setExpandedWishlist(!expandedWishlist)}
onClick={() => setBrowserSettings(prev => ({ ...prev, expandedWishlist: !prev.expandedWishlist }))}
className="text-sm text-muted-foreground hover:text-primary flex items-center gap-1"
>
{expandedWishlist ? (
{wishlistItems.length > DESKTOP_DISPLAY_ITEM_COUNT && (browserSettings.expandedWishlist ? (
<>
Show less
{t('showLessButton')}
<ChevronUp className="h-3 w-3" />
</>
) : (
<>
Show all
{t('showAllButton')}
<ChevronDown className="h-3 w-3" />
</>
)}
))}
</button>
<Link
href="/wishlist"
className="text-sm text-muted-foreground hover:text-primary flex items-center gap-1"
>
View
{t('viewButton')}
<ArrowRight className="h-3 w-3" />
</Link>
</div>
@@ -340,6 +548,17 @@ export default function DailyOverview({
</div>
</CardContent>
</Card>
{modalConfig.isOpen && (
<AddEditHabitModal
onClose={() => setModalConfig({ isOpen: false, isTask: false })}
onSave={async (habit) => {
await saveHabit({ ...habit, isTask: modalConfig.isTask })
setModalConfig({ isOpen: false, isTask: false });
}}
habit={null}
isTask={modalConfig.isTask}
/>
)}
</>
)
}

View File

@@ -1,31 +1,33 @@
'use client'
import { useCoins } from '@/hooks/useCoins'
import { habitsAtom, wishlistAtom } from '@/lib/atoms'
import { useAtom } from 'jotai'
import { wishlistAtom, habitsAtom, settingsAtom, coinsAtom } from '@/lib/atoms'
import { useTranslations } from 'next-intl'
import CoinBalance from './CoinBalance'
import DailyOverview from './DailyOverview'
import HabitStreak from './HabitStreak'
import CoinBalance from './CoinBalance'
import { useHabits } from '@/hooks/useHabits'
export default function Dashboard() {
const t = useTranslations('Dashboard');
const [habitsData] = useAtom(habitsAtom)
const habits = habitsData.habits
const [settings] = useAtom(settingsAtom)
const [coins] = useAtom(coinsAtom)
const coinBalance = coins.balance
const { balance } = useCoins()
const [wishlist] = useAtom(wishlistAtom)
const wishlistItems = wishlist.items
return (
<div className="container mx-auto px-4 py-8">
<h1 className="text-3xl font-bold mb-6">Dashboard</h1>
<div>
<div className="flex justify-between items-center mb-6">
<h1 className="text-xl xs:text-3xl font-bold">{t('title')}</h1>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
<CoinBalance coinBalance={coinBalance} />
<CoinBalance coinBalance={balance} />
<HabitStreak habits={habits} />
<DailyOverview
wishlistItems={wishlistItems}
habits={habits}
coinBalance={coinBalance}
coinBalance={balance}
/>
{/* <HabitHeatmap habits={habits} /> */}

View File

View File

@@ -0,0 +1,241 @@
'use client'
import React, { useState, useRef, useEffect } from 'react'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Undo2, Trash2, Palette } from 'lucide-react'
import { useTranslations } from 'next-intl'
interface DrawingCanvasProps {
initialDrawing?: string
onSave: (drawingData: string) => void
onClear?: () => void
}
export default function DrawingCanvas({ initialDrawing, onSave, onClear }: DrawingCanvasProps) {
const t = useTranslations('DrawingModal')
const [drawingHistory, setDrawingHistory] = useState<Array<{
color: string
thickness: number
points: Array<{ x: number; y: number }>
}>>([])
const [isDrawing, setIsDrawing] = useState(false)
const [color, setColor] = useState('#000000')
const [thickness, setThickness] = useState(4)
const canvasRef = useRef<HTMLCanvasElement>(null)
const contextRef = useRef<CanvasRenderingContext2D | null>(null)
useEffect(() => {
const canvas = canvasRef.current
if (!canvas) return
const context = canvas.getContext('2d')
if (!context) return
context.lineCap = 'round'
context.lineJoin = 'round'
contextRef.current = context
const resizeCanvas = () => {
const rect = canvas.getBoundingClientRect()
canvas.width = rect.width
canvas.height = rect.height
}
window.addEventListener('resize', resizeCanvas)
resizeCanvas()
return () => {
window.removeEventListener('resize', resizeCanvas)
}
}, [])
useEffect(() => {
if (initialDrawing) {
try {
const loadedData = JSON.parse(initialDrawing)
if (Array.isArray(loadedData)) {
setDrawingHistory(loadedData)
}
} catch (e) {
console.warn('Failed to load initial drawing data')
}
}
}, [initialDrawing])
useEffect(() => {
redrawCanvas()
}, [drawingHistory])
const getMousePos = (event: React.MouseEvent) => {
const canvas = canvasRef.current
if (!canvas) return { x: 0, y: 0 }
const rect = canvas.getBoundingClientRect()
const scaleX = canvas.width / rect.width
const scaleY = canvas.height / rect.height
return {
x: (event.clientX - rect.left) * scaleX,
y: (event.clientY - rect.top) * scaleY
}
}
const startDrawing = (event: React.MouseEvent) => {
event.preventDefault()
event.stopPropagation()
const { x, y } = getMousePos(event)
setIsDrawing(true)
contextRef.current?.beginPath()
contextRef.current?.moveTo(x, y)
setDrawingHistory(prevHistory => [
...prevHistory,
{ color, thickness, points: [{ x, y }] }
])
}
const draw = (event: React.MouseEvent) => {
if (!isDrawing || !contextRef.current) return
event.preventDefault()
event.stopPropagation()
const { x, y } = getMousePos(event)
contextRef.current.lineTo(x, y)
contextRef.current.strokeStyle = color
contextRef.current.lineWidth = thickness
contextRef.current.stroke()
setDrawingHistory(prevHistory => {
const lastStroke = prevHistory[prevHistory.length - 1]
if (lastStroke) {
lastStroke.points.push({ x, y })
}
return [...prevHistory]
})
}
const stopDrawing = (event?: React.MouseEvent) => {
if (event) {
event.preventDefault()
event.stopPropagation()
}
setIsDrawing(false)
contextRef.current?.closePath()
}
const redrawCanvas = () => {
const canvas = canvasRef.current
if (!canvas || !contextRef.current) return
const context = contextRef.current
context.clearRect(0, 0, canvas.width, canvas.height)
drawingHistory.forEach(stroke => {
if (stroke.points.length === 0) return
context.beginPath()
context.strokeStyle = stroke.color
context.lineWidth = stroke.thickness
context.moveTo(stroke.points[0].x, stroke.points[0].y)
stroke.points.forEach(point => {
context.lineTo(point.x, point.y)
})
context.stroke()
})
}
const handleUndo = () => {
setDrawingHistory(prevHistory => {
const newHistory = [...prevHistory]
newHistory.pop()
return newHistory
})
}
const handleClear = () => {
setDrawingHistory([])
onClear?.()
}
const handleSave = () => {
const jsonString = drawingHistory.length > 0 ? JSON.stringify(drawingHistory) : ''
onSave(jsonString)
}
return (
<div className="flex flex-col space-y-4">
<canvas
ref={canvasRef}
onMouseDown={startDrawing}
onMouseMove={draw}
onMouseUp={(e) => stopDrawing(e)}
onMouseLeave={(e) => stopDrawing(e)}
className="border border-gray-300 rounded-lg bg-white touch-none w-full h-80 cursor-crosshair"
/>
<div className="flex flex-wrap items-center gap-4">
<div className="flex items-center gap-2">
<Label htmlFor="colorPicker" className="text-sm font-medium">
{t('colorLabel')}
</Label>
<div className="flex items-center gap-1">
<Palette className="h-4 w-4 text-muted-foreground" />
<Input
type="color"
id="colorPicker"
value={color}
onChange={(e) => setColor(e.target.value)}
className="w-8 h-8 border-2 border-gray-300 rounded cursor-pointer p-0"
/>
</div>
</div>
<div className="flex items-center gap-2">
<Label htmlFor="lineThickness" className="text-sm font-medium">
{t('thicknessLabel')}
</Label>
<Input
type="range"
id="lineThickness"
min="1"
max="20"
value={thickness}
onChange={(e) => setThickness(Number(e.target.value))}
className="w-20"
/>
<span className="text-xs text-muted-foreground w-6">{thickness}</span>
</div>
<div className="flex gap-2 ml-auto">
<Button
type="button"
variant="outline"
size="sm"
onClick={handleUndo}
disabled={drawingHistory.length === 0}
>
<Undo2 className="h-4 w-4 mr-1" />
{t('undoButton')}
</Button>
<Button
type="button"
variant="outline"
size="sm"
onClick={handleClear}
disabled={drawingHistory.length === 0}
>
<Trash2 className="h-4 w-4 mr-1" />
{t('clearButton')}
</Button>
</div>
</div>
<div className="flex justify-end">
<Button onClick={handleSave}>
{t('saveDrawingButton')}
</Button>
</div>
</div>
)
}

View File

@@ -0,0 +1,113 @@
'use client'
import { useEffect, useRef } from 'react'
interface DrawingDisplayProps {
drawingData?: string
width?: number
height?: number
className?: string
}
interface DrawingStroke {
color: string
thickness: number
points: Array<{ x: number; y: number }>
}
export default function DrawingDisplay({
drawingData,
width = 120,
height = 80,
className = ''
}: DrawingDisplayProps) {
const canvasRef = useRef<HTMLCanvasElement>(null)
useEffect(() => {
const canvas = canvasRef.current
if (!canvas || !drawingData) return
const context = canvas.getContext('2d')
if (!context) return
try {
const strokes: DrawingStroke[] = JSON.parse(drawingData)
// Clear canvas
context.clearRect(0, 0, canvas.width, canvas.height)
// Set up context for drawing
context.lineCap = 'round'
context.lineJoin = 'round'
// Calculate scaling to fit the drawing in the small canvas
if (strokes.length === 0) return
// Find bounds of the drawing
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity
strokes.forEach(stroke => {
stroke.points.forEach(point => {
minX = Math.min(minX, point.x)
minY = Math.min(minY, point.y)
maxX = Math.max(maxX, point.x)
maxY = Math.max(maxY, point.y)
})
})
// Add padding
const padding = 10
const drawingWidth = maxX - minX + padding * 2
const drawingHeight = maxY - minY + padding * 2
// Calculate scale to fit in canvas
const scaleX = canvas.width / drawingWidth
const scaleY = canvas.height / drawingHeight
const scale = Math.min(scaleX, scaleY, 1) // Don't scale up
// Center the drawing
const offsetX = (canvas.width - drawingWidth * scale) / 2 - (minX - padding) * scale
const offsetY = (canvas.height - drawingHeight * scale) / 2 - (minY - padding) * scale
// Draw each stroke
strokes.forEach(stroke => {
if (stroke.points.length === 0) return
context.beginPath()
context.strokeStyle = stroke.color
context.lineWidth = Math.max(1, stroke.thickness * scale) // Ensure minimum line width
const firstPoint = stroke.points[0]
context.moveTo(
firstPoint.x * scale + offsetX,
firstPoint.y * scale + offsetY
)
stroke.points.forEach(point => {
context.lineTo(
point.x * scale + offsetX,
point.y * scale + offsetY
)
})
context.stroke()
})
} catch (error) {
console.warn('Failed to render drawing:', error)
}
}, [drawingData, width, height])
if (!drawingData) {
return null
}
return (
<canvas
ref={canvasRef}
width={width}
height={height}
className={`border-2 border-muted-foreground rounded bg-white ${className}`}
style={{ width: `${width}px`, height: `${height}px` }}
/>
)
}

View File

@@ -0,0 +1,83 @@
'use client'
import { useState } from 'react'
import { Button } from '@/components/ui/button'
import { X } from 'lucide-react'
import DrawingCanvas from './DrawingCanvas'
import { useTranslations } from 'next-intl'
interface DrawingModalProps {
isOpen: boolean
onClose: () => void
onSave: (drawingData: string) => void
initialDrawing?: string
title?: string
}
export default function DrawingModal({
isOpen,
onClose,
onSave,
initialDrawing,
title = 'Drawing'
}: DrawingModalProps) {
const t = useTranslations('DrawingModal')
const [currentDrawing, setCurrentDrawing] = useState<string>(initialDrawing || '')
const handleSave = (drawingData: string) => {
setCurrentDrawing(drawingData)
onSave(drawingData)
onClose()
}
const handleClear = () => {
setCurrentDrawing('')
}
if (!isOpen) return null
return (
<div className="fixed inset-0 z-[100] flex items-center justify-center">
{/* Backdrop */}
<div
className="fixed inset-0 bg-black/50 backdrop-blur-sm"
onClick={onClose}
/>
{/* Modal */}
<div
className="relative bg-white dark:bg-gray-900 rounded-lg shadow-xl max-w-2xl w-full mx-4 max-h-[90vh] overflow-auto"
onClick={(e) => e.stopPropagation()}
>
{/* Header */}
<div className="flex items-center justify-between p-6 border-b">
<h2 className="text-lg font-semibold">{title}</h2>
<Button
variant="ghost"
size="icon"
onClick={onClose}
className="h-6 w-6"
>
<X className="h-4 w-4" />
</Button>
</div>
{/* Content */}
<div className="p-6" onClick={(e) => e.stopPropagation()}>
<DrawingCanvas
initialDrawing={currentDrawing}
onSave={handleSave}
onClear={handleClear}
/>
</div>
{/* Footer */}
<div className="flex justify-end gap-2 p-6 border-t">
<Button variant="outline" onClick={onClose}>
{t('cancelButton')}
</Button>
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,51 @@
'use client'
import { useState } from 'react'
import { Button } from '@/components/ui/button'
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
import { SmilePlus } from 'lucide-react'
import data from '@emoji-mart/data'
import Picker from '@emoji-mart/react'
interface EmojiPickerButtonProps {
onEmojiSelect: (emoji: string) => void
inputIdToFocus?: string // Optional: ID of the input to focus after selection
}
export default function EmojiPickerButton({ onEmojiSelect, inputIdToFocus }: EmojiPickerButtonProps) {
const [isEmojiPickerOpen, setIsEmojiPickerOpen] = useState(false)
return (
<Popover modal={false} open={isEmojiPickerOpen} onOpenChange={setIsEmojiPickerOpen}>
<PopoverTrigger asChild>
<Button
type="button"
variant="ghost"
size="icon"
className="h-8 w-8" // Consistent sizing
>
<SmilePlus className="h-4 w-4" /> {/* Consistent icon size */}
</Button>
</PopoverTrigger>
<PopoverContent
className="w-[300px] p-0"
onCloseAutoFocus={(event) => {
if (inputIdToFocus) {
event.preventDefault();
const input = document.getElementById(inputIdToFocus) as HTMLInputElement;
input?.focus();
}
}}
>
<Picker
data={data}
onEmojiSelect={(emoji: { native: string }) => {
onEmojiSelect(emoji.native);
setIsEmojiPickerOpen(false);
// Focus is handled by onCloseAutoFocus
}}
/>
</PopoverContent>
</Popover>
)
}

View File

@@ -1,49 +1,71 @@
'use client'
import { useState } from 'react'
import CompletionCountBadge from '@/components/CompletionCountBadge'
import { Calendar } from '@/components/ui/calendar'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
import { d2s, getNow, t2d, getCompletedHabitsForDate } from '@/lib/utils'
import { useAtom } from 'jotai'
import { habitsAtom, settingsAtom } from '@/lib/atoms'
import { DateTime } from 'luxon'
import Linkify from './linkify'
import { useHabits } from '@/hooks/useHabits'
import { completedHabitsMapAtom, habitsAtom, hasTasksAtom, settingsAtom } from '@/lib/atoms'
import { Habit } from '@/lib/types'
import { d2s, getCompletionsForDate, getISODate, getNow, isHabitDue } from '@/lib/utils'
import { useAtom } from 'jotai'
import { Circle, CircleCheck } from 'lucide-react'
import { DateTime } from 'luxon'
import { useTranslations } from 'next-intl'
import { useCallback, useMemo, useState } from 'react'
import Linkify from './linkify'
export default function HabitCalendar() {
const t = useTranslations('HabitCalendar')
const { completePastHabit } = useHabits()
const handleCompletePastHabit = useCallback(async (habit: Habit, date: DateTime) => {
try {
await completePastHabit(habit, date)
} catch (error) {
console.error(t('errorCompletingPastHabit'), error)
}
}, [completePastHabit, t])
const [settings] = useAtom(settingsAtom)
const [selectedDate, setSelectedDate] = useState<DateTime>(getNow({ timezone: settings.system.timezone }))
const [selectedDateTime, setSelectedDateTime] = useState<DateTime>(getNow({ timezone: settings.system.timezone }))
const selectedDate = selectedDateTime.toFormat("yyyy-MM-dd")
const [habitsData] = useAtom(habitsAtom)
const [hasTasks] = useAtom(hasTasksAtom)
const habits = habitsData.habits
const getHabitsForDate = (date: Date) => {
return getCompletedHabitsForDate({
habits,
date: DateTime.fromJSDate(date),
timezone: settings.system.timezone
})
}
const [completedHabitsMap] = useAtom(completedHabitsMapAtom)
// Get completed dates for calendar modifiers
const completedDates = useMemo(() => {
return new Set(Array.from(completedHabitsMap.keys()).map(date =>
getISODate({ dateTime: DateTime.fromISO(date), timezone: settings.system.timezone })
))
}, [completedHabitsMap, settings.system.timezone])
return (
<div className="container mx-auto px-4 py-8">
<h1 className="text-3xl font-bold mb-6">Habit Calendar</h1>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div>
<h1 className="text-xl xs:text-3xl font-bold mb-6">{t('title')}</h1>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
<Card>
<CardHeader>
<CardTitle>Calendar</CardTitle>
<CardTitle>{t('calendarCardTitle')}</CardTitle>
</CardHeader>
<CardContent>
<Calendar
mode="single"
selected={selectedDate.toJSDate()}
onSelect={(e) => e && setSelectedDate(DateTime.fromJSDate(e))}
selected={selectedDateTime.toJSDate()}
onSelect={(e) => e && setSelectedDateTime(DateTime.fromJSDate(e))}
weekStartsOn={settings.system.weekStartDay}
className="rounded-md border"
modifiers={{
completed: (date) => getHabitsForDate(date).length > 0,
completed: (date) => completedDates.has(
getISODate({
dateTime: DateTime.fromJSDate(date),
timezone: settings.system.timezone
})!
)
}}
modifiersClassNames={{
completed: 'bg-green-100 text-green-800 font-bold',
completed: 'bg-green-100 dark:bg-green-900/30 text-green-800 dark:text-green-300 font-medium rounded-md',
}}
/>
</CardContent>
@@ -51,32 +73,135 @@ export default function HabitCalendar() {
<Card>
<CardHeader>
<CardTitle>
{selectedDate ? (
<>Habits for {d2s({ dateTime: selectedDate, timezone: settings.system.timezone, format: "yyyy-MM-dd" })}</>
{selectedDateTime ? (
<>{d2s({ dateTime: selectedDateTime, timezone: settings.system.timezone, format: DateTime.DATE_MED_WITH_WEEKDAY })}</>
) : (
'Select a date'
t('selectDatePrompt')
)}
</CardTitle>
</CardHeader>
<CardContent>
{selectedDate && (
<ul className="space-y-2">
{habits.map((habit) => {
const isCompleted = getHabitsForDate(selectedDate.toJSDate()).some((h: Habit) => h.id === habit.id)
return (
<li key={habit.id} className="flex items-center justify-between">
<span>
<Linkify>{habit.name}</Linkify>
</span>
{isCompleted ? (
<Badge variant="default">Completed</Badge>
) : (
<Badge variant="secondary">Not Completed</Badge>
)}
</li>
)
})}
</ul>
{selectedDateTime && (
<div className="space-y-8">
{hasTasks && (
<div className="pt-2 border-t">
<div className="flex items-center justify-between mb-3">
<h3 className="font-medium text-sm text-muted-foreground uppercase tracking-wide">{t('tasksSectionTitle')}</h3>
<CompletionCountBadge type="tasks" date={selectedDate.toString()} />
</div>
<ul className="space-y-3">
{habits
.filter(habit => habit.isTask && isHabitDue({
habit,
timezone: settings.system.timezone,
date: selectedDateTime
}))
.map((habit) => {
const completions = getCompletionsForDate({ habit, date: selectedDateTime, timezone: settings.system.timezone })
const isCompleted = completions >= (habit.targetCompletions || 1)
return (
<li key={habit.id} className="flex items-center justify-between gap-2 px-3 py-2 rounded-lg hover:bg-muted/50 transition-colors">
<span className="flex items-center gap-2">
<Linkify>{habit.name}</Linkify>
</span>
<div className="flex items-center gap-2">
<div className="flex items-center gap-2">
{habit.targetCompletions && (
<span className="text-sm text-muted-foreground">
{completions}/{habit.targetCompletions}
</span>
)}
<button
onClick={() => handleCompletePastHabit(habit, selectedDateTime)}
disabled={isCompleted}
className="relative h-4 w-4 hover:opacity-70 transition-opacity disabled:opacity-100"
>
{isCompleted ? (
<CircleCheck className="h-4 w-4 text-green-500" />
) : (
<div className="relative h-4 w-4">
<Circle className="absolute h-4 w-4 text-muted-foreground" />
<div
className="absolute h-4 w-4 rounded-full overflow-hidden"
style={{
background: `conic-gradient(
currentColor ${(completions / (habit.targetCompletions ?? 1)) * 360}deg,
transparent ${(completions / (habit.targetCompletions ?? 1)) * 360}deg 360deg
)`,
mask: 'radial-gradient(transparent 50%, black 51%)',
WebkitMask: 'radial-gradient(transparent 50%, black 51%)'
}}
/>
</div>
)}
</button>
</div>
</div>
</li>
)
})}
</ul>
</div>
)}
<div>
<div className="flex items-center justify-between mb-3">
<h3 className="font-medium text-sm text-muted-foreground uppercase tracking-wide">{t('habitsSectionTitle')}</h3>
<CompletionCountBadge type="habits" date={selectedDate.toString()} />
</div>
<ul className="space-y-3">
{habits
.filter(habit => !habit.isTask && isHabitDue({
habit,
timezone: settings.system.timezone,
date: selectedDateTime
}))
.map((habit) => {
const completions = getCompletionsForDate({ habit, date: selectedDateTime, timezone: settings.system.timezone })
const isCompleted = completions >= (habit.targetCompletions || 1)
return (
<li key={habit.id} className="flex items-center justify-between gap-2 px-3 py-2 rounded-lg hover:bg-muted/50 transition-colors">
<span className="flex items-center gap-2">
<Linkify>{habit.name}</Linkify>
</span>
<div className="flex items-center gap-2">
<div className="flex items-center gap-2">
{habit.targetCompletions && (
<span className="text-sm text-muted-foreground">
{completions}/{habit.targetCompletions}
</span>
)}
<button
onClick={() => handleCompletePastHabit(habit, selectedDateTime)}
disabled={isCompleted}
className="relative h-4 w-4 hover:opacity-70 transition-opacity disabled:opacity-100"
>
{isCompleted ? (
<CircleCheck className="h-4 w-4 text-green-500" />
) : (
<div className="relative h-4 w-4">
<Circle className="absolute h-4 w-4 text-muted-foreground" />
<div
className="absolute h-4 w-4 rounded-full overflow-hidden"
style={{
background: `conic-gradient(
currentColor ${(completions / (habit.targetCompletions ?? 1)) * 360}deg,
transparent ${(completions / (habit.targetCompletions ?? 1)) * 360}deg 360deg
)`,
mask: 'radial-gradient(transparent 50%, black 51%)',
WebkitMask: 'radial-gradient(transparent 50%, black 51%)'
}}
/>
</div>
)}
</button>
</div>
</div>
</li>
)
})}
</ul>
</div>
</div>
)}
</CardContent>
</Card>

View File

@@ -0,0 +1,158 @@
import { Habit } from '@/lib/types';
import { useHabits } from '@/hooks/useHabits';
import { useAtom } from 'jotai';
import { pomodoroAtom, settingsAtom, currentUserAtom } from '@/lib/atoms';
import { d2t, getNow, isHabitDueToday, hasPermission } from '@/lib/utils';
import { DropdownMenuItem, DropdownMenuSeparator } from '@/components/ui/dropdown-menu';
import { ContextMenuItem, ContextMenuSeparator } from '@/components/ui/context-menu';
import { Timer, Calendar, Pin, Edit, Archive, ArchiveRestore, Trash2 } from 'lucide-react';
import { useTranslations } from 'next-intl';
interface HabitContextMenuItemsProps {
habit: Habit;
onEditRequest: () => void;
onDeleteRequest: () => void;
context?: 'daily-overview' | 'habit-item';
onClose?: () => void; // Optional: To close the dropdown if an action is taken
}
export function HabitContextMenuItems({
habit,
onEditRequest,
onDeleteRequest,
context = 'habit-item',
onClose,
}: HabitContextMenuItemsProps) {
const t = useTranslations('HabitContextMenuItems');
const { saveHabit, archiveHabit, unarchiveHabit } = useHabits();
const [settings] = useAtom(settingsAtom);
const [, setPomo] = useAtom(pomodoroAtom);
const [currentUser] = useAtom(currentUserAtom);
const canWrite = hasPermission(currentUser, 'habit', 'write'); // For UI disabling if not handled by useHabits' actions
const canInteract = hasPermission(currentUser, 'habit', 'interact');
const MenuItemComponent = context === 'daily-overview' ? ContextMenuItem : DropdownMenuItem;
const MenuSeparatorComponent = context === 'daily-overview' ? ContextMenuSeparator : DropdownMenuSeparator;
const taskIsDueToday = habit.isTask ? isHabitDueToday({ habit, timezone: settings.system.timezone }) : false;
const handleAction = (action: () => void) => {
action();
onClose?.();
};
return (
<>
{!habit.archived && (
<MenuItemComponent
disabled={!canInteract}
onClick={() => handleAction(() => {
setPomo((prev) => ({
...prev,
show: true,
selectedHabitId: habit.id,
}));
})}
>
<Timer className="mr-2 h-4 w-4" />
<span>{t('startPomodoro')}</span>
</MenuItemComponent>
)}
{/* "Move to Today" option: Show if task is not due today */}
{habit.isTask && !habit.archived && !taskIsDueToday && (
<MenuItemComponent
disabled={!canWrite}
onClick={() => handleAction(() => {
const today = getNow({ timezone: settings.system.timezone });
saveHabit({ ...habit, frequency: d2t({ dateTime: today }) });
})}
>
<Calendar className="mr-2 h-4 w-4" />
<span>{t('moveToToday')}</span>
</MenuItemComponent>
)}
{/* "Move to Tomorrow" option: Show if task is due today OR not due today */}
{habit.isTask && !habit.archived && (
<MenuItemComponent
disabled={!canWrite}
onClick={() => handleAction(() => {
const tomorrow = getNow({ timezone: settings.system.timezone }).plus({ days: 1 });
saveHabit({ ...habit, frequency: d2t({ dateTime: tomorrow }) });
})}
>
<Calendar className="mr-2 h-4 w-4" />
<span>{t('moveToTomorrow')}</span>
</MenuItemComponent>
)}
{!habit.archived && (
<MenuItemComponent
disabled={!canWrite}
onClick={() => handleAction(() => saveHabit({ ...habit, pinned: !habit.pinned }))}
>
<Pin className="mr-2 h-4 w-4" />
<span>{t(habit.pinned ? 'unpin' : 'pin')}</span>
</MenuItemComponent>
)}
{context === 'habit-item' && !habit.archived && ( // Edit button visible in dropdown only for habit-item context on small screens
<MenuItemComponent
onClick={() => handleAction(onEditRequest)}
className="sm:hidden" // Kept the sm:hidden for HabitItem specific responsive behavior
disabled={!canWrite}
>
<Edit className="mr-2 h-4 w-4" />
<span>{t('edit')}</span>
</MenuItemComponent>
)}
{context === 'daily-overview' && !habit.archived && ( // Edit button always visible in dropdown for daily-overview context
<MenuItemComponent
onClick={() => handleAction(onEditRequest)}
disabled={!canWrite}
>
<Edit className="mr-2 h-4 w-4" />
<span>{t('edit')}</span>
</MenuItemComponent>
)}
{!habit.archived && (
<MenuItemComponent
disabled={!canWrite}
onClick={() => handleAction(() => archiveHabit(habit.id))}
>
<Archive className="mr-2 h-4 w-4" />
<span>{t('archive')}</span>
</MenuItemComponent>
)}
{habit.archived && (
<MenuItemComponent
disabled={!canWrite}
onClick={() => handleAction(() => unarchiveHabit(habit.id))}
>
<ArchiveRestore className="mr-2 h-4 w-4" />
<span>{t('unarchive')}</span>
</MenuItemComponent>
)}
{context === 'habit-item' && !habit.archived && <MenuSeparatorComponent className="sm:hidden" />}
{(context === 'daily-overview' || habit.archived) && <MenuSeparatorComponent />}
<MenuItemComponent
onClick={() => handleAction(onDeleteRequest)}
className="text-red-600 focus:text-red-600 dark:text-red-400 dark:focus:text-red-400"
disabled={!canWrite} // Assuming delete is a write operation
>
<Trash2 className="mr-2 h-4 w-4" />
<span>{t('delete')}</span>
</MenuItemComponent>
</>
);
}

View File

@@ -1,22 +1,22 @@
import { Habit } from '@/lib/types'
import { useAtom } from 'jotai'
import { settingsAtom } from '@/lib/atoms'
import { getTodayInTimezone, isSameDate, t2d, d2t, getNow, parseNaturalLanguageRRule, parseRRule } from '@/lib/utils'
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from '@/components/ui/card'
import ReactMarkdown from 'react-markdown'
import { Button } from '@/components/ui/button'
import { Coins, Edit, Trash2, Check, Undo2, MoreVertical } from 'lucide-react'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu'
import { useEffect, useState } from 'react'
import { useHabits } from '@/hooks/useHabits'
import { RRule } from 'rrule'
import { INITIAL_RECURRENCE_RULE } from '@/lib/constants'
import { currentUserAtom, settingsAtom, usersAtom } from '@/lib/atoms'
import { Habit, User } from '@/lib/types'
import { convertMachineReadableFrequencyToHumanReadable, getCompletionsForToday, hasPermission, isTaskOverdue } from '@/lib/utils'
import { useAtom } from 'jotai'
import { Check, Coins, Edit, MoreVertical, Pin, Undo2 } from 'lucide-react'
import { useTranslations } from 'next-intl'
import { usePathname } from 'next/navigation'
import { useEffect, useState } from 'react'
import DrawingDisplay from './DrawingDisplay'
import { HabitContextMenuItems } from './HabitContextMenuItems'
import { Avatar, AvatarFallback, AvatarImage } from './ui/avatar'
import { Button } from './ui/button'
interface HabitItemProps {
habit: Habit
@@ -24,16 +24,39 @@ interface HabitItemProps {
onDelete: () => void
}
const renderUserAvatars = (habit: Habit, currentUser: User | null, usersData: { users: User[] }) => {
if (!habit.userIds || habit.userIds.length <= 1) return <></>;
return (
<div className="flex -space-x-2 ml-2 flex-shrink-0">
{habit.userIds?.filter((u) => u !== currentUser?.id).map(userId => {
const user = usersData.users.find(u => u.id === userId)
if (!user) return <></>;
return (
<Avatar key={user.id} className="h-6 w-6">
<AvatarImage src={user?.avatarPath && `/api/avatars/${user.avatarPath.split('/').pop()}` || ""} />
<AvatarFallback>{user.username[0]}</AvatarFallback>
</Avatar>
)
})}
</div>
);
};
export default function HabitItem({ habit, onEdit, onDelete }: HabitItemProps) {
const { completeHabit, undoComplete } = useHabits()
const [settings] = useAtom(settingsAtom)
const today = getTodayInTimezone(settings.system.timezone)
const completionsToday = habit.completions?.filter(completion =>
isSameDate(t2d({ timestamp: completion, timezone: settings.system.timezone }), t2d({ timestamp: d2t({ dateTime: getNow({ timezone: settings.system.timezone }) }), timezone: settings.system.timezone }))
).length || 0
const completionsToday = getCompletionsForToday({ habit, timezone: settings.system.timezone })
const target = habit.targetCompletions || 1
const isCompletedToday = completionsToday >= target
const [isHighlighted, setIsHighlighted] = useState(false)
const t = useTranslations('HabitItem');
const [usersData] = useAtom(usersAtom)
const pathname = usePathname();
const [currentUser] = useAtom(currentUserAtom)
const canWrite = hasPermission(currentUser, 'habit', 'write')
const canInteract = hasPermission(currentUser, 'habit', 'interact')
useEffect(() => {
const params = new URLSearchParams(window.location.search)
@@ -57,51 +80,90 @@ export default function HabitItem({ habit, onEdit, onDelete }: HabitItemProps) {
return (
<Card
id={`habit-${habit.id}`}
className={`h-full flex flex-col transition-all duration-500 ${isHighlighted ? 'bg-yellow-100 dark:bg-yellow-900' : ''}`}
className={`h-full flex flex-col transition-all duration-500 ${isHighlighted ? 'bg-yellow-100 dark:bg-yellow-900' : ''} ${habit.archived ? 'opacity-75' : ''}`}
>
<CardHeader className="flex-none">
<CardTitle className="line-clamp-1">{habit.name}</CardTitle>
{habit.description && (
<CardDescription className="whitespace-pre-line">
{habit.description}
</CardDescription>
<CardHeader className="flex-shrink-0">
<div className="flex justify-between items-start">
<CardTitle className={`line-clamp-1 ${habit.archived ? 'text-gray-400 dark:text-gray-500' : ''} flex items-center ${pathname.includes("tasks") ? 'w-full' : ''} justify-between`}>
<div className="flex items-center gap-1">
{habit.pinned && (
<Pin className="h-4 w-4 text-yellow-500" />
)}
<span>{habit.name}</span>
</div>
{isTaskOverdue(habit, settings.system.timezone) && (
<span className="ml-2 inline-flex items-center rounded-md bg-red-50 dark:bg-red-900/30 px-2 py-1 text-xs font-medium text-red-700 dark:text-red-400 ring-1 ring-inset ring-red-600/10 dark:ring-red-500/20">
{t('overdue')}
</span>
)}
</CardTitle>
{renderUserAvatars(habit, currentUser as User, usersData)}
</div>
{(habit.description || habit.drawing) && (
<div className={`flex gap-4 mt-2 ${!habit.description ? 'justify-end' : ''}`}>
{habit.description && (
<CardDescription className={`whitespace-pre-line flex-1 min-w-0 break-words ${habit.archived ? 'text-gray-400 dark:text-gray-500' : ''}`}>
{habit.description}
</CardDescription>
)}
{habit.drawing && (
<div className="flex-shrink-0">
<DrawingDisplay
drawingData={habit.drawing}
width={120}
height={80}
className=""
/>
</div>
)}
</div>
)}
</CardHeader>
<CardContent className="flex-1">
<p className="text-sm text-gray-500">Frequency: {parseRRule(habit.frequency || INITIAL_RECURRENCE_RULE).toText()}</p>
<div className="flex items-center mt-2">
<Coins className="h-4 w-4 text-yellow-400 mr-1" />
<span className="text-sm font-medium">{habit.coinReward} coins per completion</span>
<CardContent className="flex-grow flex flex-col justify-end">
<div className="mt-auto">
<p className={`text-sm ${habit.archived ? 'text-gray-400 dark:text-gray-500' : 'text-gray-500'}`}>
{t('whenLabel', {
frequency: convertMachineReadableFrequencyToHumanReadable({
frequency: habit.frequency,
isRecurRule: pathname.includes("habits"),
timezone: settings.system.timezone
})
})}
</p>
<div className="flex items-center mt-2">
<Coins className={`h-4 w-4 mr-1 ${habit.archived ? 'text-gray-400 dark:text-gray-500' : 'text-yellow-400'}`} />
<span className={`text-sm font-medium ${habit.archived ? 'text-gray-400 dark:text-gray-500' : ''}`}>{t('coinsPerCompletion', { count: habit.coinReward })}</span>
</div>
</div>
</CardContent>
<CardFooter className="flex justify-between gap-2">
<CardFooter className="flex-shrink-0 flex justify-between gap-2">
<div className="flex gap-2">
<div className="relative">
<Button
variant={isCompletedToday ? "secondary" : "default"}
size="sm"
onClick={async () => await completeHabit(habit)}
disabled={isCompletedToday && completionsToday >= target}
className="overflow-hidden w-24 sm:w-auto"
disabled={!canInteract || habit.archived || (isCompletedToday && completionsToday >= target)}
className={`overflow-hidden w-24 sm:w-auto ${habit.archived ? 'cursor-not-allowed' : ''}`}
>
<Check className="h-4 w-4 sm:mr-2" />
<span>
{isCompletedToday ? (
target > 1 ? (
<>
<span className="sm:hidden">{completionsToday}/{target}</span>
<span className="hidden sm:inline">Completed ({completionsToday}/{target})</span>
<span className="sm:hidden">{t('completedStatusCountMobile', { completed: completionsToday, target })}</span>
<span className="hidden sm:inline">{t('completedStatusCount', { completed: completionsToday, target })}</span>
</>
) : (
'Completed'
t('completedStatus')
)
) : (
target > 1 ? (
<>
<span className="sm:hidden">{completionsToday}/{target}</span>
<span className="hidden sm:inline">Complete ({completionsToday}/{target})</span>
<span className="sm:hidden">{t('completeButtonCountMobile', { completed: completionsToday, target })}</span>
<span className="hidden sm:inline">{t('completeButtonCount', { completed: completionsToday, target })}</span>
</>
) : 'Complete'
) : t('completeButton')
)}
</span>
{habit.targetCompletions && habit.targetCompletions > 1 && (
@@ -114,47 +176,45 @@ export default function HabitItem({ habit, onEdit, onDelete }: HabitItemProps) {
)}
</Button>
</div>
{completionsToday > 0 && (
{completionsToday > 0 && !habit.archived && (
<Button
variant="outline"
size="sm"
onClick={async () => await undoComplete(habit)}
disabled={!canWrite}
className="w-10 sm:w-auto"
>
<Undo2 className="h-4 w-4" />
<span className="hidden sm:inline ml-2">Undo</span>
<span className="hidden sm:inline ml-2">{t('undoButton')}</span>
</Button>
)}
</div>
<div className="flex gap-2">
<Button
variant="edit"
size="sm"
onClick={onEdit}
className="hidden sm:flex"
>
<Edit className="h-4 w-4" />
<span className="ml-2">Edit</span>
</Button>
<DropdownMenu>
{!habit.archived && (
<Button
variant="edit"
size="sm"
onClick={onEdit}
disabled={!canWrite}
className="hidden sm:flex"
>
<Edit className="h-4 w-4" />
<span className="ml-2">{t('editButton')}</span>
</Button>
)}
<DropdownMenu modal={false}>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="sm" className="h-8 w-8 p-0">
<MoreVertical className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={onEdit} className="sm:hidden">
<Edit className="mr-2 h-4 w-4" />
Edit
</DropdownMenuItem>
<DropdownMenuSeparator className="sm:hidden" />
<DropdownMenuItem
className="text-red-600 focus:text-red-600 dark:text-red-400 dark:focus:text-red-400 cursor-pointer"
onClick={onDelete}
>
<Trash2 className="mr-2 h-4 w-4" />
Delete
</DropdownMenuItem>
<HabitContextMenuItems
habit={habit}
onEditRequest={onEdit}
onDeleteRequest={onDelete}
context="habit-item"
/>
</DropdownMenuContent>
</DropdownMenu>
</div>
@@ -162,4 +222,3 @@ export default function HabitItem({ habit, onEdit, onDelete }: HabitItemProps) {
</Card>
)
}

View File

@@ -1,23 +1,117 @@
'use client'
import { useState } from 'react'
import { Plus, ListTodo } from 'lucide-react'
import { useAtom } from 'jotai'
import { habitsAtom, settingsAtom } from '@/lib/atoms'
import EmptyState from './EmptyState'
import { Button } from '@/components/ui/button'
import HabitItem from './HabitItem'
import { Input } from '@/components/ui/input'; // Added
import { Label } from '@/components/ui/label'; // Added
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; // Added
import { useHabits } from '@/hooks/useHabits'
import { habitsAtom } from '@/lib/atoms'
import { HabitIcon, TaskIcon } from '@/lib/constants'
import { Habit } from '@/lib/types'
import { getHabitFreq } from '@/lib/utils'; // Added
import { useAtom } from 'jotai'
import { ArrowDownWideNarrow, ArrowUpNarrowWide, Plus, Search } from 'lucide-react'; // Added sort icons, Search icon
import { DateTime } from 'luxon'; // Added
import { useTranslations } from 'next-intl'
import { useEffect, useMemo, useState } from 'react'; // Added useMemo, useEffect
import AddEditHabitModal from './AddEditHabitModal'
import ConfirmDialog from './ConfirmDialog'
import { Habit } from '@/lib/types'
import { useHabits } from '@/hooks/useHabits'
import EmptyState from './EmptyState'
import HabitItem from './HabitItem'
export default function HabitList() {
export default function HabitList({ isTasksView }: { isTasksView: boolean}) {
const t = useTranslations('HabitList');
const { saveHabit, deleteHabit } = useHabits()
const [habitsData, setHabitsData] = useAtom(habitsAtom)
const habits = habitsData.habits
const [settings] = useAtom(settingsAtom)
const [isModalOpen, setIsModalOpen] = useState(false)
const [habitsData] = useAtom(habitsAtom) // setHabitsData removed as it's not used
type SortableField = 'name' | 'coinReward' | 'dueDate' | 'frequency';
type SortOrder = 'asc' | 'desc';
const [searchTerm, setSearchTerm] = useState('');
const [sortBy, setSortBy] = useState<SortableField>('name');
const [sortOrder, setSortOrder] = useState<SortOrder>('asc');
useEffect(() => {
if (isTasksView && sortBy === 'frequency') {
setSortBy('name');
} else if (!isTasksView && sortBy === 'dueDate') {
setSortBy('name');
}
}, [isTasksView, sortBy]);
const compareHabits = useMemo(() => {
return (a: Habit, b: Habit, currentSortBy: SortableField, currentSortOrder: SortOrder, tasksView: boolean): number => {
let comparison = 0;
switch (currentSortBy) {
case 'name':
comparison = a.name.localeCompare(b.name);
break;
case 'coinReward':
comparison = a.coinReward - b.coinReward;
break;
case 'dueDate':
if (tasksView && a.isTask && b.isTask) {
const dateA = DateTime.fromISO(a.frequency);
const dateB = DateTime.fromISO(b.frequency);
if (dateA.isValid && dateB.isValid) comparison = dateA.toMillis() - dateB.toMillis();
else if (dateA.isValid) comparison = -1; // Valid dates first
else if (dateB.isValid) comparison = 1;
// If both invalid, comparison remains 0
}
break;
case 'frequency':
if (!tasksView && !a.isTask && !b.isTask) {
const freqOrder = ['daily', 'weekly', 'monthly', 'yearly'];
const freqAVal = getHabitFreq(a);
const freqBVal = getHabitFreq(b);
comparison = freqOrder.indexOf(freqAVal) - freqOrder.indexOf(freqBVal);
}
break;
}
return currentSortOrder === 'asc' ? comparison : -comparison;
};
}, []);
const allHabitsInView = useMemo(() => {
return habitsData.habits.filter(habit =>
isTasksView ? habit.isTask : !habit.isTask
);
}, [habitsData.habits, isTasksView]);
const searchedHabits = useMemo(() => {
if (!searchTerm.trim()) {
return allHabitsInView;
}
const lowercasedSearchTerm = searchTerm.toLowerCase();
return allHabitsInView.filter(habit =>
habit.name.toLowerCase().includes(lowercasedSearchTerm) ||
(habit.description && habit.description.toLowerCase().includes(lowercasedSearchTerm))
);
}, [allHabitsInView, searchTerm]);
const activeHabits = useMemo(() => {
return searchedHabits
.filter(h => !h.archived)
.sort((a, b) => {
if (a.pinned && !b.pinned) return -1;
if (!a.pinned && b.pinned) return 1;
// For items in the same pinned group (both pinned or both not pinned), apply general sort
return compareHabits(a, b, sortBy, sortOrder, isTasksView);
});
}, [searchedHabits, sortBy, sortOrder, isTasksView, compareHabits]);
const archivedHabits = useMemo(() => {
return searchedHabits
.filter(h => h.archived)
.sort((a, b) => compareHabits(a, b, sortBy, sortOrder, isTasksView));
}, [searchedHabits, sortBy, sortOrder, isTasksView, compareHabits]);
const [modalConfig, setModalConfig] = useState<{
isOpen: boolean,
isTask: boolean
}>({
isOpen: false,
isTask: false
})
const [editingHabit, setEditingHabit] = useState<Habit | null>(null)
const [deleteConfirmation, setDeleteConfirmation] = useState<{ isOpen: boolean, habitId: string | null }>({
isOpen: false,
@@ -26,48 +120,113 @@ export default function HabitList() {
return (
<div className="container mx-auto px-4 py-8">
<div>
<div className="flex justify-between items-center mb-6">
<h1 className="text-3xl font-bold">My Habits</h1>
<Button onClick={() => setIsModalOpen(true)}>
<Plus className="mr-2 h-4 w-4" /> Add Habit
</Button>
<h1 className="text-xl xs:text-3xl font-bold">
{t(isTasksView ? 'myTasks' : 'myHabits')}
</h1>
<span>
<Button onClick={() => setModalConfig({ isOpen: true, isTask: isTasksView })}>
<Plus className='mr-2 h-4 w-4' />{isTasksView ? t("addTaskButton") : t("addHabitButton")}
</Button>
</span>
</div>
{/* Search and Sort Controls */}
<div className="flex flex-col sm:flex-row items-center gap-4 my-4">
<div className="relative flex-grow w-full sm:w-auto">
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<Search className="h-5 w-5 text-muted-foreground" />
</div>
<Input
type="search"
placeholder={t(isTasksView ? 'searchTasksPlaceholder' : 'searchHabitsPlaceholder')}
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="pl-10 w-full"
/>
</div>
<div className="flex items-center gap-2 self-start sm:self-center w-full sm:w-auto">
<Label htmlFor="sort-by" className="text-sm font-medium whitespace-nowrap sr-only sm:not-sr-only">{t('sortByLabel')}</Label>
<Select value={sortBy} onValueChange={(value) => setSortBy(value as SortableField)}>
<SelectTrigger id="sort-by" className="w-full sm:w-[180px]">
<SelectValue placeholder={t('sortByLabel')} />
</SelectTrigger>
<SelectContent>
<SelectItem value="name">{t('sortByName')}</SelectItem>
<SelectItem value="coinReward">{t('sortByCoinReward')}</SelectItem>
{isTasksView && <SelectItem value="dueDate">{t('sortByDueDate')}</SelectItem>}
{!isTasksView && <SelectItem value="frequency">{t('sortByFrequency')}</SelectItem>}
</SelectContent>
</Select>
<Button variant="outline" size="icon" onClick={() => setSortOrder(prev => prev === 'asc' ? 'desc' : 'asc')}>
{sortOrder === 'asc' ? <ArrowUpNarrowWide className="h-4 w-4" /> : <ArrowDownWideNarrow className="h-4 w-4" />}
<span className="sr-only">{t('toggleSortOrderAriaLabel')}</span>
</Button>
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 items-stretch">
{habits.length === 0 ? (
{activeHabits.length === 0 && searchTerm.trim() ? (
<div className="col-span-2 text-center text-muted-foreground py-8">
{t(isTasksView ? 'noTasksFoundMessage' : 'noHabitsFoundMessage')}
</div>
) : activeHabits.length === 0 ? (
<div className="col-span-2">
<EmptyState
icon={ListTodo}
title="No habits yet"
description="Create your first habit to start tracking your progress"
icon={isTasksView ? TaskIcon : HabitIcon}
title={t(isTasksView ? 'emptyStateTasksTitle' : 'emptyStateHabitsTitle')}
description={t(isTasksView ? 'emptyStateTasksDescription' : 'emptyStateHabitsDescription')}
/>
</div>
) : (
habits.map((habit) => (
activeHabits.map((habit: Habit) => (
<HabitItem
key={habit.id}
habit={habit}
onEdit={() => {
setEditingHabit(habit)
setIsModalOpen(true)
setModalConfig({ isOpen: true, isTask: isTasksView })
}}
onDelete={() => setDeleteConfirmation({ isOpen: true, habitId: habit.id })}
/>
))
)}
{archivedHabits.length > 0 && (
<>
<div className="col-span-1 sm:col-span-2 relative flex items-center my-6">
<div className="flex-grow border-t border-gray-300 dark:border-gray-600" />
<span className="mx-4 text-sm text-gray-500 dark:text-gray-400">{t('archivedSectionTitle')}</span>
<div className="flex-grow border-t border-gray-300 dark:border-gray-600" />
</div>
{archivedHabits.map((habit: Habit) => (
<HabitItem
key={habit.id}
habit={habit}
onEdit={() => {
setEditingHabit(habit)
setModalConfig({ isOpen: true, isTask: isTasksView })
}}
onDelete={() => setDeleteConfirmation({ isOpen: true, habitId: habit.id })}
/>
))}
</>
)}
</div>
{isModalOpen &&
{modalConfig.isOpen &&
<AddEditHabitModal
onClose={() => {
setIsModalOpen(false)
setModalConfig({ isOpen: false, isTask: false })
setEditingHabit(null)
}}
onSave={async (habit) => {
await saveHabit({ ...habit, id: editingHabit?.id })
setIsModalOpen(false)
await saveHabit({ ...habit, id: editingHabit?.id, isTask: modalConfig.isTask })
setModalConfig({ isOpen: false, isTask: false })
setEditingHabit(null)
}}
habit={editingHabit}
isTask={modalConfig.isTask}
/>
}
<ConfirmDialog
@@ -79,9 +238,9 @@ export default function HabitList() {
}
setDeleteConfirmation({ isOpen: false, habitId: null })
}}
title="Delete Habit"
message="Are you sure you want to delete this habit? This action cannot be undone."
confirmText="Delete"
title={t(isTasksView ? 'deleteTaskDialogTitle' : 'deleteHabitDialogTitle')}
message={t(isTasksView ? 'deleteTaskDialogMessage' : 'deleteHabitDialogMessage')}
confirmText={t('deleteButton')}
/>
</div>
)

View File

@@ -1,18 +1,24 @@
'use client'
import { Habit } from '@/lib/types'
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
import { d2s, getNow, t2d, getCompletedHabitsForDate } from '@/lib/utils'
import { LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer } from 'recharts'
import { useAtom } from 'jotai'
import { settingsAtom } from '@/lib/atoms'
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { completedHabitsMapAtom, hasTasksAtom, settingsAtom } from '@/lib/atoms'; // Added completedHabitsMapAtom
import { Habit } from '@/lib/types';
import { d2s, getNow } from '@/lib/utils'; // Removed getCompletedHabitsForDate
import { useAtom } from 'jotai';
import { useTranslations } from 'next-intl';
import { CartesianGrid, Line, LineChart, ResponsiveContainer, Tooltip, XAxis, YAxis } from 'recharts';
interface HabitStreakProps {
habits: Habit[]
}
export default function HabitStreak({ habits }: HabitStreakProps) {
const t = useTranslations('HabitStreak');
const [settings] = useAtom(settingsAtom)
const [hasTasks] = useAtom(hasTasksAtom)
const [completedHabitsMap] = useAtom(completedHabitsMapAtom) // Use the atom
// Get the last 7 days of data
const dates = Array.from({ length: 7 }, (_, i) => {
const d = getNow({ timezone: settings.system.timezone });
@@ -20,21 +26,24 @@ export default function HabitStreak({ habits }: HabitStreakProps) {
}).reverse()
const completions = dates.map(date => {
const completedCount = getCompletedHabitsForDate({
habits,
date: t2d({ timestamp: date, timezone: settings.system.timezone }),
timezone: settings.system.timezone
}).length;
// Get completed habits for the date from the map
const completedOnDate = completedHabitsMap.get(date) || [];
// Filter the completed list to count habits and tasks
const completedHabitsCount = completedOnDate.filter(h => !h.isTask).length;
const completedTasksCount = completedOnDate.filter(h => h.isTask).length;
return {
date,
completed: completedCount
habits: completedHabitsCount,
tasks: completedTasksCount
};
});
return (
<Card>
<CardHeader>
<CardTitle>Daily Habit Completion Streak</CardTitle>
<CardTitle>{t('dailyCompletionStreakTitle')}</CardTitle>
</CardHeader>
<CardContent>
<div className="w-full aspect-[2/1]">
@@ -50,15 +59,29 @@ export default function HabitStreak({ habits }: HabitStreakProps) {
>
<CartesianGrid strokeDasharray="3 3" />
<XAxis dataKey="date" />
<YAxis />
<Tooltip formatter={(value) => [`${value} habits`, 'Completed']} />
<YAxis allowDecimals={false} />
<Tooltip formatter={(value, name) => {
const translatedName = name === 'habits' ? t('tooltipHabitsLabel') : t('tooltipTasksLabel');
return [`${value} ${translatedName}`, t('tooltipCompletedLabel')];
}} />
<Line
type="monotone"
dataKey="completed"
name={t('tooltipHabitsLabel')}
dataKey="habits"
stroke="#14b8a6"
strokeWidth={2}
dot={false}
/>
{hasTasks && (
<Line
type="monotone"
name={t('tooltipTasksLabel')}
dataKey="tasks"
stroke="#f59e0b"
strokeWidth={2}
dot={false}
/>
)}
</LineChart>
</ResponsiveContainer>
</div>

View File

@@ -1,34 +1,13 @@
'use client'
import { useState } from 'react'
import { useAtom } from 'jotai'
import { coinsAtom, settingsAtom } from '@/lib/atoms'
import { useCoins } from '@/hooks/useCoins'
import { FormattedNumber } from '@/components/FormattedNumber'
import { Bell, Menu, Settings, User, Info, Coins } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { Logo } from '@/components/Logo'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu'
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'
import AboutModal from './AboutModal'
import Link from 'next/link'
import dynamic from 'next/dynamic'
import HeaderActions from './HeaderActions'
interface HeaderProps {
className?: string
}
const TodayEarnedCoins = dynamic(() => import('./TodayEarnedCoins'), { ssr: false })
export default function Header({ className }: HeaderProps) {
const [showAbout, setShowAbout] = useState(false)
const [settings] = useAtom(settingsAtom)
const [coins] = useAtom(coinsAtom)
return (
<>
<header className={`border-b bg-white dark:bg-gray-800 shadow-sm ${className || ''}`}>
@@ -37,62 +16,10 @@ export default function Header({ className }: HeaderProps) {
<Link href="/" className="mr-3 sm:mr-4">
<Logo />
</Link>
<div className="flex items-center gap-1 sm:gap-2">
<Link href="/coins" className="flex items-center gap-1 sm:gap-2 px-3 py-1.5 bg-white hover:bg-gray-50 dark:bg-gray-700 dark:hover:bg-gray-600 rounded-full transition-colors border border-gray-200 dark:border-gray-600">
<Coins className="h-5 w-5 text-yellow-500 dark:text-yellow-400" />
<div className="flex items-baseline gap-1 sm:gap-2">
<FormattedNumber
amount={coins.balance}
settings={settings}
className="text-gray-800 dark:text-gray-100 font-medium text-lg"
/>
<div className="hidden sm:block">
<TodayEarnedCoins />
</div>
</div>
</Link>
<Button variant="ghost" size="icon" aria-label="Notifications">
<Bell className="h-5 w-5" />
</Button>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" className="flex items-center gap-2">
{/* <Menu className="h-5 w-5" /> */}
<Avatar className="h-8 w-8">
<AvatarImage src={settings?.profile?.avatarPath ? `/api/avatars/${settings.profile.avatarPath.split('/').pop()}` : '/avatars/default.png'} />
<AvatarFallback>
<User className="h-4 w-4" />
</AvatarFallback>
</Avatar>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-56 p-2">
<DropdownMenuItem className="cursor-pointer px-3 py-2" asChild>
<Link
href="/settings"
aria-label='settings'
className="flex items-center w-full gap-2"
>
<Settings className="h-4 w-4" />
<span>Settings</span>
</Link>
</DropdownMenuItem>
<DropdownMenuItem className="cursor-pointer px-3 py-2" asChild>
<button
onClick={() => setShowAbout(true)}
className="flex items-center w-full gap-2"
>
<Info className="h-4 w-4" />
<span>About</span>
</button>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
<HeaderActions />
</div>
</div>
</header>
<AboutModal isOpen={showAbout} onClose={() => setShowAbout(false)} />
</>
)
}

View File

@@ -0,0 +1,38 @@
'use client'
import Link from 'next/link'
import { useAtom } from 'jotai'
import { settingsAtom } from '@/lib/atoms'
import { useCoins } from '@/hooks/useCoins'
import { FormattedNumber } from '@/components/FormattedNumber'
import { Coins } from 'lucide-react'
import NotificationBell from './NotificationBell'
import dynamic from 'next/dynamic'
import { Profile } from './Profile'
const TodayEarnedCoins = dynamic(() => import('./TodayEarnedCoins'), { ssr: false })
export default function HeaderActions() {
const [settings] = useAtom(settingsAtom)
const { balance } = useCoins()
return (
<div className="flex items-center gap-1 sm:gap-2">
<Link href="/coins" className="flex items-center gap-1 sm:gap-2 px-3 py-1.5 bg-white hover:bg-gray-50 dark:bg-gray-700 dark:hover:bg-gray-600 rounded-full transition-colors border border-gray-200 dark:border-gray-600">
<Coins className="h-5 w-5 text-yellow-500 dark:text-yellow-400" />
<div className="flex items-baseline gap-1 sm:gap-2">
<FormattedNumber
amount={balance}
settings={settings}
className="text-gray-800 dark:text-gray-100 font-medium text-lg"
/>
<div className="hidden sm:block">
<TodayEarnedCoins />
</div>
</div>
</Link>
<NotificationBell />
<Profile />
</div>
)
}

View File

@@ -1,20 +1,26 @@
import ClientWrapper from './ClientWrapper'
import Header from './Header'
import Navigation from './Navigation'
import PermissionError from './PermissionError'
export default function Layout({ children }: { children: React.ReactNode }) {
return (
<div className="flex flex-col h-screen bg-gray-100 dark:bg-gray-900 overflow-hidden">
<Header className="sticky top-0 z-50" />
<div className="flex flex-1 overflow-hidden">
<Navigation viewPort='main' />
<div className="flex-1 flex flex-col">
<main className="flex-1 overflow-x-hidden overflow-y-auto bg-gray-100 dark:bg-gray-900 relative">
<ClientWrapper>
{children}
</ClientWrapper>
<Header className="sticky top-0 z-50" />
<div className="flex flex-1 overflow-hidden">
<Navigation position='main' />
<div className="flex-1 flex flex-col">
<main className="flex-1 overflow-x-hidden overflow-y-auto bg-gray-100 dark:bg-gray-900 relative">
{/* responsive container (optimized for mobile) */}
<div className="mx-auto px-2 xs:px-4 py-8 max-w-sm xs:max-w-full">
<ClientWrapper>
<PermissionError />
{children}
</ClientWrapper>
</div>
</main>
<Navigation viewPort='mobile' />
<Navigation position='mobile' />
</div>
</div>
</div>

View File

@@ -0,0 +1,61 @@
'use client'
import React, { useEffect, useState } from 'react';
import { Coins } from 'lucide-react';
import { Logo } from '@/components/Logo';
const subtexts = [
"Unearthing your treasures",
"Polishing your gems",
"Mining for good habits",
"Stumbling upon brilliance",
"Discovering your potential",
"Crafting your success story",
"Forging new paths",
"Summoning success",
"Brewing brilliance",
"Charging up your awesome",
"Assembling achievements",
"Leveling up your day",
"Questing for quality",
"Unlocking awesomeness",
"Plotting your progress",
];
const LoadingSpinner: React.FC = () => {
const [currentSubtext, setCurrentSubtext] = useState<string>('Loading your data');
const [animatedDots, setAnimatedDots] = useState<string>('');
useEffect(() => {
const randomIndex = Math.floor(Math.random() * subtexts.length);
setCurrentSubtext(subtexts[randomIndex]);
const dotAnimationInterval = setInterval(() => {
setAnimatedDots(prevDots => {
if (prevDots.length >= 3) {
return '';
}
return prevDots + '.';
});
}, 200); // Adjust timing as needed
return () => clearInterval(dotAnimationInterval);
}, []);
return (
<div className="flex flex-col items-center justify-center h-screen">
<div className="flex flex-col items-center space-y-4">
<Coins className="h-12 w-12 animate-bounce text-yellow-500" />
<Logo />
{currentSubtext && (
<p className="text-lg text-gray-600 dark:text-gray-400">
{currentSubtext}{animatedDots}
</p>
)}
</div>
</div>
);
};
export default LoadingSpinner;

View File

@@ -1,9 +1,9 @@
import { Sparkles } from "lucide-react"
import Image from "next/image"
export function Logo() {
return (
<div className="flex items-center gap-2">
<Sparkles className="h-6 w-6 text-primary" />
<Image src="/icons/icon.png" alt="HabitTrove Logo" width={96} height={96} className="h-12 w-12 hidden xs:inline" />
<span className="font-bold text-xl">HabitTrove</span>
</div>
)

View File

@@ -0,0 +1,9 @@
/**
* ModalOverlay provides a dimming effect for non-modal dialogs or popovers
* that need to appear modal (e.g., to prevent interaction with background elements).
* It should be rendered alongside the dialog/popover it's intended to overlay for.
* Ensure the dialog/popover has a z-index higher than this overlay (default z-40).
*/
export default function ModalOverlay() {
return <div className="fixed inset-0 z-50 bg-black/80" />
}

61
components/NavDisplay.tsx Normal file
View File

@@ -0,0 +1,61 @@
import { useHelpers } from '@/lib/client-helpers';
import Link from 'next/link';
import { usePathname } from 'next/navigation';
import { NavItemType } from './Navigation';
export default function NavDisplay({ navItems, isMobile }: { navItems: NavItemType[], isMobile: boolean }) {
const pathname = usePathname();
const { isIOS } = useHelpers()
if (isMobile) {
return (
<>
{isMobile && (<div className={isIOS ? "pb-20" : "pb-16"} />)}
<nav className={`lg:hidden fixed bottom-0 left-0 right-0 bg-white dark:bg-gray-800 shadow-lg ${isIOS ? "pb-4" : ""}`}>
<div className="grid grid-cols-6 w-full">
{navItems.map((item) => (
<Link
key={item.label}
href={item.href}
className={"flex flex-col items-center py-2 hover:text-blue-600 dark:hover:text-blue-300 " +
(pathname === (item.href) ?
"text-blue-500 dark:text-blue-500" :
"text-gray-300 dark:text-gray-300")
}
>
<item.icon className="h-6 w-6" />
<span className="text-xs mt-1">{item.label}</span>
</Link>
))}
</div>
</nav>
</>
);
} else {
return (
<div className="hidden lg:flex lg:flex-shrink-0">
<div className="flex flex-col w-64">
<div className="flex flex-col h-0 flex-1 bg-gray-800">
<div className="flex-1 flex flex-col pt-5 pb-4 overflow-y-auto">
<nav className="mt-5 flex-1 px-2 space-y-1">
{navItems.map((item) => (
<Link
key={item.label}
href={item.href}
className={"flex items-center px-2 py-2 font-medium rounded-md " +
(pathname === (item.href) ?
"text-blue-500 hover:text-blue-600 hover:bg-gray-700" :
"text-gray-300 hover:text-white hover:bg-gray-700")}
>
<item.icon className="mr-4 flex-shrink-0 h-6 w-6" aria-hidden="true" />
{item.label}
</Link>
))}
</nav>
</div>
</div>
</div>
</div>
);
}
}

View File

@@ -1,90 +1,40 @@
'use client'
import Link from 'next/link'
import { Home, Calendar, List, Gift, Coins, Settings, Info } from 'lucide-react'
import { useEffect, useState } from 'react'
import AboutModal from './AboutModal'
import { HabitIcon, TaskIcon } from '@/lib/constants'
import { Calendar, Coins, Gift, Home } from 'lucide-react'
import { useTranslations } from 'next-intl'
import { ElementType, useEffect, useState } from 'react'
import NavDisplay from './NavDisplay'
type ViewPort = 'main' | 'mobile'
const navItems = [
{ icon: Home, label: 'Dashboard', href: '/', position: 'main' },
{ icon: List, label: 'Habits', href: '/habits', position: 'main' },
{ icon: Calendar, label: 'Calendar', href: '/calendar', position: 'main' },
{ icon: Gift, label: 'Wishlist', href: '/wishlist', position: 'main' },
{ icon: Coins, label: 'Coins', href: '/coins', position: 'main' },
]
interface NavigationProps {
className?: string
viewPort: ViewPort
export interface NavItemType {
icon: ElementType;
label: string;
href: string;
}
export default function Navigation({ className, viewPort }: NavigationProps) {
const [showAbout, setShowAbout] = useState(false)
const [isMobileView, setIsMobileView] = useState(false)
export default function Navigation({ position }: { position: 'main' | 'mobile' }) {
const t = useTranslations('Navigation');
const [isMobile, setIsMobile] = useState(window.innerWidth < 1024);
useEffect(() => {
const handleResize = () => {
setIsMobileView(window.innerWidth < 1024)
}
const handleResize = () => {setIsMobile(window.innerWidth < 1024); };
window.addEventListener("resize", handleResize);
return () => window.removeEventListener("resize", handleResize);
}, [setIsMobile]);
// Set initial value
handleResize()
const currentNavItems: NavItemType[] = [
{ icon: Home, label: t('dashboard'), href: '/' },
{ icon: HabitIcon, label: t('habits'), href: '/habits' },
{ icon: TaskIcon, label: t('tasks'), href: '/tasks' },
{ icon: Calendar, label: t('calendar'), href: '/calendar' },
{ icon: Gift, label: t('wishlist'), href: '/wishlist' },
{ icon: Coins, label: t('coins'), href: '/coins' },
]
// Add event listener
window.addEventListener('resize', handleResize)
// Cleanup
return () => window.removeEventListener('resize', handleResize)
}, [])
if (viewPort === 'mobile' && isMobileView) {
return (
<>
<div className="pb-16" /> {/* Add padding at the bottom to prevent content from being hidden */}
<nav className="lg:hidden fixed bottom-0 left-0 right-0 bg-white dark:bg-gray-800 shadow-lg">
<div className="flex justify-around">
{[...navItems.filter(item => item.position === 'main'), ...navItems.filter(item => item.position === 'bottom')].map((item) => (
<Link
key={item.label}
href={item.href}
className="flex flex-col items-center py-2 text-gray-600 dark:text-gray-300 hover:text-blue-500 dark:hover:text-blue-400"
>
<item.icon className="h-6 w-6" />
<span className="text-xs mt-1">{item.label}</span>
</Link>
))}
</div>
</nav>
<AboutModal isOpen={showAbout} onClose={() => setShowAbout(false)} />
</>
)
if ((position === 'mobile' && isMobile) || (position === 'main' && !isMobile)) {
return <NavDisplay navItems={currentNavItems} isMobile={isMobile} />
}
if (viewPort === 'main' && !isMobileView) {
return (
<div className="hidden lg:flex lg:flex-shrink-0">
<div className="flex flex-col w-64">
<div className="flex flex-col h-0 flex-1 bg-gray-800">
<div className="flex-1 flex flex-col pt-5 pb-4 overflow-y-auto">
<nav className="mt-5 flex-1 px-2 space-y-1">
{navItems.filter(item => item.position === 'main').map((item) => (
<Link
key={item.label}
href={item.href}
className="group flex items-center px-2 py-2 text-sm leading-6 font-medium rounded-md text-gray-300 hover:text-white hover:bg-gray-700"
>
<item.icon className="mr-4 flex-shrink-0 h-6 w-6 text-gray-400" aria-hidden="true" />
{item.label}
</Link>
))}
</nav>
</div>
</div>
</div>
<AboutModal isOpen={showAbout} onClose={() => setShowAbout(false)} />
</div>
)
else {
return <></>
}
}

View File

@@ -0,0 +1,134 @@
'use client'
import { useMemo } from 'react'
import { useAtom } from 'jotai'
import { coinsAtom, habitsAtom, wishlistAtom, usersAtom, currentUserAtom } from '@/lib/atoms'
import { Bell } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { useTranslations } from 'next-intl';
import NotificationDropdown from './NotificationDropdown';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu'
import { updateLastNotificationReadTimestamp } from '@/app/actions/data';
import { d2t, getNow, t2d } from '@/lib/utils';
import { User, CoinTransaction } from '@/lib/types';
export default function NotificationBell() {
const t = useTranslations('NotificationBell');
const [currentUser] = useAtom(currentUserAtom);
const [coinsData] = useAtom(coinsAtom)
const [habitsData] = useAtom(habitsAtom)
const [wishlistData] = useAtom(wishlistAtom)
const [usersData] = useAtom(usersAtom);
// --- Calculate Unread and Read Notifications ---
const { unreadNotifications, displayedReadNotifications } = useMemo(() => {
const unread: CoinTransaction[] = [];
const read: CoinTransaction[] = [];
const MAX_READ_NOTIFICATIONS = 10; // Limit the number of past notifications shown
if (!currentUser || !currentUser.id) {
return { unreadNotifications: [], displayedReadNotifications: [] };
}
const lastReadTimestamp = currentUser.lastNotificationReadTimestamp
? t2d({ timestamp: currentUser.lastNotificationReadTimestamp, timezone: 'UTC' })
: null;
// Iterate through transactions (assuming they are sorted newest first)
for (const tx of coinsData.transactions) {
// Stop processing if we have enough read notifications
if (read.length >= MAX_READ_NOTIFICATIONS && (!lastReadTimestamp || t2d({ timestamp: tx.timestamp, timezone: 'UTC' }) <= lastReadTimestamp)) {
break; // Optimization: stop early if we have enough read and are past the unread ones
}
// Basic checks: must have a related item and be triggered by someone else
if (!tx.relatedItemId || tx.userId === currentUser.id) {
continue;
}
// Check if the transaction type indicates a notification-worthy event
const isRelevantType = tx.type === 'HABIT_COMPLETION' || tx.type === 'TASK_COMPLETION' || tx.type === 'WISH_REDEMPTION';
if (!isRelevantType) {
continue;
}
// Check if the related item is shared with the current user
let isShared = false;
const isHabitCompletion = tx.type === 'HABIT_COMPLETION' || tx.type === 'TASK_COMPLETION';
const isWishRedemption = tx.type === 'WISH_REDEMPTION';
if (isHabitCompletion) {
const habit = habitsData.habits.find(h => h.id === tx.relatedItemId);
if (habit?.userIds?.includes(currentUser.id) && tx.userId && habit.userIds.includes(tx.userId)) {
isShared = true;
}
} else if (isWishRedemption) {
const wish = wishlistData.items.find(w => w.id === tx.relatedItemId);
if (wish?.userIds?.includes(currentUser.id) && tx.userId && wish.userIds.includes(tx.userId)) {
isShared = true;
}
}
if (!isShared) {
continue; // Skip if not shared
}
// Transaction is relevant, determine if read or unread
const txTimestamp = t2d({ timestamp: tx.timestamp, timezone: 'UTC' });
if (!lastReadTimestamp || txTimestamp > lastReadTimestamp) {
unread.push(tx);
} else if (read.length < MAX_READ_NOTIFICATIONS) {
// Only add to read if we haven't hit the limit
read.push(tx);
}
}
// Transactions are assumed to be sorted newest first from the source
return { unreadNotifications: unread, displayedReadNotifications: read };
}, [coinsData.transactions, habitsData.habits, wishlistData.items, currentUser]);
// --- End Calculate Notifications ---
const unreadCount = unreadNotifications.length;
const handleNotificationClick = async () => {
if (!currentUser || !currentUser.id || unreadCount === 0) return; // Only update if there are unread notifications
try {
const nowTimestamp = d2t({ dateTime: getNow({}) });
await updateLastNotificationReadTimestamp(currentUser.id, nowTimestamp);
} catch (error) {
console.error(t('errorUpdateTimestamp'), error);
}
};
return (
<DropdownMenu onOpenChange={(open) => {
// Update timestamp only when opening the dropdown and there are unread notifications
if (open && unreadCount > 0) {
handleNotificationClick();
}
}}>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon" aria-label="Notifications" className="relative">
<Bell className="h-5 w-5" />
{unreadCount > 0 && (
<span className="absolute top-1 right-1 block h-2 w-2 rounded-full bg-red-500 ring-1 ring-white dark:ring-gray-800" />
)}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="p-0 w-80 md:w-96">
<NotificationDropdown
currentUser={currentUser as User | null} // Cast needed as as currentUser can be undefined
unreadNotifications={unreadNotifications}
displayedReadNotifications={displayedReadNotifications}
habitsData={habitsData} // Pass necessary data down
wishlistData={wishlistData}
usersData={usersData}
/>
</DropdownMenuContent>
</DropdownMenu>
);
}

View File

@@ -0,0 +1,133 @@
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
import { DropdownMenuItem } from '@/components/ui/dropdown-menu';
import { ScrollArea } from '@/components/ui/scroll-area';
import { Separator } from '@/components/ui/separator';
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from '@/components/ui/tooltip';
import { CoinTransaction, HabitsData, User, UserData, WishlistData } from '@/lib/types';
import { t2d } from '@/lib/utils';
import { Info } from 'lucide-react';
import { useTranslations } from 'next-intl';
import Link from 'next/link';
interface NotificationDropdownProps {
currentUser: User | null;
unreadNotifications: CoinTransaction[];
displayedReadNotifications: CoinTransaction[];
habitsData: HabitsData;
wishlistData: WishlistData;
usersData: UserData;
}
// Helper function to get the name of the related item
const getRelatedItemName = (tx: CoinTransaction, habitsData: HabitsData, wishlistData: WishlistData): string | undefined => {
if (!tx.relatedItemId) return undefined;
if (tx.type === 'HABIT_COMPLETION' || tx.type === 'TASK_COMPLETION') {
return habitsData.habits.find(h => h.id === tx.relatedItemId)?.name;
}
if (tx.type === 'WISH_REDEMPTION') {
return wishlistData.items.find(w => w.id === tx.relatedItemId)?.name;
}
return undefined;
};
export default function NotificationDropdown({
currentUser,
unreadNotifications, // Use props directly
displayedReadNotifications, // Use props directly
habitsData,
wishlistData,
usersData,
}: NotificationDropdownProps) {
const t = useTranslations('NotificationDropdown');
// Helper function to generate notification message, now using t
const getNotificationMessage = (tx: CoinTransaction, triggeringUser?: User, relatedItemName?: string): string => {
const username = triggeringUser?.username || t('defaultUsername');
const itemName = relatedItemName || t('defaultItemName');
switch (tx.type) {
case 'HABIT_COMPLETION':
case 'TASK_COMPLETION':
return t('userCompletedItem', { username, itemName });
case 'WISH_REDEMPTION':
return t('userRedeemedItem', { username, itemName });
default:
return t('activityRelatedToItem', { username, itemName });
}
};
if (!currentUser) {
return <div className="p-4 text-sm text-gray-500">{t('notLoggedIn')}</div>;
}
const renderNotification = (tx: CoinTransaction, isUnread: boolean) => {
const triggeringUser = usersData.users.find(u => u.id === tx.userId);
const relatedItemName = getRelatedItemName(tx, habitsData, wishlistData);
const message = getNotificationMessage(tx, triggeringUser, relatedItemName); // Uses the new t-aware helper
const txTimestamp = t2d({ timestamp: tx.timestamp, timezone: 'UTC' });
const timeAgo = txTimestamp.toRelative();
const linkHref = `/coins?highlight=${tx.id}${tx.userId ? `&user=${tx.userId}` : ''}`;
return (
// Wrap the Link with DropdownMenuItem and use asChild to pass props
<DropdownMenuItem key={tx.id} asChild className={`p-0 focus:bg-inherit dark:focus:bg-inherit cursor-pointer`}>
<Link href={linkHref} className={`block hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors ${isUnread ? 'bg-blue-50 dark:bg-blue-900/20' : ''}`} scroll={true}>
<div className="p-3 flex items-start gap-3">
<Avatar className="h-8 w-8 mt-1">
<AvatarImage src={triggeringUser?.avatarPath ? `/api/avatars/${triggeringUser.avatarPath.split('/').pop()}` : undefined} alt={triggeringUser?.username} />
<AvatarFallback>{triggeringUser?.username?.charAt(0).toUpperCase() || '?'}</AvatarFallback>
</Avatar>
<div className="flex-1">
<p className={`text-sm ${isUnread ? 'font-semibold' : ''}`}>{message}</p>
<p className="text-xs text-gray-500 dark:text-gray-400">{timeAgo}</p>
</div>
</div>
</Link>
</DropdownMenuItem>
);
};
return (
<TooltipProvider>
{/* Removed the outer div as width is now set on DropdownMenuContent in NotificationBell */}
<>
<div className="p-3 border-b border-gray-200 dark:border-gray-700 flex items-center gap-2">
<h4 className="text-sm font-medium">{t('notificationsTitle')}</h4>
<Tooltip>
<TooltipTrigger asChild>
<Info className="h-4 w-4 text-muted-foreground cursor-help" />
</TooltipTrigger>
<TooltipContent side="left" className="max-w-xs">
<p className="text-xs">
{t('notificationsTooltip')}
</p>
</TooltipContent>
</Tooltip>
</div>
<ScrollArea className="h-[400px]">
{unreadNotifications.length === 0 && displayedReadNotifications.length === 0 && (
<div className="p-4 text-center text-sm text-gray-500">{t('noNotificationsYet')}</div>
)}
{unreadNotifications.length > 0 && (
<>
{unreadNotifications.map(tx => renderNotification(tx, true))}
{displayedReadNotifications.length > 0 && <Separator className="my-2" />}
</>
)}
{displayedReadNotifications.length > 0 && (
<>
{displayedReadNotifications.map(tx => renderNotification(tx, false))}
</>
)}
</ScrollArea>
</> {/* Close the fragment */}
</TooltipProvider>
);
}

View File

@@ -0,0 +1,95 @@
'use client';
import { toast } from '@/hooks/use-toast';
import { User } from '@/lib/types';
import { User as UserIcon } from 'lucide-react';
import { useTranslations } from 'next-intl';
import { useState } from 'react';
import { Avatar, AvatarFallback, AvatarImage } from './ui/avatar';
import { Button } from './ui/button';
import { Input } from './ui/input';
import { Label } from './ui/label';
interface PasswordEntryFormProps {
user: User;
onCancel: () => void;
onSubmit: (password: string) => Promise<void>;
error?: string;
}
export default function PasswordEntryForm({
user,
onCancel,
onSubmit,
error
}: PasswordEntryFormProps) {
const t = useTranslations('PasswordEntryForm');
const hasPassword = !!user.password;
const [password, setPassword] = useState('');
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
try {
await onSubmit(password);
} catch (err) {
toast({
title: t('loginErrorToastTitle'),
description: err instanceof Error ? err.message : t('loginFailedErrorToastDescription'),
variant: "destructive"
});
}
};
return (
<form onSubmit={handleSubmit} className="space-y-6">
<div className="flex flex-col items-center gap-4 p-4 bg-muted/50 rounded-lg">
<Avatar className="h-24 w-24">
<AvatarImage
src={user.avatarPath && `/api/avatars/${user.avatarPath.split('/').pop()}`}
/>
<AvatarFallback>
<UserIcon className="h-12 w-12" />
</AvatarFallback>
</Avatar>
<div className="text-center">
<div className="font-medium text-lg">
{user.username}
</div>
<button
type="button"
onClick={onCancel}
className="text-sm text-blue-500 hover:text-blue-600 mt-1"
>
{t('notYouButton')}
</button>
</div>
</div>
{hasPassword && <div className="space-y-4">
<div className="space-y-2">
<Label htmlFor="password">{t('passwordLabel')}</Label>
<Input
id="password"
type="password"
placeholder={t('passwordPlaceholder')}
value={password}
onChange={(e) => setPassword(e.target.value)}
className={error ? 'border-red-500' : ''}
/>
{error && (
<p className="text-sm text-red-500 bg-red-50 dark:bg-red-950/50 p-2 rounded">{error}</p>
)}
</div>
</div>}
<div className="flex justify-end gap-2 pt-2">
<Button type="button" variant="outline" onClick={onCancel}>
{t('cancelButton')}
</Button>
<Button type="submit" disabled={hasPassword && !password}>
{t('loginButton')}
</Button>
</div>
</form>
);
}

View File

@@ -0,0 +1,43 @@
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
import { AlertTriangle } from 'lucide-react'
import { checkStartupPermissions } from '@/lib/startup-checks'
import RecheckButton from './RecheckButton'
export default async function PermissionError() {
const permissionResult = await checkStartupPermissions()
// If everything is fine, render nothing
if (permissionResult.success) {
return <></>
}
// Get error message
const getErrorMessage = () => {
return permissionResult.error?.message || 'Unknown permission error occurred.'
}
return (
<Alert variant="destructive" className="mb-4">
<AlertTriangle className="h-4 w-4" />
<AlertTitle className="font-bold">Permission Error</AlertTitle>
<AlertDescription className="mt-2 flex flex-col">
<div className="space-y-3">
<span className="text-sm">
{getErrorMessage()}{" "}
<a
href="https://docs.habittrove.com/troubleshooting"
target="_blank"
rel="noopener noreferrer"
className="underline hover:text-red-300"
>
Troubleshooting Guide
</a>
</span>
<div>
<RecheckButton />
</div>
</div>
</AlertDescription>
</Alert>
)
}

View File

@@ -0,0 +1,110 @@
'use client';
import { Switch } from './ui/switch';
import { Label } from './ui/label';
import { Permission } from '@/lib/types';
import { useTranslations } from 'next-intl';
interface PermissionSelectorProps {
permissions: Permission[];
isAdmin: boolean;
onPermissionsChange: (permissions: Permission[]) => void;
onAdminChange: (isAdmin: boolean) => void;
}
export function PermissionSelector({
permissions,
isAdmin,
onPermissionsChange,
onAdminChange,
}: PermissionSelectorProps) {
const t = useTranslations('PermissionSelector');
const permissionLabels: { [key: string]: string } = {
habit: t('resourceHabitTask'),
wishlist: t('resourceWishlist'),
coins: t('resourceCoins')
};
const currentPermissions = isAdmin ?
{
habit: { write: true, interact: true },
wishlist: { write: true, interact: true },
coins: { write: true, interact: true }
} :
permissions[0] || {
habit: { write: false, interact: true },
wishlist: { write: false, interact: true },
coins: { write: false, interact: true }
};
const handlePermissionChange = (resource: keyof Permission, type: 'write' | 'interact', checked: boolean) => {
const newPermissions = [{
...currentPermissions,
[resource]: {
...currentPermissions[resource],
[type]: checked
}
}];
onPermissionsChange(newPermissions);
};
return (
<div className="space-y-4">
<div className="space-y-2">
<Label>{t('permissionsTitle')}</Label>
<div className="grid grid-cols-1 gap-4">
<div className="flex items-center justify-between p-3 rounded-lg border bg-muted/50">
<div className="flex items-center gap-2">
<div className="font-medium text-sm">{t('adminAccessLabel')}</div>
</div>
<Switch
id="isAdmin"
className="h-4 w-7"
checked={isAdmin}
onCheckedChange={onAdminChange}
/>
</div>
{isAdmin ? (
<p className="text-xs text-muted-foreground px-3">
{t('adminAccessDescription')}
</p>
) : (
<div className="grid grid-cols-3 gap-4">
{['habit', 'wishlist', 'coins'].map((resource) => (
<div key={resource} className="p-3 space-y-3 rounded-lg border bg-muted/50">
<div className="font-medium capitalize text-sm border-b pb-2">{permissionLabels[resource]}</div>
<div className="flex flex-col gap-2.5">
<div className="flex flex-col sm:flex-row items-start sm:items-center justify-between gap-2">
<Label htmlFor={`${resource}-write`} className="text-xs text-muted-foreground break-words">{t('permissionWrite')}</Label>
<Switch
id={`${resource}-write`}
className="h-4 w-7"
checked={currentPermissions[resource as keyof Permission].write}
onCheckedChange={(checked) =>
handlePermissionChange(resource as keyof Permission, 'write', checked)
}
/>
</div>
<div className="flex flex-col sm:flex-row items-start sm:items-center justify-between gap-2">
<Label htmlFor={`${resource}-interact`} className="text-xs text-muted-foreground break-words">{t('permissionInteract')}</Label>
<Switch
id={`${resource}-interact`}
className="h-4 w-7"
checked={currentPermissions[resource as keyof Permission].interact}
onCheckedChange={(checked) =>
handlePermissionChange(resource as keyof Permission, 'interact', checked)
}
/>
</div>
</div>
</div>
))}
</div>
)}
</div>
</div>
</div>
)
}

View File

@@ -1,58 +1,43 @@
'use client'
import { useState, useEffect, useRef, useCallback } from 'react'
import { Button } from '@/components/ui/button'
import { Progress } from '@/components/ui/progress'
import { Play, Pause, RotateCw, Minus, X, Clock, SkipForward } from 'lucide-react'
import { cn, getCompletionsForToday } from '@/lib/utils'
import { useAtom } from 'jotai'
import { settingsAtom, pomodoroAtom, habitsAtom, pomodoroTodayCompletionsAtom } from '@/lib/atoms'
import { getCompletionsForDate, getTodayInTimezone } from '@/lib/utils'
import { useHabits } from '@/hooks/useHabits'
import { habitsAtom, pomodoroAtom, pomodoroTodayCompletionsAtom } from '@/lib/atoms'
import { cn } from '@/lib/utils'
import { useAtom } from 'jotai'
import { Clock, Minus, Pause, Play, RotateCw, SkipForward, X } from 'lucide-react'
import { useTranslations } from 'next-intl'
import { useEffect, useRef, useState } from 'react'
interface PomoConfig {
labels: string[]
getLabels: () => string[]
duration: number
type: 'focus' | 'break'
}
const PomoConfigs: Record<PomoConfig['type'], PomoConfig> = {
focus: {
labels: [
'Stay Focused',
'You Got This',
'Keep Going',
'Crush It',
'Make It Happen',
'Stay Strong',
'Push Through',
'One Step at a Time',
'You Can Do It',
'Focus and Conquer'
],
duration: 25 * 60,
type: 'focus',
},
break: {
labels: [
'Take a Break',
'Relax and Recharge',
'Breathe Deeply',
'Stretch It Out',
'Refresh Yourself',
'You Deserve This',
'Recharge Your Energy',
'Step Away for a Bit',
'Clear Your Mind',
'Rest and Rejuvenate'
],
duration: 5 * 60,
type: 'break',
},
}
export default function PomodoroTimer() {
const [settings] = useAtom(settingsAtom)
const t = useTranslations('PomodoroTimer')
const PomoConfigs: Record<PomoConfig['type'], PomoConfig> = {
focus: {
getLabels: () => [
t('focusLabel1'), t('focusLabel2'), t('focusLabel3'), t('focusLabel4'), t('focusLabel5'),
t('focusLabel6'), t('focusLabel7'), t('focusLabel8'), t('focusLabel9'), t('focusLabel10')
],
duration: 25 * 60,
type: 'focus',
},
break: {
getLabels: () => [
t('breakLabel1'), t('breakLabel2'), t('breakLabel3'), t('breakLabel4'), t('breakLabel5'),
t('breakLabel6'), t('breakLabel7'), t('breakLabel8'), t('breakLabel9'), t('breakLabel10')
],
duration: 5 * 60,
type: 'break',
},
}
const [pomo, setPomo] = useAtom(pomodoroAtom)
const { show, selectedHabitId, autoStart, minimized } = pomo
const [habitsData] = useAtom(habitsAtom)
@@ -62,21 +47,23 @@ export default function PomodoroTimer() {
const [state, setState] = useState<'started' | 'stopped' | 'paused'>(autoStart ? 'started' : 'stopped')
const wakeLock = useRef<WakeLockSentinel | null>(null)
const [todayCompletions] = useAtom(pomodoroTodayCompletionsAtom)
const currentTimer = useRef<PomoConfig>(PomoConfigs.focus)
const [currentLabel, setCurrentLabel] = useState(
currentTimer.current.labels[Math.floor(Math.random() * currentTimer.current.labels.length)]
)
const currentTimerRef = useRef<PomoConfig>(PomoConfigs.focus)
const [currentLabel, setCurrentLabel] = useState(() => {
const labels = currentTimerRef.current.getLabels();
return labels[Math.floor(Math.random() * labels.length)];
});
// Handle wake lock
useEffect(() => {
const requestWakeLock = async () => {
try {
if (!('wakeLock' in navigator)) {
console.debug('Browser does not support wakelock')
console.debug(t('wakeLockNotSupported'))
return
}
if (wakeLock.current && !wakeLock.current.released) {
console.debug('Wake lock already in use')
console.debug(t('wakeLockInUse'))
return
}
if (state === 'started') {
@@ -85,7 +72,7 @@ export default function PomodoroTimer() {
return
}
} catch (err) {
console.error('Error requesting wake lock:', err)
console.error(t('wakeLockRequestError'), err)
}
}
@@ -96,7 +83,7 @@ export default function PomodoroTimer() {
wakeLock.current = null
}
} catch (err) {
console.error('Error releasing wake lock:', err)
console.error(t('wakeLockReleaseError'), err)
}
}
@@ -121,59 +108,48 @@ export default function PomodoroTimer() {
document.removeEventListener('visibilitychange', handleVisibilityChange);
releaseWakeLock()
}
}, [state])
}, [state, t])
// Timer logic
useEffect(() => {
let interval: ReturnType<typeof setInterval> | null = null
const handleTimerEnd = async () => {
setState("stopped");
const currentTimerType = currentTimerRef.current.type;
currentTimerRef.current =
currentTimerType === "focus" ? PomoConfigs.break : PomoConfigs.focus;
setTimeLeft(currentTimerRef.current.duration);
const newLabels = currentTimerRef.current.getLabels();
setCurrentLabel(newLabels[Math.floor(Math.random() * newLabels.length)]);
if (state === 'started') {
// update habits only after focus sessions
if (selectedHabit && currentTimerType === "focus") {
await completeHabit(selectedHabit);
// The atom will automatically update with the new completions
}
};
let interval: ReturnType<typeof setInterval> | null = null;
if (state === "started") {
// Calculate the target end time based on current timeLeft
const targetEndTime = Date.now() + timeLeft * 1000
const targetEndTime = Date.now() + timeLeft * 1000;
interval = setInterval(() => {
const remaining = Math.floor((targetEndTime - Date.now()) / 1000)
const remaining = Math.floor((targetEndTime - Date.now()) / 1000);
if (remaining <= 0) {
handleTimerEnd()
handleTimerEnd();
} else {
setTimeLeft(remaining)
setTimeLeft(remaining);
}
}, 1000)
}, 1000);
}
// return handles any other states
return () => {
if (interval) clearInterval(interval)
}
}, [state])
const playSound = useCallback(() => {
const audio = new Audio('/sounds/timer-end.wav')
audio.play().catch(error => {
console.error('Error playing sound:', error)
})
}, [])
const handleTimerEnd = async () => {
setState("stopped")
const currentTimerType = currentTimer.current.type
currentTimer.current = currentTimerType === 'focus' ? PomoConfigs.break : PomoConfigs.focus
setTimeLeft(currentTimer.current.duration)
setCurrentLabel(
currentTimer.current.labels[Math.floor(Math.random() * currentTimer.current.labels.length)]
)
// Play sound
playSound()
// update habits only after focus sessions
if (selectedHabit && currentTimerType === 'focus') {
await completeHabit(selectedHabit)
// The atom will automatically update with the new completions
}
}
if (interval) clearInterval(interval);
};
}, [state, timeLeft, PomoConfigs.break, PomoConfigs.focus, completeHabit, selectedHabit]);
const toggleTimer = () => {
setState(prev => prev === 'started' ? 'paused' : 'started')
@@ -181,7 +157,16 @@ export default function PomodoroTimer() {
const resetTimer = () => {
setState("stopped")
setTimeLeft(currentTimer.current.duration)
setTimeLeft(currentTimerRef.current.duration)
}
const skipTimer = () => {
currentTimerRef.current = currentTimerRef.current.type === 'focus'
? PomoConfigs.break
: PomoConfigs.focus
resetTimer() // This will also reset timeLeft to the new timer's duration
const newLabels = currentTimerRef.current.getLabels();
setCurrentLabel(newLabels[Math.floor(Math.random() * newLabels.length)])
}
const formatTime = (seconds: number) => {
@@ -190,9 +175,9 @@ export default function PomodoroTimer() {
return `${minutes}:${secs < 10 ? '0' : ''}${secs}`
}
const progress = (timeLeft / currentTimer.current.duration) * 100
const progress = (timeLeft / currentTimerRef.current.duration) * 100
if (!show) return null
if (!show) return <></>
return (
<div className="fixed bottom-20 right-4 lg:bottom-4 bg-background border rounded-lg shadow-lg">
@@ -243,11 +228,11 @@ export default function PomodoroTimer() {
<div className={cn(
'w-2 h-2 rounded-full flex-none',
// order matters here
currentTimer.current.type === 'focus' && 'bg-green-500',
currentTimerRef.current.type === 'focus' && 'bg-green-500',
state === 'started' && 'animate-pulse',
state === 'paused' && 'bg-yellow-500',
state === 'stopped' && 'bg-red-500',
currentTimer.current.type === 'break' && 'bg-blue-500',
currentTimerRef.current.type === 'break' && 'bg-blue-500',
)} />
<div className="font-bold text-foreground">
{selectedHabit.name}
@@ -255,7 +240,9 @@ export default function PomodoroTimer() {
</div>
</div>
)}
<span>{currentTimer.current.type.charAt(0).toUpperCase() + currentTimer.current.type.slice(1)}: {currentLabel}</span>
<span>
{currentTimerRef.current.type === 'focus' ? t('focusType') : t('breakType')}: {currentLabel}
</span>
{selectedHabit && selectedHabit.targetCompletions && selectedHabit.targetCompletions > 1 && (
<div className="flex justify-center gap-1 mt-2">
{(() => {
@@ -294,12 +281,12 @@ export default function PomodoroTimer() {
{state === "started" ? (
<>
<Pause className="h-4 w-4 sm:mr-2" />
<span className="hidden sm:inline">Pause</span>
<span className="hidden sm:inline">{t('pauseButton')}</span>
</>
) : (
<>
<Play className="h-4 w-4 sm:mr-2" />
<span className="hidden sm:inline">Start</span>
<span className="hidden sm:inline">{t('startButton')}</span>
</>
)}
</Button>
@@ -310,21 +297,16 @@ export default function PomodoroTimer() {
className="sm:px-4"
>
<RotateCw className="h-4 w-4 sm:mr-2" />
<span className="hidden sm:inline">Reset</span>
<span className="hidden sm:inline">{t('resetButton')}</span>
</Button>
<Button
variant="outline"
onClick={() => {
currentTimer.current = currentTimer.current.type === 'focus'
? PomoConfigs.break
: PomoConfigs.focus
resetTimer()
}}
onClick={skipTimer}
disabled={state === "started"}
className="sm:px-4"
>
<SkipForward className="h-4 w-4 sm:mr-2" />
<span className="hidden sm:inline">Skip</span>
<span className="hidden sm:inline">{t('skipButton')}</span>
</Button>
</div>
</div>

196
components/Profile.tsx Normal file
View File

@@ -0,0 +1,196 @@
'use client'
import { signOut } from "@/app/actions/user"
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"
import { Button } from "@/components/ui/button"
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui/dropdown-menu"
import { toast } from "@/hooks/use-toast"
import { aboutOpenAtom, currentUserAtom, settingsAtom, userSelectAtom } from "@/lib/atoms"
import { useAtom } from "jotai"
import { ArrowRightLeft, Crown, Info, LogOut, Moon, Palette, Settings, Sun, User } from "lucide-react"
import { useTranslations } from 'next-intl'
import { useTheme } from "next-themes"
import Link from "next/link"
import { useState } from "react"
import { Dialog, DialogContent, DialogHeader, DialogTitle } from './ui/dialog'
import UserForm from './UserForm'
export function Profile() {
const t = useTranslations('Profile');
const [settings] = useAtom(settingsAtom)
const [userSelect, setUserSelect] = useAtom(userSelectAtom)
const [isEditing, setIsEditing] = useState(false)
const [aboutOpen, setAboutOpen] = useAtom(aboutOpenAtom)
const { theme, setTheme } = useTheme()
const [user] = useAtom(currentUserAtom)
const [open, setOpen] = useState(false)
const handleSignOut = async () => {
try {
await signOut()
toast({
title: t('signOutSuccessTitle'),
description: t('signOutSuccessDescription'),
})
setTimeout(() => window.location.reload(), 300);
} catch (error) {
toast({
title: t('signOutErrorTitle'),
description: t('signOutErrorDescription'),
variant: "destructive",
})
}
}
return (
<>
<DropdownMenu open={open} onOpenChange={setOpen}>
<DropdownMenuTrigger asChild>
<Button variant="ghost" className="flex items-center gap-2">
<Avatar className="h-8 w-8">
<AvatarImage src={user?.avatarPath && `/api/avatars/${user.avatarPath.split('/').pop()}` || ""} />
<AvatarFallback>
<User className="h-4 w-4" />
</AvatarFallback>
</Avatar>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-[200px] p-2">
<div className="px-2 py-1.5 mb-2 border-b">
<div className="flex items-center gap-2">
<Avatar className="h-8 w-8">
<AvatarImage src={user?.avatarPath && `/api/avatars/${user.avatarPath.split('/').pop()}` || ""} />
<AvatarFallback>
<User className="h-4 w-4" />
</AvatarFallback>
</Avatar>
<div className="flex flex-col mr-4">
<span className="text-sm font-semibold flex items-center gap-1 break-all">
{user?.username || t('guestUsername')}
{user?.isAdmin && <Crown className="h-3 w-3 text-yellow-500" />}
</span>
{user && (
<button
onClick={(e) => {
e.stopPropagation();
setOpen(false);
setIsEditing(true);
}}
className="text-xs text-muted-foreground hover:text-primary transition-colors text-left"
>
{t('editProfileButton')}
</button>
)}
</div>
{user && (
<button
onClick={(e) => {
e.stopPropagation();
setOpen(false);
handleSignOut();
}}
className="border border-primary/50 text-primary rounded-md p-1.5 transition-colors hover:bg-primary/10 hover:border-primary active:scale-95"
>
<LogOut className="h-4 w-4" />
</button>
)}
</div>
</div>
<DropdownMenuItem className="cursor-pointer px-2 py-1.5" onClick={() => {
setOpen(false); // Close the dropdown
setUserSelect(true); // Open the user select modal
}}>
<div className="flex items-center justify-between w-full">
<div className="flex items-center gap-2">
<ArrowRightLeft className="h-4 w-4" />
<span>{t('switchUserButton')}</span>
</div>
</div>
</DropdownMenuItem>
<DropdownMenuItem className="cursor-pointer px-2 py-1.5" asChild>
{/* need the Link element to be the direct child of the DropdownMenuItem, since we are using asChild here */}
<Link
href="/settings"
aria-label={t('settingsLink')}
className="flex items-center justify-between w-full"
onClick={() => setOpen(false)} // Ensure dropdown closes on click
>
<div className="flex items-center gap-2">
<Settings className="h-4 w-4" />
<span>{t('settingsLink')}</span>
</div>
</Link>
</DropdownMenuItem>
<DropdownMenuItem className="cursor-pointer px-2 py-1.5" onClick={() => {
setOpen(false); // Close the dropdown
setAboutOpen(true); // Open the about modal
}}>
<div className="flex items-center justify-between w-full">
<div className="flex items-center gap-2">
<Info className="h-4 w-4" />
<span>{t('aboutButton')}</span>
</div>
</div>
</DropdownMenuItem>
<DropdownMenuItem className="cursor-pointer px-2 py-1.5">
<div className="flex items-center justify-between w-full">
<div className="flex items-center gap-2">
<Palette className="h-4 w-4" />
<span>{t('themeLabel')}</span>
</div>
<button
onClick={(e) => {
e.stopPropagation();
setTheme(theme === 'dark' ? 'light' : 'dark');
}}
className={`
w-12 h-6 rounded-full relative transition-all duration-300 ease-in-out
hover:scale-105 shadow-inner
${theme === 'dark'
? 'bg-blue-600/90 hover:bg-blue-600'
: 'bg-gray-200 hover:bg-gray-300'
}
`}
>
<div className={`
w-5 h-5 rounded-full absolute top-0.5 left-0.5
transition-all duration-300 ease-in-out
shadow-md bg-white
${theme === 'dark' ? 'translate-x-6' : 'translate-x-0'}
`}>
<div className="absolute inset-0 flex items-center justify-center">
{theme === 'dark' ? (
<Moon className="h-3 w-3 text-gray-600" />
) : (
<Sun className="h-3 w-3 text-gray-600" />
)}
</div>
</div>
</button>
</div>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
{/* Add the UserForm dialog */}
{isEditing && user && (
<Dialog open={isEditing} onOpenChange={() => setIsEditing(false)}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>{t('editProfileModalTitle')}</DialogTitle>
</DialogHeader>
<UserForm
userId={user.id}
onCancel={() => setIsEditing(false)}
onSuccess={() => {
setIsEditing(false);
window.location.reload();
}}
/>
</DialogContent>
</Dialog>
)}
</>
)
}

View File

@@ -0,0 +1,22 @@
'use client'
import { Button } from '@/components/ui/button'
import { RefreshCw } from 'lucide-react'
export default function RecheckButton() {
const handleRecheck = () => {
window.location.reload()
}
return (
<Button
onClick={handleRecheck}
variant="outline"
size="sm"
className="bg-red-50 border-red-300 text-red-700 hover:bg-red-100"
>
<RefreshCw className="h-4 w-4 mr-2" />
Recheck
</Button>
)
}

View File

@@ -0,0 +1,27 @@
'use client'
import { Button } from "@/components/ui/button"
import { AlertTriangle } from "lucide-react"
interface RefreshBannerProps {
onRefresh: () => void;
}
export default function RefreshBanner({ onRefresh }: RefreshBannerProps) {
return (
<div className="fixed bottom-4 right-4 z-[100] bg-yellow-400 dark:bg-yellow-500 text-black dark:text-gray-900 p-4 rounded-lg shadow-lg flex items-center gap-3">
<AlertTriangle className="h-6 w-6 text-yellow-800 dark:text-yellow-900" />
<div>
<p className="font-semibold">Data out of sync</p>
<p className="text-sm">New data is available. Please refresh to see the latest updates.</p>
</div>
<Button
onClick={onRefresh}
variant="outline"
className="ml-auto bg-yellow-500 hover:bg-yellow-600 dark:bg-yellow-600 dark:hover:bg-yellow-700 border-yellow-600 dark:border-yellow-700 text-white dark:text-gray-900"
>
Refresh
</Button>
</div>
)
}

View File

@@ -1,20 +1,22 @@
import { useAtom } from 'jotai'
import { useTranslations } from 'next-intl'
import { settingsAtom } from '@/lib/atoms'
import { useCoins } from '@/hooks/useCoins'
import { FormattedNumber } from '@/components/FormattedNumber'
export default function TodayEarnedCoins({ longFormat }: { longFormat?: boolean }) {
const t = useTranslations('TodayEarnedCoins')
const [settings] = useAtom(settingsAtom)
const { coinsEarnedToday } = useCoins()
if (coinsEarnedToday <= 0) return null
if (coinsEarnedToday <= 0) return <></>;
return (
<span className="text-md text-green-600 dark:text-green-400 font-medium mt-1">
{"+"}
<FormattedNumber amount={coinsEarnedToday} settings={settings} />
{longFormat ?
<span className="text-sm text-muted-foreground"> today</span>
<span className="text-sm text-muted-foreground"> {t('todaySuffix')}</span>
: null}
</span>
)

View File

@@ -5,6 +5,7 @@ import { Input } from '@/components/ui/input'
import { Button } from '@/components/ui/button'
import { Check, Loader2, Pencil, Trash2, X } from 'lucide-react'
import { toast } from '@/hooks/use-toast'
import { useTranslations } from 'next-intl'
interface TransactionNoteEditorProps {
transactionId: string
@@ -19,6 +20,7 @@ export function TransactionNoteEditor({
onSave,
onDelete
}: TransactionNoteEditorProps) {
const t = useTranslations('TransactionNoteEditor');
const [isEditing, setIsEditing] = useState(false)
const [noteText, setNoteText] = useState(initialNote)
const [isSaving, setIsSaving] = useState(false)
@@ -27,8 +29,8 @@ export function TransactionNoteEditor({
const trimmedNote = noteText.trim()
if (trimmedNote.length > 200) {
toast({
title: 'Note too long',
description: 'Notes must be less than 200 characters',
title: t('noteTooLongTitle'),
description: t('noteTooLongDescription'),
variant: 'destructive'
})
return
@@ -40,8 +42,8 @@ export function TransactionNoteEditor({
setIsEditing(false)
} catch (error) {
toast({
title: 'Error saving note',
description: 'Please try again',
title: t('errorSavingNoteTitle'),
description: t('pleaseTryAgainDescription'),
variant: 'destructive'
})
// Revert to initial value on error
@@ -59,8 +61,8 @@ export function TransactionNoteEditor({
setIsEditing(false)
} catch (error) {
toast({
title: 'Error deleting note',
description: 'Please try again',
title: t('errorDeletingNoteTitle'),
description: t('pleaseTryAgainDescription'),
variant: 'destructive'
})
} finally {
@@ -74,7 +76,7 @@ export function TransactionNoteEditor({
<Input
value={noteText}
onChange={(e) => setNoteText(e.target.value)}
placeholder="Add a note..."
placeholder={t('addNotePlaceholder')}
className="w-64"
maxLength={200}
/>
@@ -85,7 +87,7 @@ export function TransactionNoteEditor({
onClick={handleSave}
disabled={isSaving}
className="text-green-600 dark:text-green-500 hover:text-green-700 dark:hover:text-green-400 transition-colors"
title="Save note"
title={t('saveNoteTitle')}
>
{isSaving ? <Loader2 className="h-4 w-4 animate-spin" /> : <Check className="h-4 w-4" />}
</Button>
@@ -98,7 +100,7 @@ export function TransactionNoteEditor({
}}
disabled={isSaving}
className="text-red-600 dark:text-red-500 hover:text-red-700 dark:hover:text-red-400 transition-colors"
title="Cancel"
title={t('cancelButtonTitle')}
>
<X className="h-4 w-4" />
</Button>
@@ -109,7 +111,7 @@ export function TransactionNoteEditor({
onClick={handleDelete}
disabled={isSaving}
className="text-gray-600 dark:text-gray-500 hover:text-gray-700 dark:hover:text-gray-400 transition-colors"
title="Delete note"
title={t('deleteNoteTitle')}
>
{isSaving ? <Loader2 className="h-4 w-4 animate-spin" /> : <Trash2 className="h-4 w-4" />}
</Button>
@@ -129,7 +131,7 @@ export function TransactionNoteEditor({
<button
onClick={() => setIsEditing(true)}
className="text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200"
aria-label="Edit note"
aria-label={t('editNoteAriaLabel')}
>
<Pencil className="h-4 w-4" />
</button>

396
components/UserForm.tsx Normal file
View File

@@ -0,0 +1,396 @@
'use client';
import { createUser, updateUser, updateUserPassword, uploadAvatar } from '@/app/actions/data';
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from "@/components/ui/alert-dialog";
import { toast } from '@/hooks/use-toast';
import { currentUserAtom, serverSettingsAtom, usersAtom } from '@/lib/atoms';
import { Permission } from '@/lib/types';
import { passwordSchema, usernameSchema } from '@/lib/zod';
import { useAtom, useAtomValue } from 'jotai';
import _ from 'lodash';
import { User as UserIcon } from 'lucide-react';
import { useTranslations } from 'next-intl';
import { useState } from 'react';
import { PermissionSelector } from './PermissionSelector';
import { Avatar, AvatarFallback, AvatarImage } from './ui/avatar';
import { Button } from './ui/button';
import { Input } from './ui/input';
import { Label } from './ui/label';
import { Switch } from './ui/switch';
interface UserFormProps {
userId?: string; // if provided, we're editing; if not, we're creating
onCancel: () => void;
onSuccess: () => void;
}
export default function UserForm({ userId, onCancel, onSuccess }: UserFormProps) {
const t = useTranslations('UserForm');
const [users, setUsersData] = useAtom(usersAtom);
const serverSettings = useAtomValue(serverSettingsAtom)
const user = userId ? users.users.find(u => u.id === userId) : undefined;
const [currentUser] = useAtom(currentUserAtom)
const getDefaultPermissions = (): Permission[] => [{
habit: {
write: true,
interact: true
},
wishlist: {
write: true,
interact: true
},
coins: {
write: true,
interact: true
}
}];
const [avatarPath, setAvatarPath] = useState(user?.avatarPath)
const [username, setUsername] = useState(user?.username || '');
const [password, setPassword] = useState<string | undefined>('');
const [disablePassword, setDisablePassword] = useState(user?.password === '' || serverSettings.isDemo);
const [error, setError] = useState('');
const [avatarFile, setAvatarFile] = useState<File | null>(null);
const [isAdmin, setIsAdmin] = useState(user?.isAdmin || false);
const [permissions, setPermissions] = useState<Permission[]>(
user?.permissions || getDefaultPermissions()
);
const isEditing = !!user;
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
const [isDeleting, setIsDeleting] = useState(false);
const handleDeleteUser = async () => {
if (!user) return;
if (serverSettings.isDemo) {
toast({
title: t('errorTitle'),
description: t('toastDemoDeleteDisabled'),
variant: 'destructive',
});
return;
}
if (currentUser && currentUser.id === user.id) {
toast({
title: t('errorTitle'),
description: t('toastCannotDeleteSelf'),
variant: 'destructive',
});
return;
}
setIsDeleting(true);
try {
const response = await fetch('/api/user/delete', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ userId: user.id }),
});
if (response.ok) {
setUsersData(prev => ({
...prev,
users: prev.users.filter(u => u.id !== user.id),
}));
toast({
title: t('toastUserDeletedTitle'),
description: t('toastUserDeletedDescription', { username: user.username }),
variant: 'default'
});
onSuccess();
} else {
const errorData = await response.json();
toast({
title: t('errorTitle'),
description: errorData.error || t('genericError'),
variant: 'destructive',
});
}
} catch (error) {
toast({
title: t('errorTitle'),
description: t('networkError'),
variant: 'destructive',
});
} finally {
setIsDeleting(false);
setShowDeleteConfirm(false);
}
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
try {
// Validate username
const usernameResult = usernameSchema.safeParse(username);
if (!usernameResult.success) {
setError(usernameResult.error.errors[0].message);
return;
}
// Validate password unless disabled
if (!disablePassword && password) {
const passwordResult = passwordSchema.safeParse(password);
if (!passwordResult.success) {
setError(passwordResult.error.errors[0].message);
return;
}
}
if (isEditing) {
// Update existing user
if (username !== user.username || avatarPath !== user.avatarPath || !_.isEqual(permissions, user.permissions) || isAdmin !== user.isAdmin) {
await updateUser(user.id, { username, avatarPath, permissions, isAdmin });
}
// Handle password update
if (disablePassword) {
await updateUserPassword(user.id, undefined);
} else if (password) {
await updateUserPassword(user.id, password);
}
setUsersData(prev => ({
...prev,
users: prev.users.map(u =>
u.id === user.id ? {
...u,
username,
avatarPath,
permissions,
isAdmin,
password: disablePassword ? '' : (password || u.password) // use the correct password to update atom
} : u
),
}));
toast({
title: t('toastUserUpdatedTitle'),
description: t('toastUserUpdatedDescription', { username }),
variant: 'default'
});
} else {
// Create new user
const formData = new FormData();
formData.append('username', username);
if (disablePassword) {
formData.append('password', '');
} else if (password) {
formData.append('password', password);
}
formData.append('permissions', JSON.stringify(isAdmin ? undefined : permissions));
formData.append('isAdmin', JSON.stringify(isAdmin));
formData.append('avatarPath', avatarPath || '');
const newUser = await createUser(formData);
setUsersData(prev => ({
...prev,
users: [...prev.users, newUser]
}));
toast({
title: t('toastUserCreatedTitle'),
description: t('toastUserCreatedDescription', { username }),
variant: 'default'
});
}
setPassword('');
setError('');
onSuccess();
} catch (err) {
const action = isEditing ? t('actionUpdate') : t('actionCreate');
setError(err instanceof Error ? err.message : t('errorFailedUserAction', { action }));
}
};
const handleAvatarChange = async (file: File) => {
if (file.size > 5 * 1024 * 1024) { // 5MB
toast({
title: t('errorTitle'),
description: t('errorFileSizeLimit'),
variant: 'destructive'
});
return;
}
const formData = new FormData();
formData.append('avatar', file);
try {
const path = await uploadAvatar(formData);
setAvatarPath(path);
setAvatarFile(null); // Clear the file since we've uploaded it
toast({
title: t('toastAvatarUploadedTitle'),
description: t('toastAvatarUploadedDescription'),
variant: 'default'
});
} catch (err) {
toast({
title: t('errorTitle'),
description: t('errorFailedAvatarUpload'),
variant: 'destructive'
});
}
};
return (
<form onSubmit={handleSubmit} className="space-y-6 max-h-[80vh] overflow-y-auto p-4">
<div className="flex flex-col items-center gap-4 p-4 bg-muted/50 rounded-lg">
<Avatar className="h-24 w-24">
<AvatarImage
src={avatarPath && `/api/avatars/${avatarPath.split('/').pop()}`}
alt={username}
/>
<AvatarFallback>
<UserIcon className="h-12 w-12" />
</AvatarFallback>
</Avatar>
<div>
<input
type="file"
id="avatar"
name="avatar"
accept="image/png, image/jpeg"
className="hidden"
onChange={(e) => {
const file = e.target.files?.[0];
if (file) {
handleAvatarChange(file);
}
}}
/>
<Button
type="button"
variant="secondary"
onClick={() => {
const input = document.getElementById('avatar') as HTMLInputElement;
input.value = ''; // Reset input to allow selecting same file again
input.click();
}}
className="w-full"
>
{isEditing ? t('changeAvatarButton') : t('uploadAvatarButton')}
</Button>
</div>
</div>
<div className="space-y-4">
<div className="space-y-2">
<Label htmlFor="username">{t('usernameLabel')}</Label>
<Input
id="username"
type="text"
placeholder={t('usernamePlaceholder')}
value={username}
onChange={(e) => setUsername(e.target.value)}
className={error ? 'border-red-500' : ''}
/>
</div>
<div className="space-y-4">
<div className="space-y-2">
<Label htmlFor="password">
{isEditing ? t('newPasswordLabel') : t('passwordLabel')}
</Label>
<Input
id="password"
type="password"
placeholder={isEditing ? t('passwordPlaceholderEdit') : t('passwordPlaceholderCreate')}
value={password || ''}
onChange={(e) => setPassword(e.target.value)}
className={error ? 'border-red-500' : ''}
disabled={disablePassword}
/>
{serverSettings.isDemo && (
<p className="text-sm text-red-500">{t('demoPasswordDisabledMessage')}</p>
)}
</div>
<div className="flex items-center space-x-2">
<Switch
id="disable-password"
checked={disablePassword}
onCheckedChange={setDisablePassword}
disabled={serverSettings.isDemo}
/>
<Label htmlFor="disable-password">{t('disablePasswordLabel')}</Label>
</div>
</div>
{error && (
<p className="text-sm text-red-500 bg-red-50 dark:bg-red-950/50 p-2 rounded">{error}</p>
)}
{currentUser && currentUser.isAdmin && <PermissionSelector
permissions={permissions}
isAdmin={isAdmin}
onPermissionsChange={setPermissions}
onAdminChange={setIsAdmin}
/>}
</div>
<div className="flex justify-end gap-2 pt-2">
{isEditing && (
<AlertDialog open={showDeleteConfirm} onOpenChange={setShowDeleteConfirm}>
<AlertDialogTrigger asChild>
<Button
type="button"
variant="destructive"
className="mr-auto"
disabled={serverSettings.isDemo || isDeleting}
>
{isDeleting ? t('deletingButtonText') : t('deleteAccountButton')}
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>{t('areYouSure')}</AlertDialogTitle>
<AlertDialogDescription>
{t('deleteUserConfirmation', { username: user.username })}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={isDeleting}>{t('cancel')}</AlertDialogCancel>
<AlertDialogAction
onClick={handleDeleteUser}
disabled={isDeleting}
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
>
{isDeleting ? t('deletingButtonText') : t('confirmDeleteButtonText')}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
)}
<Button
type="button"
variant="outline"
onClick={onCancel}
>
{t('cancelButton')}
</Button>
<Button type="submit" disabled={!username}>
{isEditing ? t('saveChangesButton') : t('createUserButton')}
</Button>
</div>
</form>
);
}

View File

@@ -0,0 +1,222 @@
'use client';
import { signIn } from '@/app/actions/user';
import { toast } from '@/hooks/use-toast';
import { currentUserAtom, usersAtom } from '@/lib/atoms';
import { SafeUser, User } from '@/lib/types';
import { cn } from '@/lib/utils';
import { Description } from '@radix-ui/react-dialog';
import { useAtom } from 'jotai';
import { Crown, Plus, User as UserIcon, UserRoundPen } from 'lucide-react';
import { useTranslations } from 'next-intl';
import { useState } from 'react';
import PasswordEntryForm from './PasswordEntryForm';
import UserForm from './UserForm';
import { Avatar, AvatarFallback, AvatarImage } from './ui/avatar';
import { Dialog, DialogContent, DialogHeader, DialogTitle } from './ui/dialog';
function UserCard({
user,
onSelect,
onEdit,
showEdit,
isCurrentUser,
}: {
user: User,
onSelect: () => void,
onEdit: () => void,
showEdit: boolean,
isCurrentUser: boolean,
}) {
const t = useTranslations('UserSelectModal');
return (
<div key={user.id} className="relative group">
<button
onClick={onSelect}
className={cn(
"flex flex-col items-center gap-2 p-2 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors w-full",
isCurrentUser && "ring-2 ring-primary"
)}
>
<Avatar className="h-16 w-16">
<AvatarImage
src={user.avatarPath && `/api/avatars/${user.avatarPath.split('/').pop()}`}
alt={user.username}
/>
<AvatarFallback>
<UserIcon className="h-8 w-8" />
</AvatarFallback>
</Avatar>
<span className="text-sm font-medium flex items-center gap-1">
{user.username}
{user.isAdmin && <Crown className="h-4 w-4 text-yellow-500" />}
</span>
</button>
{showEdit && (
<div className="absolute top-0 right-0 flex space-x-1">
{showEdit && (
<button
onClick={(e) => {
e.stopPropagation(); // Prevent card selection
onEdit();
}}
className="p-1 rounded-full bg-gray-200 hover:bg-gray-300 dark:bg-gray-700 dark:hover:bg-gray-600 transition-colors"
title={t('editUserTooltip')}
>
<UserRoundPen className="h-4 w-4" />
</button>
)}
</div>
)}
</div>
);
}
function AddUserButton({ onClick }: { onClick: () => void }) {
const t = useTranslations('UserSelectModal');
return (
<button
onClick={onClick}
className="flex flex-col items-center gap-2 p-2 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors"
>
<Avatar className="h-16 w-16">
<AvatarFallback>
<Plus className="h-8 w-8" />
</AvatarFallback>
</Avatar>
<span className="text-sm font-medium">{t('addUserButton')}</span>
</button>
);
}
function UserSelectionView({
users,
currentUserFromHook, // Renamed to avoid confusion with map variable
onUserSelect,
onEditUser,
onCreateUser,
}: {
users: User[],
currentUserFromHook?: SafeUser,
onUserSelect: (userId: string) => void,
onEditUser: (userId: string) => void,
onCreateUser: () => void,
}) {
return (
<div className="grid grid-cols-3 gap-4 p-2 max-h-80 overflow-y-auto">
{users
.filter(user => user.id !== currentUserFromHook?.id) // Show other users
.map((user) => (
<UserCard
key={user.id}
user={user}
onSelect={() => onUserSelect(user.id)}
onEdit={() => onEditUser(user.id)}
showEdit={!!currentUserFromHook?.isAdmin}
isCurrentUser={false} // This card isn't the currently logged-in user for switching TO
/>
))}
{currentUserFromHook?.isAdmin && <AddUserButton onClick={onCreateUser} />}
</div>
);
}
export default function UserSelectModal({ onClose }: { onClose: () => void }) {
const t = useTranslations('UserSelectModal');
const [selectedUser, setSelectedUser] = useState<string>();
const [isCreating, setIsCreating] = useState(false);
const [isEditing, setIsEditing] = useState(false);
const [error, setError] = useState('');
const [usersData, setUsersData] = useAtom(usersAtom);
const users = usersData.users;
const [currentUser] = useAtom(currentUserAtom);
const handleUserSelect = (userId: string) => {
setSelectedUser(userId);
setError('');
};
const handleEditUser = (userId: string) => {
setSelectedUser(userId);
setIsEditing(true);
};
const handleCreateUser = () => {
setIsCreating(true);
};
const handleFormSuccess = () => {
setSelectedUser(undefined);
setIsCreating(false);
setIsEditing(false);
onClose();
};
const handleFormCancel = () => {
setSelectedUser(undefined);
setIsCreating(false);
setIsEditing(false);
setError('');
};
return (
<Dialog open={true} onOpenChange={onClose}>
<DialogContent className="sm:max-w-md">
<Description></Description>
<DialogHeader>
<DialogTitle>{isCreating ? t('createNewUserTitle') : t('selectUserTitle')}</DialogTitle>
</DialogHeader>
<div className="flex flex-col gap-4">
{!selectedUser && !isCreating && !isEditing ? (
<UserSelectionView
users={users}
currentUserFromHook={currentUser}
onUserSelect={handleUserSelect}
onEditUser={handleEditUser}
onCreateUser={handleCreateUser}
/>
) : isCreating || isEditing ? (
<UserForm
userId={isEditing ? selectedUser : undefined}
onCancel={handleFormCancel}
onSuccess={handleFormSuccess}
/>
) : (
<PasswordEntryForm
user={users.find(u => u.id === selectedUser)!}
onCancel={() => setSelectedUser(undefined)}
onSubmit={async (password) => {
try {
setError('');
const user = users.find(u => u.id === selectedUser);
if (!user) throw new Error("User not found");
await signIn(user.username, password);
setError('');
onClose();
toast({
title: t('signInSuccessTitle'),
description: t('signInSuccessDescription', { username: user.username }),
variant: "default"
});
setTimeout(() => window.location.reload(), 300);
} catch (err) {
setError(t('errorInvalidPassword'));
throw err;
}
}}
error={error}
/>
)}
</div>
</DialogContent>
</Dialog>
);
}

73
components/ViewToggle.tsx Normal file
View File

@@ -0,0 +1,73 @@
'use client'
import { browserSettingsAtom, habitsAtom, settingsAtom } from '@/lib/atoms'
import { HabitIcon, TaskIcon } from '@/lib/constants'
import { cn, isHabitDueToday } from '@/lib/utils'
import { useAtom } from 'jotai'
import { useTranslations } from 'next-intl'
import { usePathname, useRouter } from 'next/navigation'
import { NotificationBadge } from './ui/notification-badge'
interface ViewToggleProps {
className?: string
}
export function ViewToggle({
className
}: ViewToggleProps) {
const t = useTranslations('ViewToggle')
const [browserSettings, setBrowserSettings] = useAtom(browserSettingsAtom)
const [habits] = useAtom(habitsAtom)
const [settings] = useAtom(settingsAtom)
const pathname = usePathname();
const router = useRouter();
const handleViewChange = () => {
router.push(pathname.includes("habits") ? "/tasks" : "/habits");
}
// Calculate due tasks count
const dueTasksCount = habits.habits.filter(habit =>
habit.isTask && isHabitDueToday({ habit, timezone: settings.system.timezone })
).length
return (
<div className={cn('inline-flex rounded-full bg-muted/50 h-8', className)}>
<div className="relative flex gap-0.5 rounded-full bg-background p-0.5 h-full">
<button
onClick={handleViewChange}
className={cn(
'relative z-10 rounded-full px-4 py-2 text-sm font-medium transition-colors flex items-center gap-2',
pathname.includes('habits') ? 'text-primary-foreground' : 'text-muted-foreground hover:text-foreground'
)}
>
<HabitIcon className="h-4 w-4" />
<span className="hidden sm:inline">{t('habitsLabel')}</span>
</button>
<NotificationBadge
label={dueTasksCount}
show={dueTasksCount > 0}
variant={pathname.includes('tasks') ? 'secondary' : 'default'}
className="shadow-md"
>
<button
onClick={handleViewChange}
className={cn(
'relative z-10 rounded-full px-4 py-2 text-sm font-medium transition-colors flex items-center gap-2',
pathname.includes('tasks') ? 'text-primary-foreground' : 'text-muted-foreground hover:text-foreground'
)}
>
<TaskIcon className="h-4 w-4" />
<span className="hidden sm:inline">{t('tasksLabel')}</span>
</button>
</NotificationBadge>
<div
className={cn(
'absolute left-0.5 top-0.5 h-[calc(100%-0.25rem)] rounded-full bg-primary transition-transform',
pathname.includes('habits') ? 'w-[calc(50%-0.125rem)]' : 'w-[calc(50%-0.125rem)] translate-x-[calc(100%+0.125rem)]'
)}
/>
</div>
</div>
)
}

View File

@@ -1,8 +1,5 @@
import { WishlistItemType } from '@/lib/types'
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from '@/components/ui/card'
import ReactMarkdown from 'react-markdown'
import { Button } from '@/components/ui/button'
import { Coins, Edit, Trash2, Gift, MoreVertical } from 'lucide-react'
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from '@/components/ui/card'
import {
DropdownMenu,
DropdownMenuContent,
@@ -10,100 +7,185 @@ import {
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu'
import { currentUserAtom, usersAtom } from '@/lib/atoms'
import { User, WishlistItemType } from '@/lib/types'
import { hasPermission } from '@/lib/utils'
import { useAtom } from 'jotai'
import { useTranslations } from 'next-intl'
import DrawingDisplay from './DrawingDisplay'
import { Avatar, AvatarFallback, AvatarImage } from './ui/avatar'
import { Archive, ArchiveRestore, Coins, Edit, Gift, MoreVertical, Trash2 } from 'lucide-react'
interface WishlistItemProps {
item: WishlistItemType
onEdit: () => void
onDelete: () => void
onRedeem: () => void
onArchive: () => void
onUnarchive: () => void
canRedeem: boolean
isHighlighted?: boolean
isRecentlyRedeemed?: boolean
isArchived?: boolean
}
const renderUserAvatars = (item: WishlistItemType, currentUser: User | null, usersData: { users: User[] }) => {
if (!item.userIds || item.userIds.length <= 1) return <></>;
return (
<div className="flex -space-x-2 ml-2 flex-shrink-0">
{item.userIds?.filter((u) => u !== currentUser?.id).map(userId => {
const user = usersData.users.find(u => u.id === userId)
if (!user) return <></>;
return (
<Avatar key={user.id} className="h-6 w-6">
<AvatarImage src={user?.avatarPath && `/api/avatars/${user.avatarPath.split('/').pop()}` || ""} />
<AvatarFallback>{user.username[0]}</AvatarFallback>
</Avatar>
)
})}
</div>
);
};
export default function WishlistItem({
item,
onEdit,
onDelete,
onRedeem,
onArchive,
onUnarchive,
canRedeem,
isHighlighted,
isRecentlyRedeemed
}: WishlistItemProps) {
const t = useTranslations('WishlistItem')
const [currentUser] = useAtom(currentUserAtom)
const canWrite = hasPermission(currentUser, 'wishlist', 'write')
const canInteract = hasPermission(currentUser, 'wishlist', 'interact')
const [usersData] = useAtom(usersAtom)
return (
<Card
id={`wishlist-${item.id}`}
className={`h-full flex flex-col transition-all duration-500 ${isHighlighted ? 'bg-yellow-100 dark:bg-yellow-900' : ''
} ${isRecentlyRedeemed ? 'animate-[celebrate_1s_ease-in-out] shadow-lg ring-2 ring-primary' : ''
}`}
} ${item.archived ? 'opacity-75' : ''}`}
>
<CardHeader className="flex-none">
<CardTitle className="line-clamp-1">{item.name}</CardTitle>
{item.description && (
<CardDescription className="whitespace-pre-line">
{item.description}
</CardDescription>
<CardHeader className="flex-shrink-0">
<div className="flex justify-between items-start">
<div className="flex items-center gap-2 flex-1 min-w-0">
<CardTitle className={`line-clamp-1 ${item.archived ? 'text-gray-400 dark:text-gray-500' : ''}`}>
{item.name}
</CardTitle>
{item.targetCompletions && (
<span className="text-sm text-gray-500 dark:text-gray-400 flex-shrink-0">
({item.targetCompletions === 1 ? t('usesLeftSingular') : t('usesLeftPlural', { count: item.targetCompletions })})
</span>
)}
</div>
{renderUserAvatars(item, currentUser as User, usersData)}
</div>
{(item.description || item.drawing) && (
<div className={`flex gap-4 mt-2 ${!item.description ? 'justify-end' : ''}`}>
{item.description && (
<CardDescription className={`whitespace-pre-line flex-1 min-w-0 break-words ${item.archived ? 'text-gray-400 dark:text-gray-500' : ''}`}>
{item.description}
</CardDescription>
)}
{item.drawing && (
<div className="flex-shrink-0">
<DrawingDisplay
drawingData={item.drawing}
width={120}
height={80}
className=""
/>
</div>
)}
</div>
)}
</CardHeader>
<CardContent className="flex-1">
<div className="flex items-center">
<Coins className="h-4 w-4 text-yellow-400 mr-1" />
<span className="text-sm font-medium">{item.coinCost} coins</span>
<CardContent className="flex-grow flex flex-col justify-end">
<div className="mt-auto">
<div className="flex items-center gap-2">
<Coins className={`h-4 w-4 ${item.archived ? 'text-gray-400 dark:text-gray-500' : 'text-yellow-400'}`} />
<span className={`text-sm font-medium ${item.archived ? 'text-gray-400 dark:text-gray-500' : ''}`}>
{item.coinCost} {t('coinsSuffix')}
</span>
</div>
</div>
</CardContent>
<CardFooter className="flex justify-between gap-2">
<CardFooter className="flex-shrink-0 flex justify-between gap-2">
<div className="flex gap-2">
<Button
variant={canRedeem ? "default" : "secondary"}
size="sm"
onClick={onRedeem}
disabled={!canRedeem}
className={`transition-all duration-300 w-24 sm:w-auto ${isRecentlyRedeemed ? 'bg-green-500 hover:bg-green-600' : ''}`}
disabled={!canRedeem || !canInteract || item.archived}
className={`transition-all duration-300 w-24 sm:w-auto ${isRecentlyRedeemed ? 'bg-green-500 hover:bg-green-600' : ''} ${item.archived ? 'cursor-not-allowed' : ''}`}
>
<Gift className={`h-4 w-4 sm:mr-2 ${isRecentlyRedeemed ? 'animate-spin' : ''}`} />
<span>
{isRecentlyRedeemed ? (
<>
<span className="sm:hidden">Done</span>
<span className="hidden sm:inline">Redeemed!</span>
<span className="sm:hidden">{t('redeemedDone')}</span>
<span className="hidden sm:inline">{t('redeemedExclamation')}</span>
</>
) : (
<>
<span className="sm:hidden">Redeem</span>
<span className="hidden sm:inline">Redeem</span>
<span className="sm:hidden">{t('redeem')}</span>
<span className="hidden sm:inline">{t('redeem')}</span>
</>
)}
</span>
</Button>
</div>
<div className="flex gap-2">
<Button
variant="edit"
size="sm"
onClick={onEdit}
className="hidden sm:flex"
>
<Edit className="h-4 w-4" />
<span className="ml-2">Edit</span>
</Button>
<DropdownMenu>
{!item.archived && (
<Button
variant="edit"
size="sm"
onClick={onEdit}
disabled={!canWrite}
className="hidden sm:flex"
>
<Edit className="h-4 w-4" />
<span className="ml-2">{t('editButton')}</span>
</Button>
)}
<DropdownMenu modal={false}>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="sm" className="h-8 w-8 p-0">
<MoreVertical className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
{!item.archived && (
<DropdownMenuItem disabled={!canWrite} onClick={onArchive}>
<Archive className="mr-2 h-4 w-4" />
<span>{t('archiveButton')}</span>
</DropdownMenuItem>
)}
{item.archived && (
<DropdownMenuItem disabled={!canWrite} onClick={onUnarchive}>
<ArchiveRestore className="mr-2 h-4 w-4" />
<span>{t('unarchiveButton')}</span>
</DropdownMenuItem>
)}
<DropdownMenuItem onClick={onEdit} className="sm:hidden">
<Edit className="mr-2 h-4 w-4" />
Edit
{t('editButton')}
</DropdownMenuItem>
<DropdownMenuSeparator className="sm:hidden" />
<DropdownMenuItem
className="text-red-600 focus:text-red-600 dark:text-red-400 dark:focus:text-red-400 cursor-pointer"
className="text-red-600 focus:text-red-600 dark:text-red-400 dark:focus:text-red-400"
onClick={onDelete}
disabled={!canWrite}
>
<Trash2 className="mr-2 h-4 w-4" />
Delete
{t('deleteButton')}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
@@ -112,4 +194,3 @@ export default function WishlistItem({
</Card>
)
}

View File

@@ -2,6 +2,7 @@
import { useState, useEffect, useRef } from 'react'
import { useWishlist } from '@/hooks/useWishlist'
import { useTranslations } from 'next-intl'
import { Plus, Gift } from 'lucide-react'
import EmptyState from './EmptyState'
import { Button } from '@/components/ui/button'
@@ -9,17 +10,25 @@ import WishlistItem from './WishlistItem'
import AddEditWishlistItemModal from './AddEditWishlistItemModal'
import ConfirmDialog from './ConfirmDialog'
import { WishlistItemType } from '@/lib/types'
import { openWindow } from '@/lib/utils'
import { toast } from '@/hooks/use-toast'
export default function WishlistManager() {
const t = useTranslations('WishlistManager')
const {
addWishlistItem,
editWishlistItem,
deleteWishlistItem,
redeemWishlistItem,
archiveWishlistItem,
unarchiveWishlistItem,
canRedeem,
wishlistItems
} = useWishlist()
const activeItems = wishlistItems.filter(item => !item.archived)
const archivedItems = wishlistItems.filter(item => item.archived)
const [highlightedItemId, setHighlightedItemId] = useState<string | null>(null)
const [recentlyRedeemedId, setRecentlyRedeemedId] = useState<string | null>(null)
const [isModalOpen, setIsModalOpen] = useState(false)
@@ -57,28 +66,41 @@ export default function WishlistManager() {
setTimeout(() => {
setRecentlyRedeemedId(null)
}, 3000)
if (item.link) {
setTimeout(() => {
const opened = openWindow(item.link!)
if (!opened) {
toast({
title: t('popupBlockedTitle'),
description: t('popupBlockedDescription'),
variant: "destructive"
})
}
}, 300)
}
}
}
return (
<div className="container mx-auto px-4 py-8">
<div>
<div className="flex justify-between items-center mb-6">
<h1 className="text-3xl font-bold">My Wishlist</h1>
<h1 className="text-xl xs:text-3xl font-bold">{t('title')}</h1>
<Button onClick={() => setIsModalOpen(true)}>
<Plus className="mr-2 h-4 w-4" /> Add Reward
<Plus className="mr-2 h-4 w-4" /> {t('addRewardButton')}
</Button>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 items-stretch">
{wishlistItems.length === 0 ? (
<div className="col-span-2">
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4 items-stretch">
{activeItems.length === 0 ? (
<div className="col-span-1 lg:col-span-2">
<EmptyState
icon={Gift}
title="Your wishlist is empty"
description="Add rewards that you'd like to earn with your coins"
title={t('emptyStateTitle')}
description={t('emptyStateDescription')}
/>
</div>
) : (
wishlistItems.map((item) => (
activeItems.map((item) => (
<div
key={item.id}
ref={(el) => {
@@ -97,29 +119,48 @@ export default function WishlistManager() {
}}
onDelete={() => setDeleteConfirmation({ isOpen: true, itemId: item.id })}
onRedeem={() => handleRedeem(item)}
onArchive={() => archiveWishlistItem(item.id)}
onUnarchive={() => unarchiveWishlistItem(item.id)}
canRedeem={canRedeem(item.coinCost)}
/>
</div>
))
)}
{archivedItems.length > 0 && (
<>
<div className="col-span-1 lg:col-span-2 relative flex items-center my-6">
<div className="flex-grow border-t border-gray-300 dark:border-gray-600" />
<span className="mx-4 text-sm text-gray-500 dark:text-gray-400">{t('archivedSectionTitle')}</span>
<div className="flex-grow border-t border-gray-300 dark:border-gray-600" />
</div>
{archivedItems.map((item) => (
<WishlistItem
key={item.id}
item={item}
onEdit={() => {
setEditingItem(item)
setIsModalOpen(true)
}}
onDelete={() => setDeleteConfirmation({ isOpen: true, itemId: item.id })}
onRedeem={() => handleRedeem(item)}
onArchive={() => archiveWishlistItem(item.id)}
onUnarchive={() => unarchiveWishlistItem(item.id)}
canRedeem={canRedeem(item.coinCost)}
/>
))}
</>
)}
</div>
<AddEditWishlistItemModal
isOpen={isModalOpen}
onClose={() => {
setIsModalOpen(false)
setEditingItem(null)
}}
onSave={(item) => {
if (editingItem) {
editWishlistItem({ ...item, id: editingItem.id })
} else {
addWishlistItem(item)
}
setIsModalOpen(false)
setEditingItem(null)
}}
item={editingItem}
/>
{isModalOpen &&
<AddEditWishlistItemModal
setIsOpen={setIsModalOpen}
editingItem={editingItem}
setEditingItem={setEditingItem}
addWishlistItem={addWishlistItem}
editWishlistItem={editWishlistItem}
/>
}
<ConfirmDialog
isOpen={deleteConfirmation.isOpen}
onClose={() => setDeleteConfirmation({ isOpen: false, itemId: null })}
@@ -129,9 +170,9 @@ export default function WishlistManager() {
}
setDeleteConfirmation({ isOpen: false, itemId: null })
}}
title="Delete Reward"
message="Are you sure you want to delete this reward? This action cannot be undone."
confirmText="Delete"
title={t('deleteDialogTitle')}
message={t('deleteDialogMessage')}
confirmText={t('deleteButton')}
/>
</div>
)

View File

@@ -1,65 +0,0 @@
"use client"
import * as React from "react"
import { ChevronLeft, ChevronRight } from 'lucide-react'
import { DayPicker } from "react-day-picker"
import { cn } from "@/lib/utils"
import { buttonVariants } from "@/components/ui/button"
export type CalendarProps = React.ComponentProps<typeof DayPicker>
function Calendar({
className,
classNames,
showOutsideDays = true,
...props
}: CalendarProps) {
return (
<DayPicker
showOutsideDays={showOutsideDays}
className={cn("p-3", className)}
classNames={{
months: "flex flex-col sm:flex-row space-y-4 sm:space-x-4 sm:space-y-0",
month: "space-y-4",
caption: "flex justify-center pt-1 relative items-center",
caption_label: "text-sm font-medium",
nav: "space-x-1 flex items-center",
nav_button: cn(
buttonVariants({ variant: "outline" }),
"h-7 w-7 bg-transparent p-0 opacity-50 hover:opacity-100"
),
nav_button_previous: "absolute left-1",
nav_button_next: "absolute right-1",
table: "w-full border-collapse space-y-1",
head_row: "flex",
head_cell:
"text-muted-foreground rounded-md w-9 font-normal text-[0.8rem]",
row: "flex w-full mt-2",
cell: "text-center text-sm p-0 relative [&:has([aria-selected])]:bg-accent first:[&:has([aria-selected])]:rounded-l-md last:[&:has([aria-selected])]:rounded-r-md focus-within:relative focus-within:z-20",
day: cn(
buttonVariants({ variant: "ghost" }),
"h-9 w-9 p-0 font-normal aria-selected:opacity-100"
),
day_selected:
"bg-primary text-primary-foreground hover:bg-primary hover:text-primary-foreground focus:bg-primary focus:text-primary-foreground",
day_today: "bg-accent text-accent-foreground",
day_outside: "text-muted-foreground opacity-50",
day_disabled: "text-muted-foreground opacity-50",
day_range_middle:
"aria-selected:bg-accent aria-selected:text-accent-foreground",
day_hidden: "invisible",
...classNames,
}}
components={{
IconLeft: ({ ...props }) => <ChevronLeft className="h-4 w-4" />,
IconRight: ({ ...props }) => <ChevronRight className="h-4 w-4" />,
}}
{...props}
/>
)
}
Calendar.displayName = "Calendar"
export { Calendar }

View File

@@ -1,6 +1,6 @@
'use client'
import { settingsAtom, habitsAtom, coinsAtom, wishlistAtom } from "@/lib/atoms"
import { settingsAtom, habitsAtom, coinsAtom, wishlistAtom, usersAtom, serverSettingsAtom } from "@/lib/atoms"
import { useHydrateAtoms } from "jotai/utils"
import { JotaiHydrateInitialValues } from "@/lib/types"
@@ -12,7 +12,9 @@ export function JotaiHydrate({
[settingsAtom, initialValues.settings],
[habitsAtom, initialValues.habits],
[coinsAtom, initialValues.coins],
[wishlistAtom, initialValues.wishlist]
[wishlistAtom, initialValues.wishlist],
[usersAtom, initialValues.users],
[serverSettingsAtom, initialValues.serverSettings],
])
return children
}

View File

@@ -0,0 +1,11 @@
"use client"
import * as React from "react"
import { ThemeProvider as NextThemesProvider } from "next-themes"
export function ThemeProvider({
children,
...props
}: React.ComponentProps<typeof NextThemesProvider>) {
return <NextThemesProvider {...props}>{children}</NextThemesProvider>
}

View File

@@ -0,0 +1,39 @@
"use client"
import { Moon, Sun } from "lucide-react"
import { useTheme } from "next-themes"
import { Button } from "@/components/ui/button"
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"
export function ThemeToggle() {
const { setTheme } = useTheme()
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" size="icon">
<Sun className="h-[1.2rem] w-[1.2rem] rotate-0 scale-100 transition-all dark:-rotate-90 dark:scale-0" />
<Moon className="absolute h-[1.2rem] w-[1.2rem] rotate-90 scale-0 transition-all dark:rotate-0 dark:scale-100" />
<span className="sr-only">Toggle theme</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => setTheme("light")}>
Light
</DropdownMenuItem>
<DropdownMenuItem onClick={() => setTheme("dark")}>
Dark
</DropdownMenuItem>
<DropdownMenuItem onClick={() => setTheme("system")}>
System
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)
}

View File

@@ -0,0 +1,141 @@
"use client"
import * as React from "react"
import * as AlertDialogPrimitive from "@radix-ui/react-alert-dialog"
import { cn } from "@/lib/utils"
import { buttonVariants } from "@/components/ui/button"
const AlertDialog = AlertDialogPrimitive.Root
const AlertDialogTrigger = AlertDialogPrimitive.Trigger
const AlertDialogPortal = AlertDialogPrimitive.Portal
const AlertDialogOverlay = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Overlay>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Overlay>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Overlay
className={cn(
"fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
className
)}
{...props}
ref={ref}
/>
))
AlertDialogOverlay.displayName = AlertDialogPrimitive.Overlay.displayName
const AlertDialogContent = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Content>
>(({ className, ...props }, ref) => (
<AlertDialogPortal>
<AlertDialogOverlay />
<AlertDialogPrimitive.Content
ref={ref}
className={cn(
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 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-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg",
className
)}
{...props}
/>
</AlertDialogPortal>
))
AlertDialogContent.displayName = AlertDialogPrimitive.Content.displayName
const AlertDialogHeader = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
"flex flex-col space-y-2 text-center sm:text-left",
className
)}
{...props}
/>
)
AlertDialogHeader.displayName = "AlertDialogHeader"
const AlertDialogFooter = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
className
)}
{...props}
/>
)
AlertDialogFooter.displayName = "AlertDialogFooter"
const AlertDialogTitle = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Title>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Title>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Title
ref={ref}
className={cn("text-lg font-semibold", className)}
{...props}
/>
))
AlertDialogTitle.displayName = AlertDialogPrimitive.Title.displayName
const AlertDialogDescription = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Description>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Description>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Description
ref={ref}
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
))
AlertDialogDescription.displayName =
AlertDialogPrimitive.Description.displayName
const AlertDialogAction = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Action>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Action>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Action
ref={ref}
className={cn(buttonVariants(), className)}
{...props}
/>
))
AlertDialogAction.displayName = AlertDialogPrimitive.Action.displayName
const AlertDialogCancel = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Cancel>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Cancel>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Cancel
ref={ref}
className={cn(
buttonVariants({ variant: "outline" }),
"mt-2 sm:mt-0",
className
)}
{...props}
/>
))
AlertDialogCancel.displayName = AlertDialogPrimitive.Cancel.displayName
export {
AlertDialog,
AlertDialogPortal,
AlertDialogOverlay,
AlertDialogTrigger,
AlertDialogContent,
AlertDialogHeader,
AlertDialogFooter,
AlertDialogTitle,
AlertDialogDescription,
AlertDialogAction,
AlertDialogCancel,
}

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

@@ -0,0 +1,61 @@
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 p-4 [&>svg~*]:pl-7 [&>svg+div]:translate-y-[-3px] [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4 [&>svg]:text-foreground",
{
variants: {
variant: {
default: "bg-background text-foreground",
destructive:
"border-destructive/50 text-destructive dark:border-destructive [&>svg]:text-destructive",
warning:
"border-yellow-200 bg-yellow-50 text-yellow-800 dark:border-yellow-800 dark:bg-yellow-950 dark:text-yellow-200 [&>svg]:text-yellow-600 dark:[&>svg]:text-yellow-400",
},
},
defaultVariants: {
variant: "default",
},
}
)
const Alert = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement> & VariantProps<typeof alertVariants>
>(({ className, variant, ...props }, ref) => (
<div
ref={ref}
role="alert"
className={cn(alertVariants({ variant }), className)}
{...props}
/>
))
Alert.displayName = "Alert"
const AlertTitle = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLHeadingElement>
>(({ className, ...props }, ref) => (
<h5
ref={ref}
className={cn("mb-1 font-medium leading-none tracking-tight", className)}
{...props}
/>
))
AlertTitle.displayName = "AlertTitle"
const AlertDescription = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLParagraphElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("text-sm [&_p]:leading-relaxed", className)}
{...props}
/>
))
AlertDescription.displayName = "AlertDescription"
export { Alert, AlertTitle, AlertDescription }

View File

@@ -73,7 +73,7 @@ const ChartStyle = ({ id, config }: { id: string; config: ChartConfig }) => {
)
if (!colorConfig.length) {
return null
return <></>;
}
return (
@@ -135,7 +135,7 @@ const ChartTooltipContent = React.forwardRef<
const tooltipLabel = React.useMemo(() => {
if (hideLabel || !payload?.length) {
return null
return <></>;
}
const [item] = payload
@@ -155,7 +155,7 @@ const ChartTooltipContent = React.forwardRef<
}
if (!value) {
return null
return <></>;
}
return <div className={cn("font-medium", labelClassName)}>{value}</div>
@@ -170,7 +170,7 @@ const ChartTooltipContent = React.forwardRef<
])
if (!active || !payload?.length) {
return null
return <></>;
}
const nestLabel = payload.length === 1 && indicator !== "dot"
@@ -273,7 +273,7 @@ const ChartLegendContent = React.forwardRef<
const { config } = useChart()
if (!payload?.length) {
return null
return <></>;
}
return (

View File

@@ -0,0 +1,41 @@
import React from 'react';
import {Badge, BadgeProps} from './badge';
import {cn} from '@/lib/utils';
export interface NotificationBadgeProps extends BadgeProps {
label?: string | number;
show?: boolean;
variant?: 'destructive' | 'default' | 'secondary';
}
export const NotificationBadge = ({
label,
className,
show,
variant = 'destructive',
children,
...props
}: NotificationBadgeProps) => {
const showBadge =
typeof label !== 'undefined' && (typeof show === 'undefined' || show);
return (
<div className='inline-flex relative'>
{children}
{showBadge && (
<Badge
variant={variant}
className={cn(
'absolute rounded-full -top-1.5 -right-1.5 z-20 border h-4 w-4 p-0 flex items-center justify-center text-xs',
typeof label !== 'undefined' && ('' + label).length === 0
? ''
: 'min-w-[1rem]',
className
)}
{...props}
>
{'' + label}
</Badge>
)}
</div>
);
};

View File

@@ -0,0 +1,48 @@
"use client"
import * as React from "react"
import * as ScrollAreaPrimitive from "@radix-ui/react-scroll-area"
import { cn } from "@/lib/utils"
const ScrollArea = React.forwardRef<
React.ElementRef<typeof ScrollAreaPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.Root>
>(({ className, children, ...props }, ref) => (
<ScrollAreaPrimitive.Root
ref={ref}
className={cn("relative overflow-hidden", className)}
{...props}
>
<ScrollAreaPrimitive.Viewport className="h-full w-full rounded-[inherit]">
{children}
</ScrollAreaPrimitive.Viewport>
<ScrollBar />
<ScrollAreaPrimitive.Corner />
</ScrollAreaPrimitive.Root>
))
ScrollArea.displayName = ScrollAreaPrimitive.Root.displayName
const ScrollBar = React.forwardRef<
React.ElementRef<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>,
React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>
>(({ className, orientation = "vertical", ...props }, ref) => (
<ScrollAreaPrimitive.ScrollAreaScrollbar
ref={ref}
orientation={orientation}
className={cn(
"flex touch-none select-none transition-colors",
orientation === "vertical" &&
"h-full w-2.5 border-l border-l-transparent p-[1px]",
orientation === "horizontal" &&
"h-2.5 flex-col border-t border-t-transparent p-[1px]",
className
)}
{...props}
>
<ScrollAreaPrimitive.ScrollAreaThumb className="relative flex-1 rounded-full bg-border" />
</ScrollAreaPrimitive.ScrollAreaScrollbar>
))
ScrollBar.displayName = ScrollAreaPrimitive.ScrollAreaScrollbar.displayName
export { ScrollArea, ScrollBar }

View File

@@ -0,0 +1,31 @@
"use client"
import * as React from "react"
import * as SeparatorPrimitive from "@radix-ui/react-separator"
import { cn } from "@/lib/utils"
const Separator = React.forwardRef<
React.ElementRef<typeof SeparatorPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof SeparatorPrimitive.Root>
>(
(
{ className, orientation = "horizontal", decorative = true, ...props },
ref
) => (
<SeparatorPrimitive.Root
ref={ref}
decorative={decorative}
orientation={orientation}
className={cn(
"shrink-0 bg-border",
orientation === "horizontal" ? "h-[1px] w-full" : "h-full w-[1px]",
className
)}
{...props}
/>
)
)
Separator.displayName = SeparatorPrimitive.Root.displayName
export { Separator }

View File

@@ -1,7 +1,11 @@
services:
habittrove:
image: ghcr.io/manindark/habittrove
container_name: habittrove
ports:
- "3000:3000"
volumes:
- "./data:/app/data" # Use a relative path instead of $(pwd)
image: dohsimpson/habittrove
- "./data:/app/data"
- "./backups:/app/backups"
environment:
- AUTH_SECRET=your-secret-key-here # Replace with your actual secret

77
docs/translation-guide.md Normal file
View File

@@ -0,0 +1,77 @@
# Language Guide
## Adding/Updating Translations
### Adding a New Language
To add a new language translation to HabitTrove:
1. **Create translation file**:
- Copy `messages/en.json` as a template
- Save as `messages/{language-code}.json` (e.g., `ko.json` for Korean)
- Translate all values while preserving keys and placeholder variables like `{username}`, `{count}`, etc.
2. **Validate translation structure**:
```bash
# Ensure JSON is valid
jq empty messages/{language-code}.json
# Compare structure with English (should show no differences)
diff <(jq -S . messages/en.json | jq -r 'keys | sort | .[]') <(jq -S . messages/{language-code}.json | jq -r 'keys | sort | .[]')
```
3. **Add language option to UI**:
- Edit `app/settings/page.tsx`
- Add new `<option value="{language-code}">{Language Name}</option>` in alphabetical order
4. **Update documentation**:
- Add language to README.md supported languages list
- Create new changelog entry with version bump
- Update package.json version
### Example: Adding Korean (한국어)
```bash
# 1. Copy translation file
cp /path/to/ko.json messages/ko.json
# 2. Add to settings page
# Add: <option value="ko">한국어</option>
# 3. Update README.md
# Change: 简体中文, 日본語
# To: 简체中文, 한국어, 日본語
# 4. Add changelog entry
# Create new version section with language addition
# 5. Bump package version
# Update version in package.json
```
### Translation Quality Guidelines
- Use natural, contextually appropriate expressions
- Maintain consistent terminology throughout
- Preserve all placeholder variables exactly: `{username}`, `{count}`, `{target}`, etc.
- Use appropriate formality level for the target language
- Ensure JSON structure matches English file exactly (385 total keys)
### Validation Commands
```bash
# Check JSON validity
jq empty messages/{lang}.json
# Compare key structure
node -e "
const en = require('./messages/en.json');
const target = require('./messages/{lang}.json');
// ... deep key comparison script
"
# Verify placeholder consistency
grep -o '{[^}]*}' messages/en.json | sort | uniq > en_vars.txt
grep -o '{[^}]*}' messages/{lang}.json | sort | uniq > {lang}_vars.txt
diff en_vars.txt {lang}_vars.txt
```

View File

@@ -1,63 +1,162 @@
import { useAtom } from 'jotai'
import { addCoins, removeCoins, saveCoinsData } from '@/app/actions/data';
import { toast } from '@/hooks/use-toast';
import {
coinsAtom,
coinsBalanceAtom,
coinsEarnedTodayAtom,
coinsSpentTodayAtom,
currentUserAtom,
settingsAtom,
totalEarnedAtom,
totalSpentAtom,
coinsSpentTodayAtom,
transactionsTodayAtom
} from '@/lib/atoms'
import { addCoins, removeCoins, saveCoinsData } from '@/app/actions/data'
import { CoinsData } from '@/lib/types'
import { toast } from '@/hooks/use-toast'
transactionsTodayAtom,
usersAtom,
} from '@/lib/atoms';
import { MAX_COIN_LIMIT } from '@/lib/constants';
import { CoinsData } from '@/lib/types';
import { calculateCoinsEarnedToday, calculateCoinsSpentToday, calculateTotalEarned, calculateTotalSpent, calculateTransactionsToday, handlePermissionCheck, roundToInteger } from '@/lib/utils';
import { useAtom } from 'jotai';
import { useTranslations } from 'next-intl';
import { useEffect, useMemo, useState } from 'react';
export function useCoins() {
export function useCoins(options?: { selectedUser?: string }) {
const t = useTranslations('useCoins');
const tCommon = useTranslations('Common');
const [coins, setCoins] = useAtom(coinsAtom)
const [coinsEarnedToday] = useAtom(coinsEarnedTodayAtom)
const [totalEarned] = useAtom(totalEarnedAtom)
const [totalSpent] = useAtom(totalSpentAtom)
const [coinsSpentToday] = useAtom(coinsSpentTodayAtom)
const [transactionsToday] = useAtom(transactionsTodayAtom)
const [settings] = useAtom(settingsAtom)
const [users] = useAtom(usersAtom)
const [currentUser] = useAtom(currentUserAtom)
const [allCoinsData] = useAtom(coinsAtom) // All coin transactions
const [loggedInUserBalance] = useAtom(coinsBalanceAtom) // Balance of the *currently logged-in* user
const [atomCoinsEarnedToday] = useAtom(coinsEarnedTodayAtom);
const [atomTotalEarned] = useAtom(totalEarnedAtom)
const [atomTotalSpent] = useAtom(totalSpentAtom)
const [atomCoinsSpentToday] = useAtom(coinsSpentTodayAtom);
const [atomTransactionsToday] = useAtom(transactionsTodayAtom);
const targetUser = options?.selectedUser ? users.users.find(u => u.id === options.selectedUser) : currentUser
const transactions = useMemo(() => {
return allCoinsData.transactions.filter(t => t.userId === targetUser?.id);
}, [allCoinsData, targetUser?.id]);
const timezone = settings.system.timezone;
const [coinsEarnedToday, setCoinsEarnedToday] = useState(0);
const [totalEarned, setTotalEarned] = useState(0);
const [totalSpent, setTotalSpent] = useState(0);
const [coinsSpentToday, setCoinsSpentToday] = useState(0);
const [transactionsToday, setTransactionsToday] = useState<number>(0);
const [balance, setBalance] = useState(0);
useEffect(() => {
// Calculate other metrics
if (targetUser?.id && targetUser.id === currentUser?.id) {
// If the target user is the currently logged-in user, use the derived atom's value
setCoinsEarnedToday(atomCoinsEarnedToday);
setTotalEarned(atomTotalEarned);
setTotalSpent(atomTotalSpent);
setCoinsSpentToday(atomCoinsSpentToday);
setTransactionsToday(atomTransactionsToday);
setBalance(loggedInUserBalance);
} else if (targetUser?.id) {
// If an admin is viewing another user, calculate their metrics manually
const earnedToday = calculateCoinsEarnedToday(transactions, timezone);
setCoinsEarnedToday(roundToInteger(earnedToday));
const totalEarnedVal = calculateTotalEarned(transactions);
setTotalEarned(roundToInteger(totalEarnedVal));
const totalSpentVal = calculateTotalSpent(transactions);
setTotalSpent(roundToInteger(totalSpentVal));
const spentToday = calculateCoinsSpentToday(transactions, timezone);
setCoinsSpentToday(roundToInteger(spentToday));
setTransactionsToday(calculateTransactionsToday(transactions, timezone)); // This is a count
const calculatedBalance = transactions.reduce((acc, t) => acc + t.amount, 0);
setBalance(roundToInteger(calculatedBalance));
}
}, [
targetUser?.id,
currentUser?.id,
transactions, // Memoized: depends on allCoinsData and targetUser?.id
timezone,
loggedInUserBalance,
atomCoinsEarnedToday,
atomTotalEarned,
atomTotalSpent,
atomCoinsSpentToday,
atomTransactionsToday,
]);
const add = async (amount: number, description: string, note?: string) => {
if (!handlePermissionCheck(currentUser, 'coins', 'write', tCommon)) return <></>;
if (isNaN(amount) || amount <= 0) {
toast({
title: "Invalid amount",
description: "Please enter a valid positive number"
title: t("invalidAmountTitle"),
description: t("invalidAmountDescription")
})
return null
return <></>;
}
if (amount > MAX_COIN_LIMIT) {
toast({
title: t("invalidAmountTitle"),
description: t("maxAmountExceededDescription", { max: MAX_COIN_LIMIT })
})
return <></>;
}
const data = await addCoins(amount, description, 'MANUAL_ADJUSTMENT', undefined, note)
const data = await addCoins({
amount,
description,
type: 'MANUAL_ADJUSTMENT',
note,
userId: targetUser?.id
})
setCoins(data)
toast({ title: "Success", description: `Added ${amount} coins` })
toast({ title: t("successTitle"), description: t("addedCoinsDescription", { amount }) })
return data
}
const remove = async (amount: number, description: string, note?: string) => {
if (!handlePermissionCheck(currentUser, 'coins', 'write', tCommon)) return <></>;
const numAmount = Math.abs(amount)
if (isNaN(numAmount) || numAmount <= 0) {
toast({
title: "Invalid amount",
description: "Please enter a valid positive number"
title: t("invalidAmountTitle"),
description: t("invalidAmountDescription")
})
return null
return <></>;
}
if (numAmount > MAX_COIN_LIMIT) {
toast({
title: t("invalidAmountTitle"),
description: t("maxAmountExceededDescription", { max: MAX_COIN_LIMIT })
})
return <></>;
}
const data = await removeCoins(numAmount, description, 'MANUAL_ADJUSTMENT', undefined, note)
const data = await removeCoins({
amount: numAmount,
description,
type: 'MANUAL_ADJUSTMENT',
note,
userId: targetUser?.id
})
setCoins(data)
toast({ title: "Success", description: `Removed ${numAmount} coins` })
toast({ title: t("successTitle"), description: t("removedCoinsDescription", { amount: numAmount }) })
return data
}
const updateNote = async (transactionId: string, note: string) => {
if (!handlePermissionCheck(currentUser, 'coins', 'write', tCommon)) return <></>;
const transaction = coins.transactions.find(t => t.id === transactionId)
if (!transaction) {
toast({
title: "Error",
description: "Transaction not found"
title: tCommon("errorTitle"),
description: t("transactionNotFoundDescription")
})
return null
return <></>;
}
const updatedTransaction = {
@@ -83,8 +182,8 @@ export function useCoins() {
add,
remove,
updateNote,
balance: coins.balance,
transactions: coins.transactions,
balance,
transactions: transactions,
coinsEarnedToday,
totalEarned,
totalSpent,

View File

@@ -1,18 +1,37 @@
import { useAtom } from 'jotai'
import { habitsAtom, coinsAtom, settingsAtom } from '@/lib/atoms'
import { addCoins, removeCoins, saveHabitsData } from '@/app/actions/data'
import { Habit } from '@/lib/types'
import { getNowInMilliseconds, getTodayInTimezone, isSameDate, t2d, d2t, getNow, getCompletionsForDate } from '@/lib/utils'
import { toast } from '@/hooks/use-toast'
import { ToastAction } from '@/components/ui/toast'
import { toast } from '@/hooks/use-toast'
import { coinsAtom, currentUserAtom, habitFreqMapAtom, habitsAtom, settingsAtom, usersAtom } from '@/lib/atoms'
import { Habit } from '@/lib/types'
import {
d2s,
d2t,
getCompletionsForDate,
getISODate,
getNow,
getTodayInTimezone,
handlePermissionCheck,
isSameDate,
playSound,
t2d
} from '@/lib/utils'
import { useAtom } from 'jotai'
import { Undo2 } from 'lucide-react'
import { DateTime } from 'luxon'
import { useTranslations } from 'next-intl'
export function useHabits() {
const t = useTranslations('useHabits');
const tCommon = useTranslations('Common');
const [usersData] = useAtom(usersAtom)
const [currentUser] = useAtom(currentUserAtom)
const [habitsData, setHabitsData] = useAtom(habitsAtom)
const [coins, setCoins] = useAtom(coinsAtom)
const [settings] = useAtom(settingsAtom)
const [habitFreqMap] = useAtom(habitFreqMapAtom)
const completeHabit = async (habit: Habit) => {
if (!handlePermissionCheck(currentUser, 'habit', 'interact', tCommon)) return
const timezone = settings.system.timezone
const today = getTodayInTimezone(timezone)
@@ -27,17 +46,19 @@ export function useHabits() {
// Check if already completed
if (completionsToday >= target) {
toast({
title: "Already completed",
description: `You've already completed this habit today.`,
title: t("alreadyCompletedTitle"),
description: t("alreadyCompletedDescription"),
variant: "destructive",
})
return null
return
}
// Add new completion
const updatedHabit = {
...habit,
completions: [...habit.completions, d2t({ dateTime: getNow({ timezone }) })]
completions: [...habit.completions, d2t({ dateTime: getNow({ timezone }) })],
// Archive the habit if it's a task and we're about to reach the target
archived: habit.isTask && completionsToday + 1 === target ? true : habit.archived
}
const updatedHabits = habitsData.habits.map(h =>
@@ -45,29 +66,36 @@ export function useHabits() {
)
await saveHabitsData({ habits: updatedHabits })
setHabitsData({ habits: updatedHabits })
// Check if we've now reached the target
const isTargetReached = completionsToday + 1 === target
if (isTargetReached) {
const updatedCoins = await addCoins(
habit.coinReward,
`Completed habit: ${habit.name}`,
'HABIT_COMPLETION',
habit.id
)
const updatedCoins = await addCoins({
amount: habit.coinReward,
description: `Completed: ${habit.name}`,
type: habit.isTask ? 'TASK_COMPLETION' : 'HABIT_COMPLETION',
relatedItemId: habit.id,
})
playSound()
toast({
title: t("completedTitle"),
description: t("earnedCoinsDescription", { coinReward: habit.coinReward }),
action: <ToastAction altText={tCommon('undoButton')} className="gap-2" onClick={() => undoComplete(updatedHabit)}>
<Undo2 className="h-4 w-4" />{tCommon('undoButton')}
</ToastAction>
})
setCoins(updatedCoins)
} else {
toast({
title: t("progressTitle"),
description: t("progressDescription", { count: completionsToday + 1, target }),
action: <ToastAction altText={tCommon('undoButton')} className="gap-2" onClick={() => undoComplete(updatedHabit)}>
<Undo2 className="h-4 w-4" />{tCommon('undoButton')}
</ToastAction>
})
}
toast({
title: isTargetReached ? "Habit completed!" : "Progress!",
description: isTargetReached
? `You earned ${habit.coinReward} coins.`
: `You've completed ${completionsToday + 1}/${target} times today.`,
action: <ToastAction altText="Undo" className="gap-2" onClick={() => undoComplete(updatedHabit)}>
<Undo2 className="h-4 w-4" />Undo
</ToastAction>
})
// move atom update at the end of function to improve UI responsiveness
setHabitsData({ habits: updatedHabits })
return {
updatedHabits,
@@ -77,6 +105,7 @@ export function useHabits() {
}
const undoComplete = async (habit: Habit) => {
if (!handlePermissionCheck(currentUser, 'habit', 'interact', tCommon)) return
const timezone = settings.system.timezone
const today = t2d({ timestamp: getTodayInTimezone(timezone), timezone })
@@ -86,12 +115,13 @@ export function useHabits() {
)
if (todayCompletions.length > 0) {
// Remove the most recent completion
// Remove the most recent completion and unarchive if needed
const updatedHabit = {
...habit,
completions: habit.completions.filter(
(_, index) => index !== habit.completions.length - 1
)
),
archived: habit.isTask ? false : habit.archived // Unarchive if it's a task
}
const updatedHabits = habitsData.habits.map(h =>
@@ -104,24 +134,27 @@ export function useHabits() {
// If we were at the target, remove the coins
const target = habit.targetCompletions || 1
if (todayCompletions.length === target) {
const updatedCoins = await removeCoins(
habit.coinReward,
`Undid habit completion: ${habit.name}`,
'HABIT_UNDO',
habit.id
)
const updatedCoins = await removeCoins({
amount: habit.coinReward,
description: `Undid completion: ${habit.name}`,
type: habit.isTask ? 'TASK_UNDO' : 'HABIT_UNDO',
relatedItemId: habit.id,
})
setCoins(updatedCoins)
}
toast({
title: "Completion undone",
description: `You have ${getCompletionsForDate({
habit: updatedHabit,
date: today,
timezone
})}/${target} completions today.`,
action: <ToastAction altText="Redo" onClick={() => completeHabit(updatedHabit)}>
<Undo2 className="h-4 w-4" />Redo
title: t("completionUndoneTitle"),
description: t("completionUndoneDescription", {
count: getCompletionsForDate({
habit: updatedHabit,
date: today,
timezone
}),
target
}),
action: <ToastAction altText={tCommon('redoButton')} onClick={() => completeHabit(updatedHabit)}>
<Undo2 className="h-4 w-4" />{tCommon('redoButton')}
</ToastAction>
})
@@ -132,18 +165,19 @@ export function useHabits() {
}
} else {
toast({
title: "No completions to undo",
description: "This habit hasn't been completed today.",
title: t("noCompletionsToUndoTitle"),
description: t("noCompletionsToUndoDescription"),
variant: "destructive",
})
return null
return
}
}
const saveHabit = async (habit: Omit<Habit, 'id'> & { id?: string }) => {
if (!handlePermissionCheck(currentUser, 'habit', 'write', tCommon)) return
const newHabit = {
...habit,
id: habit.id || getNowInMilliseconds().toString()
id: habit.id || crypto.randomUUID()
}
const updatedHabits = habit.id
? habitsData.habits.map(h => h.id === habit.id ? newHabit : h)
@@ -155,16 +189,109 @@ export function useHabits() {
}
const deleteHabit = async (id: string) => {
if (!handlePermissionCheck(currentUser, 'habit', 'write', tCommon)) return
const updatedHabits = habitsData.habits.filter(h => h.id !== id)
await saveHabitsData({ habits: updatedHabits })
setHabitsData({ habits: updatedHabits })
return updatedHabits
}
const completePastHabit = async (habit: Habit, date: DateTime) => {
if (!handlePermissionCheck(currentUser, 'habit', 'interact', tCommon)) return
const timezone = settings.system.timezone
const dateKey = getISODate({ dateTime: date, timezone })
// Check if already completed on this date
const completionsOnDate = habit.completions.filter(completion =>
isSameDate(t2d({ timestamp: completion, timezone }), date)
).length
const target = habit.targetCompletions || 1
if (completionsOnDate >= target) {
toast({
title: t("alreadyCompletedPastDateTitle"),
description: t("alreadyCompletedPastDateDescription", { dateKey: d2s({ dateTime: date, timezone, format: 'yyyy-MM-dd' }) }),
variant: "destructive",
})
return
}
// Use current time but with the past date
const now = getNow({ timezone })
const completionDateTime = date.set({
hour: now.hour,
minute: now.minute,
second: now.second,
millisecond: now.millisecond
})
const completionTimestamp = d2t({ dateTime: completionDateTime })
const updatedHabit = {
...habit,
completions: [...habit.completions, completionTimestamp]
}
const updatedHabits = habitsData.habits.map(h =>
h.id === habit.id ? updatedHabit : h
)
await saveHabitsData({ habits: updatedHabits })
setHabitsData({ habits: updatedHabits })
// Check if we've now reached the target
const isTargetReached = completionsOnDate + 1 === target
if (isTargetReached) {
const updatedCoins = await addCoins({
amount: habit.coinReward,
description: `Completed: ${habit.name} on ${d2s({ dateTime: date, timezone, format: 'yyyy-MM-dd' })}`,
type: habit.isTask ? 'TASK_COMPLETION' : 'HABIT_COMPLETION',
relatedItemId: habit.id,
})
setCoins(updatedCoins)
}
toast({
title: isTargetReached ? t("completedTitle") : t("progressTitle"),
description: isTargetReached
? t("earnedCoinsPastDateDescription", { coinReward: habit.coinReward, dateKey })
: t("progressPastDateDescription", { count: completionsOnDate + 1, target, dateKey }),
action: <ToastAction altText={tCommon('undoButton')} className="gap-2" onClick={() => undoComplete(updatedHabit)}>
<Undo2 className="h-4 w-4" />{tCommon('undoButton')}
</ToastAction>
})
return {
updatedHabits,
newBalance: coins.balance,
newTransactions: coins.transactions
}
}
const archiveHabit = async (id: string) => {
if (!handlePermissionCheck(currentUser, 'habit', 'write', tCommon)) return
const updatedHabits = habitsData.habits.map(h =>
h.id === id ? { ...h, archived: true } : h
)
await saveHabitsData({ habits: updatedHabits })
setHabitsData({ habits: updatedHabits })
}
const unarchiveHabit = async (id: string) => {
if (!handlePermissionCheck(currentUser, 'habit', 'write', tCommon)) return
const updatedHabits = habitsData.habits.map(h =>
h.id === id ? { ...h, archived: false } : h
)
await saveHabitsData({ habits: updatedHabits })
setHabitsData({ habits: updatedHabits })
}
return {
completeHabit,
undoComplete,
saveHabit,
deleteHabit
deleteHabit,
completePastHabit,
archiveHabit,
unarchiveHabit,
habitFreqMap,
}
}

View File

@@ -1,46 +1,94 @@
import { useAtom } from 'jotai'
import { wishlistAtom, coinsAtom } from '@/lib/atoms'
import { saveWishlistItems, removeCoins } from '@/app/actions/data'
import { removeCoins, saveWishlistItems } from '@/app/actions/data'
import { toast } from '@/hooks/use-toast'
import { coinsAtom, currentUserAtom, wishlistAtom } from '@/lib/atoms'
import { WishlistItemType } from '@/lib/types'
import { handlePermissionCheck } from '@/lib/utils'
import { celebrations } from '@/utils/celebrations'
import { useAtom } from 'jotai'
import { useTranslations } from 'next-intl'
import { useCoins } from './useCoins'
export function useWishlist() {
const t = useTranslations('useWishlist');
const tCommon = useTranslations('Common');
const [user] = useAtom(currentUserAtom)
const [wishlist, setWishlist] = useAtom(wishlistAtom)
const [coins, setCoins] = useAtom(coinsAtom)
const balance = coins.balance
const { balance } = useCoins()
const addWishlistItem = async (item: Omit<WishlistItemType, 'id'>) => {
if (!handlePermissionCheck(user, 'wishlist', 'write', tCommon)) return
const newItem = { ...item, id: Date.now().toString() }
const newItems = [...wishlist.items, newItem]
setWishlist({ items: newItems })
await saveWishlistItems(newItems)
const newWishListData = { items: newItems }
setWishlist(newWishListData)
await saveWishlistItems(newWishListData)
}
const editWishlistItem = async (updatedItem: WishlistItemType) => {
if (!handlePermissionCheck(user, 'wishlist', 'write', tCommon)) return
const newItems = wishlist.items.map(item =>
item.id === updatedItem.id ? updatedItem : item
)
setWishlist({ items: newItems })
await saveWishlistItems(newItems)
const newWishListData = { items: newItems }
setWishlist(newWishListData)
await saveWishlistItems(newWishListData)
}
const deleteWishlistItem = async (id: string) => {
if (!handlePermissionCheck(user, 'wishlist', 'write', tCommon)) return
const newItems = wishlist.items.filter(item => item.id !== id)
setWishlist({ items: newItems })
await saveWishlistItems(newItems)
const newWishListData = { items: newItems }
setWishlist(newWishListData)
await saveWishlistItems(newWishListData)
}
const redeemWishlistItem = async (item: WishlistItemType) => {
if (!handlePermissionCheck(user, 'wishlist', 'interact', tCommon)) return false
if (balance >= item.coinCost) {
const data = await removeCoins(
item.coinCost,
`Redeemed reward: ${item.name}`,
'WISH_REDEMPTION',
item.id
)
// Check if item has target completions and if we've reached the limit
if (item.targetCompletions && item.targetCompletions <= 0) {
toast({
title: t("redemptionLimitReachedTitle"),
description: t("redemptionLimitReachedDescription", { itemName: item.name }),
variant: "destructive",
})
return false
}
const data = await removeCoins({
amount: item.coinCost,
description: `Redeemed reward: ${item.name}`,
type: 'WISH_REDEMPTION',
relatedItemId: item.id
})
setCoins(data)
// Update target completions if set
if (item.targetCompletions !== undefined) {
const newItems = wishlist.items.map(wishlistItem => {
if (wishlistItem.id === item.id) {
const newTarget = wishlistItem.targetCompletions! - 1
// If target reaches 0, archive the item
if (newTarget <= 0) {
return {
...wishlistItem,
targetCompletions: undefined,
archived: true
}
}
return {
...wishlistItem,
targetCompletions: newTarget
}
}
return wishlistItem
})
const newWishListData = { items: newItems }
setWishlist(newWishListData)
await saveWishlistItems(newWishListData)
}
// Randomly choose a celebration effect
const celebrationEffects = [
celebrations.emojiParty
@@ -49,15 +97,15 @@ export function useWishlist() {
randomEffect()
toast({
title: "🎉 Reward Redeemed!",
description: `You've redeemed "${item.name}" for ${item.coinCost} coins.`,
title: t("rewardRedeemedTitle"),
description: t("rewardRedeemedDescription", { itemName: item.name, itemCoinCost: item.coinCost }),
})
return true
} else {
toast({
title: "Not enough coins",
description: `You need ${item.coinCost - balance} more coins to redeem this reward.`,
title: t("notEnoughCoinsTitle"),
description: t("notEnoughCoinsDescription", { coinsNeeded: item.coinCost - balance }),
variant: "destructive",
})
return false
@@ -66,11 +114,33 @@ export function useWishlist() {
const canRedeem = (cost: number) => balance >= cost
const archiveWishlistItem = async (id: string) => {
if (!handlePermissionCheck(user, 'wishlist', 'write', tCommon)) return
const newItems = wishlist.items.map(item =>
item.id === id ? { ...item, archived: true } : item
)
const newWishListData = { items: newItems }
setWishlist(newWishListData)
await saveWishlistItems(newWishListData)
}
const unarchiveWishlistItem = async (id: string) => {
if (!handlePermissionCheck(user, 'wishlist', 'write', tCommon)) return
const newItems = wishlist.items.map(item =>
item.id === id ? { ...item, archived: false } : item
)
const newWishListData = { items: newItems }
setWishlist(newWishListData)
await saveWishlistItems(newWishListData)
}
return {
addWishlistItem,
editWishlistItem,
deleteWishlistItem,
redeemWishlistItem,
archiveWishlistItem,
unarchiveWishlistItem,
canRedeem,
wishlistItems: wishlist.items
}

13
i18n/request.ts Normal file
View File

@@ -0,0 +1,13 @@
import { getRequestConfig } from 'next-intl/server';
import { loadSettings } from '@/app/actions/data'; // Adjust path as necessary
export default getRequestConfig(async () => {
// Load settings to get the user's preferred language
const settings = await loadSettings();
const locale = settings.system.language || 'en'; // Fallback to 'en' if not set
return {
locale,
messages: (await import(`../messages/${locale}.json`)).default
};
});

28
instrumentation.ts Normal file
View File

@@ -0,0 +1,28 @@
import { init } from '@/lib/env.server'; // startup env var check
// Ensure this function is exported
export async function register() {
// We only want to run this code on the server side
if (process.env.NEXT_RUNTIME === 'nodejs') {
console.log('Node.js runtime detected, running server-side instrumentation...');
// Initialize environment variables first
console.log('Initializing environment variables...');
init();
console.log('Environment variables initialized.');
// Dynamically import the scheduler initializer
// Use await import() for ESM compatibility
try {
console.log('Attempting to import scheduler...');
// Ensure the path is correct relative to the project root
const { initializeScheduler } = await import('./lib/scheduler');
console.log('Scheduler imported successfully. Initializing...');
initializeScheduler();
console.log('Scheduler initialization called.');
} catch (error) {
console.error('Failed to import or initialize scheduler:', error);
}
} else {
console.log(`Instrumentation hook running in environment: ${process.env.NEXT_RUNTIME}. Skipping server-side initialization.`);
}
}

View File

@@ -1,52 +1,86 @@
import { atom } from "jotai";
import {
getDefaultSettings,
getDefaultHabitsData,
getDefaultCoinsData,
getDefaultWishlistData,
Habit
} from "./types";
import {
getTodayInTimezone,
isSameDate,
t2d,
calculateCoinsEarnedToday,
calculateCoinsSpentToday,
calculateTotalEarned,
calculateTotalSpent,
calculateCoinsSpentToday,
calculateTransactionsToday,
getCompletionsForToday
generateCryptoHash,
getCompletionsForToday,
getHabitFreq,
isHabitDue,
prepareDataForHashing,
roundToInteger,
t2d
} from "@/lib/utils";
import { atom } from "jotai";
import { atomFamily, atomWithStorage } from "jotai/utils";
import { DateTime } from "luxon";
import {
CoinsData,
CompletionCache,
Freq,
getDefaultCoinsData,
getDefaultHabitsData,
getDefaultServerSettings,
getDefaultSettings,
getDefaultUsersData,
getDefaultWishlistData,
Habit,
HabitsData,
ServerSettings,
Settings,
UserData,
UserId,
WishlistData
} from "./types";
export const settingsAtom = atom(getDefaultSettings());
export const habitsAtom = atom(getDefaultHabitsData());
export const coinsAtom = atom(getDefaultCoinsData());
export const wishlistAtom = atom(getDefaultWishlistData());
export interface BrowserSettings {
expandedHabits: boolean
expandedTasks: boolean
expandedWishlist: boolean
}
export const browserSettingsAtom = atomWithStorage('browserSettings', {
expandedHabits: false,
expandedTasks: false,
expandedWishlist: false
} as BrowserSettings)
export const usersAtom = atom(getDefaultUsersData<UserData>())
export const settingsAtom = atom(getDefaultSettings<Settings>());
export const habitsAtom = atom(getDefaultHabitsData<HabitsData>());
export const coinsAtom = atom(getDefaultCoinsData<CoinsData>());
export const wishlistAtom = atom(getDefaultWishlistData<WishlistData>());
export const serverSettingsAtom = atom(getDefaultServerSettings<ServerSettings>());
// Derived atom for coins earned today
export const coinsEarnedTodayAtom = atom((get) => {
const coins = get(coinsAtom);
const settings = get(settingsAtom);
return calculateCoinsEarnedToday(coins.transactions, settings.system.timezone);
const value = calculateCoinsEarnedToday(coins.transactions, settings.system.timezone);
return roundToInteger(value);
});
// Derived atom for total earned
export const totalEarnedAtom = atom((get) => {
const coins = get(coinsAtom);
return calculateTotalEarned(coins.transactions);
const value = calculateTotalEarned(coins.transactions);
return roundToInteger(value);
});
// Derived atom for total spent
export const totalSpentAtom = atom((get) => {
const coins = get(coinsAtom);
return calculateTotalSpent(coins.transactions);
const value = calculateTotalSpent(coins.transactions);
return roundToInteger(value);
});
// Derived atom for coins spent today
export const coinsSpentTodayAtom = atom((get) => {
const coins = get(coinsAtom);
const settings = get(settingsAtom);
return calculateCoinsSpentToday(coins.transactions, settings.system.timezone);
const value = calculateCoinsSpentToday(coins.transactions, settings.system.timezone);
return roundToInteger(value);
});
// Derived atom for transactions today
@@ -56,6 +90,29 @@ export const transactionsTodayAtom = atom((get) => {
return calculateTransactionsToday(coins.transactions, settings.system.timezone);
});
// Atom to store the current logged-in user's ID.
// This should be set by your application when the user session is available.
export const currentUserIdAtom = atom<UserId | undefined>(undefined);
export const currentUserAtom = atom((get) => {
const currentUserId = get(currentUserIdAtom);
const users = get(usersAtom);
return users.users.find(user => user.id === currentUserId);
})
// Derived atom for current balance for the logged-in user
export const coinsBalanceAtom = atom((get) => {
const loggedInUserId = get(currentUserIdAtom);
if (!loggedInUserId) {
return 0; // No user logged in or ID not set, so balance is 0
}
const coins = get(coinsAtom);
const balance = coins.transactions
.filter(transaction => transaction.userId === loggedInUserId)
.reduce((sum, transaction) => sum + transaction.amount, 0);
return roundToInteger(balance);
});
/* transient atoms */
interface PomodoroAtom {
show: boolean
@@ -71,7 +128,79 @@ export const pomodoroAtom = atom<PomodoroAtom>({
minimized: false,
})
// Derived atom for today's completions of selected habit
export const userSelectAtom = atom<boolean>(false)
export const aboutOpenAtom = atom<boolean>(false)
/**
* Asynchronous atom that calculates a freshness token (hash) based on the current client-side data.
* This token can be compared with a server-generated token to detect data discrepancies.
*/
export const clientFreshnessTokenAtom = atom(async (get) => {
const settings = get(settingsAtom);
const habits = get(habitsAtom);
const coins = get(coinsAtom);
const wishlist = get(wishlistAtom);
const users = get(usersAtom);
const dataString = prepareDataForHashing(settings, habits, coins, wishlist, users);
const hash = await generateCryptoHash(dataString);
return hash;
});
// Derived atom for completion cache
export const completionCacheAtom = atom((get) => {
const habits = get(habitsAtom).habits;
const timezone = get(settingsAtom).system.timezone;
const cache: CompletionCache = {};
habits.forEach(habit => {
habit.completions.forEach(utcTimestamp => {
const localDate = t2d({ timestamp: utcTimestamp, timezone })
.toFormat('yyyy-MM-dd');
if (!cache[localDate]) {
cache[localDate] = {};
}
cache[localDate][habit.id] = (cache[localDate][habit.id] || 0) + 1;
});
});
return cache;
});
// Derived atom for completed habits by date, using the cache
export const completedHabitsMapAtom = atom((get) => {
const habits = get(habitsAtom).habits;
const completionCache = get(completionCacheAtom);
const map = new Map<string, Habit[]>();
// For each date in the cache
Object.entries(completionCache).forEach(([dateKey, habitCompletions]) => {
const completedHabits = habits.filter(habit => {
const completionsNeeded = habit.targetCompletions || 1;
const completionsAchieved = habitCompletions[habit.id] || 0;
return completionsAchieved >= completionsNeeded;
});
if (completedHabits.length > 0) {
map.set(dateKey, completedHabits);
}
});
return map;
});
// Derived atom for habit frequency map
export const habitFreqMapAtom = atom((get) => {
const habits = get(habitsAtom).habits;
const map = new Map<string, Freq>();
habits.forEach(habit => {
map.set(habit.id, getHabitFreq(habit));
});
return map;
});
export const pomodoroTodayCompletionsAtom = atom((get) => {
const pomo = get(pomodoroAtom)
const habits = get(habitsAtom)
@@ -79,7 +208,7 @@ export const pomodoroTodayCompletionsAtom = atom((get) => {
if (!pomo.selectedHabitId) return 0
const selectedHabit = habits.habits.find(h => h.id === pomo.selectedHabitId)
const selectedHabit = habits.habits.find(h => h.id === pomo.selectedHabitId!)
if (!selectedHabit) return 0
return getCompletionsForToday({
@@ -87,3 +216,21 @@ export const pomodoroTodayCompletionsAtom = atom((get) => {
timezone: settings.system.timezone
})
})
// Derived atom to check if any habits are tasks
export const hasTasksAtom = atom((get) => {
const habits = get(habitsAtom)
return habits.habits.some(habit => habit.isTask === true)
})
// Atom family for habits by specific date
export const habitsByDateFamily = atomFamily((dateString: string) =>
atom((get) => {
const habits = get(habitsAtom).habits;
const settings = get(settingsAtom);
const timezone = settings.system.timezone;
const date = DateTime.fromISO(dateString).setZone(timezone);
return habits.filter(habit => isHabitDue({ habit, timezone, date }));
})
);

143
lib/backup.ts Normal file
View File

@@ -0,0 +1,143 @@
import fs from 'fs/promises';
import { createWriteStream } from 'fs'; // Use specific import for createWriteStream
import path from 'path';
import archiver from 'archiver';
import { loadSettings } from '@/app/actions/data'; // Adjust path if needed
import { DateTime } from 'luxon';
const BACKUP_DIR = path.join(process.cwd(), 'backups');
const DATA_DIR = path.join(process.cwd(), 'data');
const MAX_BACKUPS = 7; // Number of backups to keep
async function ensureBackupDir() {
try {
await fs.access(BACKUP_DIR);
} catch {
await fs.mkdir(BACKUP_DIR, { recursive: true });
console.log('Created backup directory:', BACKUP_DIR);
}
}
async function rotateBackups() {
try {
await ensureBackupDir();
const files = await fs.readdir(BACKUP_DIR);
const backupFiles = files
.filter(file => file.startsWith('backup-') && file.endsWith('.zip'))
.map(file => ({
name: file,
path: path.join(BACKUP_DIR, file),
}));
if (backupFiles.length <= MAX_BACKUPS) {
console.log(`Rotation check: ${backupFiles.length} backups found, less than or equal to max ${MAX_BACKUPS}. No rotation needed.`);
return; // No rotation needed
}
console.log(`Rotation check: ${backupFiles.length} backups found, exceeding max ${MAX_BACKUPS}. Starting rotation.`);
// Get stats to sort by creation time (mtime as proxy)
const fileStats = await Promise.all(
backupFiles.map(async (file) => ({
...file,
stat: await fs.stat(file.path),
}))
);
// Sort oldest first
fileStats.sort((a, b) => a.stat.mtime.getTime() - b.stat.mtime.getTime());
const filesToDelete = fileStats.slice(0, fileStats.length - MAX_BACKUPS);
console.log(`Identified ${filesToDelete.length} backups to delete.`);
for (const file of filesToDelete) {
try {
await fs.unlink(file.path);
console.log(`Rotated (deleted) old backup: ${file.name}`);
} catch (err) {
console.error(`Error deleting old backup ${file.name}:`, err);
}
}
} catch (error) {
console.error('Error during backup rotation:', error);
}
}
export async function runBackup() {
try {
const settings = await loadSettings();
if (!settings.system.autoBackupEnabled) {
console.log('Auto backup is disabled in settings. Skipping backup.');
return;
}
console.log('Starting daily backup...');
await ensureBackupDir();
const timestamp = DateTime.now().toFormat('yyyy-MM-dd_HH-mm-ss');
const backupFileName = `backup-${timestamp}.zip`;
const backupFilePath = path.join(BACKUP_DIR, backupFileName);
// Use createWriteStream from fs directly
const output = createWriteStream(backupFilePath);
const archive = archiver('zip', {
zlib: { level: 9 } // Sets the compression level.
});
return new Promise<void>((resolve, reject) => {
output.on('close', async () => {
console.log(`Backup created successfully: ${backupFileName} (${archive.pointer()} total bytes)`);
try {
await rotateBackups(); // Rotate after successful backup
resolve();
} catch (rotationError) {
console.error("Error during post-backup rotation:", rotationError);
// Decide if backup failure should depend on rotation failure
// For now, resolve even if rotation fails, as backup itself succeeded.
resolve();
}
});
// Handle stream finish event for better completion tracking
output.on('finish', () => {
console.log('Backup file stream finished writing.');
});
archive.on('warning', (err) => {
if (err.code === 'ENOENT') {
// Log specific warnings but don't necessarily reject
console.warn('Archiver warning (ENOENT):', err);
} else {
// Treat other warnings as potential issues, but maybe not fatal
console.warn('Archiver warning:', err);
}
});
archive.on('error', (err) => {
console.error('Archiver error:', err);
reject(err); // Reject the promise on critical archiver errors
});
// Pipe archive data to the file
archive.pipe(output);
// Append the entire data directory to the archive
// The second argument specifies the path prefix inside the zip file (false means root)
console.log(`Archiving directory: ${DATA_DIR}`);
archive.directory(DATA_DIR, false);
// Finalize the archive (writes the central directory)
console.log('Finalizing archive...');
archive.finalize().catch(err => {
// Catch potential errors during finalization
console.error('Error during archive finalization:', err);
reject(err);
});
});
} catch (error) {
console.error('Failed to run backup:', error);
// Rethrow or handle as appropriate for the scheduler
throw error;
}
}

36
lib/client-helpers.ts Normal file
View File

@@ -0,0 +1,36 @@
// client helpers
'use-client'
import { useAtom } from 'jotai'
import { useSession } from "next-auth/react"
import { usersAtom } from './atoms'
import { hasPermission } from './utils'
export function useHelpers() {
const { data: session, status } = useSession()
const currentUserId = session?.user.id
const [usersData] = useAtom(usersAtom)
const currentUser = usersData.users.find((u) => u.id === currentUserId)
// detect iOS: https://stackoverflow.com/a/9039885
function iOS() {
return typeof navigator !== "undefined" && ([
'iPad Simulator',
'iPhone Simulator',
'iPod Simulator',
'iPad',
'iPhone',
'iPod',
].includes(navigator.platform)
// iPad on iOS 13 detection
|| (navigator.userAgent.includes("Mac") && "ontouchend" in document))
}
return {
currentUserId,
currentUser,
usersData,
status,
hasPermission,
isIOS: iOS(),
}
}

View File

@@ -1,4 +1,7 @@
export const INITIAL_RECURRENCE_RULE = 'daily'
import { CheckSquare, Target } from "lucide-react"
export const INITIAL_RECURRENCE_RULE = 'every day'
export const INITIAL_DUE = 'today'
export const RECURRENCE_RULE_MAP: { [key: string]: string } = {
'daily': 'FREQ=DAILY',
@@ -8,3 +11,26 @@ export const RECURRENCE_RULE_MAP: { [key: string]: string } = {
'': 'invalid',
}
export const DUE_MAP: { [key: string]: string } = {
'tom': 'tomorrow',
'tod': 'today',
'yes': 'yesterday',
}
export const HabitIcon = Target
export const TaskIcon = CheckSquare;
export const QUICK_DATES = [
{ label: 'Today', value: 'today' },
{ label: 'Tomorrow', value: 'tomorrow' },
{ label: 'Monday', value: 'this monday' },
{ label: 'Tuesday', value: 'this tuesday' },
{ label: 'Wednesday', value: 'this wednesday' },
{ label: 'Thursday', value: 'this thursday' },
{ label: 'Friday', value: 'this friday' },
{ label: 'Saturday', value: 'this saturday' },
{ label: 'Sunday', value: 'this sunday' },
] as const
export const MAX_COIN_LIMIT = 9999
export const DESKTOP_DISPLAY_ITEM_COUNT = 4

32
lib/env.server.ts Normal file
View File

@@ -0,0 +1,32 @@
import { z } from "zod"
const zodEnv = z.object({
AUTH_SECRET: z.string(),
DEMO: z.string().optional(),
})
declare global {
interface ProcessEnv extends z.TypeOf<typeof zodEnv> {
AUTH_SECRET: string;
DEMO?: string;
}
}
export function init() {
try {
zodEnv.parse(process.env)
} catch (err) {
if (err instanceof z.ZodError) {
const { fieldErrors } = err.flatten()
const errorMessage = Object.entries(fieldErrors)
.map(([field, errors]) =>
errors ? `${field}: ${errors.join(", ")}` : field,
)
.join("\n ")
throw new Error(
`Missing environment variables:\n ${errorMessage}`,
)
}
}
}

6
lib/exceptions.ts Normal file
View File

@@ -0,0 +1,6 @@
export class PermissionError extends Error {
constructor(message: string) {
super(message)
this.name = 'PermissionError'
}
}

54
lib/scheduler.ts Normal file
View File

@@ -0,0 +1,54 @@
import cron from 'node-cron';
import { runBackup } from './backup';
let isSchedulerInitialized = false;
export function initializeScheduler() {
if (isSchedulerInitialized) {
console.log('Scheduler already initialized.');
return;
}
console.log('Initializing scheduler...');
// Schedule backup to run daily at 2:00 AM server time
// Format: second minute hour day-of-month month day-of-week
// '0 2 * * *' means at minute 0 of hour 2 (2:00 AM) every day
const backupJob = cron.schedule('0 2 * * *', async () => {
console.log(`[${new Date().toISOString()}] Running scheduled daily backup task...`);
try {
await runBackup();
console.log(`[${new Date().toISOString()}] Scheduled backup task completed successfully.`);
} catch (err) {
console.error(`[${new Date().toISOString()}] Scheduled backup task failed:`, err);
}
}, {
scheduled: true,
// Consider adding timezone support later if needed, based on user settings
// timezone: "Your/Timezone"
});
console.log('Scheduler initialized. Daily backup scheduled for 2:00 AM server time.');
isSchedulerInitialized = true;
// Graceful shutdown handling (optional but recommended)
process.on('SIGTERM', () => {
console.log('SIGTERM signal received. Stopping scheduler...');
backupJob.stop();
// Add cleanup for other jobs if needed
process.exit(0);
});
process.on('SIGINT', () => {
console.log('SIGINT signal received. Stopping scheduler...');
backupJob.stop();
// Add cleanup for other jobs if needed
process.exit(0);
});
// --- Add other scheduled tasks here in the future ---
// Example:
// cron.schedule('* * * * *', () => {
// console.log('Running every minute');
// });
}

40
lib/server-helpers.ts Normal file
View File

@@ -0,0 +1,40 @@
import { auth } from '@/auth'
import 'server-only'
import { User, UserId } from './types'
import { loadUsersData } from '@/app/actions/data'
import { randomBytes, scryptSync } from 'crypto'
export async function getCurrentUserId(): Promise<UserId | undefined> {
const session = await auth()
const user = session?.user
return user?.id
}
export async function getCurrentUser(): Promise<User | undefined> {
const currentUserId = await getCurrentUserId()
if (!currentUserId) {
return undefined
}
const usersData = await loadUsersData()
return usersData.users.find((u) => u.id === currentUserId)
}
export function saltAndHashPassword(password: string, salt?: string): string {
if (password.length === 0) throw new Error('Password must not be empty')
salt = salt || randomBytes(16).toString('hex')
const hash = scryptSync(password, salt, 64).toString('hex')
return `${salt}:${hash}`
}
export function verifyPassword(password?: string, storedHash?: string): boolean {
// if both password and storedHash is undefined, return true
if (!password && !storedHash) return true
// else if either password or storedHash is undefined, return false
if (!password || !storedHash) return false
// Split the stored hash into its salt and hash components
const [salt, hash] = storedHash.split(':')
// Hash the input password with the same salt
const newHash = saltAndHashPassword(password, salt).split(':')[1]
// Compare the new hash with the stored hash
return newHash === hash
}

205
lib/startup-checks.test.ts Normal file
View File

@@ -0,0 +1,205 @@
import { describe, expect, test, beforeEach, mock } from 'bun:test'
import { checkStartupPermissions } from './startup-checks'
// Mock the fs promises module
const mockStat = mock()
const mockWriteFile = mock()
const mockReadFile = mock()
const mockUnlink = mock()
mock.module('fs', () => ({
promises: {
stat: mockStat,
writeFile: mockWriteFile,
readFile: mockReadFile,
unlink: mockUnlink,
},
}))
describe('checkStartupPermissions', () => {
beforeEach(() => {
// Reset all mocks before each test
mockStat.mockReset()
mockWriteFile.mockReset()
mockReadFile.mockReset()
mockUnlink.mockReset()
})
test('should return success when directory exists and has proper permissions', async () => {
// Mock successful directory stat
mockStat.mockResolvedValue({
isDirectory: () => true,
})
// Mock successful file operations
mockWriteFile.mockResolvedValue(undefined)
mockReadFile.mockResolvedValue('permission-test')
mockUnlink.mockResolvedValue(undefined)
const result = await checkStartupPermissions()
expect(result).toEqual({ success: true })
expect(mockStat).toHaveBeenCalledWith('data')
expect(mockWriteFile).toHaveBeenCalledWith('data/.habittrove-permission-test', 'permission-test')
expect(mockReadFile).toHaveBeenCalledWith('data/.habittrove-permission-test', 'utf8')
expect(mockUnlink).toHaveBeenCalledWith('data/.habittrove-permission-test')
})
test('should return error when directory does not exist', async () => {
mockStat.mockRejectedValue(new Error('ENOENT: no such file or directory'))
const result = await checkStartupPermissions()
expect(result).toEqual({
success: false,
error: {
path: 'data',
message: 'Data directory \'data\' does not exist or is not accessible. Check volume mounts and permissions.',
type: 'writable_data_dir'
}
})
expect(mockStat).toHaveBeenCalledWith('data')
expect(mockWriteFile).not.toHaveBeenCalled()
})
test('should return error when path exists but is not a directory', async () => {
// Mock path exists but is a file, not directory
mockStat.mockResolvedValue({
isDirectory: () => false,
})
const result = await checkStartupPermissions()
expect(result).toEqual({
success: false,
error: {
path: 'data',
message: 'Path \'data\' exists but is not a directory. Please ensure the data directory is properly configured.',
type: 'writable_data_dir'
}
})
expect(mockStat).toHaveBeenCalledWith('data')
expect(mockWriteFile).not.toHaveBeenCalled()
})
test('should return error when write permission fails', async () => {
// Mock successful directory stat
mockStat.mockResolvedValue({
isDirectory: () => true,
})
// Mock write failure
mockWriteFile.mockRejectedValue(new Error('EACCES: permission denied'))
const result = await checkStartupPermissions()
expect(result).toEqual({
success: false,
error: {
path: 'data',
message: 'Insufficient read/write permissions for data directory \'data\'. Check file permissions and ownership.',
type: 'writable_data_dir'
}
})
expect(mockStat).toHaveBeenCalledWith('data')
expect(mockWriteFile).toHaveBeenCalledWith('data/.habittrove-permission-test', 'permission-test')
expect(mockReadFile).not.toHaveBeenCalled()
})
test('should return error when read permission fails', async () => {
// Mock successful directory stat and write
mockStat.mockResolvedValue({
isDirectory: () => true,
})
mockWriteFile.mockResolvedValue(undefined)
// Mock read failure
mockReadFile.mockRejectedValue(new Error('EACCES: permission denied'))
const result = await checkStartupPermissions()
expect(result).toEqual({
success: false,
error: {
path: 'data',
message: 'Insufficient read/write permissions for data directory \'data\'. Check file permissions and ownership.',
type: 'writable_data_dir'
}
})
expect(mockStat).toHaveBeenCalledWith('data')
expect(mockWriteFile).toHaveBeenCalledWith('data/.habittrove-permission-test', 'permission-test')
expect(mockReadFile).toHaveBeenCalledWith('data/.habittrove-permission-test', 'utf8')
})
test('should return error when read content does not match written content', async () => {
// Mock successful directory stat and write
mockStat.mockResolvedValue({
isDirectory: () => true,
})
mockWriteFile.mockResolvedValue(undefined)
// Mock read with different content
mockReadFile.mockResolvedValue('different-content')
const result = await checkStartupPermissions()
expect(result).toEqual({
success: false,
error: {
path: 'data',
message: 'Data integrity check failed in \'data\'. File system may be corrupted or have inconsistent behavior.',
type: 'writable_data_dir'
}
})
expect(mockStat).toHaveBeenCalledWith('data')
expect(mockWriteFile).toHaveBeenCalledWith('data/.habittrove-permission-test', 'permission-test')
expect(mockReadFile).toHaveBeenCalledWith('data/.habittrove-permission-test', 'utf8')
expect(mockUnlink).not.toHaveBeenCalled()
})
test('should return error when cleanup (unlink) fails', async () => {
// Mock successful directory stat, write, and read
mockStat.mockResolvedValue({
isDirectory: () => true,
})
mockWriteFile.mockResolvedValue(undefined)
mockReadFile.mockResolvedValue('permission-test')
// Mock cleanup failure
mockUnlink.mockRejectedValue(new Error('EACCES: permission denied'))
const result = await checkStartupPermissions()
// Should return error since cleanup failed and is part of the try-catch block
expect(result).toEqual({
success: false,
error: {
path: 'data',
message: 'Insufficient read/write permissions for data directory \'data\'. Check file permissions and ownership.',
type: 'writable_data_dir'
}
})
expect(mockStat).toHaveBeenCalledWith('data')
expect(mockWriteFile).toHaveBeenCalledWith('data/.habittrove-permission-test', 'permission-test')
expect(mockReadFile).toHaveBeenCalledWith('data/.habittrove-permission-test', 'utf8')
expect(mockUnlink).toHaveBeenCalledWith('data/.habittrove-permission-test')
})
test('should use correct file paths', async () => {
// Mock successful operations
mockStat.mockResolvedValue({
isDirectory: () => true,
})
mockWriteFile.mockResolvedValue(undefined)
mockReadFile.mockResolvedValue('permission-test')
mockUnlink.mockResolvedValue(undefined)
await checkStartupPermissions()
// Verify the correct paths are used
expect(mockStat).toHaveBeenCalledWith('data')
expect(mockWriteFile).toHaveBeenCalledWith('data/.habittrove-permission-test', 'permission-test')
expect(mockReadFile).toHaveBeenCalledWith('data/.habittrove-permission-test', 'utf8')
expect(mockUnlink).toHaveBeenCalledWith('data/.habittrove-permission-test')
})
})

73
lib/startup-checks.ts Normal file
View File

@@ -0,0 +1,73 @@
import { promises as fs } from 'fs'
import { join } from 'path'
const DEFAULT_DATA_DIR = 'data'
/**
* Checks startup permissions for the data directory
*/
interface StartupPermissionResult {
success: boolean
error?: { path: string; message: string; type?: 'writable_data_dir' }
}
export async function checkStartupPermissions(): Promise<StartupPermissionResult> {
const dirPath = DEFAULT_DATA_DIR
// Check if directory exists and is accessible
try {
const stats = await fs.stat(dirPath)
if (!stats.isDirectory()) {
return {
success: false,
error: {
path: dirPath,
message: `Path '${dirPath}' exists but is not a directory. Please ensure the data directory is properly configured.`,
type: 'writable_data_dir'
}
}
}
} catch (statError) {
return {
success: false,
error: {
path: dirPath,
message: `Data directory '${dirPath}' does not exist or is not accessible. Check volume mounts and permissions.`,
type: 'writable_data_dir'
}
}
}
// Test read/write permissions with a temporary file
const testFilePath = join(dirPath, '.habittrove-permission-test')
const testContent = 'permission-test'
try {
await fs.writeFile(testFilePath, testContent)
const readContent = await fs.readFile(testFilePath, 'utf8')
if (readContent !== testContent) {
return {
success: false,
error: {
path: dirPath,
message: `Data integrity check failed in '${dirPath}'. File system may be corrupted or have inconsistent behavior.`,
type: 'writable_data_dir'
}
}
}
await fs.unlink(testFilePath)
return { success: true }
} catch (rwError) {
return {
success: false,
error: {
path: dirPath,
message: `Insufficient read/write permissions for data directory '${dirPath}'. Check file permissions and ownership.`,
type: 'writable_data_dir'
}
}
}
}

View File

@@ -1,3 +1,39 @@
import { RRule } from "rrule"
import { DateTime } from "luxon"
export type UserId = string
export type Permission = {
habit: {
write: boolean
interact: boolean
}
wishlist: {
write: boolean
interact: boolean
}
coins: {
write: boolean
interact: boolean
}
}
export type SessionUser = {
id: UserId
}
export type SafeUser = SessionUser & {
username: string
avatarPath?: string
permissions?: Permission[]
isAdmin?: boolean
}
export type User = SafeUser & {
password?: string // Optional: Allow users without passwords (e.g., initial setup)
lastNotificationReadTimestamp?: string // UTC ISO date string
}
export type Habit = {
id: string
name: string
@@ -6,8 +42,14 @@ export type Habit = {
coinReward: number
targetCompletions?: number // Optional field, default to 1
completions: string[] // Array of UTC ISO date strings
isTask?: boolean // mark the habit as a task
archived?: boolean // mark the habit as archived
pinned?: boolean // mark the habit as pinned
userIds?: UserId[]
drawing?: string // Optional JSON string of drawing data
}
export type Freq = 'daily' | 'weekly' | 'monthly' | 'yearly'
export type WishlistItemType = {
@@ -15,9 +57,14 @@ export type WishlistItemType = {
name: string
description: string
coinCost: number
archived?: boolean // mark the wishlist item as archived
targetCompletions?: number // Optional field, infinity when unset
link?: string // Optional URL to external resource
userIds?: UserId[]
drawing?: string // Optional JSON string of drawing data
}
export type TransactionType = 'HABIT_COMPLETION' | 'HABIT_UNDO' | 'WISH_REDEMPTION' | 'MANUAL_ADJUSTMENT';
export type TransactionType = 'HABIT_COMPLETION' | 'HABIT_UNDO' | 'WISH_REDEMPTION' | 'MANUAL_ADJUSTMENT' | 'TASK_COMPLETION' | 'TASK_UNDO';
export interface CoinTransaction {
id: string;
@@ -27,12 +74,18 @@ export interface CoinTransaction {
timestamp: string;
relatedItemId?: string;
note?: string;
userId?: UserId;
}
export interface UserData {
users: User[]
}
export interface HabitsData {
habits: Habit[];
}
export interface CoinsData {
balance: number;
transactions: CoinTransaction[];
@@ -45,36 +98,63 @@ export interface WishlistData {
}
// Default value functions
export const getDefaultHabitsData = (): HabitsData => ({
habits: []
});
export function getDefaultUsersData<UserData>(): UserData {
return {
users: [
{
id: crypto.randomUUID(),
username: 'admin',
// password: '', // No default password for admin initially? Or set a secure default?
isAdmin: true,
lastNotificationReadTimestamp: undefined, // Initialize as undefined
}
]
} as UserData;
};
export const getDefaultCoinsData = (): CoinsData => ({
balance: 0,
transactions: []
});
export function getDefaultHabitsData<HabitsData>(): HabitsData {
return { habits: [] } as HabitsData;
}
export const getDefaultWishlistData = (): WishlistData => ({
items: []
});
export function getDefaultTasksData<TasksData>(): TasksData {
return { tasks: [] } as TasksData;
};
export const getDefaultSettings = (): Settings => ({
ui: {
useNumberFormatting: true,
useGrouping: true,
},
system: {
timezone: Intl.DateTimeFormat().resolvedOptions().timeZone
},
profile: {}
});
export function getDefaultCoinsData<CoinsData>(): CoinsData {
return { balance: 0, transactions: [] } as CoinsData;
};
export function getDefaultWishlistData<WishlistData>(): WishlistData {
return { items: [] } as WishlistData;
}
export function getDefaultSettings<Settings>(): Settings {
return {
ui: {
useNumberFormatting: true,
useGrouping: true,
},
system: {
timezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
weekStartDay: 1, // Monday
autoBackupEnabled: true, // Add this line (default to true)
language: 'en', // Default language
},
profile: {}
} as Settings;
};
export function getDefaultServerSettings<ServerSettings>(): ServerSettings {
return { isDemo: false } as ServerSettings;
}
// Map of data types to their default values
export const DATA_DEFAULTS = {
export const DATA_DEFAULTS: { [key: string]: <T>() => T } = {
wishlist: getDefaultWishlistData,
habits: getDefaultHabitsData,
coins: getDefaultCoinsData,
settings: getDefaultSettings,
auth: getDefaultUsersData,
} as const;
// Type for all possible data types
@@ -85,12 +165,17 @@ export interface UISettings {
useGrouping: boolean;
}
export type WeekDay = 0 | 1 | 2 | 3 | 4 | 5 | 6; // 0 = Sunday, 6 = Saturday
export interface SystemSettings {
timezone: string;
weekStartDay: WeekDay;
autoBackupEnabled: boolean; // Add this line
language: string; // Add this line for language preference
}
export interface ProfileSettings {
avatarPath?: string;
avatarPath?: string; // deprecated
}
export interface Settings {
@@ -99,9 +184,29 @@ export interface Settings {
profile: ProfileSettings;
}
export type CompletionCache = {
[dateKey: string]: { // dateKey format: "YYYY-MM-DD"
[habitId: string]: number // number of completions on that date
}
}
export interface JotaiHydrateInitialValues {
settings: Settings;
coins: CoinsData;
habits: HabitsData;
wishlist: WishlistData;
users: UserData;
serverSettings: ServerSettings;
}
export interface ServerSettings {
isDemo: boolean
}
export type ParsedResultType = DateTime<true> | RRule | string | null // null if invalid
// return rrule / datetime (machine-readable frequency), string (human-readable frequency), or null (invalid)
export interface ParsedFrequencyResult {
message: string | null
result: ParsedResultType
}

Some files were not shown because too many files have changed in this diff Show More