mirror of
https://github.com/ManInDark/HabitTrove.git
synced 2026-03-09 20:09:50 +01:00
Compare commits
37 Commits
v0.2.21
...
1b17d6b50a
| Author | SHA1 | Date | |
|---|---|---|---|
|
1b17d6b50a
|
|||
|
8269f3adad
|
|||
|
4cadf4cea7
|
|||
|
06e802f2f5
|
|||
|
6c0b196de2
|
|||
|
0f073760ee
|
|||
|
55c2e3577d
|
|||
|
043201217f
|
|||
|
4e11f17729
|
|||
|
faa6f4cb76
|
|||
|
84d6321153
|
|||
|
1af98fb233
|
|||
|
|
8d2bfaf62c | ||
|
9046d40a7a
|
|||
|
be0a5c48b3
|
|||
|
|
98b5d5eebb | ||
|
|
276e8a8a7b | ||
|
3d78a00c66
|
|||
|
9c2e3f7dec
|
|||
|
e93b1c1c57
|
|||
|
92d1462010
|
|||
|
eff14f3772
|
|||
|
d9fa0426ce
|
|||
|
49a0ea8804
|
|||
|
9bf24db477
|
|||
|
8530f703d9
|
|||
|
1a447e00bf
|
|||
|
ac116e8322
|
|||
|
8c7a7a63d0
|
|||
|
7c7d0e2f32
|
|||
|
e908f1edec
|
|||
|
8e6ddf0b9f
|
|||
|
c5a8f403ef
|
|||
|
33d36d0600
|
|||
|
942356eaed
|
|||
|
e4a52657af
|
|||
|
dbd0d0c7b7
|
95
.github/workflows/docker-publish.yml
vendored
95
.github/workflows/docker-publish.yml
vendored
@@ -1,95 +0,0 @@
|
|||||||
name: Docker Build and Publish
|
|
||||||
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
branches:
|
|
||||||
- main
|
|
||||||
- github-actions
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
build-and-push:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
outputs:
|
|
||||||
EXISTS: ${{ steps.check-version.outputs.EXISTS }}
|
|
||||||
VERSION: ${{ steps.package-version.outputs.VERSION }}
|
|
||||||
|
|
||||||
steps:
|
|
||||||
- name: Checkout code
|
|
||||||
uses: actions/checkout@v4
|
|
||||||
|
|
||||||
- name: Set up Node.js
|
|
||||||
uses: actions/setup-node@v4
|
|
||||||
with:
|
|
||||||
node-version: "20"
|
|
||||||
|
|
||||||
- name: Get version from package.json
|
|
||||||
id: package-version
|
|
||||||
run: echo "VERSION=$(node -p "require('./package.json').version")" >> $GITHUB_OUTPUT
|
|
||||||
|
|
||||||
- name: Login to DockerHub
|
|
||||||
uses: docker/login-action@v3
|
|
||||||
with:
|
|
||||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
|
||||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
|
||||||
|
|
||||||
- name: Check if version exists
|
|
||||||
id: check-version
|
|
||||||
run: |
|
|
||||||
if docker pull dohsimpson/habittrove:v${{ steps.package-version.outputs.VERSION }} 2>/dev/null; then
|
|
||||||
echo "EXISTS=true" >> $GITHUB_OUTPUT
|
|
||||||
else
|
|
||||||
echo "EXISTS=false" >> $GITHUB_OUTPUT
|
|
||||||
fi
|
|
||||||
|
|
||||||
- name: Set up Docker Buildx
|
|
||||||
uses: docker/setup-buildx-action@v3
|
|
||||||
|
|
||||||
- name: Build and push Docker images
|
|
||||||
uses: docker/build-push-action@v5
|
|
||||||
with:
|
|
||||||
context: .
|
|
||||||
platforms: linux/amd64,linux/arm64
|
|
||||||
push: true
|
|
||||||
tags: |
|
|
||||||
${{ steps.check-version.outputs.EXISTS == 'false' && format('dohsimpson/habittrove:v{0}', steps.package-version.outputs.VERSION) || '' }}
|
|
||||||
dohsimpson/habittrove:demo
|
|
||||||
dohsimpson/habittrove:latest
|
|
||||||
|
|
||||||
deploy-demo:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
needs: build-and-push
|
|
||||||
# demo tracks the demo tag
|
|
||||||
if: needs.build-and-push.outputs.EXISTS == 'false'
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v4
|
|
||||||
- uses: actions-hub/kubectl@master
|
|
||||||
env:
|
|
||||||
KUBE_CONFIG: ${{ secrets.KUBE_CONFIG }}
|
|
||||||
with:
|
|
||||||
args: rollout restart -n ${{ secrets.KUBE_NAMESPACE }} deploy/${{ secrets.KUBE_DEPLOYMENT }}
|
|
||||||
|
|
||||||
create-release:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
needs: build-and-push
|
|
||||||
if: needs.build-and-push.outputs.EXISTS == 'false'
|
|
||||||
permissions:
|
|
||||||
contents: write
|
|
||||||
steps:
|
|
||||||
- name: Checkout code
|
|
||||||
uses: actions/checkout@v4
|
|
||||||
|
|
||||||
- name: Create GitHub release
|
|
||||||
env:
|
|
||||||
GH_TOKEN: ${{ secrets.GH_TOKEN }}
|
|
||||||
VERSION: ${{ needs.build-and-push.outputs.VERSION }}
|
|
||||||
run: |
|
|
||||||
# Extract release notes from CHANGELOG.md
|
|
||||||
notes=$(awk -v version="$VERSION" '
|
|
||||||
$0 ~ "## Version " version {flag=1;next}
|
|
||||||
$0 ~ "## Version " && flag {exit}
|
|
||||||
flag' CHANGELOG.md)
|
|
||||||
|
|
||||||
gh release create "v$VERSION" \
|
|
||||||
--repo="$GITHUB_REPOSITORY" \
|
|
||||||
--title="v$VERSION" \
|
|
||||||
--notes="$notes"
|
|
||||||
40
.github/workflows/release.yml
vendored
Normal file
40
.github/workflows/release.yml
vendored
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
name: Create and publish a Docker image to Github Container Registry
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
tags:
|
||||||
|
- v*
|
||||||
|
|
||||||
|
env:
|
||||||
|
REGISTRY: ghcr.io
|
||||||
|
IMAGE_NAME: ${{ github.repository }}
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
create-and-publish:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
packages: write
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
- name: Log in to the Container registry
|
||||||
|
uses: docker/login-action@v3
|
||||||
|
with:
|
||||||
|
registry: ${{ env.REGISTRY }}
|
||||||
|
username: ${{ github.actor }}
|
||||||
|
password: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
- name: Extract metadata (tags, labels) for Docker
|
||||||
|
id: meta
|
||||||
|
uses: docker/metadata-action@v5
|
||||||
|
with:
|
||||||
|
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
|
||||||
|
- name: Set up Docker Buildx
|
||||||
|
uses: docker/setup-buildx-action@v3
|
||||||
|
- name: Build and push Docker image
|
||||||
|
uses: docker/build-push-action@v5
|
||||||
|
with:
|
||||||
|
context: .
|
||||||
|
push: true
|
||||||
|
tags: ${{ steps.meta.outputs.tags }}
|
||||||
|
labels: ${{ steps.meta.outputs.labels }}
|
||||||
28
.github/workflows/test.yml
vendored
28
.github/workflows/test.yml
vendored
@@ -1,28 +0,0 @@
|
|||||||
name: Unit Tests
|
|
||||||
|
|
||||||
on:
|
|
||||||
pull_request:
|
|
||||||
branches:
|
|
||||||
- main
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
test:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
|
|
||||||
steps:
|
|
||||||
- name: Checkout code
|
|
||||||
uses: actions/checkout@v4
|
|
||||||
|
|
||||||
- name: Set up Bun
|
|
||||||
uses: oven-sh/setup-bun@v1
|
|
||||||
with:
|
|
||||||
bun-version: latest
|
|
||||||
|
|
||||||
- name: Install dependencies
|
|
||||||
run: bun install
|
|
||||||
|
|
||||||
- name: Run lint
|
|
||||||
run: bun run lint
|
|
||||||
|
|
||||||
- name: Run unit tests
|
|
||||||
run: bun test
|
|
||||||
2
.gitignore
vendored
2
.gitignore
vendored
@@ -46,3 +46,5 @@ next-env.d.ts
|
|||||||
Budfile
|
Budfile
|
||||||
certificates
|
certificates
|
||||||
/backups/*
|
/backups/*
|
||||||
|
|
||||||
|
CHANGELOG.md.tmp
|
||||||
|
|||||||
6
.vscode/settings.json
vendored
Normal file
6
.vscode/settings.json
vendored
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
{
|
||||||
|
"i18n-ally.localesPaths": [
|
||||||
|
"i18n",
|
||||||
|
"messages"
|
||||||
|
]
|
||||||
|
}
|
||||||
19
CHANGELOG.md
19
CHANGELOG.md
@@ -1,5 +1,24 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## Version 0.2.23
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
* floating number coin balance (#155)
|
||||||
|
* disable freshness check if browser does not support web crypto (#161)
|
||||||
|
|
||||||
|
### Improved
|
||||||
|
|
||||||
|
* use transparent background PWA icon with correct text (#103)
|
||||||
|
* display icon in logo
|
||||||
|
|
||||||
|
## Version 0.2.22
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
* auto check data freshness on interval (#138)
|
||||||
|
* warn about out-of-sync data
|
||||||
|
|
||||||
## Version 0.2.21
|
## Version 0.2.21
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|||||||
31
README.md
31
README.md
@@ -1,14 +1,24 @@
|
|||||||
# HabitTrove
|
# <img align="left" width="50" height="50" src="https://github.com/user-attachments/assets/99dcf223-3680-4b3a-8050-d9788f051682" /> HabitTrove
|
||||||
|
|
||||||

|
|
||||||
|
|
||||||
HabitTrove is a gamified habit tracking application that helps you build and maintain positive habits by rewarding you with coins, which you can use to exchange for rewards.
|
HabitTrove is a gamified habit tracking application that helps you build and maintain positive habits by rewarding you with coins, which you can use to exchange for rewards.
|
||||||
|
|
||||||
> **⚠️ Important:** HabitTrove is currently in beta. Please regularly backup your `data/` directory to prevent any potential data loss.
|
**⚠️ Important:** HabitTrove is currently in beta. Please regularly backup your `data/` directory to prevent any potential data loss.
|
||||||
|
|
||||||
## Try the Demo
|
## Differences to Upstream
|
||||||
|
|
||||||
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)
|
I generally try to keep the `main` branch up to date with upstream features, merging tagged versions and mapping them to `<upstream-version>.0`.
|
||||||
|
|
||||||
|
In this version I've taken steps to ensure a smoother experience and decreased the chance of the program bricking itself. This doesn't mean that it's completely stable, but I've fixed the most glaring bugs I encountered.
|
||||||
|
|
||||||
|
Differences (as of writing) are:
|
||||||
|
- resolved linting problems so you can actually commit things
|
||||||
|
- added missing dependency
|
||||||
|
- refactored adding habit modal to cause less errors
|
||||||
|
- resolved undefined error
|
||||||
|
- replaced dockerhub release flow with github
|
||||||
|
- miscellaneous refactorings
|
||||||
|
- split habits & tasks page into two different pages
|
||||||
|
- only display "show all" if there are more than 4 entries
|
||||||
|
|
||||||
## Features
|
## Features
|
||||||
|
|
||||||
@@ -25,11 +35,8 @@ Want to try HabitTrove before installing? Visit the public [demo instance](https
|
|||||||
## Usage
|
## Usage
|
||||||
|
|
||||||
1. **Creating Habits**: Click the "Add Habit" button to create a new habit. Set a name, description, and coin reward.
|
1. **Creating Habits**: Click the "Add Habit" button to create a new habit. Set a name, description, and coin reward.
|
||||||
|
|
||||||
2. **Tracking Habits**: Mark habits as complete on your dashboard. Each completion earns you the specified coins.
|
2. **Tracking Habits**: Mark habits as complete on your dashboard. Each completion earns you the specified coins.
|
||||||
|
|
||||||
3. **Wishlist**: Add rewards to your wishlist that you can redeem with earned coins.
|
3. **Wishlist**: Add rewards to your wishlist that you can redeem with earned coins.
|
||||||
|
|
||||||
4. **Statistics**: View your progress through the heatmap and streak counters.
|
4. **Statistics**: View your progress through the heatmap and streak counters.
|
||||||
|
|
||||||
## Docker Deployment
|
## Docker Deployment
|
||||||
@@ -66,7 +73,7 @@ docker run -d \
|
|||||||
-v ./data:/app/data \
|
-v ./data:/app/data \
|
||||||
-v ./backups:/app/backups \ # Add this line to map the backups directory
|
-v ./backups:/app/backups \ # Add this line to map the backups directory
|
||||||
-e AUTH_SECRET=$AUTH_SECRET \
|
-e AUTH_SECRET=$AUTH_SECRET \
|
||||||
dohsimpson/habittrove
|
ghcr.io/manindark/habittrove
|
||||||
```
|
```
|
||||||
|
|
||||||
Available image tags:
|
Available image tags:
|
||||||
@@ -113,7 +120,7 @@ To contribute to HabitTrove, you'll need to set up a development environment. He
|
|||||||
1. Clone the repository and navigate to the project directory:
|
1. Clone the repository and navigate to the project directory:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
git clone https://github.com/dohsimpson/habittrove.git
|
git clone https://github.com/ManInDark/HabitTrove.git
|
||||||
cd habittrove
|
cd habittrove
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -166,7 +173,7 @@ Run these commands regularly during development to catch issues early.
|
|||||||
|
|
||||||
## Contributing
|
## Contributing
|
||||||
|
|
||||||
We welcome feature requests and bug reports! Please [open an issue](https://github.com/dohsimpson/habittrove/issues/new). We do not accept pull request at the moment.
|
We welcome feature requests and bug reports! Please [open an issue](https://github.com/ManInDark/habittrove/issues/new).
|
||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
|
|||||||
@@ -1,56 +1,38 @@
|
|||||||
'use server'
|
'use server'
|
||||||
|
|
||||||
import fs from 'fs/promises'
|
import { getCurrentUser, saltAndHashPassword, verifyPassword } from "@/lib/server-helpers";
|
||||||
import path from 'path'
|
|
||||||
import {
|
import {
|
||||||
HabitsData,
|
|
||||||
CoinsData,
|
CoinsData,
|
||||||
CoinTransaction,
|
CoinTransaction,
|
||||||
TransactionType,
|
|
||||||
WishlistItemType,
|
|
||||||
WishlistData,
|
|
||||||
Settings,
|
|
||||||
DataType,
|
|
||||||
DATA_DEFAULTS,
|
DATA_DEFAULTS,
|
||||||
getDefaultSettings,
|
DataType,
|
||||||
UserData,
|
|
||||||
getDefaultUsersData,
|
|
||||||
User,
|
|
||||||
getDefaultWishlistData,
|
|
||||||
getDefaultHabitsData,
|
|
||||||
getDefaultCoinsData,
|
getDefaultCoinsData,
|
||||||
|
getDefaultHabitsData,
|
||||||
|
getDefaultSettings,
|
||||||
|
getDefaultUsersData,
|
||||||
|
getDefaultWishlistData,
|
||||||
|
HabitsData,
|
||||||
Permission,
|
Permission,
|
||||||
ServerSettings
|
ServerSettings,
|
||||||
} from '@/lib/types'
|
Settings,
|
||||||
import { d2t, deepMerge, getNow, checkPermission, uuid } from '@/lib/utils';
|
TransactionType,
|
||||||
import { verifyPassword } from "@/lib/server-helpers";
|
User,
|
||||||
import { saltAndHashPassword } from "@/lib/server-helpers";
|
UserData,
|
||||||
|
WishlistData,
|
||||||
|
WishlistItemType
|
||||||
|
} from '@/lib/types';
|
||||||
|
import { d2t, generateCryptoHash, getNow, prepareDataForHashing } from '@/lib/utils';
|
||||||
import { signInSchema } from '@/lib/zod';
|
import { signInSchema } from '@/lib/zod';
|
||||||
import { auth } from '@/auth';
|
import fs from 'fs/promises';
|
||||||
import _ from 'lodash';
|
import _ from 'lodash';
|
||||||
import { getCurrentUser, getCurrentUserId } from '@/lib/server-helpers'
|
import path from 'path';
|
||||||
|
|
||||||
|
|
||||||
import { PermissionError } from '@/lib/exceptions'
|
|
||||||
|
|
||||||
type ResourceType = 'habit' | 'wishlist' | 'coins'
|
type ResourceType = 'habit' | 'wishlist' | 'coins'
|
||||||
type ActionType = 'write' | 'interact'
|
type ActionType = 'write' | 'interact'
|
||||||
|
|
||||||
|
|
||||||
async function verifyPermission(
|
|
||||||
resource: ResourceType,
|
|
||||||
action: ActionType
|
|
||||||
): Promise<void> {
|
|
||||||
// const user = await getCurrentUser()
|
|
||||||
|
|
||||||
// if (!user) throw new PermissionError('User not authenticated')
|
|
||||||
// if (user.isAdmin) return // Admins bypass permission checks
|
|
||||||
|
|
||||||
// if (!checkPermission(user.permissions, resource, action)) {
|
|
||||||
// throw new PermissionError(`User does not have ${action} permission for ${resource}`)
|
|
||||||
// }
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
function getDefaultData<T>(type: DataType): T {
|
function getDefaultData<T>(type: DataType): T {
|
||||||
return DATA_DEFAULTS[type]() as T;
|
return DATA_DEFAULTS[type]() as T;
|
||||||
}
|
}
|
||||||
@@ -94,7 +76,7 @@ async function loadData<T>(type: DataType): Promise<T> {
|
|||||||
await fs.access(filePath)
|
await fs.access(filePath)
|
||||||
} catch {
|
} catch {
|
||||||
// File doesn't exist, create it with default data
|
// File doesn't exist, create it with default data
|
||||||
const initialData = getDefaultData(type)
|
const initialData = getDefaultData<T>(type)
|
||||||
await fs.writeFile(filePath, JSON.stringify(initialData, null, 2))
|
await fs.writeFile(filePath, JSON.stringify(initialData, null, 2))
|
||||||
return initialData as T
|
return initialData as T
|
||||||
}
|
}
|
||||||
@@ -123,10 +105,38 @@ async function saveData<T>(type: DataType, data: T): Promise<void> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calculates the server's global freshness token based on all core data files.
|
||||||
|
* This is an expensive operation as it reads all data files.
|
||||||
|
*/
|
||||||
|
async function calculateServerFreshnessToken(): Promise<string | null> {
|
||||||
|
try {
|
||||||
|
const [settings, habits, coins, wishlist, users] = await Promise.all([
|
||||||
|
loadSettings(),
|
||||||
|
loadHabitsData(),
|
||||||
|
loadCoinsData(),
|
||||||
|
loadWishlistData(),
|
||||||
|
loadUsersData()
|
||||||
|
]);
|
||||||
|
|
||||||
|
const dataString = prepareDataForHashing(
|
||||||
|
settings,
|
||||||
|
habits,
|
||||||
|
coins,
|
||||||
|
wishlist,
|
||||||
|
users
|
||||||
|
);
|
||||||
|
return generateCryptoHash(dataString);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error calculating server freshness token:", error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Wishlist specific functions
|
// Wishlist specific functions
|
||||||
export async function loadWishlistData(): Promise<WishlistData> {
|
export async function loadWishlistData(): Promise<WishlistData> {
|
||||||
const user = await getCurrentUser()
|
const user = await getCurrentUser()
|
||||||
if (!user) return getDefaultWishlistData()
|
if (!user) return getDefaultWishlistData<WishlistData>()
|
||||||
|
|
||||||
const data = await loadData<WishlistData>('wishlist')
|
const data = await loadData<WishlistData>('wishlist')
|
||||||
return {
|
return {
|
||||||
@@ -141,7 +151,6 @@ export async function loadWishlistItems(): Promise<WishlistItemType[]> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function saveWishlistItems(data: WishlistData): Promise<void> {
|
export async function saveWishlistItems(data: WishlistData): Promise<void> {
|
||||||
await verifyPermission('wishlist', 'write')
|
|
||||||
const user = await getCurrentUser()
|
const user = await getCurrentUser()
|
||||||
|
|
||||||
data.items = data.items.map(wishlist => ({
|
data.items = data.items.map(wishlist => ({
|
||||||
@@ -164,17 +173,14 @@ export async function saveWishlistItems(data: WishlistData): Promise<void> {
|
|||||||
// Habits specific functions
|
// Habits specific functions
|
||||||
export async function loadHabitsData(): Promise<HabitsData> {
|
export async function loadHabitsData(): Promise<HabitsData> {
|
||||||
const user = await getCurrentUser()
|
const user = await getCurrentUser()
|
||||||
if (!user) return getDefaultHabitsData()
|
if (!user) return getDefaultHabitsData<HabitsData>()
|
||||||
const data = await loadData<HabitsData>('habits')
|
const data = await loadData<HabitsData>('habits')
|
||||||
return {
|
return {
|
||||||
...data,
|
|
||||||
habits: data.habits.filter(x => user.isAdmin || x.userIds?.includes(user.id))
|
habits: data.habits.filter(x => user.isAdmin || x.userIds?.includes(user.id))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function saveHabitsData(data: HabitsData): Promise<void> {
|
export async function saveHabitsData(data: HabitsData): Promise<void> {
|
||||||
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)
|
||||||
@@ -186,7 +192,7 @@ export async function saveHabitsData(data: HabitsData): Promise<void> {
|
|||||||
}))
|
}))
|
||||||
|
|
||||||
if (!user?.isAdmin) {
|
if (!user?.isAdmin) {
|
||||||
const existingData = await loadData<HabitsData>('habits')
|
const existingData = await loadHabitsData();
|
||||||
const existingHabits = existingData.habits.filter(x => user?.id && !x.userIds?.includes(user?.id))
|
const existingHabits = existingData.habits.filter(x => user?.id && !x.userIds?.includes(user?.id))
|
||||||
newData.habits = [
|
newData.habits = [
|
||||||
...existingHabits,
|
...existingHabits,
|
||||||
@@ -202,14 +208,14 @@ export async function saveHabitsData(data: HabitsData): Promise<void> {
|
|||||||
export async function loadCoinsData(): Promise<CoinsData> {
|
export async function loadCoinsData(): Promise<CoinsData> {
|
||||||
try {
|
try {
|
||||||
const user = await getCurrentUser()
|
const user = await getCurrentUser()
|
||||||
if (!user) return getDefaultCoinsData()
|
if (!user) return getDefaultCoinsData<CoinsData>()
|
||||||
const data = await loadData<CoinsData>('coins')
|
const data = await loadData<CoinsData>('coins')
|
||||||
return {
|
return {
|
||||||
...data,
|
...data,
|
||||||
transactions: user.isAdmin ? 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<CoinsData>()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -249,11 +255,10 @@ export async function addCoins({
|
|||||||
note?: string
|
note?: string
|
||||||
userId?: string
|
userId?: string
|
||||||
}): Promise<CoinsData> {
|
}): Promise<CoinsData> {
|
||||||
await verifyPermission('coins', type === 'MANUAL_ADJUSTMENT' ? 'write' : 'interact')
|
|
||||||
const currentUser = await getCurrentUser()
|
const currentUser = await getCurrentUser()
|
||||||
const data = await loadCoinsData()
|
const data = await loadCoinsData()
|
||||||
const newTransaction: CoinTransaction = {
|
const newTransaction: CoinTransaction = {
|
||||||
id: uuid(),
|
id: crypto.randomUUID(),
|
||||||
amount,
|
amount,
|
||||||
type,
|
type,
|
||||||
description,
|
description,
|
||||||
@@ -273,7 +278,7 @@ export async function addCoins({
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function loadSettings(): Promise<Settings> {
|
export async function loadSettings(): Promise<Settings> {
|
||||||
const defaultSettings = getDefaultSettings()
|
const defaultSettings = getDefaultSettings<Settings>()
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const user = await getCurrentUser()
|
const user = await getCurrentUser()
|
||||||
@@ -304,11 +309,10 @@ export async function removeCoins({
|
|||||||
note?: string
|
note?: string
|
||||||
userId?: string
|
userId?: string
|
||||||
}): Promise<CoinsData> {
|
}): Promise<CoinsData> {
|
||||||
await verifyPermission('coins', type === 'MANUAL_ADJUSTMENT' ? 'write' : 'interact')
|
|
||||||
const currentUser = await getCurrentUser()
|
const currentUser = await getCurrentUser()
|
||||||
const data = await loadCoinsData()
|
const data = await loadCoinsData()
|
||||||
const newTransaction: CoinTransaction = {
|
const newTransaction: CoinTransaction = {
|
||||||
id: uuid(),
|
id: crypto.randomUUID(),
|
||||||
amount: -amount,
|
amount: -amount,
|
||||||
type,
|
type,
|
||||||
description,
|
description,
|
||||||
@@ -366,7 +370,7 @@ export async function loadUsersData(): Promise<UserData> {
|
|||||||
try {
|
try {
|
||||||
return await loadData<UserData>('auth')
|
return await loadData<UserData>('auth')
|
||||||
} catch {
|
} catch {
|
||||||
return getDefaultUsersData()
|
return getDefaultUsersData<UserData>()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -410,7 +414,7 @@ export async function createUser(formData: FormData): Promise<User> {
|
|||||||
|
|
||||||
|
|
||||||
const newUser: User = {
|
const newUser: User = {
|
||||||
id: uuid(),
|
id: crypto.randomUUID(),
|
||||||
username,
|
username,
|
||||||
password: hashedPassword,
|
password: hashedPassword,
|
||||||
permissions,
|
permissions,
|
||||||
@@ -595,3 +599,24 @@ export async function loadServerSettings(): Promise<ServerSettings> {
|
|||||||
isDemo: !!process.env.DEMO,
|
isDemo: !!process.env.DEMO,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Checks if the client's data is fresh by comparing its token with the server's token.
|
||||||
|
* @param clientToken The freshness token calculated by the client.
|
||||||
|
* @returns A promise that resolves to an object { isFresh: boolean }.
|
||||||
|
*/
|
||||||
|
export async function checkDataFreshness(clientToken: string): Promise<{ isFresh: boolean }> {
|
||||||
|
try {
|
||||||
|
const serverToken = await calculateServerFreshnessToken();
|
||||||
|
const isFresh = clientToken === serverToken;
|
||||||
|
if (!isFresh) {
|
||||||
|
console.log(`Data freshness check: Stale. Client token: ${clientToken}, Server token: ${serverToken}`);
|
||||||
|
}
|
||||||
|
return { isFresh };
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error in checkDataFreshness:", error);
|
||||||
|
// If server fails to determine its token, assume client might be stale to be safe,
|
||||||
|
// or handle error reporting differently.
|
||||||
|
return { isFresh: false };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,14 +1,8 @@
|
|||||||
import Layout from '@/components/Layout'
|
|
||||||
import HabitCalendar from '@/components/HabitCalendar'
|
import HabitCalendar from '@/components/HabitCalendar'
|
||||||
import { ViewToggle } from '@/components/ViewToggle'
|
|
||||||
import CompletionCountBadge from '@/components/CompletionCountBadge'
|
|
||||||
|
|
||||||
export default function CalendarPage() {
|
export default function CalendarPage() {
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-4">
|
<div className="flex flex-col">
|
||||||
<div className="flex justify-end">
|
|
||||||
{/* <ViewToggle /> */}
|
|
||||||
</div>
|
|
||||||
<HabitCalendar />
|
<HabitCalendar />
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
import Layout from '@/components/Layout'
|
|
||||||
import CoinsManager from '@/components/CoinsManager'
|
import CoinsManager from '@/components/CoinsManager'
|
||||||
|
|
||||||
export default function CoinsPage() {
|
export default function CoinsPage() {
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { useHabits } from "@/hooks/useHabits";
|
|
||||||
import { habitsAtom, settingsAtom } from "@/lib/atoms";
|
import { habitsAtom, settingsAtom } from "@/lib/atoms";
|
||||||
import { Habit } from "@/lib/types";
|
import { Habit } from "@/lib/types";
|
||||||
import { useAtom } from "jotai";
|
import { useAtom } from "jotai";
|
||||||
|
|||||||
@@ -1,14 +1,9 @@
|
|||||||
import Layout from '@/components/Layout'
|
|
||||||
import HabitList from '@/components/HabitList'
|
import HabitList from '@/components/HabitList'
|
||||||
import { ViewToggle } from '@/components/ViewToggle'
|
|
||||||
|
|
||||||
export default function HabitsPage() {
|
export default function HabitsPage() {
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-4">
|
<div className="flex flex-col">
|
||||||
<div className="flex justify-end">
|
<HabitList isTasksView={false} />
|
||||||
{/* <ViewToggle /> */}
|
|
||||||
</div>
|
|
||||||
<HabitList />
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,33 +1,23 @@
|
|||||||
import './globals.css'
|
|
||||||
import { Inter } from 'next/font/google'
|
|
||||||
import { DM_Sans } from 'next/font/google'
|
|
||||||
import { JotaiProvider } from '@/components/jotai-providers'
|
|
||||||
import { JotaiHydrate } from '@/components/jotai-hydrate'
|
import { JotaiHydrate } from '@/components/jotai-hydrate'
|
||||||
import { loadSettings, loadHabitsData, loadCoinsData, loadWishlistData, loadUsersData, loadServerSettings } from './actions/data'
|
import { JotaiProvider } from '@/components/jotai-providers'
|
||||||
import Layout from '@/components/Layout'
|
import Layout from '@/components/Layout'
|
||||||
import { Toaster } from '@/components/ui/toaster'
|
|
||||||
import { ThemeProvider } from "@/components/theme-provider"
|
|
||||||
import { SessionProvider } from 'next-auth/react'
|
|
||||||
import { NextIntlClientProvider } from 'next-intl';
|
|
||||||
import { getLocale, getMessages } from 'next-intl/server';
|
|
||||||
import { Suspense } from 'react'
|
|
||||||
import LoadingSpinner from '@/components/LoadingSpinner'
|
import LoadingSpinner from '@/components/LoadingSpinner'
|
||||||
|
import { ThemeProvider } from "@/components/theme-provider"
|
||||||
|
import { Toaster } from '@/components/ui/toaster'
|
||||||
// Inter (clean, modern, excellent readability)
|
import { SessionProvider } from 'next-auth/react'
|
||||||
// const inter = Inter({
|
import { NextIntlClientProvider } from 'next-intl'
|
||||||
// subsets: ['latin'],
|
import { getLocale, getMessages } from 'next-intl/server'
|
||||||
// weight: ['400', '500', '600', '700']
|
import { DM_Sans } from 'next/font/google'
|
||||||
// })
|
import { Suspense } from 'react'
|
||||||
|
import { loadCoinsData, loadHabitsData, loadServerSettings, loadSettings, loadUsersData, loadWishlistData } from './actions/data'
|
||||||
|
import './globals.css'
|
||||||
|
|
||||||
// Clean and contemporary
|
// Clean and contemporary
|
||||||
const dmSans = DM_Sans({
|
const activeFont = DM_Sans({
|
||||||
subsets: ['latin'],
|
subsets: ['latin'],
|
||||||
weight: ['400', '500', '600', '700']
|
weight: ['400', '500', '600', '700']
|
||||||
})
|
})
|
||||||
|
|
||||||
const activeFont = dmSans
|
|
||||||
|
|
||||||
export const metadata = {
|
export const metadata = {
|
||||||
title: 'HabitTrove',
|
title: 'HabitTrove',
|
||||||
description: 'Track your habits and get rewarded',
|
description: 'Track your habits and get rewarded',
|
||||||
|
|||||||
@@ -1,26 +1,24 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
import { DynamicTimeNoSSR } from '@/components/DynamicTimeNoSSR';
|
||||||
import { Switch } from '@/components/ui/switch';
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
import { Label } from '@/components/ui/label';
|
import { Label } from '@/components/ui/label';
|
||||||
|
import { Switch } from '@/components/ui/switch';
|
||||||
import {
|
import {
|
||||||
Tooltip,
|
Tooltip,
|
||||||
TooltipContent,
|
TooltipContent,
|
||||||
TooltipProvider,
|
TooltipProvider,
|
||||||
TooltipTrigger,
|
TooltipTrigger,
|
||||||
} from "@/components/ui/tooltip";
|
} from "@/components/ui/tooltip";
|
||||||
import { DynamicTimeNoSSR } from '@/components/DynamicTimeNoSSR';
|
import { toast } from '@/hooks/use-toast';
|
||||||
|
import { serverSettingsAtom, settingsAtom } from '@/lib/atoms';
|
||||||
|
import { Settings, WeekDay } from '@/lib/types';
|
||||||
import { useAtom } from 'jotai';
|
import { useAtom } from 'jotai';
|
||||||
import { useTranslations } from 'next-intl';
|
import { Info } from 'lucide-react'; // Import Info icon
|
||||||
import { settingsAtom, serverSettingsAtom } from '@/lib/atoms';
|
|
||||||
import { Settings, WeekDay } from '@/lib/types'
|
|
||||||
import { saveSettings, uploadAvatar } from '../actions/data'
|
|
||||||
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'
|
|
||||||
import { Button } from '@/components/ui/button';
|
|
||||||
import { User, Info } from 'lucide-react'; // Import Info icon
|
|
||||||
import { toast } from '@/hooks/use-toast'
|
|
||||||
import { useSession } from 'next-auth/react'; // signOut removed
|
import { useSession } from 'next-auth/react'; // signOut removed
|
||||||
|
import { useTranslations } from 'next-intl';
|
||||||
import { useRouter } from 'next/navigation';
|
import { useRouter } from 'next/navigation';
|
||||||
|
import { saveSettings } from '../actions/data';
|
||||||
// AlertDialog components and useState removed
|
// AlertDialog components and useState removed
|
||||||
// Trash2 icon removed
|
// Trash2 icon removed
|
||||||
|
|
||||||
|
|||||||
10
app/tasks/page.tsx
Normal file
10
app/tasks/page.tsx
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
import HabitList from '@/components/HabitList'
|
||||||
|
|
||||||
|
export default function TasksPage() {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col">
|
||||||
|
<HabitList isTasksView={true} />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
@@ -1,4 +1,3 @@
|
|||||||
import Layout from '@/components/Layout'
|
|
||||||
import WishlistManager from '@/components/WishlistManager'
|
import WishlistManager from '@/components/WishlistManager'
|
||||||
|
|
||||||
export default function WishlistPage() {
|
export default function WishlistPage() {
|
||||||
|
|||||||
@@ -57,11 +57,13 @@ export default function AboutModal({ onClose }: AboutModalProps) {
|
|||||||
>
|
>
|
||||||
@dohsimpson
|
@dohsimpson
|
||||||
</a>
|
</a>
|
||||||
|
<br/>
|
||||||
|
Fork by <a href="https://github.com/ManInDark" target="_blank" rel="noopener noreferrer" className="font-medium hover:underline">@ManInDark</a>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex justify-center">
|
<div className="flex justify-center">
|
||||||
<a
|
<a
|
||||||
href="https://github.com/dohsimpson/habittrove"
|
href="https://github.com/ManInDark/HabitTrove"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -1,24 +1,24 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { useState } from 'react'
|
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'
|
||||||
import { RRule, RRuleSet, rrulestr } from 'rrule'
|
|
||||||
import { useAtom } from 'jotai'
|
|
||||||
import { useTranslations } from 'next-intl'
|
|
||||||
import { settingsAtom, browserSettingsAtom, usersAtom, currentUserAtom } from '@/lib/atoms'
|
|
||||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '@/components/ui/dialog'
|
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar'
|
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog'
|
||||||
import { Input } from '@/components/ui/input'
|
import { Input } from '@/components/ui/input'
|
||||||
import { Label } from '@/components/ui/label'
|
import { Label } from '@/components/ui/label'
|
||||||
import { Textarea } from '@/components/ui/textarea'
|
|
||||||
import { Zap } from 'lucide-react'
|
|
||||||
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
|
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
|
||||||
import { Habit, SafeUser } from '@/lib/types'
|
import { Textarea } from '@/components/ui/textarea'
|
||||||
import EmojiPickerButton from './EmojiPickerButton'
|
import { currentUserAtom, settingsAtom, usersAtom } from '@/lib/atoms'
|
||||||
import ModalOverlay from './ModalOverlay' // Import the new component
|
import { INITIAL_DUE, INITIAL_RECURRENCE_RULE, MAX_COIN_LIMIT, QUICK_DATES } from '@/lib/constants'
|
||||||
import { convertHumanReadableFrequencyToMachineReadable, convertMachineReadableFrequencyToHumanReadable, d2s, d2t, serializeRRule } from '@/lib/utils'
|
import { Habit } from '@/lib/types'
|
||||||
import { INITIAL_DUE, INITIAL_RECURRENCE_RULE, QUICK_DATES, RECURRENCE_RULE_MAP, MAX_COIN_LIMIT } from '@/lib/constants'
|
import { convertHumanReadableFrequencyToMachineReadable, convertMachineReadableFrequencyToHumanReadable, d2t, serializeRRule } from '@/lib/utils'
|
||||||
|
import { useAtom } from 'jotai'
|
||||||
|
import { Zap } from 'lucide-react'
|
||||||
import { DateTime } from 'luxon'
|
import { DateTime } from 'luxon'
|
||||||
|
import { useTranslations } from 'next-intl'
|
||||||
|
import { useState } from 'react'
|
||||||
|
import { RRule } from 'rrule'
|
||||||
|
import EmojiPickerButton from './EmojiPickerButton'
|
||||||
|
import ModalOverlay from './ModalOverlay'; // Import the new component
|
||||||
|
|
||||||
|
|
||||||
interface AddEditHabitModalProps {
|
interface AddEditHabitModalProps {
|
||||||
@@ -45,7 +45,6 @@ export default function AddEditHabitModal({ onClose, onSave, habit, isTask }: Ad
|
|||||||
const [ruleText, setRuleText] = useState<string>(initialRuleText)
|
const [ruleText, setRuleText] = useState<string>(initialRuleText)
|
||||||
const [currentUser] = useAtom(currentUserAtom)
|
const [currentUser] = useAtom(currentUserAtom)
|
||||||
const [isQuickDatesOpen, setIsQuickDatesOpen] = useState(false)
|
const [isQuickDatesOpen, setIsQuickDatesOpen] = useState(false)
|
||||||
const [errorMessage, setErrorMessage] = useState<string | null>(null); // State for validation message
|
|
||||||
const [selectedUserIds, setSelectedUserIds] = useState<string[]>((habit?.userIds || []).filter(id => id !== currentUser?.id))
|
const [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
|
||||||
@@ -87,6 +86,8 @@ export default function AddEditHabitModal({ onClose, onSave, habit, isTask }: Ad
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const { result, message: errorMessage } = convertHumanReadableFrequencyToMachineReadable({ text: ruleText, timezone: settings.system.timezone, isRecurring: isRecurRule });
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<ModalOverlay />
|
<ModalOverlay />
|
||||||
@@ -184,25 +185,9 @@ export default function AddEditHabitModal({ onClose, onSave, habit, isTask }: Ad
|
|||||||
</div>
|
</div>
|
||||||
{/* rrule input (habit) */}
|
{/* rrule input (habit) */}
|
||||||
<div className="col-start-2 col-span-3 text-sm">
|
<div className="col-start-2 col-span-3 text-sm">
|
||||||
{(() => {
|
<span className={errorMessage ? 'text-destructive' : 'text-muted-foreground'}>
|
||||||
let displayText = '';
|
{errorMessage ? errorMessage : convertMachineReadableFrequencyToHumanReadable({ frequency: result, isRecurRule, timezone: settings.system.timezone })}
|
||||||
const { result, message } = convertHumanReadableFrequencyToMachineReadable({ text: ruleText, timezone: settings.system.timezone, isRecurring: isRecurRule });
|
</span>
|
||||||
if (message !== errorMessage) { // Only update if it changed to avoid re-renders
|
|
||||||
setErrorMessage(message);
|
|
||||||
}
|
|
||||||
displayText = convertMachineReadableFrequencyToHumanReadable({ frequency: result, isRecurRule, timezone: settings.system.timezone })
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<span className={errorMessage ? 'text-destructive' : 'text-muted-foreground'}>
|
|
||||||
{displayText}
|
|
||||||
</span>
|
|
||||||
{errorMessage && (
|
|
||||||
<p className="text-destructive text-xs mt-1">{errorMessage}</p>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
})()}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="grid grid-cols-4 items-center gap-4">
|
<div className="grid grid-cols-4 items-center gap-4">
|
||||||
@@ -336,4 +321,3 @@ export default function AddEditHabitModal({ onClose, onSave, habit, isTask }: Ad
|
|||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,20 +1,19 @@
|
|||||||
import { useState, useEffect } from 'react'
|
|
||||||
import { useAtom } from 'jotai'
|
|
||||||
import { useTranslations } from 'next-intl'
|
|
||||||
import { usersAtom, currentUserAtom } from '@/lib/atoms'
|
|
||||||
import { Avatar, AvatarFallback, AvatarImage } from './ui/avatar'
|
|
||||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '@/components/ui/dialog'
|
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog'
|
||||||
import { Input } from '@/components/ui/input'
|
import { Input } from '@/components/ui/input'
|
||||||
import { Label } from '@/components/ui/label'
|
import { Label } from '@/components/ui/label'
|
||||||
import { Textarea } from '@/components/ui/textarea'
|
import { Textarea } from '@/components/ui/textarea'
|
||||||
|
import { currentUserAtom, usersAtom } from '@/lib/atoms'
|
||||||
|
import { MAX_COIN_LIMIT } from '@/lib/constants'
|
||||||
import { WishlistItemType } from '@/lib/types'
|
import { WishlistItemType } from '@/lib/types'
|
||||||
|
import { useAtom } from 'jotai'
|
||||||
|
import { useTranslations } from 'next-intl'
|
||||||
|
import { useEffect, useState } from 'react'
|
||||||
import EmojiPickerButton from './EmojiPickerButton'
|
import EmojiPickerButton from './EmojiPickerButton'
|
||||||
import ModalOverlay from './ModalOverlay'
|
import ModalOverlay from './ModalOverlay'
|
||||||
import { MAX_COIN_LIMIT } from '@/lib/constants'
|
import { Avatar, AvatarFallback, AvatarImage } from './ui/avatar'
|
||||||
|
|
||||||
interface AddEditWishlistItemModalProps {
|
interface AddEditWishlistItemModalProps {
|
||||||
isOpen: boolean
|
|
||||||
setIsOpen: (isOpen: boolean) => void
|
setIsOpen: (isOpen: boolean) => void
|
||||||
editingItem: WishlistItemType | null
|
editingItem: WishlistItemType | null
|
||||||
setEditingItem: (item: WishlistItemType | null) => void
|
setEditingItem: (item: WishlistItemType | null) => void
|
||||||
@@ -23,7 +22,6 @@ interface AddEditWishlistItemModalProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function AddEditWishlistItemModal({
|
export default function AddEditWishlistItemModal({
|
||||||
isOpen,
|
|
||||||
setIsOpen,
|
setIsOpen,
|
||||||
editingItem,
|
editingItem,
|
||||||
setEditingItem,
|
setEditingItem,
|
||||||
|
|||||||
@@ -1,27 +1,29 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { ReactNode, Suspense, useEffect, useState } from 'react'
|
import { checkDataFreshness as checkServerDataFreshness } from '@/app/actions/data';
|
||||||
import { useAtom, useSetAtom } from 'jotai' // Import useSetAtom
|
import { aboutOpenAtom, clientFreshnessTokenAtom, currentUserIdAtom, pomodoroAtom, userSelectAtom } from '@/lib/atoms';
|
||||||
import { aboutOpenAtom, pomodoroAtom, userSelectAtom, currentUserIdAtom } from '@/lib/atoms' // Import currentUserIdAtom
|
import { useAtom, useAtomValue, useSetAtom } from 'jotai';
|
||||||
import PomodoroTimer from './PomodoroTimer'
|
import { useSession } from 'next-auth/react';
|
||||||
import UserSelectModal from './UserSelectModal'
|
import { ReactNode, Suspense, useCallback, useEffect, useState } from 'react';
|
||||||
import { useSession } from 'next-auth/react'
|
import AboutModal from './AboutModal';
|
||||||
import AboutModal from './AboutModal'
|
import LoadingSpinner from './LoadingSpinner';
|
||||||
import LoadingSpinner from './LoadingSpinner'
|
import PomodoroTimer from './PomodoroTimer';
|
||||||
|
import RefreshBanner from './RefreshBanner';
|
||||||
|
import UserSelectModal from './UserSelectModal';
|
||||||
|
|
||||||
export default function ClientWrapper({ children }: { children: ReactNode }) {
|
function ClientWrapperContent({ children }: { children: ReactNode }) {
|
||||||
const [pomo] = useAtom(pomodoroAtom)
|
const [pomo] = useAtom(pomodoroAtom)
|
||||||
const [userSelect, setUserSelect] = useAtom(userSelectAtom)
|
const [userSelect, setUserSelect] = useAtom(userSelectAtom)
|
||||||
const [aboutOpen, setAboutOpen] = useAtom(aboutOpenAtom)
|
const [aboutOpen, setAboutOpen] = useAtom(aboutOpenAtom)
|
||||||
const setCurrentUserIdAtom = useSetAtom(currentUserIdAtom)
|
const setCurrentUserIdAtom = useSetAtom(currentUserIdAtom)
|
||||||
const { data: session, status } = useSession()
|
const { data: session, status } = useSession()
|
||||||
const currentUserId = session?.user.id
|
const currentUserId = session?.user.id
|
||||||
const [isMounted, setIsMounted] = useState(false);
|
const [showRefreshBanner, setShowRefreshBanner] = useState(false);
|
||||||
|
|
||||||
|
// clientFreshnessTokenAtom is async, useAtomValue will suspend until it's resolved.
|
||||||
|
// Suspense boundary is in app/layout.tsx or could be added here if needed more locally.
|
||||||
|
const clientToken = useAtomValue(clientFreshnessTokenAtom);
|
||||||
|
|
||||||
// block client-side hydration until mounted (this is crucial to wait for all jotai atoms to load), to prevent SSR hydration errors in the children components
|
|
||||||
useEffect(() => {
|
|
||||||
setIsMounted(true);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (status === 'loading') return
|
if (status === 'loading') return
|
||||||
@@ -34,21 +36,62 @@ export default function ClientWrapper({ children }: { children: ReactNode }) {
|
|||||||
setCurrentUserIdAtom(currentUserId)
|
setCurrentUserIdAtom(currentUserId)
|
||||||
}, [currentUserId, setCurrentUserIdAtom])
|
}, [currentUserId, setCurrentUserIdAtom])
|
||||||
|
|
||||||
if (!isMounted) {
|
const performFreshnessCheck = useCallback(async () => {
|
||||||
return <LoadingSpinner />
|
if (!clientToken || status !== 'authenticated') return;
|
||||||
}
|
|
||||||
|
try {
|
||||||
|
const result = await checkServerDataFreshness(clientToken);
|
||||||
|
if (!result.isFresh) {
|
||||||
|
setShowRefreshBanner(true);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to check data freshness with server:", error);
|
||||||
|
}
|
||||||
|
}, [clientToken, status]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// Interval for polling data freshness
|
||||||
|
if (clientToken && !showRefreshBanner && status === 'authenticated') {
|
||||||
|
const intervalId = setInterval(() => {
|
||||||
|
performFreshnessCheck();
|
||||||
|
}, 30000); // Check every 30 seconds
|
||||||
|
|
||||||
|
return () => clearInterval(intervalId);
|
||||||
|
}
|
||||||
|
}, [clientToken, performFreshnessCheck, showRefreshBanner, status]);
|
||||||
|
|
||||||
|
const handleRefresh = () => {
|
||||||
|
setShowRefreshBanner(false);
|
||||||
|
window.location.reload();
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{children}
|
{children}
|
||||||
{pomo.show && (
|
{pomo.show && <PomodoroTimer />}
|
||||||
<PomodoroTimer />
|
{userSelect && <UserSelectModal onClose={() => setUserSelect(false)} />}
|
||||||
)}
|
{aboutOpen && <AboutModal onClose={() => setAboutOpen(false)} />}
|
||||||
{userSelect && (
|
{showRefreshBanner && <RefreshBanner onRefresh={handleRefresh} />}
|
||||||
<UserSelectModal onClose={() => setUserSelect(false)} />
|
|
||||||
)}
|
|
||||||
{aboutOpen && (
|
|
||||||
<AboutModal onClose={() => setAboutOpen(false)} />
|
|
||||||
)}
|
|
||||||
</>
|
</>
|
||||||
)
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ClientWrapper({ children }: { children: ReactNode }) {
|
||||||
|
const [isMounted, setIsMounted] = useState(false);
|
||||||
|
|
||||||
|
// block client-side hydration until mounted (this is crucial to wait for all jotai atoms to load),
|
||||||
|
// to prevent SSR hydration errors in the children components
|
||||||
|
useEffect(() => {
|
||||||
|
setIsMounted(true);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
if (!isMounted) {
|
||||||
|
return <LoadingSpinner />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Suspense fallback={<LoadingSpinner />}>
|
||||||
|
<ClientWrapperContent>{children}</ClientWrapperContent>
|
||||||
|
</Suspense>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,23 +1,23 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { useState, useEffect, useRef } from 'react' // Import useEffect, useRef
|
|
||||||
import { useSearchParams } from 'next/navigation' // Import useSearchParams
|
|
||||||
import { t2d, d2s, getNow, isSameDate } from '@/lib/utils'
|
|
||||||
import { Button } from '@/components/ui/button'
|
|
||||||
import { FormattedNumber } from '@/components/FormattedNumber'
|
import { FormattedNumber } from '@/components/FormattedNumber'
|
||||||
import { History, Pencil } from 'lucide-react'
|
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'
|
||||||
import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar'
|
import { Button } from '@/components/ui/button'
|
||||||
import EmptyState from './EmptyState'
|
|
||||||
import { Input } from '@/components/ui/input'
|
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||||
import { settingsAtom, usersAtom, currentUserAtom } from '@/lib/atoms'
|
import { Input } from '@/components/ui/input'
|
||||||
import Link from 'next/link'
|
|
||||||
import { useAtom } from 'jotai'
|
|
||||||
import { useTranslations } from 'next-intl'
|
|
||||||
import { useCoins } from '@/hooks/useCoins'
|
import { useCoins } from '@/hooks/useCoins'
|
||||||
|
import { currentUserAtom, settingsAtom, usersAtom } from '@/lib/atoms'
|
||||||
import { MAX_COIN_LIMIT } from '@/lib/constants'
|
import { MAX_COIN_LIMIT } from '@/lib/constants'
|
||||||
import { TransactionNoteEditor } from './TransactionNoteEditor'
|
|
||||||
import { TransactionType } from '@/lib/types'
|
import { TransactionType } from '@/lib/types'
|
||||||
|
import { d2s, t2d } from '@/lib/utils'
|
||||||
|
import { useAtom } from 'jotai'
|
||||||
|
import { History } from 'lucide-react'
|
||||||
|
import { useTranslations } from 'next-intl'
|
||||||
|
import Link from 'next/link'
|
||||||
|
import { useSearchParams } from 'next/navigation'; // Import useSearchParams
|
||||||
|
import { useEffect, useRef, useState } from 'react'; // Import useEffect, useRef
|
||||||
|
import EmptyState from './EmptyState'
|
||||||
|
import { TransactionNoteEditor } from './TransactionNoteEditor'
|
||||||
|
|
||||||
export default function CoinsManager() {
|
export default function CoinsManager() {
|
||||||
const t = useTranslations('CoinsManager')
|
const t = useTranslations('CoinsManager')
|
||||||
@@ -46,6 +46,7 @@ export default function CoinsManager() {
|
|||||||
const highlightId = searchParams.get('highlight')
|
const highlightId = searchParams.get('highlight')
|
||||||
const userIdFromQuery = searchParams.get('user') // Get user ID from query
|
const userIdFromQuery = searchParams.get('user') // Get user ID from query
|
||||||
const transactionRefs = useRef<Record<string, HTMLDivElement | null>>({});
|
const transactionRefs = useRef<Record<string, HTMLDivElement | null>>({});
|
||||||
|
const PAGE_ENTRY_COUNTS = [10, 50, 100, 500];
|
||||||
|
|
||||||
// Effect to set selected user from query param if admin
|
// Effect to set selected user from query param if admin
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -56,7 +57,7 @@ export default function CoinsManager() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Only run when userIdFromQuery or currentUser changes, avoid re-running on selectedUser change within this effect
|
// Only run when userIdFromQuery or currentUser changes, avoid re-running on selectedUser change within this effect
|
||||||
}, [userIdFromQuery, currentUser, usersData.users]);
|
}, [userIdFromQuery, currentUser, usersData.users, selectedUser]);
|
||||||
|
|
||||||
// Effect to scroll to highlighted transaction
|
// Effect to scroll to highlighted transaction
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -106,7 +107,7 @@ export default function CoinsManager() {
|
|||||||
<h1 className="text-xl xs:text-3xl font-bold mr-6">{t('title')}</h1>
|
<h1 className="text-xl xs:text-3xl font-bold mr-6">{t('title')}</h1>
|
||||||
{currentUser?.isAdmin && (
|
{currentUser?.isAdmin && (
|
||||||
<select
|
<select
|
||||||
className="w-[110px] xs:w-[200px] rounded-md border border-input bg-background px-3 py-2"
|
className="border rounded p-2"
|
||||||
value={selectedUser}
|
value={selectedUser}
|
||||||
onChange={(e) => setSelectedUser(e.target.value)}
|
onChange={(e) => setSelectedUser(e.target.value)}
|
||||||
>
|
>
|
||||||
@@ -274,9 +275,7 @@ export default function CoinsManager() {
|
|||||||
setCurrentPage(1) // Reset to first page when changing page size
|
setCurrentPage(1) // Reset to first page when changing page size
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<option value={50}>50</option>
|
{PAGE_ENTRY_COUNTS.map(n => <option key={n} value={n}>{n}</option>)}
|
||||||
<option value={100}>100</option>
|
|
||||||
<option value={500}>500</option>
|
|
||||||
</select>
|
</select>
|
||||||
<span className="text-sm text-muted-foreground">{t('entriesSuffix')}</span>
|
<span className="text-sm text-muted-foreground">{t('entriesSuffix')}</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -312,6 +311,7 @@ export default function CoinsManager() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const isHighlighted = transaction.id === highlightId;
|
const isHighlighted = transaction.id === highlightId;
|
||||||
|
const transactionUser = usersData.users.find(u => u.id === transaction.userId);
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={transaction.id}
|
key={transaction.id}
|
||||||
@@ -340,12 +340,12 @@ export default function CoinsManager() {
|
|||||||
{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={transactionUser?.avatarPath ?
|
||||||
`/api/avatars/${usersData.users.find(u => u.id === transaction.userId)?.avatarPath?.split('/').pop()}` : undefined}
|
`/api/avatars/${transactionUser?.avatarPath?.split('/').pop()}` : undefined}
|
||||||
alt={usersData.users.find(u => u.id === transaction.userId)?.username}
|
alt={transactionUser?.username}
|
||||||
/>
|
/>
|
||||||
<AvatarFallback>
|
<AvatarFallback>
|
||||||
{usersData.users.find(u => u.id === transaction.userId)?.username?.[0] || '?'}
|
{transactionUser?.username?.[0] || '?'}
|
||||||
</AvatarFallback>
|
</AvatarFallback>
|
||||||
</Avatar>
|
</Avatar>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,9 +1,7 @@
|
|||||||
import { Badge } from "@/components/ui/badge"
|
import { Badge } from "@/components/ui/badge"
|
||||||
import { useAtom } from 'jotai'
|
import { completedHabitsMapAtom, habitsByDateFamily, settingsAtom } from '@/lib/atoms'
|
||||||
import { completedHabitsMapAtom, habitsAtom, habitsByDateFamily } from '@/lib/atoms'
|
|
||||||
import { getTodayInTimezone } from '@/lib/utils'
|
import { getTodayInTimezone } from '@/lib/utils'
|
||||||
// import { useHabits } from '@/hooks/useHabits' // Not used
|
import { useAtom } from 'jotai'
|
||||||
import { settingsAtom } from '@/lib/atoms'
|
|
||||||
import { useTranslations } from 'next-intl'
|
import { useTranslations } from 'next-intl'
|
||||||
|
|
||||||
interface CompletionCountBadgeProps {
|
interface CompletionCountBadgeProps {
|
||||||
|
|||||||
@@ -1,36 +1,33 @@
|
|||||||
import { Circle, Coins, ArrowRight, CircleCheck, ChevronDown, ChevronUp, Plus, Pin, AlertTriangle } from 'lucide-react' // Removed unused icons
|
import { Badge } from '@/components/ui/badge'
|
||||||
import CompletionCountBadge from './CompletionCountBadge'
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||||
import {
|
import {
|
||||||
ContextMenu,
|
ContextMenu,
|
||||||
ContextMenuContent,
|
ContextMenuContent,
|
||||||
ContextMenuItem,
|
ContextMenuTrigger
|
||||||
ContextMenuSeparator,
|
|
||||||
ContextMenuTrigger,
|
|
||||||
} from "@/components/ui/context-menu"
|
} from "@/components/ui/context-menu"
|
||||||
import { cn } from '@/lib/utils'
|
import { Progress } from '@/components/ui/progress'
|
||||||
import Link from 'next/link'
|
|
||||||
import { useState } from 'react'
|
|
||||||
import { useAtom } from 'jotai'
|
|
||||||
import { useTranslations } from 'next-intl'
|
|
||||||
import { pomodoroAtom, settingsAtom, completedHabitsMapAtom, browserSettingsAtom, BrowserSettings, hasTasksAtom } from '@/lib/atoms'
|
|
||||||
import { getTodayInTimezone, isSameDate, t2d, d2t, getNow, isHabitDue, isTaskOverdue } from '@/lib/utils'
|
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
|
||||||
import { Badge } from '@/components/ui/badge'
|
|
||||||
import {
|
import {
|
||||||
Tooltip,
|
Tooltip,
|
||||||
TooltipContent,
|
TooltipContent,
|
||||||
TooltipProvider,
|
TooltipProvider,
|
||||||
TooltipTrigger,
|
TooltipTrigger,
|
||||||
} from "@/components/ui/tooltip"
|
} from "@/components/ui/tooltip"
|
||||||
import { Progress } from '@/components/ui/progress'
|
|
||||||
import { Settings, WishlistItemType } from '@/lib/types'
|
|
||||||
import { Habit } from '@/lib/types'
|
|
||||||
import Linkify from './linkify'
|
|
||||||
import { useHabits } from '@/hooks/useHabits'
|
import { useHabits } from '@/hooks/useHabits'
|
||||||
|
import { browserSettingsAtom, completedHabitsMapAtom, hasTasksAtom, pomodoroAtom, settingsAtom } from '@/lib/atoms'
|
||||||
|
import { Habit, WishlistItemType } from '@/lib/types'
|
||||||
|
import { cn, d2t, getNow, getTodayInTimezone, isHabitDue, isSameDate, isTaskOverdue, t2d } from '@/lib/utils'
|
||||||
|
import { useAtom } from 'jotai'
|
||||||
|
import { AlertTriangle, ArrowRight, ChevronDown, ChevronUp, Circle, CircleCheck, Coins, Pin, Plus } from 'lucide-react'; // Removed unused icons
|
||||||
|
import { useTranslations } from 'next-intl'
|
||||||
|
import Link from 'next/link'
|
||||||
|
import { useState } from 'react'
|
||||||
import AddEditHabitModal from './AddEditHabitModal'
|
import AddEditHabitModal from './AddEditHabitModal'
|
||||||
|
import CompletionCountBadge from './CompletionCountBadge'
|
||||||
import ConfirmDialog from './ConfirmDialog'
|
import ConfirmDialog from './ConfirmDialog'
|
||||||
import { Button } from './ui/button'
|
|
||||||
import { HabitContextMenuItems } from './HabitContextMenuItems'
|
import { HabitContextMenuItems } from './HabitContextMenuItems'
|
||||||
|
import Linkify from './linkify'
|
||||||
|
import { Button } from './ui/button'
|
||||||
|
import { DESKTOP_DISPLAY_ITEM_COUNT } from '@/lib/constants'
|
||||||
|
|
||||||
interface UpcomingItemsProps {
|
interface UpcomingItemsProps {
|
||||||
habits: Habit[]
|
habits: Habit[]
|
||||||
@@ -169,7 +166,7 @@ const ItemSection = ({
|
|||||||
const bTarget = b.targetCompletions || 1;
|
const bTarget = b.targetCompletions || 1;
|
||||||
return bTarget - aTarget;
|
return bTarget - aTarget;
|
||||||
})
|
})
|
||||||
.slice(0, currentExpanded ? undefined : 5)
|
.slice(0, currentExpanded ? undefined : DESKTOP_DISPLAY_ITEM_COUNT)
|
||||||
.map((habit) => {
|
.map((habit) => {
|
||||||
const completionsToday = habit.completions.filter(completion =>
|
const completionsToday = habit.completions.filter(completion =>
|
||||||
isSameDate(t2d({ timestamp: completion, timezone: settings.system.timezone }), t2d({ timestamp: d2t({ dateTime: getNow({ timezone: settings.system.timezone }) }), timezone: settings.system.timezone }))
|
isSameDate(t2d({ timestamp: completion, timezone: settings.system.timezone }), t2d({ timestamp: d2t({ dateTime: getNow({ timezone: settings.system.timezone }) }), timezone: settings.system.timezone }))
|
||||||
@@ -226,12 +223,6 @@ const ItemSection = ({
|
|||||||
<Link
|
<Link
|
||||||
href={`/habits?highlight=${habit.id}`}
|
href={`/habits?highlight=${habit.id}`}
|
||||||
className="flex items-center gap-1 hover:text-primary transition-colors"
|
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 && (
|
{isTask && isTaskOverdue(habit, settings.system.timezone) && !isCompleted && (
|
||||||
<TooltipProvider>
|
<TooltipProvider>
|
||||||
@@ -305,7 +296,7 @@ const ItemSection = ({
|
|||||||
onClick={() => setCurrentExpanded(!currentExpanded)}
|
onClick={() => setCurrentExpanded(!currentExpanded)}
|
||||||
className="text-sm text-muted-foreground hover:text-primary flex items-center gap-1"
|
className="text-sm text-muted-foreground hover:text-primary flex items-center gap-1"
|
||||||
>
|
>
|
||||||
{currentExpanded ? (
|
{items.length > DESKTOP_DISPLAY_ITEM_COUNT && (currentExpanded ? (
|
||||||
<>
|
<>
|
||||||
{t('showLessButton')}
|
{t('showLessButton')}
|
||||||
<ChevronUp className="h-3 w-3" />
|
<ChevronUp className="h-3 w-3" />
|
||||||
@@ -315,17 +306,11 @@ const ItemSection = ({
|
|||||||
{t('showAllButton')}
|
{t('showAllButton')}
|
||||||
<ChevronDown className="h-3 w-3" />
|
<ChevronDown className="h-3 w-3" />
|
||||||
</>
|
</>
|
||||||
)}
|
))}
|
||||||
</button>
|
</button>
|
||||||
<Link
|
<Link
|
||||||
href={viewLink}
|
href={viewLink}
|
||||||
className="text-sm text-muted-foreground hover:text-primary flex items-center gap-1"
|
className="text-sm text-muted-foreground hover:text-primary flex items-center gap-1"
|
||||||
onClick={() => {
|
|
||||||
const newViewType = isTask ? 'tasks' : 'habits';
|
|
||||||
if (browserSettings.viewType !== newViewType) {
|
|
||||||
setBrowserSettings(prev => ({ ...prev, viewType: newViewType }));
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
View
|
View
|
||||||
<ArrowRight className="h-3 w-3" />
|
<ArrowRight className="h-3 w-3" />
|
||||||
@@ -460,7 +445,7 @@ export default function DailyOverview({
|
|||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
{sortedWishlistItems
|
{sortedWishlistItems
|
||||||
.slice(0, browserSettings.expandedWishlist ? undefined : 5)
|
.slice(0, browserSettings.expandedWishlist ? undefined : DESKTOP_DISPLAY_ITEM_COUNT)
|
||||||
.map((item) => {
|
.map((item) => {
|
||||||
const isRedeemable = item.coinCost <= coinBalance
|
const isRedeemable = item.coinCost <= coinBalance
|
||||||
return (
|
return (
|
||||||
@@ -517,7 +502,7 @@ export default function DailyOverview({
|
|||||||
onClick={() => setBrowserSettings(prev => ({ ...prev, expandedWishlist: !prev.expandedWishlist }))}
|
onClick={() => setBrowserSettings(prev => ({ ...prev, expandedWishlist: !prev.expandedWishlist }))}
|
||||||
className="text-sm text-muted-foreground hover:text-primary flex items-center gap-1"
|
className="text-sm text-muted-foreground hover:text-primary flex items-center gap-1"
|
||||||
>
|
>
|
||||||
{browserSettings.expandedWishlist ? (
|
{wishlistItems.length > DESKTOP_DISPLAY_ITEM_COUNT && (browserSettings.expandedWishlist ? (
|
||||||
<>
|
<>
|
||||||
{t('showLessButton')}
|
{t('showLessButton')}
|
||||||
<ChevronUp className="h-3 w-3" />
|
<ChevronUp className="h-3 w-3" />
|
||||||
@@ -527,7 +512,7 @@ export default function DailyOverview({
|
|||||||
{t('showAllButton')}
|
{t('showAllButton')}
|
||||||
<ChevronDown className="h-3 w-3" />
|
<ChevronDown className="h-3 w-3" />
|
||||||
</>
|
</>
|
||||||
)}
|
))}
|
||||||
</button>
|
</button>
|
||||||
<Link
|
<Link
|
||||||
href="/wishlist"
|
href="/wishlist"
|
||||||
|
|||||||
@@ -1,19 +1,17 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
|
import { useCoins } from '@/hooks/useCoins'
|
||||||
|
import { habitsAtom, wishlistAtom } from '@/lib/atoms'
|
||||||
import { useAtom } from 'jotai'
|
import { useAtom } from 'jotai'
|
||||||
import { wishlistAtom, habitsAtom, settingsAtom } from '@/lib/atoms'
|
import { useTranslations } from 'next-intl'
|
||||||
|
import CoinBalance from './CoinBalance'
|
||||||
import DailyOverview from './DailyOverview'
|
import DailyOverview from './DailyOverview'
|
||||||
import HabitStreak from './HabitStreak'
|
import HabitStreak from './HabitStreak'
|
||||||
import CoinBalance from './CoinBalance'
|
|
||||||
// import { useHabits } from '@/hooks/useHabits' // useHabits is not used
|
|
||||||
import { useCoins } from '@/hooks/useCoins'
|
|
||||||
import { useTranslations } from 'next-intl';
|
|
||||||
|
|
||||||
export default function Dashboard() {
|
export default function Dashboard() {
|
||||||
const t = useTranslations('Dashboard');
|
const t = useTranslations('Dashboard');
|
||||||
const [habitsData] = useAtom(habitsAtom)
|
const [habitsData] = useAtom(habitsAtom)
|
||||||
const habits = habitsData.habits
|
const habits = habitsData.habits
|
||||||
const [settings] = useAtom(settingsAtom)
|
|
||||||
const { balance } = useCoins()
|
const { balance } = useCoins()
|
||||||
const [wishlist] = useAtom(wishlistAtom)
|
const [wishlist] = useAtom(wishlistAtom)
|
||||||
const wishlistItems = wishlist.items
|
const wishlistItems = wishlist.items
|
||||||
|
|||||||
@@ -1,42 +0,0 @@
|
|||||||
import Link from 'next/link'
|
|
||||||
import type { ElementType } from 'react'
|
|
||||||
|
|
||||||
export interface NavItemType {
|
|
||||||
icon: ElementType;
|
|
||||||
label: string;
|
|
||||||
href: string;
|
|
||||||
position: 'main' | 'bottom';
|
|
||||||
}
|
|
||||||
|
|
||||||
interface DesktopNavDisplayProps {
|
|
||||||
navItems: NavItemType[];
|
|
||||||
className?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function DesktopNavDisplay({ navItems, className }: DesktopNavDisplayProps) {
|
|
||||||
// Filter for items relevant to desktop view, typically 'main' position
|
|
||||||
const desktopNavItems = navItems.filter(item => item.position === 'main');
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className={`hidden lg:flex lg:flex-shrink-0 ${className || ''}`}>
|
|
||||||
<div className="flex flex-col w-64">
|
|
||||||
<div className="flex flex-col h-0 flex-1 bg-gray-800">
|
|
||||||
<div className="flex-1 flex flex-col pt-5 pb-4 overflow-y-auto">
|
|
||||||
<nav className="mt-5 flex-1 px-2 space-y-1">
|
|
||||||
{desktopNavItems.map((item) => (
|
|
||||||
<Link
|
|
||||||
key={item.label} // Assuming labels are unique
|
|
||||||
href={item.href}
|
|
||||||
className="group flex items-center px-2 py-2 text-sm leading-6 font-medium rounded-md text-gray-300 hover:text-white hover:bg-gray-700"
|
|
||||||
>
|
|
||||||
<item.icon className="mr-4 flex-shrink-0 h-6 w-6 text-gray-400" aria-hidden="true" />
|
|
||||||
{item.label}
|
|
||||||
</Link>
|
|
||||||
))}
|
|
||||||
</nav>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,19 +1,18 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { useState, useMemo, useCallback } from 'react'
|
import CompletionCountBadge from '@/components/CompletionCountBadge'
|
||||||
import { Calendar } from '@/components/ui/calendar'
|
import { Calendar } from '@/components/ui/calendar'
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||||
import CompletionCountBadge from '@/components/CompletionCountBadge'
|
|
||||||
import { Button } from '@/components/ui/button'
|
|
||||||
import { Check, Circle, CircleCheck } from 'lucide-react'
|
|
||||||
import { d2s, getNow, t2d, isHabitDue, getISODate, getCompletionsForDate } from '@/lib/utils'
|
|
||||||
import { useAtom } from 'jotai'
|
|
||||||
import { useTranslations } from 'next-intl'
|
|
||||||
import { useHabits } from '@/hooks/useHabits'
|
import { useHabits } from '@/hooks/useHabits'
|
||||||
import { habitsAtom, settingsAtom, completedHabitsMapAtom, hasTasksAtom } from '@/lib/atoms'
|
import { completedHabitsMapAtom, habitsAtom, hasTasksAtom, settingsAtom } from '@/lib/atoms'
|
||||||
import { DateTime } from 'luxon'
|
|
||||||
import Linkify from './linkify'
|
|
||||||
import { Habit } from '@/lib/types'
|
import { Habit } from '@/lib/types'
|
||||||
|
import { d2s, getCompletionsForDate, getISODate, getNow, isHabitDue } from '@/lib/utils'
|
||||||
|
import { useAtom } from 'jotai'
|
||||||
|
import { Circle, CircleCheck } from 'lucide-react'
|
||||||
|
import { DateTime } from 'luxon'
|
||||||
|
import { useTranslations } from 'next-intl'
|
||||||
|
import { useCallback, useMemo, useState } from 'react'
|
||||||
|
import Linkify from './linkify'
|
||||||
|
|
||||||
export default function HabitCalendar() {
|
export default function HabitCalendar() {
|
||||||
const t = useTranslations('HabitCalendar')
|
const t = useTranslations('HabitCalendar')
|
||||||
@@ -44,7 +43,7 @@ export default function HabitCalendar() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-xl xs:text-3xl font-semibold mb-6">{t('title')}</h1>
|
<h1 className="text-xl xs:text-3xl font-bold mb-6">{t('title')}</h1>
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { Habit, User } from '@/lib/types';
|
import { Habit } from '@/lib/types';
|
||||||
import { useHabits } from '@/hooks/useHabits';
|
import { useHabits } from '@/hooks/useHabits';
|
||||||
import { useAtom } from 'jotai';
|
import { useAtom } from 'jotai';
|
||||||
import { pomodoroAtom, settingsAtom, currentUserAtom } from '@/lib/atoms';
|
import { pomodoroAtom, settingsAtom, currentUserAtom } from '@/lib/atoms';
|
||||||
|
|||||||
@@ -1,25 +1,21 @@
|
|||||||
import { Habit, SafeUser, User, Permission } from '@/lib/types'
|
|
||||||
import { useAtom } from 'jotai'
|
|
||||||
import { settingsAtom, pomodoroAtom, browserSettingsAtom, usersAtom, currentUserAtom } from '@/lib/atoms'
|
|
||||||
import { getTodayInTimezone, isSameDate, t2d, d2t, getNow, d2s, getCompletionsForToday, isTaskOverdue, convertMachineReadableFrequencyToHumanReadable } from '@/lib/utils'
|
|
||||||
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from '@/components/ui/card'
|
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import { Coins, Edit, Check, Undo2, MoreVertical, Pin } from 'lucide-react'
|
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from '@/components/ui/card'
|
||||||
import {
|
import {
|
||||||
DropdownMenu,
|
DropdownMenu,
|
||||||
DropdownMenuContent,
|
DropdownMenuContent,
|
||||||
DropdownMenuItem,
|
DropdownMenuTrigger
|
||||||
DropdownMenuSeparator,
|
|
||||||
DropdownMenuTrigger,
|
|
||||||
} from '@/components/ui/dropdown-menu'
|
} from '@/components/ui/dropdown-menu'
|
||||||
import { useEffect, useState } from 'react'
|
|
||||||
import { useHabits } from '@/hooks/useHabits'
|
import { useHabits } from '@/hooks/useHabits'
|
||||||
|
import { currentUserAtom, settingsAtom, usersAtom } from '@/lib/atoms'
|
||||||
|
import { Habit, User } from '@/lib/types'
|
||||||
|
import { convertMachineReadableFrequencyToHumanReadable, getCompletionsForToday, hasPermission, isTaskOverdue } from '@/lib/utils'
|
||||||
|
import { useAtom } from 'jotai'
|
||||||
|
import { Check, Coins, Edit, MoreVertical, Pin, Undo2 } from 'lucide-react'
|
||||||
import { useTranslations } from 'next-intl'
|
import { useTranslations } from 'next-intl'
|
||||||
import { INITIAL_RECURRENCE_RULE, RECURRENCE_RULE_MAP } from '@/lib/constants'
|
import { usePathname } from 'next/navigation'
|
||||||
import { DateTime } from 'luxon'
|
import { useEffect, useState } from 'react'
|
||||||
import { Avatar, AvatarFallback, AvatarImage } from './ui/avatar'
|
|
||||||
import { hasPermission } from '@/lib/utils'
|
|
||||||
import { HabitContextMenuItems } from './HabitContextMenuItems'
|
import { HabitContextMenuItems } from './HabitContextMenuItems'
|
||||||
|
import { Avatar, AvatarFallback, AvatarImage } from './ui/avatar'
|
||||||
|
|
||||||
interface HabitItemProps {
|
interface HabitItemProps {
|
||||||
habit: Habit
|
habit: Habit
|
||||||
@@ -48,21 +44,18 @@ const renderUserAvatars = (habit: Habit, currentUser: User | null, usersData: {
|
|||||||
|
|
||||||
|
|
||||||
export default function HabitItem({ habit, onEdit, onDelete }: HabitItemProps) {
|
export default function HabitItem({ habit, onEdit, onDelete }: HabitItemProps) {
|
||||||
const { completeHabit, undoComplete, archiveHabit, unarchiveHabit, saveHabit } = useHabits()
|
const { completeHabit, undoComplete } = useHabits()
|
||||||
const [settings] = useAtom(settingsAtom)
|
const [settings] = useAtom(settingsAtom)
|
||||||
const [_, setPomo] = useAtom(pomodoroAtom)
|
|
||||||
const completionsToday = getCompletionsForToday({ habit, timezone: settings.system.timezone })
|
const completionsToday = getCompletionsForToday({ habit, timezone: settings.system.timezone })
|
||||||
const target = habit.targetCompletions || 1
|
const target = habit.targetCompletions || 1
|
||||||
const isCompletedToday = completionsToday >= target
|
const isCompletedToday = completionsToday >= target
|
||||||
const [isHighlighted, setIsHighlighted] = useState(false)
|
const [isHighlighted, setIsHighlighted] = useState(false)
|
||||||
const t = useTranslations('HabitItem');
|
const t = useTranslations('HabitItem');
|
||||||
const [usersData] = useAtom(usersAtom)
|
const [usersData] = useAtom(usersAtom)
|
||||||
|
const pathname = usePathname();
|
||||||
const [currentUser] = useAtom(currentUserAtom)
|
const [currentUser] = useAtom(currentUserAtom)
|
||||||
const canWrite = hasPermission(currentUser, 'habit', 'write')
|
const canWrite = hasPermission(currentUser, 'habit', 'write')
|
||||||
const canInteract = hasPermission(currentUser, 'habit', 'interact')
|
const canInteract = hasPermission(currentUser, 'habit', 'interact')
|
||||||
const [browserSettings] = useAtom(browserSettingsAtom)
|
|
||||||
const isTasksView = browserSettings.viewType === 'tasks'
|
|
||||||
const isRecurRule = !isTasksView
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const params = new URLSearchParams(window.location.search)
|
const params = new URLSearchParams(window.location.search)
|
||||||
@@ -90,7 +83,7 @@ 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 ${pathname.includes("tasks") ? 'w-full' : ''} justify-between`}>
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1">
|
||||||
{habit.pinned && (
|
{habit.pinned && (
|
||||||
<Pin className="h-4 w-4 text-yellow-500" />
|
<Pin className="h-4 w-4 text-yellow-500" />
|
||||||
@@ -116,7 +109,7 @@ export default function HabitItem({ habit, onEdit, onDelete }: HabitItemProps) {
|
|||||||
{t('whenLabel', {
|
{t('whenLabel', {
|
||||||
frequency: convertMachineReadableFrequencyToHumanReadable({
|
frequency: convertMachineReadableFrequencyToHumanReadable({
|
||||||
frequency: habit.frequency,
|
frequency: habit.frequency,
|
||||||
isRecurRule,
|
isRecurRule: pathname.includes("habits"),
|
||||||
timezone: settings.system.timezone
|
timezone: settings.system.timezone
|
||||||
})
|
})
|
||||||
})}
|
})}
|
||||||
|
|||||||
@@ -1,32 +1,28 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { useState, useMemo, useEffect } from 'react' // Added useMemo, useEffect
|
|
||||||
import { Plus, ArrowUpNarrowWide, ArrowDownWideNarrow, Search } from 'lucide-react' // Added sort icons, Search icon
|
|
||||||
import { useAtom } from 'jotai'
|
|
||||||
import { useTranslations } from 'next-intl'
|
|
||||||
import { habitsAtom, settingsAtom, browserSettingsAtom } from '@/lib/atoms'
|
|
||||||
import EmptyState from './EmptyState'
|
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import HabitItem from './HabitItem'
|
import { Input } from '@/components/ui/input'; // Added
|
||||||
|
import { Label } from '@/components/ui/label'; // Added
|
||||||
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; // Added
|
||||||
|
import { useHabits } from '@/hooks/useHabits'
|
||||||
|
import { habitsAtom } from '@/lib/atoms'
|
||||||
|
import { HabitIcon, TaskIcon } from '@/lib/constants'
|
||||||
|
import { Habit } from '@/lib/types'
|
||||||
|
import { getHabitFreq } from '@/lib/utils'; // Added
|
||||||
|
import { useAtom } from 'jotai'
|
||||||
|
import { ArrowDownWideNarrow, ArrowUpNarrowWide, Plus, Search } from 'lucide-react'; // Added sort icons, Search icon
|
||||||
|
import { DateTime } from 'luxon'; // Added
|
||||||
|
import { useTranslations } from 'next-intl'
|
||||||
|
import { useEffect, useMemo, useState } from 'react'; // Added useMemo, useEffect
|
||||||
import AddEditHabitModal from './AddEditHabitModal'
|
import AddEditHabitModal from './AddEditHabitModal'
|
||||||
import ConfirmDialog from './ConfirmDialog'
|
import ConfirmDialog from './ConfirmDialog'
|
||||||
import { Habit } from '@/lib/types'
|
import EmptyState from './EmptyState'
|
||||||
import { useHabits } from '@/hooks/useHabits'
|
import HabitItem from './HabitItem'
|
||||||
import { HabitIcon, TaskIcon } from '@/lib/constants'
|
|
||||||
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({ isTasksView }: { isTasksView: boolean}) {
|
||||||
const t = useTranslations('HabitList');
|
const t = useTranslations('HabitList');
|
||||||
const { saveHabit, deleteHabit } = useHabits()
|
const { saveHabit, deleteHabit } = useHabits()
|
||||||
const [habitsData] = useAtom(habitsAtom) // setHabitsData removed as it's not used
|
const [habitsData] = useAtom(habitsAtom) // setHabitsData removed as it's not used
|
||||||
const [browserSettings] = useAtom(browserSettingsAtom)
|
|
||||||
const isTasksView = browserSettings.viewType === 'tasks'
|
|
||||||
// const [settings] = useAtom(settingsAtom); // settingsAtom is not directly used in HabitList itself.
|
|
||||||
|
|
||||||
type SortableField = 'name' | 'coinReward' | 'dueDate' | 'frequency';
|
type SortableField = 'name' | 'coinReward' | 'dueDate' | 'frequency';
|
||||||
type SortOrder = 'asc' | 'desc';
|
type SortOrder = 'asc' | 'desc';
|
||||||
@@ -130,17 +126,11 @@ export default function HabitList() {
|
|||||||
{t(isTasksView ? 'myTasks' : 'myHabits')}
|
{t(isTasksView ? 'myTasks' : 'myHabits')}
|
||||||
</h1>
|
</h1>
|
||||||
<span>
|
<span>
|
||||||
<Button className="mr-2" onClick={() => setModalConfig({ isOpen: true, isTask: true })}>
|
<Button onClick={() => setModalConfig({ isOpen: true, isTask: isTasksView })}>
|
||||||
<Plus className="mr-2 h-4 w-4" /> {t('addTaskButton')}
|
<Plus className='mr-2 h-4 w-4' />{isTasksView ? t("addTaskButton") : t("addHabitButton")}
|
||||||
</Button>
|
|
||||||
<Button onClick={() => setModalConfig({ isOpen: true, isTask: false })}>
|
|
||||||
<Plus className="mr-2 h-4 w-4" /> {t('addHabitButton')}
|
|
||||||
</Button>
|
</Button>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className='py-4'>
|
|
||||||
<ViewToggle />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Search and Sort Controls */}
|
{/* Search and Sort Controls */}
|
||||||
<div className="flex flex-col sm:flex-row items-center gap-4 my-4">
|
<div className="flex flex-col sm:flex-row items-center gap-4 my-4">
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { Habit } from '@/lib/types'
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
|
import { completedHabitsMapAtom, hasTasksAtom, settingsAtom } from '@/lib/atoms'; // Added completedHabitsMapAtom
|
||||||
import { d2s, getNow, t2d } from '@/lib/utils' // Removed getCompletedHabitsForDate
|
import { Habit } from '@/lib/types';
|
||||||
import { LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer } from 'recharts'
|
import { d2s, getNow } from '@/lib/utils'; // Removed getCompletedHabitsForDate
|
||||||
import { useAtom } from 'jotai'
|
import { useAtom } from 'jotai';
|
||||||
import { useTranslations } from 'next-intl'
|
import { useTranslations } from 'next-intl';
|
||||||
import { settingsAtom, hasTasksAtom, completedHabitsMapAtom } from '@/lib/atoms' // Added completedHabitsMapAtom
|
import { CartesianGrid, Line, LineChart, ResponsiveContainer, Tooltip, XAxis, YAxis } from 'recharts';
|
||||||
|
|
||||||
interface HabitStreakProps {
|
interface HabitStreakProps {
|
||||||
habits: Habit[]
|
habits: Habit[]
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
import { ResponsiveContainer } from 'recharts'
|
|
||||||
import ClientWrapper from './ClientWrapper'
|
import ClientWrapper from './ClientWrapper'
|
||||||
import Header from './Header'
|
import Header from './Header'
|
||||||
import Navigation from './Navigation'
|
import Navigation from './Navigation'
|
||||||
@@ -6,21 +5,21 @@ import Navigation from './Navigation'
|
|||||||
export default function Layout({ children }: { children: React.ReactNode }) {
|
export default function Layout({ children }: { children: React.ReactNode }) {
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col h-screen bg-gray-100 dark:bg-gray-900 overflow-hidden">
|
<div className="flex flex-col h-screen bg-gray-100 dark:bg-gray-900 overflow-hidden">
|
||||||
<ClientWrapper>
|
<Header className="sticky top-0 z-50" />
|
||||||
<Header className="sticky top-0 z-50" />
|
<div className="flex flex-1 overflow-hidden">
|
||||||
<div className="flex flex-1 overflow-hidden">
|
<Navigation position='main' />
|
||||||
<Navigation viewPort='main' />
|
<div className="flex-1 flex flex-col">
|
||||||
<div className="flex-1 flex flex-col">
|
<main className="flex-1 overflow-x-hidden overflow-y-auto bg-gray-100 dark:bg-gray-900 relative">
|
||||||
<main className="flex-1 overflow-x-hidden overflow-y-auto bg-gray-100 dark:bg-gray-900 relative">
|
{/* responsive container (optimized for mobile) */}
|
||||||
{/* responsive container (optimized for mobile) */}
|
<div className="mx-auto px-2 xs:px-4 py-8 max-w-sm xs:max-w-full">
|
||||||
<div className="mx-auto px-2 xs:px-4 py-8 max-w-sm xs:max-w-full">
|
<ClientWrapper>
|
||||||
{children}
|
{children}
|
||||||
</div>
|
</ClientWrapper>
|
||||||
</main>
|
</div>
|
||||||
<Navigation viewPort='mobile' />
|
</main>
|
||||||
</div>
|
<Navigation position='mobile' />
|
||||||
</div>
|
</div>
|
||||||
</ClientWrapper>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
import { Sparkles } from "lucide-react"
|
import Image from "next/image"
|
||||||
|
|
||||||
export function Logo() {
|
export function Logo() {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
{/* <Sparkles className="h-6 w-6 text-primary" /> */}
|
<Image src="/icons/icon.png" alt="HabitTrove Logo" width={96} height={96} className="h-12 w-12 hidden xs:inline" />
|
||||||
<span className="font-bold text-xl">HabitTrove</span>
|
<span className="font-bold text-xl">HabitTrove</span>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,60 +0,0 @@
|
|||||||
import Link from 'next/link'
|
|
||||||
import type { ElementType } from 'react'
|
|
||||||
|
|
||||||
export interface NavItemType {
|
|
||||||
icon: ElementType;
|
|
||||||
label: string;
|
|
||||||
href: string;
|
|
||||||
position: 'main' | 'bottom';
|
|
||||||
}
|
|
||||||
|
|
||||||
interface MobileNavDisplayProps {
|
|
||||||
navItems: NavItemType[];
|
|
||||||
}
|
|
||||||
|
|
||||||
// detect iOS: https://stackoverflow.com/a/9039885
|
|
||||||
function iOS() {
|
|
||||||
return [
|
|
||||||
'iPad Simulator',
|
|
||||||
'iPhone Simulator',
|
|
||||||
'iPod Simulator',
|
|
||||||
'iPad',
|
|
||||||
'iPhone',
|
|
||||||
'iPod',
|
|
||||||
].includes(navigator.platform)
|
|
||||||
// iPad on iOS 13 detection
|
|
||||||
|| (navigator.userAgent.includes("Mac") && "ontouchend" in document)
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
export default function MobileNavDisplay({ navItems }: MobileNavDisplayProps) {
|
|
||||||
// Filter for items relevant to mobile view, typically 'main' and 'bottom' positions
|
|
||||||
const mobileNavItems = navItems.filter(item => item.position === 'main' || item.position === 'bottom');
|
|
||||||
// The original code spread main and bottom items separately, effectively concatenating them.
|
|
||||||
// If specific ordering or duplication was intended, that logic would be here.
|
|
||||||
// For now, a simple filter and map should suffice if all items are distinct.
|
|
||||||
// The original code: [...navItems(isTasksView).filter(item => item.position === 'main'), ...navItems(isTasksView).filter(item => item.position === 'bottom')]
|
|
||||||
// This implies that items could be in 'main' or 'bottom'. The current navItems only have 'main'.
|
|
||||||
// A simple combined list is fine.
|
|
||||||
const isIOS = iOS()
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<div className={isIOS ? "pb-20" : "pb-16"} /> {/* Add padding at the bottom to prevent content from being hidden */}
|
|
||||||
<nav className={`lg:hidden fixed bottom-0 left-0 right-0 bg-white dark:bg-gray-800 shadow-lg ${isIOS ? "pb-4" : ""}`}>
|
|
||||||
<div className="grid grid-cols-5 w-full">
|
|
||||||
{mobileNavItems.map((item) => (
|
|
||||||
<Link
|
|
||||||
key={item.label} // Assuming labels are unique
|
|
||||||
href={item.href}
|
|
||||||
className="flex flex-col items-center justify-center py-2 text-gray-600 dark:text-gray-300 hover:text-blue-500 dark:hover:text-blue-400"
|
|
||||||
>
|
|
||||||
<item.icon className="h-6 w-6" />
|
|
||||||
<span className="text-xs mt-1">{item.label}</span>
|
|
||||||
</Link>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</nav>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
61
components/NavDisplay.tsx
Normal file
61
components/NavDisplay.tsx
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
import { useHelpers } from '@/lib/client-helpers';
|
||||||
|
import Link from 'next/link';
|
||||||
|
import { usePathname } from 'next/navigation';
|
||||||
|
import { NavItemType } from './Navigation';
|
||||||
|
|
||||||
|
export default function NavDisplay({ navItems, isMobile }: { navItems: NavItemType[], isMobile: boolean }) {
|
||||||
|
const pathname = usePathname();
|
||||||
|
const { isIOS } = useHelpers()
|
||||||
|
|
||||||
|
if (isMobile) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{isMobile && (<div className={isIOS ? "pb-20" : "pb-16"} />)}
|
||||||
|
<nav className={`lg:hidden fixed bottom-0 left-0 right-0 bg-white dark:bg-gray-800 shadow-lg ${isIOS ? "pb-4" : ""}`}>
|
||||||
|
<div className="grid grid-cols-6 w-full">
|
||||||
|
{navItems.map((item) => (
|
||||||
|
<Link
|
||||||
|
key={item.label}
|
||||||
|
href={item.href}
|
||||||
|
className={"flex flex-col items-center py-2 hover:text-blue-600 dark:hover:text-blue-300 " +
|
||||||
|
(pathname === (item.href) ?
|
||||||
|
"text-blue-500 dark:text-blue-500" :
|
||||||
|
"text-gray-300 dark:text-gray-300")
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<item.icon className="h-6 w-6" />
|
||||||
|
<span className="text-xs mt-1">{item.label}</span>
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
return (
|
||||||
|
<div className="hidden lg:flex lg:flex-shrink-0">
|
||||||
|
<div className="flex flex-col w-64">
|
||||||
|
<div className="flex flex-col h-0 flex-1 bg-gray-800">
|
||||||
|
<div className="flex-1 flex flex-col pt-5 pb-4 overflow-y-auto">
|
||||||
|
<nav className="mt-5 flex-1 px-2 space-y-1">
|
||||||
|
{navItems.map((item) => (
|
||||||
|
<Link
|
||||||
|
key={item.label}
|
||||||
|
href={item.href}
|
||||||
|
className={"flex items-center px-2 py-2 font-medium rounded-md " +
|
||||||
|
(pathname === (item.href) ?
|
||||||
|
"text-blue-500 hover:text-blue-600 hover:bg-gray-700" :
|
||||||
|
"text-gray-300 hover:text-white hover:bg-gray-700")}
|
||||||
|
>
|
||||||
|
<item.icon className="mr-4 flex-shrink-0 h-6 w-6" aria-hidden="true" />
|
||||||
|
{item.label}
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,70 +1,40 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { Home, Calendar, Gift, Coins } from 'lucide-react'
|
|
||||||
import { useAtom } from 'jotai'
|
|
||||||
import { browserSettingsAtom } from '@/lib/atoms'
|
|
||||||
import { useEffect, useState, ElementType } from 'react'
|
|
||||||
import { useTranslations } from 'next-intl'
|
|
||||||
import { HabitIcon, TaskIcon } from '@/lib/constants'
|
import { HabitIcon, TaskIcon } from '@/lib/constants'
|
||||||
import MobileNavDisplay from './MobileNavDisplay'
|
import { Calendar, Coins, Gift, Home } from 'lucide-react'
|
||||||
import DesktopNavDisplay from './DesktopNavDisplay'
|
import { useTranslations } from 'next-intl'
|
||||||
|
import { ElementType, useEffect, useState } from 'react'
|
||||||
type ViewPort = 'main' | 'mobile'
|
import NavDisplay from './NavDisplay'
|
||||||
|
|
||||||
export interface NavItemType {
|
export interface NavItemType {
|
||||||
icon: ElementType;
|
icon: ElementType;
|
||||||
label: string;
|
label: string;
|
||||||
href: string;
|
href: string;
|
||||||
position: 'main' | 'bottom';
|
|
||||||
}
|
}
|
||||||
|
|
||||||
interface NavigationProps {
|
export default function Navigation({ position }: { position: 'main' | 'mobile' }) {
|
||||||
className?: string
|
const t = useTranslations('Navigation');
|
||||||
viewPort: ViewPort
|
const [isMobile, setIsMobile] = useState(window.innerWidth < 1024);
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
export default function Navigation({ className, viewPort }: NavigationProps) {
|
|
||||||
const t = useTranslations('Navigation')
|
|
||||||
const [isMobileView, setIsMobileView] = useState(false)
|
|
||||||
const [browserSettings] = useAtom(browserSettingsAtom)
|
|
||||||
const isTasksView = browserSettings.viewType === 'tasks'
|
|
||||||
|
|
||||||
const currentNavItems: NavItemType[] = [
|
|
||||||
{ icon: Home, label: t('dashboard'), href: '/', position: 'main' },
|
|
||||||
{
|
|
||||||
icon: isTasksView ? TaskIcon : HabitIcon,
|
|
||||||
label: isTasksView ? t('tasks') : t('habits'),
|
|
||||||
href: '/habits',
|
|
||||||
position: 'main'
|
|
||||||
},
|
|
||||||
{ icon: Calendar, label: t('calendar'), href: '/calendar', position: 'main' },
|
|
||||||
{ icon: Gift, label: t('wishlist'), href: '/wishlist', position: 'main' },
|
|
||||||
{ icon: Coins, label: t('coins'), href: '/coins', position: 'main' },
|
|
||||||
]
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleResize = () => {
|
const handleResize = () => {setIsMobile(window.innerWidth < 1024); };
|
||||||
setIsMobileView(window.innerWidth < 1024)
|
window.addEventListener("resize", handleResize);
|
||||||
}
|
return () => window.removeEventListener("resize", handleResize);
|
||||||
|
}, [setIsMobile]);
|
||||||
|
|
||||||
// Set initial value
|
const currentNavItems: NavItemType[] = [
|
||||||
handleResize()
|
{ icon: Home, label: t('dashboard'), href: '/' },
|
||||||
|
{ icon: HabitIcon, label: t('habits'), href: '/habits' },
|
||||||
|
{ icon: TaskIcon, label: t('tasks'), href: '/tasks' },
|
||||||
|
{ icon: Calendar, label: t('calendar'), href: '/calendar' },
|
||||||
|
{ icon: Gift, label: t('wishlist'), href: '/wishlist' },
|
||||||
|
{ icon: Coins, label: t('coins'), href: '/coins' },
|
||||||
|
]
|
||||||
|
|
||||||
// Add event listener
|
if ((position === 'mobile' && isMobile) || (position === 'main' && !isMobile)) {
|
||||||
window.addEventListener('resize', handleResize)
|
return <NavDisplay navItems={currentNavItems} isMobile={isMobile} />
|
||||||
|
|
||||||
// Cleanup
|
|
||||||
return () => window.removeEventListener('resize', handleResize)
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
if (viewPort === 'mobile' && isMobileView) {
|
|
||||||
return <MobileNavDisplay navItems={currentNavItems} />
|
|
||||||
}
|
}
|
||||||
|
else {
|
||||||
if (viewPort === 'main' && !isMobileView) {
|
return <></>
|
||||||
return <DesktopNavDisplay navItems={currentNavItems} className={className} />
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return null // Explicitly return null if no view matches
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,19 +1,18 @@
|
|||||||
import React from 'react';
|
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
|
||||||
|
import { DropdownMenuItem } from '@/components/ui/dropdown-menu';
|
||||||
import { ScrollArea } from '@/components/ui/scroll-area';
|
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||||
import { Separator } from '@/components/ui/separator';
|
import { Separator } from '@/components/ui/separator';
|
||||||
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
|
|
||||||
import { CoinsData, HabitsData, WishlistData, UserData, User, CoinTransaction } from '@/lib/types';
|
|
||||||
import { t2d } from '@/lib/utils';
|
|
||||||
import Link from 'next/link';
|
|
||||||
import { DropdownMenuItem } from '@/components/ui/dropdown-menu';
|
|
||||||
import { Info } from 'lucide-react';
|
|
||||||
import { useTranslations } from 'next-intl';
|
|
||||||
import {
|
import {
|
||||||
Tooltip,
|
Tooltip,
|
||||||
TooltipContent,
|
TooltipContent,
|
||||||
TooltipProvider,
|
TooltipProvider,
|
||||||
TooltipTrigger,
|
TooltipTrigger,
|
||||||
} from '@/components/ui/tooltip';
|
} from '@/components/ui/tooltip';
|
||||||
|
import { CoinTransaction, HabitsData, User, UserData, WishlistData } from '@/lib/types';
|
||||||
|
import { t2d } from '@/lib/utils';
|
||||||
|
import { Info } from 'lucide-react';
|
||||||
|
import { useTranslations } from 'next-intl';
|
||||||
|
import Link from 'next/link';
|
||||||
|
|
||||||
interface NotificationDropdownProps {
|
interface NotificationDropdownProps {
|
||||||
currentUser: User | null;
|
currentUser: User | null;
|
||||||
|
|||||||
@@ -1,14 +1,14 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { Avatar, AvatarFallback, AvatarImage } from './ui/avatar';
|
|
||||||
import { Input } from './ui/input';
|
|
||||||
import { Button } from './ui/button';
|
|
||||||
import { Label } from './ui/label';
|
|
||||||
import { User as UserIcon } from 'lucide-react';
|
|
||||||
import { Permission, User } from '@/lib/types';
|
|
||||||
import { toast } from '@/hooks/use-toast';
|
import { toast } from '@/hooks/use-toast';
|
||||||
import { useState } from 'react';
|
import { User } from '@/lib/types';
|
||||||
|
import { User as UserIcon } from 'lucide-react';
|
||||||
import { useTranslations } from 'next-intl';
|
import { useTranslations } from 'next-intl';
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { Avatar, AvatarFallback, AvatarImage } from './ui/avatar';
|
||||||
|
import { Button } from './ui/button';
|
||||||
|
import { Input } from './ui/input';
|
||||||
|
import { Label } from './ui/label';
|
||||||
|
|
||||||
interface PasswordEntryFormProps {
|
interface PasswordEntryFormProps {
|
||||||
user: User;
|
user: User;
|
||||||
|
|||||||
@@ -1,15 +1,14 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { useState, useEffect, useRef, useCallback } from 'react'
|
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import { Progress } from '@/components/ui/progress'
|
import { Progress } from '@/components/ui/progress'
|
||||||
import { Play, Pause, RotateCw, Minus, X, Clock, SkipForward } from 'lucide-react'
|
import { useHabits } from '@/hooks/useHabits'
|
||||||
|
import { habitsAtom, pomodoroAtom, pomodoroTodayCompletionsAtom } from '@/lib/atoms'
|
||||||
import { cn } from '@/lib/utils'
|
import { cn } from '@/lib/utils'
|
||||||
import { useAtom } from 'jotai'
|
import { useAtom } from 'jotai'
|
||||||
|
import { Clock, Minus, Pause, Play, RotateCw, SkipForward, X } from 'lucide-react'
|
||||||
import { useTranslations } from 'next-intl'
|
import { useTranslations } from 'next-intl'
|
||||||
import { settingsAtom, pomodoroAtom, habitsAtom, pomodoroTodayCompletionsAtom } from '@/lib/atoms'
|
import { useEffect, useRef, useState } from 'react'
|
||||||
// import { getCompletionsForDate, getTodayInTimezone } from '@/lib/utils' // Not used after pomodoroTodayCompletionsAtom
|
|
||||||
import { useHabits } from '@/hooks/useHabits'
|
|
||||||
|
|
||||||
interface PomoConfig {
|
interface PomoConfig {
|
||||||
getLabels: () => string[]
|
getLabels: () => string[]
|
||||||
@@ -39,7 +38,6 @@ export default function PomodoroTimer() {
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
const [settings] = useAtom(settingsAtom)
|
|
||||||
const [pomo, setPomo] = useAtom(pomodoroAtom)
|
const [pomo, setPomo] = useAtom(pomodoroAtom)
|
||||||
const { show, selectedHabitId, autoStart, minimized } = pomo
|
const { show, selectedHabitId, autoStart, minimized } = pomo
|
||||||
const [habitsData] = useAtom(habitsAtom)
|
const [habitsData] = useAtom(habitsAtom)
|
||||||
@@ -110,47 +108,48 @@ export default function PomodoroTimer() {
|
|||||||
document.removeEventListener('visibilitychange', handleVisibilityChange);
|
document.removeEventListener('visibilitychange', handleVisibilityChange);
|
||||||
releaseWakeLock()
|
releaseWakeLock()
|
||||||
}
|
}
|
||||||
}, [state])
|
}, [state, t])
|
||||||
|
|
||||||
// Timer logic
|
// Timer logic
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let interval: ReturnType<typeof setInterval> | null = null
|
const handleTimerEnd = async () => {
|
||||||
|
setState("stopped");
|
||||||
|
const currentTimerType = currentTimerRef.current.type;
|
||||||
|
currentTimerRef.current =
|
||||||
|
currentTimerType === "focus" ? PomoConfigs.break : PomoConfigs.focus;
|
||||||
|
setTimeLeft(currentTimerRef.current.duration);
|
||||||
|
const newLabels = currentTimerRef.current.getLabels();
|
||||||
|
setCurrentLabel(newLabels[Math.floor(Math.random() * newLabels.length)]);
|
||||||
|
|
||||||
if (state === 'started') {
|
// update habits only after focus sessions
|
||||||
|
if (selectedHabit && currentTimerType === "focus") {
|
||||||
|
await completeHabit(selectedHabit);
|
||||||
|
// The atom will automatically update with the new completions
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let interval: ReturnType<typeof setInterval> | null = null;
|
||||||
|
|
||||||
|
if (state === "started") {
|
||||||
// Calculate the target end time based on current timeLeft
|
// Calculate the target end time based on current timeLeft
|
||||||
const targetEndTime = Date.now() + timeLeft * 1000
|
const targetEndTime = Date.now() + timeLeft * 1000;
|
||||||
|
|
||||||
interval = setInterval(() => {
|
interval = setInterval(() => {
|
||||||
const remaining = Math.floor((targetEndTime - Date.now()) / 1000)
|
const remaining = Math.floor((targetEndTime - Date.now()) / 1000);
|
||||||
|
|
||||||
if (remaining <= 0) {
|
if (remaining <= 0) {
|
||||||
handleTimerEnd()
|
handleTimerEnd();
|
||||||
} else {
|
} else {
|
||||||
setTimeLeft(remaining)
|
setTimeLeft(remaining);
|
||||||
}
|
}
|
||||||
}, 1000)
|
}, 1000);
|
||||||
}
|
}
|
||||||
|
|
||||||
// return handles any other states
|
// return handles any other states
|
||||||
return () => {
|
return () => {
|
||||||
if (interval) clearInterval(interval)
|
if (interval) clearInterval(interval);
|
||||||
}
|
};
|
||||||
}, [state])
|
}, [state, timeLeft, PomoConfigs.break, PomoConfigs.focus, completeHabit, selectedHabit]);
|
||||||
|
|
||||||
const handleTimerEnd = async () => {
|
|
||||||
setState("stopped")
|
|
||||||
const currentTimerType = currentTimerRef.current.type
|
|
||||||
currentTimerRef.current = currentTimerType === 'focus' ? PomoConfigs.break : PomoConfigs.focus
|
|
||||||
setTimeLeft(currentTimerRef.current.duration)
|
|
||||||
const newLabels = currentTimerRef.current.getLabels();
|
|
||||||
setCurrentLabel(newLabels[Math.floor(Math.random() * newLabels.length)])
|
|
||||||
|
|
||||||
// update habits only after focus sessions
|
|
||||||
if (selectedHabit && currentTimerType === 'focus') {
|
|
||||||
await completeHabit(selectedHabit)
|
|
||||||
// The atom will automatically update with the new completions
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const toggleTimer = () => {
|
const toggleTimer = () => {
|
||||||
setState(prev => prev === 'started' ? 'paused' : 'started')
|
setState(prev => prev === 'started' ? 'paused' : 'started')
|
||||||
|
|||||||
@@ -1,19 +1,19 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
|
import { signOut } from "@/app/actions/user"
|
||||||
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"
|
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui/dropdown-menu"
|
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui/dropdown-menu"
|
||||||
import { Settings, Info, User, Moon, Sun, Palette, ArrowRightLeft, LogOut, Crown } from "lucide-react"
|
import { toast } from "@/hooks/use-toast"
|
||||||
|
import { aboutOpenAtom, currentUserAtom, settingsAtom, userSelectAtom } from "@/lib/atoms"
|
||||||
|
import { useAtom } from "jotai"
|
||||||
|
import { ArrowRightLeft, Crown, Info, LogOut, Moon, Palette, Settings, Sun, User } from "lucide-react"
|
||||||
|
import { useTranslations } from 'next-intl'
|
||||||
|
import { useTheme } from "next-themes"
|
||||||
|
import Link from "next/link"
|
||||||
|
import { useState } from "react"
|
||||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from './ui/dialog'
|
import { Dialog, DialogContent, DialogHeader, DialogTitle } from './ui/dialog'
|
||||||
import UserForm from './UserForm'
|
import UserForm from './UserForm'
|
||||||
import Link from "next/link"
|
|
||||||
import { useAtom } from "jotai"
|
|
||||||
import { aboutOpenAtom, settingsAtom, userSelectAtom, currentUserAtom } from "@/lib/atoms"
|
|
||||||
import { useEffect, useState } from "react"
|
|
||||||
import { useTheme } from "next-themes"
|
|
||||||
import { signOut } from "@/app/actions/user"
|
|
||||||
import { toast } from "@/hooks/use-toast"
|
|
||||||
import { useTranslations } from 'next-intl'
|
|
||||||
|
|
||||||
export function Profile() {
|
export function Profile() {
|
||||||
const t = useTranslations('Profile');
|
const t = useTranslations('Profile');
|
||||||
@@ -65,7 +65,7 @@ export function Profile() {
|
|||||||
</AvatarFallback>
|
</AvatarFallback>
|
||||||
</Avatar>
|
</Avatar>
|
||||||
<div className="flex flex-col mr-4">
|
<div className="flex flex-col mr-4">
|
||||||
<span className="text-sm font-semibold flex items-center gap-1">
|
<span className="text-sm font-semibold flex items-center gap-1 break-all">
|
||||||
{user?.username || t('guestUsername')}
|
{user?.username || t('guestUsername')}
|
||||||
{user?.isAdmin && <Crown className="h-3 w-3 text-yellow-500" />}
|
{user?.isAdmin && <Crown className="h-3 w-3 text-yellow-500" />}
|
||||||
</span>
|
</span>
|
||||||
|
|||||||
27
components/RefreshBanner.tsx
Normal file
27
components/RefreshBanner.tsx
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { Button } from "@/components/ui/button"
|
||||||
|
import { AlertTriangle } from "lucide-react"
|
||||||
|
|
||||||
|
interface RefreshBannerProps {
|
||||||
|
onRefresh: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function RefreshBanner({ onRefresh }: RefreshBannerProps) {
|
||||||
|
return (
|
||||||
|
<div className="fixed bottom-4 right-4 z-[100] bg-yellow-400 dark:bg-yellow-500 text-black dark:text-gray-900 p-4 rounded-lg shadow-lg flex items-center gap-3">
|
||||||
|
<AlertTriangle className="h-6 w-6 text-yellow-800 dark:text-yellow-900" />
|
||||||
|
<div>
|
||||||
|
<p className="font-semibold">Data out of sync</p>
|
||||||
|
<p className="text-sm">New data is available. Please refresh to see the latest updates.</p>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
onClick={onRefresh}
|
||||||
|
variant="outline"
|
||||||
|
className="ml-auto bg-yellow-500 hover:bg-yellow-600 dark:bg-yellow-600 dark:hover:bg-yellow-700 border-yellow-600 dark:border-yellow-700 text-white dark:text-gray-900"
|
||||||
|
>
|
||||||
|
Refresh
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,10 +1,6 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useState } from 'react';
|
import { createUser, updateUser, updateUserPassword, uploadAvatar } from '@/app/actions/data';
|
||||||
import { passwordSchema, usernameSchema } from '@/lib/zod';
|
|
||||||
import { useTranslations } from 'next-intl';
|
|
||||||
import { Input } from './ui/input';
|
|
||||||
import { Button } from './ui/button';
|
|
||||||
import {
|
import {
|
||||||
AlertDialog,
|
AlertDialog,
|
||||||
AlertDialogAction,
|
AlertDialogAction,
|
||||||
@@ -15,19 +11,22 @@ import {
|
|||||||
AlertDialogHeader,
|
AlertDialogHeader,
|
||||||
AlertDialogTitle,
|
AlertDialogTitle,
|
||||||
AlertDialogTrigger,
|
AlertDialogTrigger,
|
||||||
} from "@/components/ui/alert-dialog"
|
} from "@/components/ui/alert-dialog";
|
||||||
|
import { toast } from '@/hooks/use-toast';
|
||||||
|
import { currentUserAtom, serverSettingsAtom, usersAtom } from '@/lib/atoms';
|
||||||
|
import { Permission } from '@/lib/types';
|
||||||
|
import { passwordSchema, usernameSchema } from '@/lib/zod';
|
||||||
|
import { useAtom, useAtomValue } from 'jotai';
|
||||||
|
import _ from 'lodash';
|
||||||
|
import { User as UserIcon } from 'lucide-react';
|
||||||
|
import { useTranslations } from 'next-intl';
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { PermissionSelector } from './PermissionSelector';
|
||||||
|
import { Avatar, AvatarFallback, AvatarImage } from './ui/avatar';
|
||||||
|
import { Button } from './ui/button';
|
||||||
|
import { Input } from './ui/input';
|
||||||
import { Label } from './ui/label';
|
import { Label } from './ui/label';
|
||||||
import { Switch } from './ui/switch';
|
import { Switch } from './ui/switch';
|
||||||
import { Permission } from '@/lib/types';
|
|
||||||
import { toast } from '@/hooks/use-toast';
|
|
||||||
import { useAtom, useAtomValue } from 'jotai';
|
|
||||||
import { serverSettingsAtom, usersAtom, currentUserAtom } from '@/lib/atoms';
|
|
||||||
import { createUser, updateUser, updateUserPassword, uploadAvatar } from '@/app/actions/data';
|
|
||||||
import { SafeUser, User } from '@/lib/types';
|
|
||||||
import { Avatar, AvatarFallback, AvatarImage } from './ui/avatar';
|
|
||||||
import { User as UserIcon } from 'lucide-react';
|
|
||||||
import _ from 'lodash';
|
|
||||||
import { PermissionSelector } from './PermissionSelector';
|
|
||||||
|
|
||||||
|
|
||||||
interface UserFormProps {
|
interface UserFormProps {
|
||||||
|
|||||||
@@ -1,34 +1,19 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
|
import { signIn } from '@/app/actions/user';
|
||||||
|
import { toast } from '@/hooks/use-toast';
|
||||||
|
import { currentUserAtom, usersAtom } from '@/lib/atoms';
|
||||||
|
import { SafeUser, User } from '@/lib/types';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
import { Description } from '@radix-ui/react-dialog';
|
||||||
|
import { useAtom } from 'jotai';
|
||||||
|
import { Crown, Plus, User as UserIcon, UserRoundPen } from 'lucide-react';
|
||||||
|
import { useTranslations } from 'next-intl';
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import PasswordEntryForm from './PasswordEntryForm';
|
import PasswordEntryForm from './PasswordEntryForm';
|
||||||
import UserForm from './UserForm';
|
import UserForm from './UserForm';
|
||||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from './ui/dialog';
|
|
||||||
import { Avatar, AvatarFallback, AvatarImage } from './ui/avatar';
|
import { Avatar, AvatarFallback, AvatarImage } from './ui/avatar';
|
||||||
import { Crown, Pencil, Plus, User as UserIcon, UserRoundPen, Trash2 } from 'lucide-react';
|
import { Dialog, DialogContent, DialogHeader, DialogTitle } from './ui/dialog';
|
||||||
import { Input } from './ui/input';
|
|
||||||
import { Button } from './ui/button';
|
|
||||||
import {
|
|
||||||
AlertDialog,
|
|
||||||
AlertDialogAction,
|
|
||||||
AlertDialogCancel,
|
|
||||||
AlertDialogContent,
|
|
||||||
AlertDialogDescription,
|
|
||||||
AlertDialogFooter,
|
|
||||||
AlertDialogHeader,
|
|
||||||
AlertDialogTitle,
|
|
||||||
AlertDialogTrigger,
|
|
||||||
} from "@/components/ui/alert-dialog"
|
|
||||||
import { useAtom } from 'jotai';
|
|
||||||
import { usersAtom, currentUserAtom } from '@/lib/atoms';
|
|
||||||
import { signIn } from '@/app/actions/user';
|
|
||||||
import { createUser } from '@/app/actions/data';
|
|
||||||
import { useTranslations } from 'next-intl';
|
|
||||||
import { toast } from '@/hooks/use-toast';
|
|
||||||
import { Description } from '@radix-ui/react-dialog';
|
|
||||||
import { SafeUser, User } from '@/lib/types';
|
|
||||||
import { cn } from '@/lib/utils';
|
|
||||||
|
|
||||||
|
|
||||||
function UserCard({
|
function UserCard({
|
||||||
user,
|
user,
|
||||||
|
|||||||
@@ -1,34 +1,29 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { cn } from '@/lib/utils'
|
import { browserSettingsAtom, habitsAtom, settingsAtom } from '@/lib/atoms'
|
||||||
|
import { HabitIcon, TaskIcon } from '@/lib/constants'
|
||||||
|
import { cn, isHabitDueToday } from '@/lib/utils'
|
||||||
import { useAtom } from 'jotai'
|
import { useAtom } from 'jotai'
|
||||||
import { useTranslations } from 'next-intl'
|
import { useTranslations } from 'next-intl'
|
||||||
import { browserSettingsAtom, habitsAtom, settingsAtom } from '@/lib/atoms'
|
import { usePathname, useRouter } from 'next/navigation'
|
||||||
import type { ViewType } from '@/lib/types'
|
|
||||||
import { HabitIcon, TaskIcon } from '@/lib/constants'
|
|
||||||
import { isHabitDueToday } from '@/lib/utils'
|
|
||||||
import { NotificationBadge } from './ui/notification-badge'
|
import { NotificationBadge } from './ui/notification-badge'
|
||||||
|
|
||||||
interface ViewToggleProps {
|
interface ViewToggleProps {
|
||||||
defaultView?: ViewType
|
|
||||||
className?: string
|
className?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ViewToggle({
|
export function ViewToggle({
|
||||||
defaultView = 'habits',
|
|
||||||
className
|
className
|
||||||
}: ViewToggleProps) {
|
}: ViewToggleProps) {
|
||||||
const t = useTranslations('ViewToggle')
|
const t = useTranslations('ViewToggle')
|
||||||
const [browserSettings, setBrowserSettings] = useAtom(browserSettingsAtom)
|
const [browserSettings, setBrowserSettings] = useAtom(browserSettingsAtom)
|
||||||
const [habits] = useAtom(habitsAtom)
|
const [habits] = useAtom(habitsAtom)
|
||||||
const [settings] = useAtom(settingsAtom)
|
const [settings] = useAtom(settingsAtom)
|
||||||
|
const pathname = usePathname();
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
const handleViewChange = (checked: boolean) => {
|
const handleViewChange = () => {
|
||||||
const newView = checked ? 'tasks' : 'habits'
|
router.push(pathname.includes("habits") ? "/tasks" : "/habits");
|
||||||
setBrowserSettings({
|
|
||||||
...browserSettings,
|
|
||||||
viewType: newView,
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Calculate due tasks count
|
// Calculate due tasks count
|
||||||
@@ -40,10 +35,10 @@ export function ViewToggle({
|
|||||||
<div className={cn('inline-flex rounded-full bg-muted/50 h-8', className)}>
|
<div className={cn('inline-flex rounded-full bg-muted/50 h-8', className)}>
|
||||||
<div className="relative flex gap-0.5 rounded-full bg-background p-0.5 h-full">
|
<div className="relative flex gap-0.5 rounded-full bg-background p-0.5 h-full">
|
||||||
<button
|
<button
|
||||||
onClick={() => handleViewChange(false)}
|
onClick={handleViewChange}
|
||||||
className={cn(
|
className={cn(
|
||||||
'relative z-10 rounded-full px-4 py-2 text-sm font-medium transition-colors flex items-center gap-2',
|
'relative z-10 rounded-full px-4 py-2 text-sm font-medium transition-colors flex items-center gap-2',
|
||||||
browserSettings.viewType === 'habits' ? 'text-primary-foreground' : 'text-muted-foreground hover:text-foreground'
|
pathname.includes('habits') ? 'text-primary-foreground' : 'text-muted-foreground hover:text-foreground'
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<HabitIcon className="h-4 w-4" />
|
<HabitIcon className="h-4 w-4" />
|
||||||
@@ -52,14 +47,14 @@ export function ViewToggle({
|
|||||||
<NotificationBadge
|
<NotificationBadge
|
||||||
label={dueTasksCount}
|
label={dueTasksCount}
|
||||||
show={dueTasksCount > 0}
|
show={dueTasksCount > 0}
|
||||||
variant={browserSettings.viewType === 'tasks' ? 'secondary' : 'default'}
|
variant={pathname.includes('tasks') ? 'secondary' : 'default'}
|
||||||
className="shadow-md"
|
className="shadow-md"
|
||||||
>
|
>
|
||||||
<button
|
<button
|
||||||
onClick={() => handleViewChange(true)}
|
onClick={handleViewChange}
|
||||||
className={cn(
|
className={cn(
|
||||||
'relative z-10 rounded-full px-4 py-2 text-sm font-medium transition-colors flex items-center gap-2',
|
'relative z-10 rounded-full px-4 py-2 text-sm font-medium transition-colors flex items-center gap-2',
|
||||||
browserSettings.viewType === 'tasks' ? 'text-primary-foreground' : 'text-muted-foreground hover:text-foreground'
|
pathname.includes('tasks') ? 'text-primary-foreground' : 'text-muted-foreground hover:text-foreground'
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<TaskIcon className="h-4 w-4" />
|
<TaskIcon className="h-4 w-4" />
|
||||||
@@ -69,7 +64,7 @@ export function ViewToggle({
|
|||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
'absolute left-0.5 top-0.5 h-[calc(100%-0.25rem)] rounded-full bg-primary transition-transform',
|
'absolute left-0.5 top-0.5 h-[calc(100%-0.25rem)] rounded-full bg-primary transition-transform',
|
||||||
browserSettings.viewType === 'habits' ? 'w-[calc(50%-0.125rem)]' : 'w-[calc(50%-0.125rem)] translate-x-[calc(100%+0.125rem)]'
|
pathname.includes('habits') ? 'w-[calc(50%-0.125rem)]' : 'w-[calc(50%-0.125rem)] translate-x-[calc(100%+0.125rem)]'
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,13 +1,5 @@
|
|||||||
import { WishlistItemType, User } from '@/lib/types'
|
|
||||||
import { useAtom } from 'jotai'
|
|
||||||
import { useTranslations } from 'next-intl'
|
|
||||||
import { usersAtom, currentUserAtom } from '@/lib/atoms'
|
|
||||||
import { Avatar, AvatarFallback, AvatarImage } from './ui/avatar'
|
|
||||||
import { hasPermission } from '@/lib/utils'
|
|
||||||
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from '@/components/ui/card'
|
|
||||||
import ReactMarkdown from 'react-markdown'
|
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import { Coins, Edit, Trash2, Gift, MoreVertical, Archive, ArchiveRestore } from 'lucide-react'
|
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from '@/components/ui/card'
|
||||||
import {
|
import {
|
||||||
DropdownMenu,
|
DropdownMenu,
|
||||||
DropdownMenuContent,
|
DropdownMenuContent,
|
||||||
@@ -15,6 +7,13 @@ import {
|
|||||||
DropdownMenuSeparator,
|
DropdownMenuSeparator,
|
||||||
DropdownMenuTrigger,
|
DropdownMenuTrigger,
|
||||||
} from '@/components/ui/dropdown-menu'
|
} from '@/components/ui/dropdown-menu'
|
||||||
|
import { currentUserAtom, usersAtom } from '@/lib/atoms'
|
||||||
|
import { User, WishlistItemType } from '@/lib/types'
|
||||||
|
import { hasPermission } from '@/lib/utils'
|
||||||
|
import { useAtom } from 'jotai'
|
||||||
|
import { Archive, ArchiveRestore, Coins, Edit, Gift, MoreVertical, Trash2 } from 'lucide-react'
|
||||||
|
import { useTranslations } from 'next-intl'
|
||||||
|
import { Avatar, AvatarFallback, AvatarImage } from './ui/avatar'
|
||||||
|
|
||||||
interface WishlistItemProps {
|
interface WishlistItemProps {
|
||||||
item: WishlistItemType
|
item: WishlistItemType
|
||||||
|
|||||||
@@ -154,7 +154,6 @@ export default function WishlistManager() {
|
|||||||
</div>
|
</div>
|
||||||
{isModalOpen &&
|
{isModalOpen &&
|
||||||
<AddEditWishlistItemModal
|
<AddEditWishlistItemModal
|
||||||
isOpen={isModalOpen}
|
|
||||||
setIsOpen={setIsModalOpen}
|
setIsOpen={setIsModalOpen}
|
||||||
editingItem={editingItem}
|
editingItem={editingItem}
|
||||||
setEditingItem={setEditingItem}
|
setEditingItem={setEditingItem}
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ export function JotaiHydrate({
|
|||||||
[coinsAtom, initialValues.coins],
|
[coinsAtom, initialValues.coins],
|
||||||
[wishlistAtom, initialValues.wishlist],
|
[wishlistAtom, initialValues.wishlist],
|
||||||
[usersAtom, initialValues.users],
|
[usersAtom, initialValues.users],
|
||||||
[serverSettingsAtom, initialValues.serverSettings]
|
[serverSettingsAtom, initialValues.serverSettings],
|
||||||
])
|
])
|
||||||
return children
|
return children
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import * as React from "react"
|
import { Moon, Sun } from "lucide-react"
|
||||||
import { Moon, MoonIcon, Sun } from "lucide-react"
|
|
||||||
import { useTheme } from "next-themes"
|
import { useTheme } from "next-themes"
|
||||||
|
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
services:
|
services:
|
||||||
habittrove:
|
habittrove:
|
||||||
|
image: ghcr.io/manindark/habittrove
|
||||||
|
container_name: habittrove
|
||||||
ports:
|
ports:
|
||||||
- "3000:3000"
|
- "3000:3000"
|
||||||
volumes:
|
volumes:
|
||||||
- "./data:/app/data"
|
- "./data:/app/data"
|
||||||
- "./backups:/app/backups"
|
- "./backups:/app/backups"
|
||||||
image: dohsimpson/habittrove
|
|
||||||
environment:
|
environment:
|
||||||
- AUTH_SECRET=your-secret-key-here # Replace with your actual secret
|
- AUTH_SECRET=your-secret-key-here # Replace with your actual secret
|
||||||
|
|||||||
@@ -1,50 +1,23 @@
|
|||||||
import { useAtom } from 'jotai';
|
import { addCoins, removeCoins, saveCoinsData } from '@/app/actions/data';
|
||||||
import { useState, useEffect, useMemo } from 'react';
|
import { toast } from '@/hooks/use-toast';
|
||||||
import { useTranslations } from 'next-intl';
|
|
||||||
import { calculateCoinsEarnedToday, calculateCoinsSpentToday, calculateTotalEarned, calculateTotalSpent, calculateTransactionsToday, checkPermission } from '@/lib/utils'
|
|
||||||
import {
|
import {
|
||||||
coinsAtom,
|
coinsAtom,
|
||||||
|
coinsBalanceAtom,
|
||||||
coinsEarnedTodayAtom,
|
coinsEarnedTodayAtom,
|
||||||
|
coinsSpentTodayAtom,
|
||||||
|
currentUserAtom,
|
||||||
|
settingsAtom,
|
||||||
totalEarnedAtom,
|
totalEarnedAtom,
|
||||||
totalSpentAtom,
|
totalSpentAtom,
|
||||||
coinsSpentTodayAtom,
|
|
||||||
transactionsTodayAtom,
|
transactionsTodayAtom,
|
||||||
coinsBalanceAtom,
|
|
||||||
settingsAtom,
|
|
||||||
usersAtom,
|
usersAtom,
|
||||||
currentUserAtom,
|
} from '@/lib/atoms';
|
||||||
} from '@/lib/atoms'
|
import { MAX_COIN_LIMIT } from '@/lib/constants';
|
||||||
import { addCoins, removeCoins, saveCoinsData } from '@/app/actions/data'
|
import { CoinsData } from '@/lib/types';
|
||||||
import { CoinsData, User } from '@/lib/types'
|
import { calculateCoinsEarnedToday, calculateCoinsSpentToday, calculateTotalEarned, calculateTotalSpent, calculateTransactionsToday, handlePermissionCheck, roundToInteger } from '@/lib/utils';
|
||||||
import { toast } from '@/hooks/use-toast'
|
import { useAtom } from 'jotai';
|
||||||
import { MAX_COIN_LIMIT } from '@/lib/constants'
|
import { useTranslations } from 'next-intl';
|
||||||
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
function handlePermissionCheck(
|
|
||||||
user: User | undefined,
|
|
||||||
resource: 'habit' | 'wishlist' | 'coins',
|
|
||||||
action: 'write' | 'interact',
|
|
||||||
tCommon: (key: string, values?: Record<string, any>) => string
|
|
||||||
): boolean {
|
|
||||||
if (!user) {
|
|
||||||
toast({
|
|
||||||
title: tCommon("authenticationRequiredTitle"),
|
|
||||||
description: tCommon("authenticationRequiredDescription"),
|
|
||||||
variant: "destructive",
|
|
||||||
})
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!user.isAdmin && !checkPermission(user.permissions, resource, action)) {
|
|
||||||
toast({
|
|
||||||
title: tCommon("permissionDeniedTitle"),
|
|
||||||
description: tCommon("permissionDeniedDescription", { action, resource }),
|
|
||||||
variant: "destructive",
|
|
||||||
})
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useCoins(options?: { selectedUser?: string }) {
|
export function useCoins(options?: { selectedUser?: string }) {
|
||||||
const t = useTranslations('useCoins');
|
const t = useTranslations('useCoins');
|
||||||
@@ -86,12 +59,22 @@ export function useCoins(options?: { selectedUser?: string }) {
|
|||||||
setBalance(loggedInUserBalance);
|
setBalance(loggedInUserBalance);
|
||||||
} else if (targetUser?.id) {
|
} else if (targetUser?.id) {
|
||||||
// If an admin is viewing another user, calculate their metrics manually
|
// If an admin is viewing another user, calculate their metrics manually
|
||||||
setCoinsEarnedToday(calculateCoinsEarnedToday(transactions, timezone));
|
const earnedToday = calculateCoinsEarnedToday(transactions, timezone);
|
||||||
setTotalEarned(calculateTotalEarned(transactions));
|
setCoinsEarnedToday(roundToInteger(earnedToday));
|
||||||
setTotalSpent(calculateTotalSpent(transactions));
|
|
||||||
setCoinsSpentToday(calculateCoinsSpentToday(transactions, timezone));
|
const totalEarnedVal = calculateTotalEarned(transactions);
|
||||||
setTransactionsToday(calculateTransactionsToday(transactions, timezone));
|
setTotalEarned(roundToInteger(totalEarnedVal));
|
||||||
setBalance(transactions.reduce((acc, t) => acc + t.amount, 0));
|
|
||||||
|
const totalSpentVal = calculateTotalSpent(transactions);
|
||||||
|
setTotalSpent(roundToInteger(totalSpentVal));
|
||||||
|
|
||||||
|
const spentToday = calculateCoinsSpentToday(transactions, timezone);
|
||||||
|
setCoinsSpentToday(roundToInteger(spentToday));
|
||||||
|
|
||||||
|
setTransactionsToday(calculateTransactionsToday(transactions, timezone)); // This is a count
|
||||||
|
|
||||||
|
const calculatedBalance = transactions.reduce((acc, t) => acc + t.amount, 0);
|
||||||
|
setBalance(roundToInteger(calculatedBalance));
|
||||||
}
|
}
|
||||||
}, [
|
}, [
|
||||||
targetUser?.id,
|
targetUser?.id,
|
||||||
|
|||||||
@@ -1,54 +1,24 @@
|
|||||||
import { useAtom, atom } from 'jotai'
|
|
||||||
import { useTranslations } from 'next-intl'
|
|
||||||
import { habitsAtom, coinsAtom, settingsAtom, usersAtom, habitFreqMapAtom, currentUserAtom } from '@/lib/atoms'
|
|
||||||
import { addCoins, removeCoins, saveHabitsData } from '@/app/actions/data'
|
import { addCoins, removeCoins, saveHabitsData } from '@/app/actions/data'
|
||||||
import { Habit, Permission, SafeUser, User } from '@/lib/types'
|
import { ToastAction } from '@/components/ui/toast'
|
||||||
import { toast } from '@/hooks/use-toast'
|
import { toast } from '@/hooks/use-toast'
|
||||||
import { DateTime } from 'luxon'
|
import { coinsAtom, currentUserAtom, habitFreqMapAtom, habitsAtom, settingsAtom, usersAtom } from '@/lib/atoms'
|
||||||
|
import { Habit } from '@/lib/types'
|
||||||
import {
|
import {
|
||||||
getNowInMilliseconds,
|
d2s,
|
||||||
getTodayInTimezone,
|
|
||||||
isSameDate,
|
|
||||||
t2d,
|
|
||||||
d2t,
|
d2t,
|
||||||
getNow,
|
|
||||||
getCompletionsForDate,
|
getCompletionsForDate,
|
||||||
getISODate,
|
getISODate,
|
||||||
d2s,
|
getNow,
|
||||||
|
getTodayInTimezone,
|
||||||
|
handlePermissionCheck,
|
||||||
|
isSameDate,
|
||||||
playSound,
|
playSound,
|
||||||
checkPermission
|
t2d
|
||||||
} from '@/lib/utils'
|
} from '@/lib/utils'
|
||||||
import { ToastAction } from '@/components/ui/toast'
|
import { useAtom } from 'jotai'
|
||||||
import { Undo2 } from 'lucide-react'
|
import { Undo2 } from 'lucide-react'
|
||||||
|
import { DateTime } from 'luxon'
|
||||||
|
import { useTranslations } from 'next-intl'
|
||||||
function handlePermissionCheck(
|
|
||||||
user: SafeUser | User | undefined,
|
|
||||||
resource: 'habit' | 'wishlist' | 'coins',
|
|
||||||
action: 'write' | 'interact',
|
|
||||||
tCommon: (key: string, values?: Record<string, any>) => string
|
|
||||||
): boolean {
|
|
||||||
if (!user) {
|
|
||||||
toast({
|
|
||||||
title: tCommon("authenticationRequiredTitle"),
|
|
||||||
description: tCommon("authenticationRequiredDescription"),
|
|
||||||
variant: "destructive",
|
|
||||||
})
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!user.isAdmin && !checkPermission(user.permissions, resource, action)) {
|
|
||||||
toast({
|
|
||||||
title: tCommon("permissionDeniedTitle"),
|
|
||||||
description: tCommon("permissionDeniedDescription", { action, resource }),
|
|
||||||
variant: "destructive",
|
|
||||||
})
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
export function useHabits() {
|
export function useHabits() {
|
||||||
const t = useTranslations('useHabits');
|
const t = useTranslations('useHabits');
|
||||||
@@ -106,7 +76,7 @@ export function useHabits() {
|
|||||||
type: habit.isTask ? 'TASK_COMPLETION' : 'HABIT_COMPLETION',
|
type: habit.isTask ? 'TASK_COMPLETION' : 'HABIT_COMPLETION',
|
||||||
relatedItemId: habit.id,
|
relatedItemId: habit.id,
|
||||||
})
|
})
|
||||||
isTargetReached && playSound()
|
playSound()
|
||||||
toast({
|
toast({
|
||||||
title: t("completedTitle"),
|
title: t("completedTitle"),
|
||||||
description: t("earnedCoinsDescription", { coinReward: habit.coinReward }),
|
description: t("earnedCoinsDescription", { coinReward: habit.coinReward }),
|
||||||
@@ -207,7 +177,7 @@ export function useHabits() {
|
|||||||
if (!handlePermissionCheck(currentUser, 'habit', 'write', tCommon)) return
|
if (!handlePermissionCheck(currentUser, 'habit', 'write', tCommon)) return
|
||||||
const newHabit = {
|
const newHabit = {
|
||||||
...habit,
|
...habit,
|
||||||
id: habit.id || getNowInMilliseconds().toString()
|
id: habit.id || crypto.randomUUID()
|
||||||
}
|
}
|
||||||
const updatedHabits = habit.id
|
const updatedHabits = habit.id
|
||||||
? habitsData.habits.map(h => h.id === habit.id ? newHabit : h)
|
? habitsData.habits.map(h => h.id === habit.id ? newHabit : h)
|
||||||
|
|||||||
@@ -1,40 +1,13 @@
|
|||||||
|
import { removeCoins, saveWishlistItems } from '@/app/actions/data'
|
||||||
|
import { toast } from '@/hooks/use-toast'
|
||||||
|
import { coinsAtom, currentUserAtom, wishlistAtom } from '@/lib/atoms'
|
||||||
|
import { WishlistItemType } from '@/lib/types'
|
||||||
|
import { handlePermissionCheck } from '@/lib/utils'
|
||||||
|
import { celebrations } from '@/utils/celebrations'
|
||||||
import { useAtom } from 'jotai'
|
import { useAtom } from 'jotai'
|
||||||
import { useTranslations } from 'next-intl'
|
import { useTranslations } from 'next-intl'
|
||||||
import { wishlistAtom, coinsAtom, currentUserAtom } from '@/lib/atoms'
|
|
||||||
import { saveWishlistItems, removeCoins } from '@/app/actions/data'
|
|
||||||
import { toast } from '@/hooks/use-toast'
|
|
||||||
import { WishlistItemType, User, SafeUser } from '@/lib/types'
|
|
||||||
import { celebrations } from '@/utils/celebrations'
|
|
||||||
import { checkPermission } from '@/lib/utils'
|
|
||||||
import { useCoins } from './useCoins'
|
import { useCoins } from './useCoins'
|
||||||
|
|
||||||
function handlePermissionCheck(
|
|
||||||
user: User | SafeUser | undefined,
|
|
||||||
resource: 'habit' | 'wishlist' | 'coins',
|
|
||||||
action: 'write' | 'interact',
|
|
||||||
tCommon: (key: string, values?: Record<string, any>) => string
|
|
||||||
): boolean {
|
|
||||||
if (!user) {
|
|
||||||
toast({
|
|
||||||
title: tCommon("authenticationRequiredTitle"),
|
|
||||||
description: tCommon("authenticationRequiredDescription"),
|
|
||||||
variant: "destructive",
|
|
||||||
})
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!user.isAdmin && !checkPermission(user.permissions, resource, action)) {
|
|
||||||
toast({
|
|
||||||
title: tCommon("permissionDeniedTitle"),
|
|
||||||
description: tCommon("permissionDeniedDescription", { action, resource }),
|
|
||||||
variant: "destructive",
|
|
||||||
})
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useWishlist() {
|
export function useWishlist() {
|
||||||
const t = useTranslations('useWishlist');
|
const t = useTranslations('useWishlist');
|
||||||
const tCommon = useTranslations('Common');
|
const tCommon = useTranslations('Common');
|
||||||
|
|||||||
100
lib/atoms.ts
100
lib/atoms.ts
@@ -1,82 +1,86 @@
|
|||||||
import { atom } from "jotai";
|
|
||||||
import {
|
import {
|
||||||
getDefaultSettings,
|
|
||||||
getDefaultHabitsData,
|
|
||||||
getDefaultCoinsData,
|
|
||||||
getDefaultWishlistData,
|
|
||||||
Habit,
|
|
||||||
ViewType,
|
|
||||||
getDefaultUsersData,
|
|
||||||
CompletionCache,
|
|
||||||
getDefaultServerSettings,
|
|
||||||
User,
|
|
||||||
UserId,
|
|
||||||
} from "./types";
|
|
||||||
import {
|
|
||||||
getTodayInTimezone,
|
|
||||||
isSameDate,
|
|
||||||
t2d,
|
|
||||||
calculateCoinsEarnedToday,
|
calculateCoinsEarnedToday,
|
||||||
|
calculateCoinsSpentToday,
|
||||||
calculateTotalEarned,
|
calculateTotalEarned,
|
||||||
calculateTotalSpent,
|
calculateTotalSpent,
|
||||||
calculateCoinsSpentToday,
|
|
||||||
calculateTransactionsToday,
|
calculateTransactionsToday,
|
||||||
|
generateCryptoHash,
|
||||||
getCompletionsForToday,
|
getCompletionsForToday,
|
||||||
getISODate,
|
getHabitFreq,
|
||||||
isHabitDueToday,
|
|
||||||
getNow,
|
|
||||||
isHabitDue,
|
isHabitDue,
|
||||||
getHabitFreq
|
prepareDataForHashing,
|
||||||
|
roundToInteger,
|
||||||
|
t2d
|
||||||
} from "@/lib/utils";
|
} from "@/lib/utils";
|
||||||
|
import { atom } from "jotai";
|
||||||
import { atomFamily, atomWithStorage } from "jotai/utils";
|
import { atomFamily, atomWithStorage } from "jotai/utils";
|
||||||
import { DateTime } from "luxon";
|
import { DateTime } from "luxon";
|
||||||
import { Freq } from "./types";
|
import {
|
||||||
|
CoinsData,
|
||||||
|
CompletionCache,
|
||||||
|
Freq,
|
||||||
|
getDefaultCoinsData,
|
||||||
|
getDefaultHabitsData,
|
||||||
|
getDefaultServerSettings,
|
||||||
|
getDefaultSettings,
|
||||||
|
getDefaultUsersData,
|
||||||
|
getDefaultWishlistData,
|
||||||
|
Habit,
|
||||||
|
HabitsData,
|
||||||
|
ServerSettings,
|
||||||
|
Settings,
|
||||||
|
UserData,
|
||||||
|
UserId,
|
||||||
|
WishlistData
|
||||||
|
} from "./types";
|
||||||
|
|
||||||
export interface BrowserSettings {
|
export interface BrowserSettings {
|
||||||
viewType: ViewType
|
|
||||||
expandedHabits: boolean
|
expandedHabits: boolean
|
||||||
expandedTasks: boolean
|
expandedTasks: boolean
|
||||||
expandedWishlist: boolean
|
expandedWishlist: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export const browserSettingsAtom = atomWithStorage('browserSettings', {
|
export const browserSettingsAtom = atomWithStorage('browserSettings', {
|
||||||
viewType: 'habits',
|
|
||||||
expandedHabits: false,
|
expandedHabits: false,
|
||||||
expandedTasks: false,
|
expandedTasks: false,
|
||||||
expandedWishlist: false
|
expandedWishlist: false
|
||||||
} as BrowserSettings)
|
} as BrowserSettings)
|
||||||
|
|
||||||
export const usersAtom = atom(getDefaultUsersData())
|
export const usersAtom = atom(getDefaultUsersData<UserData>())
|
||||||
export const settingsAtom = atom(getDefaultSettings());
|
export const settingsAtom = atom(getDefaultSettings<Settings>());
|
||||||
export const habitsAtom = atom(getDefaultHabitsData());
|
export const habitsAtom = atom(getDefaultHabitsData<HabitsData>());
|
||||||
export const coinsAtom = atom(getDefaultCoinsData());
|
export const coinsAtom = atom(getDefaultCoinsData<CoinsData>());
|
||||||
export const wishlistAtom = atom(getDefaultWishlistData());
|
export const wishlistAtom = atom(getDefaultWishlistData<WishlistData>());
|
||||||
export const serverSettingsAtom = atom(getDefaultServerSettings());
|
export const serverSettingsAtom = atom(getDefaultServerSettings<ServerSettings>());
|
||||||
|
|
||||||
// Derived atom for coins earned today
|
// Derived atom for coins earned today
|
||||||
export const coinsEarnedTodayAtom = atom((get) => {
|
export const coinsEarnedTodayAtom = atom((get) => {
|
||||||
const coins = get(coinsAtom);
|
const coins = get(coinsAtom);
|
||||||
const settings = get(settingsAtom);
|
const settings = get(settingsAtom);
|
||||||
return calculateCoinsEarnedToday(coins.transactions, settings.system.timezone);
|
const value = calculateCoinsEarnedToday(coins.transactions, settings.system.timezone);
|
||||||
|
return roundToInteger(value);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Derived atom for total earned
|
// Derived atom for total earned
|
||||||
export const totalEarnedAtom = atom((get) => {
|
export const totalEarnedAtom = atom((get) => {
|
||||||
const coins = get(coinsAtom);
|
const coins = get(coinsAtom);
|
||||||
return calculateTotalEarned(coins.transactions);
|
const value = calculateTotalEarned(coins.transactions);
|
||||||
|
return roundToInteger(value);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Derived atom for total spent
|
// Derived atom for total spent
|
||||||
export const totalSpentAtom = atom((get) => {
|
export const totalSpentAtom = atom((get) => {
|
||||||
const coins = get(coinsAtom);
|
const coins = get(coinsAtom);
|
||||||
return calculateTotalSpent(coins.transactions);
|
const value = calculateTotalSpent(coins.transactions);
|
||||||
|
return roundToInteger(value);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Derived atom for coins spent today
|
// Derived atom for coins spent today
|
||||||
export const coinsSpentTodayAtom = atom((get) => {
|
export const coinsSpentTodayAtom = atom((get) => {
|
||||||
const coins = get(coinsAtom);
|
const coins = get(coinsAtom);
|
||||||
const settings = get(settingsAtom);
|
const settings = get(settingsAtom);
|
||||||
return calculateCoinsSpentToday(coins.transactions, settings.system.timezone);
|
const value = calculateCoinsSpentToday(coins.transactions, settings.system.timezone);
|
||||||
|
return roundToInteger(value);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Derived atom for transactions today
|
// Derived atom for transactions today
|
||||||
@@ -103,9 +107,10 @@ export const coinsBalanceAtom = atom((get) => {
|
|||||||
return 0; // No user logged in or ID not set, so balance is 0
|
return 0; // No user logged in or ID not set, so balance is 0
|
||||||
}
|
}
|
||||||
const coins = get(coinsAtom);
|
const coins = get(coinsAtom);
|
||||||
return coins.transactions
|
const balance = coins.transactions
|
||||||
.filter(transaction => transaction.userId === loggedInUserId)
|
.filter(transaction => transaction.userId === loggedInUserId)
|
||||||
.reduce((sum, transaction) => sum + transaction.amount, 0);
|
.reduce((sum, transaction) => sum + transaction.amount, 0);
|
||||||
|
return roundToInteger(balance);
|
||||||
});
|
});
|
||||||
|
|
||||||
/* transient atoms */
|
/* transient atoms */
|
||||||
@@ -126,6 +131,22 @@ export const pomodoroAtom = atom<PomodoroAtom>({
|
|||||||
export const userSelectAtom = atom<boolean>(false)
|
export const userSelectAtom = atom<boolean>(false)
|
||||||
export const aboutOpenAtom = atom<boolean>(false)
|
export const aboutOpenAtom = atom<boolean>(false)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Asynchronous atom that calculates a freshness token (hash) based on the current client-side data.
|
||||||
|
* This token can be compared with a server-generated token to detect data discrepancies.
|
||||||
|
*/
|
||||||
|
export const clientFreshnessTokenAtom = atom(async (get) => {
|
||||||
|
const settings = get(settingsAtom);
|
||||||
|
const habits = get(habitsAtom);
|
||||||
|
const coins = get(coinsAtom);
|
||||||
|
const wishlist = get(wishlistAtom);
|
||||||
|
const users = get(usersAtom);
|
||||||
|
|
||||||
|
const dataString = prepareDataForHashing(settings, habits, coins, wishlist, users);
|
||||||
|
const hash = await generateCryptoHash(dataString);
|
||||||
|
return hash;
|
||||||
|
});
|
||||||
|
|
||||||
// Derived atom for completion cache
|
// Derived atom for completion cache
|
||||||
export const completionCacheAtom = atom((get) => {
|
export const completionCacheAtom = atom((get) => {
|
||||||
const habits = get(habitsAtom).habits;
|
const habits = get(habitsAtom).habits;
|
||||||
@@ -213,10 +234,3 @@ export const habitsByDateFamily = atomFamily((dateString: string) =>
|
|||||||
return habits.filter(habit => isHabitDue({ habit, timezone, date }));
|
return habits.filter(habit => isHabitDue({ habit, timezone, date }));
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
// Derived atom for daily habits
|
|
||||||
export const dailyHabitsAtom = atom((get) => {
|
|
||||||
const settings = get(settingsAtom);
|
|
||||||
const today = getTodayInTimezone(settings.system.timezone);
|
|
||||||
return get(habitsByDateFamily(today));
|
|
||||||
});
|
|
||||||
|
|||||||
36
lib/client-helpers.ts
Normal file
36
lib/client-helpers.ts
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
// client helpers
|
||||||
|
'use-client'
|
||||||
|
|
||||||
|
import { useAtom } from 'jotai'
|
||||||
|
import { useSession } from "next-auth/react"
|
||||||
|
import { usersAtom } from './atoms'
|
||||||
|
import { hasPermission } from './utils'
|
||||||
|
|
||||||
|
export function useHelpers() {
|
||||||
|
const { data: session, status } = useSession()
|
||||||
|
const currentUserId = session?.user.id
|
||||||
|
const [usersData] = useAtom(usersAtom)
|
||||||
|
const currentUser = usersData.users.find((u) => u.id === currentUserId)
|
||||||
|
// detect iOS: https://stackoverflow.com/a/9039885
|
||||||
|
function iOS() {
|
||||||
|
return typeof navigator !== "undefined" && ([
|
||||||
|
'iPad Simulator',
|
||||||
|
'iPhone Simulator',
|
||||||
|
'iPod Simulator',
|
||||||
|
'iPad',
|
||||||
|
'iPhone',
|
||||||
|
'iPod',
|
||||||
|
].includes(navigator.platform)
|
||||||
|
// iPad on iOS 13 detection
|
||||||
|
|| (navigator.userAgent.includes("Mac") && "ontouchend" in document))
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
currentUserId,
|
||||||
|
currentUser,
|
||||||
|
usersData,
|
||||||
|
status,
|
||||||
|
hasPermission,
|
||||||
|
isIOS: iOS(),
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -31,4 +31,6 @@ export const QUICK_DATES = [
|
|||||||
{ label: 'Sunday', value: 'this sunday' },
|
{ label: 'Sunday', value: 'this sunday' },
|
||||||
] as const
|
] as const
|
||||||
|
|
||||||
export const MAX_COIN_LIMIT = 9999
|
export const MAX_COIN_LIMIT = 9999
|
||||||
|
|
||||||
|
export const DESKTOP_DISPLAY_ITEM_COUNT = 4
|
||||||
@@ -37,4 +37,4 @@ export function verifyPassword(password?: string, storedHash?: string): boolean
|
|||||||
const newHash = saltAndHashPassword(password, salt).split(':')[1]
|
const newHash = saltAndHashPassword(password, salt).split(':')[1]
|
||||||
// Compare the new hash with the stored hash
|
// Compare the new hash with the stored hash
|
||||||
return newHash === hash
|
return newHash === hash
|
||||||
}
|
}
|
||||||
|
|||||||
85
lib/types.ts
85
lib/types.ts
@@ -1,5 +1,4 @@
|
|||||||
import { RRule } from "rrule"
|
import { RRule } from "rrule"
|
||||||
import { uuid } from "./utils"
|
|
||||||
import { DateTime } from "luxon"
|
import { DateTime } from "luxon"
|
||||||
|
|
||||||
export type UserId = string
|
export type UserId = string
|
||||||
@@ -97,52 +96,58 @@ export interface WishlistData {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Default value functions
|
// Default value functions
|
||||||
export const getDefaultUsersData = (): UserData => ({
|
export function getDefaultUsersData<UserData>(): UserData {
|
||||||
users: [
|
return {
|
||||||
{
|
users: [
|
||||||
id: uuid(),
|
{
|
||||||
username: 'admin',
|
id: crypto.randomUUID(),
|
||||||
// password: '', // No default password for admin initially? Or set a secure default?
|
username: 'admin',
|
||||||
isAdmin: true,
|
// password: '', // No default password for admin initially? Or set a secure default?
|
||||||
lastNotificationReadTimestamp: undefined, // Initialize as undefined
|
isAdmin: true,
|
||||||
}
|
lastNotificationReadTimestamp: undefined, // Initialize as undefined
|
||||||
]
|
}
|
||||||
});
|
]
|
||||||
|
} as UserData;
|
||||||
|
};
|
||||||
|
|
||||||
export const getDefaultHabitsData = (): HabitsData => ({
|
export function getDefaultHabitsData<HabitsData>(): HabitsData {
|
||||||
habits: []
|
return { habits: [] } as HabitsData;
|
||||||
});
|
}
|
||||||
|
|
||||||
|
export function getDefaultTasksData<TasksData>(): TasksData {
|
||||||
|
return { tasks: [] } as TasksData;
|
||||||
|
};
|
||||||
|
|
||||||
export const getDefaultCoinsData = (): CoinsData => ({
|
export function getDefaultCoinsData<CoinsData>(): CoinsData {
|
||||||
balance: 0,
|
return { balance: 0, transactions: [] } as CoinsData;
|
||||||
transactions: []
|
};
|
||||||
});
|
|
||||||
|
|
||||||
export const getDefaultWishlistData = (): WishlistData => ({
|
export function getDefaultWishlistData<WishlistData>(): WishlistData {
|
||||||
items: []
|
return { items: [] } as WishlistData;
|
||||||
});
|
}
|
||||||
|
|
||||||
export const getDefaultSettings = (): Settings => ({
|
export function getDefaultSettings<Settings>(): Settings {
|
||||||
ui: {
|
return {
|
||||||
useNumberFormatting: true,
|
ui: {
|
||||||
useGrouping: true,
|
useNumberFormatting: true,
|
||||||
},
|
useGrouping: true,
|
||||||
system: {
|
},
|
||||||
timezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
|
system: {
|
||||||
weekStartDay: 1, // Monday
|
timezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
|
||||||
autoBackupEnabled: true, // Add this line (default to true)
|
weekStartDay: 1, // Monday
|
||||||
language: 'en', // Default language
|
autoBackupEnabled: true, // Add this line (default to true)
|
||||||
},
|
language: 'en', // Default language
|
||||||
profile: {}
|
},
|
||||||
});
|
profile: {}
|
||||||
|
} as Settings;
|
||||||
|
};
|
||||||
|
|
||||||
export const getDefaultServerSettings = (): ServerSettings => ({
|
export function getDefaultServerSettings<ServerSettings>(): ServerSettings {
|
||||||
isDemo: false
|
return { isDemo: false } as ServerSettings;
|
||||||
})
|
}
|
||||||
|
|
||||||
// Map of data types to their default values
|
// Map of data types to their default values
|
||||||
export const DATA_DEFAULTS = {
|
export const DATA_DEFAULTS: { [key: string]: <T>() => T } = {
|
||||||
wishlist: getDefaultWishlistData,
|
wishlist: getDefaultWishlistData,
|
||||||
habits: getDefaultHabitsData,
|
habits: getDefaultHabitsData,
|
||||||
coins: getDefaultCoinsData,
|
coins: getDefaultCoinsData,
|
||||||
@@ -183,8 +188,6 @@ export type CompletionCache = {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export type ViewType = 'habits' | 'tasks'
|
|
||||||
|
|
||||||
export interface JotaiHydrateInitialValues {
|
export interface JotaiHydrateInitialValues {
|
||||||
settings: Settings;
|
settings: Settings;
|
||||||
coins: CoinsData;
|
coins: CoinsData;
|
||||||
|
|||||||
@@ -3,12 +3,9 @@ import {
|
|||||||
cn,
|
cn,
|
||||||
getTodayInTimezone,
|
getTodayInTimezone,
|
||||||
getNow,
|
getNow,
|
||||||
getNowInMilliseconds,
|
|
||||||
t2d,
|
t2d,
|
||||||
d2t,
|
d2t,
|
||||||
d2s,
|
d2s,
|
||||||
d2sDate,
|
|
||||||
d2n,
|
|
||||||
isSameDate,
|
isSameDate,
|
||||||
calculateCoinsEarnedToday,
|
calculateCoinsEarnedToday,
|
||||||
calculateTotalEarned,
|
calculateTotalEarned,
|
||||||
@@ -16,16 +13,19 @@ import {
|
|||||||
calculateCoinsSpentToday,
|
calculateCoinsSpentToday,
|
||||||
isHabitDueToday,
|
isHabitDueToday,
|
||||||
isHabitDue,
|
isHabitDue,
|
||||||
uuid,
|
|
||||||
isTaskOverdue,
|
isTaskOverdue,
|
||||||
deserializeRRule,
|
deserializeRRule,
|
||||||
serializeRRule,
|
serializeRRule,
|
||||||
convertHumanReadableFrequencyToMachineReadable,
|
convertHumanReadableFrequencyToMachineReadable,
|
||||||
convertMachineReadableFrequencyToHumanReadable,
|
convertMachineReadableFrequencyToHumanReadable,
|
||||||
getUnsupportedRRuleReason
|
prepareDataForHashing,
|
||||||
|
getUnsupportedRRuleReason,
|
||||||
|
roundToInteger,
|
||||||
|
generateCryptoHash
|
||||||
} from './utils'
|
} from './utils'
|
||||||
import { CoinTransaction, ParsedResultType } from './types'
|
import { CoinTransaction, ParsedResultType, Settings, HabitsData, CoinsData, WishlistData, UserData } from './types'
|
||||||
import { DateTime } from "luxon";
|
import { DateTime } from "luxon";
|
||||||
|
import { getDefaultSettings, getDefaultHabitsData, getDefaultCoinsData, getDefaultWishlistData, getDefaultUsersData } from './types';
|
||||||
import { RRule, Weekday } from 'rrule';
|
import { RRule, Weekday } from 'rrule';
|
||||||
import { Habit } from '@/lib/types';
|
import { Habit } from '@/lib/types';
|
||||||
import { INITIAL_DUE } from './constants';
|
import { INITIAL_DUE } from './constants';
|
||||||
@@ -39,6 +39,33 @@ describe('cn utility', () => {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
describe('roundToInteger', () => {
|
||||||
|
test('should round positive numbers correctly', () => {
|
||||||
|
expect(roundToInteger(10.123)).toBe(10);
|
||||||
|
expect(roundToInteger(10.5)).toBe(11);
|
||||||
|
expect(roundToInteger(10.75)).toBe(11);
|
||||||
|
expect(roundToInteger(10.49)).toBe(10);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should round negative numbers correctly', () => {
|
||||||
|
expect(roundToInteger(-10.123)).toBe(-10);
|
||||||
|
expect(roundToInteger(-10.5)).toBe(-10); // Math.round rounds -x.5 to -(x-1) e.g. -10.5 to -10
|
||||||
|
expect(roundToInteger(-10.75)).toBe(-11);
|
||||||
|
expect(roundToInteger(-10.49)).toBe(-10);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should handle zero correctly', () => {
|
||||||
|
expect(roundToInteger(0)).toBe(0);
|
||||||
|
expect(roundToInteger(0.0)).toBe(0);
|
||||||
|
expect(roundToInteger(-0.0)).toBe(-0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should handle integers correctly', () => {
|
||||||
|
expect(roundToInteger(15)).toBe(15);
|
||||||
|
expect(roundToInteger(-15)).toBe(-15);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe('getUnsupportedRRuleReason', () => {
|
describe('getUnsupportedRRuleReason', () => {
|
||||||
test('should return message for HOURLY frequency', () => {
|
test('should return message for HOURLY frequency', () => {
|
||||||
const rrule = new RRule({ freq: RRule.HOURLY });
|
const rrule = new RRule({ freq: RRule.HOURLY });
|
||||||
@@ -139,7 +166,7 @@ describe('isTaskOverdue', () => {
|
|||||||
// Create a task due "tomorrow" in UTC
|
// Create a task due "tomorrow" in UTC
|
||||||
const tomorrow = DateTime.now().plus({ days: 1 }).toUTC().toISO()
|
const tomorrow = DateTime.now().plus({ days: 1 }).toUTC().toISO()
|
||||||
const habit = createTestHabit(tomorrow)
|
const habit = createTestHabit(tomorrow)
|
||||||
|
|
||||||
// Test in various timezones
|
// Test in various timezones
|
||||||
expect(isTaskOverdue(habit, 'UTC')).toBe(false)
|
expect(isTaskOverdue(habit, 'UTC')).toBe(false)
|
||||||
expect(isTaskOverdue(habit, 'America/New_York')).toBe(false)
|
expect(isTaskOverdue(habit, 'America/New_York')).toBe(false)
|
||||||
@@ -147,32 +174,6 @@ describe('isTaskOverdue', () => {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
describe('uuid', () => {
|
|
||||||
test('should generate valid UUIDs', () => {
|
|
||||||
const id = uuid()
|
|
||||||
// UUID v4 format: 8-4-4-4-12 hex digits
|
|
||||||
expect(id).toMatch(/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/)
|
|
||||||
})
|
|
||||||
|
|
||||||
test('should generate unique UUIDs', () => {
|
|
||||||
const ids = new Set()
|
|
||||||
for (let i = 0; i < 1000; i++) {
|
|
||||||
ids.add(uuid())
|
|
||||||
}
|
|
||||||
// All 1000 UUIDs should be unique
|
|
||||||
expect(ids.size).toBe(1000)
|
|
||||||
})
|
|
||||||
|
|
||||||
test('should generate v4 UUIDs', () => {
|
|
||||||
const id = uuid()
|
|
||||||
// Version 4 UUID has specific bits set:
|
|
||||||
// - 13th character is '4'
|
|
||||||
// - 17th character is '8', '9', 'a', or 'b'
|
|
||||||
expect(id.charAt(14)).toBe('4')
|
|
||||||
expect('89ab').toContain(id.charAt(19))
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe('datetime utilities', () => {
|
describe('datetime utilities', () => {
|
||||||
let fixedNow: DateTime;
|
let fixedNow: DateTime;
|
||||||
let currentDateIndex = 0;
|
let currentDateIndex = 0;
|
||||||
@@ -290,13 +291,6 @@ describe('getNow', () => {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
describe('getNowInMilliseconds', () => {
|
|
||||||
test('should return current time in milliseconds', () => {
|
|
||||||
const now = DateTime.now().setZone('UTC')
|
|
||||||
expect(getNowInMilliseconds()).toBe(now.toMillis().toString())
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe('timestamp conversion utilities', () => {
|
describe('timestamp conversion utilities', () => {
|
||||||
const testTimestamp = '2024-01-01T00:00:00.000Z';
|
const testTimestamp = '2024-01-01T00:00:00.000Z';
|
||||||
const testDateTime = DateTime.fromISO(testTimestamp);
|
const testDateTime = DateTime.fromISO(testTimestamp);
|
||||||
@@ -320,16 +314,6 @@ describe('timestamp conversion utilities', () => {
|
|||||||
const customFormat = d2s({ dateTime: testDateTime, format: 'yyyy-MM-dd', timezone: 'utc' });
|
const customFormat = d2s({ dateTime: testDateTime, format: 'yyyy-MM-dd', timezone: 'utc' });
|
||||||
expect(customFormat).toBe('2024-01-01')
|
expect(customFormat).toBe('2024-01-01')
|
||||||
})
|
})
|
||||||
|
|
||||||
test('d2sDate should format DateTime as date string', () => {
|
|
||||||
const result = d2sDate({ dateTime: testDateTime });
|
|
||||||
expect(result).toBeString()
|
|
||||||
})
|
|
||||||
|
|
||||||
test('d2n should convert DateTime to milliseconds string', () => {
|
|
||||||
const result = d2n({ dateTime: testDateTime });
|
|
||||||
expect(result).toBe('1704067200000')
|
|
||||||
})
|
|
||||||
})
|
})
|
||||||
|
|
||||||
describe('isSameDate', () => {
|
describe('isSameDate', () => {
|
||||||
@@ -594,7 +578,7 @@ 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')
|
||||||
const consoleSpy = spyOn(console, 'error').mockImplementation(() => {})
|
const consoleSpy = spyOn(console, 'error').mockImplementation(() => { })
|
||||||
expect(isHabitDueToday({ habit, timezone: 'UTC' })).toBe(false)
|
expect(isHabitDueToday({ habit, timezone: 'UTC' })).toBe(false)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
@@ -707,7 +691,7 @@ 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 })).toBe(false)
|
expect(isHabitDue({ habit, timezone: 'UTC', date })).toBe(false)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
@@ -956,3 +940,96 @@ describe('convertMachineReadableFrequencyToHumanReadable', () => {
|
|||||||
expect(humanReadable).toBe('invalid')
|
expect(humanReadable).toBe('invalid')
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
describe('freshness utilities', () => {
|
||||||
|
const mockSettings: Settings = getDefaultSettings<Settings>();
|
||||||
|
const mockHabits: HabitsData = getDefaultHabitsData<HabitsData>();
|
||||||
|
const mockCoins: CoinsData = getDefaultCoinsData<CoinsData>();
|
||||||
|
const mockWishlist: WishlistData = getDefaultWishlistData<WishlistData>();
|
||||||
|
const mockUsers: UserData = getDefaultUsersData<UserData>();
|
||||||
|
|
||||||
|
// Add a user to mockUsers for more realistic testing
|
||||||
|
mockUsers.users.push({
|
||||||
|
id: 'user-123',
|
||||||
|
username: 'testuser',
|
||||||
|
isAdmin: false,
|
||||||
|
});
|
||||||
|
mockHabits.habits.push({
|
||||||
|
id: 'habit-123',
|
||||||
|
name: 'Test Habit',
|
||||||
|
description: 'A habit for testing',
|
||||||
|
frequency: 'FREQ=DAILY',
|
||||||
|
coinReward: 10,
|
||||||
|
completions: [],
|
||||||
|
userIds: ['user-123']
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
describe('prepareDataForHashing', () => {
|
||||||
|
test('should produce a consistent string for the same data', () => {
|
||||||
|
const data1 = { settings: mockSettings, habits: mockHabits, coins: mockCoins, wishlist: mockWishlist, users: mockUsers };
|
||||||
|
const data2 = { settings: mockSettings, habits: mockHabits, coins: mockCoins, wishlist: mockWishlist, users: mockUsers }; // Identical data
|
||||||
|
|
||||||
|
const string1 = prepareDataForHashing(data1.settings, data1.habits, data1.coins, data1.wishlist, data1.users);
|
||||||
|
const string2 = prepareDataForHashing(data2.settings, data2.habits, data2.coins, data2.wishlist, data2.users);
|
||||||
|
|
||||||
|
expect(string1).toBe(string2);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should produce a different string if settings data changes', () => {
|
||||||
|
const string1 = prepareDataForHashing(mockSettings, mockHabits, mockCoins, mockWishlist, mockUsers);
|
||||||
|
const modifiedSettings = { ...mockSettings, system: { ...mockSettings.system, timezone: 'America/Chicago' } };
|
||||||
|
const string2 = prepareDataForHashing(modifiedSettings, mockHabits, mockCoins, mockWishlist, mockUsers);
|
||||||
|
expect(string1).not.toBe(string2);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should produce a different string if habits data changes', () => {
|
||||||
|
const string1 = prepareDataForHashing(mockSettings, mockHabits, mockCoins, mockWishlist, mockUsers);
|
||||||
|
const modifiedHabits = { ...mockHabits, habits: [...mockHabits.habits, { id: 'new-habit', name: 'New', description: '', frequency: 'FREQ=DAILY', coinReward: 5, completions: [] }] };
|
||||||
|
const string2 = prepareDataForHashing(mockSettings, modifiedHabits, mockCoins, mockWishlist, mockUsers);
|
||||||
|
expect(string1).not.toBe(string2);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should handle empty data consistently', () => {
|
||||||
|
const emptySettings = getDefaultSettings<Settings>();
|
||||||
|
const emptyHabits = getDefaultHabitsData<HabitsData>();
|
||||||
|
const emptyCoins = getDefaultCoinsData<CoinsData>();
|
||||||
|
const emptyWishlist = getDefaultWishlistData<WishlistData>();
|
||||||
|
const emptyUsers = getDefaultUsersData<UserData>();
|
||||||
|
|
||||||
|
const string1 = prepareDataForHashing(emptySettings, emptyHabits, emptyCoins, emptyWishlist, emptyUsers);
|
||||||
|
const string2 = prepareDataForHashing(emptySettings, emptyHabits, emptyCoins, emptyWishlist, emptyUsers);
|
||||||
|
expect(string1).toBe(string2);
|
||||||
|
expect(string1).toBeDefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('generateCryptoHash', () => {
|
||||||
|
test('should generate a SHA-256 hex string', async () => {
|
||||||
|
const dataString = 'test string';
|
||||||
|
const hash = await generateCryptoHash(dataString);
|
||||||
|
expect(hash).toMatch(/^[a-f0-9]{64}$/); // SHA-256 hex is 64 chars
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should generate different hashes for different strings', async () => {
|
||||||
|
const hash1 = await generateCryptoHash('test string 1');
|
||||||
|
const hash2 = await generateCryptoHash('test string 2');
|
||||||
|
expect(hash1).not.toBe(hash2);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should generate the same hash for the same string', async () => {
|
||||||
|
const hash1 = await generateCryptoHash('consistent string');
|
||||||
|
const hash2 = await generateCryptoHash('consistent string');
|
||||||
|
expect(hash1).toBe(hash2);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Test with a known SHA-256 value if possible, or ensure crypto.subtle.digest is available
|
||||||
|
// For "hello world", SHA-256 is "b94d27b9934d3e08a52e52d7da7dabfac484efe37a5380ee9088f7ace2efcde9"
|
||||||
|
test('should generate correct hash for a known string', async () => {
|
||||||
|
const knownString = "hello world";
|
||||||
|
const expectedHash = "b94d27b9934d3e08a52e52d7da7dabfac484efe37a5380ee9088f7ace2efcde9";
|
||||||
|
const actualHash = await generateCryptoHash(knownString);
|
||||||
|
expect(actualHash).toBe(expectedHash);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
})
|
||||||
|
|||||||
152
lib/utils.ts
152
lib/utils.ts
@@ -1,12 +1,12 @@
|
|||||||
import { clsx, type ClassValue } from "clsx"
|
import { toast } from "@/hooks/use-toast"
|
||||||
import { twMerge } from "tailwind-merge"
|
import { CoinsData, CoinTransaction, Freq, Habit, HabitsData, ParsedFrequencyResult, ParsedResultType, SafeUser, Settings, User, UserData, WishlistData } from '@/lib/types'
|
||||||
import { DateTime, DateTimeFormatOptions } from "luxon"
|
|
||||||
import { datetime, RRule } from 'rrule'
|
|
||||||
import { Freq, Habit, CoinTransaction, Permission, ParsedFrequencyResult, ParsedResultType, User } from '@/lib/types'
|
|
||||||
import { DUE_MAP, INITIAL_DUE, RECURRENCE_RULE_MAP } from "./constants"
|
|
||||||
import * as chrono from 'chrono-node'
|
import * as chrono from 'chrono-node'
|
||||||
import _ from "lodash"
|
import { clsx, type ClassValue } from "clsx"
|
||||||
import { v4 as uuidv4 } from 'uuid'
|
import { DateTime, DateTimeFormatOptions } from "luxon"
|
||||||
|
import { Formats } from "next-intl"
|
||||||
|
import { datetime, RRule } from 'rrule'
|
||||||
|
import { twMerge } from "tailwind-merge"
|
||||||
|
import { DUE_MAP, INITIAL_DUE, RECURRENCE_RULE_MAP } from "./constants"
|
||||||
|
|
||||||
export function cn(...inputs: ClassValue[]) {
|
export function cn(...inputs: ClassValue[]) {
|
||||||
return twMerge(clsx(inputs))
|
return twMerge(clsx(inputs))
|
||||||
@@ -18,6 +18,11 @@ export function getTodayInTimezone(timezone: string): string {
|
|||||||
return getISODate({ dateTime: now, timezone });
|
return getISODate({ dateTime: now, timezone });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// round a number to the nearest integer
|
||||||
|
export function roundToInteger(value: number): number {
|
||||||
|
return Math.round(value);
|
||||||
|
}
|
||||||
|
|
||||||
export function getISODate({ dateTime, timezone }: { dateTime: DateTime, timezone: string }): string {
|
export function getISODate({ dateTime, timezone }: { dateTime: DateTime, timezone: string }): string {
|
||||||
return dateTime.setZone(timezone).toISODate()!;
|
return dateTime.setZone(timezone).toISODate()!;
|
||||||
}
|
}
|
||||||
@@ -27,12 +32,6 @@ export function getNow({ timezone = 'utc', keepLocalTime }: { timezone?: string,
|
|||||||
return DateTime.now().setZone(timezone, { keepLocalTime });
|
return DateTime.now().setZone(timezone, { keepLocalTime });
|
||||||
}
|
}
|
||||||
|
|
||||||
// get current time in epoch milliseconds
|
|
||||||
export function getNowInMilliseconds() {
|
|
||||||
const now = getNow({});
|
|
||||||
return d2n({ dateTime: now });
|
|
||||||
}
|
|
||||||
|
|
||||||
// iso timestamp to datetime object, most for storage read
|
// iso timestamp to datetime object, most for storage read
|
||||||
export function t2d({ timestamp, timezone }: { timestamp: string; timezone: string }) {
|
export function t2d({ timestamp, timezone }: { timestamp: string; timezone: string }) {
|
||||||
return DateTime.fromISO(timestamp).setZone(timezone);
|
return DateTime.fromISO(timestamp).setZone(timezone);
|
||||||
@@ -55,30 +54,11 @@ export function d2s({ dateTime, format, timezone }: { dateTime: DateTime, format
|
|||||||
return dateTime.setZone(timezone).toLocaleString(DateTime.DATETIME_MED);
|
return dateTime.setZone(timezone).toLocaleString(DateTime.DATETIME_MED);
|
||||||
}
|
}
|
||||||
|
|
||||||
// convert datetime object to date string, mostly for display
|
|
||||||
export function d2sDate({ dateTime }: { dateTime: DateTime }) {
|
|
||||||
return dateTime.toLocaleString(DateTime.DATE_MED);
|
|
||||||
}
|
|
||||||
|
|
||||||
// convert datetime object to epoch milliseconds string, mostly for storage write
|
|
||||||
export function d2n({ dateTime }: { dateTime: DateTime }) {
|
|
||||||
return dateTime.toMillis().toString();
|
|
||||||
}
|
|
||||||
|
|
||||||
// compare the date portion of two datetime objects (i.e. same year, month, day)
|
// compare the date portion of two datetime objects (i.e. same year, month, day)
|
||||||
export function isSameDate(a: DateTime, b: DateTime) {
|
export function isSameDate(a: DateTime, b: DateTime) {
|
||||||
return a.hasSame(b, 'day');
|
return a.hasSame(b, 'day');
|
||||||
}
|
}
|
||||||
|
|
||||||
export function normalizeCompletionDate(date: string, timezone: string): string {
|
|
||||||
// If already in ISO format, return as is
|
|
||||||
if (date.includes('T')) {
|
|
||||||
return date;
|
|
||||||
}
|
|
||||||
// Convert from yyyy-MM-dd to ISO format
|
|
||||||
return DateTime.fromFormat(date, 'yyyy-MM-dd', { zone: timezone }).toUTC().toISO()!;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getCompletionsForDate({
|
export function getCompletionsForDate({
|
||||||
habit,
|
habit,
|
||||||
date,
|
date,
|
||||||
@@ -432,22 +412,20 @@ export const openWindow = (url: string): boolean => {
|
|||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
export function deepMerge<T>(a: T, b: T) {
|
export function hasPermission(
|
||||||
return _.merge(a, b, (x: unknown, y: unknown) => {
|
user: User | undefined,
|
||||||
if (_.isArray(a)) {
|
|
||||||
return a.concat(b)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
export function checkPermission(
|
|
||||||
permissions: Permission[] | undefined,
|
|
||||||
resource: 'habit' | 'wishlist' | 'coins',
|
resource: 'habit' | 'wishlist' | 'coins',
|
||||||
action: 'write' | 'interact'
|
action: 'write' | 'interact'
|
||||||
): boolean {
|
): boolean {
|
||||||
if (!permissions) return false
|
if (!user || !user.permissions) {
|
||||||
|
return false;
|
||||||
return permissions.some(permission => {
|
}
|
||||||
|
// If user is admin, they have all permissions.
|
||||||
|
if (user.isAdmin) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
// Otherwise, check specific permissions.
|
||||||
|
return user.permissions.some(permission => {
|
||||||
switch (resource) {
|
switch (resource) {
|
||||||
case 'habit':
|
case 'habit':
|
||||||
return permission.habit[action]
|
return permission.habit[action]
|
||||||
@@ -461,23 +439,73 @@ export function checkPermission(
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
export function uuid() {
|
/**
|
||||||
return uuidv4()
|
* Prepares a consistent string representation of the data for hashing.
|
||||||
|
* It combines all relevant data pieces into a single object and then stringifies it stably.
|
||||||
|
*/
|
||||||
|
export function prepareDataForHashing(
|
||||||
|
settings: Settings,
|
||||||
|
habits: HabitsData,
|
||||||
|
coins: CoinsData,
|
||||||
|
wishlist: WishlistData,
|
||||||
|
users: UserData
|
||||||
|
): string {
|
||||||
|
return JSON.stringify({
|
||||||
|
settings,
|
||||||
|
habits,
|
||||||
|
coins,
|
||||||
|
wishlist,
|
||||||
|
users,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function hasPermission(
|
/**
|
||||||
currentUser: User | undefined,
|
* Generates a SHA-256 hash for a given string using the Web Crypto API.
|
||||||
resource: 'habit' | 'wishlist' | 'coins',
|
* This function is suitable for both client-side and server-side (Node.js 19+) environments.
|
||||||
action: 'write' | 'interact'
|
* @param dataString The string to hash.
|
||||||
): boolean {
|
* @returns A promise that resolves to the hex string of the hash.
|
||||||
// If no current user, no permissions.
|
*/
|
||||||
if (!currentUser) {
|
export async function generateCryptoHash(dataString: string): Promise<string | null> {
|
||||||
return false;
|
try {
|
||||||
|
const encoder = new TextEncoder();
|
||||||
|
const data = encoder.encode(dataString);
|
||||||
|
// globalThis.crypto should be available in modern browsers and Node.js (v19+)
|
||||||
|
// For Node.js v15-v18, you might need: const { subtle } = require('node:crypto').webcrypto;
|
||||||
|
const hashBuffer = await globalThis.crypto.subtle.digest('SHA-256', data);
|
||||||
|
const hashArray = Array.from(new Uint8Array(hashBuffer));
|
||||||
|
// Convert buffer to hex string
|
||||||
|
const hashHex = hashArray.map(b => b.toString(16).padStart(2, '0')).join('');
|
||||||
|
return hashHex;
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Failed to generate hash: ${error}`);
|
||||||
|
return null;
|
||||||
}
|
}
|
||||||
// If user is admin, they have all permissions.
|
|
||||||
if (currentUser.isAdmin) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
// Otherwise, check specific permissions.
|
|
||||||
return checkPermission(currentUser.permissions, resource, action);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function handlePermissionCheck(
|
||||||
|
user: User | SafeUser | undefined,
|
||||||
|
resource: 'habit' | 'wishlist' | 'coins',
|
||||||
|
action: 'write' | 'interact',
|
||||||
|
tCommon: (key: string, values?: Record<string, string | number | Date> | undefined, formats?: Formats | undefined) => string
|
||||||
|
): boolean {
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
toast({
|
||||||
|
title: tCommon("authenticationRequiredTitle"),
|
||||||
|
description: tCommon("authenticationRequiredDescription"),
|
||||||
|
variant: "destructive",
|
||||||
|
})
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!hasPermission(user, resource, action)) {
|
||||||
|
toast({
|
||||||
|
title: tCommon("permissionDeniedTitle"),
|
||||||
|
description: tCommon("permissionDeniedDescription", { action, resource }),
|
||||||
|
variant: "destructive",
|
||||||
|
})
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
return true
|
||||||
|
}
|
||||||
112
package-lock.json
generated
112
package-lock.json
generated
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "habittrove",
|
"name": "habittrove",
|
||||||
"version": "0.2.13",
|
"version": "0.2.20",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "habittrove",
|
"name": "habittrove",
|
||||||
"version": "0.2.13",
|
"version": "0.2.20",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@emoji-mart/data": "^1.2.1",
|
"@emoji-mart/data": "^1.2.1",
|
||||||
"@emoji-mart/react": "^1.1.1",
|
"@emoji-mart/react": "^1.1.1",
|
||||||
@@ -36,6 +36,7 @@
|
|||||||
"date-fns": "^3.6.0",
|
"date-fns": "^3.6.0",
|
||||||
"jotai": "^2.8.0",
|
"jotai": "^2.8.0",
|
||||||
"js-confetti": "^0.12.0",
|
"js-confetti": "^0.12.0",
|
||||||
|
"json-stable-stringify": "^1.3.0",
|
||||||
"linkify": "^0.2.1",
|
"linkify": "^0.2.1",
|
||||||
"linkify-react": "^4.2.0",
|
"linkify-react": "^4.2.0",
|
||||||
"lucide-react": "^0.469.0",
|
"lucide-react": "^0.469.0",
|
||||||
@@ -63,6 +64,7 @@
|
|||||||
"@tailwindcss/typography": "^0.5.15",
|
"@tailwindcss/typography": "^0.5.15",
|
||||||
"@types/archiver": "^6.0.3",
|
"@types/archiver": "^6.0.3",
|
||||||
"@types/bun": "^1.1.14",
|
"@types/bun": "^1.1.14",
|
||||||
|
"@types/json-stable-stringify": "^1.1.0",
|
||||||
"@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",
|
||||||
@@ -2851,6 +2853,13 @@
|
|||||||
"integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==",
|
"integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==",
|
||||||
"dev": true
|
"dev": true
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/json-stable-stringify": {
|
||||||
|
"version": "1.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/json-stable-stringify/-/json-stable-stringify-1.1.0.tgz",
|
||||||
|
"integrity": "sha512-ESTsHWB72QQq+pjUFIbEz9uSCZppD31YrVkbt2rnUciTYEvcwN6uZIhX5JZeBHqRlFJ41x/7MewCs7E2Qux6Cg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/@types/json5": {
|
"node_modules/@types/json5": {
|
||||||
"version": "0.0.29",
|
"version": "0.0.29",
|
||||||
"resolved": "https://registry.npmjs.org/@types/json5/-/json5-0.0.29.tgz",
|
"resolved": "https://registry.npmjs.org/@types/json5/-/json5-0.0.29.tgz",
|
||||||
@@ -3999,7 +4008,6 @@
|
|||||||
"version": "1.0.8",
|
"version": "1.0.8",
|
||||||
"resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.8.tgz",
|
"resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.8.tgz",
|
||||||
"integrity": "sha512-oKlSFMcMwpUg2ednkhQ454wfWiU/ul3CkJe/PEHcTKuiX6RpbehUiFMXu13HalGZxfUwCQzZG747YXBn1im9ww==",
|
"integrity": "sha512-oKlSFMcMwpUg2ednkhQ454wfWiU/ul3CkJe/PEHcTKuiX6RpbehUiFMXu13HalGZxfUwCQzZG747YXBn1im9ww==",
|
||||||
"dev": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"call-bind-apply-helpers": "^1.0.0",
|
"call-bind-apply-helpers": "^1.0.0",
|
||||||
"es-define-property": "^1.0.0",
|
"es-define-property": "^1.0.0",
|
||||||
@@ -4014,10 +4022,10 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/call-bind-apply-helpers": {
|
"node_modules/call-bind-apply-helpers": {
|
||||||
"version": "1.0.1",
|
"version": "1.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",
|
||||||
"integrity": "sha512-BhYE+WDaywFg2TBWYNXAE+8B1ATnThNBqXHP5nQu0jWJdVvY2hvkpyB3qOmtmDePiS5/BDQ8wASEWGMWRG148g==",
|
"integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==",
|
||||||
"dev": true,
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"es-errors": "^1.3.0",
|
"es-errors": "^1.3.0",
|
||||||
"function-bind": "^1.1.2"
|
"function-bind": "^1.1.2"
|
||||||
@@ -4027,13 +4035,13 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/call-bound": {
|
"node_modules/call-bound": {
|
||||||
"version": "1.0.3",
|
"version": "1.0.4",
|
||||||
"resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.3.tgz",
|
"resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz",
|
||||||
"integrity": "sha512-YTd+6wGlNlPxSuri7Y6X8tY2dmm12UMH66RpKMhiX6rsk5wXXnYgbUcOt8kiS31/AjfoTOvCsE+w8nZQLQnzHA==",
|
"integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==",
|
||||||
"dev": true,
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"call-bind-apply-helpers": "^1.0.1",
|
"call-bind-apply-helpers": "^1.0.2",
|
||||||
"get-intrinsic": "^1.2.6"
|
"get-intrinsic": "^1.3.0"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">= 0.4"
|
"node": ">= 0.4"
|
||||||
@@ -4604,7 +4612,6 @@
|
|||||||
"version": "1.1.4",
|
"version": "1.1.4",
|
||||||
"resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz",
|
"resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz",
|
||||||
"integrity": "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==",
|
"integrity": "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==",
|
||||||
"dev": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"es-define-property": "^1.0.0",
|
"es-define-property": "^1.0.0",
|
||||||
"es-errors": "^1.3.0",
|
"es-errors": "^1.3.0",
|
||||||
@@ -4703,7 +4710,6 @@
|
|||||||
"version": "1.0.1",
|
"version": "1.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
|
||||||
"integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==",
|
"integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==",
|
||||||
"dev": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"call-bind-apply-helpers": "^1.0.1",
|
"call-bind-apply-helpers": "^1.0.1",
|
||||||
"es-errors": "^1.3.0",
|
"es-errors": "^1.3.0",
|
||||||
@@ -4831,7 +4837,6 @@
|
|||||||
"version": "1.0.1",
|
"version": "1.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
|
||||||
"integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==",
|
"integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==",
|
||||||
"dev": true,
|
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">= 0.4"
|
"node": ">= 0.4"
|
||||||
}
|
}
|
||||||
@@ -4840,7 +4845,6 @@
|
|||||||
"version": "1.3.0",
|
"version": "1.3.0",
|
||||||
"resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz",
|
"resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz",
|
||||||
"integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==",
|
"integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==",
|
||||||
"dev": true,
|
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">= 0.4"
|
"node": ">= 0.4"
|
||||||
}
|
}
|
||||||
@@ -4880,10 +4884,10 @@
|
|||||||
"peer": true
|
"peer": true
|
||||||
},
|
},
|
||||||
"node_modules/es-object-atoms": {
|
"node_modules/es-object-atoms": {
|
||||||
"version": "1.0.0",
|
"version": "1.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz",
|
||||||
"integrity": "sha512-MZ4iQ6JwHOBQjahnjwaC1ZtIBH+2ohjamzAO3oaHcXYup7qxjF2fixyH+Q71voWHeOkI2q/TnJao/KfXYIZWbw==",
|
"integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==",
|
||||||
"dev": true,
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"es-errors": "^1.3.0"
|
"es-errors": "^1.3.0"
|
||||||
},
|
},
|
||||||
@@ -5644,21 +5648,21 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/get-intrinsic": {
|
"node_modules/get-intrinsic": {
|
||||||
"version": "1.2.6",
|
"version": "1.3.0",
|
||||||
"resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.2.6.tgz",
|
"resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
|
||||||
"integrity": "sha512-qxsEs+9A+u85HhllWJJFicJfPDhRmjzoYdl64aMWW9yRIJmSyxdn8IEkuIM530/7T+lv0TIHd8L6Q/ra0tEoeA==",
|
"integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==",
|
||||||
"dev": true,
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"call-bind-apply-helpers": "^1.0.1",
|
"call-bind-apply-helpers": "^1.0.2",
|
||||||
"dunder-proto": "^1.0.0",
|
|
||||||
"es-define-property": "^1.0.1",
|
"es-define-property": "^1.0.1",
|
||||||
"es-errors": "^1.3.0",
|
"es-errors": "^1.3.0",
|
||||||
"es-object-atoms": "^1.0.0",
|
"es-object-atoms": "^1.1.1",
|
||||||
"function-bind": "^1.1.2",
|
"function-bind": "^1.1.2",
|
||||||
|
"get-proto": "^1.0.1",
|
||||||
"gopd": "^1.2.0",
|
"gopd": "^1.2.0",
|
||||||
"has-symbols": "^1.1.0",
|
"has-symbols": "^1.1.0",
|
||||||
"hasown": "^2.0.2",
|
"hasown": "^2.0.2",
|
||||||
"math-intrinsics": "^1.0.0"
|
"math-intrinsics": "^1.1.0"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">= 0.4"
|
"node": ">= 0.4"
|
||||||
@@ -5675,6 +5679,19 @@
|
|||||||
"node": ">=6"
|
"node": ">=6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/get-proto": {
|
||||||
|
"version": "1.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz",
|
||||||
|
"integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"dunder-proto": "^1.0.1",
|
||||||
|
"es-object-atoms": "^1.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.4"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/get-symbol-description": {
|
"node_modules/get-symbol-description": {
|
||||||
"version": "1.1.0",
|
"version": "1.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/get-symbol-description/-/get-symbol-description-1.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/get-symbol-description/-/get-symbol-description-1.1.0.tgz",
|
||||||
@@ -5795,7 +5812,6 @@
|
|||||||
"version": "1.2.0",
|
"version": "1.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
|
||||||
"integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==",
|
"integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==",
|
||||||
"dev": true,
|
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">= 0.4"
|
"node": ">= 0.4"
|
||||||
},
|
},
|
||||||
@@ -5839,7 +5855,6 @@
|
|||||||
"version": "1.0.2",
|
"version": "1.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz",
|
||||||
"integrity": "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==",
|
"integrity": "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==",
|
||||||
"dev": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"es-define-property": "^1.0.0"
|
"es-define-property": "^1.0.0"
|
||||||
},
|
},
|
||||||
@@ -5866,7 +5881,6 @@
|
|||||||
"version": "1.1.0",
|
"version": "1.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz",
|
||||||
"integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==",
|
"integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==",
|
||||||
"dev": true,
|
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">= 0.4"
|
"node": ">= 0.4"
|
||||||
},
|
},
|
||||||
@@ -6523,8 +6537,7 @@
|
|||||||
"node_modules/isarray": {
|
"node_modules/isarray": {
|
||||||
"version": "2.0.5",
|
"version": "2.0.5",
|
||||||
"resolved": "https://registry.npmjs.org/isarray/-/isarray-2.0.5.tgz",
|
"resolved": "https://registry.npmjs.org/isarray/-/isarray-2.0.5.tgz",
|
||||||
"integrity": "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==",
|
"integrity": "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw=="
|
||||||
"dev": true
|
|
||||||
},
|
},
|
||||||
"node_modules/isexe": {
|
"node_modules/isexe": {
|
||||||
"version": "2.0.0",
|
"version": "2.0.0",
|
||||||
@@ -6671,6 +6684,25 @@
|
|||||||
"integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==",
|
"integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==",
|
||||||
"dev": true
|
"dev": true
|
||||||
},
|
},
|
||||||
|
"node_modules/json-stable-stringify": {
|
||||||
|
"version": "1.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/json-stable-stringify/-/json-stable-stringify-1.3.0.tgz",
|
||||||
|
"integrity": "sha512-qtYiSSFlwot9XHtF9bD9c7rwKjr+RecWT//ZnPvSmEjpV5mmPOCN4j8UjY5hbjNkOwZ/jQv3J6R1/pL7RwgMsg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"call-bind": "^1.0.8",
|
||||||
|
"call-bound": "^1.0.4",
|
||||||
|
"isarray": "^2.0.5",
|
||||||
|
"jsonify": "^0.0.1",
|
||||||
|
"object-keys": "^1.1.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.4"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/json-stable-stringify-without-jsonify": {
|
"node_modules/json-stable-stringify-without-jsonify": {
|
||||||
"version": "1.0.1",
|
"version": "1.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz",
|
||||||
@@ -6689,6 +6721,15 @@
|
|||||||
"json5": "lib/cli.js"
|
"json5": "lib/cli.js"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/jsonify": {
|
||||||
|
"version": "0.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/jsonify/-/jsonify-0.0.1.tgz",
|
||||||
|
"integrity": "sha512-2/Ki0GcmuqSrgFyelQq9M05y7PS0mEwuIzrf3f1fPqkVDVRvZrPZtVSMHxdgo8Aq0sxAOb/cr2aqqA3LeWHVPg==",
|
||||||
|
"license": "Public Domain",
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/jsx-ast-utils": {
|
"node_modules/jsx-ast-utils": {
|
||||||
"version": "3.3.5",
|
"version": "3.3.5",
|
||||||
"resolved": "https://registry.npmjs.org/jsx-ast-utils/-/jsx-ast-utils-3.3.5.tgz",
|
"resolved": "https://registry.npmjs.org/jsx-ast-utils/-/jsx-ast-utils-3.3.5.tgz",
|
||||||
@@ -6966,7 +7007,6 @@
|
|||||||
"version": "1.1.0",
|
"version": "1.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
|
||||||
"integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==",
|
"integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==",
|
||||||
"dev": true,
|
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">= 0.4"
|
"node": ">= 0.4"
|
||||||
}
|
}
|
||||||
@@ -7896,7 +7936,6 @@
|
|||||||
"version": "1.1.1",
|
"version": "1.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/object-keys/-/object-keys-1.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/object-keys/-/object-keys-1.1.1.tgz",
|
||||||
"integrity": "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==",
|
"integrity": "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==",
|
||||||
"dev": true,
|
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">= 0.4"
|
"node": ">= 0.4"
|
||||||
}
|
}
|
||||||
@@ -8958,7 +8997,6 @@
|
|||||||
"version": "1.2.2",
|
"version": "1.2.2",
|
||||||
"resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz",
|
"resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz",
|
||||||
"integrity": "sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==",
|
"integrity": "sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==",
|
||||||
"dev": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"define-data-property": "^1.1.4",
|
"define-data-property": "^1.1.4",
|
||||||
"es-errors": "^1.3.0",
|
"es-errors": "^1.3.0",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "habittrove",
|
"name": "habittrove",
|
||||||
"version": "0.2.21",
|
"version": "0.2.23",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "next dev --turbopack",
|
"dev": "next dev --turbopack",
|
||||||
@@ -45,6 +45,7 @@
|
|||||||
"js-confetti": "^0.12.0",
|
"js-confetti": "^0.12.0",
|
||||||
"linkify": "^0.2.1",
|
"linkify": "^0.2.1",
|
||||||
"linkify-react": "^4.2.0",
|
"linkify-react": "^4.2.0",
|
||||||
|
"lodash": "^4.17.21",
|
||||||
"lucide-react": "^0.469.0",
|
"lucide-react": "^0.469.0",
|
||||||
"luxon": "^3.5.0",
|
"luxon": "^3.5.0",
|
||||||
"next": "15.2.3",
|
"next": "15.2.3",
|
||||||
@@ -61,7 +62,6 @@
|
|||||||
"rrule": "^2.8.1",
|
"rrule": "^2.8.1",
|
||||||
"tailwind-merge": "^2.6.0",
|
"tailwind-merge": "^2.6.0",
|
||||||
"tailwindcss-animate": "^1.0.7",
|
"tailwindcss-animate": "^1.0.7",
|
||||||
"uuid": "^11.0.5",
|
|
||||||
"web-push": "^3.6.7",
|
"web-push": "^3.6.7",
|
||||||
"zod": "^3.24.1"
|
"zod": "^3.24.1"
|
||||||
},
|
},
|
||||||
|
|||||||
Binary file not shown.
|
Before Width: | Height: | Size: 9.4 KiB After Width: | Height: | Size: 8.1 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 28 KiB After Width: | Height: | Size: 22 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 130 KiB After Width: | Height: | Size: 101 KiB |
Reference in New Issue
Block a user