Compare commits

..

8 Commits

36 changed files with 751 additions and 412 deletions

View File

@@ -1,5 +1,19 @@
# Changelog # Changelog
## Version 0.2.31
**This Release contains important security patches, please update as soon as possible**
### Fixed
* Security: Updated Next.js from 15.5.7 to 15.5.10
* Security: Hardened avatar route against path traversal attacks
* Security: Sanitized user data in client-facing payloads
* Security: Hardened debug actions and avatar validation
* Fixed missing English translations for DrawingModal
Thank you @1ARdotNO for the security audit!
## Version 0.2.30 ## Version 0.2.30
### Fixed ### Fixed

View File

@@ -181,3 +181,26 @@ This project is licensed under the GNU Affero General Public License v3.0 - see
## Support ## Support
If you encounter any issues or have questions, please file an issue on the GitHub repository. If you encounter any issues or have questions, please file an issue on the GitHub repository.
## Issues
### Missing Permissions
Especially when updating from older versions, it may be that the permissions used in the newer versions have never been set. This causes numerous `missing permissions` errors to appear. The solution is to update the `auth.json` in the `data` directory for each user to include the following json:
```json
"permissions": [{
"habit": {
"write": true,
"interact": true
},
"wishlist": {
"write": true,
"interact": true
},
"coins": {
"write": true,
"interact": true
}
}
```

88
app/actions/data.test.ts Normal file
View File

@@ -0,0 +1,88 @@
import { describe, expect, test } from 'bun:test'
import { sanitizeUserData } from '@/lib/user-sanitizer'
import { UserData } from '@/lib/types'
describe('sanitizeUserData', () => {
test('removes password field from every user', () => {
const input: UserData = {
users: [
{
id: 'u1',
username: 'admin',
password: 'abcd1234:ef567890',
isAdmin: true,
},
{
id: 'u2',
username: 'no-pass',
isAdmin: false,
},
],
}
const output = sanitizeUserData(input)
expect(output.users).toHaveLength(2)
expect(output.users[0]).not.toHaveProperty('password')
expect(output.users[1]).not.toHaveProperty('password')
})
test('adds hasPassword metadata based on stored password', () => {
const input: UserData = {
users: [
{
id: 'u1',
username: 'with-hash',
password: 'abcd1234:ef567890',
isAdmin: false,
},
{
id: 'u2',
username: 'empty-pass',
password: '',
isAdmin: false,
},
{
id: 'u3',
username: 'no-pass',
isAdmin: false,
},
],
}
const output = sanitizeUserData(input)
expect(output.users[0].hasPassword).toBe(true)
expect(output.users[1].hasPassword).toBe(false)
expect(output.users[2].hasPassword).toBe(false)
})
test('preserves other user properties', () => {
const input: UserData = {
users: [
{
id: 'u1',
username: 'user',
password: 'hash',
avatarPath: '/data/avatars/u1.png',
isAdmin: false,
permissions: [
{
habit: { write: true, interact: true },
wishlist: { write: true, interact: true },
coins: { write: true, interact: true },
},
],
},
],
}
const output = sanitizeUserData(input)
expect(output.users[0].id).toBe('u1')
expect(output.users[0].username).toBe('user')
expect(output.users[0].avatarPath).toBe('/data/avatars/u1.png')
expect(output.users[0].isAdmin).toBe(false)
expect(output.users[0].permissions?.[0].habit.write).toBe(true)
})
})

View File

@@ -1,5 +1,6 @@
'use server' 'use server'
import { ALLOWED_AVATAR_EXTENSIONS, ALLOWED_AVATAR_MIME_TYPES } from '@/lib/avatar';
import { getCurrentUser, saltAndHashPassword, verifyPassword } from "@/lib/server-helpers"; import { getCurrentUser, saltAndHashPassword, verifyPassword } from "@/lib/server-helpers";
import { import {
CoinsData, CoinsData,
@@ -13,6 +14,8 @@ import {
getDefaultWishlistData, getDefaultWishlistData,
HabitsData, HabitsData,
Permission, Permission,
PublicUser,
PublicUserData,
ServerSettings, ServerSettings,
Settings, Settings,
TransactionType, TransactionType,
@@ -21,8 +24,10 @@ import {
WishlistData, WishlistData,
WishlistItemType WishlistItemType
} from '@/lib/types'; } from '@/lib/types';
import { sanitizeUserData } from '@/lib/user-sanitizer';
import { d2t, generateCryptoHash, getNow, prepareDataForHashing } from '@/lib/utils'; import { d2t, generateCryptoHash, getNow, prepareDataForHashing } from '@/lib/utils';
import { signInSchema } from '@/lib/zod'; import { signInSchema } from '@/lib/zod';
import { randomUUID } from "crypto";
import fs from 'fs/promises'; import fs from 'fs/promises';
import _ from 'lodash'; import _ from 'lodash';
import path from 'path'; import path from 'path';
@@ -32,6 +37,20 @@ 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;
@@ -48,22 +67,28 @@ async function ensureDataDir() {
// --- Backup Debug Action --- // --- Backup Debug Action ---
export async function triggerManualBackup(): Promise<{ success: boolean; message: string }> { export async function triggerManualBackup(): Promise<{ success: boolean; message: string }> {
// Optional: Add extra permission check if needed for debug actions if (process.env.NODE_ENV !== 'development') {
// const user = await getCurrentUser(); return { success: false, message: 'Permission denied.' }
// if (!user?.isAdmin) { }
// return { success: false, message: "Permission denied." };
// }
console.log("Manual backup trigger requested..."); const user = await getCurrentUser()
if (!user?.isAdmin) {
return { success: false, message: 'Permission denied.' }
}
console.log('Manual backup trigger requested...')
try { try {
// Import runBackup locally to avoid potential circular dependencies if moved // Import runBackup locally to avoid potential circular dependencies if moved
const { runBackup } = await import('@/lib/backup'); const { runBackup } = await import('@/lib/backup')
await runBackup(); await runBackup()
console.log("Manual backup trigger completed successfully."); console.log('Manual backup trigger completed successfully.')
return { success: true, message: "Backup process completed successfully." }; return { success: true, message: 'Backup process completed successfully.' }
} catch (error) { } catch (error) {
console.error("Manual backup trigger failed:", error); console.error('Manual backup trigger failed:', error)
return { success: false, message: `Backup failed: ${error instanceof Error ? error.message : 'Unknown error'}` }; return {
success: false,
message: `Backup failed: ${error instanceof Error ? error.message : 'Unknown error'}`,
}
} }
} }
@@ -136,7 +161,7 @@ async function calculateServerFreshnessToken(): Promise<string | null> {
// 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<WishlistData>() if (!user) return getDefaultWishlistData()
const data = await loadData<WishlistData>('wishlist') const data = await loadData<WishlistData>('wishlist')
return { return {
@@ -173,7 +198,7 @@ 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<HabitsData>() if (!user) return getDefaultHabitsData()
const data = await loadData<HabitsData>('habits') const data = await loadData<HabitsData>('habits')
return { return {
habits: data.habits.filter(x => user.isAdmin || x.userIds?.includes(user.id)) habits: data.habits.filter(x => user.isAdmin || x.userIds?.includes(user.id))
@@ -208,14 +233,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<CoinsData>() if (!user) return getDefaultCoinsData()
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<CoinsData>() return getDefaultCoinsData()
} }
} }
@@ -278,7 +303,7 @@ export async function addCoins({
} }
export async function loadSettings(): Promise<Settings> { export async function loadSettings(): Promise<Settings> {
const defaultSettings = getDefaultSettings<Settings>() const defaultSettings = getDefaultSettings()
try { try {
const user = await getCurrentUser() const user = await getCurrentUser()
@@ -339,13 +364,22 @@ export async function uploadAvatar(formData: FormData): Promise<string> {
throw new Error('File size must be less than 5MB') throw new Error('File size must be less than 5MB')
} }
const mimeType = file.type.toLowerCase()
if (!ALLOWED_AVATAR_MIME_TYPES.has(mimeType)) {
throw new Error('Unsupported avatar MIME type')
}
const ext = path.extname(file.name).toLowerCase()
if (!ALLOWED_AVATAR_EXTENSIONS.has(ext)) {
throw new Error('Unsupported avatar file extension')
}
// Create avatars directory if it doesn't exist // Create avatars directory if it doesn't exist
const avatarsDir = path.join(process.cwd(), 'data', 'avatars') const avatarsDir = path.join(process.cwd(), 'data', 'avatars')
await fs.mkdir(avatarsDir, { recursive: true }) await fs.mkdir(avatarsDir, { recursive: true })
// Generate unique filename // Generate unique filename
const ext = file.name.split('.').pop() const filename = `${Date.now()}-${randomUUID()}${ext}`
const filename = `${Date.now()}.${ext}`
const filePath = path.join(avatarsDir, filename) const filePath = path.join(avatarsDir, filename)
// Save file // Save file
@@ -366,14 +400,19 @@ export async function getChangelog(): Promise<string> {
} }
// user logic // user logic
export async function loadUsersData(): Promise<UserData> { async function loadUsersData(): Promise<UserData> {
try { try {
return await loadData<UserData>('auth') return await loadData<UserData>('auth')
} catch { } catch {
return getDefaultUsersData<UserData>() return getDefaultUsersData()
} }
} }
export async function loadUsersPublicData(): Promise<PublicUserData> {
const data = await loadUsersData()
return sanitizeUserData(data)
}
export async function saveUsersData(data: UserData): Promise<void> { export async function saveUsersData(data: UserData): Promise<void> {
return saveData('auth', data) return saveData('auth', data)
} }
@@ -391,7 +430,7 @@ export async function getUser(username: string, plainTextPassword?: string): Pro
return user return user
} }
export async function createUser(formData: FormData): Promise<User> { export async function createUser(formData: FormData): Promise<PublicUser> {
const username = formData.get('username') as string; const username = formData.get('username') as string;
let password = formData.get('password') as string | undefined; let password = formData.get('password') as string | undefined;
const avatarPath = formData.get('avatarPath') as string; const avatarPath = formData.get('avatarPath') as string;
@@ -428,10 +467,10 @@ export async function createUser(formData: FormData): Promise<User> {
}; };
await saveUsersData(newData); await saveUsersData(newData);
return newUser; return sanitizeUserData({ users: [newUser] }).users[0]
} }
export async function updateUser(userId: string, updates: Partial<Omit<User, 'id' | 'password'>>): Promise<User> { export async function updateUser(userId: string, updates: Partial<Omit<User, 'id' | 'password'>>): Promise<PublicUser> {
const data = await loadUsersData() const data = await loadUsersData()
const userIndex = data.users.findIndex(user => user.id === userId) const userIndex = data.users.findIndex(user => user.id === userId)
@@ -463,7 +502,7 @@ export async function updateUser(userId: string, updates: Partial<Omit<User, 'id
} }
await saveUsersData(newData) await saveUsersData(newData)
return updatedUser return sanitizeUserData({ users: [updatedUser] }).users[0]
} }
export async function updateUserPassword(userId: string, newPassword?: string): Promise<void> { export async function updateUserPassword(userId: string, newPassword?: string): Promise<void> {

View File

@@ -0,0 +1,197 @@
import { afterEach, beforeAll, beforeEach, describe, expect, mock, spyOn, test } from 'bun:test'
import path from 'path'
const mockReadFile = mock()
const mockRealpath = mock()
const mockLstat = mock()
mock.module('fs/promises', () => ({
default: {
readFile: mockReadFile,
realpath: mockRealpath,
lstat: mockLstat,
},
readFile: mockReadFile,
realpath: mockRealpath,
lstat: mockLstat,
}))
let GET: typeof import('./route').GET
beforeAll(async () => {
;({ GET } = await import('./route'))
})
afterEach(() => {
mock.restore()
})
describe('GET /api/avatars/[...path]', () => {
beforeEach(() => {
mockReadFile.mockReset()
mockRealpath.mockReset()
mockLstat.mockReset()
spyOn(process, 'cwd').mockReturnValue('/app')
mockRealpath.mockImplementation(async (value: string) => value)
mockLstat.mockResolvedValue({ isSymbolicLink: () => false })
})
test('returns avatar image for valid file path', async () => {
mockReadFile.mockResolvedValue(Buffer.from('avatar-binary'))
const response = await GET(new Request('http://localhost:3000/api/avatars/avatar.png'), {
params: Promise.resolve({ path: ['avatar.png'] }),
})
expect(response.status).toBe(200)
expect(response.headers.get('Content-Type')).toBe('image/png')
expect(response.headers.get('X-Content-Type-Options')).toBe('nosniff')
expect(mockReadFile).toHaveBeenCalledWith(path.resolve('/app', 'data', 'avatars', 'avatar.png'))
})
test('allows nested valid avatar paths', async () => {
mockReadFile.mockResolvedValue(Buffer.from('avatar-binary'))
const response = await GET(new Request('http://localhost:3000/api/avatars/user-1/avatar.png'), {
params: Promise.resolve({ path: ['user-1', 'avatar.png'] }),
})
expect(response.status).toBe(200)
expect(mockReadFile).toHaveBeenCalledWith(path.resolve('/app', 'data', 'avatars', 'user-1', 'avatar.png'))
})
test('supports uppercase extensions', async () => {
mockReadFile.mockResolvedValue(Buffer.from('avatar-binary'))
const response = await GET(new Request('http://localhost:3000/api/avatars/avatar.PNG'), {
params: Promise.resolve({ path: ['avatar.PNG'] }),
})
expect(response.status).toBe(200)
expect(response.headers.get('Content-Type')).toBe('image/png')
})
test('rejects traversal segments', async () => {
const response = await GET(new Request('http://localhost:3000/api/avatars/../auth.json'), {
params: Promise.resolve({ path: ['..', 'auth.json'] }),
})
expect(response.status).toBe(400)
expect(await response.json()).toEqual({ error: 'Invalid avatar path' })
expect(mockReadFile).not.toHaveBeenCalled()
})
test('rejects encoded traversal payloads', async () => {
const response = await GET(new Request('http://localhost:3000/api/avatars/%2e%2e%2fauth.json'), {
params: Promise.resolve({ path: ['%2e%2e%2fauth.json'] }),
})
expect(response.status).toBe(400)
expect(await response.json()).toEqual({ error: 'Invalid avatar path' })
expect(mockReadFile).not.toHaveBeenCalled()
})
test('rejects encoded backslash traversal payloads', async () => {
const response = await GET(new Request('http://localhost:3000/api/avatars/..%5cauth.png'), {
params: Promise.resolve({ path: ['..%5cauth.png'] }),
})
expect(response.status).toBe(400)
expect(await response.json()).toEqual({ error: 'Invalid avatar path' })
expect(mockReadFile).not.toHaveBeenCalled()
})
test('rejects null byte payloads', async () => {
const response = await GET(new Request('http://localhost:3000/api/avatars/avatar.png%00'), {
params: Promise.resolve({ path: ['avatar.png%00'] }),
})
expect(response.status).toBe(400)
expect(await response.json()).toEqual({ error: 'Invalid avatar path' })
expect(mockReadFile).not.toHaveBeenCalled()
})
test('rejects dot-only segments', async () => {
const response = await GET(new Request('http://localhost:3000/api/avatars/./avatar.png'), {
params: Promise.resolve({ path: ['.', 'avatar.png'] }),
})
expect(response.status).toBe(400)
expect(await response.json()).toEqual({ error: 'Invalid avatar path' })
expect(mockReadFile).not.toHaveBeenCalled()
})
test('rejects malformed encoded segments', async () => {
const response = await GET(new Request('http://localhost:3000/api/avatars/%E0%A4%A'), {
params: Promise.resolve({ path: ['%E0%A4%A'] }),
})
expect(response.status).toBe(400)
expect(await response.json()).toEqual({ error: 'Invalid avatar path' })
expect(mockReadFile).not.toHaveBeenCalled()
})
test('rejects unsupported file extensions', async () => {
const response = await GET(new Request('http://localhost:3000/api/avatars/config.json'), {
params: Promise.resolve({ path: ['config.json'] }),
})
expect(response.status).toBe(400)
expect(await response.json()).toEqual({ error: 'Unsupported file type' })
expect(mockReadFile).not.toHaveBeenCalled()
})
test('rejects symlinked avatar files', async () => {
mockLstat.mockResolvedValue({ isSymbolicLink: () => true })
const response = await GET(new Request('http://localhost:3000/api/avatars/avatar.png'), {
params: Promise.resolve({ path: ['avatar.png'] }),
})
expect(response.status).toBe(400)
expect(await response.json()).toEqual({ error: 'Invalid avatar path' })
expect(mockReadFile).not.toHaveBeenCalled()
})
test('rejects files whose real path escapes avatars directory', async () => {
mockRealpath.mockImplementation(async (value: string) => {
if (value === path.resolve('/app', 'data', 'avatars')) {
return value
}
return path.resolve('/app', 'data', 'auth.png')
})
const response = await GET(new Request('http://localhost:3000/api/avatars/avatar.png'), {
params: Promise.resolve({ path: ['avatar.png'] }),
})
expect(response.status).toBe(400)
expect(await response.json()).toEqual({ error: 'Invalid avatar path' })
expect(mockReadFile).not.toHaveBeenCalled()
})
test('returns 404 when file is missing', async () => {
mockLstat.mockRejectedValue({ code: 'ENOENT' })
const response = await GET(new Request('http://localhost:3000/api/avatars/missing.png'), {
params: Promise.resolve({ path: ['missing.png'] }),
})
expect(response.status).toBe(404)
expect(await response.json()).toEqual({ error: 'File not found' })
})
test('returns 500 for non-ENOENT read errors', async () => {
mockReadFile.mockRejectedValue({ code: 'EACCES' })
const response = await GET(new Request('http://localhost:3000/api/avatars/avatar.png'), {
params: Promise.resolve({ path: ['avatar.png'] }),
})
expect(response.status).toBe(500)
expect(await response.json()).toEqual({ error: 'Internal server error' })
})
})

View File

@@ -1,26 +1,109 @@
import { NextResponse } from 'next/server' import { NextResponse } from 'next/server'
import fs from 'fs/promises' import fs from 'fs/promises'
import path from 'path' import path from 'path'
import { ALLOWED_AVATAR_EXTENSIONS, AVATAR_CONTENT_TYPE } from '@/lib/avatar'
function sanitizePathSegments(pathSegments?: string[]): string[] | null {
if (!pathSegments || pathSegments.length === 0) {
return null
}
const safeSegments: string[] = []
for (const rawSegment of pathSegments) {
let segment = rawSegment
try {
segment = decodeURIComponent(rawSegment)
} catch {
return null
}
if (!segment || segment === '.' || segment === '..') {
return null
}
if (segment.includes('/') || segment.includes('\\') || segment.includes('\0')) {
return null
}
safeSegments.push(segment)
}
return safeSegments
}
function isPathInsideBase(basePath: string, targetPath: string): boolean {
return targetPath === basePath || targetPath.startsWith(`${basePath}${path.sep}`)
}
function getErrorCode(error: unknown): string | null {
if (typeof error !== 'object' || error === null || !('code' in error)) {
return null
}
const { code } = error as { code?: unknown }
return typeof code === 'string' ? code : null
}
export async function GET( export async function GET(
request: Request, _request: Request,
{ params }: { params: Promise<{ path: string[] }> } { params }: { params: Promise<{ path: string[] }> }
) { ) {
try {
const { path: pathSegments } = await Promise.resolve(params) const { path: pathSegments } = await Promise.resolve(params)
const filePath = path.join(process.cwd(), 'data', 'avatars', ...(pathSegments || [])) const safeSegments = sanitizePathSegments(pathSegments)
const file = await fs.readFile(filePath)
const ext = path.extname(filePath).slice(1) if (!safeSegments) {
return NextResponse.json({ error: 'Invalid avatar path' }, { status: 400 })
}
const avatarsDir = path.resolve(process.cwd(), 'data', 'avatars')
const filePath = path.resolve(avatarsDir, ...safeSegments)
if (!isPathInsideBase(avatarsDir, filePath)) {
return NextResponse.json({ error: 'Invalid avatar path' }, { status: 400 })
}
const ext = path.extname(filePath).toLowerCase()
if (!ALLOWED_AVATAR_EXTENSIONS.has(ext)) {
return NextResponse.json({ error: 'Unsupported file type' }, { status: 400 })
}
try {
const realAvatarsDir = await fs.realpath(avatarsDir)
const fileStats = await fs.lstat(filePath)
if (fileStats.isSymbolicLink()) {
return NextResponse.json({ error: 'Invalid avatar path' }, { status: 400 })
}
const realFilePath = await fs.realpath(filePath)
if (!isPathInsideBase(realAvatarsDir, realFilePath)) {
return NextResponse.json({ error: 'Invalid avatar path' }, { status: 400 })
}
const file = await fs.readFile(realFilePath)
return new NextResponse(file, { return new NextResponse(file, {
headers: { headers: {
'Content-Type': `image/${ext}`, 'Content-Type': AVATAR_CONTENT_TYPE[ext] ?? 'application/octet-stream',
'X-Content-Type-Options': 'nosniff',
}, },
}) })
} catch (error) { } catch (error) {
if (getErrorCode(error) === 'ENOENT') {
return NextResponse.json( return NextResponse.json(
{ error: 'File not found' }, { error: 'File not found' },
{ status: 404 } { status: 404 }
) )
} }
console.error('Error reading avatar file:', error)
return NextResponse.json(
{ error: 'Internal server error' },
{ status: 500 }
)
}
} }

View File

@@ -1,7 +1,11 @@
import { notFound } from 'next/navigation';
import { ReactNode } from "react"; import { ReactNode } from "react";
export default function Debug({children}: {children: ReactNode}) { export default function Debug({children}: {children: ReactNode}) {
if (process.env.NODE_ENV !== 'development') return <></> if (process.env.NODE_ENV !== 'development') {
notFound()
}
return ( return (
<div className="debug"> <div className="debug">
{children} {children}

View File

@@ -9,7 +9,7 @@ import { NextIntlClientProvider } from 'next-intl'
import { getLocale, getMessages } from 'next-intl/server' import { getLocale, getMessages } from 'next-intl/server'
import { DM_Sans } from 'next/font/google' import { 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, loadUsersPublicData, loadWishlistData } from './actions/data'
import './globals.css' import './globals.css'
// Clean and contemporary // Clean and contemporary
@@ -40,7 +40,7 @@ export default async function RootLayout({
loadHabitsData(), loadHabitsData(),
loadCoinsData(), loadCoinsData(),
loadWishlistData(), loadWishlistData(),
loadUsersData(), loadUsersPublicData(),
loadServerSettings(), loadServerSettings(),
]) ])
@@ -48,23 +48,6 @@ export default async function RootLayout({
// set suppressHydrationWarning to true to prevent hydration errors when using ThemeProvider (https://ui.shadcn.com/docs/dark-mode/next) // set suppressHydrationWarning to true to prevent hydration errors when using ThemeProvider (https://ui.shadcn.com/docs/dark-mode/next)
<html lang={locale} suppressHydrationWarning> <html lang={locale} suppressHydrationWarning>
<body className={activeFont.className}> <body className={activeFont.className}>
<script
dangerouslySetInnerHTML={{
__html: `
if ('serviceWorker' in navigator) {
window.addEventListener('load', () => {
navigator.serviceWorker.register('/sw.js')
.then(registration => {
console.log('ServiceWorker registration successful');
})
.catch(err => {
console.log('ServiceWorker registration failed: ', err);
});
});
}
`,
}}
/>
<JotaiProvider> <JotaiProvider>
<Suspense fallback={<LoadingSpinner />}> <Suspense fallback={<LoadingSpinner />}>
<JotaiHydrate <JotaiHydrate

View File

@@ -1,9 +1,8 @@
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Coins } from 'lucide-react'
import { FormattedNumber } from '@/components/FormattedNumber'
import { useAtom } from 'jotai'
import { useTranslations } from 'next-intl'
import { settingsAtom } from '@/lib/atoms' import { settingsAtom } from '@/lib/atoms'
import { useAtom } from 'jotai'
import { Coins } from 'lucide-react'
import { useTranslations } from 'next-intl'
import dynamic from 'next/dynamic' import dynamic from 'next/dynamic'
const TodayEarnedCoins = dynamic(() => import('./TodayEarnedCoins'), { ssr: false }) const TodayEarnedCoins = dynamic(() => import('./TodayEarnedCoins'), { ssr: false })
@@ -21,9 +20,7 @@ export default function CoinBalance({ coinBalance }: { coinBalance: number }) {
<Coins className="h-12 w-12 text-yellow-400 mr-4" /> <Coins className="h-12 w-12 text-yellow-400 mr-4" />
<div className="flex flex-col"> <div className="flex flex-col">
<div className="flex flex-col"> <div className="flex flex-col">
<span className="text-4xl font-bold"> <span className="text-4xl font-bold">{coinBalance}</span>
<FormattedNumber amount={coinBalance} settings={settings} />
</span>
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">
<TodayEarnedCoins longFormat={true} /> <TodayEarnedCoins longFormat={true} />
</div> </div>

View File

@@ -1,6 +1,5 @@
'use client' 'use client'
import { FormattedNumber } from '@/components/FormattedNumber'
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar' import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
@@ -10,7 +9,7 @@ import { useCoins } from '@/hooks/useCoins'
import { currentUserAtom, settingsAtom, usersAtom } from '@/lib/atoms' import { currentUserAtom, settingsAtom, usersAtom } from '@/lib/atoms'
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 { calculateTransactionsToday, d2s, t2d } from '@/lib/utils'
import { useAtom } from 'jotai' import { useAtom } from 'jotai'
import { History } from 'lucide-react' import { History } from 'lucide-react'
import { useTranslations } from 'next-intl' import { useTranslations } from 'next-intl'
@@ -33,8 +32,7 @@ export default function CoinsManager() {
coinsEarnedToday, coinsEarnedToday,
totalEarned, totalEarned,
totalSpent, totalSpent,
coinsSpentToday, coinsSpentToday
transactionsToday
} = useCoins({ selectedUser }) } = useCoins({ selectedUser })
const [settings] = useAtom(settingsAtom) const [settings] = useAtom(settingsAtom)
const [usersData] = useAtom(usersAtom) const [usersData] = useAtom(usersAtom)
@@ -128,7 +126,7 @@ export default function CoinsManager() {
<span className="text-2xl animate-bounce hover:animate-none cursor-default">💰</span> <span className="text-2xl animate-bounce hover:animate-none cursor-default">💰</span>
<div> <div>
<div className="text-sm font-normal text-muted-foreground">{t('currentBalanceLabel')}</div> <div className="text-sm font-normal text-muted-foreground">{t('currentBalanceLabel')}</div>
<div className="text-3xl font-bold"><FormattedNumber amount={balance} settings={settings} /> {t('coinsSuffix')}</div> <div className="text-3xl font-bold">{balance} {t('coinsSuffix')}</div>
</div> </div>
</CardTitle> </CardTitle>
</CardHeader> </CardHeader>
@@ -215,16 +213,12 @@ export default function CoinsManager() {
{/* Top Row - Totals */} {/* Top Row - Totals */}
<div className="p-4 rounded-lg bg-green-100 dark:bg-green-900"> <div className="p-4 rounded-lg bg-green-100 dark:bg-green-900">
<div className="text-sm text-green-800 dark:text-green-100 mb-1">{t('totalEarnedLabel')}</div> <div className="text-sm text-green-800 dark:text-green-100 mb-1">{t('totalEarnedLabel')}</div>
<div className="text-2xl font-bold text-green-900 dark:text-green-50"> <div className="text-2xl font-bold text-green-900 dark:text-green-50">{totalEarned} 🪙</div>
<FormattedNumber amount={totalEarned} settings={settings} /> 🪙
</div>
</div> </div>
<div className="p-4 rounded-lg bg-red-100 dark:bg-red-900"> <div className="p-4 rounded-lg bg-red-100 dark:bg-red-900">
<div className="text-sm text-red-800 dark:text-red-100 mb-1">{t('totalSpentLabel')}</div> <div className="text-sm text-red-800 dark:text-red-100 mb-1">{t('totalSpentLabel')}</div>
<div className="text-2xl font-bold text-red-900 dark:text-red-50"> <div className="text-2xl font-bold text-red-900 dark:text-red-50">{totalSpent} 💸</div>
<FormattedNumber amount={totalSpent} settings={settings} /> 💸
</div>
</div> </div>
<div className="p-4 rounded-lg bg-pink-100 dark:bg-pink-900"> <div className="p-4 rounded-lg bg-pink-100 dark:bg-pink-900">
@@ -237,22 +231,18 @@ export default function CoinsManager() {
{/* Bottom Row - Today */} {/* Bottom Row - Today */}
<div className="p-4 rounded-lg bg-blue-100 dark:bg-blue-900"> <div className="p-4 rounded-lg bg-blue-100 dark:bg-blue-900">
<div className="text-sm text-blue-800 dark:text-blue-100 mb-1">{t('todaysEarnedLabel')}</div> <div className="text-sm text-blue-800 dark:text-blue-100 mb-1">{t('todaysEarnedLabel')}</div>
<div className="text-2xl font-bold text-blue-900 dark:text-blue-50"> <div className="text-2xl font-bold text-blue-900 dark:text-blue-50">{coinsEarnedToday} 🪙</div>
<FormattedNumber amount={coinsEarnedToday} settings={settings} /> 🪙
</div>
</div> </div>
<div className="p-4 rounded-lg bg-purple-100 dark:bg-purple-900"> <div className="p-4 rounded-lg bg-purple-100 dark:bg-purple-900">
<div className="text-sm text-purple-800 dark:text-purple-100 mb-1">{t('todaysSpentLabel')}</div> <div className="text-sm text-purple-800 dark:text-purple-100 mb-1">{t('todaysSpentLabel')}</div>
<div className="text-2xl font-bold text-purple-900 dark:text-purple-50"> <div className="text-2xl font-bold text-purple-900 dark:text-purple-50">{coinsSpentToday} 💸</div>
<FormattedNumber amount={coinsSpentToday} settings={settings} /> 💸
</div>
</div> </div>
<div className="p-4 rounded-lg bg-orange-100 dark:bg-orange-900"> <div className="p-4 rounded-lg bg-orange-100 dark:bg-orange-900">
<div className="text-sm text-orange-800 dark:text-orange-100 mb-1">{t('todaysTransactionsLabel')}</div> <div className="text-sm text-orange-800 dark:text-orange-100 mb-1">{t('todaysTransactionsLabel')}</div>
<div className="text-2xl font-bold text-orange-900 dark:text-orange-50"> <div className="text-2xl font-bold text-orange-900 dark:text-orange-50">
{transactionsToday} 📊 {calculateTransactionsToday(transactions, settings.system.timezone)} 📊
</div> </div>
</div> </div>
</div> </div>

View File

@@ -13,22 +13,22 @@ import {
TooltipTrigger, TooltipTrigger,
} from "@/components/ui/tooltip" } from "@/components/ui/tooltip"
import { useHabits } from '@/hooks/useHabits' import { useHabits } from '@/hooks/useHabits'
import { browserSettingsAtom, completedHabitsMapAtom, hasTasksAtom, pomodoroAtom, settingsAtom } from '@/lib/atoms' import { browserSettingsAtom, completedHabitsMapAtom, settingsAtom } from '@/lib/atoms'
import { DESKTOP_DISPLAY_ITEM_COUNT } from '@/lib/constants' import { DESKTOP_DISPLAY_ITEM_COUNT } from '@/lib/constants'
import { Habit, WishlistItemType } from '@/lib/types' import { Habit, WishlistItemType } from '@/lib/types'
import { cn, d2t, getNow, getTodayInTimezone, isHabitDue, isSameDate, isTaskOverdue, t2d } from '@/lib/utils' import { cn, d2t, getNow, getTodayInTimezone, isHabitDue, isSameDate, isTaskOverdue, t2d } from '@/lib/utils'
import { useAtom } from 'jotai' import { useAtom } from 'jotai'
import { AlertTriangle, ArrowRight, ChevronDown, ChevronUp, Circle, CircleCheck, Coins, Pin, Plus } from 'lucide-react'; // Removed unused icons import { AlertTriangle, ArrowRight, ChevronDown, ChevronUp, Circle, CircleCheck, Coins, Pin, Plus } from 'lucide-react';
import { useTranslations } from 'next-intl' import { useTranslations } from 'next-intl'
import Link from 'next/link' import Link from 'next/link'
import { useState } from 'react' import { useState } from 'react'
import AddEditHabitModal from './AddEditHabitModal' import AddEditHabitModal from './AddEditHabitModal'
import CompletionCountBadge from './CompletionCountBadge' import CompletionCountBadge from './CompletionCountBadge'
import ConfirmDialog from './ConfirmDialog' import ConfirmDialog from './ConfirmDialog'
import DrawingDisplay from './DrawingDisplay'
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 DrawingDisplay from './DrawingDisplay'
interface UpcomingItemsProps { interface UpcomingItemsProps {
habits: Habit[] habits: Habit[]
@@ -54,8 +54,7 @@ const ItemSection = ({
addNewItem, addNewItem,
}: ItemSectionProps) => { }: ItemSectionProps) => {
const t = useTranslations('DailyOverview'); const t = useTranslations('DailyOverview');
const { completeHabit, undoComplete, saveHabit, deleteHabit, archiveHabit, habitFreqMap } = useHabits(); const { completeHabit, undoComplete, saveHabit, deleteHabit, habitFreqMap } = useHabits();
const [_, setPomo] = useAtom(pomodoroAtom);
const [browserSettings, setBrowserSettings] = useAtom(browserSettingsAtom); const [browserSettings, setBrowserSettings] = useAtom(browserSettingsAtom);
const [settings] = useAtom(settingsAtom); const [settings] = useAtom(settingsAtom);
const [completedHabitsMap] = useAtom(completedHabitsMapAtom); const [completedHabitsMap] = useAtom(completedHabitsMapAtom);
@@ -397,8 +396,6 @@ export default function DailyOverview({
return a.coinCost - b.coinCost return a.coinCost - b.coinCost
}) })
const [hasTasks] = useAtom(hasTasksAtom)
const [, setPomo] = useAtom(pomodoroAtom)
const [modalConfig, setModalConfig] = useState<{ const [modalConfig, setModalConfig] = useState<{
isOpen: boolean, isOpen: boolean,
isTask: boolean isTask: boolean
@@ -416,7 +413,7 @@ export default function DailyOverview({
<CardContent> <CardContent>
<div className="space-y-6"> <div className="space-y-6">
{/* Tasks Section */} {/* Tasks Section */}
{hasTasks && ( {habits.some(habit => habit.isTask === true) && (
<ItemSection <ItemSection
title={t('dailyTasksTitle')} title={t('dailyTasksTitle')}
items={dailyTasks} items={dailyTasks}

View File

@@ -1,16 +0,0 @@
import { formatNumber } from '@/lib/utils/formatNumber'
import { Settings } from '@/lib/types'
interface FormattedNumberProps {
amount: number
settings: Settings
className?: string
}
export function FormattedNumber({ amount, settings, className }: FormattedNumberProps) {
return (
<span className={`break-all ${className || ''}`}>
{formatNumber({ amount, settings })}
</span>
)
}

View File

@@ -4,7 +4,7 @@ import CompletionCountBadge from '@/components/CompletionCountBadge'
import { Calendar } from '@/components/ui/calendar' import { Calendar } from '@/components/ui/calendar'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { useHabits } from '@/hooks/useHabits' import { useHabits } from '@/hooks/useHabits'
import { completedHabitsMapAtom, habitsAtom, hasTasksAtom, settingsAtom } from '@/lib/atoms' import { completedHabitsMapAtom, habitsAtom, settingsAtom } from '@/lib/atoms'
import { Habit } from '@/lib/types' import { Habit } from '@/lib/types'
import { d2s, getCompletionsForDate, getISODate, getNow, isHabitDue } from '@/lib/utils' import { d2s, getCompletionsForDate, getISODate, getNow, isHabitDue } from '@/lib/utils'
import { useAtom } from 'jotai' import { useAtom } from 'jotai'
@@ -29,7 +29,6 @@ export default function HabitCalendar() {
const [selectedDateTime, setSelectedDateTime] = useState<DateTime>(getNow({ timezone: settings.system.timezone })) const [selectedDateTime, setSelectedDateTime] = useState<DateTime>(getNow({ timezone: settings.system.timezone }))
const selectedDate = selectedDateTime.toFormat("yyyy-MM-dd") const selectedDate = selectedDateTime.toFormat("yyyy-MM-dd")
const [habitsData] = useAtom(habitsAtom) const [habitsData] = useAtom(habitsAtom)
const [hasTasks] = useAtom(hasTasksAtom)
const habits = habitsData.habits const habits = habitsData.habits
const [completedHabitsMap] = useAtom(completedHabitsMapAtom) const [completedHabitsMap] = useAtom(completedHabitsMapAtom)
@@ -83,7 +82,7 @@ export default function HabitCalendar() {
<CardContent> <CardContent>
{selectedDateTime && ( {selectedDateTime && (
<div className="space-y-8"> <div className="space-y-8">
{hasTasks && ( {habits.some(habit => habit.isTask === true) && (
<div className="pt-2 border-t"> <div className="pt-2 border-t">
<div className="flex items-center justify-between mb-3"> <div className="flex items-center justify-between mb-3">
<h3 className="font-medium text-sm text-muted-foreground uppercase tracking-wide">{t('tasksSectionTitle')}</h3> <h3 className="font-medium text-sm text-muted-foreground uppercase tracking-wide">{t('tasksSectionTitle')}</h3>

View File

@@ -1,22 +1,16 @@
'use client' 'use client'
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { completedHabitsMapAtom, hasTasksAtom, settingsAtom } from '@/lib/atoms'; // Added completedHabitsMapAtom import { completedHabitsMapAtom, settingsAtom } from '@/lib/atoms';
import { Habit } from '@/lib/types'; import { Habit } from '@/lib/types';
import { d2s, getNow } from '@/lib/utils'; // Removed getCompletedHabitsForDate import { d2s, getNow } from '@/lib/utils';
import { useAtom } from 'jotai'; import { useAtom } from 'jotai';
import { useTranslations } from 'next-intl'; import { useTranslations } from 'next-intl';
import { CartesianGrid, Line, LineChart, ResponsiveContainer, Tooltip, XAxis, YAxis } from 'recharts'; import { CartesianGrid, Line, LineChart, ResponsiveContainer, Tooltip, XAxis, YAxis } from 'recharts';
export default function HabitStreak({ habits }: { habits: Habit[] }) {
interface HabitStreakProps {
habits: Habit[]
}
export default function HabitStreak({ habits }: HabitStreakProps) {
const t = useTranslations('HabitStreak'); const t = useTranslations('HabitStreak');
const [settings] = useAtom(settingsAtom) const [settings] = useAtom(settingsAtom)
const [hasTasks] = useAtom(hasTasksAtom)
const [completedHabitsMap] = useAtom(completedHabitsMapAtom) // Use the atom const [completedHabitsMap] = useAtom(completedHabitsMapAtom) // Use the atom
// Get the last 7 days of data // Get the last 7 days of data
@@ -72,7 +66,7 @@ export default function HabitStreak({ habits }: HabitStreakProps) {
strokeWidth={2} strokeWidth={2}
dot={false} dot={false}
/> />
{hasTasks && ( {habits.some(habit => habit.isTask === true) && (
<Line <Line
type="monotone" type="monotone"
name={t('tooltipTasksLabel')} name={t('tooltipTasksLabel')}

View File

@@ -1,13 +1,12 @@
'use client' 'use client'
import Link from 'next/link'
import { useAtom } from 'jotai'
import { settingsAtom } from '@/lib/atoms'
import { useCoins } from '@/hooks/useCoins' import { useCoins } from '@/hooks/useCoins'
import { FormattedNumber } from '@/components/FormattedNumber' import { settingsAtom } from '@/lib/atoms'
import { useAtom } from 'jotai'
import { Coins } from 'lucide-react' import { Coins } from 'lucide-react'
import NotificationBell from './NotificationBell'
import dynamic from 'next/dynamic' import dynamic from 'next/dynamic'
import Link from 'next/link'
import NotificationBell from './NotificationBell'
import { Profile } from './Profile' import { Profile } from './Profile'
const TodayEarnedCoins = dynamic(() => import('./TodayEarnedCoins'), { ssr: false }) const TodayEarnedCoins = dynamic(() => import('./TodayEarnedCoins'), { ssr: false })
@@ -21,11 +20,7 @@ export default function 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"> <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" /> <Coins className="h-5 w-5 text-yellow-500 dark:text-yellow-400" />
<div className="flex items-baseline gap-1 sm:gap-2"> <div className="flex items-baseline gap-1 sm:gap-2">
<FormattedNumber <span className="text-gray-800 dark:text-gray-100 font-medium text-lg">{balance}</span>
amount={balance}
settings={settings}
className="text-gray-800 dark:text-gray-100 font-medium text-lg"
/>
<div className="hidden sm:block"> <div className="hidden sm:block">
<TodayEarnedCoins /> <TodayEarnedCoins />
</div> </div>

View File

@@ -14,7 +14,7 @@ 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 { User, CoinTransaction } from '@/lib/types'; import { CoinTransaction } from '@/lib/types';
export default function NotificationBell() { export default function NotificationBell() {
const t = useTranslations('NotificationBell'); const t = useTranslations('NotificationBell');
@@ -121,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 as currentUser can be undefined currentUser={currentUser ?? null}
unreadNotifications={unreadNotifications} unreadNotifications={unreadNotifications}
displayedReadNotifications={displayedReadNotifications} displayedReadNotifications={displayedReadNotifications}
habitsData={habitsData} // Pass necessary data down habitsData={habitsData} // Pass necessary data down

View File

@@ -8,19 +8,19 @@ import {
TooltipProvider, TooltipProvider,
TooltipTrigger, TooltipTrigger,
} from '@/components/ui/tooltip'; } from '@/components/ui/tooltip';
import { CoinTransaction, HabitsData, User, UserData, WishlistData } from '@/lib/types'; import { CoinTransaction, HabitsData, PublicUser, PublicUserData, WishlistData } from '@/lib/types';
import { t2d } from '@/lib/utils'; import { t2d } from '@/lib/utils';
import { Info } from 'lucide-react'; import { Info } from 'lucide-react';
import { useTranslations } from 'next-intl'; import { useTranslations } from 'next-intl';
import Link from 'next/link'; import Link from 'next/link';
interface NotificationDropdownProps { interface NotificationDropdownProps {
currentUser: User | null; currentUser: PublicUser | null;
unreadNotifications: CoinTransaction[]; unreadNotifications: CoinTransaction[];
displayedReadNotifications: CoinTransaction[]; displayedReadNotifications: CoinTransaction[];
habitsData: HabitsData; habitsData: HabitsData;
wishlistData: WishlistData; wishlistData: WishlistData;
usersData: UserData; usersData: PublicUserData;
} }
// Helper function to get the name of the related item // Helper function to get the name of the related item
@@ -47,7 +47,7 @@ export default function NotificationDropdown({
const t = useTranslations('NotificationDropdown'); const t = useTranslations('NotificationDropdown');
// Helper function to generate notification message, now using t // Helper function to generate notification message, now using t
const getNotificationMessage = (tx: CoinTransaction, triggeringUser?: User, relatedItemName?: string): string => { const getNotificationMessage = (tx: CoinTransaction, triggeringUser?: PublicUser, relatedItemName?: string): string => {
const username = triggeringUser?.username || t('defaultUsername'); const username = triggeringUser?.username || t('defaultUsername');
const itemName = relatedItemName || t('defaultItemName'); const itemName = relatedItemName || t('defaultItemName');
switch (tx.type) { switch (tx.type) {

View File

@@ -1,7 +1,7 @@
'use client'; 'use client';
import { toast } from '@/hooks/use-toast'; import { toast } from '@/hooks/use-toast';
import { User } from '@/lib/types'; import { SafeUser } from '@/lib/types';
import { User as UserIcon } from 'lucide-react'; import { User as UserIcon } from 'lucide-react';
import { useTranslations } from 'next-intl'; import { useTranslations } from 'next-intl';
import { useState } from 'react'; import { useState } from 'react';
@@ -11,7 +11,7 @@ import { Input } from './ui/input';
import { Label } from './ui/label'; import { Label } from './ui/label';
interface PasswordEntryFormProps { interface PasswordEntryFormProps {
user: User; user: SafeUser;
onCancel: () => void; onCancel: () => void;
onSubmit: (password: string) => Promise<void>; onSubmit: (password: string) => Promise<void>;
error?: string; error?: string;
@@ -24,7 +24,7 @@ export default function PasswordEntryForm({
error error
}: PasswordEntryFormProps) { }: PasswordEntryFormProps) {
const t = useTranslations('PasswordEntryForm'); const t = useTranslations('PasswordEntryForm');
const hasPassword = !!user.password; const hasPassword = user.hasPassword ?? false;
const [password, setPassword] = useState(''); const [password, setPassword] = useState('');
const handleSubmit = async (e: React.FormEvent) => { const handleSubmit = async (e: React.FormEvent) => {

View File

@@ -3,8 +3,8 @@
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { Progress } from '@/components/ui/progress' import { Progress } from '@/components/ui/progress'
import { useHabits } from '@/hooks/useHabits' import { useHabits } from '@/hooks/useHabits'
import { habitsAtom, pomodoroAtom, pomodoroTodayCompletionsAtom } from '@/lib/atoms' import { habitsAtom, pomodoroAtom, settingsAtom } from '@/lib/atoms'
import { cn } from '@/lib/utils' import { cn, getTodayCompletions } from '@/lib/utils'
import { useAtom } from 'jotai' import { useAtom } from 'jotai'
import { Clock, Minus, Pause, Play, RotateCw, SkipForward, X } from 'lucide-react' import { Clock, Minus, Pause, Play, RotateCw, SkipForward, X } from 'lucide-react'
import { useTranslations } from 'next-intl' import { useTranslations } from 'next-intl'
@@ -41,12 +41,13 @@ export default function PomodoroTimer() {
const [pomo, setPomo] = useAtom(pomodoroAtom) const [pomo, setPomo] = useAtom(pomodoroAtom)
const { show, selectedHabitId, autoStart, minimized } = pomo const { show, selectedHabitId, autoStart, minimized } = pomo
const [habitsData] = useAtom(habitsAtom) const [habitsData] = useAtom(habitsAtom)
const [settingsData] = useAtom(settingsAtom)
const { completeHabit } = useHabits() const { completeHabit } = useHabits()
const selectedHabit = selectedHabitId ? habitsData.habits.find(habit => habit.id === selectedHabitId) : null const selectedHabit = selectedHabitId ? habitsData.habits.find(habit => habit.id === selectedHabitId) : null
const [timeLeft, setTimeLeft] = useState(PomoConfigs.focus.duration) const [timeLeft, setTimeLeft] = useState(PomoConfigs.focus.duration)
const [state, setState] = useState<'started' | 'stopped' | 'paused'>(autoStart ? 'started' : 'stopped') const [state, setState] = useState<'started' | 'stopped' | 'paused'>(autoStart ? 'started' : 'stopped')
const wakeLock = useRef<WakeLockSentinel | null>(null) const wakeLock = useRef<WakeLockSentinel | null>(null)
const [todayCompletions] = useAtom(pomodoroTodayCompletionsAtom) const todayCompletions = getTodayCompletions(pomo, habitsData, settingsData);
const currentTimerRef = useRef<PomoConfig>(PomoConfigs.focus) const currentTimerRef = useRef<PomoConfig>(PomoConfigs.focus)
const [currentLabel, setCurrentLabel] = useState(() => { const [currentLabel, setCurrentLabel] = useState(() => {
const labels = currentTimerRef.current.getLabels(); const labels = currentTimerRef.current.getLabels();

View File

@@ -1,20 +1,15 @@
import { useAtom } from 'jotai'
import { useTranslations } from 'next-intl'
import { settingsAtom } from '@/lib/atoms'
import { useCoins } from '@/hooks/useCoins' import { useCoins } from '@/hooks/useCoins'
import { FormattedNumber } from '@/components/FormattedNumber' import { useTranslations } from 'next-intl'
export default function TodayEarnedCoins({ longFormat }: { longFormat?: boolean }) { export default function TodayEarnedCoins({ longFormat }: { longFormat?: boolean }) {
const t = useTranslations('TodayEarnedCoins') const t = useTranslations('TodayEarnedCoins')
const [settings] = useAtom(settingsAtom)
const { coinsEarnedToday } = useCoins() const { coinsEarnedToday } = useCoins()
if (coinsEarnedToday <= 0) return <></>; if (coinsEarnedToday <= 0) return <></>;
return ( return (
<span className="text-md text-green-600 dark:text-green-400 font-medium mt-1"> <span className="text-md text-green-600 dark:text-green-400 font-medium mt-1">
{"+"} {"+"}{coinsEarnedToday}
<FormattedNumber amount={coinsEarnedToday} settings={settings} />
{longFormat ? {longFormat ?
<span className="text-sm text-muted-foreground"> {t('todaySuffix')}</span> <span className="text-sm text-muted-foreground"> {t('todaySuffix')}</span>
: null} : null}

View File

@@ -59,7 +59,7 @@ export default function UserForm({ userId, onCancel, onSuccess }: UserFormProps)
const [avatarPath, setAvatarPath] = useState(user?.avatarPath) const [avatarPath, setAvatarPath] = useState(user?.avatarPath)
const [username, setUsername] = useState(user?.username || ''); const [username, setUsername] = useState(user?.username || '');
const [password, setPassword] = useState<string | undefined>(''); const [password, setPassword] = useState<string | undefined>('');
const [disablePassword, setDisablePassword] = useState(user?.password === '' || serverSettings.isDemo); const [disablePassword, setDisablePassword] = useState(user ? !user.hasPassword : serverSettings.isDemo);
const [error, setError] = useState(''); const [error, setError] = useState('');
const [avatarFile, setAvatarFile] = useState<File | null>(null); const [avatarFile, setAvatarFile] = useState<File | null>(null);
const [isAdmin, setIsAdmin] = useState(user?.isAdmin || false); const [isAdmin, setIsAdmin] = useState(user?.isAdmin || false);
@@ -173,7 +173,7 @@ export default function UserForm({ userId, onCancel, onSuccess }: UserFormProps)
avatarPath, avatarPath,
permissions, permissions,
isAdmin, isAdmin,
password: disablePassword ? '' : (password || u.password) // use the correct password to update atom hasPassword: disablePassword ? false : (password ? true : !!u.hasPassword)
} : u } : u
), ),
})); }));

View File

@@ -3,7 +3,7 @@
import { signIn } from '@/app/actions/user'; import { signIn } from '@/app/actions/user';
import { toast } from '@/hooks/use-toast'; import { toast } from '@/hooks/use-toast';
import { currentUserAtom, usersAtom } from '@/lib/atoms'; import { currentUserAtom, usersAtom } from '@/lib/atoms';
import { SafeUser, User } from '@/lib/types'; import { SafeUser } 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';
@@ -15,8 +15,6 @@ import UserForm from './UserForm';
import { Avatar, AvatarFallback, AvatarImage } from './ui/avatar'; import { Avatar, AvatarFallback, AvatarImage } from './ui/avatar';
import { Dialog, DialogContent, DialogHeader, DialogTitle } from './ui/dialog'; import { Dialog, DialogContent, DialogHeader, DialogTitle } from './ui/dialog';
function UserCard({ function UserCard({
user, user,
onSelect, onSelect,
@@ -24,7 +22,7 @@ function UserCard({
showEdit, showEdit,
isCurrentUser, isCurrentUser,
}: { }: {
user: User, user: SafeUser,
onSelect: () => void, onSelect: () => void,
onEdit: () => void, onEdit: () => void,
showEdit: boolean, showEdit: boolean,
@@ -99,7 +97,7 @@ function UserSelectionView({
onEditUser, onEditUser,
onCreateUser, onCreateUser,
}: { }: {
users: User[], users: SafeUser[],
currentUserFromHook?: SafeUser, currentUserFromHook?: SafeUser,
onUserSelect: (userId: string) => void, onUserSelect: (userId: string) => void,
onEditUser: (userId: string) => void, onEditUser: (userId: string) => void,

View File

@@ -2,15 +2,13 @@ import { addCoins, removeCoins, saveCoinsData } from '@/app/actions/data';
import { toast } from '@/hooks/use-toast'; import { toast } from '@/hooks/use-toast';
import { import {
coinsAtom, coinsAtom,
coinsBalanceAtom,
coinsEarnedTodayAtom, coinsEarnedTodayAtom,
coinsSpentTodayAtom, coinsSpentTodayAtom,
currentUserAtom, currentUserAtom,
currentUserIdAtom,
settingsAtom, settingsAtom,
totalEarnedAtom, totalEarnedAtom,
totalSpentAtom, usersAtom
transactionsTodayAtom,
usersAtom,
} from '@/lib/atoms'; } from '@/lib/atoms';
import { MAX_COIN_LIMIT } from '@/lib/constants'; import { MAX_COIN_LIMIT } from '@/lib/constants';
import { CoinsData } from '@/lib/types'; import { CoinsData } from '@/lib/types';
@@ -24,27 +22,24 @@ export function useCoins(options?: { selectedUser?: string }) {
const tCommon = useTranslations('Common'); const tCommon = useTranslations('Common');
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] = useAtom(currentUserAtom) const [currentUser] = useAtom(currentUserAtom)
const [allCoinsData] = useAtom(coinsAtom) // All coin transactions const [coinsData] = useAtom(coinsAtom) // All coin transactions
const [loggedInUserBalance] = useAtom(coinsBalanceAtom) // Balance of the *currently logged-in* user const [loggedInUserId] = useAtom(currentUserIdAtom);
const loggedInUserBalance = loggedInUserId ? coins.transactions.filter(transaction => transaction.userId === loggedInUserId).reduce((sum, transaction) => sum + transaction.amount, 0) : 0;
const [atomCoinsEarnedToday] = useAtom(coinsEarnedTodayAtom); const [atomCoinsEarnedToday] = useAtom(coinsEarnedTodayAtom);
const [atomTotalEarned] = useAtom(totalEarnedAtom) const [atomTotalEarned] = useAtom(totalEarnedAtom)
const [atomTotalSpent] = useAtom(totalSpentAtom)
const [atomCoinsSpentToday] = useAtom(coinsSpentTodayAtom); const [atomCoinsSpentToday] = useAtom(coinsSpentTodayAtom);
const [atomTransactionsToday] = useAtom(transactionsTodayAtom); const targetUser = options?.selectedUser ? users.find(u => u.id === options.selectedUser) : currentUser
const targetUser = options?.selectedUser ? users.users.find(u => u.id === options.selectedUser) : currentUser
const transactions = useMemo(() => { const transactions = useMemo(() => {
return allCoinsData.transactions.filter(t => t.userId === targetUser?.id); return coinsData.transactions.filter(t => t.userId === targetUser?.id);
}, [allCoinsData, targetUser?.id]); }, [coinsData, targetUser?.id]);
const timezone = settings.system.timezone; const timezone = settings.system.timezone;
const [coinsEarnedToday, setCoinsEarnedToday] = useState(0); const [coinsEarnedToday, setCoinsEarnedToday] = useState(0);
const [totalEarned, setTotalEarned] = useState(0); const [totalEarned, setTotalEarned] = useState(0);
const [totalSpent, setTotalSpent] = useState(0);
const [coinsSpentToday, setCoinsSpentToday] = useState(0); const [coinsSpentToday, setCoinsSpentToday] = useState(0);
const [transactionsToday, setTransactionsToday] = useState<number>(0);
const [balance, setBalance] = useState(0); const [balance, setBalance] = useState(0);
useEffect(() => { useEffect(() => {
@@ -53,9 +48,7 @@ export function useCoins(options?: { selectedUser?: string }) {
// If the target user is the currently logged-in user, use the derived atom's value // If the target user is the currently logged-in user, use the derived atom's value
setCoinsEarnedToday(atomCoinsEarnedToday); setCoinsEarnedToday(atomCoinsEarnedToday);
setTotalEarned(atomTotalEarned); setTotalEarned(atomTotalEarned);
setTotalSpent(atomTotalSpent);
setCoinsSpentToday(atomCoinsSpentToday); setCoinsSpentToday(atomCoinsSpentToday);
setTransactionsToday(atomTransactionsToday);
setBalance(loggedInUserBalance); setBalance(loggedInUserBalance);
} else if (targetUser?.id) { } else if (targetUser?.id) {
// If an admin is viewing another user, calculate their metrics manually // If an admin is viewing another user, calculate their metrics manually
@@ -65,14 +58,9 @@ export function useCoins(options?: { selectedUser?: string }) {
const totalEarnedVal = calculateTotalEarned(transactions); const totalEarnedVal = calculateTotalEarned(transactions);
setTotalEarned(roundToInteger(totalEarnedVal)); setTotalEarned(roundToInteger(totalEarnedVal));
const totalSpentVal = calculateTotalSpent(transactions);
setTotalSpent(roundToInteger(totalSpentVal));
const spentToday = calculateCoinsSpentToday(transactions, timezone); const spentToday = calculateCoinsSpentToday(transactions, timezone);
setCoinsSpentToday(roundToInteger(spentToday)); setCoinsSpentToday(roundToInteger(spentToday));
setTransactionsToday(calculateTransactionsToday(transactions, timezone)); // This is a count
const calculatedBalance = transactions.reduce((acc, t) => acc + t.amount, 0); const calculatedBalance = transactions.reduce((acc, t) => acc + t.amount, 0);
setBalance(roundToInteger(calculatedBalance)); setBalance(roundToInteger(calculatedBalance));
} }
@@ -84,9 +72,7 @@ export function useCoins(options?: { selectedUser?: string }) {
loggedInUserBalance, loggedInUserBalance,
atomCoinsEarnedToday, atomCoinsEarnedToday,
atomTotalEarned, atomTotalEarned,
atomTotalSpent, atomCoinsSpentToday
atomCoinsSpentToday,
atomTransactionsToday,
]); ]);
const add = async (amount: number, description: string, note?: string) => { const add = async (amount: number, description: string, note?: string) => {
@@ -186,8 +172,7 @@ export function useCoins(options?: { selectedUser?: string }) {
transactions: transactions, transactions: transactions,
coinsEarnedToday, coinsEarnedToday,
totalEarned, totalEarned,
totalSpent, totalSpent: calculateTotalSpent(coins.transactions),
coinsSpentToday, coinsSpentToday
transactionsToday
} }
} }

View File

@@ -1,12 +1,13 @@
import { addCoins, removeCoins, saveHabitsData } from '@/app/actions/data' import { addCoins, removeCoins, saveHabitsData } from '@/app/actions/data'
import { ToastAction } from '@/components/ui/toast' import { ToastAction } from '@/components/ui/toast'
import { toast } from '@/hooks/use-toast' import { toast } from '@/hooks/use-toast'
import { coinsAtom, currentUserAtom, habitFreqMapAtom, habitsAtom, settingsAtom, usersAtom } from '@/lib/atoms' import { coinsAtom, currentUserAtom, habitsAtom, settingsAtom } from '@/lib/atoms'
import { Habit } from '@/lib/types' import { Freq, Habit } from '@/lib/types'
import { import {
d2s, d2s,
d2t, d2t,
getCompletionsForDate, getCompletionsForDate,
getHabitFreq,
getISODate, getISODate,
getNow, getNow,
getTodayInTimezone, getTodayInTimezone,
@@ -23,12 +24,15 @@ import { useTranslations } from 'next-intl'
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 [currentUser] = useAtom(currentUserAtom) 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)
const [habitFreqMap] = useAtom(habitFreqMapAtom) // const [habitFreqMap] = useAtom(habitFreqMapAtom)
const habitFreqMap = new Map<string, Freq>();
habitsData.habits.forEach(habit => {
habitFreqMap.set(habit.id, getHabitFreq(habit));
})
const completeHabit = async (habit: Habit) => { const completeHabit = async (habit: Habit) => {
if (!handlePermissionCheck(currentUser, 'habit', 'interact', tCommon)) return if (!handlePermissionCheck(currentUser, 'habit', 'interact', tCommon)) return

View File

@@ -2,11 +2,7 @@ import {
calculateCoinsEarnedToday, calculateCoinsEarnedToday,
calculateCoinsSpentToday, calculateCoinsSpentToday,
calculateTotalEarned, calculateTotalEarned,
calculateTotalSpent,
calculateTransactionsToday,
generateCryptoHash, generateCryptoHash,
getCompletionsForToday,
getHabitFreq,
isHabitDue, isHabitDue,
prepareDataForHashing, prepareDataForHashing,
roundToInteger, roundToInteger,
@@ -16,42 +12,41 @@ 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, BrowserSettings,
CompletionCache, CompletionCache,
Freq,
getDefaultCoinsData, getDefaultCoinsData,
getDefaultHabitsData, getDefaultHabitsData,
getDefaultPublicUsersData,
getDefaultServerSettings, getDefaultServerSettings,
getDefaultSettings, getDefaultSettings,
getDefaultUsersData,
getDefaultWishlistData, getDefaultWishlistData,
Habit, Habit,
HabitsData, PomodoroAtom,
ServerSettings, UserId
Settings,
UserData,
UserId,
WishlistData
} from "./types"; } from "./types";
export interface BrowserSettings {
expandedHabits: boolean
expandedTasks: boolean
expandedWishlist: boolean
}
export const browserSettingsAtom = atomWithStorage('browserSettings', { export const browserSettingsAtom = atomWithStorage('browserSettings', {
expandedHabits: false, expandedHabits: false,
expandedTasks: false, expandedTasks: false,
expandedWishlist: false expandedWishlist: false
} as BrowserSettings) } as BrowserSettings)
export const usersAtom = atom(getDefaultUsersData<UserData>()) export const usersAtom = atom(getDefaultPublicUsersData())
export const settingsAtom = atom(getDefaultSettings<Settings>()); export const currentUserIdAtom = atom<UserId | undefined>(undefined);
export const habitsAtom = atom(getDefaultHabitsData<HabitsData>()); export const settingsAtom = atom(getDefaultSettings());
export const coinsAtom = atom(getDefaultCoinsData<CoinsData>()); export const habitsAtom = atom(getDefaultHabitsData());
export const wishlistAtom = atom(getDefaultWishlistData<WishlistData>()); export const coinsAtom = atom(getDefaultCoinsData());
export const serverSettingsAtom = atom(getDefaultServerSettings<ServerSettings>()); export const wishlistAtom = atom(getDefaultWishlistData());
export const serverSettingsAtom = atom(getDefaultServerSettings());
export const userSelectAtom = atom<boolean>(false)
export const aboutOpenAtom = atom<boolean>(false)
export const pomodoroAtom = atom<PomodoroAtom>({
show: false,
selectedHabitId: null,
autoStart: true,
minimized: false,
})
// Derived atom for coins earned today // Derived atom for coins earned today
export const coinsEarnedTodayAtom = atom((get) => { export const coinsEarnedTodayAtom = atom((get) => {
@@ -68,13 +63,6 @@ export const totalEarnedAtom = atom((get) => {
return roundToInteger(value); return roundToInteger(value);
}); });
// Derived atom for total spent
export const totalSpentAtom = atom((get) => {
const coins = get(coinsAtom);
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);
@@ -83,54 +71,12 @@ export const coinsSpentTodayAtom = atom((get) => {
return roundToInteger(value); return roundToInteger(value);
}); });
// Derived atom for transactions today
export const transactionsTodayAtom = atom((get) => {
const coins = get(coinsAtom);
const settings = get(settingsAtom);
return calculateTransactionsToday(coins.transactions, settings.system.timezone);
});
// 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) => { export const currentUserAtom = atom((get) => {
const currentUserId = get(currentUserIdAtom); const currentUserId = get(currentUserIdAtom);
const users = get(usersAtom); const users = get(usersAtom);
return users.users.find(user => user.id === currentUserId); return users.users.find(user => user.id === currentUserId);
}) })
// Derived atom for current balance for the logged-in user
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 balance = coins.transactions
.filter(transaction => transaction.userId === loggedInUserId)
.reduce((sum, transaction) => sum + transaction.amount, 0);
return roundToInteger(balance);
});
/* transient atoms */
interface PomodoroAtom {
show: boolean
selectedHabitId: string | null
autoStart: boolean
minimized: boolean
}
export const pomodoroAtom = atom<PomodoroAtom>({
show: false,
selectedHabitId: null,
autoStart: true,
minimized: 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. * 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. * This token can be compared with a server-generated token to detect data discrepancies.
@@ -147,34 +93,26 @@ export const clientFreshnessTokenAtom = atom(async (get) => {
return hash; return hash;
}); });
// Derived atom for completion cache // Derived atom for completed habits by date, using the cache
export const completionCacheAtom = atom((get) => { export const completedHabitsMapAtom = atom((get) => {
const habits = get(habitsAtom).habits; const habits = get(habitsAtom).habits;
const completionCache: CompletionCache = {};
const map = new Map<string, Habit[]>();
const timezone = get(settingsAtom).system.timezone; const timezone = get(settingsAtom).system.timezone;
const cache: CompletionCache = {};
habits.forEach(habit => { habits.forEach(habit => {
habit.completions.forEach(utcTimestamp => { habit.completions.forEach(utcTimestamp => {
const localDate = t2d({ timestamp: utcTimestamp, timezone }) const localDate = t2d({ timestamp: utcTimestamp, timezone })
.toFormat('yyyy-MM-dd'); .toFormat('yyyy-MM-dd');
if (!cache[localDate]) { if (!completionCache[localDate]) {
cache[localDate] = {}; completionCache[localDate] = {};
} }
cache[localDate][habit.id] = (cache[localDate][habit.id] || 0) + 1; completionCache[localDate][habit.id] = (completionCache[localDate][habit.id] || 0) + 1;
}); });
}); });
return cache;
});
// Derived atom for completed habits by date, using the cache
export const completedHabitsMapAtom = atom((get) => {
const habits = get(habitsAtom).habits;
const completionCache = get(completionCacheAtom);
const map = new Map<string, Habit[]>();
// For each date in the cache // For each date in the cache
Object.entries(completionCache).forEach(([dateKey, habitCompletions]) => { Object.entries(completionCache).forEach(([dateKey, habitCompletions]) => {
const completedHabits = habits.filter(habit => { const completedHabits = habits.filter(habit => {
@@ -191,38 +129,6 @@ export const completedHabitsMapAtom = atom((get) => {
return map; return map;
}); });
// Derived atom for habit frequency map
export const habitFreqMapAtom = atom((get) => {
const habits = get(habitsAtom).habits;
const map = new Map<string, Freq>();
habits.forEach(habit => {
map.set(habit.id, getHabitFreq(habit));
});
return map;
});
export const pomodoroTodayCompletionsAtom = atom((get) => {
const pomo = get(pomodoroAtom)
const habits = get(habitsAtom)
const settings = get(settingsAtom)
if (!pomo.selectedHabitId) return 0
const selectedHabit = habits.habits.find(h => h.id === pomo.selectedHabitId!)
if (!selectedHabit) return 0
return getCompletionsForToday({
habit: selectedHabit,
timezone: settings.system.timezone
})
})
// Derived atom to check if any habits are tasks
export const hasTasksAtom = atom((get) => {
const habits = get(habitsAtom)
return habits.habits.some(habit => habit.isTask === true)
})
// Atom family for habits by specific date // Atom family for habits by specific date
export const habitsByDateFamily = atomFamily((dateString: string) => export const habitsByDateFamily = atomFamily((dateString: string) =>
atom((get) => { atom((get) => {

25
lib/avatar.ts Normal file
View File

@@ -0,0 +1,25 @@
export const ALLOWED_AVATAR_EXTENSIONS = new Set([
'.png',
'.jpg',
'.jpeg',
'.gif',
'.webp',
'.avif',
])
export const ALLOWED_AVATAR_MIME_TYPES = new Set([
'image/png',
'image/jpeg',
'image/gif',
'image/webp',
'image/avif',
])
export const AVATAR_CONTENT_TYPE: Record<string, string> = {
'.png': 'image/png',
'.jpg': 'image/jpeg',
'.jpeg': 'image/jpeg',
'.gif': 'image/gif',
'.webp': 'image/webp',
'.avif': 'image/avif',
}

View File

@@ -1,8 +1,19 @@
import { auth } from '@/auth' import { auth } from '@/auth'
import 'server-only' import 'server-only'
import { User, UserId } from './types' import { User, UserData, UserId, getDefaultUsersData } from './types'
import { loadUsersData } from '@/app/actions/data'
import { randomBytes, scryptSync } from 'crypto' import { randomBytes, scryptSync } from 'crypto'
import fs from 'fs/promises'
import path from 'path'
async function loadUsersDataFromStore(): Promise<UserData> {
try {
const filePath = path.join(process.cwd(), 'data', 'auth.json')
const data = await fs.readFile(filePath, 'utf8')
return JSON.parse(data) as UserData
} catch {
return getDefaultUsersData()
}
}
export async function getCurrentUserId(): Promise<UserId | undefined> { export async function getCurrentUserId(): Promise<UserId | undefined> {
const session = await auth() const session = await auth()
@@ -15,7 +26,7 @@ export async function getCurrentUser(): Promise<User | undefined> {
if (!currentUserId) { if (!currentUserId) {
return undefined return undefined
} }
const usersData = await loadUsersData() const usersData = await loadUsersDataFromStore()
return usersData.users.find((u) => u.id === currentUserId) return usersData.users.find((u) => u.id === currentUserId)
} }
export function saltAndHashPassword(password: string, salt?: string): string { export function saltAndHashPassword(password: string, salt?: string): string {

View File

@@ -27,6 +27,7 @@ export type SafeUser = SessionUser & {
avatarPath?: string avatarPath?: string
permissions?: Permission[] permissions?: Permission[]
isAdmin?: boolean isAdmin?: boolean
hasPassword?: boolean
} }
export type User = SafeUser & { export type User = SafeUser & {
@@ -34,6 +35,10 @@ export type User = SafeUser & {
lastNotificationReadTimestamp?: string // UTC ISO date string lastNotificationReadTimestamp?: string // UTC ISO date string
} }
export type PublicUser = Omit<User, 'password'> & {
hasPassword: boolean
}
export type Habit = { export type Habit = {
id: string id: string
name: string name: string
@@ -81,6 +86,10 @@ export interface UserData {
users: User[] users: User[]
} }
export interface PublicUserData {
users: PublicUser[]
}
export interface HabitsData { export interface HabitsData {
habits: Habit[]; habits: Habit[];
} }
@@ -98,7 +107,7 @@ export interface WishlistData {
} }
// Default value functions // Default value functions
export function getDefaultUsersData<UserData>(): UserData { export function getDefaultUsersData(): UserData {
return { return {
users: [ users: [
{ {
@@ -112,23 +121,30 @@ export function getDefaultUsersData<UserData>(): UserData {
} as UserData; } as UserData;
}; };
export function getDefaultHabitsData<HabitsData>(): HabitsData { export const getDefaultPublicUsersData = (): PublicUserData => ({
return { habits: [] } as HabitsData; users: getDefaultUsersData().users.map(({ password, ...user }) => ({
} ...user,
hasPassword: !!password,
})),
});
export const getDefaultHabitsData = (): HabitsData => ({
habits: []
});
export function getDefaultTasksData<TasksData>(): TasksData { export function getDefaultTasksData<TasksData>(): TasksData {
return { tasks: [] } as TasksData; return { tasks: [] } as TasksData;
}; };
export function getDefaultCoinsData<CoinsData>(): CoinsData { export function getDefaultCoinsData(): CoinsData {
return { balance: 0, transactions: [] } as CoinsData; return { balance: 0, transactions: [] } as CoinsData;
}; };
export function getDefaultWishlistData<WishlistData>(): WishlistData { export function getDefaultWishlistData(): WishlistData {
return { items: [] } as WishlistData; return { items: [] } as WishlistData;
} }
export function getDefaultSettings<Settings>(): Settings { export function getDefaultSettings(): Settings {
return { return {
ui: { ui: {
useNumberFormatting: true, useNumberFormatting: true,
@@ -144,12 +160,12 @@ export function getDefaultSettings<Settings>(): Settings {
} as Settings; } as Settings;
}; };
export function getDefaultServerSettings<ServerSettings>(): ServerSettings { export function getDefaultServerSettings(): ServerSettings {
return { isDemo: false } as ServerSettings; 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: { [key: string]: <T>() => T } = { export const DATA_DEFAULTS = {
wishlist: getDefaultWishlistData, wishlist: getDefaultWishlistData,
habits: getDefaultHabitsData, habits: getDefaultHabitsData,
coins: getDefaultCoinsData, coins: getDefaultCoinsData,
@@ -195,7 +211,7 @@ export interface JotaiHydrateInitialValues {
coins: CoinsData; coins: CoinsData;
habits: HabitsData; habits: HabitsData;
wishlist: WishlistData; wishlist: WishlistData;
users: UserData; users: PublicUserData;
serverSettings: ServerSettings; serverSettings: ServerSettings;
} }
@@ -210,3 +226,16 @@ export interface ParsedFrequencyResult {
message: string | null message: string | null
result: ParsedResultType result: ParsedResultType
} }
export interface PomodoroAtom {
show: boolean
selectedHabitId: string | null
autoStart: boolean
minimized: boolean
}
export interface BrowserSettings {
expandedHabits: boolean
expandedTasks: boolean
expandedWishlist: boolean
}

10
lib/user-sanitizer.ts Normal file
View File

@@ -0,0 +1,10 @@
import { PublicUserData, UserData } from './types'
export function sanitizeUserData(data: UserData): PublicUserData {
return {
users: data.users.map(({ password, ...user }) => ({
...user,
hasPassword: !!password,
})),
}
}

View File

@@ -942,11 +942,11 @@ describe('convertMachineReadableFrequencyToHumanReadable', () => {
}) })
describe('freshness utilities', () => { describe('freshness utilities', () => {
const mockSettings: Settings = getDefaultSettings<Settings>(); const mockSettings: Settings = getDefaultSettings();
const mockHabits: HabitsData = getDefaultHabitsData<HabitsData>(); const mockHabits: HabitsData = getDefaultHabitsData();
const mockCoins: CoinsData = getDefaultCoinsData<CoinsData>(); const mockCoins: CoinsData = getDefaultCoinsData();
const mockWishlist: WishlistData = getDefaultWishlistData<WishlistData>(); const mockWishlist: WishlistData = getDefaultWishlistData();
const mockUsers: UserData = getDefaultUsersData<UserData>(); const mockUsers: UserData = getDefaultUsersData();
// Add a user to mockUsers for more realistic testing // Add a user to mockUsers for more realistic testing
mockUsers.users.push({ mockUsers.users.push({
@@ -991,11 +991,11 @@ describe('freshness utilities', () => {
}); });
test('should handle empty data consistently', () => { test('should handle empty data consistently', () => {
const emptySettings = getDefaultSettings<Settings>(); const emptySettings = getDefaultSettings();
const emptyHabits = getDefaultHabitsData<HabitsData>(); const emptyHabits = getDefaultHabitsData();
const emptyCoins = getDefaultCoinsData<CoinsData>(); const emptyCoins = getDefaultCoinsData();
const emptyWishlist = getDefaultWishlistData<WishlistData>(); const emptyWishlist = getDefaultWishlistData();
const emptyUsers = getDefaultUsersData<UserData>(); const emptyUsers = getDefaultUsersData();
const string1 = prepareDataForHashing(emptySettings, emptyHabits, emptyCoins, emptyWishlist, emptyUsers); const string1 = prepareDataForHashing(emptySettings, emptyHabits, emptyCoins, emptyWishlist, emptyUsers);
const string2 = prepareDataForHashing(emptySettings, emptyHabits, emptyCoins, emptyWishlist, emptyUsers); const string2 = prepareDataForHashing(emptySettings, emptyHabits, emptyCoins, emptyWishlist, emptyUsers);

View File

@@ -1,5 +1,5 @@
import { toast } from "@/hooks/use-toast" import { toast } from "@/hooks/use-toast"
import { CoinsData, CoinTransaction, Freq, Habit, HabitsData, ParsedFrequencyResult, ParsedResultType, SafeUser, Settings, User, UserData, WishlistData } from '@/lib/types' import { CoinsData, CoinTransaction, Freq, Habit, HabitsData, ParsedFrequencyResult, ParsedResultType, PomodoroAtom, PublicUserData, SafeUser, Settings, User, UserData, WishlistData } from '@/lib/types'
import * as chrono from 'chrono-node' import * as chrono from 'chrono-node'
import { clsx, type ClassValue } from "clsx" import { clsx, type ClassValue } from "clsx"
import { DateTime, DateTimeFormatOptions } from "luxon" import { DateTime, DateTimeFormatOptions } from "luxon"
@@ -84,6 +84,20 @@ export function getCompletionsForToday({
return getCompletionsForDate({ habit, date: getTodayInTimezone(timezone), timezone }) return getCompletionsForDate({ habit, date: getTodayInTimezone(timezone), timezone })
} }
export function getTodayCompletions({ selectedHabitId }: PomodoroAtom, { habits }: HabitsData, { system: { timezone } }: Settings): number {
if (!selectedHabitId)
return 0;
const selectedHabit = habits.find(h => h.id === selectedHabitId!);
if (!selectedHabit)
return 0;
return getCompletionsForToday({
habit: selectedHabit,
timezone: timezone
});
}
export function getCompletedHabitsForDate({ export function getCompletedHabitsForDate({
habits, habits,
date, date,
@@ -448,7 +462,7 @@ export function prepareDataForHashing(
habits: HabitsData, habits: HabitsData,
coins: CoinsData, coins: CoinsData,
wishlist: WishlistData, wishlist: WishlistData,
users: UserData users: UserData | PublicUserData
): string { ): string {
return JSON.stringify({ return JSON.stringify({
settings, settings,

13
middleware.ts Normal file
View File

@@ -0,0 +1,13 @@
import { NextResponse, type NextRequest } from 'next/server'
export function middleware(request: NextRequest) {
if (process.env.NODE_ENV !== 'development' && request.nextUrl.pathname.startsWith('/debug')) {
return new NextResponse('Not Found', { status: 404 })
}
return NextResponse.next()
}
export const config = {
matcher: ['/debug/:path*'],
}

View File

@@ -30,24 +30,7 @@ const nextConfig: NextConfig = {
value: 'strict-origin-when-cross-origin', value: 'strict-origin-when-cross-origin',
}, },
], ],
}, }
{
source: '/sw.js',
headers: [
{
key: 'Content-Type',
value: 'application/javascript; charset=utf-8',
},
{
key: 'Cache-Control',
value: 'no-cache, no-store, must-revalidate',
},
{
key: 'Content-Security-Policy',
value: "default-src 'self'; script-src 'self'",
},
],
},
] ]
}, },
}; };

84
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{ {
"name": "habittrove", "name": "habittrove",
"version": "0.2.30", "version": "0.2.31",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "habittrove", "name": "habittrove",
"version": "0.2.30", "version": "0.2.31",
"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",
@@ -41,7 +41,7 @@
"linkify-react": "^4.2.0", "linkify-react": "^4.2.0",
"lucide-react": "^0.469.0", "lucide-react": "^0.469.0",
"luxon": "^3.5.0", "luxon": "^3.5.0",
"next": "^v15.5.7", "next": "^15.5.10",
"next-auth": "^5.0.0-beta.25", "next-auth": "^5.0.0-beta.25",
"next-intl": "^4.1.0", "next-intl": "^4.1.0",
"next-themes": "^0.4.4", "next-themes": "^0.4.4",
@@ -969,9 +969,9 @@
} }
}, },
"node_modules/@next/env": { "node_modules/@next/env": {
"version": "15.5.7", "version": "15.5.12",
"resolved": "https://registry.npmjs.org/@next/env/-/env-15.5.7.tgz", "resolved": "https://registry.npmjs.org/@next/env/-/env-15.5.12.tgz",
"integrity": "sha512-4h6Y2NyEkIEN7Z8YxkA27pq6zTkS09bUSYC0xjd0NpwFxjnIKeZEeH591o5WECSmjpUhLn3H2QLJcDye3Uzcvg==", "integrity": "sha512-pUvdJN1on574wQHjaBfNGDt9Mz5utDSZFsIIQkMzPgNS8ZvT4H2mwOrOIClwsQOb6EGx5M76/CZr6G8i6pSpLg==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/@next/eslint-plugin-next": { "node_modules/@next/eslint-plugin-next": {
@@ -992,9 +992,9 @@
} }
}, },
"node_modules/@next/swc-darwin-arm64": { "node_modules/@next/swc-darwin-arm64": {
"version": "15.5.7", "version": "15.5.12",
"resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-15.5.7.tgz", "resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-15.5.12.tgz",
"integrity": "sha512-IZwtxCEpI91HVU/rAUOOobWSZv4P2DeTtNaCdHqLcTJU4wdNXgAySvKa/qJCgR5m6KI8UsKDXtO2B31jcaw1Yw==", "integrity": "sha512-RnRjBtH8S8eXCpUNkQ+543DUc7ys8y15VxmFU9HRqlo9BG3CcBUiwNtF8SNoi2xvGCVJq1vl2yYq+3oISBS0Zg==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@@ -1008,9 +1008,9 @@
} }
}, },
"node_modules/@next/swc-darwin-x64": { "node_modules/@next/swc-darwin-x64": {
"version": "15.5.7", "version": "15.5.12",
"resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-15.5.7.tgz", "resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-15.5.12.tgz",
"integrity": "sha512-UP6CaDBcqaCBuiq/gfCEJw7sPEoX1aIjZHnBWN9v9qYHQdMKvCKcAVs4OX1vIjeE+tC5EIuwDTVIoXpUes29lg==", "integrity": "sha512-nqa9/7iQlboF1EFtNhWxQA0rQstmYRSBGxSM6g3GxvxHxcoeqVXfGNr9stJOme674m2V7r4E3+jEhhGvSQhJRA==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@@ -1024,9 +1024,9 @@
} }
}, },
"node_modules/@next/swc-linux-arm64-gnu": { "node_modules/@next/swc-linux-arm64-gnu": {
"version": "15.5.7", "version": "15.5.12",
"resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-15.5.7.tgz", "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-15.5.12.tgz",
"integrity": "sha512-NCslw3GrNIw7OgmRBxHtdWFQYhexoUCq+0oS2ccjyYLtcn1SzGzeM54jpTFonIMUjNbHmpKpziXnpxhSWLcmBA==", "integrity": "sha512-dCzAjqhDHwmoB2M4eYfVKqXs99QdQxNQVpftvP1eGVppamXh/OkDAwV737Zr0KPXEqRUMN4uCjh6mjO+XtF3Mw==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@@ -1040,9 +1040,9 @@
} }
}, },
"node_modules/@next/swc-linux-arm64-musl": { "node_modules/@next/swc-linux-arm64-musl": {
"version": "15.5.7", "version": "15.5.12",
"resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-15.5.7.tgz", "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-15.5.12.tgz",
"integrity": "sha512-nfymt+SE5cvtTrG9u1wdoxBr9bVB7mtKTcj0ltRn6gkP/2Nu1zM5ei8rwP9qKQP0Y//umK+TtkKgNtfboBxRrw==", "integrity": "sha512-+fpGWvQiITgf7PUtbWY1H7qUSnBZsPPLyyq03QuAKpVoTy/QUx1JptEDTQMVvQhvizCEuNLEeghrQUyXQOekuw==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@@ -1056,9 +1056,9 @@
} }
}, },
"node_modules/@next/swc-linux-x64-gnu": { "node_modules/@next/swc-linux-x64-gnu": {
"version": "15.5.7", "version": "15.5.12",
"resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-15.5.7.tgz", "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-15.5.12.tgz",
"integrity": "sha512-hvXcZvCaaEbCZcVzcY7E1uXN9xWZfFvkNHwbe/n4OkRhFWrs1J1QV+4U1BN06tXLdaS4DazEGXwgqnu/VMcmqw==", "integrity": "sha512-jSLvgdRRL/hrFAPqEjJf1fFguC719kmcptjNVDJl26BnJIpjL3KH5h6mzR4mAweociLQaqvt4UyzfbFjgAdDcw==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@@ -1072,9 +1072,9 @@
} }
}, },
"node_modules/@next/swc-linux-x64-musl": { "node_modules/@next/swc-linux-x64-musl": {
"version": "15.5.7", "version": "15.5.12",
"resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-15.5.7.tgz", "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-15.5.12.tgz",
"integrity": "sha512-4IUO539b8FmF0odY6/SqANJdgwn1xs1GkPO5doZugwZ3ETF6JUdckk7RGmsfSf7ws8Qb2YB5It33mvNL/0acqA==", "integrity": "sha512-/uaF0WfmYqQgLfPmN6BvULwxY0dufI2mlN2JbOKqqceZh1G4hjREyi7pg03zjfyS6eqNemHAZPSoP84x17vo6w==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@@ -1088,9 +1088,9 @@
} }
}, },
"node_modules/@next/swc-win32-arm64-msvc": { "node_modules/@next/swc-win32-arm64-msvc": {
"version": "15.5.7", "version": "15.5.12",
"resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-15.5.7.tgz", "resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-15.5.12.tgz",
"integrity": "sha512-CpJVTkYI3ZajQkC5vajM7/ApKJUOlm6uP4BknM3XKvJ7VXAvCqSjSLmM0LKdYzn6nBJVSjdclx8nYJSa3xlTgQ==", "integrity": "sha512-xhsL1OvQSfGmlL5RbOmU+FV120urrgFpYLq+6U8C6KIym32gZT6XF/SDE92jKzzlPWskkbjOKCpqk5m4i8PEfg==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@@ -1104,9 +1104,9 @@
} }
}, },
"node_modules/@next/swc-win32-x64-msvc": { "node_modules/@next/swc-win32-x64-msvc": {
"version": "15.5.7", "version": "15.5.12",
"resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-15.5.7.tgz", "resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-15.5.12.tgz",
"integrity": "sha512-gMzgBX164I6DN+9/PGA+9dQiwmTkE4TloBNx8Kv9UiGARsr9Nba7IpcBRA1iTV9vwlYnrE3Uy6I7Aj6qLjQuqw==", "integrity": "sha512-Z1Dh6lhFkxvBDH1FoW6OU/L6prYwPSlwjLiZkExIAh8fbP6iI/M7iGTQAJPYJ9YFlWobCZ1PHbchFhFYb2ADkw==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@@ -7767,13 +7767,13 @@
"dev": true "dev": true
}, },
"node_modules/next": { "node_modules/next": {
"version": "15.5.7", "version": "15.5.12",
"resolved": "https://registry.npmjs.org/next/-/next-15.5.7.tgz", "resolved": "https://registry.npmjs.org/next/-/next-15.5.12.tgz",
"integrity": "sha512-+t2/0jIJ48kUpGKkdlhgkv+zPTEOoXyr60qXe68eB/pl3CMJaLeIGjzp5D6Oqt25hCBiBTt8wEeeAzfJvUKnPQ==", "integrity": "sha512-Fi/wQ4Etlrn60rz78bebG1i1SR20QxvV8tVp6iJspjLUSHcZoeUXCt+vmWoEcza85ElZzExK/jJ/F6SvtGktjA==",
"license": "MIT", "license": "MIT",
"peer": true, "peer": true,
"dependencies": { "dependencies": {
"@next/env": "15.5.7", "@next/env": "15.5.12",
"@swc/helpers": "0.5.15", "@swc/helpers": "0.5.15",
"caniuse-lite": "^1.0.30001579", "caniuse-lite": "^1.0.30001579",
"postcss": "8.4.31", "postcss": "8.4.31",
@@ -7786,14 +7786,14 @@
"node": "^18.18.0 || ^19.8.0 || >= 20.0.0" "node": "^18.18.0 || ^19.8.0 || >= 20.0.0"
}, },
"optionalDependencies": { "optionalDependencies": {
"@next/swc-darwin-arm64": "15.5.7", "@next/swc-darwin-arm64": "15.5.12",
"@next/swc-darwin-x64": "15.5.7", "@next/swc-darwin-x64": "15.5.12",
"@next/swc-linux-arm64-gnu": "15.5.7", "@next/swc-linux-arm64-gnu": "15.5.12",
"@next/swc-linux-arm64-musl": "15.5.7", "@next/swc-linux-arm64-musl": "15.5.12",
"@next/swc-linux-x64-gnu": "15.5.7", "@next/swc-linux-x64-gnu": "15.5.12",
"@next/swc-linux-x64-musl": "15.5.7", "@next/swc-linux-x64-musl": "15.5.12",
"@next/swc-win32-arm64-msvc": "15.5.7", "@next/swc-win32-arm64-msvc": "15.5.12",
"@next/swc-win32-x64-msvc": "15.5.7", "@next/swc-win32-x64-msvc": "15.5.12",
"sharp": "^0.34.3" "sharp": "^0.34.3"
}, },
"peerDependencies": { "peerDependencies": {

View File

@@ -1,6 +1,6 @@
{ {
"name": "habittrove", "name": "habittrove",
"version": "0.2.30", "version": "0.2.31",
"private": true, "private": true,
"scripts": { "scripts": {
"dev": "next dev --turbopack", "dev": "next dev --turbopack",
@@ -48,7 +48,7 @@
"lodash": "^4.17.21", "lodash": "^4.17.21",
"lucide-react": "^0.469.0", "lucide-react": "^0.469.0",
"luxon": "^3.5.0", "luxon": "^3.5.0",
"next": "^v15.5.7", "next": "^15.5.10",
"next-auth": "^5.0.0-beta.25", "next-auth": "^5.0.0-beta.25",
"next-intl": "^4.1.0", "next-intl": "^4.1.0",
"next-themes": "^0.4.4", "next-themes": "^0.4.4",

View File

@@ -1,22 +0,0 @@
self.addEventListener('push', function (event) {
if (event.data) {
const data = event.data.json()
const options = {
body: data.body,
icon: data.icon || '/icon.png',
badge: '/badge.png',
vibrate: [100, 50, 100],
data: {
dateOfArrival: Date.now(),
primaryKey: '2',
},
}
event.waitUntil(self.registration.showNotification(data.title, options))
}
})
self.addEventListener('notificationclick', function (event) {
console.log('Notification click received.')
event.notification.close()
event.waitUntil(clients.openWindow('/'))
})