Compare commits

...

39 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
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
66 changed files with 1536 additions and 1507 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,30 @@
# 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 ## Version 0.2.20
### Fixed ### Fixed

View File

@@ -1,14 +1,24 @@
# HabitTrove # <img align="left" width="50" height="50" src="https://github.com/user-attachments/assets/99dcf223-3680-4b3a-8050-d9788f051682" /> HabitTrove
![cover](https://github.com/user-attachments/assets/b63e98b4-64ae-49c7-ae7e-21ef76c04a5a)
HabitTrove is a gamified habit tracking application that helps you build and maintain positive habits by rewarding you with coins, which you can use to exchange for rewards. HabitTrove is a gamified habit tracking application that helps you build and maintain positive habits by rewarding you with coins, which you can use to exchange for rewards.
> **⚠️ Important:** HabitTrove is currently in beta. Please regularly backup your `data/` directory to prevent any potential data loss. **⚠️ Important:** HabitTrove is currently in beta. Please regularly backup your `data/` directory to prevent any potential data loss.
## Try the Demo ## Differences to Upstream
Want to try HabitTrove before installing? Visit the public [demo instance](https://demo.habittrove.com) to experience all features without any setup required. (do not store personal info. Data on the demo instance is reset daily) I generally try to keep the `main` branch up to date with upstream features, merging tagged versions and mapping them to `<upstream-version>.0`.
In this version I've taken steps to ensure a smoother experience and decreased the chance of the program bricking itself. This doesn't mean that it's completely stable, but I've fixed the most glaring bugs I encountered.
Differences (as of writing) are:
- resolved linting problems so you can actually commit things
- added missing dependency
- refactored adding habit modal to cause less errors
- resolved undefined error
- replaced dockerhub release flow with github
- miscellaneous refactorings
- split habits & tasks page into two different pages
- only display "show all" if there are more than 4 entries
## Features ## Features
@@ -25,11 +35,8 @@ Want to try HabitTrove before installing? Visit the public [demo instance](https
## Usage ## Usage
1. **Creating Habits**: Click the "Add Habit" button to create a new habit. Set a name, description, and coin reward. 1. **Creating Habits**: Click the "Add Habit" button to create a new habit. Set a name, description, and coin reward.
2. **Tracking Habits**: Mark habits as complete on your dashboard. Each completion earns you the specified coins. 2. **Tracking Habits**: Mark habits as complete on your dashboard. Each completion earns you the specified coins.
3. **Wishlist**: Add rewards to your wishlist that you can redeem with earned coins. 3. **Wishlist**: Add rewards to your wishlist that you can redeem with earned coins.
4. **Statistics**: View your progress through the heatmap and streak counters. 4. **Statistics**: View your progress through the heatmap and streak counters.
## Docker Deployment ## Docker Deployment
@@ -66,7 +73,7 @@ docker run -d \
-v ./data:/app/data \ -v ./data:/app/data \
-v ./backups:/app/backups \ # Add this line to map the backups directory -v ./backups:/app/backups \ # Add this line to map the backups directory
-e AUTH_SECRET=$AUTH_SECRET \ -e AUTH_SECRET=$AUTH_SECRET \
dohsimpson/habittrove ghcr.io/manindark/habittrove
``` ```
Available image tags: Available image tags:
@@ -113,7 +120,7 @@ To contribute to HabitTrove, you'll need to set up a development environment. He
1. Clone the repository and navigate to the project directory: 1. Clone the repository and navigate to the project directory:
```bash ```bash
git clone https://github.com/dohsimpson/habittrove.git git clone https://github.com/ManInDark/HabitTrove.git
cd habittrove cd habittrove
``` ```
@@ -166,7 +173,7 @@ Run these commands regularly during development to catch issues early.
## Contributing ## Contributing
We welcome feature requests and bug reports! Please [open an issue](https://github.com/dohsimpson/habittrove/issues/new). We do not accept pull request at the moment. We welcome feature requests and bug reports! Please [open an issue](https://github.com/ManInDark/habittrove/issues/new).
## License ## License

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

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

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

View File

@@ -1,23 +1,24 @@
'use client' 'use client'
import { useState } from 'react' import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'
import { RRule, RRuleSet, rrulestr } from 'rrule'
import { useAtom } from 'jotai'
import { useTranslations } from 'next-intl'
import { settingsAtom, browserSettingsAtom, usersAtom, currentUserAtom } from '@/lib/atoms'
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '@/components/ui/dialog'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar' import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog'
import { Input } from '@/components/ui/input' import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label' import { Label } from '@/components/ui/label'
import { Textarea } from '@/components/ui/textarea'
import { Zap } from 'lucide-react'
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover' import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
import { Habit, SafeUser } from '@/lib/types' import { Textarea } from '@/components/ui/textarea'
import EmojiPickerButton from './EmojiPickerButton' import { currentUserAtom, settingsAtom, usersAtom } from '@/lib/atoms'
import { convertHumanReadableFrequencyToMachineReadable, convertMachineReadableFrequencyToHumanReadable, d2s, d2t, serializeRRule } from '@/lib/utils' import { INITIAL_DUE, INITIAL_RECURRENCE_RULE, MAX_COIN_LIMIT, QUICK_DATES } from '@/lib/constants'
import { INITIAL_DUE, INITIAL_RECURRENCE_RULE, QUICK_DATES, RECURRENCE_RULE_MAP, MAX_COIN_LIMIT } from '@/lib/constants' import { Habit } from '@/lib/types'
import { convertHumanReadableFrequencyToMachineReadable, convertMachineReadableFrequencyToHumanReadable, d2t, serializeRRule } from '@/lib/utils'
import { useAtom } from 'jotai'
import { Zap } from 'lucide-react'
import { DateTime } from 'luxon' import { DateTime } from 'luxon'
import { useTranslations } from 'next-intl'
import { useState } from 'react'
import { RRule } from 'rrule'
import EmojiPickerButton from './EmojiPickerButton'
import ModalOverlay from './ModalOverlay'; // Import the new component
interface AddEditHabitModalProps { interface AddEditHabitModalProps {
@@ -44,7 +45,6 @@ export default function AddEditHabitModal({ onClose, onSave, habit, isTask }: Ad
const [ruleText, setRuleText] = useState<string>(initialRuleText) const [ruleText, setRuleText] = useState<string>(initialRuleText)
const [currentUser] = useAtom(currentUserAtom) const [currentUser] = useAtom(currentUserAtom)
const [isQuickDatesOpen, setIsQuickDatesOpen] = useState(false) const [isQuickDatesOpen, setIsQuickDatesOpen] = useState(false)
const [errorMessage, setErrorMessage] = useState<string | null>(null); // State for validation message
const [selectedUserIds, setSelectedUserIds] = useState<string[]>((habit?.userIds || []).filter(id => id !== currentUser?.id)) const [selectedUserIds, setSelectedUserIds] = useState<string[]>((habit?.userIds || []).filter(id => id !== currentUser?.id))
const [usersData] = useAtom(usersAtom) const [usersData] = useAtom(usersAtom)
const users = usersData.users const users = usersData.users
@@ -86,250 +86,238 @@ 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 />
<DialogHeader> <Dialog open={true} onOpenChange={onClose} modal={false}>
<DialogTitle> <DialogContent> {/* DialogContent from shadcn/ui is typically z-50, ModalOverlay is z-40 */}
{habit <DialogHeader>
? t(isTask ? 'editTaskTitle' : 'editHabitTitle') <DialogTitle>
: t(isTask ? 'addNewTaskTitle' : 'addNewHabitTitle')} {habit
</DialogTitle> ? t(isTask ? 'editTaskTitle' : 'editHabitTitle')
</DialogHeader> : t(isTask ? 'addNewTaskTitle' : 'addNewHabitTitle')}
<form onSubmit={handleSubmit}> </DialogTitle>
<div className="grid gap-4 py-4"> </DialogHeader>
<div className="grid grid-cols-4 items-center gap-4"> <form onSubmit={handleSubmit}>
<Label htmlFor="name" className="text-right"> <div className="grid gap-4 py-4">
{t('nameLabel')} <div className="grid grid-cols-4 items-center gap-4">
</Label> <Label htmlFor="name" className="text-right">
<div className='flex col-span-3 gap-2'> {t('nameLabel')}
<Input </Label>
id="name" <div className='flex col-span-3 gap-2'>
value={name}
onChange={(e) => setName(e.target.value)}
required
/>
<EmojiPickerButton
inputIdToFocus="name"
onEmojiSelect={(emoji) => {
setName(prev => {
const space = prev.length > 0 && !prev.endsWith(' ') ? ' ' : '';
return `${prev}${space}${emoji}`;
})
}}
/>
</div>
</div>
<div className="grid grid-cols-4 items-center gap-4">
<Label htmlFor="description" className="text-right">
{t('descriptionLabel')}
</Label>
<Textarea
id="description"
value={description}
onChange={(e) => setDescription(e.target.value)}
className="col-span-3"
/>
</div>
<div className="grid grid-cols-4 items-center gap-4">
<Label htmlFor="recurrence" className="text-right">
{t('whenLabel')}
</Label>
{/* date input (task) */}
<div className="col-span-3 space-y-2">
<div className="flex gap-2">
<Input <Input
id="recurrence" id="name"
value={ruleText} value={name}
onChange={(e) => setRuleText(e.target.value)} onChange={(e) => setName(e.target.value)}
required required
/> />
{isTask && ( <EmojiPickerButton
<Popover open={isQuickDatesOpen} onOpenChange={setIsQuickDatesOpen}> inputIdToFocus="name"
<PopoverTrigger asChild> onEmojiSelect={(emoji) => {
<Button setName(prev => {
type="button" const space = prev.length > 0 && !prev.endsWith(' ') ? ' ' : '';
variant="ghost" return `${prev}${space}${emoji}`;
size="icon" })
className="h-8 w-8" }}
> />
<Zap className="h-4 w-4" />
</Button>
</PopoverTrigger>
<PopoverContent className="p-3 w-[280px] max-h-[40vh] overflow-y-auto" align="start">
<div className="space-y-1">
<div className="grid grid-cols-2 gap-2">
{QUICK_DATES.map((date) => (
<Button
key={date.value}
variant="outline"
className="justify-start h-9 px-3 hover:bg-primary hover:text-primary-foreground transition-colors"
onClick={() => {
setRuleText(date.value);
setIsQuickDatesOpen(false);
}}
>
{date.label}
</Button>
))}
</div>
</div>
</PopoverContent>
</Popover>
)}
</div> </div>
</div> </div>
{/* rrule input (habit) */}
<div className="col-start-2 col-span-3 text-sm">
{(() => {
let displayText = '';
const { result, message } = convertHumanReadableFrequencyToMachineReadable({ text: ruleText, timezone: settings.system.timezone, isRecurring: isRecurRule });
if (message !== errorMessage) { // Only update if it changed to avoid re-renders
setErrorMessage(message);
}
displayText = convertMachineReadableFrequencyToHumanReadable({ frequency: result, isRecurRule, timezone: settings.system.timezone })
return (
<>
<span className={errorMessage ? 'text-destructive' : 'text-muted-foreground'}>
{displayText}
</span>
{errorMessage && (
<p className="text-destructive text-xs mt-1">{errorMessage}</p>
)}
</>
);
})()}
</div>
</div>
<div className="grid grid-cols-4 items-center gap-4">
<div className="flex items-center gap-2 justify-end">
<Label htmlFor="targetCompletions">
{t('completeLabel')}
</Label>
</div>
<div className="col-span-3">
<div className="flex items-center gap-4">
<div className="flex items-center border rounded-lg overflow-hidden">
<button
type="button"
onClick={() => setTargetCompletions(prev => Math.max(1, prev - 1))}
className="px-3 py-2 bg-gray-100 hover:bg-gray-200 dark:bg-gray-800 dark:hover:bg-gray-700 transition-colors"
>
-
</button>
<Input
id="targetCompletions"
type="number"
value={targetCompletions}
onChange={(e) => {
const value = parseInt(e.target.value)
setTargetCompletions(isNaN(value) ? 1 : Math.max(1, value))
}}
min={1}
max={10}
className="w-20 text-center border-0 [appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none"
/>
<button
type="button"
onClick={() => setTargetCompletions(prev => Math.min(10, prev + 1))}
className="px-3 py-2 bg-gray-100 hover:bg-gray-200 dark:bg-gray-800 dark:hover:bg-gray-700 transition-colors"
>
+
</button>
</div>
<span className="text-sm text-muted-foreground">
{t('timesSuffix')}
</span>
</div>
</div>
</div>
<div className="grid grid-cols-4 items-center gap-4">
<div className="flex items-center gap-2 justify-end">
<Label htmlFor="coinReward">
{t('rewardLabel')}
</Label>
</div>
<div className="col-span-3">
<div className="flex items-center gap-4">
<div className="flex items-center border rounded-lg overflow-hidden">
<button
type="button"
onClick={() => setCoinReward(prev => Math.max(0, prev - 1))}
className="px-3 py-2 bg-gray-100 hover:bg-gray-200 dark:bg-gray-800 dark:hover:bg-gray-700 transition-colors"
>
-
</button>
<Input
id="coinReward"
type="number"
value={coinReward}
onChange={(e) => {
const value = parseInt(e.target.value === "" ? "0" : e.target.value)
setCoinReward(Math.min(value, MAX_COIN_LIMIT))
}}
min={0}
max={MAX_COIN_LIMIT}
required
className="w-20 text-center border-0 [appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none"
/>
<button
type="button"
onClick={() => setCoinReward(prev => Math.min(prev + 1, MAX_COIN_LIMIT))}
className="px-3 py-2 bg-gray-100 hover:bg-gray-200 dark:bg-gray-800 dark:hover:bg-gray-700 transition-colors"
>
+
</button>
</div>
<span className="text-sm text-muted-foreground">
{t('coinsSuffix')}
</span>
</div>
</div>
</div>
{users && users.length > 1 && (
<div className="grid grid-cols-4 items-center gap-4"> <div className="grid grid-cols-4 items-center gap-4">
<div className="flex items-center justify-end gap-2"> <Label htmlFor="description" className="text-right">
<Label htmlFor="sharing-toggle">{t('shareLabel')}</Label> {t('descriptionLabel')}
</Label>
<Textarea
id="description"
value={description}
onChange={(e) => setDescription(e.target.value)}
className="col-span-3"
/>
</div>
<div className="grid grid-cols-4 items-center gap-4">
<Label htmlFor="recurrence" className="text-right">
{t('whenLabel')}
</Label>
{/* date input (task) */}
<div className="col-span-3 space-y-2">
<div className="flex gap-2">
<Input
id="recurrence"
value={ruleText}
onChange={(e) => setRuleText(e.target.value)}
required
/>
{isTask && (
<Popover open={isQuickDatesOpen} onOpenChange={setIsQuickDatesOpen}>
<PopoverTrigger asChild>
<Button
type="button"
variant="ghost"
size="icon"
className="h-8 w-8"
>
<Zap className="h-4 w-4" />
</Button>
</PopoverTrigger>
<PopoverContent className="p-3 w-[280px] max-h-[40vh] overflow-y-auto" align="start">
<div className="space-y-1">
<div className="grid grid-cols-2 gap-2">
{QUICK_DATES.map((date) => (
<Button
key={date.value}
variant="outline"
className="justify-start h-9 px-3 hover:bg-primary hover:text-primary-foreground transition-colors"
onClick={() => {
setRuleText(date.value);
setIsQuickDatesOpen(false);
}}
>
{date.label}
</Button>
))}
</div>
</div>
</PopoverContent>
</Popover>
)}
</div>
</div>
{/* rrule input (habit) */}
<div className="col-start-2 col-span-3 text-sm">
<span className={errorMessage ? 'text-destructive' : 'text-muted-foreground'}>
{errorMessage ? errorMessage : convertMachineReadableFrequencyToHumanReadable({ frequency: result, isRecurRule, timezone: settings.system.timezone })}
</span>
</div>
</div>
<div className="grid grid-cols-4 items-center gap-4">
<div className="flex items-center gap-2 justify-end">
<Label htmlFor="targetCompletions">
{t('completeLabel')}
</Label>
</div> </div>
<div className="col-span-3"> <div className="col-span-3">
<div className="flex flex-wrap gap-2"> <div className="flex items-center gap-4">
{users.filter((u) => u.id !== currentUser?.id).map(user => ( <div className="flex items-center border rounded-lg overflow-hidden">
<Avatar <button
key={user.id} type="button"
className={`h-8 w-8 border-2 cursor-pointer onClick={() => setTargetCompletions(prev => Math.max(1, prev - 1))}
${selectedUserIds.includes(user.id) className="px-3 py-2 bg-gray-100 hover:bg-gray-200 dark:bg-gray-800 dark:hover:bg-gray-700 transition-colors"
? 'border-primary'
: 'border-muted'
}`}
title={user.username}
onClick={() => {
setSelectedUserIds(prev =>
prev.includes(user.id)
? prev.filter(id => id !== user.id)
: [...prev, user.id]
)
}}
> >
<AvatarImage src={user?.avatarPath && `/api/avatars/${user.avatarPath.split('/').pop()}` || ""} /> -
<AvatarFallback>{user.username[0]}</AvatarFallback> </button>
</Avatar> <Input
))} id="targetCompletions"
type="number"
value={targetCompletions}
onChange={(e) => {
const value = parseInt(e.target.value)
setTargetCompletions(isNaN(value) ? 1 : Math.max(1, value))
}}
min={1}
max={10}
className="w-20 text-center border-0 [appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none"
/>
<button
type="button"
onClick={() => setTargetCompletions(prev => Math.min(10, prev + 1))}
className="px-3 py-2 bg-gray-100 hover:bg-gray-200 dark:bg-gray-800 dark:hover:bg-gray-700 transition-colors"
>
+
</button>
</div>
<span className="text-sm text-muted-foreground">
{t('timesSuffix')}
</span>
</div> </div>
</div> </div>
</div> </div>
)} <div className="grid grid-cols-4 items-center gap-4">
</div> <div className="flex items-center gap-2 justify-end">
<DialogFooter> <Label htmlFor="coinReward">
<Button type="submit" disabled={!!errorMessage}> {t('rewardLabel')}
{habit </Label>
? t('saveChangesButton') </div>
: t(isTask ? 'addTaskButton' : 'addHabitButton')} <div className="col-span-3">
</Button> <div className="flex items-center gap-4">
</DialogFooter> <div className="flex items-center border rounded-lg overflow-hidden">
</form> <button
</DialogContent> type="button"
</Dialog> onClick={() => setCoinReward(prev => Math.max(0, prev - 1))}
className="px-3 py-2 bg-gray-100 hover:bg-gray-200 dark:bg-gray-800 dark:hover:bg-gray-700 transition-colors"
>
-
</button>
<Input
id="coinReward"
type="number"
value={coinReward}
onChange={(e) => {
const value = parseInt(e.target.value === "" ? "0" : e.target.value)
setCoinReward(Math.min(value, MAX_COIN_LIMIT))
}}
min={0}
max={MAX_COIN_LIMIT}
required
className="w-20 text-center border-0 [appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none"
/>
<button
type="button"
onClick={() => setCoinReward(prev => Math.min(prev + 1, MAX_COIN_LIMIT))}
className="px-3 py-2 bg-gray-100 hover:bg-gray-200 dark:bg-gray-800 dark:hover:bg-gray-700 transition-colors"
>
+
</button>
</div>
<span className="text-sm text-muted-foreground">
{t('coinsSuffix')}
</span>
</div>
</div>
</div>
{users && users.length > 1 && (
<div className="grid grid-cols-4 items-center gap-4">
<div className="flex items-center justify-end gap-2">
<Label htmlFor="sharing-toggle">{t('shareLabel')}</Label>
</div>
<div className="col-span-3">
<div className="flex flex-wrap gap-2">
{users.filter((u) => u.id !== currentUser?.id).map(user => (
<Avatar
key={user.id}
className={`h-8 w-8 border-2 cursor-pointer
${selectedUserIds.includes(user.id)
? 'border-primary'
: 'border-muted'
}`}
title={user.username}
onClick={() => {
setSelectedUserIds(prev =>
prev.includes(user.id)
? prev.filter(id => id !== user.id)
: [...prev, user.id]
)
}}
>
<AvatarImage src={user?.avatarPath && `/api/avatars/${user.avatarPath.split('/').pop()}` || ""} />
<AvatarFallback>{user.username[0]}</AvatarFallback>
</Avatar>
))}
</div>
</div>
</div>
)}
</div>
<DialogFooter>
<Button type="submit" disabled={!!errorMessage}>
{habit
? t('saveChangesButton')
: t(isTask ? 'addTaskButton' : 'addHabitButton')}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
</>
) )
} }

View File

@@ -1,19 +1,19 @@
import { useState, useEffect } from 'react'
import { useAtom } from 'jotai'
import { useTranslations } from 'next-intl'
import { usersAtom, currentUserAtom } from '@/lib/atoms'
import { Avatar, AvatarFallback, AvatarImage } from './ui/avatar'
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '@/components/ui/dialog'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog'
import { Input } from '@/components/ui/input' import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label' import { Label } from '@/components/ui/label'
import { Textarea } from '@/components/ui/textarea' import { Textarea } from '@/components/ui/textarea'
import { WishlistItemType } from '@/lib/types' import { currentUserAtom, usersAtom } from '@/lib/atoms'
import EmojiPickerButton from './EmojiPickerButton'
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
@@ -22,7 +22,6 @@ interface AddEditWishlistItemModalProps {
} }
export default function AddEditWishlistItemModal({ export default function AddEditWishlistItemModal({
isOpen,
setIsOpen, setIsOpen,
editingItem, editingItem,
setEditingItem, setEditingItem,
@@ -115,196 +114,199 @@ export default function AddEditWishlistItemModal({
} }
return ( return (
<Dialog open={isOpen} onOpenChange={handleClose}> <>
<DialogContent> <ModalOverlay />
<DialogHeader> <Dialog open={true} onOpenChange={handleClose} modal={false}>
<DialogTitle>{editingItem ? t('editTitle') : t('addTitle')}</DialogTitle> <DialogContent> {/* DialogContent from shadcn/ui is typically z-50, ModalOverlay is z-40 */}
</DialogHeader> <DialogHeader>
<form onSubmit={handleSave}> <DialogTitle>{editingItem ? t('editTitle') : t('addTitle')}</DialogTitle>
<div className="grid gap-4 py-4"> </DialogHeader>
<div className="grid grid-cols-4 items-center gap-4"> <form onSubmit={handleSave}>
<Label htmlFor="name" className="text-right"> <div className="grid gap-4 py-4">
{t('nameLabel')} <div className="grid grid-cols-4 items-center gap-4">
</Label> <Label htmlFor="name" className="text-right">
<div className="col-span-3 flex gap-2"> {t('nameLabel')}
<Input
id="name"
value={name}
onChange={(e) => setName(e.target.value)}
className="flex-1"
required
/>
<EmojiPickerButton
inputIdToFocus="name"
onEmojiSelect={(emoji) => {
setName(prev => {
const space = prev.length > 0 && !prev.endsWith(' ') ? ' ' : '';
return `${prev}${space}${emoji}`;
})
}}
/>
</div>
</div>
<div className="grid grid-cols-4 items-center gap-4">
<Label htmlFor="description" className="text-right">
{t('descriptionLabel')}
</Label>
<Textarea
id="description"
value={description}
onChange={(e) => setDescription(e.target.value)}
className="col-span-3"
/>
</div>
<div className="grid grid-cols-4 items-center gap-4">
<div className="flex items-center gap-2 justify-end">
<Label htmlFor="coinReward">
{t('costLabel')}
</Label> </Label>
</div> <div className="col-span-3 flex gap-2">
<div className="col-span-3"> <Input
<div className="flex items-center gap-4"> id="name"
<div className="flex items-center border rounded-lg overflow-hidden"> value={name}
<button onChange={(e) => setName(e.target.value)}
type="button" className="flex-1"
onClick={() => setCoinCost(prev => Math.max(0, prev - 1))} required
className="px-3 py-2 bg-gray-100 hover:bg-gray-200 dark:bg-gray-800 dark:hover:bg-gray-700 transition-colors" />
> <EmojiPickerButton
- inputIdToFocus="name"
</button> onEmojiSelect={(emoji) => {
<Input setName(prev => {
id="coinReward" const space = prev.length > 0 && !prev.endsWith(' ') ? ' ' : '';
type="number" return `${prev}${space}${emoji}`;
value={coinCost} })
onChange={(e) => { }}
const value = parseInt(e.target.value === "" ? "0" : e.target.value) />
setCoinCost(Math.min(value, MAX_COIN_LIMIT))
}}
min={0}
max={MAX_COIN_LIMIT}
required
className="w-20 text-center border-0 [appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none"
/>
<button
type="button"
onClick={() => setCoinCost(prev => Math.min(prev + 1, MAX_COIN_LIMIT))}
className="px-3 py-2 bg-gray-100 hover:bg-gray-200 dark:bg-gray-800 dark:hover:bg-gray-700 transition-colors"
>
+
</button>
</div>
<span className="text-sm text-muted-foreground">
{t('coinsSuffix')}
</span>
</div> </div>
</div> </div>
</div> <div className="grid grid-cols-4 items-center gap-4">
<div className="grid grid-cols-4 items-center gap-4"> <Label htmlFor="description" className="text-right">
<div className="flex items-center gap-2 justify-end"> {t('descriptionLabel')}
<Label htmlFor="targetCompletions">
{t('redeemableLabel')}
</Label> </Label>
</div> <Textarea
<div className="col-span-3"> id="description"
<div className="flex items-center gap-4"> value={description}
<div className="flex items-center border rounded-lg overflow-hidden"> onChange={(e) => setDescription(e.target.value)}
<button
type="button"
onClick={() => setTargetCompletions(prev => prev !== undefined && prev > 1 ? prev - 1 : undefined)}
className="px-3 py-2 bg-gray-100 hover:bg-gray-200 dark:bg-gray-800 dark:hover:bg-gray-700 transition-colors"
>
-
</button>
<Input
id="targetCompletions"
type="number"
value={targetCompletions || ''}
onChange={(e) => {
const value = e.target.value
setTargetCompletions(value && value !== "0" ? parseInt(value) : undefined)
}}
min={0}
placeholder="∞"
className="w-20 text-center border-0 [appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none"
/>
<button
type="button"
onClick={() => setTargetCompletions(prev => Math.min(10, (prev || 0) + 1))}
className="px-3 py-2 bg-gray-100 hover:bg-gray-200 dark:bg-gray-800 dark:hover:bg-gray-700 transition-colors"
>
+
</button>
</div>
<span className="text-sm text-muted-foreground">
{t('timesSuffix')}
</span>
</div>
{errors.targetCompletions && (
<div className="text-sm text-red-500">
{errors.targetCompletions}
</div>
)}
</div>
</div>
<div className="grid grid-cols-4 items-center gap-4">
<Label htmlFor="link" className="text-right">
{t('linkLabel')}
</Label>
<div className="col-span-3">
<Input
id="link"
type="url"
placeholder="https://..."
value={link}
onChange={(e) => setLink(e.target.value)}
className="col-span-3" className="col-span-3"
/> />
{errors.link && (
<div className="text-sm text-red-500">
{errors.link}
</div>
)}
</div> </div>
</div>
{usersData.users && usersData.users.length > 1 && (
<div className="grid grid-cols-4 items-center gap-4"> <div className="grid grid-cols-4 items-center gap-4">
<div className="flex items-center justify-end gap-2"> <div className="flex items-center gap-2 justify-end">
<Label htmlFor="sharing-toggle">{t('shareLabel')}</Label> <Label htmlFor="coinReward">
{t('costLabel')}
</Label>
</div> </div>
<div className="col-span-3"> <div className="col-span-3">
<div className="flex flex-wrap gap-2"> <div className="flex items-center gap-4">
{usersData.users.filter((u) => u.id !== currentUser?.id).map(user => ( <div className="flex items-center border rounded-lg overflow-hidden">
<Avatar <button
key={user.id} type="button"
className={`h-8 w-8 border-2 cursor-pointer onClick={() => setCoinCost(prev => Math.max(0, prev - 1))}
${selectedUserIds.includes(user.id) className="px-3 py-2 bg-gray-100 hover:bg-gray-200 dark:bg-gray-800 dark:hover:bg-gray-700 transition-colors"
? 'border-primary'
: 'border-muted'
}`}
title={user.username}
onClick={() => {
setSelectedUserIds(prev =>
prev.includes(user.id)
? prev.filter(id => id !== user.id)
: [...prev, user.id]
)
}}
> >
<AvatarImage src={user?.avatarPath && `/api/avatars/${user.avatarPath.split('/').pop()}` || ""} /> -
<AvatarFallback>{user.username[0]}</AvatarFallback> </button>
</Avatar> <Input
))} id="coinReward"
type="number"
value={coinCost}
onChange={(e) => {
const value = parseInt(e.target.value === "" ? "0" : e.target.value)
setCoinCost(Math.min(value, MAX_COIN_LIMIT))
}}
min={0}
max={MAX_COIN_LIMIT}
required
className="w-20 text-center border-0 [appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none"
/>
<button
type="button"
onClick={() => setCoinCost(prev => Math.min(prev + 1, MAX_COIN_LIMIT))}
className="px-3 py-2 bg-gray-100 hover:bg-gray-200 dark:bg-gray-800 dark:hover:bg-gray-700 transition-colors"
>
+
</button>
</div>
<span className="text-sm text-muted-foreground">
{t('coinsSuffix')}
</span>
</div> </div>
</div> </div>
</div> </div>
)} <div className="grid grid-cols-4 items-center gap-4">
</div> <div className="flex items-center gap-2 justify-end">
<DialogFooter> <Label htmlFor="targetCompletions">
<Button type="submit">{editingItem ? t('saveButton') : t('addButton')}</Button> {t('redeemableLabel')}
</DialogFooter> </Label>
</form> </div>
</DialogContent> <div className="col-span-3">
</Dialog> <div className="flex items-center gap-4">
<div className="flex items-center border rounded-lg overflow-hidden">
<button
type="button"
onClick={() => setTargetCompletions(prev => prev !== undefined && prev > 1 ? prev - 1 : undefined)}
className="px-3 py-2 bg-gray-100 hover:bg-gray-200 dark:bg-gray-800 dark:hover:bg-gray-700 transition-colors"
>
-
</button>
<Input
id="targetCompletions"
type="number"
value={targetCompletions || ''}
onChange={(e) => {
const value = e.target.value
setTargetCompletions(value && value !== "0" ? parseInt(value) : undefined)
}}
min={0}
placeholder="∞"
className="w-20 text-center border-0 [appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none"
/>
<button
type="button"
onClick={() => setTargetCompletions(prev => Math.min(10, (prev || 0) + 1))}
className="px-3 py-2 bg-gray-100 hover:bg-gray-200 dark:bg-gray-800 dark:hover:bg-gray-700 transition-colors"
>
+
</button>
</div>
<span className="text-sm text-muted-foreground">
{t('timesSuffix')}
</span>
</div>
{errors.targetCompletions && (
<div className="text-sm text-red-500">
{errors.targetCompletions}
</div>
)}
</div>
</div>
<div className="grid grid-cols-4 items-center gap-4">
<Label htmlFor="link" className="text-right">
{t('linkLabel')}
</Label>
<div className="col-span-3">
<Input
id="link"
type="url"
placeholder="https://..."
value={link}
onChange={(e) => setLink(e.target.value)}
className="col-span-3"
/>
{errors.link && (
<div className="text-sm text-red-500">
{errors.link}
</div>
)}
</div>
</div>
{usersData.users && usersData.users.length > 1 && (
<div className="grid grid-cols-4 items-center gap-4">
<div className="flex items-center justify-end gap-2">
<Label htmlFor="sharing-toggle">{t('shareLabel')}</Label>
</div>
<div className="col-span-3">
<div className="flex flex-wrap gap-2">
{usersData.users.filter((u) => u.id !== currentUser?.id).map(user => (
<Avatar
key={user.id}
className={`h-8 w-8 border-2 cursor-pointer
${selectedUserIds.includes(user.id)
? 'border-primary'
: 'border-muted'
}`}
title={user.username}
onClick={() => {
setSelectedUserIds(prev =>
prev.includes(user.id)
? prev.filter(id => id !== user.id)
: [...prev, user.id]
)
}}
>
<AvatarImage src={user?.avatarPath && `/api/avatars/${user.avatarPath.split('/').pop()}` || ""} />
<AvatarFallback>{user.username[0]}</AvatarFallback>
</Avatar>
))}
</div>
</div>
</div>
)}
</div>
<DialogFooter>
<Button type="submit">{editingItem ? t('saveButton') : t('addButton')}</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
</>
) )
} }

View File

@@ -1,27 +1,29 @@
'use client' 'use client'
import { ReactNode, Suspense, useEffect, useState } from 'react' import { checkDataFreshness as checkServerDataFreshness } from '@/app/actions/data';
import { useAtom, useSetAtom } from 'jotai' // Import useSetAtom import { aboutOpenAtom, clientFreshnessTokenAtom, currentUserIdAtom, pomodoroAtom, userSelectAtom } from '@/lib/atoms';
import { aboutOpenAtom, pomodoroAtom, userSelectAtom, currentUserIdAtom } from '@/lib/atoms' // Import currentUserIdAtom import { useAtom, useAtomValue, useSetAtom } from 'jotai';
import PomodoroTimer from './PomodoroTimer' import { useSession } from 'next-auth/react';
import UserSelectModal from './UserSelectModal' import { ReactNode, Suspense, useCallback, useEffect, useState } from 'react';
import { useSession } from 'next-auth/react' import AboutModal from './AboutModal';
import AboutModal from './AboutModal' import LoadingSpinner from './LoadingSpinner';
import LoadingSpinner from './LoadingSpinner' import PomodoroTimer from './PomodoroTimer';
import RefreshBanner from './RefreshBanner';
import UserSelectModal from './UserSelectModal';
export default function ClientWrapper({ children }: { children: ReactNode }) { function ClientWrapperContent({ children }: { children: ReactNode }) {
const [pomo] = useAtom(pomodoroAtom) const [pomo] = useAtom(pomodoroAtom)
const [userSelect, setUserSelect] = useAtom(userSelectAtom) const [userSelect, setUserSelect] = useAtom(userSelectAtom)
const [aboutOpen, setAboutOpen] = useAtom(aboutOpenAtom) const [aboutOpen, setAboutOpen] = useAtom(aboutOpenAtom)
const setCurrentUserIdAtom = useSetAtom(currentUserIdAtom) const setCurrentUserIdAtom = useSetAtom(currentUserIdAtom)
const { data: session, status } = useSession() const { data: session, status } = useSession()
const currentUserId = session?.user.id const currentUserId = session?.user.id
const [isMounted, setIsMounted] = useState(false); const [showRefreshBanner, setShowRefreshBanner] = useState(false);
// clientFreshnessTokenAtom is async, useAtomValue will suspend until it's resolved.
// Suspense boundary is in app/layout.tsx or could be added here if needed more locally.
const clientToken = useAtomValue(clientFreshnessTokenAtom);
// block client-side hydration until mounted (this is crucial to wait for all jotai atoms to load), to prevent SSR hydration errors in the children components
useEffect(() => {
setIsMounted(true);
}, []);
useEffect(() => { useEffect(() => {
if (status === 'loading') return if (status === 'loading') return
@@ -34,21 +36,62 @@ export default function ClientWrapper({ children }: { children: ReactNode }) {
setCurrentUserIdAtom(currentUserId) setCurrentUserIdAtom(currentUserId)
}, [currentUserId, setCurrentUserIdAtom]) }, [currentUserId, setCurrentUserIdAtom])
if (!isMounted) { const performFreshnessCheck = useCallback(async () => {
return <LoadingSpinner /> if (!clientToken || status !== 'authenticated') return;
}
try {
const result = await checkServerDataFreshness(clientToken);
if (!result.isFresh) {
setShowRefreshBanner(true);
}
} catch (error) {
console.error("Failed to check data freshness with server:", error);
}
}, [clientToken, status]);
useEffect(() => {
// Interval for polling data freshness
if (clientToken && !showRefreshBanner && status === 'authenticated') {
const intervalId = setInterval(() => {
performFreshnessCheck();
}, 30000); // Check every 30 seconds
return () => clearInterval(intervalId);
}
}, [clientToken, performFreshnessCheck, showRefreshBanner, status]);
const handleRefresh = () => {
setShowRefreshBanner(false);
window.location.reload();
};
return ( return (
<> <>
{children} {children}
{pomo.show && ( {pomo.show && <PomodoroTimer />}
<PomodoroTimer /> {userSelect && <UserSelectModal onClose={() => setUserSelect(false)} />}
)} {aboutOpen && <AboutModal onClose={() => setAboutOpen(false)} />}
{userSelect && ( {showRefreshBanner && <RefreshBanner onRefresh={handleRefresh} />}
<UserSelectModal onClose={() => setUserSelect(false)} />
)}
{aboutOpen && (
<AboutModal onClose={() => setAboutOpen(false)} />
)}
</> </>
) );
}
export default function ClientWrapper({ children }: { children: ReactNode }) {
const [isMounted, setIsMounted] = useState(false);
// block client-side hydration until mounted (this is crucial to wait for all jotai atoms to load),
// to prevent SSR hydration errors in the children components
useEffect(() => {
setIsMounted(true);
}, []);
if (!isMounted) {
return <LoadingSpinner />;
}
return (
<Suspense fallback={<LoadingSpinner />}>
<ClientWrapperContent>{children}</ClientWrapperContent>
</Suspense>
);
} }

View File

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

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

@@ -1,42 +0,0 @@
import Link from 'next/link'
import type { ElementType } from 'react'
export interface NavItemType {
icon: ElementType;
label: string;
href: string;
position: 'main' | 'bottom';
}
interface DesktopNavDisplayProps {
navItems: NavItemType[];
className?: string;
}
export default function DesktopNavDisplay({ navItems, className }: DesktopNavDisplayProps) {
// Filter for items relevant to desktop view, typically 'main' position
const desktopNavItems = navItems.filter(item => item.position === 'main');
return (
<div className={`hidden lg:flex lg:flex-shrink-0 ${className || ''}`}>
<div className="flex flex-col w-64">
<div className="flex flex-col h-0 flex-1 bg-gray-800">
<div className="flex-1 flex flex-col pt-5 pb-4 overflow-y-auto">
<nav className="mt-5 flex-1 px-2 space-y-1">
{desktopNavItems.map((item) => (
<Link
key={item.label} // Assuming labels are unique
href={item.href}
className="group flex items-center px-2 py-2 text-sm leading-6 font-medium rounded-md text-gray-300 hover:text-white hover:bg-gray-700"
>
<item.icon className="mr-4 flex-shrink-0 h-6 w-6 text-gray-400" aria-hidden="true" />
{item.label}
</Link>
))}
</nav>
</div>
</div>
</div>
</div>
);
}

View File

@@ -16,7 +16,7 @@ export default function EmojiPickerButton({ onEmojiSelect, inputIdToFocus }: Emo
const [isEmojiPickerOpen, setIsEmojiPickerOpen] = useState(false) const [isEmojiPickerOpen, setIsEmojiPickerOpen] = useState(false)
return ( return (
<Popover modal={true} open={isEmojiPickerOpen} onOpenChange={setIsEmojiPickerOpen}> <Popover modal={false} open={isEmojiPickerOpen} onOpenChange={setIsEmojiPickerOpen}>
<PopoverTrigger asChild> <PopoverTrigger asChild>
<Button <Button
type="button" type="button"

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,4 +1,4 @@
import { Habit, User } from '@/lib/types'; import { Habit } from '@/lib/types';
import { useHabits } from '@/hooks/useHabits'; import { useHabits } from '@/hooks/useHabits';
import { useAtom } from 'jotai'; import { useAtom } from 'jotai';
import { pomodoroAtom, settingsAtom, currentUserAtom } from '@/lib/atoms'; import { pomodoroAtom, settingsAtom, currentUserAtom } from '@/lib/atoms';

View File

@@ -1,25 +1,21 @@
import { Habit, SafeUser, User, Permission } from '@/lib/types'
import { useAtom } from 'jotai'
import { settingsAtom, pomodoroAtom, browserSettingsAtom, usersAtom, currentUserAtom } from '@/lib/atoms'
import { getTodayInTimezone, isSameDate, t2d, d2t, getNow, d2s, getCompletionsForToday, isTaskOverdue, convertMachineReadableFrequencyToHumanReadable } from '@/lib/utils'
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from '@/components/ui/card'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { Coins, Edit, Check, Undo2, MoreVertical, Pin } from 'lucide-react' import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from '@/components/ui/card'
import { import {
DropdownMenu, DropdownMenu,
DropdownMenuContent, DropdownMenuContent,
DropdownMenuItem, DropdownMenuTrigger
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu' } from '@/components/ui/dropdown-menu'
import { useEffect, useState } from 'react'
import { useHabits } from '@/hooks/useHabits' import { useHabits } from '@/hooks/useHabits'
import { currentUserAtom, settingsAtom, usersAtom } from '@/lib/atoms'
import { Habit, User } from '@/lib/types'
import { convertMachineReadableFrequencyToHumanReadable, getCompletionsForToday, hasPermission, isTaskOverdue } from '@/lib/utils'
import { useAtom } from 'jotai'
import { Check, Coins, Edit, MoreVertical, Pin, Undo2 } from 'lucide-react'
import { useTranslations } from 'next-intl' import { useTranslations } from 'next-intl'
import { INITIAL_RECURRENCE_RULE, RECURRENCE_RULE_MAP } from '@/lib/constants' import { usePathname } from 'next/navigation'
import { DateTime } from 'luxon' import { useEffect, useState } from 'react'
import { Avatar, AvatarFallback, AvatarImage } from './ui/avatar'
import { hasPermission } from '@/lib/utils'
import { HabitContextMenuItems } from './HabitContextMenuItems' import { HabitContextMenuItems } from './HabitContextMenuItems'
import { Avatar, AvatarFallback, AvatarImage } from './ui/avatar'
interface HabitItemProps { interface HabitItemProps {
habit: Habit habit: Habit
@@ -48,21 +44,18 @@ const renderUserAvatars = (habit: Habit, currentUser: User | null, usersData: {
export default function HabitItem({ habit, onEdit, onDelete }: HabitItemProps) { export default function HabitItem({ habit, onEdit, onDelete }: HabitItemProps) {
const { completeHabit, undoComplete, archiveHabit, unarchiveHabit, saveHabit } = useHabits() const { completeHabit, undoComplete } = useHabits()
const [settings] = useAtom(settingsAtom) const [settings] = useAtom(settingsAtom)
const [_, setPomo] = useAtom(pomodoroAtom)
const completionsToday = getCompletionsForToday({ habit, timezone: settings.system.timezone }) const completionsToday = getCompletionsForToday({ habit, timezone: settings.system.timezone })
const target = habit.targetCompletions || 1 const target = habit.targetCompletions || 1
const isCompletedToday = completionsToday >= target const isCompletedToday = completionsToday >= target
const [isHighlighted, setIsHighlighted] = useState(false) const [isHighlighted, setIsHighlighted] = useState(false)
const t = useTranslations('HabitItem'); const t = useTranslations('HabitItem');
const [usersData] = useAtom(usersAtom) const [usersData] = useAtom(usersAtom)
const pathname = usePathname();
const [currentUser] = useAtom(currentUserAtom) const [currentUser] = useAtom(currentUserAtom)
const canWrite = hasPermission(currentUser, 'habit', 'write') const canWrite = hasPermission(currentUser, 'habit', 'write')
const canInteract = hasPermission(currentUser, 'habit', 'interact') const canInteract = hasPermission(currentUser, 'habit', 'interact')
const [browserSettings] = useAtom(browserSettingsAtom)
const isTasksView = browserSettings.viewType === 'tasks'
const isRecurRule = !isTasksView
useEffect(() => { useEffect(() => {
const params = new URLSearchParams(window.location.search) const params = new URLSearchParams(window.location.search)
@@ -90,7 +83,7 @@ export default function HabitItem({ habit, onEdit, onDelete }: HabitItemProps) {
> >
<CardHeader className="flex-none"> <CardHeader className="flex-none">
<div className="flex justify-between items-start"> <div className="flex justify-between items-start">
<CardTitle className={`line-clamp-1 ${habit.archived ? 'text-gray-400 dark:text-gray-500' : ''} flex items-center ${isTasksView ? 'w-full' : ''} justify-between`}> <CardTitle className={`line-clamp-1 ${habit.archived ? 'text-gray-400 dark:text-gray-500' : ''} flex items-center ${pathname.includes("tasks") ? 'w-full' : ''} justify-between`}>
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">
{habit.pinned && ( {habit.pinned && (
<Pin className="h-4 w-4 text-yellow-500" /> <Pin className="h-4 w-4 text-yellow-500" />
@@ -116,7 +109,7 @@ export default function HabitItem({ habit, onEdit, onDelete }: HabitItemProps) {
{t('whenLabel', { {t('whenLabel', {
frequency: convertMachineReadableFrequencyToHumanReadable({ frequency: convertMachineReadableFrequencyToHumanReadable({
frequency: habit.frequency, frequency: habit.frequency,
isRecurRule, isRecurRule: pathname.includes("habits"),
timezone: settings.system.timezone timezone: settings.system.timezone
}) })
})} })}

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

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

@@ -1,60 +0,0 @@
import Link from 'next/link'
import type { ElementType } from 'react'
export interface NavItemType {
icon: ElementType;
label: string;
href: string;
position: 'main' | 'bottom';
}
interface MobileNavDisplayProps {
navItems: NavItemType[];
}
// detect iOS: https://stackoverflow.com/a/9039885
function iOS() {
return [
'iPad Simulator',
'iPhone Simulator',
'iPod Simulator',
'iPad',
'iPhone',
'iPod',
].includes(navigator.platform)
// iPad on iOS 13 detection
|| (navigator.userAgent.includes("Mac") && "ontouchend" in document)
}
export default function MobileNavDisplay({ navItems }: MobileNavDisplayProps) {
// Filter for items relevant to mobile view, typically 'main' and 'bottom' positions
const mobileNavItems = navItems.filter(item => item.position === 'main' || item.position === 'bottom');
// The original code spread main and bottom items separately, effectively concatenating them.
// If specific ordering or duplication was intended, that logic would be here.
// For now, a simple filter and map should suffice if all items are distinct.
// The original code: [...navItems(isTasksView).filter(item => item.position === 'main'), ...navItems(isTasksView).filter(item => item.position === 'bottom')]
// This implies that items could be in 'main' or 'bottom'. The current navItems only have 'main'.
// A simple combined list is fine.
const isIOS = iOS()
return (
<>
<div className={isIOS ? "pb-20" : "pb-16"} /> {/* Add padding at the bottom to prevent content from being hidden */}
<nav className={`lg:hidden fixed bottom-0 left-0 right-0 bg-white dark:bg-gray-800 shadow-lg ${isIOS ? "pb-4" : ""}`}>
<div className="grid grid-cols-5 w-full">
{mobileNavItems.map((item) => (
<Link
key={item.label} // Assuming labels are unique
href={item.href}
className="flex flex-col items-center justify-center py-2 text-gray-600 dark:text-gray-300 hover:text-blue-500 dark:hover:text-blue-400"
>
<item.icon className="h-6 w-6" />
<span className="text-xs mt-1">{item.label}</span>
</Link>
))}
</div>
</nav>
</>
);
}

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

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,19 +1,19 @@
'use client' 'use client'
import { signOut } from "@/app/actions/user"
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar" import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"
import { Button } from "@/components/ui/button" import { Button } from "@/components/ui/button"
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui/dropdown-menu" import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui/dropdown-menu"
import { Settings, Info, User, Moon, Sun, Palette, ArrowRightLeft, LogOut, Crown } from "lucide-react" import { toast } from "@/hooks/use-toast"
import { aboutOpenAtom, currentUserAtom, settingsAtom, userSelectAtom } from "@/lib/atoms"
import { useAtom } from "jotai"
import { ArrowRightLeft, Crown, Info, LogOut, Moon, Palette, Settings, Sun, User } from "lucide-react"
import { useTranslations } from 'next-intl'
import { useTheme } from "next-themes"
import Link from "next/link"
import { useState } from "react"
import { Dialog, DialogContent, DialogHeader, DialogTitle } from './ui/dialog' import { Dialog, DialogContent, DialogHeader, DialogTitle } from './ui/dialog'
import UserForm from './UserForm' import UserForm from './UserForm'
import Link from "next/link"
import { useAtom } from "jotai"
import { aboutOpenAtom, settingsAtom, userSelectAtom, currentUserAtom } from "@/lib/atoms"
import { useEffect, useState } from "react"
import { useTheme } from "next-themes"
import { signOut } from "@/app/actions/user"
import { toast } from "@/hooks/use-toast"
import { useTranslations } from 'next-intl'
export function Profile() { export function Profile() {
const t = useTranslations('Profile'); const t = useTranslations('Profile');
@@ -65,7 +65,7 @@ export function Profile() {
</AvatarFallback> </AvatarFallback>
</Avatar> </Avatar>
<div className="flex flex-col mr-4"> <div className="flex flex-col mr-4">
<span className="text-sm font-semibold flex items-center gap-1"> <span className="text-sm font-semibold flex items-center gap-1 break-all">
{user?.username || t('guestUsername')} {user?.username || t('guestUsername')}
{user?.isAdmin && <Crown className="h-3 w-3 text-yellow-500" />} {user?.isAdmin && <Crown className="h-3 w-3 text-yellow-500" />}
</span> </span>

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

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, currentUserAtom } from '@/lib/atoms';
import { signIn } from '@/app/actions/user';
import { createUser } from '@/app/actions/data';
import { useTranslations } from 'next-intl';
import { toast } from '@/hooks/use-toast';
import { Description } from '@radix-ui/react-dialog';
import { SafeUser, User } from '@/lib/types';
import { cn } from '@/lib/utils';
function UserCard({ function UserCard({
user, user,

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, currentUserAtom } from '@/lib/atoms'
import { Avatar, AvatarFallback, AvatarImage } from './ui/avatar'
import { hasPermission } from '@/lib/utils'
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from '@/components/ui/card'
import ReactMarkdown from 'react-markdown'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { Coins, Edit, Trash2, Gift, MoreVertical, Archive, ArchiveRestore } from 'lucide-react' import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from '@/components/ui/card'
import { import {
DropdownMenu, DropdownMenu,
DropdownMenuContent, DropdownMenuContent,
@@ -15,6 +7,13 @@ import {
DropdownMenuSeparator, DropdownMenuSeparator,
DropdownMenuTrigger, DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu' } from '@/components/ui/dropdown-menu'
import { currentUserAtom, usersAtom } from '@/lib/atoms'
import { User, WishlistItemType } from '@/lib/types'
import { hasPermission } from '@/lib/utils'
import { useAtom } from 'jotai'
import { Archive, ArchiveRestore, Coins, Edit, Gift, MoreVertical, Trash2 } from 'lucide-react'
import { useTranslations } from 'next-intl'
import { Avatar, AvatarFallback, AvatarImage } from './ui/avatar'
interface WishlistItemProps { interface WishlistItemProps {
item: WishlistItemType item: WishlistItemType

View File

@@ -152,14 +152,15 @@ export default function WishlistManager() {
</> </>
)} )}
</div> </div>
<AddEditWishlistItemModal {isModalOpen &&
isOpen={isModalOpen} <AddEditWishlistItemModal
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,50 +1,23 @@
import { useAtom } from 'jotai'; import { addCoins, removeCoins, saveCoinsData } from '@/app/actions/data';
import { useState, useEffect, useMemo } from 'react'; import { toast } from '@/hooks/use-toast';
import { useTranslations } from 'next-intl';
import { calculateCoinsEarnedToday, calculateCoinsSpentToday, calculateTotalEarned, calculateTotalSpent, calculateTransactionsToday, checkPermission } from '@/lib/utils'
import { import {
coinsAtom, coinsAtom,
coinsBalanceAtom,
coinsEarnedTodayAtom, coinsEarnedTodayAtom,
coinsSpentTodayAtom,
currentUserAtom,
settingsAtom,
totalEarnedAtom, totalEarnedAtom,
totalSpentAtom, totalSpentAtom,
coinsSpentTodayAtom,
transactionsTodayAtom, transactionsTodayAtom,
coinsBalanceAtom,
settingsAtom,
usersAtom, usersAtom,
currentUserAtom, } from '@/lib/atoms';
} from '@/lib/atoms' import { MAX_COIN_LIMIT } from '@/lib/constants';
import { addCoins, removeCoins, saveCoinsData } from '@/app/actions/data' import { CoinsData } from '@/lib/types';
import { CoinsData, User } from '@/lib/types' import { calculateCoinsEarnedToday, calculateCoinsSpentToday, calculateTotalEarned, calculateTotalSpent, calculateTransactionsToday, handlePermissionCheck, roundToInteger } from '@/lib/utils';
import { toast } from '@/hooks/use-toast' import { useAtom } from 'jotai';
import { MAX_COIN_LIMIT } from '@/lib/constants' import { useTranslations } from 'next-intl';
import { useEffect, useMemo, useState } from 'react';
function handlePermissionCheck(
user: User | undefined,
resource: 'habit' | 'wishlist' | 'coins',
action: 'write' | 'interact',
tCommon: (key: string, values?: Record<string, any>) => string
): boolean {
if (!user) {
toast({
title: tCommon("authenticationRequiredTitle"),
description: tCommon("authenticationRequiredDescription"),
variant: "destructive",
})
return false
}
if (!user.isAdmin && !checkPermission(user.permissions, resource, action)) {
toast({
title: tCommon("permissionDeniedTitle"),
description: tCommon("permissionDeniedDescription", { action, resource }),
variant: "destructive",
})
return false
}
return true
}
export function useCoins(options?: { selectedUser?: string }) { export function useCoins(options?: { selectedUser?: string }) {
const t = useTranslations('useCoins'); const t = useTranslations('useCoins');
@@ -86,12 +59,22 @@ export function useCoins(options?: { selectedUser?: string }) {
setBalance(loggedInUserBalance); setBalance(loggedInUserBalance);
} else if (targetUser?.id) { } else if (targetUser?.id) {
// If an admin is viewing another user, calculate their metrics manually // If an admin is viewing another user, calculate their metrics manually
setCoinsEarnedToday(calculateCoinsEarnedToday(transactions, timezone)); const earnedToday = calculateCoinsEarnedToday(transactions, timezone);
setTotalEarned(calculateTotalEarned(transactions)); setCoinsEarnedToday(roundToInteger(earnedToday));
setTotalSpent(calculateTotalSpent(transactions));
setCoinsSpentToday(calculateCoinsSpentToday(transactions, timezone)); const totalEarnedVal = calculateTotalEarned(transactions);
setTransactionsToday(calculateTransactionsToday(transactions, timezone)); setTotalEarned(roundToInteger(totalEarnedVal));
setBalance(transactions.reduce((acc, t) => acc + t.amount, 0));
const totalSpentVal = calculateTotalSpent(transactions);
setTotalSpent(roundToInteger(totalSpentVal));
const spentToday = calculateCoinsSpentToday(transactions, timezone);
setCoinsSpentToday(roundToInteger(spentToday));
setTransactionsToday(calculateTransactionsToday(transactions, timezone)); // This is a count
const calculatedBalance = transactions.reduce((acc, t) => acc + t.amount, 0);
setBalance(roundToInteger(calculatedBalance));
} }
}, [ }, [
targetUser?.id, targetUser?.id,

View File

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

View File

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

View File

@@ -1,82 +1,86 @@
import { atom } from "jotai";
import { import {
getDefaultSettings,
getDefaultHabitsData,
getDefaultCoinsData,
getDefaultWishlistData,
Habit,
ViewType,
getDefaultUsersData,
CompletionCache,
getDefaultServerSettings,
User,
UserId,
} from "./types";
import {
getTodayInTimezone,
isSameDate,
t2d,
calculateCoinsEarnedToday, calculateCoinsEarnedToday,
calculateCoinsSpentToday,
calculateTotalEarned, calculateTotalEarned,
calculateTotalSpent, calculateTotalSpent,
calculateCoinsSpentToday,
calculateTransactionsToday, calculateTransactionsToday,
generateCryptoHash,
getCompletionsForToday, getCompletionsForToday,
getISODate, getHabitFreq,
isHabitDueToday,
getNow,
isHabitDue, isHabitDue,
getHabitFreq prepareDataForHashing,
roundToInteger,
t2d
} from "@/lib/utils"; } from "@/lib/utils";
import { atom } from "jotai";
import { atomFamily, atomWithStorage } from "jotai/utils"; import { atomFamily, atomWithStorage } from "jotai/utils";
import { DateTime } from "luxon"; import { DateTime } from "luxon";
import { Freq } from "./types"; import {
CoinsData,
CompletionCache,
Freq,
getDefaultCoinsData,
getDefaultHabitsData,
getDefaultServerSettings,
getDefaultSettings,
getDefaultUsersData,
getDefaultWishlistData,
Habit,
HabitsData,
ServerSettings,
Settings,
UserData,
UserId,
WishlistData
} from "./types";
export interface BrowserSettings { export interface BrowserSettings {
viewType: ViewType
expandedHabits: boolean expandedHabits: boolean
expandedTasks: boolean expandedTasks: boolean
expandedWishlist: boolean expandedWishlist: boolean
} }
export const browserSettingsAtom = atomWithStorage('browserSettings', { export const browserSettingsAtom = atomWithStorage('browserSettings', {
viewType: 'habits',
expandedHabits: false, expandedHabits: false,
expandedTasks: false, expandedTasks: false,
expandedWishlist: false expandedWishlist: false
} as BrowserSettings) } as BrowserSettings)
export const usersAtom = atom(getDefaultUsersData()) export const usersAtom = atom(getDefaultUsersData<UserData>())
export const settingsAtom = atom(getDefaultSettings()); export const settingsAtom = atom(getDefaultSettings<Settings>());
export const habitsAtom = atom(getDefaultHabitsData()); export const habitsAtom = atom(getDefaultHabitsData<HabitsData>());
export const coinsAtom = atom(getDefaultCoinsData()); export const coinsAtom = atom(getDefaultCoinsData<CoinsData>());
export const wishlistAtom = atom(getDefaultWishlistData()); export const wishlistAtom = atom(getDefaultWishlistData<WishlistData>());
export const serverSettingsAtom = atom(getDefaultServerSettings()); export const serverSettingsAtom = atom(getDefaultServerSettings<ServerSettings>());
// Derived atom for coins earned today // Derived atom for coins earned today
export const coinsEarnedTodayAtom = atom((get) => { export const coinsEarnedTodayAtom = atom((get) => {
const coins = get(coinsAtom); const coins = get(coinsAtom);
const settings = get(settingsAtom); const settings = get(settingsAtom);
return calculateCoinsEarnedToday(coins.transactions, settings.system.timezone); const value = calculateCoinsEarnedToday(coins.transactions, settings.system.timezone);
return roundToInteger(value);
}); });
// Derived atom for total earned // Derived atom for total earned
export const totalEarnedAtom = atom((get) => { export const totalEarnedAtom = atom((get) => {
const coins = get(coinsAtom); const coins = get(coinsAtom);
return calculateTotalEarned(coins.transactions); const value = calculateTotalEarned(coins.transactions);
return roundToInteger(value);
}); });
// Derived atom for total spent // Derived atom for total spent
export const totalSpentAtom = atom((get) => { export const totalSpentAtom = atom((get) => {
const coins = get(coinsAtom); const coins = get(coinsAtom);
return calculateTotalSpent(coins.transactions); const value = calculateTotalSpent(coins.transactions);
return roundToInteger(value);
}); });
// Derived atom for coins spent today // Derived atom for coins spent today
export const coinsSpentTodayAtom = atom((get) => { export const coinsSpentTodayAtom = atom((get) => {
const coins = get(coinsAtom); const coins = get(coinsAtom);
const settings = get(settingsAtom); const settings = get(settingsAtom);
return calculateCoinsSpentToday(coins.transactions, settings.system.timezone); const value = calculateCoinsSpentToday(coins.transactions, settings.system.timezone);
return roundToInteger(value);
}); });
// Derived atom for transactions today // Derived atom for transactions today
@@ -103,9 +107,10 @@ export const coinsBalanceAtom = atom((get) => {
return 0; // No user logged in or ID not set, so balance is 0 return 0; // No user logged in or ID not set, so balance is 0
} }
const coins = get(coinsAtom); const coins = get(coinsAtom);
return coins.transactions const balance = coins.transactions
.filter(transaction => transaction.userId === loggedInUserId) .filter(transaction => transaction.userId === loggedInUserId)
.reduce((sum, transaction) => sum + transaction.amount, 0); .reduce((sum, transaction) => sum + transaction.amount, 0);
return roundToInteger(balance);
}); });
/* transient atoms */ /* transient atoms */
@@ -126,6 +131,22 @@ export const pomodoroAtom = atom<PomodoroAtom>({
export const userSelectAtom = atom<boolean>(false) export const userSelectAtom = atom<boolean>(false)
export const aboutOpenAtom = atom<boolean>(false) export const aboutOpenAtom = atom<boolean>(false)
/**
* Asynchronous atom that calculates a freshness token (hash) based on the current client-side data.
* This token can be compared with a server-generated token to detect data discrepancies.
*/
export const clientFreshnessTokenAtom = atom(async (get) => {
const settings = get(settingsAtom);
const habits = get(habitsAtom);
const coins = get(coinsAtom);
const wishlist = get(wishlistAtom);
const users = get(usersAtom);
const dataString = prepareDataForHashing(settings, habits, coins, wishlist, users);
const hash = await generateCryptoHash(dataString);
return hash;
});
// Derived atom for completion cache // Derived atom for completion cache
export const completionCacheAtom = atom((get) => { export const completionCacheAtom = atom((get) => {
const habits = get(habitsAtom).habits; const habits = get(habitsAtom).habits;
@@ -213,10 +234,3 @@ export const habitsByDateFamily = atomFamily((dateString: string) =>
return habits.filter(habit => isHabitDue({ habit, timezone, date })); return habits.filter(habit => isHabitDue({ habit, timezone, date }));
}) })
); );
// Derived atom for daily habits
export const dailyHabitsAtom = atom((get) => {
const settings = get(settingsAtom);
const today = getTodayInTimezone(settings.system.timezone);
return get(habitsByDateFamily(today));
});

36
lib/client-helpers.ts Normal file
View File

@@ -0,0 +1,36 @@
// client helpers
'use-client'
import { useAtom } from 'jotai'
import { useSession } from "next-auth/react"
import { usersAtom } from './atoms'
import { hasPermission } from './utils'
export function useHelpers() {
const { data: session, status } = useSession()
const currentUserId = session?.user.id
const [usersData] = useAtom(usersAtom)
const currentUser = usersData.users.find((u) => u.id === currentUserId)
// detect iOS: https://stackoverflow.com/a/9039885
function iOS() {
return typeof navigator !== "undefined" && ([
'iPad Simulator',
'iPhone Simulator',
'iPod Simulator',
'iPad',
'iPhone',
'iPod',
].includes(navigator.platform)
// iPad on iOS 13 detection
|| (navigator.userAgent.includes("Mac") && "ontouchend" in document))
}
return {
currentUserId,
currentUser,
usersData,
status,
hasPermission,
isIOS: iOS(),
}
}

View File

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

View File

@@ -37,4 +37,4 @@ export function verifyPassword(password?: string, storedHash?: string): boolean
const newHash = saltAndHashPassword(password, salt).split(':')[1] const newHash = saltAndHashPassword(password, salt).split(':')[1]
// Compare the new hash with the stored hash // Compare the new hash with the stored hash
return newHash === hash return newHash === hash
} }

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,52 +96,58 @@ export interface WishlistData {
} }
// Default value functions // Default value functions
export const getDefaultUsersData = (): UserData => ({ export function getDefaultUsersData<UserData>(): UserData {
users: [ return {
{ users: [
id: uuid(), {
username: 'admin', id: crypto.randomUUID(),
// password: '', // No default password for admin initially? Or set a secure default? username: 'admin',
isAdmin: true, // password: '', // No default password for admin initially? Or set a secure default?
lastNotificationReadTimestamp: undefined, // Initialize as undefined isAdmin: true,
} lastNotificationReadTimestamp: undefined, // Initialize as undefined
] }
}); ]
} as UserData;
};
export const getDefaultHabitsData = (): HabitsData => ({ export function getDefaultHabitsData<HabitsData>(): HabitsData {
habits: [] return { habits: [] } as HabitsData;
}); }
export function getDefaultTasksData<TasksData>(): TasksData {
return { tasks: [] } as TasksData;
};
export const getDefaultCoinsData = (): CoinsData => ({ export function getDefaultCoinsData<CoinsData>(): CoinsData {
balance: 0, return { balance: 0, transactions: [] } as CoinsData;
transactions: [] };
});
export const getDefaultWishlistData = (): WishlistData => ({ export function getDefaultWishlistData<WishlistData>(): WishlistData {
items: [] return { items: [] } as WishlistData;
}); }
export const getDefaultSettings = (): Settings => ({ export function getDefaultSettings<Settings>(): Settings {
ui: { return {
useNumberFormatting: true, ui: {
useGrouping: true, useNumberFormatting: true,
}, useGrouping: true,
system: { },
timezone: Intl.DateTimeFormat().resolvedOptions().timeZone, system: {
weekStartDay: 1, // Monday timezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
autoBackupEnabled: true, // Add this line (default to true) weekStartDay: 1, // Monday
language: 'en', // Default language autoBackupEnabled: true, // Add this line (default to true)
}, language: 'en', // Default language
profile: {} },
}); profile: {}
} as Settings;
};
export const getDefaultServerSettings = (): ServerSettings => ({ export function getDefaultServerSettings<ServerSettings>(): ServerSettings {
isDemo: false return { isDemo: false } as ServerSettings;
}) }
// Map of data types to their default values // Map of data types to their default values
export const DATA_DEFAULTS = { export const DATA_DEFAULTS: { [key: string]: <T>() => T } = {
wishlist: getDefaultWishlistData, wishlist: getDefaultWishlistData,
habits: getDefaultHabitsData, habits: getDefaultHabitsData,
coins: getDefaultCoinsData, coins: getDefaultCoinsData,
@@ -183,8 +188,6 @@ export type CompletionCache = {
} }
} }
export type ViewType = 'habits' | 'tasks'
export interface JotaiHydrateInitialValues { export interface JotaiHydrateInitialValues {
settings: Settings; settings: Settings;
coins: CoinsData; coins: CoinsData;

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 });
@@ -139,7 +166,7 @@ describe('isTaskOverdue', () => {
// Create a task due "tomorrow" in UTC // Create a task due "tomorrow" in UTC
const tomorrow = DateTime.now().plus({ days: 1 }).toUTC().toISO() const tomorrow = DateTime.now().plus({ days: 1 }).toUTC().toISO()
const habit = createTestHabit(tomorrow) const habit = createTestHabit(tomorrow)
// Test in various timezones // Test in various timezones
expect(isTaskOverdue(habit, 'UTC')).toBe(false) expect(isTaskOverdue(habit, 'UTC')).toBe(false)
expect(isTaskOverdue(habit, 'America/New_York')).toBe(false) expect(isTaskOverdue(habit, 'America/New_York')).toBe(false)
@@ -147,32 +174,6 @@ describe('isTaskOverdue', () => {
}) })
}) })
describe('uuid', () => {
test('should generate valid UUIDs', () => {
const id = uuid()
// UUID v4 format: 8-4-4-4-12 hex digits
expect(id).toMatch(/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/)
})
test('should generate unique UUIDs', () => {
const ids = new Set()
for (let i = 0; i < 1000; i++) {
ids.add(uuid())
}
// All 1000 UUIDs should be unique
expect(ids.size).toBe(1000)
})
test('should generate v4 UUIDs', () => {
const id = uuid()
// Version 4 UUID has specific bits set:
// - 13th character is '4'
// - 17th character is '8', '9', 'a', or 'b'
expect(id.charAt(14)).toBe('4')
expect('89ab').toContain(id.charAt(19))
})
})
describe('datetime utilities', () => { describe('datetime utilities', () => {
let fixedNow: DateTime; let fixedNow: DateTime;
let currentDateIndex = 0; let currentDateIndex = 0;
@@ -290,13 +291,6 @@ describe('getNow', () => {
}) })
}) })
describe('getNowInMilliseconds', () => {
test('should return current time in milliseconds', () => {
const now = DateTime.now().setZone('UTC')
expect(getNowInMilliseconds()).toBe(now.toMillis().toString())
})
})
describe('timestamp conversion utilities', () => { describe('timestamp conversion utilities', () => {
const testTimestamp = '2024-01-01T00:00:00.000Z'; const testTimestamp = '2024-01-01T00:00:00.000Z';
const testDateTime = DateTime.fromISO(testTimestamp); const testDateTime = DateTime.fromISO(testTimestamp);
@@ -320,16 +314,6 @@ describe('timestamp conversion utilities', () => {
const customFormat = d2s({ dateTime: testDateTime, format: 'yyyy-MM-dd', timezone: 'utc' }); const customFormat = d2s({ dateTime: testDateTime, format: 'yyyy-MM-dd', timezone: 'utc' });
expect(customFormat).toBe('2024-01-01') expect(customFormat).toBe('2024-01-01')
}) })
test('d2sDate should format DateTime as date string', () => {
const result = d2sDate({ dateTime: testDateTime });
expect(result).toBeString()
})
test('d2n should convert DateTime to milliseconds string', () => {
const result = d2n({ dateTime: testDateTime });
expect(result).toBe('1704067200000')
})
}) })
describe('isSameDate', () => { describe('isSameDate', () => {
@@ -594,7 +578,7 @@ describe('isHabitDueToday', () => {
test('should return false for invalid recurrence rule', () => { test('should return false for invalid recurrence rule', () => {
const habit = testHabit('INVALID_RRULE') const habit = testHabit('INVALID_RRULE')
const consoleSpy = spyOn(console, 'error').mockImplementation(() => {}) const consoleSpy = spyOn(console, 'error').mockImplementation(() => { })
expect(isHabitDueToday({ habit, timezone: 'UTC' })).toBe(false) expect(isHabitDueToday({ habit, timezone: 'UTC' })).toBe(false)
}) })
}) })
@@ -707,7 +691,7 @@ describe('isHabitDue', () => {
test('should return false for invalid recurrence rule', () => { test('should return false for invalid recurrence rule', () => {
const habit = testHabit('INVALID_RRULE') const habit = testHabit('INVALID_RRULE')
const date = DateTime.fromISO('2024-01-01T00:00:00Z') const date = DateTime.fromISO('2024-01-01T00:00:00Z')
const consoleSpy = spyOn(console, 'error').mockImplementation(() => {}) const consoleSpy = spyOn(console, 'error').mockImplementation(() => { })
expect(isHabitDue({ habit, timezone: 'UTC', date })).toBe(false) expect(isHabitDue({ habit, timezone: 'UTC', date })).toBe(false)
}) })
}) })
@@ -956,3 +940,96 @@ describe('convertMachineReadableFrequencyToHumanReadable', () => {
expect(humanReadable).toBe('invalid') expect(humanReadable).toBe('invalid')
}) })
}) })
describe('freshness utilities', () => {
const mockSettings: Settings = getDefaultSettings<Settings>();
const mockHabits: HabitsData = getDefaultHabitsData<HabitsData>();
const mockCoins: CoinsData = getDefaultCoinsData<CoinsData>();
const mockWishlist: WishlistData = getDefaultWishlistData<WishlistData>();
const mockUsers: UserData = getDefaultUsersData<UserData>();
// Add a user to mockUsers for more realistic testing
mockUsers.users.push({
id: 'user-123',
username: 'testuser',
isAdmin: false,
});
mockHabits.habits.push({
id: 'habit-123',
name: 'Test Habit',
description: 'A habit for testing',
frequency: 'FREQ=DAILY',
coinReward: 10,
completions: [],
userIds: ['user-123']
});
describe('prepareDataForHashing', () => {
test('should produce a consistent string for the same data', () => {
const data1 = { settings: mockSettings, habits: mockHabits, coins: mockCoins, wishlist: mockWishlist, users: mockUsers };
const data2 = { settings: mockSettings, habits: mockHabits, coins: mockCoins, wishlist: mockWishlist, users: mockUsers }; // Identical data
const string1 = prepareDataForHashing(data1.settings, data1.habits, data1.coins, data1.wishlist, data1.users);
const string2 = prepareDataForHashing(data2.settings, data2.habits, data2.coins, data2.wishlist, data2.users);
expect(string1).toBe(string2);
});
test('should produce a different string if settings data changes', () => {
const string1 = prepareDataForHashing(mockSettings, mockHabits, mockCoins, mockWishlist, mockUsers);
const modifiedSettings = { ...mockSettings, system: { ...mockSettings.system, timezone: 'America/Chicago' } };
const string2 = prepareDataForHashing(modifiedSettings, mockHabits, mockCoins, mockWishlist, mockUsers);
expect(string1).not.toBe(string2);
});
test('should produce a different string if habits data changes', () => {
const string1 = prepareDataForHashing(mockSettings, mockHabits, mockCoins, mockWishlist, mockUsers);
const modifiedHabits = { ...mockHabits, habits: [...mockHabits.habits, { id: 'new-habit', name: 'New', description: '', frequency: 'FREQ=DAILY', coinReward: 5, completions: [] }] };
const string2 = prepareDataForHashing(mockSettings, modifiedHabits, mockCoins, mockWishlist, mockUsers);
expect(string1).not.toBe(string2);
});
test('should handle empty data consistently', () => {
const emptySettings = getDefaultSettings<Settings>();
const emptyHabits = getDefaultHabitsData<HabitsData>();
const emptyCoins = getDefaultCoinsData<CoinsData>();
const emptyWishlist = getDefaultWishlistData<WishlistData>();
const emptyUsers = getDefaultUsersData<UserData>();
const string1 = prepareDataForHashing(emptySettings, emptyHabits, emptyCoins, emptyWishlist, emptyUsers);
const string2 = prepareDataForHashing(emptySettings, emptyHabits, emptyCoins, emptyWishlist, emptyUsers);
expect(string1).toBe(string2);
expect(string1).toBeDefined();
});
});
describe('generateCryptoHash', () => {
test('should generate a SHA-256 hex string', async () => {
const dataString = 'test string';
const hash = await generateCryptoHash(dataString);
expect(hash).toMatch(/^[a-f0-9]{64}$/); // SHA-256 hex is 64 chars
});
test('should generate different hashes for different strings', async () => {
const hash1 = await generateCryptoHash('test string 1');
const hash2 = await generateCryptoHash('test string 2');
expect(hash1).not.toBe(hash2);
});
test('should generate the same hash for the same string', async () => {
const hash1 = await generateCryptoHash('consistent string');
const hash2 = await generateCryptoHash('consistent string');
expect(hash1).toBe(hash2);
});
// Test with a known SHA-256 value if possible, or ensure crypto.subtle.digest is available
// For "hello world", SHA-256 is "b94d27b9934d3e08a52e52d7da7dabfac484efe37a5380ee9088f7ace2efcde9"
test('should generate correct hash for a known string', async () => {
const knownString = "hello world";
const expectedHash = "b94d27b9934d3e08a52e52d7da7dabfac484efe37a5380ee9088f7ace2efcde9";
const actualHash = await generateCryptoHash(knownString);
expect(actualHash).toBe(expectedHash);
});
});
})

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, User } from '@/lib/types'
import { DUE_MAP, INITIAL_DUE, RECURRENCE_RULE_MAP } from "./constants"
import * as chrono from 'chrono-node' import * as chrono from 'chrono-node'
import _ from "lodash" import { clsx, type ClassValue } from "clsx"
import { v4 as uuidv4 } from 'uuid' import { DateTime, DateTimeFormatOptions } from "luxon"
import { Formats } from "next-intl"
import { datetime, RRule } from 'rrule'
import { twMerge } from "tailwind-merge"
import { DUE_MAP, INITIAL_DUE, RECURRENCE_RULE_MAP } from "./constants"
export function cn(...inputs: ClassValue[]) { export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs)) return twMerge(clsx(inputs))
@@ -18,6 +18,11 @@ export function getTodayInTimezone(timezone: string): string {
return getISODate({ dateTime: now, timezone }); return getISODate({ dateTime: now, timezone });
} }
// round a number to the nearest integer
export function roundToInteger(value: number): number {
return Math.round(value);
}
export function getISODate({ dateTime, timezone }: { dateTime: DateTime, timezone: string }): string { export function getISODate({ dateTime, timezone }: { dateTime: DateTime, timezone: string }): string {
return dateTime.setZone(timezone).toISODate()!; return dateTime.setZone(timezone).toISODate()!;
} }
@@ -27,12 +32,6 @@ export function getNow({ timezone = 'utc', keepLocalTime }: { timezone?: string,
return DateTime.now().setZone(timezone, { keepLocalTime }); return DateTime.now().setZone(timezone, { keepLocalTime });
} }
// get current time in epoch milliseconds
export function getNowInMilliseconds() {
const now = getNow({});
return d2n({ dateTime: now });
}
// iso timestamp to datetime object, most for storage read // iso timestamp to datetime object, most for storage read
export function t2d({ timestamp, timezone }: { timestamp: string; timezone: string }) { export function t2d({ timestamp, timezone }: { timestamp: string; timezone: string }) {
return DateTime.fromISO(timestamp).setZone(timezone); return DateTime.fromISO(timestamp).setZone(timezone);
@@ -55,30 +54,11 @@ export function d2s({ dateTime, format, timezone }: { dateTime: DateTime, format
return dateTime.setZone(timezone).toLocaleString(DateTime.DATETIME_MED); return dateTime.setZone(timezone).toLocaleString(DateTime.DATETIME_MED);
} }
// convert datetime object to date string, mostly for display
export function d2sDate({ dateTime }: { dateTime: DateTime }) {
return dateTime.toLocaleString(DateTime.DATE_MED);
}
// convert datetime object to epoch milliseconds string, mostly for storage write
export function d2n({ dateTime }: { dateTime: DateTime }) {
return dateTime.toMillis().toString();
}
// compare the date portion of two datetime objects (i.e. same year, month, day) // compare the date portion of two datetime objects (i.e. same year, month, day)
export function isSameDate(a: DateTime, b: DateTime) { export function isSameDate(a: DateTime, b: DateTime) {
return a.hasSame(b, 'day'); return a.hasSame(b, 'day');
} }
export function normalizeCompletionDate(date: string, timezone: string): string {
// If already in ISO format, return as is
if (date.includes('T')) {
return date;
}
// Convert from yyyy-MM-dd to ISO format
return DateTime.fromFormat(date, 'yyyy-MM-dd', { zone: timezone }).toUTC().toISO()!;
}
export function getCompletionsForDate({ export function getCompletionsForDate({
habit, habit,
date, date,
@@ -432,22 +412,20 @@ export const openWindow = (url: string): boolean => {
return true return true
} }
export function deepMerge<T>(a: T, b: T) { export function hasPermission(
return _.merge(a, b, (x: unknown, y: unknown) => { user: User | undefined,
if (_.isArray(a)) {
return a.concat(b)
}
})
}
export function checkPermission(
permissions: Permission[] | undefined,
resource: 'habit' | 'wishlist' | 'coins', resource: 'habit' | 'wishlist' | 'coins',
action: 'write' | 'interact' action: 'write' | 'interact'
): boolean { ): boolean {
if (!permissions) return false if (!user || !user.permissions) {
return false;
return permissions.some(permission => { }
// If user is admin, they have all permissions.
if (user.isAdmin) {
return true;
}
// Otherwise, check specific permissions.
return user.permissions.some(permission => {
switch (resource) { switch (resource) {
case 'habit': case 'habit':
return permission.habit[action] return permission.habit[action]
@@ -461,23 +439,73 @@ export function checkPermission(
}) })
} }
export function uuid() { /**
return uuidv4() * Prepares a consistent string representation of the data for hashing.
* It combines all relevant data pieces into a single object and then stringifies it stably.
*/
export function prepareDataForHashing(
settings: Settings,
habits: HabitsData,
coins: CoinsData,
wishlist: WishlistData,
users: UserData
): string {
return JSON.stringify({
settings,
habits,
coins,
wishlist,
users,
});
} }
export function hasPermission( /**
currentUser: User | undefined, * Generates a SHA-256 hash for a given string using the Web Crypto API.
resource: 'habit' | 'wishlist' | 'coins', * This function is suitable for both client-side and server-side (Node.js 19+) environments.
action: 'write' | 'interact' * @param dataString The string to hash.
): boolean { * @returns A promise that resolves to the hex string of the hash.
// If no current user, no permissions. */
if (!currentUser) { export async function generateCryptoHash(dataString: string): Promise<string | null> {
return false; try {
const encoder = new TextEncoder();
const data = encoder.encode(dataString);
// globalThis.crypto should be available in modern browsers and Node.js (v19+)
// For Node.js v15-v18, you might need: const { subtle } = require('node:crypto').webcrypto;
const hashBuffer = await globalThis.crypto.subtle.digest('SHA-256', data);
const hashArray = Array.from(new Uint8Array(hashBuffer));
// Convert buffer to hex string
const hashHex = hashArray.map(b => b.toString(16).padStart(2, '0')).join('');
return hashHex;
} catch (error) {
console.error(`Failed to generate hash: ${error}`);
return null;
} }
// If user is admin, they have all permissions.
if (currentUser.isAdmin) {
return true;
}
// Otherwise, check specific permissions.
return checkPermission(currentUser.permissions, resource, action);
} }
export function handlePermissionCheck(
user: User | SafeUser | undefined,
resource: 'habit' | 'wishlist' | 'coins',
action: 'write' | 'interact',
tCommon: (key: string, values?: Record<string, string | number | Date> | undefined, formats?: Formats | undefined) => string
): boolean {
if (!user) {
toast({
title: tCommon("authenticationRequiredTitle"),
description: tCommon("authenticationRequiredDescription"),
variant: "destructive",
})
return false
}
if (!hasPermission(user, resource, action)) {
toast({
title: tCommon("permissionDeniedTitle"),
description: tCommon("permissionDeniedDescription", { action, resource }),
variant: "destructive",
})
return false
}
return true
}

112
package-lock.json generated
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.20", "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