Compare commits

..

31 Commits

Author SHA1 Message Date
dohsimpson
5ae659469b fix infinite render 2025-05-28 17:43:28 -04:00
dohsimpson
6ef4aacfb8 fix coin balance 2025-05-28 17:17:13 -04:00
dohsimpson
95203426a3 fix modal and invalid frequency 2025-05-27 02:42:13 -04:00
Doh
b673d54ede Added improved loading screen (#148) 2025-05-26 08:42:00 -04:00
Doh
42c8d14d6d fix emoji picker and about modal (#146) 2025-05-25 20:33:08 -04:00
Doh
3ac311c3fd add cover image in README 2025-05-25 20:27:46 -04:00
Doh
1a286a99f4 feat: Move delete account button to user edit modal (#144)
Co-authored-by: google-labs-jules[bot] <161369871+google-labs-jules[bot]@users.noreply.github.com>
2025-05-25 17:30:41 -04:00
Doh
82f45343ae max coin limit (#140)
Co-authored-by: google-labs-jules[bot] <161369871+google-labs-jules[bot]@users.noreply.github.com>
2025-05-22 22:05:49 -04:00
Doh
a3d2b1ef96 support delete user (#139)
Co-authored-by: google-labs-jules[bot] <161369871+google-labs-jules[bot]@users.noreply.github.com>
2025-05-22 21:24:30 -04:00
Doh
ac71c94d53 fix responsive (#135) 2025-05-20 22:12:31 -04:00
Doh
91ffe46863 Added i18n support (#129) 2025-05-18 09:00:48 -04:00
Doh
95197e216c Update README.md 2025-05-10 19:55:03 -04:00
Doh
660005d857 Show overdue tasks and improved context menu (#110) 2025-05-10 15:51:39 -04:00
Doh
2408ed84bd performance optimization via atoms (#108) 2025-04-20 12:14:51 -04:00
Doh
dda8b522e3 Added auto-backups feature (#107) 2025-04-17 23:18:37 -04:00
Doh
909bfa7c6f Added notification for admin user (#106) 2025-04-13 22:01:07 -04:00
dohsimpson
e53e2f649a fix build 2025-04-10 17:03:07 -04:00
dohsimpson
a42c0324c5 fix build 2025-04-10 16:56:11 -04:00
Doh
685cb80321 add support for habit pinning (#105) 2025-04-10 16:47:59 -04:00
Doh
f1e3ee5747 support interval habit frequency (#104) 2025-04-10 15:33:33 -04:00
dohsimpson
d31982bf29 [SECURITY] patched cve-2025-29927 2025-03-25 10:47:03 -04:00
Doh
9052c9f37a per-user coins data for admin (#82)
* admin user can see per-user coins data

* fixes

* fix
2025-02-28 17:07:44 -05:00
dohsimpson
a615a45c39 fix demo bugs 2025-02-26 18:51:13 -05:00
dohsimpson
dea2b30c3b fix completion badge 2025-02-21 18:16:15 -05:00
Doh
ea0203dc86 added iOS padding (#69) 2025-02-19 20:00:17 -05:00
dohsimpson
b7933ea040 fix build error 2025-02-18 23:59:41 -05:00
Doh
8ac2ec053d Multiuser support (#60) 2025-02-18 23:43:23 -05:00
dohsimpson
363b31e934 fix mobile responsive layout for habit and task list 2025-01-27 22:41:27 -05:00
dohsimpson
7065d5694b fix redeem link 2025-01-27 18:43:15 -05:00
dohsimpson
b62cf77ba8 redeem link + completing task + play sound 2025-01-27 18:24:53 -05:00
dohsimpson
c66e28162c dark mode 2025-01-25 13:03:07 -05:00
93 changed files with 10914 additions and 1245 deletions

View File

@@ -52,13 +52,13 @@ jobs:
push: true
tags: |
${{ steps.check-version.outputs.EXISTS == 'false' && format('dohsimpson/habittrove:v{0}', steps.package-version.outputs.VERSION) || '' }}
${{ steps.check-version.outputs.EXISTS == 'false' && 'dohsimpson/habittrove:latest' || '' }}
dohsimpson/habittrove:dev
dohsimpson/habittrove:demo
dohsimpson/habittrove:latest
deploy-demo:
runs-on: ubuntu-latest
needs: build-and-push
# demo tracks the latest tag
# demo tracks the demo tag
if: needs.build-and-push.outputs.EXISTS == 'false'
steps:
- uses: actions/checkout@v4

View File

@@ -21,5 +21,8 @@ jobs:
- name: Install dependencies
run: bun install
- name: Run lint
run: bun run lint
- name: Run unit tests
run: bun test

4
.gitignore vendored
View File

@@ -41,6 +41,8 @@ yarn-error.log*
next-env.d.ts
# customize
data/*
/data/*
/data.*/*
Budfile
certificates
/backups/*

View File

@@ -1,7 +1 @@
if git diff --cached --name-only --diff-filter=d | xargs grep -n '🪚'; then
echo "Error: Found debug marker 🪚 in these files:"
git diff --cached --name-only --diff-filter=d | xargs grep -n '🪚' | awk -F: '{print " " $1 ":" $2}'
exit 1
fi
npm run typecheck && npm run test
npm run typecheck && npm run lint && npm run test

View File

@@ -1,5 +1,212 @@
# Changelog
## Version 0.2.20
### Fixed
* coin balance shows correct value for selected user in coin management view (#151)
### Improved
* refactor code to remove client-helpers hook
## Version 0.2.19
### Fixed
* settings button not working
* fixed delete dialog modal blocks page interaction (#149)
* disable submit button when frequency is invaid
## Version 0.2.18
### Improved
* nicer loading UI (#147)
* header and navigation code refactor
## Version 0.2.17
### Fixed
* fix emoji selector (#142)
* fix about modal (#145)
## Version 0.2.16
### Improved
* move delete user button to user form
* disable deleting user on demo instance
## Version 0.2.15
### Improved
* max coins set to 9999, to prevent js large number precision issue (#137)
## Version 0.2.14
### Added
* support deleting user (#93)
## Version 0.2.13
### Fixed
* fix responsive design on mobile (#134)
* fix translation (#132)
* fix latest docker tag auto build (#131)
## Version 0.2.12
### Added
* 🌍 Added multi-language support! Users can now select their preferred language in settings.
* Supported languages: English, Español (Spanish), Deutsch (German), Français (French), Русский (Russian), 简体中文 (Simplified Chinese) and 日本語 (Japanese).
## Version 0.2.11
### Added
* support searching and sorting in habit list
### Improved
* Show overdue tasks in daily overview
* Context menu option for tasks changed from "Move to Today" to "Move to Tomorrow"
* More context menu items in daily overview
* code refactor for context menu and daily overview item section
## Version 0.2.10
### Improved
* performance optimization: faster load time for large data set
## Version 0.2.9
### Added
* Auto backup feature: Automatically backs up data
* Backup rotation: Keeps the last 7 daily backups
* Setting to enable/disable auto backup.
## Version 0.2.8
### Added
* notification for admin users on shared habit / wishlist completion (#92)
## Version 0.2.7
### Added
* visual pin indicators for pinned habits/tasks
* pin/unpin options in context menus
* support click and right-click context menu in dailyoverview
## Version 0.2.6
### Added
* support weekly / monthly intervals for recurring frequency (#99)
* show error when frequency is unsupported (#56)
* add task / habit button in habit view
### Fixed
* make user select modal scrollable
## Version 0.2.5
### Changed
* bumped Nextjs version (cve-2025-29927)
## Version 0.2.4
### Added
* admin can select user to view coins for that user
### Fixed
* fix disable password in demo instance (#74)
## Version 0.2.3
### Fixed
* gracefully handle invalid rrule (#76)
* fix long habit name overflow in daily (#75)
* disable password in demo instance (#74)
## Version 0.2.2
### Changed
* persist "show all" settings in browser (#72)
### Fixed
* nav bar spacing
* completion count badge
## Version 0.2.1
### Changed
* Added bottom padding for nav bar on iOS devices (#63)
## Version 0.2.0
### Added
* Multi-user support with permissions system
* Sharing habits and wishlist items with other users
* show both tasks and habits in dashboard (#58)
* show tasks in completion streak (#57)
### BREAKING CHANGE
* PLEASE BACK UP `data/` DIRECTORY BEFORE UPGRADE.
* Requires AUTH_SECRET environment variable for user authentication. Generate a secure secret with: `openssl rand -base64 32`
* Previous coin balance will be hidden. If this is undesirable, consider using manual adjustment to adjust coin balance after upgrade.
## Version 0.1.30
### Fixed
- fix responsive layout on mobile for habits and wishlist when has archived items
## Version 0.1.29
### Fixed
- actually working redeem link for wishlist items (#52)
## Version 0.1.28
### Added
- redeem link for wishlist items (#52)
- sound effect for habit / task completion (#53)
### Fixed
- fail habit create or edit if frequency is not set (#54)
- archive task when completed (#50)
## Version 0.1.27
### Added
- dark mode toggle (#48)
- notification badge for tasks (#51)
## Version 0.1.26
### Added

View File

@@ -1,9 +1,9 @@
# syntax=docker.io/docker/dockerfile:1
FROM --platform=$BUILDPLATFORM node:18-alpine AS base
FROM node:18-alpine AS base
# Install dependencies only when needed
FROM --platform=$BUILDPLATFORM base AS deps
FROM base AS deps
# Check https://github.com/nodejs/docker-node/tree/b4117f9333da4138b03a546ec926ef50a31506c3#nodealpine to understand why libc6-compat might be needed.
RUN apk add --no-cache libc6-compat
WORKDIR /app
@@ -19,7 +19,7 @@ RUN \
fi
# Rebuild the source code only when needed
FROM --platform=$BUILDPLATFORM base AS builder
FROM base AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
@@ -43,8 +43,9 @@ ENV NEXT_TELEMETRY_DISABLED=1
RUN addgroup --system --gid 1001 nodejs
RUN adduser --system --uid 1001 nextjs
# Create data directory and set permissions
RUN mkdir -p /app/data && chown nextjs:nodejs /app/data
# Create data and backups directories and set permissions
RUN 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 ./
@@ -61,6 +62,6 @@ EXPOSE 3000
ENV PORT=3000
ENV HOSTNAME="0.0.0.0"
VOLUME ["/app/data"]
VOLUME ["/app/data", "/app/backups"]
CMD ["node", "server.js"]

View File

@@ -1,12 +1,14 @@
# HabitTrove
![cover](https://github.com/user-attachments/assets/b63e98b4-64ae-49c7-ae7e-21ef76c04a5a)
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.
## Try the Demo
Want to try HabitTrove before installing? Visit the public [demo instance](https://habittrove.app.enting.org) to experience all features without any setup required. (do not store personal info. Data on the demo instance is reset daily)
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)
## Features
@@ -15,8 +17,10 @@ Want to try HabitTrove before installing? Visit the public [demo instance](https
- 💰 Create a wishlist of rewards to redeem with earned coins
- 📊 View your habit completion streaks and statistics
- 📅 Calendar heatmap to visualize your progress (WIP)
- 🌙 Dark mode support (WIP)
- 📲 Progressive Web App (PWA) support (Planned)
- 🌍 Multi-language support (English, Español, Deutsch, Français, Русский, 简体中文, 日本語)
- 🌙 Dark mode support
- 📲 Progressive Web App (PWA) support
- 💾 Automatic daily backups with rotation
## Usage
@@ -39,18 +43,30 @@ The easiest way to run HabitTrove is using our pre-built Docker images from Dock
1. First, prepare the data directory with correct permissions:
```bash
mkdir -p data
chown -R 1001:1001 data # Required for the nextjs user in container
mkdir -p data backups
chown -R 1001:1001 data backups # Required for the nextjs user in container
```
2. Then run using either method:
```bash
# Generate a secure authentication secret
export AUTH_SECRET=$(openssl rand -base64 32)
echo $AUTH_SECRET
# Using docker-compose (recommended)
## Update the AUTH_SECRET environment variable in docker-compose.yaml
nano docker-compose.yaml
## Start the container
docker compose up -d
# Or using docker run directly
docker run -d -p 3000:3000 -v ./data:/app/data dohsimpson/habittrove
docker run -d \
-p 3000:3000 \
-v ./data:/app/data \
-v ./backups:/app/backups \ # Add this line to map the backups directory
-e AUTH_SECRET=$AUTH_SECRET \
dohsimpson/habittrove
```
Available image tags:
@@ -62,9 +78,11 @@ Available image tags:
Choose your tag based on needs:
- Use `latest` for general production use
- Use version tags (e.g., `v0.1.4`) for reproducible deployments
- Use version tags (e.g., `v0.2.9`) for reproducible deployments
- Use `dev` for testing new features
**Note on Volumes:** The application stores user data in `/app/data` and backups in `/app/backups` inside the container. The examples above map `./data` and `./backups` from your host machine to these container directories. Ensure these host directories exist and have the correct permissions (`chown -R 1001:1001 data backups`).
### Building Locally
If you want to build the image locally (useful for development):

View File

@@ -12,9 +12,44 @@ import {
Settings,
DataType,
DATA_DEFAULTS,
getDefaultSettings
getDefaultSettings,
UserData,
getDefaultUsersData,
User,
getDefaultWishlistData,
getDefaultHabitsData,
getDefaultCoinsData,
Permission,
ServerSettings
} from '@/lib/types'
import { d2t, getNow } from '@/lib/utils';
import { d2t, deepMerge, getNow, checkPermission, uuid } from '@/lib/utils';
import { verifyPassword } from "@/lib/server-helpers";
import { saltAndHashPassword } from "@/lib/server-helpers";
import { signInSchema } from '@/lib/zod';
import { auth } from '@/auth';
import _ from 'lodash';
import { getCurrentUser, getCurrentUserId } from '@/lib/server-helpers'
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;
@@ -29,6 +64,27 @@ async function ensureDataDir() {
}
}
// --- Backup Debug Action ---
export async function triggerManualBackup(): Promise<{ success: boolean; message: string }> {
// Optional: Add extra permission check if needed for debug actions
// const user = await getCurrentUser();
// if (!user?.isAdmin) {
// return { success: false, message: "Permission denied." };
// }
console.log("Manual backup trigger requested...");
try {
// Import runBackup locally to avoid potential circular dependencies if moved
const { runBackup } = await import('@/lib/backup');
await runBackup();
console.log("Manual backup trigger completed successfully.");
return { success: true, message: "Backup process completed successfully." };
} catch (error) {
console.error("Manual backup trigger failed:", error);
return { success: false, message: `Backup failed: ${error instanceof Error ? error.message : 'Unknown error'}` };
}
}
async function loadData<T>(type: DataType): Promise<T> {
try {
await ensureDataDir()
@@ -45,7 +101,7 @@ async function loadData<T>(type: DataType): Promise<T> {
// File exists, read and return its contents
const data = await fs.readFile(filePath, 'utf8')
const jsonData = JSON.parse(data)
const jsonData = JSON.parse(data) as T
return jsonData
} catch (error) {
console.error(`Error loading ${type} data:`, error)
@@ -55,6 +111,9 @@ async function loadData<T>(type: DataType): Promise<T> {
async function saveData<T>(type: DataType, data: T): Promise<void> {
try {
const user = await getCurrentUser()
if (!user) throw new Error('User not authenticated')
await ensureDataDir()
const filePath = path.join(process.cwd(), 'data', `${type}.json`)
const saveData = data
@@ -66,7 +125,14 @@ async function saveData<T>(type: DataType, data: T): Promise<void> {
// Wishlist specific functions
export async function loadWishlistData(): Promise<WishlistData> {
return loadData<WishlistData>('wishlist')
const user = await getCurrentUser()
if (!user) return getDefaultWishlistData()
const data = await loadData<WishlistData>('wishlist')
return {
...data,
items: data.items.filter(x => user.isAdmin || x.userIds?.includes(user.id))
}
}
export async function loadWishlistItems(): Promise<WishlistItemType[]> {
@@ -74,31 +140,98 @@ export async function loadWishlistItems(): Promise<WishlistItemType[]> {
return data.items
}
export async function saveWishlistItems(items: WishlistItemType[]): Promise<void> {
return saveData('wishlist', { items })
export async function saveWishlistItems(data: WishlistData): Promise<void> {
await verifyPermission('wishlist', 'write')
const user = await getCurrentUser()
data.items = data.items.map(wishlist => ({
...wishlist,
userIds: wishlist.userIds || (user ? [user.id] : undefined)
}))
if (!user?.isAdmin) {
const existingData = await loadData<WishlistData>('wishlist')
existingData.items = existingData.items.filter(x => user?.id && !x.userIds?.includes(user?.id))
data.items = [
...existingData.items,
...data.items
]
}
return saveData('wishlist', data)
}
// Habits specific functions
export async function loadHabitsData(): Promise<HabitsData> {
return loadData<HabitsData>('habits')
const user = await getCurrentUser()
if (!user) return getDefaultHabitsData()
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> {
return saveData('habits', data)
await verifyPermission('habit', 'write')
const user = await getCurrentUser()
// Create clone of input data
const newData = _.cloneDeep(data)
// Map habits with user IDs
newData.habits = newData.habits.map(habit => ({
...habit,
userIds: habit.userIds || (user ? [user.id] : undefined)
}))
if (!user?.isAdmin) {
const existingData = await loadData<HabitsData>('habits')
const existingHabits = existingData.habits.filter(x => user?.id && !x.userIds?.includes(user?.id))
newData.habits = [
...existingHabits,
...newData.habits
]
}
return saveData('habits', newData)
}
// Coins specific functions
export async function loadCoinsData(): Promise<CoinsData> {
try {
return await loadData<CoinsData>('coins')
const user = await getCurrentUser()
if (!user) return getDefaultCoinsData()
const data = await loadData<CoinsData>('coins')
return {
...data,
transactions: user.isAdmin ? data.transactions : data.transactions.filter(x => x.userId === user.id)
}
} catch {
return { balance: 0, transactions: [] }
return getDefaultCoinsData()
}
}
export async function saveCoinsData(data: CoinsData): Promise<void> {
return saveData('coins', data)
const user = await getCurrentUser()
// Create clones of the data
const newData = _.cloneDeep(data)
newData.transactions = newData.transactions.map(transaction => ({
...transaction,
userId: transaction.userId || user?.id
}))
if (!user?.isAdmin) {
const existingData = await loadData<CoinsData>('coins')
const existingTransactions = existingData.transactions.filter(x => user?.id && x.userId !== user.id)
newData.transactions = [
...newData.transactions,
...existingTransactions
]
}
return saveData('coins', newData)
}
export async function addCoins({
@@ -107,22 +240,27 @@ export async function addCoins({
type = 'MANUAL_ADJUSTMENT',
relatedItemId,
note,
userId,
}: {
amount: number
description: string
type?: TransactionType
relatedItemId?: string
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: crypto.randomUUID(),
id: uuid(),
amount,
type,
description,
timestamp: d2t({ dateTime: getNow({}) }),
...(relatedItemId && { relatedItemId }),
...(note && note.trim() !== '' && { note })
...(note && note.trim() !== '' && { note }),
userId: userId || currentUser?.id
}
const newData: CoinsData = {
@@ -138,6 +276,8 @@ export async function loadSettings(): Promise<Settings> {
const defaultSettings = getDefaultSettings()
try {
const user = await getCurrentUser()
if (!user) return defaultSettings
const data = await loadData<Settings>('settings')
return { ...defaultSettings, ...data }
} catch {
@@ -155,22 +295,27 @@ export async function removeCoins({
type = 'MANUAL_ADJUSTMENT',
relatedItemId,
note,
userId,
}: {
amount: number
description: string
type?: TransactionType
relatedItemId?: string
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: crypto.randomUUID(),
id: uuid(),
amount: -amount,
type,
description,
timestamp: d2t({ dateTime: getNow({}) }),
...(relatedItemId && { relatedItemId }),
...(note && note.trim() !== '' && { note })
...(note && note.trim() !== '' && { note }),
userId: userId || currentUser?.id
}
const newData: CoinsData = {
@@ -182,7 +327,7 @@ export async function removeCoins({
return newData
}
export async function uploadAvatar(formData: FormData) {
export async function uploadAvatar(formData: FormData): Promise<string> {
const file = formData.get('avatar') as File
if (!file) throw new Error('No file provided')
@@ -203,18 +348,7 @@ export async function uploadAvatar(formData: FormData) {
const buffer = await file.arrayBuffer()
await fs.writeFile(filePath, Buffer.from(buffer))
// Update settings with new avatar path
const settings = await loadSettings()
const newSettings = {
...settings,
profile: {
...settings.profile,
avatarPath: `/data/avatars/${filename}`
}
}
await saveSettings(newSettings)
return newSettings;
return `/data/avatars/${filename}`
}
export async function getChangelog(): Promise<string> {
@@ -226,3 +360,238 @@ export async function getChangelog(): Promise<string> {
return '# Changelog\n\nNo changelog available.'
}
}
// user logic
export async function loadUsersData(): Promise<UserData> {
try {
return await loadData<UserData>('auth')
} catch {
return getDefaultUsersData()
}
}
export async function saveUsersData(data: UserData): Promise<void> {
return saveData('auth', data)
}
export async function getUser(username: string, plainTextPassword?: string): Promise<User | null> {
const data = await loadUsersData()
const user = data.users.find(user => user.username === username)
if (!user) return null
// Verify the plaintext password against the stored salt:hash
const isValidPassword = verifyPassword(plainTextPassword, user.password)
if (!isValidPassword) return null
return user
}
export async function createUser(formData: FormData): Promise<User> {
const username = formData.get('username') as string;
let password = formData.get('password') as string | undefined;
const avatarPath = formData.get('avatarPath') as string;
const permissions = formData.get('permissions') ?
JSON.parse(formData.get('permissions') as string) as Permission[] :
undefined;
if (password === null) password = undefined
// Validate username and password against schema
await signInSchema.parseAsync({ username, password });
const data = await loadUsersData();
// Check if username already exists
if (data.users.some(user => user.username === username)) {
throw new Error('Username already exists');
}
const hashedPassword = password ? saltAndHashPassword(password) : undefined;
const newUser: User = {
id: uuid(),
username,
password: hashedPassword,
permissions,
isAdmin: false,
lastNotificationReadTimestamp: undefined,
...(avatarPath && { avatarPath })
};
const newData: UserData = {
users: [...data.users, newUser]
};
await saveUsersData(newData);
return newUser;
}
export async function updateUser(userId: string, updates: Partial<Omit<User, 'id' | 'password'>>): Promise<User> {
const data = await loadUsersData()
const userIndex = data.users.findIndex(user => user.id === userId)
if (userIndex === -1) {
throw new Error('User not found')
}
// If username is being updated, check for duplicates
if (updates.username) {
const isDuplicate = data.users.some(
user => user.username === updates.username && user.id !== userId
)
if (isDuplicate) {
throw new Error('Username already exists')
}
}
const updatedUser = {
...data.users[userIndex],
...updates
}
const newData: UserData = {
users: [
...data.users.slice(0, userIndex),
updatedUser,
...data.users.slice(userIndex + 1)
]
}
await saveUsersData(newData)
return updatedUser
}
export async function updateUserPassword(userId: string, newPassword?: string): Promise<void> {
const data = await loadUsersData()
const userIndex = data.users.findIndex(user => user.id === userId)
if (userIndex === -1) {
throw new Error('User not found')
}
const hashedPassword = newPassword ? saltAndHashPassword(newPassword) : ''
const updatedUser = {
...data.users[userIndex],
password: hashedPassword
}
const newData: UserData = {
users: [
...data.users.slice(0, userIndex),
updatedUser,
...data.users.slice(userIndex + 1)
]
}
await saveUsersData(newData)
}
export async function deleteUser(userId: string): Promise<void> {
// Load all necessary data
const wishlistData = await loadData<WishlistData>('wishlist')
const habitsData = await loadData<HabitsData>('habits')
const coinsData = await loadData<CoinsData>('coins')
const authData = await loadUsersData()
// Process Wishlist Data
const updatedWishlistItems = wishlistData.items.reduce((acc, item) => {
if (item.userIds?.includes(userId)) {
if (item.userIds.length === 1) {
// Remove item if this is the only user
return acc
} else {
// Remove userId from item's userIds
acc.push({
...item,
userIds: item.userIds.filter(id => id !== userId)
})
}
} else {
// Keep item as is
acc.push(item)
}
return acc
}, [] as WishlistItemType[])
wishlistData.items = updatedWishlistItems
await saveData('wishlist', wishlistData)
// Process Habits Data
const updatedHabits = habitsData.habits.reduce((acc, habit) => {
if (habit.userIds?.includes(userId)) {
if (habit.userIds.length === 1) {
// Remove habit if this is the only user
return acc
} else {
// Remove userId from habit's userIds
acc.push({
...habit,
userIds: habit.userIds.filter(id => id !== userId)
})
}
} else {
// Keep habit as is
acc.push(habit)
}
return acc
}, [] as HabitsData['habits'])
habitsData.habits = updatedHabits
await saveData('habits', habitsData)
// Process Coins Data
coinsData.transactions = coinsData.transactions.filter(
transaction => transaction.userId !== userId
)
// Recalculate balance
coinsData.balance = coinsData.transactions.reduce(
(sum, transaction) => sum + transaction.amount,
0
)
await saveData('coins', coinsData)
// Delete User from Auth Data
const userIndex = authData.users.findIndex(user => user.id === userId)
if (userIndex === -1) {
throw new Error('User not found')
}
authData.users = [
...authData.users.slice(0, userIndex),
...authData.users.slice(userIndex + 1)
]
await saveUsersData(authData)
}
export async function updateLastNotificationReadTimestamp(userId: string, timestamp: string): Promise<void> {
const data = await loadUsersData()
const userIndex = data.users.findIndex(user => user.id === userId)
if (userIndex === -1) {
throw new Error('User not found for updating notification timestamp')
}
const updatedUser = {
...data.users[userIndex],
lastNotificationReadTimestamp: timestamp
}
const newData: UserData = {
users: [
...data.users.slice(0, userIndex),
updatedUser,
...data.users.slice(userIndex + 1)
]
}
await saveUsersData(newData)
}
export async function loadServerSettings(): Promise<ServerSettings> {
return {
isDemo: !!process.env.DEMO,
}
}

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

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

View File

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

View File

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

View File

@@ -1,6 +1,7 @@
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 (

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

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

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

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

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

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

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

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

View File

@@ -2,11 +2,17 @@ import './globals.css'
import { Inter } from 'next/font/google'
import { DM_Sans } from 'next/font/google'
import { JotaiProvider } from '@/components/jotai-providers'
import { Suspense } from 'react'
import { JotaiHydrate } from '@/components/jotai-hydrate'
import { loadSettings, loadHabitsData, loadCoinsData, loadWishlistData } from './actions/data'
import { loadSettings, loadHabitsData, loadCoinsData, loadWishlistData, loadUsersData, loadServerSettings } from './actions/data'
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({
@@ -34,15 +40,23 @@ export default async function RootLayout({
}: {
children: React.ReactNode
}) {
const [initialSettings, initialHabits, initialCoins, initialWishlist] = await Promise.all([
const locale = await getLocale();
// Providing all messages to the client
// side is the easiest way to get started
const messages = await getMessages();
const [initialSettings, initialHabits, initialCoins, initialWishlist, initialUsers, initialServerSettings] = await Promise.all([
loadSettings(),
loadHabitsData(),
loadCoinsData(),
loadWishlistData()
loadWishlistData(),
loadUsersData(),
loadServerSettings(),
])
return (
<html lang="en">
// set suppressHydrationWarning to true to prevent hydration errors when using ThemeProvider (https://ui.shadcn.com/docs/dark-mode/next)
<html lang={locale} suppressHydrationWarning>
<body className={activeFont.className}>
<script
dangerouslySetInnerHTML={{
@@ -62,18 +76,31 @@ export default async function RootLayout({
}}
/>
<JotaiProvider>
<Suspense fallback="loading">
<Suspense fallback={<LoadingSpinner />}>
<JotaiHydrate
initialValues={{
settings: initialSettings,
habits: initialHabits,
coins: initialCoins,
wishlist: initialWishlist
wishlist: initialWishlist,
users: initialUsers,
serverSettings: initialServerSettings,
}}
>
<Layout>
{children}
</Layout>
<NextIntlClientProvider locale={locale} messages={messages}>
<ThemeProvider
attribute="class"
defaultTheme="system"
enableSystem
disableTransitionOnChange
>
<SessionProvider>
<Layout>
{children}
</Layout>
</SessionProvider>
</ThemeProvider>
</NextIntlClientProvider>
</JotaiHydrate>
</Suspense>
</JotaiProvider>

View File

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

44
auth.ts Normal file
View File

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

View File

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

View File

@@ -1,42 +1,77 @@
'use client'
import { useState, useEffect } from 'react'
import { useState } from 'react'
import { RRule, RRuleSet, rrulestr } from 'rrule'
import { useAtom } from 'jotai'
import { settingsAtom, browserSettingsAtom } from '@/lib/atoms'
import { useTranslations } from 'next-intl'
import { settingsAtom, browserSettingsAtom, usersAtom, currentUserAtom } from '@/lib/atoms'
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '@/components/ui/dialog'
import { Button } from '@/components/ui/button'
import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Textarea } from '@/components/ui/textarea'
import { Info, SmilePlus } from 'lucide-react'
import { Zap } from 'lucide-react'
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
import data from '@emoji-mart/data'
import Picker from '@emoji-mart/react'
import { Habit } from '@/lib/types'
import { d2s, d2t, getISODate, getNow, parseNaturalLanguageDate, parseNaturalLanguageRRule, parseRRule, serializeRRule } from '@/lib/utils'
import { INITIAL_DUE, INITIAL_RECURRENCE_RULE } from '@/lib/constants'
import * as chrono from 'chrono-node';
import { Habit, SafeUser } from '@/lib/types'
import EmojiPickerButton from './EmojiPickerButton'
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 { DateTime } from 'luxon'
interface AddEditHabitModalProps {
onClose: () => void
onSave: (habit: Omit<Habit, 'id'>) => Promise<void>
habit?: Habit | null
isTask: boolean
}
export default function AddEditHabitModal({ onClose, onSave, habit }: AddEditHabitModalProps) {
export default function AddEditHabitModal({ onClose, onSave, habit, isTask }: AddEditHabitModalProps) {
const t = useTranslations('AddEditHabitModal');
const [settings] = useAtom(settingsAtom)
const [browserSettings] = useAtom(browserSettingsAtom)
const isTasksView = browserSettings.viewType === 'tasks'
const [name, setName] = useState(habit?.name || '')
const [description, setDescription] = useState(habit?.description || '')
const [coinReward, setCoinReward] = useState(habit?.coinReward || 1)
const [targetCompletions, setTargetCompletions] = useState(habit?.targetCompletions || 1)
const isRecurRule = !isTasksView
const origRuleText = isRecurRule ? parseRRule(habit?.frequency || INITIAL_RECURRENCE_RULE).toText() : habit?.frequency || INITIAL_DUE
const [ruleText, setRuleText] = useState<string>(origRuleText)
const now = getNow({ timezone: settings.system.timezone })
const isRecurRule = !isTask
// Initialize ruleText with the actual frequency string or default, not the display text
const initialRuleText = habit?.frequency ? convertMachineReadableFrequencyToHumanReadable({
frequency: habit.frequency,
isRecurRule,
timezone: settings.system.timezone
}) : (isRecurRule ? INITIAL_RECURRENCE_RULE : INITIAL_DUE);
const [ruleText, setRuleText] = useState<string>(initialRuleText)
const [currentUser] = useAtom(currentUserAtom)
const [isQuickDatesOpen, setIsQuickDatesOpen] = useState(false)
const [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
function getFrequencyUpdate() {
if (ruleText === initialRuleText && habit?.frequency) {
// If text hasn't changed and original frequency exists, return it
return habit.frequency;
}
const parsedResult = convertHumanReadableFrequencyToMachineReadable({
text: ruleText,
timezone: settings.system.timezone,
isRecurring: isRecurRule
});
if (parsedResult.result) {
return isRecurRule
? serializeRRule(parsedResult.result as RRule)
: d2t({
dateTime: parsedResult.result as DateTime,
timezone: settings.system.timezone
});
} else {
return 'invalid';
}
}
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
@@ -46,8 +81,8 @@ export default function AddEditHabitModal({ onClose, onSave, habit }: AddEditHab
coinReward,
targetCompletions: targetCompletions > 1 ? targetCompletions : undefined,
completions: habit?.completions || [],
frequency: isRecurRule ? serializeRRule(parseNaturalLanguageRRule(ruleText)) : d2t({ dateTime: parseNaturalLanguageDate({ text: ruleText, timezone: settings.system.timezone }) }),
isTask: isTasksView ? true : undefined
frequency: getFrequencyUpdate(),
userIds: selectedUserIds.length > 0 ? selectedUserIds.concat(currentUser?.id || []) : (currentUser && [currentUser.id])
})
}
@@ -55,13 +90,17 @@ export default function AddEditHabitModal({ onClose, onSave, habit }: AddEditHab
<Dialog open={true} onOpenChange={onClose}>
<DialogContent>
<DialogHeader>
<DialogTitle>{habit ? `Edit ${isTasksView ? 'Task' : 'Habit'}` : `Add New ${isTasksView ? 'Task' : 'Habit'}`}</DialogTitle>
<DialogTitle>
{habit
? t(isTask ? 'editTaskTitle' : 'editHabitTitle')
: t(isTask ? 'addNewTaskTitle' : 'addNewHabitTitle')}
</DialogTitle>
</DialogHeader>
<form onSubmit={handleSubmit}>
<div className="grid gap-4 py-4">
<div className="grid grid-cols-4 items-center gap-4">
<Label htmlFor="name" className="text-right">
Name
{t('nameLabel')}
</Label>
<div className='flex col-span-3 gap-2'>
<Input
@@ -70,38 +109,20 @@ export default function AddEditHabitModal({ onClose, onSave, habit }: AddEditHab
onChange={(e) => setName(e.target.value)}
required
/>
<Popover>
<PopoverTrigger asChild>
<Button
type="button"
variant="ghost"
size="icon"
className="h-8 w-8"
>
<SmilePlus className="h-8 w-8" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-[300px] p-0">
<Picker
data={data}
onEmojiSelect={(emoji: { native: string }) => {
setName(prev => {
// Add space before emoji if there isn't one already
const space = prev.length > 0 && !prev.endsWith(' ') ? ' ' : '';
return `${prev}${space}${emoji.native}`;
})
// Focus back on input after selection
const input = document.getElementById('name') as HTMLInputElement
input?.focus()
}}
/>
</PopoverContent>
</Popover>
<EmojiPickerButton
inputIdToFocus="name"
onEmojiSelect={(emoji) => {
setName(prev => {
const space = prev.length > 0 && !prev.endsWith(' ') ? ' ' : '';
return `${prev}${space}${emoji}`;
})
}}
/>
</div>
</div>
<div className="grid grid-cols-4 items-center gap-4">
<Label htmlFor="description" className="text-right">
Description
{t('descriptionLabel')}
</Label>
<Textarea
id="description"
@@ -112,32 +133,79 @@ export default function AddEditHabitModal({ onClose, onSave, habit }: AddEditHab
</div>
<div className="grid grid-cols-4 items-center gap-4">
<Label htmlFor="recurrence" className="text-right">
When
{t('whenLabel')}
</Label>
{/* date input (task) */}
<div className="col-span-3 space-y-2">
<Input
id="recurrence"
value={ruleText}
onChange={(e) => setRuleText(e.target.value)}
// placeholder="e.g. 'every weekday' or 'every 2 weeks on Monday, Wednesday'"
/>
<div className="flex gap-2">
<Input
id="recurrence"
value={ruleText}
onChange={(e) => setRuleText(e.target.value)}
required
/>
{isTask && (
<Popover open={isQuickDatesOpen} onOpenChange={setIsQuickDatesOpen}>
<PopoverTrigger asChild>
<Button
type="button"
variant="ghost"
size="icon"
className="h-8 w-8"
>
<Zap className="h-4 w-4" />
</Button>
</PopoverTrigger>
<PopoverContent className="p-3 w-[280px] max-h-[40vh] overflow-y-auto" align="start">
<div className="space-y-1">
<div className="grid grid-cols-2 gap-2">
{QUICK_DATES.map((date) => (
<Button
key={date.value}
variant="outline"
className="justify-start h-9 px-3 hover:bg-primary hover:text-primary-foreground transition-colors"
onClick={() => {
setRuleText(date.value);
setIsQuickDatesOpen(false);
}}
>
{date.label}
</Button>
))}
</div>
</div>
</PopoverContent>
</Popover>
)}
</div>
</div>
<div className="col-start-2 col-span-3 text-sm text-muted-foreground">
<span>
{(() => {
try {
return isRecurRule ? parseNaturalLanguageRRule(ruleText).toText() : d2s({ dateTime: parseNaturalLanguageDate({ text: ruleText, timezone: settings.system.timezone }), timezone: settings.system.timezone, format: DateTime.DATE_MED_WITH_WEEKDAY })
} catch (e: unknown) {
return `Invalid rule: ${e instanceof Error ? e.message : 'Invalid recurrence rule'}`
}
})()}
</span>
{/* 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>
)}
</>
);
})()}
</div>
</div>
<div className="grid grid-cols-4 items-center gap-4">
<div className="flex items-center gap-2 justify-end">
<Label htmlFor="targetCompletions">
Complete
{t('completeLabel')}
</Label>
</div>
<div className="col-span-3">
@@ -171,7 +239,7 @@ export default function AddEditHabitModal({ onClose, onSave, habit }: AddEditHab
</button>
</div>
<span className="text-sm text-muted-foreground">
times
{t('timesSuffix')}
</span>
</div>
</div>
@@ -179,7 +247,7 @@ export default function AddEditHabitModal({ onClose, onSave, habit }: AddEditHab
<div className="grid grid-cols-4 items-center gap-4">
<div className="flex items-center gap-2 justify-end">
<Label htmlFor="coinReward">
Reward
{t('rewardLabel')}
</Label>
</div>
<div className="col-span-3">
@@ -196,28 +264,68 @@ export default function AddEditHabitModal({ onClose, onSave, habit }: AddEditHab
id="coinReward"
type="number"
value={coinReward}
onChange={(e) => setCoinReward(parseInt(e.target.value === "" ? "0" : e.target.value))}
onChange={(e) => {
const value = parseInt(e.target.value === "" ? "0" : e.target.value)
setCoinReward(Math.min(value, MAX_COIN_LIMIT))
}}
min={0}
max={MAX_COIN_LIMIT}
required
className="w-20 text-center border-0 [appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none"
/>
<button
type="button"
onClick={() => setCoinReward(prev => prev + 1)}
onClick={() => setCoinReward(prev => Math.min(prev + 1, MAX_COIN_LIMIT))}
className="px-3 py-2 bg-gray-100 hover:bg-gray-200 dark:bg-gray-800 dark:hover:bg-gray-700 transition-colors"
>
+
</button>
</div>
<span className="text-sm text-muted-foreground">
coins
{t('coinsSuffix')}
</span>
</div>
</div>
</div>
{users && users.length > 1 && (
<div className="grid grid-cols-4 items-center gap-4">
<div className="flex items-center justify-end gap-2">
<Label htmlFor="sharing-toggle">{t('shareLabel')}</Label>
</div>
<div className="col-span-3">
<div className="flex flex-wrap gap-2">
{users.filter((u) => u.id !== currentUser?.id).map(user => (
<Avatar
key={user.id}
className={`h-8 w-8 border-2 cursor-pointer
${selectedUserIds.includes(user.id)
? 'border-primary'
: 'border-muted'
}`}
title={user.username}
onClick={() => {
setSelectedUserIds(prev =>
prev.includes(user.id)
? prev.filter(id => id !== user.id)
: [...prev, user.id]
)
}}
>
<AvatarImage src={user?.avatarPath && `/api/avatars/${user.avatarPath.split('/').pop()}` || ""} />
<AvatarFallback>{user.username[0]}</AvatarFallback>
</Avatar>
))}
</div>
</div>
</div>
)}
</div>
<DialogFooter>
<Button type="submit">{habit ? 'Save Changes' : `Add ${isTasksView ? 'Task' : 'Habit'}`}</Button>
<Button type="submit" disabled={!!errorMessage}>
{habit
? t('saveChangesButton')
: t(isTask ? 'addTaskButton' : 'addHabitButton')}
</Button>
</DialogFooter>
</form>
</DialogContent>

View File

@@ -1,82 +1,130 @@
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 { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Textarea } from '@/components/ui/textarea'
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'
import { SmilePlus, Info } from 'lucide-react'
import data from '@emoji-mart/data'
import Picker from '@emoji-mart/react'
import { WishlistItemType } from '@/lib/types'
import EmojiPickerButton from './EmojiPickerButton'
import { MAX_COIN_LIMIT } from '@/lib/constants'
interface AddEditWishlistItemModalProps {
isOpen: boolean
onClose: () => void
onSave: (item: Omit<WishlistItemType, 'id'>) => void
item?: WishlistItemType | null
setIsOpen: (isOpen: boolean) => void
editingItem: WishlistItemType | null
setEditingItem: (item: WishlistItemType | null) => void
addWishlistItem: (item: Omit<WishlistItemType, 'id'>) => void
editWishlistItem: (item: WishlistItemType) => void
}
export default function AddEditWishlistItemModal({ isOpen, onClose, onSave, item }: AddEditWishlistItemModalProps) {
const [name, setName] = useState(item?.name || '')
const [description, setDescription] = useState(item?.description || '')
const [coinCost, setCoinCost] = useState(item?.coinCost || 1)
const [targetCompletions, setTargetCompletions] = useState<number | undefined>(item?.targetCompletions)
export default function AddEditWishlistItemModal({
isOpen,
setIsOpen,
editingItem,
setEditingItem,
addWishlistItem,
editWishlistItem
}: AddEditWishlistItemModalProps) {
const t = useTranslations('AddEditWishlistItemModal')
const [name, setName] = useState(editingItem?.name || '')
const [description, setDescription] = useState(editingItem?.description || '')
const [coinCost, setCoinCost] = useState(editingItem?.coinCost || 1)
const [targetCompletions, setTargetCompletions] = useState<number | undefined>(editingItem?.targetCompletions)
const [link, setLink] = useState(editingItem?.link || '')
const [currentUser] = useAtom(currentUserAtom)
const [selectedUserIds, setSelectedUserIds] = useState<string[]>((editingItem?.userIds || []).filter(id => id !== currentUser?.id))
const [errors, setErrors] = useState<{ [key: string]: string }>({})
const [usersData] = useAtom(usersAtom)
useEffect(() => {
if (item) {
setName(item.name)
setDescription(item.description)
setCoinCost(item.coinCost)
setTargetCompletions(item.targetCompletions)
if (editingItem) {
setName(editingItem.name)
setDescription(editingItem.description)
setCoinCost(editingItem.coinCost)
setTargetCompletions(editingItem.targetCompletions)
setLink(editingItem.link || '')
} else {
setName('')
setDescription('')
setCoinCost(1)
setTargetCompletions(undefined)
setLink('')
}
setErrors({})
}, [item])
}, [editingItem])
const validate = () => {
const newErrors: { [key: string]: string } = {}
if (!name.trim()) {
newErrors.name = 'Name is required'
newErrors.name = t('errorNameRequired')
}
if (coinCost < 1) {
newErrors.coinCost = 'Coin cost must be at least 1'
newErrors.coinCost = t('errorCoinCostMin')
} else if (coinCost > MAX_COIN_LIMIT) {
newErrors.coinCost = t('errorCoinCostMax', { max: MAX_COIN_LIMIT })
}
if (targetCompletions !== undefined && targetCompletions < 1) {
newErrors.targetCompletions = 'Target completions must be at least 1'
newErrors.targetCompletions = t('errorTargetCompletionsMin')
}
if (link && !isValidUrl(link)) {
newErrors.link = t('errorInvalidUrl')
}
setErrors(newErrors)
return Object.keys(newErrors).length === 0
}
const handleSubmit = (e: React.FormEvent) => {
const isValidUrl = (url: string) => {
try {
new URL(url)
return true
} catch {
return false
}
}
const handleClose = () => {
setIsOpen(false)
setEditingItem(null)
}
const handleSave = (e: React.FormEvent) => {
e.preventDefault()
if (!validate()) return
onSave({
const itemData = {
name,
description,
coinCost,
targetCompletions: targetCompletions || undefined
})
targetCompletions: targetCompletions || undefined,
link: link.trim() || undefined,
userIds: selectedUserIds.length > 0 ? selectedUserIds.concat(currentUser?.id || []) : (currentUser && [currentUser.id])
}
if (editingItem) {
editWishlistItem({ ...itemData, id: editingItem.id })
} else {
addWishlistItem(itemData)
}
setIsOpen(false)
setEditingItem(null)
}
return (
<Dialog open={isOpen} onOpenChange={onClose}>
<Dialog open={isOpen} onOpenChange={handleClose}>
<DialogContent>
<DialogHeader>
<DialogTitle>{item ? 'Edit Reward' : 'Add New Reward'}</DialogTitle>
<DialogTitle>{editingItem ? t('editTitle') : t('addTitle')}</DialogTitle>
</DialogHeader>
<form onSubmit={handleSubmit}>
<form onSubmit={handleSave}>
<div className="grid gap-4 py-4">
<div className="grid grid-cols-4 items-center gap-4">
<Label htmlFor="name" className="text-right">
Name
{t('nameLabel')}
</Label>
<div className="col-span-3 flex gap-2">
<Input
@@ -86,34 +134,20 @@ export default function AddEditWishlistItemModal({ isOpen, onClose, onSave, item
className="flex-1"
required
/>
<Popover>
<PopoverTrigger asChild>
<Button
type="button"
variant="ghost"
size="icon"
className="h-8 w-8"
>
<SmilePlus className="h-4 w-4" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-[300px] p-0">
<Picker
data={data}
onEmojiSelect={(emoji: { native: string }) => {
setName(prev => `${prev}${emoji.native}`)
// Focus back on input after selection
const input = document.getElementById('name') as HTMLInputElement
input?.focus()
}}
/>
</PopoverContent>
</Popover>
<EmojiPickerButton
inputIdToFocus="name"
onEmojiSelect={(emoji) => {
setName(prev => {
const space = prev.length > 0 && !prev.endsWith(' ') ? ' ' : '';
return `${prev}${space}${emoji}`;
})
}}
/>
</div>
</div>
<div className="grid grid-cols-4 items-center gap-4">
<Label htmlFor="description" className="text-right">
Description
{t('descriptionLabel')}
</Label>
<Textarea
id="description"
@@ -125,7 +159,7 @@ export default function AddEditWishlistItemModal({ isOpen, onClose, onSave, item
<div className="grid grid-cols-4 items-center gap-4">
<div className="flex items-center gap-2 justify-end">
<Label htmlFor="coinReward">
Cost
{t('costLabel')}
</Label>
</div>
<div className="col-span-3">
@@ -142,21 +176,25 @@ export default function AddEditWishlistItemModal({ isOpen, onClose, onSave, item
id="coinReward"
type="number"
value={coinCost}
onChange={(e) => setCoinCost(parseInt(e.target.value === "" ? "0" : e.target.value))}
onChange={(e) => {
const value = parseInt(e.target.value === "" ? "0" : e.target.value)
setCoinCost(Math.min(value, MAX_COIN_LIMIT))
}}
min={0}
max={MAX_COIN_LIMIT}
required
className="w-20 text-center border-0 [appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none"
/>
<button
type="button"
onClick={() => setCoinCost(prev => prev + 1)}
onClick={() => setCoinCost(prev => Math.min(prev + 1, MAX_COIN_LIMIT))}
className="px-3 py-2 bg-gray-100 hover:bg-gray-200 dark:bg-gray-800 dark:hover:bg-gray-700 transition-colors"
>
+
</button>
</div>
<span className="text-sm text-muted-foreground">
coins
{t('coinsSuffix')}
</span>
</div>
</div>
@@ -164,7 +202,7 @@ export default function AddEditWishlistItemModal({ isOpen, onClose, onSave, item
<div className="grid grid-cols-4 items-center gap-4">
<div className="flex items-center gap-2 justify-end">
<Label htmlFor="targetCompletions">
Redeemable
{t('redeemableLabel')}
</Label>
</div>
<div className="col-span-3">
@@ -198,7 +236,7 @@ export default function AddEditWishlistItemModal({ isOpen, onClose, onSave, item
</button>
</div>
<span className="text-sm text-muted-foreground">
times
{t('timesSuffix')}
</span>
</div>
{errors.targetCompletions && (
@@ -208,9 +246,61 @@ export default function AddEditWishlistItemModal({ isOpen, onClose, onSave, item
)}
</div>
</div>
<div className="grid grid-cols-4 items-center gap-4">
<Label htmlFor="link" className="text-right">
{t('linkLabel')}
</Label>
<div className="col-span-3">
<Input
id="link"
type="url"
placeholder="https://..."
value={link}
onChange={(e) => setLink(e.target.value)}
className="col-span-3"
/>
{errors.link && (
<div className="text-sm text-red-500">
{errors.link}
</div>
)}
</div>
</div>
{usersData.users && usersData.users.length > 1 && (
<div className="grid grid-cols-4 items-center gap-4">
<div className="flex items-center justify-end gap-2">
<Label htmlFor="sharing-toggle">{t('shareLabel')}</Label>
</div>
<div className="col-span-3">
<div className="flex flex-wrap gap-2">
{usersData.users.filter((u) => u.id !== currentUser?.id).map(user => (
<Avatar
key={user.id}
className={`h-8 w-8 border-2 cursor-pointer
${selectedUserIds.includes(user.id)
? 'border-primary'
: 'border-muted'
}`}
title={user.username}
onClick={() => {
setSelectedUserIds(prev =>
prev.includes(user.id)
? prev.filter(id => id !== user.id)
: [...prev, user.id]
)
}}
>
<AvatarImage src={user?.avatarPath && `/api/avatars/${user.avatarPath.split('/').pop()}` || ""} />
<AvatarFallback>{user.username[0]}</AvatarFallback>
</Avatar>
))}
</div>
</div>
</div>
)}
</div>
<DialogFooter>
<Button type="submit">{item ? 'Save Changes' : 'Add Reward'}</Button>
<Button type="submit">{editingItem ? t('saveButton') : t('addButton')}</Button>
</DialogFooter>
</form>
</DialogContent>

View File

@@ -1,19 +1,54 @@
'use client'
import { ReactNode } from 'react'
import { useAtom } from 'jotai'
import { pomodoroAtom } from '@/lib/atoms'
import { ReactNode, Suspense, useEffect, useState } from 'react'
import { useAtom, useSetAtom } from 'jotai' // Import useSetAtom
import { aboutOpenAtom, pomodoroAtom, userSelectAtom, currentUserIdAtom } from '@/lib/atoms' // Import currentUserIdAtom
import PomodoroTimer from './PomodoroTimer'
import UserSelectModal from './UserSelectModal'
import { useSession } from 'next-auth/react'
import AboutModal from './AboutModal'
import LoadingSpinner from './LoadingSpinner'
export default function ClientWrapper({ children }: { children: ReactNode }) {
const [pomo] = useAtom(pomodoroAtom)
const [userSelect, setUserSelect] = useAtom(userSelectAtom)
const [aboutOpen, setAboutOpen] = useAtom(aboutOpenAtom)
const setCurrentUserIdAtom = useSetAtom(currentUserIdAtom)
const { data: session, status } = useSession()
const currentUserId = session?.user.id
const [isMounted, setIsMounted] = useState(false);
// block client-side hydration until mounted (this is crucial to wait for all jotai atoms to load), to prevent SSR hydration errors in the children components
useEffect(() => {
setIsMounted(true);
}, []);
useEffect(() => {
if (status === 'loading') return
if (!currentUserId && !userSelect) {
setUserSelect(true)
}
}, [currentUserId, status, userSelect, setUserSelect])
useEffect(() => {
setCurrentUserIdAtom(currentUserId)
}, [currentUserId, setCurrentUserIdAtom])
if (!isMounted) {
return <LoadingSpinner />
}
return (
<>
{children}
{pomo.show && (
<PomodoroTimer />
)}
{userSelect && (
<UserSelectModal onClose={() => setUserSelect(false)} />
)}
{aboutOpen && (
<AboutModal onClose={() => setAboutOpen(false)} />
)}
</>
)
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,10 +1,10 @@
import { Habit } from '@/lib/types'
import { Habit, SafeUser, User, Permission } from '@/lib/types'
import { useAtom } from 'jotai'
import { settingsAtom, pomodoroAtom, browserSettingsAtom } from '@/lib/atoms'
import { getTodayInTimezone, isSameDate, t2d, d2t, getNow, parseNaturalLanguageRRule, parseRRule, d2s } from '@/lib/utils'
import { 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, Trash2, Check, Undo2, MoreVertical, Timer, Archive, ArchiveRestore } from 'lucide-react'
import { Coins, Edit, Check, Undo2, MoreVertical, Pin } from 'lucide-react'
import {
DropdownMenu,
DropdownMenuContent,
@@ -14,8 +14,12 @@ import {
} from '@/components/ui/dropdown-menu'
import { useEffect, useState } from 'react'
import { useHabits } from '@/hooks/useHabits'
import { INITIAL_RECURRENCE_RULE } from '@/lib/constants'
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 { HabitContextMenuItems } from './HabitContextMenuItems'
interface HabitItemProps {
habit: Habit
@@ -23,16 +27,39 @@ interface HabitItemProps {
onDelete: () => void
}
const renderUserAvatars = (habit: Habit, currentUser: User | null, usersData: { users: User[] }) => {
if (!habit.userIds || habit.userIds.length <= 1) return null;
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
return (
<Avatar key={user.id} className="h-6 w-6">
<AvatarImage src={user?.avatarPath && `/api/avatars/${user.avatarPath.split('/').pop()}` || ""} />
<AvatarFallback>{user.username[0]}</AvatarFallback>
</Avatar>
)
})}
</div>
);
};
export default function HabitItem({ habit, onEdit, onDelete }: HabitItemProps) {
const { completeHabit, undoComplete, archiveHabit, unarchiveHabit } = useHabits()
const { completeHabit, undoComplete, archiveHabit, unarchiveHabit, saveHabit } = useHabits()
const [settings] = useAtom(settingsAtom)
const [_, setPomo] = useAtom(pomodoroAtom)
const completionsToday = habit.completions?.filter(completion =>
isSameDate(t2d({ timestamp: completion, timezone: settings.system.timezone }), t2d({ timestamp: d2t({ dateTime: getNow({ timezone: settings.system.timezone }) }), timezone: settings.system.timezone }))
).length || 0
const completionsToday = getCompletionsForToday({ habit, timezone: settings.system.timezone })
const target = habit.targetCompletions || 1
const isCompletedToday = completionsToday >= target
const [isHighlighted, setIsHighlighted] = useState(false)
const t = useTranslations('HabitItem');
const [usersData] = useAtom(usersAtom)
const [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
@@ -62,18 +89,41 @@ export default function HabitItem({ habit, onEdit, onDelete }: HabitItemProps) {
className={`h-full flex flex-col transition-all duration-500 ${isHighlighted ? 'bg-yellow-100 dark:bg-yellow-900' : ''} ${habit.archived ? 'opacity-75' : ''}`}
>
<CardHeader className="flex-none">
<CardTitle className={`line-clamp-1 ${habit.archived ? 'text-gray-400 dark:text-gray-500' : ''}`}>{habit.name}</CardTitle>
<div className="flex justify-between items-start">
<CardTitle className={`line-clamp-1 ${habit.archived ? 'text-gray-400 dark:text-gray-500' : ''} flex items-center ${isTasksView ? 'w-full' : ''} justify-between`}>
<div className="flex items-center gap-1">
{habit.pinned && (
<Pin className="h-4 w-4 text-yellow-500" />
)}
<span>{habit.name}</span>
</div>
{isTaskOverdue(habit, settings.system.timezone) && (
<span className="ml-2 inline-flex items-center rounded-md bg-red-50 dark:bg-red-900/30 px-2 py-1 text-xs font-medium text-red-700 dark:text-red-400 ring-1 ring-inset ring-red-600/10 dark:ring-red-500/20">
{t('overdue')}
</span>
)}
</CardTitle>
{renderUserAvatars(habit, currentUser as User, usersData)}
</div>
{habit.description && (
<CardDescription className={`whitespace-pre-line ${habit.archived ? 'text-gray-400 dark:text-gray-500' : ''}`}>
<CardDescription className={`whitespace-pre-line mt-2 ${habit.archived ? 'text-gray-400 dark:text-gray-500' : ''}`}>
{habit.description}
</CardDescription>
)}
</CardHeader>
<CardContent className="flex-1">
<p className={`text-sm ${habit.archived ? 'text-gray-400 dark:text-gray-500' : 'text-gray-500'}`}>When: {isRecurRule ? parseRRule(habit.frequency || INITIAL_RECURRENCE_RULE).toText() : d2s({ dateTime: t2d({ timestamp: habit.frequency, timezone: settings.system.timezone }), timezone: settings.system.timezone, format: DateTime.DATE_MED_WITH_WEEKDAY })}</p>
<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' : ''}`}>{habit.coinReward} coins per completion</span>
<span className={`text-sm font-medium ${habit.archived ? 'text-gray-400 dark:text-gray-500' : ''}`}>{t('coinsPerCompletion', { count: habit.coinReward })}</span>
</div>
</CardContent>
<CardFooter className="flex justify-between gap-2">
@@ -83,7 +133,7 @@ export default function HabitItem({ habit, onEdit, onDelete }: HabitItemProps) {
variant={isCompletedToday ? "secondary" : "default"}
size="sm"
onClick={async () => await completeHabit(habit)}
disabled={habit.archived || (isCompletedToday && completionsToday >= target)}
disabled={!canInteract || habit.archived || (isCompletedToday && completionsToday >= target)}
className={`overflow-hidden w-24 sm:w-auto ${habit.archived ? 'cursor-not-allowed' : ''}`}
>
<Check className="h-4 w-4 sm:mr-2" />
@@ -91,19 +141,19 @@ export default function HabitItem({ habit, onEdit, onDelete }: HabitItemProps) {
{isCompletedToday ? (
target > 1 ? (
<>
<span className="sm:hidden">{completionsToday}/{target}</span>
<span className="hidden sm:inline">Completed ({completionsToday}/{target})</span>
<span className="sm:hidden">{t('completedStatusCountMobile', { completed: completionsToday, target })}</span>
<span className="hidden sm:inline">{t('completedStatusCount', { completed: completionsToday, target })}</span>
</>
) : (
'Completed'
t('completedStatus')
)
) : (
target > 1 ? (
<>
<span className="sm:hidden">{completionsToday}/{target}</span>
<span className="hidden sm:inline">Complete ({completionsToday}/{target})</span>
<span className="sm:hidden">{t('completeButtonCountMobile', { completed: completionsToday, target })}</span>
<span className="hidden sm:inline">{t('completeButtonCount', { completed: completionsToday, target })}</span>
</>
) : 'Complete'
) : t('completeButton')
)}
</span>
{habit.targetCompletions && habit.targetCompletions > 1 && (
@@ -121,10 +171,11 @@ export default function HabitItem({ habit, onEdit, onDelete }: HabitItemProps) {
variant="outline"
size="sm"
onClick={async () => await undoComplete(habit)}
disabled={!canWrite}
className="w-10 sm:w-auto"
>
<Undo2 className="h-4 w-4" />
<span className="hidden sm:inline ml-2">Undo</span>
<span className="hidden sm:inline ml-2">{t('undoButton')}</span>
</Button>
)}
</div>
@@ -134,59 +185,26 @@ export default function HabitItem({ habit, onEdit, onDelete }: HabitItemProps) {
variant="edit"
size="sm"
onClick={onEdit}
disabled={!canWrite}
className="hidden sm:flex"
>
<Edit className="h-4 w-4" />
<span className="ml-2">Edit</span>
<span className="ml-2">{t('editButton')}</span>
</Button>
)}
<DropdownMenu>
<DropdownMenu modal={false}>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="sm" className="h-8 w-8 p-0">
<MoreVertical className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
{!habit.archived && (
<DropdownMenuItem onClick={() => {
setPomo((prev) => ({
...prev,
show: true,
selectedHabitId: habit.id
}))
}}>
<Timer className="mr-2 h-4 w-4" />
<span>Start Pomodoro</span>
</DropdownMenuItem>
)}
{!habit.archived && (
<DropdownMenuItem onClick={() => archiveHabit(habit.id)}>
<Archive className="mr-2 h-4 w-4" />
<span>Archive</span>
</DropdownMenuItem>
)}
{habit.archived && (
<DropdownMenuItem onClick={() => unarchiveHabit(habit.id)}>
<ArchiveRestore className="mr-2 h-4 w-4" />
<span>Unarchive</span>
</DropdownMenuItem>
)}
<DropdownMenuItem
onClick={onEdit}
className="sm:hidden"
disabled={habit.archived}
>
<Edit className="mr-2 h-4 w-4" />
Edit
</DropdownMenuItem>
<DropdownMenuSeparator className="sm:hidden" />
<DropdownMenuItem
className="text-red-600 focus:text-red-600 dark:text-red-400 dark:focus:text-red-400 cursor-pointer"
onClick={onDelete}
>
<Trash2 className="mr-2 h-4 w-4" />
Delete
</DropdownMenuItem>
<HabitContextMenuItems
habit={habit}
onEditRequest={onEdit}
onDeleteRequest={onDelete}
context="habit-item"
/>
</DropdownMenuContent>
</DropdownMenu>
</div>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -3,7 +3,7 @@ import { Sparkles } from "lucide-react"
export function Logo() {
return (
<div className="flex items-center gap-2">
<Sparkles className="h-6 w-6 text-primary" />
{/* <Sparkles className="h-6 w-6 text-primary" /> */}
<span className="font-bold text-xl">HabitTrove</span>
</div>
)

View File

@@ -0,0 +1,60 @@
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>
</>
);
}

View File

@@ -1,39 +1,48 @@
'use client'
import Link from 'next/link'
import { Home, Calendar, List, Gift, Coins, Settings, Info, CheckSquare } from 'lucide-react'
import { Home, Calendar, Gift, Coins } from 'lucide-react'
import { useAtom } from 'jotai'
import { browserSettingsAtom } from '@/lib/atoms'
import { useEffect, useState } from 'react'
import AboutModal from './AboutModal'
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'
const navItems = (isTasksView: boolean) => [
{ icon: Home, label: 'Dashboard', href: '/', position: 'main' },
{
icon: isTasksView ? TaskIcon : HabitIcon,
label: isTasksView ? 'Tasks' : 'Habits',
href: '/habits',
position: 'main'
},
{ icon: Calendar, label: 'Calendar', href: '/calendar', position: 'main' },
{ icon: Gift, label: 'Wishlist', href: '/wishlist', position: 'main' },
{ icon: Coins, label: 'Coins', href: '/coins', position: 'main' },
]
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 [showAbout, setShowAbout] = useState(false)
const t = useTranslations('Navigation')
const [isMobileView, setIsMobileView] = useState(false)
const [browserSettings] = useAtom(browserSettingsAtom)
const isTasksView = browserSettings.viewType === 'tasks'
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' },
]
useEffect(() => {
const handleResize = () => {
setIsMobileView(window.innerWidth < 1024)
@@ -50,51 +59,12 @@ export default function Navigation({ className, viewPort }: NavigationProps) {
}, [])
if (viewPort === 'mobile' && isMobileView) {
return (
<>
<div className="pb-16" /> {/* Add padding at the bottom to prevent content from being hidden */}
<nav className="lg:hidden fixed bottom-0 left-0 right-0 bg-white dark:bg-gray-800 shadow-lg">
<div className="flex justify-around">
{[...navItems(isTasksView).filter(item => item.position === 'main'), ...navItems(isTasksView).filter(item => item.position === 'bottom')].map((item) => (
<Link
key={item.label}
href={item.href}
className="flex flex-col items-center py-2 text-gray-600 dark:text-gray-300 hover:text-blue-500 dark:hover:text-blue-400"
>
<item.icon className="h-6 w-6" />
<span className="text-xs mt-1">{item.label}</span>
</Link>
))}
</div>
</nav>
<AboutModal isOpen={showAbout} onClose={() => setShowAbout(false)} />
</>
)
return <MobileNavDisplay navItems={currentNavItems} />
}
if (viewPort === 'main' && !isMobileView) {
return (
<div className="hidden lg:flex lg:flex-shrink-0">
<div className="flex flex-col w-64">
<div className="flex flex-col h-0 flex-1 bg-gray-800">
<div className="flex-1 flex flex-col pt-5 pb-4 overflow-y-auto">
<nav className="mt-5 flex-1 px-2 space-y-1">
{navItems(isTasksView).filter(item => item.position === 'main').map((item) => (
<Link
key={item.label}
href={item.href}
className="group flex items-center px-2 py-2 text-sm leading-6 font-medium rounded-md text-gray-300 hover:text-white hover:bg-gray-700"
>
<item.icon className="mr-4 flex-shrink-0 h-6 w-6 text-gray-400" aria-hidden="true" />
{item.label}
</Link>
))}
</nav>
</div>
</div>
</div>
<AboutModal isOpen={showAbout} onClose={() => setShowAbout(false)} />
</div>
)
return <DesktopNavDisplay navItems={currentNavItems} className={className} />
}
return null // Explicitly return null if no view matches
}

View File

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

View File

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

View File

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

View File

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

View File

@@ -4,54 +4,41 @@ import { useState, useEffect, useRef, useCallback } from 'react'
import { Button } from '@/components/ui/button'
import { Progress } from '@/components/ui/progress'
import { Play, Pause, RotateCw, Minus, X, Clock, SkipForward } from 'lucide-react'
import { cn, getCompletionsForToday } from '@/lib/utils'
import { cn } from '@/lib/utils'
import { useAtom } from 'jotai'
import { useTranslations } from 'next-intl'
import { settingsAtom, pomodoroAtom, habitsAtom, pomodoroTodayCompletionsAtom } from '@/lib/atoms'
import { getCompletionsForDate, getTodayInTimezone } from '@/lib/utils'
// import { getCompletionsForDate, getTodayInTimezone } from '@/lib/utils' // Not used after pomodoroTodayCompletionsAtom
import { useHabits } from '@/hooks/useHabits'
interface PomoConfig {
labels: string[]
getLabels: () => string[]
duration: number
type: 'focus' | 'break'
}
const PomoConfigs: Record<PomoConfig['type'], PomoConfig> = {
focus: {
labels: [
'Stay Focused',
'You Got This',
'Keep Going',
'Crush It',
'Make It Happen',
'Stay Strong',
'Push Through',
'One Step at a Time',
'You Can Do It',
'Focus and Conquer'
],
duration: 25 * 60,
type: 'focus',
},
break: {
labels: [
'Take a Break',
'Relax and Recharge',
'Breathe Deeply',
'Stretch It Out',
'Refresh Yourself',
'You Deserve This',
'Recharge Your Energy',
'Step Away for a Bit',
'Clear Your Mind',
'Rest and Rejuvenate'
],
duration: 5 * 60,
type: 'break',
},
}
export default function PomodoroTimer() {
const t = useTranslations('PomodoroTimer')
const PomoConfigs: Record<PomoConfig['type'], PomoConfig> = {
focus: {
getLabels: () => [
t('focusLabel1'), t('focusLabel2'), t('focusLabel3'), t('focusLabel4'), t('focusLabel5'),
t('focusLabel6'), t('focusLabel7'), t('focusLabel8'), t('focusLabel9'), t('focusLabel10')
],
duration: 25 * 60,
type: 'focus',
},
break: {
getLabels: () => [
t('breakLabel1'), t('breakLabel2'), t('breakLabel3'), t('breakLabel4'), t('breakLabel5'),
t('breakLabel6'), t('breakLabel7'), t('breakLabel8'), t('breakLabel9'), t('breakLabel10')
],
duration: 5 * 60,
type: 'break',
},
}
const [settings] = useAtom(settingsAtom)
const [pomo, setPomo] = useAtom(pomodoroAtom)
const { show, selectedHabitId, autoStart, minimized } = pomo
@@ -62,21 +49,23 @@ export default function PomodoroTimer() {
const [state, setState] = useState<'started' | 'stopped' | 'paused'>(autoStart ? 'started' : 'stopped')
const wakeLock = useRef<WakeLockSentinel | null>(null)
const [todayCompletions] = useAtom(pomodoroTodayCompletionsAtom)
const currentTimer = useRef<PomoConfig>(PomoConfigs.focus)
const [currentLabel, setCurrentLabel] = useState(
currentTimer.current.labels[Math.floor(Math.random() * currentTimer.current.labels.length)]
)
const currentTimerRef = useRef<PomoConfig>(PomoConfigs.focus)
const [currentLabel, setCurrentLabel] = useState(() => {
const labels = currentTimerRef.current.getLabels();
return labels[Math.floor(Math.random() * labels.length)];
});
// Handle wake lock
useEffect(() => {
const requestWakeLock = async () => {
try {
if (!('wakeLock' in navigator)) {
console.debug('Browser does not support wakelock')
console.debug(t('wakeLockNotSupported'))
return
}
if (wakeLock.current && !wakeLock.current.released) {
console.debug('Wake lock already in use')
console.debug(t('wakeLockInUse'))
return
}
if (state === 'started') {
@@ -85,7 +74,7 @@ export default function PomodoroTimer() {
return
}
} catch (err) {
console.error('Error requesting wake lock:', err)
console.error(t('wakeLockRequestError'), err)
}
}
@@ -96,7 +85,7 @@ export default function PomodoroTimer() {
wakeLock.current = null
}
} catch (err) {
console.error('Error releasing wake lock:', err)
console.error(t('wakeLockReleaseError'), err)
}
}
@@ -148,25 +137,13 @@ export default function PomodoroTimer() {
}
}, [state])
const playSound = useCallback(() => {
const audio = new Audio('/sounds/timer-end.wav')
audio.play().catch(error => {
console.error('Error playing sound:', error)
})
}, [])
const handleTimerEnd = async () => {
setState("stopped")
const currentTimerType = currentTimer.current.type
currentTimer.current = currentTimerType === 'focus' ? PomoConfigs.break : PomoConfigs.focus
setTimeLeft(currentTimer.current.duration)
setCurrentLabel(
currentTimer.current.labels[Math.floor(Math.random() * currentTimer.current.labels.length)]
)
// Play sound
playSound()
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') {
@@ -181,17 +158,16 @@ export default function PomodoroTimer() {
const resetTimer = () => {
setState("stopped")
setTimeLeft(currentTimer.current.duration)
setTimeLeft(currentTimerRef.current.duration)
}
const skipTimer = () => {
currentTimer.current = currentTimer.current.type === 'focus'
currentTimerRef.current = currentTimerRef.current.type === 'focus'
? PomoConfigs.break
: PomoConfigs.focus
resetTimer()
setCurrentLabel(
currentTimer.current.labels[Math.floor(Math.random() * currentTimer.current.labels.length)]
)
resetTimer() // This will also reset timeLeft to the new timer's duration
const newLabels = currentTimerRef.current.getLabels();
setCurrentLabel(newLabels[Math.floor(Math.random() * newLabels.length)])
}
const formatTime = (seconds: number) => {
@@ -200,7 +176,7 @@ export default function PomodoroTimer() {
return `${minutes}:${secs < 10 ? '0' : ''}${secs}`
}
const progress = (timeLeft / currentTimer.current.duration) * 100
const progress = (timeLeft / currentTimerRef.current.duration) * 100
if (!show) return null
@@ -253,11 +229,11 @@ export default function PomodoroTimer() {
<div className={cn(
'w-2 h-2 rounded-full flex-none',
// order matters here
currentTimer.current.type === 'focus' && 'bg-green-500',
currentTimerRef.current.type === 'focus' && 'bg-green-500',
state === 'started' && 'animate-pulse',
state === 'paused' && 'bg-yellow-500',
state === 'stopped' && 'bg-red-500',
currentTimer.current.type === 'break' && 'bg-blue-500',
currentTimerRef.current.type === 'break' && 'bg-blue-500',
)} />
<div className="font-bold text-foreground">
{selectedHabit.name}
@@ -265,7 +241,9 @@ export default function PomodoroTimer() {
</div>
</div>
)}
<span>{currentTimer.current.type.charAt(0).toUpperCase() + currentTimer.current.type.slice(1)}: {currentLabel}</span>
<span>
{currentTimerRef.current.type === 'focus' ? t('focusType') : t('breakType')}: {currentLabel}
</span>
{selectedHabit && selectedHabit.targetCompletions && selectedHabit.targetCompletions > 1 && (
<div className="flex justify-center gap-1 mt-2">
{(() => {
@@ -304,12 +282,12 @@ export default function PomodoroTimer() {
{state === "started" ? (
<>
<Pause className="h-4 w-4 sm:mr-2" />
<span className="hidden sm:inline">Pause</span>
<span className="hidden sm:inline">{t('pauseButton')}</span>
</>
) : (
<>
<Play className="h-4 w-4 sm:mr-2" />
<span className="hidden sm:inline">Start</span>
<span className="hidden sm:inline">{t('startButton')}</span>
</>
)}
</Button>
@@ -320,7 +298,7 @@ export default function PomodoroTimer() {
className="sm:px-4"
>
<RotateCw className="h-4 w-4 sm:mr-2" />
<span className="hidden sm:inline">Reset</span>
<span className="hidden sm:inline">{t('resetButton')}</span>
</Button>
<Button
variant="outline"
@@ -329,7 +307,7 @@ export default function PomodoroTimer() {
className="sm:px-4"
>
<SkipForward className="h-4 w-4 sm:mr-2" />
<span className="hidden sm:inline">Skip</span>
<span className="hidden sm:inline">{t('skipButton')}</span>
</Button>
</div>
</div>

196
components/Profile.tsx Normal file
View File

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

View File

@@ -1,9 +1,11 @@
import { useAtom } from 'jotai'
import { useTranslations } from 'next-intl'
import { settingsAtom } from '@/lib/atoms'
import { useCoins } from '@/hooks/useCoins'
import { FormattedNumber } from '@/components/FormattedNumber'
export default function TodayEarnedCoins({ longFormat }: { longFormat?: boolean }) {
const t = useTranslations('TodayEarnedCoins')
const [settings] = useAtom(settingsAtom)
const { coinsEarnedToday } = useCoins()
@@ -14,7 +16,7 @@ export default function TodayEarnedCoins({ longFormat }: { longFormat?: boolean
{"+"}
<FormattedNumber amount={coinsEarnedToday} settings={settings} />
{longFormat ?
<span className="text-sm text-muted-foreground"> today</span>
<span className="text-sm text-muted-foreground"> {t('todaySuffix')}</span>
: null}
</span>
)

View File

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

397
components/UserForm.tsx Normal file
View File

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

View File

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

View File

@@ -2,10 +2,12 @@
import { cn } from '@/lib/utils'
import { useAtom } from 'jotai'
import { CheckSquare, ListChecks } from 'lucide-react'
import { browserSettingsAtom } from '@/lib/atoms'
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 { NotificationBadge } from './ui/notification-badge'
interface ViewToggleProps {
defaultView?: ViewType
@@ -16,7 +18,10 @@ 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 handleViewChange = (checked: boolean) => {
const newView = checked ? 'tasks' : 'habits'
@@ -26,6 +31,11 @@ export function ViewToggle({
})
}
// Calculate due tasks count
const dueTasksCount = habits.habits.filter(habit =>
habit.isTask && isHabitDueToday({ habit, timezone: settings.system.timezone })
).length
return (
<div className={cn('inline-flex rounded-full bg-muted/50 h-8', className)}>
<div className="relative flex gap-0.5 rounded-full bg-background p-0.5 h-full">
@@ -37,18 +47,25 @@ export function ViewToggle({
)}
>
<HabitIcon className="h-4 w-4" />
<span className="hidden sm:inline">Habits</span>
<span className="hidden sm:inline">{t('habitsLabel')}</span>
</button>
<button
onClick={() => handleViewChange(true)}
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'
)}
<NotificationBadge
label={dueTasksCount}
show={dueTasksCount > 0}
variant={browserSettings.viewType === 'tasks' ? 'secondary' : 'default'}
className="shadow-md"
>
<TaskIcon className="h-4 w-4" />
<span className="hidden sm:inline">Tasks</span>
</button>
<button
onClick={() => handleViewChange(true)}
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'
)}
>
<TaskIcon className="h-4 w-4" />
<span className="hidden sm:inline">{t('tasksLabel')}</span>
</button>
</NotificationBadge>
<div
className={cn(
'absolute left-0.5 top-0.5 h-[calc(100%-0.25rem)] rounded-full bg-primary transition-transform',

View File

@@ -1,4 +1,9 @@
import { WishlistItemType } from '@/lib/types'
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'
@@ -24,6 +29,25 @@ interface WishlistItemProps {
isArchived?: boolean
}
const renderUserAvatars = (item: WishlistItemType, currentUser: User | null, usersData: { users: User[] }) => {
if (!item.userIds || item.userIds.length <= 1) return null;
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
return (
<Avatar key={user.id} className="h-6 w-6">
<AvatarImage src={user?.avatarPath && `/api/avatars/${user.avatarPath.split('/').pop()}` || ""} />
<AvatarFallback>{user.username[0]}</AvatarFallback>
</Avatar>
)
})}
</div>
);
};
export default function WishlistItem({
item,
onEdit,
@@ -35,6 +59,13 @@ export default function WishlistItem({
isHighlighted,
isRecentlyRedeemed
}: WishlistItemProps) {
const t = useTranslations('WishlistItem')
const [currentUser] = useAtom(currentUserAtom)
const canWrite = hasPermission(currentUser, 'wishlist', 'write')
const canInteract = hasPermission(currentUser, 'wishlist', 'interact')
const [usersData] = useAtom(usersAtom)
return (
<Card
id={`wishlist-${item.id}`}
@@ -49,21 +80,26 @@ export default function WishlistItem({
</CardTitle>
{item.targetCompletions && (
<span className="text-sm text-gray-500 dark:text-gray-400">
({item.targetCompletions} {item.targetCompletions === 1 ? 'use' : 'uses'} left)
({item.targetCompletions === 1 ? t('usesLeftSingular') : t('usesLeftPlural', { count: item.targetCompletions })})
</span>
)}
</div>
{item.description && (
<CardDescription className={`whitespace-pre-line ${item.archived ? 'text-gray-400 dark:text-gray-500' : ''}`}>
{item.description}
</CardDescription>
)}
<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>
)}
</div>
{renderUserAvatars(item, currentUser as User, usersData)}
</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} coins
{item.coinCost} {t('coinsSuffix')}
</span>
</div>
</CardContent>
@@ -73,20 +109,20 @@ export default function WishlistItem({
variant={canRedeem ? "default" : "secondary"}
size="sm"
onClick={onRedeem}
disabled={!canRedeem || item.archived}
disabled={!canRedeem || !canInteract || item.archived}
className={`transition-all duration-300 w-24 sm:w-auto ${isRecentlyRedeemed ? 'bg-green-500 hover:bg-green-600' : ''} ${item.archived ? 'cursor-not-allowed' : ''}`}
>
<Gift className={`h-4 w-4 sm:mr-2 ${isRecentlyRedeemed ? 'animate-spin' : ''}`} />
<span>
{isRecentlyRedeemed ? (
<>
<span className="sm:hidden">Done</span>
<span className="hidden sm:inline">Redeemed!</span>
<span className="sm:hidden">{t('redeemedDone')}</span>
<span className="hidden sm:inline">{t('redeemedExclamation')}</span>
</>
) : (
<>
<span className="sm:hidden">Redeem</span>
<span className="hidden sm:inline">Redeem</span>
<span className="sm:hidden">{t('redeem')}</span>
<span className="hidden sm:inline">{t('redeem')}</span>
</>
)}
</span>
@@ -98,13 +134,14 @@ export default function WishlistItem({
variant="edit"
size="sm"
onClick={onEdit}
disabled={!canWrite}
className="hidden sm:flex"
>
<Edit className="h-4 w-4" />
<span className="ml-2">Edit</span>
<span className="ml-2">{t('editButton')}</span>
</Button>
)}
<DropdownMenu>
<DropdownMenu modal={false}>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="sm" className="h-8 w-8 p-0">
<MoreVertical className="h-4 w-4" />
@@ -112,28 +149,29 @@ export default function WishlistItem({
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
{!item.archived && (
<DropdownMenuItem onClick={onArchive}>
<DropdownMenuItem disabled={!canWrite} onClick={onArchive}>
<Archive className="mr-2 h-4 w-4" />
<span>Archive</span>
<span>{t('archiveButton')}</span>
</DropdownMenuItem>
)}
{item.archived && (
<DropdownMenuItem onClick={onUnarchive}>
<DropdownMenuItem disabled={!canWrite} onClick={onUnarchive}>
<ArchiveRestore className="mr-2 h-4 w-4" />
<span>Unarchive</span>
<span>{t('unarchiveButton')}</span>
</DropdownMenuItem>
)}
<DropdownMenuItem onClick={onEdit} className="sm:hidden">
<Edit className="mr-2 h-4 w-4" />
Edit
{t('editButton')}
</DropdownMenuItem>
<DropdownMenuSeparator className="sm:hidden" />
<DropdownMenuItem
className="text-red-600 focus:text-red-600 dark:text-red-400 dark:focus:text-red-400 cursor-pointer"
className="text-red-600 focus:text-red-600 dark:text-red-400 dark:focus:text-red-400"
onClick={onDelete}
disabled={!canWrite}
>
<Trash2 className="mr-2 h-4 w-4" />
Delete
{t('deleteButton')}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>

View File

@@ -2,6 +2,7 @@
import { useState, useEffect, useRef } from 'react'
import { useWishlist } from '@/hooks/useWishlist'
import { useTranslations } from 'next-intl'
import { Plus, Gift } from 'lucide-react'
import EmptyState from './EmptyState'
import { Button } from '@/components/ui/button'
@@ -9,8 +10,11 @@ import WishlistItem from './WishlistItem'
import AddEditWishlistItemModal from './AddEditWishlistItemModal'
import ConfirmDialog from './ConfirmDialog'
import { WishlistItemType } from '@/lib/types'
import { openWindow } from '@/lib/utils'
import { toast } from '@/hooks/use-toast'
export default function WishlistManager() {
const t = useTranslations('WishlistManager')
const {
addWishlistItem,
editWishlistItem,
@@ -62,24 +66,37 @@ export default function WishlistManager() {
setTimeout(() => {
setRecentlyRedeemedId(null)
}, 3000)
if (item.link) {
setTimeout(() => {
const opened = openWindow(item.link!)
if (!opened) {
toast({
title: t('popupBlockedTitle'),
description: t('popupBlockedDescription'),
variant: "destructive"
})
}
}, 300)
}
}
}
return (
<div className="container mx-auto px-4 py-8">
<div>
<div className="flex justify-between items-center mb-6">
<h1 className="text-3xl font-bold">My Wishlist</h1>
<h1 className="text-xl xs:text-3xl font-bold">{t('title')}</h1>
<Button onClick={() => setIsModalOpen(true)}>
<Plus className="mr-2 h-4 w-4" /> Add Reward
<Plus className="mr-2 h-4 w-4" /> {t('addRewardButton')}
</Button>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 items-stretch">
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4 items-stretch">
{activeItems.length === 0 ? (
<div className="col-span-2">
<div className="col-span-1 lg:col-span-2">
<EmptyState
icon={Gift}
title="Your wishlist is empty"
description="Add rewards that you'd like to earn with your coins"
title={t('emptyStateTitle')}
description={t('emptyStateDescription')}
/>
</div>
) : (
@@ -109,12 +126,12 @@ export default function WishlistManager() {
</div>
))
)}
{archivedItems.length > 0 && (
<>
<div className="col-span-2 relative flex items-center my-6">
<div className="col-span-1 lg:col-span-2 relative flex items-center my-6">
<div className="flex-grow border-t border-gray-300 dark:border-gray-600" />
<span className="mx-4 text-sm text-gray-500 dark:text-gray-400">Archived</span>
<span className="mx-4 text-sm text-gray-500 dark:text-gray-400">{t('archivedSectionTitle')}</span>
<div className="flex-grow border-t border-gray-300 dark:border-gray-600" />
</div>
{archivedItems.map((item) => (
@@ -137,20 +154,11 @@ export default function WishlistManager() {
</div>
<AddEditWishlistItemModal
isOpen={isModalOpen}
onClose={() => {
setIsModalOpen(false)
setEditingItem(null)
}}
onSave={(item) => {
if (editingItem) {
editWishlistItem({ ...item, id: editingItem.id })
} else {
addWishlistItem(item)
}
setIsModalOpen(false)
setEditingItem(null)
}}
item={editingItem}
setIsOpen={setIsModalOpen}
editingItem={editingItem}
setEditingItem={setEditingItem}
addWishlistItem={addWishlistItem}
editWishlistItem={editWishlistItem}
/>
<ConfirmDialog
isOpen={deleteConfirmation.isOpen}
@@ -161,9 +169,9 @@ export default function WishlistManager() {
}
setDeleteConfirmation({ isOpen: false, itemId: null })
}}
title="Delete Reward"
message="Are you sure you want to delete this reward? This action cannot be undone."
confirmText="Delete"
title={t('deleteDialogTitle')}
message={t('deleteDialogMessage')}
confirmText={t('deleteButton')}
/>
</div>
)

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -3,5 +3,8 @@ services:
ports:
- "3000:3000"
volumes:
- "./data:/app/data" # Use a relative path instead of $(pwd)
- "./data:/app/data"
- "./backups:/app/backups"
image: dohsimpson/habittrove
environment:
- AUTH_SECRET=your-secret-key-here # Replace with your actual secret

View File

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

View File

@@ -1,19 +1,67 @@
import { useAtom } from 'jotai'
import { habitsAtom, coinsAtom, settingsAtom } from '@/lib/atoms'
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 } from '@/lib/types'
import { DateTime } from 'luxon'
import { getNowInMilliseconds, getTodayInTimezone, isSameDate, t2d, d2t, getNow, getCompletionsForDate, getISODate, d2s } from '@/lib/utils'
import { Habit, Permission, SafeUser, User } from '@/lib/types'
import { toast } from '@/hooks/use-toast'
import { DateTime } from 'luxon'
import {
getNowInMilliseconds,
getTodayInTimezone,
isSameDate,
t2d,
d2t,
getNow,
getCompletionsForDate,
getISODate,
d2s,
playSound,
checkPermission
} from '@/lib/utils'
import { ToastAction } from '@/components/ui/toast'
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
}
export function useHabits() {
const t = useTranslations('useHabits');
const tCommon = useTranslations('Common');
const [usersData] = useAtom(usersAtom)
const [currentUser] = useAtom(currentUserAtom)
const [habitsData, setHabitsData] = useAtom(habitsAtom)
const [coins, setCoins] = useAtom(coinsAtom)
const [settings] = useAtom(settingsAtom)
const [habitFreqMap] = useAtom(habitFreqMapAtom)
const completeHabit = async (habit: Habit) => {
if (!handlePermissionCheck(currentUser, 'habit', 'interact', tCommon)) return
const timezone = settings.system.timezone
const today = getTodayInTimezone(timezone)
@@ -28,17 +76,19 @@ export function useHabits() {
// Check if already completed
if (completionsToday >= target) {
toast({
title: "Already completed",
description: `You've already completed this habit today.`,
title: t("alreadyCompletedTitle"),
description: t("alreadyCompletedDescription"),
variant: "destructive",
})
return null
return
}
// Add new completion
const updatedHabit = {
...habit,
completions: [...habit.completions, d2t({ dateTime: getNow({ timezone }) })]
completions: [...habit.completions, d2t({ dateTime: getNow({ timezone }) })],
// Archive the habit if it's a task and we're about to reach the target
archived: habit.isTask && completionsToday + 1 === target ? true : habit.archived
}
const updatedHabits = habitsData.habits.map(h =>
@@ -46,29 +96,36 @@ export function useHabits() {
)
await saveHabitsData({ habits: updatedHabits })
setHabitsData({ habits: updatedHabits })
// Check if we've now reached the target
const isTargetReached = completionsToday + 1 === target
if (isTargetReached) {
const updatedCoins = await addCoins({
amount: habit.coinReward,
description: `Completed habit: ${habit.name}`,
description: `Completed: ${habit.name}`,
type: habit.isTask ? 'TASK_COMPLETION' : 'HABIT_COMPLETION',
relatedItemId: habit.id,
})
isTargetReached && playSound()
toast({
title: t("completedTitle"),
description: t("earnedCoinsDescription", { coinReward: habit.coinReward }),
action: <ToastAction altText={tCommon('undoButton')} className="gap-2" onClick={() => undoComplete(updatedHabit)}>
<Undo2 className="h-4 w-4" />{tCommon('undoButton')}
</ToastAction>
})
setCoins(updatedCoins)
} else {
toast({
title: t("progressTitle"),
description: t("progressDescription", { count: completionsToday + 1, target }),
action: <ToastAction altText={tCommon('undoButton')} className="gap-2" onClick={() => undoComplete(updatedHabit)}>
<Undo2 className="h-4 w-4" />{tCommon('undoButton')}
</ToastAction>
})
}
toast({
title: isTargetReached ? "Habit completed!" : "Progress!",
description: isTargetReached
? `You earned ${habit.coinReward} coins.`
: `You've completed ${completionsToday + 1}/${target} times today.`,
action: <ToastAction altText="Undo" className="gap-2" onClick={() => undoComplete(updatedHabit)}>
<Undo2 className="h-4 w-4" />Undo
</ToastAction>
})
// move atom update at the end of function to improve UI responsiveness
setHabitsData({ habits: updatedHabits })
return {
updatedHabits,
@@ -78,6 +135,7 @@ export function useHabits() {
}
const undoComplete = async (habit: Habit) => {
if (!handlePermissionCheck(currentUser, 'habit', 'interact', tCommon)) return
const timezone = settings.system.timezone
const today = t2d({ timestamp: getTodayInTimezone(timezone), timezone })
@@ -87,12 +145,13 @@ export function useHabits() {
)
if (todayCompletions.length > 0) {
// Remove the most recent completion
// Remove the most recent completion and unarchive if needed
const updatedHabit = {
...habit,
completions: habit.completions.filter(
(_, index) => index !== habit.completions.length - 1
)
),
archived: habit.isTask ? false : habit.archived // Unarchive if it's a task
}
const updatedHabits = habitsData.habits.map(h =>
@@ -107,7 +166,7 @@ export function useHabits() {
if (todayCompletions.length === target) {
const updatedCoins = await removeCoins({
amount: habit.coinReward,
description: `Undid habit completion: ${habit.name}`,
description: `Undid completion: ${habit.name}`,
type: habit.isTask ? 'TASK_UNDO' : 'HABIT_UNDO',
relatedItemId: habit.id,
})
@@ -115,14 +174,17 @@ export function useHabits() {
}
toast({
title: "Completion undone",
description: `You have ${getCompletionsForDate({
habit: updatedHabit,
date: today,
timezone
})}/${target} completions today.`,
action: <ToastAction altText="Redo" onClick={() => completeHabit(updatedHabit)}>
<Undo2 className="h-4 w-4" />Redo
title: t("completionUndoneTitle"),
description: t("completionUndoneDescription", {
count: getCompletionsForDate({
habit: updatedHabit,
date: today,
timezone
}),
target
}),
action: <ToastAction altText={tCommon('redoButton')} onClick={() => completeHabit(updatedHabit)}>
<Undo2 className="h-4 w-4" />{tCommon('redoButton')}
</ToastAction>
})
@@ -133,15 +195,16 @@ export function useHabits() {
}
} else {
toast({
title: "No completions to undo",
description: "This habit hasn't been completed today.",
title: t("noCompletionsToUndoTitle"),
description: t("noCompletionsToUndoDescription"),
variant: "destructive",
})
return null
return
}
}
const saveHabit = async (habit: Omit<Habit, 'id'> & { id?: string }) => {
if (!handlePermissionCheck(currentUser, 'habit', 'write', tCommon)) return
const newHabit = {
...habit,
id: habit.id || getNowInMilliseconds().toString()
@@ -156,6 +219,7 @@ export function useHabits() {
}
const deleteHabit = async (id: string) => {
if (!handlePermissionCheck(currentUser, 'habit', 'write', tCommon)) return
const updatedHabits = habitsData.habits.filter(h => h.id !== id)
await saveHabitsData({ habits: updatedHabits })
setHabitsData({ habits: updatedHabits })
@@ -163,6 +227,7 @@ export function useHabits() {
}
const completePastHabit = async (habit: Habit, date: DateTime) => {
if (!handlePermissionCheck(currentUser, 'habit', 'interact', tCommon)) return
const timezone = settings.system.timezone
const dateKey = getISODate({ dateTime: date, timezone })
@@ -174,11 +239,11 @@ export function useHabits() {
if (completionsOnDate >= target) {
toast({
title: "Already completed",
description: `This habit was already completed on ${d2s({ dateTime: date, timezone, format: 'yyyy-MM-dd' })}.`,
title: t("alreadyCompletedPastDateTitle"),
description: t("alreadyCompletedPastDateDescription", { dateKey: d2s({ dateTime: date, timezone, format: 'yyyy-MM-dd' }) }),
variant: "destructive",
})
return null
return
}
// Use current time but with the past date
@@ -207,7 +272,7 @@ export function useHabits() {
if (isTargetReached) {
const updatedCoins = await addCoins({
amount: habit.coinReward,
description: `Completed habit: ${habit.name} on ${d2s({ dateTime: date, timezone, format: 'yyyy-MM-dd' })}`,
description: `Completed: ${habit.name} on ${d2s({ dateTime: date, timezone, format: 'yyyy-MM-dd' })}`,
type: habit.isTask ? 'TASK_COMPLETION' : 'HABIT_COMPLETION',
relatedItemId: habit.id,
})
@@ -215,12 +280,12 @@ export function useHabits() {
}
toast({
title: isTargetReached ? "Habit completed!" : "Progress!",
title: isTargetReached ? t("completedTitle") : t("progressTitle"),
description: isTargetReached
? `You earned ${habit.coinReward} coins for ${dateKey}.`
: `You've completed ${completionsOnDate + 1}/${target} times on ${dateKey}.`,
action: <ToastAction altText="Undo" className="gap-2" onClick={() => undoComplete(updatedHabit)}>
<Undo2 className="h-4 w-4" />Undo
? t("earnedCoinsPastDateDescription", { coinReward: habit.coinReward, dateKey })
: t("progressPastDateDescription", { count: completionsOnDate + 1, target, dateKey }),
action: <ToastAction altText={tCommon('undoButton')} className="gap-2" onClick={() => undoComplete(updatedHabit)}>
<Undo2 className="h-4 w-4" />{tCommon('undoButton')}
</ToastAction>
})
@@ -232,6 +297,7 @@ export function useHabits() {
}
const archiveHabit = async (id: string) => {
if (!handlePermissionCheck(currentUser, 'habit', 'write', tCommon)) return
const updatedHabits = habitsData.habits.map(h =>
h.id === id ? { ...h, archived: true } : h
)
@@ -240,8 +306,9 @@ export function useHabits() {
}
const unarchiveHabit = async (id: string) => {
if (!handlePermissionCheck(currentUser, 'habit', 'write', tCommon)) return
const updatedHabits = habitsData.habits.map(h =>
h.id === id ? { ...h, archived: undefined } : h
h.id === id ? { ...h, archived: false } : h
)
await saveHabitsData({ habits: updatedHabits })
setHabitsData({ habits: updatedHabits })
@@ -254,6 +321,7 @@ export function useHabits() {
deleteHabit,
completePastHabit,
archiveHabit,
unarchiveHabit
unarchiveHabit,
habitFreqMap,
}
}

View File

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

13
i18n/request.ts Normal file
View File

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

28
instrumentation.ts Normal file
View File

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

View File

@@ -6,6 +6,11 @@ import {
getDefaultWishlistData,
Habit,
ViewType,
getDefaultUsersData,
CompletionCache,
getDefaultServerSettings,
User,
UserId,
} from "./types";
import {
getTodayInTimezone,
@@ -17,22 +22,36 @@ import {
calculateCoinsSpentToday,
calculateTransactionsToday,
getCompletionsForToday,
getISODate
getISODate,
isHabitDueToday,
getNow,
isHabitDue,
getHabitFreq
} from "@/lib/utils";
import { atomWithStorage } from "jotai/utils";
import { atomFamily, atomWithStorage } from "jotai/utils";
import { DateTime } from "luxon";
import { Freq } from "./types";
export interface BrowserSettings {
viewType: ViewType
expandedHabits: boolean
expandedTasks: boolean
expandedWishlist: boolean
}
export const browserSettingsAtom = atomWithStorage('browserSettings', {
viewType: 'habits'
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());
// Derived atom for coins earned today
export const coinsEarnedTodayAtom = atom((get) => {
@@ -67,6 +86,28 @@ export const transactionsTodayAtom = atom((get) => {
return calculateTransactionsToday(coins.transactions, settings.system.timezone);
});
// Atom to store the current logged-in user's ID.
// This should be set by your application when the user session is available.
export const currentUserIdAtom = atom<UserId | undefined>(undefined);
export const currentUserAtom = atom((get) => {
const currentUserId = get(currentUserIdAtom);
const users = get(usersAtom);
return users.users.find(user => user.id === currentUserId);
})
// Derived atom for current balance for the logged-in user
export const coinsBalanceAtom = atom((get) => {
const loggedInUserId = get(currentUserIdAtom);
if (!loggedInUserId) {
return 0; // No user logged in or ID not set, so balance is 0
}
const coins = get(coinsAtom);
return coins.transactions
.filter(transaction => transaction.userId === loggedInUserId)
.reduce((sum, transaction) => sum + transaction.amount, 0);
});
/* transient atoms */
interface PomodoroAtom {
show: boolean
@@ -82,37 +123,62 @@ export const pomodoroAtom = atom<PomodoroAtom>({
minimized: false,
})
// Derived atom for *fully* completed habits by date, respecting target completions
export const completedHabitsMapAtom = atom((get) => {
const habits = get(habitsAtom).habits
const timezone = get(settingsAtom).system.timezone
export const userSelectAtom = atom<boolean>(false)
export const aboutOpenAtom = atom<boolean>(false)
const map = new Map<string, Habit[]>()
// Derived atom for completion cache
export const completionCacheAtom = atom((get) => {
const habits = get(habitsAtom).habits;
const timezone = get(settingsAtom).system.timezone;
const cache: CompletionCache = {};
habits.forEach(habit => {
// Group completions by date
const completionsByDate = new Map<string, number>()
habit.completions.forEach(utcTimestamp => {
const localDate = t2d({ timestamp: utcTimestamp, timezone })
.toFormat('yyyy-MM-dd');
habit.completions.forEach(completion => {
const dateKey = getISODate({ dateTime: t2d({ timestamp: completion, timezone }), timezone })
completionsByDate.set(dateKey, (completionsByDate.get(dateKey) || 0) + 1)
})
// Check if habit meets target completions for each date
completionsByDate.forEach((count, dateKey) => {
const target = habit.targetCompletions || 1
if (count >= target) {
if (!map.has(dateKey)) {
map.set(dateKey, [])
}
map.get(dateKey)!.push(habit)
if (!cache[localDate]) {
cache[localDate] = {};
}
})
})
return map
})
cache[localDate][habit.id] = (cache[localDate][habit.id] || 0) + 1;
});
});
return cache;
});
// Derived atom for completed habits by date, using the cache
export const completedHabitsMapAtom = atom((get) => {
const habits = get(habitsAtom).habits;
const completionCache = get(completionCacheAtom);
const map = new Map<string, Habit[]>();
// For each date in the cache
Object.entries(completionCache).forEach(([dateKey, habitCompletions]) => {
const completedHabits = habits.filter(habit => {
const completionsNeeded = habit.targetCompletions || 1;
const completionsAchieved = habitCompletions[habit.id] || 0;
return completionsAchieved >= completionsNeeded;
});
if (completedHabits.length > 0) {
map.set(dateKey, completedHabits);
}
});
return map;
});
// Derived atom for habit frequency map
export const habitFreqMapAtom = atom((get) => {
const habits = get(habitsAtom).habits;
const map = new Map<string, Freq>();
habits.forEach(habit => {
map.set(habit.id, getHabitFreq(habit));
});
return map;
});
export const pomodoroTodayCompletionsAtom = atom((get) => {
const pomo = get(pomodoroAtom)
@@ -129,3 +195,28 @@ export const pomodoroTodayCompletionsAtom = atom((get) => {
timezone: settings.system.timezone
})
})
// Derived atom to check if any habits are tasks
export const hasTasksAtom = atom((get) => {
const habits = get(habitsAtom)
return habits.habits.some(habit => habit.isTask === true)
})
// Atom family for habits by specific date
export const habitsByDateFamily = atomFamily((dateString: string) =>
atom((get) => {
const habits = get(habitsAtom).habits;
const settings = get(settingsAtom);
const timezone = settings.system.timezone;
const date = DateTime.fromISO(dateString).setZone(timezone);
return habits.filter(habit => isHabitDue({ habit, timezone, date }));
})
);
// Derived atom for daily habits
export const dailyHabitsAtom = atom((get) => {
const settings = get(settingsAtom);
const today = getTodayInTimezone(settings.system.timezone);
return get(habitsByDateFamily(today));
});

143
lib/backup.ts Normal file
View File

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

View File

@@ -1,6 +1,6 @@
import { CheckSquare, Target } from "lucide-react"
export const INITIAL_RECURRENCE_RULE = 'daily'
export const INITIAL_RECURRENCE_RULE = 'every day'
export const INITIAL_DUE = 'today'
export const RECURRENCE_RULE_MAP: { [key: string]: string } = {
@@ -19,3 +19,16 @@ export const DUE_MAP: { [key: string]: string } = {
export const HabitIcon = Target
export const TaskIcon = CheckSquare;
export const QUICK_DATES = [
{ label: 'Today', value: 'today' },
{ label: 'Tomorrow', value: 'tomorrow' },
{ label: 'Monday', value: 'this monday' },
{ label: 'Tuesday', value: 'this tuesday' },
{ label: 'Wednesday', value: 'this wednesday' },
{ label: 'Thursday', value: 'this thursday' },
{ label: 'Friday', value: 'this friday' },
{ label: 'Saturday', value: 'this saturday' },
{ label: 'Sunday', value: 'this sunday' },
] as const
export const MAX_COIN_LIMIT = 9999

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

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

6
lib/exceptions.ts Normal file
View File

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

54
lib/scheduler.ts Normal file
View File

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

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

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

View File

@@ -1,3 +1,40 @@
import { RRule } from "rrule"
import { uuid } from "./utils"
import { DateTime } from "luxon"
export type UserId = string
export type Permission = {
habit: {
write: boolean
interact: boolean
}
wishlist: {
write: boolean
interact: boolean
}
coins: {
write: boolean
interact: boolean
}
}
export type SessionUser = {
id: UserId
}
export type SafeUser = SessionUser & {
username: string
avatarPath?: string
permissions?: Permission[]
isAdmin?: boolean
}
export type User = SafeUser & {
password?: string // Optional: Allow users without passwords (e.g., initial setup)
lastNotificationReadTimestamp?: string // UTC ISO date string
}
export type Habit = {
id: string
name: string
@@ -8,6 +45,8 @@ export type Habit = {
completions: string[] // Array of UTC ISO date strings
isTask?: boolean // mark the habit as a task
archived?: boolean // mark the habit as archived
pinned?: boolean // mark the habit as pinned
userIds?: UserId[]
}
@@ -20,6 +59,8 @@ export type WishlistItemType = {
coinCost: number
archived?: boolean // mark the wishlist item as archived
targetCompletions?: number // Optional field, infinity when unset
link?: string // Optional URL to external resource
userIds?: UserId[]
}
export type TransactionType = 'HABIT_COMPLETION' | 'HABIT_UNDO' | 'WISH_REDEMPTION' | 'MANUAL_ADJUSTMENT' | 'TASK_COMPLETION' | 'TASK_UNDO';
@@ -32,6 +73,11 @@ export interface CoinTransaction {
timestamp: string;
relatedItemId?: string;
note?: string;
userId?: UserId;
}
export interface UserData {
users: User[]
}
export interface HabitsData {
@@ -51,6 +97,18 @@ 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 const getDefaultHabitsData = (): HabitsData => ({
habits: []
});
@@ -72,17 +130,24 @@ export const getDefaultSettings = (): Settings => ({
},
system: {
timezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
weekStartDay: 1 // Monday
weekStartDay: 1, // Monday
autoBackupEnabled: true, // Add this line (default to true)
language: 'en', // Default language
},
profile: {}
});
export const getDefaultServerSettings = (): ServerSettings => ({
isDemo: false
})
// Map of data types to their default values
export const DATA_DEFAULTS = {
wishlist: getDefaultWishlistData,
habits: getDefaultHabitsData,
coins: getDefaultCoinsData,
settings: getDefaultSettings,
auth: getDefaultUsersData,
} as const;
// Type for all possible data types
@@ -98,10 +163,12 @@ export type WeekDay = 0 | 1 | 2 | 3 | 4 | 5 | 6; // 0 = Sunday, 6 = Saturday
export interface SystemSettings {
timezone: string;
weekStartDay: WeekDay;
autoBackupEnabled: boolean; // Add this line
language: string; // Add this line for language preference
}
export interface ProfileSettings {
avatarPath?: string;
avatarPath?: string; // deprecated
}
export interface Settings {
@@ -110,6 +177,12 @@ export interface Settings {
profile: ProfileSettings;
}
export type CompletionCache = {
[dateKey: string]: { // dateKey format: "YYYY-MM-DD"
[habitId: string]: number // number of completions on that date
}
}
export type ViewType = 'habits' | 'tasks'
export interface JotaiHydrateInitialValues {
@@ -117,4 +190,18 @@ export interface JotaiHydrateInitialValues {
coins: CoinsData;
habits: HabitsData;
wishlist: WishlistData;
users: UserData;
serverSettings: ServerSettings;
}
export interface ServerSettings {
isDemo: boolean
}
export type ParsedResultType = DateTime<true> | RRule | string | null // null if invalid
// return rrule / datetime (machine-readable frequency), string (human-readable frequency), or null (invalid)
export interface ParsedFrequencyResult {
message: string | null
result: ParsedResultType
}

View File

@@ -1,4 +1,4 @@
import { expect, test, describe, beforeAll, beforeEach, afterAll, spyOn } from "bun:test";
import { expect, test, describe, beforeEach, spyOn } from "bun:test";
import {
cn,
getTodayInTimezone,
@@ -15,12 +15,20 @@ import {
calculateTotalSpent,
calculateCoinsSpentToday,
isHabitDueToday,
isHabitDue
isHabitDue,
uuid,
isTaskOverdue,
deserializeRRule,
serializeRRule,
convertHumanReadableFrequencyToMachineReadable,
convertMachineReadableFrequencyToHumanReadable,
getUnsupportedRRuleReason
} from './utils'
import { CoinTransaction } from './types'
import { CoinTransaction, ParsedResultType } from './types'
import { DateTime } from "luxon";
import { RRule } from 'rrule';
import { RRule, Weekday } from 'rrule';
import { Habit } from '@/lib/types';
import { INITIAL_DUE } from './constants';
describe('cn utility', () => {
test('should merge class names correctly', () => {
@@ -31,6 +39,140 @@ describe('cn utility', () => {
})
})
describe('getUnsupportedRRuleReason', () => {
test('should return message for HOURLY frequency', () => {
const rrule = new RRule({ freq: RRule.HOURLY });
expect(getUnsupportedRRuleReason(rrule)).toBe('Hourly frequency is not supported.');
});
test('should return message for MINUTELY frequency', () => {
const rrule = new RRule({ freq: RRule.MINUTELY });
expect(getUnsupportedRRuleReason(rrule)).toBe('Minutely frequency is not supported.');
});
test('should return message for SECONDLY frequency', () => {
const rrule = new RRule({ freq: RRule.SECONDLY });
expect(getUnsupportedRRuleReason(rrule)).toBe('Secondly frequency is not supported.');
});
test('should return message for DAILY frequency with interval > 1', () => {
const rrule = new RRule({ freq: RRule.DAILY, interval: 2 });
expect(getUnsupportedRRuleReason(rrule)).toBe('Daily frequency with intervals greater than 1 is not supported.');
});
test('should return null for DAILY frequency without interval', () => {
const rrule = new RRule({ freq: RRule.DAILY });
expect(getUnsupportedRRuleReason(rrule)).toBeNull();
});
test('should return null for DAILY frequency with interval = 1', () => {
const rrule = new RRule({ freq: RRule.DAILY, interval: 1 });
expect(getUnsupportedRRuleReason(rrule)).toBeNull();
});
test('should return null for WEEKLY frequency', () => {
const rrule = new RRule({ freq: RRule.WEEKLY, byweekday: [RRule.MO] }); // Added byweekday for validity
expect(getUnsupportedRRuleReason(rrule)).toBeNull();
});
test('should return null for MONTHLY frequency', () => {
const rrule = new RRule({ freq: RRule.MONTHLY, bymonthday: [1] }); // Added bymonthday for validity
expect(getUnsupportedRRuleReason(rrule)).toBeNull();
});
test('should return null for YEARLY frequency', () => {
const rrule = new RRule({ freq: RRule.YEARLY, bymonth: [1], bymonthday: [1] }); // Added bymonth/bymonthday for validity
expect(getUnsupportedRRuleReason(rrule)).toBeNull();
});
test('should return null for WEEKLY frequency with interval', () => {
// Weekly with interval is supported
const rrule = new RRule({ freq: RRule.WEEKLY, interval: 2, byweekday: [RRule.TU] }); // Added byweekday for validity
expect(getUnsupportedRRuleReason(rrule)).toBeNull();
});
});
describe('isTaskOverdue', () => {
const createTestHabit = (frequency: string, isTask = true, archived = false): Habit => ({
id: 'test-habit',
name: 'Test Habit',
description: '',
frequency,
coinReward: 10,
completions: [],
isTask,
archived
})
test('should return false for non-tasks', () => {
const habit = createTestHabit('FREQ=DAILY', false)
expect(isTaskOverdue(habit, 'UTC')).toBe(false)
})
test('should return false for archived tasks', () => {
const habit = createTestHabit('2024-01-01T00:00:00Z', true, true)
expect(isTaskOverdue(habit, 'UTC')).toBe(false)
})
test('should return false for future tasks', () => {
const tomorrow = DateTime.now().plus({ days: 1 }).toUTC().toISO()
const habit = createTestHabit(tomorrow)
expect(isTaskOverdue(habit, 'UTC')).toBe(false)
})
test('should return false for completed past tasks', () => {
const yesterday = DateTime.now().minus({ days: 1 }).toUTC().toISO()
const habit = {
...createTestHabit(yesterday),
completions: [DateTime.now().toUTC().toISO()]
}
expect(isTaskOverdue(habit, 'UTC')).toBe(false)
})
test('should return true for incomplete past tasks', () => {
const yesterday = DateTime.now().minus({ days: 1 }).toUTC().toISO()
const habit = createTestHabit(yesterday)
expect(isTaskOverdue(habit, 'UTC')).toBe(true)
})
test('should handle timezone differences correctly', () => {
// Create a task due "tomorrow" in UTC
const tomorrow = DateTime.now().plus({ days: 1 }).toUTC().toISO()
const habit = createTestHabit(tomorrow)
// Test in various timezones
expect(isTaskOverdue(habit, 'UTC')).toBe(false)
expect(isTaskOverdue(habit, 'America/New_York')).toBe(false)
expect(isTaskOverdue(habit, 'Asia/Tokyo')).toBe(false)
})
})
describe('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;
@@ -452,13 +594,8 @@ describe('isHabitDueToday', () => {
test('should return false for invalid recurrence rule', () => {
const habit = testHabit('INVALID_RRULE')
// Mock console.error to prevent test output pollution
const consoleSpy = spyOn(console, 'error').mockImplementation(() => { })
// Expect the function to throw an error
expect(() => isHabitDueToday({ habit, timezone: 'UTC' })).toThrow()
consoleSpy.mockRestore()
const consoleSpy = spyOn(console, 'error').mockImplementation(() => {})
expect(isHabitDueToday({ habit, timezone: 'UTC' })).toBe(false)
})
})
@@ -570,8 +707,252 @@ 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(() => { })
expect(() => isHabitDue({ habit, timezone: 'UTC', date })).toThrow()
consoleSpy.mockRestore()
const consoleSpy = spyOn(console, 'error').mockImplementation(() => {})
expect(isHabitDue({ habit, timezone: 'UTC', date })).toBe(false)
})
})
describe('deserializeRRule', () => {
test('should deserialize valid RRule string', () => {
const rruleStr = 'FREQ=DAILY;INTERVAL=1'
const rrule = deserializeRRule(rruleStr)
expect(rrule).toBeInstanceOf(RRule)
expect(rrule?.origOptions.freq).toBe(RRule.DAILY)
expect(rrule?.origOptions.interval).toBe(1)
})
test('should return null for invalid RRule string', () => {
const rruleStr = 'INVALID_RRULE_STRING'
const rrule = deserializeRRule(rruleStr)
expect(rrule).toBeNull()
})
test('should handle complex RRule strings', () => {
const rruleStr = 'FREQ=WEEKLY;BYDAY=MO,WE,FR;INTERVAL=2;COUNT=10'
const rrule = deserializeRRule(rruleStr)
expect(rrule).toBeInstanceOf(RRule)
expect(rrule?.origOptions.freq).toBe(RRule.WEEKLY)
expect(rrule?.origOptions.byweekday).toEqual([RRule.MO, RRule.WE, RRule.FR])
expect(rrule?.origOptions.interval).toBe(2)
expect(rrule?.origOptions.count).toBe(10)
})
})
describe('serializeRRule', () => {
test('should serialize RRule object to string', () => {
const rrule = new RRule({
freq: RRule.DAILY,
interval: 1
})
const rruleStr = serializeRRule(rrule)
// RRule adds DTSTART automatically if not provided, so we check the core parts
expect(rruleStr).toContain('FREQ=DAILY')
expect(rruleStr).toContain('INTERVAL=1')
})
test('should return "invalid" for null input', () => {
const rruleStr = serializeRRule(null)
expect(rruleStr).toBe('invalid')
})
test('should serialize complex RRule objects', () => {
const rrule = new RRule({
freq: RRule.WEEKLY,
byweekday: [RRule.MO, RRule.WE, RRule.FR],
interval: 2,
count: 10
})
const rruleStr = serializeRRule(rrule)
expect(rruleStr).toContain('FREQ=WEEKLY')
expect(rruleStr).toContain('BYDAY=MO,WE,FR')
expect(rruleStr).toContain('INTERVAL=2')
expect(rruleStr).toContain('COUNT=10')
})
})
describe('convertHumanReadableFrequencyToMachineReadable', () => {
const timezone = 'America/New_York'
beforeEach(() => {
// Set a fixed date for consistent relative date parsing
const mockDate = DateTime.fromISO('2024-07-15T10:00:00', { zone: timezone }) as DateTime<true>
DateTime.now = () => mockDate
})
// Non-recurring tests
test('should parse specific date (non-recurring)', () => {
const { result, message } = convertHumanReadableFrequencyToMachineReadable({ text: 'July 16, 2024', timezone, isRecurring: false })
expect(message).toBeNull()
expect(result).toBeInstanceOf(DateTime)
expect((result as DateTime).toISODate()).toBe('2024-07-16')
})
test('should parse relative date "tomorrow" (non-recurring)', () => {
const { result, message } = convertHumanReadableFrequencyToMachineReadable({ text: 'tomorrow', timezone, isRecurring: false })
expect(message).toBeNull()
expect(result).toBeInstanceOf(DateTime)
expect((result as DateTime).toISODate()).toBe('2024-07-16') // Based on mock date 2024-07-15
})
test('should parse relative date "next friday" (non-recurring)', () => {
const { result, message } = convertHumanReadableFrequencyToMachineReadable({ text: 'next friday', timezone, isRecurring: false })
expect(message).toBeNull()
expect(result).toBeInstanceOf(DateTime)
// chrono-node interprets "next friday" from Mon July 15 as Fri July 26
expect((result as DateTime).toISODate()).toBe('2024-07-26')
})
test('should return null for invalid date string (non-recurring)', () => {
const { result, message } = convertHumanReadableFrequencyToMachineReadable({ text: 'invalid date', timezone, isRecurring: false })
expect(result).toBeNull()
expect(message).toBe('Invalid due date.')
})
// Recurring tests
test('should parse "daily" (recurring)', () => {
const { result, message } = convertHumanReadableFrequencyToMachineReadable({ text: 'daily', timezone, isRecurring: true })
expect(message).toBeNull()
expect(result).toBeInstanceOf(RRule)
expect((result as RRule).origOptions.freq).toBe(RRule.DAILY)
})
test('should parse "every week on Monday" (recurring)', () => {
const { result, message } = convertHumanReadableFrequencyToMachineReadable({ text: 'every week on Monday', timezone, isRecurring: true })
expect(message).toBeNull()
expect(result).toBeInstanceOf(RRule)
expect((result as RRule).origOptions.freq).toBe(RRule.WEEKLY)
// RRule.fromText returns Weekday objects, check the weekday property
const byweekday = (result as RRule).origOptions.byweekday;
const weekdayValues = byweekday
? (Array.isArray(byweekday)
? byweekday.map(d => typeof d === 'number' ? d : (d as Weekday).weekday)
: [typeof byweekday === 'number' ? byweekday : (byweekday as Weekday).weekday])
: [];
expect(weekdayValues).toEqual([RRule.MO.weekday])
})
test('should parse "every month on the 15th" (recurring)', () => {
const { result, message } = convertHumanReadableFrequencyToMachineReadable({ text: 'every month on the 15th', timezone, isRecurring: true })
expect(message).toBeNull()
expect(result).toBeInstanceOf(RRule)
expect((result as RRule).origOptions.freq).toBe(RRule.MONTHLY)
expect((result as RRule).origOptions.bymonthday).toEqual([15])
})
test('should parse "every year on Jan 1" (recurring)', () => {
const { result, message } = convertHumanReadableFrequencyToMachineReadable({ text: 'every year on Jan 1', timezone, isRecurring: true })
expect(message).toBeNull()
expect(result).toBeInstanceOf(RRule)
expect((result as RRule).origOptions.freq).toBe(RRule.YEARLY)
// Note: RRule.fromText parses 'Jan 1' into bymonth/bymonthday
expect((result as RRule).origOptions.bymonth).toEqual([1])
// RRule.fromText might not reliably set bymonthday in origOptions for this text
// expect((result as RRule).origOptions.bymonthday).toEqual([1])
})
test('should return validation error for "every week" without day (recurring)', () => {
const { result, message } = convertHumanReadableFrequencyToMachineReadable({ text: 'every week', timezone, isRecurring: true })
expect(result).toBeNull() // RRule.fromText might parse it, but our validation catches it
expect(message).toBe('Please specify day(s) of the week (e.g., "every week on Mon, Wed").')
})
test('should return validation error for "every month" without day/position (recurring)', () => {
const { result, message } = convertHumanReadableFrequencyToMachineReadable({ text: 'every month', timezone, isRecurring: true })
expect(result).toBeNull() // RRule.fromText might parse it, but our validation catches it
expect(message).toBe('Please specify day of the month (e.g., "every month on the 15th") or position (e.g., "every month on the last Friday").')
})
test('should return null for invalid recurrence string (recurring)', () => {
const { result, message } = convertHumanReadableFrequencyToMachineReadable({ text: 'invalid recurrence', timezone, isRecurring: true })
expect(result).toBeNull()
expect(message).toBe('Invalid recurrence rule.')
})
test('should return specific error for unsupported hourly frequency', () => {
const { result, message } = convertHumanReadableFrequencyToMachineReadable({ text: 'every hour', timezone, isRecurring: true })
expect(result).toBeInstanceOf(RRule) // RRule parses it, but our validation catches it
expect(message).toBe('Hourly frequency is not supported.')
})
test('should return specific error for unsupported daily interval', () => {
const { result, message } = convertHumanReadableFrequencyToMachineReadable({ text: 'every 2 days', timezone, isRecurring: true })
expect(result).toBeInstanceOf(RRule) // RRule parses it, but our validation catches it
expect(message).toBe('Daily frequency with intervals greater than 1 is not supported.')
})
test('should handle predefined constants like "weekdays"', () => {
const { result, message } = convertHumanReadableFrequencyToMachineReadable({ text: 'weekdays', timezone, isRecurring: true })
expect(message).toBeNull()
expect(result).toBeInstanceOf(RRule)
expect((result as RRule).origOptions.freq).toBe(RRule.WEEKLY)
// Check the weekday property of the Weekday objects
const weekdays = (result as RRule).origOptions.byweekday;
const weekdayNumbers = weekdays
? (Array.isArray(weekdays)
? weekdays.map(d => typeof d === 'number' ? d : (d as Weekday).weekday)
: [typeof weekdays === 'number' ? weekdays : (weekdays as Weekday).weekday])
: [];
expect(weekdayNumbers).toEqual([RRule.MO.weekday, RRule.TU.weekday, RRule.WE.weekday, RRule.TH.weekday, RRule.FR.weekday])
})
})
describe('convertMachineReadableFrequencyToHumanReadable', () => {
const timezone = 'America/New_York'
// Non-recurring tests
test('should format DateTime object (non-recurring)', () => {
const dateTime = DateTime.fromISO('2024-07-16T00:00:00', { zone: timezone }) as DateTime<true>
const humanReadable = convertMachineReadableFrequencyToHumanReadable({ frequency: dateTime, isRecurRule: false, timezone })
// Expected format depends on locale, check for key parts
expect(humanReadable).toContain('Jul 16, 2024')
expect(humanReadable).toContain('Tue') // Tuesday
})
test('should format ISO string (non-recurring)', () => {
const isoString = '2024-07-16T00:00:00.000-04:00' // Example ISO string with offset
const humanReadable = convertMachineReadableFrequencyToHumanReadable({ frequency: isoString, isRecurRule: false, timezone })
expect(humanReadable).toContain('Jul 16, 2024')
expect(humanReadable).toContain('Tue')
})
test('should return "Initial Due" for null frequency (non-recurring)', () => {
const humanReadable = convertMachineReadableFrequencyToHumanReadable({ frequency: null, isRecurRule: false, timezone })
// Check against the imported constant value
expect(humanReadable).toBe(INITIAL_DUE)
})
// Recurring tests
test('should format RRule object (recurring)', () => {
const rrule = new RRule({ freq: RRule.DAILY })
const humanReadable = convertMachineReadableFrequencyToHumanReadable({ frequency: rrule, isRecurRule: true, timezone })
// rrule.toText() returns "every day" for daily rules
expect(humanReadable).toBe('every day')
})
test('should format RRule string (recurring)', () => {
const rruleStr = 'FREQ=WEEKLY;BYDAY=MO,WE,FR'
const humanReadable = convertMachineReadableFrequencyToHumanReadable({ frequency: rruleStr, isRecurRule: true, timezone })
expect(humanReadable).toBe('every week on Monday, Wednesday, Friday')
})
test('should return "invalid" for invalid RRule string (recurring)', () => {
const rruleStr = 'INVALID_RRULE'
const humanReadable = convertMachineReadableFrequencyToHumanReadable({ frequency: rruleStr, isRecurRule: true, timezone })
expect(humanReadable).toBe('invalid')
})
test('should return "invalid" for null frequency (recurring)', () => {
const humanReadable = convertMachineReadableFrequencyToHumanReadable({ frequency: null, isRecurRule: true, timezone })
expect(humanReadable).toBe('invalid')
})
test('should return "invalid" for unexpected type (recurring)', () => {
const humanReadable = convertMachineReadableFrequencyToHumanReadable({ frequency: 123 as unknown as ParsedResultType, isRecurRule: true, timezone })
expect(humanReadable).toBe('invalid')
})
test('should return "invalid" for unexpected type (non-recurring)', () => {
const humanReadable = convertMachineReadableFrequencyToHumanReadable({ frequency: new RRule({ freq: RRule.DAILY }) as unknown as ParsedResultType, isRecurRule: false, timezone })
expect(humanReadable).toBe('invalid')
})
})

View File

@@ -2,9 +2,11 @@ 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 } from '@/lib/types'
import { DUE_MAP, INITIAL_RECURRENCE_RULE, RECURRENCE_RULE_MAP } from "./constants"
import * as chrono from 'chrono-node';
import { Freq, Habit, CoinTransaction, Permission, ParsedFrequencyResult, ParsedResultType, User } from '@/lib/types'
import { DUE_MAP, INITIAL_DUE, RECURRENCE_RULE_MAP } from "./constants"
import * as chrono from 'chrono-node'
import _ from "lodash"
import { v4 as uuidv4 } from 'uuid'
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
@@ -36,7 +38,7 @@ export function t2d({ timestamp, timezone }: { timestamp: string; timezone: stri
return DateTime.fromISO(timestamp).setZone(timezone);
}
// convert datetime object to iso timestamp, mostly for storage write
// convert datetime object to iso timestamp, mostly for storage write (be sure to use default utc timezone when writing)
export function d2t({ dateTime, timezone = 'utc' }: { dateTime: DateTime, timezone?: string }) {
return dateTime.setZone(timezone).toISO()!;
}
@@ -183,41 +185,126 @@ export function calculateTransactionsToday(transactions: CoinTransaction[], time
).length;
}
export function getRRuleUTC(recurrenceRule: string) {
return RRule.fromString(recurrenceRule); // this returns UTC
}
export function parseNaturalLanguageRRule(ruleText: string) {
ruleText = ruleText.trim()
if (RECURRENCE_RULE_MAP[ruleText]) {
return RRule.fromString(RECURRENCE_RULE_MAP[ruleText])
// Enhanced validation for weekly/monthly rules
function validateRecurrenceRule(rrule: RRule | null): ParsedFrequencyResult {
if (!rrule) {
return { result: null, message: 'Invalid recurrence rule.' };
}
return RRule.fromText(ruleText)
}
export function parseRRule(ruleText: string) {
ruleText = ruleText.trim()
if (RECURRENCE_RULE_MAP[ruleText]) {
return RRule.fromString(RECURRENCE_RULE_MAP[ruleText])
const unsupportedReason = getUnsupportedRRuleReason(rrule);
if (unsupportedReason) {
return { result: rrule, message: unsupportedReason };
}
return RRule.fromString(ruleText)
const options = rrule.origOptions;
if (options.freq === RRule.WEEKLY && (!options.byweekday || !Array.isArray(options.byweekday) || options.byweekday.length === 0)) {
return { result: null, message: 'Please specify day(s) of the week (e.g., "every week on Mon, Wed").' };
}
if (options.freq === RRule.MONTHLY &&
(!options.bymonthday || !Array.isArray(options.bymonthday) || options.bymonthday.length === 0) &&
(!options.bysetpos || !Array.isArray(options.bysetpos) || options.bysetpos.length === 0) && // Need to check bysetpos for rules like "last Friday"
(!options.byweekday || !Array.isArray(options.byweekday) || options.byweekday.length === 0)) { // Need byweekday with bysetpos
return { result: null, message: 'Please specify day of the month (e.g., "every month on the 15th") or position (e.g., "every month on the last Friday").' };
}
return { result: rrule, message: null };
}
export function serializeRRule(rrule: RRule) {
// Convert a human-readable frequency (recurring or non-recurring) into a machine-readable one
export function convertHumanReadableFrequencyToMachineReadable({ text, timezone, isRecurring = false }: { text: string, timezone: string, isRecurring?: boolean }): ParsedFrequencyResult {
text = text.trim()
if (!isRecurring) {
if (DUE_MAP[text]) {
text = DUE_MAP[text]
}
const now = getNow({ timezone })
const due = chrono.parseDate(text, { instant: now.toJSDate(), timezone })
if (!due) return { result: null, message: 'Invalid due date.' }
const result = due ? DateTime.fromJSDate(due).setZone(timezone) : null
return { message: null, result: result ? (result.isValid ? result : null) : null }
}
let rrule: RRule | null
if (RECURRENCE_RULE_MAP[text]) {
rrule = deserializeRRule(RECURRENCE_RULE_MAP[text])
} else if (text.toLowerCase() === 'weekdays') {
// Handle 'weekdays' specifically if not in the map
rrule = new RRule({
freq: RRule.WEEKLY,
byweekday: [RRule.MO, RRule.TU, RRule.WE, RRule.TH, RRule.FR]
});
} else {
try {
rrule = RRule.fromText(text)
} catch (error) {
rrule = null
}
}
return validateRecurrenceRule(rrule);
}
// convert a machine-readable rrule **string** to an rrule object
export function deserializeRRule(rruleStr: string): RRule | null {
try {
return RRule.fromString(rruleStr);
} catch (error) {
return null;
}
}
// convert a machine-readable rrule **object** to an rrule string
export function serializeRRule(rrule: RRule | null): string {
if (!rrule) return 'invalid'; // Handle null case explicitly
return rrule.toString()
}
export function parseNaturalLanguageDate({ text, timezone }: { text: string, timezone: string }) {
if (DUE_MAP[text]) {
text = DUE_MAP[text]
// Convert a machine-readable frequency (recurring or non-recurring) into a human-readable one
export function convertMachineReadableFrequencyToHumanReadable({
frequency,
isRecurRule,
timezone
}: {
frequency: ParsedResultType,
isRecurRule: boolean,
timezone: string
}): string {
if (isRecurRule) {
if (!frequency) {
return 'invalid'; // Handle null/undefined for recurring rules
}
if (frequency instanceof RRule) {
return frequency.toText();
} else if (typeof frequency === "string") {
const parsedResult = deserializeRRule(frequency);
return parsedResult?.toText() || 'invalid';
} else {
return 'invalid';
}
} else {
// Handle non-recurring frequency
if (!frequency) {
// Use the imported constant for initial due date text
return INITIAL_DUE;
}
if (typeof frequency === 'string') {
return d2s({
dateTime: t2d({ timestamp: frequency, timezone: timezone }),
timezone: timezone,
format: DateTime.DATE_MED_WITH_WEEKDAY
});
} else if (frequency instanceof DateTime) {
return d2s({
dateTime: frequency,
timezone: timezone,
format: DateTime.DATE_MED_WITH_WEEKDAY
});
} else {
return 'invalid';
}
}
const now = getNow({ timezone })
const due = chrono.parseDate(text, { instant: now.toJSDate(), timezone })
if (!due) throw Error('invalid rule')
// return d2s({ dateTime: DateTime.fromJSDate(due), timezone, format: DateTime.DATE_MED_WITH_WEEKDAY })
return DateTime.fromJSDate(due).setZone(timezone)
}
export function isHabitDue({
@@ -229,17 +316,24 @@ export function isHabitDue({
timezone: string
date: DateTime
}): boolean {
// handle task
if (habit.isTask) {
// For tasks, frequency is stored as a UTC ISO timestamp
const taskDueDate = t2d({ timestamp: habit.frequency, timezone })
return isSameDate(taskDueDate, date);
}
// handle habit
if (habit.archived) {
return false
}
const startOfDay = date.setZone(timezone).startOf('day')
const endOfDay = date.setZone(timezone).endOf('day')
const ruleText = habit.frequency
const rrule = parseRRule(ruleText)
const rrule = deserializeRRule(ruleText)
if (!rrule) return false
rrule.origOptions.tzid = timezone
rrule.options.tzid = rrule.origOptions.tzid
rrule.origOptions.dtstart = datetime(startOfDay.year, startOfDay.month, startOfDay.day, startOfDay.hour, startOfDay.minute, startOfDay.second)
@@ -253,6 +347,17 @@ export function isHabitDue({
return startOfDay <= t && t <= endOfDay
}
export function isHabitCompleted(habit: Habit, timezone: string): boolean {
return getCompletionsForToday({ habit, timezone: timezone }) >= (habit.targetCompletions || 1)
}
export function isTaskOverdue(habit: Habit, timezone: string): boolean {
if (!habit.isTask || habit.archived) return false
const dueDate = t2d({ timestamp: habit.frequency, timezone }).startOf('day')
const now = getNow({ timezone }).startOf('day')
return dueDate < now && !isHabitCompleted(habit, timezone)
}
export function isHabitDueToday({
habit,
timezone
@@ -269,13 +374,110 @@ export function getHabitFreq(habit: Habit): Freq {
// don't support recurring task yet
return 'daily'
}
const rrule = parseRRule(habit.frequency)
const rrule = RRule.fromString(habit.frequency)
const freq = rrule.origOptions.freq
switch (freq) {
case RRule.DAILY: return 'daily'
case RRule.WEEKLY: return 'weekly'
case RRule.MONTHLY: return 'monthly'
case RRule.YEARLY: return 'yearly'
default: throw new Error(`Invalid frequency: ${freq}`)
default:
console.error(`Invalid frequency: ${freq} (habit: ${habit.id} ${habit.name}) (rrule: ${rrule.toString()}). Defaulting to daily`)
return 'daily'
}
}
/**
* Checks if an RRule is unsupported and returns the reason.
* @param rrule The RRule object to check.
* @returns A string message explaining why the rule is unsupported, or null if it's supported.
*/
export function getUnsupportedRRuleReason(rrule: RRule): string | null {
const freq = rrule.origOptions.freq;
const interval = rrule.origOptions.interval || 1; // RRule defaults interval to 1
if (freq === RRule.HOURLY) {
return 'Hourly frequency is not supported.';
}
if (freq === RRule.MINUTELY) {
return 'Minutely frequency is not supported.';
}
if (freq === RRule.SECONDLY) {
return 'Secondly frequency is not supported.';
}
if (freq === RRule.DAILY && interval > 1) {
return 'Daily frequency with intervals greater than 1 is not supported.';
}
return null; // Rule is supported
}
// play sound (client side only, must be run in browser)
export const playSound = (soundPath: string = '/sounds/timer-end.wav') => {
const audio = new Audio(soundPath)
audio.play().catch(error => {
console.error('Error playing sound:', error)
})
}
// open a new window (client side only, must be run in browser)
export const openWindow = (url: string): boolean => {
const newWindow = window.open(url, '_blank')
if (newWindow === null) {
// Popup was blocked
return false
}
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,
resource: 'habit' | 'wishlist' | 'coins',
action: 'write' | 'interact'
): boolean {
if (!permissions) return false
return permissions.some(permission => {
switch (resource) {
case 'habit':
return permission.habit[action]
case 'wishlist':
return permission.wishlist[action]
case 'coins':
return permission.coins[action]
default:
return false
}
})
}
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);
}

17
lib/zod.ts Normal file
View File

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

435
messages/de.json Normal file
View File

@@ -0,0 +1,435 @@
{
"Dashboard": {
"title": "Dashboard"
},
"HabitList": {
"myTasks": "Meine Aufgaben",
"myHabits": "Meine Gewohnheiten",
"addTaskButton": "Aufgabe hinzufügen",
"addHabitButton": "Gewohnheit hinzufügen",
"searchTasksPlaceholder": "Aufgaben suchen...",
"searchHabitsPlaceholder": "Gewohnheiten suchen...",
"sortByLabel": "Sortieren nach:",
"sortByName": "Name",
"sortByCoinReward": "Münzbelohnung",
"sortByDueDate": "Fälligkeitsdatum",
"sortByFrequency": "Häufigkeit",
"toggleSortOrderAriaLabel": "Sortierreihenfolge umkehren",
"noTasksFoundMessage": "Keine Aufgaben gefunden, die Ihrer Suche entsprechen.",
"noHabitsFoundMessage": "Keine Gewohnheiten gefunden, die Ihrer Suche entsprechen.",
"emptyStateTasksTitle": "Noch keine Aufgaben",
"emptyStateHabitsTitle": "Noch keine Gewohnheiten",
"emptyStateTasksDescription": "Erstellen Sie Ihre erste Aufgabe, um Ihren Fortschritt zu verfolgen",
"emptyStateHabitsDescription": "Erstellen Sie Ihre erste Gewohnheit, um Ihren Fortschritt zu verfolgen",
"archivedSectionTitle": "Archiviert",
"deleteTaskDialogTitle": "Aufgabe löschen",
"deleteHabitDialogTitle": "Gewohnheit löschen",
"deleteTaskDialogMessage": "Sind Sie sicher, dass Sie diese Aufgabe löschen möchten? Diese Aktion kann nicht rückgängig gemacht werden.",
"deleteHabitDialogMessage": "Sind Sie sicher, dass Sie diese Gewohnheit löschen möchten? Diese Aktion kann nicht rückgängig gemacht werden.",
"deleteButton": "Löschen"
},
"DailyOverview": {
"addTaskButtonLabel": "Aufgabe hinzufügen",
"addHabitButtonLabel": "Gewohnheit hinzufügen",
"todaysOverviewTitle": "Heutige Übersicht",
"dailyTasksTitle": "Tägliche Aufgaben",
"noTasksDueTodayMessage": "Heute keine Aufgaben fällig. Fügen Sie Aufgaben hinzu, um zu beginnen!",
"dailyHabitsTitle": "Tägliche Gewohnheiten",
"noHabitsDueTodayMessage": "Heute keine Gewohnheiten fällig. Fügen Sie Gewohnheiten hinzu, um zu beginnen!",
"wishlistGoalsTitle": "Wunschlisten-Ziele",
"redeemableBadgeLabel": "{count}/{total} einlösbar",
"noWishlistItemsMessage": "Noch keine Elemente auf der Wunschliste. Fügen Sie Ziele hinzu, auf die Sie hinarbeiten können!",
"readyToRedeemMessage": "Bereit zum Einlösen!",
"coinsToGoMessage": "Noch {amount} Münzen benötigt",
"showLessButton": "Weniger anzeigen",
"showAllButton": "Alles anzeigen",
"viewButton": "Anzeigen",
"deleteTaskDialogTitle": "Aufgabe löschen",
"deleteHabitDialogTitle": "Gewohnheit löschen",
"confirmDeleteDialogMessage": "Sind Sie sicher, dass Sie \"{name}\" löschen möchten? Diese Aktion kann nicht rückgängig gemacht werden.",
"deleteButton": "Löschen",
"overdueTooltip": "Überfällig"
},
"HabitContextMenuItems": {
"startPomodoro": "Pomodoro starten",
"moveToToday": "Auf heute verschieben",
"moveToTomorrow": "Auf morgen verschieben",
"unpin": "Abheften",
"pin": "Anheften",
"edit": "Bearbeiten",
"archive": "Archivieren",
"unarchive": "Dearchivieren",
"delete": "Löschen"
},
"HabitStreak": {
"dailyCompletionStreakTitle": "Tägliche Abschluss-Serie",
"tooltipHabitsLabel": "Gewohnheiten",
"tooltipTasksLabel": "Aufgaben",
"tooltipCompletedLabel": "Abgeschlossen"
},
"CoinBalance": {
"coinBalanceTitle": "Münzguthaben"
},
"AddEditHabitModal": {
"editTaskTitle": "Aufgabe bearbeiten",
"editHabitTitle": "Gewohnheit bearbeiten",
"addNewTaskTitle": "Neue Aufgabe hinzufügen",
"addNewHabitTitle": "Neue Gewohnheit hinzufügen",
"nameLabel": "Name *",
"descriptionLabel": "Beschreibung",
"whenLabel": "Wann *",
"completeLabel": "Abschließen",
"timesSuffix": "mal",
"rewardLabel": "Belohnung",
"coinsSuffix": "Münzen",
"shareLabel": "Teilen",
"saveChangesButton": "Änderungen speichern",
"addTaskButton": "Aufgabe hinzufügen",
"addHabitButton": "Gewohnheit hinzufügen"
},
"ConfirmDialog": {
"confirmButton": "Bestätigen",
"cancelButton": "Abbrechen"
},
"AddEditWishlistItemModal": {
"editTitle": "Belohnung bearbeiten",
"addTitle": "Neue Belohnung hinzufügen",
"nameLabel": "Name *",
"descriptionLabel": "Beschreibung",
"costLabel": "Kosten",
"coinsSuffix": "Münzen",
"redeemableLabel": "Einlösbar",
"timesSuffix": "mal",
"errorNameRequired": "Name ist erforderlich",
"errorCoinCostMin": "Münzkosten müssen mindestens 1 sein",
"errorTargetCompletionsMin": "Zielabschlüsse müssen mindestens 1 sein",
"errorInvalidUrl": "Bitte geben Sie eine gültige URL ein",
"linkLabel": "Link",
"shareLabel": "Teilen",
"saveButton": "Änderungen speichern",
"addButton": "Belohnung hinzufügen"
},
"Navigation": {
"dashboard": "Dashboard",
"tasks": "Aufgaben",
"habits": "Gewohnheiten",
"calendar": "Kalender",
"wishlist": "Wunschliste",
"coins": "Münzen"
},
"TodayEarnedCoins": {
"todaySuffix": "heute"
},
"WishlistItem": {
"usesLeftSingular": "Verwendung übrig",
"usesLeftPlural": "Verwendungen übrig",
"coinsSuffix": "Münzen",
"redeem": "Einlösen",
"redeemedDone": "Erledigt",
"redeemedExclamation": "Eingelöst!",
"editButton": "Bearbeiten",
"archiveButton": "Archivieren",
"unarchiveButton": "Dearchivieren",
"deleteButton": "Löschen"
},
"WishlistManager": {
"title": "Meine Wunschliste",
"addRewardButton": "Belohnung hinzufügen",
"emptyStateTitle": "Ihre Wunschliste ist leer",
"emptyStateDescription": "Fügen Sie Belohnungen hinzu, die Sie mit Ihren Münzen verdienen möchten",
"archivedSectionTitle": "Archiviert",
"popupBlockedTitle": "Popup blockiert",
"popupBlockedDescription": "Bitte erlauben Sie Popups, um den Link zu öffnen",
"deleteDialogTitle": "Belohnung löschen",
"deleteDialogMessage": "Sind Sie sicher, dass Sie diese Belohnung löschen möchten? Diese Aktion kann nicht rückgängig gemacht werden.",
"deleteButton": "Löschen"
},
"UserSelectModal": {
"addUserButton": "Benutzer hinzufügen",
"createNewUserTitle": "Neuen Benutzer erstellen",
"selectUserTitle": "Benutzer auswählen",
"signInSuccessTitle": "Erfolgreich angemeldet",
"signInSuccessDescription": "Willkommen zurück, {username}!",
"errorInvalidPassword": "Ungültiges Passwort",
"deleteUserConfirmation": "Sind Sie sicher, dass Sie Benutzer {username} löschen möchten? Diese Aktion kann nicht rückgängig gemacht werden.",
"confirmDeleteButtonText": "Löschen",
"deletingButtonText": "Wird gelöscht...",
"deleteUserSuccessTitle": "Benutzer gelöscht",
"deleteUserSuccessDescription": "Benutzer {username} wurde erfolgreich gelöscht.",
"deleteUserErrorTitle": "Löschen fehlgeschlagen",
"genericError": "Ein unerwarteter Fehler ist aufgetreten.",
"networkError": "Ein Netzwerkfehler ist aufgetreten. Bitte versuchen Sie es erneut.",
"editUserTooltip": "Benutzer bearbeiten",
"deleteUserTooltip": "Benutzer löschen"
},
"CoinsManager": {
"title": "Münzverwaltung",
"currentBalanceLabel": "Aktuelles Guthaben",
"coinsSuffix": "Münzen",
"addCoinsButton": "Münzen hinzufügen",
"removeCoinsButton": "Münzen entfernen",
"statisticsTitle": "Statistiken",
"totalEarnedLabel": "Gesamt verdient",
"totalSpentLabel": "Gesamt ausgegeben",
"totalTransactionsLabel": "Gesamt Transaktionen",
"todaysEarnedLabel": "Heute verdient",
"todaysSpentLabel": "Heute ausgegeben",
"todaysTransactionsLabel": "Heutige Transaktionen",
"transactionHistoryTitle": "Transaktionsverlauf",
"showLabel": "Anzeigen:",
"entriesSuffix": "Einträge",
"showingEntries": "Zeige {from} bis {to} von {total} Einträgen",
"noTransactionsTitle": "Noch keine Transaktionen",
"noTransactionsDescription": "Ihr Transaktionsverlauf wird hier angezeigt, sobald Sie beginnen, Münzen zu verdienen oder auszugeben",
"pageLabel": "Seite",
"ofLabel": "von",
"transactionTypeHabitCompletion": "Gewohnheitsabschluss",
"transactionTypeTaskCompletion": "Aufgabenabschluss",
"transactionTypeHabitUndo": "Gewohnheitsrückgängig",
"transactionTypeTaskUndo": "Aufgabenrückgängig",
"transactionTypeWishRedemption": "Wunscherfüllung",
"transactionTypeManualAdjustment": "Manuelle Anpassung",
"transactionTypeCoinReset": "Münzrücksetzung",
"transactionTypeInitialBalance": "Anfangsguthaben"
},
"NotificationBell": {
"errorUpdateTimestamp": "Fehler beim Aktualisieren des gelesenen Benachrichtigungszeitstempels:"
},
"PomodoroTimer": {
"focusLabel1": "Bleiben Sie konzentriert",
"focusLabel2": "Sie schaffen das",
"focusLabel3": "Machen Sie weiter",
"focusLabel4": "Zerquetschen Sie es",
"focusLabel5": "Lassen Sie es geschehen",
"focusLabel6": "Bleiben Sie stark",
"focusLabel7": "Durchhalten",
"focusLabel8": "Ein Schritt nach dem anderen",
"focusLabel9": "Sie können es schaffen",
"focusLabel10": "Konzentrieren und Erobern",
"breakLabel1": "Machen Sie eine Pause",
"breakLabel2": "Entspannen und Aufladen",
"breakLabel3": "Tief durchatmen",
"breakLabel4": "Dehnen Sie sich",
"breakLabel5": "Erfrischen Sie sich",
"breakLabel6": "Sie verdienen dies",
"breakLabel7": "Laden Sie Ihre Energie auf",
"breakLabel8": "Gehen Sie kurz weg",
"breakLabel9": "Klaren Sie Ihren Geist",
"breakLabel10": "Ruhen Sie sich aus und erfrischen Sie sich",
"focusType": "Konzentration",
"breakType": "Pause",
"pauseButton": "Pause",
"startButton": "Start",
"resetButton": "Zurücksetzen",
"skipButton": "Überspringen",
"wakeLockNotSupported": "Browser unterstützt WakeLock nicht",
"wakeLockInUse": "WakeLock bereits in Benutzung",
"wakeLockRequestError": "Fehler beim Anfordern des WakeLock:",
"wakeLockReleaseError": "Fehler beim Freigeben des WakeLock:"
},
"HabitCalendar": {
"title": "Gewohnheitskalender",
"calendarCardTitle": "Kalender",
"selectDatePrompt": "Wählen Sie ein Datum",
"tasksSectionTitle": "Aufgaben",
"habitsSectionTitle": "Gewohnheiten",
"errorCompletingPastHabit": "Fehler beim Abschließen vergangener Gewohnheit:"
},
"NotificationDropdown": {
"notLoggedIn": "Nicht eingeloggt.",
"userCompletedItem": "{username} hat {itemName} abgeschlossen.",
"userRedeemedItem": "{username} hat {itemName} eingelöst.",
"activityRelatedToItem": "Aktivität bezüglich {itemName} von {username}.",
"defaultUsername": "Jemand",
"defaultItemName": "ein geteilter Gegenstand",
"notificationsTitle": "Benachrichtungen",
"notificationsTooltip": "Zeigt Abschlüsse oder Einlösungen von anderen Benutzern für Gewohnheiten oder Wunschlisten, die Sie mit ihnen geteilt haben (Sie müssen Admin sein)",
"noNotificationsYet": "Noch keine Benachrichtigkeiten"
},
"AboutModal": {
"dialogArisLabel": "über",
"changelogButton": "Änderungsprotokoll",
"createdByPrefix": "Erstellt mit ❤️ von",
"starOnGitHubButton": "Auf GitHub bewerten"
},
"PermissionSelector": {
"permissionsTitle": "Berechtigungen",
"adminAccessLabel": "Admin-Zugriff",
"adminAccessDescription": "Admins haben uneingeschränkte Berechtigungen für alle Daten aller Benutzer",
"resourceHabitTask": "Gewohnheit / Aufgabe",
"resourceWishlist": "Wunschliste",
"resourceCoins": "Münzen",
"permissionWrite": "Schreiben",
"permissionInteract": "Interagieren"
},
"UserForm": {
"toastUserUpdatedTitle": "Benutzer aktualisiert",
"toastUserUpdatedDescription": "Benutzer {username} erfolgreich aktualisiert",
"toastUserCreatedTitle": "Benutzer erstellt",
"toastUserCreatedDescription": "Benutzer {username} erfolgreich erstellt",
"actionUpdate": "aktualisieren",
"actionCreate": "erstellen",
"errorFailedUserAction": "Fehler beim {action} des Benutzers",
"toastDemoDeleteDisabled": "Löschen ist in der Demo-Instanz deaktiviert",
"toastCannotDeleteSelf": "Sie können Ihr eigenes Konto nicht löschen",
"confirmDeleteUser": "Sind Sie sicher, dass Sie den Benutzer {username} löschen möchten?",
"toastUserDeletedTitle": "Benutzer gelöscht",
"toastUserDeletedDescription": "Benutzer {username} wurde erfolgreich gelöscht",
"toastDeleteUserFailed": "Fehler beim Löschen des Benutzers: {error}",
"errorTitle": "Fehler",
"errorFileSizeLimit": "Die Dateigröße muss kleiner als 5MB sein",
"toastAvatarUploadedTitle": "Avatar hochgeladen",
"toastAvatarUploadedDescription": "Avatar erfolgreich hochgeladen",
"errorFailedAvatarUpload": "Fehler beim Hochladen des Avatars",
"changeAvatarButton": "Avatar ändern",
"uploadAvatarButton": "Avatar hochladen",
"usernameLabel": "Benutzername",
"usernamePlaceholder": "Benutzername",
"newPasswordLabel": "Neues Passwort",
"passwordLabel": "Passwort",
"passwordPlaceholderEdit": "Leerlassen, um das aktuelle beizubehalten",
"passwordPlaceholderCreate": "Passwort eingeben",
"demoPasswordDisabledMessage": "Passwort ist in der Demo-Instanz automatisch deaktiviert",
"disablePasswordLabel": "Passwort deaktivieren",
"cancelButton": "Abbrechen",
"saveChangesButton": "Änderungen speichern",
"createUserButton": "Benutzer erstellen",
"deleteAccountButton": "Konto löschen",
"deletingButtonText": "Wird gelöscht...",
"areYouSure": "Sind Sie sicher?",
"deleteUserConfirmation": "Sind Sie sicher, dass Sie den Benutzer {username} löschen möchten?",
"cancel": "Abbrechen",
"confirmDeleteButtonText": "Löschen"
},
"ViewToggle": {
"habitsLabel": "Gewohnheiten",
"tasksLabel": "Aufgaben"
},
"HabitItem": {
"overdue": "Überfällig",
"whenLabel": "Wann: {frequency}",
"coinsPerCompletion": "{count} Münzen pro Abschluss",
"completedStatus": "Abgeschlossen",
"completedStatusCount": "Abgeschlossen ({completed}/{target})",
"completedStatusCountMobile": "{completed}/{target}",
"completeButton": "Abschließen",
"completeButtonCount": "Abschließen ({completed}/{target})",
"completeButtonCountMobile": "{completed}/{target}",
"undoButton": "Rückgängig",
"editButton": "Bearbeiten"
},
"TransactionNoteEditor": {
"noteTooLongTitle": "Notiz zu lang",
"noteTooLongDescription": "Notizen müssen weniger als 200 Zeichen haben",
"errorSavingNoteTitle": "Fehler beim Speichern der Notiz",
"errorDeletingNoteTitle": "Fehler beim Löschen der Notiz",
"pleaseTryAgainDescription": "Bitte versuchen Sie es erneut",
"addNotePlaceholder": "Notiz hinzufügen...",
"saveNoteTitle": "Notiz speichern",
"cancelButtonTitle": "Abbrechen",
"deleteNoteTitle": "Notiz löschen",
"editNoteAriaLabel": "Notiz bearbeiten"
},
"Profile": {
"guestUsername": "Gast",
"editProfileButton": "Profil bearbeiten",
"signOutSuccessTitle": "Erfolgreich abgemeldet",
"signOutSuccessDescription": "Sie wurden von Ihrem Konto abgemeldet",
"signOutErrorTitle": "Abmeldefehler",
"signOutErrorDescription": "Abmeldung fehlgeschlagen",
"switchUserButton": "Benutzer wechseln",
"settingsLink": "Einstellungen",
"aboutButton": "Über",
"themeLabel": "Thema",
"editProfileModalTitle": "Profil bearbeiten"
},
"PasswordEntryForm": {
"notYouButton": "Sind Sie es nicht?",
"passwordLabel": "Passwort",
"passwordPlaceholder": "Passwort eingeben",
"loginErrorToastTitle": "Fehler",
"loginFailedErrorToastDescription": "Anmeldung fehlgeschlagen",
"cancelButton": "Abbrechen",
"loginButton": "Anmelden"
},
"CompletionCountBadge": {
"countCompleted": "{completedCount}/{totalCount} abgeschlossen"
},
"SettingsPage": {
"title": "Einstellungen",
"uiSettingsTitle": "UI-Einstellungen",
"numberFormattingLabel": "Zahlenformatierung",
"numberFormattingDescription": "Große Zahlen formatieren (z.B. 1K, 1M, 1B)",
"numberGroupingLabel": "Zahlengruppierung",
"numberGroupingDescription": "Tausendertrennzeichen verwenden (z.B. 1,000 vs 1000)",
"systemSettingsTitle": "Systemeinstellungen",
"timezoneLabel": "Zeitzone",
"timezoneDescription": "Wählen Sie Ihre Zeitzone für eine genaue Datumsverfolgung",
"weekStartDayLabel": "Wochenstarttag",
"weekStartDayDescription": "Wählen Sie Ihren bevorzugten ersten Tag der Woche",
"weekdays": {
"sunday": "Sonntag",
"monday": "Montag",
"tuesday": "Dienstag",
"wednesday": "Mittwoch",
"thursday": "Donnerstag",
"friday": "Freitag",
"saturday": "Samstag"
},
"autoBackupLabel": "Automatische Sicherung",
"autoBackupTooltip": "Wenn aktiviert, werden die Anwendungsdaten (Gewohnheiten, Münzen, Einstellungen usw.) täglich um 2 Uhr morgens Serverzeit automatisch gesichert. Backups werden als ZIP-Dateien im Verzeichnis `backups/` im Projektstamm gespeichert. Nur die letzten 7 Backups werden gespeichert; ältere werden automatisch gelöscht.",
"autoBackupDescription": "Daten automatisch täglich sichern",
"languageLabel": "Sprache",
"languageDescription": "Wählen Sie Ihre bevorzugte Anzeigesprache für die Anwendung.",
"languageChangedTitle": "Sprache geändert",
"languageChangedDescription": "Bitte aktualisieren Sie die Seite, um die Änderungen zu sehen",
"languageDisabledInDemoTooltip": "Das Ändern der Sprache ist in der Demoversion deaktiviert."
},
"Common": {
"authenticationRequiredTitle": "Authentifizierung erforderlich",
"authenticationRequiredDescription": "Bitte melden Sie sich an, um fortzufahren.",
"permissionDeniedTitle": "Berechtigung verweigert",
"permissionDeniedDescription": "Sie haben keine {action}-Berechtigung für {resource}.",
"undoButton": "Rückgängig",
"redoButton": "Wiederholen",
"errorTitle": "Fehler"
},
"useHabits": {
"alreadyCompletedTitle": "Schon abgeschlossen",
"alreadyCompletedDescription": "Sie haben diese Gewohnheit heute bereits abgeschlossen.",
"completedTitle": "Abgeschlossen!",
"earnedCoinsDescription": "Sie haben {coinReward} Münzen verdient.",
"progressTitle": "Fortschritt!",
"progressDescription": "Sie haben {count}/{target} mal heute abgeschlossen.",
"completionUndoneTitle": "Abschluss rückgängig gemacht",
"completionUndounDescription": "Sie haben {count}/{target} Abschlüsse heute.",
"noCompletionsToUndoTitle": "Keine Abschlüsse zum Rückgängigmachen",
"noCompletionsToUndoDescription": "Diese Gewohnheit wurde heute nicht abgeschlossen.",
"alreadyCompletedPastDateTitle": "Schon abgeschlossen",
"alreadyCompletedPastDateDescription": "Diese Gewohnheit wurde bereits am {dateKey} abgeschlossen.",
"earnedCoinsPastDateDescription": "Sie haben {coinReward} Münzen für {dateKey} verdient.",
"progressPastDateDescription": "Sie haben {count}/{target} mal am {dateKey} abgeschlossen."
},
"useWishlist": {
"redemptionLimitReachedTitle": "Einlösungslimit erreicht",
"redemptionLimitReachedDescription": "Sie haben das maximale Einlösungslimit für \"{itemName}\" erreicht.",
"rewardRedeemedTitle": "🎉 Belohnung eingelöst!",
"rewardRedeemedDescription": "Sie haben \"{itemName}\" für {itemCoinCost} Münzen eingelöst.",
"notEnoughCoinsTitle": "Nicht genug Münzen",
"notEnoughCoinsDescription": "Sie benötigen {coinsNeeded} Münzen mehr, um diese Belohnung einzulösen."
},
"Warning": {
"areYouSure": "Sind Sie sicher?",
"cancel": "Abbrechen"
},
"useCoins": {
"addedCoinsDescription": "{amount} Münzen hinzugefügt",
"invalidAmountTitle": "Ungültiger Betrag",
"invalidAmountDescription": "Bitte geben Sie eine gültige positive Zahl ein",
"successTitle": "Erfolg",
"transactionNotFoundDescription": "Transaktion nicht gefunden",
"maxAmountExceededDescription": "Der Betrag darf {max} nicht überschreiten.",
"transactionNotFoundDescription": "Transaktion nicht gefunden",
"maxAmountExceededDescription": "Der Betrag darf {max} nicht überschreiten."
}
}

424
messages/en.json Normal file
View File

@@ -0,0 +1,424 @@
{
"Dashboard": {
"title": "Dashboard"
},
"HabitList": {
"myTasks": "My Tasks",
"myHabits": "My Habits",
"addTaskButton": "Add Task",
"addHabitButton": "Add Habit",
"searchTasksPlaceholder": "Search tasks...",
"searchHabitsPlaceholder": "Search habits...",
"sortByLabel": "Sort by:",
"sortByName": "Name",
"sortByCoinReward": "Coin Reward",
"sortByDueDate": "Due Date",
"sortByFrequency": "Frequency",
"toggleSortOrderAriaLabel": "Toggle sort order",
"noTasksFoundMessage": "No tasks found matching your search.",
"noHabitsFoundMessage": "No habits found matching your search.",
"emptyStateTasksTitle": "No tasks yet",
"emptyStateHabitsTitle": "No habits yet",
"emptyStateTasksDescription": "Create your first task to start tracking your progress",
"emptyStateHabitsDescription": "Create your first habit to start tracking your progress",
"archivedSectionTitle": "Archived",
"deleteTaskDialogTitle": "Delete Task",
"deleteHabitDialogTitle": "Delete Habit",
"deleteTaskDialogMessage": "Are you sure you want to delete this task? This action cannot be undone.",
"deleteHabitDialogMessage": "Are you sure you want to delete this habit? This action cannot be undone.",
"deleteButton": "Delete"
},
"DailyOverview": {
"addTaskButtonLabel": "Add Task",
"addHabitButtonLabel": "Add Habit",
"todaysOverviewTitle": "Today's Overview",
"dailyTasksTitle": "Daily Tasks",
"noTasksDueTodayMessage": "No tasks due today. Add some tasks to get started!",
"dailyHabitsTitle": "Daily Habits",
"noHabitsDueTodayMessage": "No habits due today. Add some habits to get started!",
"wishlistGoalsTitle": "Wishlist Goals",
"redeemableBadgeLabel": "{count}/{total} Redeemable",
"noWishlistItemsMessage": "No wishlist items yet. Add some goals to work towards!",
"readyToRedeemMessage": "Ready to redeem!",
"coinsToGoMessage": "{amount} coins to go",
"showLessButton": "Show less",
"showAllButton": "Show all",
"viewButton": "View",
"deleteTaskDialogTitle": "Delete Task",
"deleteHabitDialogTitle": "Delete Habit",
"confirmDeleteDialogMessage": "Are you sure you want to delete \"{name}\"? This action cannot be undone.",
"deleteButton": "Delete",
"overdueTooltip": "Overdue"
},
"HabitContextMenuItems": {
"startPomodoro": "Start Pomodoro",
"moveToToday": "Move to Today",
"moveToTomorrow": "Move to Tomorrow",
"unpin": "Unpin",
"pin": "Pin",
"edit": "Edit",
"archive": "Archive",
"unarchive": "Unarchive",
"delete": "Delete"
},
"HabitStreak": {
"dailyCompletionStreakTitle": "Daily Completion Streak",
"tooltipHabitsLabel": "habits",
"tooltipTasksLabel": "tasks",
"tooltipCompletedLabel": "Completed"
},
"CoinBalance": {
"coinBalanceTitle": "Coin Balance"
},
"AddEditHabitModal": {
"editTaskTitle": "Edit Task",
"editHabitTitle": "Edit Habit",
"addNewTaskTitle": "Add New Task",
"addNewHabitTitle": "Add New Habit",
"nameLabel": "Name *",
"descriptionLabel": "Description",
"whenLabel": "When *",
"completeLabel": "Complete",
"timesSuffix": "times",
"rewardLabel": "Reward",
"coinsSuffix": "coins",
"shareLabel": "Share",
"saveChangesButton": "Save Changes",
"addTaskButton": "Add Task",
"addHabitButton": "Add Habit"
},
"ConfirmDialog": {
"confirmButton": "Confirm",
"cancelButton": "Cancel"
},
"AddEditWishlistItemModal": {
"editTitle": "Edit Reward",
"addTitle": "Add New Reward",
"nameLabel": "Name *",
"descriptionLabel": "Description",
"costLabel": "Cost",
"coinsSuffix": "coins",
"redeemableLabel": "Redeemable",
"timesSuffix": "times",
"errorNameRequired": "Name is required",
"errorCoinCostMin": "Coin cost must be at least 1",
"errorTargetCompletionsMin": "Target completions must be at least 1",
"errorInvalidUrl": "Please enter a valid URL",
"linkLabel": "Link",
"shareLabel": "Share",
"saveButton": "Save Changes",
"addButton": "Add Reward"
},
"Navigation": {
"dashboard": "Dashboard",
"tasks": "Tasks",
"habits": "Habits",
"calendar": "Calendar",
"wishlist": "Wishlist",
"coins": "Coins"
},
"TodayEarnedCoins": {
"todaySuffix": "today"
},
"WishlistItem": {
"usesLeftSingular": "use left",
"usesLeftPlural": "uses left",
"coinsSuffix": "coins",
"redeem": "Redeem",
"redeemedDone": "Done",
"redeemedExclamation": "Redeemed!",
"editButton": "Edit",
"archiveButton": "Archive",
"unarchiveButton": "Unarchive",
"deleteButton": "Delete"
},
"WishlistManager": {
"title": "My Wishlist",
"addRewardButton": "Add Reward",
"emptyStateTitle": "Your wishlist is empty",
"emptyStateDescription": "Add rewards that you'd like to earn with your coins",
"archivedSectionTitle": "Archived",
"popupBlockedTitle": "Popup Blocked",
"popupBlockedDescription": "Please allow popups to open the link",
"deleteDialogTitle": "Delete Reward",
"deleteDialogMessage": "Are you sure you want to delete this reward? This action cannot be undone.",
"deleteButton": "Delete"
},
"UserSelectModal": {
"addUserButton": "Add User",
"createNewUserTitle": "Create New User",
"selectUserTitle": "Select User",
"signInSuccessTitle": "Signed in successfully",
"signInSuccessDescription": "Welcome back, {username}!",
"errorInvalidPassword": "invalid password",
"deleteUserConfirmation": "Are you sure you want to delete user {username}? This action cannot be undone.",
"confirmDeleteButtonText": "Delete",
"deletingButtonText": "Deleting...",
"deleteUserSuccessTitle": "User Deleted",
"deleteUserSuccessDescription": "User {username} has been successfully deleted.",
"deleteUserErrorTitle": "Deletion Failed",
"genericError": "An unexpected error occurred.",
"networkError": "A network error occurred. Please try again.",
"deleteUserTooltip": "Delete user",
"editUserTooltip": "Edit user"
},
"CoinsManager": {
"title": "Coins Management",
"currentBalanceLabel": "Current Balance",
"coinsSuffix": "coins",
"addCoinsButton": "Add Coins",
"removeCoinsButton": "Remove Coins",
"statisticsTitle": "Statistics",
"totalEarnedLabel": "Total Earned",
"totalSpentLabel": "Total Spent",
"totalTransactionsLabel": "Total Transactions",
"todaysEarnedLabel": "Today's Earned",
"todaysSpentLabel": "Today's Spent",
"todaysTransactionsLabel": "Today's Transactions",
"transactionHistoryTitle": "Transaction History",
"showLabel": "Show:",
"entriesSuffix": "entries",
"showingEntries": "Showing {from} to {to} of {total} entries",
"noTransactionsTitle": "No transactions yet",
"noTransactionsDescription": "Your transaction history will appear here once you start earning or spending coins",
"pageLabel": "Page",
"ofLabel": "of",
"transactionTypeHabitCompletion": "Habit Completion",
"transactionTypeTaskCompletion": "Task Completion",
"transactionTypeHabitUndo": "Habit Undo",
"transactionTypeTaskUndo": "Task Undo",
"transactionTypeWishRedemption": "Wish Redemption",
"transactionTypeManualAdjustment": "Manual Adjustment",
"transactionTypeCoinReset": "Coin Reset",
"transactionTypeInitialBalance": "Initial Balance"
},
"NotificationBell": {
"errorUpdateTimestamp": "Failed to update notification read timestamp:"
},
"PomodoroTimer": {
"focusLabel1": "Stay Focused",
"focusLabel2": "You Got This",
"focusLabel3": "Keep Going",
"focusLabel4": "Crush It",
"focusLabel5": "Make It Happen",
"focusLabel6": "Stay Strong",
"focusLabel7": "Push Through",
"focusLabel8": "One Step at a Time",
"focusLabel9": "You Can Do It",
"focusLabel10": "Focus and Conquer",
"breakLabel1": "Take a Break",
"breakLabel2": "Relax and Recharge",
"breakLabel3": "Breathe Deeply",
"breakLabel4": "Stretch It Out",
"breakLabel5": "Refresh Yourself",
"breakLabel6": "You Deserve This",
"breakLabel7": "Recharge Your Energy",
"breakLabel8": "Step Away for a Bit",
"breakLabel9": "Clear Your Mind",
"breakLabel10": "Rest and Rejuvenate",
"focusType": "Focus",
"breakType": "Break",
"pauseButton": "Pause",
"startButton": "Start",
"resetButton": "Reset",
"skipButton": "Skip",
"wakeLockNotSupported": "Browser does not support wakelock",
"wakeLockInUse": "Wake lock already in use",
"wakeLockRequestError": "Error requesting wake lock:",
"wakeLockReleaseError": "Error releasing wake lock:"
},
"HabitCalendar": {
"title": "Habit Calendar",
"calendarCardTitle": "Calendar",
"selectDatePrompt": "Select a date",
"tasksSectionTitle": "Tasks",
"habitsSectionTitle": "Habits",
"errorCompletingPastHabit": "Error completing past habit:"
},
"NotificationDropdown": {
"notLoggedIn": "Not logged in.",
"userCompletedItem": "{username} completed {itemName}.",
"userRedeemedItem": "{username} redeemed {itemName}.",
"activityRelatedToItem": "Activity related to {itemName} by {username}.",
"defaultUsername": "Someone",
"defaultItemName": "a shared item",
"notificationsTitle": "Notifications",
"notificationsTooltip": "Shows completions or redemptions by other users for habits or wishlist that you shared with them (you must be admin)",
"noNotificationsYet": "No notifications yet."
},
"AboutModal": {
"dialogArisLabel": "about",
"changelogButton": "Changelog",
"createdByPrefix": "Created with ❤️ by",
"starOnGitHubButton": "Star on GitHub"
},
"PermissionSelector": {
"permissionsTitle": "Permissions",
"adminAccessLabel": "Admin Access",
"adminAccessDescription": "Admins have full permission to all data for all users",
"resourceHabitTask": "Habit / Task",
"resourceWishlist": "Wishlist",
"resourceCoins": "Coins",
"permissionWrite": "Write",
"permissionInteract": "Interact"
},
"UserForm": {
"toastUserUpdatedTitle": "User updated",
"toastUserUpdatedDescription": "Successfully updated user {username}",
"toastUserCreatedTitle": "User created",
"toastUserCreatedDescription": "Successfully created user {username}",
"actionUpdate": "update",
"actionCreate": "create",
"errorFailedUserAction": "Failed to {action} user",
"errorTitle": "Error",
"errorFileSizeLimit": "File size must be less than 5MB",
"toastAvatarUploadedTitle": "Avatar uploaded",
"toastAvatarUploadedDescription": "Successfully uploaded avatar",
"errorFailedAvatarUpload": "Failed to upload avatar",
"changeAvatarButton": "Change Avatar",
"uploadAvatarButton": "Upload Avatar",
"usernameLabel": "Username",
"usernamePlaceholder": "Username",
"newPasswordLabel": "New Password",
"passwordLabel": "Password",
"passwordPlaceholderEdit": "Leave blank to keep current",
"passwordPlaceholderCreate": "Enter password",
"demoPasswordDisabledMessage": "Password is automatically disabled in demo instance",
"disablePasswordLabel": "Disable password",
"cancelButton": "Cancel",
"saveChangesButton": "Save Changes",
"createUserButton": "Create User",
"deleteAccountButton": "Delete Account"
},
"ViewToggle": {
"habitsLabel": "Habits",
"tasksLabel": "Tasks"
},
"HabitItem": {
"overdue": "Overdue",
"whenLabel": "When: {frequency}",
"coinsPerCompletion": "{count} coins per completion",
"completedStatus": "Completed",
"completedStatusCount": "Completed ({completed}/{target})",
"completedStatusCountMobile": "{completed}/{target}",
"completeButton": "Complete",
"completeButtonCount": "Complete ({completed}/{target})",
"completeButtonCountMobile": "{completed}/{target}",
"undoButton": "Undo",
"editButton": "Edit"
},
"TransactionNoteEditor": {
"noteTooLongTitle": "Note too long",
"noteTooLongDescription": "Notes must be less than 200 characters",
"errorSavingNoteTitle": "Error saving note",
"errorDeletingNoteTitle": "Error deleting note",
"pleaseTryAgainDescription": "Please try again",
"addNotePlaceholder": "Add a note...",
"saveNoteTitle": "Save note",
"cancelButtonTitle": "Cancel",
"deleteNoteTitle": "Delete note",
"editNoteAriaLabel": "Edit note"
},
"Profile": {
"guestUsername": "Guest",
"editProfileButton": "Edit profile",
"signOutSuccessTitle": "Signed out successfully",
"signOutSuccessDescription": "You have been logged out of your account",
"signOutErrorTitle": "Sign Out Error",
"signOutErrorDescription": "Failed to sign out",
"switchUserButton": "Switch user",
"settingsLink": "Settings",
"aboutButton": "About",
"themeLabel": "Theme",
"editProfileModalTitle": "Edit Profile"
},
"PasswordEntryForm": {
"notYouButton": "Not you?",
"passwordLabel": "Password",
"passwordPlaceholder": "Enter password",
"loginErrorToastTitle": "Error",
"loginFailedErrorToastDescription": "Login failed",
"cancelButton": "Cancel",
"loginButton": "Login"
},
"CompletionCountBadge": {
"countCompleted": "{completedCount}/{totalCount} completed"
},
"SettingsPage": {
"title": "Settings",
"uiSettingsTitle": "UI Settings",
"numberFormattingLabel": "Number Formatting",
"numberFormattingDescription": "Format large numbers (e.g., 1K, 1M, 1B)",
"numberGroupingLabel": "Number Grouping",
"numberGroupingDescription": "Use thousand separators (e.g., 1,000 vs 1000)",
"systemSettingsTitle": "System Settings",
"timezoneLabel": "Timezone",
"timezoneDescription": "Select your timezone for accurate date tracking",
"weekStartDayLabel": "Week Start Day",
"weekStartDayDescription": "Select your preferred first day of the week",
"weekdays": {
"sunday": "Sunday",
"monday": "Monday",
"tuesday": "Tuesday",
"wednesday": "Wednesday",
"thursday": "Thursday",
"friday": "Friday",
"saturday": "Saturday"
},
"autoBackupLabel": "Auto Backup",
"autoBackupTooltip": "When enabled, the application data (habits, coins, settings, etc.) will be automatically backed up daily around 2 AM server time. Backups are stored as ZIP files in the `backups/` directory at the project root. Only the last 7 backups are kept; older ones are automatically deleted.",
"autoBackupDescription": "Automatically back up data daily",
"languageLabel": "Language",
"languageDescription": "Choose your preferred display language for the application.",
"languageChangedTitle": "Language Changed",
"languageChangedDescription": "Please refresh the page to see the changes",
"languageDisabledInDemoTooltip": "Changing the language is disabled in the demo version."
},
"Common": {
"authenticationRequiredTitle": "Authentication Required",
"authenticationRequiredDescription": "Please sign in to continue.",
"permissionDeniedTitle": "Permission Denied",
"permissionDeniedDescription": "You don't have {action} permission for {resource}s.",
"undoButton": "Undo",
"redoButton": "Redo",
"errorTitle": "Error"
},
"useHabits": {
"alreadyCompletedTitle": "Already completed",
"alreadyCompletedDescription": "You've already completed this habit today.",
"completedTitle": "Completed!",
"earnedCoinsDescription": "You earned {coinReward} coins.",
"progressTitle": "Progress!",
"progressDescription": "You've completed {count}/{target} times today.",
"completionUndoneTitle": "Completion undone",
"completionUndoneDescription": "You have {count}/{target} completions today.",
"noCompletionsToUndoTitle": "No completions to undo",
"noCompletionsToUndoDescription": "This habit hasn't been completed today.",
"alreadyCompletedPastDateTitle": "Already completed",
"alreadyCompletedPastDateDescription": "This habit was already completed on {dateKey}.",
"earnedCoinsPastDateDescription": "You earned {coinReward} coins for {dateKey}.",
"progressPastDateDescription": "You've completed {count}/{target} times on {dateKey}."
},
"useWishlist": {
"redemptionLimitReachedTitle": "Redemption limit reached",
"redemptionLimitReachedDescription": "You've reached the maximum redemptions for \"{itemName}\".",
"rewardRedeemedTitle": "🎉 Reward Redeemed!",
"rewardRedeemedDescription": "You've redeemed \"{itemName}\" for {itemCoinCost} coins.",
"notEnoughCoinsTitle": "Not enough coins",
"notEnoughCoinsDescription": "You need {coinsNeeded} more coins to redeem this reward."
},
"useCoins": {
"addedCoinsDescription": "Added {amount} coins",
"invalidAmountTitle": "Invalid amount",
"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"
}
}

435
messages/es.json Normal file
View File

@@ -0,0 +1,435 @@
{
"Dashboard": {
"title": "Tablero"
},
"HabitList": {
"myTasks": "Mis tareas",
"myHabits": "Mis hábitos",
"addTaskButton": "Añadir tarea",
"addHabitButton": "Añadir hábito",
"searchTasksPlaceholder": "Buscar tareas...",
"searchHabitsPlaceholder": "Buscar hábitos...",
"sortByLabel": "Ordenar por:",
"sortByName": "Nombre",
"sortByCoinReward": "Recompensa de monedas",
"sortByDueDate": "Fecha límite",
"sortByFrequency": "Frecuencia",
"toggleSortOrderAriaLabel": "Cambiar orden de clasificación",
"noTasksFoundMessage": "No se encontraron tareas que coincidan con tu búsqueda.",
"noHabitsFoundMessage": "No se encontraron hábitos que coincidan con tu búsqueda.",
"emptyStateTasksTitle": "Aún no hay tareas",
"emptyStateHabitsTitle": "Aún no hay hábitos",
"emptyStateTasksDescription": "Crea tu primera tarea para empezar a seguir tu progreso",
"emptyStateHabitsDescription": "Crea tu primer hábito para empezar a seguir tu progreso",
"archivedSectionTitle": "Archivado",
"deleteTaskDialogTitle": "Eliminar tarea",
"deleteHabitDialogTitle": "Eliminar hábito",
"deleteTaskDialogMessage": "¿Estás seguro de que quieres eliminar esta tarea? Esta acción no se puede deshacer.",
"deleteHabitDialogMessage": "¿Estás seguro de que quieres eliminar este hábito? Esta acción no se puede deshacer.",
"deleteButton": "Eliminar"
},
"DailyOverview": {
"addTaskButtonLabel": "Añadir tarea",
"addHabitButtonLabel": "Añadir hábito",
"todaysOverviewTitle": "Resumen de hoy",
"dailyTasksTitle": "Tareas diarias",
"noTasksDueTodayMessage": "No hay tareas para hoy. ¡Añade algunas para empezar!",
"dailyHabitsTitle": "Hábitos diarios",
"noHabitsDueTodayMessage": "No hay hábitos para hoy. ¡Añade algunos para empezar!",
"wishlistGoalsTitle": "Objetivos de lista de deseos",
"redeemableBadgeLabel": "{count}/{total} canjeable",
"noWishlistItemsMessage": "Aún no hay items en la lista de deseos. ¡Añade algunas metas para trabajar!",
"readyToRedeemMessage": "¡Listo para canjear!",
"coinsToGoMessage": "Faltan {amount} monedas",
"showLessButton": "Mostrar menos",
"showAllButton": "Mostrar todo",
"viewButton": "Ver",
"deleteTaskDialogTitle": "Eliminar tarea",
"deleteHabitDialogTitle": "Eliminar hábito",
"confirmDeleteDialogMessage": "¿Estás seguro de que quieres eliminar \"{name}\"? Esta acción no se puede deshacer.",
"deleteButton": "Eliminar",
"overdueTooltip": "Vencido"
},
"HabitContextMenuItems": {
"startPomodoro": "Iniciar Pomodoro",
"moveToToday": "Mover a hoy",
"moveToTomorrow": "Mover a mañana",
"unpin": "Desanclar",
"pin": "Anclar",
"edit": "Editar",
"archive": "Archivar",
"unarchive": "Desarchivar",
"delete": "Eliminar"
},
"HabitStreak": {
"dailyCompletionStreakTitle": "Racha de finalización diaria",
"tooltipHabitsLabel": "hábitos",
"tooltipTasksLabel": "tareas",
"tooltipCompletedLabel": "Completado"
},
"CoinBalance": {
"coinBalanceTitle": "Saldo de monedas"
},
"AddEditHabitModal": {
"editTaskTitle": "Editar tarea",
"editHabitTitle": "Editar hábito",
"addNewTaskTitle": "Añadir nueva tarea",
"addNewHabitTitle": "Añadir nuevo hábito",
"nameLabel": "Nombre *",
"descriptionLabel": "Descripción",
"whenLabel": "Cuándo *",
"completeLabel": "Completar",
"timesSuffix": "veces",
"rewardLabel": "Recompensa",
"coinsSuffix": "monedas",
"shareLabel": "Compartir",
"saveChangesButton": "Guardar cambios",
"addTaskButton": "Añadir tarea",
"addHabitButton": "Añadir hábito"
},
"ConfirmDialog": {
"confirmButton": "Confirmar",
"cancelButton": "Cancelar"
},
"AddEditWishlistItemModal": {
"editTitle": "Editar recompensa",
"addTitle": "Añadir nueva recompensa",
"nameLabel": "Nombre *",
"descriptionLabel": "Descripción",
"costLabel": "Costo",
"coinsSuffix": "monedas",
"redeemableLabel": "Canjeable",
"timesSuffix": "veces",
"errorNameRequired": "El nombre es requerido",
"errorCoinCostMin": "El costo en monedas debe ser al menos 1",
"errorTargetCompletionsMin": "El número de finalizaciones objetivo debe ser al menos 1",
"errorInvalidUrl": "Por favor ingresa una URL válida",
"linkLabel": "Enlace",
"shareLabel": "Compartir",
"saveButton": "Guardar cambios",
"addButton": "Añadir recompensa"
},
"Navigation": {
"dashboard": "Tablero",
"tasks": "Tareas",
"habits": "Hábitos",
"calendar": "Calendario",
"wishlist": "Lista de deseos",
"coins": "Monedas"
},
"TodayEarnedCoins": {
"todaySuffix": "hoy"
},
"WishlistItem": {
"usesLeftSingular": "uso restante",
"usesLeftPlural": "usos restantes",
"coinsSuffix": "monedas",
"redeem": "Canjear",
"redeemedDone": "Hecho",
"redeemedExclamation": "¡Canjeado!",
"editButton": "Editar",
"archiveButton": "Archivar",
"unarchiveButton": "Desarchivar",
"deleteButton": "Eliminar"
},
"WishlistManager": {
"title": "Mi lista de deseos",
"addRewardButton": "Añadir recompensa",
"emptyStateTitle": "Tu lista de deseos está vacía",
"emptyStateDescription": "Añade recompensas que te gustaría ganar con tus monedas",
"archivedSectionTitle": "Archivado",
"popupBlockedTitle": "Popup bloqueado",
"popupBlockedDescription": "Por favor permite los popups para abrir el enlace",
"deleteDialogTitle": "Eliminar recompensa",
"deleteDialogMessage": "¿Estás seguro de que quieres eliminar esta recompensa? Esta acción no se puede deshacer.",
"deleteButton": "Eliminar"
},
"UserSelectModal": {
"addUserButton": "Añadir usuario",
"createNewUserTitle": "Crear nuevo usuario",
"selectUserTitle": "Seleccionar usuario",
"signInSuccessTitle": "Inicio de sesión exitoso",
"signInSuccessDescription": "¡Bienvenido de nuevo, {username}!",
"errorInvalidPassword": "contraseña inválida",
"deleteUserConfirmation": "¿Estás seguro de que quieres eliminar al usuario {username}? Esta acción no se puede deshacer.",
"confirmDeleteButtonText": "Eliminar",
"deletingButtonText": "Eliminando...",
"deleteUserSuccessTitle": "Usuario eliminado",
"deleteUserSuccessDescription": "El usuario {username} ha sido eliminado correctamente.",
"deleteUserErrorTitle": "Error al eliminar",
"genericError": "Ocurrió un error inesperado.",
"networkError": "Ocurrió un error de red. Por favor, inténtalo de nuevo.",
"editUserTooltip": "Editar usuario",
"deleteUserTooltip": "Eliminar usuario"
},
"CoinsManager": {
"title": "Gestión de monedas",
"currentBalanceLabel": "Saldo actual",
"coinsSuffix": "monedas",
"addCoinsButton": "Añadir monedas",
"removeCoinsButton": "Quitar monedas",
"statisticsTitle": "Estadísticas",
"totalEarnedLabel": "Total ganado",
"totalSpentLabel": "Total gastado",
"totalTransactionsLabel": "Transacciones totales",
"todaysEarnedLabel": "Ganado hoy",
"todaysSpentLabel": "Gastado hoy",
"todaysTransactionsLabel": "Transacciones hoy",
"transactionHistoryTitle": "Historial de transacciones",
"showLabel": "Mostrar:",
"entriesSuffix": "entradas",
"showingEntries": "Mostrando {from} a {to} de {total} entradas",
"noTransactionsTitle": "Aún no hay transacciones",
"noTransactionsDescription": "Tu historial de transacciones aparecerá aquí una vez que empieces a ganar o gastar monedas",
"pageLabel": "Página",
"ofLabel": "de",
"transactionTypeHabitCompletion": "Finalización de hábito",
"transactionTypeTaskCompletion": "Finalización de tarea",
"transactionTypeHabitUndo": "Deshacer hábito",
"transactionTypeTaskUndo": "Deshacer tarea",
"transactionTypeWishRedemption": "Canje de deseo",
"transactionTypeManualAdjustment": "Ajuste manual",
"transactionTypeCoinReset": "Reinicio de monedas",
"transactionTypeInitialBalance": "Saldo inicial"
},
"NotificationBell": {
"errorUpdateTimestamp": "Error al actualizar la marca de tiempo de notificación leída:"
},
"PomodoroTimer": {
"focusLabel1": "Mantente enfocado",
"focusLabel2": "Tú puedes",
"focusLabel3": "Sigue adelante",
"focusLabel4": "Hazlo",
"focusLabel5": "Haz que suceda",
"focusLabel6": "Mantente fuerte",
"focusLabel7": "Esfuérzate",
"focusLabel8": "Un paso a la vez",
"focusLabel9": "Tú puedes hacerlo",
"focusLabel10": "Enfócate y conquista",
"breakLabel1": "Toma un descanso",
"breakLabel2": "Relájate y recarga",
"breakLabel3": "Respira profundamente",
"breakLabel4": "Estírate",
"breakLabel5": "Refréscate",
"breakLabel6": "Te lo mereces",
"breakLabel7": "Recarga tu energía",
"breakLabel8": "Aléjate un momento",
"breakLabel9": "Despeja tu mente",
"breakLabel10": "Descansa y recupérate",
"focusType": "Enfoque",
"breakType": "Descanso",
"pauseButton": "Pausar",
"startButton": "Iniciar",
"resetButton": "Reiniciar",
"skipButton": "Saltar",
"wakeLockNotSupported": "El navegador no soporta wake lock",
"wakeLockInUse": "Wake lock ya está en uso",
"wakeLockRequestError": "Error al solicitar wake lock:",
"wakeLockReleaseError": "Error al liberar wake lock:"
},
"HabitCalendar": {
"title": "Calendario de hábitos",
"calendarCardTitle": "Calendario",
"selectDatePrompt": "Selecciona una fecha",
"tasksSectionTitle": "Tareas",
"habitsSectionTitle": "Hábitos",
"errorCompletingPastHabit": "Error al completar hábito pasado:"
},
"NotificationDropdown": {
"notLoggedIn": "No has iniciado sesión.",
"userCompletedItem": "{username} completó {itemName}.",
"userRedeemedItem": "{username} canjeó {itemName}.",
"activityRelatedToItem": "Actividad relacionada con {itemName} por {username}.",
"defaultUsername": "Alguien",
"defaultItemName": "un item compartido",
"notificationsTitle": "Notificaciones",
"notificationsTooltip": "Muestra finalizaciones o canjes de otros usuarios para hábitos o lista de deseos que compartiste con ellos (debes ser admin)",
"noNotificationsYet": "Aún no hay notificaciones."
},
"AboutModal": {
"dialogArisLabel": "acerca de",
"changelogButton": "Registro de cambios",
"createdByPrefix": "Creado con ❤️ por",
"starOnGitHubButton": "Dar estrella en GitHub"
},
"PermissionSelector": {
"permissionsTitle": "Permisos",
"adminAccessLabel": "Acceso de administrador",
"adminAccessDescription": "Los administradores tienen permiso completo sobre todos los datos de todos los usuarios",
"resourceHabitTask": "Hábito / Tarea",
"resourceWishlist": "Lista de deseos",
"resourceCoins": "Monedas",
"permissionWrite": "Escritura",
"permissionInteract": "Interactuar"
},
"UserForm": {
"toastUserUpdatedTitle": "Usuario actualizado",
"toastUserUpdatedDescription": "Usuario {username} actualizado con éxito",
"toastUserCreatedTitle": "Usuario creado",
"toastUserCreatedDescription": "Usuario {username} creado con éxito",
"actionUpdate": "actualizar",
"actionCreate": "crear",
"errorFailedUserAction": "Error al {action} usuario",
"toastDemoDeleteDisabled": "La eliminación está deshabilitada en la instancia demo",
"toastCannotDeleteSelf": "No puedes eliminar tu propia cuenta",
"confirmDeleteUser": "¿Estás seguro de que deseas eliminar al usuario {username}?",
"toastUserDeletedTitle": "Usuario eliminado",
"toastUserDeletedDescription": "El usuario {username} ha sido eliminado correctamente",
"toastDeleteUserFailed": "Error al eliminar el usuario: {error}",
"errorTitle": "Error",
"errorFileSizeLimit": "El tamaño del archivo debe ser menor a 5MB",
"toastAvatarUploadedTitle": "Avatar subido",
"toastAvatarUploadedDescription": "Avatar subido con éxito",
"errorFailedAvatarUpload": "Error al subir avatar",
"changeAvatarButton": "Cambiar avatar",
"uploadAvatarButton": "Subir avatar",
"usernameLabel": "Nombre de usuario",
"usernamePlaceholder": "Nombre de usuario",
"newPasswordLabel": "Nueva contraseña",
"passwordLabel": "Contraseña",
"passwordPlaceholderEdit": "Dejar en blanco para mantener la actual",
"passwordPlaceholderCreate": "Ingresar contraseña",
"demoPasswordDisabledMessage": "La contraseña está automáticamente desactivada en la instancia demo",
"disablePasswordLabel": "Desactivar contraseña",
"cancelButton": "Cancelar",
"saveChangesButton": "Guardar cambios",
"createUserButton": "Crear usuario",
"deleteAccountButton": "Eliminar cuenta",
"deletingButtonText": "Eliminando...",
"areYouSure": "¿Estás seguro?",
"deleteUserConfirmation": "¿Estás seguro de que deseas eliminar al usuario {username}?",
"cancel": "Cancelar",
"confirmDeleteButtonText": "Eliminar"
},
"ViewToggle": {
"habitsLabel": "Hábitos",
"tasksLabel": "Tareas"
},
"HabitItem": {
"overdue": "Vencido",
"whenLabel": "Cuándo: {frequency}",
"coinsPerCompletion": "{count} monedas por finalización",
"completedStatus": "Completado",
"completedStatusCount": "Completado ({completed}/{target})",
"completedStatusCountMobile": "{completed}/{target}",
"completeButton": "Completar",
"completeButtonCount": "Completar ({completed}/{target})",
"completeButtonCountMobile": "{completed}/{target}",
"undoButton": "Deshacer",
"editButton": "Editar"
},
"TransactionNoteEditor": {
"noteTooLongTitle": "Nota demasiado larga",
"noteTooLongDescription": "Las notas deben tener menos de 200 caracteres",
"errorSavingNoteTitle": "Error al guardar nota",
"errorDeletingNoteTitle": "Error al eliminar nota",
"pleaseTryAgainDescription": "Por favor inténtalo de nuevo",
"addNotePlaceholder": "Añadir nota...",
"saveNoteTitle": "Guardar nota",
"cancelButtonTitle": "Cancelar",
"deleteNoteTitle": "Eliminar nota",
"editNoteAriaLabel": "Editar nota"
},
"Profile": {
"guestUsername": "Invitado",
"editProfileButton": "Editar perfil",
"signOutSuccessTitle": "Cierre de sesión exitoso",
"signOutSuccessDescription": "Has cerrado sesión en tu cuenta",
"signOutErrorTitle": "Error al cerrar sesión",
"signOutErrorDescription": "Error al cerrar sesión",
"switchUserButton": "Cambiar usuario",
"settingsLink": "Configuración",
"aboutButton": "Acerca de",
"themeLabel": "Tema",
"editProfileModalTitle": "Editar perfil"
},
"PasswordEntryForm": {
"notYouButton": "¿No eres tú?",
"passwordLabel": "Contraseña",
"passwordPlaceholder": "Ingresar contraseña",
"loginErrorToastTitle": "Error",
"loginFailedErrorToastDescription": "Error al iniciar sesión",
"cancelButton": "Cancelar",
"loginButton": "Iniciar sesión"
},
"CompletionCountBadge": {
"countCompleted": "{completedCount}/{totalCount} completado"
},
"SettingsPage": {
"title": "Configuración",
"uiSettingsTitle": "Configuración de interfaz",
"numberFormattingLabel": "Formato numérico",
"numberFormattingDescription": "Formatear números grandes (ej: 1K, 1M, 1B)",
"numberGroupingLabel": "Agrupación numérica",
"numberGroupingDescription": "Usar separadores de miles (ej: 1,000 vs 1000)",
"systemSettingsTitle": "Configuración del sistema",
"timezoneLabel": "Zona horaria",
"timezoneDescription": "Selecciona tu zona horaria para un seguimiento preciso de fechas",
"weekStartDayLabel": "Día de inicio de semana",
"weekStartDayDescription": "Selecciona tu día preferido para iniciar la semana",
"weekdays": {
"sunday": "Domingo",
"monday": "Lunes",
"tuesday": "Martes",
"wednesday": "Miércoles",
"thursday": "Jueves",
"friday": "Viernes",
"saturday": "Sábado"
},
"autoBackupLabel": "Copia de seguridad automática",
"autoBackupTooltip": "Cuando está habilitado, los datos de la aplicación (hábitos, monedas, configuraciones, etc.) se respaldan automáticamente diariamente alrededor de las 2 AM hora del servidor. Las copias de seguridad se almacenan como archivos ZIP en el directorio `backups/` en la raíz del proyecto. Solo se conservan las últimas 7 copias de seguridad; las más antiguas se eliminan automáticamente.",
"autoBackupDescription": "Realizar copia de seguridad automática diaria",
"languageLabel": "Idioma",
"languageDescription": "Elige tu idioma preferido para mostrar en la aplicación.",
"languageChangedTitle": "Idioma cambiado",
"languageChangedDescription": "Por favor actualiza la página para ver los cambios",
"languageDisabledInDemoTooltip": "Cambiar el idioma está deshabilitado en la versión de demostración."
},
"Common": {
"authenticationRequiredTitle": "Autenticación requerida",
"authenticationRequiredDescription": "Por favor inicia sesión para continuar.",
"permissionDeniedTitle": "Permiso denegado",
"permissionDeniedDescription": "No tienes permiso de {action} para {resource}.",
"undoButton": "Deshacer",
"redoButton": "Rehacer",
"errorTitle": "Error"
},
"useHabits": {
"alreadyCompletedTitle": "Ya completado",
"alreadyCompletedDescription": "Ya has completado este hábito hoy.",
"completedTitle": "¡Completado!",
"earnedCoinsDescription": "Ganaste {coinReward} monedas.",
"progressTitle": "¡Progreso!",
"progressDescription": "Has completado {count}/{target} veces hoy.",
"completionUndoneTitle": "Finalización deshecha",
"completionUndoneDescription": "Tienes {count}/{target} finalizaciones hoy.",
"noCompletionsToUndoTitle": "No hay finalizaciones para deshacer",
"noCompletionsToUndoDescription": "Este hábito no ha sido completado hoy.",
"alreadyCompletedPastDateTitle": "Ya completado",
"alreadyCompletedPastDateDescription": "Este hábito ya fue completado el {dateKey}.",
"earnedCoinsPastDateDescription": "Ganaste {coinReward} monedas por {dateKey}.",
"progressPastDateDescription": "Has completado {count}/{target} veces el {dateKey}."
},
"useWishlist": {
"redemptionLimitReachedTitle": "Límite de canjes alcanzado",
"redemptionLimitReachedDescription": "Has alcanzado el máximo de canjes para \"{itemName}\".",
"rewardRedeemedTitle": "🎉 ¡Recompensa canjeada!",
"rewardRedeemedDescription": "Has canjeado \"{itemName}\" por {itemCoinCost} monedas.",
"notEnoughCoinsTitle": "No hay suficientes monedas",
"notEnoughCoinsDescription": "Necesitas {coinsNeeded} monedas más para canjear esta recompensa."
},
"Warning": {
"areYouSure": "¿Estás seguro?",
"cancel": "Cancelar"
},
"useCoins": {
"addedCoinsDescription": "Se añadieron {amount} monedas",
"invalidAmountTitle": "Cantidad inválida",
"invalidAmountDescription": "Por favor ingresa un número positivo válido",
"successTitle": "Éxito",
"transactionNotFoundDescription": "Transacción no encontrada",
"maxAmountExceededDescription": "La cantidad no puede exceder {max}.",
"transactionNotFoundDescription": "Transacción no encontrada",
"maxAmountExceededDescription": "La cantidad no puede exceder {max}."
}
}

435
messages/fr.json Normal file
View File

@@ -0,0 +1,435 @@
{
"Dashboard": {
"title": "Tableau de bord"
},
"HabitList": {
"myTasks": "Mes tâches",
"myHabits": "Mes habitudes",
"addTaskButton": "Ajouter une tâche",
"addHabitButton": "Ajouter une habitude",
"searchTasksPlaceholder": "Rechercher des tâches...",
"searchHabitsPlaceholder": "Rechercher des habitudes...",
"sortByLabel": "Trier par :",
"sortByName": "Nom",
"sortByCoinReward": "Récompense en pièces",
"sortByDueDate": "Date d'échéance",
"sortByFrequency": "Fréquence",
"toggleSortOrderAriaLabel": "Changer l'ordre de tri",
"noTasksFoundMessage": "Aucune tâche ne correspond à votre recherche.",
"noHabitsFoundMessage": "Aucune habitude ne correspond à votre recherche.",
"emptyStateTasksTitle": "Aucune tâche pour l'instant",
"emptyStateHabitsTitle": "Aucune habitude pour l'instant",
"emptyStateTasksDescription": "Créez votre première tâche pour commencer à suivre vos progrès",
"emptyStateHabitsDescription": "Créez votre première habitude pour commencer à suivre vos progrès",
"archivedSectionTitle": "Archivé",
"deleteTaskDialogTitle": "Supprimer la tâche",
"deleteHabitDialogTitle": "Supprimer l'habitude",
"deleteTaskDialogMessage": "Êtes-vous sûr de vouloir supprimer cette tâche ? Cette action est irréversible.",
"deleteHabitDialogMessage": "Êtes-vous sûr de vouloir supprimer cette habitude ? Cette action est irréversible.",
"deleteButton": "Supprimer"
},
"DailyOverview": {
"addTaskButtonLabel": "Ajouter une tâche",
"addHabitButtonLabel": "Ajouter une habitude",
"todaysOverviewTitle": "Aperçu du jour",
"dailyTasksTitle": "Tâches quotidiennes",
"noTasksDueTodayMessage": "Aucune tâche pour aujourd'hui. Ajoutez des tâches pour commencer !",
"dailyHabitsTitle": "Habitudes quotidiennes",
"noHishabitsDueTodayMessage": "Aucune habitude pour aujourd'hui. Ajoutez des habitudes pour commencer !",
"wishlistGoalsTitle": "Objectifs de la liste de souhaits",
"redeemableBadgeLabel": "{count}/{total} échangeable",
"noWishlistItemsMessage": "Aucun élément dans la liste de souhaits. Ajoutez des objectifs à atteindre !",
"readyToRedeemMessage": "Prêt à échanger !",
"coinsToGoMessage": "Il manque {amount} pièces",
"showLessButton": "Afficher moins",
"showAllButton": "Afficher tout",
"viewButton": "Voir",
"deleteTaskDialogTitle": "Supprimer la tâche",
"deleteHabitDialogTitle": "Supprimer l'habitude",
"confirmDeleteDialogMessage": "Êtes-vous sûr de vouloir supprimer \"{name}\" ? Cette action est irréversible.",
"deleteButton": "Supprimer",
"overdueTooltip": "En retard"
},
"HabitContextMenuItems": {
"startPomodoro": "Démarrer Pomodoro",
"moveToToday": "Déplacer à aujourd'hui",
"moveToTomorrow": "Déplacer à demain",
"unpin": "Détacher",
"pin": "Attacher",
"edit": "Modifier",
"archive": "Archiver",
"unarchive": "Désarchiver",
"delete": "Supprimer"
},
"HabitStreak": {
"dailyCompletionStreakTitle": "Série de complétions quotidiennes",
"tooltipHabitsLabel": "habitudes",
"tooltipTasksLabel": "tâches",
"tooltipCompletedLabel": "Complété"
},
"CoinBalance": {
"coinBalanceTitle": "Solde de pièces"
},
"AddEditHabitModal": {
"editTaskTitle": "Modifier une tâche",
"editHabitTitle": "Modifier une habitude",
"addNewTaskTitle": "Ajouter une nouvelle tâche",
"AddNewHabitTitle": "Ajouter une nouvelle habitude",
"nameLabel": "Nom *",
"descriptionLabel": "Description",
"whenLabel": "Quand *",
"completeLabel": "Compléter",
"timesSuffix": "fois",
"rewardLabel": "Récompense",
"coinsSuffix": "pièces",
"shareLabel": "Partager",
"saveChangesButton": "Sauvegarder les modifications",
"addTaskButton": "Ajouter une tâche",
"addHabitButton": "Ajouter une habitude"
},
"ConfirmDialog": {
"confirmButton": "Confirmer",
"cancelButton": "Annuler"
},
"AddEditWishlistItemModal": {
"editTitle": "Modifier la récompense",
"addTitle": "Ajouter une nouvelle récompense",
"nameLabel": "Nom *",
"descriptionLabel": "Description",
"costLabel": "Coût",
"coinsSuffix": "pièces",
"redeemableLabel": "Échangeable",
"timesSuffix": "fois",
"errorNameRequired": "Le nom est requis",
"errorCoinCostMin": "Le coût en pièces doit être d'au moins 1",
"errorTargetCompletionsMin": "Les complétions cibles doivent être d'au moins 1",
"errorInvalidUrl": "Veuillez entrer une URL valide",
"linkLabel": "Lien",
"shareLabel": "Partager",
"saveButton": "Sauvegarder les modifications",
"addButton": "Ajouter une récompense"
},
"Navigation": {
"dashboard": "Tableau de bord",
"tasks": "Tâches",
"habits": "Habitudes",
"calendar": "Calendrier",
"wishlist": "Liste de souhaits",
"coins": "Pièces"
},
"TodayEarnedCoins": {
"todaySuffix": "aujourd'hui"
},
"WishlistItem": {
"usesLeftSingular": "utilisation restante",
"usesLeftPlural": "utilisations restantes",
"coinsSuffix": "pièces",
"redeem": "Échanger",
"redeemedDone": "Fait",
"redeemedExclamation": "Échangé !",
"editButton": "Modifier",
"archiveButton": "Archiver",
"unarchiveButton": "Désarchiver",
"deleteButton": "Supprimer"
},
"WishlistManager": {
"title": "Ma liste de souhaits",
"addRewardButton": "Ajouter une récompense",
"emptyStateTitle": "Votre liste de souhaits est vide",
"emptyStateDescription": "Ajoutez des récompenses que vous aimeriez gagner avec vos pièces",
"archivedSectionTitle": "Archivé",
"popupBlockedTitle": "Popup bloqué",
"popupBlockedDescription": "Veuillez autoriser les popups pour ouvrir le lien",
"deleteDialogTitle": "Supprimer la récompense",
"deleteDialogMessage": "Êtes-vous sûr de vouloir supprimer cette récompense ? Cette action est irréversible.",
"deleteButton": "Supprimer"
},
"UserSelectModal": {
"addUserButton": "Ajouter un utilisateur",
"createNewUserTitle": "Créer un nouvel utilisateur",
"selectUserTitle": "Sélectionner un utilisateur",
"signInSuccessTitle": "Connecté avec succès",
"signInSuccessDescription": "Bienvenue, {username} !",
"errorInvalidPassword": "mot de passe invalide",
"deleteUserConfirmation": "Êtes-vous sûr de vouloir supprimer l'utilisateur {username} ? Cette action est irréversible.",
"confirmDeleteButtonText": "Supprimer",
"deletingButtonText": "Suppression en cours...",
"deleteUserSuccessTitle": "Utilisateur supprimé",
"deleteUserSuccessDescription": "L'utilisateur {username} a été supprimé avec succès.",
"deleteUserErrorTitle": "Échec de la suppression",
"genericError": "Une erreur inattendue s'est produite.",
"networkError": "Une erreur réseau s'est produite. Veuillez réessayer.",
"editUserTooltip": "Modifier l'utilisateur",
"deleteUserTooltip": "Supprimer l'utilisateur"
},
"CoinsManager": {
"title": "Gestion des pièces",
"currentBalanceLabel": "Solde actuel",
"coinsSuffix": "pièces",
"addCoinsButton": "Ajouter des pièces",
"removeCoinsButton": "Retirer des pièces",
"statisticsTitle": "Statistiques",
"totalEarnedLabel": "Total gagné",
"totalSpentLabel": "Total dépensé",
"totalTransactionsLabel": "Total des transactions",
"todaysEarnedLabel": "Gagné aujourd'hui",
"todaysSpentLabel": "Dépensé aujourd'hui",
"todaysTransactionsLabel": "Transactions d'aujourd'hui",
"transactionHistoryTitle": "Historique des transactions",
"showLabel": "Afficher :",
"entriesSuffix": "entrées",
"showingEntries": "Affichage de {from} à {to} de {total} entrées",
"noTransactionsTitle": "Aucune transaction pour l'instant",
"noTransactionsDescription": "Votre historique de transactions apparaîtra ici une fois que vous commencerez à gagner ou dépenser des pièces",
"pageLabel": "Page",
"ofLabel": "sur",
"transactionTypeHabitCompletion": "Complétion d'habitude",
"transactionTypeTaskCompletion": "Complétion de tâche",
"transactionTypeHabitUndo": "Annulation d'habitude",
"transactionTypeTaskUndo": "Annulation de tâche",
"transactionTypeWishRedemption": "Échange de souhait",
"transactionTypeManualAdjustment": "Ajustement manuel",
"transactionTypeCoinReset": "Réinitialisation des pièces",
"transactionTypeInitialBalance": "Solde initial"
},
"NotificationBell": {
"errorUpdateTimestamp": "Échec de la mise à jour du timestamp de lecture de notification :"
},
"PomodoroTimer": {
"focusLabel1": "Reste concentré",
"focusLabel2": "Tu peux le faire",
"focusLabel3": "Continue",
"focusLabel4": "Tout donner",
"focusLabel5": "Fais-le arriver",
"focusLabel6": "Reste fort",
"focusLabel7": "Persiste",
"focusLabel8": "Un pas à la fois",
"focusLabel9": "Tu peux y arriver",
"focusLabel10": "Concentre-toi et conquiers",
"breakLabel1": "Prends une pause",
"breakLabel2": "Relaxe-toi et recharge",
"breakLabel3": "Respire profondément",
"breakLabel4": "Étire-toi",
"breakLabel5": "Rafraîchis-toi",
"breakLabel6": "Tu le mérites",
"breakLabel7": "Recharge ton énergie",
"breakLabel8": "Éloigne-toi un moment",
"breakLabel9": "Vide ton esprit",
"breakLabel10": "Repose-toi et récupère",
"focusType": "Concentration",
"breakType": "Pause",
"pauseButton": "Pause",
"startButton": "Démarrer",
"resetButton": "Réinitialiser",
"skipButton": "Passer",
"wakeLockNotSupported": "Le navigateur ne supporte pas le verrouillage de veille",
"wakeLockInUse": "Le verrouillage de veille est déjà actif",
"wakeLockRequestError": "Erreur lors de la demande de verrouillage de veille :",
"wakeLockReleaseError": "Erreur lors de la libération du verrouillage de veille :"
},
"HabitCalendar": {
"title": "Calendrier des habitudes",
"calendarCardTitle": "Calendrier",
"selectDatePrompt": "Sélectionner une date",
"tasksSectionTitle": "Tâches",
"habitsSectionTitle": "Habitudes",
"errorCompletingPastHabit": "Erreur lors de la complétion d'une habitude passée :"
},
"NotificationDropdown": {
"notLoggedIn": "Non connecté.",
"userCompletedItem": "{username} a complété {itemName}.",
"userRedeemedItem": "{username} a échangé {itemName}.",
"activityRelatedToItem": "Activité liée à {itemName} par {username}.",
"defaultUsername": "Quelqu'un",
"defaultItemName": "un élément partagé",
"notificationsTitle": "Notifications",
"notificationsTooltip": "Affiche les complétions ou les échanges par d'autres utilisateurs pour les habitudes ou la liste de souhaits que vous avez partagés avec eux (vous devez être admin)",
"noNotificationsYet": "Aucune notification pour l'instant."
},
"AboutModal": {
"dialogArisLabel": "à propos",
"changelogButton": "Journal des modifications",
"createdByPrefix": "Créé avec ❤️ par",
"starOnGitHubButton": "Étoile sur GitHub"
},
"PermissionSelector": {
"permissionsTitle": "Permissions",
"adminAccessLabel": "Accès administrateur",
"adminAccessDescription": "Les administrateurs ont tous les droits sur les données de tous les utilisateurs",
"resourceHabitTask": "Habitude / Tâche",
"resourceWishlist": "Liste de souhaits",
"resourceCoins": "Pièces",
"permissionWrite": "Écriture",
"permissionInteract": "Interaction"
},
"UserForm": {
"toastUserUpdatedTitle": "Utilisateur mis à jour",
"toastUserUpdatedDescription": "Utilisateur {username} mis à jour avec succès",
"toastUserCreatedTitle": "Utilisateur créé",
"toastUserCreatedDescription": "Utilisateur {username} créé avec succès",
"actionUpdate": "mise à jour",
"actionCreate": "création",
"errorFailedUserAction": "Échec de la {action} de l'utilisateur",
"toastDemoDeleteDisabled": "La suppression est désactivée dans la version de démonstration",
"toastCannotDeleteSelf": "Vous ne pouvez pas supprimer votre propre compte",
"confirmDeleteUser": "Êtes-vous sûr de vouloir supprimer l'utilisateur {username}?",
"toastUserDeletedTitle": "Utilisateur supprimé",
"toastUserDeletedDescription": "L'utilisateur {username} a été supprimé avec succès",
"toastDeleteUserFailed": "Échec de la suppression de l'utilisateur : {error}",
"errorTitle": "Erreur",
"errorFileSizeLimit": "La taille du fichier doit être inférieure à 5MB",
"toastAvatarUploadedTitle": "Avatar téléchargé",
"toastAvatarUploadedDescription": "Avatar téléchargé avec succès",
"errorFailedAvatarUpload": "Échec du téléchargement de l'avatar",
"changeAvatarButton": "Changer l'avatar",
"uploadAvatarButton": "Télécharger l'avatar",
"usernameLabel": "Nom d'utilisateur",
"usernamePlaceholder": "Nom d'utilisateur",
"newPasswordLabel": "Nouveau mot de passe",
"passwordLabel": "Mot de passe",
"passwordPlaceholderEdit": "Laisser vide pour conserver l'actuel",
"passwordPlaceholderCreate": "Entrer le mot de passe",
"demoPasswordDisabledMessage": "Le mot de passe est automatiquement désactivé dans l'instance de démonstration",
"disablePasswordLabel": "Désactiver le mot de passe",
"cancelButton": "Annuler",
"saveChangesButton": "Sauvegarder les modifications",
"createUserButton": "Créer un utilisateur",
"deleteAccountButton": "Supprimer le compte",
"deletingButtonText": "Suppression en cours...",
"areYouSure": "Êtes-vous sûr ?",
"deleteUserConfirmation": "Êtes-vous sûr de vouloir supprimer l'utilisateur {username} ?",
"cancel": "Annuler",
"confirmDeleteButtonText": "Supprimer"
},
"ViewToggle": {
"habitsLabel": "Habitudes",
"tasksLabel": "Tâches"
},
"HabitItem": {
"overdue": "En retard",
"whenLabel": "Quand : {frequency}",
"coinsPerCompletion": "{count} pièces par complétion",
"completedStatus": "Complété",
"completedStatusCount": "Complété ({completed}/{target})",
"completedStatusCountMobile": "{completed}/{target}",
"completeButton": "Compléter",
"completeButtonCount": "Compléter ({completed}/{target})",
"completeButtonCountMobile": "{completed}/{target}",
"undoButton": "Annuler",
"editButton": "Modifier"
},
"TransactionNoteEditor": {
"noteTooLongTitle": "Note trop longue",
"noteTooLongDescription": "Les notes doivent faire moins de 200 caractères",
"errorSavingNoteTitle": "Erreur lors de la sauvegarde de la note",
"errorDeletingNoteTitle": "Erreur lors de la suppression de la note",
"pleaseTryAgainDescription": "Veuillez réessayer",
"addNotePlaceholder": "Ajouter une note...",
"saveNoteTitle": "Sauvegarder la note",
"cancelButtonTitle": "Annuler",
"deleteNoteTitle": "Supprimer la note",
"editNoteAriaLabel": "Modifier la note"
},
"Profile": {
"guestUsername": "Invité",
"editProfileButton": "Modifier le profil",
"signOutSuccessTitle": "Déconnexion réussie",
"signOutSuccessDescription": "Vous avez été déconnecté de votre compte",
"signOutErrorTitle": "Erreur de déconnexion",
"signOutErrorDescription": "Échec de la déconnexion",
"switchUserButton": "Changer d'utilisateur",
"settingsLink": "Paramètres",
"aboutButton": "À propos",
"themeLabel": "Thème",
"editProfileModalTitle": "Modifier le profil"
},
"PasswordEntryForm": {
"notYouButton": "Ce n'est pas vous ?",
"passwordLabel": "Mot de passe",
"passwordPlaceholder": "Entrer le mot de passe",
"loginErrorToastTitle": "Erreur",
"loginFailedErrorToastDescription": "Échec de la connexion",
"cancelButton": "Annuler",
"loginButton": "Se connecter"
},
"CompletionCountBadge": {
"countCompleted": "{completedCount}/{totalCount} complété"
},
"SettingsPage": {
"title": "Paramètres",
"uiSettingsTitle": "Paramètres de l'interface",
"numberFormattingLabel": "Formatage des nombres",
"numberFormattingDescription": "Formater les grands nombres (ex: 1K, 1M, 1B)",
"numberGroupingLabel": "Regroupement des nombres",
"numberGroupingDescription": "Utiliser les séparateurs de milliers (ex: 1,000 vs 1000)",
"systemSettingsTitle": "Paramètres système",
"timezoneLabel": "Fuseau horaire",
"timezoneDescription": "Sélectionnez votre fuseau horaire pour un suivi précis des dates",
"weekStartDayLabel": "Jour de début de semaine",
"weekStartDayDescription": "Sélectionnez votre jour préféré pour commencer la semaine",
"weekdays": {
"sunday": "Dimanche",
"monday": "Lundi",
"tuesday": "Mardi",
"wednesday": "Mercredi",
"thursday": "Jeudi",
"friday": "Vendredi",
"saturday": "Samedi"
},
"autoBackupLabel": "Sauvegarde automatique",
"autoBackupTooltip": "Lorsqu'il est activé, les données de l'application (habitudes, pièces, paramètres, etc.) sont automatiquement sauvegardées quotidiennement vers 2 heures du matin, heure du serveur. Les sauvegardes sont stockées sous forme de fichiers ZIP dans le répertoire `backups/` à la racine du projet. Seules les 7 dernières sauvegardes sont conservées ; les plus anciennes sont automatiquement supprimées.",
"autoBackupDescription": "Effectuer une sauvegarde automatique quotidienne",
"languageLabel": "Langue",
"languageDescription": "Choisissez votre langue d'affichage préférée pour l'application.",
"languageChangedTitle": "Langue modifiée",
"languageChangedDescription": "Veuillez actualiser la page pour voir les changements",
"languageDisabledInDemoTooltip": "Le changement de langue est désactivé dans la version de démonstration."
},
"Common": {
"authenticationRequiredTitle": "Authentification requise",
"authenticationRequiredDescription": "Veuillez vous connecter pour continuer.",
"permissionDeniedTitle": "Permission refusée",
"permissionDeniedDescription": "Vous n'avez pas la permission de {action} pour {resource}.",
"undoButton": "Annuler",
"redoButton": "Rétablir",
"errorTitle": "Erreur"
},
"useHabits": {
"alreadyCompletedTitle": "Déjà complété",
"alreadyCompletedDescription": "Vous avez déjà complété cette habitude aujourd'hui.",
"completedTitle": "Complété !",
"earnedCoinsDescription": "Vous avez gagné {coinReward} pièces.",
"progressTitle": "Progrès !",
"progressDescription": "Vous avez complété {count}/{target} fois aujourd'hui.",
"completionUndoneTitle": "Complétion annulée",
"completionUndoneDescription": "Vous avez {count}/{target} complétions aujourd'hui.",
"noCompletionsToUndoTitle": "Aucune complétion à annuler",
"noCompletionsToUndoDescription": "Cette habitude n'a pas été complétée aujourd'hui.",
"alreadyCompletedPastDateTitle": "Déjà complété",
"alreadyCompletedPastDateDescription": "Cette habitude a déjà été complétée le {dateKey}.",
"earnedCoinsPastDateDescription": "Vous avez gagné {coinReward} pièces pour {dateKey}.",
"progressPastDateDescription": "Vous avez complété {count}/{target} fois le {dateKey}."
},
"useWishlist": {
"redemptionLimitReachedTitle": "Limite de rachat atteinte",
"redemptionLimitReachedDescription": "Vous avez atteint le nombre maximum de rachats pour \"{itemName}\".",
"rewardRedeemedTitle": "🎉 Récompense échangée !",
"rewardRedeemedDescription": "Vous avez échangé \"{itemName}\" pour {itemCoinCost} pièces.",
"notEnoughCoinsTitle": "Pas assez de pièces",
"notEnoughCoinsDescription": "Il vous manque {coinsNeeded} pièces pour échanger cette récompense."
},
"Warning": {
"areYouSure": "Êtes-vous sûr ?",
"cancel": "Annuler"
},
"useCoins": {
"addedCoinsDescription": "{amount} pièces ajoutées",
"invalidAmountTitle": "Montant invalide",
"invalidAmountDescription": "Veuillez entrer un nombre positif valide",
"successTitle": "Succès",
"transactionNotFoundDescription": "Transaction non trouvée",
"maxAmountExceededDescription": "Le montant ne peut pas dépasser {max}.",
"transactionNotFoundDescription": "Transaction non trouvée",
"maxAmountExceededDescription": "Le montant ne peut pas dépasser {max}."
}
}

435
messages/ja.json Normal file
View File

@@ -0,0 +1,435 @@
{
"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": "コイン",
"shareLabel": "共有",
"saveChangesButton": "変更を保存",
"addTaskButton": "タスクを追加",
"addHabitButton": "習慣を追加"
},
"ConfirmDialog": {
"confirmButton": "確認",
"cancelButton": "キャンセル"
},
"AddEditWishlistItemModal": {
"editTitle": "報酬を編集",
"addTitle": "新しい報酬を追加",
"nameLabel": "名前 *",
"descriptionLabel": "説明",
"costLabel": "コスト",
"coinsSuffix": "コイン",
"redeemableLabel": "使用可能",
"timesSuffix": "回",
"errorNameRequired": "名前は必須です",
"errorCoinCostMin": "コインコストは1以上である必要があります",
"errorTargetCompletionsMin": "目標達成回数は1以上である必要があります",
"errorInvalidUrl": "有効なURLを入力してください",
"linkLabel": "リンク",
"shareLabel": "共有",
"saveButton": "変更を保存",
"addButton": "報酬を追加"
},
"Navigation": {
"dashboard": "ダッシュボード",
"tasks": "タスク",
"habits": "習慣",
"calendar": "カレンダー",
"wishlist": "ウィッシュリスト",
"coins": "コイン"
},
"TodayEarnedCoins": {
"todaySuffix": "今日"
},
"WishlistItem": {
"usesLeftSingular": "使用可能回数: 1回",
"usesLeftPlural": "使用可能回数: {count}回",
"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": "ネットワークエラーが発生しました。もう一度お試しください。",
"editUserTooltip": "ユーザーを編集",
"deleteUserTooltip": "ユーザーを削除"
},
"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": "1歩ずつ進もう",
"focusLabel9": "君にはできる",
"focusLabel10": "集中して征服しよう",
"breakLabel1": "休憩しよう",
"breakLabel2": "リラックスして充電しよう",
"breakLabel3": "深呼吸しよう",
"breakLabel4": "ストレッチしよう",
"breakLabel5": "リフレッシュしよう",
"breakLabel6": "君ならできる",
"breakLabel7": "エネルギーを充電しよう",
"breakLabel8": "少し離れよう",
"breakLabel9": "心をクリアにしよう",
"breakLabel10": "休んで回復しよう",
"focusType": "集中",
"breakType": "休憩",
"pauseButton": "一時停止",
"startButton": "開始",
"resetButton": "リセット",
"skipButton": "スキップ",
"wakeLockNotSupported": "ブラウザがWake Lockをサポートしていません",
"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}に失敗しました",
"toastDemoDeleteDisabled": "デモインスタンスでは削除が無効になっています",
"toastCannotDeleteSelf": "自分のアカウントは削除できません",
"confirmDeleteUser": "ユーザー {username} を削除してもよろしいですか?",
"toastUserDeletedTitle": "ユーザーが削除されました",
"toastUserDeletedDescription": "ユーザー {username} は正常に削除されました",
"toastDeleteUserFailed": "ユーザーの削除に失敗しました: {error}",
"errorTitle": "エラー",
"errorFileSizeLimit": "ファイルサイズは5MB以下である必要があります",
"toastAvatarUploadedTitle": "アバターをアップロードしました",
"toastAvatarUploadedDescription": "アバターのアップロードに成功しました",
"errorFailedAvatarUpload": "アバターのアップロードに失敗しました",
"changeAvatarButton": "アバターを変更",
"uploadAvatarButton": "アバターをアップロード",
"usernameLabel": "ユーザー名",
"usernamePlaceholder": "ユーザー名",
"newPasswordLabel": "新しいパスワード",
"passwordLabel": "パスワード",
"passwordPlaceholderEdit": "現在のままにする場合は空欄",
"passwordPlaceholderCreate": "パスワードを入力",
"demoPasswordDisabledMessage": "デモインスタンスではパスワードは自動的に無効化されます",
"disablePasswordLabel": "パスワードを無効化",
"cancelButton": "キャンセル",
"saveChangesButton": "変更を保存",
"createUserButton": "ユーザーを作成",
"deleteAccountButton": "アカウントを削除",
"deletingButtonText": "削除中...",
"areYouSure": "本当によろしいですか?",
"deleteUserConfirmation": "ユーザー {username} を削除してもよろしいですか?",
"cancel": "キャンセル",
"confirmDeleteButtonText": "削除"
},
"ViewToggle": {
"habitsLabel": "習慣",
"tasksLabel": "タスク"
},
"HabitItem": {
"overdue": "期限超過",
"whenLabel": "いつ: {frequency}",
"coinsPerCompletion": "1回あたり{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": "3桁区切りを使用する1,000 対 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}sに対する{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}コイン必要です。"
},
"Warning": {
"areYouSure": "本当によろしいですか?",
"cancel": "キャンセル"
},
"useCoins": {
"addedCoinsDescription": "{amount}コインを追加しました",
"invalidAmountTitle": "無効な値です",
"invalidAmountDescription": "有効な正の数を入力してください",
"successTitle": "成功しました",
"transactionNotFoundDescription": "取引が見つかりません",
"maxAmountExceededDescription": "金額は{max}を超えることはできません。",
"transactionNotFoundDescription": "取引が見つかりません",
"maxAmountExceededDescription": "金額は{max}を超えることはできません。"
}
}

435
messages/ru.json Normal file
View File

@@ -0,0 +1,435 @@
{
"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": "монет",
"shareLabel": "Поделиться",
"saveChangesButton": "Сохранить",
"addTaskButton": "Добавить задачу",
"addHabitButton": "Добавить привычку"
},
"ConfirmDialog": {
"confirmButton": "Подтвердить",
"cancelButton": "Отмена"
},
"AddEditWishlistItemModal": {
"editTitle": "Редактировать цель",
"addTitle": "Новая цель",
"nameLabel": "Название *",
"descriptionLabel": "Описание",
"costLabel": "Стоимость",
"coinsSuffix": "монет",
"redeemableLabel": "Доступно",
"timesSuffix": "раз",
"errorNameRequired": "Название обязательно",
"errorCoinCostMin": "Минимальная стоимость 1 монета",
"errorTargetCompletionsMin": "Минимум 1 выполнение",
"errorInvalidUrl": "Некорректная ссылка",
"linkLabel": "Ссылка",
"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": "Произошла сетевая ошибка. Пожалуйста, попробуйте еще раз.",
"editUserTooltip": "Редактировать пользователя",
"deleteUserTooltip": "Удалить пользователя"
},
"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": "Браузер не поддерживает блокировку экрана",
"wakeLockInUse": "Блокировка экрана уже используется",
"wakeLockRequestError": "Ошибка запроса блокировки экрана:",
"wakeLockReleaseError": "Ошибка освобождения блокировки экрана:"
},
"HabitCalendar": {
"title": "Календарь привычек",
"calendarCardTitle": "Календарь",
"selectDatePrompt": "Выберите дату",
"tasksSectionTitle": "Задачи",
"habitsSectionTitle": "Привычки",
"errorCompletingPastHabit": "Ошибка завершения прошлой привычки:"
},
"NotificationDropdown": {
"notLoggedIn": "Не выполнен вход.",
"userCompletedItem": "{username} выполнил(а) {itemName}.",
"userRedeemedItem": "{username} использовал(а) {itemName}.",
"activityRelatedToItem": "Действие, связанное с {itemName}, пользователем {username}.",
"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} пользователя",
"toastDemoDeleteDisabled": "Удаление отключено в демо-версии",
"toastCannotDeleteSelf": "Вы не можете удалить свою учетную запись",
"confirmDeleteUser": "Вы уверены, что хотите удалить пользователя {username}?",
"toastUserDeletedTitle": "Пользователь удален",
"toastUserDeletedDescription": "Пользователь {username} успешно удален",
"toastDeleteUserFailed": "Не удалось удалить пользователя: {error}",
"errorTitle": "Ошибка",
"errorFileSizeLimit": "Размер файла должен быть менее 5 МБ",
"toastAvatarUploadedTitle": "Аватар загружен",
"toastAvatarUploadedDescription": "Аватар успешно загружен",
"errorFailedAvatarUpload": "Не удалось загрузить аватар",
"changeAvatarButton": "Изменить аватар",
"uploadAvatarButton": "Загрузить аватар",
"usernameLabel": "Имя пользователя",
"usernamePlaceholder": "Имя пользователя",
"newPasswordLabel": "Новый пароль",
"passwordLabel": "Пароль",
"passwordPlaceholderEdit": "Оставьте пустым, чтобы сохранить текущий",
"passwordPlaceholderCreate": "Введите пароль",
"demoPasswordDisabledMessage": "Пароль автоматически отключен в демонстрационном экземпляре",
"disablePasswordLabel": "Отключить пароль",
"cancelButton": "Отмена",
"saveChangesButton": "Сохранить изменения",
"createUserButton": "Создать пользователя",
"deleteAccountButton": "Удалить аккаунт",
"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": "Интерфейс",
"numberFormattingLabel": "Формат чисел",
"numberFormattingDescription": "Использовать сокращения (например, 1К, 1М, 1Млрд)",
"numberGroupingLabel": "Разделители",
"numberGroupingDescription": "Использовать разделители тысяч (например, 1 000 вместо 1000)",
"systemSettingsTitle": "Система",
"timezoneLabel": "Часовой пояс",
"timezoneDescription": "Выберите ваш часовой пояс",
"weekStartDayLabel": "Первый день недели",
"weekStartDayDescription": "Выберите первый день недели",
"weekdays": {
"sunday": "Воскресенье",
"monday": "Понедельник",
"tuesday": "Вторник",
"wednesday": "Среда",
"thursday": "Четверг",
"friday": "Пятница",
"saturday": "Суббота"
},
"autoBackupLabel": "Авто-бэкап",
"autoBackupTooltip": "При включении данные будут автоматически резервироваться ежедневно около 2:00 по времени сервера. Бэкапы хранятся в виде ZIP-файлов в директории `backups/`. Хранятся только последние 7 бэкапов.",
"autoBackupDescription": "Автоматическое резервное копирование данных",
"languageLabel": "Язык",
"languageDescription": "Выберите предпочитаемый язык интерфейса.",
"languageChangedTitle": "Язык изменен",
"languageChangedDescription": "Перезагрузите страницу для применения изменений",
"languageDisabledInDemoTooltip": "Смена языка недоступна в демо-версии."
},
"Common": {
"authenticationRequiredTitle": "Требуется аутентификация",
"authenticationRequiredDescription": "Пожалуйста, войдите, чтобы продолжить.",
"permissionDeniedTitle": "Отказано в доступе",
"permissionDeniedDescription": "У вас нет разрешения на {action} для {resource}.",
"undoButton": "Отменить",
"redoButton": "Повторить",
"errorTitle": "Ошибка"
},
"useHabits": {
"alreadyCompletedTitle": "Уже выполнено",
"alreadyCompletedDescription": "Вы уже выполнили эту привычку сегодня.",
"completedTitle": "Выполнено!",
"earnedCoinsDescription": "Вы заработали {coinReward} монет.",
"progressTitle": "Прогресс!",
"progressDescription": "Вы выполнили {count}/{target} раз сегодня.",
"completionUndoneTitle": "Выполнение отменено",
"completionUndoneDescription": "У вас {count}/{target} выполнений сегодня.",
"noCompletionsToUndoTitle": "Нет отмен",
"noCompletionsToUndoDescription": "Эта привычка не была выполнена сегодня.",
"alreadyCompletedPastDateTitle": "Уже выполнено",
"alreadyCompletedPastDateDescription": "Эта привычка уже была выполнена {dateKey}.",
"earnedCoinsPastDateDescription": "Вы заработали {coinReward} монет за {dateKey}.",
"progressPastDateDescription": "Вы выполнили {count}/{target} раз {dateKey}."
},
"useWishlist": {
"redemptionLimitReachedTitle": "Достигнут лимит погашения",
"redemptionLimitReachedDescription": "Вы достигли максимального количества погашений для \"{itemName}\".",
"rewardRedeemedTitle": "🎉 Награда получена!",
"rewardRedeemedDescription": "Вы получили \"{itemName}\" за {itemCoinCost} монет.",
"notEnoughCoinsTitle": "Недостаточно монет",
"notEnoughCoinsDescription": "Вам нужно еще {coinsNeeded} монет, чтобы получить эту награду."
},
"Warning": {
"areYouSure": "Вы уверены?",
"cancel": "Отмена"
},
"useCoins": {
"addedCoinsDescription": "Добавлено {amount} монет",
"invalidAmountTitle": "Неверная сумма",
"invalidAmountDescription": "Пожалуйста, введите положительное число",
"successTitle": "Успех",
"transactionNotFoundDescription": "Транзакция не найдена",
"maxAmountExceededDescription": "Сумма не может превышать {max}.",
"transactionNotFoundDescription": "Транзакция не найдена",
"maxAmountExceededDescription": "Сумма не может превышать {max}."
}
}

435
messages/zh.json Normal file
View File

@@ -0,0 +1,435 @@
{
"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": "金币",
"shareLabel": "分享",
"saveChangesButton": "保存更改",
"addTaskButton": "添加任务",
"addHabitButton": "添加习惯"
},
"ConfirmDialog": {
"confirmButton": "确认",
"cancelButton": "取消"
},
"AddEditWishlistItemModal": {
"editTitle": "编辑奖励",
"addTitle": "添加新奖励",
"nameLabel": "名称 *",
"descriptionLabel": "描述",
"costLabel": "成本",
"coinsSuffix": "金币",
"redeemableLabel": "可兑换",
"timesSuffix": "次",
"errorNameRequired": "名称是必填项",
"errorCoinCostMin": "金币成本至少为 1",
"errorTargetCompletionsMin": "目标完成次数至少为 1",
"errorInvalidUrl": "请输入有效的 URL",
"linkLabel": "链接",
"shareLabel": "分享",
"saveButton": "保存更改",
"addButton": "添加奖励"
},
"Navigation": {
"dashboard": "仪表板",
"tasks": "任务",
"habits": "习惯",
"calendar": "日历",
"wishlist": "愿望清单",
"coins": "金币"
},
"TodayEarnedCoins": {
"todaySuffix": "今天"
},
"WishlistItem": {
"usesLeftSingular": "剩余 1 次",
"usesLeftPlural": "剩余 {count} 次",
"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": "发生网络错误。请再试一次。",
"editUserTooltip": "编辑用户",
"deleteUserTooltip": "删除用户"
},
"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": "浏览器不支持唤醒锁",
"wakeLockInUse": "唤醒锁已在使用中",
"wakeLockRequestError": "请求唤醒锁时发生错误:",
"wakeLockReleaseError": "释放唤醒锁时发生错误:"
},
"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} 失败",
"toastDemoDeleteDisabled": "在演示实例中删除已禁用",
"toastCannotDeleteSelf": "您不能删除自己的帐户",
"confirmDeleteUser": "您确定要删除用户 {username} 吗?",
"toastUserDeletedTitle": "用户已删除",
"toastUserDeletedDescription": "用户 {username} 已成功删除",
"toastDeleteUserFailed": "删除用户失败: {error}",
"errorTitle": "错误",
"errorFileSizeLimit": "文件大小必须小于 5MB",
"toastAvatarUploadedTitle": "头像已上传",
"toastAvatarUploadedDescription": "成功上传头像",
"errorFailedAvatarUpload": "头像上传失败",
"changeAvatarButton": "更改头像",
"uploadAvatarButton": "上传头像",
"usernameLabel": "用户名",
"usernamePlaceholder": "用户名",
"newPasswordLabel": "新密码",
"passwordLabel": "密码",
"passwordPlaceholderEdit": "留空以保持当前密码",
"passwordPlaceholderCreate": "输入密码",
"demoPasswordDisabledMessage": "在演示实例中密码自动禁用",
"disablePasswordLabel": "禁用密码",
"cancelButton": "取消",
"saveChangesButton": "保存更改",
"createUserButton": "创建用户",
"deleteAccountButton": "删除账户",
"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": "界面设置",
"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/`目录中仅保留最近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": "您已用{itemCoinCost}金币兑换了\"{itemName}\"。",
"notEnoughCoinsTitle": "金币不足",
"notEnoughCoinsDescription": "您还需要{coinsNeeded}金币才能兑换此奖励。"
},
"Warning": {
"areYouSure": "您确定吗?",
"cancel": "取消"
},
"useCoins": {
"addedCoinsDescription": "已添加 {amount} 个金币",
"invalidAmountTitle": "无效金额",
"invalidAmountDescription": "请输入有效的正数",
"successTitle": "成功",
"transactionNotFoundDescription": "未找到交易记录",
"maxAmountExceededDescription": "金额不能超过 {max}。",
"transactionNotFoundDescription": "未找到交易记录",
"maxAmountExceededDescription": "金额不能超过 {max}。"
}
}

View File

@@ -1,4 +1,5 @@
import type { NextConfig } from "next";
import createNextIntlPlugin from 'next-intl/plugin';
const nextConfig: NextConfig = {
/* config options here */
@@ -51,4 +52,5 @@ const nextConfig: NextConfig = {
},
};
export default nextConfig;
const withNextIntl = createNextIntlPlugin();
export default withNextIntl(nextConfig);

1659
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,6 @@
{
"name": "habittrove",
"version": "0.1.26",
"version": "0.2.20",
"private": true,
"scripts": {
"dev": "next dev --turbopack",
@@ -18,6 +18,7 @@
"@emoji-mart/data": "^1.2.1",
"@emoji-mart/react": "^1.1.1",
"@next/font": "^14.2.15",
"@radix-ui/react-alert-dialog": "^1.1.14",
"@radix-ui/react-avatar": "^1.1.2",
"@radix-ui/react-context-menu": "^2.2.4",
"@radix-ui/react-dialog": "^1.1.4",
@@ -25,13 +26,16 @@
"@radix-ui/react-label": "^2.1.1",
"@radix-ui/react-popover": "^1.1.4",
"@radix-ui/react-progress": "^1.1.1",
"@radix-ui/react-scroll-area": "^1.2.4",
"@radix-ui/react-select": "^2.1.4",
"@radix-ui/react-slot": "^1.1.1",
"@radix-ui/react-separator": "^1.1.3",
"@radix-ui/react-slot": "^1.2.3",
"@radix-ui/react-switch": "^1.1.2",
"@radix-ui/react-toast": "^1.2.4",
"@radix-ui/react-tooltip": "^1.1.6",
"@types/canvas-confetti": "^1.9.0",
"@uiw/react-heat-map": "^2.3.2",
"archiver": "^7.0.1",
"canvas-confetti": "^1.9.3",
"chrono-node": "^2.7.7",
"class-variance-authority": "^0.7.1",
@@ -43,7 +47,11 @@
"linkify-react": "^4.2.0",
"lucide-react": "^0.469.0",
"luxon": "^3.5.0",
"next": "15.1.3",
"next": "15.2.3",
"next-auth": "^5.0.0-beta.25",
"next-intl": "^4.1.0",
"next-themes": "^0.4.4",
"node-cron": "^3.0.3",
"react": "^19.0.0",
"react-confetti": "^6.2.2",
"react-day-picker": "^8.10.1",
@@ -53,14 +61,19 @@
"rrule": "^2.8.1",
"tailwind-merge": "^2.6.0",
"tailwindcss-animate": "^1.0.7",
"web-push": "^3.6.7"
"uuid": "^11.0.5",
"web-push": "^3.6.7",
"zod": "^3.24.1"
},
"devDependencies": {
"@eslint/eslintrc": "^3",
"@tailwindcss/typography": "^0.5.15",
"@types/archiver": "^6.0.3",
"@types/bun": "^1.1.14",
"@types/lodash": "^4.17.15",
"@types/luxon": "^3.4.2",
"@types/node": "^20.17.10",
"@types/node-cron": "^3.0.11",
"@types/react": "^19",
"@types/react-dom": "^19",
"@types/web-push": "^3.6.4",

View File

@@ -8,6 +8,17 @@ export default {
"./app/**/*.{js,ts,jsx,tsx,mdx}",
],
theme: {
screens: {
'3xs': '320px',
'2xs': '375px',
'xs': '480px',
'sm': '640px',
'md': '768px',
'lg': '1024px',
'xl': '1280px',
'2xl': '1536px',
'3xl': '1920px'
},
extend: {
keyframes: {
celebrate: {

8
types/next-auth.d.ts vendored Normal file
View File

@@ -0,0 +1,8 @@
import 'next-auth'
import { SafeUser } from '@/lib/types'
declare module 'next-auth' {
interface Session {
user: SafeUser
}
}