Compare commits

...

26 Commits

Author SHA1 Message Date
1b17d6b50a fix: add TS types 2025-08-17 19:49:11 +02:00
8269f3adad fix: refactored code & removed unused parts 2025-08-09 18:57:04 +02:00
4cadf4cea7 fix: added upstream differences to README 2025-07-10 00:45:45 +02:00
06e802f2f5 fix: resolved mobile display errors 2025-06-17 23:45:55 +02:00
6c0b196de2 fix: only display 'show all' if there are more than 4 entries 2025-06-17 23:20:39 +02:00
0f073760ee fix: unify NavDisplays 2025-06-17 22:30:18 +02:00
55c2e3577d Merge Tag v0.2.23 2025-06-13 21:59:16 +02:00
043201217f Merge Tag v0.2.22 2025-06-13 21:57:27 +02:00
4e11f17729 Merge Tag v0.2.21 2025-06-13 21:52:24 +02:00
faa6f4cb76 Merge Tag v0.2.20 2025-06-13 21:43:44 +02:00
84d6321153 Merge Tag v0.2.19 2025-06-13 21:26:19 +02:00
1af98fb233 Merge Tag v0.2.18 2025-06-13 21:22:11 +02:00
Doh
8d2bfaf62c update PWA icon, fix floating number balance (#159) 2025-06-04 18:40:48 -04:00
9046d40a7a Merge Tag v0.2.17.0 2025-06-04 16:08:11 +02:00
be0a5c48b3 Merge Tag v0.2.16.0 2025-06-04 16:02:37 +02:00
Doh
98b5d5eebb Added logo to README 2025-05-31 10:53:04 -04:00
Doh
276e8a8a7b refresh stale data (#156) 2025-05-30 18:04:03 -04:00
dohsimpson
1967d154ed bump version 2025-05-29 08:58:46 -04:00
Doh
9e0ae1e0da Fix emojipicker (#152) 2025-05-29 08:46:08 -04:00
dohsimpson
5ae659469b fix infinite render 2025-05-28 17:43:28 -04:00
dohsimpson
6ef4aacfb8 fix coin balance 2025-05-28 17:17:13 -04:00
dohsimpson
95203426a3 fix modal and invalid frequency 2025-05-27 02:42:13 -04:00
Doh
b673d54ede Added improved loading screen (#148) 2025-05-26 08:42:00 -04:00
Doh
42c8d14d6d fix emoji picker and about modal (#146) 2025-05-25 20:33:08 -04:00
Doh
3ac311c3fd add cover image in README 2025-05-25 20:27:46 -04:00
Doh
1a286a99f4 feat: Move delete account button to user edit modal (#144)
Co-authored-by: google-labs-jules[bot] <161369871+google-labs-jules[bot]@users.noreply.github.com>
2025-05-25 17:30:41 -04:00
52 changed files with 1702 additions and 1164 deletions

2
.gitignore vendored
View File

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

View File

@@ -1,5 +1,69 @@
# Changelog # Changelog
## Version 0.2.23
### Fixed
* floating number coin balance (#155)
* disable freshness check if browser does not support web crypto (#161)
### Improved
* use transparent background PWA icon with correct text (#103)
* display icon in logo
## Version 0.2.22
### Added
* auto check data freshness on interval (#138)
* warn about out-of-sync data
## Version 0.2.21
### Fixed
* emoji picker overlay issue (#150)
## Version 0.2.20
### Fixed
* coin balance shows correct value for selected user in coin management view (#151)
### Improved
* refactor code to remove client-helpers hook
## Version 0.2.19
### Fixed
* settings button not working
* fixed delete dialog modal blocks page interaction (#149)
* disable submit button when frequency is invaid
## Version 0.2.18
### Improved
* nicer loading UI (#147)
* header and navigation code refactor
## Version 0.2.17
### Fixed
* fix emoji selector (#142)
* fix about modal (#145)
## Version 0.2.16
### Improved
* move delete user button to user form
* disable deleting user on demo instance
## Version 0.2.15 ## Version 0.2.15
### Improved ### Improved

View File

@@ -1,12 +1,24 @@
# HabitTrove # <img align="left" width="50" height="50" src="https://github.com/user-attachments/assets/99dcf223-3680-4b3a-8050-d9788f051682" /> HabitTrove
HabitTrove is a gamified habit tracking application that helps you build and maintain positive habits by rewarding you with coins, which you can use to exchange for rewards. HabitTrove is a gamified habit tracking application that helps you build and maintain positive habits by rewarding you with coins, which you can use to exchange for rewards.
> **⚠️ Important:** HabitTrove is currently in beta. Please regularly backup your `data/` directory to prevent any potential data loss. **⚠️ Important:** HabitTrove is currently in beta. Please regularly backup your `data/` directory to prevent any potential data loss.
## Try the Demo ## Differences to Upstream
Want to try HabitTrove before installing? Visit the public [demo instance](https://demo.habittrove.com) to experience all features without any setup required. (do not store personal info. Data on the demo instance is reset daily) I generally try to keep the `main` branch up to date with upstream features, merging tagged versions and mapping them to `<upstream-version>.0`.
In this version I've taken steps to ensure a smoother experience and decreased the chance of the program bricking itself. This doesn't mean that it's completely stable, but I've fixed the most glaring bugs I encountered.
Differences (as of writing) are:
- resolved linting problems so you can actually commit things
- added missing dependency
- refactored adding habit modal to cause less errors
- resolved undefined error
- replaced dockerhub release flow with github
- miscellaneous refactorings
- split habits & tasks page into two different pages
- only display "show all" if there are more than 4 entries
## Features ## Features

View File

@@ -21,32 +21,18 @@ import {
WishlistData, WishlistData,
WishlistItemType WishlistItemType
} from '@/lib/types'; } from '@/lib/types';
import { d2t, getNow, uuid } from '@/lib/utils'; import { d2t, generateCryptoHash, getNow, prepareDataForHashing } from '@/lib/utils';
import { signInSchema } from '@/lib/zod'; import { signInSchema } from '@/lib/zod';
import fs from 'fs/promises'; import fs from 'fs/promises';
import _ from 'lodash'; import _ from 'lodash';
import path from 'path'; import path from 'path';
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;
} }
@@ -90,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
} }
@@ -119,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 {
@@ -137,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 => ({
@@ -160,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)
@@ -182,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,
@@ -198,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>()
} }
} }
@@ -245,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,
@@ -269,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()
@@ -300,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,
@@ -362,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>()
} }
} }
@@ -406,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,
@@ -591,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,30 +1,23 @@
import { JotaiHydrate } from '@/components/jotai-hydrate' import { JotaiHydrate } from '@/components/jotai-hydrate'
import { JotaiProvider } from '@/components/jotai-providers' import { JotaiProvider } from '@/components/jotai-providers'
import Layout from '@/components/Layout' import Layout from '@/components/Layout'
import LoadingSpinner from '@/components/LoadingSpinner'
import { ThemeProvider } from "@/components/theme-provider" import { ThemeProvider } from "@/components/theme-provider"
import { Toaster } from '@/components/ui/toaster' import { Toaster } from '@/components/ui/toaster'
import { SessionProvider } from 'next-auth/react' import { SessionProvider } from 'next-auth/react'
import { NextIntlClientProvider } from 'next-intl'
import { getLocale, getMessages } from 'next-intl/server'
import { DM_Sans } from 'next/font/google' import { DM_Sans } from 'next/font/google'
import { Suspense } from 'react' import { Suspense } from 'react'
import { loadCoinsData, loadHabitsData, loadServerSettings, loadSettings, loadUsersData, loadWishlistData } from './actions/data' import { loadCoinsData, loadHabitsData, loadServerSettings, loadSettings, loadUsersData, loadWishlistData } from './actions/data'
import './globals.css' import './globals.css'
import { NextIntlClientProvider } from 'next-intl';
import { getLocale, getMessages } from 'next-intl/server';
// Inter (clean, modern, excellent readability)
// const inter = Inter({
// subsets: ['latin'],
// weight: ['400', '500', '600', '700']
// })
// Clean and contemporary // Clean and contemporary
const dmSans = DM_Sans({ const activeFont = DM_Sans({
subsets: ['latin'], subsets: ['latin'],
weight: ['400', '500', '600', '700'] weight: ['400', '500', '600', '700']
}) })
const activeFont = dmSans
export const metadata = { export const metadata = {
title: 'HabitTrove', title: 'HabitTrove',
description: 'Track your habits and get rewarded', description: 'Track your habits and get rewarded',
@@ -73,7 +66,7 @@ export default async function RootLayout({
}} }}
/> />
<JotaiProvider> <JotaiProvider>
<Suspense fallback="loading"> <Suspense fallback={<LoadingSpinner />}>
<JotaiHydrate <JotaiHydrate
initialValues={{ initialValues={{
settings: initialSettings, settings: initialSettings,

View File

@@ -11,17 +11,16 @@ import ChangelogModal from "./ChangelogModal"
import { useState } from "react" import { useState } from "react"
interface AboutModalProps { interface AboutModalProps {
isOpen: boolean
onClose: () => void onClose: () => void
} }
export default function AboutModal({ isOpen, onClose }: AboutModalProps) { export default function AboutModal({ onClose }: AboutModalProps) {
const t = useTranslations('AboutModal') const t = useTranslations('AboutModal')
const version = packageJson.version const version = packageJson.version
const [changelogOpen, setChangelogOpen] = useState(false) const [changelogOpen, setChangelogOpen] = useState(false)
return ( return (
<Dialog open={isOpen} onOpenChange={onClose}> <Dialog open={true} onOpenChange={onClose}>
<DialogContent className="max-w-sm"> <DialogContent className="max-w-sm">
<DialogHeader> <DialogHeader>
<DialogTitle aria-label={t('dialogArisLabel')}></DialogTitle> <DialogTitle aria-label={t('dialogArisLabel')}></DialogTitle>

View File

@@ -7,19 +7,19 @@ import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label' import { Label } from '@/components/ui/label'
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover' import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
import { Textarea } from '@/components/ui/textarea' import { Textarea } from '@/components/ui/textarea'
import { settingsAtom, usersAtom } from '@/lib/atoms' import { currentUserAtom, settingsAtom, usersAtom } from '@/lib/atoms'
import { useHelpers } from '@/lib/client-helpers'
import { INITIAL_DUE, INITIAL_RECURRENCE_RULE, MAX_COIN_LIMIT, QUICK_DATES } from '@/lib/constants' import { INITIAL_DUE, INITIAL_RECURRENCE_RULE, MAX_COIN_LIMIT, QUICK_DATES } from '@/lib/constants'
import { Habit } from '@/lib/types' import { Habit } from '@/lib/types'
import { convertHumanReadableFrequencyToMachineReadable, convertMachineReadableFrequencyToHumanReadable, d2t, serializeRRule } from '@/lib/utils' import { convertHumanReadableFrequencyToMachineReadable, convertMachineReadableFrequencyToHumanReadable, d2t, serializeRRule } from '@/lib/utils'
import data from '@emoji-mart/data'
import Picker from '@emoji-mart/react'
import { useAtom } from 'jotai' import { useAtom } from 'jotai'
import { SmilePlus, Zap } from 'lucide-react' import { Zap } from 'lucide-react'
import { DateTime } from 'luxon' import { DateTime } from 'luxon'
import { useTranslations } from 'next-intl' import { useTranslations } from 'next-intl'
import { useState } from 'react' import { useState } from 'react'
import { RRule } from 'rrule' import { RRule } from 'rrule'
import EmojiPickerButton from './EmojiPickerButton'
import ModalOverlay from './ModalOverlay'; // Import the new component
interface AddEditHabitModalProps { interface AddEditHabitModalProps {
onClose: () => void onClose: () => void
@@ -43,7 +43,7 @@ export default function AddEditHabitModal({ onClose, onSave, habit, isTask }: Ad
timezone: settings.system.timezone timezone: settings.system.timezone
}) : (isRecurRule ? INITIAL_RECURRENCE_RULE : INITIAL_DUE); }) : (isRecurRule ? INITIAL_RECURRENCE_RULE : INITIAL_DUE);
const [ruleText, setRuleText] = useState<string>(initialRuleText) const [ruleText, setRuleText] = useState<string>(initialRuleText)
const { currentUser } = useHelpers() const [currentUser] = useAtom(currentUserAtom)
const [isQuickDatesOpen, setIsQuickDatesOpen] = useState(false) const [isQuickDatesOpen, setIsQuickDatesOpen] = useState(false)
const [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)
@@ -89,8 +89,10 @@ export default function AddEditHabitModal({ onClose, onSave, habit, isTask }: Ad
const { result, message: errorMessage } = convertHumanReadableFrequencyToMachineReadable({ text: ruleText, timezone: settings.system.timezone, isRecurring: isRecurRule }); const { result, message: errorMessage } = convertHumanReadableFrequencyToMachineReadable({ text: ruleText, timezone: settings.system.timezone, isRecurring: isRecurRule });
return ( return (
<Dialog open={true} onOpenChange={onClose}> <>
<DialogContent> <ModalOverlay />
<Dialog open={true} onOpenChange={onClose} modal={false}>
<DialogContent> {/* DialogContent from shadcn/ui is typically z-50, ModalOverlay is z-40 */}
<DialogHeader> <DialogHeader>
<DialogTitle> <DialogTitle>
{habit {habit
@@ -111,33 +113,15 @@ export default function AddEditHabitModal({ onClose, onSave, habit, isTask }: Ad
onChange={(e) => setName(e.target.value)} onChange={(e) => setName(e.target.value)}
required required
/> />
<Popover> <EmojiPickerButton
<PopoverTrigger asChild> inputIdToFocus="name"
<Button onEmojiSelect={(emoji) => {
type="button"
variant="ghost"
size="icon"
className="h-8 w-8"
>
<SmilePlus className="h-8 w-8" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-[300px] p-0">
<Picker
data={data}
onEmojiSelect={(emoji: { native: string }) => {
setName(prev => { setName(prev => {
// Add space before emoji if there isn't one already
const space = prev.length > 0 && !prev.endsWith(' ') ? ' ' : ''; const space = prev.length > 0 && !prev.endsWith(' ') ? ' ' : '';
return `${prev}${space}${emoji.native}`; return `${prev}${space}${emoji}`;
}) })
// Focus back on input after selection
const input = document.getElementById('name') as HTMLInputElement
input?.focus()
}} }}
/> />
</PopoverContent>
</Popover>
</div> </div>
</div> </div>
<div className="grid grid-cols-4 items-center gap-4"> <div className="grid grid-cols-4 items-center gap-4">
@@ -325,7 +309,7 @@ export default function AddEditHabitModal({ onClose, onSave, habit, isTask }: Ad
)} )}
</div> </div>
<DialogFooter> <DialogFooter>
<Button type="submit"> <Button type="submit" disabled={!!errorMessage}>
{habit {habit
? t('saveChangesButton') ? t('saveChangesButton')
: t(isTask ? 'addTaskButton' : 'addHabitButton')} : t(isTask ? 'addTaskButton' : 'addHabitButton')}
@@ -334,6 +318,6 @@ export default function AddEditHabitModal({ onClose, onSave, habit, isTask }: Ad
</form> </form>
</DialogContent> </DialogContent>
</Dialog> </Dialog>
</>
) )
} }

View File

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

View File

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

View File

@@ -6,8 +6,7 @@ import { Button } from '@/components/ui/button'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Input } from '@/components/ui/input' import { Input } from '@/components/ui/input'
import { useCoins } from '@/hooks/useCoins' import { useCoins } from '@/hooks/useCoins'
import { settingsAtom, usersAtom } from '@/lib/atoms' import { currentUserAtom, settingsAtom, usersAtom } from '@/lib/atoms'
import { useHelpers } from '@/lib/client-helpers'
import { MAX_COIN_LIMIT } from '@/lib/constants' import { MAX_COIN_LIMIT } from '@/lib/constants'
import { TransactionType } from '@/lib/types' import { TransactionType } from '@/lib/types'
import { d2s, t2d } from '@/lib/utils' import { d2s, t2d } from '@/lib/utils'
@@ -22,7 +21,7 @@ import { TransactionNoteEditor } from './TransactionNoteEditor'
export default function CoinsManager() { export default function CoinsManager() {
const t = useTranslations('CoinsManager') const t = useTranslations('CoinsManager')
const { currentUser } = useHelpers() const [currentUser] = useAtom(currentUserAtom)
const [selectedUser, setSelectedUser] = useState<string>() const [selectedUser, setSelectedUser] = useState<string>()
const { const {
add, add,

View File

@@ -27,6 +27,7 @@ import ConfirmDialog from './ConfirmDialog'
import { HabitContextMenuItems } from './HabitContextMenuItems' import { HabitContextMenuItems } from './HabitContextMenuItems'
import Linkify from './linkify' import Linkify from './linkify'
import { Button } from './ui/button' import { Button } from './ui/button'
import { DESKTOP_DISPLAY_ITEM_COUNT } from '@/lib/constants'
interface UpcomingItemsProps { interface UpcomingItemsProps {
habits: Habit[] habits: Habit[]
@@ -165,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 }))
@@ -295,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" />
@@ -305,7 +306,7 @@ 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}
@@ -444,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 (
@@ -501,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" />
@@ -511,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

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

View File

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

View File

@@ -6,12 +6,11 @@ import {
DropdownMenuTrigger DropdownMenuTrigger
} from '@/components/ui/dropdown-menu' } from '@/components/ui/dropdown-menu'
import { useHabits } from '@/hooks/useHabits' import { useHabits } from '@/hooks/useHabits'
import { settingsAtom, usersAtom } from '@/lib/atoms' import { currentUserAtom, settingsAtom, usersAtom } from '@/lib/atoms'
import { useHelpers } from '@/lib/client-helpers'
import { Habit, User } from '@/lib/types' import { Habit, User } from '@/lib/types'
import { convertMachineReadableFrequencyToHumanReadable, getCompletionsForToday, isTaskOverdue } from '@/lib/utils' import { convertMachineReadableFrequencyToHumanReadable, getCompletionsForToday, hasPermission, isTaskOverdue } from '@/lib/utils'
import { useAtom } from 'jotai' import { useAtom } from 'jotai'
import { Check, Coins, Edit, MoreVertical, Pin, Undo2 } from 'lucide-react'; // Removed unused icons import { Check, Coins, Edit, MoreVertical, Pin, Undo2 } from 'lucide-react'
import { useTranslations } from 'next-intl' import { useTranslations } from 'next-intl'
import { usePathname } from 'next/navigation' import { usePathname } from 'next/navigation'
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
@@ -45,7 +44,7 @@ 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 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
@@ -53,10 +52,10 @@ export default function HabitItem({ habit, onEdit, onDelete }: HabitItemProps) {
const [isHighlighted, setIsHighlighted] = useState(false) const [isHighlighted, setIsHighlighted] = useState(false)
const t = useTranslations('HabitItem'); const t = useTranslations('HabitItem');
const [usersData] = useAtom(usersAtom) const [usersData] = useAtom(usersAtom)
const { currentUser, hasPermission } = useHelpers()
const canWrite = hasPermission('habit', 'write')
const canInteract = hasPermission('habit', 'interact')
const pathname = usePathname(); const pathname = usePathname();
const [currentUser] = useAtom(currentUserAtom)
const canWrite = hasPermission(currentUser, 'habit', 'write')
const canInteract = hasPermission(currentUser, 'habit', 'interact')
useEffect(() => { useEffect(() => {
const params = new URLSearchParams(window.location.search) const params = new URLSearchParams(window.location.search)
@@ -107,11 +106,13 @@ export default function HabitItem({ habit, onEdit, onDelete }: HabitItemProps) {
</CardHeader> </CardHeader>
<CardContent className="flex-1"> <CardContent className="flex-1">
<p className={`text-sm ${habit.archived ? 'text-gray-400 dark:text-gray-500' : 'text-gray-500'}`}> <p className={`text-sm ${habit.archived ? 'text-gray-400 dark:text-gray-500' : 'text-gray-500'}`}>
{t('whenLabel', { frequency: convertMachineReadableFrequencyToHumanReadable({ {t('whenLabel', {
frequency: convertMachineReadableFrequencyToHumanReadable({
frequency: habit.frequency, frequency: habit.frequency,
isRecurRule: pathname.includes("habits"), isRecurRule: pathname.includes("habits"),
timezone: settings.system.timezone timezone: settings.system.timezone
})})} })
})}
</p> </p>
<div className="flex items-center mt-2"> <div className="flex items-center mt-2">
<Coins className={`h-4 w-4 mr-1 ${habit.archived ? 'text-gray-400 dark:text-gray-500' : 'text-yellow-400'}`} /> <Coins className={`h-4 w-4 mr-1 ${habit.archived ? 'text-gray-400 dark:text-gray-500' : 'text-yellow-400'}`} />
@@ -184,7 +185,7 @@ export default function HabitItem({ habit, onEdit, onDelete }: HabitItemProps) {
<span className="ml-2">{t('editButton')}</span> <span className="ml-2">{t('editButton')}</span>
</Button> </Button>
)} )}
<DropdownMenu> <DropdownMenu modal={false}>
<DropdownMenuTrigger asChild> <DropdownMenuTrigger asChild>
<Button variant="ghost" size="sm" className="h-8 w-8 p-0"> <Button variant="ghost" size="sm" className="h-8 w-8 p-0">
<MoreVertical className="h-4 w-4" /> <MoreVertical className="h-4 w-4" />

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

61
components/NavDisplay.tsx Normal file
View File

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

View File

@@ -1,104 +1,40 @@
'use client' 'use client'
import { useHelpers } from '@/lib/client-helpers'
import { HabitIcon, TaskIcon } from '@/lib/constants' import { HabitIcon, TaskIcon } from '@/lib/constants'
import { Calendar, Coins, Gift, Home } from 'lucide-react' import { Calendar, Coins, Gift, Home } from 'lucide-react'
import { useTranslations } from 'next-intl' import { useTranslations } from 'next-intl'
import Link from 'next/link' import { ElementType, useEffect, useState } from 'react'
import { usePathname } from 'next/navigation' import NavDisplay from './NavDisplay'
import { useEffect, useState } from 'react'
import AboutModal from './AboutModal'
type ViewPort = 'main' | 'mobile' export interface NavItemType {
icon: ElementType;
interface NavigationProps { label: string;
viewPort: ViewPort href: string;
} }
export default function Navigation({ viewPort }: NavigationProps) { export default function Navigation({ position }: { position: 'main' | 'mobile' }) {
const t = useTranslations('Navigation') const t = useTranslations('Navigation');
const [showAbout, setShowAbout] = useState(false) const [isMobile, setIsMobile] = useState(window.innerWidth < 1024);
const [isMobileView, setIsMobileView] = useState(false)
const { isIOS } = useHelpers()
const pathname = usePathname();
const navItems = () => [
{ icon: Home, label: t('dashboard'), href: '/', position: 'main' },
{ icon: HabitIcon, label: t('habits'), href: '/habits', position: 'main' },
{ icon: TaskIcon, label: t('tasks'), href: '/tasks', position: 'main' },
{ icon: Calendar, label: t('calendar'), href: '/calendar', position: 'main' },
{ icon: Gift, label: t('wishlist'), href: '/wishlist', position: 'main' },
{ icon: Coins, label: t('coins'), href: '/coins', position: 'main' },
]
useEffect(() => { useEffect(() => {
const handleResize = () => { const handleResize = () => {setIsMobile(window.innerWidth < 1024); };
setIsMobileView(window.innerWidth < 1024) window.addEventListener("resize", handleResize);
return () => window.removeEventListener("resize", handleResize);
}, [setIsMobile]);
const currentNavItems: NavItemType[] = [
{ icon: Home, label: t('dashboard'), href: '/' },
{ icon: HabitIcon, label: t('habits'), href: '/habits' },
{ icon: TaskIcon, label: t('tasks'), href: '/tasks' },
{ icon: Calendar, label: t('calendar'), href: '/calendar' },
{ icon: Gift, label: t('wishlist'), href: '/wishlist' },
{ icon: Coins, label: t('coins'), href: '/coins' },
]
if ((position === 'mobile' && isMobile) || (position === 'main' && !isMobile)) {
return <NavDisplay navItems={currentNavItems} isMobile={isMobile} />
} }
else {
// Set initial value return <></>
handleResize()
// Add event listener
window.addEventListener('resize', handleResize)
// Cleanup
return () => window.removeEventListener('resize', handleResize)
}, [])
if (viewPort === 'mobile' && isMobileView) {
return (
<>
<div className={isIOS ? "pb-20" : "pb-16"} /> {/* Add padding at the bottom to prevent content from being hidden */}
<nav className={`lg:hidden fixed bottom-0 left-0 right-0 bg-white dark:bg-gray-800 shadow-lg ${isIOS ? "pb-4" : ""}`}>
<div className="grid grid-cols-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>
<AboutModal isOpen={showAbout} onClose={() => setShowAbout(false)} />
</>
)
}
if (viewPort === 'main' && !isMobileView) {
return (
<div className="hidden lg:flex lg:flex-shrink-0">
<div className="flex flex-col w-64">
<div className="flex flex-col h-0 flex-1 bg-gray-800">
<div className="flex-1 flex flex-col pt-5 pb-4 overflow-y-auto">
<nav className="mt-5 flex-1 px-2 space-y-1">
{navItems().filter(item => item.position === 'main').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>
<AboutModal isOpen={showAbout} onClose={() => setShowAbout(false)} />
</div>
)
} }
} }

View File

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

View File

@@ -5,15 +5,13 @@ 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 { toast } from "@/hooks/use-toast" import { toast } from "@/hooks/use-toast"
import { settingsAtom, userSelectAtom } from "@/lib/atoms" import { aboutOpenAtom, currentUserAtom, settingsAtom, userSelectAtom } from "@/lib/atoms"
import { useHelpers } from "@/lib/client-helpers"
import { useAtom } from "jotai" import { useAtom } from "jotai"
import { ArrowRightLeft, Crown, Info, LogOut, Moon, Palette, Settings, Sun, User } from "lucide-react" import { ArrowRightLeft, Crown, Info, LogOut, Moon, Palette, Settings, Sun, User } from "lucide-react"
import { useTranslations } from 'next-intl' import { useTranslations } from 'next-intl'
import { useTheme } from "next-themes" import { useTheme } from "next-themes"
import Link from "next/link" import Link from "next/link"
import { useState } from "react" import { useState } from "react"
import AboutModal from "./AboutModal"
import { Dialog, DialogContent, DialogHeader, DialogTitle } from './ui/dialog' import { Dialog, DialogContent, DialogHeader, DialogTitle } from './ui/dialog'
import UserForm from './UserForm' import UserForm from './UserForm'
@@ -22,9 +20,9 @@ export function Profile() {
const [settings] = useAtom(settingsAtom) const [settings] = useAtom(settingsAtom)
const [userSelect, setUserSelect] = useAtom(userSelectAtom) const [userSelect, setUserSelect] = useAtom(userSelectAtom)
const [isEditing, setIsEditing] = useState(false) const [isEditing, setIsEditing] = useState(false)
const [showAbout, setShowAbout] = useState(false) const [aboutOpen, setAboutOpen] = useAtom(aboutOpenAtom)
const { theme, setTheme } = useTheme() const { theme, setTheme } = useTheme()
const { currentUser: user } = useHelpers() const [user] = useAtom(currentUserAtom)
const [open, setOpen] = useState(false) const [open, setOpen] = useState(false)
const handleSignOut = async () => { const handleSignOut = async () => {
@@ -67,7 +65,7 @@ export function Profile() {
</AvatarFallback> </AvatarFallback>
</Avatar> </Avatar>
<div className="flex flex-col mr-4"> <div className="flex flex-col mr-4">
<span className="text-sm font-semibold flex items-center gap-1"> <span className="text-sm font-semibold flex items-center gap-1 break-all">
{user?.username || t('guestUsername')} {user?.username || t('guestUsername')}
{user?.isAdmin && <Crown className="h-3 w-3 text-yellow-500" />} {user?.isAdmin && <Crown className="h-3 w-3 text-yellow-500" />}
</span> </span>
@@ -111,27 +109,33 @@ export function Profile() {
</div> </div>
</DropdownMenuItem> </DropdownMenuItem>
<DropdownMenuItem className="cursor-pointer px-2 py-1.5" asChild> <DropdownMenuItem className="cursor-pointer px-2 py-1.5" asChild>
{/* need the Link element to be the direct child of the DropdownMenuItem, since we are using asChild here */}
<Link <Link
href="/settings" href="/settings"
aria-label={t('settingsLink')} aria-label={t('settingsLink')}
className="flex items-center w-full gap-3" className="flex items-center justify-between w-full"
onClick={() => setOpen(false)} // Ensure dropdown closes on click
> >
<div className="flex items-center gap-2">
<Settings className="h-4 w-4" /> <Settings className="h-4 w-4" />
<span>{t('settingsLink')}</span> <span>{t('settingsLink')}</span>
</div>
</Link> </Link>
</DropdownMenuItem> </DropdownMenuItem>
<DropdownMenuItem className="cursor-pointer px-2 py-1.5" asChild> <DropdownMenuItem className="cursor-pointer px-2 py-1.5" onClick={() => {
<button setOpen(false); // Close the dropdown
onClick={() => setShowAbout(true)} setAboutOpen(true); // Open the about modal
className="flex items-center w-full gap-3" }}>
> <div className="flex items-center justify-between w-full">
<div className="flex items-center gap-2">
<Info className="h-4 w-4" /> <Info className="h-4 w-4" />
<span>{t('aboutButton')}</span> <span>{t('aboutButton')}</span>
</button> </div>
</div>
</DropdownMenuItem> </DropdownMenuItem>
<DropdownMenuItem className="cursor-pointer px-2 py-1.5"> <DropdownMenuItem className="cursor-pointer px-2 py-1.5">
<div className="flex items-center justify-between w-full gap-3"> <div className="flex items-center justify-between w-full">
<div className="flex items-center gap-3"> <div className="flex items-center gap-2">
<Palette className="h-4 w-4" /> <Palette className="h-4 w-4" />
<span>{t('themeLabel')}</span> <span>{t('themeLabel')}</span>
</div> </div>
@@ -169,8 +173,6 @@ export function Profile() {
</DropdownMenuContent> </DropdownMenuContent>
</DropdownMenu> </DropdownMenu>
<AboutModal isOpen={showAbout} onClose={() => setShowAbout(false)} />
{/* Add the UserForm dialog */} {/* Add the UserForm dialog */}
{isEditing && user && ( {isEditing && user && (
<Dialog open={isEditing} onOpenChange={() => setIsEditing(false)}> <Dialog open={isEditing} onOpenChange={() => setIsEditing(false)}>

View File

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

View File

@@ -1,9 +1,19 @@
'use client'; 'use client';
import { createUser, updateUser, updateUserPassword, uploadAvatar } from '@/app/actions/data'; import { createUser, updateUser, updateUserPassword, uploadAvatar } from '@/app/actions/data';
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from "@/components/ui/alert-dialog";
import { toast } from '@/hooks/use-toast'; import { toast } from '@/hooks/use-toast';
import { serverSettingsAtom, usersAtom } from '@/lib/atoms'; import { currentUserAtom, serverSettingsAtom, usersAtom } from '@/lib/atoms';
import { useHelpers } from '@/lib/client-helpers';
import { Permission } from '@/lib/types'; import { Permission } from '@/lib/types';
import { passwordSchema, usernameSchema } from '@/lib/zod'; import { passwordSchema, usernameSchema } from '@/lib/zod';
import { useAtom, useAtomValue } from 'jotai'; import { useAtom, useAtomValue } from 'jotai';
@@ -18,6 +28,7 @@ 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';
interface UserFormProps { interface UserFormProps {
userId?: string; // if provided, we're editing; if not, we're creating userId?: string; // if provided, we're editing; if not, we're creating
onCancel: () => void; onCancel: () => void;
@@ -29,7 +40,7 @@ export default function UserForm({ userId, onCancel, onSuccess }: UserFormProps)
const [users, setUsersData] = useAtom(usersAtom); const [users, setUsersData] = useAtom(usersAtom);
const serverSettings = useAtomValue(serverSettingsAtom) const serverSettings = useAtomValue(serverSettingsAtom)
const user = userId ? users.users.find(u => u.id === userId) : undefined; const user = userId ? users.users.find(u => u.id === userId) : undefined;
const { currentUser } = useHelpers() const [currentUser] = useAtom(currentUserAtom)
const getDefaultPermissions = (): Permission[] => [{ const getDefaultPermissions = (): Permission[] => [{
habit: { habit: {
write: true, write: true,
@@ -57,6 +68,69 @@ export default function UserForm({ userId, onCancel, onSuccess }: UserFormProps)
); );
const isEditing = !!user; const isEditing = !!user;
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
const [isDeleting, setIsDeleting] = useState(false);
const handleDeleteUser = async () => {
if (!user) return;
if (serverSettings.isDemo) {
toast({
title: t('errorTitle'),
description: t('toastDemoDeleteDisabled'),
variant: 'destructive',
});
return;
}
if (currentUser && currentUser.id === user.id) {
toast({
title: t('errorTitle'),
description: t('toastCannotDeleteSelf'),
variant: 'destructive',
});
return;
}
setIsDeleting(true);
try {
const response = await fetch('/api/user/delete', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ userId: user.id }),
});
if (response.ok) {
setUsersData(prev => ({
...prev,
users: prev.users.filter(u => u.id !== user.id),
}));
toast({
title: t('toastUserDeletedTitle'),
description: t('toastUserDeletedDescription', { username: user.username }),
variant: 'default'
});
onSuccess();
} else {
const errorData = await response.json();
toast({
title: t('errorTitle'),
description: errorData.error || t('genericError'),
variant: 'destructive',
});
}
} catch (error) {
toast({
title: t('errorTitle'),
description: t('networkError'),
variant: 'destructive',
});
} finally {
setIsDeleting(false);
setShowDeleteConfirm(false);
}
};
const handleSubmit = async (e: React.FormEvent) => { const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault(); e.preventDefault();
@@ -274,6 +348,38 @@ export default function UserForm({ userId, onCancel, onSuccess }: UserFormProps)
</div> </div>
<div className="flex justify-end gap-2 pt-2"> <div className="flex justify-end gap-2 pt-2">
{isEditing && (
<AlertDialog open={showDeleteConfirm} onOpenChange={setShowDeleteConfirm}>
<AlertDialogTrigger asChild>
<Button
type="button"
variant="destructive"
className="mr-auto"
disabled={serverSettings.isDemo || isDeleting}
>
{isDeleting ? t('deletingButtonText') : t('deleteAccountButton')}
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>{t('areYouSure')}</AlertDialogTitle>
<AlertDialogDescription>
{t('deleteUserConfirmation', { username: user.username })}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={isDeleting}>{t('cancel')}</AlertDialogCancel>
<AlertDialogAction
onClick={handleDeleteUser}
disabled={isDeleting}
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
>
{isDeleting ? t('deletingButtonText') : t('confirmDeleteButtonText')}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
)}
<Button <Button
type="button" type="button"
variant="outline" variant="outline"

View File

@@ -1,25 +1,13 @@
'use client'; 'use client';
import { signIn } from '@/app/actions/user'; import { signIn } from '@/app/actions/user';
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from "@/components/ui/alert-dialog";
import { toast } from '@/hooks/use-toast'; import { toast } from '@/hooks/use-toast';
import { usersAtom } from '@/lib/atoms'; import { currentUserAtom, usersAtom } from '@/lib/atoms';
import { useHelpers } from '@/lib/client-helpers';
import { SafeUser, User } from '@/lib/types'; import { SafeUser, User } from '@/lib/types';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { Description } from '@radix-ui/react-dialog'; import { Description } from '@radix-ui/react-dialog';
import { useAtom } from 'jotai'; import { useAtom } from 'jotai';
import { Crown, Plus, Trash2, User as UserIcon, UserRoundPen } from 'lucide-react'; import { Crown, Plus, User as UserIcon, UserRoundPen } from 'lucide-react';
import { useTranslations } from 'next-intl'; import { useTranslations } from 'next-intl';
import { useState } from 'react'; import { useState } from 'react';
import PasswordEntryForm from './PasswordEntryForm'; import PasswordEntryForm from './PasswordEntryForm';
@@ -33,62 +21,19 @@ function UserCard({
onEdit, onEdit,
showEdit, showEdit,
isCurrentUser, isCurrentUser,
currentLoggedInUserId, // For "don't delete self" check
onUserDeleted // Callback to update usersAtom
}: { }: {
user: User, user: User,
onSelect: () => void, onSelect: () => void,
onEdit: () => void, onEdit: () => void,
showEdit: boolean, showEdit: boolean,
isCurrentUser: boolean, isCurrentUser: boolean,
currentLoggedInUserId?: string,
onUserDeleted: (userId: string) => void,
}) { }) {
const t = useTranslations('UserSelectModal'); const t = useTranslations('UserSelectModal');
const tWarning = useTranslations('Warning');
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
const [isDeleting, setIsDeleting] = useState(false);
const handleDeleteUser = async () => {
setIsDeleting(true);
try {
const response = await fetch('/api/user/delete', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ userId: user.id }),
});
if (response.ok) {
toast({
title: t('deleteUserSuccessTitle'),
description: t('deleteUserSuccessDescription', { username: user.username }),
});
onUserDeleted(user.id);
} else {
const errorData = await response.json();
toast({
title: t('deleteUserErrorTitle'),
description: errorData.error || t('genericError'),
variant: 'destructive',
});
}
} catch (error) {
toast({
title: t('deleteUserErrorTitle'),
description: t('networkError'),
variant: 'destructive',
});
} finally {
setIsDeleting(false);
setShowDeleteConfirm(false);
}
};
return ( return (
<div key={user.id} className="relative group"> <div key={user.id} className="relative group">
<button <button
onClick={onSelect} onClick={onSelect}
disabled={isDeleting} // Disable main button while deleting this user
className={cn( className={cn(
"flex flex-col items-center gap-2 p-2 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors w-full", "flex flex-col items-center gap-2 p-2 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors w-full",
isCurrentUser && "ring-2 ring-primary" isCurrentUser && "ring-2 ring-primary"
@@ -116,48 +61,12 @@ function UserCard({
e.stopPropagation(); // Prevent card selection e.stopPropagation(); // Prevent card selection
onEdit(); onEdit();
}} }}
disabled={isDeleting}
className="p-1 rounded-full bg-gray-200 hover:bg-gray-300 dark:bg-gray-700 dark:hover:bg-gray-600 transition-colors" className="p-1 rounded-full bg-gray-200 hover:bg-gray-300 dark:bg-gray-700 dark:hover:bg-gray-600 transition-colors"
title={t('editUserTooltip')} title={t('editUserTooltip')}
> >
<UserRoundPen className="h-4 w-4" /> <UserRoundPen className="h-4 w-4" />
</button> </button>
)} )}
{showEdit && user.id !== currentLoggedInUserId && (
<AlertDialog open={showDeleteConfirm} onOpenChange={setShowDeleteConfirm}>
<AlertDialogTrigger asChild>
<button
onClick={(e) => {
e.stopPropagation(); // Prevent card selection
setShowDeleteConfirm(true);
}}
disabled={isDeleting}
className="p-1 rounded-full bg-red-200 hover:bg-red-300 dark:bg-red-700 dark:hover:bg-red-600 transition-colors text-red-600 dark:text-red-300"
title={t('deleteUserTooltip')}
>
<Trash2 className="h-4 w-4" />
</button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>{tWarning('areYouSure')}</AlertDialogTitle>
<AlertDialogDescription>
{t('deleteUserConfirmation', { username: user.username })}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel onClick={(e) => { e.stopPropagation(); setShowDeleteConfirm(false);}} disabled={isDeleting}>{tWarning('cancel')}</AlertDialogCancel>
<AlertDialogAction
onClick={(e) => { e.stopPropagation(); handleDeleteUser();}}
disabled={isDeleting}
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
>
{isDeleting ? t('deletingButtonText') : t('confirmDeleteButtonText')}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
)}
</div> </div>
)} )}
</div> </div>
@@ -187,14 +96,12 @@ function UserSelectionView({
onUserSelect, onUserSelect,
onEditUser, onEditUser,
onCreateUser, onCreateUser,
onUserDeleted, // Pass through the delete handler
}: { }: {
users: User[], users: User[],
currentUserFromHook?: SafeUser, currentUserFromHook?: SafeUser,
onUserSelect: (userId: string) => void, onUserSelect: (userId: string) => void,
onEditUser: (userId: string) => void, onEditUser: (userId: string) => void,
onCreateUser: () => void, onCreateUser: () => void,
onUserDeleted: (userId: string) => void,
}) { }) {
return ( return (
<div className="grid grid-cols-3 gap-4 p-2 max-h-80 overflow-y-auto"> <div className="grid grid-cols-3 gap-4 p-2 max-h-80 overflow-y-auto">
@@ -208,8 +115,6 @@ function UserSelectionView({
onEdit={() => onEditUser(user.id)} onEdit={() => onEditUser(user.id)}
showEdit={!!currentUserFromHook?.isAdmin} showEdit={!!currentUserFromHook?.isAdmin}
isCurrentUser={false} // This card isn't the currently logged-in user for switching TO isCurrentUser={false} // This card isn't the currently logged-in user for switching TO
currentLoggedInUserId={currentUserFromHook?.id} // For the "don't delete self" check
onUserDeleted={onUserDeleted}
/> />
))} ))}
{currentUserFromHook?.isAdmin && <AddUserButton onClick={onCreateUser} />} {currentUserFromHook?.isAdmin && <AddUserButton onClick={onCreateUser} />}
@@ -225,14 +130,8 @@ export default function UserSelectModal({ onClose }: { onClose: () => void }) {
const [error, setError] = useState(''); const [error, setError] = useState('');
const [usersData, setUsersData] = useAtom(usersAtom); const [usersData, setUsersData] = useAtom(usersAtom);
const users = usersData.users; const users = usersData.users;
const { currentUser } = useHelpers(); const [currentUser] = useAtom(currentUserAtom);
const handleUserDeleted = (userIdToDelete: string) => {
setUsersData(prevData => ({
...prevData,
users: prevData.users.filter(u => u.id !== userIdToDelete)
}));
};
const handleUserSelect = (userId: string) => { const handleUserSelect = (userId: string) => {
setSelectedUser(userId); setSelectedUser(userId);
@@ -278,7 +177,6 @@ export default function UserSelectModal({ onClose }: { onClose: () => void }) {
onUserSelect={handleUserSelect} onUserSelect={handleUserSelect}
onEditUser={handleEditUser} onEditUser={handleEditUser}
onCreateUser={handleCreateUser} onCreateUser={handleCreateUser}
onUserDeleted={handleUserDeleted}
/> />
) : isCreating || isEditing ? ( ) : isCreating || isEditing ? (
<UserForm <UserForm

View File

@@ -7,9 +7,9 @@ import {
DropdownMenuSeparator, DropdownMenuSeparator,
DropdownMenuTrigger, DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu' } from '@/components/ui/dropdown-menu'
import { usersAtom } from '@/lib/atoms' import { currentUserAtom, usersAtom } from '@/lib/atoms'
import { useHelpers } from '@/lib/client-helpers'
import { User, WishlistItemType } from '@/lib/types' import { User, WishlistItemType } from '@/lib/types'
import { hasPermission } from '@/lib/utils'
import { useAtom } from 'jotai' import { useAtom } from 'jotai'
import { Archive, ArchiveRestore, Coins, Edit, Gift, MoreVertical, Trash2 } from 'lucide-react' import { Archive, ArchiveRestore, Coins, Edit, Gift, MoreVertical, Trash2 } from 'lucide-react'
import { useTranslations } from 'next-intl' import { useTranslations } from 'next-intl'
@@ -59,9 +59,9 @@ export default function WishlistItem({
isRecentlyRedeemed isRecentlyRedeemed
}: WishlistItemProps) { }: WishlistItemProps) {
const t = useTranslations('WishlistItem') const t = useTranslations('WishlistItem')
const { currentUser, hasPermission } = useHelpers() const [currentUser] = useAtom(currentUserAtom)
const canWrite = hasPermission('wishlist', 'write') const canWrite = hasPermission(currentUser, 'wishlist', 'write')
const canInteract = hasPermission('wishlist', 'interact') const canInteract = hasPermission(currentUser, 'wishlist', 'interact')
const [usersData] = useAtom(usersAtom) const [usersData] = useAtom(usersAtom)
@@ -140,7 +140,7 @@ export default function WishlistItem({
<span className="ml-2">{t('editButton')}</span> <span className="ml-2">{t('editButton')}</span>
</Button> </Button>
)} )}
<DropdownMenu> <DropdownMenu modal={false}>
<DropdownMenuTrigger asChild> <DropdownMenuTrigger asChild>
<Button variant="ghost" size="sm" className="h-8 w-8 p-0"> <Button variant="ghost" size="sm" className="h-8 w-8 p-0">
<MoreVertical className="h-4 w-4" /> <MoreVertical className="h-4 w-4" />
@@ -165,7 +165,7 @@ export default function WishlistItem({
</DropdownMenuItem> </DropdownMenuItem>
<DropdownMenuSeparator className="sm:hidden" /> <DropdownMenuSeparator className="sm:hidden" />
<DropdownMenuItem <DropdownMenuItem
className="text-red-600 focus:text-red-600 dark:text-red-400 dark:focus:text-red-400 cursor-pointer" className="text-red-600 focus:text-red-600 dark:text-red-400 dark:focus:text-red-400"
onClick={onDelete} onClick={onDelete}
disabled={!canWrite} disabled={!canWrite}
> >

View File

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

View File

@@ -14,7 +14,7 @@ export function JotaiHydrate({
[coinsAtom, initialValues.coins], [coinsAtom, initialValues.coins],
[wishlistAtom, initialValues.wishlist], [wishlistAtom, initialValues.wishlist],
[usersAtom, initialValues.users], [usersAtom, initialValues.users],
[serverSettingsAtom, initialValues.serverSettings] [serverSettingsAtom, initialValues.serverSettings],
]) ])
return children return children
} }

View File

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

View File

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

View File

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

View File

@@ -4,16 +4,19 @@ import {
calculateTotalEarned, calculateTotalEarned,
calculateTotalSpent, calculateTotalSpent,
calculateTransactionsToday, calculateTransactionsToday,
generateCryptoHash,
getCompletionsForToday, getCompletionsForToday,
getHabitFreq, getHabitFreq,
getTodayInTimezone,
isHabitDue, isHabitDue,
prepareDataForHashing,
roundToInteger,
t2d t2d
} from "@/lib/utils"; } from "@/lib/utils";
import { atom } from "jotai"; 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 { import {
CoinsData,
CompletionCache, CompletionCache,
Freq, Freq,
getDefaultCoinsData, getDefaultCoinsData,
@@ -22,7 +25,13 @@ import {
getDefaultSettings, getDefaultSettings,
getDefaultUsersData, getDefaultUsersData,
getDefaultWishlistData, getDefaultWishlistData,
Habit Habit,
HabitsData,
ServerSettings,
Settings,
UserData,
UserId,
WishlistData
} from "./types"; } from "./types";
export interface BrowserSettings { export interface BrowserSettings {
@@ -37,37 +46,41 @@ export const browserSettingsAtom = atomWithStorage('browserSettings', {
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
@@ -77,10 +90,27 @@ export const transactionsTodayAtom = atom((get) => {
return calculateTransactionsToday(coins.transactions, settings.system.timezone); return calculateTransactionsToday(coins.transactions, settings.system.timezone);
}); });
// Derived atom for current balance from all transactions // Atom to store the current logged-in user's ID.
// This should be set by your application when the user session is available.
export const currentUserIdAtom = atom<UserId | undefined>(undefined);
export const currentUserAtom = atom((get) => {
const currentUserId = get(currentUserIdAtom);
const users = get(usersAtom);
return users.users.find(user => user.id === currentUserId);
})
// Derived atom for current balance for the logged-in user
export const coinsBalanceAtom = atom((get) => { export const coinsBalanceAtom = atom((get) => {
const loggedInUserId = get(currentUserIdAtom);
if (!loggedInUserId) {
return 0; // No user logged in or ID not set, so balance is 0
}
const coins = get(coinsAtom); const coins = get(coinsAtom);
return coins.transactions.reduce((sum, transaction) => sum + transaction.amount, 0); const balance = coins.transactions
.filter(transaction => transaction.userId === loggedInUserId)
.reduce((sum, transaction) => sum + transaction.amount, 0);
return roundToInteger(balance);
}); });
/* transient atoms */ /* transient atoms */
@@ -99,6 +129,23 @@ export const pomodoroAtom = atom<PomodoroAtom>({
}) })
export const userSelectAtom = atom<boolean>(false) export const userSelectAtom = atom<boolean>(false)
export const aboutOpenAtom = atom<boolean>(false)
/**
* Asynchronous atom that calculates a freshness token (hash) based on the current client-side data.
* This token can be compared with a server-generated token to detect data discrepancies.
*/
export const clientFreshnessTokenAtom = atom(async (get) => {
const settings = get(settingsAtom);
const habits = get(habitsAtom);
const coins = get(coinsAtom);
const wishlist = get(wishlistAtom);
const users = get(usersAtom);
const dataString = prepareDataForHashing(settings, habits, coins, wishlist, users);
const hash = await generateCryptoHash(dataString);
return hash;
});
// Derived atom for completion cache // Derived atom for completion cache
export const completionCacheAtom = atom((get) => { export const completionCacheAtom = atom((get) => {
@@ -187,10 +234,3 @@ export const habitsByDateFamily = atomFamily((dateString: string) =>
return habits.filter(habit => isHabitDue({ habit, timezone, date })); return habits.filter(habit => isHabitDue({ habit, timezone, date }));
}) })
); );
// Derived atom for daily habits
export const dailyHabitsAtom = atom((get) => {
const settings = get(settingsAtom);
const today = getTodayInTimezone(settings.system.timezone);
return get(habitsByDateFamily(today));
});

View File

@@ -4,7 +4,7 @@
import { useAtom } from 'jotai' import { useAtom } from 'jotai'
import { useSession } from "next-auth/react" import { useSession } from "next-auth/react"
import { usersAtom } from './atoms' import { usersAtom } from './atoms'
import { checkPermission } from './utils' import { hasPermission } from './utils'
export function useHelpers() { export function useHelpers() {
const { data: session, status } = useSession() const { data: session, status } = useSession()
@@ -30,8 +30,7 @@ export function useHelpers() {
currentUser, currentUser,
usersData, usersData,
status, status,
hasPermission: (resource: 'habit' | 'wishlist' | 'coins', action: 'write' | 'interact') => currentUser?.isAdmin || hasPermission,
checkPermission(currentUser?.permissions, resource, action),
isIOS: iOS(), isIOS: iOS(),
} }
} }

View File

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

View File

@@ -1,5 +1,4 @@
import { RRule } from "rrule" import { RRule } from "rrule"
import { uuid } from "./utils"
import { DateTime } from "luxon" import { DateTime } from "luxon"
export type UserId = string export type UserId = string
@@ -97,33 +96,38 @@ export interface WishlistData {
} }
// Default value functions // Default value functions
export const getDefaultUsersData = (): UserData => ({ export function getDefaultUsersData<UserData>(): UserData {
return {
users: [ users: [
{ {
id: uuid(), id: crypto.randomUUID(),
username: 'admin', username: 'admin',
// password: '', // No default password for admin initially? Or set a secure default? // password: '', // No default password for admin initially? Or set a secure default?
isAdmin: true, isAdmin: true,
lastNotificationReadTimestamp: undefined, // Initialize as undefined lastNotificationReadTimestamp: undefined, // Initialize as undefined
} }
] ]
}); } as UserData;
};
export const getDefaultHabitsData = (): HabitsData => ({ export function getDefaultHabitsData<HabitsData>(): HabitsData {
habits: [] return { habits: [] } as HabitsData;
}); }
export function getDefaultTasksData<TasksData>(): TasksData {
return { tasks: [] } as TasksData;
};
export const getDefaultCoinsData = (): CoinsData => ({ export function getDefaultCoinsData<CoinsData>(): CoinsData {
balance: 0, return { balance: 0, transactions: [] } as CoinsData;
transactions: [] };
});
export const getDefaultWishlistData = (): WishlistData => ({ export function getDefaultWishlistData<WishlistData>(): WishlistData {
items: [] return { items: [] } as WishlistData;
}); }
export const getDefaultSettings = (): Settings => ({ export function getDefaultSettings<Settings>(): Settings {
return {
ui: { ui: {
useNumberFormatting: true, useNumberFormatting: true,
useGrouping: true, useGrouping: true,
@@ -135,14 +139,15 @@ export const getDefaultSettings = (): Settings => ({
language: 'en', // Default language language: 'en', // Default language
}, },
profile: {} profile: {}
}); } as Settings;
};
export const getDefaultServerSettings = (): ServerSettings => ({ export function getDefaultServerSettings<ServerSettings>(): ServerSettings {
isDemo: false return { isDemo: false } as ServerSettings;
}) }
// Map of data types to their default values // Map of data types to their default values
export const DATA_DEFAULTS = { export const DATA_DEFAULTS: { [key: string]: <T>() => T } = {
wishlist: getDefaultWishlistData, wishlist: getDefaultWishlistData,
habits: getDefaultHabitsData, habits: getDefaultHabitsData,
coins: getDefaultCoinsData, coins: getDefaultCoinsData,

View File

@@ -3,12 +3,9 @@ import {
cn, cn,
getTodayInTimezone, getTodayInTimezone,
getNow, getNow,
getNowInMilliseconds,
t2d, t2d,
d2t, d2t,
d2s, d2s,
d2sDate,
d2n,
isSameDate, isSameDate,
calculateCoinsEarnedToday, calculateCoinsEarnedToday,
calculateTotalEarned, calculateTotalEarned,
@@ -16,16 +13,19 @@ import {
calculateCoinsSpentToday, calculateCoinsSpentToday,
isHabitDueToday, isHabitDueToday,
isHabitDue, isHabitDue,
uuid,
isTaskOverdue, isTaskOverdue,
deserializeRRule, deserializeRRule,
serializeRRule, serializeRRule,
convertHumanReadableFrequencyToMachineReadable, convertHumanReadableFrequencyToMachineReadable,
convertMachineReadableFrequencyToHumanReadable, convertMachineReadableFrequencyToHumanReadable,
getUnsupportedRRuleReason prepareDataForHashing,
getUnsupportedRRuleReason,
roundToInteger,
generateCryptoHash
} from './utils' } from './utils'
import { CoinTransaction, ParsedResultType } from './types' import { CoinTransaction, ParsedResultType, Settings, HabitsData, CoinsData, WishlistData, UserData } from './types'
import { DateTime } from "luxon"; import { DateTime } from "luxon";
import { getDefaultSettings, getDefaultHabitsData, getDefaultCoinsData, getDefaultWishlistData, getDefaultUsersData } from './types';
import { RRule, Weekday } from 'rrule'; import { RRule, Weekday } from 'rrule';
import { Habit } from '@/lib/types'; import { Habit } from '@/lib/types';
import { INITIAL_DUE } from './constants'; import { INITIAL_DUE } from './constants';
@@ -39,6 +39,33 @@ describe('cn utility', () => {
}) })
}) })
describe('roundToInteger', () => {
test('should round positive numbers correctly', () => {
expect(roundToInteger(10.123)).toBe(10);
expect(roundToInteger(10.5)).toBe(11);
expect(roundToInteger(10.75)).toBe(11);
expect(roundToInteger(10.49)).toBe(10);
});
test('should round negative numbers correctly', () => {
expect(roundToInteger(-10.123)).toBe(-10);
expect(roundToInteger(-10.5)).toBe(-10); // Math.round rounds -x.5 to -(x-1) e.g. -10.5 to -10
expect(roundToInteger(-10.75)).toBe(-11);
expect(roundToInteger(-10.49)).toBe(-10);
});
test('should handle zero correctly', () => {
expect(roundToInteger(0)).toBe(0);
expect(roundToInteger(0.0)).toBe(0);
expect(roundToInteger(-0.0)).toBe(-0);
});
test('should handle integers correctly', () => {
expect(roundToInteger(15)).toBe(15);
expect(roundToInteger(-15)).toBe(-15);
});
});
describe('getUnsupportedRRuleReason', () => { describe('getUnsupportedRRuleReason', () => {
test('should return message for HOURLY frequency', () => { test('should return message for HOURLY frequency', () => {
const rrule = new RRule({ freq: RRule.HOURLY }); const rrule = new RRule({ freq: RRule.HOURLY });
@@ -147,32 +174,6 @@ describe('isTaskOverdue', () => {
}) })
}) })
describe('uuid', () => {
test('should generate valid UUIDs', () => {
const id = uuid()
// UUID v4 format: 8-4-4-4-12 hex digits
expect(id).toMatch(/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/)
})
test('should generate unique UUIDs', () => {
const ids = new Set()
for (let i = 0; i < 1000; i++) {
ids.add(uuid())
}
// All 1000 UUIDs should be unique
expect(ids.size).toBe(1000)
})
test('should generate v4 UUIDs', () => {
const id = uuid()
// Version 4 UUID has specific bits set:
// - 13th character is '4'
// - 17th character is '8', '9', 'a', or 'b'
expect(id.charAt(14)).toBe('4')
expect('89ab').toContain(id.charAt(19))
})
})
describe('datetime utilities', () => { describe('datetime utilities', () => {
let fixedNow: DateTime; let fixedNow: DateTime;
let currentDateIndex = 0; let currentDateIndex = 0;
@@ -290,13 +291,6 @@ describe('getNow', () => {
}) })
}) })
describe('getNowInMilliseconds', () => {
test('should return current time in milliseconds', () => {
const now = DateTime.now().setZone('UTC')
expect(getNowInMilliseconds()).toBe(now.toMillis().toString())
})
})
describe('timestamp conversion utilities', () => { describe('timestamp conversion utilities', () => {
const testTimestamp = '2024-01-01T00:00:00.000Z'; const testTimestamp = '2024-01-01T00:00:00.000Z';
const testDateTime = DateTime.fromISO(testTimestamp); const testDateTime = DateTime.fromISO(testTimestamp);
@@ -320,16 +314,6 @@ describe('timestamp conversion utilities', () => {
const customFormat = d2s({ dateTime: testDateTime, format: 'yyyy-MM-dd', timezone: 'utc' }); const customFormat = d2s({ dateTime: testDateTime, format: 'yyyy-MM-dd', timezone: 'utc' });
expect(customFormat).toBe('2024-01-01') expect(customFormat).toBe('2024-01-01')
}) })
test('d2sDate should format DateTime as date string', () => {
const result = d2sDate({ dateTime: testDateTime });
expect(result).toBeString()
})
test('d2n should convert DateTime to milliseconds string', () => {
const result = d2n({ dateTime: testDateTime });
expect(result).toBe('1704067200000')
})
}) })
describe('isSameDate', () => { describe('isSameDate', () => {
@@ -594,7 +578,7 @@ describe('isHabitDueToday', () => {
test('should return false for invalid recurrence rule', () => { test('should return false for invalid recurrence rule', () => {
const habit = testHabit('INVALID_RRULE') const habit = testHabit('INVALID_RRULE')
const consoleSpy = spyOn(console, 'error').mockImplementation(() => {}) const consoleSpy = spyOn(console, 'error').mockImplementation(() => { })
expect(isHabitDueToday({ habit, timezone: 'UTC' })).toBe(false) expect(isHabitDueToday({ habit, timezone: 'UTC' })).toBe(false)
}) })
}) })
@@ -707,7 +691,7 @@ describe('isHabitDue', () => {
test('should return false for invalid recurrence rule', () => { test('should return false for invalid recurrence rule', () => {
const habit = testHabit('INVALID_RRULE') const habit = testHabit('INVALID_RRULE')
const date = DateTime.fromISO('2024-01-01T00:00:00Z') const date = DateTime.fromISO('2024-01-01T00:00:00Z')
const consoleSpy = spyOn(console, 'error').mockImplementation(() => {}) const consoleSpy = spyOn(console, 'error').mockImplementation(() => { })
expect(isHabitDue({ habit, timezone: 'UTC', date })).toBe(false) expect(isHabitDue({ habit, timezone: 'UTC', date })).toBe(false)
}) })
}) })
@@ -956,3 +940,96 @@ describe('convertMachineReadableFrequencyToHumanReadable', () => {
expect(humanReadable).toBe('invalid') expect(humanReadable).toBe('invalid')
}) })
}) })
describe('freshness utilities', () => {
const mockSettings: Settings = getDefaultSettings<Settings>();
const mockHabits: HabitsData = getDefaultHabitsData<HabitsData>();
const mockCoins: CoinsData = getDefaultCoinsData<CoinsData>();
const mockWishlist: WishlistData = getDefaultWishlistData<WishlistData>();
const mockUsers: UserData = getDefaultUsersData<UserData>();
// Add a user to mockUsers for more realistic testing
mockUsers.users.push({
id: 'user-123',
username: 'testuser',
isAdmin: false,
});
mockHabits.habits.push({
id: 'habit-123',
name: 'Test Habit',
description: 'A habit for testing',
frequency: 'FREQ=DAILY',
coinReward: 10,
completions: [],
userIds: ['user-123']
});
describe('prepareDataForHashing', () => {
test('should produce a consistent string for the same data', () => {
const data1 = { settings: mockSettings, habits: mockHabits, coins: mockCoins, wishlist: mockWishlist, users: mockUsers };
const data2 = { settings: mockSettings, habits: mockHabits, coins: mockCoins, wishlist: mockWishlist, users: mockUsers }; // Identical data
const string1 = prepareDataForHashing(data1.settings, data1.habits, data1.coins, data1.wishlist, data1.users);
const string2 = prepareDataForHashing(data2.settings, data2.habits, data2.coins, data2.wishlist, data2.users);
expect(string1).toBe(string2);
});
test('should produce a different string if settings data changes', () => {
const string1 = prepareDataForHashing(mockSettings, mockHabits, mockCoins, mockWishlist, mockUsers);
const modifiedSettings = { ...mockSettings, system: { ...mockSettings.system, timezone: 'America/Chicago' } };
const string2 = prepareDataForHashing(modifiedSettings, mockHabits, mockCoins, mockWishlist, mockUsers);
expect(string1).not.toBe(string2);
});
test('should produce a different string if habits data changes', () => {
const string1 = prepareDataForHashing(mockSettings, mockHabits, mockCoins, mockWishlist, mockUsers);
const modifiedHabits = { ...mockHabits, habits: [...mockHabits.habits, { id: 'new-habit', name: 'New', description: '', frequency: 'FREQ=DAILY', coinReward: 5, completions: [] }] };
const string2 = prepareDataForHashing(mockSettings, modifiedHabits, mockCoins, mockWishlist, mockUsers);
expect(string1).not.toBe(string2);
});
test('should handle empty data consistently', () => {
const emptySettings = getDefaultSettings<Settings>();
const emptyHabits = getDefaultHabitsData<HabitsData>();
const emptyCoins = getDefaultCoinsData<CoinsData>();
const emptyWishlist = getDefaultWishlistData<WishlistData>();
const emptyUsers = getDefaultUsersData<UserData>();
const string1 = prepareDataForHashing(emptySettings, emptyHabits, emptyCoins, emptyWishlist, emptyUsers);
const string2 = prepareDataForHashing(emptySettings, emptyHabits, emptyCoins, emptyWishlist, emptyUsers);
expect(string1).toBe(string2);
expect(string1).toBeDefined();
});
});
describe('generateCryptoHash', () => {
test('should generate a SHA-256 hex string', async () => {
const dataString = 'test string';
const hash = await generateCryptoHash(dataString);
expect(hash).toMatch(/^[a-f0-9]{64}$/); // SHA-256 hex is 64 chars
});
test('should generate different hashes for different strings', async () => {
const hash1 = await generateCryptoHash('test string 1');
const hash2 = await generateCryptoHash('test string 2');
expect(hash1).not.toBe(hash2);
});
test('should generate the same hash for the same string', async () => {
const hash1 = await generateCryptoHash('consistent string');
const hash2 = await generateCryptoHash('consistent string');
expect(hash1).toBe(hash2);
});
// Test with a known SHA-256 value if possible, or ensure crypto.subtle.digest is available
// For "hello world", SHA-256 is "b94d27b9934d3e08a52e52d7da7dabfac484efe37a5380ee9088f7ace2efcde9"
test('should generate correct hash for a known string', async () => {
const knownString = "hello world";
const expectedHash = "b94d27b9934d3e08a52e52d7da7dabfac484efe37a5380ee9088f7ace2efcde9";
const actualHash = await generateCryptoHash(knownString);
expect(actualHash).toBe(expectedHash);
});
});
})

View File

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

View File

@@ -270,6 +270,12 @@
"actionUpdate": "aktualisieren", "actionUpdate": "aktualisieren",
"actionCreate": "erstellen", "actionCreate": "erstellen",
"errorFailedUserAction": "Fehler beim {action} des Benutzers", "errorFailedUserAction": "Fehler beim {action} des Benutzers",
"toastDemoDeleteDisabled": "Löschen ist in der Demo-Instanz deaktiviert",
"toastCannotDeleteSelf": "Sie können Ihr eigenes Konto nicht löschen",
"confirmDeleteUser": "Sind Sie sicher, dass Sie den Benutzer {username} löschen möchten?",
"toastUserDeletedTitle": "Benutzer gelöscht",
"toastUserDeletedDescription": "Benutzer {username} wurde erfolgreich gelöscht",
"toastDeleteUserFailed": "Fehler beim Löschen des Benutzers: {error}",
"errorTitle": "Fehler", "errorTitle": "Fehler",
"errorFileSizeLimit": "Die Dateigröße muss kleiner als 5MB sein", "errorFileSizeLimit": "Die Dateigröße muss kleiner als 5MB sein",
"toastAvatarUploadedTitle": "Avatar hochgeladen", "toastAvatarUploadedTitle": "Avatar hochgeladen",
@@ -287,7 +293,13 @@
"disablePasswordLabel": "Passwort deaktivieren", "disablePasswordLabel": "Passwort deaktivieren",
"cancelButton": "Abbrechen", "cancelButton": "Abbrechen",
"saveChangesButton": "Änderungen speichern", "saveChangesButton": "Änderungen speichern",
"createUserButton": "Benutzer erstellen" "createUserButton": "Benutzer erstellen",
"deleteAccountButton": "Konto löschen",
"deletingButtonText": "Wird gelöscht...",
"areYouSure": "Sind Sie sicher?",
"deleteUserConfirmation": "Sind Sie sicher, dass Sie den Benutzer {username} löschen möchten?",
"cancel": "Abbrechen",
"confirmDeleteButtonText": "Löschen"
}, },
"ViewToggle": { "ViewToggle": {
"habitsLabel": "Gewohnheiten", "habitsLabel": "Gewohnheiten",
@@ -411,6 +423,7 @@
"cancel": "Abbrechen" "cancel": "Abbrechen"
}, },
"useCoins": { "useCoins": {
"addedCoinsDescription": "{amount} Münzen hinzugefügt",
"invalidAmountTitle": "Ungültiger Betrag", "invalidAmountTitle": "Ungültiger Betrag",
"invalidAmountDescription": "Bitte geben Sie eine gültige positive Zahl ein", "invalidAmountDescription": "Bitte geben Sie eine gültige positive Zahl ein",
"successTitle": "Erfolg", "successTitle": "Erfolg",

View File

@@ -287,7 +287,8 @@
"disablePasswordLabel": "Disable password", "disablePasswordLabel": "Disable password",
"cancelButton": "Cancel", "cancelButton": "Cancel",
"saveChangesButton": "Save Changes", "saveChangesButton": "Save Changes",
"createUserButton": "Create User" "createUserButton": "Create User",
"deleteAccountButton": "Delete Account"
}, },
"ViewToggle": { "ViewToggle": {
"habitsLabel": "Habits", "habitsLabel": "Habits",
@@ -407,6 +408,7 @@
"notEnoughCoinsDescription": "You need {coinsNeeded} more coins to redeem this reward." "notEnoughCoinsDescription": "You need {coinsNeeded} more coins to redeem this reward."
}, },
"useCoins": { "useCoins": {
"addedCoinsDescription": "Added {amount} coins",
"invalidAmountTitle": "Invalid amount", "invalidAmountTitle": "Invalid amount",
"invalidAmountDescription": "Please enter a valid positive number", "invalidAmountDescription": "Please enter a valid positive number",
"successTitle": "Success", "successTitle": "Success",

View File

@@ -270,6 +270,12 @@
"actionUpdate": "actualizar", "actionUpdate": "actualizar",
"actionCreate": "crear", "actionCreate": "crear",
"errorFailedUserAction": "Error al {action} usuario", "errorFailedUserAction": "Error al {action} usuario",
"toastDemoDeleteDisabled": "La eliminación está deshabilitada en la instancia demo",
"toastCannotDeleteSelf": "No puedes eliminar tu propia cuenta",
"confirmDeleteUser": "¿Estás seguro de que deseas eliminar al usuario {username}?",
"toastUserDeletedTitle": "Usuario eliminado",
"toastUserDeletedDescription": "El usuario {username} ha sido eliminado correctamente",
"toastDeleteUserFailed": "Error al eliminar el usuario: {error}",
"errorTitle": "Error", "errorTitle": "Error",
"errorFileSizeLimit": "El tamaño del archivo debe ser menor a 5MB", "errorFileSizeLimit": "El tamaño del archivo debe ser menor a 5MB",
"toastAvatarUploadedTitle": "Avatar subido", "toastAvatarUploadedTitle": "Avatar subido",
@@ -287,7 +293,13 @@
"disablePasswordLabel": "Desactivar contraseña", "disablePasswordLabel": "Desactivar contraseña",
"cancelButton": "Cancelar", "cancelButton": "Cancelar",
"saveChangesButton": "Guardar cambios", "saveChangesButton": "Guardar cambios",
"createUserButton": "Crear usuario" "createUserButton": "Crear usuario",
"deleteAccountButton": "Eliminar cuenta",
"deletingButtonText": "Eliminando...",
"areYouSure": "¿Estás seguro?",
"deleteUserConfirmation": "¿Estás seguro de que deseas eliminar al usuario {username}?",
"cancel": "Cancelar",
"confirmDeleteButtonText": "Eliminar"
}, },
"ViewToggle": { "ViewToggle": {
"habitsLabel": "Hábitos", "habitsLabel": "Hábitos",
@@ -411,6 +423,7 @@
"cancel": "Cancelar" "cancel": "Cancelar"
}, },
"useCoins": { "useCoins": {
"addedCoinsDescription": "Se añadieron {amount} monedas",
"invalidAmountTitle": "Cantidad inválida", "invalidAmountTitle": "Cantidad inválida",
"invalidAmountDescription": "Por favor ingresa un número positivo válido", "invalidAmountDescription": "Por favor ingresa un número positivo válido",
"successTitle": "Éxito", "successTitle": "Éxito",

View File

@@ -270,6 +270,12 @@
"actionUpdate": "mise à jour", "actionUpdate": "mise à jour",
"actionCreate": "création", "actionCreate": "création",
"errorFailedUserAction": "Échec de la {action} de l'utilisateur", "errorFailedUserAction": "Échec de la {action} de l'utilisateur",
"toastDemoDeleteDisabled": "La suppression est désactivée dans la version de démonstration",
"toastCannotDeleteSelf": "Vous ne pouvez pas supprimer votre propre compte",
"confirmDeleteUser": "Êtes-vous sûr de vouloir supprimer l'utilisateur {username}?",
"toastUserDeletedTitle": "Utilisateur supprimé",
"toastUserDeletedDescription": "L'utilisateur {username} a été supprimé avec succès",
"toastDeleteUserFailed": "Échec de la suppression de l'utilisateur : {error}",
"errorTitle": "Erreur", "errorTitle": "Erreur",
"errorFileSizeLimit": "La taille du fichier doit être inférieure à 5MB", "errorFileSizeLimit": "La taille du fichier doit être inférieure à 5MB",
"toastAvatarUploadedTitle": "Avatar téléchargé", "toastAvatarUploadedTitle": "Avatar téléchargé",
@@ -287,7 +293,13 @@
"disablePasswordLabel": "Désactiver le mot de passe", "disablePasswordLabel": "Désactiver le mot de passe",
"cancelButton": "Annuler", "cancelButton": "Annuler",
"saveChangesButton": "Sauvegarder les modifications", "saveChangesButton": "Sauvegarder les modifications",
"createUserButton": "Créer un utilisateur" "createUserButton": "Créer un utilisateur",
"deleteAccountButton": "Supprimer le compte",
"deletingButtonText": "Suppression en cours...",
"areYouSure": "Êtes-vous sûr ?",
"deleteUserConfirmation": "Êtes-vous sûr de vouloir supprimer l'utilisateur {username} ?",
"cancel": "Annuler",
"confirmDeleteButtonText": "Supprimer"
}, },
"ViewToggle": { "ViewToggle": {
"habitsLabel": "Habitudes", "habitsLabel": "Habitudes",
@@ -411,6 +423,7 @@
"cancel": "Annuler" "cancel": "Annuler"
}, },
"useCoins": { "useCoins": {
"addedCoinsDescription": "{amount} pièces ajoutées",
"invalidAmountTitle": "Montant invalide", "invalidAmountTitle": "Montant invalide",
"invalidAmountDescription": "Veuillez entrer un nombre positif valide", "invalidAmountDescription": "Veuillez entrer un nombre positif valide",
"successTitle": "Succès", "successTitle": "Succès",

View File

@@ -270,6 +270,12 @@
"actionUpdate": "更新", "actionUpdate": "更新",
"actionCreate": "作成", "actionCreate": "作成",
"errorFailedUserAction": "ユーザーの{action}に失敗しました", "errorFailedUserAction": "ユーザーの{action}に失敗しました",
"toastDemoDeleteDisabled": "デモインスタンスでは削除が無効になっています",
"toastCannotDeleteSelf": "自分のアカウントは削除できません",
"confirmDeleteUser": "ユーザー {username} を削除してもよろしいですか?",
"toastUserDeletedTitle": "ユーザーが削除されました",
"toastUserDeletedDescription": "ユーザー {username} は正常に削除されました",
"toastDeleteUserFailed": "ユーザーの削除に失敗しました: {error}",
"errorTitle": "エラー", "errorTitle": "エラー",
"errorFileSizeLimit": "ファイルサイズは5MB以下である必要があります", "errorFileSizeLimit": "ファイルサイズは5MB以下である必要があります",
"toastAvatarUploadedTitle": "アバターをアップロードしました", "toastAvatarUploadedTitle": "アバターをアップロードしました",
@@ -287,7 +293,13 @@
"disablePasswordLabel": "パスワードを無効化", "disablePasswordLabel": "パスワードを無効化",
"cancelButton": "キャンセル", "cancelButton": "キャンセル",
"saveChangesButton": "変更を保存", "saveChangesButton": "変更を保存",
"createUserButton": "ユーザーを作成" "createUserButton": "ユーザーを作成",
"deleteAccountButton": "アカウントを削除",
"deletingButtonText": "削除中...",
"areYouSure": "本当によろしいですか?",
"deleteUserConfirmation": "ユーザー {username} を削除してもよろしいですか?",
"cancel": "キャンセル",
"confirmDeleteButtonText": "削除"
}, },
"ViewToggle": { "ViewToggle": {
"habitsLabel": "習慣", "habitsLabel": "習慣",
@@ -411,6 +423,7 @@
"cancel": "キャンセル" "cancel": "キャンセル"
}, },
"useCoins": { "useCoins": {
"addedCoinsDescription": "{amount}コインを追加しました",
"invalidAmountTitle": "無効な値です", "invalidAmountTitle": "無効な値です",
"invalidAmountDescription": "有効な正の数を入力してください", "invalidAmountDescription": "有効な正の数を入力してください",
"successTitle": "成功しました", "successTitle": "成功しました",

View File

@@ -270,6 +270,12 @@
"actionUpdate": "обновить", "actionUpdate": "обновить",
"actionCreate": "создать", "actionCreate": "создать",
"errorFailedUserAction": "Не удалось {action} пользователя", "errorFailedUserAction": "Не удалось {action} пользователя",
"toastDemoDeleteDisabled": "Удаление отключено в демо-версии",
"toastCannotDeleteSelf": "Вы не можете удалить свою учетную запись",
"confirmDeleteUser": "Вы уверены, что хотите удалить пользователя {username}?",
"toastUserDeletedTitle": "Пользователь удален",
"toastUserDeletedDescription": "Пользователь {username} успешно удален",
"toastDeleteUserFailed": "Не удалось удалить пользователя: {error}",
"errorTitle": "Ошибка", "errorTitle": "Ошибка",
"errorFileSizeLimit": "Размер файла должен быть менее 5 МБ", "errorFileSizeLimit": "Размер файла должен быть менее 5 МБ",
"toastAvatarUploadedTitle": "Аватар загружен", "toastAvatarUploadedTitle": "Аватар загружен",
@@ -287,7 +293,13 @@
"disablePasswordLabel": "Отключить пароль", "disablePasswordLabel": "Отключить пароль",
"cancelButton": "Отмена", "cancelButton": "Отмена",
"saveChangesButton": "Сохранить изменения", "saveChangesButton": "Сохранить изменения",
"createUserButton": "Создать пользователя" "createUserButton": "Создать пользователя",
"deleteAccountButton": "Удалить аккаунт",
"deletingButtonText": "Удаление...",
"areYouSure": "Вы уверены?",
"deleteUserConfirmation": "Вы уверены, что хотите удалить пользователя {username}?",
"cancel": "Отмена",
"confirmDeleteButtonText": "Удалить"
}, },
"ViewToggle": { "ViewToggle": {
"habitsLabel": "Привычки", "habitsLabel": "Привычки",
@@ -411,6 +423,7 @@
"cancel": "Отмена" "cancel": "Отмена"
}, },
"useCoins": { "useCoins": {
"addedCoinsDescription": "Добавлено {amount} монет",
"invalidAmountTitle": "Неверная сумма", "invalidAmountTitle": "Неверная сумма",
"invalidAmountDescription": "Пожалуйста, введите положительное число", "invalidAmountDescription": "Пожалуйста, введите положительное число",
"successTitle": "Успех", "successTitle": "Успех",

View File

@@ -270,6 +270,12 @@
"actionUpdate": "更新", "actionUpdate": "更新",
"actionCreate": "创建", "actionCreate": "创建",
"errorFailedUserAction": "用户 {action} 失败", "errorFailedUserAction": "用户 {action} 失败",
"toastDemoDeleteDisabled": "在演示实例中删除已禁用",
"toastCannotDeleteSelf": "您不能删除自己的帐户",
"confirmDeleteUser": "您确定要删除用户 {username} 吗?",
"toastUserDeletedTitle": "用户已删除",
"toastUserDeletedDescription": "用户 {username} 已成功删除",
"toastDeleteUserFailed": "删除用户失败: {error}",
"errorTitle": "错误", "errorTitle": "错误",
"errorFileSizeLimit": "文件大小必须小于 5MB", "errorFileSizeLimit": "文件大小必须小于 5MB",
"toastAvatarUploadedTitle": "头像已上传", "toastAvatarUploadedTitle": "头像已上传",
@@ -287,7 +293,13 @@
"disablePasswordLabel": "禁用密码", "disablePasswordLabel": "禁用密码",
"cancelButton": "取消", "cancelButton": "取消",
"saveChangesButton": "保存更改", "saveChangesButton": "保存更改",
"createUserButton": "创建用户" "createUserButton": "创建用户",
"deleteAccountButton": "删除账户",
"deletingButtonText": "正在删除...",
"areYouSure": "您确定吗?",
"deleteUserConfirmation": "您确定要删除用户 {username} 吗?",
"cancel": "取消",
"confirmDeleteButtonText": "删除"
}, },
"ViewToggle": { "ViewToggle": {
"habitsLabel": "习惯", "habitsLabel": "习惯",
@@ -411,6 +423,7 @@
"cancel": "取消" "cancel": "取消"
}, },
"useCoins": { "useCoins": {
"addedCoinsDescription": "已添加 {amount} 个金币",
"invalidAmountTitle": "无效金额", "invalidAmountTitle": "无效金额",
"invalidAmountDescription": "请输入有效的正数", "invalidAmountDescription": "请输入有效的正数",
"successTitle": "成功", "successTitle": "成功",

112
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{ {
"name": "habittrove", "name": "habittrove",
"version": "0.2.13", "version": "0.2.20",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "habittrove", "name": "habittrove",
"version": "0.2.13", "version": "0.2.20",
"dependencies": { "dependencies": {
"@emoji-mart/data": "^1.2.1", "@emoji-mart/data": "^1.2.1",
"@emoji-mart/react": "^1.1.1", "@emoji-mart/react": "^1.1.1",
@@ -36,6 +36,7 @@
"date-fns": "^3.6.0", "date-fns": "^3.6.0",
"jotai": "^2.8.0", "jotai": "^2.8.0",
"js-confetti": "^0.12.0", "js-confetti": "^0.12.0",
"json-stable-stringify": "^1.3.0",
"linkify": "^0.2.1", "linkify": "^0.2.1",
"linkify-react": "^4.2.0", "linkify-react": "^4.2.0",
"lucide-react": "^0.469.0", "lucide-react": "^0.469.0",
@@ -63,6 +64,7 @@
"@tailwindcss/typography": "^0.5.15", "@tailwindcss/typography": "^0.5.15",
"@types/archiver": "^6.0.3", "@types/archiver": "^6.0.3",
"@types/bun": "^1.1.14", "@types/bun": "^1.1.14",
"@types/json-stable-stringify": "^1.1.0",
"@types/lodash": "^4.17.15", "@types/lodash": "^4.17.15",
"@types/luxon": "^3.4.2", "@types/luxon": "^3.4.2",
"@types/node": "^20.17.10", "@types/node": "^20.17.10",
@@ -2851,6 +2853,13 @@
"integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==",
"dev": true "dev": true
}, },
"node_modules/@types/json-stable-stringify": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@types/json-stable-stringify/-/json-stable-stringify-1.1.0.tgz",
"integrity": "sha512-ESTsHWB72QQq+pjUFIbEz9uSCZppD31YrVkbt2rnUciTYEvcwN6uZIhX5JZeBHqRlFJ41x/7MewCs7E2Qux6Cg==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/json5": { "node_modules/@types/json5": {
"version": "0.0.29", "version": "0.0.29",
"resolved": "https://registry.npmjs.org/@types/json5/-/json5-0.0.29.tgz", "resolved": "https://registry.npmjs.org/@types/json5/-/json5-0.0.29.tgz",
@@ -3999,7 +4008,6 @@
"version": "1.0.8", "version": "1.0.8",
"resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.8.tgz", "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.8.tgz",
"integrity": "sha512-oKlSFMcMwpUg2ednkhQ454wfWiU/ul3CkJe/PEHcTKuiX6RpbehUiFMXu13HalGZxfUwCQzZG747YXBn1im9ww==", "integrity": "sha512-oKlSFMcMwpUg2ednkhQ454wfWiU/ul3CkJe/PEHcTKuiX6RpbehUiFMXu13HalGZxfUwCQzZG747YXBn1im9ww==",
"dev": true,
"dependencies": { "dependencies": {
"call-bind-apply-helpers": "^1.0.0", "call-bind-apply-helpers": "^1.0.0",
"es-define-property": "^1.0.0", "es-define-property": "^1.0.0",
@@ -4014,10 +4022,10 @@
} }
}, },
"node_modules/call-bind-apply-helpers": { "node_modules/call-bind-apply-helpers": {
"version": "1.0.1", "version": "1.0.2",
"resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.1.tgz", "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",
"integrity": "sha512-BhYE+WDaywFg2TBWYNXAE+8B1ATnThNBqXHP5nQu0jWJdVvY2hvkpyB3qOmtmDePiS5/BDQ8wASEWGMWRG148g==", "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==",
"dev": true, "license": "MIT",
"dependencies": { "dependencies": {
"es-errors": "^1.3.0", "es-errors": "^1.3.0",
"function-bind": "^1.1.2" "function-bind": "^1.1.2"
@@ -4027,13 +4035,13 @@
} }
}, },
"node_modules/call-bound": { "node_modules/call-bound": {
"version": "1.0.3", "version": "1.0.4",
"resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.3.tgz", "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz",
"integrity": "sha512-YTd+6wGlNlPxSuri7Y6X8tY2dmm12UMH66RpKMhiX6rsk5wXXnYgbUcOt8kiS31/AjfoTOvCsE+w8nZQLQnzHA==", "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==",
"dev": true, "license": "MIT",
"dependencies": { "dependencies": {
"call-bind-apply-helpers": "^1.0.1", "call-bind-apply-helpers": "^1.0.2",
"get-intrinsic": "^1.2.6" "get-intrinsic": "^1.3.0"
}, },
"engines": { "engines": {
"node": ">= 0.4" "node": ">= 0.4"
@@ -4604,7 +4612,6 @@
"version": "1.1.4", "version": "1.1.4",
"resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz", "resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz",
"integrity": "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==", "integrity": "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==",
"dev": true,
"dependencies": { "dependencies": {
"es-define-property": "^1.0.0", "es-define-property": "^1.0.0",
"es-errors": "^1.3.0", "es-errors": "^1.3.0",
@@ -4703,7 +4710,6 @@
"version": "1.0.1", "version": "1.0.1",
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
"integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==",
"dev": true,
"dependencies": { "dependencies": {
"call-bind-apply-helpers": "^1.0.1", "call-bind-apply-helpers": "^1.0.1",
"es-errors": "^1.3.0", "es-errors": "^1.3.0",
@@ -4831,7 +4837,6 @@
"version": "1.0.1", "version": "1.0.1",
"resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
"integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==",
"dev": true,
"engines": { "engines": {
"node": ">= 0.4" "node": ">= 0.4"
} }
@@ -4840,7 +4845,6 @@
"version": "1.3.0", "version": "1.3.0",
"resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz",
"integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==",
"dev": true,
"engines": { "engines": {
"node": ">= 0.4" "node": ">= 0.4"
} }
@@ -4880,10 +4884,10 @@
"peer": true "peer": true
}, },
"node_modules/es-object-atoms": { "node_modules/es-object-atoms": {
"version": "1.0.0", "version": "1.1.1",
"resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.0.0.tgz", "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz",
"integrity": "sha512-MZ4iQ6JwHOBQjahnjwaC1ZtIBH+2ohjamzAO3oaHcXYup7qxjF2fixyH+Q71voWHeOkI2q/TnJao/KfXYIZWbw==", "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==",
"dev": true, "license": "MIT",
"dependencies": { "dependencies": {
"es-errors": "^1.3.0" "es-errors": "^1.3.0"
}, },
@@ -5644,21 +5648,21 @@
} }
}, },
"node_modules/get-intrinsic": { "node_modules/get-intrinsic": {
"version": "1.2.6", "version": "1.3.0",
"resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.2.6.tgz", "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
"integrity": "sha512-qxsEs+9A+u85HhllWJJFicJfPDhRmjzoYdl64aMWW9yRIJmSyxdn8IEkuIM530/7T+lv0TIHd8L6Q/ra0tEoeA==", "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==",
"dev": true, "license": "MIT",
"dependencies": { "dependencies": {
"call-bind-apply-helpers": "^1.0.1", "call-bind-apply-helpers": "^1.0.2",
"dunder-proto": "^1.0.0",
"es-define-property": "^1.0.1", "es-define-property": "^1.0.1",
"es-errors": "^1.3.0", "es-errors": "^1.3.0",
"es-object-atoms": "^1.0.0", "es-object-atoms": "^1.1.1",
"function-bind": "^1.1.2", "function-bind": "^1.1.2",
"get-proto": "^1.0.1",
"gopd": "^1.2.0", "gopd": "^1.2.0",
"has-symbols": "^1.1.0", "has-symbols": "^1.1.0",
"hasown": "^2.0.2", "hasown": "^2.0.2",
"math-intrinsics": "^1.0.0" "math-intrinsics": "^1.1.0"
}, },
"engines": { "engines": {
"node": ">= 0.4" "node": ">= 0.4"
@@ -5675,6 +5679,19 @@
"node": ">=6" "node": ">=6"
} }
}, },
"node_modules/get-proto": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz",
"integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==",
"license": "MIT",
"dependencies": {
"dunder-proto": "^1.0.1",
"es-object-atoms": "^1.0.0"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/get-symbol-description": { "node_modules/get-symbol-description": {
"version": "1.1.0", "version": "1.1.0",
"resolved": "https://registry.npmjs.org/get-symbol-description/-/get-symbol-description-1.1.0.tgz", "resolved": "https://registry.npmjs.org/get-symbol-description/-/get-symbol-description-1.1.0.tgz",
@@ -5795,7 +5812,6 @@
"version": "1.2.0", "version": "1.2.0",
"resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
"integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==",
"dev": true,
"engines": { "engines": {
"node": ">= 0.4" "node": ">= 0.4"
}, },
@@ -5839,7 +5855,6 @@
"version": "1.0.2", "version": "1.0.2",
"resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz", "resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz",
"integrity": "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==", "integrity": "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==",
"dev": true,
"dependencies": { "dependencies": {
"es-define-property": "^1.0.0" "es-define-property": "^1.0.0"
}, },
@@ -5866,7 +5881,6 @@
"version": "1.1.0", "version": "1.1.0",
"resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz",
"integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==",
"dev": true,
"engines": { "engines": {
"node": ">= 0.4" "node": ">= 0.4"
}, },
@@ -6523,8 +6537,7 @@
"node_modules/isarray": { "node_modules/isarray": {
"version": "2.0.5", "version": "2.0.5",
"resolved": "https://registry.npmjs.org/isarray/-/isarray-2.0.5.tgz", "resolved": "https://registry.npmjs.org/isarray/-/isarray-2.0.5.tgz",
"integrity": "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==", "integrity": "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw=="
"dev": true
}, },
"node_modules/isexe": { "node_modules/isexe": {
"version": "2.0.0", "version": "2.0.0",
@@ -6671,6 +6684,25 @@
"integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==",
"dev": true "dev": true
}, },
"node_modules/json-stable-stringify": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/json-stable-stringify/-/json-stable-stringify-1.3.0.tgz",
"integrity": "sha512-qtYiSSFlwot9XHtF9bD9c7rwKjr+RecWT//ZnPvSmEjpV5mmPOCN4j8UjY5hbjNkOwZ/jQv3J6R1/pL7RwgMsg==",
"license": "MIT",
"dependencies": {
"call-bind": "^1.0.8",
"call-bound": "^1.0.4",
"isarray": "^2.0.5",
"jsonify": "^0.0.1",
"object-keys": "^1.1.1"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/json-stable-stringify-without-jsonify": { "node_modules/json-stable-stringify-without-jsonify": {
"version": "1.0.1", "version": "1.0.1",
"resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz",
@@ -6689,6 +6721,15 @@
"json5": "lib/cli.js" "json5": "lib/cli.js"
} }
}, },
"node_modules/jsonify": {
"version": "0.0.1",
"resolved": "https://registry.npmjs.org/jsonify/-/jsonify-0.0.1.tgz",
"integrity": "sha512-2/Ki0GcmuqSrgFyelQq9M05y7PS0mEwuIzrf3f1fPqkVDVRvZrPZtVSMHxdgo8Aq0sxAOb/cr2aqqA3LeWHVPg==",
"license": "Public Domain",
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/jsx-ast-utils": { "node_modules/jsx-ast-utils": {
"version": "3.3.5", "version": "3.3.5",
"resolved": "https://registry.npmjs.org/jsx-ast-utils/-/jsx-ast-utils-3.3.5.tgz", "resolved": "https://registry.npmjs.org/jsx-ast-utils/-/jsx-ast-utils-3.3.5.tgz",
@@ -6966,7 +7007,6 @@
"version": "1.1.0", "version": "1.1.0",
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
"integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==",
"dev": true,
"engines": { "engines": {
"node": ">= 0.4" "node": ">= 0.4"
} }
@@ -7896,7 +7936,6 @@
"version": "1.1.1", "version": "1.1.1",
"resolved": "https://registry.npmjs.org/object-keys/-/object-keys-1.1.1.tgz", "resolved": "https://registry.npmjs.org/object-keys/-/object-keys-1.1.1.tgz",
"integrity": "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==", "integrity": "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==",
"dev": true,
"engines": { "engines": {
"node": ">= 0.4" "node": ">= 0.4"
} }
@@ -8958,7 +8997,6 @@
"version": "1.2.2", "version": "1.2.2",
"resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz", "resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz",
"integrity": "sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==", "integrity": "sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==",
"dev": true,
"dependencies": { "dependencies": {
"define-data-property": "^1.1.4", "define-data-property": "^1.1.4",
"es-errors": "^1.3.0", "es-errors": "^1.3.0",

View File

@@ -1,6 +1,6 @@
{ {
"name": "habittrove", "name": "habittrove",
"version": "0.2.15", "version": "0.2.23",
"private": true, "private": true,
"scripts": { "scripts": {
"dev": "next dev --turbopack", "dev": "next dev --turbopack",
@@ -62,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