Compare commits

...

84 Commits

Author SHA1 Message Date
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
117 changed files with 13572 additions and 1933 deletions

View File

@@ -7,3 +7,17 @@ Dockerfile
node_modules node_modules
npm-debug.log npm-debug.log
data 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,95 +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 }}
VERSION: ${{ steps.package-version.outputs.VERSION }}
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 }}
create-release:
runs-on: ubuntu-latest
needs: build-and-push
if: needs.build-and-push.outputs.EXISTS == 'false'
permissions:
contents: write
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Create GitHub release
env:
GH_TOKEN: ${{ secrets.GH_TOKEN }}
VERSION: ${{ needs.build-and-push.outputs.VERSION }}
run: |
# Extract release notes from CHANGELOG.md
notes=$(awk -v version="$VERSION" '
$0 ~ "## Version " version {flag=1;next}
$0 ~ "## Version " && flag {exit}
flag' CHANGELOG.md)
gh release create "v$VERSION" \
--repo="$GITHUB_REPOSITORY" \
--title="v$VERSION" \
--notes="$notes"

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 next-env.d.ts
# customize # customize
data/* /data/*
/data.*/*
Budfile Budfile
certificates 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 #!/bin/sh
echo "Error: Found debug marker 🪚 in these files:" # Check that package.json version exists in CHANGELOG.md
git diff --cached --name-only --diff-filter=d | xargs grep -n '🪚' | awk -F: '{print " " $1 ":" $2}' 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 exit 1
fi 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,258 @@
# Changelog # Changelog
## 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 ## Version 0.1.29
### Fixed ### Fixed

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 # 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 # Install dependencies only when needed
FROM --platform=$BUILDPLATFORM base AS deps FROM base AS deps
# Check https://github.com/nodejs/docker-node/tree/b4117f9333da4138b03a546ec926ef50a31506c3#nodealpine to understand why libc6-compat might be needed.
RUN apk add --no-cache libc6-compat RUN apk add --no-cache libc6-compat
WORKDIR /app 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* ./ 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; \ 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 package-lock.json ]; then npm ci --force; \
elif [ -f pnpm-lock.yaml ]; then corepack enable pnpm && pnpm i --frozen-lockfile; \ elif [ -f pnpm-lock.yaml ]; then corepack enable pnpm && pnpm i --frozen-lockfile; \
else echo "Lockfile not found." && exit 1; \ else echo "Lockfile not found." && exit 1; \
fi fi
# Rebuild the source code only when needed # Rebuild the source code only when needed
FROM --platform=$BUILDPLATFORM base AS builder FROM base AS builder
WORKDIR /app WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules COPY --from=deps /app/node_modules ./node_modules
COPY . . COPY . .
ENV NEXT_TELEMETRY_DISABLED=1 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; \ if [ -f yarn.lock ]; then yarn run build; \
elif [ -f package-lock.json ]; then npm run build; \ elif [ -f package-lock.json ]; then npm run build; \
elif [ -f pnpm-lock.yaml ]; then corepack enable pnpm && pnpm run build; \ elif [ -f pnpm-lock.yaml ]; then corepack enable pnpm && pnpm run build; \
else echo "Lockfile not found." && exit 1; \ else echo "Lockfile not found." && exit 1; \
fi fi
# Production image, copy all the files and run next # Production image - use target platform
FROM base AS runner FROM node:22-alpine AS runner
WORKDIR /app WORKDIR /app
ENV NODE_ENV=production ENV NODE_ENV=production
ENV NEXT_TELEMETRY_DISABLED=1 ENV NEXT_TELEMETRY_DISABLED=1
RUN addgroup --system --gid 1001 nodejs RUN addgroup --system --gid 1001 nodejs && \
RUN adduser --system --uid 1001 nextjs adduser --system --uid 1001 nextjs && \
mkdir -p /app/data /app/backups && \
# Create data directory and set permissions chown nextjs:nodejs /app/data /app/backups
RUN mkdir -p /app/data && chown nextjs:nodejs /app/data
COPY --from=builder /app/public ./public COPY --from=builder /app/public ./public
COPY --from=builder /app/CHANGELOG.md ./ 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/standalone ./
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
@@ -61,6 +57,6 @@ EXPOSE 3000
ENV PORT=3000 ENV PORT=3000
ENV HOSTNAME="0.0.0.0" ENV HOSTNAME="0.0.0.0"
VOLUME ["/app/data"] VOLUME ["/app/data", "/app/backups"]
CMD ["node", "server.js"] 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. 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 ## Features
@@ -14,18 +24,18 @@ Want to try HabitTrove before installing? Visit the public [demo instance](https
- 🏆 Earn coins for completing habits - 🏆 Earn coins for completing habits
- 💰 Create a wishlist of rewards to redeem with earned coins - 💰 Create a wishlist of rewards to redeem with earned coins
- 📊 View your habit completion streaks and statistics - 📊 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) - 📅 Calendar heatmap to visualize your progress (WIP)
- 🌙 Dark mode support (WIP) - 🌍 Multi-language support (English, Español, Català, Deutsch, Français, Русский, 简体中文, 한국어, 日本語)
- 📲 Progressive Web App (PWA) support (Planned) - 🌙 Dark mode support
- 📲 Progressive Web App (PWA) support
- 💾 Automatic daily backups with rotation
## Usage ## Usage
1. **Creating Habits**: Click the "Add Habit" button to create a new habit. Set a name, description, and coin reward. 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. 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. 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. 4. **Statistics**: View your progress through the heatmap and streak counters.
## Docker Deployment ## 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: 1. First, prepare the data directory with correct permissions:
```bash ```bash
mkdir -p data mkdir -p data backups
chown -R 1001:1001 data # Required for the nextjs user in container chown -R 1001:1001 data backups # Required for the nextjs user in container
``` ```
2. Then run using either method: 2. Then run using either method:
```bash ```bash
# Generate a secure authentication secret
export AUTH_SECRET=$(openssl rand -base64 32)
echo $AUTH_SECRET
# Using docker-compose (recommended) # 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 docker compose up -d
# Or using docker run directly # 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: Available image tags:
@@ -62,9 +84,11 @@ Available image tags:
Choose your tag based on needs: Choose your tag based on needs:
- Use `latest` for general production use - 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 - 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 ### Building Locally
If you want to build the image locally (useful for development): 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: 1. Clone the repository and navigate to the project directory:
```bash ```bash
git clone https://github.com/dohsimpson/habittrove.git git clone https://github.com/ManInDark/HabitTrove.git
cd habittrove cd habittrove
``` ```
@@ -148,7 +172,7 @@ Run these commands regularly during development to catch issues early.
## Contributing ## 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 ## License

View File

@@ -1,20 +1,37 @@
'use server' 'use server'
import fs from 'fs/promises' import { getCurrentUser, saltAndHashPassword, verifyPassword } from "@/lib/server-helpers";
import path from 'path'
import { import {
HabitsData,
CoinsData, CoinsData,
CoinTransaction, CoinTransaction,
TransactionType,
WishlistItemType,
WishlistData,
Settings,
DataType,
DATA_DEFAULTS, DATA_DEFAULTS,
getDefaultSettings DataType,
} from '@/lib/types' getDefaultCoinsData,
import { d2t, getNow } from '@/lib/utils'; 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 { function getDefaultData<T>(type: DataType): T {
return DATA_DEFAULTS[type]() as 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> { async function loadData<T>(type: DataType): Promise<T> {
try { try {
await ensureDataDir() await ensureDataDir()
@@ -38,14 +76,14 @@ async function loadData<T>(type: DataType): Promise<T> {
await fs.access(filePath) await fs.access(filePath)
} catch { } catch {
// File doesn't exist, create it with default data // 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)) await fs.writeFile(filePath, JSON.stringify(initialData, null, 2))
return initialData as T return initialData as T
} }
// File exists, read and return its contents // File exists, read and return its contents
const data = await fs.readFile(filePath, 'utf8') const data = await fs.readFile(filePath, 'utf8')
const jsonData = JSON.parse(data) const jsonData = JSON.parse(data) as T
return jsonData return jsonData
} catch (error) { } catch (error) {
console.error(`Error loading ${type} data:`, 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> { async function saveData<T>(type: DataType, data: T): Promise<void> {
try { try {
const user = await getCurrentUser()
if (!user) throw new Error('User not authenticated')
await ensureDataDir() await ensureDataDir()
const filePath = path.join(process.cwd(), 'data', `${type}.json`) const filePath = path.join(process.cwd(), 'data', `${type}.json`)
const saveData = data 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 // Wishlist specific functions
export async function loadWishlistData(): Promise<WishlistData> { 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[]> { export async function loadWishlistItems(): Promise<WishlistItemType[]> {
@@ -74,31 +150,94 @@ export async function loadWishlistItems(): Promise<WishlistItemType[]> {
return data.items return data.items
} }
export async function saveWishlistItems(items: WishlistItemType[]): Promise<void> { export async function saveWishlistItems(data: WishlistData): Promise<void> {
return saveData('wishlist', { items }) 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 // Habits specific functions
export async function loadHabitsData(): Promise<HabitsData> { 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> { 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 // Coins specific functions
export async function loadCoinsData(): Promise<CoinsData> { export async function loadCoinsData(): Promise<CoinsData> {
try { 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 { } catch {
return { balance: 0, transactions: [] } return getDefaultCoinsData<CoinsData>()
} }
} }
export async function saveCoinsData(data: CoinsData): Promise<void> { 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({ export async function addCoins({
@@ -107,13 +246,16 @@ export async function addCoins({
type = 'MANUAL_ADJUSTMENT', type = 'MANUAL_ADJUSTMENT',
relatedItemId, relatedItemId,
note, note,
userId,
}: { }: {
amount: number amount: number
description: string description: string
type?: TransactionType type?: TransactionType
relatedItemId?: string relatedItemId?: string
note?: string note?: string
userId?: string
}): Promise<CoinsData> { }): Promise<CoinsData> {
const currentUser = await getCurrentUser()
const data = await loadCoinsData() const data = await loadCoinsData()
const newTransaction: CoinTransaction = { const newTransaction: CoinTransaction = {
id: crypto.randomUUID(), id: crypto.randomUUID(),
@@ -122,7 +264,8 @@ export async function addCoins({
description, description,
timestamp: d2t({ dateTime: getNow({}) }), timestamp: d2t({ dateTime: getNow({}) }),
...(relatedItemId && { relatedItemId }), ...(relatedItemId && { relatedItemId }),
...(note && note.trim() !== '' && { note }) ...(note && note.trim() !== '' && { note }),
userId: userId || currentUser?.id
} }
const newData: CoinsData = { const newData: CoinsData = {
@@ -135,9 +278,11 @@ export async function addCoins({
} }
export async function loadSettings(): Promise<Settings> { export async function loadSettings(): Promise<Settings> {
const defaultSettings = getDefaultSettings() const defaultSettings = getDefaultSettings<Settings>()
try { try {
const user = await getCurrentUser()
if (!user) return defaultSettings
const data = await loadData<Settings>('settings') const data = await loadData<Settings>('settings')
return { ...defaultSettings, ...data } return { ...defaultSettings, ...data }
} catch { } catch {
@@ -155,13 +300,16 @@ export async function removeCoins({
type = 'MANUAL_ADJUSTMENT', type = 'MANUAL_ADJUSTMENT',
relatedItemId, relatedItemId,
note, note,
userId,
}: { }: {
amount: number amount: number
description: string description: string
type?: TransactionType type?: TransactionType
relatedItemId?: string relatedItemId?: string
note?: string note?: string
userId?: string
}): Promise<CoinsData> { }): Promise<CoinsData> {
const currentUser = await getCurrentUser()
const data = await loadCoinsData() const data = await loadCoinsData()
const newTransaction: CoinTransaction = { const newTransaction: CoinTransaction = {
id: crypto.randomUUID(), id: crypto.randomUUID(),
@@ -170,7 +318,8 @@ export async function removeCoins({
description, description,
timestamp: d2t({ dateTime: getNow({}) }), timestamp: d2t({ dateTime: getNow({}) }),
...(relatedItemId && { relatedItemId }), ...(relatedItemId && { relatedItemId }),
...(note && note.trim() !== '' && { note }) ...(note && note.trim() !== '' && { note }),
userId: userId || currentUser?.id
} }
const newData: CoinsData = { const newData: CoinsData = {
@@ -182,7 +331,7 @@ export async function removeCoins({
return newData return newData
} }
export async function uploadAvatar(formData: FormData) { export async function uploadAvatar(formData: FormData): Promise<string> {
const file = formData.get('avatar') as File const file = formData.get('avatar') as File
if (!file) throw new Error('No file provided') if (!file) throw new Error('No file provided')
@@ -203,18 +352,7 @@ export async function uploadAvatar(formData: FormData) {
const buffer = await file.arrayBuffer() const buffer = await file.arrayBuffer()
await fs.writeFile(filePath, Buffer.from(buffer)) await fs.writeFile(filePath, Buffer.from(buffer))
// Update settings with new avatar path return `/data/avatars/${filename}`
const settings = await loadSettings()
const newSettings = {
...settings,
profile: {
...settings.profile,
avatarPath: `/data/avatars/${filename}`
}
}
await saveSettings(newSettings)
return newSettings;
} }
export async function getChangelog(): Promise<string> { export async function getChangelog(): Promise<string> {
@@ -226,3 +364,259 @@ export async function getChangelog(): Promise<string> {
return '# Changelog\n\nNo changelog available.' 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,13 +1,8 @@
import Layout from '@/components/Layout'
import HabitCalendar from '@/components/HabitCalendar' import HabitCalendar from '@/components/HabitCalendar'
import { ViewToggle } from '@/components/ViewToggle'
export default function CalendarPage() { export default function CalendarPage() {
return ( return (
<div className="flex flex-col gap-4"> <div className="flex flex-col">
<div className="flex justify-end">
{/* <ViewToggle /> */}
</div>
<HabitCalendar /> <HabitCalendar />
</div> </div>
) )

View File

@@ -1,4 +1,3 @@
import Layout from '@/components/Layout'
import CoinsManager from '@/components/CoinsManager' import CoinsManager from '@/components/CoinsManager'
export default function CoinsPage() { 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,14 +1,9 @@
import Layout from '@/components/Layout'
import HabitList from '@/components/HabitList' import HabitList from '@/components/HabitList'
import { ViewToggle } from '@/components/ViewToggle'
export default function HabitsPage() { export default function HabitsPage() {
return ( return (
<div className="flex flex-col gap-4"> <div className="flex flex-col">
<div className="flex justify-end"> <HabitList isTasksView={false} />
{/* <ViewToggle /> */}
</div>
<HabitList />
</div> </div>
) )
} }

View File

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

View File

@@ -1,42 +1,62 @@
'use client' 'use client'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' import { DynamicTimeNoSSR } from '@/components/DynamicTimeNoSSR';
import { Switch } from '@/components/ui/switch' import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Label } from '@/components/ui/label' import { Label } from '@/components/ui/label';
import { DynamicTimeNoSSR } from '@/components/DynamicTimeNoSSR' import { Switch } from '@/components/ui/switch';
import { useAtom } from 'jotai' import {
import { settingsAtom } from '@/lib/atoms' Tooltip,
import { Settings, WeekDay } from '@/lib/types' TooltipContent,
import { saveSettings, uploadAvatar } from '../actions/data' TooltipProvider,
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar' TooltipTrigger,
import { Button } from '@/components/ui/button' } from "@/components/ui/tooltip";
import { User } from 'lucide-react' 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() { 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) => { const updateSettings = async (newSettings: Settings) => {
await saveSettings(newSettings) await saveSettings(newSettings)
setSettings(newSettings) setSettings(newSettings)
} }
// handleDeleteAccount function removed
if (!settings) return null if (!settings) return <></>
return ( return (
<> <>
<div className="container mx-auto px-4 py-8"> <div>
<h1 className="text-3xl font-bold mb-6">Settings</h1> <h1 className="text-3xl font-bold mb-6">{t('title')}</h1>
<Card className="mb-6"> <Card className="mb-6">
<CardHeader> <CardHeader>
<CardTitle>UI Settings</CardTitle> <CardTitle>{t('uiSettingsTitle')}</CardTitle>
</CardHeader> </CardHeader>
<CardContent className="space-y-6"> <CardContent className="space-y-6">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div className="space-y-0.5"> <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"> <div className="text-sm text-muted-foreground">
Format large numbers (e.g., 1K, 1M, 1B) {t('numberFormattingDescription')}
</div> </div>
</div> </div>
<Switch <Switch
@@ -53,9 +73,9 @@ export default function SettingsPage() {
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div className="space-y-0.5"> <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"> <div className="text-sm text-muted-foreground">
Use thousand separators (e.g., 1,000 vs 1000) {t('numberGroupingDescription')}
</div> </div>
</div> </div>
<Switch <Switch
@@ -74,14 +94,14 @@ export default function SettingsPage() {
<Card className="mb-6"> <Card className="mb-6">
<CardHeader> <CardHeader>
<CardTitle>System Settings</CardTitle> <CardTitle>{t('systemSettingsTitle')}</CardTitle>
</CardHeader> </CardHeader>
<CardContent className="space-y-6"> <CardContent className="space-y-6">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div className="space-y-0.5"> <div className="space-y-0.5">
<Label htmlFor="timezone">Timezone</Label> <Label htmlFor="timezone">{t('timezoneLabel')}</Label>
<div className="text-sm text-muted-foreground"> <div className="text-sm text-muted-foreground">
Select your timezone for accurate date tracking {t('timezoneDescription')}
</div> </div>
</div> </div>
<div className="flex flex-col items-end gap-2"> <div className="flex flex-col items-end gap-2">
@@ -94,7 +114,7 @@ export default function SettingsPage() {
system: { ...settings.system, timezone: e.target.value } system: { ...settings.system, timezone: e.target.value }
}) })
} }
className="w-[200px] rounded-md border border-input bg-background px-3 py-2 mb-4" className="w-[110px] xs:w-[200px] rounded-md border border-input bg-background px-3 py-2 mb-4"
> >
{Intl.supportedValuesOf('timeZone').map((tz) => ( {Intl.supportedValuesOf('timeZone').map((tz) => (
<option key={tz} value={tz}> <option key={tz} value={tz}>
@@ -107,9 +127,9 @@ export default function SettingsPage() {
</div> </div>
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div className="space-y-0.5"> <div className="space-y-0.5">
<Label htmlFor="timezone">Week Start Day</Label> <Label htmlFor="weekStartDay">{t('weekStartDayLabel')}</Label>
<div className="text-sm text-muted-foreground"> <div className="text-sm text-muted-foreground">
Select your preferred first day of the week {t('weekStartDayDescription')}
</div> </div>
</div> </div>
<div className="flex flex-col items-end gap-2"> <div className="flex flex-col items-end gap-2">
@@ -122,7 +142,7 @@ export default function SettingsPage() {
system: { ...settings.system, weekStartDay: Number(e.target.value) as WeekDay } system: { ...settings.system, weekStartDay: Number(e.target.value) as WeekDay }
}) })
} }
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"
> >
{([ {([
['sunday', 0], ['sunday', 0],
@@ -132,70 +152,98 @@ export default function SettingsPage() {
['thursday', 4], ['thursday', 4],
['friday', 5], ['friday', 5],
['saturday', 6] ['saturday', 6]
] as Array<[string, WeekDay]>).map(([dayName, dayNumber]) => ( ] as Array<["sunday" | "monday" | "tuesday" | "wednesday" | "thursday" | "friday" | "saturday", WeekDay]>).map(([dayName, dayNumber]) => (
<option key={dayNumber} value={dayNumber}> <option key={dayNumber} value={dayNumber}>
{dayName.charAt(0).toUpperCase() + dayName.slice(1)} {t(`weekdays.${dayName}`)}
</option> </option>
))} ))}
</select> </select>
</div> </div>
</div> </div>
</CardContent>
</Card> {/* Add this section for Auto Backup */}
<Card className="mb-6">
<CardHeader>
<CardTitle>Profile Settings</CardTitle>
</CardHeader>
<CardContent className="space-y-6">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div className="space-y-0.5"> <div className="space-y-0.5">
<Label htmlFor="avatar">Avatar</Label> <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"> <div className="text-sm text-muted-foreground">
Customize your profile picture {t('autoBackupDescription')}
</div> </div>
</div> </div>
<div className="flex items-center gap-4"> <Switch
<Avatar className="h-16 w-16"> id="auto-backup"
<AvatarImage src={settings.profile?.avatarPath ? `/api/avatars/${settings.profile.avatarPath.split('/').pop()}` : '/avatars/default.png'} /> checked={settings.system.autoBackupEnabled}
<AvatarFallback> onCheckedChange={(checked) =>
<User className="h-8 w-8" /> updateSettings({
</AvatarFallback> ...settings,
</Avatar> system: { ...settings.system, autoBackupEnabled: checked }
<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 </div>
type="button" {/* End of Auto Backup section */}
variant="outline"
onClick={() => document.getElementById('avatar')?.click()} <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' : ''}`}
> >
Change {/* Add more languages as needed */}
</Button> <option value="en">English</option>
</form> <option value="es">Español</option>
</div> <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> </div>
</CardContent> </CardContent>
</Card> </Card>
{/* Danger Zone Card Removed */}
</div > </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' import WishlistManager from '@/components/WishlistManager'
export default function WishlistPage() { 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 { Star, History } from "lucide-react"
import packageJson from '../package.json' import packageJson from '../package.json'
import { DialogTitle } from "@radix-ui/react-dialog" import { DialogTitle } from "@radix-ui/react-dialog"
import { useTranslations } from "next-intl"
import { Logo } from "./Logo" import { Logo } from "./Logo"
import ChangelogModal from "./ChangelogModal" import ChangelogModal from "./ChangelogModal"
import { useState } from "react" import { useState } from "react"
interface AboutModalProps { interface AboutModalProps {
isOpen: boolean
onClose: () => void onClose: () => void
} }
export default function AboutModal({ isOpen, onClose }: AboutModalProps) { export default function AboutModal({ onClose }: AboutModalProps) {
const t = useTranslations('AboutModal')
const version = packageJson.version const version = packageJson.version
const [changelogOpen, setChangelogOpen] = useState(false) const [changelogOpen, setChangelogOpen] = useState(false)
return ( return (
<Dialog open={isOpen} onOpenChange={onClose}> <Dialog open={true} onOpenChange={onClose}>
<DialogContent className="max-w-sm"> <DialogContent className="max-w-sm">
<DialogHeader> <DialogHeader>
<DialogTitle aria-label="about"></DialogTitle> <DialogTitle aria-label={t('dialogArisLabel')}></DialogTitle>
</DialogHeader> </DialogHeader>
<div className="space-y-6 text-center py-4"> <div className="space-y-6 text-center py-4">
<div> <div>
@@ -40,14 +41,14 @@ export default function AboutModal({ isOpen, onClose }: AboutModalProps) {
onClick={() => setChangelogOpen(true)} onClick={() => setChangelogOpen(true)}
> >
<History className="w-3 h-3 mr-1" /> <History className="w-3 h-3 mr-1" />
Changelog {t('changelogButton')}
</Button> </Button>
</div> </div>
</div> </div>
<div className="space-y-4"> <div className="space-y-4">
<div className="text-sm"> <div className="text-sm">
Created with by{' '} {t('createdByPrefix')}{' '}
<a <a
href="https://github.com/dohsimpson" href="https://github.com/dohsimpson"
target="_blank" target="_blank"
@@ -56,17 +57,19 @@ export default function AboutModal({ isOpen, onClose }: AboutModalProps) {
> >
@dohsimpson @dohsimpson
</a> </a>
<br/>
Fork by <a href="https://github.com/ManInDark" target="_blank" rel="noopener noreferrer" className="font-medium hover:underline">@ManInDark</a>
</div> </div>
<div className="flex justify-center"> <div className="flex justify-center">
<a <a
href="https://github.com/dohsimpson/habittrove" href="https://github.com/ManInDark/HabitTrove"
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
> >
<Button variant="outline" size="sm"> <Button variant="outline" size="sm">
<Star className="w-4 h-4 mr-2" /> <Star className="w-4 h-4 mr-2" />
Star on GitHub {t('starOnGitHubButton')}
</Button> </Button>
</a> </a>
</div> </div>

View File

@@ -1,42 +1,81 @@
'use client' 'use client'
import { useState, useEffect } from 'react' import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'
import { RRule, RRuleSet, rrulestr } from 'rrule'
import { useAtom } from 'jotai'
import { settingsAtom, browserSettingsAtom } from '@/lib/atoms'
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '@/components/ui/dialog'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog'
import { Input } from '@/components/ui/input' import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label' import { Label } from '@/components/ui/label'
import { Textarea } from '@/components/ui/textarea'
import { Info, SmilePlus } from 'lucide-react'
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover' import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
import data from '@emoji-mart/data' import { Textarea } from '@/components/ui/textarea'
import Picker from '@emoji-mart/react' 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 { Habit } from '@/lib/types'
import { d2s, d2t, getISODate, getNow, parseNaturalLanguageDate, parseNaturalLanguageRRule, parseRRule, serializeRRule } from '@/lib/utils' import { convertHumanReadableFrequencyToMachineReadable, convertMachineReadableFrequencyToHumanReadable, d2t, serializeRRule } from '@/lib/utils'
import { INITIAL_DUE, INITIAL_RECURRENCE_RULE } from '@/lib/constants' import { useAtom } from 'jotai'
import * as chrono from 'chrono-node'; import { Brush, Zap } from 'lucide-react'
import { DateTime } from 'luxon' 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 { interface AddEditHabitModalProps {
onClose: () => void onClose: () => void
onSave: (habit: Omit<Habit, 'id'>) => Promise<void> onSave: (habit: Omit<Habit, 'id'>) => Promise<void>
habit?: Habit | null 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 [settings] = useAtom(settingsAtom)
const [browserSettings] = useAtom(browserSettingsAtom)
const isTasksView = browserSettings.viewType === 'tasks'
const [name, setName] = useState(habit?.name || '') const [name, setName] = useState(habit?.name || '')
const [description, setDescription] = useState(habit?.description || '') const [description, setDescription] = useState(habit?.description || '')
const [coinReward, setCoinReward] = useState(habit?.coinReward || 1) const [coinReward, setCoinReward] = useState(habit?.coinReward || 1)
const [targetCompletions, setTargetCompletions] = useState(habit?.targetCompletions || 1) const [targetCompletions, setTargetCompletions] = useState(habit?.targetCompletions || 1)
const isRecurRule = !isTasksView const isRecurRule = !isTask
const origRuleText = isRecurRule ? parseRRule(habit?.frequency || INITIAL_RECURRENCE_RULE).toText() : habit?.frequency || INITIAL_DUE // Initialize ruleText with the actual frequency string or default, not the display text
const [ruleText, setRuleText] = useState<string>(origRuleText) const initialRuleText = habit?.frequency ? convertMachineReadableFrequencyToHumanReadable({
const now = getNow({ timezone: settings.system.timezone }) 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) => { const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault() e.preventDefault()
@@ -46,22 +85,35 @@ export default function AddEditHabitModal({ onClose, onSave, habit }: AddEditHab
coinReward, coinReward,
targetCompletions: targetCompletions > 1 ? targetCompletions : undefined, targetCompletions: targetCompletions > 1 ? targetCompletions : undefined,
completions: habit?.completions || [], completions: habit?.completions || [],
frequency: isRecurRule ? serializeRRule(parseNaturalLanguageRRule(ruleText)) : d2t({ dateTime: parseNaturalLanguageDate({ text: ruleText, timezone: settings.system.timezone }) }), frequency: getFrequencyUpdate(),
isTask: isTasksView ? true : undefined 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 ( return (
<Dialog open={true} onOpenChange={onClose}> <>
<DialogContent> <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> <DialogHeader>
<DialogTitle>{habit ? `Edit ${isTasksView ? 'Task' : 'Habit'}` : `Add New ${isTasksView ? 'Task' : 'Habit'}`}</DialogTitle> <DialogTitle>
{habit
? t(isTask ? 'editTaskTitle' : 'editHabitTitle')
: t(isTask ? 'addNewTaskTitle' : 'addNewHabitTitle')}
</DialogTitle>
</DialogHeader> </DialogHeader>
<form onSubmit={handleSubmit}> <form onSubmit={handleSubmit}>
<div className="grid gap-4 py-4"> <div className="grid gap-4 py-4">
<div className="grid grid-cols-4 items-center gap-4"> <div className="grid grid-cols-4 items-center gap-4">
<Label htmlFor="name" className="text-right"> <Label htmlFor="name" className="text-right">
Name * {t('nameLabel')}
</Label> </Label>
<div className='flex col-span-3 gap-2'> <div className='flex col-span-3 gap-2'>
<Input <Input
@@ -70,38 +122,20 @@ export default function AddEditHabitModal({ onClose, onSave, habit }: AddEditHab
onChange={(e) => setName(e.target.value)} onChange={(e) => setName(e.target.value)}
required required
/> />
<Popover> <EmojiPickerButton
<PopoverTrigger asChild> inputIdToFocus="name"
<Button onEmojiSelect={(emoji) => {
type="button"
variant="ghost"
size="icon"
className="h-8 w-8"
>
<SmilePlus className="h-8 w-8" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-[300px] p-0">
<Picker
data={data}
onEmojiSelect={(emoji: { native: string }) => {
setName(prev => { setName(prev => {
// Add space before emoji if there isn't one already
const space = prev.length > 0 && !prev.endsWith(' ') ? ' ' : ''; const space = prev.length > 0 && !prev.endsWith(' ') ? ' ' : '';
return `${prev}${space}${emoji.native}`; return `${prev}${space}${emoji}`;
}) })
// Focus back on input after selection
const input = document.getElementById('name') as HTMLInputElement
input?.focus()
}} }}
/> />
</PopoverContent>
</Popover>
</div> </div>
</div> </div>
<div className="grid grid-cols-4 items-center gap-4"> <div className="grid grid-cols-4 items-center gap-4">
<Label htmlFor="description" className="text-right"> <Label htmlFor="description" className="text-right">
Description {t('descriptionLabel')}
</Label> </Label>
<Textarea <Textarea
id="description" id="description"
@@ -112,33 +146,63 @@ export default function AddEditHabitModal({ onClose, onSave, habit }: AddEditHab
</div> </div>
<div className="grid grid-cols-4 items-center gap-4"> <div className="grid grid-cols-4 items-center gap-4">
<Label htmlFor="recurrence" className="text-right"> <Label htmlFor="recurrence" className="text-right">
When * {t('whenLabel')}
</Label> </Label>
{/* date input (task) */}
<div className="col-span-3 space-y-2"> <div className="col-span-3 space-y-2">
<div className="flex gap-2">
<Input <Input
id="recurrence" id="recurrence"
value={ruleText} value={ruleText}
onChange={(e) => setRuleText(e.target.value)} onChange={(e) => setRuleText(e.target.value)}
required required
// placeholder="e.g. 'every weekday' or 'every 2 weeks on Monday, Wednesday'"
/> />
{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>
<div className="col-start-2 col-span-3 text-sm text-muted-foreground"> </div>
<span> </PopoverContent>
{(() => { </Popover>
try { )}
return isRecurRule ? parseNaturalLanguageRRule(ruleText).toText() : d2s({ dateTime: parseNaturalLanguageDate({ text: ruleText, timezone: settings.system.timezone }), timezone: settings.system.timezone, format: DateTime.DATE_MED_WITH_WEEKDAY }) </div>
} catch (e: unknown) { </div>
return `Invalid rule: ${e instanceof Error ? e.message : 'Invalid recurrence rule'}` {/* 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> </span>
</div> </div>
</div> </div>
<div className="grid grid-cols-4 items-center gap-4"> <div className="grid grid-cols-4 items-center gap-4">
<div className="flex items-center gap-2 justify-end"> <div className="flex items-center gap-2 justify-end">
<Label htmlFor="targetCompletions"> <Label htmlFor="targetCompletions">
Complete {t('completeLabel')}
</Label> </Label>
</div> </div>
<div className="col-span-3"> <div className="col-span-3">
@@ -172,7 +236,7 @@ export default function AddEditHabitModal({ onClose, onSave, habit }: AddEditHab
</button> </button>
</div> </div>
<span className="text-sm text-muted-foreground"> <span className="text-sm text-muted-foreground">
times {t('timesSuffix')}
</span> </span>
</div> </div>
</div> </div>
@@ -180,7 +244,7 @@ export default function AddEditHabitModal({ onClose, onSave, habit }: AddEditHab
<div className="grid grid-cols-4 items-center gap-4"> <div className="grid grid-cols-4 items-center gap-4">
<div className="flex items-center gap-2 justify-end"> <div className="flex items-center gap-2 justify-end">
<Label htmlFor="coinReward"> <Label htmlFor="coinReward">
Reward {t('rewardLabel')}
</Label> </Label>
</div> </div>
<div className="col-span-3"> <div className="col-span-3">
@@ -197,32 +261,111 @@ export default function AddEditHabitModal({ onClose, onSave, habit }: AddEditHab
id="coinReward" id="coinReward"
type="number" type="number"
value={coinReward} value={coinReward}
onChange={(e) => setCoinReward(parseInt(e.target.value === "" ? "0" : e.target.value))} onChange={(e) => {
const value = parseInt(e.target.value === "" ? "0" : e.target.value)
setCoinReward(Math.min(value, MAX_COIN_LIMIT))
}}
min={0} min={0}
max={MAX_COIN_LIMIT}
required required
className="w-20 text-center border-0 [appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none" className="w-20 text-center border-0 [appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none"
/> />
<button <button
type="button" type="button"
onClick={() => setCoinReward(prev => prev + 1)} 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" className="px-3 py-2 bg-gray-100 hover:bg-gray-200 dark:bg-gray-800 dark:hover:bg-gray-700 transition-colors"
> >
+ +
</button> </button>
</div> </div>
<span className="text-sm text-muted-foreground"> <span className="text-sm text-muted-foreground">
coins {t('coinsSuffix')}
</span> </span>
</div> </div>
</div> </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="outline"
onClick={(e) => {
e.preventDefault()
e.stopPropagation()
setIsDrawingModalOpen(true)
}}
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>
{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>
</div>
)}
</div> </div>
<DialogFooter> <DialogFooter>
<Button type="submit">{habit ? 'Save Changes' : `Add ${isTasksView ? 'Task' : 'Habit'}`}</Button> <Button type="submit" disabled={!!errorMessage}>
{habit
? t('saveChangesButton')
: t(isTask ? 'addTaskButton' : 'addHabitButton')}
</Button>
</DialogFooter> </DialogFooter>
</form> </form>
</DialogContent> </DialogContent>
</Dialog> </Dialog>
<DrawingModal
isOpen={isDrawingModalOpen}
onClose={() => setIsDrawingModalOpen(false)}
onSave={(drawingData) => setDrawing(drawingData)}
initialDrawing={drawing}
title={name}
/>
</>
) )
} }

View File

@@ -1,18 +1,22 @@
import { useState, useEffect } from 'react'
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '@/components/ui/dialog'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog'
import { Input } from '@/components/ui/input' import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label' import { Label } from '@/components/ui/label'
import { Textarea } from '@/components/ui/textarea' import { Textarea } from '@/components/ui/textarea'
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover' import { currentUserAtom, usersAtom } from '@/lib/atoms'
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip' import { MAX_COIN_LIMIT } from '@/lib/constants'
import { SmilePlus, Info } from 'lucide-react'
import data from '@emoji-mart/data'
import Picker from '@emoji-mart/react'
import { WishlistItemType } from '@/lib/types' 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 { interface AddEditWishlistItemModalProps {
isOpen: boolean
setIsOpen: (isOpen: boolean) => void setIsOpen: (isOpen: boolean) => void
editingItem: WishlistItemType | null editingItem: WishlistItemType | null
setEditingItem: (item: WishlistItemType | null) => void setEditingItem: (item: WishlistItemType | null) => void
@@ -21,19 +25,24 @@ interface AddEditWishlistItemModalProps {
} }
export default function AddEditWishlistItemModal({ export default function AddEditWishlistItemModal({
isOpen,
setIsOpen, setIsOpen,
editingItem, editingItem,
setEditingItem, setEditingItem,
addWishlistItem, addWishlistItem,
editWishlistItem editWishlistItem
}: AddEditWishlistItemModalProps) { }: AddEditWishlistItemModalProps) {
const t = useTranslations('AddEditWishlistItemModal')
const [name, setName] = useState(editingItem?.name || '') const [name, setName] = useState(editingItem?.name || '')
const [description, setDescription] = useState(editingItem?.description || '') const [description, setDescription] = useState(editingItem?.description || '')
const [coinCost, setCoinCost] = useState(editingItem?.coinCost || 1) const [coinCost, setCoinCost] = useState(editingItem?.coinCost || 1)
const [targetCompletions, setTargetCompletions] = useState<number | undefined>(editingItem?.targetCompletions) const [targetCompletions, setTargetCompletions] = useState<number | undefined>(editingItem?.targetCompletions)
const [link, setLink] = useState(editingItem?.link || '') 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 [errors, setErrors] = useState<{ [key: string]: string }>({})
const [usersData] = useAtom(usersAtom)
const [drawing, setDrawing] = useState<string>(editingItem?.drawing || '')
const [isDrawingModalOpen, setIsDrawingModalOpen] = useState(false)
useEffect(() => { useEffect(() => {
if (editingItem) { if (editingItem) {
@@ -42,12 +51,14 @@ export default function AddEditWishlistItemModal({
setCoinCost(editingItem.coinCost) setCoinCost(editingItem.coinCost)
setTargetCompletions(editingItem.targetCompletions) setTargetCompletions(editingItem.targetCompletions)
setLink(editingItem.link || '') setLink(editingItem.link || '')
setDrawing(editingItem.drawing || '')
} else { } else {
setName('') setName('')
setDescription('') setDescription('')
setCoinCost(1) setCoinCost(1)
setTargetCompletions(undefined) setTargetCompletions(undefined)
setLink('') setLink('')
setDrawing('')
} }
setErrors({}) setErrors({})
}, [editingItem]) }, [editingItem])
@@ -55,16 +66,18 @@ export default function AddEditWishlistItemModal({
const validate = () => { const validate = () => {
const newErrors: { [key: string]: string } = {} const newErrors: { [key: string]: string } = {}
if (!name.trim()) { if (!name.trim()) {
newErrors.name = 'Name is required' newErrors.name = t('errorNameRequired')
} }
if (coinCost < 1) { if (coinCost < 1) {
newErrors.coinCost = 'Coin cost must be at least 1' newErrors.coinCost = t('errorCoinCostMin')
} else if (coinCost > MAX_COIN_LIMIT) {
newErrors.coinCost = t('errorCoinCostMax', { max: MAX_COIN_LIMIT })
} }
if (targetCompletions !== undefined && targetCompletions < 1) { if (targetCompletions !== undefined && targetCompletions < 1) {
newErrors.targetCompletions = 'Target completions must be at least 1' newErrors.targetCompletions = t('errorTargetCompletionsMin')
} }
if (link && !isValidUrl(link)) { if (link && !isValidUrl(link)) {
newErrors.link = 'Please enter a valid URL' newErrors.link = t('errorInvalidUrl')
} }
setErrors(newErrors) setErrors(newErrors)
return Object.keys(newErrors).length === 0 return Object.keys(newErrors).length === 0
@@ -93,7 +106,9 @@ export default function AddEditWishlistItemModal({
description, description,
coinCost, coinCost,
targetCompletions: targetCompletions || undefined, targetCompletions: targetCompletions || undefined,
link: link.trim() || undefined link: link.trim() || undefined,
userIds: selectedUserIds.length > 0 ? selectedUserIds.concat(currentUser?.id || []) : (currentUser && [currentUser.id]),
drawing: drawing && drawing !== '[]' ? drawing : undefined
} }
if (editingItem) { if (editingItem) {
@@ -107,16 +122,22 @@ export default function AddEditWishlistItemModal({
} }
return ( return (
<Dialog open={isOpen} onOpenChange={handleClose}> <>
<DialogContent> <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> <DialogHeader>
<DialogTitle>{editingItem ? 'Edit Reward' : 'Add New Reward'}</DialogTitle> <DialogTitle>{editingItem ? t('editTitle') : t('addTitle')}</DialogTitle>
</DialogHeader> </DialogHeader>
<form onSubmit={handleSave}> <form onSubmit={handleSave}>
<div className="grid gap-4 py-4"> <div className="grid gap-4 py-4">
<div className="grid grid-cols-4 items-center gap-4"> <div className="grid grid-cols-4 items-center gap-4">
<Label htmlFor="name" className="text-right"> <Label htmlFor="name" className="text-right">
Name * {t('nameLabel')}
</Label> </Label>
<div className="col-span-3 flex gap-2"> <div className="col-span-3 flex gap-2">
<Input <Input
@@ -126,34 +147,20 @@ export default function AddEditWishlistItemModal({
className="flex-1" className="flex-1"
required required
/> />
<Popover> <EmojiPickerButton
<PopoverTrigger asChild> inputIdToFocus="name"
<Button onEmojiSelect={(emoji) => {
type="button" setName(prev => {
variant="ghost" const space = prev.length > 0 && !prev.endsWith(' ') ? ' ' : '';
size="icon" return `${prev}${space}${emoji}`;
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()
}} }}
/> />
</PopoverContent>
</Popover>
</div> </div>
</div> </div>
<div className="grid grid-cols-4 items-center gap-4"> <div className="grid grid-cols-4 items-center gap-4">
<Label htmlFor="description" className="text-right"> <Label htmlFor="description" className="text-right">
Description {t('descriptionLabel')}
</Label> </Label>
<Textarea <Textarea
id="description" id="description"
@@ -165,7 +172,7 @@ export default function AddEditWishlistItemModal({
<div className="grid grid-cols-4 items-center gap-4"> <div className="grid grid-cols-4 items-center gap-4">
<div className="flex items-center gap-2 justify-end"> <div className="flex items-center gap-2 justify-end">
<Label htmlFor="coinReward"> <Label htmlFor="coinReward">
Cost {t('costLabel')}
</Label> </Label>
</div> </div>
<div className="col-span-3"> <div className="col-span-3">
@@ -182,21 +189,25 @@ export default function AddEditWishlistItemModal({
id="coinReward" id="coinReward"
type="number" type="number"
value={coinCost} value={coinCost}
onChange={(e) => setCoinCost(parseInt(e.target.value === "" ? "0" : e.target.value))} onChange={(e) => {
const value = parseInt(e.target.value === "" ? "0" : e.target.value)
setCoinCost(Math.min(value, MAX_COIN_LIMIT))
}}
min={0} min={0}
max={MAX_COIN_LIMIT}
required required
className="w-20 text-center border-0 [appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none" className="w-20 text-center border-0 [appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none"
/> />
<button <button
type="button" type="button"
onClick={() => setCoinCost(prev => prev + 1)} 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" className="px-3 py-2 bg-gray-100 hover:bg-gray-200 dark:bg-gray-800 dark:hover:bg-gray-700 transition-colors"
> >
+ +
</button> </button>
</div> </div>
<span className="text-sm text-muted-foreground"> <span className="text-sm text-muted-foreground">
coins {t('coinsSuffix')}
</span> </span>
</div> </div>
</div> </div>
@@ -204,7 +215,7 @@ export default function AddEditWishlistItemModal({
<div className="grid grid-cols-4 items-center gap-4"> <div className="grid grid-cols-4 items-center gap-4">
<div className="flex items-center gap-2 justify-end"> <div className="flex items-center gap-2 justify-end">
<Label htmlFor="targetCompletions"> <Label htmlFor="targetCompletions">
Redeemable {t('redeemableLabel')}
</Label> </Label>
</div> </div>
<div className="col-span-3"> <div className="col-span-3">
@@ -238,7 +249,7 @@ export default function AddEditWishlistItemModal({
</button> </button>
</div> </div>
<span className="text-sm text-muted-foreground"> <span className="text-sm text-muted-foreground">
times {t('timesSuffix')}
</span> </span>
</div> </div>
{errors.targetCompletions && ( {errors.targetCompletions && (
@@ -250,7 +261,7 @@ export default function AddEditWishlistItemModal({
</div> </div>
<div className="grid grid-cols-4 items-center gap-4"> <div className="grid grid-cols-4 items-center gap-4">
<Label htmlFor="link" className="text-right"> <Label htmlFor="link" className="text-right">
Link {t('linkLabel')}
</Label> </Label>
<div className="col-span-3"> <div className="col-span-3">
<Input <Input
@@ -268,13 +279,85 @@ export default function AddEditWishlistItemModal({
)} )}
</div> </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="outline"
onClick={(e) => {
e.preventDefault()
e.stopPropagation()
setIsDrawingModalOpen(true)
}}
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>
<DialogFooter> <DialogFooter>
<Button type="submit">{editingItem ? 'Save Changes' : 'Add Reward'}</Button> <Button type="submit">{editingItem ? t('saveButton') : t('addButton')}</Button>
</DialogFooter> </DialogFooter>
</form> </form>
</DialogContent> </DialogContent>
</Dialog> </Dialog>
<DrawingModal
isOpen={isDrawingModalOpen}
onClose={() => setIsDrawingModalOpen(false)}
onSave={(drawingData) => setDrawing(drawingData)}
initialDrawing={drawing}
title={name}
/>
</>
) )
} }

View File

@@ -1,19 +1,97 @@
'use client' 'use client'
import { ReactNode } from 'react' import { checkDataFreshness as checkServerDataFreshness } from '@/app/actions/data';
import { useAtom } from 'jotai' import { aboutOpenAtom, clientFreshnessTokenAtom, currentUserIdAtom, pomodoroAtom, userSelectAtom } from '@/lib/atoms';
import { pomodoroAtom } from '@/lib/atoms' import { useAtom, useAtomValue, useSetAtom } from 'jotai';
import PomodoroTimer from './PomodoroTimer' 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 [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 ( return (
<> <>
{children} {children}
{pomo.show && ( {pomo.show && <PomodoroTimer />}
<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 { Coins } from 'lucide-react'
import { FormattedNumber } from '@/components/FormattedNumber' import { FormattedNumber } from '@/components/FormattedNumber'
import { useAtom } from 'jotai' import { useAtom } from 'jotai'
import { useTranslations } from 'next-intl'
import { settingsAtom } from '@/lib/atoms' import { settingsAtom } from '@/lib/atoms'
import dynamic from 'next/dynamic' import dynamic from 'next/dynamic'
const TodayEarnedCoins = dynamic(() => import('./TodayEarnedCoins'), { ssr: false }) const TodayEarnedCoins = dynamic(() => import('./TodayEarnedCoins'), { ssr: false })
export default function CoinBalance({ coinBalance }: { coinBalance: number }) { export default function CoinBalance({ coinBalance }: { coinBalance: number }) {
const t = useTranslations('CoinBalance');
const [settings] = useAtom(settingsAtom) const [settings] = useAtom(settingsAtom)
return ( return (
<Card> <Card>
<CardHeader> <CardHeader>
<CardTitle>Coin Balance</CardTitle> <CardTitle>{t('coinBalanceTitle')}</CardTitle>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<div className="flex items-center justify-center"> <div className="flex items-center justify-center">

View File

@@ -1,20 +1,29 @@
'use client' '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 { FormattedNumber } from '@/components/FormattedNumber'
import { History, Pencil } from 'lucide-react' import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'
import EmptyState from './EmptyState' import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { settingsAtom } from '@/lib/atoms' import { Input } from '@/components/ui/input'
import Link from 'next/link'
import { useAtom } from 'jotai'
import { useCoins } from '@/hooks/useCoins' 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' import { TransactionNoteEditor } from './TransactionNoteEditor'
export default function CoinsManager() { export default function CoinsManager() {
const t = useTranslations('CoinsManager')
const [currentUser] = useAtom(currentUserAtom)
const [selectedUser, setSelectedUser] = useState<string>()
const { const {
add, add,
remove, remove,
@@ -26,14 +35,40 @@ export default function CoinsManager() {
totalSpent, totalSpent,
coinsSpentToday, coinsSpentToday,
transactionsToday transactionsToday
} = useCoins() } = useCoins({ selectedUser })
const [settings] = useAtom(settingsAtom) const [settings] = useAtom(settingsAtom)
const [usersData] = useAtom(usersAtom)
const DEFAULT_AMOUNT = '0' const DEFAULT_AMOUNT = '0'
const [amount, setAmount] = useState(DEFAULT_AMOUNT) const [amount, setAmount] = useState(DEFAULT_AMOUNT)
const [pageSize, setPageSize] = useState(50) const [pageSize, setPageSize] = useState(50)
const [currentPage, setCurrentPage] = useState(1) const [currentPage, setCurrentPage] = useState(1)
const [note, setNote] = useState('') 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) => { const handleSaveNote = async (transactionId: string, note: string) => {
await updateNote(transactionId, note) 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 ( return (
<div className="container mx-auto px-4 py-8"> <div>
<h1 className="text-3xl font-bold mb-6">Coins Management</h1> <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"> <div className="grid gap-6 md:grid-cols-2">
<Card> <Card>
@@ -66,8 +127,8 @@ export default function CoinsManager() {
<CardTitle className="flex items-center gap-2"> <CardTitle className="flex items-center gap-2">
<span className="text-2xl animate-bounce hover:animate-none cursor-default">💰</span> <span className="text-2xl animate-bounce hover:animate-none cursor-default">💰</span>
<div> <div>
<div className="text-sm font-normal text-muted-foreground">Current Balance</div> <div className="text-sm font-normal text-muted-foreground">{t('currentBalanceLabel')}</div>
<div className="text-3xl font-bold"><FormattedNumber amount={balance} settings={settings} /> coins</div> <div className="text-3xl font-bold"><FormattedNumber amount={balance} settings={settings} /> {t('coinsSuffix')}</div>
</div> </div>
</CardTitle> </CardTitle>
</CardHeader> </CardHeader>
@@ -79,7 +140,11 @@ export default function CoinsManager() {
variant="outline" variant="outline"
size="icon" size="icon"
className="h-10 w-10 text-lg" 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> </Button>
@@ -87,7 +152,22 @@ export default function CoinsManager() {
<Input <Input
type="number" type="number"
value={amount} 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" className="text-center text-xl font-medium h-12"
/> />
<div className="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground"> <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" variant="outline"
size="icon" size="icon"
className="h-10 w-10 text-lg" 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> </Button>
@@ -112,7 +196,7 @@ export default function CoinsManager() {
variant="default" variant="default"
> >
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
{Number(amount) >= 0 ? 'Add Coins' : 'Remove Coins'} {Number(amount) >= 0 ? t('addCoinsButton') : t('removeCoinsButton')}
</div> </div>
</Button> </Button>
</div> </div>
@@ -124,27 +208,27 @@ export default function CoinsManager() {
<Card> <Card>
<CardHeader> <CardHeader>
<CardTitle>Statistics</CardTitle> <CardTitle>{t('statisticsTitle')}</CardTitle>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<div className="grid grid-cols-1 xl:grid-cols-3 gap-4"> <div className="grid grid-cols-1 xl:grid-cols-3 gap-4">
{/* Top Row - Totals */} {/* Top Row - Totals */}
<div className="p-4 rounded-lg bg-green-100 dark:bg-green-900"> <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"> <div className="text-2xl font-bold text-green-900 dark:text-green-50">
<FormattedNumber amount={totalEarned} settings={settings} /> 🪙 <FormattedNumber amount={totalEarned} settings={settings} /> 🪙
</div> </div>
</div> </div>
<div className="p-4 rounded-lg bg-red-100 dark:bg-red-900"> <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"> <div className="text-2xl font-bold text-red-900 dark:text-red-50">
<FormattedNumber amount={totalSpent} settings={settings} /> 💸 <FormattedNumber amount={totalSpent} settings={settings} /> 💸
</div> </div>
</div> </div>
<div className="p-4 rounded-lg bg-pink-100 dark:bg-pink-900"> <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"> <div className="text-2xl font-bold text-pink-900 dark:text-pink-50">
{transactions.length} 📈 {transactions.length} 📈
</div> </div>
@@ -152,21 +236,21 @@ export default function CoinsManager() {
{/* Bottom Row - Today */} {/* Bottom Row - Today */}
<div className="p-4 rounded-lg bg-blue-100 dark:bg-blue-900"> <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"> <div className="text-2xl font-bold text-blue-900 dark:text-blue-50">
<FormattedNumber amount={coinsEarnedToday} settings={settings} /> 🪙 <FormattedNumber amount={coinsEarnedToday} settings={settings} /> 🪙
</div> </div>
</div> </div>
<div className="p-4 rounded-lg bg-purple-100 dark:bg-purple-900"> <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"> <div className="text-2xl font-bold text-purple-900 dark:text-purple-50">
<FormattedNumber amount={coinsSpentToday} settings={settings} /> 💸 <FormattedNumber amount={coinsSpentToday} settings={settings} /> 💸
</div> </div>
</div> </div>
<div className="p-4 rounded-lg bg-orange-100 dark:bg-orange-900"> <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"> <div className="text-2xl font-bold text-orange-900 dark:text-orange-50">
{transactionsToday} 📊 {transactionsToday} 📊
</div> </div>
@@ -177,13 +261,13 @@ export default function CoinsManager() {
<Card className="md:col-span-2"> <Card className="md:col-span-2">
<CardHeader> <CardHeader>
<CardTitle>Transaction History</CardTitle> <CardTitle>{t('transactionHistoryTitle')}</CardTitle>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<div className="space-y-4"> <div className="space-y-4">
<div className="flex justify-between items-center mb-4"> <div className="flex justify-between items-center mb-4">
<div className="flex items-center gap-2"> <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 <select
className="border rounded p-1" className="border rounded p-1"
value={pageSize} value={pageSize}
@@ -192,22 +276,20 @@ export default function CoinsManager() {
setCurrentPage(1) // Reset to first page when changing page size setCurrentPage(1) // Reset to first page when changing page size
}} }}
> >
<option value={50}>50</option> {PAGE_ENTRY_COUNTS.map(n => <option key={n} value={n}>{n}</option>)}
<option value={100}>100</option>
<option value={500}>500</option>
</select> </select>
<span className="text-sm text-muted-foreground">entries</span> <span className="text-sm text-muted-foreground">{t('entriesSuffix')}</span>
</div> </div>
<div className="text-sm text-muted-foreground"> <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>
</div> </div>
{transactions.length === 0 ? ( {transactions.length === 0 ? (
<EmptyState <EmptyState
icon={History} icon={History}
title="No transactions yet" title={t('noTransactionsTitle')}
description="Your transaction history will appear here once you start earning or spending coins" 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 ( return (
<div <div
key={transaction.id} 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="space-y-1 flex-grow mr-4"> {/* Added flex-grow and margin */}
<div className="flex items-center gap-2"> <div className="flex items-center gap-2 flex-wrap"> {/* Added flex-wrap */}
{transaction.relatedItemId ? ( {transaction.relatedItemId ? (
<Link <Link
href={`${transaction.type === 'WISH_REDEMPTION' ? '/wishlist' : '/habits'}?highlight=${transaction.relatedItemId}`} href={`${transaction.type === 'WISH_REDEMPTION' ? '/wishlist' : '/habits'}?highlight=${transaction.relatedItemId}`}
@@ -250,8 +336,20 @@ export default function CoinsManager() {
<span <span
className={`text-xs px-2 py-1 rounded-full ${getBadgeStyles()}`} className={`text-xs px-2 py-1 rounded-full ${getBadgeStyles()}`}
> >
{transaction.type.split('_').join(' ')} {getTransactionTypeLabel(transaction.type as TransactionType)}
</span> </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> </div>
<p className="text-sm text-gray-500"> <p className="text-sm text-gray-500">
{d2s({ dateTime: t2d({ timestamp: transaction.timestamp, timezone: settings.system.timezone }), timezone: settings.system.timezone })} {d2s({ dateTime: t2d({ timestamp: transaction.timestamp, timezone: settings.system.timezone }), timezone: settings.system.timezone })}
@@ -263,6 +361,7 @@ export default function CoinsManager() {
onDelete={handleDeleteNote} onDelete={handleDeleteNote}
/> />
</div> </div>
<div className="flex-shrink-0 text-right"> {/* Ensure amount stays on the right */}
<span <span
className={`font-mono ${transaction.amount >= 0 className={`font-mono ${transaction.amount >= 0
? 'text-green-600 dark:text-green-400' ? 'text-green-600 dark:text-green-400'
@@ -272,6 +371,7 @@ export default function CoinsManager() {
{transaction.amount >= 0 ? '+' : ''}{transaction.amount} {transaction.amount >= 0 ? '+' : ''}{transaction.amount}
</span> </span>
</div> </div>
</div>
) )
})} })}
@@ -294,9 +394,9 @@ export default function CoinsManager() {
</Button> </Button>
<div className="flex items-center gap-1 px-4 py-2 rounded-md bg-muted"> <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-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> <span className="text-sm font-bold">{Math.ceil(transactions.length / pageSize)}</span>
</div> </div>
<Button <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, DialogFooter,
} from '@/components/ui/dialog' } from '@/components/ui/dialog'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { useTranslations } from 'next-intl'
interface ConfirmDialogProps { interface ConfirmDialogProps {
isOpen: boolean isOpen: boolean
@@ -23,9 +24,13 @@ export default function ConfirmDialog({
onConfirm, onConfirm,
title, title,
message, message,
confirmText = "Confirm", confirmText,
cancelText = "Cancel" cancelText,
}: ConfirmDialogProps) { }: ConfirmDialogProps) {
const t = useTranslations('ConfirmDialog');
const finalConfirmText = confirmText || t('confirmButton');
const finalCancelText = cancelText || t('cancelButton');
return ( return (
<Dialog open={isOpen} onOpenChange={onClose}> <Dialog open={isOpen} onOpenChange={onClose}>
<DialogContent> <DialogContent>
@@ -37,10 +42,10 @@ export default function ConfirmDialog({
</div> </div>
<DialogFooter> <DialogFooter>
<Button variant="outline" onClick={onClose}> <Button variant="outline" onClick={onClose}>
{cancelText} {finalCancelText}
</Button> </Button>
<Button variant="destructive" onClick={onConfirm}> <Button variant="destructive" onClick={onConfirm}>
{confirmText} {finalConfirmText}
</Button> </Button>
</DialogFooter> </DialogFooter>
</DialogContent> </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 { import {
ContextMenu, ContextMenu,
ContextMenuContent, ContextMenuContent,
ContextMenuItem, ContextMenuTrigger
ContextMenuTrigger,
} from "@/components/ui/context-menu" } 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, completedHabitsMapAtom, browserSettingsAtom } from '@/lib/atoms'
import { getTodayInTimezone, isSameDate, t2d, d2t, getNow } 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 { Progress } from '@/components/ui/progress'
import { WishlistItemType } from '@/lib/types' import {
import { Habit } from '@/lib/types' Tooltip,
import Linkify from './linkify' TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip"
import { useHabits } from '@/hooks/useHabits' 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 { interface UpcomingItemsProps {
habits: Habit[] habits: Habit[]
@@ -25,80 +36,122 @@ interface UpcomingItemsProps {
coinBalance: number coinBalance: number
} }
export default function DailyOverview({ interface ItemSectionProps {
habits, title: string;
wishlistItems, items: Habit[];
coinBalance, emptyMessage: string;
}: UpcomingItemsProps) { isTask: boolean;
const { completeHabit, undoComplete } = useHabits() viewLink: string;
const [settings] = useAtom(settingsAtom) addNewItem: () => void;
const [browserSettings] = useAtom(browserSettingsAtom)
const [dailyHabits, setDailyHabits] = useState<Habit[]>([])
const [completedHabitsMap] = useAtom(completedHabitsMapAtom)
const today = getTodayInTimezone(settings.system.timezone)
const todayCompletions = completedHabitsMap.get(today) || []
const isTasksView = browserSettings.viewType === 'tasks'
useEffect(() => {
// Filter habits that are due today based on their recurrence rule
const filteredHabits = habits.filter(habit =>
(isTasksView ? habit.isTask : !habit.isTask) &&
isHabitDueToday({ habit, timezone: settings.system.timezone })
)
setDailyHabits(filteredHabits)
}, [habits, isTasksView])
// Get all wishlist items sorted by redeemable status (non-redeemable first) then by coin cost
const sortedWishlistItems = wishlistItems
.sort((a, b) => {
const aRedeemable = a.coinCost <= coinBalance
const bRedeemable = b.coinCost <= coinBalance
// Non-redeemable items first
if (aRedeemable !== bRedeemable) {
return aRedeemable ? 1 : -1
} }
// Then sort by coin cost (lower cost first) const ItemSection = ({
return a.coinCost - b.coinCost 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 [expandedHabits, setExpandedHabits] = useState(false) const today = getTodayInTimezone(settings.system.timezone);
const [expandedWishlist, setExpandedWishlist] = useState(false) const currentTodayCompletions = completedHabitsMap.get(today) || [];
const [_, setPomo] = useAtom(pomodoroAtom) 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 ( return (
<>
<Card>
<CardHeader>
<CardTitle>Today's Overview</CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-4">
<div> <div>
<div className="flex items-center justify-between mb-2"> <div className="flex items-center justify-between mb-2">
<h3 className="font-semibold">{isTasksView ? 'Daily Tasks' : 'Daily Habits'}</h3> <h3 className="font-semibold">{title}</h3>
<Badge variant="secondary"> <Button
{`${dailyHabits.filter(habit => { variant="ghost"
const completions = (completedHabitsMap.get(today) || []) size="sm"
.filter(h => h.id === habit.id).length; className="h-7 w-7 rounded-full hover:bg-primary/10 hover:text-primary"
return completions >= (habit.targetCompletions || 1); onClick={addNewItem}
}).length}/${dailyHabits.length} Completed`} >
</Badge> <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 ${expandedHabits ? 'max-h-none' : 'max-h-[200px]'} overflow-hidden`}> <div className="text-center text-muted-foreground text-sm py-4">
{dailyHabits {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) => { .sort((a, b) => {
// First by completion status // First by pinned status
const aCompleted = todayCompletions.includes(a); if (a.pinned !== b.pinned) {
const bCompleted = todayCompletions.includes(b); return a.pinned ? -1 : 1;
}
// Then by completion status
const aCompleted = currentTodayCompletions.includes(a);
const bCompleted = currentTodayCompletions.includes(b);
if (aCompleted !== bCompleted) { if (aCompleted !== bCompleted) {
return aCompleted ? 1 : -1; return aCompleted ? 1 : -1;
} }
// Then by frequency (daily first) // Then by frequency (daily first)
const aFreq = getHabitFreq(a); const aFreq = habitFreqMap.get(a.id) || 'daily';
const bFreq = getHabitFreq(b); const bFreq = habitFreqMap.get(b.id) || 'daily';
const freqOrder = ['daily', 'weekly', 'monthly', 'yearly']; const freqOrder = ['daily', 'weekly', 'monthly', 'yearly'];
if (freqOrder.indexOf(aFreq) !== freqOrder.indexOf(bFreq)) { if (freqOrder.indexOf(aFreq) !== freqOrder.indexOf(bFreq)) {
return freqOrder.indexOf(aFreq) - freqOrder.indexOf(bFreq); return freqOrder.indexOf(aFreq) - freqOrder.indexOf(bFreq);
@@ -114,26 +167,28 @@ export default function DailyOverview({
const bTarget = b.targetCompletions || 1; const bTarget = b.targetCompletions || 1;
return bTarget - aTarget; return bTarget - aTarget;
}) })
.slice(0, expandedHabits ? undefined : 5) .slice(0, currentExpanded ? undefined : DESKTOP_DISPLAY_ITEM_COUNT)
.map((habit) => { .map((habit) => {
const completionsToday = habit.completions.filter(completion => 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 })) isSameDate(t2d({ timestamp: completion, timezone: settings.system.timezone }), t2d({ timestamp: d2t({ dateTime: getNow({ timezone: settings.system.timezone }) }), timezone: settings.system.timezone }))
).length ).length
const target = habit.targetCompletions || 1 const target = habit.targetCompletions || 1
const isCompleted = completionsToday >= target const isCompleted = completionsToday >= target || (isTask && habit.archived)
return ( return (
<li <li
className={`flex items-center justify-between text-sm p-2 rounded-md className={`flex items-center justify-between text-sm p-2 rounded-md
${isCompleted ? 'bg-secondary/50' : 'bg-secondary/20'}`} ${isCompleted ? 'bg-secondary/50' : 'bg-secondary/20'}`}
key={habit.id} key={habit.id}
> >
<span className="flex items-center gap-2"> <span className="flex items-center gap-2 flex-1 min-w-0">
<ContextMenu> <ContextMenu>
<ContextMenuTrigger asChild> <ContextMenuTrigger asChild>
<div className="flex-none"> <div className="flex items-center gap-2 cursor-pointer flex-1 min-w-0">
<div className="flex-shrink-0">
<button <button
onClick={(e) => { onClick={(e) => {
e.preventDefault(); e.preventDefault();
e.stopPropagation();
if (isCompleted) { if (isCompleted) {
undoComplete(habit); undoComplete(habit);
} else { } else {
@@ -162,35 +217,68 @@ export default function DailyOverview({
)} )}
</button> </button>
</div> </div>
</ContextMenuTrigger> <span className="flex items-center gap-1">
<span className={isCompleted ? 'line-through' : ''}> {habit.pinned && (
<Linkify> <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} {habit.name}
</Linkify>
</span> </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"> <ContextMenuContent className="w-64">
<ContextMenuItem onClick={() => { <HabitContextMenuItems
setPomo((prev) => ({ habit={habit}
...prev, onEditRequest={() => handleEditClick(habit)}
show: true, onDeleteRequest={() => handleDeleteClick(habit)}
selectedHabitId: habit.id context="daily-overview"
})) />
}}>
<Timer className="mr-2 h-4 w-4" />
<span>Start Pomodoro</span>
</ContextMenuItem>
</ContextMenuContent> </ContextMenuContent>
</ContextMenu> </ContextMenu>
</span> </span>
<span className="flex items-center gap-2 text-xs text-muted-foreground"> <span className="flex items-center gap-2 text-xs text-muted-foreground flex-shrink-0">
{habit.targetCompletions && ( {habit.targetCompletions && (
<span className="bg-secondary px-1.5 py-0.5 rounded-full"> <span className="bg-secondary px-1.5 py-0.5 rounded-full">
{completionsToday}/{target} {completionsToday}/{target}
</span> </span>
)} )}
{getHabitFreq(habit) !== 'daily' && ( {habitFreqMap.get(habit.id) !== 'daily' && (
<Badge variant="outline" className="text-xs"> <Badge variant="outline" className="text-xs">
{getHabitFreq(habit)} {habitFreqMap.get(habit.id)}
</Badge> </Badge>
)} )}
<span className="flex items-center"> <span className="flex items-center">
@@ -216,48 +304,159 @@ export default function DailyOverview({
</ul> </ul>
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<button <button
onClick={() => setExpandedHabits(!expandedHabits)} onClick={() => setCurrentExpanded(!currentExpanded)}
className="text-sm text-muted-foreground hover:text-primary flex items-center gap-1" className="text-sm text-muted-foreground hover:text-primary flex items-center gap-1"
> >
{expandedHabits ? ( {items.length > DESKTOP_DISPLAY_ITEM_COUNT && (currentExpanded ? (
<> <>
Show less {t('showLessButton')}
<ChevronUp className="h-3 w-3" /> <ChevronUp className="h-3 w-3" />
</> </>
) : ( ) : (
<> <>
Show all {t('showAllButton')}
<ChevronDown className="h-3 w-3" /> <ChevronDown className="h-3 w-3" />
</> </>
)} ))}
</button> </button>
<Link <Link
href="/habits" href={viewLink}
className="text-sm text-muted-foreground hover:text-primary flex items-center gap-1" className="text-sm text-muted-foreground hover:text-primary flex items-center gap-1"
> >
View View
<ArrowRight className="h-3 w-3" /> <ArrowRight className="h-3 w-3" />
</Link> </Link>
</div> </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> </div>
);
};
export default function DailyOverview({
habits,
wishlistItems,
coinBalance,
}: UpcomingItemsProps) {
const t = useTranslations('DailyOverview');
const { completeHabit, undoComplete } = useHabits()
const [settings] = useAtom(settingsAtom)
const [completedHabitsMap] = useAtom(completedHabitsMapAtom)
const [browserSettings, setBrowserSettings] = useAtom(browserSettingsAtom)
const today = getTodayInTimezone(settings.system.timezone)
const todayCompletions = completedHabitsMap.get(today) || []
const { saveHabit } = useHabits()
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
// Non-redeemable items first
if (aRedeemable !== bRedeemable) {
return aRedeemable ? 1 : -1
}
// Then sort by coin cost (lower cost first)
return a.coinCost - b.coinCost
})
const [hasTasks] = useAtom(hasTasksAtom)
const [, setPomo] = useAtom(pomodoroAtom)
const [modalConfig, setModalConfig] = useState<{
isOpen: boolean,
isTask: boolean
}>({
isOpen: false,
isTask: false
});
return (
<>
<Card>
<CardHeader>
<CardTitle>{t('todaysOverviewTitle')}</CardTitle>
</CardHeader>
<CardContent>
<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 })}
/>
)}
{/* 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="space-y-2">
<div className="flex items-center justify-between mb-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"> <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> </Badge>
</div> </div>
<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 ? ( {sortedWishlistItems.length === 0 ? (
<div className="text-center text-muted-foreground text-sm py-4"> <div className="text-center text-muted-foreground text-sm py-4">
No wishlist items yet. Add some goals to work towards! {t('noWishlistItemsMessage')}
</div> </div>
) : ( ) : (
<> <>
{sortedWishlistItems {sortedWishlistItems
.slice(0, expandedWishlist ? undefined : 5) .slice(0, browserSettings.expandedWishlist ? undefined : DESKTOP_DISPLAY_ITEM_COUNT)
.map((item) => { .map((item) => {
const isRedeemable = item.coinCost <= coinBalance const isRedeemable = item.coinCost <= coinBalance
return ( return (
@@ -270,9 +469,19 @@ export default function DailyOverview({
)} )}
> >
<div className="flex items-center justify-between mb-2"> <div className="flex items-center justify-between mb-2">
<div className="flex items-center gap-2">
<span className="text-sm"> <span className="text-sm">
<Linkify>{item.name}</Linkify> <Linkify>{item.name}</Linkify>
</span> </span>
{item.drawing && (
<DrawingDisplay
drawingData={item.drawing}
width={40}
height={26}
className="border-0"
/>
)}
</div>
<span className="text-xs flex items-center"> <span className="text-xs flex items-center">
<Coins className={cn( <Coins className={cn(
"h-3 w-3 mr-1 transition-all", "h-3 w-3 mr-1 transition-all",
@@ -299,8 +508,8 @@ export default function DailyOverview({
/> />
<p className="text-xs text-muted-foreground mt-2"> <p className="text-xs text-muted-foreground mt-2">
{isRedeemable {isRedeemable
? "Ready to redeem!" ? t('readyToRedeemMessage')
: `${item.coinCost - coinBalance} coins to go` : t('coinsToGoMessage', { amount: item.coinCost - coinBalance })
} }
</p> </p>
</Link> </Link>
@@ -311,26 +520,26 @@ export default function DailyOverview({
</div> </div>
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<button <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" 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" /> <ChevronUp className="h-3 w-3" />
</> </>
) : ( ) : (
<> <>
Show all {t('showAllButton')}
<ChevronDown className="h-3 w-3" /> <ChevronDown className="h-3 w-3" />
</> </>
)} ))}
</button> </button>
<Link <Link
href="/wishlist" href="/wishlist"
className="text-sm text-muted-foreground hover:text-primary flex items-center gap-1" className="text-sm text-muted-foreground hover:text-primary flex items-center gap-1"
> >
View {t('viewButton')}
<ArrowRight className="h-3 w-3" /> <ArrowRight className="h-3 w-3" />
</Link> </Link>
</div> </div>
@@ -339,6 +548,17 @@ export default function DailyOverview({
</div> </div>
</CardContent> </CardContent>
</Card> </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,35 +1,33 @@
'use client' 'use client'
import { useCoins } from '@/hooks/useCoins'
import { habitsAtom, wishlistAtom } from '@/lib/atoms'
import { useAtom } from 'jotai' 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 DailyOverview from './DailyOverview'
import HabitStreak from './HabitStreak' import HabitStreak from './HabitStreak'
import CoinBalance from './CoinBalance'
import { useHabits } from '@/hooks/useHabits'
import { ViewToggle } from './ViewToggle'
export default function Dashboard() { export default function Dashboard() {
const t = useTranslations('Dashboard');
const [habitsData] = useAtom(habitsAtom) const [habitsData] = useAtom(habitsAtom)
const habits = habitsData.habits const habits = habitsData.habits
const [settings] = useAtom(settingsAtom) const { balance } = useCoins()
const [coins] = useAtom(coinsAtom)
const coinBalance = coins.balance
const [wishlist] = useAtom(wishlistAtom) const [wishlist] = useAtom(wishlistAtom)
const wishlistItems = wishlist.items const wishlistItems = wishlist.items
return ( return (
<div className="container mx-auto px-4 py-8"> <div>
<div className="flex justify-between items-center mb-6"> <div className="flex justify-between items-center mb-6">
<h1 className="text-3xl font-bold">Dashboard</h1> <h1 className="text-xl xs:text-3xl font-bold">{t('title')}</h1>
<ViewToggle />
</div> </div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6"> <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} /> <HabitStreak habits={habits} />
<DailyOverview <DailyOverview
wishlistItems={wishlistItems} wishlistItems={wishlistItems}
habits={habits} habits={habits}
coinBalance={coinBalance} coinBalance={balance}
/> />
{/* <HabitHeatmap habits={habits} /> */} {/* <HabitHeatmap habits={habits} /> */}

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,32 +1,35 @@
'use client' 'use client'
import { useState, useMemo, useCallback } from 'react' import CompletionCountBadge from '@/components/CompletionCountBadge'
import { Calendar } from '@/components/ui/calendar' import { Calendar } from '@/components/ui/calendar'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { Check, Circle, CircleCheck } from 'lucide-react'
import { d2s, getNow, t2d, getCompletedHabitsForDate, isHabitDue, getISODate, getCompletionsForToday, getCompletionsForDate } from '@/lib/utils'
import { useAtom } from 'jotai'
import { useHabits } from '@/hooks/useHabits' import { useHabits } from '@/hooks/useHabits'
import { habitsAtom, settingsAtom, completedHabitsMapAtom } from '@/lib/atoms' import { completedHabitsMapAtom, habitsAtom, hasTasksAtom, settingsAtom } from '@/lib/atoms'
import { DateTime } from 'luxon'
import Linkify from './linkify'
import { Habit } from '@/lib/types' 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() { export default function HabitCalendar() {
const t = useTranslations('HabitCalendar')
const { completePastHabit } = useHabits() const { completePastHabit } = useHabits()
const handleCompletePastHabit = useCallback(async (habit: Habit, date: DateTime) => { const handleCompletePastHabit = useCallback(async (habit: Habit, date: DateTime) => {
try { try {
await completePastHabit(habit, date) await completePastHabit(habit, date)
} catch (error) { } catch (error) {
console.error('Error completing past habit:', error) console.error(t('errorCompletingPastHabit'), error)
} }
}, [completePastHabit]) }, [completePastHabit, t])
const [settings] = useAtom(settingsAtom) 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 [habitsData] = useAtom(habitsAtom)
const [hasTasks] = useAtom(hasTasksAtom)
const habits = habitsData.habits const habits = habitsData.habits
const [completedHabitsMap] = useAtom(completedHabitsMapAtom) const [completedHabitsMap] = useAtom(completedHabitsMapAtom)
@@ -39,18 +42,18 @@ export default function HabitCalendar() {
}, [completedHabitsMap, settings.system.timezone]) }, [completedHabitsMap, settings.system.timezone])
return ( return (
<div className="container mx-auto px-4 py-8"> <div>
<h1 className="text-3xl font-bold mb-6">Habit Calendar</h1> <h1 className="text-xl xs:text-3xl font-bold mb-6">{t('title')}</h1>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6"> <div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
<Card> <Card>
<CardHeader> <CardHeader>
<CardTitle>Calendar</CardTitle> <CardTitle>{t('calendarCardTitle')}</CardTitle>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<Calendar <Calendar
mode="single" mode="single"
selected={selectedDate.toJSDate()} selected={selectedDateTime.toJSDate()}
onSelect={(e) => e && setSelectedDate(DateTime.fromJSDate(e))} onSelect={(e) => e && setSelectedDateTime(DateTime.fromJSDate(e))}
weekStartsOn={settings.system.weekStartDay} weekStartsOn={settings.system.weekStartDay}
className="rounded-md border" className="rounded-md border"
modifiers={{ modifiers={{
@@ -62,7 +65,7 @@ export default function HabitCalendar() {
) )
}} }}
modifiersClassNames={{ 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> </CardContent>
@@ -70,28 +73,35 @@ export default function HabitCalendar() {
<Card> <Card>
<CardHeader> <CardHeader>
<CardTitle> <CardTitle>
{selectedDate ? ( {selectedDateTime ? (
<>Habits for {d2s({ dateTime: selectedDate, timezone: settings.system.timezone, format: "yyyy-MM-dd" })}</> <>{d2s({ dateTime: selectedDateTime, timezone: settings.system.timezone, format: DateTime.DATE_MED_WITH_WEEKDAY })}</>
) : ( ) : (
'Select a date' t('selectDatePrompt')
)} )}
</CardTitle> </CardTitle>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
{selectedDate && ( {selectedDateTime && (
<ul className="space-y-2"> <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 {habits
.filter(habit => isHabitDue({ .filter(habit => habit.isTask && isHabitDue({
habit, habit,
timezone: settings.system.timezone, timezone: settings.system.timezone,
date: selectedDate date: selectedDateTime
})) }))
.map((habit) => { .map((habit) => {
const completions = getCompletionsForDate({ habit, date: selectedDate, timezone: settings.system.timezone }) const completions = getCompletionsForDate({ habit, date: selectedDateTime, timezone: settings.system.timezone })
const isCompleted = completions >= (habit.targetCompletions || 1) const isCompleted = completions >= (habit.targetCompletions || 1)
return ( return (
<li key={habit.id} className="flex items-center justify-between gap-2"> <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> <span className="flex items-center gap-2">
<Linkify>{habit.name}</Linkify> <Linkify>{habit.name}</Linkify>
</span> </span>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
@@ -102,7 +112,7 @@ export default function HabitCalendar() {
</span> </span>
)} )}
<button <button
onClick={() => handleCompletePastHabit(habit, selectedDate)} onClick={() => handleCompletePastHabit(habit, selectedDateTime)}
disabled={isCompleted} disabled={isCompleted}
className="relative h-4 w-4 hover:opacity-70 transition-opacity disabled:opacity-100" className="relative h-4 w-4 hover:opacity-70 transition-opacity disabled:opacity-100"
> >
@@ -131,6 +141,67 @@ export default function HabitCalendar() {
) )
})} })}
</ul> </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> </CardContent>
</Card> </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,21 +1,22 @@
import { Habit } from '@/lib/types'
import { useAtom } from 'jotai'
import { settingsAtom, pomodoroAtom, browserSettingsAtom } from '@/lib/atoms'
import { getTodayInTimezone, isSameDate, t2d, d2t, getNow, parseNaturalLanguageRRule, parseRRule, d2s } from '@/lib/utils'
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from '@/components/ui/card' import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { Coins, Edit, Trash2, Check, Undo2, MoreVertical, Timer, Archive, ArchiveRestore } from 'lucide-react'
import { import {
DropdownMenu, DropdownMenu,
DropdownMenuContent, DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger, DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu' } from '@/components/ui/dropdown-menu'
import { useEffect, useState } from 'react'
import { useHabits } from '@/hooks/useHabits' import { useHabits } from '@/hooks/useHabits'
import { INITIAL_RECURRENCE_RULE } from '@/lib/constants' import { currentUserAtom, settingsAtom, usersAtom } from '@/lib/atoms'
import { DateTime } from 'luxon' 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 { interface HabitItemProps {
habit: Habit habit: Habit
@@ -23,19 +24,39 @@ interface HabitItemProps {
onDelete: () => void 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) { export default function HabitItem({ habit, onEdit, onDelete }: HabitItemProps) {
const { completeHabit, undoComplete, archiveHabit, unarchiveHabit } = useHabits() const { completeHabit, undoComplete } = useHabits()
const [settings] = useAtom(settingsAtom) const [settings] = useAtom(settingsAtom)
const [_, setPomo] = useAtom(pomodoroAtom) const completionsToday = getCompletionsForToday({ habit, timezone: 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 target = habit.targetCompletions || 1 const target = habit.targetCompletions || 1
const isCompletedToday = completionsToday >= target const isCompletedToday = completionsToday >= target
const [isHighlighted, setIsHighlighted] = useState(false) const [isHighlighted, setIsHighlighted] = useState(false)
const [browserSettings] = useAtom(browserSettingsAtom) const t = useTranslations('HabitItem');
const isTasksView = browserSettings.viewType === 'tasks' const [usersData] = useAtom(usersAtom)
const isRecurRule = !isTasksView const pathname = usePathname();
const [currentUser] = useAtom(currentUserAtom)
const canWrite = hasPermission(currentUser, 'habit', 'write')
const canInteract = hasPermission(currentUser, 'habit', 'interact')
useEffect(() => { useEffect(() => {
const params = new URLSearchParams(window.location.search) const params = new URLSearchParams(window.location.search)
@@ -61,29 +82,68 @@ export default function HabitItem({ habit, onEdit, onDelete }: HabitItemProps) {
id={`habit-${habit.id}`} id={`habit-${habit.id}`}
className={`h-full flex flex-col transition-all duration-500 ${isHighlighted ? 'bg-yellow-100 dark:bg-yellow-900' : ''} ${habit.archived ? 'opacity-75' : ''}`} 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"> <CardHeader className="flex-shrink-0">
<CardTitle className={`line-clamp-1 ${habit.archived ? 'text-gray-400 dark:text-gray-500' : ''}`}>{habit.name}</CardTitle> <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 && ( {habit.description && (
<CardDescription className={`whitespace-pre-line ${habit.archived ? 'text-gray-400 dark:text-gray-500' : ''}`}> <CardDescription className={`whitespace-pre-line flex-1 min-w-0 break-words ${habit.archived ? 'text-gray-400 dark:text-gray-500' : ''}`}>
{habit.description} {habit.description}
</CardDescription> </CardDescription>
)} )}
{habit.drawing && (
<div className="flex-shrink-0">
<DrawingDisplay
drawingData={habit.drawing}
width={120}
height={80}
className=""
/>
</div>
)}
</div>
)}
</CardHeader> </CardHeader>
<CardContent className="flex-1"> <CardContent className="flex-grow flex flex-col justify-end">
<p className={`text-sm ${habit.archived ? 'text-gray-400 dark:text-gray-500' : 'text-gray-500'}`}>When: {isRecurRule ? parseRRule(habit.frequency || INITIAL_RECURRENCE_RULE).toText() : d2s({ dateTime: t2d({ timestamp: habit.frequency, timezone: settings.system.timezone }), timezone: settings.system.timezone, format: DateTime.DATE_MED_WITH_WEEKDAY })}</p> <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"> <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'}`} /> <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' : ''}`}>{habit.coinReward} coins per completion</span> <span className={`text-sm font-medium ${habit.archived ? 'text-gray-400 dark:text-gray-500' : ''}`}>{t('coinsPerCompletion', { count: habit.coinReward })}</span>
</div>
</div> </div>
</CardContent> </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="flex gap-2">
<div className="relative"> <div className="relative">
<Button <Button
variant={isCompletedToday ? "secondary" : "default"} variant={isCompletedToday ? "secondary" : "default"}
size="sm" size="sm"
onClick={async () => await completeHabit(habit)} onClick={async () => await completeHabit(habit)}
disabled={habit.archived || (isCompletedToday && completionsToday >= target)} disabled={!canInteract || habit.archived || (isCompletedToday && completionsToday >= target)}
className={`overflow-hidden w-24 sm:w-auto ${habit.archived ? 'cursor-not-allowed' : ''}`} className={`overflow-hidden w-24 sm:w-auto ${habit.archived ? 'cursor-not-allowed' : ''}`}
> >
<Check className="h-4 w-4 sm:mr-2" /> <Check className="h-4 w-4 sm:mr-2" />
@@ -91,19 +151,19 @@ export default function HabitItem({ habit, onEdit, onDelete }: HabitItemProps) {
{isCompletedToday ? ( {isCompletedToday ? (
target > 1 ? ( target > 1 ? (
<> <>
<span className="sm:hidden">{completionsToday}/{target}</span> <span className="sm:hidden">{t('completedStatusCountMobile', { completed: completionsToday, target })}</span>
<span className="hidden sm:inline">Completed ({completionsToday}/{target})</span> <span className="hidden sm:inline">{t('completedStatusCount', { completed: completionsToday, target })}</span>
</> </>
) : ( ) : (
'Completed' t('completedStatus')
) )
) : ( ) : (
target > 1 ? ( target > 1 ? (
<> <>
<span className="sm:hidden">{completionsToday}/{target}</span> <span className="sm:hidden">{t('completeButtonCountMobile', { completed: completionsToday, target })}</span>
<span className="hidden sm:inline">Complete ({completionsToday}/{target})</span> <span className="hidden sm:inline">{t('completeButtonCount', { completed: completionsToday, target })}</span>
</> </>
) : 'Complete' ) : t('completeButton')
)} )}
</span> </span>
{habit.targetCompletions && habit.targetCompletions > 1 && ( {habit.targetCompletions && habit.targetCompletions > 1 && (
@@ -121,10 +181,11 @@ export default function HabitItem({ habit, onEdit, onDelete }: HabitItemProps) {
variant="outline" variant="outline"
size="sm" size="sm"
onClick={async () => await undoComplete(habit)} onClick={async () => await undoComplete(habit)}
disabled={!canWrite}
className="w-10 sm:w-auto" className="w-10 sm:w-auto"
> >
<Undo2 className="h-4 w-4" /> <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> </Button>
)} )}
</div> </div>
@@ -134,59 +195,26 @@ export default function HabitItem({ habit, onEdit, onDelete }: HabitItemProps) {
variant="edit" variant="edit"
size="sm" size="sm"
onClick={onEdit} onClick={onEdit}
disabled={!canWrite}
className="hidden sm:flex" className="hidden sm:flex"
> >
<Edit className="h-4 w-4" /> <Edit className="h-4 w-4" />
<span className="ml-2">Edit</span> <span className="ml-2">{t('editButton')}</span>
</Button> </Button>
)} )}
<DropdownMenu> <DropdownMenu modal={false}>
<DropdownMenuTrigger asChild> <DropdownMenuTrigger asChild>
<Button variant="ghost" size="sm" className="h-8 w-8 p-0"> <Button variant="ghost" size="sm" className="h-8 w-8 p-0">
<MoreVertical className="h-4 w-4" /> <MoreVertical className="h-4 w-4" />
</Button> </Button>
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent align="end"> <DropdownMenuContent align="end">
{!habit.archived && ( <HabitContextMenuItems
<DropdownMenuItem onClick={() => { habit={habit}
setPomo((prev) => ({ onEditRequest={onEdit}
...prev, onDeleteRequest={onDelete}
show: true, context="habit-item"
selectedHabitId: habit.id />
}))
}}>
<Timer className="mr-2 h-4 w-4" />
<span>Start Pomodoro</span>
</DropdownMenuItem>
)}
{!habit.archived && (
<DropdownMenuItem onClick={() => archiveHabit(habit.id)}>
<Archive className="mr-2 h-4 w-4" />
<span>Archive</span>
</DropdownMenuItem>
)}
{habit.archived && (
<DropdownMenuItem onClick={() => unarchiveHabit(habit.id)}>
<ArchiveRestore className="mr-2 h-4 w-4" />
<span>Unarchive</span>
</DropdownMenuItem>
)}
<DropdownMenuItem
onClick={onEdit}
className="sm:hidden"
disabled={habit.archived}
>
<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>
</DropdownMenuContent> </DropdownMenuContent>
</DropdownMenu> </DropdownMenu>
</div> </div>
@@ -194,4 +222,3 @@ export default function HabitItem({ habit, onEdit, onDelete }: HabitItemProps) {
</Card> </Card>
) )
} }

View File

@@ -1,30 +1,117 @@
'use client' 'use client'
import { useState } from 'react'
import { Plus, ListTodo } from 'lucide-react'
import { useAtom } from 'jotai'
import { habitsAtom, settingsAtom, browserSettingsAtom } from '@/lib/atoms'
import EmptyState from './EmptyState'
import { Button } from '@/components/ui/button' 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 AddEditHabitModal from './AddEditHabitModal'
import ConfirmDialog from './ConfirmDialog' import ConfirmDialog from './ConfirmDialog'
import { Habit } from '@/lib/types' import EmptyState from './EmptyState'
import { useHabits } from '@/hooks/useHabits' import HabitItem from './HabitItem'
import { HabitIcon, TaskIcon } from '@/lib/constants'
export default function HabitList() { export default function HabitList({ isTasksView }: { isTasksView: boolean}) {
const t = useTranslations('HabitList');
const { saveHabit, deleteHabit } = useHabits() const { saveHabit, deleteHabit } = useHabits()
const [habitsData, setHabitsData] = useAtom(habitsAtom) const [habitsData] = useAtom(habitsAtom) // setHabitsData removed as it's not used
const [browserSettings] = useAtom(browserSettingsAtom)
const isTasksView = browserSettings.viewType === 'tasks' type SortableField = 'name' | 'coinReward' | 'dueDate' | 'frequency';
const habits = habitsData.habits.filter(habit => 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 isTasksView ? habit.isTask : !habit.isTask
) );
const activeHabits = habits.filter(h => !h.archived) }, [habitsData.habits, isTasksView]);
const archivedHabits = habits.filter(h => h.archived)
const [settings] = useAtom(settingsAtom) const searchedHabits = useMemo(() => {
const [isModalOpen, setIsModalOpen] = useState(false) 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 [editingHabit, setEditingHabit] = useState<Habit | null>(null)
const [deleteConfirmation, setDeleteConfirmation] = useState<{ isOpen: boolean, habitId: string | null }>({ const [deleteConfirmation, setDeleteConfirmation] = useState<{ isOpen: boolean, habitId: string | null }>({
isOpen: false, isOpen: false,
@@ -33,22 +120,63 @@ export default function HabitList() {
return ( return (
<div className="container mx-auto px-4 py-8"> <div>
<div className="flex justify-between items-center mb-6"> <div className="flex justify-between items-center mb-6">
<h1 className="text-3xl font-bold"> <h1 className="text-xl xs:text-3xl font-bold">
{isTasksView ? 'My Tasks' : 'My Habits'} {t(isTasksView ? 'myTasks' : 'myHabits')}
</h1> </h1>
<Button onClick={() => setIsModalOpen(true)}> <span>
<Plus className="mr-2 h-4 w-4" /> {isTasksView ? 'Add Task' : 'Add Habit'} <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> </Button>
</div> </div>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 items-stretch"> <div className="grid grid-cols-1 md:grid-cols-2 gap-4 items-stretch">
{activeHabits.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"> <div className="col-span-2">
<EmptyState <EmptyState
icon={isTasksView ? TaskIcon : HabitIcon} icon={isTasksView ? TaskIcon : HabitIcon}
title={isTasksView ? "No tasks yet" : "No habits yet"} title={t(isTasksView ? 'emptyStateTasksTitle' : 'emptyStateHabitsTitle')}
description={isTasksView ? "Create your first task to start tracking your progress" : "Create your first habit to start tracking your progress"} description={t(isTasksView ? 'emptyStateTasksDescription' : 'emptyStateHabitsDescription')}
/> />
</div> </div>
) : ( ) : (
@@ -58,7 +186,7 @@ export default function HabitList() {
habit={habit} habit={habit}
onEdit={() => { onEdit={() => {
setEditingHabit(habit) setEditingHabit(habit)
setIsModalOpen(true) setModalConfig({ isOpen: true, isTask: isTasksView })
}} }}
onDelete={() => setDeleteConfirmation({ isOpen: true, habitId: habit.id })} onDelete={() => setDeleteConfirmation({ isOpen: true, habitId: habit.id })}
/> />
@@ -67,9 +195,9 @@ export default function HabitList() {
{archivedHabits.length > 0 && ( {archivedHabits.length > 0 && (
<> <>
<div className="col-span-2 relative flex items-center my-6"> <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" /> <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">Archived</span> <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 className="flex-grow border-t border-gray-300 dark:border-gray-600" />
</div> </div>
{archivedHabits.map((habit: Habit) => ( {archivedHabits.map((habit: Habit) => (
@@ -78,7 +206,7 @@ export default function HabitList() {
habit={habit} habit={habit}
onEdit={() => { onEdit={() => {
setEditingHabit(habit) setEditingHabit(habit)
setIsModalOpen(true) setModalConfig({ isOpen: true, isTask: isTasksView })
}} }}
onDelete={() => setDeleteConfirmation({ isOpen: true, habitId: habit.id })} onDelete={() => setDeleteConfirmation({ isOpen: true, habitId: habit.id })}
/> />
@@ -86,18 +214,19 @@ export default function HabitList() {
</> </>
)} )}
</div> </div>
{isModalOpen && {modalConfig.isOpen &&
<AddEditHabitModal <AddEditHabitModal
onClose={() => { onClose={() => {
setIsModalOpen(false) setModalConfig({ isOpen: false, isTask: false })
setEditingHabit(null) setEditingHabit(null)
}} }}
onSave={async (habit) => { onSave={async (habit) => {
await saveHabit({ ...habit, id: editingHabit?.id }) await saveHabit({ ...habit, id: editingHabit?.id, isTask: modalConfig.isTask })
setIsModalOpen(false) setModalConfig({ isOpen: false, isTask: false })
setEditingHabit(null) setEditingHabit(null)
}} }}
habit={editingHabit} habit={editingHabit}
isTask={modalConfig.isTask}
/> />
} }
<ConfirmDialog <ConfirmDialog
@@ -109,9 +238,9 @@ export default function HabitList() {
} }
setDeleteConfirmation({ isOpen: false, habitId: null }) setDeleteConfirmation({ isOpen: false, habitId: null })
}} }}
title={isTasksView ? "Delete Task" : "Delete Habit"} title={t(isTasksView ? 'deleteTaskDialogTitle' : 'deleteHabitDialogTitle')}
message={isTasksView ? "Are you sure you want to delete this task? This action cannot be undone." : "Are you sure you want to delete this habit? This action cannot be undone."} message={t(isTasksView ? 'deleteTaskDialogMessage' : 'deleteHabitDialogMessage')}
confirmText="Delete" confirmText={t('deleteButton')}
/> />
</div> </div>
) )

View File

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

View File

@@ -1,36 +1,13 @@
'use client'
import { useState } from 'react'
import { useAtom } from 'jotai'
import { coinsAtom, settingsAtom, browserSettingsAtom } 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 { 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 Link from 'next/link'
import dynamic from 'next/dynamic' import HeaderActions from './HeaderActions'
import { Profile } from './Profile'
interface HeaderProps { interface HeaderProps {
className?: string className?: string
} }
const TodayEarnedCoins = dynamic(() => import('./TodayEarnedCoins'), { ssr: false })
export default function Header({ className }: HeaderProps) { export default function Header({ className }: HeaderProps) {
const [settings] = useAtom(settingsAtom)
const [coins] = useAtom(coinsAtom)
const [browserSettings] = useAtom(browserSettingsAtom)
const isTasksView = browserSettings.viewType === 'tasks'
return ( return (
<> <>
<header className={`border-b bg-white dark:bg-gray-800 shadow-sm ${className || ''}`}> <header className={`border-b bg-white dark:bg-gray-800 shadow-sm ${className || ''}`}>
@@ -39,25 +16,7 @@ export default function Header({ className }: HeaderProps) {
<Link href="/" className="mr-3 sm:mr-4"> <Link href="/" className="mr-3 sm:mr-4">
<Logo /> <Logo />
</Link> </Link>
<div className="flex items-center gap-1 sm:gap-2"> <HeaderActions />
<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>
<Profile />
</div>
</div> </div>
</div> </div>
</header> </header>

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 ClientWrapper from './ClientWrapper'
import Header from './Header' import Header from './Header'
import Navigation from './Navigation' import Navigation from './Navigation'
import PermissionError from './PermissionError'
export default function Layout({ children }: { children: React.ReactNode }) { export default function Layout({ children }: { children: React.ReactNode }) {
return ( return (
<div className="flex flex-col h-screen bg-gray-100 dark:bg-gray-900 overflow-hidden"> <div className="flex flex-col h-screen bg-gray-100 dark:bg-gray-900 overflow-hidden">
<Header className="sticky top-0 z-50" /> <Header className="sticky top-0 z-50" />
<div className="flex flex-1 overflow-hidden"> <div className="flex flex-1 overflow-hidden">
<Navigation viewPort='main' /> <Navigation position='main' />
<div className="flex-1 flex flex-col"> <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"> <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> <ClientWrapper>
<PermissionError />
{children} {children}
</ClientWrapper> </ClientWrapper>
</div>
</main> </main>
<Navigation viewPort='mobile' /> <Navigation position='mobile' />
</div> </div>
</div> </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() { export function Logo() {
return ( return (
<div className="flex items-center gap-2"> <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> <span className="font-bold text-xl">HabitTrove</span>
</div> </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,100 +1,40 @@
'use client' 'use client'
import Link from 'next/link'
import { Home, Calendar, List, Gift, Coins, Settings, Info, CheckSquare } from 'lucide-react'
import { useAtom } from 'jotai'
import { browserSettingsAtom } from '@/lib/atoms'
import { useEffect, useState } from 'react'
import AboutModal from './AboutModal'
import { HabitIcon, TaskIcon } from '@/lib/constants' 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' export interface NavItemType {
icon: ElementType;
const navItems = (isTasksView: boolean) => [ label: string;
{ icon: Home, label: 'Dashboard', href: '/', position: 'main' }, href: string;
{
icon: isTasksView ? TaskIcon : HabitIcon,
label: isTasksView ? 'Tasks' : '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 default function Navigation({ className, viewPort }: NavigationProps) { export default function Navigation({ position }: { position: 'main' | 'mobile' }) {
const [showAbout, setShowAbout] = useState(false) const t = useTranslations('Navigation');
const [isMobileView, setIsMobileView] = useState(false) const [isMobile, setIsMobile] = useState(window.innerWidth < 1024);
const [browserSettings] = useAtom(browserSettingsAtom)
const isTasksView = browserSettings.viewType === 'tasks'
useEffect(() => { useEffect(() => {
const handleResize = () => { const handleResize = () => {setIsMobile(window.innerWidth < 1024); };
setIsMobileView(window.innerWidth < 1024) window.addEventListener("resize", handleResize);
return () => window.removeEventListener("resize", handleResize);
}, [setIsMobile]);
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' },
]
if ((position === 'mobile' && isMobile) || (position === 'main' && !isMobile)) {
return <NavDisplay navItems={currentNavItems} isMobile={isMobile} />
} }
else {
// Set initial value return <></>
handleResize()
// 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(isTasksView).filter(item => item.position === 'main'), ...navItems(isTasksView).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 (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(isTasksView).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>
)
} }
} }

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' 'use client'
import { useState, useEffect, useRef, useCallback } from 'react'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { Progress } from '@/components/ui/progress' 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 { 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 { interface PomoConfig {
labels: string[] getLabels: () => string[]
duration: number duration: number
type: 'focus' | 'break' type: 'focus' | 'break'
} }
export default function PomodoroTimer() {
const t = useTranslations('PomodoroTimer')
const PomoConfigs: Record<PomoConfig['type'], PomoConfig> = { const PomoConfigs: Record<PomoConfig['type'], PomoConfig> = {
focus: { focus: {
labels: [ getLabels: () => [
'Stay Focused', t('focusLabel1'), t('focusLabel2'), t('focusLabel3'), t('focusLabel4'), t('focusLabel5'),
'You Got This', t('focusLabel6'), t('focusLabel7'), t('focusLabel8'), t('focusLabel9'), t('focusLabel10')
'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, duration: 25 * 60,
type: 'focus', type: 'focus',
}, },
break: { break: {
labels: [ getLabels: () => [
'Take a Break', t('breakLabel1'), t('breakLabel2'), t('breakLabel3'), t('breakLabel4'), t('breakLabel5'),
'Relax and Recharge', t('breakLabel6'), t('breakLabel7'), t('breakLabel8'), t('breakLabel9'), t('breakLabel10')
'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, duration: 5 * 60,
type: 'break', type: 'break',
}, },
} }
export default function PomodoroTimer() {
const [settings] = useAtom(settingsAtom)
const [pomo, setPomo] = useAtom(pomodoroAtom) const [pomo, setPomo] = useAtom(pomodoroAtom)
const { show, selectedHabitId, autoStart, minimized } = pomo const { show, selectedHabitId, autoStart, minimized } = pomo
const [habitsData] = useAtom(habitsAtom) const [habitsData] = useAtom(habitsAtom)
@@ -62,21 +47,23 @@ export default function PomodoroTimer() {
const [state, setState] = useState<'started' | 'stopped' | 'paused'>(autoStart ? 'started' : 'stopped') const [state, setState] = useState<'started' | 'stopped' | 'paused'>(autoStart ? 'started' : 'stopped')
const wakeLock = useRef<WakeLockSentinel | null>(null) const wakeLock = useRef<WakeLockSentinel | null>(null)
const [todayCompletions] = useAtom(pomodoroTodayCompletionsAtom) const [todayCompletions] = useAtom(pomodoroTodayCompletionsAtom)
const currentTimer = useRef<PomoConfig>(PomoConfigs.focus) const currentTimerRef = useRef<PomoConfig>(PomoConfigs.focus)
const [currentLabel, setCurrentLabel] = useState( const [currentLabel, setCurrentLabel] = useState(() => {
currentTimer.current.labels[Math.floor(Math.random() * currentTimer.current.labels.length)] const labels = currentTimerRef.current.getLabels();
) return labels[Math.floor(Math.random() * labels.length)];
});
// Handle wake lock // Handle wake lock
useEffect(() => { useEffect(() => {
const requestWakeLock = async () => { const requestWakeLock = async () => {
try { try {
if (!('wakeLock' in navigator)) { if (!('wakeLock' in navigator)) {
console.debug('Browser does not support wakelock') console.debug(t('wakeLockNotSupported'))
return return
} }
if (wakeLock.current && !wakeLock.current.released) { if (wakeLock.current && !wakeLock.current.released) {
console.debug('Wake lock already in use') console.debug(t('wakeLockInUse'))
return return
} }
if (state === 'started') { if (state === 'started') {
@@ -85,7 +72,7 @@ export default function PomodoroTimer() {
return return
} }
} catch (err) { } 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 wakeLock.current = null
} }
} catch (err) { } catch (err) {
console.error('Error releasing wake lock:', err) console.error(t('wakeLockReleaseError'), err)
} }
} }
@@ -121,48 +108,48 @@ export default function PomodoroTimer() {
document.removeEventListener('visibilitychange', handleVisibilityChange); document.removeEventListener('visibilitychange', handleVisibilityChange);
releaseWakeLock() releaseWakeLock()
} }
}, [state]) }, [state, t])
// Timer logic // Timer logic
useEffect(() => { 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 // Calculate the target end time based on current timeLeft
const targetEndTime = Date.now() + timeLeft * 1000 const targetEndTime = Date.now() + timeLeft * 1000;
interval = setInterval(() => { interval = setInterval(() => {
const remaining = Math.floor((targetEndTime - Date.now()) / 1000) const remaining = Math.floor((targetEndTime - Date.now()) / 1000);
if (remaining <= 0) { if (remaining <= 0) {
handleTimerEnd() handleTimerEnd();
} else { } else {
setTimeLeft(remaining) setTimeLeft(remaining);
} }
}, 1000) }, 1000);
} }
// return handles any other states // return handles any other states
return () => { return () => {
if (interval) clearInterval(interval) if (interval) clearInterval(interval);
} };
}, [state]) }, [state, timeLeft, PomoConfigs.break, PomoConfigs.focus, completeHabit, selectedHabit]);
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)]
)
// update habits only after focus sessions
if (selectedHabit && currentTimerType === 'focus') {
await completeHabit(selectedHabit)
// The atom will automatically update with the new completions
}
}
const toggleTimer = () => { const toggleTimer = () => {
setState(prev => prev === 'started' ? 'paused' : 'started') setState(prev => prev === 'started' ? 'paused' : 'started')
@@ -170,17 +157,16 @@ export default function PomodoroTimer() {
const resetTimer = () => { const resetTimer = () => {
setState("stopped") setState("stopped")
setTimeLeft(currentTimer.current.duration) setTimeLeft(currentTimerRef.current.duration)
} }
const skipTimer = () => { const skipTimer = () => {
currentTimer.current = currentTimer.current.type === 'focus' currentTimerRef.current = currentTimerRef.current.type === 'focus'
? PomoConfigs.break ? PomoConfigs.break
: PomoConfigs.focus : PomoConfigs.focus
resetTimer() resetTimer() // This will also reset timeLeft to the new timer's duration
setCurrentLabel( const newLabels = currentTimerRef.current.getLabels();
currentTimer.current.labels[Math.floor(Math.random() * currentTimer.current.labels.length)] setCurrentLabel(newLabels[Math.floor(Math.random() * newLabels.length)])
)
} }
const formatTime = (seconds: number) => { const formatTime = (seconds: number) => {
@@ -189,9 +175,9 @@ export default function PomodoroTimer() {
return `${minutes}:${secs < 10 ? '0' : ''}${secs}` 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 ( return (
<div className="fixed bottom-20 right-4 lg:bottom-4 bg-background border rounded-lg shadow-lg"> <div className="fixed bottom-20 right-4 lg:bottom-4 bg-background border rounded-lg shadow-lg">
@@ -242,11 +228,11 @@ export default function PomodoroTimer() {
<div className={cn( <div className={cn(
'w-2 h-2 rounded-full flex-none', 'w-2 h-2 rounded-full flex-none',
// order matters here // order matters here
currentTimer.current.type === 'focus' && 'bg-green-500', currentTimerRef.current.type === 'focus' && 'bg-green-500',
state === 'started' && 'animate-pulse', state === 'started' && 'animate-pulse',
state === 'paused' && 'bg-yellow-500', state === 'paused' && 'bg-yellow-500',
state === 'stopped' && 'bg-red-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"> <div className="font-bold text-foreground">
{selectedHabit.name} {selectedHabit.name}
@@ -254,7 +240,9 @@ export default function PomodoroTimer() {
</div> </div>
</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 && ( {selectedHabit && selectedHabit.targetCompletions && selectedHabit.targetCompletions > 1 && (
<div className="flex justify-center gap-1 mt-2"> <div className="flex justify-center gap-1 mt-2">
{(() => { {(() => {
@@ -293,12 +281,12 @@ export default function PomodoroTimer() {
{state === "started" ? ( {state === "started" ? (
<> <>
<Pause className="h-4 w-4 sm:mr-2" /> <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" /> <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> </Button>
@@ -309,7 +297,7 @@ export default function PomodoroTimer() {
className="sm:px-4" className="sm:px-4"
> >
<RotateCw className="h-4 w-4 sm:mr-2" /> <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>
<Button <Button
variant="outline" variant="outline"
@@ -318,7 +306,7 @@ export default function PomodoroTimer() {
className="sm:px-4" className="sm:px-4"
> >
<SkipForward className="h-4 w-4 sm:mr-2" /> <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> </Button>
</div> </div>
</div> </div>

View File

@@ -1,28 +1,54 @@
'use client' 'use client'
import { signOut } from "@/app/actions/user"
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar" import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"
import { Button } from "@/components/ui/button" import { Button } from "@/components/ui/button"
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui/dropdown-menu" import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui/dropdown-menu"
import { Settings, Info, User, Moon, Sun, Palette } from "lucide-react" import { toast } from "@/hooks/use-toast"
import Link from "next/link" import { aboutOpenAtom, currentUserAtom, settingsAtom, userSelectAtom } from "@/lib/atoms"
import { useAtom } from "jotai" import { useAtom } from "jotai"
import { settingsAtom } from "@/lib/atoms" import { ArrowRightLeft, Crown, Info, LogOut, Moon, Palette, Settings, Sun, User } from "lucide-react"
import AboutModal from "./AboutModal" import { useTranslations } from 'next-intl'
import { useState } from "react"
import { useTheme } from "next-themes" 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() { export function Profile() {
const t = useTranslations('Profile');
const [settings] = useAtom(settingsAtom) const [settings] = useAtom(settingsAtom)
const [showAbout, setShowAbout] = useState(false) const [userSelect, setUserSelect] = useAtom(userSelectAtom)
const [isEditing, setIsEditing] = useState(false)
const [aboutOpen, setAboutOpen] = useAtom(aboutOpenAtom)
const { theme, setTheme } = useTheme() 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 ( return (
<> <>
<DropdownMenu> <DropdownMenu open={open} onOpenChange={setOpen}>
<DropdownMenuTrigger asChild> <DropdownMenuTrigger asChild>
<Button variant="ghost" className="flex items-center gap-2"> <Button variant="ghost" className="flex items-center gap-2">
<Avatar className="h-8 w-8"> <Avatar className="h-8 w-8">
<AvatarImage src={settings?.profile?.avatarPath ? `/api/avatars/${settings.profile.avatarPath.split('/').pop()}` : '/avatars/default.png'} /> <AvatarImage src={user?.avatarPath && `/api/avatars/${user.avatarPath.split('/').pop()}` || ""} />
<AvatarFallback> <AvatarFallback>
<User className="h-4 w-4" /> <User className="h-4 w-4" />
</AvatarFallback> </AvatarFallback>
@@ -30,33 +56,94 @@ export function Profile() {
</Button> </Button>
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-[200px] p-2"> <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> <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 <Link
href="/settings" href="/settings"
aria-label='settings' aria-label={t('settingsLink')}
className="flex items-center w-full gap-3" 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" /> <Settings className="h-4 w-4" />
<span>Settings</span> <span>{t('settingsLink')}</span>
</div>
</Link> </Link>
</DropdownMenuItem> </DropdownMenuItem>
<DropdownMenuItem className="cursor-pointer px-2 py-1.5" asChild> <DropdownMenuItem className="cursor-pointer px-2 py-1.5" onClick={() => {
<button setOpen(false); // Close the dropdown
onClick={() => setShowAbout(true)} setAboutOpen(true); // Open the about modal
className="flex items-center w-full gap-3" }}>
> <div className="flex items-center justify-between w-full">
<div className="flex items-center gap-2">
<Info className="h-4 w-4" /> <Info className="h-4 w-4" />
<span>About</span> <span>{t('aboutButton')}</span>
</button> </div>
</div>
</DropdownMenuItem> </DropdownMenuItem>
<DropdownMenuItem className="cursor-pointer px-2 py-1.5"> <DropdownMenuItem className="cursor-pointer px-2 py-1.5">
<div className="flex items-center justify-between w-full gap-3"> <div className="flex items-center justify-between w-full">
<div className="flex items-center gap-3"> <div className="flex items-center gap-2">
<Palette className="h-4 w-4" /> <Palette className="h-4 w-4" />
<span>Theme</span> <span>{t('themeLabel')}</span>
</div> </div>
<button <button
onClick={() => setTheme(theme === 'dark' ? 'light' : 'dark')} onClick={(e) => {
e.stopPropagation();
setTheme(theme === 'dark' ? 'light' : 'dark');
}}
className={` className={`
w-12 h-6 rounded-full relative transition-all duration-300 ease-in-out w-12 h-6 rounded-full relative transition-all duration-300 ease-in-out
hover:scale-105 shadow-inner hover:scale-105 shadow-inner
@@ -86,7 +173,24 @@ export function Profile() {
</DropdownMenuContent> </DropdownMenuContent>
</DropdownMenu> </DropdownMenu>
<AboutModal isOpen={showAbout} onClose={() => setShowAbout(false)} /> {/* 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 { useAtom } from 'jotai'
import { useTranslations } from 'next-intl'
import { settingsAtom } from '@/lib/atoms' import { settingsAtom } from '@/lib/atoms'
import { useCoins } from '@/hooks/useCoins' import { useCoins } from '@/hooks/useCoins'
import { FormattedNumber } from '@/components/FormattedNumber' import { FormattedNumber } from '@/components/FormattedNumber'
export default function TodayEarnedCoins({ longFormat }: { longFormat?: boolean }) { export default function TodayEarnedCoins({ longFormat }: { longFormat?: boolean }) {
const t = useTranslations('TodayEarnedCoins')
const [settings] = useAtom(settingsAtom) const [settings] = useAtom(settingsAtom)
const { coinsEarnedToday } = useCoins() const { coinsEarnedToday } = useCoins()
if (coinsEarnedToday <= 0) return null if (coinsEarnedToday <= 0) return <></>;
return ( return (
<span className="text-md text-green-600 dark:text-green-400 font-medium mt-1"> <span className="text-md text-green-600 dark:text-green-400 font-medium mt-1">
{"+"} {"+"}
<FormattedNumber amount={coinsEarnedToday} settings={settings} /> <FormattedNumber amount={coinsEarnedToday} settings={settings} />
{longFormat ? {longFormat ?
<span className="text-sm text-muted-foreground"> today</span> <span className="text-sm text-muted-foreground"> {t('todaySuffix')}</span>
: null} : null}
</span> </span>
) )

View File

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

View File

@@ -1,74 +1,70 @@
'use client' 'use client'
import { cn } from '@/lib/utils'
import { useAtom } from 'jotai'
import { CheckSquare, ListChecks } from 'lucide-react'
import { browserSettingsAtom, habitsAtom, settingsAtom } from '@/lib/atoms' import { browserSettingsAtom, habitsAtom, settingsAtom } from '@/lib/atoms'
import type { ViewType } from '@/lib/types'
import { HabitIcon, TaskIcon } from '@/lib/constants' import { HabitIcon, TaskIcon } from '@/lib/constants'
import { isHabitDueToday } from '@/lib/utils' 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' import { NotificationBadge } from './ui/notification-badge'
interface ViewToggleProps { interface ViewToggleProps {
defaultView?: ViewType
className?: string className?: string
} }
export function ViewToggle({ export function ViewToggle({
defaultView = 'habits',
className className
}: ViewToggleProps) { }: ViewToggleProps) {
const t = useTranslations('ViewToggle')
const [browserSettings, setBrowserSettings] = useAtom(browserSettingsAtom) const [browserSettings, setBrowserSettings] = useAtom(browserSettingsAtom)
const [habits] = useAtom(habitsAtom) const [habits] = useAtom(habitsAtom)
const [settings] = useAtom(settingsAtom) const [settings] = useAtom(settingsAtom)
const pathname = usePathname();
const router = useRouter();
const handleViewChange = (checked: boolean) => { const handleViewChange = () => {
const newView = checked ? 'tasks' : 'habits' router.push(pathname.includes("habits") ? "/tasks" : "/habits");
setBrowserSettings({
...browserSettings,
viewType: newView,
})
} }
// Calculate due tasks count // Calculate due tasks count
const dueTasksCount = habits.habits.filter(habit => const dueTasksCount = habits.habits.filter(habit =>
habit.isTask && !habit.archived && isHabitDueToday({ habit, timezone: settings.system.timezone }) habit.isTask && isHabitDueToday({ habit, timezone: settings.system.timezone })
).length ).length
return ( return (
<div className={cn('inline-flex rounded-full bg-muted/50 h-8', className)}> <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"> <div className="relative flex gap-0.5 rounded-full bg-background p-0.5 h-full">
<button <button
onClick={() => handleViewChange(false)} onClick={handleViewChange}
className={cn( className={cn(
'relative z-10 rounded-full px-4 py-2 text-sm font-medium transition-colors flex items-center gap-2', 'relative z-10 rounded-full px-4 py-2 text-sm font-medium transition-colors flex items-center gap-2',
browserSettings.viewType === 'habits' ? 'text-primary-foreground' : 'text-muted-foreground hover:text-foreground' pathname.includes('habits') ? 'text-primary-foreground' : 'text-muted-foreground hover:text-foreground'
)} )}
> >
<HabitIcon className="h-4 w-4" /> <HabitIcon className="h-4 w-4" />
<span className="hidden sm:inline">Habits</span> <span className="hidden sm:inline">{t('habitsLabel')}</span>
</button> </button>
<NotificationBadge <NotificationBadge
label={dueTasksCount} label={dueTasksCount}
show={dueTasksCount > 0} show={dueTasksCount > 0}
variant={browserSettings.viewType === 'tasks' ? 'secondary' : 'default'} variant={pathname.includes('tasks') ? 'secondary' : 'default'}
className="shadow-md" className="shadow-md"
> >
<button <button
onClick={() => handleViewChange(true)} onClick={handleViewChange}
className={cn( className={cn(
'relative z-10 rounded-full px-4 py-2 text-sm font-medium transition-colors flex items-center gap-2', 'relative z-10 rounded-full px-4 py-2 text-sm font-medium transition-colors flex items-center gap-2',
browserSettings.viewType === 'tasks' ? 'text-primary-foreground' : 'text-muted-foreground hover:text-foreground' pathname.includes('tasks') ? 'text-primary-foreground' : 'text-muted-foreground hover:text-foreground'
)} )}
> >
<TaskIcon className="h-4 w-4" /> <TaskIcon className="h-4 w-4" />
<span className="hidden sm:inline">Tasks</span> <span className="hidden sm:inline">{t('tasksLabel')}</span>
</button> </button>
</NotificationBadge> </NotificationBadge>
<div <div
className={cn( className={cn(
'absolute left-0.5 top-0.5 h-[calc(100%-0.25rem)] rounded-full bg-primary transition-transform', 'absolute left-0.5 top-0.5 h-[calc(100%-0.25rem)] rounded-full bg-primary transition-transform',
browserSettings.viewType === 'habits' ? 'w-[calc(50%-0.125rem)]' : 'w-[calc(50%-0.125rem)] translate-x-[calc(100%+0.125rem)]' 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 { Button } from '@/components/ui/button'
import { Coins, Edit, Trash2, Gift, MoreVertical, Archive, ArchiveRestore } from 'lucide-react' import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from '@/components/ui/card'
import { import {
DropdownMenu, DropdownMenu,
DropdownMenuContent, DropdownMenuContent,
@@ -10,6 +7,14 @@ import {
DropdownMenuSeparator, DropdownMenuSeparator,
DropdownMenuTrigger, DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu' } 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 { interface WishlistItemProps {
item: WishlistItemType item: WishlistItemType
@@ -24,6 +29,25 @@ interface WishlistItemProps {
isArchived?: 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({ export default function WishlistItem({
item, item,
onEdit, onEdit,
@@ -35,6 +59,13 @@ export default function WishlistItem({
isHighlighted, isHighlighted,
isRecentlyRedeemed isRecentlyRedeemed
}: WishlistItemProps) { }: 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 ( return (
<Card <Card
id={`wishlist-${item.id}`} id={`wishlist-${item.id}`}
@@ -42,51 +73,70 @@ export default function WishlistItem({
} ${isRecentlyRedeemed ? 'animate-[celebrate_1s_ease-in-out] shadow-lg ring-2 ring-primary' : '' } ${isRecentlyRedeemed ? 'animate-[celebrate_1s_ease-in-out] shadow-lg ring-2 ring-primary' : ''
} ${item.archived ? 'opacity-75' : ''}`} } ${item.archived ? 'opacity-75' : ''}`}
> >
<CardHeader className="flex-none"> <CardHeader className="flex-shrink-0">
<div className="flex items-center gap-2"> <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' : ''}`}> <CardTitle className={`line-clamp-1 ${item.archived ? 'text-gray-400 dark:text-gray-500' : ''}`}>
{item.name} {item.name}
</CardTitle> </CardTitle>
{item.targetCompletions && ( {item.targetCompletions && (
<span className="text-sm text-gray-500 dark:text-gray-400"> <span className="text-sm text-gray-500 dark:text-gray-400 flex-shrink-0">
({item.targetCompletions} {item.targetCompletions === 1 ? 'use' : 'uses'} left) ({item.targetCompletions === 1 ? t('usesLeftSingular') : t('usesLeftPlural', { count: item.targetCompletions })})
</span> </span>
)} )}
</div> </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 && ( {item.description && (
<CardDescription className={`whitespace-pre-line ${item.archived ? 'text-gray-400 dark:text-gray-500' : ''}`}> <CardDescription className={`whitespace-pre-line flex-1 min-w-0 break-words ${item.archived ? 'text-gray-400 dark:text-gray-500' : ''}`}>
{item.description} {item.description}
</CardDescription> </CardDescription>
)} )}
{item.drawing && (
<div className="flex-shrink-0">
<DrawingDisplay
drawingData={item.drawing}
width={120}
height={80}
className=""
/>
</div>
)}
</div>
)}
</CardHeader> </CardHeader>
<CardContent className="flex-1"> <CardContent className="flex-grow flex flex-col justify-end">
<div className="mt-auto">
<div className="flex items-center gap-2"> <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'}`} /> <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' : ''}`}> <span className={`text-sm font-medium ${item.archived ? 'text-gray-400 dark:text-gray-500' : ''}`}>
{item.coinCost} coins {item.coinCost} {t('coinsSuffix')}
</span> </span>
</div> </div>
</div>
</CardContent> </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="flex gap-2">
<Button <Button
variant={canRedeem ? "default" : "secondary"} variant={canRedeem ? "default" : "secondary"}
size="sm" size="sm"
onClick={onRedeem} onClick={onRedeem}
disabled={!canRedeem || item.archived} 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' : ''}`} 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' : ''}`} /> <Gift className={`h-4 w-4 sm:mr-2 ${isRecentlyRedeemed ? 'animate-spin' : ''}`} />
<span> <span>
{isRecentlyRedeemed ? ( {isRecentlyRedeemed ? (
<> <>
<span className="sm:hidden">Done</span> <span className="sm:hidden">{t('redeemedDone')}</span>
<span className="hidden sm:inline">Redeemed!</span> <span className="hidden sm:inline">{t('redeemedExclamation')}</span>
</> </>
) : ( ) : (
<> <>
<span className="sm:hidden">Redeem</span> <span className="sm:hidden">{t('redeem')}</span>
<span className="hidden sm:inline">Redeem</span> <span className="hidden sm:inline">{t('redeem')}</span>
</> </>
)} )}
</span> </span>
@@ -98,13 +148,14 @@ export default function WishlistItem({
variant="edit" variant="edit"
size="sm" size="sm"
onClick={onEdit} onClick={onEdit}
disabled={!canWrite}
className="hidden sm:flex" className="hidden sm:flex"
> >
<Edit className="h-4 w-4" /> <Edit className="h-4 w-4" />
<span className="ml-2">Edit</span> <span className="ml-2">{t('editButton')}</span>
</Button> </Button>
)} )}
<DropdownMenu> <DropdownMenu modal={false}>
<DropdownMenuTrigger asChild> <DropdownMenuTrigger asChild>
<Button variant="ghost" size="sm" className="h-8 w-8 p-0"> <Button variant="ghost" size="sm" className="h-8 w-8 p-0">
<MoreVertical className="h-4 w-4" /> <MoreVertical className="h-4 w-4" />
@@ -112,28 +163,29 @@ export default function WishlistItem({
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent align="end"> <DropdownMenuContent align="end">
{!item.archived && ( {!item.archived && (
<DropdownMenuItem onClick={onArchive}> <DropdownMenuItem disabled={!canWrite} onClick={onArchive}>
<Archive className="mr-2 h-4 w-4" /> <Archive className="mr-2 h-4 w-4" />
<span>Archive</span> <span>{t('archiveButton')}</span>
</DropdownMenuItem> </DropdownMenuItem>
)} )}
{item.archived && ( {item.archived && (
<DropdownMenuItem onClick={onUnarchive}> <DropdownMenuItem disabled={!canWrite} onClick={onUnarchive}>
<ArchiveRestore className="mr-2 h-4 w-4" /> <ArchiveRestore className="mr-2 h-4 w-4" />
<span>Unarchive</span> <span>{t('unarchiveButton')}</span>
</DropdownMenuItem> </DropdownMenuItem>
)} )}
<DropdownMenuItem onClick={onEdit} className="sm:hidden"> <DropdownMenuItem onClick={onEdit} className="sm:hidden">
<Edit className="mr-2 h-4 w-4" /> <Edit className="mr-2 h-4 w-4" />
Edit {t('editButton')}
</DropdownMenuItem> </DropdownMenuItem>
<DropdownMenuSeparator className="sm:hidden" /> <DropdownMenuSeparator className="sm:hidden" />
<DropdownMenuItem <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} onClick={onDelete}
disabled={!canWrite}
> >
<Trash2 className="mr-2 h-4 w-4" /> <Trash2 className="mr-2 h-4 w-4" />
Delete {t('deleteButton')}
</DropdownMenuItem> </DropdownMenuItem>
</DropdownMenuContent> </DropdownMenuContent>
</DropdownMenu> </DropdownMenu>
@@ -142,4 +194,3 @@ export default function WishlistItem({
</Card> </Card>
) )
} }

View File

@@ -2,6 +2,7 @@
import { useState, useEffect, useRef } from 'react' import { useState, useEffect, useRef } from 'react'
import { useWishlist } from '@/hooks/useWishlist' import { useWishlist } from '@/hooks/useWishlist'
import { useTranslations } from 'next-intl'
import { Plus, Gift } from 'lucide-react' import { Plus, Gift } from 'lucide-react'
import EmptyState from './EmptyState' import EmptyState from './EmptyState'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
@@ -13,6 +14,7 @@ import { openWindow } from '@/lib/utils'
import { toast } from '@/hooks/use-toast' import { toast } from '@/hooks/use-toast'
export default function WishlistManager() { export default function WishlistManager() {
const t = useTranslations('WishlistManager')
const { const {
addWishlistItem, addWishlistItem,
editWishlistItem, editWishlistItem,
@@ -70,8 +72,8 @@ export default function WishlistManager() {
const opened = openWindow(item.link!) const opened = openWindow(item.link!)
if (!opened) { if (!opened) {
toast({ toast({
title: "Popup Blocked", title: t('popupBlockedTitle'),
description: "Please allow popups to open the link", description: t('popupBlockedDescription'),
variant: "destructive" variant: "destructive"
}) })
} }
@@ -81,20 +83,20 @@ export default function WishlistManager() {
} }
return ( return (
<div className="container mx-auto px-4 py-8"> <div>
<div className="flex justify-between items-center mb-6"> <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)}> <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> </Button>
</div> </div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 items-stretch"> <div className="grid grid-cols-1 lg:grid-cols-2 gap-4 items-stretch">
{activeItems.length === 0 ? ( {activeItems.length === 0 ? (
<div className="col-span-2"> <div className="col-span-1 lg:col-span-2">
<EmptyState <EmptyState
icon={Gift} icon={Gift}
title="Your wishlist is empty" title={t('emptyStateTitle')}
description="Add rewards that you'd like to earn with your coins" description={t('emptyStateDescription')}
/> />
</div> </div>
) : ( ) : (
@@ -127,9 +129,9 @@ export default function WishlistManager() {
{archivedItems.length > 0 && ( {archivedItems.length > 0 && (
<> <>
<div className="col-span-2 relative flex items-center my-6"> <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" /> <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">Archived</span> <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 className="flex-grow border-t border-gray-300 dark:border-gray-600" />
</div> </div>
{archivedItems.map((item) => ( {archivedItems.map((item) => (
@@ -150,14 +152,15 @@ export default function WishlistManager() {
</> </>
)} )}
</div> </div>
{isModalOpen &&
<AddEditWishlistItemModal <AddEditWishlistItemModal
isOpen={isModalOpen}
setIsOpen={setIsModalOpen} setIsOpen={setIsModalOpen}
editingItem={editingItem} editingItem={editingItem}
setEditingItem={setEditingItem} setEditingItem={setEditingItem}
addWishlistItem={addWishlistItem} addWishlistItem={addWishlistItem}
editWishlistItem={editWishlistItem} editWishlistItem={editWishlistItem}
/> />
}
<ConfirmDialog <ConfirmDialog
isOpen={deleteConfirmation.isOpen} isOpen={deleteConfirmation.isOpen}
onClose={() => setDeleteConfirmation({ isOpen: false, itemId: null })} onClose={() => setDeleteConfirmation({ isOpen: false, itemId: null })}
@@ -167,9 +170,9 @@ export default function WishlistManager() {
} }
setDeleteConfirmation({ isOpen: false, itemId: null }) setDeleteConfirmation({ isOpen: false, itemId: null })
}} }}
title="Delete Reward" title={t('deleteDialogTitle')}
message="Are you sure you want to delete this reward? This action cannot be undone." message={t('deleteDialogMessage')}
confirmText="Delete" confirmText={t('deleteButton')}
/> />
</div> </div>
) )

View File

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

View File

@@ -1,7 +1,6 @@
"use client" "use client"
import * as React from "react" import { Moon, Sun } from "lucide-react"
import { Moon, MoonIcon, Sun } from "lucide-react"
import { useTheme } from "next-themes" import { useTheme } from "next-themes"
import { Button } from "@/components/ui/button" import { Button } from "@/components/ui/button"

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

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: services:
habittrove: habittrove:
image: ghcr.io/manindark/habittrove
container_name: habittrove
ports: ports:
- "3000:3000" - "3000:3000"
volumes: volumes:
- "./data:/app/data" # Use a relative path instead of $(pwd) - "./data:/app/data"
image: dohsimpson/habittrove - "./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,73 +1,162 @@
import { useAtom } from 'jotai' import { addCoins, removeCoins, saveCoinsData } from '@/app/actions/data';
import { toast } from '@/hooks/use-toast';
import { import {
coinsAtom, coinsAtom,
coinsBalanceAtom,
coinsEarnedTodayAtom, coinsEarnedTodayAtom,
coinsSpentTodayAtom,
currentUserAtom,
settingsAtom,
totalEarnedAtom, totalEarnedAtom,
totalSpentAtom, totalSpentAtom,
coinsSpentTodayAtom, transactionsTodayAtom,
transactionsTodayAtom usersAtom,
} from '@/lib/atoms' } from '@/lib/atoms';
import { addCoins, removeCoins, saveCoinsData } from '@/app/actions/data' import { MAX_COIN_LIMIT } from '@/lib/constants';
import { CoinsData } from '@/lib/types' import { CoinsData } from '@/lib/types';
import { toast } from '@/hooks/use-toast' 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 [coins, setCoins] = useAtom(coinsAtom)
const [coinsEarnedToday] = useAtom(coinsEarnedTodayAtom) const [settings] = useAtom(settingsAtom)
const [totalEarned] = useAtom(totalEarnedAtom) const [users] = useAtom(usersAtom)
const [totalSpent] = useAtom(totalSpentAtom) const [currentUser] = useAtom(currentUserAtom)
const [coinsSpentToday] = useAtom(coinsSpentTodayAtom) const [allCoinsData] = useAtom(coinsAtom) // All coin transactions
const [transactionsToday] = useAtom(transactionsTodayAtom) 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) => { const add = async (amount: number, description: string, note?: string) => {
if (!handlePermissionCheck(currentUser, 'coins', 'write', tCommon)) return <></>;
if (isNaN(amount) || amount <= 0) { if (isNaN(amount) || amount <= 0) {
toast({ toast({
title: "Invalid amount", title: t("invalidAmountTitle"),
description: "Please enter a valid positive number" 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({ const data = await addCoins({
amount, amount,
description, description,
type: 'MANUAL_ADJUSTMENT', type: 'MANUAL_ADJUSTMENT',
note note,
userId: targetUser?.id
}) })
setCoins(data) setCoins(data)
toast({ title: "Success", description: `Added ${amount} coins` }) toast({ title: t("successTitle"), description: t("addedCoinsDescription", { amount }) })
return data return data
} }
const remove = async (amount: number, description: string, note?: string) => { const remove = async (amount: number, description: string, note?: string) => {
if (!handlePermissionCheck(currentUser, 'coins', 'write', tCommon)) return <></>;
const numAmount = Math.abs(amount) const numAmount = Math.abs(amount)
if (isNaN(numAmount) || numAmount <= 0) { if (isNaN(numAmount) || numAmount <= 0) {
toast({ toast({
title: "Invalid amount", title: t("invalidAmountTitle"),
description: "Please enter a valid positive number" 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({ const data = await removeCoins({
amount: numAmount, amount: numAmount,
description, description,
type: 'MANUAL_ADJUSTMENT', type: 'MANUAL_ADJUSTMENT',
note note,
userId: targetUser?.id
}) })
setCoins(data) setCoins(data)
toast({ title: "Success", description: `Removed ${numAmount} coins` }) toast({ title: t("successTitle"), description: t("removedCoinsDescription", { amount: numAmount }) })
return data return data
} }
const updateNote = async (transactionId: string, note: string) => { const updateNote = async (transactionId: string, note: string) => {
if (!handlePermissionCheck(currentUser, 'coins', 'write', tCommon)) return <></>;
const transaction = coins.transactions.find(t => t.id === transactionId) const transaction = coins.transactions.find(t => t.id === transactionId)
if (!transaction) { if (!transaction) {
toast({ toast({
title: "Error", title: tCommon("errorTitle"),
description: "Transaction not found" description: t("transactionNotFoundDescription")
}) })
return null return <></>;
} }
const updatedTransaction = { const updatedTransaction = {
@@ -93,8 +182,8 @@ export function useCoins() {
add, add,
remove, remove,
updateNote, updateNote,
balance: coins.balance, balance,
transactions: coins.transactions, transactions: transactions,
coinsEarnedToday, coinsEarnedToday,
totalEarned, totalEarned,
totalSpent, totalSpent,

View File

@@ -1,30 +1,37 @@
import { useAtom } from 'jotai'
import { habitsAtom, coinsAtom, settingsAtom } from '@/lib/atoms'
import { addCoins, removeCoins, saveHabitsData } from '@/app/actions/data' import { addCoins, removeCoins, saveHabitsData } from '@/app/actions/data'
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 { Habit } from '@/lib/types'
import { DateTime } from 'luxon'
import { import {
getNowInMilliseconds, d2s,
getTodayInTimezone,
isSameDate,
t2d,
d2t, d2t,
getNow,
getCompletionsForDate, getCompletionsForDate,
getISODate, getISODate,
d2s, getNow,
playSound getTodayInTimezone,
handlePermissionCheck,
isSameDate,
playSound,
t2d
} from '@/lib/utils' } from '@/lib/utils'
import { toast } from '@/hooks/use-toast' import { useAtom } from 'jotai'
import { ToastAction } from '@/components/ui/toast'
import { Undo2 } from 'lucide-react' import { Undo2 } from 'lucide-react'
import { DateTime } from 'luxon'
import { useTranslations } from 'next-intl'
export function useHabits() { 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 [habitsData, setHabitsData] = useAtom(habitsAtom)
const [coins, setCoins] = useAtom(coinsAtom) const [coins, setCoins] = useAtom(coinsAtom)
const [settings] = useAtom(settingsAtom) const [settings] = useAtom(settingsAtom)
const [habitFreqMap] = useAtom(habitFreqMapAtom)
const completeHabit = async (habit: Habit) => { const completeHabit = async (habit: Habit) => {
if (!handlePermissionCheck(currentUser, 'habit', 'interact', tCommon)) return
const timezone = settings.system.timezone const timezone = settings.system.timezone
const today = getTodayInTimezone(timezone) const today = getTodayInTimezone(timezone)
@@ -39,11 +46,11 @@ export function useHabits() {
// Check if already completed // Check if already completed
if (completionsToday >= target) { if (completionsToday >= target) {
toast({ toast({
title: "Already completed", title: t("alreadyCompletedTitle"),
description: `You've already completed this habit today.`, description: t("alreadyCompletedDescription"),
variant: "destructive", variant: "destructive",
}) })
return null return
} }
// Add new completion // Add new completion
@@ -69,21 +76,21 @@ export function useHabits() {
type: habit.isTask ? 'TASK_COMPLETION' : 'HABIT_COMPLETION', type: habit.isTask ? 'TASK_COMPLETION' : 'HABIT_COMPLETION',
relatedItemId: habit.id, relatedItemId: habit.id,
}) })
isTargetReached && playSound() playSound()
toast({ toast({
title: "Habit completed!", title: t("completedTitle"),
description: `You earned ${habit.coinReward} coins.`, description: t("earnedCoinsDescription", { coinReward: habit.coinReward }),
action: <ToastAction altText="Undo" className="gap-2" onClick={() => undoComplete(updatedHabit)}> action: <ToastAction altText={tCommon('undoButton')} className="gap-2" onClick={() => undoComplete(updatedHabit)}>
<Undo2 className="h-4 w-4" />Undo <Undo2 className="h-4 w-4" />{tCommon('undoButton')}
</ToastAction> </ToastAction>
}) })
setCoins(updatedCoins) setCoins(updatedCoins)
} else { } else {
toast({ toast({
title: "Progress!", title: t("progressTitle"),
description: `You've completed ${completionsToday + 1}/${target} times today.`, description: t("progressDescription", { count: completionsToday + 1, target }),
action: <ToastAction altText="Undo" className="gap-2" onClick={() => undoComplete(updatedHabit)}> action: <ToastAction altText={tCommon('undoButton')} className="gap-2" onClick={() => undoComplete(updatedHabit)}>
<Undo2 className="h-4 w-4" />Undo <Undo2 className="h-4 w-4" />{tCommon('undoButton')}
</ToastAction> </ToastAction>
}) })
} }
@@ -98,6 +105,7 @@ export function useHabits() {
} }
const undoComplete = async (habit: Habit) => { const undoComplete = async (habit: Habit) => {
if (!handlePermissionCheck(currentUser, 'habit', 'interact', tCommon)) return
const timezone = settings.system.timezone const timezone = settings.system.timezone
const today = t2d({ timestamp: getTodayInTimezone(timezone), timezone }) const today = t2d({ timestamp: getTodayInTimezone(timezone), timezone })
@@ -113,7 +121,7 @@ export function useHabits() {
completions: habit.completions.filter( completions: habit.completions.filter(
(_, index) => index !== habit.completions.length - 1 (_, index) => index !== habit.completions.length - 1
), ),
archived: habit.isTask ? undefined : habit.archived // Unarchive if it's a task archived: habit.isTask ? false : habit.archived // Unarchive if it's a task
} }
const updatedHabits = habitsData.habits.map(h => const updatedHabits = habitsData.habits.map(h =>
@@ -136,14 +144,17 @@ export function useHabits() {
} }
toast({ toast({
title: "Completion undone", title: t("completionUndoneTitle"),
description: `You have ${getCompletionsForDate({ description: t("completionUndoneDescription", {
count: getCompletionsForDate({
habit: updatedHabit, habit: updatedHabit,
date: today, date: today,
timezone timezone
})}/${target} completions today.`, }),
action: <ToastAction altText="Redo" onClick={() => completeHabit(updatedHabit)}> target
<Undo2 className="h-4 w-4" />Redo }),
action: <ToastAction altText={tCommon('redoButton')} onClick={() => completeHabit(updatedHabit)}>
<Undo2 className="h-4 w-4" />{tCommon('redoButton')}
</ToastAction> </ToastAction>
}) })
@@ -154,18 +165,19 @@ export function useHabits() {
} }
} else { } else {
toast({ toast({
title: "No completions to undo", title: t("noCompletionsToUndoTitle"),
description: "This habit hasn't been completed today.", description: t("noCompletionsToUndoDescription"),
variant: "destructive", variant: "destructive",
}) })
return null return
} }
} }
const saveHabit = async (habit: Omit<Habit, 'id'> & { id?: string }) => { const saveHabit = async (habit: Omit<Habit, 'id'> & { id?: string }) => {
if (!handlePermissionCheck(currentUser, 'habit', 'write', tCommon)) return
const newHabit = { const newHabit = {
...habit, ...habit,
id: habit.id || getNowInMilliseconds().toString() id: habit.id || crypto.randomUUID()
} }
const updatedHabits = habit.id const updatedHabits = habit.id
? habitsData.habits.map(h => h.id === habit.id ? newHabit : h) ? habitsData.habits.map(h => h.id === habit.id ? newHabit : h)
@@ -177,6 +189,7 @@ export function useHabits() {
} }
const deleteHabit = async (id: string) => { const deleteHabit = async (id: string) => {
if (!handlePermissionCheck(currentUser, 'habit', 'write', tCommon)) return
const updatedHabits = habitsData.habits.filter(h => h.id !== id) const updatedHabits = habitsData.habits.filter(h => h.id !== id)
await saveHabitsData({ habits: updatedHabits }) await saveHabitsData({ habits: updatedHabits })
setHabitsData({ habits: updatedHabits }) setHabitsData({ habits: updatedHabits })
@@ -184,6 +197,7 @@ export function useHabits() {
} }
const completePastHabit = async (habit: Habit, date: DateTime) => { const completePastHabit = async (habit: Habit, date: DateTime) => {
if (!handlePermissionCheck(currentUser, 'habit', 'interact', tCommon)) return
const timezone = settings.system.timezone const timezone = settings.system.timezone
const dateKey = getISODate({ dateTime: date, timezone }) const dateKey = getISODate({ dateTime: date, timezone })
@@ -195,11 +209,11 @@ export function useHabits() {
if (completionsOnDate >= target) { if (completionsOnDate >= target) {
toast({ toast({
title: "Already completed", title: t("alreadyCompletedPastDateTitle"),
description: `This habit was already completed on ${d2s({ dateTime: date, timezone, format: 'yyyy-MM-dd' })}.`, description: t("alreadyCompletedPastDateDescription", { dateKey: d2s({ dateTime: date, timezone, format: 'yyyy-MM-dd' }) }),
variant: "destructive", variant: "destructive",
}) })
return null return
} }
// Use current time but with the past date // Use current time but with the past date
@@ -236,12 +250,12 @@ export function useHabits() {
} }
toast({ toast({
title: isTargetReached ? "Habit completed!" : "Progress!", title: isTargetReached ? t("completedTitle") : t("progressTitle"),
description: isTargetReached description: isTargetReached
? `You earned ${habit.coinReward} coins for ${dateKey}.` ? t("earnedCoinsPastDateDescription", { coinReward: habit.coinReward, dateKey })
: `You've completed ${completionsOnDate + 1}/${target} times on ${dateKey}.`, : t("progressPastDateDescription", { count: completionsOnDate + 1, target, dateKey }),
action: <ToastAction altText="Undo" className="gap-2" onClick={() => undoComplete(updatedHabit)}> action: <ToastAction altText={tCommon('undoButton')} className="gap-2" onClick={() => undoComplete(updatedHabit)}>
<Undo2 className="h-4 w-4" />Undo <Undo2 className="h-4 w-4" />{tCommon('undoButton')}
</ToastAction> </ToastAction>
}) })
@@ -253,6 +267,7 @@ export function useHabits() {
} }
const archiveHabit = async (id: string) => { const archiveHabit = async (id: string) => {
if (!handlePermissionCheck(currentUser, 'habit', 'write', tCommon)) return
const updatedHabits = habitsData.habits.map(h => const updatedHabits = habitsData.habits.map(h =>
h.id === id ? { ...h, archived: true } : h h.id === id ? { ...h, archived: true } : h
) )
@@ -261,8 +276,9 @@ export function useHabits() {
} }
const unarchiveHabit = async (id: string) => { const unarchiveHabit = async (id: string) => {
if (!handlePermissionCheck(currentUser, 'habit', 'write', tCommon)) return
const updatedHabits = habitsData.habits.map(h => const updatedHabits = habitsData.habits.map(h =>
h.id === id ? { ...h, archived: undefined } : h h.id === id ? { ...h, archived: false } : h
) )
await saveHabitsData({ habits: updatedHabits }) await saveHabitsData({ habits: updatedHabits })
setHabitsData({ habits: updatedHabits }) setHabitsData({ habits: updatedHabits })
@@ -275,6 +291,7 @@ export function useHabits() {
deleteHabit, deleteHabit,
completePastHabit, completePastHabit,
archiveHabit, archiveHabit,
unarchiveHabit unarchiveHabit,
habitFreqMap,
} }
} }

View File

View File

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

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,63 +1,86 @@
import { atom } from "jotai";
import { import {
getDefaultSettings,
getDefaultHabitsData,
getDefaultCoinsData,
getDefaultWishlistData,
Habit,
ViewType,
} from "./types";
import {
getTodayInTimezone,
isSameDate,
t2d,
calculateCoinsEarnedToday, calculateCoinsEarnedToday,
calculateCoinsSpentToday,
calculateTotalEarned, calculateTotalEarned,
calculateTotalSpent, calculateTotalSpent,
calculateCoinsSpentToday,
calculateTransactionsToday, calculateTransactionsToday,
generateCryptoHash,
getCompletionsForToday, getCompletionsForToday,
getISODate getHabitFreq,
isHabitDue,
prepareDataForHashing,
roundToInteger,
t2d
} from "@/lib/utils"; } from "@/lib/utils";
import { atomWithStorage } from "jotai/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 interface BrowserSettings { export interface BrowserSettings {
viewType: ViewType expandedHabits: boolean
expandedTasks: boolean
expandedWishlist: boolean
} }
export const browserSettingsAtom = atomWithStorage('browserSettings', { export const browserSettingsAtom = atomWithStorage('browserSettings', {
viewType: 'habits' expandedHabits: false,
expandedTasks: false,
expandedWishlist: false
} as BrowserSettings) } as BrowserSettings)
export const settingsAtom = atom(getDefaultSettings()); export const usersAtom = atom(getDefaultUsersData<UserData>())
export const habitsAtom = atom(getDefaultHabitsData()); export const settingsAtom = atom(getDefaultSettings<Settings>());
export const coinsAtom = atom(getDefaultCoinsData()); export const habitsAtom = atom(getDefaultHabitsData<HabitsData>());
export const wishlistAtom = atom(getDefaultWishlistData()); export const coinsAtom = atom(getDefaultCoinsData<CoinsData>());
export const wishlistAtom = atom(getDefaultWishlistData<WishlistData>());
export const serverSettingsAtom = atom(getDefaultServerSettings<ServerSettings>());
// Derived atom for coins earned today // Derived atom for coins earned today
export const coinsEarnedTodayAtom = atom((get) => { export const coinsEarnedTodayAtom = atom((get) => {
const coins = get(coinsAtom); const coins = get(coinsAtom);
const settings = get(settingsAtom); 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 // Derived atom for total earned
export const totalEarnedAtom = atom((get) => { export const totalEarnedAtom = atom((get) => {
const coins = get(coinsAtom); const coins = get(coinsAtom);
return calculateTotalEarned(coins.transactions); const value = calculateTotalEarned(coins.transactions);
return roundToInteger(value);
}); });
// Derived atom for total spent // Derived atom for total spent
export const totalSpentAtom = atom((get) => { export const totalSpentAtom = atom((get) => {
const coins = get(coinsAtom); const coins = get(coinsAtom);
return calculateTotalSpent(coins.transactions); const value = calculateTotalSpent(coins.transactions);
return roundToInteger(value);
}); });
// Derived atom for coins spent today // Derived atom for coins spent today
export const coinsSpentTodayAtom = atom((get) => { export const coinsSpentTodayAtom = atom((get) => {
const coins = get(coinsAtom); const coins = get(coinsAtom);
const settings = get(settingsAtom); 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 // Derived atom for transactions today
@@ -67,6 +90,29 @@ export const transactionsTodayAtom = atom((get) => {
return calculateTransactionsToday(coins.transactions, settings.system.timezone); 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 */ /* transient atoms */
interface PomodoroAtom { interface PomodoroAtom {
show: boolean show: boolean
@@ -82,37 +128,78 @@ export const pomodoroAtom = atom<PomodoroAtom>({
minimized: false, minimized: false,
}) })
// Derived atom for *fully* completed habits by date, respecting target completions export const userSelectAtom = atom<boolean>(false)
export const completedHabitsMapAtom = atom((get) => { export const aboutOpenAtom = atom<boolean>(false)
const habits = get(habitsAtom).habits
const timezone = get(settingsAtom).system.timezone
const map = new Map<string, Habit[]>() /**
* 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 => { habits.forEach(habit => {
// Group completions by date habit.completions.forEach(utcTimestamp => {
const completionsByDate = new Map<string, number>() const localDate = t2d({ timestamp: utcTimestamp, timezone })
.toFormat('yyyy-MM-dd');
habit.completions.forEach(completion => { if (!cache[localDate]) {
const dateKey = getISODate({ dateTime: t2d({ timestamp: completion, timezone }), timezone }) cache[localDate] = {};
completionsByDate.set(dateKey, (completionsByDate.get(dateKey) || 0) + 1)
})
// Check if habit meets target completions for each date
completionsByDate.forEach((count, dateKey) => {
const target = habit.targetCompletions || 1
if (count >= target) {
if (!map.has(dateKey)) {
map.set(dateKey, [])
} }
map.get(dateKey)!.push(habit)
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 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) => { export const pomodoroTodayCompletionsAtom = atom((get) => {
const pomo = get(pomodoroAtom) const pomo = get(pomodoroAtom)
@@ -129,3 +216,21 @@ export const pomodoroTodayCompletionsAtom = atom((get) => {
timezone: settings.system.timezone 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,6 +1,6 @@
import { CheckSquare, Target } from "lucide-react" import { CheckSquare, Target } from "lucide-react"
export const INITIAL_RECURRENCE_RULE = 'daily' export const INITIAL_RECURRENCE_RULE = 'every day'
export const INITIAL_DUE = 'today' export const INITIAL_DUE = 'today'
export const RECURRENCE_RULE_MAP: { [key: string]: string } = { export const RECURRENCE_RULE_MAP: { [key: string]: string } = {
@@ -19,3 +19,18 @@ export const DUE_MAP: { [key: string]: string } = {
export const HabitIcon = Target export const HabitIcon = Target
export const TaskIcon = CheckSquare; 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 = { export type Habit = {
id: string id: string
name: string name: string
@@ -8,6 +44,9 @@ export type Habit = {
completions: string[] // Array of UTC ISO date strings completions: string[] // Array of UTC ISO date strings
isTask?: boolean // mark the habit as a task isTask?: boolean // mark the habit as a task
archived?: boolean // mark the habit as archived archived?: boolean // mark the habit as archived
pinned?: boolean // mark the habit as pinned
userIds?: UserId[]
drawing?: string // Optional JSON string of drawing data
} }
@@ -21,6 +60,8 @@ export type WishlistItemType = {
archived?: boolean // mark the wishlist item as archived archived?: boolean // mark the wishlist item as archived
targetCompletions?: number // Optional field, infinity when unset targetCompletions?: number // Optional field, infinity when unset
link?: string // Optional URL to external resource 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' | 'TASK_COMPLETION' | 'TASK_UNDO'; export type TransactionType = 'HABIT_COMPLETION' | 'HABIT_UNDO' | 'WISH_REDEMPTION' | 'MANUAL_ADJUSTMENT' | 'TASK_COMPLETION' | 'TASK_UNDO';
@@ -33,6 +74,11 @@ export interface CoinTransaction {
timestamp: string; timestamp: string;
relatedItemId?: string; relatedItemId?: string;
note?: string; note?: string;
userId?: UserId;
}
export interface UserData {
users: User[]
} }
export interface HabitsData { export interface HabitsData {
@@ -52,38 +98,63 @@ export interface WishlistData {
} }
// Default value functions // Default value functions
export const getDefaultHabitsData = (): HabitsData => ({ export function getDefaultUsersData<UserData>(): UserData {
habits: [] 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 function getDefaultHabitsData<HabitsData>(): HabitsData {
return { habits: [] } as HabitsData;
}
export const getDefaultCoinsData = (): CoinsData => ({ export function getDefaultTasksData<TasksData>(): TasksData {
balance: 0, return { tasks: [] } as TasksData;
transactions: [] };
});
export const getDefaultWishlistData = (): WishlistData => ({ export function getDefaultCoinsData<CoinsData>(): CoinsData {
items: [] return { balance: 0, transactions: [] } as CoinsData;
}); };
export const getDefaultSettings = (): Settings => ({ export function getDefaultWishlistData<WishlistData>(): WishlistData {
return { items: [] } as WishlistData;
}
export function getDefaultSettings<Settings>(): Settings {
return {
ui: { ui: {
useNumberFormatting: true, useNumberFormatting: true,
useGrouping: true, useGrouping: true,
}, },
system: { system: {
timezone: Intl.DateTimeFormat().resolvedOptions().timeZone, timezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
weekStartDay: 1 // Monday weekStartDay: 1, // Monday
autoBackupEnabled: true, // Add this line (default to true)
language: 'en', // Default language
}, },
profile: {} profile: {}
}); } as Settings;
};
export function getDefaultServerSettings<ServerSettings>(): ServerSettings {
return { isDemo: false } as ServerSettings;
}
// Map of data types to their default values // Map of data types to their default values
export const DATA_DEFAULTS = { export const DATA_DEFAULTS: { [key: string]: <T>() => T } = {
wishlist: getDefaultWishlistData, wishlist: getDefaultWishlistData,
habits: getDefaultHabitsData, habits: getDefaultHabitsData,
coins: getDefaultCoinsData, coins: getDefaultCoinsData,
settings: getDefaultSettings, settings: getDefaultSettings,
auth: getDefaultUsersData,
} as const; } as const;
// Type for all possible data types // Type for all possible data types
@@ -99,10 +170,12 @@ export type WeekDay = 0 | 1 | 2 | 3 | 4 | 5 | 6; // 0 = Sunday, 6 = Saturday
export interface SystemSettings { export interface SystemSettings {
timezone: string; timezone: string;
weekStartDay: WeekDay; weekStartDay: WeekDay;
autoBackupEnabled: boolean; // Add this line
language: string; // Add this line for language preference
} }
export interface ProfileSettings { export interface ProfileSettings {
avatarPath?: string; avatarPath?: string; // deprecated
} }
export interface Settings { export interface Settings {
@@ -111,11 +184,29 @@ export interface Settings {
profile: ProfileSettings; profile: ProfileSettings;
} }
export type ViewType = 'habits' | 'tasks' export type CompletionCache = {
[dateKey: string]: { // dateKey format: "YYYY-MM-DD"
[habitId: string]: number // number of completions on that date
}
}
export interface JotaiHydrateInitialValues { export interface JotaiHydrateInitialValues {
settings: Settings; settings: Settings;
coins: CoinsData; coins: CoinsData;
habits: HabitsData; habits: HabitsData;
wishlist: WishlistData; 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
} }

View File

@@ -1,26 +1,34 @@
import { expect, test, describe, beforeAll, beforeEach, afterAll, spyOn } from "bun:test"; import { expect, test, describe, beforeEach, spyOn } from "bun:test";
import { import {
cn, cn,
getTodayInTimezone, getTodayInTimezone,
getNow, getNow,
getNowInMilliseconds,
t2d, t2d,
d2t, d2t,
d2s, d2s,
d2sDate,
d2n,
isSameDate, isSameDate,
calculateCoinsEarnedToday, calculateCoinsEarnedToday,
calculateTotalEarned, calculateTotalEarned,
calculateTotalSpent, calculateTotalSpent,
calculateCoinsSpentToday, calculateCoinsSpentToday,
isHabitDueToday, isHabitDueToday,
isHabitDue isHabitDue,
isTaskOverdue,
deserializeRRule,
serializeRRule,
convertHumanReadableFrequencyToMachineReadable,
convertMachineReadableFrequencyToHumanReadable,
prepareDataForHashing,
getUnsupportedRRuleReason,
roundToInteger,
generateCryptoHash
} from './utils' } from './utils'
import { CoinTransaction } from './types' import { CoinTransaction, ParsedResultType, Settings, HabitsData, CoinsData, WishlistData, UserData } from './types'
import { DateTime } from "luxon"; import { DateTime } from "luxon";
import { RRule } from 'rrule'; import { getDefaultSettings, getDefaultHabitsData, getDefaultCoinsData, getDefaultWishlistData, getDefaultUsersData } from './types';
import { RRule, Weekday } from 'rrule';
import { Habit } from '@/lib/types'; import { Habit } from '@/lib/types';
import { INITIAL_DUE } from './constants';
describe('cn utility', () => { describe('cn utility', () => {
test('should merge class names correctly', () => { test('should merge class names correctly', () => {
@@ -31,6 +39,141 @@ describe('cn utility', () => {
}) })
}) })
describe('roundToInteger', () => {
test('should round positive numbers correctly', () => {
expect(roundToInteger(10.123)).toBe(10);
expect(roundToInteger(10.5)).toBe(11);
expect(roundToInteger(10.75)).toBe(11);
expect(roundToInteger(10.49)).toBe(10);
});
test('should round negative numbers correctly', () => {
expect(roundToInteger(-10.123)).toBe(-10);
expect(roundToInteger(-10.5)).toBe(-10); // Math.round rounds -x.5 to -(x-1) e.g. -10.5 to -10
expect(roundToInteger(-10.75)).toBe(-11);
expect(roundToInteger(-10.49)).toBe(-10);
});
test('should handle zero correctly', () => {
expect(roundToInteger(0)).toBe(0);
expect(roundToInteger(0.0)).toBe(0);
expect(roundToInteger(-0.0)).toBe(-0);
});
test('should handle integers correctly', () => {
expect(roundToInteger(15)).toBe(15);
expect(roundToInteger(-15)).toBe(-15);
});
});
describe('getUnsupportedRRuleReason', () => {
test('should return message for HOURLY frequency', () => {
const rrule = new RRule({ freq: RRule.HOURLY });
expect(getUnsupportedRRuleReason(rrule)).toBe('Hourly frequency is not supported.');
});
test('should return message for MINUTELY frequency', () => {
const rrule = new RRule({ freq: RRule.MINUTELY });
expect(getUnsupportedRRuleReason(rrule)).toBe('Minutely frequency is not supported.');
});
test('should return message for SECONDLY frequency', () => {
const rrule = new RRule({ freq: RRule.SECONDLY });
expect(getUnsupportedRRuleReason(rrule)).toBe('Secondly frequency is not supported.');
});
test('should return message for DAILY frequency with interval > 1', () => {
const rrule = new RRule({ freq: RRule.DAILY, interval: 2 });
expect(getUnsupportedRRuleReason(rrule)).toBe('Daily frequency with intervals greater than 1 is not supported.');
});
test('should return null for DAILY frequency without interval', () => {
const rrule = new RRule({ freq: RRule.DAILY });
expect(getUnsupportedRRuleReason(rrule)).toBeNull();
});
test('should return null for DAILY frequency with interval = 1', () => {
const rrule = new RRule({ freq: RRule.DAILY, interval: 1 });
expect(getUnsupportedRRuleReason(rrule)).toBeNull();
});
test('should return null for WEEKLY frequency', () => {
const rrule = new RRule({ freq: RRule.WEEKLY, byweekday: [RRule.MO] }); // Added byweekday for validity
expect(getUnsupportedRRuleReason(rrule)).toBeNull();
});
test('should return null for MONTHLY frequency', () => {
const rrule = new RRule({ freq: RRule.MONTHLY, bymonthday: [1] }); // Added bymonthday for validity
expect(getUnsupportedRRuleReason(rrule)).toBeNull();
});
test('should return null for YEARLY frequency', () => {
const rrule = new RRule({ freq: RRule.YEARLY, bymonth: [1], bymonthday: [1] }); // Added bymonth/bymonthday for validity
expect(getUnsupportedRRuleReason(rrule)).toBeNull();
});
test('should return null for WEEKLY frequency with interval', () => {
// Weekly with interval is supported
const rrule = new RRule({ freq: RRule.WEEKLY, interval: 2, byweekday: [RRule.TU] }); // Added byweekday for validity
expect(getUnsupportedRRuleReason(rrule)).toBeNull();
});
});
describe('isTaskOverdue', () => {
const createTestHabit = (frequency: string, isTask = true, archived = false): Habit => ({
id: 'test-habit',
name: 'Test Habit',
description: '',
frequency,
coinReward: 10,
completions: [],
isTask,
archived
})
test('should return false for non-tasks', () => {
const habit = createTestHabit('FREQ=DAILY', false)
expect(isTaskOverdue(habit, 'UTC')).toBe(false)
})
test('should return false for archived tasks', () => {
const habit = createTestHabit('2024-01-01T00:00:00Z', true, true)
expect(isTaskOverdue(habit, 'UTC')).toBe(false)
})
test('should return false for future tasks', () => {
const tomorrow = DateTime.now().plus({ days: 1 }).toUTC().toISO()
const habit = createTestHabit(tomorrow)
expect(isTaskOverdue(habit, 'UTC')).toBe(false)
})
test('should return false for completed past tasks', () => {
const yesterday = DateTime.now().minus({ days: 1 }).toUTC().toISO()
const habit = {
...createTestHabit(yesterday),
completions: [DateTime.now().toUTC().toISO()]
}
expect(isTaskOverdue(habit, 'UTC')).toBe(false)
})
test('should return true for incomplete past tasks', () => {
const yesterday = DateTime.now().minus({ days: 1 }).toUTC().toISO()
const habit = createTestHabit(yesterday)
expect(isTaskOverdue(habit, 'UTC')).toBe(true)
})
test('should handle timezone differences correctly', () => {
// Create a task due "tomorrow" in UTC
const tomorrow = DateTime.now().plus({ days: 1 }).toUTC().toISO()
const habit = createTestHabit(tomorrow)
// Test in various timezones
expect(isTaskOverdue(habit, 'UTC')).toBe(false)
expect(isTaskOverdue(habit, 'America/New_York')).toBe(false)
expect(isTaskOverdue(habit, 'Asia/Tokyo')).toBe(false)
})
})
describe('datetime utilities', () => { describe('datetime utilities', () => {
let fixedNow: DateTime; let fixedNow: DateTime;
let currentDateIndex = 0; let currentDateIndex = 0;
@@ -148,13 +291,6 @@ describe('getNow', () => {
}) })
}) })
describe('getNowInMilliseconds', () => {
test('should return current time in milliseconds', () => {
const now = DateTime.now().setZone('UTC')
expect(getNowInMilliseconds()).toBe(now.toMillis().toString())
})
})
describe('timestamp conversion utilities', () => { describe('timestamp conversion utilities', () => {
const testTimestamp = '2024-01-01T00:00:00.000Z'; const testTimestamp = '2024-01-01T00:00:00.000Z';
const testDateTime = DateTime.fromISO(testTimestamp); const testDateTime = DateTime.fromISO(testTimestamp);
@@ -178,16 +314,6 @@ describe('timestamp conversion utilities', () => {
const customFormat = d2s({ dateTime: testDateTime, format: 'yyyy-MM-dd', timezone: 'utc' }); const customFormat = d2s({ dateTime: testDateTime, format: 'yyyy-MM-dd', timezone: 'utc' });
expect(customFormat).toBe('2024-01-01') expect(customFormat).toBe('2024-01-01')
}) })
test('d2sDate should format DateTime as date string', () => {
const result = d2sDate({ dateTime: testDateTime });
expect(result).toBeString()
})
test('d2n should convert DateTime to milliseconds string', () => {
const result = d2n({ dateTime: testDateTime });
expect(result).toBe('1704067200000')
})
}) })
describe('isSameDate', () => { describe('isSameDate', () => {
@@ -452,13 +578,8 @@ describe('isHabitDueToday', () => {
test('should return false for invalid recurrence rule', () => { test('should return false for invalid recurrence rule', () => {
const habit = testHabit('INVALID_RRULE') const habit = testHabit('INVALID_RRULE')
// Mock console.error to prevent test output pollution
const consoleSpy = spyOn(console, 'error').mockImplementation(() => { }) const consoleSpy = spyOn(console, 'error').mockImplementation(() => { })
expect(isHabitDueToday({ habit, timezone: 'UTC' })).toBe(false)
// Expect the function to throw an error
expect(() => isHabitDueToday({ habit, timezone: 'UTC' })).toThrow()
consoleSpy.mockRestore()
}) })
}) })
@@ -571,7 +692,344 @@ describe('isHabitDue', () => {
const habit = testHabit('INVALID_RRULE') const habit = testHabit('INVALID_RRULE')
const date = DateTime.fromISO('2024-01-01T00:00:00Z') const date = DateTime.fromISO('2024-01-01T00:00:00Z')
const consoleSpy = spyOn(console, 'error').mockImplementation(() => { }) const consoleSpy = spyOn(console, 'error').mockImplementation(() => { })
expect(() => isHabitDue({ habit, timezone: 'UTC', date })).toThrow() expect(isHabitDue({ habit, timezone: 'UTC', date })).toBe(false)
consoleSpy.mockRestore()
}) })
}) })
describe('deserializeRRule', () => {
test('should deserialize valid RRule string', () => {
const rruleStr = 'FREQ=DAILY;INTERVAL=1'
const rrule = deserializeRRule(rruleStr)
expect(rrule).toBeInstanceOf(RRule)
expect(rrule?.origOptions.freq).toBe(RRule.DAILY)
expect(rrule?.origOptions.interval).toBe(1)
})
test('should return null for invalid RRule string', () => {
const rruleStr = 'INVALID_RRULE_STRING'
const rrule = deserializeRRule(rruleStr)
expect(rrule).toBeNull()
})
test('should handle complex RRule strings', () => {
const rruleStr = 'FREQ=WEEKLY;BYDAY=MO,WE,FR;INTERVAL=2;COUNT=10'
const rrule = deserializeRRule(rruleStr)
expect(rrule).toBeInstanceOf(RRule)
expect(rrule?.origOptions.freq).toBe(RRule.WEEKLY)
expect(rrule?.origOptions.byweekday).toEqual([RRule.MO, RRule.WE, RRule.FR])
expect(rrule?.origOptions.interval).toBe(2)
expect(rrule?.origOptions.count).toBe(10)
})
})
describe('serializeRRule', () => {
test('should serialize RRule object to string', () => {
const rrule = new RRule({
freq: RRule.DAILY,
interval: 1
})
const rruleStr = serializeRRule(rrule)
// RRule adds DTSTART automatically if not provided, so we check the core parts
expect(rruleStr).toContain('FREQ=DAILY')
expect(rruleStr).toContain('INTERVAL=1')
})
test('should return "invalid" for null input', () => {
const rruleStr = serializeRRule(null)
expect(rruleStr).toBe('invalid')
})
test('should serialize complex RRule objects', () => {
const rrule = new RRule({
freq: RRule.WEEKLY,
byweekday: [RRule.MO, RRule.WE, RRule.FR],
interval: 2,
count: 10
})
const rruleStr = serializeRRule(rrule)
expect(rruleStr).toContain('FREQ=WEEKLY')
expect(rruleStr).toContain('BYDAY=MO,WE,FR')
expect(rruleStr).toContain('INTERVAL=2')
expect(rruleStr).toContain('COUNT=10')
})
})
describe('convertHumanReadableFrequencyToMachineReadable', () => {
const timezone = 'America/New_York'
beforeEach(() => {
// Set a fixed date for consistent relative date parsing
const mockDate = DateTime.fromISO('2024-07-15T10:00:00', { zone: timezone }) as DateTime<true>
DateTime.now = () => mockDate
})
// Non-recurring tests
test('should parse specific date (non-recurring)', () => {
const { result, message } = convertHumanReadableFrequencyToMachineReadable({ text: 'July 16, 2024', timezone, isRecurring: false })
expect(message).toBeNull()
expect(result).toBeInstanceOf(DateTime)
expect((result as DateTime).toISODate()).toBe('2024-07-16')
})
test('should parse relative date "tomorrow" (non-recurring)', () => {
const { result, message } = convertHumanReadableFrequencyToMachineReadable({ text: 'tomorrow', timezone, isRecurring: false })
expect(message).toBeNull()
expect(result).toBeInstanceOf(DateTime)
expect((result as DateTime).toISODate()).toBe('2024-07-16') // Based on mock date 2024-07-15
})
test('should parse relative date "next friday" (non-recurring)', () => {
const { result, message } = convertHumanReadableFrequencyToMachineReadable({ text: 'next friday', timezone, isRecurring: false })
expect(message).toBeNull()
expect(result).toBeInstanceOf(DateTime)
// chrono-node interprets "next friday" from Mon July 15 as Fri July 26
expect((result as DateTime).toISODate()).toBe('2024-07-26')
})
test('should return null for invalid date string (non-recurring)', () => {
const { result, message } = convertHumanReadableFrequencyToMachineReadable({ text: 'invalid date', timezone, isRecurring: false })
expect(result).toBeNull()
expect(message).toBe('Invalid due date.')
})
// Recurring tests
test('should parse "daily" (recurring)', () => {
const { result, message } = convertHumanReadableFrequencyToMachineReadable({ text: 'daily', timezone, isRecurring: true })
expect(message).toBeNull()
expect(result).toBeInstanceOf(RRule)
expect((result as RRule).origOptions.freq).toBe(RRule.DAILY)
})
test('should parse "every week on Monday" (recurring)', () => {
const { result, message } = convertHumanReadableFrequencyToMachineReadable({ text: 'every week on Monday', timezone, isRecurring: true })
expect(message).toBeNull()
expect(result).toBeInstanceOf(RRule)
expect((result as RRule).origOptions.freq).toBe(RRule.WEEKLY)
// RRule.fromText returns Weekday objects, check the weekday property
const byweekday = (result as RRule).origOptions.byweekday;
const weekdayValues = byweekday
? (Array.isArray(byweekday)
? byweekday.map(d => typeof d === 'number' ? d : (d as Weekday).weekday)
: [typeof byweekday === 'number' ? byweekday : (byweekday as Weekday).weekday])
: [];
expect(weekdayValues).toEqual([RRule.MO.weekday])
})
test('should parse "every month on the 15th" (recurring)', () => {
const { result, message } = convertHumanReadableFrequencyToMachineReadable({ text: 'every month on the 15th', timezone, isRecurring: true })
expect(message).toBeNull()
expect(result).toBeInstanceOf(RRule)
expect((result as RRule).origOptions.freq).toBe(RRule.MONTHLY)
expect((result as RRule).origOptions.bymonthday).toEqual([15])
})
test('should parse "every year on Jan 1" (recurring)', () => {
const { result, message } = convertHumanReadableFrequencyToMachineReadable({ text: 'every year on Jan 1', timezone, isRecurring: true })
expect(message).toBeNull()
expect(result).toBeInstanceOf(RRule)
expect((result as RRule).origOptions.freq).toBe(RRule.YEARLY)
// Note: RRule.fromText parses 'Jan 1' into bymonth/bymonthday
expect((result as RRule).origOptions.bymonth).toEqual([1])
// RRule.fromText might not reliably set bymonthday in origOptions for this text
// expect((result as RRule).origOptions.bymonthday).toEqual([1])
})
test('should return validation error for "every week" without day (recurring)', () => {
const { result, message } = convertHumanReadableFrequencyToMachineReadable({ text: 'every week', timezone, isRecurring: true })
expect(result).toBeNull() // RRule.fromText might parse it, but our validation catches it
expect(message).toBe('Please specify day(s) of the week (e.g., "every week on Mon, Wed").')
})
test('should return validation error for "every month" without day/position (recurring)', () => {
const { result, message } = convertHumanReadableFrequencyToMachineReadable({ text: 'every month', timezone, isRecurring: true })
expect(result).toBeNull() // RRule.fromText might parse it, but our validation catches it
expect(message).toBe('Please specify day of the month (e.g., "every month on the 15th") or position (e.g., "every month on the last Friday").')
})
test('should return null for invalid recurrence string (recurring)', () => {
const { result, message } = convertHumanReadableFrequencyToMachineReadable({ text: 'invalid recurrence', timezone, isRecurring: true })
expect(result).toBeNull()
expect(message).toBe('Invalid recurrence rule.')
})
test('should return specific error for unsupported hourly frequency', () => {
const { result, message } = convertHumanReadableFrequencyToMachineReadable({ text: 'every hour', timezone, isRecurring: true })
expect(result).toBeInstanceOf(RRule) // RRule parses it, but our validation catches it
expect(message).toBe('Hourly frequency is not supported.')
})
test('should return specific error for unsupported daily interval', () => {
const { result, message } = convertHumanReadableFrequencyToMachineReadable({ text: 'every 2 days', timezone, isRecurring: true })
expect(result).toBeInstanceOf(RRule) // RRule parses it, but our validation catches it
expect(message).toBe('Daily frequency with intervals greater than 1 is not supported.')
})
test('should handle predefined constants like "weekdays"', () => {
const { result, message } = convertHumanReadableFrequencyToMachineReadable({ text: 'weekdays', timezone, isRecurring: true })
expect(message).toBeNull()
expect(result).toBeInstanceOf(RRule)
expect((result as RRule).origOptions.freq).toBe(RRule.WEEKLY)
// Check the weekday property of the Weekday objects
const weekdays = (result as RRule).origOptions.byweekday;
const weekdayNumbers = weekdays
? (Array.isArray(weekdays)
? weekdays.map(d => typeof d === 'number' ? d : (d as Weekday).weekday)
: [typeof weekdays === 'number' ? weekdays : (weekdays as Weekday).weekday])
: [];
expect(weekdayNumbers).toEqual([RRule.MO.weekday, RRule.TU.weekday, RRule.WE.weekday, RRule.TH.weekday, RRule.FR.weekday])
})
})
describe('convertMachineReadableFrequencyToHumanReadable', () => {
const timezone = 'America/New_York'
// Non-recurring tests
test('should format DateTime object (non-recurring)', () => {
const dateTime = DateTime.fromISO('2024-07-16T00:00:00', { zone: timezone }) as DateTime<true>
const humanReadable = convertMachineReadableFrequencyToHumanReadable({ frequency: dateTime, isRecurRule: false, timezone })
// Expected format depends on locale, check for key parts
expect(humanReadable).toContain('Jul 16, 2024')
expect(humanReadable).toContain('Tue') // Tuesday
})
test('should format ISO string (non-recurring)', () => {
const isoString = '2024-07-16T00:00:00.000-04:00' // Example ISO string with offset
const humanReadable = convertMachineReadableFrequencyToHumanReadable({ frequency: isoString, isRecurRule: false, timezone })
expect(humanReadable).toContain('Jul 16, 2024')
expect(humanReadable).toContain('Tue')
})
test('should return "Initial Due" for null frequency (non-recurring)', () => {
const humanReadable = convertMachineReadableFrequencyToHumanReadable({ frequency: null, isRecurRule: false, timezone })
// Check against the imported constant value
expect(humanReadable).toBe(INITIAL_DUE)
})
// Recurring tests
test('should format RRule object (recurring)', () => {
const rrule = new RRule({ freq: RRule.DAILY })
const humanReadable = convertMachineReadableFrequencyToHumanReadable({ frequency: rrule, isRecurRule: true, timezone })
// rrule.toText() returns "every day" for daily rules
expect(humanReadable).toBe('every day')
})
test('should format RRule string (recurring)', () => {
const rruleStr = 'FREQ=WEEKLY;BYDAY=MO,WE,FR'
const humanReadable = convertMachineReadableFrequencyToHumanReadable({ frequency: rruleStr, isRecurRule: true, timezone })
expect(humanReadable).toBe('every week on Monday, Wednesday, Friday')
})
test('should return "invalid" for invalid RRule string (recurring)', () => {
const rruleStr = 'INVALID_RRULE'
const humanReadable = convertMachineReadableFrequencyToHumanReadable({ frequency: rruleStr, isRecurRule: true, timezone })
expect(humanReadable).toBe('invalid')
})
test('should return "invalid" for null frequency (recurring)', () => {
const humanReadable = convertMachineReadableFrequencyToHumanReadable({ frequency: null, isRecurRule: true, timezone })
expect(humanReadable).toBe('invalid')
})
test('should return "invalid" for unexpected type (recurring)', () => {
const humanReadable = convertMachineReadableFrequencyToHumanReadable({ frequency: 123 as unknown as ParsedResultType, isRecurRule: true, timezone })
expect(humanReadable).toBe('invalid')
})
test('should return "invalid" for unexpected type (non-recurring)', () => {
const humanReadable = convertMachineReadableFrequencyToHumanReadable({ frequency: new RRule({ freq: RRule.DAILY }) as unknown as ParsedResultType, isRecurRule: false, timezone })
expect(humanReadable).toBe('invalid')
})
})
describe('freshness utilities', () => {
const mockSettings: Settings = getDefaultSettings<Settings>();
const mockHabits: HabitsData = getDefaultHabitsData<HabitsData>();
const mockCoins: CoinsData = getDefaultCoinsData<CoinsData>();
const mockWishlist: WishlistData = getDefaultWishlistData<WishlistData>();
const mockUsers: UserData = getDefaultUsersData<UserData>();
// Add a user to mockUsers for more realistic testing
mockUsers.users.push({
id: 'user-123',
username: 'testuser',
isAdmin: false,
});
mockHabits.habits.push({
id: 'habit-123',
name: 'Test Habit',
description: 'A habit for testing',
frequency: 'FREQ=DAILY',
coinReward: 10,
completions: [],
userIds: ['user-123']
});
describe('prepareDataForHashing', () => {
test('should produce a consistent string for the same data', () => {
const data1 = { settings: mockSettings, habits: mockHabits, coins: mockCoins, wishlist: mockWishlist, users: mockUsers };
const data2 = { settings: mockSettings, habits: mockHabits, coins: mockCoins, wishlist: mockWishlist, users: mockUsers }; // Identical data
const string1 = prepareDataForHashing(data1.settings, data1.habits, data1.coins, data1.wishlist, data1.users);
const string2 = prepareDataForHashing(data2.settings, data2.habits, data2.coins, data2.wishlist, data2.users);
expect(string1).toBe(string2);
});
test('should produce a different string if settings data changes', () => {
const string1 = prepareDataForHashing(mockSettings, mockHabits, mockCoins, mockWishlist, mockUsers);
const modifiedSettings = { ...mockSettings, system: { ...mockSettings.system, timezone: 'America/Chicago' } };
const string2 = prepareDataForHashing(modifiedSettings, mockHabits, mockCoins, mockWishlist, mockUsers);
expect(string1).not.toBe(string2);
});
test('should produce a different string if habits data changes', () => {
const string1 = prepareDataForHashing(mockSettings, mockHabits, mockCoins, mockWishlist, mockUsers);
const modifiedHabits = { ...mockHabits, habits: [...mockHabits.habits, { id: 'new-habit', name: 'New', description: '', frequency: 'FREQ=DAILY', coinReward: 5, completions: [] }] };
const string2 = prepareDataForHashing(mockSettings, modifiedHabits, mockCoins, mockWishlist, mockUsers);
expect(string1).not.toBe(string2);
});
test('should handle empty data consistently', () => {
const emptySettings = getDefaultSettings<Settings>();
const emptyHabits = getDefaultHabitsData<HabitsData>();
const emptyCoins = getDefaultCoinsData<CoinsData>();
const emptyWishlist = getDefaultWishlistData<WishlistData>();
const emptyUsers = getDefaultUsersData<UserData>();
const string1 = prepareDataForHashing(emptySettings, emptyHabits, emptyCoins, emptyWishlist, emptyUsers);
const string2 = prepareDataForHashing(emptySettings, emptyHabits, emptyCoins, emptyWishlist, emptyUsers);
expect(string1).toBe(string2);
expect(string1).toBeDefined();
});
});
describe('generateCryptoHash', () => {
test('should generate a SHA-256 hex string', async () => {
const dataString = 'test string';
const hash = await generateCryptoHash(dataString);
expect(hash).toMatch(/^[a-f0-9]{64}$/); // SHA-256 hex is 64 chars
});
test('should generate different hashes for different strings', async () => {
const hash1 = await generateCryptoHash('test string 1');
const hash2 = await generateCryptoHash('test string 2');
expect(hash1).not.toBe(hash2);
});
test('should generate the same hash for the same string', async () => {
const hash1 = await generateCryptoHash('consistent string');
const hash2 = await generateCryptoHash('consistent string');
expect(hash1).toBe(hash2);
});
// Test with a known SHA-256 value if possible, or ensure crypto.subtle.digest is available
// For "hello world", SHA-256 is "b94d27b9934d3e08a52e52d7da7dabfac484efe37a5380ee9088f7ace2efcde9"
test('should generate correct hash for a known string', async () => {
const knownString = "hello world";
const expectedHash = "b94d27b9934d3e08a52e52d7da7dabfac484efe37a5380ee9088f7ace2efcde9";
const actualHash = await generateCryptoHash(knownString);
expect(actualHash).toBe(expectedHash);
});
});
})

View File

@@ -1,10 +1,12 @@
import { toast } from "@/hooks/use-toast"
import { CoinsData, CoinTransaction, Freq, Habit, HabitsData, ParsedFrequencyResult, ParsedResultType, SafeUser, Settings, User, UserData, WishlistData } from '@/lib/types'
import * as chrono from 'chrono-node'
import { clsx, type ClassValue } from "clsx" import { clsx, type ClassValue } from "clsx"
import { twMerge } from "tailwind-merge"
import { DateTime, DateTimeFormatOptions } from "luxon" import { DateTime, DateTimeFormatOptions } from "luxon"
import { Formats } from "next-intl"
import { datetime, RRule } from 'rrule' import { datetime, RRule } from 'rrule'
import { Freq, Habit, CoinTransaction } from '@/lib/types' import { twMerge } from "tailwind-merge"
import { DUE_MAP, INITIAL_RECURRENCE_RULE, RECURRENCE_RULE_MAP } from "./constants" import { DUE_MAP, INITIAL_DUE, RECURRENCE_RULE_MAP } from "./constants"
import * as chrono from 'chrono-node';
export function cn(...inputs: ClassValue[]) { export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs)) return twMerge(clsx(inputs))
@@ -16,6 +18,11 @@ export function getTodayInTimezone(timezone: string): string {
return getISODate({ dateTime: now, timezone }); return getISODate({ dateTime: now, timezone });
} }
// round a number to the nearest integer
export function roundToInteger(value: number): number {
return Math.round(value);
}
export function getISODate({ dateTime, timezone }: { dateTime: DateTime, timezone: string }): string { export function getISODate({ dateTime, timezone }: { dateTime: DateTime, timezone: string }): string {
return dateTime.setZone(timezone).toISODate()!; return dateTime.setZone(timezone).toISODate()!;
} }
@@ -25,18 +32,12 @@ export function getNow({ timezone = 'utc', keepLocalTime }: { timezone?: string,
return DateTime.now().setZone(timezone, { keepLocalTime }); return DateTime.now().setZone(timezone, { keepLocalTime });
} }
// get current time in epoch milliseconds
export function getNowInMilliseconds() {
const now = getNow({});
return d2n({ dateTime: now });
}
// iso timestamp to datetime object, most for storage read // iso timestamp to datetime object, most for storage read
export function t2d({ timestamp, timezone }: { timestamp: string; timezone: string }) { export function t2d({ timestamp, timezone }: { timestamp: string; timezone: string }) {
return DateTime.fromISO(timestamp).setZone(timezone); return DateTime.fromISO(timestamp).setZone(timezone);
} }
// convert datetime object to iso timestamp, mostly for storage write // convert datetime object to iso timestamp, mostly for storage write (be sure to use default utc timezone when writing)
export function d2t({ dateTime, timezone = 'utc' }: { dateTime: DateTime, timezone?: string }) { export function d2t({ dateTime, timezone = 'utc' }: { dateTime: DateTime, timezone?: string }) {
return dateTime.setZone(timezone).toISO()!; return dateTime.setZone(timezone).toISO()!;
} }
@@ -53,30 +54,11 @@ export function d2s({ dateTime, format, timezone }: { dateTime: DateTime, format
return dateTime.setZone(timezone).toLocaleString(DateTime.DATETIME_MED); return dateTime.setZone(timezone).toLocaleString(DateTime.DATETIME_MED);
} }
// convert datetime object to date string, mostly for display
export function d2sDate({ dateTime }: { dateTime: DateTime }) {
return dateTime.toLocaleString(DateTime.DATE_MED);
}
// convert datetime object to epoch milliseconds string, mostly for storage write
export function d2n({ dateTime }: { dateTime: DateTime }) {
return dateTime.toMillis().toString();
}
// compare the date portion of two datetime objects (i.e. same year, month, day) // compare the date portion of two datetime objects (i.e. same year, month, day)
export function isSameDate(a: DateTime, b: DateTime) { export function isSameDate(a: DateTime, b: DateTime) {
return a.hasSame(b, 'day'); return a.hasSame(b, 'day');
} }
export function normalizeCompletionDate(date: string, timezone: string): string {
// If already in ISO format, return as is
if (date.includes('T')) {
return date;
}
// Convert from yyyy-MM-dd to ISO format
return DateTime.fromFormat(date, 'yyyy-MM-dd', { zone: timezone }).toUTC().toISO()!;
}
export function getCompletionsForDate({ export function getCompletionsForDate({
habit, habit,
date, date,
@@ -183,41 +165,126 @@ export function calculateTransactionsToday(transactions: CoinTransaction[], time
).length; ).length;
} }
export function getRRuleUTC(recurrenceRule: string) { // Enhanced validation for weekly/monthly rules
return RRule.fromString(recurrenceRule); // this returns UTC function validateRecurrenceRule(rrule: RRule | null): ParsedFrequencyResult {
if (!rrule) {
return { result: null, message: 'Invalid recurrence rule.' };
} }
export function parseNaturalLanguageRRule(ruleText: string) { const unsupportedReason = getUnsupportedRRuleReason(rrule);
ruleText = ruleText.trim() if (unsupportedReason) {
if (RECURRENCE_RULE_MAP[ruleText]) { return { result: rrule, message: unsupportedReason };
return RRule.fromString(RECURRENCE_RULE_MAP[ruleText])
} }
return RRule.fromText(ruleText) const options = rrule.origOptions;
if (options.freq === RRule.WEEKLY && (!options.byweekday || !Array.isArray(options.byweekday) || options.byweekday.length === 0)) {
return { result: null, message: 'Please specify day(s) of the week (e.g., "every week on Mon, Wed").' };
} }
export function parseRRule(ruleText: string) { if (options.freq === RRule.MONTHLY &&
ruleText = ruleText.trim() (!options.bymonthday || !Array.isArray(options.bymonthday) || options.bymonthday.length === 0) &&
if (RECURRENCE_RULE_MAP[ruleText]) { (!options.bysetpos || !Array.isArray(options.bysetpos) || options.bysetpos.length === 0) && // Need to check bysetpos for rules like "last Friday"
return RRule.fromString(RECURRENCE_RULE_MAP[ruleText]) (!options.byweekday || !Array.isArray(options.byweekday) || options.byweekday.length === 0)) { // Need byweekday with bysetpos
return { result: null, message: 'Please specify day of the month (e.g., "every month on the 15th") or position (e.g., "every month on the last Friday").' };
} }
return RRule.fromString(ruleText) return { result: rrule, message: null };
} }
export function serializeRRule(rrule: RRule) { // Convert a human-readable frequency (recurring or non-recurring) into a machine-readable one
return rrule.toString() export function convertHumanReadableFrequencyToMachineReadable({ text, timezone, isRecurring = false }: { text: string, timezone: string, isRecurring?: boolean }): ParsedFrequencyResult {
} text = text.trim()
export function parseNaturalLanguageDate({ text, timezone }: { text: string, timezone: string }) { if (!isRecurring) {
if (DUE_MAP[text]) { if (DUE_MAP[text]) {
text = DUE_MAP[text] text = DUE_MAP[text]
} }
const now = getNow({ timezone }) const now = getNow({ timezone })
const due = chrono.parseDate(text, { instant: now.toJSDate(), timezone }) const due = chrono.parseDate(text, { instant: now.toJSDate(), timezone })
if (!due) throw Error('invalid rule') if (!due) return { result: null, message: 'Invalid due date.' }
// return d2s({ dateTime: DateTime.fromJSDate(due), timezone, format: DateTime.DATE_MED_WITH_WEEKDAY }) const result = due ? DateTime.fromJSDate(due).setZone(timezone) : null
return DateTime.fromJSDate(due).setZone(timezone) return { message: null, result: result ? (result.isValid ? result : null) : null }
}
let rrule: RRule | null
if (RECURRENCE_RULE_MAP[text]) {
rrule = deserializeRRule(RECURRENCE_RULE_MAP[text])
} else if (text.toLowerCase() === 'weekdays') {
// Handle 'weekdays' specifically if not in the map
rrule = new RRule({
freq: RRule.WEEKLY,
byweekday: [RRule.MO, RRule.TU, RRule.WE, RRule.TH, RRule.FR]
});
} else {
try {
rrule = RRule.fromText(text)
} catch (error) {
rrule = null
}
}
return validateRecurrenceRule(rrule);
}
// convert a machine-readable rrule **string** to an rrule object
export function deserializeRRule(rruleStr: string): RRule | null {
try {
return RRule.fromString(rruleStr);
} catch (error) {
return null;
}
}
// convert a machine-readable rrule **object** to an rrule string
export function serializeRRule(rrule: RRule | null): string {
if (!rrule) return 'invalid'; // Handle null case explicitly
return rrule.toString()
}
// Convert a machine-readable frequency (recurring or non-recurring) into a human-readable one
export function convertMachineReadableFrequencyToHumanReadable({
frequency,
isRecurRule,
timezone
}: {
frequency: ParsedResultType,
isRecurRule: boolean,
timezone: string
}): string {
if (isRecurRule) {
if (!frequency) {
return 'invalid'; // Handle null/undefined for recurring rules
}
if (frequency instanceof RRule) {
return frequency.toText();
} else if (typeof frequency === "string") {
const parsedResult = deserializeRRule(frequency);
return parsedResult?.toText() || 'invalid';
} else {
return 'invalid';
}
} else {
// Handle non-recurring frequency
if (!frequency) {
// Use the imported constant for initial due date text
return INITIAL_DUE;
}
if (typeof frequency === 'string') {
return d2s({
dateTime: t2d({ timestamp: frequency, timezone: timezone }),
timezone: timezone,
format: DateTime.DATE_MED_WITH_WEEKDAY
});
} else if (frequency instanceof DateTime) {
return d2s({
dateTime: frequency,
timezone: timezone,
format: DateTime.DATE_MED_WITH_WEEKDAY
});
} else {
return 'invalid';
}
}
} }
export function isHabitDue({ export function isHabitDue({
@@ -229,17 +296,24 @@ export function isHabitDue({
timezone: string timezone: string
date: DateTime date: DateTime
}): boolean { }): boolean {
// handle task
if (habit.isTask) { if (habit.isTask) {
// For tasks, frequency is stored as a UTC ISO timestamp // For tasks, frequency is stored as a UTC ISO timestamp
const taskDueDate = t2d({ timestamp: habit.frequency, timezone }) const taskDueDate = t2d({ timestamp: habit.frequency, timezone })
return isSameDate(taskDueDate, date); return isSameDate(taskDueDate, date);
} }
// handle habit
if (habit.archived) {
return false
}
const startOfDay = date.setZone(timezone).startOf('day') const startOfDay = date.setZone(timezone).startOf('day')
const endOfDay = date.setZone(timezone).endOf('day') const endOfDay = date.setZone(timezone).endOf('day')
const ruleText = habit.frequency const ruleText = habit.frequency
const rrule = parseRRule(ruleText) const rrule = deserializeRRule(ruleText)
if (!rrule) return false
rrule.origOptions.tzid = timezone rrule.origOptions.tzid = timezone
rrule.options.tzid = rrule.origOptions.tzid rrule.options.tzid = rrule.origOptions.tzid
rrule.origOptions.dtstart = datetime(startOfDay.year, startOfDay.month, startOfDay.day, startOfDay.hour, startOfDay.minute, startOfDay.second) rrule.origOptions.dtstart = datetime(startOfDay.year, startOfDay.month, startOfDay.day, startOfDay.hour, startOfDay.minute, startOfDay.second)
@@ -253,6 +327,17 @@ export function isHabitDue({
return startOfDay <= t && t <= endOfDay return startOfDay <= t && t <= endOfDay
} }
export function isHabitCompleted(habit: Habit, timezone: string): boolean {
return getCompletionsForToday({ habit, timezone: timezone }) >= (habit.targetCompletions || 1)
}
export function isTaskOverdue(habit: Habit, timezone: string): boolean {
if (!habit.isTask || habit.archived) return false
const dueDate = t2d({ timestamp: habit.frequency, timezone }).startOf('day')
const now = getNow({ timezone }).startOf('day')
return dueDate < now && !isHabitCompleted(habit, timezone)
}
export function isHabitDueToday({ export function isHabitDueToday({
habit, habit,
timezone timezone
@@ -269,17 +354,46 @@ export function getHabitFreq(habit: Habit): Freq {
// don't support recurring task yet // don't support recurring task yet
return 'daily' return 'daily'
} }
const rrule = parseRRule(habit.frequency) const rrule = RRule.fromString(habit.frequency)
const freq = rrule.origOptions.freq const freq = rrule.origOptions.freq
switch (freq) { switch (freq) {
case RRule.DAILY: return 'daily' case RRule.DAILY: return 'daily'
case RRule.WEEKLY: return 'weekly' case RRule.WEEKLY: return 'weekly'
case RRule.MONTHLY: return 'monthly' case RRule.MONTHLY: return 'monthly'
case RRule.YEARLY: return 'yearly' case RRule.YEARLY: return 'yearly'
default: throw new Error(`Invalid frequency: ${freq}`)
default:
console.error(`Invalid frequency: ${freq} (habit: ${habit.id} ${habit.name}) (rrule: ${rrule.toString()}). Defaulting to daily`)
return 'daily'
} }
} }
/**
* Checks if an RRule is unsupported and returns the reason.
* @param rrule The RRule object to check.
* @returns A string message explaining why the rule is unsupported, or null if it's supported.
*/
export function getUnsupportedRRuleReason(rrule: RRule): string | null {
const freq = rrule.origOptions.freq;
const interval = rrule.origOptions.interval || 1; // RRule defaults interval to 1
if (freq === RRule.HOURLY) {
return 'Hourly frequency is not supported.';
}
if (freq === RRule.MINUTELY) {
return 'Minutely frequency is not supported.';
}
if (freq === RRule.SECONDLY) {
return 'Secondly frequency is not supported.';
}
if (freq === RRule.DAILY && interval > 1) {
return 'Daily frequency with intervals greater than 1 is not supported.';
}
return null; // Rule is supported
}
// play sound (client side only, must be run in browser) // play sound (client side only, must be run in browser)
export const playSound = (soundPath: string = '/sounds/timer-end.wav') => { export const playSound = (soundPath: string = '/sounds/timer-end.wav') => {
const audio = new Audio(soundPath) const audio = new Audio(soundPath)
@@ -297,3 +411,101 @@ export const openWindow = (url: string): boolean => {
} }
return true return true
} }
export function hasPermission(
user: User | undefined,
resource: 'habit' | 'wishlist' | 'coins',
action: 'write' | 'interact'
): boolean {
if (!user || !user.permissions) {
return false;
}
// If user is admin, they have all permissions.
if (user.isAdmin) {
return true;
}
// Otherwise, check specific permissions.
return user.permissions.some(permission => {
switch (resource) {
case 'habit':
return permission.habit[action]
case 'wishlist':
return permission.wishlist[action]
case 'coins':
return permission.coins[action]
default:
return false
}
})
}
/**
* Prepares a consistent string representation of the data for hashing.
* It combines all relevant data pieces into a single object and then stringifies it stably.
*/
export function prepareDataForHashing(
settings: Settings,
habits: HabitsData,
coins: CoinsData,
wishlist: WishlistData,
users: UserData
): string {
return JSON.stringify({
settings,
habits,
coins,
wishlist,
users,
});
}
/**
* Generates a SHA-256 hash for a given string using the Web Crypto API.
* This function is suitable for both client-side and server-side (Node.js 19+) environments.
* @param dataString The string to hash.
* @returns A promise that resolves to the hex string of the hash.
*/
export async function generateCryptoHash(dataString: string): Promise<string | null> {
try {
const encoder = new TextEncoder();
const data = encoder.encode(dataString);
// globalThis.crypto should be available in modern browsers and Node.js (v19+)
// For Node.js v15-v18, you might need: const { subtle } = require('node:crypto').webcrypto;
const hashBuffer = await globalThis.crypto.subtle.digest('SHA-256', data);
const hashArray = Array.from(new Uint8Array(hashBuffer));
// Convert buffer to hex string
const hashHex = hashArray.map(b => b.toString(16).padStart(2, '0')).join('');
return hashHex;
} catch (error) {
console.error(`Failed to generate hash: ${error}`);
return null;
}
}
export function handlePermissionCheck(
user: User | SafeUser | undefined,
resource: 'habit' | 'wishlist' | 'coins',
action: 'write' | 'interact',
tCommon: (key: string, values?: Record<string, string | number | Date> | undefined, formats?: Formats | undefined) => string
): boolean {
if (!user) {
toast({
title: tCommon("authenticationRequiredTitle"),
description: tCommon("authenticationRequiredDescription"),
variant: "destructive",
})
return false
}
if (!hasPermission(user, resource, action)) {
toast({
title: tCommon("permissionDeniedTitle"),
description: tCommon("permissionDeniedDescription", { action, resource }),
variant: "destructive",
})
return false
}
return true
}

17
lib/zod.ts Normal file
View File

@@ -0,0 +1,17 @@
import { literal, object, string } from "zod"
export const usernameSchema = string()
.min(3, "Username must be at least 3 characters")
.max(20, "Username must be less than 20 characters")
.regex(/^[a-zA-Z0-9]+$/, "Username must be alphanumeric")
export const passwordSchema = string()
.min(4, "Password must be more than 4 characters")
.max(32, "Password must be less than 32 characters")
.optional()
.or(literal(''))
export const signInSchema = object({
username: usernameSchema,
password: passwordSchema,
})

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