Compare commits

..

45 Commits

Author SHA1 Message Date
1b17d6b50a fix: add TS types 2025-08-17 19:49:11 +02:00
8269f3adad fix: refactored code & removed unused parts 2025-08-09 18:57:04 +02:00
4cadf4cea7 fix: added upstream differences to README 2025-07-10 00:45:45 +02:00
06e802f2f5 fix: resolved mobile display errors 2025-06-17 23:45:55 +02:00
6c0b196de2 fix: only display 'show all' if there are more than 4 entries 2025-06-17 23:20:39 +02:00
0f073760ee fix: unify NavDisplays 2025-06-17 22:30:18 +02:00
55c2e3577d Merge Tag v0.2.23 2025-06-13 21:59:16 +02:00
043201217f Merge Tag v0.2.22 2025-06-13 21:57:27 +02:00
4e11f17729 Merge Tag v0.2.21 2025-06-13 21:52:24 +02:00
faa6f4cb76 Merge Tag v0.2.20 2025-06-13 21:43:44 +02:00
84d6321153 Merge Tag v0.2.19 2025-06-13 21:26:19 +02:00
1af98fb233 Merge Tag v0.2.18 2025-06-13 21:22:11 +02:00
Doh
8d2bfaf62c update PWA icon, fix floating number balance (#159) 2025-06-04 18:40:48 -04:00
9046d40a7a Merge Tag v0.2.17.0 2025-06-04 16:08:11 +02:00
be0a5c48b3 Merge Tag v0.2.16.0 2025-06-04 16:02:37 +02:00
Doh
98b5d5eebb Added logo to README 2025-05-31 10:53:04 -04:00
Doh
276e8a8a7b refresh stale data (#156) 2025-05-30 18:04:03 -04:00
dohsimpson
1967d154ed bump version 2025-05-29 08:58:46 -04:00
Doh
9e0ae1e0da Fix emojipicker (#152) 2025-05-29 08:46:08 -04:00
dohsimpson
5ae659469b fix infinite render 2025-05-28 17:43:28 -04:00
dohsimpson
6ef4aacfb8 fix coin balance 2025-05-28 17:17:13 -04:00
dohsimpson
95203426a3 fix modal and invalid frequency 2025-05-27 02:42:13 -04:00
Doh
b673d54ede Added improved loading screen (#148) 2025-05-26 08:42:00 -04:00
Doh
42c8d14d6d fix emoji picker and about modal (#146) 2025-05-25 20:33:08 -04:00
Doh
3ac311c3fd add cover image in README 2025-05-25 20:27:46 -04:00
3d78a00c66 Merge Tag v0.2.15.0 2025-05-25 17:46:20 +02:00
9c2e3f7dec Merge Tag v0.2.14 2025-05-25 17:41:03 +02:00
e93b1c1c57 fix: resolved linting errors 2025-05-21 15:05:12 +02:00
92d1462010 Merge Tag v0.2.13 2025-05-21 14:57:45 +02:00
eff14f3772 Merge Tag v0.2.12 2025-05-19 12:56:21 +02:00
d9fa0426ce fix: removed viewType from browser Settings Atom, converted to using path to identify pages 2025-05-18 02:03:54 +02:00
49a0ea8804 fix: refactored habit / task page 2025-05-18 01:34:28 +02:00
9bf24db477 fix: remove empty file 2025-05-18 01:16:39 +02:00
8530f703d9 fix: unified display of header 2025-05-18 01:16:17 +02:00
1a447e00bf fix: adapted notes to reflect fork 2025-05-17 17:27:26 +02:00
ac116e8322 feat: highlight selected navigation item 2025-05-17 17:16:09 +02:00
8c7a7a63d0 fix: refactored code on wishlist page 2025-05-17 16:46:54 +02:00
7c7d0e2f32 fix: switched docker-compose.yaml image remote 2025-05-17 16:29:54 +02:00
e908f1edec fix: resolved linting problems 2025-05-14 11:01:05 +02:00
8e6ddf0b9f fix: removed other build workflows 2025-05-14 10:38:06 +02:00
c5a8f403ef feat: added ghcr release workflow 2025-05-14 10:35:28 +02:00
33d36d0600 fix: refactored error display in add habit modal & disables button if
invalid
2025-05-12 18:11:25 +02:00
942356eaed fix: resolved navigator undefined error 2025-05-12 18:11:03 +02:00
e4a52657af fix: refactored error display in add habit modal & disables button if invalid 2025-05-12 18:00:04 +02:00
dbd0d0c7b7 fix: added missing dependency lodash 2025-05-12 17:05:16 +02:00
75 changed files with 1822 additions and 1566 deletions

View File

@@ -1,95 +0,0 @@
name: Docker Build and Publish
on:
push:
branches:
- main
- github-actions
jobs:
build-and-push:
runs-on: ubuntu-latest
outputs:
EXISTS: ${{ steps.check-version.outputs.EXISTS }}
VERSION: ${{ steps.package-version.outputs.VERSION }}
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Set up Node.js
uses: actions/setup-node@v4
with:
node-version: "20"
- name: Get version from package.json
id: package-version
run: echo "VERSION=$(node -p "require('./package.json').version")" >> $GITHUB_OUTPUT
- name: Login to DockerHub
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Check if version exists
id: check-version
run: |
if docker pull dohsimpson/habittrove:v${{ steps.package-version.outputs.VERSION }} 2>/dev/null; then
echo "EXISTS=true" >> $GITHUB_OUTPUT
else
echo "EXISTS=false" >> $GITHUB_OUTPUT
fi
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Build and push Docker images
uses: docker/build-push-action@v5
with:
context: .
platforms: linux/amd64,linux/arm64
push: true
tags: |
${{ steps.check-version.outputs.EXISTS == 'false' && format('dohsimpson/habittrove:v{0}', steps.package-version.outputs.VERSION) || '' }}
dohsimpson/habittrove:demo
dohsimpson/habittrove:latest
deploy-demo:
runs-on: ubuntu-latest
needs: build-and-push
# demo tracks the demo tag
if: needs.build-and-push.outputs.EXISTS == 'false'
steps:
- uses: actions/checkout@v4
- uses: actions-hub/kubectl@master
env:
KUBE_CONFIG: ${{ secrets.KUBE_CONFIG }}
with:
args: rollout restart -n ${{ secrets.KUBE_NAMESPACE }} deploy/${{ secrets.KUBE_DEPLOYMENT }}
create-release:
runs-on: ubuntu-latest
needs: build-and-push
if: needs.build-and-push.outputs.EXISTS == 'false'
permissions:
contents: write
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Create GitHub release
env:
GH_TOKEN: ${{ secrets.GH_TOKEN }}
VERSION: ${{ needs.build-and-push.outputs.VERSION }}
run: |
# Extract release notes from CHANGELOG.md
notes=$(awk -v version="$VERSION" '
$0 ~ "## Version " version {flag=1;next}
$0 ~ "## Version " && flag {exit}
flag' CHANGELOG.md)
gh release create "v$VERSION" \
--repo="$GITHUB_REPOSITORY" \
--title="v$VERSION" \
--notes="$notes"

40
.github/workflows/release.yml vendored Normal file
View File

@@ -0,0 +1,40 @@
name: Create and publish a Docker image to Github Container Registry
on:
push:
tags:
- v*
env:
REGISTRY: ghcr.io
IMAGE_NAME: ${{ github.repository }}
jobs:
create-and-publish:
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Log in to the Container registry
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Extract metadata (tags, labels) for Docker
id: meta
uses: docker/metadata-action@v5
with:
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Build and push Docker image
uses: docker/build-push-action@v5
with:
context: .
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}

View File

@@ -1,28 +0,0 @@
name: Unit Tests
on:
pull_request:
branches:
- main
jobs:
test:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Set up Bun
uses: oven-sh/setup-bun@v1
with:
bun-version: latest
- name: Install dependencies
run: bun install
- name: Run lint
run: bun run lint
- name: Run unit tests
run: bun test

2
.gitignore vendored
View File

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

6
.vscode/settings.json vendored Normal file
View File

@@ -0,0 +1,6 @@
{
"i18n-ally.localesPaths": [
"i18n",
"messages"
]
}

View File

@@ -1,5 +1,62 @@
# 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
### Fixed
* emoji picker overlay issue (#150)
## Version 0.2.20
### Fixed
* coin balance shows correct value for selected user in coin management view (#151)
### Improved
* refactor code to remove client-helpers hook
## Version 0.2.19
### Fixed
* settings button not working
* fixed delete dialog modal blocks page interaction (#149)
* disable submit button when frequency is invaid
## Version 0.2.18
### Improved
* nicer loading UI (#147)
* header and navigation code refactor
## Version 0.2.17
### Fixed
* fix emoji selector (#142)
* fix about modal (#145)
## Version 0.2.16 ## Version 0.2.16
### Improved ### Improved

View File

@@ -1,12 +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
@@ -23,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
@@ -64,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:
@@ -111,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
``` ```
@@ -164,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

View File

@@ -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 };
}
}

View File

@@ -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>
) )

View File

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

View File

@@ -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";

View File

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

View File

@@ -1,32 +1,23 @@
import './globals.css'
import { Inter } from 'next/font/google'
import { DM_Sans } from 'next/font/google'
import { JotaiProvider } from '@/components/jotai-providers'
import { Suspense } from 'react'
import { JotaiHydrate } from '@/components/jotai-hydrate' import { JotaiHydrate } from '@/components/jotai-hydrate'
import { loadSettings, loadHabitsData, loadCoinsData, loadWishlistData, 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 LoadingSpinner from '@/components/LoadingSpinner'
import { ThemeProvider } from "@/components/theme-provider" import { ThemeProvider } from "@/components/theme-provider"
import { Toaster } from '@/components/ui/toaster'
import { SessionProvider } from 'next-auth/react' import { SessionProvider } from 'next-auth/react'
import { NextIntlClientProvider } from 'next-intl'; import { NextIntlClientProvider } from 'next-intl'
import { getLocale, getMessages } from 'next-intl/server'; import { getLocale, getMessages } from 'next-intl/server'
import { DM_Sans } from 'next/font/google'
import { Suspense } from 'react'
// Inter (clean, modern, excellent readability) import { loadCoinsData, loadHabitsData, loadServerSettings, loadSettings, loadUsersData, loadWishlistData } from './actions/data'
// const inter = Inter({ import './globals.css'
// subsets: ['latin'],
// weight: ['400', '500', '600', '700']
// })
// 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',
@@ -75,7 +66,7 @@ export default async function RootLayout({
}} }}
/> />
<JotaiProvider> <JotaiProvider>
<Suspense fallback="loading"> <Suspense fallback={<LoadingSpinner />}>
<JotaiHydrate <JotaiHydrate
initialValues={{ initialValues={{
settings: initialSettings, settings: initialSettings,

View File

@@ -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
View File

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

View File

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

View File

@@ -11,17 +11,16 @@ import ChangelogModal from "./ChangelogModal"
import { useState } from "react" import { useState } from "react"
interface AboutModalProps { interface AboutModalProps {
isOpen: boolean
onClose: () => void onClose: () => void
} }
export default function AboutModal({ isOpen, onClose }: AboutModalProps) { export default function AboutModal({ onClose }: AboutModalProps) {
const t = useTranslations('AboutModal') const t = useTranslations('AboutModal')
const version = packageJson.version const version = packageJson.version
const [changelogOpen, setChangelogOpen] = useState(false) const [changelogOpen, setChangelogOpen] = useState(false)
return ( return (
<Dialog open={isOpen} onOpenChange={onClose}> <Dialog open={true} onOpenChange={onClose}>
<DialogContent className="max-w-sm"> <DialogContent className="max-w-sm">
<DialogHeader> <DialogHeader>
<DialogTitle aria-label={t('dialogArisLabel')}></DialogTitle> <DialogTitle aria-label={t('dialogArisLabel')}></DialogTitle>
@@ -58,11 +57,13 @@ export default function AboutModal({ isOpen, onClose }: AboutModalProps) {
> >
@dohsimpson @dohsimpson
</a> </a>
<br/>
Fork by <a href="https://github.com/ManInDark" target="_blank" rel="noopener noreferrer" className="font-medium hover:underline">@ManInDark</a>
</div> </div>
<div className="flex justify-center"> <div className="flex justify-center">
<a <a
href="https://github.com/dohsimpson/habittrove" href="https://github.com/ManInDark/HabitTrove"
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
> >

View File

@@ -1,34 +1,25 @@
'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 } 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 { Switch } from '@/components/ui/switch'
import { Input } from '@/components/ui/input' import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label' import { Label } from '@/components/ui/label'
import { Textarea } from '@/components/ui/textarea'
import { Info, SmilePlus, Zap } from 'lucide-react'
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover' import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
import data from '@emoji-mart/data' import { Textarea } from '@/components/ui/textarea'
import Picker from '@emoji-mart/react' import { currentUserAtom, settingsAtom, usersAtom } from '@/lib/atoms'
import { Habit, SafeUser } from '@/lib/types' 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 * as chrono from 'chrono-node'; import { useAtom } from 'jotai'
import { Zap } from 'lucide-react'
import { DateTime } from 'luxon' import { DateTime } from 'luxon'
import { import { useTranslations } from 'next-intl'
Select, import { useState } from 'react'
SelectContent, import { RRule } from 'rrule'
SelectItem, import EmojiPickerButton from './EmojiPickerButton'
SelectTrigger, import ModalOverlay from './ModalOverlay'; // Import the new component
SelectValue,
} from "@/components/ui/select"
import { useHelpers } from '@/lib/client-helpers'
interface AddEditHabitModalProps { interface AddEditHabitModalProps {
onClose: () => void onClose: () => void
@@ -52,9 +43,8 @@ export default function AddEditHabitModal({ onClose, onSave, habit, isTask }: Ad
timezone: settings.system.timezone timezone: settings.system.timezone
}) : (isRecurRule ? INITIAL_RECURRENCE_RULE : INITIAL_DUE); }) : (isRecurRule ? INITIAL_RECURRENCE_RULE : INITIAL_DUE);
const [ruleText, setRuleText] = useState<string>(initialRuleText) const [ruleText, setRuleText] = useState<string>(initialRuleText)
const { currentUser } = useHelpers() const [currentUser] = useAtom(currentUserAtom)
const [isQuickDatesOpen, setIsQuickDatesOpen] = useState(false) const [isQuickDatesOpen, setIsQuickDatesOpen] = useState(false)
const [ruleError, setRuleError] = useState<string | null>(null); // State for validation message
const [selectedUserIds, setSelectedUserIds] = useState<string[]>((habit?.userIds || []).filter(id => id !== currentUser?.id)) const [selectedUserIds, setSelectedUserIds] = useState<string[]>((habit?.userIds || []).filter(id => id !== currentUser?.id))
const [usersData] = useAtom(usersAtom) const [usersData] = useAtom(usersAtom)
const users = usersData.users const users = usersData.users
@@ -96,9 +86,13 @@ export default function AddEditHabitModal({ onClose, onSave, habit, isTask }: Ad
}) })
} }
const { result, message: errorMessage } = convertHumanReadableFrequencyToMachineReadable({ text: ruleText, timezone: settings.system.timezone, isRecurring: isRecurRule });
return ( return (
<Dialog open={true} onOpenChange={onClose}> <>
<DialogContent> <ModalOverlay />
<Dialog open={true} onOpenChange={onClose} modal={false}>
<DialogContent> {/* DialogContent from shadcn/ui is typically z-50, ModalOverlay is z-40 */}
<DialogHeader> <DialogHeader>
<DialogTitle> <DialogTitle>
{habit {habit
@@ -119,33 +113,15 @@ export default function AddEditHabitModal({ onClose, onSave, habit, isTask }: Ad
onChange={(e) => setName(e.target.value)} onChange={(e) => setName(e.target.value)}
required required
/> />
<Popover> <EmojiPickerButton
<PopoverTrigger asChild> inputIdToFocus="name"
<Button onEmojiSelect={(emoji) => {
type="button"
variant="ghost"
size="icon"
className="h-8 w-8"
>
<SmilePlus className="h-8 w-8" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-[300px] p-0">
<Picker
data={data}
onEmojiSelect={(emoji: { native: string }) => {
setName(prev => { setName(prev => {
// Add space before emoji if there isn't one already
const space = prev.length > 0 && !prev.endsWith(' ') ? ' ' : ''; const space = prev.length > 0 && !prev.endsWith(' ') ? ' ' : '';
return `${prev}${space}${emoji.native}`; return `${prev}${space}${emoji}`;
}) })
// Focus back on input after selection
const input = document.getElementById('name') as HTMLInputElement
input?.focus()
}} }}
/> />
</PopoverContent>
</Popover>
</div> </div>
</div> </div>
<div className="grid grid-cols-4 items-center gap-4"> <div className="grid grid-cols-4 items-center gap-4">
@@ -209,24 +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">
{(() => {
let displayText = '';
let errorMessage: string | null = null;
const { result, message } = convertHumanReadableFrequencyToMachineReadable({ text: ruleText, timezone: settings.system.timezone, isRecurring: isRecurRule });
errorMessage = message;
displayText = convertMachineReadableFrequencyToHumanReadable({ frequency: result, isRecurRule, timezone: settings.system.timezone })
return (
<>
<span className={errorMessage ? 'text-destructive' : 'text-muted-foreground'}> <span className={errorMessage ? 'text-destructive' : 'text-muted-foreground'}>
{displayText} {errorMessage ? errorMessage : convertMachineReadableFrequencyToHumanReadable({ frequency: result, isRecurRule, timezone: settings.system.timezone })}
</span> </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">
@@ -348,7 +309,7 @@ export default function AddEditHabitModal({ onClose, onSave, habit, isTask }: Ad
)} )}
</div> </div>
<DialogFooter> <DialogFooter>
<Button type="submit"> <Button type="submit" disabled={!!errorMessage}>
{habit {habit
? t('saveChangesButton') ? t('saveChangesButton')
: t(isTask ? 'addTaskButton' : 'addHabitButton')} : t(isTask ? 'addTaskButton' : 'addHabitButton')}
@@ -357,6 +318,6 @@ export default function AddEditHabitModal({ onClose, onSave, habit, isTask }: Ad
</form> </form>
</DialogContent> </DialogContent>
</Dialog> </Dialog>
</>
) )
} }

View File

@@ -1,24 +1,19 @@
import { useState, useEffect } from 'react'
import { useAtom } from 'jotai'
import { useTranslations } from 'next-intl'
import { usersAtom } from '@/lib/atoms'
import { useHelpers } from '@/lib/client-helpers'
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 { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover' import { currentUserAtom, usersAtom } from '@/lib/atoms'
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'
import { SmilePlus, Info } from 'lucide-react'
import data from '@emoji-mart/data'
import Picker from '@emoji-mart/react'
import { WishlistItemType } from '@/lib/types'
import { MAX_COIN_LIMIT } from '@/lib/constants' import { MAX_COIN_LIMIT } from '@/lib/constants'
import { WishlistItemType } from '@/lib/types'
import { useAtom } from 'jotai'
import { useTranslations } from 'next-intl'
import { useEffect, useState } from 'react'
import EmojiPickerButton from './EmojiPickerButton'
import ModalOverlay from './ModalOverlay'
import { Avatar, AvatarFallback, AvatarImage } from './ui/avatar'
interface AddEditWishlistItemModalProps { interface AddEditWishlistItemModalProps {
isOpen: boolean
setIsOpen: (isOpen: boolean) => void setIsOpen: (isOpen: boolean) => void
editingItem: WishlistItemType | null editingItem: WishlistItemType | null
setEditingItem: (item: WishlistItemType | null) => void setEditingItem: (item: WishlistItemType | null) => void
@@ -27,7 +22,6 @@ interface AddEditWishlistItemModalProps {
} }
export default function AddEditWishlistItemModal({ export default function AddEditWishlistItemModal({
isOpen,
setIsOpen, setIsOpen,
editingItem, editingItem,
setEditingItem, setEditingItem,
@@ -40,7 +34,7 @@ export default function AddEditWishlistItemModal({
const [coinCost, setCoinCost] = useState(editingItem?.coinCost || 1) const [coinCost, setCoinCost] = useState(editingItem?.coinCost || 1)
const [targetCompletions, setTargetCompletions] = useState<number | undefined>(editingItem?.targetCompletions) const [targetCompletions, setTargetCompletions] = useState<number | undefined>(editingItem?.targetCompletions)
const [link, setLink] = useState(editingItem?.link || '') const [link, setLink] = useState(editingItem?.link || '')
const { currentUser } = useHelpers() const [currentUser] = useAtom(currentUserAtom)
const [selectedUserIds, setSelectedUserIds] = useState<string[]>((editingItem?.userIds || []).filter(id => id !== currentUser?.id)) const [selectedUserIds, setSelectedUserIds] = useState<string[]>((editingItem?.userIds || []).filter(id => id !== currentUser?.id))
const [errors, setErrors] = useState<{ [key: string]: string }>({}) const [errors, setErrors] = useState<{ [key: string]: string }>({})
const [usersData] = useAtom(usersAtom) const [usersData] = useAtom(usersAtom)
@@ -120,8 +114,10 @@ export default function AddEditWishlistItemModal({
} }
return ( return (
<Dialog open={isOpen} onOpenChange={handleClose}> <>
<DialogContent> <ModalOverlay />
<Dialog open={true} onOpenChange={handleClose} modal={false}>
<DialogContent> {/* DialogContent from shadcn/ui is typically z-50, ModalOverlay is z-40 */}
<DialogHeader> <DialogHeader>
<DialogTitle>{editingItem ? t('editTitle') : t('addTitle')}</DialogTitle> <DialogTitle>{editingItem ? t('editTitle') : t('addTitle')}</DialogTitle>
</DialogHeader> </DialogHeader>
@@ -139,29 +135,15 @@ export default function AddEditWishlistItemModal({
className="flex-1" className="flex-1"
required required
/> />
<Popover> <EmojiPickerButton
<PopoverTrigger asChild> inputIdToFocus="name"
<Button onEmojiSelect={(emoji) => {
type="button" setName(prev => {
variant="ghost" const space = prev.length > 0 && !prev.endsWith(' ') ? ' ' : '';
size="icon" return `${prev}${space}${emoji}`;
className="h-8 w-8" })
>
<SmilePlus className="h-4 w-4" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-[300px] p-0">
<Picker
data={data}
onEmojiSelect={(emoji: { native: string }) => {
setName(prev => `${prev}${emoji.native}`)
// Focus back on input after selection
const input = document.getElementById('name') as HTMLInputElement
input?.focus()
}} }}
/> />
</PopoverContent>
</Popover>
</div> </div>
</div> </div>
<div className="grid grid-cols-4 items-center gap-4"> <div className="grid grid-cols-4 items-center gap-4">
@@ -324,6 +306,7 @@ export default function AddEditWishlistItemModal({
</form> </form>
</DialogContent> </DialogContent>
</Dialog> </Dialog>
</>
) )
} }

View File

@@ -1,34 +1,97 @@
'use client' 'use client'
import { ReactNode, useEffect } from 'react' import { checkDataFreshness as checkServerDataFreshness } from '@/app/actions/data';
import { useAtom } from 'jotai' import { aboutOpenAtom, clientFreshnessTokenAtom, currentUserIdAtom, pomodoroAtom, userSelectAtom } from '@/lib/atoms';
import { pomodoroAtom, userSelectAtom } from '@/lib/atoms' 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 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 setCurrentUserIdAtom = useSetAtom(currentUserIdAtom)
const { data: session, status } = useSession() const { data: session, status } = useSession()
const currentUserId = session?.user.id const currentUserId = session?.user.id
const [showRefreshBanner, setShowRefreshBanner] = useState(false);
// clientFreshnessTokenAtom is async, useAtomValue will suspend until it's resolved.
// Suspense boundary is in app/layout.tsx or could be added here if needed more locally.
const clientToken = useAtomValue(clientFreshnessTokenAtom);
useEffect(() => { useEffect(() => {
if (status === 'loading') return if (status === 'loading') return
if (!currentUserId && !userSelect) { if (!currentUserId && !userSelect) {
setUserSelect(true) setUserSelect(true)
} }
}, [currentUserId, status, userSelect]) }, [currentUserId, status, userSelect, setUserSelect])
useEffect(() => {
setCurrentUserIdAtom(currentUserId)
}, [currentUserId, setCurrentUserIdAtom])
const performFreshnessCheck = useCallback(async () => {
if (!clientToken || status !== 'authenticated') return;
try {
const result = await checkServerDataFreshness(clientToken);
if (!result.isFresh) {
setShowRefreshBanner(true);
}
} catch (error) {
console.error("Failed to check data freshness with server:", error);
}
}, [clientToken, status]);
useEffect(() => {
// Interval for polling data freshness
if (clientToken && !showRefreshBanner && status === 'authenticated') {
const intervalId = setInterval(() => {
performFreshnessCheck();
}, 30000); // Check every 30 seconds
return () => clearInterval(intervalId);
}
}, [clientToken, performFreshnessCheck, showRefreshBanner, status]);
const handleRefresh = () => {
setShowRefreshBanner(false);
window.location.reload();
};
return ( return (
<> <>
{children} {children}
{pomo.show && ( {pomo.show && <PomodoroTimer />}
<PomodoroTimer /> {userSelect && <UserSelectModal onClose={() => setUserSelect(false)} />}
)} {aboutOpen && <AboutModal onClose={() => setAboutOpen(false)} />}
{userSelect && ( {showRefreshBanner && <RefreshBanner onRefresh={handleRefresh} />}
<UserSelectModal onClose={() => setUserSelect(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>
);
} }

View File

@@ -1,28 +1,27 @@
'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 } 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 { useHelpers } from '@/lib/client-helpers'
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')
const { currentUser } = useHelpers() const [currentUser] = useAtom(currentUserAtom)
const [selectedUser, setSelectedUser] = useState<string>() const [selectedUser, setSelectedUser] = useState<string>()
const { const {
add, add,
@@ -47,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(() => {
@@ -57,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(() => {
@@ -107,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)}
> >
@@ -275,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>
@@ -313,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}
@@ -341,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>
)} )}

View File

@@ -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 {

View File

@@ -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"

View File

@@ -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

View File

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

View File

@@ -1,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>

View File

@@ -1,12 +1,11 @@
import { Habit } 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 } from '@/lib/atoms'; import { pomodoroAtom, settingsAtom, currentUserAtom } from '@/lib/atoms';
import { d2t, getNow, isHabitDueToday } from '@/lib/utils'; import { d2t, getNow, isHabitDueToday, hasPermission } from '@/lib/utils';
import { DropdownMenuItem, DropdownMenuSeparator } from '@/components/ui/dropdown-menu'; import { DropdownMenuItem, DropdownMenuSeparator } from '@/components/ui/dropdown-menu';
import { ContextMenuItem, ContextMenuSeparator } from '@/components/ui/context-menu'; import { ContextMenuItem, ContextMenuSeparator } from '@/components/ui/context-menu';
import { Timer, Calendar, Pin, Edit, Archive, ArchiveRestore, Trash2 } from 'lucide-react'; import { Timer, Calendar, Pin, Edit, Archive, ArchiveRestore, Trash2 } from 'lucide-react';
import { useHelpers } from '@/lib/client-helpers'; // For permission checks if needed, though useHabits handles most
import { useTranslations } from 'next-intl'; import { useTranslations } from 'next-intl';
interface HabitContextMenuItemsProps { interface HabitContextMenuItemsProps {
@@ -28,10 +27,10 @@ export function HabitContextMenuItems({
const { saveHabit, archiveHabit, unarchiveHabit } = useHabits(); const { saveHabit, archiveHabit, unarchiveHabit } = useHabits();
const [settings] = useAtom(settingsAtom); const [settings] = useAtom(settingsAtom);
const [, setPomo] = useAtom(pomodoroAtom); const [, setPomo] = useAtom(pomodoroAtom);
const { hasPermission } = useHelpers(); // Assuming useHabits handles permissions for its actions const [currentUser] = useAtom(currentUserAtom);
const canWrite = hasPermission('habit', 'write'); // For UI disabling if not handled by useHabits' actions const canWrite = hasPermission(currentUser, 'habit', 'write'); // For UI disabling if not handled by useHabits' actions
const canInteract = hasPermission('habit', 'interact'); const canInteract = hasPermission(currentUser, 'habit', 'interact');
const MenuItemComponent = context === 'daily-overview' ? ContextMenuItem : DropdownMenuItem; const MenuItemComponent = context === 'daily-overview' ? ContextMenuItem : DropdownMenuItem;
const MenuSeparatorComponent = context === 'daily-overview' ? ContextMenuSeparator : DropdownMenuSeparator; const MenuSeparatorComponent = context === 'daily-overview' ? ContextMenuSeparator : DropdownMenuSeparator;

View File

@@ -1,25 +1,21 @@
import { Habit, SafeUser, User, Permission } from '@/lib/types'
import { useAtom } from 'jotai'
import { settingsAtom, pomodoroAtom, browserSettingsAtom, usersAtom } 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' // Removed unused icons 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 { useHelpers } from '@/lib/client-helpers'
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 { currentUser, hasPermission } = useHelpers() const pathname = usePathname();
const canWrite = hasPermission('habit', 'write') const [currentUser] = useAtom(currentUserAtom)
const canInteract = hasPermission('habit', 'interact') const canWrite = hasPermission(currentUser, 'habit', 'write')
const [browserSettings] = useAtom(browserSettingsAtom) const canInteract = hasPermission(currentUser, 'habit', 'interact')
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" />
@@ -113,11 +106,13 @@ export default function HabitItem({ habit, onEdit, onDelete }: HabitItemProps) {
</CardHeader> </CardHeader>
<CardContent className="flex-1"> <CardContent className="flex-1">
<p className={`text-sm ${habit.archived ? 'text-gray-400 dark:text-gray-500' : 'text-gray-500'}`}> <p className={`text-sm ${habit.archived ? 'text-gray-400 dark:text-gray-500' : 'text-gray-500'}`}>
{t('whenLabel', { frequency: convertMachineReadableFrequencyToHumanReadable({ {t('whenLabel', {
frequency: convertMachineReadableFrequencyToHumanReadable({
frequency: habit.frequency, frequency: habit.frequency,
isRecurRule, isRecurRule: pathname.includes("habits"),
timezone: settings.system.timezone timezone: settings.system.timezone
})})} })
})}
</p> </p>
<div className="flex items-center mt-2"> <div className="flex items-center mt-2">
<Coins className={`h-4 w-4 mr-1 ${habit.archived ? 'text-gray-400 dark:text-gray-500' : 'text-yellow-400'}`} /> <Coins className={`h-4 w-4 mr-1 ${habit.archived ? 'text-gray-400 dark:text-gray-500' : 'text-yellow-400'}`} />
@@ -190,7 +185,7 @@ export default function HabitItem({ habit, onEdit, onDelete }: HabitItemProps) {
<span className="ml-2">{t('editButton')}</span> <span className="ml-2">{t('editButton')}</span>
</Button> </Button>
)} )}
<DropdownMenu> <DropdownMenu modal={false}>
<DropdownMenuTrigger asChild> <DropdownMenuTrigger asChild>
<Button variant="ghost" size="sm" className="h-8 w-8 p-0"> <Button variant="ghost" size="sm" className="h-8 w-8 p-0">
<MoreVertical className="h-4 w-4" /> <MoreVertical className="h-4 w-4" />

View File

@@ -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">

View File

@@ -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[]

View File

@@ -1,37 +1,13 @@
'use client'
import { useEffect, useState } from 'react'
import { useAtom } from 'jotai'
import { coinsAtom, settingsAtom, browserSettingsAtom } from '@/lib/atoms'
import { useCoins } from '@/hooks/useCoins'
import { FormattedNumber } from '@/components/FormattedNumber'
import { Menu, Settings, User, Info, Coins } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { Logo } from '@/components/Logo' import { Logo } from '@/components/Logo'
import NotificationBell from './NotificationBell'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu'
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'
import AboutModal from './AboutModal'
import Link from 'next/link' import Link from 'next/link'
import dynamic from 'next/dynamic' import HeaderActions from './HeaderActions'
import { Profile } from './Profile'
import { useHelpers } from '@/lib/client-helpers'
interface HeaderProps { interface HeaderProps {
className?: string className?: string
} }
const TodayEarnedCoins = dynamic(() => import('./TodayEarnedCoins'), { ssr: false })
export default function Header({ className }: HeaderProps) { export default function Header({ className }: HeaderProps) {
const [settings] = useAtom(settingsAtom)
const [browserSettings] = useAtom(browserSettingsAtom)
const { balance } = useCoins()
return ( return (
<> <>
<header className={`border-b bg-white dark:bg-gray-800 shadow-sm ${className || ''}`}> <header className={`border-b bg-white dark:bg-gray-800 shadow-sm ${className || ''}`}>
@@ -40,23 +16,7 @@ export default function Header({ className }: HeaderProps) {
<Link href="/" className="mr-3 sm:mr-4"> <Link href="/" className="mr-3 sm:mr-4">
<Logo /> <Logo />
</Link> </Link>
<div className="flex items-center gap-1 sm:gap-2"> <HeaderActions />
<Link href="/coins" className="flex items-center gap-1 sm:gap-2 px-3 py-1.5 bg-white hover:bg-gray-50 dark:bg-gray-700 dark:hover:bg-gray-600 rounded-full transition-colors border border-gray-200 dark:border-gray-600">
<Coins className="h-5 w-5 text-yellow-500 dark:text-yellow-400" />
<div className="flex items-baseline gap-1 sm:gap-2">
<FormattedNumber
amount={balance}
settings={settings}
className="text-gray-800 dark:text-gray-100 font-medium text-lg"
/>
<div className="hidden sm:block">
<TodayEarnedCoins />
</div>
</div>
</Link>
<NotificationBell />
<Profile />
</div>
</div> </div>
</div> </div>
</header> </header>

View File

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

View File

@@ -1,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'
@@ -8,7 +7,7 @@ export default function Layout({ children }: { children: React.ReactNode }) {
<div className="flex flex-col h-screen bg-gray-100 dark:bg-gray-900 overflow-hidden"> <div className="flex flex-col h-screen bg-gray-100 dark:bg-gray-900 overflow-hidden">
<Header className="sticky top-0 z-50" /> <Header className="sticky top-0 z-50" />
<div className="flex flex-1 overflow-hidden"> <div className="flex flex-1 overflow-hidden">
<Navigation viewPort='main' /> <Navigation position='main' />
<div className="flex-1 flex flex-col"> <div className="flex-1 flex flex-col">
<main className="flex-1 overflow-x-hidden overflow-y-auto bg-gray-100 dark:bg-gray-900 relative"> <main className="flex-1 overflow-x-hidden overflow-y-auto bg-gray-100 dark:bg-gray-900 relative">
{/* responsive container (optimized for mobile) */} {/* responsive container (optimized for mobile) */}
@@ -18,7 +17,7 @@ export default function Layout({ children }: { children: React.ReactNode }) {
</ClientWrapper> </ClientWrapper>
</div> </div>
</main> </main>
<Navigation viewPort='mobile' /> <Navigation position='mobile' />
</div> </div>
</div> </div>
</div> </div>

View File

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

View File

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

View File

@@ -0,0 +1,9 @@
/**
* ModalOverlay provides a dimming effect for non-modal dialogs or popovers
* that need to appear modal (e.g., to prevent interaction with background elements).
* It should be rendered alongside the dialog/popover it's intended to overlay for.
* Ensure the dialog/popover has a z-index higher than this overlay (default z-40).
*/
export default function ModalOverlay() {
return <div className="fixed inset-0 z-50 bg-black/80" />
}

61
components/NavDisplay.tsx Normal file
View File

@@ -0,0 +1,61 @@
import { useHelpers } from '@/lib/client-helpers';
import Link from 'next/link';
import { usePathname } from 'next/navigation';
import { NavItemType } from './Navigation';
export default function NavDisplay({ navItems, isMobile }: { navItems: NavItemType[], isMobile: boolean }) {
const pathname = usePathname();
const { isIOS } = useHelpers()
if (isMobile) {
return (
<>
{isMobile && (<div className={isIOS ? "pb-20" : "pb-16"} />)}
<nav className={`lg:hidden fixed bottom-0 left-0 right-0 bg-white dark:bg-gray-800 shadow-lg ${isIOS ? "pb-4" : ""}`}>
<div className="grid grid-cols-6 w-full">
{navItems.map((item) => (
<Link
key={item.label}
href={item.href}
className={"flex flex-col items-center py-2 hover:text-blue-600 dark:hover:text-blue-300 " +
(pathname === (item.href) ?
"text-blue-500 dark:text-blue-500" :
"text-gray-300 dark:text-gray-300")
}
>
<item.icon className="h-6 w-6" />
<span className="text-xs mt-1">{item.label}</span>
</Link>
))}
</div>
</nav>
</>
);
} else {
return (
<div className="hidden lg:flex lg:flex-shrink-0">
<div className="flex flex-col w-64">
<div className="flex flex-col h-0 flex-1 bg-gray-800">
<div className="flex-1 flex flex-col pt-5 pb-4 overflow-y-auto">
<nav className="mt-5 flex-1 px-2 space-y-1">
{navItems.map((item) => (
<Link
key={item.label}
href={item.href}
className={"flex items-center px-2 py-2 font-medium rounded-md " +
(pathname === (item.href) ?
"text-blue-500 hover:text-blue-600 hover:bg-gray-700" :
"text-gray-300 hover:text-white hover:bg-gray-700")}
>
<item.icon className="mr-4 flex-shrink-0 h-6 w-6" aria-hidden="true" />
{item.label}
</Link>
))}
</nav>
</div>
</div>
</div>
</div>
);
}
}

View File

@@ -1,104 +1,40 @@
'use client' 'use client'
import Link from 'next/link'
import { Home, Calendar, List, Gift, Coins, Settings, Info, CheckSquare } from 'lucide-react'
import { useAtom } from 'jotai'
import { browserSettingsAtom } from '@/lib/atoms'
import { useEffect, useState } from 'react'
import { useTranslations } from 'next-intl'
import AboutModal from './AboutModal'
import { HabitIcon, TaskIcon } from '@/lib/constants' import { HabitIcon, TaskIcon } from '@/lib/constants'
import { useHelpers } from '@/lib/client-helpers' import { Calendar, Coins, Gift, Home } from 'lucide-react'
import { useTranslations } from 'next-intl'
import { ElementType, useEffect, useState } from 'react'
import NavDisplay from './NavDisplay'
type ViewPort = 'main' | 'mobile' export interface NavItemType {
icon: ElementType;
interface NavigationProps { label: string;
className?: string href: string;
viewPort: ViewPort
} }
export default function Navigation({ className, viewPort }: NavigationProps) { export default function Navigation({ position }: { position: 'main' | 'mobile' }) {
const t = useTranslations('Navigation') const t = useTranslations('Navigation');
const [showAbout, setShowAbout] = useState(false) const [isMobile, setIsMobile] = useState(window.innerWidth < 1024);
const [isMobileView, setIsMobileView] = useState(false)
const [browserSettings] = useAtom(browserSettingsAtom)
const isTasksView = browserSettings.viewType === 'tasks'
const { isIOS } = useHelpers()
const navItems = (isTasksView: boolean) => [
{ 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]);
const currentNavItems: NavItemType[] = [
{ icon: Home, label: t('dashboard'), href: '/' },
{ icon: HabitIcon, label: t('habits'), href: '/habits' },
{ icon: TaskIcon, label: t('tasks'), href: '/tasks' },
{ icon: Calendar, label: t('calendar'), href: '/calendar' },
{ icon: Gift, label: t('wishlist'), href: '/wishlist' },
{ icon: Coins, label: t('coins'), href: '/coins' },
]
if ((position === 'mobile' && isMobile) || (position === 'main' && !isMobile)) {
return <NavDisplay navItems={currentNavItems} isMobile={isMobile} />
} }
else {
// Set initial value return <></>
handleResize()
// Add event listener
window.addEventListener('resize', handleResize)
// Cleanup
return () => window.removeEventListener('resize', handleResize)
}, [])
if (viewPort === 'mobile' && isMobileView) {
return (
<>
<div className={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">
{[...navItems(isTasksView).filter(item => item.position === 'main'), ...navItems(isTasksView).filter(item => item.position === 'bottom')].map((item) => (
<Link
key={item.label}
href={item.href}
className="flex flex-col items-center 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>
<AboutModal isOpen={showAbout} onClose={() => setShowAbout(false)} />
</>
)
}
if (viewPort === 'main' && !isMobileView) {
return (
<div className="hidden lg:flex lg:flex-shrink-0">
<div className="flex flex-col w-64">
<div className="flex flex-col h-0 flex-1 bg-gray-800">
<div className="flex-1 flex flex-col pt-5 pb-4 overflow-y-auto">
<nav className="mt-5 flex-1 px-2 space-y-1">
{navItems(isTasksView).filter(item => item.position === 'main').map((item) => (
<Link
key={item.label}
href={item.href}
className="group flex items-center px-2 py-2 text-sm leading-6 font-medium rounded-md text-gray-300 hover:text-white hover:bg-gray-700"
>
<item.icon className="mr-4 flex-shrink-0 h-6 w-6 text-gray-400" aria-hidden="true" />
{item.label}
</Link>
))}
</nav>
</div>
</div>
</div>
<AboutModal isOpen={showAbout} onClose={() => setShowAbout(false)} />
</div>
)
} }
} }

View File

@@ -2,7 +2,7 @@
import { useMemo } from 'react' import { useMemo } from 'react'
import { useAtom } from 'jotai' import { useAtom } from 'jotai'
import { coinsAtom, habitsAtom, wishlistAtom, usersAtom } from '@/lib/atoms' import { coinsAtom, habitsAtom, wishlistAtom, usersAtom, currentUserAtom } from '@/lib/atoms'
import { Bell } from 'lucide-react'; import { Bell } from 'lucide-react';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { useTranslations } from 'next-intl'; import { useTranslations } from 'next-intl';
@@ -14,12 +14,11 @@ import {
} from '@/components/ui/dropdown-menu' } from '@/components/ui/dropdown-menu'
import { updateLastNotificationReadTimestamp } from '@/app/actions/data'; import { updateLastNotificationReadTimestamp } from '@/app/actions/data';
import { d2t, getNow, t2d } from '@/lib/utils'; import { d2t, getNow, t2d } from '@/lib/utils';
import { useHelpers } from '@/lib/client-helpers';
import { User, CoinTransaction } from '@/lib/types'; import { User, CoinTransaction } from '@/lib/types';
export default function NotificationBell() { export default function NotificationBell() {
const t = useTranslations('NotificationBell'); const t = useTranslations('NotificationBell');
const { currentUser } = useHelpers(); const [currentUser] = useAtom(currentUserAtom);
const [coinsData] = useAtom(coinsAtom) const [coinsData] = useAtom(coinsAtom)
const [habitsData] = useAtom(habitsAtom) const [habitsData] = useAtom(habitsAtom)
const [wishlistData] = useAtom(wishlistAtom) const [wishlistData] = useAtom(wishlistAtom)
@@ -122,7 +121,7 @@ export default function NotificationBell() {
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent align="end" className="p-0 w-80 md:w-96"> <DropdownMenuContent align="end" className="p-0 w-80 md:w-96">
<NotificationDropdown <NotificationDropdown
currentUser={currentUser as User | null} // Cast needed as useHelpers can return undefined initially currentUser={currentUser as User | null} // Cast needed as as currentUser can be undefined
unreadNotifications={unreadNotifications} unreadNotifications={unreadNotifications}
displayedReadNotifications={displayedReadNotifications} displayedReadNotifications={displayedReadNotifications}
habitsData={habitsData} // Pass necessary data down habitsData={habitsData} // Pass necessary data down

View File

@@ -1,19 +1,18 @@
import React from 'react'; import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
import { DropdownMenuItem } from '@/components/ui/dropdown-menu';
import { ScrollArea } from '@/components/ui/scroll-area'; import { 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;

View File

@@ -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;

View File

@@ -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')

View File

@@ -1,30 +1,28 @@
'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 { settingsAtom, userSelectAtom } from "@/lib/atoms"
import AboutModal from "./AboutModal"
import { useEffect, useState } from "react"
import { useTheme } from "next-themes"
import { signOut } from "@/app/actions/user"
import { toast } from "@/hooks/use-toast"
import { useHelpers } from "@/lib/client-helpers"
import { useTranslations } from 'next-intl'
export function Profile() { export function Profile() {
const t = useTranslations('Profile'); const t = useTranslations('Profile');
const [settings] = useAtom(settingsAtom) const [settings] = useAtom(settingsAtom)
const [userSelect, setUserSelect] = useAtom(userSelectAtom) const [userSelect, setUserSelect] = useAtom(userSelectAtom)
const [isEditing, setIsEditing] = useState(false) const [isEditing, setIsEditing] = useState(false)
const [showAbout, setShowAbout] = useState(false) const [aboutOpen, setAboutOpen] = useAtom(aboutOpenAtom)
const { theme, setTheme } = useTheme() const { theme, setTheme } = useTheme()
const { currentUser: user } = useHelpers() const [user] = useAtom(currentUserAtom)
const [open, setOpen] = useState(false) const [open, setOpen] = useState(false)
const handleSignOut = async () => { const handleSignOut = async () => {
@@ -67,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>
@@ -111,27 +109,33 @@ export function Profile() {
</div> </div>
</DropdownMenuItem> </DropdownMenuItem>
<DropdownMenuItem className="cursor-pointer px-2 py-1.5" asChild> <DropdownMenuItem className="cursor-pointer px-2 py-1.5" asChild>
{/* need the Link element to be the direct child of the DropdownMenuItem, since we are using asChild here */}
<Link <Link
href="/settings" href="/settings"
aria-label={t('settingsLink')} aria-label={t('settingsLink')}
className="flex items-center w-full gap-3" className="flex items-center justify-between w-full"
onClick={() => setOpen(false)} // Ensure dropdown closes on click
> >
<div className="flex items-center gap-2">
<Settings className="h-4 w-4" /> <Settings className="h-4 w-4" />
<span>{t('settingsLink')}</span> <span>{t('settingsLink')}</span>
</div>
</Link> </Link>
</DropdownMenuItem> </DropdownMenuItem>
<DropdownMenuItem className="cursor-pointer px-2 py-1.5" asChild> <DropdownMenuItem className="cursor-pointer px-2 py-1.5" onClick={() => {
<button setOpen(false); // Close the dropdown
onClick={() => setShowAbout(true)} setAboutOpen(true); // Open the about modal
className="flex items-center w-full gap-3" }}>
> <div className="flex items-center justify-between w-full">
<div className="flex items-center gap-2">
<Info className="h-4 w-4" /> <Info className="h-4 w-4" />
<span>{t('aboutButton')}</span> <span>{t('aboutButton')}</span>
</button> </div>
</div>
</DropdownMenuItem> </DropdownMenuItem>
<DropdownMenuItem className="cursor-pointer px-2 py-1.5"> <DropdownMenuItem className="cursor-pointer px-2 py-1.5">
<div className="flex items-center justify-between w-full gap-3"> <div className="flex items-center justify-between w-full">
<div className="flex items-center gap-3"> <div className="flex items-center gap-2">
<Palette className="h-4 w-4" /> <Palette className="h-4 w-4" />
<span>{t('themeLabel')}</span> <span>{t('themeLabel')}</span>
</div> </div>
@@ -169,8 +173,6 @@ export function Profile() {
</DropdownMenuContent> </DropdownMenuContent>
</DropdownMenu> </DropdownMenu>
<AboutModal isOpen={showAbout} onClose={() => setShowAbout(false)} />
{/* Add the UserForm dialog */} {/* Add the UserForm dialog */}
{isEditing && user && ( {isEditing && user && (
<Dialog open={isEditing} onOpenChange={() => setIsEditing(false)}> <Dialog open={isEditing} onOpenChange={() => setIsEditing(false)}>

View File

@@ -0,0 +1,27 @@
'use client'
import { Button } from "@/components/ui/button"
import { AlertTriangle } from "lucide-react"
interface RefreshBannerProps {
onRefresh: () => void;
}
export default function RefreshBanner({ onRefresh }: RefreshBannerProps) {
return (
<div className="fixed bottom-4 right-4 z-[100] bg-yellow-400 dark:bg-yellow-500 text-black dark:text-gray-900 p-4 rounded-lg shadow-lg flex items-center gap-3">
<AlertTriangle className="h-6 w-6 text-yellow-800 dark:text-yellow-900" />
<div>
<p className="font-semibold">Data out of sync</p>
<p className="text-sm">New data is available. Please refresh to see the latest updates.</p>
</div>
<Button
onClick={onRefresh}
variant="outline"
className="ml-auto bg-yellow-500 hover:bg-yellow-600 dark:bg-yellow-600 dark:hover:bg-yellow-700 border-yellow-600 dark:border-yellow-700 text-white dark:text-gray-900"
>
Refresh
</Button>
</div>
)
}

View File

@@ -1,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,20 +11,23 @@ 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 } 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';
import { useHelpers } from '@/lib/client-helpers';
interface UserFormProps { interface UserFormProps {
userId?: string; // if provided, we're editing; if not, we're creating userId?: string; // if provided, we're editing; if not, we're creating
@@ -41,7 +40,7 @@ export default function UserForm({ userId, onCancel, onSuccess }: UserFormProps)
const [users, setUsersData] = useAtom(usersAtom); const [users, setUsersData] = useAtom(usersAtom);
const serverSettings = useAtomValue(serverSettingsAtom) const serverSettings = useAtomValue(serverSettingsAtom)
const user = userId ? users.users.find(u => u.id === userId) : undefined; const user = userId ? users.users.find(u => u.id === userId) : undefined;
const { currentUser } = useHelpers() const [currentUser] = useAtom(currentUserAtom)
const getDefaultPermissions = (): Permission[] => [{ const getDefaultPermissions = (): Permission[] => [{
habit: { habit: {
write: true, write: true,

View File

@@ -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 } from '@/lib/atoms';
import { signIn } from '@/app/actions/user';
import { createUser } from '@/app/actions/data';
import { useTranslations } from 'next-intl';
import { toast } from '@/hooks/use-toast';
import { Description } from '@radix-ui/react-dialog';
import { SafeUser, User } from '@/lib/types';
import { cn } from '@/lib/utils';
import { useHelpers } from '@/lib/client-helpers';
function UserCard({ function UserCard({
user, user,
@@ -145,7 +130,7 @@ export default function UserSelectModal({ onClose }: { onClose: () => void }) {
const [error, setError] = useState(''); const [error, setError] = useState('');
const [usersData, setUsersData] = useAtom(usersAtom); const [usersData, setUsersData] = useAtom(usersAtom);
const users = usersData.users; const users = usersData.users;
const { currentUser } = useHelpers(); const [currentUser] = useAtom(currentUserAtom);
const handleUserSelect = (userId: string) => { const handleUserSelect = (userId: string) => {

View File

@@ -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>

View File

@@ -1,13 +1,5 @@
import { WishlistItemType, User } from '@/lib/types'
import { useAtom } from 'jotai'
import { useTranslations } from 'next-intl'
import { usersAtom } from '@/lib/atoms'
import { Avatar, AvatarFallback, AvatarImage } from './ui/avatar'
import { useHelpers } from '@/lib/client-helpers'
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
@@ -60,9 +59,9 @@ export default function WishlistItem({
isRecentlyRedeemed isRecentlyRedeemed
}: WishlistItemProps) { }: WishlistItemProps) {
const t = useTranslations('WishlistItem') const t = useTranslations('WishlistItem')
const { currentUser, hasPermission } = useHelpers() const [currentUser] = useAtom(currentUserAtom)
const canWrite = hasPermission('wishlist', 'write') const canWrite = hasPermission(currentUser, 'wishlist', 'write')
const canInteract = hasPermission('wishlist', 'interact') const canInteract = hasPermission(currentUser, 'wishlist', 'interact')
const [usersData] = useAtom(usersAtom) const [usersData] = useAtom(usersAtom)
@@ -141,7 +140,7 @@ export default function WishlistItem({
<span className="ml-2">{t('editButton')}</span> <span className="ml-2">{t('editButton')}</span>
</Button> </Button>
)} )}
<DropdownMenu> <DropdownMenu modal={false}>
<DropdownMenuTrigger asChild> <DropdownMenuTrigger asChild>
<Button variant="ghost" size="sm" className="h-8 w-8 p-0"> <Button variant="ghost" size="sm" className="h-8 w-8 p-0">
<MoreVertical className="h-4 w-4" /> <MoreVertical className="h-4 w-4" />
@@ -166,7 +165,7 @@ export default function WishlistItem({
</DropdownMenuItem> </DropdownMenuItem>
<DropdownMenuSeparator className="sm:hidden" /> <DropdownMenuSeparator className="sm:hidden" />
<DropdownMenuItem <DropdownMenuItem
className="text-red-600 focus:text-red-600 dark:text-red-400 dark:focus:text-red-400 cursor-pointer" className="text-red-600 focus:text-red-600 dark:text-red-400 dark:focus:text-red-400"
onClick={onDelete} onClick={onDelete}
disabled={!canWrite} disabled={!canWrite}
> >

View File

@@ -152,14 +152,15 @@ export default function WishlistManager() {
</> </>
)} )}
</div> </div>
{isModalOpen &&
<AddEditWishlistItemModal <AddEditWishlistItemModal
isOpen={isModalOpen}
setIsOpen={setIsModalOpen} setIsOpen={setIsModalOpen}
editingItem={editingItem} editingItem={editingItem}
setEditingItem={setEditingItem} setEditingItem={setEditingItem}
addWishlistItem={addWishlistItem} addWishlistItem={addWishlistItem}
editWishlistItem={editWishlistItem} editWishlistItem={editWishlistItem}
/> />
}
<ConfirmDialog <ConfirmDialog
isOpen={deleteConfirmation.isOpen} isOpen={deleteConfirmation.isOpen}
onClose={() => setDeleteConfirmation({ isOpen: false, itemId: null })} onClose={() => setDeleteConfirmation({ isOpen: false, itemId: null })}

View File

@@ -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
} }

View File

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

View File

@@ -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

View File

@@ -1,49 +1,23 @@
import { useAtom } from 'jotai' import { addCoins, removeCoins, saveCoinsData } from '@/app/actions/data';
import { useTranslations } from 'next-intl' import { toast } from '@/hooks/use-toast';
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,
} from '@/lib/atoms' } from '@/lib/atoms';
import { addCoins, removeCoins, saveCoinsData } from '@/app/actions/data' import { MAX_COIN_LIMIT } from '@/lib/constants';
import { CoinsData, User } from '@/lib/types' import { CoinsData } from '@/lib/types';
import { toast } from '@/hooks/use-toast' import { calculateCoinsEarnedToday, calculateCoinsSpentToday, calculateTotalEarned, calculateTotalSpent, calculateTransactionsToday, handlePermissionCheck, roundToInteger } from '@/lib/utils';
import { useHelpers } from '@/lib/client-helpers' 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');
@@ -51,23 +25,69 @@ export function useCoins(options?: { selectedUser?: string }) {
const [coins, setCoins] = useAtom(coinsAtom) const [coins, setCoins] = useAtom(coinsAtom)
const [settings] = useAtom(settingsAtom) const [settings] = useAtom(settingsAtom)
const [users] = useAtom(usersAtom) const [users] = useAtom(usersAtom)
const { currentUser } = useHelpers() const [currentUser] = useAtom(currentUserAtom)
let user: User | undefined; const [allCoinsData] = useAtom(coinsAtom) // All coin transactions
if (!options?.selectedUser) { const [loggedInUserBalance] = useAtom(coinsBalanceAtom) // Balance of the *currently logged-in* user
user = currentUser; const [atomCoinsEarnedToday] = useAtom(coinsEarnedTodayAtom);
} else { const [atomTotalEarned] = useAtom(totalEarnedAtom)
user = users.users.find(u => u.id === options.selectedUser) const [atomTotalSpent] = useAtom(totalSpentAtom)
const [atomCoinsSpentToday] = useAtom(coinsSpentTodayAtom);
const [atomTransactionsToday] = useAtom(transactionsTodayAtom);
const targetUser = options?.selectedUser ? users.users.find(u => u.id === options.selectedUser) : currentUser
const transactions = useMemo(() => {
return allCoinsData.transactions.filter(t => t.userId === targetUser?.id);
}, [allCoinsData, targetUser?.id]);
const timezone = settings.system.timezone;
const [coinsEarnedToday, setCoinsEarnedToday] = useState(0);
const [totalEarned, setTotalEarned] = useState(0);
const [totalSpent, setTotalSpent] = useState(0);
const [coinsSpentToday, setCoinsSpentToday] = useState(0);
const [transactionsToday, setTransactionsToday] = useState<number>(0);
const [balance, setBalance] = useState(0);
useEffect(() => {
// Calculate other metrics
if (targetUser?.id && targetUser.id === currentUser?.id) {
// If the target user is the currently logged-in user, use the derived atom's value
setCoinsEarnedToday(atomCoinsEarnedToday);
setTotalEarned(atomTotalEarned);
setTotalSpent(atomTotalSpent);
setCoinsSpentToday(atomCoinsSpentToday);
setTransactionsToday(atomTransactionsToday);
setBalance(loggedInUserBalance);
} else if (targetUser?.id) {
// If an admin is viewing another user, calculate their metrics manually
const earnedToday = calculateCoinsEarnedToday(transactions, timezone);
setCoinsEarnedToday(roundToInteger(earnedToday));
const totalEarnedVal = calculateTotalEarned(transactions);
setTotalEarned(roundToInteger(totalEarnedVal));
const totalSpentVal = calculateTotalSpent(transactions);
setTotalSpent(roundToInteger(totalSpentVal));
const spentToday = calculateCoinsSpentToday(transactions, timezone);
setCoinsSpentToday(roundToInteger(spentToday));
setTransactionsToday(calculateTransactionsToday(transactions, timezone)); // This is a count
const calculatedBalance = transactions.reduce((acc, t) => acc + t.amount, 0);
setBalance(roundToInteger(calculatedBalance));
} }
}, [
// Filter transactions for the selectd user targetUser?.id,
const transactions = coins.transactions.filter(t => t.userId === user?.id) currentUser?.id,
transactions, // Memoized: depends on allCoinsData and targetUser?.id
const [balance] = useAtom(coinsBalanceAtom) timezone,
const [coinsEarnedToday] = useAtom(coinsEarnedTodayAtom) loggedInUserBalance,
const [totalEarned] = useAtom(totalEarnedAtom) atomCoinsEarnedToday,
const [totalSpent] = useAtom(totalSpentAtom) atomTotalEarned,
const [coinsSpentToday] = useAtom(coinsSpentTodayAtom) atomTotalSpent,
const [transactionsToday] = useAtom(transactionsTodayAtom) atomCoinsSpentToday,
atomTransactionsToday,
]);
const add = async (amount: number, description: string, note?: string) => { const add = async (amount: number, description: string, note?: string) => {
if (!handlePermissionCheck(currentUser, 'coins', 'write', tCommon)) return null if (!handlePermissionCheck(currentUser, 'coins', 'write', tCommon)) return null
@@ -91,7 +111,7 @@ export function useCoins(options?: { selectedUser?: string }) {
description, description,
type: 'MANUAL_ADJUSTMENT', type: 'MANUAL_ADJUSTMENT',
note, note,
userId: user?.id userId: targetUser?.id
}) })
setCoins(data) setCoins(data)
toast({ title: t("successTitle"), description: t("addedCoinsDescription", { amount }) }) toast({ title: t("successTitle"), description: t("addedCoinsDescription", { amount }) })
@@ -121,7 +141,7 @@ export function useCoins(options?: { selectedUser?: string }) {
description, description,
type: 'MANUAL_ADJUSTMENT', type: 'MANUAL_ADJUSTMENT',
note, note,
userId: user?.id userId: targetUser?.id
}) })
setCoins(data) setCoins(data)
toast({ title: t("successTitle"), description: t("removedCoinsDescription", { amount: numAmount }) }) toast({ title: t("successTitle"), description: t("removedCoinsDescription", { amount: numAmount }) })

View File

@@ -1,60 +1,30 @@
import { useAtom, atom } from 'jotai'
import { useTranslations } from 'next-intl'
import { habitsAtom, coinsAtom, settingsAtom, usersAtom, habitFreqMapAtom } from '@/lib/atoms'
import { addCoins, removeCoins, saveHabitsData } from '@/app/actions/data' import { addCoins, removeCoins, saveHabitsData } from '@/app/actions/data'
import { Habit, Permission, SafeUser, User } from '@/lib/types' import { 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 { useHelpers } from '@/lib/client-helpers' import { DateTime } from 'luxon'
import { useTranslations } from 'next-intl'
function handlePermissionCheck(
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 useHabits() { export function useHabits() {
const t = useTranslations('useHabits'); const t = useTranslations('useHabits');
const tCommon = useTranslations('Common'); const tCommon = useTranslations('Common');
const [usersData] = useAtom(usersAtom) const [usersData] = useAtom(usersAtom)
const { currentUser } = useHelpers() const [currentUser] = useAtom(currentUserAtom)
const [habitsData, setHabitsData] = useAtom(habitsAtom) const [habitsData, setHabitsData] = useAtom(habitsAtom)
const [coins, setCoins] = useAtom(coinsAtom) const [coins, setCoins] = useAtom(coinsAtom)
const [settings] = useAtom(settingsAtom) const [settings] = useAtom(settingsAtom)
@@ -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)

View File

@@ -1,45 +1,17 @@
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 } from '@/lib/atoms'
import { saveWishlistItems, removeCoins } from '@/app/actions/data'
import { toast } from '@/hooks/use-toast'
import { WishlistItemType } from '@/lib/types'
import { celebrations } from '@/utils/celebrations'
import { checkPermission } from '@/lib/utils'
import { useHelpers } from '@/lib/client-helpers'
import { useCoins } from './useCoins' import { useCoins } from './useCoins'
function handlePermissionCheck(
user: any, // Consider using a more specific type like 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 useWishlist() { export function useWishlist() {
const t = useTranslations('useWishlist'); const t = useTranslations('useWishlist');
const tCommon = useTranslations('Common'); const tCommon = useTranslations('Common');
const { currentUser: user } = useHelpers() const [user] = useAtom(currentUserAtom)
const [wishlist, setWishlist] = useAtom(wishlistAtom) const [wishlist, setWishlist] = useAtom(wishlistAtom)
const [coins, setCoins] = useAtom(coinsAtom) const [coins, setCoins] = useAtom(coinsAtom)
const { balance } = useCoins() const { balance } = useCoins()

View File

@@ -1,81 +1,86 @@
import { atom } from "jotai";
import { import {
getDefaultSettings,
getDefaultHabitsData,
getDefaultCoinsData,
getDefaultWishlistData,
Habit,
ViewType,
getDefaultUsersData,
CompletionCache,
getDefaultServerSettings,
User,
} 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
@@ -85,10 +90,27 @@ export const transactionsTodayAtom = atom((get) => {
return calculateTransactionsToday(coins.transactions, settings.system.timezone); return calculateTransactionsToday(coins.transactions, settings.system.timezone);
}); });
// Derived atom for current balance from all transactions // Atom to store the current logged-in user's ID.
// This should be set by your application when the user session is available.
export const currentUserIdAtom = atom<UserId | undefined>(undefined);
export const currentUserAtom = atom((get) => {
const currentUserId = get(currentUserIdAtom);
const users = get(usersAtom);
return users.users.find(user => user.id === currentUserId);
})
// Derived atom for current balance for the logged-in user
export const coinsBalanceAtom = atom((get) => { export const coinsBalanceAtom = atom((get) => {
const loggedInUserId = get(currentUserIdAtom);
if (!loggedInUserId) {
return 0; // No user logged in or ID not set, so balance is 0
}
const coins = get(coinsAtom); const coins = get(coinsAtom);
return coins.transactions.reduce((sum, transaction) => sum + transaction.amount, 0); const balance = coins.transactions
.filter(transaction => transaction.userId === loggedInUserId)
.reduce((sum, transaction) => sum + transaction.amount, 0);
return roundToInteger(balance);
}); });
/* transient atoms */ /* transient atoms */
@@ -107,6 +129,23 @@ export const pomodoroAtom = atom<PomodoroAtom>({
}) })
export const userSelectAtom = atom<boolean>(false) export const userSelectAtom = 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) => {
@@ -195,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));
});

View File

@@ -1,11 +1,10 @@
// client helpers // client helpers
'use-client' 'use-client'
import { useSession } from "next-auth/react"
import { User, UserId } from './types'
import { useAtom } from 'jotai' import { useAtom } from 'jotai'
import { useSession } from "next-auth/react"
import { usersAtom } from './atoms' import { usersAtom } from './atoms'
import { checkPermission } from './utils' import { hasPermission } from './utils'
export function useHelpers() { export function useHelpers() {
const { data: session, status } = useSession() const { data: session, status } = useSession()
@@ -14,7 +13,7 @@ export function useHelpers() {
const currentUser = usersData.users.find((u) => u.id === currentUserId) const currentUser = usersData.users.find((u) => u.id === currentUserId)
// detect iOS: https://stackoverflow.com/a/9039885 // detect iOS: https://stackoverflow.com/a/9039885
function iOS() { function iOS() {
return [ return typeof navigator !== "undefined" && ([
'iPad Simulator', 'iPad Simulator',
'iPhone Simulator', 'iPhone Simulator',
'iPod Simulator', 'iPod Simulator',
@@ -23,7 +22,7 @@ export function useHelpers() {
'iPod', 'iPod',
].includes(navigator.platform) ].includes(navigator.platform)
// iPad on iOS 13 detection // iPad on iOS 13 detection
|| (navigator.userAgent.includes("Mac") && "ontouchend" in document) || (navigator.userAgent.includes("Mac") && "ontouchend" in document))
} }
return { return {
@@ -31,8 +30,7 @@ export function useHelpers() {
currentUser, currentUser,
usersData, usersData,
status, status,
hasPermission: (resource: 'habit' | 'wishlist' | 'coins', action: 'write' | 'interact') => currentUser?.isAdmin || hasPermission,
checkPermission(currentUser?.permissions, resource, action),
isIOS: iOS(), isIOS: iOS(),
} }
} }

View File

@@ -32,3 +32,5 @@ export const QUICK_DATES = [
] as const ] as const
export const MAX_COIN_LIMIT = 9999 export const MAX_COIN_LIMIT = 9999
export const DESKTOP_DISPLAY_ITEM_COUNT = 4

View File

@@ -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,33 +96,38 @@ export interface WishlistData {
} }
// Default value functions // Default value functions
export const getDefaultUsersData = (): UserData => ({ export function getDefaultUsersData<UserData>(): UserData {
return {
users: [ users: [
{ {
id: uuid(), id: crypto.randomUUID(),
username: 'admin', username: 'admin',
// password: '', // No default password for admin initially? Or set a secure default? // password: '', // No default password for admin initially? Or set a secure default?
isAdmin: true, isAdmin: true,
lastNotificationReadTimestamp: undefined, // Initialize as undefined 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 {
return {
ui: { ui: {
useNumberFormatting: true, useNumberFormatting: true,
useGrouping: true, useGrouping: true,
@@ -135,14 +139,15 @@ export const getDefaultSettings = (): Settings => ({
language: 'en', // Default language 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;

View File

@@ -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 });
@@ -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);
});
});
})

View File

@@ -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 } 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,6 +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,
});
}
/**
* Generates a SHA-256 hash for a given string using the Web Crypto API.
* This function is suitable for both client-side and server-side (Node.js 19+) environments.
* @param dataString The string to hash.
* @returns A promise that resolves to the hex string of the hash.
*/
export async function generateCryptoHash(dataString: string): Promise<string | null> {
try {
const encoder = new TextEncoder();
const data = encoder.encode(dataString);
// globalThis.crypto should be available in modern browsers and Node.js (v19+)
// For Node.js v15-v18, you might need: const { subtle } = require('node:crypto').webcrypto;
const hashBuffer = await globalThis.crypto.subtle.digest('SHA-256', data);
const hashArray = Array.from(new Uint8Array(hashBuffer));
// Convert buffer to hex string
const hashHex = hashArray.map(b => b.toString(16).padStart(2, '0')).join('');
return hashHex;
} catch (error) {
console.error(`Failed to generate hash: ${error}`);
return null;
}
}
export function handlePermissionCheck(
user: User | SafeUser | undefined,
resource: 'habit' | 'wishlist' | 'coins',
action: 'write' | 'interact',
tCommon: (key: string, values?: Record<string, string | number | Date> | undefined, formats?: Formats | undefined) => string
): boolean {
if (!user) {
toast({
title: tCommon("authenticationRequiredTitle"),
description: tCommon("authenticationRequiredDescription"),
variant: "destructive",
})
return false
}
if (!hasPermission(user, resource, action)) {
toast({
title: tCommon("permissionDeniedTitle"),
description: tCommon("permissionDeniedDescription", { action, resource }),
variant: "destructive",
})
return false
}
return true
} }

View File

@@ -423,6 +423,7 @@
"cancel": "Abbrechen" "cancel": "Abbrechen"
}, },
"useCoins": { "useCoins": {
"addedCoinsDescription": "{amount} Münzen hinzugefügt",
"invalidAmountTitle": "Ungültiger Betrag", "invalidAmountTitle": "Ungültiger Betrag",
"invalidAmountDescription": "Bitte geben Sie eine gültige positive Zahl ein", "invalidAmountDescription": "Bitte geben Sie eine gültige positive Zahl ein",
"successTitle": "Erfolg", "successTitle": "Erfolg",

View File

@@ -408,6 +408,7 @@
"notEnoughCoinsDescription": "You need {coinsNeeded} more coins to redeem this reward." "notEnoughCoinsDescription": "You need {coinsNeeded} more coins to redeem this reward."
}, },
"useCoins": { "useCoins": {
"addedCoinsDescription": "Added {amount} coins",
"invalidAmountTitle": "Invalid amount", "invalidAmountTitle": "Invalid amount",
"invalidAmountDescription": "Please enter a valid positive number", "invalidAmountDescription": "Please enter a valid positive number",
"successTitle": "Success", "successTitle": "Success",

View File

@@ -423,6 +423,7 @@
"cancel": "Cancelar" "cancel": "Cancelar"
}, },
"useCoins": { "useCoins": {
"addedCoinsDescription": "Se añadieron {amount} monedas",
"invalidAmountTitle": "Cantidad inválida", "invalidAmountTitle": "Cantidad inválida",
"invalidAmountDescription": "Por favor ingresa un número positivo válido", "invalidAmountDescription": "Por favor ingresa un número positivo válido",
"successTitle": "Éxito", "successTitle": "Éxito",

View File

@@ -423,6 +423,7 @@
"cancel": "Annuler" "cancel": "Annuler"
}, },
"useCoins": { "useCoins": {
"addedCoinsDescription": "{amount} pièces ajoutées",
"invalidAmountTitle": "Montant invalide", "invalidAmountTitle": "Montant invalide",
"invalidAmountDescription": "Veuillez entrer un nombre positif valide", "invalidAmountDescription": "Veuillez entrer un nombre positif valide",
"successTitle": "Succès", "successTitle": "Succès",

View File

@@ -423,6 +423,7 @@
"cancel": "キャンセル" "cancel": "キャンセル"
}, },
"useCoins": { "useCoins": {
"addedCoinsDescription": "{amount}コインを追加しました",
"invalidAmountTitle": "無効な値です", "invalidAmountTitle": "無効な値です",
"invalidAmountDescription": "有効な正の数を入力してください", "invalidAmountDescription": "有効な正の数を入力してください",
"successTitle": "成功しました", "successTitle": "成功しました",

View File

@@ -423,6 +423,7 @@
"cancel": "Отмена" "cancel": "Отмена"
}, },
"useCoins": { "useCoins": {
"addedCoinsDescription": "Добавлено {amount} монет",
"invalidAmountTitle": "Неверная сумма", "invalidAmountTitle": "Неверная сумма",
"invalidAmountDescription": "Пожалуйста, введите положительное число", "invalidAmountDescription": "Пожалуйста, введите положительное число",
"successTitle": "Успех", "successTitle": "Успех",

View File

@@ -423,6 +423,7 @@
"cancel": "取消" "cancel": "取消"
}, },
"useCoins": { "useCoins": {
"addedCoinsDescription": "已添加 {amount} 个金币",
"invalidAmountTitle": "无效金额", "invalidAmountTitle": "无效金额",
"invalidAmountDescription": "请输入有效的正数", "invalidAmountDescription": "请输入有效的正数",
"successTitle": "成功", "successTitle": "成功",

112
package-lock.json generated
View File

@@ -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",

View File

@@ -1,6 +1,6 @@
{ {
"name": "habittrove", "name": "habittrove",
"version": "0.2.16", "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