Compare commits

...

57 Commits

Author SHA1 Message Date
c397f40239 fix: linting problem 2026-02-24 19:02:37 +01:00
244692d8f9 fix: faulty isMobile check 2026-02-24 18:59:22 +01:00
bb2e4be41b Merge Tag 'v0.2.30' 2025-12-04 23:21:16 +01:00
dohsimpson
b01c5dcd6a feat: update Next.js to v15.5.7 to address CVE-2025-55182 2025-12-03 12:48:53 -05:00
3f9cd87c4d fix: added missing i18n en entries 2025-09-02 22:52:25 +02:00
083fae020a fix: imports 2025-09-02 22:47:24 +02:00
38da61c6c2 Merge Tag 'v0.2.29' 2025-09-02 22:46:45 +02:00
0689a5827f fix: replace return null with empty tags 2025-09-02 22:35:36 +02:00
ab0c5e3e99 Merge Tag 'v0.2.28' 2025-09-02 22:31:39 +02:00
3cc8543067 Merge Tag 'v0.2.27' 2025-09-02 22:29:16 +02:00
06aa27af63 Merge Tag 'v0.2.26' 2025-09-02 22:28:48 +02:00
c5bacb719c Merge Tag 'v0.2.25' 2025-09-02 22:28:15 +02:00
3ae2a3cb79 Merge Tag 'v0.2.24' 2025-09-02 22:27:46 +02:00
Doh
3e6b4b75ec feat: freehand drawing capability and card layout improvements and v0.2.29 release (#180) 2025-08-21 23:04:50 -04:00
Doh
31700c9a45 feat: server permission checking and v0.2.28 release (#178) 2025-08-20 17:27:50 -04:00
Doh
e05b982307 feat: mobile navigation text centering and v0.2.27 release (#177) 2025-08-20 17:14:32 -04:00
Doh
ee2821b2bf feat: optimize Docker build performance and add version validation (#176) 2025-08-20 10:04:23 -04:00
Doh
8fb7cd1810 Add project documentation and translation guide (#174) 2025-08-20 09:26:29 -04:00
1b17d6b50a fix: add TS types 2025-08-17 19:49:11 +02:00
Doh
a6f5bf1baa Update README.md
updated tasktrove banner
2025-08-17 09:40:10 -04:00
Doh
8dda60b9b1 Add Korean translation (#169) 2025-08-14 14:56:09 -04:00
8269f3adad fix: refactored code & removed unused parts 2025-08-09 18:57:04 +02:00
Doh
ad2504dc7f Update README.md 2025-07-11 22:12:00 -04:00
4cadf4cea7 fix: added upstream differences to README 2025-07-10 00:45:45 +02:00
06e802f2f5 fix: resolved mobile display errors 2025-06-17 23:45:55 +02:00
6c0b196de2 fix: only display 'show all' if there are more than 4 entries 2025-06-17 23:20:39 +02:00
0f073760ee fix: unify NavDisplays 2025-06-17 22:30:18 +02:00
55c2e3577d Merge Tag v0.2.23 2025-06-13 21:59:16 +02:00
043201217f Merge Tag v0.2.22 2025-06-13 21:57:27 +02:00
4e11f17729 Merge Tag v0.2.21 2025-06-13 21:52:24 +02:00
faa6f4cb76 Merge Tag v0.2.20 2025-06-13 21:43:44 +02:00
84d6321153 Merge Tag v0.2.19 2025-06-13 21:26:19 +02:00
1af98fb233 Merge Tag v0.2.18 2025-06-13 21:22:11 +02:00
Doh
8d2bfaf62c update PWA icon, fix floating number balance (#159) 2025-06-04 18:40:48 -04:00
9046d40a7a Merge Tag v0.2.17.0 2025-06-04 16:08:11 +02:00
be0a5c48b3 Merge Tag v0.2.16.0 2025-06-04 16:02:37 +02:00
Doh
98b5d5eebb Added logo to README 2025-05-31 10:53:04 -04:00
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
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
eff14f3772 Merge Tag v0.2.12 2025-05-19 12:56:21 +02: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
86 changed files with 3417 additions and 1474 deletions

View File

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

View File

@@ -1,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) || '' }}
dohsimpson/habittrove:demo
dohsimpson/habittrove:latest
deploy-demo:
runs-on: ubuntu-latest
needs: build-and-push
# demo tracks the demo 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,28 +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 lint
run: bun run lint
- name: Run unit tests
run: bun test

3
.gitignore vendored
View File

@@ -46,3 +46,6 @@ next-env.d.ts
Budfile
certificates
/backups/*
CLAUDE.md
CHANGELOG.md.tmp

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

@@ -1 +1,11 @@
#!/bin/sh
# Check that package.json version exists in CHANGELOG.md
VERSION=$(node -p "require('./package.json').version")
if ! grep -q "## Version $VERSION" CHANGELOG.md; then
echo "❌ Error: Version $VERSION from package.json not found in CHANGELOG.md"
echo "Please add an entry for version $VERSION in CHANGELOG.md"
exit 1
fi
echo "✅ Version $VERSION found in CHANGELOG.md"
npm run typecheck && npm run 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,69 @@
# Changelog
## Version 0.2.30
### Fixed
* Security: Updated Next.js from 15.2.3 to 15.5.7 to address CVE-2025-55182 (https://github.com/vercel/next.js/security/advisories/GHSA-9qr9-h5gf-34mp)
## Version 0.2.29
### Added
* ✏️ Freehand drawing capability for habits and wishlist items
### Fixed
* Wishlist and Habit card layout - time and rewards sections now stay at bottom regardless of description length
* Wishlist card user avatars now appear on same row as title for consistency with habit cards
## Version 0.2.28
### Added
* Server permission checking system to validate data directory access on startup
* Permission error display with troubleshooting guidance and recheck functionality
## Version 0.2.27
### Fixed
* Mobile navigation text centering and sizing for multi-word translations
## Version 0.2.26
### Improved
* Docker build performance optimization with cache mounts
## Version 0.2.25
### Added
* 🌍 Added Catalan language support (Català)
### Fixed
* Translation files consistency: Added missing UserForm keys to English and Korean translations
## Version 0.2.24
### Added
* 🌍 Added Korean language support (한국어)
## Version 0.2.23
### Fixed
* floating number coin balance (#155)
* disable freshness check if browser does not support web crypto (#161)
### Improved
* use transparent background PWA icon with correct text (#103)
* display icon in logo
## Version 0.2.22
### Added

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,18 +1,17 @@
# syntax=docker.io/docker/dockerfile:1
FROM node:18-alpine AS base
# Use build platform for base images to avoid emulation
FROM --platform=$BUILDPLATFORM node:22-alpine AS base
# Install dependencies only when needed
FROM 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
WORKDIR /app
# Install dependencies based on the preferred package manager
# Use cache mounts for npm cache
COPY package.json yarn.lock* package-lock.json* pnpm-lock.yaml* .npmrc* ./
RUN \
RUN --mount=type=cache,target=/root/.npm \
if [ -f yarn.lock ]; then yarn --frozen-lockfile; \
# use --force flag until all deps supports react19
elif [ -f package-lock.json ]; then npm ci --force; \
elif [ -f pnpm-lock.yaml ]; then corepack enable pnpm && pnpm i --frozen-lockfile; \
else echo "Lockfile not found." && exit 1; \
@@ -26,32 +25,28 @@ COPY . .
ENV NEXT_TELEMETRY_DISABLED=1
RUN \
# Use cache mount for Next.js cache
RUN --mount=type=cache,target=/app/.next/cache \
if [ -f yarn.lock ]; then yarn run build; \
elif [ -f package-lock.json ]; then npm run build; \
elif [ -f pnpm-lock.yaml ]; then corepack enable pnpm && pnpm run build; \
else echo "Lockfile not found." && exit 1; \
fi
# Production image, copy all the files and run next
FROM base AS runner
# Production image - use target platform
FROM node:22-alpine AS runner
WORKDIR /app
ENV NODE_ENV=production
ENV NEXT_TELEMETRY_DISABLED=1
RUN addgroup --system --gid 1001 nodejs
RUN adduser --system --uid 1001 nextjs
# Create data and backups directories and set permissions
RUN mkdir -p /app/data /app/backups \
&& chown nextjs:nodejs /app/data /app/backups
RUN addgroup --system --gid 1001 nodejs && \
adduser --system --uid 1001 nextjs && \
mkdir -p /app/data /app/backups && \
chown nextjs:nodejs /app/data /app/backups
COPY --from=builder /app/public ./public
COPY --from=builder /app/CHANGELOG.md ./
# Automatically leverage output traces to reduce image size
# https://nextjs.org/docs/advanced-features/output-file-tracing
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static

View File

@@ -1,14 +1,22 @@
# HabitTrove
![cover](https://github.com/user-attachments/assets/b63e98b4-64ae-49c7-ae7e-21ef76c04a5a)
# <img align="left" width="50" height="50" src="https://github.com/user-attachments/assets/99dcf223-3680-4b3a-8050-d9788f051682" /> HabitTrove
HabitTrove is a gamified habit tracking application that helps you build and maintain positive habits by rewarding you with coins, which you can use to exchange for rewards.
> **⚠️ Important:** HabitTrove is currently in beta. Please regularly backup your `data/` directory to prevent any potential data loss.
## Differences to Upstream
## Try the Demo
I generally try to keep the `main` branch up to date with upstream features, merging tagged versions and mapping them to `<upstream-version>.0`.
Want to try HabitTrove before installing? Visit the public [demo instance](https://demo.habittrove.com) 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
@@ -16,8 +24,9 @@ Want to try HabitTrove before installing? Visit the public [demo instance](https
- 🏆 Earn coins for completing habits
- 💰 Create a wishlist of rewards to redeem with earned coins
- 📊 View your habit completion streaks and statistics
- ✏️ Add freehand drawings to habits and wishlist items for visual reminders
- 📅 Calendar heatmap to visualize your progress (WIP)
- 🌍 Multi-language support (English, Español, Deutsch, Français, Русский, 简体中文, 日本語)
- 🌍 Multi-language support (English, Español, Català, Deutsch, Français, Русский, 简体中文, 한국어, 日本語)
- 🌙 Dark mode support
- 📲 Progressive Web App (PWA) support
- 💾 Automatic daily backups with rotation
@@ -25,11 +34,8 @@ Want to try HabitTrove before installing? Visit the public [demo instance](https
## Usage
1. **Creating Habits**: Click the "Add Habit" button to create a new habit. Set a name, description, and coin reward.
2. **Tracking Habits**: Mark habits as complete on your dashboard. Each completion earns you the specified coins.
3. **Wishlist**: Add rewards to your wishlist that you can redeem with earned coins.
4. **Statistics**: View your progress through the heatmap and streak counters.
## Docker Deployment
@@ -66,7 +72,7 @@ docker run -d \
-v ./data:/app/data \
-v ./backups:/app/backups \ # Add this line to map the backups directory
-e AUTH_SECRET=$AUTH_SECRET \
dohsimpson/habittrove
ghcr.io/manindark/habittrove
```
Available image tags:
@@ -113,7 +119,7 @@ To contribute to HabitTrove, you'll need to set up a development environment. He
1. Clone the repository and navigate to the project directory:
```bash
git clone https://github.com/dohsimpson/habittrove.git
git clone https://github.com/ManInDark/HabitTrove.git
cd habittrove
```
@@ -166,7 +172,7 @@ Run these commands regularly during development to catch issues early.
## Contributing
We welcome feature requests and bug reports! Please [open an issue](https://github.com/dohsimpson/habittrove/issues/new). We do not accept pull request at the moment.
We welcome feature requests and bug reports! Please [open an issue](https://github.com/ManInDark/habittrove/issues/new).
## License

View File

@@ -1,59 +1,38 @@
'use server'
import fs from 'fs/promises'
import path from 'path'
import { getCurrentUser, saltAndHashPassword, verifyPassword } from "@/lib/server-helpers";
import {
HabitsData,
CoinsData,
CoinTransaction,
TransactionType,
WishlistItemType,
WishlistData,
Settings,
DataType,
DATA_DEFAULTS,
getDefaultSettings,
UserData,
getDefaultUsersData,
User,
getDefaultWishlistData,
getDefaultHabitsData,
DataType,
getDefaultCoinsData,
getDefaultHabitsData,
getDefaultSettings,
getDefaultUsersData,
getDefaultWishlistData,
HabitsData,
Permission,
ServerSettings
} from '@/lib/types'
import { d2t, deepMerge, getNow, checkPermission, uuid } from '@/lib/utils';
import { verifyPassword } from "@/lib/server-helpers";
import { saltAndHashPassword } from "@/lib/server-helpers";
ServerSettings,
Settings,
TransactionType,
User,
UserData,
WishlistData,
WishlistItemType
} from '@/lib/types';
import { d2t, generateCryptoHash, getNow, prepareDataForHashing } from '@/lib/utils';
import { signInSchema } from '@/lib/zod';
import { auth } from '@/auth';
import fs from 'fs/promises';
import _ from 'lodash';
import { getCurrentUser, getCurrentUserId } from '@/lib/server-helpers'
import stableStringify from 'json-stable-stringify';
import { prepareDataForHashing, generateCryptoHash } from '@/lib/utils';
import path from 'path';
import { PermissionError } from '@/lib/exceptions'
type ResourceType = 'habit' | 'wishlist' | 'coins'
type ActionType = 'write' | 'interact'
async function verifyPermission(
resource: ResourceType,
action: ActionType
): Promise<void> {
// const user = await getCurrentUser()
// if (!user) throw new PermissionError('User not authenticated')
// if (user.isAdmin) return // Admins bypass permission checks
// if (!checkPermission(user.permissions, resource, action)) {
// throw new PermissionError(`User does not have ${action} permission for ${resource}`)
// }
return
}
function getDefaultData<T>(type: DataType): T {
return DATA_DEFAULTS[type]() as T;
}
@@ -97,7 +76,7 @@ async function loadData<T>(type: DataType): Promise<T> {
await fs.access(filePath)
} catch {
// File doesn't exist, create it with default data
const initialData = getDefaultData(type)
const initialData = getDefaultData<T>(type)
await fs.writeFile(filePath, JSON.stringify(initialData, null, 2))
return initialData as T
}
@@ -130,13 +109,15 @@ 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> {
async function calculateServerFreshnessToken(): Promise<string | null> {
try {
const settings = await loadSettings();
const habits = await loadHabitsData();
const coins = await loadCoinsData();
const wishlist = await loadWishlistData();
const users = await loadUsersData();
const [settings, habits, coins, wishlist, users] = await Promise.all([
loadSettings(),
loadHabitsData(),
loadCoinsData(),
loadWishlistData(),
loadUsersData()
]);
const dataString = prepareDataForHashing(
settings,
@@ -145,8 +126,7 @@ async function calculateServerFreshnessToken(): Promise<string> {
wishlist,
users
);
const serverToken = await generateCryptoHash(dataString);
return serverToken;
return generateCryptoHash(dataString);
} catch (error) {
console.error("Error calculating server freshness token:", error);
throw error;
@@ -156,7 +136,7 @@ async function calculateServerFreshnessToken(): Promise<string> {
// Wishlist specific functions
export async function loadWishlistData(): Promise<WishlistData> {
const user = await getCurrentUser()
if (!user) return getDefaultWishlistData()
if (!user) return getDefaultWishlistData<WishlistData>()
const data = await loadData<WishlistData>('wishlist')
return {
@@ -171,7 +151,6 @@ export async function loadWishlistItems(): Promise<WishlistItemType[]> {
}
export async function saveWishlistItems(data: WishlistData): Promise<void> {
await verifyPermission('wishlist', 'write')
const user = await getCurrentUser()
data.items = data.items.map(wishlist => ({
@@ -194,17 +173,14 @@ export async function saveWishlistItems(data: WishlistData): Promise<void> {
// Habits specific functions
export async function loadHabitsData(): Promise<HabitsData> {
const user = await getCurrentUser()
if (!user) return getDefaultHabitsData()
if (!user) return getDefaultHabitsData<HabitsData>()
const data = await loadData<HabitsData>('habits')
return {
...data,
habits: data.habits.filter(x => user.isAdmin || x.userIds?.includes(user.id))
}
}
export async function saveHabitsData(data: HabitsData): Promise<void> {
await verifyPermission('habit', 'write')
const user = await getCurrentUser()
// Create clone of input data
const newData = _.cloneDeep(data)
@@ -216,7 +192,7 @@ export async function saveHabitsData(data: HabitsData): Promise<void> {
}))
if (!user?.isAdmin) {
const existingData = await loadData<HabitsData>('habits')
const existingData = await loadHabitsData();
const existingHabits = existingData.habits.filter(x => user?.id && !x.userIds?.includes(user?.id))
newData.habits = [
...existingHabits,
@@ -232,14 +208,14 @@ export async function saveHabitsData(data: HabitsData): Promise<void> {
export async function loadCoinsData(): Promise<CoinsData> {
try {
const user = await getCurrentUser()
if (!user) return getDefaultCoinsData()
if (!user) return getDefaultCoinsData<CoinsData>()
const data = await loadData<CoinsData>('coins')
return {
...data,
transactions: user.isAdmin ? data.transactions : data.transactions.filter(x => x.userId === user.id)
}
} catch {
return getDefaultCoinsData()
return getDefaultCoinsData<CoinsData>()
}
}
@@ -279,11 +255,10 @@ export async function addCoins({
note?: string
userId?: string
}): Promise<CoinsData> {
await verifyPermission('coins', type === 'MANUAL_ADJUSTMENT' ? 'write' : 'interact')
const currentUser = await getCurrentUser()
const data = await loadCoinsData()
const newTransaction: CoinTransaction = {
id: uuid(),
id: crypto.randomUUID(),
amount,
type,
description,
@@ -303,7 +278,7 @@ export async function addCoins({
}
export async function loadSettings(): Promise<Settings> {
const defaultSettings = getDefaultSettings()
const defaultSettings = getDefaultSettings<Settings>()
try {
const user = await getCurrentUser()
@@ -334,11 +309,10 @@ export async function removeCoins({
note?: string
userId?: string
}): Promise<CoinsData> {
await verifyPermission('coins', type === 'MANUAL_ADJUSTMENT' ? 'write' : 'interact')
const currentUser = await getCurrentUser()
const data = await loadCoinsData()
const newTransaction: CoinTransaction = {
id: uuid(),
id: crypto.randomUUID(),
amount: -amount,
type,
description,
@@ -396,7 +370,7 @@ export async function loadUsersData(): Promise<UserData> {
try {
return await loadData<UserData>('auth')
} catch {
return getDefaultUsersData()
return getDefaultUsersData<UserData>()
}
}
@@ -440,7 +414,7 @@ export async function createUser(formData: FormData): Promise<User> {
const newUser: User = {
id: uuid(),
id: crypto.randomUUID(),
username,
password: hashedPassword,
permissions,

View File

@@ -1,14 +1,8 @@
import Layout from '@/components/Layout'
import HabitCalendar from '@/components/HabitCalendar'
import { ViewToggle } from '@/components/ViewToggle'
import CompletionCountBadge from '@/components/CompletionCountBadge'
export default function CalendarPage() {
return (
<div className="flex flex-col gap-4">
<div className="flex justify-end">
{/* <ViewToggle /> */}
</div>
<div className="flex flex-col">
<HabitCalendar />
</div>
)

View File

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

View File

@@ -1,6 +1,5 @@
'use client'
import { useHabits } from "@/hooks/useHabits";
import { habitsAtom, settingsAtom } from "@/lib/atoms";
import { Habit } from "@/lib/types";
import { useAtom } from "jotai";

View File

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

View File

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

View File

@@ -1,33 +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 { JotaiHydrate } from '@/components/jotai-hydrate'
import { loadSettings, loadHabitsData, loadCoinsData, loadWishlistData, loadUsersData, loadServerSettings } from './actions/data'
import { JotaiProvider } from '@/components/jotai-providers'
import Layout from '@/components/Layout'
import { Toaster } from '@/components/ui/toaster'
import { ThemeProvider } from "@/components/theme-provider"
import { SessionProvider } from 'next-auth/react'
import { NextIntlClientProvider } from 'next-intl';
import { getLocale, getMessages } from 'next-intl/server';
import { Suspense } from 'react'
import LoadingSpinner from '@/components/LoadingSpinner'
// Inter (clean, modern, excellent readability)
// const inter = Inter({
// subsets: ['latin'],
// weight: ['400', '500', '600', '700']
// })
import { ThemeProvider } from "@/components/theme-provider"
import { Toaster } from '@/components/ui/toaster'
import { SessionProvider } from 'next-auth/react'
import { NextIntlClientProvider } from 'next-intl'
import { getLocale, getMessages } from 'next-intl/server'
import { DM_Sans } from 'next/font/google'
import { Suspense } from 'react'
import { loadCoinsData, loadHabitsData, loadServerSettings, loadSettings, loadUsersData, loadWishlistData } from './actions/data'
import './globals.css'
// Clean and contemporary
const dmSans = DM_Sans({
const activeFont = DM_Sans({
subsets: ['latin'],
weight: ['400', '500', '600', '700']
})
const activeFont = dmSans
export const metadata = {
title: 'HabitTrove',
description: 'Track your habits and get rewarded',

View File

@@ -1,24 +1,25 @@
'use client'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Switch } from '@/components/ui/switch';
import { DynamicTimeNoSSR } from '@/components/DynamicTimeNoSSR';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Label } from '@/components/ui/label';
import { Switch } from '@/components/ui/switch';
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { DynamicTimeNoSSR } from '@/components/DynamicTimeNoSSR';
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 { settingsAtom, serverSettingsAtom } from '@/lib/atoms';
import { Settings, WeekDay } from '@/lib/types'
import { saveSettings, uploadAvatar } from '../actions/data'
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'
import { Button } from '@/components/ui/button';
import { User, Info } from 'lucide-react'; // Import Info icon
import { toast } from '@/hooks/use-toast'
import { saveSettings } from '../actions/data';
import { useSession } from 'next-auth/react'; // signOut removed
import { useRouter } from 'next/navigation';
// AlertDialog components and useState removed
@@ -40,7 +41,7 @@ export default function SettingsPage() {
// handleDeleteAccount function removed
if (!settings) return null
if (!settings) return <></>
return (
<>
@@ -229,10 +230,12 @@ export default function SettingsPage() {
{/* Add more languages as needed */}
<option value="en">English</option>
<option value="es">Español</option>
<option value="ca">Català</option>
<option value="de">Deutsch</option>
<option value="fr">Français</option>
<option value="ru">Русский</option>
<option value="zh"></option>
<option value="ko"></option>
<option value="ja"></option>
</select>
</div>

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

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

View File

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

View File

@@ -57,11 +57,13 @@ export default function AboutModal({ onClose }: AboutModalProps) {
>
@dohsimpson
</a>
<br/>
Fork by <a href="https://github.com/ManInDark" target="_blank" rel="noopener noreferrer" className="font-medium hover:underline">@ManInDark</a>
</div>
<div className="flex justify-center">
<a
href="https://github.com/dohsimpson/habittrove"
href="https://github.com/ManInDark/HabitTrove"
target="_blank"
rel="noopener noreferrer"
>

View File

@@ -1,24 +1,26 @@
'use client'
import { useState } from 'react'
import { RRule, RRuleSet, rrulestr } from 'rrule'
import { useAtom } from 'jotai'
import { useTranslations } from 'next-intl'
import { settingsAtom, browserSettingsAtom, usersAtom, currentUserAtom } from '@/lib/atoms'
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '@/components/ui/dialog'
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'
import { Button } from '@/components/ui/button'
import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar'
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Textarea } from '@/components/ui/textarea'
import { Zap } from 'lucide-react'
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
import { Habit, SafeUser } from '@/lib/types'
import EmojiPickerButton from './EmojiPickerButton'
import ModalOverlay from './ModalOverlay' // Import the new component
import { convertHumanReadableFrequencyToMachineReadable, convertMachineReadableFrequencyToHumanReadable, d2s, d2t, serializeRRule } from '@/lib/utils'
import { INITIAL_DUE, INITIAL_RECURRENCE_RULE, QUICK_DATES, RECURRENCE_RULE_MAP, MAX_COIN_LIMIT } from '@/lib/constants'
import { Textarea } from '@/components/ui/textarea'
import { currentUserAtom, settingsAtom, usersAtom } from '@/lib/atoms'
import { INITIAL_DUE, INITIAL_RECURRENCE_RULE, MAX_COIN_LIMIT, QUICK_DATES } from '@/lib/constants'
import { Habit } from '@/lib/types'
import { convertHumanReadableFrequencyToMachineReadable, convertMachineReadableFrequencyToHumanReadable, d2t, serializeRRule } from '@/lib/utils'
import { useAtom } from 'jotai'
import { Brush, Zap } from 'lucide-react'
import { DateTime } from 'luxon'
import { useTranslations } from 'next-intl'
import { useState } from 'react'
import { RRule } from 'rrule'
import DrawingDisplay from './DrawingDisplay'
import DrawingModal from './DrawingModal'
import EmojiPickerButton from './EmojiPickerButton'
import ModalOverlay from './ModalOverlay'; // Import the new component
interface AddEditHabitModalProps {
@@ -45,10 +47,11 @@ export default function AddEditHabitModal({ onClose, onSave, habit, isTask }: Ad
const [ruleText, setRuleText] = useState<string>(initialRuleText)
const [currentUser] = useAtom(currentUserAtom)
const [isQuickDatesOpen, setIsQuickDatesOpen] = useState(false)
const [errorMessage, setErrorMessage] = useState<string | null>(null); // State for validation message
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) {
@@ -83,14 +86,21 @@ export default function AddEditHabitModal({ onClose, onSave, habit, isTask }: Ad
targetCompletions: targetCompletions > 1 ? targetCompletions : undefined,
completions: habit?.completions || [],
frequency: getFrequencyUpdate(),
userIds: selectedUserIds.length > 0 ? selectedUserIds.concat(currentUser?.id || []) : (currentUser && [currentUser.id])
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 (
<>
<ModalOverlay />
<Dialog open={true} onOpenChange={onClose} modal={false}>
<Dialog open={true} onOpenChange={(open) => {
if (!open && !isDrawingModalOpen) {
onClose()
}
}} modal={false}>
<DialogContent> {/* DialogContent from shadcn/ui is typically z-50, ModalOverlay is z-40 */}
<DialogHeader>
<DialogTitle>
@@ -184,25 +194,9 @@ export default function AddEditHabitModal({ onClose, onSave, habit, isTask }: Ad
</div>
{/* rrule input (habit) */}
<div className="col-start-2 col-span-3 text-sm">
{(() => {
let displayText = '';
const { result, message } = convertHumanReadableFrequencyToMachineReadable({ text: ruleText, timezone: settings.system.timezone, isRecurring: isRecurRule });
if (message !== errorMessage) { // Only update if it changed to avoid re-renders
setErrorMessage(message);
}
displayText = convertMachineReadableFrequencyToHumanReadable({ frequency: result, isRecurRule, timezone: settings.system.timezone })
return (
<>
<span className={errorMessage ? 'text-destructive' : 'text-muted-foreground'}>
{displayText}
</span>
{errorMessage && (
<p className="text-destructive text-xs mt-1">{errorMessage}</p>
)}
</>
);
})()}
<span className={errorMessage ? 'text-destructive' : 'text-muted-foreground'}>
{errorMessage ? errorMessage : convertMachineReadableFrequencyToHumanReadable({ frequency: result, isRecurRule, timezone: settings.system.timezone })}
</span>
</div>
</div>
<div className="grid grid-cols-4 items-center gap-4">
@@ -290,6 +284,38 @@ export default function AddEditHabitModal({ onClose, onSave, habit, isTask }: Ad
</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">
@@ -333,7 +359,13 @@ export default function AddEditHabitModal({ onClose, onSave, habit, isTask }: Ad
</form>
</DialogContent>
</Dialog>
<DrawingModal
isOpen={isDrawingModalOpen}
onClose={() => setIsDrawingModalOpen(false)}
onSave={(drawingData) => setDrawing(drawingData)}
initialDrawing={drawing}
title={name}
/>
</>
)
}

View File

@@ -1,20 +1,22 @@
import { useState, useEffect } from 'react'
import { useAtom } from 'jotai'
import { useTranslations } from 'next-intl'
import { usersAtom, currentUserAtom } from '@/lib/atoms'
import { Avatar, AvatarFallback, AvatarImage } from './ui/avatar'
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '@/components/ui/dialog'
import { Button } from '@/components/ui/button'
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Textarea } from '@/components/ui/textarea'
import { currentUserAtom, usersAtom } from '@/lib/atoms'
import { MAX_COIN_LIMIT } from '@/lib/constants'
import { WishlistItemType } from '@/lib/types'
import { useAtom } from 'jotai'
import { Brush } from 'lucide-react'
import { useTranslations } from 'next-intl'
import { useEffect, useState } from 'react'
import DrawingDisplay from './DrawingDisplay'
import DrawingModal from './DrawingModal'
import EmojiPickerButton from './EmojiPickerButton'
import ModalOverlay from './ModalOverlay'
import { MAX_COIN_LIMIT } from '@/lib/constants'
import { Avatar, AvatarFallback, AvatarImage } from './ui/avatar'
interface AddEditWishlistItemModalProps {
isOpen: boolean
setIsOpen: (isOpen: boolean) => void
editingItem: WishlistItemType | null
setEditingItem: (item: WishlistItemType | null) => void
@@ -23,7 +25,6 @@ interface AddEditWishlistItemModalProps {
}
export default function AddEditWishlistItemModal({
isOpen,
setIsOpen,
editingItem,
setEditingItem,
@@ -40,6 +41,8 @@ export default function AddEditWishlistItemModal({
const [selectedUserIds, setSelectedUserIds] = useState<string[]>((editingItem?.userIds || []).filter(id => id !== currentUser?.id))
const [errors, setErrors] = useState<{ [key: string]: string }>({})
const [usersData] = useAtom(usersAtom)
const [drawing, setDrawing] = useState<string>(editingItem?.drawing || '')
const [isDrawingModalOpen, setIsDrawingModalOpen] = useState(false)
useEffect(() => {
if (editingItem) {
@@ -48,12 +51,14 @@ export default function AddEditWishlistItemModal({
setCoinCost(editingItem.coinCost)
setTargetCompletions(editingItem.targetCompletions)
setLink(editingItem.link || '')
setDrawing(editingItem.drawing || '')
} else {
setName('')
setDescription('')
setCoinCost(1)
setTargetCompletions(undefined)
setLink('')
setDrawing('')
}
setErrors({})
}, [editingItem])
@@ -102,7 +107,8 @@ export default function AddEditWishlistItemModal({
coinCost,
targetCompletions: targetCompletions || undefined,
link: link.trim() || undefined,
userIds: selectedUserIds.length > 0 ? selectedUserIds.concat(currentUser?.id || []) : (currentUser && [currentUser.id])
userIds: selectedUserIds.length > 0 ? selectedUserIds.concat(currentUser?.id || []) : (currentUser && [currentUser.id]),
drawing: drawing && drawing !== '[]' ? drawing : undefined
}
if (editingItem) {
@@ -118,7 +124,11 @@ export default function AddEditWishlistItemModal({
return (
<>
<ModalOverlay />
<Dialog open={true} onOpenChange={handleClose} modal={false}>
<Dialog open={true} onOpenChange={(open) => {
if (!open && !isDrawingModalOpen) {
handleClose()
}
}} modal={false}>
<DialogContent> {/* DialogContent from shadcn/ui is typically z-50, ModalOverlay is z-40 */}
<DialogHeader>
<DialogTitle>{editingItem ? t('editTitle') : t('addTitle')}</DialogTitle>
@@ -269,6 +279,38 @@ export default function AddEditWishlistItemModal({
)}
</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">
@@ -308,6 +350,13 @@ export default function AddEditWishlistItemModal({
</form>
</DialogContent>
</Dialog>
<DrawingModal
isOpen={isDrawingModalOpen}
onClose={() => setIsDrawingModalOpen(false)}
onSave={(drawingData) => setDrawing(drawingData)}
initialDrawing={drawing}
title={name}
/>
</>
)
}

View File

@@ -1,15 +1,15 @@
'use client'
import { ReactNode, useEffect, useCallback, useState, Suspense } from 'react'
import { useAtom, useSetAtom, useAtomValue } from 'jotai'
import { aboutOpenAtom, pomodoroAtom, userSelectAtom, currentUserIdAtom, clientFreshnessTokenAtom } from '@/lib/atoms'
import PomodoroTimer from './PomodoroTimer'
import UserSelectModal from './UserSelectModal'
import { useSession } from 'next-auth/react'
import AboutModal from './AboutModal'
import LoadingSpinner from './LoadingSpinner'
import { checkDataFreshness as checkServerDataFreshness } from '@/app/actions/data'
import RefreshBanner from './RefreshBanner'
import { checkDataFreshness as checkServerDataFreshness } from '@/app/actions/data';
import { aboutOpenAtom, clientFreshnessTokenAtom, currentUserIdAtom, pomodoroAtom, userSelectAtom } from '@/lib/atoms';
import { useAtom, useAtomValue, useSetAtom } from 'jotai';
import { useSession } from 'next-auth/react';
import { ReactNode, Suspense, useCallback, useEffect, useState } from 'react';
import AboutModal from './AboutModal';
import LoadingSpinner from './LoadingSpinner';
import PomodoroTimer from './PomodoroTimer';
import RefreshBanner from './RefreshBanner';
import UserSelectModal from './UserSelectModal';
function ClientWrapperContent({ children }: { children: ReactNode }) {
const [pomo] = useAtom(pomodoroAtom)

View File

@@ -1,23 +1,24 @@
'use client'
import { useState, useEffect, useRef } from 'react' // Import useEffect, useRef
import { useSearchParams } from 'next/navigation' // Import useSearchParams
import { t2d, d2s, getNow, isSameDate } from '@/lib/utils'
import { Button } from '@/components/ui/button'
import { FormattedNumber } from '@/components/FormattedNumber'
import { History, Pencil } from 'lucide-react'
import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar'
import EmptyState from './EmptyState'
import { Input } from '@/components/ui/input'
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'
import { Button } from '@/components/ui/button'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { settingsAtom, usersAtom, currentUserAtom } from '@/lib/atoms'
import Link from 'next/link'
import { useAtom } from 'jotai'
import { useTranslations } from 'next-intl'
import { Input } from '@/components/ui/input'
import { useCoins } from '@/hooks/useCoins'
import { currentUserAtom, settingsAtom, usersAtom } from '@/lib/atoms'
import { MAX_COIN_LIMIT } from '@/lib/constants'
import { TransactionNoteEditor } from './TransactionNoteEditor'
import { TransactionType } from '@/lib/types'
import { d2s, t2d } from '@/lib/utils'
import { useAtom } from 'jotai'
import { History } from 'lucide-react'
import { useTranslations } from 'next-intl'
import Link from 'next/link'
import { useSearchParams } from 'next/navigation'; // Import useSearchParams
import { useEffect, useRef, useState } from 'react'; // Import useEffect, useRef
import EmptyState from './EmptyState'
import { TransactionNoteEditor } from './TransactionNoteEditor'
export default function CoinsManager() {
const t = useTranslations('CoinsManager')
@@ -46,6 +47,7 @@ export default function CoinsManager() {
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(() => {
@@ -56,7 +58,7 @@ export default function CoinsManager() {
}
}
// Only run when userIdFromQuery or currentUser changes, avoid re-running on selectedUser change within this effect
}, [userIdFromQuery, currentUser, usersData.users]);
}, [userIdFromQuery, currentUser, usersData.users, selectedUser]);
// Effect to scroll to highlighted transaction
useEffect(() => {
@@ -106,7 +108,7 @@ export default function CoinsManager() {
<h1 className="text-xl xs:text-3xl font-bold mr-6">{t('title')}</h1>
{currentUser?.isAdmin && (
<select
className="w-[110px] xs:w-[200px] rounded-md border border-input bg-background px-3 py-2"
className="border rounded p-2"
value={selectedUser}
onChange={(e) => setSelectedUser(e.target.value)}
>
@@ -274,9 +276,7 @@ export default function CoinsManager() {
setCurrentPage(1) // Reset to first page when changing page size
}}
>
<option value={50}>50</option>
<option value={100}>100</option>
<option value={500}>500</option>
{PAGE_ENTRY_COUNTS.map(n => <option key={n} value={n}>{n}</option>)}
</select>
<span className="text-sm text-muted-foreground">{t('entriesSuffix')}</span>
</div>
@@ -312,6 +312,7 @@ export default function CoinsManager() {
}
const isHighlighted = transaction.id === highlightId;
const transactionUser = usersData.users.find(u => u.id === transaction.userId);
return (
<div
key={transaction.id}
@@ -340,12 +341,12 @@ export default function CoinsManager() {
{transaction.userId && currentUser?.isAdmin && (
<Avatar className="h-6 w-6">
<AvatarImage
src={usersData.users.find(u => u.id === transaction.userId)?.avatarPath ?
`/api/avatars/${usersData.users.find(u => u.id === transaction.userId)?.avatarPath?.split('/').pop()}` : undefined}
alt={usersData.users.find(u => u.id === transaction.userId)?.username}
src={transactionUser?.avatarPath ?
`/api/avatars/${transactionUser?.avatarPath?.split('/').pop()}` : undefined}
alt={transactionUser?.username}
/>
<AvatarFallback>
{usersData.users.find(u => u.id === transaction.userId)?.username?.[0] || '?'}
{transactionUser?.username?.[0] || '?'}
</AvatarFallback>
</Avatar>
)}

View File

@@ -1,9 +1,7 @@
import { Badge } from "@/components/ui/badge"
import { useAtom } from 'jotai'
import { completedHabitsMapAtom, habitsAtom, habitsByDateFamily } from '@/lib/atoms'
import { completedHabitsMapAtom, habitsByDateFamily, settingsAtom } from '@/lib/atoms'
import { getTodayInTimezone } from '@/lib/utils'
// import { useHabits } from '@/hooks/useHabits' // Not used
import { settingsAtom } from '@/lib/atoms'
import { useAtom } from 'jotai'
import { useTranslations } from 'next-intl'
interface CompletionCountBadgeProps {

View File

@@ -1,36 +1,34 @@
import { Circle, Coins, ArrowRight, CircleCheck, ChevronDown, ChevronUp, Plus, Pin, AlertTriangle } from 'lucide-react' // Removed unused icons
import CompletionCountBadge from './CompletionCountBadge'
import { Badge } from '@/components/ui/badge'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import {
ContextMenu,
ContextMenuContent,
ContextMenuItem,
ContextMenuSeparator,
ContextMenuTrigger,
ContextMenuTrigger
} from "@/components/ui/context-menu"
import { cn } from '@/lib/utils'
import Link from 'next/link'
import { useState } from 'react'
import { useAtom } from 'jotai'
import { useTranslations } from 'next-intl'
import { pomodoroAtom, settingsAtom, completedHabitsMapAtom, browserSettingsAtom, BrowserSettings, hasTasksAtom } from '@/lib/atoms'
import { getTodayInTimezone, isSameDate, t2d, d2t, getNow, isHabitDue, isTaskOverdue } 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 {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip"
import { Progress } from '@/components/ui/progress'
import { Settings, WishlistItemType } from '@/lib/types'
import { Habit } from '@/lib/types'
import Linkify from './linkify'
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 { Button } from './ui/button'
import { HabitContextMenuItems } from './HabitContextMenuItems'
import Linkify from './linkify'
import { Button } from './ui/button'
import DrawingDisplay from './DrawingDisplay'
interface UpcomingItemsProps {
habits: Habit[]
@@ -169,7 +167,7 @@ const ItemSection = ({
const bTarget = b.targetCompletions || 1;
return bTarget - aTarget;
})
.slice(0, currentExpanded ? undefined : 5)
.slice(0, currentExpanded ? undefined : DESKTOP_DISPLAY_ITEM_COUNT)
.map((habit) => {
const completionsToday = habit.completions.filter(completion =>
isSameDate(t2d({ timestamp: completion, timezone: settings.system.timezone }), t2d({ timestamp: d2t({ dateTime: getNow({ timezone: settings.system.timezone }) }), timezone: settings.system.timezone }))
@@ -226,12 +224,6 @@ const ItemSection = ({
<Link
href={`/habits?highlight=${habit.id}`}
className="flex items-center gap-1 hover:text-primary transition-colors"
onClick={() => {
const newViewType = isTask ? 'tasks' : 'habits';
if (browserSettings.viewType !== newViewType) {
setBrowserSettings(prev => ({ ...prev, viewType: newViewType }));
}
}}
>
{isTask && isTaskOverdue(habit, settings.system.timezone) && !isCompleted && (
<TooltipProvider>
@@ -255,6 +247,16 @@ const ItemSection = ({
{habit.name}
</span>
</Link>
{habit.drawing && (
<div className="ml-2 pr-2">
<DrawingDisplay
drawingData={habit.drawing}
width={40}
height={26}
className="border-0"
/>
</div>
)}
</span>
</div>
</ContextMenuTrigger>
@@ -305,7 +307,7 @@ const ItemSection = ({
onClick={() => setCurrentExpanded(!currentExpanded)}
className="text-sm text-muted-foreground hover:text-primary flex items-center gap-1"
>
{currentExpanded ? (
{items.length > DESKTOP_DISPLAY_ITEM_COUNT && (currentExpanded ? (
<>
{t('showLessButton')}
<ChevronUp className="h-3 w-3" />
@@ -315,17 +317,11 @@ const ItemSection = ({
{t('showAllButton')}
<ChevronDown className="h-3 w-3" />
</>
)}
))}
</button>
<Link
href={viewLink}
className="text-sm text-muted-foreground hover:text-primary flex items-center gap-1"
onClick={() => {
const newViewType = isTask ? 'tasks' : 'habits';
if (browserSettings.viewType !== newViewType) {
setBrowserSettings(prev => ({ ...prev, viewType: newViewType }));
}
}}
>
View
<ArrowRight className="h-3 w-3" />
@@ -460,7 +456,7 @@ export default function DailyOverview({
) : (
<>
{sortedWishlistItems
.slice(0, browserSettings.expandedWishlist ? undefined : 5)
.slice(0, browserSettings.expandedWishlist ? undefined : DESKTOP_DISPLAY_ITEM_COUNT)
.map((item) => {
const isRedeemable = item.coinCost <= coinBalance
return (
@@ -473,9 +469,19 @@ export default function DailyOverview({
)}
>
<div className="flex items-center justify-between mb-2">
<span className="text-sm">
<Linkify>{item.name}</Linkify>
</span>
<div className="flex items-center gap-2">
<span className="text-sm">
<Linkify>{item.name}</Linkify>
</span>
{item.drawing && (
<DrawingDisplay
drawingData={item.drawing}
width={40}
height={26}
className="border-0"
/>
)}
</div>
<span className="text-xs flex items-center">
<Coins className={cn(
"h-3 w-3 mr-1 transition-all",
@@ -517,7 +523,7 @@ export default function DailyOverview({
onClick={() => setBrowserSettings(prev => ({ ...prev, expandedWishlist: !prev.expandedWishlist }))}
className="text-sm text-muted-foreground hover:text-primary flex items-center gap-1"
>
{browserSettings.expandedWishlist ? (
{wishlistItems.length > DESKTOP_DISPLAY_ITEM_COUNT && (browserSettings.expandedWishlist ? (
<>
{t('showLessButton')}
<ChevronUp className="h-3 w-3" />
@@ -527,7 +533,7 @@ export default function DailyOverview({
{t('showAllButton')}
<ChevronDown className="h-3 w-3" />
</>
)}
))}
</button>
<Link
href="/wishlist"

View File

@@ -1,19 +1,17 @@
'use client'
import { useCoins } from '@/hooks/useCoins'
import { habitsAtom, wishlistAtom } from '@/lib/atoms'
import { useAtom } from 'jotai'
import { wishlistAtom, habitsAtom, settingsAtom } from '@/lib/atoms'
import { useTranslations } from 'next-intl'
import CoinBalance from './CoinBalance'
import DailyOverview from './DailyOverview'
import HabitStreak from './HabitStreak'
import CoinBalance from './CoinBalance'
// import { useHabits } from '@/hooks/useHabits' // useHabits is not used
import { useCoins } from '@/hooks/useCoins'
import { useTranslations } from 'next-intl';
export default function Dashboard() {
const t = useTranslations('Dashboard');
const [habitsData] = useAtom(habitsAtom)
const habits = habitsData.habits
const [settings] = useAtom(settingsAtom)
const { balance } = useCoins()
const [wishlist] = useAtom(wishlistAtom)
const wishlistItems = wishlist.items

View File

@@ -1,42 +0,0 @@
import Link from 'next/link'
import type { ElementType } from 'react'
export interface NavItemType {
icon: ElementType;
label: string;
href: string;
position: 'main' | 'bottom';
}
interface DesktopNavDisplayProps {
navItems: NavItemType[];
className?: string;
}
export default function DesktopNavDisplay({ navItems, className }: DesktopNavDisplayProps) {
// Filter for items relevant to desktop view, typically 'main' position
const desktopNavItems = navItems.filter(item => item.position === 'main');
return (
<div className={`hidden lg:flex lg:flex-shrink-0 ${className || ''}`}>
<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">
{desktopNavItems.map((item) => (
<Link
key={item.label} // Assuming labels are unique
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>
</div>
);
}

View File

@@ -0,0 +1,237 @@
'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(() => {
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()
})
}, [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 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

@@ -1,19 +1,18 @@
'use client'
import { useState, useMemo, useCallback } from 'react'
import CompletionCountBadge from '@/components/CompletionCountBadge'
import { Calendar } from '@/components/ui/calendar'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import CompletionCountBadge from '@/components/CompletionCountBadge'
import { Button } from '@/components/ui/button'
import { Check, Circle, CircleCheck } from 'lucide-react'
import { d2s, getNow, t2d, isHabitDue, getISODate, getCompletionsForDate } from '@/lib/utils'
import { useAtom } from 'jotai'
import { useTranslations } from 'next-intl'
import { useHabits } from '@/hooks/useHabits'
import { habitsAtom, settingsAtom, completedHabitsMapAtom, hasTasksAtom } from '@/lib/atoms'
import { DateTime } from 'luxon'
import Linkify from './linkify'
import { completedHabitsMapAtom, habitsAtom, hasTasksAtom, settingsAtom } from '@/lib/atoms'
import { Habit } from '@/lib/types'
import { d2s, getCompletionsForDate, getISODate, getNow, isHabitDue } from '@/lib/utils'
import { useAtom } from 'jotai'
import { Circle, CircleCheck } from 'lucide-react'
import { DateTime } from 'luxon'
import { useTranslations } from 'next-intl'
import { useCallback, useMemo, useState } from 'react'
import Linkify from './linkify'
export default function HabitCalendar() {
const t = useTranslations('HabitCalendar')
@@ -44,7 +43,7 @@ export default function HabitCalendar() {
return (
<div>
<h1 className="text-xl xs:text-3xl font-semibold mb-6">{t('title')}</h1>
<h1 className="text-xl xs:text-3xl font-bold mb-6">{t('title')}</h1>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
<Card>
<CardHeader>

View File

@@ -1,4 +1,4 @@
import { Habit, User } from '@/lib/types';
import { Habit } from '@/lib/types';
import { useHabits } from '@/hooks/useHabits';
import { useAtom } from 'jotai';
import { pomodoroAtom, settingsAtom, currentUserAtom } from '@/lib/atoms';

View File

@@ -1,25 +1,22 @@
import { Habit, SafeUser, User, Permission } from '@/lib/types'
import { useAtom } from 'jotai'
import { settingsAtom, pomodoroAtom, browserSettingsAtom, usersAtom, currentUserAtom } from '@/lib/atoms'
import { getTodayInTimezone, isSameDate, t2d, d2t, getNow, d2s, getCompletionsForToday, isTaskOverdue, convertMachineReadableFrequencyToHumanReadable } from '@/lib/utils'
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { Coins, Edit, Check, Undo2, MoreVertical, Pin } from 'lucide-react'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu'
import { useEffect, useState } from 'react'
import { useHabits } from '@/hooks/useHabits'
import { currentUserAtom, settingsAtom, usersAtom } from '@/lib/atoms'
import { Habit, User } from '@/lib/types'
import { convertMachineReadableFrequencyToHumanReadable, getCompletionsForToday, hasPermission, isTaskOverdue } from '@/lib/utils'
import { useAtom } from 'jotai'
import { Check, Coins, Edit, MoreVertical, Pin, Undo2 } from 'lucide-react'
import { useTranslations } from 'next-intl'
import { INITIAL_RECURRENCE_RULE, RECURRENCE_RULE_MAP } from '@/lib/constants'
import { DateTime } from 'luxon'
import { Avatar, AvatarFallback, AvatarImage } from './ui/avatar'
import { hasPermission } from '@/lib/utils'
import { usePathname } from 'next/navigation'
import { useEffect, useState } from 'react'
import DrawingDisplay from './DrawingDisplay'
import { HabitContextMenuItems } from './HabitContextMenuItems'
import { Avatar, AvatarFallback, AvatarImage } from './ui/avatar'
import { Button } from './ui/button'
interface HabitItemProps {
habit: Habit
@@ -28,13 +25,13 @@ interface HabitItemProps {
}
const renderUserAvatars = (habit: Habit, currentUser: User | null, usersData: { users: User[] }) => {
if (!habit.userIds || habit.userIds.length <= 1) return null;
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 null
if (!user) return <></>;
return (
<Avatar key={user.id} className="h-6 w-6">
<AvatarImage src={user?.avatarPath && `/api/avatars/${user.avatarPath.split('/').pop()}` || ""} />
@@ -48,21 +45,18 @@ const renderUserAvatars = (habit: Habit, currentUser: User | null, usersData: {
export default function HabitItem({ habit, onEdit, onDelete }: HabitItemProps) {
const { completeHabit, undoComplete, archiveHabit, unarchiveHabit, saveHabit } = useHabits()
const { completeHabit, undoComplete } = useHabits()
const [settings] = useAtom(settingsAtom)
const [_, setPomo] = useAtom(pomodoroAtom)
const completionsToday = getCompletionsForToday({ habit, timezone: settings.system.timezone })
const target = habit.targetCompletions || 1
const isCompletedToday = completionsToday >= target
const [isHighlighted, setIsHighlighted] = useState(false)
const t = useTranslations('HabitItem');
const [usersData] = useAtom(usersAtom)
const pathname = usePathname();
const [currentUser] = useAtom(currentUserAtom)
const canWrite = hasPermission(currentUser, 'habit', 'write')
const canInteract = hasPermission(currentUser, 'habit', 'interact')
const [browserSettings] = useAtom(browserSettingsAtom)
const isTasksView = browserSettings.viewType === 'tasks'
const isRecurRule = !isTasksView
useEffect(() => {
const params = new URLSearchParams(window.location.search)
@@ -88,9 +82,9 @@ export default function HabitItem({ habit, onEdit, onDelete }: HabitItemProps) {
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' : ''}`}
>
<CardHeader className="flex-none">
<CardHeader className="flex-shrink-0">
<div className="flex justify-between items-start">
<CardTitle className={`line-clamp-1 ${habit.archived ? 'text-gray-400 dark:text-gray-500' : ''} flex items-center ${isTasksView ? 'w-full' : ''} justify-between`}>
<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" />
@@ -105,28 +99,44 @@ export default function HabitItem({ habit, onEdit, onDelete }: HabitItemProps) {
</CardTitle>
{renderUserAvatars(habit, currentUser as User, usersData)}
</div>
{habit.description && (
<CardDescription className={`whitespace-pre-line mt-2 ${habit.archived ? 'text-gray-400 dark:text-gray-500' : ''}`}>
{habit.description}
</CardDescription>
{(habit.description || habit.drawing) && (
<div className={`flex gap-4 mt-2 ${!habit.description ? 'justify-end' : ''}`}>
{habit.description && (
<CardDescription className={`whitespace-pre-line flex-1 min-w-0 break-words ${habit.archived ? 'text-gray-400 dark:text-gray-500' : ''}`}>
{habit.description}
</CardDescription>
)}
{habit.drawing && (
<div className="flex-shrink-0">
<DrawingDisplay
drawingData={habit.drawing}
width={120}
height={80}
className=""
/>
</div>
)}
</div>
)}
</CardHeader>
<CardContent className="flex-1">
<p className={`text-sm ${habit.archived ? 'text-gray-400 dark:text-gray-500' : 'text-gray-500'}`}>
{t('whenLabel', {
frequency: convertMachineReadableFrequencyToHumanReadable({
frequency: habit.frequency,
isRecurRule,
timezone: settings.system.timezone
})
})}
</p>
<div className="flex items-center mt-2">
<Coins className={`h-4 w-4 mr-1 ${habit.archived ? 'text-gray-400 dark:text-gray-500' : 'text-yellow-400'}`} />
<span className={`text-sm font-medium ${habit.archived ? 'text-gray-400 dark:text-gray-500' : ''}`}>{t('coinsPerCompletion', { count: habit.coinReward })}</span>
<CardContent className="flex-grow flex flex-col justify-end">
<div className="mt-auto">
<p className={`text-sm ${habit.archived ? 'text-gray-400 dark:text-gray-500' : 'text-gray-500'}`}>
{t('whenLabel', {
frequency: convertMachineReadableFrequencyToHumanReadable({
frequency: habit.frequency,
isRecurRule: pathname.includes("habits"),
timezone: settings.system.timezone
})
})}
</p>
<div className="flex items-center mt-2">
<Coins className={`h-4 w-4 mr-1 ${habit.archived ? 'text-gray-400 dark:text-gray-500' : 'text-yellow-400'}`} />
<span className={`text-sm font-medium ${habit.archived ? 'text-gray-400 dark:text-gray-500' : ''}`}>{t('coinsPerCompletion', { count: habit.coinReward })}</span>
</div>
</div>
</CardContent>
<CardFooter className="flex justify-between gap-2">
<CardFooter className="flex-shrink-0 flex justify-between gap-2">
<div className="flex gap-2">
<div className="relative">
<Button
@@ -212,4 +222,3 @@ export default function HabitItem({ habit, onEdit, onDelete }: HabitItemProps) {
</Card>
)
}

View File

@@ -1,32 +1,28 @@
'use client'
import { useState, useMemo, useEffect } from 'react' // Added useMemo, useEffect
import { Plus, ArrowUpNarrowWide, ArrowDownWideNarrow, Search } from 'lucide-react' // Added sort icons, Search icon
import { useAtom } from 'jotai'
import { useTranslations } from 'next-intl'
import { habitsAtom, settingsAtom, browserSettingsAtom } from '@/lib/atoms'
import EmptyState from './EmptyState'
import { Button } from '@/components/ui/button'
import HabitItem from './HabitItem'
import { Input } from '@/components/ui/input'; // Added
import { Label } from '@/components/ui/label'; // Added
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; // Added
import { useHabits } from '@/hooks/useHabits'
import { habitsAtom } from '@/lib/atoms'
import { HabitIcon, TaskIcon } from '@/lib/constants'
import { Habit } from '@/lib/types'
import { getHabitFreq } from '@/lib/utils'; // Added
import { useAtom } from 'jotai'
import { ArrowDownWideNarrow, ArrowUpNarrowWide, Plus, Search } from 'lucide-react'; // Added sort icons, Search icon
import { DateTime } from 'luxon'; // Added
import { useTranslations } from 'next-intl'
import { useEffect, useMemo, useState } from 'react'; // Added useMemo, useEffect
import AddEditHabitModal from './AddEditHabitModal'
import ConfirmDialog from './ConfirmDialog'
import { Habit } from '@/lib/types'
import { useHabits } from '@/hooks/useHabits'
import { HabitIcon, TaskIcon } from '@/lib/constants'
import { ViewToggle } from './ViewToggle'
import { Input } from '@/components/ui/input' // Added
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select' // Added
import { Label } from '@/components/ui/label' // Added
import { DateTime } from 'luxon' // Added
import { getHabitFreq } from '@/lib/utils' // Added
import EmptyState from './EmptyState'
import HabitItem from './HabitItem'
export default function HabitList() {
export default function HabitList({ isTasksView }: { isTasksView: boolean}) {
const t = useTranslations('HabitList');
const { saveHabit, deleteHabit } = useHabits()
const [habitsData] = useAtom(habitsAtom) // setHabitsData removed as it's not used
const [browserSettings] = useAtom(browserSettingsAtom)
const isTasksView = browserSettings.viewType === 'tasks'
// const [settings] = useAtom(settingsAtom); // settingsAtom is not directly used in HabitList itself.
type SortableField = 'name' | 'coinReward' | 'dueDate' | 'frequency';
type SortOrder = 'asc' | 'desc';
@@ -130,17 +126,11 @@ export default function HabitList() {
{t(isTasksView ? 'myTasks' : 'myHabits')}
</h1>
<span>
<Button className="mr-2" onClick={() => setModalConfig({ isOpen: true, isTask: true })}>
<Plus className="mr-2 h-4 w-4" /> {t('addTaskButton')}
</Button>
<Button onClick={() => setModalConfig({ isOpen: true, isTask: false })}>
<Plus className="mr-2 h-4 w-4" /> {t('addHabitButton')}
<Button onClick={() => setModalConfig({ isOpen: true, isTask: isTasksView })}>
<Plus className='mr-2 h-4 w-4' />{isTasksView ? t("addTaskButton") : t("addHabitButton")}
</Button>
</span>
</div>
<div className='py-4'>
<ViewToggle />
</div>
{/* Search and Sort Controls */}
<div className="flex flex-col sm:flex-row items-center gap-4 my-4">

View File

@@ -1,12 +1,13 @@
'use client'
import { Habit } from '@/lib/types'
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
import { d2s, getNow, t2d } from '@/lib/utils' // Removed getCompletedHabitsForDate
import { LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer } from 'recharts'
import { useAtom } from 'jotai'
import { useTranslations } from 'next-intl'
import { settingsAtom, hasTasksAtom, completedHabitsMapAtom } from '@/lib/atoms' // Added completedHabitsMapAtom
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { completedHabitsMapAtom, hasTasksAtom, settingsAtom } from '@/lib/atoms'; // Added completedHabitsMapAtom
import { Habit } from '@/lib/types';
import { d2s, getNow } from '@/lib/utils'; // Removed getCompletedHabitsForDate
import { useAtom } from 'jotai';
import { useTranslations } from 'next-intl';
import { CartesianGrid, Line, LineChart, ResponsiveContainer, Tooltip, XAxis, YAxis } from 'recharts';
interface HabitStreakProps {
habits: Habit[]

View File

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

View File

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

View File

@@ -1,60 +0,0 @@
import Link from 'next/link'
import type { ElementType } from 'react'
export interface NavItemType {
icon: ElementType;
label: string;
href: string;
position: 'main' | 'bottom';
}
interface MobileNavDisplayProps {
navItems: NavItemType[];
}
// detect iOS: https://stackoverflow.com/a/9039885
function iOS() {
return [
'iPad Simulator',
'iPhone Simulator',
'iPod Simulator',
'iPad',
'iPhone',
'iPod',
].includes(navigator.platform)
// iPad on iOS 13 detection
|| (navigator.userAgent.includes("Mac") && "ontouchend" in document)
}
export default function MobileNavDisplay({ navItems }: MobileNavDisplayProps) {
// Filter for items relevant to mobile view, typically 'main' and 'bottom' positions
const mobileNavItems = navItems.filter(item => item.position === 'main' || item.position === 'bottom');
// The original code spread main and bottom items separately, effectively concatenating them.
// If specific ordering or duplication was intended, that logic would be here.
// For now, a simple filter and map should suffice if all items are distinct.
// The original code: [...navItems(isTasksView).filter(item => item.position === 'main'), ...navItems(isTasksView).filter(item => item.position === 'bottom')]
// This implies that items could be in 'main' or 'bottom'. The current navItems only have 'main'.
// A simple combined list is fine.
const isIOS = iOS()
return (
<>
<div className={isIOS ? "pb-20" : "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 ${isIOS ? "pb-4" : ""}`}>
<div className="grid grid-cols-5 w-full">
{mobileNavItems.map((item) => (
<Link
key={item.label} // Assuming labels are unique
href={item.href}
className="flex flex-col items-center justify-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>
</>
);
}

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, displayType }: { navItems: NavItemType[], displayType: 'main' | 'mobile' }) {
const pathname = usePathname();
const { isIOS } = useHelpers()
if (displayType === 'mobile') {
return (
<>
<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,70 +1,28 @@
'use client'
import { Home, Calendar, Gift, Coins } from 'lucide-react'
import { useAtom } from 'jotai'
import { browserSettingsAtom } from '@/lib/atoms'
import { useEffect, useState, ElementType } from 'react'
import { useTranslations } from 'next-intl'
import { HabitIcon, TaskIcon } from '@/lib/constants'
import MobileNavDisplay from './MobileNavDisplay'
import DesktopNavDisplay from './DesktopNavDisplay'
type ViewPort = 'main' | 'mobile'
import { Calendar, Coins, Gift, Home } from 'lucide-react'
import { useTranslations } from 'next-intl'
import { ElementType } from 'react'
import NavDisplay from './NavDisplay'
export interface NavItemType {
icon: ElementType;
label: string;
href: string;
position: 'main' | 'bottom';
}
interface NavigationProps {
className?: string
viewPort: ViewPort
}
export default function Navigation({ className, viewPort }: NavigationProps) {
const t = useTranslations('Navigation')
const [isMobileView, setIsMobileView] = useState(false)
const [browserSettings] = useAtom(browserSettingsAtom)
const isTasksView = browserSettings.viewType === 'tasks'
export default function Navigation({ position }: { position: 'main' | 'mobile' }) {
const t = useTranslations('Navigation');
const currentNavItems: NavItemType[] = [
{ icon: Home, label: t('dashboard'), href: '/', position: 'main' },
{
icon: isTasksView ? TaskIcon : HabitIcon,
label: isTasksView ? t('tasks') : t('habits'),
href: '/habits',
position: 'main'
},
{ icon: Calendar, label: t('calendar'), href: '/calendar', position: 'main' },
{ icon: Gift, label: t('wishlist'), href: '/wishlist', position: 'main' },
{ icon: Coins, label: t('coins'), href: '/coins', position: 'main' },
{ 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' },
]
useEffect(() => {
const handleResize = () => {
setIsMobileView(window.innerWidth < 1024)
}
// Set initial value
handleResize()
// Add event listener
window.addEventListener('resize', handleResize)
// Cleanup
return () => window.removeEventListener('resize', handleResize)
}, [])
if (viewPort === 'mobile' && isMobileView) {
return <MobileNavDisplay navItems={currentNavItems} />
}
if (viewPort === 'main' && !isMobileView) {
return <DesktopNavDisplay navItems={currentNavItems} className={className} />
}
return null // Explicitly return null if no view matches
return <NavDisplay navItems={currentNavItems} displayType={position} />
}

View File

@@ -1,19 +1,18 @@
import React from 'react';
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 { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
import { CoinsData, HabitsData, WishlistData, UserData, User, CoinTransaction } from '@/lib/types';
import { t2d } from '@/lib/utils';
import Link from 'next/link';
import { DropdownMenuItem } from '@/components/ui/dropdown-menu';
import { Info } from 'lucide-react';
import { useTranslations } from 'next-intl';
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;

View File

@@ -1,14 +1,14 @@
'use client';
import { Avatar, AvatarFallback, AvatarImage } from './ui/avatar';
import { Input } from './ui/input';
import { Button } from './ui/button';
import { Label } from './ui/label';
import { User as UserIcon } from 'lucide-react';
import { Permission, User } from '@/lib/types';
import { toast } from '@/hooks/use-toast';
import { useState } from 'react';
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;

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

@@ -1,15 +1,14 @@
'use client'
import { useState, useEffect, useRef, useCallback } from 'react'
import { Button } from '@/components/ui/button'
import { Progress } from '@/components/ui/progress'
import { Play, Pause, RotateCw, Minus, X, Clock, SkipForward } from 'lucide-react'
import { 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 { settingsAtom, pomodoroAtom, habitsAtom, pomodoroTodayCompletionsAtom } from '@/lib/atoms'
// import { getCompletionsForDate, getTodayInTimezone } from '@/lib/utils' // Not used after pomodoroTodayCompletionsAtom
import { useHabits } from '@/hooks/useHabits'
import { useEffect, useRef, useState } from 'react'
interface PomoConfig {
getLabels: () => string[]
@@ -39,7 +38,6 @@ export default function PomodoroTimer() {
},
}
const [settings] = useAtom(settingsAtom)
const [pomo, setPomo] = useAtom(pomodoroAtom)
const { show, selectedHabitId, autoStart, minimized } = pomo
const [habitsData] = useAtom(habitsAtom)
@@ -110,47 +108,48 @@ export default function PomodoroTimer() {
document.removeEventListener('visibilitychange', handleVisibilityChange);
releaseWakeLock()
}
}, [state])
}, [state, t])
// Timer logic
useEffect(() => {
let interval: ReturnType<typeof setInterval> | null = null
const handleTimerEnd = async () => {
setState("stopped");
const currentTimerType = currentTimerRef.current.type;
currentTimerRef.current =
currentTimerType === "focus" ? PomoConfigs.break : PomoConfigs.focus;
setTimeLeft(currentTimerRef.current.duration);
const newLabels = currentTimerRef.current.getLabels();
setCurrentLabel(newLabels[Math.floor(Math.random() * newLabels.length)]);
if (state === 'started') {
// update habits only after focus sessions
if (selectedHabit && currentTimerType === "focus") {
await completeHabit(selectedHabit);
// The atom will automatically update with the new completions
}
};
let interval: ReturnType<typeof setInterval> | null = null;
if (state === "started") {
// Calculate the target end time based on current timeLeft
const targetEndTime = Date.now() + timeLeft * 1000
const targetEndTime = Date.now() + timeLeft * 1000;
interval = setInterval(() => {
const remaining = Math.floor((targetEndTime - Date.now()) / 1000)
const remaining = Math.floor((targetEndTime - Date.now()) / 1000);
if (remaining <= 0) {
handleTimerEnd()
handleTimerEnd();
} else {
setTimeLeft(remaining)
setTimeLeft(remaining);
}
}, 1000)
}, 1000);
}
// return handles any other states
return () => {
if (interval) clearInterval(interval)
}
}, [state])
const 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)])
// update habits only after focus sessions
if (selectedHabit && currentTimerType === 'focus') {
await completeHabit(selectedHabit)
// The atom will automatically update with the new completions
}
}
if (interval) clearInterval(interval);
};
}, [state, timeLeft, PomoConfigs.break, PomoConfigs.focus, completeHabit, selectedHabit]);
const toggleTimer = () => {
setState(prev => prev === 'started' ? 'paused' : 'started')
@@ -178,7 +177,7 @@ export default function PomodoroTimer() {
const progress = (timeLeft / currentTimerRef.current.duration) * 100
if (!show) return null
if (!show) return <></>
return (
<div className="fixed bottom-20 right-4 lg:bottom-4 bg-background border rounded-lg shadow-lg">

View File

@@ -1,19 +1,19 @@
'use client'
import { signOut } from "@/app/actions/user"
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"
import { Button } from "@/components/ui/button"
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui/dropdown-menu"
import { Settings, Info, User, Moon, Sun, Palette, ArrowRightLeft, LogOut, Crown } from "lucide-react"
import { toast } from "@/hooks/use-toast"
import { aboutOpenAtom, currentUserAtom, settingsAtom, userSelectAtom } from "@/lib/atoms"
import { useAtom } from "jotai"
import { ArrowRightLeft, Crown, Info, LogOut, Moon, Palette, Settings, Sun, User } from "lucide-react"
import { useTranslations } from 'next-intl'
import { useTheme } from "next-themes"
import Link from "next/link"
import { useState } from "react"
import { Dialog, DialogContent, DialogHeader, DialogTitle } from './ui/dialog'
import UserForm from './UserForm'
import Link from "next/link"
import { useAtom } from "jotai"
import { aboutOpenAtom, settingsAtom, userSelectAtom, currentUserAtom } from "@/lib/atoms"
import { useEffect, useState } from "react"
import { useTheme } from "next-themes"
import { signOut } from "@/app/actions/user"
import { toast } from "@/hooks/use-toast"
import { useTranslations } from 'next-intl'
export function Profile() {
const t = useTranslations('Profile');

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

@@ -9,7 +9,7 @@ export default function TodayEarnedCoins({ longFormat }: { longFormat?: boolean
const [settings] = useAtom(settingsAtom)
const { coinsEarnedToday } = useCoins()
if (coinsEarnedToday <= 0) return null
if (coinsEarnedToday <= 0) return <></>;
return (
<span className="text-md text-green-600 dark:text-green-400 font-medium mt-1">

View File

@@ -1,10 +1,6 @@
'use client';
import { useState } from 'react';
import { passwordSchema, usernameSchema } from '@/lib/zod';
import { useTranslations } from 'next-intl';
import { Input } from './ui/input';
import { Button } from './ui/button';
import { createUser, updateUser, updateUserPassword, uploadAvatar } from '@/app/actions/data';
import {
AlertDialog,
AlertDialogAction,
@@ -15,19 +11,22 @@ import {
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from "@/components/ui/alert-dialog"
} 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';
import { Permission } from '@/lib/types';
import { toast } from '@/hooks/use-toast';
import { useAtom, useAtomValue } from 'jotai';
import { serverSettingsAtom, usersAtom, currentUserAtom } from '@/lib/atoms';
import { createUser, updateUser, updateUserPassword, uploadAvatar } from '@/app/actions/data';
import { SafeUser, User } from '@/lib/types';
import { Avatar, AvatarFallback, AvatarImage } from './ui/avatar';
import { User as UserIcon } from 'lucide-react';
import _ from 'lodash';
import { PermissionSelector } from './PermissionSelector';
interface UserFormProps {

View File

@@ -1,33 +1,20 @@
'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 { Dialog, DialogContent, DialogHeader, DialogTitle } from './ui/dialog';
import { Avatar, AvatarFallback, AvatarImage } from './ui/avatar';
import { Crown, Pencil, Plus, User as UserIcon, UserRoundPen, Trash2 } from 'lucide-react';
import { Input } from './ui/input';
import { Button } from './ui/button';
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from "@/components/ui/alert-dialog"
import { useAtom } from 'jotai';
import { usersAtom, currentUserAtom } from '@/lib/atoms';
import { signIn } from '@/app/actions/user';
import { createUser } from '@/app/actions/data';
import { useTranslations } from 'next-intl';
import { toast } from '@/hooks/use-toast';
import { Description } from '@radix-ui/react-dialog';
import { SafeUser, User } from '@/lib/types';
import { cn } from '@/lib/utils';
import { Dialog, DialogContent, DialogHeader, DialogTitle } from './ui/dialog';
function UserCard({

View File

@@ -1,34 +1,29 @@
'use client'
import { cn } from '@/lib/utils'
import { browserSettingsAtom, habitsAtom, settingsAtom } from '@/lib/atoms'
import { HabitIcon, TaskIcon } from '@/lib/constants'
import { cn, isHabitDueToday } from '@/lib/utils'
import { useAtom } from 'jotai'
import { useTranslations } from 'next-intl'
import { browserSettingsAtom, habitsAtom, settingsAtom } from '@/lib/atoms'
import type { ViewType } from '@/lib/types'
import { HabitIcon, TaskIcon } from '@/lib/constants'
import { isHabitDueToday } from '@/lib/utils'
import { usePathname, useRouter } from 'next/navigation'
import { NotificationBadge } from './ui/notification-badge'
interface ViewToggleProps {
defaultView?: ViewType
className?: string
}
export function ViewToggle({
defaultView = 'habits',
className
}: ViewToggleProps) {
const t = useTranslations('ViewToggle')
const [browserSettings, setBrowserSettings] = useAtom(browserSettingsAtom)
const [habits] = useAtom(habitsAtom)
const [settings] = useAtom(settingsAtom)
const pathname = usePathname();
const router = useRouter();
const handleViewChange = (checked: boolean) => {
const newView = checked ? 'tasks' : 'habits'
setBrowserSettings({
...browserSettings,
viewType: newView,
})
const handleViewChange = () => {
router.push(pathname.includes("habits") ? "/tasks" : "/habits");
}
// Calculate due tasks count
@@ -40,10 +35,10 @@ export function ViewToggle({
<div className={cn('inline-flex rounded-full bg-muted/50 h-8', className)}>
<div className="relative flex gap-0.5 rounded-full bg-background p-0.5 h-full">
<button
onClick={() => handleViewChange(false)}
onClick={handleViewChange}
className={cn(
'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" />
@@ -52,14 +47,14 @@ export function ViewToggle({
<NotificationBadge
label={dueTasksCount}
show={dueTasksCount > 0}
variant={browserSettings.viewType === 'tasks' ? 'secondary' : 'default'}
variant={pathname.includes('tasks') ? 'secondary' : 'default'}
className="shadow-md"
>
<button
onClick={() => handleViewChange(true)}
onClick={handleViewChange}
className={cn(
'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" />
@@ -69,7 +64,7 @@ export function ViewToggle({
<div
className={cn(
'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>

View File

@@ -1,13 +1,5 @@
import { WishlistItemType, User } from '@/lib/types'
import { useAtom } from 'jotai'
import { useTranslations } from 'next-intl'
import { usersAtom, currentUserAtom } from '@/lib/atoms'
import { Avatar, AvatarFallback, AvatarImage } from './ui/avatar'
import { hasPermission } from '@/lib/utils'
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from '@/components/ui/card'
import ReactMarkdown from 'react-markdown'
import { Button } from '@/components/ui/button'
import { Coins, Edit, Trash2, Gift, MoreVertical, Archive, ArchiveRestore } from 'lucide-react'
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from '@/components/ui/card'
import {
DropdownMenu,
DropdownMenuContent,
@@ -15,6 +7,14 @@ import {
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu'
import { currentUserAtom, usersAtom } from '@/lib/atoms'
import { User, WishlistItemType } from '@/lib/types'
import { hasPermission } from '@/lib/utils'
import { useAtom } from 'jotai'
import { useTranslations } from 'next-intl'
import DrawingDisplay from './DrawingDisplay'
import { Avatar, AvatarFallback, AvatarImage } from './ui/avatar'
import { Archive, ArchiveRestore, Coins, Edit, Gift, MoreVertical, Trash2 } from 'lucide-react'
interface WishlistItemProps {
item: WishlistItemType
@@ -30,13 +30,13 @@ interface WishlistItemProps {
}
const renderUserAvatars = (item: WishlistItemType, currentUser: User | null, usersData: { users: User[] }) => {
if (!item.userIds || item.userIds.length <= 1) return null;
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 null
if (!user) return <></>;
return (
<Avatar key={user.id} className="h-6 w-6">
<AvatarImage src={user?.avatarPath && `/api/avatars/${user.avatarPath.split('/').pop()}` || ""} />
@@ -73,37 +73,51 @@ export default function WishlistItem({
} ${isRecentlyRedeemed ? 'animate-[celebrate_1s_ease-in-out] shadow-lg ring-2 ring-primary' : ''
} ${item.archived ? 'opacity-75' : ''}`}
>
<CardHeader className="flex-none">
<div className="flex items-center gap-2">
<CardTitle className={`line-clamp-1 ${item.archived ? 'text-gray-400 dark:text-gray-500' : ''}`}>
{item.name}
</CardTitle>
{item.targetCompletions && (
<span className="text-sm text-gray-500 dark:text-gray-400">
({item.targetCompletions === 1 ? t('usesLeftSingular') : t('usesLeftPlural', { count: item.targetCompletions })})
</span>
)}
</div>
<div className="flex items-center justify-between">
<div className="flex-1">
{item.description && (
<CardDescription className={`whitespace-pre-line ${item.archived ? 'text-gray-400 dark:text-gray-500' : ''}`}>
{item.description}
</CardDescription>
<CardHeader className="flex-shrink-0">
<div className="flex justify-between items-start">
<div className="flex items-center gap-2 flex-1 min-w-0">
<CardTitle className={`line-clamp-1 ${item.archived ? 'text-gray-400 dark:text-gray-500' : ''}`}>
{item.name}
</CardTitle>
{item.targetCompletions && (
<span className="text-sm text-gray-500 dark:text-gray-400 flex-shrink-0">
({item.targetCompletions === 1 ? t('usesLeftSingular') : t('usesLeftPlural', { count: item.targetCompletions })})
</span>
)}
</div>
{renderUserAvatars(item, currentUser as User, usersData)}
</div>
{(item.description || item.drawing) && (
<div className={`flex gap-4 mt-2 ${!item.description ? 'justify-end' : ''}`}>
{item.description && (
<CardDescription className={`whitespace-pre-line flex-1 min-w-0 break-words ${item.archived ? 'text-gray-400 dark:text-gray-500' : ''}`}>
{item.description}
</CardDescription>
)}
{item.drawing && (
<div className="flex-shrink-0">
<DrawingDisplay
drawingData={item.drawing}
width={120}
height={80}
className=""
/>
</div>
)}
</div>
)}
</CardHeader>
<CardContent className="flex-1">
<div className="flex items-center gap-2">
<Coins className={`h-4 w-4 ${item.archived ? 'text-gray-400 dark:text-gray-500' : 'text-yellow-400'}`} />
<span className={`text-sm font-medium ${item.archived ? 'text-gray-400 dark:text-gray-500' : ''}`}>
{item.coinCost} {t('coinsSuffix')}
</span>
<CardContent className="flex-grow flex flex-col justify-end">
<div className="mt-auto">
<div className="flex items-center gap-2">
<Coins className={`h-4 w-4 ${item.archived ? 'text-gray-400 dark:text-gray-500' : 'text-yellow-400'}`} />
<span className={`text-sm font-medium ${item.archived ? 'text-gray-400 dark:text-gray-500' : ''}`}>
{item.coinCost} {t('coinsSuffix')}
</span>
</div>
</div>
</CardContent>
<CardFooter className="flex justify-between gap-2">
<CardFooter className="flex-shrink-0 flex justify-between gap-2">
<div className="flex gap-2">
<Button
variant={canRedeem ? "default" : "secondary"}
@@ -180,4 +194,3 @@ export default function WishlistItem({
</Card>
)
}

View File

@@ -154,7 +154,6 @@ export default function WishlistManager() {
</div>
{isModalOpen &&
<AddEditWishlistItemModal
isOpen={isModalOpen}
setIsOpen={setIsModalOpen}
editingItem={editingItem}
setEditingItem={setEditingItem}

View File

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

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

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

View File

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

View File

@@ -1,10 +1,11 @@
services:
habittrove:
image: ghcr.io/manindark/habittrove
container_name: habittrove
ports:
- "3000:3000"
volumes:
- "./data:/app/data"
- "./backups:/app/backups"
image: dohsimpson/habittrove
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,50 +1,23 @@
import { useAtom } from 'jotai';
import { useState, useEffect, useMemo } from 'react';
import { useTranslations } from 'next-intl';
import { calculateCoinsEarnedToday, calculateCoinsSpentToday, calculateTotalEarned, calculateTotalSpent, calculateTransactionsToday, checkPermission } from '@/lib/utils'
import { addCoins, removeCoins, saveCoinsData } from '@/app/actions/data';
import { toast } from '@/hooks/use-toast';
import {
coinsAtom,
coinsBalanceAtom,
coinsEarnedTodayAtom,
coinsSpentTodayAtom,
currentUserAtom,
settingsAtom,
totalEarnedAtom,
totalSpentAtom,
coinsSpentTodayAtom,
transactionsTodayAtom,
coinsBalanceAtom,
settingsAtom,
usersAtom,
currentUserAtom,
} from '@/lib/atoms'
import { addCoins, removeCoins, saveCoinsData } from '@/app/actions/data'
import { CoinsData, User } from '@/lib/types'
import { toast } from '@/hooks/use-toast'
import { MAX_COIN_LIMIT } from '@/lib/constants'
function handlePermissionCheck(
user: User | undefined,
resource: 'habit' | 'wishlist' | 'coins',
action: 'write' | 'interact',
tCommon: (key: string, values?: Record<string, any>) => string
): boolean {
if (!user) {
toast({
title: tCommon("authenticationRequiredTitle"),
description: tCommon("authenticationRequiredDescription"),
variant: "destructive",
})
return false
}
if (!user.isAdmin && !checkPermission(user.permissions, resource, action)) {
toast({
title: tCommon("permissionDeniedTitle"),
description: tCommon("permissionDeniedDescription", { action, resource }),
variant: "destructive",
})
return false
}
return true
}
} from '@/lib/atoms';
import { MAX_COIN_LIMIT } from '@/lib/constants';
import { CoinsData } from '@/lib/types';
import { calculateCoinsEarnedToday, calculateCoinsSpentToday, calculateTotalEarned, calculateTotalSpent, calculateTransactionsToday, handlePermissionCheck, roundToInteger } from '@/lib/utils';
import { useAtom } from 'jotai';
import { useTranslations } from 'next-intl';
import { useEffect, useMemo, useState } from 'react';
export function useCoins(options?: { selectedUser?: string }) {
const t = useTranslations('useCoins');
@@ -86,12 +59,22 @@ export function useCoins(options?: { selectedUser?: string }) {
setBalance(loggedInUserBalance);
} else if (targetUser?.id) {
// If an admin is viewing another user, calculate their metrics manually
setCoinsEarnedToday(calculateCoinsEarnedToday(transactions, timezone));
setTotalEarned(calculateTotalEarned(transactions));
setTotalSpent(calculateTotalSpent(transactions));
setCoinsSpentToday(calculateCoinsSpentToday(transactions, timezone));
setTransactionsToday(calculateTransactionsToday(transactions, timezone));
setBalance(transactions.reduce((acc, t) => acc + t.amount, 0));
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,
@@ -107,20 +90,20 @@ export function useCoins(options?: { selectedUser?: string }) {
]);
const add = async (amount: number, description: string, note?: string) => {
if (!handlePermissionCheck(currentUser, 'coins', 'write', tCommon)) return null
if (!handlePermissionCheck(currentUser, 'coins', 'write', tCommon)) return <></>;
if (isNaN(amount) || amount <= 0) {
toast({
title: t("invalidAmountTitle"),
description: t("invalidAmountDescription")
})
return null
return <></>;
}
if (amount > MAX_COIN_LIMIT) {
toast({
title: t("invalidAmountTitle"),
description: t("maxAmountExceededDescription", { max: MAX_COIN_LIMIT })
})
return null
return <></>;
}
const data = await addCoins({
@@ -136,21 +119,21 @@ export function useCoins(options?: { selectedUser?: string }) {
}
const remove = async (amount: number, description: string, note?: string) => {
if (!handlePermissionCheck(currentUser, 'coins', 'write', tCommon)) return null
if (!handlePermissionCheck(currentUser, 'coins', 'write', tCommon)) return <></>;
const numAmount = Math.abs(amount)
if (isNaN(numAmount) || numAmount <= 0) {
toast({
title: t("invalidAmountTitle"),
description: t("invalidAmountDescription")
})
return null
return <></>;
}
if (numAmount > MAX_COIN_LIMIT) {
toast({
title: t("invalidAmountTitle"),
description: t("maxAmountExceededDescription", { max: MAX_COIN_LIMIT })
})
return null
return <></>;
}
const data = await removeCoins({
@@ -166,14 +149,14 @@ export function useCoins(options?: { selectedUser?: string }) {
}
const updateNote = async (transactionId: string, note: string) => {
if (!handlePermissionCheck(currentUser, 'coins', 'write', tCommon)) return null
if (!handlePermissionCheck(currentUser, 'coins', 'write', tCommon)) return <></>;
const transaction = coins.transactions.find(t => t.id === transactionId)
if (!transaction) {
toast({
title: tCommon("errorTitle"),
description: t("transactionNotFoundDescription")
})
return null
return <></>;
}
const updatedTransaction = {

View File

@@ -1,54 +1,24 @@
import { useAtom, atom } from 'jotai'
import { useTranslations } from 'next-intl'
import { habitsAtom, coinsAtom, settingsAtom, usersAtom, habitFreqMapAtom, currentUserAtom } from '@/lib/atoms'
import { addCoins, removeCoins, saveHabitsData } from '@/app/actions/data'
import { Habit, Permission, SafeUser, User } from '@/lib/types'
import { ToastAction } from '@/components/ui/toast'
import { toast } from '@/hooks/use-toast'
import { DateTime } from 'luxon'
import { coinsAtom, currentUserAtom, habitFreqMapAtom, habitsAtom, settingsAtom, usersAtom } from '@/lib/atoms'
import { Habit } from '@/lib/types'
import {
getNowInMilliseconds,
getTodayInTimezone,
isSameDate,
t2d,
d2s,
d2t,
getNow,
getCompletionsForDate,
getISODate,
d2s,
getNow,
getTodayInTimezone,
handlePermissionCheck,
isSameDate,
playSound,
checkPermission
t2d
} from '@/lib/utils'
import { ToastAction } from '@/components/ui/toast'
import { useAtom } from 'jotai'
import { Undo2 } from 'lucide-react'
function handlePermissionCheck(
user: SafeUser | User | undefined,
resource: 'habit' | 'wishlist' | 'coins',
action: 'write' | 'interact',
tCommon: (key: string, values?: Record<string, any>) => string
): boolean {
if (!user) {
toast({
title: tCommon("authenticationRequiredTitle"),
description: tCommon("authenticationRequiredDescription"),
variant: "destructive",
})
return false
}
if (!user.isAdmin && !checkPermission(user.permissions, resource, action)) {
toast({
title: tCommon("permissionDeniedTitle"),
description: tCommon("permissionDeniedDescription", { action, resource }),
variant: "destructive",
})
return false
}
return true
}
import { DateTime } from 'luxon'
import { useTranslations } from 'next-intl'
export function useHabits() {
const t = useTranslations('useHabits');
@@ -106,7 +76,7 @@ export function useHabits() {
type: habit.isTask ? 'TASK_COMPLETION' : 'HABIT_COMPLETION',
relatedItemId: habit.id,
})
isTargetReached && playSound()
playSound()
toast({
title: t("completedTitle"),
description: t("earnedCoinsDescription", { coinReward: habit.coinReward }),
@@ -207,7 +177,7 @@ export function useHabits() {
if (!handlePermissionCheck(currentUser, 'habit', 'write', tCommon)) return
const newHabit = {
...habit,
id: habit.id || getNowInMilliseconds().toString()
id: habit.id || crypto.randomUUID()
}
const updatedHabits = habit.id
? habitsData.habits.map(h => h.id === habit.id ? newHabit : h)

View File

@@ -1,40 +1,13 @@
import { removeCoins, saveWishlistItems } from '@/app/actions/data'
import { toast } from '@/hooks/use-toast'
import { coinsAtom, currentUserAtom, wishlistAtom } from '@/lib/atoms'
import { WishlistItemType } from '@/lib/types'
import { handlePermissionCheck } from '@/lib/utils'
import { celebrations } from '@/utils/celebrations'
import { useAtom } from 'jotai'
import { useTranslations } from 'next-intl'
import { wishlistAtom, coinsAtom, currentUserAtom } from '@/lib/atoms'
import { saveWishlistItems, removeCoins } from '@/app/actions/data'
import { toast } from '@/hooks/use-toast'
import { WishlistItemType, User, SafeUser } from '@/lib/types'
import { celebrations } from '@/utils/celebrations'
import { checkPermission } from '@/lib/utils'
import { useCoins } from './useCoins'
function handlePermissionCheck(
user: User | SafeUser | undefined,
resource: 'habit' | 'wishlist' | 'coins',
action: 'write' | 'interact',
tCommon: (key: string, values?: Record<string, any>) => string
): boolean {
if (!user) {
toast({
title: tCommon("authenticationRequiredTitle"),
description: tCommon("authenticationRequiredDescription"),
variant: "destructive",
})
return false
}
if (!user.isAdmin && !checkPermission(user.permissions, resource, action)) {
toast({
title: tCommon("permissionDeniedTitle"),
description: tCommon("permissionDeniedDescription", { action, resource }),
variant: "destructive",
})
return false
}
return true
}
export function useWishlist() {
const t = useTranslations('useWishlist');
const tCommon = useTranslations('Common');

View File

@@ -1,82 +1,86 @@
import { atom } from "jotai";
import {
getDefaultSettings,
getDefaultHabitsData,
getDefaultCoinsData,
getDefaultWishlistData,
Habit,
ViewType,
getDefaultUsersData,
CompletionCache,
getDefaultServerSettings,
User,
UserId,
} from "./types";
import {
getTodayInTimezone,
isSameDate,
t2d,
calculateCoinsEarnedToday,
calculateCoinsSpentToday,
calculateTotalEarned,
calculateTotalSpent,
calculateCoinsSpentToday,
calculateTransactionsToday,
generateCryptoHash,
getCompletionsForToday,
getISODate,
isHabitDueToday,
getNow,
getHabitFreq,
isHabitDue,
getHabitFreq
prepareDataForHashing,
roundToInteger,
t2d
} from "@/lib/utils";
import { atom } from "jotai";
import { atomFamily, atomWithStorage } from "jotai/utils";
import { DateTime } from "luxon";
import { Freq } from "./types";
import {
CoinsData,
CompletionCache,
Freq,
getDefaultCoinsData,
getDefaultHabitsData,
getDefaultServerSettings,
getDefaultSettings,
getDefaultUsersData,
getDefaultWishlistData,
Habit,
HabitsData,
ServerSettings,
Settings,
UserData,
UserId,
WishlistData
} from "./types";
export interface BrowserSettings {
viewType: ViewType
expandedHabits: boolean
expandedTasks: boolean
expandedWishlist: boolean
}
export const browserSettingsAtom = atomWithStorage('browserSettings', {
viewType: 'habits',
expandedHabits: false,
expandedTasks: false,
expandedWishlist: false
} as BrowserSettings)
export const usersAtom = atom(getDefaultUsersData())
export const settingsAtom = atom(getDefaultSettings());
export const habitsAtom = atom(getDefaultHabitsData());
export const coinsAtom = atom(getDefaultCoinsData());
export const wishlistAtom = atom(getDefaultWishlistData());
export const serverSettingsAtom = atom(getDefaultServerSettings());
export const usersAtom = atom(getDefaultUsersData<UserData>())
export const settingsAtom = atom(getDefaultSettings<Settings>());
export const habitsAtom = atom(getDefaultHabitsData<HabitsData>());
export const coinsAtom = atom(getDefaultCoinsData<CoinsData>());
export const wishlistAtom = atom(getDefaultWishlistData<WishlistData>());
export const serverSettingsAtom = atom(getDefaultServerSettings<ServerSettings>());
// Derived atom for coins earned today
export const coinsEarnedTodayAtom = atom((get) => {
const coins = get(coinsAtom);
const settings = get(settingsAtom);
return calculateCoinsEarnedToday(coins.transactions, settings.system.timezone);
const value = calculateCoinsEarnedToday(coins.transactions, settings.system.timezone);
return roundToInteger(value);
});
// Derived atom for total earned
export const totalEarnedAtom = atom((get) => {
const coins = get(coinsAtom);
return calculateTotalEarned(coins.transactions);
const value = calculateTotalEarned(coins.transactions);
return roundToInteger(value);
});
// Derived atom for total spent
export const totalSpentAtom = atom((get) => {
const coins = get(coinsAtom);
return calculateTotalSpent(coins.transactions);
const value = calculateTotalSpent(coins.transactions);
return roundToInteger(value);
});
// Derived atom for coins spent today
export const coinsSpentTodayAtom = atom((get) => {
const coins = get(coinsAtom);
const settings = get(settingsAtom);
return calculateCoinsSpentToday(coins.transactions, settings.system.timezone);
const value = calculateCoinsSpentToday(coins.transactions, settings.system.timezone);
return roundToInteger(value);
});
// Derived atom for transactions today
@@ -103,9 +107,10 @@ export const coinsBalanceAtom = atom((get) => {
return 0; // No user logged in or ID not set, so balance is 0
}
const coins = get(coinsAtom);
return coins.transactions
const balance = coins.transactions
.filter(transaction => transaction.userId === loggedInUserId)
.reduce((sum, transaction) => sum + transaction.amount, 0);
return roundToInteger(balance);
});
/* transient atoms */
@@ -123,8 +128,6 @@ export const pomodoroAtom = atom<PomodoroAtom>({
minimized: false,
})
import { prepareDataForHashing, generateCryptoHash } from '@/lib/utils';
export const userSelectAtom = atom<boolean>(false)
export const aboutOpenAtom = atom<boolean>(false)
@@ -231,10 +234,3 @@ export const habitsByDateFamily = atomFamily((dateString: string) =>
return habits.filter(habit => isHabitDue({ habit, timezone, date }));
})
);
// Derived atom for daily habits
export const dailyHabitsAtom = atom((get) => {
const settings = get(settingsAtom);
const today = getTodayInTimezone(settings.system.timezone);
return get(habitsByDateFamily(today));
});

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

@@ -31,4 +31,6 @@ export const QUICK_DATES = [
{ label: 'Sunday', value: 'this sunday' },
] as const
export const MAX_COIN_LIMIT = 9999
export const MAX_COIN_LIMIT = 9999
export const DESKTOP_DISPLAY_ITEM_COUNT = 4

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,5 +1,4 @@
import { RRule } from "rrule"
import { uuid } from "./utils"
import { DateTime } from "luxon"
export type UserId = string
@@ -47,6 +46,7 @@ export type Habit = {
archived?: boolean // mark the habit as archived
pinned?: boolean // mark the habit as pinned
userIds?: UserId[]
drawing?: string // Optional JSON string of drawing data
}
@@ -61,6 +61,7 @@ export type WishlistItemType = {
targetCompletions?: number // Optional field, infinity when unset
link?: string // Optional URL to external resource
userIds?: UserId[]
drawing?: string // Optional JSON string of drawing data
}
export type TransactionType = 'HABIT_COMPLETION' | 'HABIT_UNDO' | 'WISH_REDEMPTION' | 'MANUAL_ADJUSTMENT' | 'TASK_COMPLETION' | 'TASK_UNDO';
@@ -97,52 +98,58 @@ export interface WishlistData {
}
// Default value functions
export const getDefaultUsersData = (): UserData => ({
users: [
{
id: uuid(),
username: 'admin',
// password: '', // No default password for admin initially? Or set a secure default?
isAdmin: true,
lastNotificationReadTimestamp: undefined, // Initialize as undefined
}
]
});
export function getDefaultUsersData<UserData>(): UserData {
return {
users: [
{
id: crypto.randomUUID(),
username: 'admin',
// password: '', // No default password for admin initially? Or set a secure default?
isAdmin: true,
lastNotificationReadTimestamp: undefined, // Initialize as undefined
}
]
} as UserData;
};
export const getDefaultHabitsData = (): HabitsData => ({
habits: []
});
export function getDefaultHabitsData<HabitsData>(): HabitsData {
return { habits: [] } as HabitsData;
}
export function getDefaultTasksData<TasksData>(): TasksData {
return { tasks: [] } as TasksData;
};
export const getDefaultCoinsData = (): CoinsData => ({
balance: 0,
transactions: []
});
export function getDefaultCoinsData<CoinsData>(): CoinsData {
return { balance: 0, transactions: [] } as CoinsData;
};
export const getDefaultWishlistData = (): WishlistData => ({
items: []
});
export function getDefaultWishlistData<WishlistData>(): WishlistData {
return { items: [] } as WishlistData;
}
export const getDefaultSettings = (): Settings => ({
ui: {
useNumberFormatting: true,
useGrouping: true,
},
system: {
timezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
weekStartDay: 1, // Monday
autoBackupEnabled: true, // Add this line (default to true)
language: 'en', // Default language
},
profile: {}
});
export function getDefaultSettings<Settings>(): Settings {
return {
ui: {
useNumberFormatting: true,
useGrouping: true,
},
system: {
timezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
weekStartDay: 1, // Monday
autoBackupEnabled: true, // Add this line (default to true)
language: 'en', // Default language
},
profile: {}
} as Settings;
};
export const getDefaultServerSettings = (): ServerSettings => ({
isDemo: false
})
export function getDefaultServerSettings<ServerSettings>(): ServerSettings {
return { isDemo: false } as ServerSettings;
}
// Map of data types to their default values
export const DATA_DEFAULTS = {
export const DATA_DEFAULTS: { [key: string]: <T>() => T } = {
wishlist: getDefaultWishlistData,
habits: getDefaultHabitsData,
coins: getDefaultCoinsData,
@@ -183,8 +190,6 @@ export type CompletionCache = {
}
}
export type ViewType = 'habits' | 'tasks'
export interface JotaiHydrateInitialValues {
settings: Settings;
coins: CoinsData;

View File

@@ -3,12 +3,9 @@ import {
cn,
getTodayInTimezone,
getNow,
getNowInMilliseconds,
t2d,
d2t,
d2s,
d2sDate,
d2n,
isSameDate,
calculateCoinsEarnedToday,
calculateTotalEarned,
@@ -16,14 +13,14 @@ import {
calculateCoinsSpentToday,
isHabitDueToday,
isHabitDue,
uuid,
isTaskOverdue,
deserializeRRule,
serializeRRule,
convertHumanReadableFrequencyToMachineReadable,
convertMachineReadableFrequencyToHumanReadable,
getUnsupportedRRuleReason,
prepareDataForHashing,
getUnsupportedRRuleReason,
roundToInteger,
generateCryptoHash
} from './utils'
import { CoinTransaction, ParsedResultType, Settings, HabitsData, CoinsData, WishlistData, UserData } from './types'
@@ -42,6 +39,33 @@ 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 });
@@ -142,7 +166,7 @@ describe('isTaskOverdue', () => {
// 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)
@@ -150,32 +174,6 @@ describe('isTaskOverdue', () => {
})
})
describe('uuid', () => {
test('should generate valid UUIDs', () => {
const id = uuid()
// UUID v4 format: 8-4-4-4-12 hex digits
expect(id).toMatch(/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/)
})
test('should generate unique UUIDs', () => {
const ids = new Set()
for (let i = 0; i < 1000; i++) {
ids.add(uuid())
}
// All 1000 UUIDs should be unique
expect(ids.size).toBe(1000)
})
test('should generate v4 UUIDs', () => {
const id = uuid()
// Version 4 UUID has specific bits set:
// - 13th character is '4'
// - 17th character is '8', '9', 'a', or 'b'
expect(id.charAt(14)).toBe('4')
expect('89ab').toContain(id.charAt(19))
})
})
describe('datetime utilities', () => {
let fixedNow: DateTime;
let currentDateIndex = 0;
@@ -293,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', () => {
const testTimestamp = '2024-01-01T00:00:00.000Z';
const testDateTime = DateTime.fromISO(testTimestamp);
@@ -323,16 +314,6 @@ describe('timestamp conversion utilities', () => {
const customFormat = d2s({ dateTime: testDateTime, format: 'yyyy-MM-dd', timezone: 'utc' });
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', () => {
@@ -597,7 +578,7 @@ describe('isHabitDueToday', () => {
test('should return false for invalid recurrence rule', () => {
const habit = testHabit('INVALID_RRULE')
const consoleSpy = spyOn(console, 'error').mockImplementation(() => {})
const consoleSpy = spyOn(console, 'error').mockImplementation(() => { })
expect(isHabitDueToday({ habit, timezone: 'UTC' })).toBe(false)
})
})
@@ -710,7 +691,7 @@ describe('isHabitDue', () => {
test('should return false for invalid recurrence rule', () => {
const habit = testHabit('INVALID_RRULE')
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 })).toBe(false)
})
})
@@ -961,11 +942,11 @@ describe('convertMachineReadableFrequencyToHumanReadable', () => {
})
describe('freshness utilities', () => {
const mockSettings: Settings = getDefaultSettings();
const mockHabits: HabitsData = getDefaultHabitsData();
const mockCoins: CoinsData = getDefaultCoinsData();
const mockWishlist: WishlistData = getDefaultWishlistData();
const mockUsers: UserData = getDefaultUsersData();
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({
@@ -1010,11 +991,11 @@ describe('freshness utilities', () => {
});
test('should handle empty data consistently', () => {
const emptySettings = getDefaultSettings();
const emptyHabits = getDefaultHabitsData();
const emptyCoins = getDefaultCoinsData();
const emptyWishlist = getDefaultWishlistData();
const emptyUsers = getDefaultUsersData();
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);

View File

@@ -1,13 +1,12 @@
import { clsx, type ClassValue } from "clsx"
import { twMerge } from "tailwind-merge"
import { DateTime, DateTimeFormatOptions } from "luxon"
import { datetime, RRule } from 'rrule'
import { Freq, Habit, CoinTransaction, Permission, ParsedFrequencyResult, ParsedResultType, User, Settings, HabitsData, CoinsData, WishlistData, UserData } from '@/lib/types'
import { DUE_MAP, INITIAL_DUE, RECURRENCE_RULE_MAP } from "./constants"
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 _ from "lodash"
import { v4 as uuidv4 } from 'uuid'
import stableStringify from 'json-stable-stringify';
import { clsx, type ClassValue } from "clsx"
import { DateTime, DateTimeFormatOptions } from "luxon"
import { Formats } from "next-intl"
import { datetime, RRule } from 'rrule'
import { twMerge } from "tailwind-merge"
import { DUE_MAP, INITIAL_DUE, RECURRENCE_RULE_MAP } from "./constants"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
@@ -19,6 +18,11 @@ export function getTodayInTimezone(timezone: string): string {
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 {
return dateTime.setZone(timezone).toISODate()!;
}
@@ -28,12 +32,6 @@ export function getNow({ timezone = 'utc', keepLocalTime }: { timezone?: string,
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
export function t2d({ timestamp, timezone }: { timestamp: string; timezone: string }) {
return DateTime.fromISO(timestamp).setZone(timezone);
@@ -56,30 +54,11 @@ export function d2s({ dateTime, format, timezone }: { dateTime: DateTime, format
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)
export function isSameDate(a: DateTime, b: DateTime) {
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({
habit,
date,
@@ -433,22 +412,20 @@ export const openWindow = (url: string): boolean => {
return true
}
export function deepMerge<T>(a: T, b: T) {
return _.merge(a, b, (x: unknown, y: unknown) => {
if (_.isArray(a)) {
return a.concat(b)
}
})
}
export function checkPermission(
permissions: Permission[] | undefined,
export function hasPermission(
user: User | undefined,
resource: 'habit' | 'wishlist' | 'coins',
action: 'write' | 'interact'
): boolean {
if (!permissions) return false
return permissions.some(permission => {
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]
@@ -462,27 +439,6 @@ export function checkPermission(
})
}
export function uuid() {
return uuidv4()
}
export function hasPermission(
currentUser: User | undefined,
resource: 'habit' | 'wishlist' | 'coins',
action: 'write' | 'interact'
): boolean {
// If no current user, no permissions.
if (!currentUser) {
return false;
}
// If user is admin, they have all permissions.
if (currentUser.isAdmin) {
return true;
}
// Otherwise, check specific permissions.
return checkPermission(currentUser.permissions, resource, action);
}
/**
* 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.
@@ -494,22 +450,13 @@ export function prepareDataForHashing(
wishlist: WishlistData,
users: UserData
): string {
// Combine all data into a single object.
// The order of keys in this object itself doesn't matter due to stableStringify,
// but being explicit helps in understanding what's being hashed.
const combinedData = {
return JSON.stringify({
settings,
habits,
coins,
wishlist,
users,
};
const stringifiedData = stableStringify(combinedData);
// Handle cases where stringify might return undefined.
if (stringifiedData === undefined) {
throw new Error("Failed to stringify data for hashing. stableStringify returned undefined.");
}
return stringifiedData;
});
}
/**
@@ -518,14 +465,47 @@ export function prepareDataForHashing(
* @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> {
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;
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
}

447
messages/ca.json Normal file
View File

@@ -0,0 +1,447 @@
{
"Dashboard": {
"title": "Tauler"
},
"HabitList": {
"myTasks": "Les meves tasques",
"myHabits": "Els meus hàbits",
"addTaskButton": "Afegeix tasca",
"addHabitButton": "Afegeix hàbit",
"searchTasksPlaceholder": "Cerca tasques...",
"searchHabitsPlaceholder": "Cerca hàbits...",
"sortByLabel": "Ordena per:",
"sortByName": "Nom",
"sortByCoinReward": "Recompensa de monedes",
"sortByDueDate": "Data límit",
"sortByFrequency": "Freqüència",
"toggleSortOrderAriaLabel": "Canvia l'ordre de classificació",
"noTasksFoundMessage": "No s'han trobat tasques que coincideixin amb la teva cerca.",
"noHabitsFoundMessage": "No s'han trobat hàbits que coincideixin amb la teva cerca.",
"emptyStateTasksTitle": "Encara no hi ha tasques",
"emptyStateHabitsTitle": "Encara no hi ha hàbits",
"emptyStateTasksDescription": "Crea la teva primera tasca per començar a seguir el teu progrés",
"emptyStateHabitsDescription": "Crea el teu primer hàbit per començar a seguir el teu progrés",
"archivedSectionTitle": "Arxivat",
"deleteTaskDialogTitle": "Elimina tasca",
"deleteHabitDialogTitle": "Elimina hàbit",
"deleteTaskDialogMessage": "Estàs segur que vols eliminar aquesta tasca? Aquesta acció no es pot desfer.",
"deleteHabitDialogMessage": "Estàs segur que vols eliminar aquest hàbit? Aquesta acció no es pot desfer.",
"deleteButton": "Elimina"
},
"DailyOverview": {
"addTaskButtonLabel": "Afegeix tasca",
"addHabitButtonLabel": "Afegeix hàbit",
"todaysOverviewTitle": "Resum d'avui",
"dailyTasksTitle": "Tasques diàries",
"noTasksDueTodayMessage": "No hi ha tasques per avui. Afegeix-ne algunes per començar!",
"dailyHabitsTitle": "Hàbits diaris",
"noHabitsDueTodayMessage": "No hi ha hàbits per avui. Afegeix-ne alguns per començar!",
"wishlistGoalsTitle": "Objectius de la llista de desitjos",
"redeemableBadgeLabel": "{count}/{total} bescanviable",
"noWishlistItemsMessage": "Encara no hi ha elements a la llista de desitjos. Afegeix algunes metes per treballar-hi!",
"readyToRedeemMessage": "Llest per bescanviar!",
"coinsToGoMessage": "Falten {amount} monedes",
"showLessButton": "Mostra menys",
"showAllButton": "Mostra tot",
"viewButton": "Visualitza",
"deleteTaskDialogTitle": "Elimina tasca",
"deleteHabitDialogTitle": "Elimina hàbit",
"confirmDeleteDialogMessage": "Estàs segur que vols eliminar \"{name}\"? Aquesta acció no es pot desfer.",
"deleteButton": "Elimina",
"overdueTooltip": "Vençut"
},
"HabitContextMenuItems": {
"startPomodoro": "Inicia Pomodoro",
"moveToToday": "Mou a avui",
"moveToTomorrow": "Mou a demà",
"unpin": "Desfixa",
"pin": "Fixa",
"edit": "Edita",
"archive": "Arxiva",
"unarchive": "Desarxiva",
"delete": "Elimina"
},
"HabitStreak": {
"dailyCompletionStreakTitle": "Ratxa de finalització diària",
"tooltipHabitsLabel": "hàbits",
"tooltipTasksLabel": "tasques",
"tooltipCompletedLabel": "Completat"
},
"CoinBalance": {
"coinBalanceTitle": "Saldo de monedes"
},
"AddEditHabitModal": {
"editTaskTitle": "Edita tasca",
"editHabitTitle": "Edita hàbit",
"addNewTaskTitle": "Afegeix nova tasca",
"addNewHabitTitle": "Afegeix nou hàbit",
"nameLabel": "Nom *",
"descriptionLabel": "Descripció",
"whenLabel": "Quan *",
"completeLabel": "Completa",
"timesSuffix": "vegades",
"rewardLabel": "Recompensa",
"coinsSuffix": "monedes",
"drawingLabel": "Dibuix",
"addDrawing": "Afegeix Dibuix",
"editDrawing": "Edita Dibuix",
"shareLabel": "Comparteix",
"saveChangesButton": "Desa els canvis",
"addTaskButton": "Afegeix tasca",
"addHabitButton": "Afegeix hàbit"
},
"ConfirmDialog": {
"confirmButton": "Confirma",
"cancelButton": "Cancel·la"
},
"AddEditWishlistItemModal": {
"editTitle": "Edita recompensa",
"addTitle": "Afegeix nova recompensa",
"nameLabel": "Nom *",
"descriptionLabel": "Descripció",
"costLabel": "Cost",
"coinsSuffix": "monedes",
"redeemableLabel": "Bescanviable",
"timesSuffix": "vegades",
"errorNameRequired": "El nom és obligatori",
"errorCoinCostMin": "El cost en monedes ha de ser com a mínim 1",
"errorTargetCompletionsMin": "El nombre de finalitzacions objectiu ha de ser com a mínim 1",
"errorInvalidUrl": "Si us plau, introdueix una URL vàlida",
"linkLabel": "Enllaç",
"drawingLabel": "Dibuix",
"addDrawing": "Afegeix Dibuix",
"editDrawing": "Edita Dibuix",
"shareLabel": "Comparteix",
"saveButton": "Desa els canvis",
"addButton": "Afegeix recompensa"
},
"Navigation": {
"dashboard": "Tauler",
"tasks": "Tasques",
"habits": "Hàbits",
"calendar": "Calendari",
"wishlist": "Llista de desitjos",
"coins": "Monedes"
},
"TodayEarnedCoins": {
"todaySuffix": "avui"
},
"WishlistItem": {
"usesLeftSingular": "ús restant",
"usesLeftPlural": "usos restants",
"coinsSuffix": "monedes",
"redeem": "Bescanvia",
"redeemedDone": "Fet",
"redeemedExclamation": "Bescanviat!",
"editButton": "Edita",
"archiveButton": "Arxiva",
"unarchiveButton": "Desarxiva",
"deleteButton": "Elimina"
},
"WishlistManager": {
"title": "La meva llista de desitjos",
"addRewardButton": "Afegeix recompensa",
"emptyStateTitle": "La teva llista de desitjos està buida",
"emptyStateDescription": "Afegeix recompenses que t'agradaria guanyar amb les teves monedes",
"archivedSectionTitle": "Arxivat",
"popupBlockedTitle": "Finestra emergent bloquejada",
"popupBlockedDescription": "Si us plau, permet les finestres emergents per obrir l'enllaç",
"deleteDialogTitle": "Elimina recompensa",
"deleteDialogMessage": "Estàs segur que vols eliminar aquesta recompensa? Aquesta acció no es pot desfer.",
"deleteButton": "Elimina"
},
"UserSelectModal": {
"addUserButton": "Afegeix usuari",
"createNewUserTitle": "Crea nou usuari",
"selectUserTitle": "Selecciona usuari",
"signInSuccessTitle": "Inici de sessió correcte",
"signInSuccessDescription": "Benvingut de nou, {username}!",
"errorInvalidPassword": "contrasenya no vàlida",
"deleteUserConfirmation": "Estàs segur que vols eliminar l'usuari {username}? Aquesta acció no es pot desfer.",
"confirmDeleteButtonText": "Elimina",
"deletingButtonText": "Eliminant...",
"deleteUserSuccessTitle": "Usuari eliminat",
"deleteUserSuccessDescription": "L'usuari {username} s'ha eliminat correctament.",
"deleteUserErrorTitle": "Error en eliminar",
"genericError": "S'ha produït un error inesperat.",
"networkError": "S'ha produït un error de xarxa. Si us plau, torna-ho a intentar.",
"editUserTooltip": "Edita usuari",
"deleteUserTooltip": "Elimina usuari"
},
"CoinsManager": {
"title": "Gestió de monedes",
"currentBalanceLabel": "Saldo actual",
"coinsSuffix": "monedes",
"addCoinsButton": "Afegeix monedes",
"removeCoinsButton": "Elimina monedes",
"statisticsTitle": "Estadístiques",
"totalEarnedLabel": "Total guanyat",
"totalSpentLabel": "Total gastat",
"totalTransactionsLabel": "Transaccions totals",
"todaysEarnedLabel": "Guanyat avui",
"todaysSpentLabel": "Gastat avui",
"todaysTransactionsLabel": "Transaccions d'avui",
"transactionHistoryTitle": "Historial de transaccions",
"showLabel": "Mostra:",
"entriesSuffix": "entrades",
"showingEntries": "Mostrant {from} a {to} de {total} entrades",
"noTransactionsTitle": "Encara no hi ha transaccions",
"noTransactionsDescription": "El teu historial de transaccions apareixerà aquí un cop comencis a guanyar o gastar monedes",
"pageLabel": "Pàgina",
"ofLabel": "de",
"transactionTypeHabitCompletion": "Finalització d'hàbit",
"transactionTypeTaskCompletion": "Finalització de tasca",
"transactionTypeHabitUndo": "Desfer hàbit",
"transactionTypeTaskUndo": "Desfer tasca",
"transactionTypeWishRedemption": "Bescanvi de desig",
"transactionTypeManualAdjustment": "Ajust manual",
"transactionTypeCoinReset": "Reinici de monedes",
"transactionTypeInitialBalance": "Saldo inicial"
},
"NotificationBell": {
"errorUpdateTimestamp": "Error en actualitzar la marca de temps de notificació llegida:"
},
"PomodoroTimer": {
"focusLabel1": "Mantén-te concentrat",
"focusLabel2": "Tu pots",
"focusLabel3": "Continua endavant",
"focusLabel4": "Fes-ho",
"focusLabel5": "Fes que passi",
"focusLabel6": "Mantén-te fort",
"focusLabel7": "Esforça't",
"focusLabel8": "Un pas cada vegada",
"focusLabel9": "Tu pots fer-ho",
"focusLabel10": "Concentra't i conquereix",
"breakLabel1": "Fes un descans",
"breakLabel2": "Relaxa't i recarrega",
"breakLabel3": "Respira profundament",
"breakLabel4": "Estira't",
"breakLabel5": "Refresca't",
"breakLabel6": "T'ho mereixes",
"breakLabel7": "Recarrega la teva energia",
"breakLabel8": "Allunya't un moment",
"breakLabel9": "Neteja la teva ment",
"breakLabel10": "Descansa i recupera't",
"focusType": "Concentració",
"breakType": "Descans",
"pauseButton": "Pausa",
"startButton": "Inicia",
"resetButton": "Reinicia",
"skipButton": "Omet",
"wakeLockNotSupported": "El navegador no suporta wake lock",
"wakeLockInUse": "Wake lock ja està en ús",
"wakeLockRequestError": "Error en sol·licitar wake lock:",
"wakeLockReleaseError": "Error en alliberar wake lock:"
},
"HabitCalendar": {
"title": "Calendari d'hàbits",
"calendarCardTitle": "Calendari",
"selectDatePrompt": "Selecciona una data",
"tasksSectionTitle": "Tasques",
"habitsSectionTitle": "Hàbits",
"errorCompletingPastHabit": "Error en completar hàbit passat:"
},
"NotificationDropdown": {
"notLoggedIn": "No has iniciat sessió.",
"userCompletedItem": "{username} ha completat {itemName}.",
"userRedeemedItem": "{username} ha bescanviat {itemName}.",
"activityRelatedToItem": "Activitat relacionada amb {itemName} per {username}.",
"defaultUsername": "Algú",
"defaultItemName": "un element compartit",
"notificationsTitle": "Notificacions",
"notificationsTooltip": "Mostra finalitzacions o bescanvis d'altres usuaris per hàbits o llista de desitjos que has compartit amb ells (has de ser administrador)",
"noNotificationsYet": "Encara no hi ha notificacions."
},
"AboutModal": {
"dialogArisLabel": "quant a",
"changelogButton": "Registre de canvis",
"createdByPrefix": "Creat amb ❤️ per",
"starOnGitHubButton": "Dona una estrella a GitHub"
},
"PermissionSelector": {
"permissionsTitle": "Permisos",
"adminAccessLabel": "Accés d'administrador",
"adminAccessDescription": "Els administradors tenen permís complet sobre totes les dades de tots els usuaris",
"resourceHabitTask": "Hàbit / Tasca",
"resourceWishlist": "Llista de desitjos",
"resourceCoins": "Monedes",
"permissionWrite": "Escriptura",
"permissionInteract": "Interactua"
},
"UserForm": {
"toastUserUpdatedTitle": "Usuari actualitzat",
"toastUserUpdatedDescription": "Usuari {username} actualitzat amb èxit",
"toastUserCreatedTitle": "Usuari creat",
"toastUserCreatedDescription": "Usuari {username} creat amb èxit",
"actionUpdate": "actualitzar",
"actionCreate": "crear",
"errorFailedUserAction": "Error en {action} usuari",
"toastDemoDeleteDisabled": "L'eliminació està deshabilitada a la instància de demostració",
"toastCannotDeleteSelf": "No pots eliminar el teu propi compte",
"confirmDeleteUser": "Estàs segur que vols eliminar l'usuari {username}?",
"toastUserDeletedTitle": "Usuari eliminat",
"toastUserDeletedDescription": "L'usuari {username} s'ha eliminat correctament",
"toastDeleteUserFailed": "Error en eliminar l'usuari: {error}",
"errorTitle": "Error",
"errorFileSizeLimit": "La mida de l'arxiu ha de ser inferior a 5MB",
"toastAvatarUploadedTitle": "Avatar pujat",
"toastAvatarUploadedDescription": "Avatar pujat amb èxit",
"errorFailedAvatarUpload": "Error en pujar l'avatar",
"changeAvatarButton": "Canvia avatar",
"uploadAvatarButton": "Puja avatar",
"usernameLabel": "Nom d'usuari",
"usernamePlaceholder": "Nom d'usuari",
"newPasswordLabel": "Nova contrasenya",
"passwordLabel": "Contrasenya",
"passwordPlaceholderEdit": "Deixa en blanc per mantenir l'actual",
"passwordPlaceholderCreate": "Introdueix contrasenya",
"demoPasswordDisabledMessage": "La contrasenya està automàticament desactivada a la instància de demostració",
"disablePasswordLabel": "Desactiva contrasenya",
"cancelButton": "Cancel·la",
"saveChangesButton": "Desa els canvis",
"createUserButton": "Crea usuari",
"deleteAccountButton": "Elimina compte",
"deletingButtonText": "Eliminant...",
"areYouSure": "Estàs segur?",
"deleteUserConfirmation": "Estàs segur que vols eliminar l'usuari {username}?",
"cancel": "Cancel·la",
"confirmDeleteButtonText": "Elimina"
},
"ViewToggle": {
"habitsLabel": "Hàbits",
"tasksLabel": "Tasques"
},
"HabitItem": {
"overdue": "Vençut",
"whenLabel": "Quan: {frequency}",
"coinsPerCompletion": "{count} monedes per finalització",
"completedStatus": "Completat",
"completedStatusCount": "Completat ({completed}/{target})",
"completedStatusCountMobile": "{completed}/{target}",
"completeButton": "Completa",
"completeButtonCount": "Completa ({completed}/{target})",
"completeButtonCountMobile": "{completed}/{target}",
"undoButton": "Desfés",
"editButton": "Edita"
},
"TransactionNoteEditor": {
"noteTooLongTitle": "Nota massa llarga",
"noteTooLongDescription": "Les notes han de tenir menys de 200 caràcters",
"errorSavingNoteTitle": "Error en desar la nota",
"errorDeletingNoteTitle": "Error en eliminar la nota",
"pleaseTryAgainDescription": "Si us plau, torna-ho a intentar",
"addNotePlaceholder": "Afegeix una nota...",
"saveNoteTitle": "Desa nota",
"cancelButtonTitle": "Cancel·la",
"deleteNoteTitle": "Elimina nota",
"editNoteAriaLabel": "Edita nota"
},
"Profile": {
"guestUsername": "Convidat",
"editProfileButton": "Edita perfil",
"signOutSuccessTitle": "Tancament de sessió correcte",
"signOutSuccessDescription": "Has tancat sessió del teu compte",
"signOutErrorTitle": "Error en tancar sessió",
"signOutErrorDescription": "Error en tancar sessió",
"switchUserButton": "Canvia usuari",
"settingsLink": "Configuració",
"aboutButton": "Quant a",
"themeLabel": "Tema",
"editProfileModalTitle": "Edita perfil"
},
"PasswordEntryForm": {
"notYouButton": "No ets tu?",
"passwordLabel": "Contrasenya",
"passwordPlaceholder": "Introdueix contrasenya",
"loginErrorToastTitle": "Error",
"loginFailedErrorToastDescription": "Error en iniciar sessió",
"cancelButton": "Cancel·la",
"loginButton": "Inicia sessió"
},
"CompletionCountBadge": {
"countCompleted": "{completedCount}/{totalCount} completat"
},
"SettingsPage": {
"title": "Configuració",
"uiSettingsTitle": "Configuració d'interfície",
"numberFormattingLabel": "Format numèric",
"numberFormattingDescription": "Formateja nombres grans (ex: 1K, 1M, 1B)",
"numberGroupingLabel": "Agrupació numèrica",
"numberGroupingDescription": "Usa separadors de milers (ex: 1.000 vs 1000)",
"systemSettingsTitle": "Configuració del sistema",
"timezoneLabel": "Zona horària",
"timezoneDescription": "Selecciona la teva zona horària per a un seguiment precís de dates",
"weekStartDayLabel": "Dia d'inici de setmana",
"weekStartDayDescription": "Selecciona el teu dia preferit per iniciar la setmana",
"weekdays": {
"sunday": "Diumenge",
"monday": "Dilluns",
"tuesday": "Dimarts",
"wednesday": "Dimecres",
"thursday": "Dijous",
"friday": "Divendres",
"saturday": "Dissabte"
},
"autoBackupLabel": "Còpia de seguretat automàtica",
"autoBackupTooltip": "Quan està habilitat, les dades de l'aplicació (hàbits, monedes, configuracions, etc.) es copien automàticament cada dia al voltant de les 2 AM hora del servidor. Les còpies de seguretat s'emmagatzemen com a fitxers ZIP al directori `backups/` a l'arrel del projecte. Només es conserven les últimes 7 còpies de seguretat; les més antigues s'eliminen automàticament.",
"autoBackupDescription": "Realitza còpia de seguretat automàtica diària",
"languageLabel": "Idioma",
"languageDescription": "Tria el teu idioma preferit per mostrar a l'aplicació.",
"languageChangedTitle": "Idioma canviat",
"languageChangedDescription": "Si us plau, actualitza la pàgina per veure els canvis",
"languageDisabledInDemoTooltip": "Canviar l'idioma està deshabilitat a la versió de demostració."
},
"Common": {
"authenticationRequiredTitle": "Autenticació requerida",
"authenticationRequiredDescription": "Si us plau, inicia sessió per continuar.",
"permissionDeniedTitle": "Permís denegat",
"permissionDeniedDescription": "No tens permís de {action} per a {resource}.",
"undoButton": "Desfés",
"redoButton": "Refés",
"errorTitle": "Error"
},
"useHabits": {
"alreadyCompletedTitle": "Ja completat",
"alreadyCompletedDescription": "Ja has completat aquest hàbit avui.",
"completedTitle": "Completat!",
"earnedCoinsDescription": "Has guanyat {coinReward} monedes.",
"progressTitle": "Progrés!",
"progressDescription": "Has completat {count}/{target} vegades avui.",
"completionUndoneTitle": "Finalització desfeta",
"completionUndoneDescription": "Tens {count}/{target} finalitzacions avui.",
"noCompletionsToUndoTitle": "No hi ha finalitzacions per desfer",
"noCompletionsToUndoDescription": "Aquest hàbit no s'ha completat avui.",
"alreadyCompletedPastDateTitle": "Ja completat",
"alreadyCompletedPastDateDescription": "Aquest hàbit ja va ser completat el {dateKey}.",
"earnedCoinsPastDateDescription": "Vas guanyar {coinReward} monedes per {dateKey}.",
"progressPastDateDescription": "Has completat {count}/{target} vegades el {dateKey}."
},
"useWishlist": {
"redemptionLimitReachedTitle": "Límit de bescanvis assolit",
"redemptionLimitReachedDescription": "Has assolit el màxim de bescanvis per \"{itemName}\".",
"rewardRedeemedTitle": "🎉 Recompensa bescanviada!",
"rewardRedeemedDescription": "Has bescanviat \"{itemName}\" per {itemCoinCost} monedes.",
"notEnoughCoinsTitle": "No hi ha prou monedes",
"notEnoughCoinsDescription": "Necessites {coinsNeeded} monedes més per bescanviar aquesta recompensa."
},
"Warning": {
"areYouSure": "Estàs segur?",
"cancel": "Cancel·la"
},
"useCoins": {
"addedCoinsDescription": "S'han afegit {amount} monedes",
"invalidAmountTitle": "Quantitat no vàlida",
"invalidAmountDescription": "Si us plau, introdueix un nombre positiu vàlid",
"successTitle": "Èxit",
"transactionNotFoundDescription": "Transacció no trobada",
"maxAmountExceededDescription": "La quantitat no pot excedir {max}."
},
"DrawingModal": {
"colorLabel": "Color:",
"thicknessLabel": "Gruix:",
"undoButton": "Desfés",
"clearButton": "Neteja",
"saveDrawingButton": "Desa el dibuix",
"cancelButton": "Cancel·la"
}
}

View File

@@ -82,6 +82,9 @@
"timesSuffix": "mal",
"rewardLabel": "Belohnung",
"coinsSuffix": "Münzen",
"drawingLabel": "Zeichnung",
"addDrawing": "Zeichnung hinzufügen",
"editDrawing": "Zeichnung bearbeiten",
"shareLabel": "Teilen",
"saveChangesButton": "Änderungen speichern",
"addTaskButton": "Aufgabe hinzufügen",
@@ -105,6 +108,9 @@
"errorTargetCompletionsMin": "Zielabschlüsse müssen mindestens 1 sein",
"errorInvalidUrl": "Bitte geben Sie eine gültige URL ein",
"linkLabel": "Link",
"drawingLabel": "Zeichnung",
"addDrawing": "Zeichnung hinzufügen",
"editDrawing": "Zeichnung bearbeiten",
"shareLabel": "Teilen",
"saveButton": "Änderungen speichern",
"addButton": "Belohnung hinzufügen"
@@ -431,5 +437,13 @@
"maxAmountExceededDescription": "Der Betrag darf {max} nicht überschreiten.",
"transactionNotFoundDescription": "Transaktion nicht gefunden",
"maxAmountExceededDescription": "Der Betrag darf {max} nicht überschreiten."
},
"DrawingModal": {
"colorLabel": "Farbe:",
"thicknessLabel": "Dicke:",
"undoButton": "Rückgängig",
"clearButton": "Löschen",
"saveDrawingButton": "Zeichnung speichern",
"cancelButton": "Abbrechen"
}
}

View File

@@ -82,6 +82,9 @@
"timesSuffix": "times",
"rewardLabel": "Reward",
"coinsSuffix": "coins",
"drawingLabel": "Drawing",
"addDrawing": "Add Drawing",
"editDrawing": "Edit Drawing",
"shareLabel": "Share",
"saveChangesButton": "Save Changes",
"addTaskButton": "Add Task",
@@ -105,6 +108,9 @@
"errorTargetCompletionsMin": "Target completions must be at least 1",
"errorInvalidUrl": "Please enter a valid URL",
"linkLabel": "Link",
"drawingLabel": "Drawing",
"addDrawing": "Add Drawing",
"editDrawing": "Edit Drawing",
"shareLabel": "Share",
"saveButton": "Save Changes",
"addButton": "Add Reward"
@@ -288,7 +294,18 @@
"cancelButton": "Cancel",
"saveChangesButton": "Save Changes",
"createUserButton": "Create User",
"deleteAccountButton": "Delete Account"
"deleteAccountButton": "Delete Account",
"toastDemoDeleteDisabled": "Deletion is disabled in demo instance",
"toastCannotDeleteSelf": "You cannot delete your own account",
"confirmDeleteUser": "Are you sure you want to delete user {username}?",
"toastUserDeletedTitle": "User deleted",
"toastUserDeletedDescription": "User {username} has been deleted successfully",
"toastDeleteUserFailed": "Failed to delete user: {error}",
"deletingButtonText": "Deleting...",
"areYouSure": "Are you sure?",
"deleteUserConfirmation": "Are you sure you want to delete user {username}?",
"cancel": "Cancel",
"confirmDeleteButtonText": "Delete"
},
"ViewToggle": {
"habitsLabel": "Habits",
@@ -413,12 +430,18 @@
"invalidAmountDescription": "Please enter a valid positive number",
"successTitle": "Success",
"transactionNotFoundDescription": "Transaction not found",
"maxAmountExceededDescription": "The amount cannot exceed {max}.",
"transactionNotFoundDescription": "Transaction not found",
"maxAmountExceededDescription": "The amount cannot exceed {max}."
},
"Warning": {
"areYouSure": "Are you sure?",
"cancel": "Cancel"
},
"DrawingModal": {
"saveDrawingButton": "Save",
"cancelButton": "Cancel",
"clearButton": "Clear",
"undoButton": "Undo",
"colorLabel": "Color",
"thicknessLabel": "Thickness"
}
}

View File

@@ -82,6 +82,9 @@
"timesSuffix": "veces",
"rewardLabel": "Recompensa",
"coinsSuffix": "monedas",
"drawingLabel": "Dibujo",
"addDrawing": "Añadir Dibujo",
"editDrawing": "Editar Dibujo",
"shareLabel": "Compartir",
"saveChangesButton": "Guardar cambios",
"addTaskButton": "Añadir tarea",
@@ -105,6 +108,9 @@
"errorTargetCompletionsMin": "El número de finalizaciones objetivo debe ser al menos 1",
"errorInvalidUrl": "Por favor ingresa una URL válida",
"linkLabel": "Enlace",
"drawingLabel": "Dibujo",
"addDrawing": "Añadir Dibujo",
"editDrawing": "Editar Dibujo",
"shareLabel": "Compartir",
"saveButton": "Guardar cambios",
"addButton": "Añadir recompensa"
@@ -385,6 +391,14 @@
"languageChangedDescription": "Por favor actualiza la página para ver los cambios",
"languageDisabledInDemoTooltip": "Cambiar el idioma está deshabilitado en la versión de demostración."
},
"DrawingModal": {
"colorLabel": "Color:",
"thicknessLabel": "Grosor:",
"undoButton": "Deshacer",
"clearButton": "Limpiar",
"saveDrawingButton": "Guardar Dibujo",
"cancelButton": "Cancelar"
},
"Common": {
"authenticationRequiredTitle": "Autenticación requerida",
"authenticationRequiredDescription": "Por favor inicia sesión para continuar.",

View File

@@ -82,6 +82,9 @@
"timesSuffix": "fois",
"rewardLabel": "Récompense",
"coinsSuffix": "pièces",
"drawingLabel": "Dessin",
"addDrawing": "Ajouter un Dessin",
"editDrawing": "Modifier le Dessin",
"shareLabel": "Partager",
"saveChangesButton": "Sauvegarder les modifications",
"addTaskButton": "Ajouter une tâche",
@@ -105,6 +108,9 @@
"errorTargetCompletionsMin": "Les complétions cibles doivent être d'au moins 1",
"errorInvalidUrl": "Veuillez entrer une URL valide",
"linkLabel": "Lien",
"drawingLabel": "Dessin",
"addDrawing": "Ajouter un Dessin",
"editDrawing": "Modifier le Dessin",
"shareLabel": "Partager",
"saveButton": "Sauvegarder les modifications",
"addButton": "Ajouter une récompense"
@@ -431,5 +437,13 @@
"maxAmountExceededDescription": "Le montant ne peut pas dépasser {max}.",
"transactionNotFoundDescription": "Transaction non trouvée",
"maxAmountExceededDescription": "Le montant ne peut pas dépasser {max}."
},
"DrawingModal": {
"colorLabel": "Couleur :",
"thicknessLabel": "Épaisseur :",
"undoButton": "Annuler",
"clearButton": "Effacer",
"saveDrawingButton": "Enregistrer le dessin",
"cancelButton": "Annuler"
}
}

View File

@@ -82,6 +82,9 @@
"timesSuffix": "回",
"rewardLabel": "報酬",
"coinsSuffix": "コイン",
"drawingLabel": "描画",
"addDrawing": "描画を追加",
"editDrawing": "描画を編集",
"shareLabel": "共有",
"saveChangesButton": "変更を保存",
"addTaskButton": "タスクを追加",
@@ -105,6 +108,9 @@
"errorTargetCompletionsMin": "目標達成回数は1以上である必要があります",
"errorInvalidUrl": "有効なURLを入力してください",
"linkLabel": "リンク",
"drawingLabel": "描画",
"addDrawing": "描画を追加",
"editDrawing": "描画を編集",
"shareLabel": "共有",
"saveButton": "変更を保存",
"addButton": "報酬を追加"
@@ -431,5 +437,13 @@
"maxAmountExceededDescription": "金額は{max}を超えることはできません。",
"transactionNotFoundDescription": "取引が見つかりません",
"maxAmountExceededDescription": "金額は{max}を超えることはできません。"
},
"DrawingModal": {
"colorLabel": "色:",
"thicknessLabel": "太さ:",
"undoButton": "元に戻す",
"clearButton": "クリア",
"saveDrawingButton": "描画を保存",
"cancelButton": "キャンセル"
}
}

449
messages/ko.json Normal file
View File

@@ -0,0 +1,449 @@
{
"Dashboard": {
"title": "대시보드"
},
"HabitList": {
"myTasks": "내 할 일",
"myHabits": "내 습관",
"addTaskButton": "할 일 추가",
"addHabitButton": "습관 추가",
"searchTasksPlaceholder": "할 일 검색...",
"searchHabitsPlaceholder": "습관 검색...",
"sortByLabel": "정렬 기준:",
"sortByName": "이름",
"sortByCoinReward": "코인 보상",
"sortByDueDate": "마감일",
"sortByFrequency": "빈도",
"toggleSortOrderAriaLabel": "정렬 순서 토글",
"noTasksFoundMessage": "검색과 일치하는 할 일이 없습니다.",
"noHabitsFoundMessage": "검색과 일치하는 습관이 없습니다.",
"emptyStateTasksTitle": "아직 할 일이 없습니다",
"emptyStateHabitsTitle": "아직 습관이 없습니다",
"emptyStateTasksDescription": "첫 할 일을 만들어 진행 상황을 추적해보세요",
"emptyStateHabitsDescription": "첫 습관을 만들어 진행 상황을 추적해보세요",
"archivedSectionTitle": "보관함",
"deleteTaskDialogTitle": "할 일 삭제",
"deleteHabitDialogTitle": "습관 삭제",
"deleteTaskDialogMessage": "이 할 일을 정말로 삭제하시겠습니까? 이 작업은 되돌릴 수 없습니다.",
"deleteHabitDialogMessage": "이 습관을 정말로 삭제하시겠습니까? 이 작업은 되돌릴 수 없습니다.",
"deleteButton": "삭제"
},
"DailyOverview": {
"addTaskButtonLabel": "할 일 추가",
"addHabitButtonLabel": "습관 추가",
"todaysOverviewTitle": "오늘의 개요",
"dailyTasksTitle": "오늘의 할 일",
"noTasksDueTodayMessage": "오늘 할 일이 없습니다. 할 일을 추가해서 시작하세요!",
"dailyHabitsTitle": "오늘의 습관",
"noHabitsDueTodayMessage": "오늘 습관이 없습니다. 습관을 추가해서 시작하세요!",
"wishlistGoalsTitle": "위시리스트 목표",
"redeemableBadgeLabel": "{count}/{total} 교환 가능",
"noWishlistItemsMessage": "아직 위시리스트 항목이 없습니다. 목표를 추가해서 노력해보세요!",
"readyToRedeemMessage": "교환할 준비 완료!",
"coinsToGoMessage": "{amount} 코인 남음",
"showLessButton": "적게 보기",
"showAllButton": "모두 보기",
"viewButton": "보기",
"deleteTaskDialogTitle": "할 일 삭제",
"deleteHabitDialogTitle": "습관 삭제",
"confirmDeleteDialogMessage": "정말로 \"{name}\"을(를) 삭제하시겠습니까? 이 작업은 되돌릴 수 없습니다.",
"deleteButton": "삭제",
"overdueTooltip": "기한이 지남"
},
"HabitContextMenuItems": {
"startPomodoro": "뽀모도로 시작",
"moveToToday": "오늘로 이동",
"moveToTomorrow": "내일로 이동",
"unpin": "고정 해제",
"pin": "고정",
"edit": "수정",
"archive": "보관",
"unarchive": "보관 해제",
"delete": "삭제"
},
"HabitStreak": {
"dailyCompletionStreakTitle": "일일 달성 연속 기록",
"tooltipHabitsLabel": "습관",
"tooltipTasksLabel": "할 일",
"tooltipCompletedLabel": "완료됨"
},
"CoinBalance": {
"coinBalanceTitle": "코인 잔액"
},
"AddEditHabitModal": {
"editTaskTitle": "할 일 수정",
"editHabitTitle": "습관 수정",
"addNewTaskTitle": "새 할 일 추가",
"addNewHabitTitle": "새 습관 추가",
"nameLabel": "이름 *",
"descriptionLabel": "설명",
"whenLabel": "시기 *",
"completeLabel": "완료",
"timesSuffix": "회",
"rewardLabel": "보상",
"coinsSuffix": "코인",
"drawingLabel": "그림",
"addDrawing": "그림 추가",
"editDrawing": "그림 편집",
"shareLabel": "공유",
"saveChangesButton": "변경 사항 저장",
"addTaskButton": "할 일 추가",
"addHabitButton": "습관 추가"
},
"ConfirmDialog": {
"confirmButton": "확인",
"cancelButton": "취소"
},
"AddEditWishlistItemModal": {
"editTitle": "보상 수정",
"addTitle": "새 보상 추가",
"nameLabel": "이름 *",
"descriptionLabel": "설명",
"costLabel": "비용",
"coinsSuffix": "코인",
"redeemableLabel": "교환 가능",
"timesSuffix": "회",
"errorNameRequired": "이름은 필수입니다",
"errorCoinCostMin": "코인 비용은 최소 1이어야 합니다",
"errorTargetCompletionsMin": "목표 완료 횟수는 최소 1이어야 합니다",
"errorInvalidUrl": "유효한 URL을 입력하세요",
"linkLabel": "링크",
"drawingLabel": "그림",
"addDrawing": "그림 추가",
"editDrawing": "그림 편집",
"shareLabel": "공유",
"saveButton": "변경 사항 저장",
"addButton": "보상 추가"
},
"Navigation": {
"dashboard": "대시보드",
"tasks": "할 일",
"habits": "습관",
"calendar": "캘린더",
"wishlist": "위시리스트",
"coins": "코인"
},
"TodayEarnedCoins": {
"todaySuffix": "오늘"
},
"WishlistItem": {
"usesLeftSingular": "사용 횟수 남음",
"usesLeftPlural": "사용 횟수 남음",
"coinsSuffix": "코인",
"redeem": "교환",
"redeemedDone": "완료",
"redeemedExclamation": "교환 완료!",
"editButton": "수정",
"archiveButton": "보관",
"unarchiveButton": "보관 해제",
"deleteButton": "삭제"
},
"WishlistManager": {
"title": "내 위시리스트",
"addRewardButton": "보상 추가",
"emptyStateTitle": "위시리스트가 비어 있습니다",
"emptyStateDescription": "코인으로 얻고 싶은 보상을 추가하세요",
"archivedSectionTitle": "보관함",
"popupBlockedTitle": "팝업 차단됨",
"popupBlockedDescription": "링크를 열려면 팝업을 허용해주세요",
"deleteDialogTitle": "보상 삭제",
"deleteDialogMessage": "이 보상을 정말로 삭제하시겠습니까? 이 작업은 되돌릴 수 없습니다.",
"deleteButton": "삭제"
},
"UserSelectModal": {
"addUserButton": "사용자 추가",
"createNewUserTitle": "새 사용자 생성",
"selectUserTitle": "사용자 선택",
"signInSuccessTitle": "로그인 성공",
"signInSuccessDescription": "환영합니다, {username}님!",
"errorInvalidPassword": "잘못된 비밀번호",
"deleteUserConfirmation": "사용자 {username}을(를) 정말로 삭제하시겠습니까? 이 작업은 되돌릴 수 없습니다.",
"confirmDeleteButtonText": "삭제",
"deletingButtonText": "삭제 중...",
"deleteUserSuccessTitle": "사용자 삭제됨",
"deleteUserSuccessDescription": "사용자 {username}이(가) 성공적으로 삭제되었습니다.",
"deleteUserErrorTitle": "삭제 실패",
"genericError": "예기치 않은 오류가 발생했습니다.",
"networkError": "네트워크 오류가 발생했습니다. 다시 시도해주세요.",
"deleteUserTooltip": "사용자 삭제",
"editUserTooltip": "사용자 수정"
},
"CoinsManager": {
"title": "코인 관리",
"currentBalanceLabel": "현재 잔액",
"coinsSuffix": "코인",
"addCoinsButton": "코인 추가",
"removeCoinsButton": "코인 제거",
"statisticsTitle": "통계",
"totalEarnedLabel": "총 획득 코인",
"totalSpentLabel": "총 사용 코인",
"totalTransactionsLabel": "총 거래 횟수",
"todaysEarnedLabel": "오늘 획득 코인",
"todaysSpentLabel": "오늘 사용 코인",
"todaysTransactionsLabel": "오늘 거래 횟수",
"transactionHistoryTitle": "거래 내역",
"showLabel": "표시:",
"entriesSuffix": "개",
"showingEntries": "{from}개부터 {to}개까지 총 {total}개 항목 표시",
"noTransactionsTitle": "아직 거래 내역이 없습니다",
"noTransactionsDescription": "코인을 획득하거나 사용하기 시작하면 거래 내역이 여기에 나타납니다",
"pageLabel": "페이지",
"ofLabel": "중",
"transactionTypeHabitCompletion": "습관 완료",
"transactionTypeTaskCompletion": "할 일 완료",
"transactionTypeHabitUndo": "습관 되돌리기",
"transactionTypeTaskUndo": "할 일 되돌리기",
"transactionTypeWishRedemption": "위시리스트 교환",
"transactionTypeManualAdjustment": "수동 조정",
"transactionTypeCoinReset": "코인 초기화",
"transactionTypeInitialBalance": "초기 잔액"
},
"NotificationBell": {
"errorUpdateTimestamp": "알림 읽은 시각을 업데이트하지 못했습니다:"
},
"PomodoroTimer": {
"focusLabel1": "집중하세요",
"focusLabel2": "할 수 있어요",
"focusLabel3": "계속하세요",
"focusLabel4": "해내세요",
"focusLabel5": "실행하세요",
"focusLabel6": "강하게 유지하세요",
"focusLabel7": "밀어붙이세요",
"focusLabel8": "한 번에 한 걸음씩",
"focusLabel9": "당신은 해낼 수 있어요",
"focusLabel10": "집중하고 정복하세요",
"breakLabel1": "휴식을 취하세요",
"breakLabel2": "편안하게 재충전하세요",
"breakLabel3": "깊게 숨쉬세요",
"breakLabel4": "몸을 쭉 펴세요",
"breakLabel5": "기분 전환하세요",
"breakLabel6": "당신은 그럴 자격이 있어요",
"breakLabel7": "에너지를 재충전하세요",
"breakLabel8": "잠시 벗어나세요",
"breakLabel9": "마음을 비우세요",
"breakLabel10": "휴식하고 활력을 되찾으세요",
"focusType": "집중",
"breakType": "휴식",
"pauseButton": "일시 정지",
"startButton": "시작",
"resetButton": "초기화",
"skipButton": "건너뛰기",
"wakeLockNotSupported": "브라우저가 wakelock을 지원하지 않습니다",
"wakeLockInUse": "Wake lock이 이미 사용 중입니다",
"wakeLockRequestError": "wake lock 요청 오류:",
"wakeLockReleaseError": "wake lock 해제 오류:"
},
"HabitCalendar": {
"title": "습관 캘린더",
"calendarCardTitle": "캘린더",
"selectDatePrompt": "날짜 선택",
"tasksSectionTitle": "할 일",
"habitsSectionTitle": "습관",
"errorCompletingPastHabit": "과거 습관 완료 오류:"
},
"NotificationDropdown": {
"notLoggedIn": "로그인되지 않았습니다.",
"userCompletedItem": "{username}님이 {itemName}을(를) 완료했습니다.",
"userRedeemedItem": "{username}님이 {itemName}을(를) 교환했습니다.",
"activityRelatedToItem": "{username}님의 {itemName} 관련 활동.",
"defaultUsername": "어떤 사용자",
"defaultItemName": "공유된 항목",
"notificationsTitle": "알림",
"notificationsTooltip": "공유한 습관이나 위시리스트에 대해 다른 사용자의 완료 또는 교환 내역을 보여줍니다 (관리자여야 합니다)",
"noNotificationsYet": "아직 알림이 없습니다."
},
"AboutModal": {
"dialogArisLabel": "정보",
"changelogButton": "변경 로그",
"createdByPrefix": "❤️로 제작: ",
"starOnGitHubButton": "GitHub에서 별 누르기"
},
"PermissionSelector": {
"permissionsTitle": "권한",
"adminAccessLabel": "관리자 접근",
"adminAccessDescription": "관리자는 모든 사용자의 모든 데이터에 대한 전체 권한을 가집니다",
"resourceHabitTask": "습관 / 할 일",
"resourceWishlist": "위시리스트",
"resourceCoins": "코인",
"permissionWrite": "쓰기",
"permissionInteract": "상호 작용"
},
"UserForm": {
"toastUserUpdatedTitle": "사용자 업데이트됨",
"toastUserUpdatedDescription": "사용자 {username}이(가) 성공적으로 업데이트되었습니다",
"toastUserCreatedTitle": "사용자 생성됨",
"toastUserCreatedDescription": "사용자 {username}이(가) 성공적으로 생성되었습니다",
"actionUpdate": "업데이트",
"actionCreate": "생성",
"errorFailedUserAction": "사용자 {action} 실패",
"errorTitle": "오류",
"errorFileSizeLimit": "파일 크기는 5MB 미만이어야 합니다",
"toastAvatarUploadedTitle": "아바타 업로드됨",
"toastAvatarUploadedDescription": "아바타가 성공적으로 업로드되었습니다",
"errorFailedAvatarUpload": "아바타 업로드 실패",
"changeAvatarButton": "아바타 변경",
"uploadAvatarButton": "아바타 업로드",
"usernameLabel": "사용자 이름",
"usernamePlaceholder": "사용자 이름",
"newPasswordLabel": "새 비밀번호",
"passwordLabel": "비밀번호",
"passwordPlaceholderEdit": "현재 비밀번호를 유지하려면 비워두세요",
"passwordPlaceholderCreate": "비밀번호를 입력하세요",
"demoPasswordDisabledMessage": "데모 인스턴스에서는 비밀번호가 자동으로 비활성화됩니다",
"disablePasswordLabel": "비밀번호 비활성화",
"cancelButton": "취소",
"saveChangesButton": "변경 사항 저장",
"createUserButton": "사용자 생성",
"deleteAccountButton": "계정 삭제",
"toastDemoDeleteDisabled": "데모 인스턴스에서는 삭제가 비활성화되어 있습니다",
"toastCannotDeleteSelf": "본인의 계정을 삭제할 수 없습니다",
"confirmDeleteUser": "사용자 {username}을(를) 삭제하시겠습니까?",
"toastUserDeletedTitle": "사용자 삭제됨",
"toastUserDeletedDescription": "사용자 {username}이(가) 성공적으로 삭제되었습니다",
"toastDeleteUserFailed": "사용자 삭제 실패: {error}",
"deletingButtonText": "삭제 중...",
"areYouSure": "정말로 삭제하시겠습니까?",
"deleteUserConfirmation": "사용자 {username}을(를) 삭제하시겠습니까?",
"cancel": "취소",
"confirmDeleteButtonText": "삭제"
},
"ViewToggle": {
"habitsLabel": "습관",
"tasksLabel": "할 일"
},
"HabitItem": {
"overdue": "기한이 지남",
"whenLabel": "시기: {frequency}",
"coinsPerCompletion": "완료당 {count} 코인",
"completedStatus": "완료됨",
"completedStatusCount": "완료됨 ({completed}/{target})",
"completedStatusCountMobile": "{completed}/{target}",
"completeButton": "완료",
"completeButtonCount": "완료 ({completed}/{target})",
"completeButtonCountMobile": "{completed}/{target}",
"undoButton": "실행 취소",
"editButton": "수정"
},
"TransactionNoteEditor": {
"noteTooLongTitle": "메모가 너무 깁니다",
"noteTooLongDescription": "메모는 200자 미만이어야 합니다",
"errorSavingNoteTitle": "메모 저장 오류",
"errorDeletingNoteTitle": "메모 삭제 오류",
"pleaseTryAgainDescription": "다시 시도해주세요",
"addNotePlaceholder": "메모 추가...",
"saveNoteTitle": "메모 저장",
"cancelButtonTitle": "취소",
"deleteNoteTitle": "메모 삭제",
"editNoteAriaLabel": "메모 수정"
},
"Profile": {
"guestUsername": "게스트",
"editProfileButton": "프로필 수정",
"signOutSuccessTitle": "로그아웃 성공",
"signOutSuccessDescription": "계정에서 로그아웃되었습니다",
"signOutErrorTitle": "로그아웃 오류",
"signOutErrorDescription": "로그아웃 실패",
"switchUserButton": "사용자 전환",
"settingsLink": "설정",
"aboutButton": "정보",
"themeLabel": "테마",
"editProfileModalTitle": "프로필 수정"
},
"PasswordEntryForm": {
"notYouButton": "당신이 아닙니까?",
"passwordLabel": "비밀번호",
"passwordPlaceholder": "비밀번호를 입력하세요",
"loginErrorToastTitle": "오류",
"loginFailedErrorToastDescription": "로그인 실패",
"cancelButton": "취소",
"loginButton": "로그인"
},
"CompletionCountBadge": {
"countCompleted": "{completedCount}/{totalCount} 완료됨"
},
"SettingsPage": {
"title": "설정",
"uiSettingsTitle": "UI 설정",
"numberFormattingLabel": "숫자 형식 지정",
"numberFormattingDescription": "큰 숫자 형식 지정 (예: 1K, 1M, 1B)",
"numberGroupingLabel": "숫자 그룹화",
"numberGroupingDescription": "천 단위 구분 기호 사용 (예: 1,000 vs 1000)",
"systemSettingsTitle": "시스템 설정",
"timezoneLabel": "시간대",
"timezoneDescription": "정확한 날짜 추적을 위해 시간대를 선택하세요",
"weekStartDayLabel": "주 시작 요일",
"weekStartDayDescription": "선호하는 주의 첫째 날을 선택하세요",
"weekdays": {
"sunday": "일요일",
"monday": "월요일",
"tuesday": "화요일",
"wednesday": "수요일",
"thursday": "목요일",
"friday": "금요일",
"saturday": "토요일"
},
"autoBackupLabel": "자동 백업",
"autoBackupTooltip": "활성화하면 애플리케이션 데이터(습관, 코인, 설정 등)가 서버 시간으로 매일 새벽 2시경에 자동으로 백업됩니다. 백업은 프로젝트 루트의 `backups/` 디렉터리에 ZIP 파일로 저장됩니다. 최근 7개의 백업만 보관되며, 오래된 백업은 자동으로 삭제됩니다.",
"autoBackupDescription": "매일 데이터 자동 백업",
"languageLabel": "언어",
"languageDescription": "애플리케이션의 표시 언어를 선택하세요.",
"languageChangedTitle": "언어 변경됨",
"languageChangedDescription": "변경 사항을 보려면 페이지를 새로고침하세요",
"languageDisabledInDemoTooltip": "데모 버전에서는 언어 변경이 비활성화됩니다."
},
"Common": {
"authenticationRequiredTitle": "인증 필요",
"authenticationRequiredDescription": "계속하려면 로그인하세요.",
"permissionDeniedTitle": "권한 거부됨",
"permissionDeniedDescription": "{resource}에 대한 {action} 권한이 없습니다.",
"undoButton": "실행 취소",
"redoButton": "다시 실행",
"errorTitle": "오류"
},
"useHabits": {
"alreadyCompletedTitle": "이미 완료됨",
"alreadyCompletedDescription": "오늘 이미 이 습관을 완료했습니다.",
"completedTitle": "완료!",
"earnedCoinsDescription": "{coinReward} 코인을 획득했습니다.",
"progressTitle": "진행 중!",
"progressDescription": "오늘 {count}/{target}회 완료했습니다.",
"completionUndoneTitle": "완료 취소됨",
"completionUndoneDescription": "오늘 {count}/{target}회 완료했습니다.",
"noCompletionsToUndoTitle": "실행 취소할 완료 내역 없음",
"noCompletionsToUndoDescription": "이 습관은 오늘 완료되지 않았습니다.",
"alreadyCompletedPastDateTitle": "이미 완료됨",
"alreadyCompletedPastDateDescription": "이 습관은 {dateKey}에 이미 완료되었습니다.",
"earnedCoinsPastDateDescription": "{dateKey}에 {coinReward} 코인을 획득했습니다.",
"progressPastDateDescription": "{dateKey}에 {count}/{target}회 완료했습니다."
},
"useWishlist": {
"redemptionLimitReachedTitle": "교환 한도 도달",
"redemptionLimitReachedDescription": "\"{itemName}\"에 대한 최대 교환 횟수에 도달했습니다.",
"rewardRedeemedTitle": "🎉 보상 교환됨!",
"rewardRedeemedDescription": "보상 \"{itemName}\"을(를) {itemCoinCost} 코인으로 교환했습니다.",
"notEnoughCoinsTitle": "코인 부족",
"notEnoughCoinsDescription": "이 보상을 교환하려면 {coinsNeeded} 코인이 더 필요합니다."
},
"useCoins": {
"addedCoinsDescription": "{amount} 코인 추가됨",
"invalidAmountTitle": "유효하지 않은 금액",
"invalidAmountDescription": "유효한 양의 숫자를 입력하세요",
"successTitle": "성공",
"transactionNotFoundDescription": "거래를 찾을 수 없습니다",
"maxAmountExceededDescription": "금액은 {max}을(를) 초과할 수 없습니다.",
"transactionNotFoundDescription": "거래를 찾을 수 없습니다",
"maxAmountExceededDescription": "금액은 {max}을(를) 초과할 수 없습니다."
},
"Warning": {
"areYouSure": "확실합니까?",
"cancel": "취소"
},
"DrawingModal": {
"colorLabel": "색상:",
"thicknessLabel": "두께:",
"undoButton": "실행 취소",
"clearButton": "지우기",
"saveDrawingButton": "그림 저장",
"cancelButton": "취소"
}
}

View File

@@ -82,6 +82,9 @@
"timesSuffix": "раз",
"rewardLabel": "Награда",
"coinsSuffix": "монет",
"drawingLabel": "Рисунок",
"addDrawing": "Добавить Рисунок",
"editDrawing": "Редактировать Рисунок",
"shareLabel": "Поделиться",
"saveChangesButton": "Сохранить",
"addTaskButton": "Добавить задачу",
@@ -105,6 +108,9 @@
"errorTargetCompletionsMin": "Минимум 1 выполнение",
"errorInvalidUrl": "Некорректная ссылка",
"linkLabel": "Ссылка",
"drawingLabel": "Рисунок",
"addDrawing": "Добавить Рисунок",
"editDrawing": "Редактировать Рисунок",
"shareLabel": "Поделиться",
"saveButton": "Сохранить",
"addButton": "Добавить цель"
@@ -431,5 +437,13 @@
"maxAmountExceededDescription": "Сумма не может превышать {max}.",
"transactionNotFoundDescription": "Транзакция не найдена",
"maxAmountExceededDescription": "Сумма не может превышать {max}."
},
"DrawingModal": {
"colorLabel": "Цвет:",
"thicknessLabel": "Толщина:",
"undoButton": "Отменить",
"clearButton": "Очистить",
"saveDrawingButton": "Сохранить рисунок",
"cancelButton": "Отмена"
}
}

View File

@@ -82,6 +82,9 @@
"timesSuffix": "次",
"rewardLabel": "奖励",
"coinsSuffix": "金币",
"drawingLabel": "绘图",
"addDrawing": "添加绘图",
"editDrawing": "编辑绘图",
"shareLabel": "分享",
"saveChangesButton": "保存更改",
"addTaskButton": "添加任务",
@@ -105,6 +108,9 @@
"errorTargetCompletionsMin": "目标完成次数至少为 1",
"errorInvalidUrl": "请输入有效的 URL",
"linkLabel": "链接",
"drawingLabel": "绘图",
"addDrawing": "添加绘图",
"editDrawing": "编辑绘图",
"shareLabel": "分享",
"saveButton": "保存更改",
"addButton": "添加奖励"
@@ -431,5 +437,13 @@
"maxAmountExceededDescription": "金额不能超过 {max}。",
"transactionNotFoundDescription": "未找到交易记录",
"maxAmountExceededDescription": "金额不能超过 {max}。"
},
"DrawingModal": {
"colorLabel": "颜色:",
"thicknessLabel": "粗细:",
"undoButton": "撤销",
"clearButton": "清除",
"saveDrawingButton": "保存绘图",
"cancelButton": "取消"
}
}

595
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,6 @@
{
"name": "habittrove",
"version": "0.2.22",
"version": "0.2.30",
"private": true,
"scripts": {
"dev": "next dev --turbopack",
@@ -43,12 +43,12 @@
"date-fns": "^3.6.0",
"jotai": "^2.8.0",
"js-confetti": "^0.12.0",
"json-stable-stringify": "^1.3.0",
"linkify": "^0.2.1",
"linkify-react": "^4.2.0",
"lodash": "^4.17.21",
"lucide-react": "^0.469.0",
"luxon": "^3.5.0",
"next": "15.2.3",
"next": "^v15.5.7",
"next-auth": "^5.0.0-beta.25",
"next-intl": "^4.1.0",
"next-themes": "^0.4.4",
@@ -62,7 +62,6 @@
"rrule": "^2.8.1",
"tailwind-merge": "^2.6.0",
"tailwindcss-animate": "^1.0.7",
"uuid": "^11.0.5",
"web-push": "^3.6.7",
"zod": "^3.24.1"
},
@@ -71,7 +70,6 @@
"@tailwindcss/typography": "^0.5.15",
"@types/archiver": "^6.0.3",
"@types/bun": "^1.1.14",
"@types/json-stable-stringify": "^1.1.0",
"@types/lodash": "^4.17.15",
"@types/luxon": "^3.4.2",
"@types/node": "^20.17.10",

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.4 KiB

After

Width:  |  Height:  |  Size: 8.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 28 KiB

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 130 KiB

After

Width:  |  Height:  |  Size: 101 KiB