mirror of
https://github.com/ManInDark/HabitTrove.git
synced 2026-01-21 06:34:30 +01:00
Compare commits
13 Commits
v0.2.2
...
client-hel
| Author | SHA1 | Date | |
|---|---|---|---|
|
ddffacbd52
|
|||
|
|
95197e216c | ||
|
|
660005d857 | ||
|
|
2408ed84bd | ||
|
|
dda8b522e3 | ||
|
|
909bfa7c6f | ||
|
|
e53e2f649a | ||
|
|
a42c0324c5 | ||
|
|
685cb80321 | ||
|
|
f1e3ee5747 | ||
|
|
d31982bf29 | ||
|
|
9052c9f37a | ||
|
|
a615a45c39 |
5
.github/workflows/docker-publish.yml
vendored
5
.github/workflows/docker-publish.yml
vendored
@@ -52,13 +52,12 @@ jobs:
|
|||||||
push: true
|
push: true
|
||||||
tags: |
|
tags: |
|
||||||
${{ steps.check-version.outputs.EXISTS == 'false' && format('dohsimpson/habittrove:v{0}', steps.package-version.outputs.VERSION) || '' }}
|
${{ 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:demo
|
||||||
dohsimpson/habittrove:dev
|
|
||||||
|
|
||||||
deploy-demo:
|
deploy-demo:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
needs: build-and-push
|
needs: build-and-push
|
||||||
# demo tracks the latest tag
|
# demo tracks the demo tag
|
||||||
if: needs.build-and-push.outputs.EXISTS == 'false'
|
if: needs.build-and-push.outputs.EXISTS == 'false'
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
|
|||||||
3
.github/workflows/test.yml
vendored
3
.github/workflows/test.yml
vendored
@@ -21,5 +21,8 @@ jobs:
|
|||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: bun install
|
run: bun install
|
||||||
|
|
||||||
|
- name: Run lint
|
||||||
|
run: bun run lint
|
||||||
|
|
||||||
- name: Run unit tests
|
- name: Run unit tests
|
||||||
run: bun test
|
run: bun test
|
||||||
|
|||||||
4
.gitignore
vendored
4
.gitignore
vendored
@@ -41,6 +41,8 @@ yarn-error.log*
|
|||||||
next-env.d.ts
|
next-env.d.ts
|
||||||
|
|
||||||
# customize
|
# customize
|
||||||
data/*
|
/data/*
|
||||||
|
/data.*/*
|
||||||
Budfile
|
Budfile
|
||||||
certificates
|
certificates
|
||||||
|
/backups/*
|
||||||
|
|||||||
@@ -1,7 +1 @@
|
|||||||
if git diff --cached --name-only --diff-filter=d | xargs grep -n '🪚'; then
|
npm run typecheck && npm run lint && npm run test
|
||||||
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
|
|
||||||
|
|||||||
78
CHANGELOG.md
78
CHANGELOG.md
@@ -1,5 +1,83 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## 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
|
## Version 0.2.2
|
||||||
|
|
||||||
### Changed
|
### Changed
|
||||||
|
|||||||
13
Dockerfile
13
Dockerfile
@@ -1,9 +1,9 @@
|
|||||||
# syntax=docker.io/docker/dockerfile:1
|
# 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
|
# 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.
|
# Check https://github.com/nodejs/docker-node/tree/b4117f9333da4138b03a546ec926ef50a31506c3#nodealpine to understand why libc6-compat might be needed.
|
||||||
RUN apk add --no-cache libc6-compat
|
RUN apk add --no-cache libc6-compat
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
@@ -19,7 +19,7 @@ RUN \
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
# Rebuild the source code only when needed
|
# Rebuild the source code only when needed
|
||||||
FROM --platform=$BUILDPLATFORM base AS builder
|
FROM base AS builder
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
COPY --from=deps /app/node_modules ./node_modules
|
COPY --from=deps /app/node_modules ./node_modules
|
||||||
COPY . .
|
COPY . .
|
||||||
@@ -43,8 +43,9 @@ ENV NEXT_TELEMETRY_DISABLED=1
|
|||||||
RUN addgroup --system --gid 1001 nodejs
|
RUN addgroup --system --gid 1001 nodejs
|
||||||
RUN adduser --system --uid 1001 nextjs
|
RUN adduser --system --uid 1001 nextjs
|
||||||
|
|
||||||
# Create data directory and set permissions
|
# Create data and backups directories and set permissions
|
||||||
RUN mkdir -p /app/data && chown nextjs:nodejs /app/data
|
RUN mkdir -p /app/data /app/backups \
|
||||||
|
&& chown nextjs:nodejs /app/data /app/backups
|
||||||
|
|
||||||
COPY --from=builder /app/public ./public
|
COPY --from=builder /app/public ./public
|
||||||
COPY --from=builder /app/CHANGELOG.md ./
|
COPY --from=builder /app/CHANGELOG.md ./
|
||||||
@@ -61,6 +62,6 @@ EXPOSE 3000
|
|||||||
ENV PORT=3000
|
ENV PORT=3000
|
||||||
ENV HOSTNAME="0.0.0.0"
|
ENV HOSTNAME="0.0.0.0"
|
||||||
|
|
||||||
VOLUME ["/app/data"]
|
VOLUME ["/app/data", "/app/backups"]
|
||||||
|
|
||||||
CMD ["node", "server.js"]
|
CMD ["node", "server.js"]
|
||||||
|
|||||||
16
README.md
16
README.md
@@ -6,7 +6,7 @@ HabitTrove is a gamified habit tracking application that helps you build and mai
|
|||||||
|
|
||||||
## Try the Demo
|
## 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
|
## Features
|
||||||
|
|
||||||
@@ -17,6 +17,7 @@ Want to try HabitTrove before installing? Visit the public [demo instance](https
|
|||||||
- 📅 Calendar heatmap to visualize your progress (WIP)
|
- 📅 Calendar heatmap to visualize your progress (WIP)
|
||||||
- 🌙 Dark mode support
|
- 🌙 Dark mode support
|
||||||
- 📲 Progressive Web App (PWA) support
|
- 📲 Progressive Web App (PWA) support
|
||||||
|
- 💾 Automatic daily backups with rotation
|
||||||
|
|
||||||
## Usage
|
## Usage
|
||||||
|
|
||||||
@@ -39,8 +40,8 @@ The easiest way to run HabitTrove is using our pre-built Docker images from Dock
|
|||||||
1. First, prepare the data directory with correct permissions:
|
1. First, prepare the data directory with correct permissions:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
mkdir -p data
|
mkdir -p data backups
|
||||||
chown -R 1001:1001 data # Required for the nextjs user in container
|
chown -R 1001:1001 data backups # Required for the nextjs user in container
|
||||||
```
|
```
|
||||||
|
|
||||||
2. Then run using either method:
|
2. Then run using either method:
|
||||||
@@ -51,15 +52,16 @@ export AUTH_SECRET=$(openssl rand -base64 32)
|
|||||||
echo $AUTH_SECRET
|
echo $AUTH_SECRET
|
||||||
|
|
||||||
# Using docker-compose (recommended)
|
# Using docker-compose (recommended)
|
||||||
## update the AUTH_SECRET environment variable in docker-compose file
|
## Update the AUTH_SECRET environment variable in docker-compose.yaml
|
||||||
nano docker-compose.yaml
|
nano docker-compose.yaml
|
||||||
## start the container
|
## Start the container
|
||||||
docker compose up -d
|
docker compose up -d
|
||||||
|
|
||||||
# Or using docker run directly
|
# Or using docker run directly
|
||||||
docker run -d \
|
docker run -d \
|
||||||
-p 3000:3000 \
|
-p 3000:3000 \
|
||||||
-v ./data:/app/data \
|
-v ./data:/app/data \
|
||||||
|
-v ./backups:/app/backups \ # Add this line to map the backups directory
|
||||||
-e AUTH_SECRET=$AUTH_SECRET \
|
-e AUTH_SECRET=$AUTH_SECRET \
|
||||||
dohsimpson/habittrove
|
dohsimpson/habittrove
|
||||||
```
|
```
|
||||||
@@ -73,9 +75,11 @@ Available image tags:
|
|||||||
Choose your tag based on needs:
|
Choose your tag based on needs:
|
||||||
|
|
||||||
- Use `latest` for general production use
|
- Use `latest` for general production use
|
||||||
- Use version tags (e.g., `v0.1.4`) for reproducible deployments
|
- Use version tags (e.g., `v0.2.9`) for reproducible deployments
|
||||||
- Use `dev` for testing new features
|
- Use `dev` for testing new features
|
||||||
|
|
||||||
|
**Note on Volumes:** The application stores user data in `/app/data` and backups in `/app/backups` inside the container. The examples above map `./data` and `./backups` from your host machine to these container directories. Ensure these host directories exist and have the correct permissions (`chown -R 1001:1001 data backups`).
|
||||||
|
|
||||||
### Building Locally
|
### Building Locally
|
||||||
|
|
||||||
If you want to build the image locally (useful for development):
|
If you want to build the image locally (useful for development):
|
||||||
|
|||||||
@@ -19,7 +19,8 @@ import {
|
|||||||
getDefaultWishlistData,
|
getDefaultWishlistData,
|
||||||
getDefaultHabitsData,
|
getDefaultHabitsData,
|
||||||
getDefaultCoinsData,
|
getDefaultCoinsData,
|
||||||
Permission
|
Permission,
|
||||||
|
ServerSettings
|
||||||
} from '@/lib/types'
|
} from '@/lib/types'
|
||||||
import { d2t, deepMerge, getNow, checkPermission, uuid } from '@/lib/utils';
|
import { d2t, deepMerge, getNow, checkPermission, uuid } from '@/lib/utils';
|
||||||
import { verifyPassword } from "@/lib/server-helpers";
|
import { verifyPassword } from "@/lib/server-helpers";
|
||||||
@@ -43,7 +44,7 @@ async function verifyPermission(
|
|||||||
|
|
||||||
// if (!user) throw new PermissionError('User not authenticated')
|
// if (!user) throw new PermissionError('User not authenticated')
|
||||||
// if (user.isAdmin) return // Admins bypass permission checks
|
// if (user.isAdmin) return // Admins bypass permission checks
|
||||||
|
|
||||||
// if (!checkPermission(user.permissions, resource, action)) {
|
// if (!checkPermission(user.permissions, resource, action)) {
|
||||||
// throw new PermissionError(`User does not have ${action} permission for ${resource}`)
|
// throw new PermissionError(`User does not have ${action} permission for ${resource}`)
|
||||||
// }
|
// }
|
||||||
@@ -63,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> {
|
async function loadData<T>(type: DataType): Promise<T> {
|
||||||
try {
|
try {
|
||||||
await ensureDataDir()
|
await ensureDataDir()
|
||||||
@@ -105,7 +127,7 @@ async function saveData<T>(type: DataType, data: T): Promise<void> {
|
|||||||
export async function loadWishlistData(): Promise<WishlistData> {
|
export async function loadWishlistData(): Promise<WishlistData> {
|
||||||
const user = await getCurrentUser()
|
const user = await getCurrentUser()
|
||||||
if (!user) return getDefaultWishlistData()
|
if (!user) return getDefaultWishlistData()
|
||||||
|
|
||||||
const data = await loadData<WishlistData>('wishlist')
|
const data = await loadData<WishlistData>('wishlist')
|
||||||
return {
|
return {
|
||||||
...data,
|
...data,
|
||||||
@@ -152,7 +174,7 @@ export async function loadHabitsData(): Promise<HabitsData> {
|
|||||||
|
|
||||||
export async function saveHabitsData(data: HabitsData): Promise<void> {
|
export async function saveHabitsData(data: HabitsData): Promise<void> {
|
||||||
await verifyPermission('habit', 'write')
|
await verifyPermission('habit', 'write')
|
||||||
|
|
||||||
const user = await getCurrentUser()
|
const user = await getCurrentUser()
|
||||||
// Create clone of input data
|
// Create clone of input data
|
||||||
const newData = _.cloneDeep(data)
|
const newData = _.cloneDeep(data)
|
||||||
@@ -184,7 +206,7 @@ export async function loadCoinsData(): Promise<CoinsData> {
|
|||||||
const data = await loadData<CoinsData>('coins')
|
const data = await loadData<CoinsData>('coins')
|
||||||
return {
|
return {
|
||||||
...data,
|
...data,
|
||||||
transactions: data.transactions.filter(x => x.userId === user.id)
|
transactions: user.isAdmin ? data.transactions : data.transactions.filter(x => x.userId === user.id)
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
return getDefaultCoinsData()
|
return getDefaultCoinsData()
|
||||||
@@ -193,7 +215,7 @@ export async function loadCoinsData(): Promise<CoinsData> {
|
|||||||
|
|
||||||
export async function saveCoinsData(data: CoinsData): Promise<void> {
|
export async function saveCoinsData(data: CoinsData): Promise<void> {
|
||||||
const user = await getCurrentUser()
|
const user = await getCurrentUser()
|
||||||
|
|
||||||
// Create clones of the data
|
// Create clones of the data
|
||||||
const newData = _.cloneDeep(data)
|
const newData = _.cloneDeep(data)
|
||||||
newData.transactions = newData.transactions.map(transaction => ({
|
newData.transactions = newData.transactions.map(transaction => ({
|
||||||
@@ -218,14 +240,17 @@ export async function addCoins({
|
|||||||
type = 'MANUAL_ADJUSTMENT',
|
type = 'MANUAL_ADJUSTMENT',
|
||||||
relatedItemId,
|
relatedItemId,
|
||||||
note,
|
note,
|
||||||
|
userId,
|
||||||
}: {
|
}: {
|
||||||
amount: number
|
amount: number
|
||||||
description: string
|
description: string
|
||||||
type?: TransactionType
|
type?: TransactionType
|
||||||
relatedItemId?: string
|
relatedItemId?: string
|
||||||
note?: string
|
note?: string
|
||||||
|
userId?: string
|
||||||
}): Promise<CoinsData> {
|
}): Promise<CoinsData> {
|
||||||
await verifyPermission('coins', type === 'MANUAL_ADJUSTMENT' ? 'write' : 'interact')
|
await verifyPermission('coins', type === 'MANUAL_ADJUSTMENT' ? 'write' : 'interact')
|
||||||
|
const currentUser = await getCurrentUser()
|
||||||
const data = await loadCoinsData()
|
const data = await loadCoinsData()
|
||||||
const newTransaction: CoinTransaction = {
|
const newTransaction: CoinTransaction = {
|
||||||
id: uuid(),
|
id: uuid(),
|
||||||
@@ -234,7 +259,8 @@ export async function addCoins({
|
|||||||
description,
|
description,
|
||||||
timestamp: d2t({ dateTime: getNow({}) }),
|
timestamp: d2t({ dateTime: getNow({}) }),
|
||||||
...(relatedItemId && { relatedItemId }),
|
...(relatedItemId && { relatedItemId }),
|
||||||
...(note && note.trim() !== '' && { note })
|
...(note && note.trim() !== '' && { note }),
|
||||||
|
userId: userId || currentUser?.id
|
||||||
}
|
}
|
||||||
|
|
||||||
const newData: CoinsData = {
|
const newData: CoinsData = {
|
||||||
@@ -269,14 +295,17 @@ export async function removeCoins({
|
|||||||
type = 'MANUAL_ADJUSTMENT',
|
type = 'MANUAL_ADJUSTMENT',
|
||||||
relatedItemId,
|
relatedItemId,
|
||||||
note,
|
note,
|
||||||
|
userId,
|
||||||
}: {
|
}: {
|
||||||
amount: number
|
amount: number
|
||||||
description: string
|
description: string
|
||||||
type?: TransactionType
|
type?: TransactionType
|
||||||
relatedItemId?: string
|
relatedItemId?: string
|
||||||
note?: string
|
note?: string
|
||||||
|
userId?: string
|
||||||
}): Promise<CoinsData> {
|
}): Promise<CoinsData> {
|
||||||
await verifyPermission('coins', type === 'MANUAL_ADJUSTMENT' ? 'write' : 'interact')
|
await verifyPermission('coins', type === 'MANUAL_ADJUSTMENT' ? 'write' : 'interact')
|
||||||
|
const currentUser = await getCurrentUser()
|
||||||
const data = await loadCoinsData()
|
const data = await loadCoinsData()
|
||||||
const newTransaction: CoinTransaction = {
|
const newTransaction: CoinTransaction = {
|
||||||
id: uuid(),
|
id: uuid(),
|
||||||
@@ -285,7 +314,8 @@ export async function removeCoins({
|
|||||||
description,
|
description,
|
||||||
timestamp: d2t({ dateTime: getNow({}) }),
|
timestamp: d2t({ dateTime: getNow({}) }),
|
||||||
...(relatedItemId && { relatedItemId }),
|
...(relatedItemId && { relatedItemId }),
|
||||||
...(note && note.trim() !== '' && { note })
|
...(note && note.trim() !== '' && { note }),
|
||||||
|
userId: userId || currentUser?.id
|
||||||
}
|
}
|
||||||
|
|
||||||
const newData: CoinsData = {
|
const newData: CoinsData = {
|
||||||
@@ -361,8 +391,8 @@ export async function createUser(formData: FormData): Promise<User> {
|
|||||||
const username = formData.get('username') as string;
|
const username = formData.get('username') as string;
|
||||||
let password = formData.get('password') as string | undefined;
|
let password = formData.get('password') as string | undefined;
|
||||||
const avatarPath = formData.get('avatarPath') as string;
|
const avatarPath = formData.get('avatarPath') as string;
|
||||||
const permissions = formData.get('permissions') ?
|
const permissions = formData.get('permissions') ?
|
||||||
JSON.parse(formData.get('permissions') as string) as Permission[] :
|
JSON.parse(formData.get('permissions') as string) as Permission[] :
|
||||||
undefined;
|
undefined;
|
||||||
|
|
||||||
if (password === null) password = undefined
|
if (password === null) password = undefined
|
||||||
@@ -376,7 +406,7 @@ export async function createUser(formData: FormData): Promise<User> {
|
|||||||
throw new Error('Username already exists');
|
throw new Error('Username already exists');
|
||||||
}
|
}
|
||||||
|
|
||||||
const hashedPassword = password ? saltAndHashPassword(password) : '';
|
const hashedPassword = password ? saltAndHashPassword(password) : undefined;
|
||||||
|
|
||||||
|
|
||||||
const newUser: User = {
|
const newUser: User = {
|
||||||
@@ -385,6 +415,7 @@ export async function createUser(formData: FormData): Promise<User> {
|
|||||||
password: hashedPassword,
|
password: hashedPassword,
|
||||||
permissions,
|
permissions,
|
||||||
isAdmin: false,
|
isAdmin: false,
|
||||||
|
lastNotificationReadTimestamp: undefined,
|
||||||
...(avatarPath && { avatarPath })
|
...(avatarPath && { avatarPath })
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -474,3 +505,34 @@ export async function deleteUser(userId: string): Promise<void> {
|
|||||||
|
|
||||||
await saveUsersData(newData)
|
await saveUsersData(newData)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
0
app/actions/wishlist.ts
Normal file
0
app/actions/wishlist.ts
Normal file
60
app/debug/backup/page.tsx
Normal file
60
app/debug/backup/page.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -4,7 +4,7 @@ import { DM_Sans } from 'next/font/google'
|
|||||||
import { JotaiProvider } from '@/components/jotai-providers'
|
import { JotaiProvider } from '@/components/jotai-providers'
|
||||||
import { Suspense } from 'react'
|
import { Suspense } from 'react'
|
||||||
import { JotaiHydrate } from '@/components/jotai-hydrate'
|
import { JotaiHydrate } from '@/components/jotai-hydrate'
|
||||||
import { loadSettings, loadHabitsData, loadCoinsData, loadWishlistData, loadUsersData } from './actions/data'
|
import { loadSettings, loadHabitsData, loadCoinsData, loadWishlistData, loadUsersData, loadServerSettings } from './actions/data'
|
||||||
import Layout from '@/components/Layout'
|
import Layout from '@/components/Layout'
|
||||||
import { Toaster } from '@/components/ui/toaster'
|
import { Toaster } from '@/components/ui/toaster'
|
||||||
import { ThemeProvider } from "@/components/theme-provider"
|
import { ThemeProvider } from "@/components/theme-provider"
|
||||||
@@ -37,12 +37,13 @@ export default async function RootLayout({
|
|||||||
}: {
|
}: {
|
||||||
children: React.ReactNode
|
children: React.ReactNode
|
||||||
}) {
|
}) {
|
||||||
const [initialSettings, initialHabits, initialCoins, initialWishlist, initialUsers] = await Promise.all([
|
const [initialSettings, initialHabits, initialCoins, initialWishlist, initialUsers, initialServerSettings] = await Promise.all([
|
||||||
loadSettings(),
|
loadSettings(),
|
||||||
loadHabitsData(),
|
loadHabitsData(),
|
||||||
loadCoinsData(),
|
loadCoinsData(),
|
||||||
loadWishlistData(),
|
loadWishlistData(),
|
||||||
loadUsersData(),
|
loadUsersData(),
|
||||||
|
loadServerSettings(),
|
||||||
])
|
])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -74,7 +75,8 @@ export default async function RootLayout({
|
|||||||
habits: initialHabits,
|
habits: initialHabits,
|
||||||
coins: initialCoins,
|
coins: initialCoins,
|
||||||
wishlist: initialWishlist,
|
wishlist: initialWishlist,
|
||||||
users: initialUsers
|
users: initialUsers,
|
||||||
|
serverSettings: initialServerSettings,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<ThemeProvider
|
<ThemeProvider
|
||||||
|
|||||||
@@ -1,19 +1,25 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||||
import { Switch } from '@/components/ui/switch'
|
import { Switch } from '@/components/ui/switch';
|
||||||
import { Label } from '@/components/ui/label'
|
import { Label } from '@/components/ui/label';
|
||||||
import { DynamicTimeNoSSR } from '@/components/DynamicTimeNoSSR'
|
import {
|
||||||
import { useAtom } from 'jotai'
|
Tooltip,
|
||||||
import { settingsAtom } from '@/lib/atoms'
|
TooltipContent,
|
||||||
|
TooltipProvider,
|
||||||
|
TooltipTrigger,
|
||||||
|
} from "@/components/ui/tooltip";
|
||||||
|
import { DynamicTimeNoSSR } from '@/components/DynamicTimeNoSSR';
|
||||||
|
import { useAtom } from 'jotai';
|
||||||
|
import { settingsAtom } from '@/lib/atoms';
|
||||||
import { Settings, WeekDay } from '@/lib/types'
|
import { Settings, WeekDay } from '@/lib/types'
|
||||||
import { saveSettings, uploadAvatar } from '../actions/data'
|
import { saveSettings, uploadAvatar } from '../actions/data'
|
||||||
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'
|
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button';
|
||||||
import { User } from 'lucide-react'
|
import { User, Info } from 'lucide-react'; // Import Info icon
|
||||||
|
|
||||||
export default function SettingsPage() {
|
export default function SettingsPage() {
|
||||||
const [settings, setSettings] = useAtom(settingsAtom)
|
const [settings, setSettings] = useAtom(settingsAtom);
|
||||||
|
|
||||||
const updateSettings = async (newSettings: Settings) => {
|
const updateSettings = async (newSettings: Settings) => {
|
||||||
await saveSettings(newSettings)
|
await saveSettings(newSettings)
|
||||||
@@ -140,6 +146,46 @@ export default function SettingsPage() {
|
|||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Add this section for Auto Backup */}
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="space-y-0.5">
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
<Label htmlFor="auto-backup">Auto Backup</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">
|
||||||
|
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.
|
||||||
|
</p>
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</TooltipProvider>
|
||||||
|
</div>
|
||||||
|
<div className="text-sm text-muted-foreground">
|
||||||
|
Automatically back up data daily
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Switch
|
||||||
|
id="auto-backup"
|
||||||
|
checked={settings.system.autoBackupEnabled}
|
||||||
|
onCheckedChange={(checked) =>
|
||||||
|
updateSettings({
|
||||||
|
...settings,
|
||||||
|
system: { ...settings.system, autoBackupEnabled: checked }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{/* End of Auto Backup section */}
|
||||||
|
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</div >
|
</div >
|
||||||
|
|||||||
@@ -16,8 +16,8 @@ import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover
|
|||||||
import data from '@emoji-mart/data'
|
import data from '@emoji-mart/data'
|
||||||
import Picker from '@emoji-mart/react'
|
import Picker from '@emoji-mart/react'
|
||||||
import { Habit, SafeUser } from '@/lib/types'
|
import { Habit, SafeUser } from '@/lib/types'
|
||||||
import { d2s, d2t, getISODate, getNow, parseNaturalLanguageDate, parseNaturalLanguageRRule, parseRRule, serializeRRule } from '@/lib/utils'
|
import { convertHumanReadableFrequencyToMachineReadable, convertMachineReadableFrequencyToHumanReadable, d2s, d2t, serializeRRule } from '@/lib/utils'
|
||||||
import { INITIAL_DUE, INITIAL_RECURRENCE_RULE, QUICK_DATES } from '@/lib/constants'
|
import { INITIAL_DUE, INITIAL_RECURRENCE_RULE, QUICK_DATES, RECURRENCE_RULE_MAP } from '@/lib/constants'
|
||||||
import * as chrono from 'chrono-node';
|
import * as chrono from 'chrono-node';
|
||||||
import { DateTime } from 'luxon'
|
import { DateTime } from 'luxon'
|
||||||
import {
|
import {
|
||||||
@@ -43,15 +43,44 @@ export default function AddEditHabitModal({ onClose, onSave, habit, isTask }: Ad
|
|||||||
const [coinReward, setCoinReward] = useState(habit?.coinReward || 1)
|
const [coinReward, setCoinReward] = useState(habit?.coinReward || 1)
|
||||||
const [targetCompletions, setTargetCompletions] = useState(habit?.targetCompletions || 1)
|
const [targetCompletions, setTargetCompletions] = useState(habit?.targetCompletions || 1)
|
||||||
const isRecurRule = !isTask
|
const isRecurRule = !isTask
|
||||||
const origRuleText = isRecurRule ? parseRRule(habit?.frequency || INITIAL_RECURRENCE_RULE).toText() : habit?.frequency || INITIAL_DUE
|
// Initialize ruleText with the actual frequency string or default, not the display text
|
||||||
const [ruleText, setRuleText] = useState<string>(origRuleText)
|
const initialRuleText = habit?.frequency ? convertMachineReadableFrequencyToHumanReadable({
|
||||||
const now = getNow({ timezone: settings.system.timezone })
|
frequency: habit.frequency,
|
||||||
|
isRecurRule,
|
||||||
|
timezone: settings.system.timezone
|
||||||
|
}) : (isRecurRule ? INITIAL_RECURRENCE_RULE : INITIAL_DUE);
|
||||||
|
const [ruleText, setRuleText] = useState<string>(initialRuleText)
|
||||||
const { currentUser } = useHelpers()
|
const { currentUser } = useHelpers()
|
||||||
const [isQuickDatesOpen, setIsQuickDatesOpen] = useState(false)
|
const [isQuickDatesOpen, setIsQuickDatesOpen] = useState(false)
|
||||||
|
const [ruleError, setRuleError] = useState<string | null>(null); // State for validation message
|
||||||
const [selectedUserIds, setSelectedUserIds] = useState<string[]>((habit?.userIds || []).filter(id => id !== currentUser?.id))
|
const [selectedUserIds, setSelectedUserIds] = useState<string[]>((habit?.userIds || []).filter(id => id !== currentUser?.id))
|
||||||
const [usersData] = useAtom(usersAtom)
|
const [usersData] = useAtom(usersAtom)
|
||||||
const users = usersData.users
|
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) => {
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
await onSave({
|
await onSave({
|
||||||
@@ -60,8 +89,7 @@ export default function AddEditHabitModal({ onClose, onSave, habit, isTask }: Ad
|
|||||||
coinReward,
|
coinReward,
|
||||||
targetCompletions: targetCompletions > 1 ? targetCompletions : undefined,
|
targetCompletions: targetCompletions > 1 ? targetCompletions : undefined,
|
||||||
completions: habit?.completions || [],
|
completions: habit?.completions || [],
|
||||||
frequency: isRecurRule ? serializeRRule(parseNaturalLanguageRRule(ruleText)) : d2t({ dateTime: parseNaturalLanguageDate({ text: ruleText, timezone: settings.system.timezone }) }),
|
frequency: getFrequencyUpdate(),
|
||||||
isTask: isTask || undefined,
|
|
||||||
userIds: selectedUserIds.length > 0 ? selectedUserIds.concat(currentUser?.id || []) : (currentUser && [currentUser.id])
|
userIds: selectedUserIds.length > 0 ? selectedUserIds.concat(currentUser?.id || []) : (currentUser && [currentUser.id])
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -129,6 +157,7 @@ export default function AddEditHabitModal({ onClose, onSave, habit, isTask }: Ad
|
|||||||
<Label htmlFor="recurrence" className="text-right">
|
<Label htmlFor="recurrence" className="text-right">
|
||||||
When *
|
When *
|
||||||
</Label>
|
</Label>
|
||||||
|
{/* date input (task) */}
|
||||||
<div className="col-span-3 space-y-2">
|
<div className="col-span-3 space-y-2">
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<Input
|
<Input
|
||||||
@@ -172,16 +201,26 @@ export default function AddEditHabitModal({ onClose, onSave, habit, isTask }: Ad
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="col-start-2 col-span-3 text-sm text-muted-foreground">
|
{/* rrule input (habit) */}
|
||||||
<span>
|
<div className="col-start-2 col-span-3 text-sm">
|
||||||
{(() => {
|
{(() => {
|
||||||
try {
|
let displayText = '';
|
||||||
return isRecurRule ? parseNaturalLanguageRRule(ruleText).toText() : d2s({ dateTime: parseNaturalLanguageDate({ text: ruleText, timezone: settings.system.timezone }), timezone: settings.system.timezone, format: DateTime.DATE_MED_WITH_WEEKDAY })
|
let errorMessage: string | null = null;
|
||||||
} catch (e: unknown) {
|
const { result, message } = convertHumanReadableFrequencyToMachineReadable({ text: ruleText, timezone: settings.system.timezone, isRecurring: isRecurRule });
|
||||||
return `Invalid rule: ${e instanceof Error ? e.message : 'Invalid recurrence rule'}`
|
errorMessage = message;
|
||||||
}
|
displayText = convertMachineReadableFrequencyToHumanReadable({ frequency: result, isRecurRule, timezone: settings.system.timezone })
|
||||||
})()}
|
|
||||||
</span>
|
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>
|
</div>
|
||||||
<div className="grid grid-cols-4 items-center gap-4">
|
<div className="grid grid-cols-4 items-center gap-4">
|
||||||
@@ -276,13 +315,13 @@ export default function AddEditHabitModal({ onClose, onSave, habit, isTask }: Ad
|
|||||||
<Avatar
|
<Avatar
|
||||||
key={user.id}
|
key={user.id}
|
||||||
className={`h-8 w-8 border-2 cursor-pointer
|
className={`h-8 w-8 border-2 cursor-pointer
|
||||||
${selectedUserIds.includes(user.id)
|
${selectedUserIds.includes(user.id)
|
||||||
? 'border-primary'
|
? 'border-primary'
|
||||||
: 'border-muted'
|
: 'border-muted'
|
||||||
}`}
|
}`}
|
||||||
title={user.username}
|
title={user.username}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setSelectedUserIds(prev =>
|
setSelectedUserIds(prev =>
|
||||||
prev.includes(user.id)
|
prev.includes(user.id)
|
||||||
? prev.filter(id => id !== user.id)
|
? prev.filter(id => id !== user.id)
|
||||||
: [...prev, user.id]
|
: [...prev, user.id]
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
'use client'
|
'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 { t2d, d2s, getNow, isSameDate } from '@/lib/utils'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import { FormattedNumber } from '@/components/FormattedNumber'
|
import { FormattedNumber } from '@/components/FormattedNumber'
|
||||||
@@ -17,6 +18,8 @@ import { TransactionNoteEditor } from './TransactionNoteEditor'
|
|||||||
import { useHelpers } from '@/lib/client-helpers'
|
import { useHelpers } from '@/lib/client-helpers'
|
||||||
|
|
||||||
export default function CoinsManager() {
|
export default function CoinsManager() {
|
||||||
|
const { currentUser } = useHelpers()
|
||||||
|
const [selectedUser, setSelectedUser] = useState<string>()
|
||||||
const {
|
const {
|
||||||
add,
|
add,
|
||||||
remove,
|
remove,
|
||||||
@@ -28,16 +31,39 @@ export default function CoinsManager() {
|
|||||||
totalSpent,
|
totalSpent,
|
||||||
coinsSpentToday,
|
coinsSpentToday,
|
||||||
transactionsToday
|
transactionsToday
|
||||||
} = useCoins()
|
} = useCoins({selectedUser})
|
||||||
const [settings] = useAtom(settingsAtom)
|
const [settings] = useAtom(settingsAtom)
|
||||||
const [usersData] = useAtom(usersAtom)
|
const [usersData] = useAtom(usersAtom)
|
||||||
const DEFAULT_AMOUNT = '0'
|
const DEFAULT_AMOUNT = '0'
|
||||||
const [amount, setAmount] = useState(DEFAULT_AMOUNT)
|
const [amount, setAmount] = useState(DEFAULT_AMOUNT)
|
||||||
const [pageSize, setPageSize] = useState(50)
|
const [pageSize, setPageSize] = useState(50)
|
||||||
const [currentPage, setCurrentPage] = useState(1)
|
const [currentPage, setCurrentPage] = useState(1)
|
||||||
const { currentUser } = useHelpers()
|
|
||||||
|
|
||||||
const [note, setNote] = useState('')
|
const [note, setNote] = useState('')
|
||||||
|
const searchParams = useSearchParams()
|
||||||
|
const highlightId = searchParams.get('highlight')
|
||||||
|
const userIdFromQuery = searchParams.get('user') // Get user ID from query
|
||||||
|
const transactionRefs = useRef<Record<string, HTMLDivElement | null>>({});
|
||||||
|
|
||||||
|
// 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) => {
|
const handleSaveNote = async (transactionId: string, note: string) => {
|
||||||
await updateNote(transactionId, note)
|
await updateNote(transactionId, note)
|
||||||
@@ -62,7 +88,22 @@ export default function CoinsManager() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="container mx-auto px-4 py-8">
|
<div className="container mx-auto px-4 py-8">
|
||||||
<h1 className="text-3xl font-bold mb-6">Coins Management</h1>
|
<div className="flex items-center justify-between mb-6">
|
||||||
|
<h1 className="text-3xl font-bold mr-6">Coins Management</h1>
|
||||||
|
{currentUser?.isAdmin && (
|
||||||
|
<select
|
||||||
|
className="border rounded p-2"
|
||||||
|
value={selectedUser}
|
||||||
|
onChange={(e) => setSelectedUser(e.target.value)}
|
||||||
|
>
|
||||||
|
{usersData.users.map(user => (
|
||||||
|
<option key={user.id} value={user.id}>
|
||||||
|
{user.username}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="grid gap-6 md:grid-cols-2">
|
<div className="grid gap-6 md:grid-cols-2">
|
||||||
<Card>
|
<Card>
|
||||||
@@ -233,13 +274,17 @@ export default function CoinsManager() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const isHighlighted = transaction.id === highlightId;
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={transaction.id}
|
key={transaction.id}
|
||||||
className="flex justify-between items-center p-3 border rounded hover:bg-gray-50 dark:hover:bg-gray-800 transition-colors"
|
ref={(el) => { transactionRefs.current[transaction.id] = el; }} // Assign ref correctly
|
||||||
|
className={`flex justify-between items-center p-3 border rounded hover:bg-gray-50 dark:hover:bg-gray-800 transition-colors ${
|
||||||
|
isHighlighted ? 'ring-2 ring-blue-500 bg-blue-50 dark:bg-blue-900/30' : '' // Apply highlight styles
|
||||||
|
}`}
|
||||||
>
|
>
|
||||||
<div className="space-y-1">
|
<div className="space-y-1 flex-grow mr-4"> {/* Added flex-grow and margin */}
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2 flex-wrap"> {/* Added flex-wrap */}
|
||||||
{transaction.relatedItemId ? (
|
{transaction.relatedItemId ? (
|
||||||
<Link
|
<Link
|
||||||
href={`${transaction.type === 'WISH_REDEMPTION' ? '/wishlist' : '/habits'}?highlight=${transaction.relatedItemId}`}
|
href={`${transaction.type === 'WISH_REDEMPTION' ? '/wishlist' : '/habits'}?highlight=${transaction.relatedItemId}`}
|
||||||
@@ -258,12 +303,13 @@ export default function CoinsManager() {
|
|||||||
</span>
|
</span>
|
||||||
{transaction.userId && currentUser?.isAdmin && (
|
{transaction.userId && currentUser?.isAdmin && (
|
||||||
<Avatar className="h-6 w-6">
|
<Avatar className="h-6 w-6">
|
||||||
<AvatarImage
|
<AvatarImage
|
||||||
src={usersData.users.find(u => u.id === transaction.userId)?.avatarPath &&
|
src={usersData.users.find(u => u.id === transaction.userId)?.avatarPath ?
|
||||||
`/api/avatars/${usersData.users.find(u => u.id === transaction.userId)?.avatarPath?.split('/').pop()}` || ""}
|
`/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>
|
<AvatarFallback>
|
||||||
{usersData.users.find(u => u.id === transaction.userId)?.username[0]}
|
{usersData.users.find(u => u.id === transaction.userId)?.username?.[0] || '?'}
|
||||||
</AvatarFallback>
|
</AvatarFallback>
|
||||||
</Avatar>
|
</Avatar>
|
||||||
)}
|
)}
|
||||||
@@ -278,14 +324,16 @@ export default function CoinsManager() {
|
|||||||
onDelete={handleDeleteNote}
|
onDelete={handleDeleteNote}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<span
|
<div className="flex-shrink-0 text-right"> {/* Ensure amount stays on the right */}
|
||||||
className={`font-mono ${transaction.amount >= 0
|
<span
|
||||||
? 'text-green-600 dark:text-green-400'
|
className={`font-mono ${transaction.amount >= 0
|
||||||
: 'text-red-600 dark:text-red-400'
|
? 'text-green-600 dark:text-green-400'
|
||||||
}`}
|
: 'text-red-600 dark:text-red-400'
|
||||||
>
|
}`}
|
||||||
{transaction.amount >= 0 ? '+' : ''}{transaction.amount}
|
>
|
||||||
</span>
|
{transaction.amount >= 0 ? '+' : ''}{transaction.amount}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
|
|||||||
@@ -1,26 +1,35 @@
|
|||||||
import { Circle, Coins, ArrowRight, CircleCheck, ChevronDown, ChevronUp, Timer, Plus } from 'lucide-react'
|
import { Circle, Coins, ArrowRight, CircleCheck, ChevronDown, ChevronUp, Plus, Pin, AlertTriangle } from 'lucide-react' // Removed unused icons
|
||||||
import CompletionCountBadge from './CompletionCountBadge'
|
import CompletionCountBadge from './CompletionCountBadge'
|
||||||
import {
|
import {
|
||||||
ContextMenu,
|
ContextMenu,
|
||||||
ContextMenuContent,
|
ContextMenuContent,
|
||||||
ContextMenuItem,
|
ContextMenuItem,
|
||||||
|
ContextMenuSeparator,
|
||||||
ContextMenuTrigger,
|
ContextMenuTrigger,
|
||||||
} from "@/components/ui/context-menu"
|
} from "@/components/ui/context-menu"
|
||||||
import { cn, isHabitDueToday, getHabitFreq } from '@/lib/utils'
|
import { cn } from '@/lib/utils'
|
||||||
import Link from 'next/link'
|
import Link from 'next/link'
|
||||||
import { useState, useEffect } from 'react'
|
import { useState } from 'react'
|
||||||
import { useAtom } from 'jotai'
|
import { useAtom } from 'jotai'
|
||||||
import { pomodoroAtom, settingsAtom, completedHabitsMapAtom, browserSettingsAtom, BrowserSettings, hasTasksAtom, dailyHabitsAtom } from '@/lib/atoms'
|
import { pomodoroAtom, settingsAtom, completedHabitsMapAtom, browserSettingsAtom, BrowserSettings, hasTasksAtom } from '@/lib/atoms'
|
||||||
import { getTodayInTimezone, isSameDate, t2d, d2t, getNow } from '@/lib/utils'
|
import { getTodayInTimezone, isSameDate, t2d, d2t, getNow, isHabitDue, isTaskOverdue } from '@/lib/utils'
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||||
import { Badge } from '@/components/ui/badge'
|
import { Badge } from '@/components/ui/badge'
|
||||||
|
import {
|
||||||
|
Tooltip,
|
||||||
|
TooltipContent,
|
||||||
|
TooltipProvider,
|
||||||
|
TooltipTrigger,
|
||||||
|
} from "@/components/ui/tooltip"
|
||||||
import { Progress } from '@/components/ui/progress'
|
import { Progress } from '@/components/ui/progress'
|
||||||
import { WishlistItemType } from '@/lib/types'
|
import { Settings, WishlistItemType } from '@/lib/types'
|
||||||
import { Habit } from '@/lib/types'
|
import { Habit } from '@/lib/types'
|
||||||
import Linkify from './linkify'
|
import Linkify from './linkify'
|
||||||
import { useHabits } from '@/hooks/useHabits'
|
import { useHabits } from '@/hooks/useHabits'
|
||||||
import AddEditHabitModal from './AddEditHabitModal'
|
import AddEditHabitModal from './AddEditHabitModal'
|
||||||
|
import ConfirmDialog from './ConfirmDialog'
|
||||||
import { Button } from './ui/button'
|
import { Button } from './ui/button'
|
||||||
|
import { HabitContextMenuItems } from './HabitContextMenuItems'
|
||||||
|
|
||||||
interface UpcomingItemsProps {
|
interface UpcomingItemsProps {
|
||||||
habits: Habit[]
|
habits: Habit[]
|
||||||
@@ -28,6 +37,323 @@ interface UpcomingItemsProps {
|
|||||||
coinBalance: number
|
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 { 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">Add {isTask ? "Task" : "Habit"}</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">Add {isTask ? "Task" : "Habit"}</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>Overdue</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 ? (
|
||||||
|
<>
|
||||||
|
Show less
|
||||||
|
<ChevronUp className="h-3 w-3" />
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
Show all
|
||||||
|
<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({
|
export default function DailyOverview({
|
||||||
habits,
|
habits,
|
||||||
wishlistItems,
|
wishlistItems,
|
||||||
@@ -36,13 +362,24 @@ export default function DailyOverview({
|
|||||||
const { completeHabit, undoComplete } = useHabits()
|
const { completeHabit, undoComplete } = useHabits()
|
||||||
const [settings] = useAtom(settingsAtom)
|
const [settings] = useAtom(settingsAtom)
|
||||||
const [completedHabitsMap] = useAtom(completedHabitsMapAtom)
|
const [completedHabitsMap] = useAtom(completedHabitsMapAtom)
|
||||||
const [dailyItems] = useAtom(dailyHabitsAtom)
|
const [browserSettings, setBrowserSettings] = useAtom(browserSettingsAtom)
|
||||||
const dailyTasks = dailyItems.filter(habit => habit.isTask)
|
|
||||||
const dailyHabits = dailyItems.filter(habit => !habit.isTask)
|
|
||||||
const today = getTodayInTimezone(settings.system.timezone)
|
const today = getTodayInTimezone(settings.system.timezone)
|
||||||
const todayCompletions = completedHabitsMap.get(today) || []
|
const todayCompletions = completedHabitsMap.get(today) || []
|
||||||
const { saveHabit } = useHabits()
|
const { saveHabit } = useHabits()
|
||||||
const [browserSettings, setBrowserSettings] = useAtom(browserSettingsAtom)
|
|
||||||
|
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
|
// Get all wishlist items sorted by redeemable status (non-redeemable first) then by coin cost
|
||||||
// Filter out archived wishlist items
|
// Filter out archived wishlist items
|
||||||
@@ -62,7 +399,7 @@ export default function DailyOverview({
|
|||||||
})
|
})
|
||||||
|
|
||||||
const [hasTasks] = useAtom(hasTasksAtom)
|
const [hasTasks] = useAtom(hasTasksAtom)
|
||||||
const [_, setPomo] = useAtom(pomodoroAtom)
|
const [, setPomo] = useAtom(pomodoroAtom)
|
||||||
const [modalConfig, setModalConfig] = useState<{
|
const [modalConfig, setModalConfig] = useState<{
|
||||||
isOpen: boolean,
|
isOpen: boolean,
|
||||||
isTask: boolean
|
isTask: boolean
|
||||||
@@ -80,414 +417,26 @@ export default function DailyOverview({
|
|||||||
<CardContent>
|
<CardContent>
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
{/* Tasks Section */}
|
{/* Tasks Section */}
|
||||||
{hasTasks && dailyTasks.length === 0 ? (
|
{hasTasks && (
|
||||||
<div>
|
<ItemSection
|
||||||
<div className="flex items-center justify-between mb-2">
|
title="Daily Tasks"
|
||||||
<h3 className="font-semibold">Daily Tasks</h3>
|
items={dailyTasks}
|
||||||
<Button
|
emptyMessage="No tasks due today. Add some tasks to get started!"
|
||||||
variant="ghost"
|
isTask={true}
|
||||||
size="sm"
|
viewLink="/habits?view=tasks"
|
||||||
className="h-7 w-7 rounded-full hover:bg-primary/10 hover:text-primary"
|
addNewItem={() => setModalConfig({ isOpen: true, isTask: true })}
|
||||||
onClick={() => {
|
/>
|
||||||
setModalConfig({
|
|
||||||
isOpen: true,
|
|
||||||
isTask: true
|
|
||||||
});
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Plus className="h-4 w-4" />
|
|
||||||
<span className="sr-only">Add Task</span>
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
<div className="text-center text-muted-foreground text-sm py-4">
|
|
||||||
No tasks due today. Add some tasks to get started!
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
) : hasTasks && (
|
|
||||||
<div>
|
|
||||||
<div className="flex items-center justify-between mb-2">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<h3 className="font-semibold">Daily Tasks</h3>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<CompletionCountBadge type="tasks" />
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
className="h-7 w-7 rounded-full hover:bg-primary/10 hover:text-primary"
|
|
||||||
onClick={() => {
|
|
||||||
setModalConfig({
|
|
||||||
isOpen: true,
|
|
||||||
isTask: true
|
|
||||||
});
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Plus className="h-4 w-4" />
|
|
||||||
<span className="sr-only">Add Task</span>
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<ul className={`grid gap-2 transition-all duration-300 ease-in-out ${browserSettings.expandedTasks ? 'max-h-none' : 'max-h-[200px]'} overflow-hidden`}>
|
|
||||||
{dailyTasks
|
|
||||||
.sort((a, b) => {
|
|
||||||
// First by completion status
|
|
||||||
const aCompleted = todayCompletions.includes(a);
|
|
||||||
const bCompleted = todayCompletions.includes(b);
|
|
||||||
if (aCompleted !== bCompleted) {
|
|
||||||
return aCompleted ? 1 : -1;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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, browserSettings.expandedTasks ? 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 || (habit.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">
|
|
||||||
<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={() => setBrowserSettings(prev => ({ ...prev, expandedTasks: !prev.expandedTasks }))}
|
|
||||||
className="text-sm text-muted-foreground hover:text-primary flex items-center gap-1"
|
|
||||||
>
|
|
||||||
{browserSettings.expandedTasks ? (
|
|
||||||
<>
|
|
||||||
Show less
|
|
||||||
<ChevronUp className="h-3 w-3" />
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
Show all
|
|
||||||
<ChevronDown className="h-3 w-3" />
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
<Link
|
|
||||||
href="/habits?view=tasks"
|
|
||||||
className="text-sm text-muted-foreground hover:text-primary flex items-center gap-1"
|
|
||||||
onClick={() => setBrowserSettings(prev => ({ ...prev, viewType: 'tasks' }))}
|
|
||||||
>
|
|
||||||
View
|
|
||||||
<ArrowRight className="h-3 w-3" />
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Habits Section */}
|
{/* Habits Section */}
|
||||||
{dailyHabits.length === 0 ? (
|
<ItemSection
|
||||||
<div>
|
title="Daily Habits"
|
||||||
<div className="flex items-center justify-between mb-2">
|
items={dailyHabits}
|
||||||
<h3 className="font-semibold">Daily Habits</h3>
|
emptyMessage="No habits due today. Add some habits to get started!"
|
||||||
<Button
|
isTask={false}
|
||||||
variant="ghost"
|
viewLink="/habits"
|
||||||
size="sm"
|
addNewItem={() => setModalConfig({ isOpen: true, isTask: false })}
|
||||||
className="h-7 w-7 rounded-full hover:bg-primary/10 hover:text-primary"
|
/>
|
||||||
onClick={() => {
|
|
||||||
setModalConfig({
|
|
||||||
isOpen: true,
|
|
||||||
isTask: false
|
|
||||||
});
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Plus className="h-4 w-4" />
|
|
||||||
<span className="sr-only">Add Habit</span>
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
<div className="text-center text-muted-foreground text-sm py-4">
|
|
||||||
No habits due today. Add some habits to get started!
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div>
|
|
||||||
<div className="flex items-center justify-between mb-2">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<h3 className="font-semibold">Daily Habits</h3>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<CompletionCountBadge type="habits" />
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
className="h-7 w-7 rounded-full hover:bg-primary/10 hover:text-primary"
|
|
||||||
onClick={() => {
|
|
||||||
setModalConfig({
|
|
||||||
isOpen: true,
|
|
||||||
isTask: false
|
|
||||||
});
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Plus className="h-4 w-4" />
|
|
||||||
<span className="sr-only">Add Habit</span>
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<ul className={`grid gap-2 transition-all duration-300 ease-in-out ${browserSettings.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;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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, browserSettings.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={() => setBrowserSettings(prev => ({ ...prev, expandedHabits: !prev.expandedHabits }))}
|
|
||||||
className="text-sm text-muted-foreground hover:text-primary flex items-center gap-1"
|
|
||||||
>
|
|
||||||
{browserSettings.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"
|
|
||||||
onClick={() => setBrowserSettings(prev => ({ ...prev, viewType: 'habits' }))}
|
|
||||||
>
|
|
||||||
View
|
|
||||||
<ArrowRight className="h-3 w-3" />
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<div className="flex items-center justify-between mb-2">
|
<div className="flex items-center justify-between mb-2">
|
||||||
|
|||||||
157
components/HabitContextMenuItems.tsx
Normal file
157
components/HabitContextMenuItems.tsx
Normal file
@@ -0,0 +1,157 @@
|
|||||||
|
import { Habit } from '@/lib/types';
|
||||||
|
import { useHabits } from '@/hooks/useHabits';
|
||||||
|
import { useAtom } from 'jotai';
|
||||||
|
import { pomodoroAtom, settingsAtom } from '@/lib/atoms';
|
||||||
|
import { d2t, getNow, isHabitDueToday } 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 { useHelpers } from '@/lib/client-helpers'; // For permission checks if needed, though useHabits handles most
|
||||||
|
|
||||||
|
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 { saveHabit, archiveHabit, unarchiveHabit } = useHabits();
|
||||||
|
const [settings] = useAtom(settingsAtom);
|
||||||
|
const [, setPomo] = useAtom(pomodoroAtom);
|
||||||
|
const { hasPermission } = useHelpers(); // Assuming useHabits handles permissions for its actions
|
||||||
|
|
||||||
|
const canWrite = hasPermission('habit', 'write'); // For UI disabling if not handled by useHabits' actions
|
||||||
|
const canInteract = hasPermission('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>Start Pomodoro</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>Move to Today</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>Move to Tomorrow</span>
|
||||||
|
</MenuItemComponent>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!habit.archived && (
|
||||||
|
<MenuItemComponent
|
||||||
|
disabled={!canWrite}
|
||||||
|
onClick={() => handleAction(() => saveHabit({ ...habit, pinned: !habit.pinned }))}
|
||||||
|
>
|
||||||
|
<Pin className="mr-2 h-4 w-4" />
|
||||||
|
<span>{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>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>Edit</span>
|
||||||
|
</MenuItemComponent>
|
||||||
|
)}
|
||||||
|
|
||||||
|
|
||||||
|
{!habit.archived && (
|
||||||
|
<MenuItemComponent
|
||||||
|
disabled={!canWrite}
|
||||||
|
onClick={() => handleAction(() => archiveHabit(habit.id))}
|
||||||
|
>
|
||||||
|
<Archive className="mr-2 h-4 w-4" />
|
||||||
|
<span>Archive</span>
|
||||||
|
</MenuItemComponent>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{habit.archived && (
|
||||||
|
<MenuItemComponent
|
||||||
|
disabled={!canWrite}
|
||||||
|
onClick={() => handleAction(() => unarchiveHabit(habit.id))}
|
||||||
|
>
|
||||||
|
<ArchiveRestore className="mr-2 h-4 w-4" />
|
||||||
|
<span>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>Delete</span>
|
||||||
|
</MenuItemComponent>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,10 +1,10 @@
|
|||||||
import { Habit, SafeUser, User, Permission } from '@/lib/types'
|
import { Habit, SafeUser, User, Permission } from '@/lib/types'
|
||||||
import { useAtom } from 'jotai'
|
import { useAtom } from 'jotai'
|
||||||
import { settingsAtom, pomodoroAtom, browserSettingsAtom, usersAtom } from '@/lib/atoms'
|
import { settingsAtom, pomodoroAtom, browserSettingsAtom, usersAtom } from '@/lib/atoms'
|
||||||
import { getTodayInTimezone, isSameDate, t2d, d2t, getNow, parseNaturalLanguageRRule, parseRRule, d2s, getCompletionsForToday, isTaskOverdue } from '@/lib/utils'
|
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 { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from '@/components/ui/card'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import { Coins, Edit, Trash2, Check, Undo2, MoreVertical, Timer, Archive, ArchiveRestore, Calendar } from 'lucide-react'
|
import { Coins, Edit, Check, Undo2, MoreVertical, Pin } from 'lucide-react' // Removed unused icons
|
||||||
import {
|
import {
|
||||||
DropdownMenu,
|
DropdownMenu,
|
||||||
DropdownMenuContent,
|
DropdownMenuContent,
|
||||||
@@ -14,10 +14,11 @@ import {
|
|||||||
} from '@/components/ui/dropdown-menu'
|
} from '@/components/ui/dropdown-menu'
|
||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
import { useHabits } from '@/hooks/useHabits'
|
import { useHabits } from '@/hooks/useHabits'
|
||||||
import { INITIAL_RECURRENCE_RULE } from '@/lib/constants'
|
import { INITIAL_RECURRENCE_RULE, RECURRENCE_RULE_MAP } from '@/lib/constants'
|
||||||
import { DateTime } from 'luxon'
|
import { DateTime } from 'luxon'
|
||||||
import { Avatar, AvatarFallback, AvatarImage } from './ui/avatar'
|
import { Avatar, AvatarFallback, AvatarImage } from './ui/avatar'
|
||||||
import { useHelpers } from '@/lib/client-helpers'
|
import { useHelpers } from '@/lib/client-helpers'
|
||||||
|
import { HabitContextMenuItems } from './HabitContextMenuItems'
|
||||||
|
|
||||||
interface HabitItemProps {
|
interface HabitItemProps {
|
||||||
habit: Habit
|
habit: Habit
|
||||||
@@ -88,7 +89,12 @@ export default function HabitItem({ habit, onEdit, onDelete }: HabitItemProps) {
|
|||||||
<CardHeader className="flex-none">
|
<CardHeader className="flex-none">
|
||||||
<div className="flex justify-between items-start">
|
<div className="flex justify-between items-start">
|
||||||
<CardTitle className={`line-clamp-1 ${habit.archived ? 'text-gray-400 dark:text-gray-500' : ''} flex items-center ${isTasksView ? 'w-full' : ''} justify-between`}>
|
<CardTitle className={`line-clamp-1 ${habit.archived ? 'text-gray-400 dark:text-gray-500' : ''} flex items-center ${isTasksView ? 'w-full' : ''} justify-between`}>
|
||||||
<span>{habit.name}</span>
|
<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) && (
|
{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">
|
<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">
|
||||||
Overdue
|
Overdue
|
||||||
@@ -104,7 +110,13 @@ export default function HabitItem({ habit, onEdit, onDelete }: HabitItemProps) {
|
|||||||
)}
|
)}
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="flex-1">
|
<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'}`}>
|
||||||
|
When: {convertMachineReadableFrequencyToHumanReadable({
|
||||||
|
frequency: habit.frequency,
|
||||||
|
isRecurRule,
|
||||||
|
timezone: settings.system.timezone
|
||||||
|
})}
|
||||||
|
</p>
|
||||||
<div className="flex items-center mt-2">
|
<div className="flex items-center mt-2">
|
||||||
<Coins className={`h-4 w-4 mr-1 ${habit.archived ? 'text-gray-400 dark:text-gray-500' : 'text-yellow-400'}`} />
|
<Coins className={`h-4 w-4 mr-1 ${habit.archived ? 'text-gray-400 dark:text-gray-500' : 'text-yellow-400'}`} />
|
||||||
<span className={`text-sm font-medium ${habit.archived ? 'text-gray-400 dark:text-gray-500' : ''}`}>{habit.coinReward} coins per completion</span>
|
<span className={`text-sm font-medium ${habit.archived ? 'text-gray-400 dark:text-gray-500' : ''}`}>{habit.coinReward} coins per completion</span>
|
||||||
@@ -183,57 +195,12 @@ export default function HabitItem({ habit, onEdit, onDelete }: HabitItemProps) {
|
|||||||
</Button>
|
</Button>
|
||||||
</DropdownMenuTrigger>
|
</DropdownMenuTrigger>
|
||||||
<DropdownMenuContent align="end">
|
<DropdownMenuContent align="end">
|
||||||
{!habit.archived && (
|
<HabitContextMenuItems
|
||||||
<DropdownMenuItem onClick={() => {
|
habit={habit}
|
||||||
if (!canInteract) return
|
onEditRequest={onEdit}
|
||||||
setPomo((prev) => ({
|
onDeleteRequest={onDelete}
|
||||||
...prev,
|
context="habit-item"
|
||||||
show: true,
|
/>
|
||||||
selectedHabitId: habit.id
|
|
||||||
}))
|
|
||||||
}}>
|
|
||||||
<Timer className="mr-2 h-4 w-4" />
|
|
||||||
<span>Start Pomodoro</span>
|
|
||||||
</DropdownMenuItem>
|
|
||||||
)}
|
|
||||||
{!habit.archived && (
|
|
||||||
<>
|
|
||||||
{habit.isTask && (
|
|
||||||
<DropdownMenuItem disabled={!canWrite} onClick={() => {
|
|
||||||
saveHabit({...habit, frequency: d2t({ dateTime: getNow({ timezone: settings.system.timezone })})})
|
|
||||||
}}>
|
|
||||||
<Calendar className="mr-2 h-4 w-4" />
|
|
||||||
<span>Move to Today</span>
|
|
||||||
</DropdownMenuItem>
|
|
||||||
)}
|
|
||||||
<DropdownMenuItem disabled={!canWrite} onClick={() => archiveHabit(habit.id)}>
|
|
||||||
<Archive className="mr-2 h-4 w-4" />
|
|
||||||
<span>Archive</span>
|
|
||||||
</DropdownMenuItem>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
{habit.archived && (
|
|
||||||
<DropdownMenuItem disabled={!canWrite} onClick={() => unarchiveHabit(habit.id)}>
|
|
||||||
<ArchiveRestore className="mr-2 h-4 w-4" />
|
|
||||||
<span>Unarchive</span>
|
|
||||||
</DropdownMenuItem>
|
|
||||||
)}
|
|
||||||
<DropdownMenuItem
|
|
||||||
onClick={onEdit}
|
|
||||||
className="sm:hidden"
|
|
||||||
disabled={habit.archived}
|
|
||||||
>
|
|
||||||
<Edit className="mr-2 h-4 w-4" />
|
|
||||||
Edit
|
|
||||||
</DropdownMenuItem>
|
|
||||||
<DropdownMenuSeparator className="sm:hidden" />
|
|
||||||
<DropdownMenuItem
|
|
||||||
className="text-red-600 focus:text-red-600 dark:text-red-400 dark:focus:text-red-400 cursor-pointer"
|
|
||||||
onClick={onDelete}
|
|
||||||
>
|
|
||||||
<Trash2 className="mr-2 h-4 w-4" />
|
|
||||||
Delete
|
|
||||||
</DropdownMenuItem>
|
|
||||||
</DropdownMenuContent>
|
</DropdownMenuContent>
|
||||||
</DropdownMenu>
|
</DropdownMenu>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { useState } from 'react'
|
import { useState, useMemo, useEffect } from 'react' // Added useMemo, useEffect
|
||||||
import { Plus, ListTodo } from 'lucide-react'
|
import { Plus, ArrowUpNarrowWide, ArrowDownWideNarrow, Search } from 'lucide-react' // Added sort icons, Search icon
|
||||||
import { useAtom } from 'jotai'
|
import { useAtom } from 'jotai'
|
||||||
import { habitsAtom, settingsAtom, browserSettingsAtom } from '@/lib/atoms'
|
import { habitsAtom, settingsAtom, browserSettingsAtom } from '@/lib/atoms'
|
||||||
import EmptyState from './EmptyState'
|
import EmptyState from './EmptyState'
|
||||||
@@ -13,18 +13,100 @@ import { Habit } from '@/lib/types'
|
|||||||
import { useHabits } from '@/hooks/useHabits'
|
import { useHabits } from '@/hooks/useHabits'
|
||||||
import { HabitIcon, TaskIcon } from '@/lib/constants'
|
import { HabitIcon, TaskIcon } from '@/lib/constants'
|
||||||
import { ViewToggle } from './ViewToggle'
|
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() {
|
export default function HabitList() {
|
||||||
const { saveHabit, deleteHabit } = useHabits()
|
const { saveHabit, deleteHabit } = useHabits()
|
||||||
const [habitsData, setHabitsData] = useAtom(habitsAtom)
|
const [habitsData] = useAtom(habitsAtom) // setHabitsData removed as it's not used
|
||||||
const [browserSettings] = useAtom(browserSettingsAtom)
|
const [browserSettings] = useAtom(browserSettingsAtom)
|
||||||
const isTasksView = browserSettings.viewType === 'tasks'
|
const isTasksView = browserSettings.viewType === 'tasks'
|
||||||
const habits = habitsData.habits.filter(habit =>
|
// const [settings] = useAtom(settingsAtom); // settingsAtom is not directly used in HabitList itself.
|
||||||
isTasksView ? habit.isTask : !habit.isTask
|
|
||||||
)
|
type SortableField = 'name' | 'coinReward' | 'dueDate' | 'frequency';
|
||||||
const activeHabits = habits.filter(h => !h.archived)
|
type SortOrder = 'asc' | 'desc';
|
||||||
const archivedHabits = habits.filter(h => h.archived)
|
|
||||||
const [settings] = useAtom(settingsAtom)
|
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<{
|
const [modalConfig, setModalConfig] = useState<{
|
||||||
isOpen: boolean,
|
isOpen: boolean,
|
||||||
isTask: boolean
|
isTask: boolean
|
||||||
@@ -41,19 +123,63 @@ export default function HabitList() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="container mx-auto px-4 py-8">
|
<div className="container mx-auto px-4 py-8">
|
||||||
<div className="flex justify-between items-center mb-6">
|
<div className="flex justify-between items-center mb-6">
|
||||||
<h1 className="text-3xl font-bold">
|
<h1 className="text-3xl font-bold">
|
||||||
{isTasksView ? 'My Tasks' : 'My Habits'}
|
{isTasksView ? 'My Tasks' : 'My Habits'}
|
||||||
</h1>
|
</h1>
|
||||||
<Button onClick={() => setModalConfig({ isOpen: true, isTask: isTasksView })}>
|
<span>
|
||||||
<Plus className="mr-2 h-4 w-4" /> {isTasksView ? 'Add Task' : 'Add Habit'}
|
<Button className="mr-2" onClick={() => setModalConfig({ isOpen: true, isTask: true })}>
|
||||||
</Button>
|
<Plus className="mr-2 h-4 w-4" /> {'Add Task'}
|
||||||
</div>
|
</Button>
|
||||||
<div className='py-4'>
|
<Button onClick={() => setModalConfig({ isOpen: true, isTask: false })}>
|
||||||
<ViewToggle />
|
<Plus className="mr-2 h-4 w-4" /> {'Add Habit'}
|
||||||
</div>
|
</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={`Search ${isTasksView ? 'tasks' : 'habits'}...`}
|
||||||
|
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">Sort by:</Label>
|
||||||
|
<Select value={sortBy} onValueChange={(value) => setSortBy(value as SortableField)}>
|
||||||
|
<SelectTrigger id="sort-by" className="w-full sm:w-[180px]">
|
||||||
|
<SelectValue placeholder="Sort by" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="name">Name</SelectItem>
|
||||||
|
<SelectItem value="coinReward">Coin Reward</SelectItem>
|
||||||
|
{isTasksView && <SelectItem value="dueDate">Due Date</SelectItem>}
|
||||||
|
{!isTasksView && <SelectItem value="frequency">Frequency</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">Toggle sort order</span>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 items-stretch">
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 items-stretch">
|
||||||
{activeHabits.length === 0 ? (
|
{activeHabits.length === 0 && searchTerm.trim() ? (
|
||||||
|
<div className="col-span-2 text-center text-muted-foreground py-8">
|
||||||
|
No {isTasksView ? 'tasks' : 'habits'} found matching your search.
|
||||||
|
</div>
|
||||||
|
) : activeHabits.length === 0 ? (
|
||||||
<div className="col-span-2">
|
<div className="col-span-2">
|
||||||
<EmptyState
|
<EmptyState
|
||||||
icon={isTasksView ? TaskIcon : HabitIcon}
|
icon={isTasksView ? TaskIcon : HabitIcon}
|
||||||
@@ -62,19 +188,19 @@ export default function HabitList() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
activeHabits.map((habit: Habit) => (
|
activeHabits.map((habit: Habit) => (
|
||||||
<HabitItem
|
<HabitItem
|
||||||
key={habit.id}
|
key={habit.id}
|
||||||
habit={habit}
|
habit={habit}
|
||||||
onEdit={() => {
|
onEdit={() => {
|
||||||
setEditingHabit(habit)
|
setEditingHabit(habit)
|
||||||
setModalConfig({ isOpen: true, isTask: isTasksView })
|
setModalConfig({ isOpen: true, isTask: isTasksView })
|
||||||
}}
|
}}
|
||||||
onDelete={() => setDeleteConfirmation({ isOpen: true, habitId: habit.id })}
|
onDelete={() => setDeleteConfirmation({ isOpen: true, habitId: habit.id })}
|
||||||
/>
|
/>
|
||||||
))
|
))
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{archivedHabits.length > 0 && (
|
{archivedHabits.length > 0 && (
|
||||||
<>
|
<>
|
||||||
<div className="col-span-1 sm:col-span-2 relative flex items-center my-6">
|
<div className="col-span-1 sm:col-span-2 relative flex items-center my-6">
|
||||||
|
|||||||
@@ -2,10 +2,10 @@
|
|||||||
|
|
||||||
import { Habit } from '@/lib/types'
|
import { Habit } from '@/lib/types'
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
|
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 { LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer } from 'recharts'
|
||||||
import { useAtom } from 'jotai'
|
import { useAtom } from 'jotai'
|
||||||
import { settingsAtom, hasTasksAtom } from '@/lib/atoms'
|
import { settingsAtom, hasTasksAtom, completedHabitsMapAtom } from '@/lib/atoms' // Added completedHabitsMapAtom
|
||||||
|
|
||||||
interface HabitStreakProps {
|
interface HabitStreakProps {
|
||||||
habits: Habit[]
|
habits: Habit[]
|
||||||
@@ -14,6 +14,8 @@ interface HabitStreakProps {
|
|||||||
export default function HabitStreak({ habits }: HabitStreakProps) {
|
export default function HabitStreak({ habits }: HabitStreakProps) {
|
||||||
const [settings] = useAtom(settingsAtom)
|
const [settings] = useAtom(settingsAtom)
|
||||||
const [hasTasks] = useAtom(hasTasksAtom)
|
const [hasTasks] = useAtom(hasTasksAtom)
|
||||||
|
const [completedHabitsMap] = useAtom(completedHabitsMapAtom) // Use the atom
|
||||||
|
|
||||||
// Get the last 7 days of data
|
// Get the last 7 days of data
|
||||||
const dates = Array.from({ length: 7 }, (_, i) => {
|
const dates = Array.from({ length: 7 }, (_, i) => {
|
||||||
const d = getNow({ timezone: settings.system.timezone });
|
const d = getNow({ timezone: settings.system.timezone });
|
||||||
@@ -21,20 +23,17 @@ export default function HabitStreak({ habits }: HabitStreakProps) {
|
|||||||
}).reverse()
|
}).reverse()
|
||||||
|
|
||||||
const completions = dates.map(date => {
|
const completions = dates.map(date => {
|
||||||
const completedHabits = getCompletedHabitsForDate({
|
// Get completed habits for the date from the map
|
||||||
habits: habits.filter(h => !h.isTask),
|
const completedOnDate = completedHabitsMap.get(date) || [];
|
||||||
date: t2d({ timestamp: date, timezone: settings.system.timezone }),
|
|
||||||
timezone: settings.system.timezone
|
// Filter the completed list to count habits and tasks
|
||||||
});
|
const completedHabitsCount = completedOnDate.filter(h => !h.isTask).length;
|
||||||
const completedTasks = getCompletedHabitsForDate({
|
const completedTasksCount = completedOnDate.filter(h => h.isTask).length;
|
||||||
habits: habits.filter(h => h.isTask),
|
|
||||||
date: t2d({ timestamp: date, timezone: settings.system.timezone }),
|
|
||||||
timezone: settings.system.timezone
|
|
||||||
});
|
|
||||||
return {
|
return {
|
||||||
date,
|
date,
|
||||||
habits: completedHabits.length,
|
habits: completedHabitsCount,
|
||||||
tasks: completedTasks.length
|
tasks: completedTasksCount
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,13 +1,14 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { useState } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
import { useAtom } from 'jotai'
|
import { useAtom } from 'jotai'
|
||||||
import { coinsAtom, settingsAtom, browserSettingsAtom } from '@/lib/atoms'
|
import { coinsAtom, settingsAtom, browserSettingsAtom } from '@/lib/atoms'
|
||||||
import { useCoins } from '@/hooks/useCoins'
|
import { useCoins } from '@/hooks/useCoins'
|
||||||
import { FormattedNumber } from '@/components/FormattedNumber'
|
import { FormattedNumber } from '@/components/FormattedNumber'
|
||||||
import { Bell, Menu, Settings, User, Info, Coins } from 'lucide-react'
|
import { Menu, Settings, User, Info, Coins } from 'lucide-react'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import { Logo } from '@/components/Logo'
|
import { Logo } from '@/components/Logo'
|
||||||
|
import NotificationBell from './NotificationBell'
|
||||||
import {
|
import {
|
||||||
DropdownMenu,
|
DropdownMenu,
|
||||||
DropdownMenuContent,
|
DropdownMenuContent,
|
||||||
@@ -19,6 +20,7 @@ import AboutModal from './AboutModal'
|
|||||||
import Link from 'next/link'
|
import Link from 'next/link'
|
||||||
import dynamic from 'next/dynamic'
|
import dynamic from 'next/dynamic'
|
||||||
import { Profile } from './Profile'
|
import { Profile } from './Profile'
|
||||||
|
import { useHelpers } from '@/lib/client-helpers'
|
||||||
|
|
||||||
interface HeaderProps {
|
interface HeaderProps {
|
||||||
className?: string
|
className?: string
|
||||||
@@ -52,9 +54,7 @@ export default function Header({ className }: HeaderProps) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Link>
|
</Link>
|
||||||
<Button variant="ghost" size="icon" aria-label="Notifications">
|
<NotificationBell />
|
||||||
<Bell className="h-5 w-5" />
|
|
||||||
</Button>
|
|
||||||
<Profile />
|
<Profile />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
133
components/NotificationBell.tsx
Normal file
133
components/NotificationBell.tsx
Normal file
@@ -0,0 +1,133 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useMemo } from 'react'
|
||||||
|
import { useAtom } from 'jotai'
|
||||||
|
import { coinsAtom, habitsAtom, wishlistAtom, usersAtom } from '@/lib/atoms'
|
||||||
|
import { Bell } from 'lucide-react';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
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 { useHelpers } from '@/lib/client-helpers';
|
||||||
|
import { User, CoinTransaction } from '@/lib/types';
|
||||||
|
|
||||||
|
export default function NotificationBell() {
|
||||||
|
const { currentUser } = useHelpers();
|
||||||
|
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("Failed to update notification read timestamp:", 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 useHelpers can return undefined initially
|
||||||
|
unreadNotifications={unreadNotifications}
|
||||||
|
displayedReadNotifications={displayedReadNotifications}
|
||||||
|
habitsData={habitsData} // Pass necessary data down
|
||||||
|
wishlistData={wishlistData}
|
||||||
|
usersData={usersData}
|
||||||
|
/>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
);
|
||||||
|
}
|
||||||
135
components/NotificationDropdown.tsx
Normal file
135
components/NotificationDropdown.tsx
Normal file
@@ -0,0 +1,135 @@
|
|||||||
|
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 {
|
||||||
|
Tooltip,
|
||||||
|
TooltipContent,
|
||||||
|
TooltipProvider,
|
||||||
|
TooltipTrigger,
|
||||||
|
} from '@/components/ui/tooltip';
|
||||||
|
|
||||||
|
interface NotificationDropdownProps {
|
||||||
|
currentUser: User | null;
|
||||||
|
unreadNotifications: CoinTransaction[];
|
||||||
|
displayedReadNotifications: CoinTransaction[];
|
||||||
|
habitsData: HabitsData; // Keep needed props
|
||||||
|
wishlistData: WishlistData;
|
||||||
|
usersData: UserData;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper function to generate notification message
|
||||||
|
const getNotificationMessage = (tx: CoinTransaction, triggeringUser?: User, relatedItemName?: string): string => {
|
||||||
|
const username = triggeringUser?.username || 'Someone';
|
||||||
|
const itemName = relatedItemName || 'a shared item';
|
||||||
|
switch (tx.type) {
|
||||||
|
case 'HABIT_COMPLETION':
|
||||||
|
case 'TASK_COMPLETION':
|
||||||
|
return `${username} completed ${itemName}.`;
|
||||||
|
case 'WISH_REDEMPTION':
|
||||||
|
return `${username} redeemed ${itemName}.`;
|
||||||
|
// Add other relevant transaction types if needed
|
||||||
|
default:
|
||||||
|
return `Activity related to ${itemName} by ${username}.`; // Fallback message
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 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) {
|
||||||
|
if (!currentUser) {
|
||||||
|
return <div className="p-4 text-sm text-gray-500">Not logged in.</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Removed the useMemo block for calculating notifications
|
||||||
|
|
||||||
|
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);
|
||||||
|
const txTimestamp = t2d({ timestamp: tx.timestamp, timezone: 'UTC' });
|
||||||
|
const timeAgo = txTimestamp.toRelative(); // e.g., "2 hours ago"
|
||||||
|
// Add the triggering user's ID to the query params if it exists
|
||||||
|
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">Notifications</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">
|
||||||
|
Shows completions or redemptions by other users for habits or wishlist that you shared with them (you must be admin)
|
||||||
|
</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">No notifications yet.</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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -8,8 +8,8 @@ import { Label } from './ui/label';
|
|||||||
import { Switch } from './ui/switch';
|
import { Switch } from './ui/switch';
|
||||||
import { Permission } from '@/lib/types';
|
import { Permission } from '@/lib/types';
|
||||||
import { toast } from '@/hooks/use-toast';
|
import { toast } from '@/hooks/use-toast';
|
||||||
import { useAtom } from 'jotai';
|
import { useAtom, useAtomValue } from 'jotai';
|
||||||
import { usersAtom } from '@/lib/atoms';
|
import { serverSettingsAtom, usersAtom } from '@/lib/atoms';
|
||||||
import { createUser, updateUser, updateUserPassword, uploadAvatar } from '@/app/actions/data';
|
import { createUser, updateUser, updateUserPassword, uploadAvatar } from '@/app/actions/data';
|
||||||
import { SafeUser, User } from '@/lib/types';
|
import { SafeUser, User } from '@/lib/types';
|
||||||
import { Avatar, AvatarFallback, AvatarImage } from './ui/avatar';
|
import { Avatar, AvatarFallback, AvatarImage } from './ui/avatar';
|
||||||
@@ -26,6 +26,7 @@ interface UserFormProps {
|
|||||||
|
|
||||||
export default function UserForm({ userId, onCancel, onSuccess }: UserFormProps) {
|
export default function UserForm({ userId, onCancel, onSuccess }: UserFormProps) {
|
||||||
const [users, setUsersData] = useAtom(usersAtom);
|
const [users, setUsersData] = useAtom(usersAtom);
|
||||||
|
const serverSettings = useAtomValue(serverSettingsAtom)
|
||||||
const user = userId ? users.users.find(u => u.id === userId) : undefined;
|
const user = userId ? users.users.find(u => u.id === userId) : undefined;
|
||||||
const { currentUser } = useHelpers()
|
const { currentUser } = useHelpers()
|
||||||
const getDefaultPermissions = (): Permission[] => [{
|
const getDefaultPermissions = (): Permission[] => [{
|
||||||
@@ -46,7 +47,7 @@ export default function UserForm({ userId, onCancel, onSuccess }: UserFormProps)
|
|||||||
const [avatarPath, setAvatarPath] = useState(user?.avatarPath)
|
const [avatarPath, setAvatarPath] = useState(user?.avatarPath)
|
||||||
const [username, setUsername] = useState(user?.username || '');
|
const [username, setUsername] = useState(user?.username || '');
|
||||||
const [password, setPassword] = useState<string | undefined>('');
|
const [password, setPassword] = useState<string | undefined>('');
|
||||||
const [disablePassword, setDisablePassword] = useState(user?.password === '' || process.env.NEXT_PUBLIC_DEMO === 'true');
|
const [disablePassword, setDisablePassword] = useState(user?.password === '' || serverSettings.isDemo);
|
||||||
const [error, setError] = useState('');
|
const [error, setError] = useState('');
|
||||||
const [avatarFile, setAvatarFile] = useState<File | null>(null);
|
const [avatarFile, setAvatarFile] = useState<File | null>(null);
|
||||||
const [isAdmin, setIsAdmin] = useState(user?.isAdmin || false);
|
const [isAdmin, setIsAdmin] = useState(user?.isAdmin || false);
|
||||||
@@ -240,7 +241,7 @@ export default function UserForm({ userId, onCancel, onSuccess }: UserFormProps)
|
|||||||
className={error ? 'border-red-500' : ''}
|
className={error ? 'border-red-500' : ''}
|
||||||
disabled={disablePassword}
|
disabled={disablePassword}
|
||||||
/>
|
/>
|
||||||
{process.env.NEXT_PUBLIC_DEMO === 'true' && (
|
{serverSettings.isDemo && (
|
||||||
<p className="text-sm text-red-500">Password is automatically disabled in demo instance</p>
|
<p className="text-sm text-red-500">Password is automatically disabled in demo instance</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -250,6 +251,7 @@ export default function UserForm({ userId, onCancel, onSuccess }: UserFormProps)
|
|||||||
id="disable-password"
|
id="disable-password"
|
||||||
checked={disablePassword}
|
checked={disablePassword}
|
||||||
onCheckedChange={setDisablePassword}
|
onCheckedChange={setDisablePassword}
|
||||||
|
disabled={serverSettings.isDemo}
|
||||||
/>
|
/>
|
||||||
<Label htmlFor="disable-password">Disable password</Label>
|
<Label htmlFor="disable-password">Disable password</Label>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -99,7 +99,7 @@ function UserSelectionView({
|
|||||||
onCreateUser: () => void
|
onCreateUser: () => void
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<div className="grid grid-cols-3 gap-4 p-2">
|
<div className="grid grid-cols-3 gap-4 p-2 max-h-80 overflow-y-auto">
|
||||||
{users
|
{users
|
||||||
.filter(user => user.id !== currentUser?.id)
|
.filter(user => user.id !== currentUser?.id)
|
||||||
.map((user) => (
|
.map((user) => (
|
||||||
|
|||||||
@@ -88,9 +88,9 @@ export default function WishlistManager() {
|
|||||||
<Plus className="mr-2 h-4 w-4" /> Add Reward
|
<Plus className="mr-2 h-4 w-4" /> Add Reward
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 items-stretch">
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4 items-stretch">
|
||||||
{activeItems.length === 0 ? (
|
{activeItems.length === 0 ? (
|
||||||
<div className="col-span-2">
|
<div className="col-span-1 lg:col-span-2">
|
||||||
<EmptyState
|
<EmptyState
|
||||||
icon={Gift}
|
icon={Gift}
|
||||||
title="Your wishlist is empty"
|
title="Your wishlist is empty"
|
||||||
@@ -127,7 +127,7 @@ export default function WishlistManager() {
|
|||||||
|
|
||||||
{archivedItems.length > 0 && (
|
{archivedItems.length > 0 && (
|
||||||
<>
|
<>
|
||||||
<div className="col-span-2 relative flex items-center my-6">
|
<div className="col-span-1 lg:col-span-2 relative flex items-center my-6">
|
||||||
<div className="flex-grow border-t border-gray-300 dark:border-gray-600" />
|
<div className="flex-grow border-t border-gray-300 dark:border-gray-600" />
|
||||||
<span className="mx-4 text-sm text-gray-500 dark:text-gray-400">Archived</span>
|
<span className="mx-4 text-sm text-gray-500 dark:text-gray-400">Archived</span>
|
||||||
<div className="flex-grow border-t border-gray-300 dark:border-gray-600" />
|
<div className="flex-grow border-t border-gray-300 dark:border-gray-600" />
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { settingsAtom, habitsAtom, coinsAtom, wishlistAtom, usersAtom } from "@/lib/atoms"
|
import { settingsAtom, habitsAtom, coinsAtom, wishlistAtom, usersAtom, serverSettingsAtom } from "@/lib/atoms"
|
||||||
import { useHydrateAtoms } from "jotai/utils"
|
import { useHydrateAtoms } from "jotai/utils"
|
||||||
import { JotaiHydrateInitialValues } from "@/lib/types"
|
import { JotaiHydrateInitialValues } from "@/lib/types"
|
||||||
|
|
||||||
@@ -13,7 +13,8 @@ export function JotaiHydrate({
|
|||||||
[habitsAtom, initialValues.habits],
|
[habitsAtom, initialValues.habits],
|
||||||
[coinsAtom, initialValues.coins],
|
[coinsAtom, initialValues.coins],
|
||||||
[wishlistAtom, initialValues.wishlist],
|
[wishlistAtom, initialValues.wishlist],
|
||||||
[usersAtom, initialValues.users]
|
[usersAtom, initialValues.users],
|
||||||
|
[serverSettingsAtom, initialValues.serverSettings]
|
||||||
])
|
])
|
||||||
return children
|
return children
|
||||||
}
|
}
|
||||||
|
|||||||
48
components/ui/scroll-area.tsx
Normal file
48
components/ui/scroll-area.tsx
Normal 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 }
|
||||||
31
components/ui/separator.tsx
Normal file
31
components/ui/separator.tsx
Normal 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 }
|
||||||
@@ -3,7 +3,8 @@ services:
|
|||||||
ports:
|
ports:
|
||||||
- "3000:3000"
|
- "3000:3000"
|
||||||
volumes:
|
volumes:
|
||||||
- "./data:/app/data" # Use a relative path instead of $(pwd)
|
- "./data:/app/data"
|
||||||
|
- "./backups:/app/backups"
|
||||||
image: dohsimpson/habittrove
|
image: dohsimpson/habittrove
|
||||||
environment:
|
environment:
|
||||||
- AUTH_SECRET=your-secret-key-here
|
- AUTH_SECRET=your-secret-key-here # Replace with your actual secret
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useAtom } from 'jotai'
|
import { useAtom } from 'jotai'
|
||||||
import { checkPermission } from '@/lib/utils'
|
import { calculateCoinsEarnedToday, calculateCoinsSpentToday, calculateTotalEarned, calculateTotalSpent, calculateTransactionsToday, checkPermission } from '@/lib/utils'
|
||||||
import {
|
import {
|
||||||
coinsAtom,
|
coinsAtom,
|
||||||
coinsEarnedTodayAtom,
|
coinsEarnedTodayAtom,
|
||||||
@@ -7,15 +7,17 @@ import {
|
|||||||
totalSpentAtom,
|
totalSpentAtom,
|
||||||
coinsSpentTodayAtom,
|
coinsSpentTodayAtom,
|
||||||
transactionsTodayAtom,
|
transactionsTodayAtom,
|
||||||
coinsBalanceAtom
|
coinsBalanceAtom,
|
||||||
|
settingsAtom,
|
||||||
|
usersAtom,
|
||||||
} from '@/lib/atoms'
|
} from '@/lib/atoms'
|
||||||
import { addCoins, removeCoins, saveCoinsData } from '@/app/actions/data'
|
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 { toast } from '@/hooks/use-toast'
|
||||||
import { useHelpers } from '@/lib/client-helpers'
|
import { useHelpers } from '@/lib/client-helpers'
|
||||||
|
|
||||||
function handlePermissionCheck(
|
function handlePermissionCheck(
|
||||||
user: any,
|
user: User | undefined,
|
||||||
resource: 'habit' | 'wishlist' | 'coins',
|
resource: 'habit' | 'wishlist' | 'coins',
|
||||||
action: 'write' | 'interact'
|
action: 'write' | 'interact'
|
||||||
): boolean {
|
): boolean {
|
||||||
@@ -27,7 +29,7 @@ function handlePermissionCheck(
|
|||||||
})
|
})
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!user.isAdmin && !checkPermission(user.permissions, resource, action)) {
|
if (!user.isAdmin && !checkPermission(user.permissions, resource, action)) {
|
||||||
toast({
|
toast({
|
||||||
title: "Permission Denied",
|
title: "Permission Denied",
|
||||||
@@ -36,22 +38,34 @@ function handlePermissionCheck(
|
|||||||
})
|
})
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useCoins() {
|
export function useCoins(options?: { selectedUser?: string }) {
|
||||||
const { currentUser: user } = useHelpers()
|
|
||||||
const [coins, setCoins] = useAtom(coinsAtom)
|
const [coins, setCoins] = useAtom(coinsAtom)
|
||||||
|
const [settings] = useAtom(settingsAtom)
|
||||||
|
const [users] = useAtom(usersAtom)
|
||||||
|
const { currentUser } = useHelpers()
|
||||||
|
let user: User | undefined;
|
||||||
|
if (!options?.selectedUser) {
|
||||||
|
user = currentUser;
|
||||||
|
} else {
|
||||||
|
user = users.users.find(u => u.id === options.selectedUser)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Filter transactions for the selectd user
|
||||||
|
const transactions = coins.transactions.filter(t => t.userId === user?.id)
|
||||||
|
|
||||||
|
const [balance] = useAtom(coinsBalanceAtom)
|
||||||
const [coinsEarnedToday] = useAtom(coinsEarnedTodayAtom)
|
const [coinsEarnedToday] = useAtom(coinsEarnedTodayAtom)
|
||||||
const [totalEarned] = useAtom(totalEarnedAtom)
|
const [totalEarned] = useAtom(totalEarnedAtom)
|
||||||
const [totalSpent] = useAtom(totalSpentAtom)
|
const [totalSpent] = useAtom(totalSpentAtom)
|
||||||
const [coinsSpentToday] = useAtom(coinsSpentTodayAtom)
|
const [coinsSpentToday] = useAtom(coinsSpentTodayAtom)
|
||||||
const [transactionsToday] = useAtom(transactionsTodayAtom)
|
const [transactionsToday] = useAtom(transactionsTodayAtom)
|
||||||
const [balance] = useAtom(coinsBalanceAtom)
|
|
||||||
|
|
||||||
const add = async (amount: number, description: string, note?: string) => {
|
const add = async (amount: number, description: string, note?: string) => {
|
||||||
if (!handlePermissionCheck(user, 'coins', 'write')) return null
|
if (!handlePermissionCheck(currentUser, 'coins', 'write')) return null
|
||||||
if (isNaN(amount) || amount <= 0) {
|
if (isNaN(amount) || amount <= 0) {
|
||||||
toast({
|
toast({
|
||||||
title: "Invalid amount",
|
title: "Invalid amount",
|
||||||
@@ -64,7 +78,8 @@ export function useCoins() {
|
|||||||
amount,
|
amount,
|
||||||
description,
|
description,
|
||||||
type: 'MANUAL_ADJUSTMENT',
|
type: 'MANUAL_ADJUSTMENT',
|
||||||
note
|
note,
|
||||||
|
userId: user?.id
|
||||||
})
|
})
|
||||||
setCoins(data)
|
setCoins(data)
|
||||||
toast({ title: "Success", description: `Added ${amount} coins` })
|
toast({ title: "Success", description: `Added ${amount} coins` })
|
||||||
@@ -72,7 +87,7 @@ export function useCoins() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const remove = async (amount: number, description: string, note?: string) => {
|
const remove = async (amount: number, description: string, note?: string) => {
|
||||||
if (!handlePermissionCheck(user, 'coins', 'write')) return null
|
if (!handlePermissionCheck(currentUser, 'coins', 'write')) return null
|
||||||
const numAmount = Math.abs(amount)
|
const numAmount = Math.abs(amount)
|
||||||
if (isNaN(numAmount) || numAmount <= 0) {
|
if (isNaN(numAmount) || numAmount <= 0) {
|
||||||
toast({
|
toast({
|
||||||
@@ -86,7 +101,8 @@ export function useCoins() {
|
|||||||
amount: numAmount,
|
amount: numAmount,
|
||||||
description,
|
description,
|
||||||
type: 'MANUAL_ADJUSTMENT',
|
type: 'MANUAL_ADJUSTMENT',
|
||||||
note
|
note,
|
||||||
|
userId: user?.id
|
||||||
})
|
})
|
||||||
setCoins(data)
|
setCoins(data)
|
||||||
toast({ title: "Success", description: `Removed ${numAmount} coins` })
|
toast({ title: "Success", description: `Removed ${numAmount} coins` })
|
||||||
@@ -94,7 +110,7 @@ export function useCoins() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const updateNote = async (transactionId: string, note: string) => {
|
const updateNote = async (transactionId: string, note: string) => {
|
||||||
if (!handlePermissionCheck(user, 'coins', 'write')) return null
|
if (!handlePermissionCheck(currentUser, 'coins', 'write')) return null
|
||||||
const transaction = coins.transactions.find(t => t.id === transactionId)
|
const transaction = coins.transactions.find(t => t.id === transactionId)
|
||||||
if (!transaction) {
|
if (!transaction) {
|
||||||
toast({
|
toast({
|
||||||
@@ -128,7 +144,7 @@ export function useCoins() {
|
|||||||
remove,
|
remove,
|
||||||
updateNote,
|
updateNote,
|
||||||
balance,
|
balance,
|
||||||
transactions: coins.transactions,
|
transactions: transactions,
|
||||||
coinsEarnedToday,
|
coinsEarnedToday,
|
||||||
totalEarned,
|
totalEarned,
|
||||||
totalSpent,
|
totalSpent,
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useAtom } from 'jotai'
|
import { useAtom, atom } from 'jotai'
|
||||||
import { habitsAtom, coinsAtom, settingsAtom, usersAtom } from '@/lib/atoms'
|
import { habitsAtom, coinsAtom, settingsAtom, usersAtom, habitFreqMapAtom } from '@/lib/atoms'
|
||||||
import { addCoins, removeCoins, saveHabitsData } from '@/app/actions/data'
|
import { addCoins, removeCoins, saveHabitsData } from '@/app/actions/data'
|
||||||
import { Habit, Permission, SafeUser, User } from '@/lib/types'
|
import { Habit, Permission, SafeUser, User } from '@/lib/types'
|
||||||
import { toast } from '@/hooks/use-toast'
|
import { toast } from '@/hooks/use-toast'
|
||||||
@@ -34,7 +34,7 @@ function handlePermissionCheck(
|
|||||||
})
|
})
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!user.isAdmin && !checkPermission(user.permissions, resource, action)) {
|
if (!user.isAdmin && !checkPermission(user.permissions, resource, action)) {
|
||||||
toast({
|
toast({
|
||||||
title: "Permission Denied",
|
title: "Permission Denied",
|
||||||
@@ -43,7 +43,7 @@ function handlePermissionCheck(
|
|||||||
})
|
})
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -54,6 +54,7 @@ export function useHabits() {
|
|||||||
const [habitsData, setHabitsData] = useAtom(habitsAtom)
|
const [habitsData, setHabitsData] = useAtom(habitsAtom)
|
||||||
const [coins, setCoins] = useAtom(coinsAtom)
|
const [coins, setCoins] = useAtom(coinsAtom)
|
||||||
const [settings] = useAtom(settingsAtom)
|
const [settings] = useAtom(settingsAtom)
|
||||||
|
const [habitFreqMap] = useAtom(habitFreqMapAtom)
|
||||||
|
|
||||||
const completeHabit = async (habit: Habit) => {
|
const completeHabit = async (habit: Habit) => {
|
||||||
if (!handlePermissionCheck(currentUser, 'habit', 'interact')) return
|
if (!handlePermissionCheck(currentUser, 'habit', 'interact')) return
|
||||||
@@ -313,6 +314,7 @@ export function useHabits() {
|
|||||||
deleteHabit,
|
deleteHabit,
|
||||||
completePastHabit,
|
completePastHabit,
|
||||||
archiveHabit,
|
archiveHabit,
|
||||||
unarchiveHabit
|
unarchiveHabit,
|
||||||
|
habitFreqMap,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,11 +1,12 @@
|
|||||||
import { useAtom } from 'jotai'
|
import { useAtom } from 'jotai'
|
||||||
import { wishlistAtom, coinsAtom, coinsBalanceAtom } from '@/lib/atoms'
|
import { wishlistAtom, coinsAtom } from '@/lib/atoms'
|
||||||
import { saveWishlistItems, removeCoins } from '@/app/actions/data'
|
import { saveWishlistItems, removeCoins } from '@/app/actions/data'
|
||||||
import { toast } from '@/hooks/use-toast'
|
import { toast } from '@/hooks/use-toast'
|
||||||
import { WishlistItemType } from '@/lib/types'
|
import { WishlistItemType } from '@/lib/types'
|
||||||
import { celebrations } from '@/utils/celebrations'
|
import { celebrations } from '@/utils/celebrations'
|
||||||
import { checkPermission } from '@/lib/utils'
|
import { checkPermission } from '@/lib/utils'
|
||||||
import { useHelpers } from '@/lib/client-helpers'
|
import { useHelpers } from '@/lib/client-helpers'
|
||||||
|
import { useCoins } from './useCoins'
|
||||||
|
|
||||||
function handlePermissionCheck(
|
function handlePermissionCheck(
|
||||||
user: any,
|
user: any,
|
||||||
@@ -37,7 +38,7 @@ export function useWishlist() {
|
|||||||
const { currentUser: user } = useHelpers()
|
const { currentUser: user } = useHelpers()
|
||||||
const [wishlist, setWishlist] = useAtom(wishlistAtom)
|
const [wishlist, setWishlist] = useAtom(wishlistAtom)
|
||||||
const [coins, setCoins] = useAtom(coinsAtom)
|
const [coins, setCoins] = useAtom(coinsAtom)
|
||||||
const [balance] = useAtom(coinsBalanceAtom)
|
const { balance } = useCoins()
|
||||||
|
|
||||||
const addWishlistItem = async (item: Omit<WishlistItemType, 'id'>) => {
|
const addWishlistItem = async (item: Omit<WishlistItemType, 'id'>) => {
|
||||||
if (!handlePermissionCheck(user, 'wishlist', 'write')) return
|
if (!handlePermissionCheck(user, 'wishlist', 'write')) return
|
||||||
|
|||||||
@@ -1,7 +1,28 @@
|
|||||||
import { init } from '@/lib/env.server' // startup env var check
|
import { init } from '@/lib/env.server'; // startup env var check
|
||||||
|
|
||||||
export function register() {
|
// Ensure this function is exported
|
||||||
if (typeof window === "undefined") {
|
export async function register() {
|
||||||
init()
|
// 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.`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
26
lib/atoms.ts
26
lib/atoms.ts
@@ -8,6 +8,8 @@ import {
|
|||||||
ViewType,
|
ViewType,
|
||||||
getDefaultUsersData,
|
getDefaultUsersData,
|
||||||
CompletionCache,
|
CompletionCache,
|
||||||
|
getDefaultServerSettings,
|
||||||
|
User,
|
||||||
} from "./types";
|
} from "./types";
|
||||||
import {
|
import {
|
||||||
getTodayInTimezone,
|
getTodayInTimezone,
|
||||||
@@ -22,10 +24,12 @@ import {
|
|||||||
getISODate,
|
getISODate,
|
||||||
isHabitDueToday,
|
isHabitDueToday,
|
||||||
getNow,
|
getNow,
|
||||||
isHabitDue
|
isHabitDue,
|
||||||
|
getHabitFreq
|
||||||
} from "@/lib/utils";
|
} from "@/lib/utils";
|
||||||
import { atomFamily, atomWithStorage } from "jotai/utils";
|
import { atomFamily, atomWithStorage } from "jotai/utils";
|
||||||
import { DateTime } from "luxon";
|
import { DateTime } from "luxon";
|
||||||
|
import { Freq } from "./types";
|
||||||
|
|
||||||
export interface BrowserSettings {
|
export interface BrowserSettings {
|
||||||
viewType: ViewType
|
viewType: ViewType
|
||||||
@@ -46,6 +50,7 @@ export const settingsAtom = atom(getDefaultSettings());
|
|||||||
export const habitsAtom = atom(getDefaultHabitsData());
|
export const habitsAtom = atom(getDefaultHabitsData());
|
||||||
export const coinsAtom = atom(getDefaultCoinsData());
|
export const coinsAtom = atom(getDefaultCoinsData());
|
||||||
export const wishlistAtom = atom(getDefaultWishlistData());
|
export const wishlistAtom = atom(getDefaultWishlistData());
|
||||||
|
export const serverSettingsAtom = atom(getDefaultServerSettings());
|
||||||
|
|
||||||
// Derived atom for coins earned today
|
// Derived atom for coins earned today
|
||||||
export const coinsEarnedTodayAtom = atom((get) => {
|
export const coinsEarnedTodayAtom = atom((get) => {
|
||||||
@@ -108,16 +113,16 @@ export const completionCacheAtom = atom((get) => {
|
|||||||
const habits = get(habitsAtom).habits;
|
const habits = get(habitsAtom).habits;
|
||||||
const timezone = get(settingsAtom).system.timezone;
|
const timezone = get(settingsAtom).system.timezone;
|
||||||
const cache: CompletionCache = {};
|
const cache: CompletionCache = {};
|
||||||
|
|
||||||
habits.forEach(habit => {
|
habits.forEach(habit => {
|
||||||
habit.completions.forEach(utcTimestamp => {
|
habit.completions.forEach(utcTimestamp => {
|
||||||
const localDate = t2d({ timestamp: utcTimestamp, timezone })
|
const localDate = t2d({ timestamp: utcTimestamp, timezone })
|
||||||
.toFormat('yyyy-MM-dd');
|
.toFormat('yyyy-MM-dd');
|
||||||
|
|
||||||
if (!cache[localDate]) {
|
if (!cache[localDate]) {
|
||||||
cache[localDate] = {};
|
cache[localDate] = {};
|
||||||
}
|
}
|
||||||
|
|
||||||
cache[localDate][habit.id] = (cache[localDate][habit.id] || 0) + 1;
|
cache[localDate][habit.id] = (cache[localDate][habit.id] || 0) + 1;
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -147,6 +152,15 @@ export const completedHabitsMapAtom = atom((get) => {
|
|||||||
return map;
|
return map;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Derived atom for habit frequency map
|
||||||
|
export const habitFreqMapAtom = atom((get) => {
|
||||||
|
const habits = get(habitsAtom).habits;
|
||||||
|
const map = new Map<string, Freq>();
|
||||||
|
habits.forEach(habit => {
|
||||||
|
map.set(habit.id, getHabitFreq(habit));
|
||||||
|
});
|
||||||
|
return map;
|
||||||
|
});
|
||||||
|
|
||||||
export const pomodoroTodayCompletionsAtom = atom((get) => {
|
export const pomodoroTodayCompletionsAtom = atom((get) => {
|
||||||
const pomo = get(pomodoroAtom)
|
const pomo = get(pomodoroAtom)
|
||||||
@@ -171,12 +185,12 @@ export const hasTasksAtom = atom((get) => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
// Atom family for habits by specific date
|
// Atom family for habits by specific date
|
||||||
export const habitsByDateFamily = atomFamily((dateString: string) =>
|
export const habitsByDateFamily = atomFamily((dateString: string) =>
|
||||||
atom((get) => {
|
atom((get) => {
|
||||||
const habits = get(habitsAtom).habits;
|
const habits = get(habitsAtom).habits;
|
||||||
const settings = get(settingsAtom);
|
const settings = get(settingsAtom);
|
||||||
const timezone = settings.system.timezone;
|
const timezone = settings.system.timezone;
|
||||||
|
|
||||||
const date = DateTime.fromISO(dateString).setZone(timezone);
|
const date = DateTime.fromISO(dateString).setZone(timezone);
|
||||||
return habits.filter(habit => isHabitDue({ habit, timezone, date }));
|
return habits.filter(habit => isHabitDue({ habit, timezone, date }));
|
||||||
})
|
})
|
||||||
|
|||||||
143
lib/backup.ts
Normal file
143
lib/backup.ts
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,9 +1,8 @@
|
|||||||
// client helpers
|
// client helpers
|
||||||
'use-client'
|
'use-client'
|
||||||
|
|
||||||
import { useSession } from "next-auth/react"
|
|
||||||
import { User, UserId } from './types'
|
|
||||||
import { useAtom } from 'jotai'
|
import { useAtom } from 'jotai'
|
||||||
|
import { useSession } from "next-auth/react"
|
||||||
import { usersAtom } from './atoms'
|
import { usersAtom } from './atoms'
|
||||||
import { checkPermission } from './utils'
|
import { checkPermission } from './utils'
|
||||||
|
|
||||||
@@ -14,7 +13,7 @@ export function useHelpers() {
|
|||||||
const currentUser = usersData.users.find((u) => u.id === currentUserId)
|
const currentUser = usersData.users.find((u) => u.id === currentUserId)
|
||||||
// detect iOS: https://stackoverflow.com/a/9039885
|
// detect iOS: https://stackoverflow.com/a/9039885
|
||||||
function iOS() {
|
function iOS() {
|
||||||
return [
|
return typeof navigator !== "undefined" && ([
|
||||||
'iPad Simulator',
|
'iPad Simulator',
|
||||||
'iPhone Simulator',
|
'iPhone Simulator',
|
||||||
'iPod Simulator',
|
'iPod Simulator',
|
||||||
@@ -23,7 +22,7 @@ export function useHelpers() {
|
|||||||
'iPod',
|
'iPod',
|
||||||
].includes(navigator.platform)
|
].includes(navigator.platform)
|
||||||
// iPad on iOS 13 detection
|
// iPad on iOS 13 detection
|
||||||
|| (navigator.userAgent.includes("Mac") && "ontouchend" in document)
|
|| (navigator.userAgent.includes("Mac") && "ontouchend" in document))
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { CheckSquare, Target } from "lucide-react"
|
import { CheckSquare, Target } from "lucide-react"
|
||||||
|
|
||||||
export const INITIAL_RECURRENCE_RULE = 'daily'
|
export const INITIAL_RECURRENCE_RULE = 'every day'
|
||||||
export const INITIAL_DUE = 'today'
|
export const INITIAL_DUE = 'today'
|
||||||
|
|
||||||
export const RECURRENCE_RULE_MAP: { [key: string]: string } = {
|
export const RECURRENCE_RULE_MAP: { [key: string]: string } = {
|
||||||
|
|||||||
@@ -2,13 +2,13 @@ import { z } from "zod"
|
|||||||
|
|
||||||
const zodEnv = z.object({
|
const zodEnv = z.object({
|
||||||
AUTH_SECRET: z.string(),
|
AUTH_SECRET: z.string(),
|
||||||
NEXT_PUBLIC_DEMO: z.string().optional(),
|
DEMO: z.string().optional(),
|
||||||
})
|
})
|
||||||
|
|
||||||
declare global {
|
declare global {
|
||||||
interface ProcessEnv extends z.TypeOf<typeof zodEnv> {
|
interface ProcessEnv extends z.TypeOf<typeof zodEnv> {
|
||||||
AUTH_SECRET: string;
|
AUTH_SECRET: string;
|
||||||
NEXT_PUBLIC_DEMO?: string;
|
DEMO?: string;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -24,10 +24,9 @@ export function init() {
|
|||||||
)
|
)
|
||||||
.join("\n ")
|
.join("\n ")
|
||||||
|
|
||||||
console.error(
|
throw new Error(
|
||||||
`Missing environment variables:\n ${errorMessage}`,
|
`Missing environment variables:\n ${errorMessage}`,
|
||||||
)
|
)
|
||||||
process.exit(1)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
54
lib/scheduler.ts
Normal file
54
lib/scheduler.ts
Normal 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');
|
||||||
|
// });
|
||||||
|
}
|
||||||
30
lib/types.ts
30
lib/types.ts
@@ -1,4 +1,6 @@
|
|||||||
|
import { RRule } from "rrule"
|
||||||
import { uuid } from "./utils"
|
import { uuid } from "./utils"
|
||||||
|
import { DateTime } from "luxon"
|
||||||
|
|
||||||
export type UserId = string
|
export type UserId = string
|
||||||
|
|
||||||
@@ -29,7 +31,8 @@ export type SafeUser = SessionUser & {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export type User = SafeUser & {
|
export type User = SafeUser & {
|
||||||
password: string
|
password?: string // Optional: Allow users without passwords (e.g., initial setup)
|
||||||
|
lastNotificationReadTimestamp?: string // UTC ISO date string
|
||||||
}
|
}
|
||||||
|
|
||||||
export type Habit = {
|
export type Habit = {
|
||||||
@@ -42,6 +45,7 @@ export type Habit = {
|
|||||||
completions: string[] // Array of UTC ISO date strings
|
completions: string[] // Array of UTC ISO date strings
|
||||||
isTask?: boolean // mark the habit as a task
|
isTask?: boolean // mark the habit as a task
|
||||||
archived?: boolean // mark the habit as archived
|
archived?: boolean // mark the habit as archived
|
||||||
|
pinned?: boolean // mark the habit as pinned
|
||||||
userIds?: UserId[]
|
userIds?: UserId[]
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -98,8 +102,9 @@ export const getDefaultUsersData = (): UserData => ({
|
|||||||
{
|
{
|
||||||
id: uuid(),
|
id: uuid(),
|
||||||
username: 'admin',
|
username: 'admin',
|
||||||
password: '',
|
// password: '', // No default password for admin initially? Or set a secure default?
|
||||||
isAdmin: true,
|
isAdmin: true,
|
||||||
|
lastNotificationReadTimestamp: undefined, // Initialize as undefined
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
});
|
});
|
||||||
@@ -125,11 +130,16 @@ export const getDefaultSettings = (): Settings => ({
|
|||||||
},
|
},
|
||||||
system: {
|
system: {
|
||||||
timezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
|
timezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
|
||||||
weekStartDay: 1 // Monday
|
weekStartDay: 1, // Monday
|
||||||
|
autoBackupEnabled: true, // Add this line (default to true)
|
||||||
},
|
},
|
||||||
profile: {}
|
profile: {}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export const getDefaultServerSettings = (): ServerSettings => ({
|
||||||
|
isDemo: false
|
||||||
|
})
|
||||||
|
|
||||||
// Map of data types to their default values
|
// Map of data types to their default values
|
||||||
export const DATA_DEFAULTS = {
|
export const DATA_DEFAULTS = {
|
||||||
wishlist: getDefaultWishlistData,
|
wishlist: getDefaultWishlistData,
|
||||||
@@ -152,6 +162,7 @@ export type WeekDay = 0 | 1 | 2 | 3 | 4 | 5 | 6; // 0 = Sunday, 6 = Saturday
|
|||||||
export interface SystemSettings {
|
export interface SystemSettings {
|
||||||
timezone: string;
|
timezone: string;
|
||||||
weekStartDay: WeekDay;
|
weekStartDay: WeekDay;
|
||||||
|
autoBackupEnabled: boolean; // Add this line
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ProfileSettings {
|
export interface ProfileSettings {
|
||||||
@@ -178,4 +189,17 @@ export interface JotaiHydrateInitialValues {
|
|||||||
habits: HabitsData;
|
habits: HabitsData;
|
||||||
wishlist: WishlistData;
|
wishlist: WishlistData;
|
||||||
users: UserData;
|
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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 {
|
import {
|
||||||
cn,
|
cn,
|
||||||
getTodayInTimezone,
|
getTodayInTimezone,
|
||||||
@@ -17,12 +17,18 @@ import {
|
|||||||
isHabitDueToday,
|
isHabitDueToday,
|
||||||
isHabitDue,
|
isHabitDue,
|
||||||
uuid,
|
uuid,
|
||||||
isTaskOverdue
|
isTaskOverdue,
|
||||||
|
deserializeRRule,
|
||||||
|
serializeRRule,
|
||||||
|
convertHumanReadableFrequencyToMachineReadable,
|
||||||
|
convertMachineReadableFrequencyToHumanReadable,
|
||||||
|
getUnsupportedRRuleReason
|
||||||
} from './utils'
|
} from './utils'
|
||||||
import { CoinTransaction } from './types'
|
import { CoinTransaction, ParsedResultType } from './types'
|
||||||
import { DateTime } from "luxon";
|
import { DateTime } from "luxon";
|
||||||
import { RRule } from 'rrule';
|
import { RRule, Weekday } from 'rrule';
|
||||||
import { Habit } from '@/lib/types';
|
import { Habit } from '@/lib/types';
|
||||||
|
import { INITIAL_DUE } from './constants';
|
||||||
|
|
||||||
describe('cn utility', () => {
|
describe('cn utility', () => {
|
||||||
test('should merge class names correctly', () => {
|
test('should merge class names correctly', () => {
|
||||||
@@ -33,6 +39,59 @@ 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', () => {
|
describe('isTaskOverdue', () => {
|
||||||
const createTestHabit = (frequency: string, isTask = true, archived = false): Habit => ({
|
const createTestHabit = (frequency: string, isTask = true, archived = false): Habit => ({
|
||||||
id: 'test-habit',
|
id: 'test-habit',
|
||||||
@@ -535,13 +594,8 @@ describe('isHabitDueToday', () => {
|
|||||||
|
|
||||||
test('should return false for invalid recurrence rule', () => {
|
test('should return false for invalid recurrence rule', () => {
|
||||||
const habit = testHabit('INVALID_RRULE')
|
const habit = testHabit('INVALID_RRULE')
|
||||||
// Mock console.error to prevent test output pollution
|
const consoleSpy = spyOn(console, 'error').mockImplementation(() => {})
|
||||||
const consoleSpy = spyOn(console, 'error').mockImplementation(() => { })
|
expect(isHabitDueToday({ habit, timezone: 'UTC' })).toBe(false)
|
||||||
|
|
||||||
// Expect the function to throw an error
|
|
||||||
expect(() => isHabitDueToday({ habit, timezone: 'UTC' })).toThrow()
|
|
||||||
|
|
||||||
consoleSpy.mockRestore()
|
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -653,8 +707,252 @@ describe('isHabitDue', () => {
|
|||||||
test('should return false for invalid recurrence rule', () => {
|
test('should return false for invalid recurrence rule', () => {
|
||||||
const habit = testHabit('INVALID_RRULE')
|
const habit = testHabit('INVALID_RRULE')
|
||||||
const date = DateTime.fromISO('2024-01-01T00:00:00Z')
|
const date = DateTime.fromISO('2024-01-01T00:00:00Z')
|
||||||
const consoleSpy = spyOn(console, 'error').mockImplementation(() => { })
|
const consoleSpy = spyOn(console, 'error').mockImplementation(() => {})
|
||||||
expect(() => isHabitDue({ habit, timezone: 'UTC', date })).toThrow()
|
expect(isHabitDue({ habit, timezone: 'UTC', date })).toBe(false)
|
||||||
consoleSpy.mockRestore()
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('deserializeRRule', () => {
|
||||||
|
test('should deserialize valid RRule string', () => {
|
||||||
|
const rruleStr = 'FREQ=DAILY;INTERVAL=1'
|
||||||
|
const rrule = deserializeRRule(rruleStr)
|
||||||
|
expect(rrule).toBeInstanceOf(RRule)
|
||||||
|
expect(rrule?.origOptions.freq).toBe(RRule.DAILY)
|
||||||
|
expect(rrule?.origOptions.interval).toBe(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('should return null for invalid RRule string', () => {
|
||||||
|
const rruleStr = 'INVALID_RRULE_STRING'
|
||||||
|
const rrule = deserializeRRule(rruleStr)
|
||||||
|
expect(rrule).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('should handle complex RRule strings', () => {
|
||||||
|
const rruleStr = 'FREQ=WEEKLY;BYDAY=MO,WE,FR;INTERVAL=2;COUNT=10'
|
||||||
|
const rrule = deserializeRRule(rruleStr)
|
||||||
|
expect(rrule).toBeInstanceOf(RRule)
|
||||||
|
expect(rrule?.origOptions.freq).toBe(RRule.WEEKLY)
|
||||||
|
expect(rrule?.origOptions.byweekday).toEqual([RRule.MO, RRule.WE, RRule.FR])
|
||||||
|
expect(rrule?.origOptions.interval).toBe(2)
|
||||||
|
expect(rrule?.origOptions.count).toBe(10)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('serializeRRule', () => {
|
||||||
|
test('should serialize RRule object to string', () => {
|
||||||
|
const rrule = new RRule({
|
||||||
|
freq: RRule.DAILY,
|
||||||
|
interval: 1
|
||||||
|
})
|
||||||
|
const rruleStr = serializeRRule(rrule)
|
||||||
|
// RRule adds DTSTART automatically if not provided, so we check the core parts
|
||||||
|
expect(rruleStr).toContain('FREQ=DAILY')
|
||||||
|
expect(rruleStr).toContain('INTERVAL=1')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('should return "invalid" for null input', () => {
|
||||||
|
const rruleStr = serializeRRule(null)
|
||||||
|
expect(rruleStr).toBe('invalid')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('should serialize complex RRule objects', () => {
|
||||||
|
const rrule = new RRule({
|
||||||
|
freq: RRule.WEEKLY,
|
||||||
|
byweekday: [RRule.MO, RRule.WE, RRule.FR],
|
||||||
|
interval: 2,
|
||||||
|
count: 10
|
||||||
|
})
|
||||||
|
const rruleStr = serializeRRule(rrule)
|
||||||
|
expect(rruleStr).toContain('FREQ=WEEKLY')
|
||||||
|
expect(rruleStr).toContain('BYDAY=MO,WE,FR')
|
||||||
|
expect(rruleStr).toContain('INTERVAL=2')
|
||||||
|
expect(rruleStr).toContain('COUNT=10')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('convertHumanReadableFrequencyToMachineReadable', () => {
|
||||||
|
const timezone = 'America/New_York'
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
// Set a fixed date for consistent relative date parsing
|
||||||
|
const mockDate = DateTime.fromISO('2024-07-15T10:00:00', { zone: timezone }) as DateTime<true>
|
||||||
|
DateTime.now = () => mockDate
|
||||||
|
})
|
||||||
|
|
||||||
|
// Non-recurring tests
|
||||||
|
test('should parse specific date (non-recurring)', () => {
|
||||||
|
const { result, message } = convertHumanReadableFrequencyToMachineReadable({ text: 'July 16, 2024', timezone, isRecurring: false })
|
||||||
|
expect(message).toBeNull()
|
||||||
|
expect(result).toBeInstanceOf(DateTime)
|
||||||
|
expect((result as DateTime).toISODate()).toBe('2024-07-16')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('should parse relative date "tomorrow" (non-recurring)', () => {
|
||||||
|
const { result, message } = convertHumanReadableFrequencyToMachineReadable({ text: 'tomorrow', timezone, isRecurring: false })
|
||||||
|
expect(message).toBeNull()
|
||||||
|
expect(result).toBeInstanceOf(DateTime)
|
||||||
|
expect((result as DateTime).toISODate()).toBe('2024-07-16') // Based on mock date 2024-07-15
|
||||||
|
})
|
||||||
|
|
||||||
|
test('should parse relative date "next friday" (non-recurring)', () => {
|
||||||
|
const { result, message } = convertHumanReadableFrequencyToMachineReadable({ text: 'next friday', timezone, isRecurring: false })
|
||||||
|
expect(message).toBeNull()
|
||||||
|
expect(result).toBeInstanceOf(DateTime)
|
||||||
|
// chrono-node interprets "next friday" from Mon July 15 as Fri July 26
|
||||||
|
expect((result as DateTime).toISODate()).toBe('2024-07-26')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('should return null for invalid date string (non-recurring)', () => {
|
||||||
|
const { result, message } = convertHumanReadableFrequencyToMachineReadable({ text: 'invalid date', timezone, isRecurring: false })
|
||||||
|
expect(result).toBeNull()
|
||||||
|
expect(message).toBe('Invalid due date.')
|
||||||
|
})
|
||||||
|
|
||||||
|
// Recurring tests
|
||||||
|
test('should parse "daily" (recurring)', () => {
|
||||||
|
const { result, message } = convertHumanReadableFrequencyToMachineReadable({ text: 'daily', timezone, isRecurring: true })
|
||||||
|
expect(message).toBeNull()
|
||||||
|
expect(result).toBeInstanceOf(RRule)
|
||||||
|
expect((result as RRule).origOptions.freq).toBe(RRule.DAILY)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('should parse "every week on Monday" (recurring)', () => {
|
||||||
|
const { result, message } = convertHumanReadableFrequencyToMachineReadable({ text: 'every week on Monday', timezone, isRecurring: true })
|
||||||
|
expect(message).toBeNull()
|
||||||
|
expect(result).toBeInstanceOf(RRule)
|
||||||
|
expect((result as RRule).origOptions.freq).toBe(RRule.WEEKLY)
|
||||||
|
// RRule.fromText returns Weekday objects, check the weekday property
|
||||||
|
const byweekday = (result as RRule).origOptions.byweekday;
|
||||||
|
const weekdayValues = byweekday
|
||||||
|
? (Array.isArray(byweekday)
|
||||||
|
? byweekday.map(d => typeof d === 'number' ? d : (d as Weekday).weekday)
|
||||||
|
: [typeof byweekday === 'number' ? byweekday : (byweekday as Weekday).weekday])
|
||||||
|
: [];
|
||||||
|
expect(weekdayValues).toEqual([RRule.MO.weekday])
|
||||||
|
})
|
||||||
|
|
||||||
|
test('should parse "every month on the 15th" (recurring)', () => {
|
||||||
|
const { result, message } = convertHumanReadableFrequencyToMachineReadable({ text: 'every month on the 15th', timezone, isRecurring: true })
|
||||||
|
expect(message).toBeNull()
|
||||||
|
expect(result).toBeInstanceOf(RRule)
|
||||||
|
expect((result as RRule).origOptions.freq).toBe(RRule.MONTHLY)
|
||||||
|
expect((result as RRule).origOptions.bymonthday).toEqual([15])
|
||||||
|
})
|
||||||
|
|
||||||
|
test('should parse "every year on Jan 1" (recurring)', () => {
|
||||||
|
const { result, message } = convertHumanReadableFrequencyToMachineReadable({ text: 'every year on Jan 1', timezone, isRecurring: true })
|
||||||
|
expect(message).toBeNull()
|
||||||
|
expect(result).toBeInstanceOf(RRule)
|
||||||
|
expect((result as RRule).origOptions.freq).toBe(RRule.YEARLY)
|
||||||
|
// Note: RRule.fromText parses 'Jan 1' into bymonth/bymonthday
|
||||||
|
expect((result as RRule).origOptions.bymonth).toEqual([1])
|
||||||
|
// RRule.fromText might not reliably set bymonthday in origOptions for this text
|
||||||
|
// expect((result as RRule).origOptions.bymonthday).toEqual([1])
|
||||||
|
})
|
||||||
|
|
||||||
|
test('should return validation error for "every week" without day (recurring)', () => {
|
||||||
|
const { result, message } = convertHumanReadableFrequencyToMachineReadable({ text: 'every week', timezone, isRecurring: true })
|
||||||
|
expect(result).toBeNull() // RRule.fromText might parse it, but our validation catches it
|
||||||
|
expect(message).toBe('Please specify day(s) of the week (e.g., "every week on Mon, Wed").')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('should return validation error for "every month" without day/position (recurring)', () => {
|
||||||
|
const { result, message } = convertHumanReadableFrequencyToMachineReadable({ text: 'every month', timezone, isRecurring: true })
|
||||||
|
expect(result).toBeNull() // RRule.fromText might parse it, but our validation catches it
|
||||||
|
expect(message).toBe('Please specify day of the month (e.g., "every month on the 15th") or position (e.g., "every month on the last Friday").')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('should return null for invalid recurrence string (recurring)', () => {
|
||||||
|
const { result, message } = convertHumanReadableFrequencyToMachineReadable({ text: 'invalid recurrence', timezone, isRecurring: true })
|
||||||
|
expect(result).toBeNull()
|
||||||
|
expect(message).toBe('Invalid recurrence rule.')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('should return specific error for unsupported hourly frequency', () => {
|
||||||
|
const { result, message } = convertHumanReadableFrequencyToMachineReadable({ text: 'every hour', timezone, isRecurring: true })
|
||||||
|
expect(result).toBeInstanceOf(RRule) // RRule parses it, but our validation catches it
|
||||||
|
expect(message).toBe('Hourly frequency is not supported.')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('should return specific error for unsupported daily interval', () => {
|
||||||
|
const { result, message } = convertHumanReadableFrequencyToMachineReadable({ text: 'every 2 days', timezone, isRecurring: true })
|
||||||
|
expect(result).toBeInstanceOf(RRule) // RRule parses it, but our validation catches it
|
||||||
|
expect(message).toBe('Daily frequency with intervals greater than 1 is not supported.')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('should handle predefined constants like "weekdays"', () => {
|
||||||
|
const { result, message } = convertHumanReadableFrequencyToMachineReadable({ text: 'weekdays', timezone, isRecurring: true })
|
||||||
|
expect(message).toBeNull()
|
||||||
|
expect(result).toBeInstanceOf(RRule)
|
||||||
|
expect((result as RRule).origOptions.freq).toBe(RRule.WEEKLY)
|
||||||
|
// Check the weekday property of the Weekday objects
|
||||||
|
const weekdays = (result as RRule).origOptions.byweekday;
|
||||||
|
const weekdayNumbers = weekdays
|
||||||
|
? (Array.isArray(weekdays)
|
||||||
|
? weekdays.map(d => typeof d === 'number' ? d : (d as Weekday).weekday)
|
||||||
|
: [typeof weekdays === 'number' ? weekdays : (weekdays as Weekday).weekday])
|
||||||
|
: [];
|
||||||
|
expect(weekdayNumbers).toEqual([RRule.MO.weekday, RRule.TU.weekday, RRule.WE.weekday, RRule.TH.weekday, RRule.FR.weekday])
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('convertMachineReadableFrequencyToHumanReadable', () => {
|
||||||
|
const timezone = 'America/New_York'
|
||||||
|
|
||||||
|
// Non-recurring tests
|
||||||
|
test('should format DateTime object (non-recurring)', () => {
|
||||||
|
const dateTime = DateTime.fromISO('2024-07-16T00:00:00', { zone: timezone }) as DateTime<true>
|
||||||
|
const humanReadable = convertMachineReadableFrequencyToHumanReadable({ frequency: dateTime, isRecurRule: false, timezone })
|
||||||
|
// Expected format depends on locale, check for key parts
|
||||||
|
expect(humanReadable).toContain('Jul 16, 2024')
|
||||||
|
expect(humanReadable).toContain('Tue') // Tuesday
|
||||||
|
})
|
||||||
|
|
||||||
|
test('should format ISO string (non-recurring)', () => {
|
||||||
|
const isoString = '2024-07-16T00:00:00.000-04:00' // Example ISO string with offset
|
||||||
|
const humanReadable = convertMachineReadableFrequencyToHumanReadable({ frequency: isoString, isRecurRule: false, timezone })
|
||||||
|
expect(humanReadable).toContain('Jul 16, 2024')
|
||||||
|
expect(humanReadable).toContain('Tue')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('should return "Initial Due" for null frequency (non-recurring)', () => {
|
||||||
|
const humanReadable = convertMachineReadableFrequencyToHumanReadable({ frequency: null, isRecurRule: false, timezone })
|
||||||
|
// Check against the imported constant value
|
||||||
|
expect(humanReadable).toBe(INITIAL_DUE)
|
||||||
|
})
|
||||||
|
|
||||||
|
// Recurring tests
|
||||||
|
test('should format RRule object (recurring)', () => {
|
||||||
|
const rrule = new RRule({ freq: RRule.DAILY })
|
||||||
|
const humanReadable = convertMachineReadableFrequencyToHumanReadable({ frequency: rrule, isRecurRule: true, timezone })
|
||||||
|
// rrule.toText() returns "every day" for daily rules
|
||||||
|
expect(humanReadable).toBe('every day')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('should format RRule string (recurring)', () => {
|
||||||
|
const rruleStr = 'FREQ=WEEKLY;BYDAY=MO,WE,FR'
|
||||||
|
const humanReadable = convertMachineReadableFrequencyToHumanReadable({ frequency: rruleStr, isRecurRule: true, timezone })
|
||||||
|
expect(humanReadable).toBe('every week on Monday, Wednesday, Friday')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('should return "invalid" for invalid RRule string (recurring)', () => {
|
||||||
|
const rruleStr = 'INVALID_RRULE'
|
||||||
|
const humanReadable = convertMachineReadableFrequencyToHumanReadable({ frequency: rruleStr, isRecurRule: true, timezone })
|
||||||
|
expect(humanReadable).toBe('invalid')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('should return "invalid" for null frequency (recurring)', () => {
|
||||||
|
const humanReadable = convertMachineReadableFrequencyToHumanReadable({ frequency: null, isRecurRule: true, timezone })
|
||||||
|
expect(humanReadable).toBe('invalid')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('should return "invalid" for unexpected type (recurring)', () => {
|
||||||
|
const humanReadable = convertMachineReadableFrequencyToHumanReadable({ frequency: 123 as unknown as ParsedResultType, isRecurRule: true, timezone })
|
||||||
|
expect(humanReadable).toBe('invalid')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('should return "invalid" for unexpected type (non-recurring)', () => {
|
||||||
|
const humanReadable = convertMachineReadableFrequencyToHumanReadable({ frequency: new RRule({ freq: RRule.DAILY }) as unknown as ParsedResultType, isRecurRule: false, timezone })
|
||||||
|
expect(humanReadable).toBe('invalid')
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
186
lib/utils.ts
186
lib/utils.ts
@@ -2,8 +2,8 @@ import { clsx, type ClassValue } from "clsx"
|
|||||||
import { twMerge } from "tailwind-merge"
|
import { twMerge } from "tailwind-merge"
|
||||||
import { DateTime, DateTimeFormatOptions } from "luxon"
|
import { DateTime, DateTimeFormatOptions } from "luxon"
|
||||||
import { datetime, RRule } from 'rrule'
|
import { datetime, RRule } from 'rrule'
|
||||||
import { Freq, Habit, CoinTransaction, Permission } from '@/lib/types'
|
import { Freq, Habit, CoinTransaction, Permission, ParsedFrequencyResult, ParsedResultType } from '@/lib/types'
|
||||||
import { DUE_MAP, RECURRENCE_RULE_MAP } from "./constants"
|
import { DUE_MAP, INITIAL_DUE, RECURRENCE_RULE_MAP } from "./constants"
|
||||||
import * as chrono from 'chrono-node'
|
import * as chrono from 'chrono-node'
|
||||||
import _ from "lodash"
|
import _ from "lodash"
|
||||||
import { v4 as uuidv4 } from 'uuid'
|
import { v4 as uuidv4 } from 'uuid'
|
||||||
@@ -185,41 +185,126 @@ export function calculateTransactionsToday(transactions: CoinTransaction[], time
|
|||||||
).length;
|
).length;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getRRuleUTC(recurrenceRule: string) {
|
// Enhanced validation for weekly/monthly rules
|
||||||
return RRule.fromString(recurrenceRule); // this returns UTC
|
function validateRecurrenceRule(rrule: RRule | null): ParsedFrequencyResult {
|
||||||
}
|
if (!rrule) {
|
||||||
|
return { result: null, message: 'Invalid recurrence rule.' };
|
||||||
export function parseNaturalLanguageRRule(ruleText: string) {
|
|
||||||
ruleText = ruleText.trim()
|
|
||||||
if (RECURRENCE_RULE_MAP[ruleText]) {
|
|
||||||
return RRule.fromString(RECURRENCE_RULE_MAP[ruleText])
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return RRule.fromText(ruleText)
|
const unsupportedReason = getUnsupportedRRuleReason(rrule);
|
||||||
}
|
if (unsupportedReason) {
|
||||||
|
return { result: rrule, message: unsupportedReason };
|
||||||
export function parseRRule(ruleText: string) {
|
|
||||||
ruleText = ruleText.trim()
|
|
||||||
if (RECURRENCE_RULE_MAP[ruleText]) {
|
|
||||||
return RRule.fromString(RECURRENCE_RULE_MAP[ruleText])
|
|
||||||
}
|
}
|
||||||
|
|
||||||
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()
|
return rrule.toString()
|
||||||
}
|
}
|
||||||
|
|
||||||
export function parseNaturalLanguageDate({ text, timezone }: { text: string, timezone: string }) {
|
// Convert a machine-readable frequency (recurring or non-recurring) into a human-readable one
|
||||||
if (DUE_MAP[text]) {
|
export function convertMachineReadableFrequencyToHumanReadable({
|
||||||
text = DUE_MAP[text]
|
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({
|
export function isHabitDue({
|
||||||
@@ -247,8 +332,8 @@ export function isHabitDue({
|
|||||||
const endOfDay = date.setZone(timezone).endOf('day')
|
const endOfDay = date.setZone(timezone).endOf('day')
|
||||||
|
|
||||||
const ruleText = habit.frequency
|
const ruleText = habit.frequency
|
||||||
const rrule = parseRRule(ruleText)
|
const rrule = deserializeRRule(ruleText)
|
||||||
|
if (!rrule) return false
|
||||||
rrule.origOptions.tzid = timezone
|
rrule.origOptions.tzid = timezone
|
||||||
rrule.options.tzid = rrule.origOptions.tzid
|
rrule.options.tzid = rrule.origOptions.tzid
|
||||||
rrule.origOptions.dtstart = datetime(startOfDay.year, startOfDay.month, startOfDay.day, startOfDay.hour, startOfDay.minute, startOfDay.second)
|
rrule.origOptions.dtstart = datetime(startOfDay.year, startOfDay.month, startOfDay.day, startOfDay.hour, startOfDay.minute, startOfDay.second)
|
||||||
@@ -289,17 +374,46 @@ export function getHabitFreq(habit: Habit): Freq {
|
|||||||
// don't support recurring task yet
|
// don't support recurring task yet
|
||||||
return 'daily'
|
return 'daily'
|
||||||
}
|
}
|
||||||
const rrule = parseRRule(habit.frequency)
|
const rrule = RRule.fromString(habit.frequency)
|
||||||
const freq = rrule.origOptions.freq
|
const freq = rrule.origOptions.freq
|
||||||
switch (freq) {
|
switch (freq) {
|
||||||
case RRule.DAILY: return 'daily'
|
case RRule.DAILY: return 'daily'
|
||||||
case RRule.WEEKLY: return 'weekly'
|
case RRule.WEEKLY: return 'weekly'
|
||||||
case RRule.MONTHLY: return 'monthly'
|
case RRule.MONTHLY: return 'monthly'
|
||||||
case RRule.YEARLY: return 'yearly'
|
case RRule.YEARLY: return 'yearly'
|
||||||
default: throw new Error(`Invalid frequency: ${freq}`)
|
|
||||||
|
default:
|
||||||
|
console.error(`Invalid frequency: ${freq} (habit: ${habit.id} ${habit.name}) (rrule: ${rrule.toString()}). Defaulting to daily`)
|
||||||
|
return 'daily'
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Checks if an RRule is unsupported and returns the reason.
|
||||||
|
* @param rrule The RRule object to check.
|
||||||
|
* @returns A string message explaining why the rule is unsupported, or null if it's supported.
|
||||||
|
*/
|
||||||
|
export function getUnsupportedRRuleReason(rrule: RRule): string | null {
|
||||||
|
const freq = rrule.origOptions.freq;
|
||||||
|
const interval = rrule.origOptions.interval || 1; // RRule defaults interval to 1
|
||||||
|
|
||||||
|
if (freq === RRule.HOURLY) {
|
||||||
|
return 'Hourly frequency is not supported.';
|
||||||
|
}
|
||||||
|
if (freq === RRule.MINUTELY) {
|
||||||
|
return 'Minutely frequency is not supported.';
|
||||||
|
}
|
||||||
|
if (freq === RRule.SECONDLY) {
|
||||||
|
return 'Secondly frequency is not supported.';
|
||||||
|
}
|
||||||
|
if (freq === RRule.DAILY && interval > 1) {
|
||||||
|
return 'Daily frequency with intervals greater than 1 is not supported.';
|
||||||
|
}
|
||||||
|
|
||||||
|
return null; // Rule is supported
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
// play sound (client side only, must be run in browser)
|
// play sound (client side only, must be run in browser)
|
||||||
export const playSound = (soundPath: string = '/sounds/timer-end.wav') => {
|
export const playSound = (soundPath: string = '/sounds/timer-end.wav') => {
|
||||||
const audio = new Audio(soundPath)
|
const audio = new Audio(soundPath)
|
||||||
@@ -320,10 +434,10 @@ export const openWindow = (url: string): boolean => {
|
|||||||
|
|
||||||
export function deepMerge<T>(a: T, b: T) {
|
export function deepMerge<T>(a: T, b: T) {
|
||||||
return _.merge(a, b, (x: unknown, y: unknown) => {
|
return _.merge(a, b, (x: unknown, y: unknown) => {
|
||||||
if (_.isArray(a)) {
|
if (_.isArray(a)) {
|
||||||
return a.concat(b)
|
return a.concat(b)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
export function checkPermission(
|
export function checkPermission(
|
||||||
@@ -332,7 +446,7 @@ export function checkPermission(
|
|||||||
action: 'write' | 'interact'
|
action: 'write' | 'interact'
|
||||||
): boolean {
|
): boolean {
|
||||||
if (!permissions) return false
|
if (!permissions) return false
|
||||||
|
|
||||||
return permissions.some(permission => {
|
return permissions.some(permission => {
|
||||||
switch (resource) {
|
switch (resource) {
|
||||||
case 'habit':
|
case 'habit':
|
||||||
|
|||||||
794
package-lock.json
generated
794
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
10
package.json
10
package.json
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "habittrove",
|
"name": "habittrove",
|
||||||
"version": "0.2.2",
|
"version": "0.2.11",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "next dev --turbopack",
|
"dev": "next dev --turbopack",
|
||||||
@@ -25,13 +25,16 @@
|
|||||||
"@radix-ui/react-label": "^2.1.1",
|
"@radix-ui/react-label": "^2.1.1",
|
||||||
"@radix-ui/react-popover": "^1.1.4",
|
"@radix-ui/react-popover": "^1.1.4",
|
||||||
"@radix-ui/react-progress": "^1.1.1",
|
"@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-select": "^2.1.4",
|
||||||
|
"@radix-ui/react-separator": "^1.1.3",
|
||||||
"@radix-ui/react-slot": "^1.1.1",
|
"@radix-ui/react-slot": "^1.1.1",
|
||||||
"@radix-ui/react-switch": "^1.1.2",
|
"@radix-ui/react-switch": "^1.1.2",
|
||||||
"@radix-ui/react-toast": "^1.2.4",
|
"@radix-ui/react-toast": "^1.2.4",
|
||||||
"@radix-ui/react-tooltip": "^1.1.6",
|
"@radix-ui/react-tooltip": "^1.1.6",
|
||||||
"@types/canvas-confetti": "^1.9.0",
|
"@types/canvas-confetti": "^1.9.0",
|
||||||
"@uiw/react-heat-map": "^2.3.2",
|
"@uiw/react-heat-map": "^2.3.2",
|
||||||
|
"archiver": "^7.0.1",
|
||||||
"canvas-confetti": "^1.9.3",
|
"canvas-confetti": "^1.9.3",
|
||||||
"chrono-node": "^2.7.7",
|
"chrono-node": "^2.7.7",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
@@ -43,9 +46,10 @@
|
|||||||
"linkify-react": "^4.2.0",
|
"linkify-react": "^4.2.0",
|
||||||
"lucide-react": "^0.469.0",
|
"lucide-react": "^0.469.0",
|
||||||
"luxon": "^3.5.0",
|
"luxon": "^3.5.0",
|
||||||
"next": "15.1.3",
|
"next": "15.2.3",
|
||||||
"next-auth": "^5.0.0-beta.25",
|
"next-auth": "^5.0.0-beta.25",
|
||||||
"next-themes": "^0.4.4",
|
"next-themes": "^0.4.4",
|
||||||
|
"node-cron": "^3.0.3",
|
||||||
"react": "^19.0.0",
|
"react": "^19.0.0",
|
||||||
"react-confetti": "^6.2.2",
|
"react-confetti": "^6.2.2",
|
||||||
"react-day-picker": "^8.10.1",
|
"react-day-picker": "^8.10.1",
|
||||||
@@ -62,10 +66,12 @@
|
|||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/eslintrc": "^3",
|
"@eslint/eslintrc": "^3",
|
||||||
"@tailwindcss/typography": "^0.5.15",
|
"@tailwindcss/typography": "^0.5.15",
|
||||||
|
"@types/archiver": "^6.0.3",
|
||||||
"@types/bun": "^1.1.14",
|
"@types/bun": "^1.1.14",
|
||||||
"@types/lodash": "^4.17.15",
|
"@types/lodash": "^4.17.15",
|
||||||
"@types/luxon": "^3.4.2",
|
"@types/luxon": "^3.4.2",
|
||||||
"@types/node": "^20.17.10",
|
"@types/node": "^20.17.10",
|
||||||
|
"@types/node-cron": "^3.0.11",
|
||||||
"@types/react": "^19",
|
"@types/react": "^19",
|
||||||
"@types/react-dom": "^19",
|
"@types/react-dom": "^19",
|
||||||
"@types/web-push": "^3.6.4",
|
"@types/web-push": "^3.6.4",
|
||||||
|
|||||||
Reference in New Issue
Block a user