Compare commits

...

12 Commits

Author SHA1 Message Date
dohsimpson
d31982bf29 [SECURITY] patched cve-2025-29927 2025-03-25 10:47:03 -04:00
Doh
9052c9f37a per-user coins data for admin (#82)
* admin user can see per-user coins data

* fixes

* fix
2025-02-28 17:07:44 -05:00
dohsimpson
a615a45c39 fix demo bugs 2025-02-26 18:51:13 -05:00
dohsimpson
dea2b30c3b fix completion badge 2025-02-21 18:16:15 -05:00
Doh
ea0203dc86 added iOS padding (#69) 2025-02-19 20:00:17 -05:00
dohsimpson
b7933ea040 fix build error 2025-02-18 23:59:41 -05:00
Doh
8ac2ec053d Multiuser support (#60) 2025-02-18 23:43:23 -05:00
dohsimpson
363b31e934 fix mobile responsive layout for habit and task list 2025-01-27 22:41:27 -05:00
dohsimpson
7065d5694b fix redeem link 2025-01-27 18:43:15 -05:00
dohsimpson
b62cf77ba8 redeem link + completing task + play sound 2025-01-27 18:24:53 -05:00
dohsimpson
c66e28162c dark mode 2025-01-25 13:03:07 -05:00
Doh
6fe10d9fa5 support archiving habit and wishlist + wishlist redeem count (#49) 2025-01-24 20:41:26 -05:00
59 changed files with 3868 additions and 581 deletions

3
.gitignore vendored
View File

@@ -41,6 +41,7 @@ yarn-error.log*
next-env.d.ts
# customize
data/*
/data/*
/data.*/*
Budfile
certificates

View File

@@ -1,5 +1,103 @@
# Changelog
## Version 0.2.5
### Changed
* bumped Nextjs version (cve-2025-29927)
## Version 0.2.4
### Added
* admin can select user to view coins for that user
### Fixed
* fix disable password in demo instance (#74)
## Version 0.2.3
### Fixed
* gracefully handle invalid rrule (#76)
* fix long habit name overflow in daily (#75)
* disable password in demo instance (#74)
## Version 0.2.2
### Changed
* persist "show all" settings in browser (#72)
### Fixed
* nav bar spacing
* completion count badge
## Version 0.2.1
### Changed
* Added bottom padding for nav bar on iOS devices (#63)
## Version 0.2.0
### Added
* Multi-user support with permissions system
* Sharing habits and wishlist items with other users
* show both tasks and habits in dashboard (#58)
* show tasks in completion streak (#57)
### BREAKING CHANGE
* PLEASE BACK UP `data/` DIRECTORY BEFORE UPGRADE.
* Requires AUTH_SECRET environment variable for user authentication. Generate a secure secret with: `openssl rand -base64 32`
* Previous coin balance will be hidden. If this is undesirable, consider using manual adjustment to adjust coin balance after upgrade.
## Version 0.1.30
### Fixed
- fix responsive layout on mobile for habits and wishlist when has archived items
## Version 0.1.29
### Fixed
- actually working redeem link for wishlist items (#52)
## Version 0.1.28
### Added
- redeem link for wishlist items (#52)
- sound effect for habit / task completion (#53)
### Fixed
- fail habit create or edit if frequency is not set (#54)
- archive task when completed (#50)
## Version 0.1.27
### Added
- dark mode toggle (#48)
- notification badge for tasks (#51)
## Version 0.1.26
### Added
- archiving habits and wishlists (#44)
- wishlist item now supports redeem count (#36)
### Fixed
- pomodoro skip should update label
## Version 0.1.25
### Added

View File

@@ -15,8 +15,8 @@ Want to try HabitTrove before installing? Visit the public [demo instance](https
- 💰 Create a wishlist of rewards to redeem with earned coins
- 📊 View your habit completion streaks and statistics
- 📅 Calendar heatmap to visualize your progress (WIP)
- 🌙 Dark mode support (WIP)
- 📲 Progressive Web App (PWA) support (Planned)
- 🌙 Dark mode support
- 📲 Progressive Web App (PWA) support
## Usage
@@ -46,11 +46,22 @@ chown -R 1001:1001 data # Required for the nextjs user in container
2. Then run using either method:
```bash
# Generate a secure authentication secret
export AUTH_SECRET=$(openssl rand -base64 32)
echo $AUTH_SECRET
# Using docker-compose (recommended)
## update the AUTH_SECRET environment variable in docker-compose file
nano docker-compose.yaml
## start the container
docker compose up -d
# Or using docker run directly
docker run -d -p 3000:3000 -v ./data:/app/data dohsimpson/habittrove
docker run -d \
-p 3000:3000 \
-v ./data:/app/data \
-e AUTH_SECRET=$AUTH_SECRET \
dohsimpson/habittrove
```
Available image tags:

View File

@@ -12,9 +12,44 @@ import {
Settings,
DataType,
DATA_DEFAULTS,
getDefaultSettings
getDefaultSettings,
UserData,
getDefaultUsersData,
User,
getDefaultWishlistData,
getDefaultHabitsData,
getDefaultCoinsData,
Permission,
ServerSettings
} from '@/lib/types'
import { d2t, getNow } from '@/lib/utils';
import { d2t, deepMerge, getNow, checkPermission, uuid } from '@/lib/utils';
import { verifyPassword } from "@/lib/server-helpers";
import { saltAndHashPassword } from "@/lib/server-helpers";
import { signInSchema } from '@/lib/zod';
import { auth } from '@/auth';
import _ from 'lodash';
import { getCurrentUser, getCurrentUserId } from '@/lib/server-helpers'
import { PermissionError } from '@/lib/exceptions'
type ResourceType = 'habit' | 'wishlist' | 'coins'
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 {
return DATA_DEFAULTS[type]() as T;
@@ -45,7 +80,7 @@ async function loadData<T>(type: DataType): Promise<T> {
// File exists, read and return its contents
const data = await fs.readFile(filePath, 'utf8')
const jsonData = JSON.parse(data)
const jsonData = JSON.parse(data) as T
return jsonData
} catch (error) {
console.error(`Error loading ${type} data:`, error)
@@ -55,6 +90,9 @@ async function loadData<T>(type: DataType): Promise<T> {
async function saveData<T>(type: DataType, data: T): Promise<void> {
try {
const user = await getCurrentUser()
if (!user) throw new Error('User not authenticated')
await ensureDataDir()
const filePath = path.join(process.cwd(), 'data', `${type}.json`)
const saveData = data
@@ -66,7 +104,14 @@ async function saveData<T>(type: DataType, data: T): Promise<void> {
// Wishlist specific functions
export async function loadWishlistData(): Promise<WishlistData> {
return loadData<WishlistData>('wishlist')
const user = await getCurrentUser()
if (!user) return getDefaultWishlistData()
const data = await loadData<WishlistData>('wishlist')
return {
...data,
items: data.items.filter(x => user.isAdmin || x.userIds?.includes(user.id))
}
}
export async function loadWishlistItems(): Promise<WishlistItemType[]> {
@@ -74,31 +119,98 @@ export async function loadWishlistItems(): Promise<WishlistItemType[]> {
return data.items
}
export async function saveWishlistItems(items: WishlistItemType[]): Promise<void> {
return saveData('wishlist', { items })
export async function saveWishlistItems(data: WishlistData): Promise<void> {
await verifyPermission('wishlist', 'write')
const user = await getCurrentUser()
data.items = data.items.map(wishlist => ({
...wishlist,
userIds: wishlist.userIds || (user ? [user.id] : undefined)
}))
if (!user?.isAdmin) {
const existingData = await loadData<WishlistData>('wishlist')
existingData.items = existingData.items.filter(x => user?.id && !x.userIds?.includes(user?.id))
data.items = [
...existingData.items,
...data.items
]
}
return saveData('wishlist', data)
}
// Habits specific functions
export async function loadHabitsData(): Promise<HabitsData> {
return loadData<HabitsData>('habits')
const user = await getCurrentUser()
if (!user) return getDefaultHabitsData()
const data = await loadData<HabitsData>('habits')
return {
...data,
habits: data.habits.filter(x => user.isAdmin || x.userIds?.includes(user.id))
}
}
export async function saveHabitsData(data: HabitsData): Promise<void> {
return saveData('habits', data)
await verifyPermission('habit', 'write')
const user = await getCurrentUser()
// Create clone of input data
const newData = _.cloneDeep(data)
// Map habits with user IDs
newData.habits = newData.habits.map(habit => ({
...habit,
userIds: habit.userIds || (user ? [user.id] : undefined)
}))
if (!user?.isAdmin) {
const existingData = await loadData<HabitsData>('habits')
const existingHabits = existingData.habits.filter(x => user?.id && !x.userIds?.includes(user?.id))
newData.habits = [
...existingHabits,
...newData.habits
]
}
return saveData('habits', newData)
}
// Coins specific functions
export async function loadCoinsData(): Promise<CoinsData> {
try {
return await loadData<CoinsData>('coins')
const user = await getCurrentUser()
if (!user) return getDefaultCoinsData()
const data = await loadData<CoinsData>('coins')
return {
...data,
transactions: user.isAdmin ? data.transactions : data.transactions.filter(x => x.userId === user.id)
}
} catch {
return { balance: 0, transactions: [] }
return getDefaultCoinsData()
}
}
export async function saveCoinsData(data: CoinsData): Promise<void> {
return saveData('coins', data)
const user = await getCurrentUser()
// Create clones of the data
const newData = _.cloneDeep(data)
newData.transactions = newData.transactions.map(transaction => ({
...transaction,
userId: transaction.userId || user?.id
}))
if (!user?.isAdmin) {
const existingData = await loadData<CoinsData>('coins')
const existingTransactions = existingData.transactions.filter(x => user?.id && x.userId !== user.id)
newData.transactions = [
...newData.transactions,
...existingTransactions
]
}
return saveData('coins', newData)
}
export async function addCoins({
@@ -107,22 +219,26 @@ export async function addCoins({
type = 'MANUAL_ADJUSTMENT',
relatedItemId,
note,
userId,
}: {
amount: number
description: string
type?: TransactionType
relatedItemId?: string
note?: string
userId?: string
}): Promise<CoinsData> {
await verifyPermission('coins', type === 'MANUAL_ADJUSTMENT' ? 'write' : 'interact')
const data = await loadCoinsData()
const newTransaction: CoinTransaction = {
id: crypto.randomUUID(),
id: uuid(),
amount,
type,
description,
timestamp: d2t({ dateTime: getNow({}) }),
...(relatedItemId && { relatedItemId }),
...(note && note.trim() !== '' && { note })
...(note && note.trim() !== '' && { note }),
userId: userId || await getCurrentUserId()
}
const newData: CoinsData = {
@@ -138,6 +254,8 @@ export async function loadSettings(): Promise<Settings> {
const defaultSettings = getDefaultSettings()
try {
const user = await getCurrentUser()
if (!user) return defaultSettings
const data = await loadData<Settings>('settings')
return { ...defaultSettings, ...data }
} catch {
@@ -155,22 +273,26 @@ export async function removeCoins({
type = 'MANUAL_ADJUSTMENT',
relatedItemId,
note,
userId,
}: {
amount: number
description: string
type?: TransactionType
relatedItemId?: string
note?: string
userId?: string
}): Promise<CoinsData> {
await verifyPermission('coins', type === 'MANUAL_ADJUSTMENT' ? 'write' : 'interact')
const data = await loadCoinsData()
const newTransaction: CoinTransaction = {
id: crypto.randomUUID(),
id: uuid(),
amount: -amount,
type,
description,
timestamp: d2t({ dateTime: getNow({}) }),
...(relatedItemId && { relatedItemId }),
...(note && note.trim() !== '' && { note })
...(note && note.trim() !== '' && { note }),
userId: userId || await getCurrentUserId()
}
const newData: CoinsData = {
@@ -182,7 +304,7 @@ export async function removeCoins({
return newData
}
export async function uploadAvatar(formData: FormData) {
export async function uploadAvatar(formData: FormData): Promise<string> {
const file = formData.get('avatar') as File
if (!file) throw new Error('No file provided')
@@ -203,18 +325,7 @@ export async function uploadAvatar(formData: FormData) {
const buffer = await file.arrayBuffer()
await fs.writeFile(filePath, Buffer.from(buffer))
// Update settings with new avatar path
const settings = await loadSettings()
const newSettings = {
...settings,
profile: {
...settings.profile,
avatarPath: `/data/avatars/${filename}`
}
}
await saveSettings(newSettings)
return newSettings;
return `/data/avatars/${filename}`
}
export async function getChangelog(): Promise<string> {
@@ -226,3 +337,153 @@ export async function getChangelog(): Promise<string> {
return '# Changelog\n\nNo changelog available.'
}
}
// user logic
export async function loadUsersData(): Promise<UserData> {
try {
return await loadData<UserData>('auth')
} catch {
return getDefaultUsersData()
}
}
export async function saveUsersData(data: UserData): Promise<void> {
return saveData('auth', data)
}
export async function getUser(username: string, plainTextPassword?: string): Promise<User | null> {
const data = await loadUsersData()
const user = data.users.find(user => user.username === username)
if (!user) return null
// Verify the plaintext password against the stored salt:hash
const isValidPassword = verifyPassword(plainTextPassword, user.password)
if (!isValidPassword) return null
return user
}
export async function createUser(formData: FormData): Promise<User> {
const username = formData.get('username') as string;
let password = formData.get('password') as string | undefined;
const avatarPath = formData.get('avatarPath') as string;
const permissions = formData.get('permissions') ?
JSON.parse(formData.get('permissions') as string) as Permission[] :
undefined;
if (password === null) password = undefined
// Validate username and password against schema
await signInSchema.parseAsync({ username, password });
const data = await loadUsersData();
// Check if username already exists
if (data.users.some(user => user.username === username)) {
throw new Error('Username already exists');
}
const hashedPassword = password ? saltAndHashPassword(password) : '';
const newUser: User = {
id: uuid(),
username,
password: hashedPassword,
permissions,
isAdmin: false,
...(avatarPath && { avatarPath })
};
const newData: UserData = {
users: [...data.users, newUser]
};
await saveUsersData(newData);
return newUser;
}
export async function updateUser(userId: string, updates: Partial<Omit<User, 'id' | 'password'>>): Promise<User> {
const data = await loadUsersData()
const userIndex = data.users.findIndex(user => user.id === userId)
if (userIndex === -1) {
throw new Error('User not found')
}
// If username is being updated, check for duplicates
if (updates.username) {
const isDuplicate = data.users.some(
user => user.username === updates.username && user.id !== userId
)
if (isDuplicate) {
throw new Error('Username already exists')
}
}
const updatedUser = {
...data.users[userIndex],
...updates
}
const newData: UserData = {
users: [
...data.users.slice(0, userIndex),
updatedUser,
...data.users.slice(userIndex + 1)
]
}
await saveUsersData(newData)
return updatedUser
}
export async function updateUserPassword(userId: string, newPassword?: string): Promise<void> {
const data = await loadUsersData()
const userIndex = data.users.findIndex(user => user.id === userId)
if (userIndex === -1) {
throw new Error('User not found')
}
const hashedPassword = newPassword ? saltAndHashPassword(newPassword) : ''
const updatedUser = {
...data.users[userIndex],
password: hashedPassword
}
const newData: UserData = {
users: [
...data.users.slice(0, userIndex),
updatedUser,
...data.users.slice(userIndex + 1)
]
}
await saveUsersData(newData)
}
export async function deleteUser(userId: string): Promise<void> {
const data = await loadUsersData()
const userIndex = data.users.findIndex(user => user.id === userId)
if (userIndex === -1) {
throw new Error('User not found')
}
const newData: UserData = {
users: [
...data.users.slice(0, userIndex),
...data.users.slice(userIndex + 1)
]
}
await saveUsersData(newData)
}
export async function loadServerSettings(): Promise<ServerSettings> {
return {
isDemo: !!process.env.DEMO,
}
}

27
app/actions/user.ts Normal file
View File

@@ -0,0 +1,27 @@
"use server"
import { signIn as signInNextAuth, signOut as signOutNextAuth } from '@/auth';
export async function signIn(username: string, password: string) {
try {
const result = await signInNextAuth("credentials", {
username,
password,
redirect: false, // This needs to be passed as an option, not as form data
});
return result;
} catch (error) {
throw new Error("Invalid credentials");
}
}
export async function signOut() {
try {
const result = await signOutNextAuth({
redirect: false,
})
} catch (error) {
throw new Error("Failed to sign out");
}
}

View File

@@ -0,0 +1,2 @@
import { handlers } from "@/auth"
export const { GET, POST } = handlers

View File

@@ -1,6 +1,7 @@
import Layout from '@/components/Layout'
import HabitCalendar from '@/components/HabitCalendar'
import { ViewToggle } from '@/components/ViewToggle'
import CompletionCountBadge from '@/components/CompletionCountBadge'
export default function CalendarPage() {
return (

70
app/debug/habits/page.tsx Normal file
View File

@@ -0,0 +1,70 @@
'use client'
import { useHabits } from "@/hooks/useHabits";
import { habitsAtom, settingsAtom } from "@/lib/atoms";
import { Habit } from "@/lib/types";
import { useAtom } from "jotai";
import { DateTime } from "luxon";
type CompletionCache = {
[dateKey: string]: { // dateKey format: "YYYY-MM-DD"
[habitId: string]: number // number of completions on that date
}
}
export default function DebugPage() {
const [habits] = useAtom(habitsAtom);
const [settings] = useAtom(settingsAtom);
function buildCompletionCache(habits: Habit[], timezone: string): CompletionCache {
const cache: CompletionCache = {};
habits.forEach(habit => {
habit.completions.forEach(utcTimestamp => {
// Convert UTC timestamp to local date string in specified timezone
const localDate = DateTime
.fromISO(utcTimestamp)
.setZone(timezone)
.toFormat('yyyy-MM-dd');
if (!cache[localDate]) {
cache[localDate] = {};
}
// Increment completion count for this habit on this date
cache[localDate][habit.id] = (cache[localDate][habit.id] || 0) + 1;
});
});
return cache;
}
function getCompletedHabitsForDate(
habits: Habit[],
date: DateTime,
timezone: string,
completionCache: CompletionCache
): Habit[] {
const dateKey = date.setZone(timezone).toFormat('yyyy-MM-dd');
const dateCompletions = completionCache[dateKey] || {};
return habits.filter(habit => {
const completionsNeeded = habit.targetCompletions || 1;
const completionsAchieved = dateCompletions[habit.id] || 0;
return completionsAchieved >= completionsNeeded;
});
}
const habitCache = buildCompletionCache(habits.habits, settings.system.timezone);
return (
<div className="p-4">
<h1 className="text-xl font-bold mb-4">Debug Page</h1>
<div className="bg-gray-100 p-4 rounded break-all">
</div>
</div>
);
}

10
app/debug/layout.tsx Normal file
View File

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

16
app/debug/user/page.tsx Normal file
View File

@@ -0,0 +1,16 @@
import { saltAndHashPassword } from "@/lib/server-helpers";
export default function DebugPage() {
const password = 'admin';
const hashedPassword = saltAndHashPassword(password);
return (
<div className="p-4">
<h1 className="text-xl font-bold mb-4">Debug Page</h1>
<div className="bg-gray-100 p-4 rounded break-all">
<p><strong>Password:</strong> {password}</p>
<p><strong>Hashed Password:</strong> {hashedPassword}</p>
</div>
</div>
);
}

View File

@@ -4,9 +4,12 @@ import { DM_Sans } from 'next/font/google'
import { JotaiProvider } from '@/components/jotai-providers'
import { Suspense } from 'react'
import { JotaiHydrate } from '@/components/jotai-hydrate'
import { loadSettings, loadHabitsData, loadCoinsData, loadWishlistData } from './actions/data'
import { loadSettings, loadHabitsData, loadCoinsData, loadWishlistData, loadUsersData, loadServerSettings } from './actions/data'
import Layout from '@/components/Layout'
import { Toaster } from '@/components/ui/toaster'
import { ThemeProvider } from "@/components/theme-provider"
import { SessionProvider } from 'next-auth/react'
// Inter (clean, modern, excellent readability)
// const inter = Inter({
@@ -34,15 +37,18 @@ export default async function RootLayout({
}: {
children: React.ReactNode
}) {
const [initialSettings, initialHabits, initialCoins, initialWishlist] = await Promise.all([
const [initialSettings, initialHabits, initialCoins, initialWishlist, initialUsers, initialServerSettings] = await Promise.all([
loadSettings(),
loadHabitsData(),
loadCoinsData(),
loadWishlistData()
loadWishlistData(),
loadUsersData(),
loadServerSettings(),
])
return (
<html lang="en">
// set suppressHydrationWarning to true to prevent hydration errors when using ThemeProvider (https://ui.shadcn.com/docs/dark-mode/next)
<html lang="en" suppressHydrationWarning>
<body className={activeFont.className}>
<script
dangerouslySetInnerHTML={{
@@ -68,12 +74,23 @@ export default async function RootLayout({
settings: initialSettings,
habits: initialHabits,
coins: initialCoins,
wishlist: initialWishlist
wishlist: initialWishlist,
users: initialUsers,
serverSettings: initialServerSettings,
}}
>
<Layout>
{children}
</Layout>
<ThemeProvider
attribute="class"
defaultTheme="system"
enableSystem
disableTransitionOnChange
>
<SessionProvider>
<Layout>
{children}
</Layout>
</SessionProvider>
</ThemeProvider>
</JotaiHydrate>
</Suspense>
</JotaiProvider>

View File

@@ -142,60 +142,6 @@ export default function SettingsPage() {
</div>
</CardContent>
</Card>
<Card className="mb-6">
<CardHeader>
<CardTitle>Profile Settings</CardTitle>
</CardHeader>
<CardContent className="space-y-6">
<div className="flex items-center justify-between">
<div className="space-y-0.5">
<Label htmlFor="avatar">Avatar</Label>
<div className="text-sm text-muted-foreground">
Customize your profile picture
</div>
</div>
<div className="flex items-center gap-4">
<Avatar className="h-16 w-16">
<AvatarImage src={settings.profile?.avatarPath ? `/api/avatars/${settings.profile.avatarPath.split('/').pop()}` : '/avatars/default.png'} />
<AvatarFallback>
<User className="h-8 w-8" />
</AvatarFallback>
</Avatar>
<form action={async (formData: FormData) => {
const newSettings = await uploadAvatar(formData)
setSettings(newSettings)
}}>
<input
type="file"
id="avatar"
name="avatar"
accept="image/png, image/jpeg"
className="hidden"
onChange={(e) => {
const file = e.target.files?.[0]
if (file) {
if (file.size > 5 * 1024 * 1024) { // 5MB
alert('File size must be less than 5MB')
e.target.value = ''
return
}
const form = e.target.form
if (form) form.requestSubmit()
}
}}
/>
<Button
type="button"
variant="outline"
onClick={() => document.getElementById('avatar')?.click()}
>
Change
</Button>
</form>
</div>
</div>
</CardContent>
</Card>
</div >
</>
)

44
auth.ts Normal file
View File

@@ -0,0 +1,44 @@
import NextAuth from "next-auth"
import Credentials from "next-auth/providers/credentials"
import { getUser } from "./app/actions/data"
import { signInSchema } from "./lib/zod"
import { SafeUser, SessionUser } from "./lib/types"
export const { handlers, signIn, signOut, auth } = NextAuth({
trustHost: true,
providers: [
Credentials({
credentials: {
username: {},
password: {},
},
authorize: async (credentials) => {
const { username, password } = await signInSchema.parseAsync(credentials)
// Pass the plaintext password to getUser for verification
const user = await getUser(username, password)
if (!user) {
throw new Error("Invalid credentials.")
}
const safeUser: SessionUser = { id: user.id }
return safeUser
},
}),
],
callbacks: {
jwt: async ({ token, user }) => {
if (user) {
token.id = (user as SessionUser).id
}
return token
},
session: async ({ session, token }) => {
if (session?.user) {
session.user.id = token.id as string
}
return session
}
}
})

View File

@@ -1,44 +1,74 @@
'use client'
import { useState, useEffect } from 'react'
import { useState } from 'react'
import { RRule, RRuleSet, rrulestr } from 'rrule'
import { useAtom } from 'jotai'
import { settingsAtom, browserSettingsAtom } from '@/lib/atoms'
import { settingsAtom, browserSettingsAtom, usersAtom } from '@/lib/atoms'
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '@/components/ui/dialog'
import { Button } from '@/components/ui/button'
import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar'
import { Switch } from '@/components/ui/switch'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Textarea } from '@/components/ui/textarea'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'
import { Info, SmilePlus } from 'lucide-react'
import { Info, SmilePlus, Zap } from 'lucide-react'
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
import data from '@emoji-mart/data'
import Picker from '@emoji-mart/react'
import { Habit } from '@/lib/types'
import { d2s, d2t, getISODate, getNow, parseNaturalLanguageDate, parseNaturalLanguageRRule, parseRRule, serializeRRule } from '@/lib/utils'
import { INITIAL_DUE, INITIAL_RECURRENCE_RULE } from '@/lib/constants'
import { Habit, SafeUser } from '@/lib/types'
import { d2s, d2t, getFrequencyDisplayText, getISODate, getNow, parseNaturalLanguageDate, parseNaturalLanguageRRule, parseRRule, serializeRRule } from '@/lib/utils'
import { INITIAL_DUE, INITIAL_RECURRENCE_RULE, QUICK_DATES, RECURRENCE_RULE_MAP } from '@/lib/constants'
import * as chrono from 'chrono-node';
import { DateTime } from 'luxon'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select"
import { useHelpers } from '@/lib/client-helpers'
interface AddEditHabitModalProps {
onClose: () => void
onSave: (habit: Omit<Habit, 'id'>) => Promise<void>
habit?: Habit | null
isTask: boolean
}
export default function AddEditHabitModal({ onClose, onSave, habit }: AddEditHabitModalProps) {
export default function AddEditHabitModal({ onClose, onSave, habit, isTask }: AddEditHabitModalProps) {
const [settings] = useAtom(settingsAtom)
const [browserSettings] = useAtom(browserSettingsAtom)
const isTasksView = browserSettings.viewType === 'tasks'
const [name, setName] = useState(habit?.name || '')
const [description, setDescription] = useState(habit?.description || '')
const [coinReward, setCoinReward] = useState(habit?.coinReward || 1)
const [targetCompletions, setTargetCompletions] = useState(habit?.targetCompletions || 1)
const isRecurRule = !isTasksView
const origRuleText = isRecurRule ? parseRRule(habit?.frequency || INITIAL_RECURRENCE_RULE).toText() : habit?.frequency || INITIAL_DUE
const isRecurRule = !isTask
const origRuleText = getFrequencyDisplayText(habit?.frequency, isRecurRule, settings.system.timezone)
const [ruleText, setRuleText] = useState<string>(origRuleText)
const now = getNow({ timezone: settings.system.timezone })
const { currentUser } = useHelpers()
const [isQuickDatesOpen, setIsQuickDatesOpen] = useState(false)
const [selectedUserIds, setSelectedUserIds] = useState<string[]>((habit?.userIds || []).filter(id => id !== currentUser?.id))
const [usersData] = useAtom(usersAtom)
const users = usersData.users
function getFrequencyUpdate() {
if (ruleText === origRuleText && habit?.frequency) {
return habit.frequency
}
if (isRecurRule) {
const parsedRule = parseNaturalLanguageRRule(ruleText)
return serializeRRule(parsedRule)
} else {
const parsedDate = parseNaturalLanguageDate({
text: ruleText,
timezone: settings.system.timezone
})
return d2t({
dateTime: parsedDate,
timezone: settings.system.timezone
})
}
}
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
@@ -48,8 +78,8 @@ export default function AddEditHabitModal({ onClose, onSave, habit }: AddEditHab
coinReward,
targetCompletions: targetCompletions > 1 ? targetCompletions : undefined,
completions: habit?.completions || [],
frequency: isRecurRule ? serializeRRule(parseNaturalLanguageRRule(ruleText)) : d2t({ dateTime: parseNaturalLanguageDate({ text: ruleText, timezone: settings.system.timezone }) }),
isTask: isTasksView ? true : undefined
frequency: getFrequencyUpdate(),
userIds: selectedUserIds.length > 0 ? selectedUserIds.concat(currentUser?.id || []) : (currentUser && [currentUser.id])
})
}
@@ -57,13 +87,13 @@ export default function AddEditHabitModal({ onClose, onSave, habit }: AddEditHab
<Dialog open={true} onOpenChange={onClose}>
<DialogContent>
<DialogHeader>
<DialogTitle>{habit ? `Edit ${isTasksView ? 'Task' : 'Habit'}` : `Add New ${isTasksView ? 'Task' : 'Habit'}`}</DialogTitle>
<DialogTitle>{habit ? `Edit ${isTask ? 'Task' : 'Habit'}` : `Add New ${isTask ? 'Task' : 'Habit'}`}</DialogTitle>
</DialogHeader>
<form onSubmit={handleSubmit}>
<div className="grid gap-4 py-4">
<div className="grid grid-cols-4 items-center gap-4">
<Label htmlFor="name" className="text-right">
Name
Name *
</Label>
<div className='flex col-span-3 gap-2'>
<Input
@@ -114,15 +144,50 @@ export default function AddEditHabitModal({ onClose, onSave, habit }: AddEditHab
</div>
<div className="grid grid-cols-4 items-center gap-4">
<Label htmlFor="recurrence" className="text-right">
When
When *
</Label>
<div className="col-span-3 space-y-2">
<Input
id="recurrence"
value={ruleText}
onChange={(e) => setRuleText(e.target.value)}
// placeholder="e.g. 'every weekday' or 'every 2 weeks on Monday, Wednesday'"
/>
<div className="flex gap-2">
<Input
id="recurrence"
value={ruleText}
onChange={(e) => setRuleText(e.target.value)}
required
/>
{isTask && (
<Popover open={isQuickDatesOpen} onOpenChange={setIsQuickDatesOpen}>
<PopoverTrigger asChild>
<Button
type="button"
variant="ghost"
size="icon"
className="h-8 w-8"
>
<Zap className="h-4 w-4" />
</Button>
</PopoverTrigger>
<PopoverContent className="p-3 w-[280px] max-h-[40vh] overflow-y-auto" align="start">
<div className="space-y-1">
<div className="grid grid-cols-2 gap-2">
{QUICK_DATES.map((date) => (
<Button
key={date.value}
variant="outline"
className="justify-start h-9 px-3 hover:bg-primary hover:text-primary-foreground transition-colors"
onClick={() => {
setRuleText(date.value);
setIsQuickDatesOpen(false);
}}
>
{date.label}
</Button>
))}
</div>
</div>
</PopoverContent>
</Popover>
)}
</div>
</div>
<div className="col-start-2 col-span-3 text-sm text-muted-foreground">
<span>
@@ -217,9 +282,41 @@ export default function AddEditHabitModal({ onClose, onSave, habit }: AddEditHab
</div>
</div>
</div>
{users && users.length > 1 && (
<div className="grid grid-cols-4 items-center gap-4">
<div className="flex items-center justify-end gap-2">
<Label htmlFor="sharing-toggle">Share</Label>
</div>
<div className="col-span-3">
<div className="flex flex-wrap gap-2">
{users.filter((u) => u.id !== currentUser?.id).map(user => (
<Avatar
key={user.id}
className={`h-8 w-8 border-2 cursor-pointer
${selectedUserIds.includes(user.id)
? 'border-primary'
: 'border-muted'
}`}
title={user.username}
onClick={() => {
setSelectedUserIds(prev =>
prev.includes(user.id)
? prev.filter(id => id !== user.id)
: [...prev, user.id]
)
}}
>
<AvatarImage src={user?.avatarPath && `/api/avatars/${user.avatarPath.split('/').pop()}` || ""} />
<AvatarFallback>{user.username[0]}</AvatarFallback>
</Avatar>
))}
</div>
</div>
</div>
)}
</div>
<DialogFooter>
<Button type="submit">{habit ? 'Save Changes' : `Add ${isTasksView ? 'Task' : 'Habit'}`}</Button>
<Button type="submit">{habit ? 'Save Changes' : `Add ${isTask ? 'Task' : 'Habit'}`}</Button>
</DialogFooter>
</form>
</DialogContent>

View File

@@ -1,55 +1,130 @@
import { useState, useEffect } from 'react'
import { useAtom } from 'jotai'
import { usersAtom } from '@/lib/atoms'
import { useHelpers } from '@/lib/client-helpers'
import { Avatar, AvatarFallback, AvatarImage } from './ui/avatar'
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '@/components/ui/dialog'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Textarea } from '@/components/ui/textarea'
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
import { SmilePlus } from 'lucide-react'
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'
import { SmilePlus, Info } from 'lucide-react'
import data from '@emoji-mart/data'
import Picker from '@emoji-mart/react'
import { WishlistItemType } from '@/lib/types'
interface AddEditWishlistItemModalProps {
isOpen: boolean
onClose: () => void
onSave: (item: Omit<WishlistItemType, 'id'>) => void
item?: WishlistItemType | null
setIsOpen: (isOpen: boolean) => void
editingItem: WishlistItemType | null
setEditingItem: (item: WishlistItemType | null) => void
addWishlistItem: (item: Omit<WishlistItemType, 'id'>) => void
editWishlistItem: (item: WishlistItemType) => void
}
export default function AddEditWishlistItemModal({ isOpen, onClose, onSave, item }: AddEditWishlistItemModalProps) {
const [name, setName] = useState('')
const [description, setDescription] = useState('')
const [coinCost, setCoinCost] = useState(1)
export default function AddEditWishlistItemModal({
isOpen,
setIsOpen,
editingItem,
setEditingItem,
addWishlistItem,
editWishlistItem
}: AddEditWishlistItemModalProps) {
const [name, setName] = useState(editingItem?.name || '')
const [description, setDescription] = useState(editingItem?.description || '')
const [coinCost, setCoinCost] = useState(editingItem?.coinCost || 1)
const [targetCompletions, setTargetCompletions] = useState<number | undefined>(editingItem?.targetCompletions)
const [link, setLink] = useState(editingItem?.link || '')
const { currentUser } = useHelpers()
const [selectedUserIds, setSelectedUserIds] = useState<string[]>((editingItem?.userIds || []).filter(id => id !== currentUser?.id))
const [errors, setErrors] = useState<{ [key: string]: string }>({})
const [usersData] = useAtom(usersAtom)
useEffect(() => {
if (item) {
setName(item.name)
setDescription(item.description)
setCoinCost(item.coinCost)
if (editingItem) {
setName(editingItem.name)
setDescription(editingItem.description)
setCoinCost(editingItem.coinCost)
setTargetCompletions(editingItem.targetCompletions)
setLink(editingItem.link || '')
} else {
setName('')
setDescription('')
setCoinCost(1)
setTargetCompletions(undefined)
setLink('')
}
}, [item])
setErrors({})
}, [editingItem])
const handleSubmit = (e: React.FormEvent) => {
const validate = () => {
const newErrors: { [key: string]: string } = {}
if (!name.trim()) {
newErrors.name = 'Name is required'
}
if (coinCost < 1) {
newErrors.coinCost = 'Coin cost must be at least 1'
}
if (targetCompletions !== undefined && targetCompletions < 1) {
newErrors.targetCompletions = 'Target completions must be at least 1'
}
if (link && !isValidUrl(link)) {
newErrors.link = 'Please enter a valid URL'
}
setErrors(newErrors)
return Object.keys(newErrors).length === 0
}
const isValidUrl = (url: string) => {
try {
new URL(url)
return true
} catch {
return false
}
}
const handleClose = () => {
setIsOpen(false)
setEditingItem(null)
}
const handleSave = (e: React.FormEvent) => {
e.preventDefault()
onSave({ name, description, coinCost })
if (!validate()) return
const itemData = {
name,
description,
coinCost,
targetCompletions: targetCompletions || undefined,
link: link.trim() || undefined,
userIds: selectedUserIds.length > 0 ? selectedUserIds.concat(currentUser?.id || []) : (currentUser && [currentUser.id])
}
if (editingItem) {
editWishlistItem({ ...itemData, id: editingItem.id })
} else {
addWishlistItem(itemData)
}
setIsOpen(false)
setEditingItem(null)
}
return (
<Dialog open={isOpen} onOpenChange={onClose}>
<Dialog open={isOpen} onOpenChange={handleClose}>
<DialogContent>
<DialogHeader>
<DialogTitle>{item ? 'Edit Reward' : 'Add New Reward'}</DialogTitle>
<DialogTitle>{editingItem ? 'Edit Reward' : 'Add New Reward'}</DialogTitle>
</DialogHeader>
<form onSubmit={handleSubmit}>
<form onSubmit={handleSave}>
<div className="grid gap-4 py-4">
<div className="grid grid-cols-4 items-center gap-4">
<Label htmlFor="name" className="text-right">
Name
Name *
</Label>
<div className="col-span-3 flex gap-2">
<Input
@@ -96,22 +171,146 @@ export default function AddEditWishlistItemModal({ isOpen, onClose, onSave, item
/>
</div>
<div className="grid grid-cols-4 items-center gap-4">
<Label htmlFor="coinCost" className="text-right">
Coin Cost
</Label>
<Input
id="coinCost"
type="number"
value={coinCost}
onChange={(e) => setCoinCost(parseInt(e.target.value === "" ? "0" : e.target.value))}
className="col-span-3"
min={1}
required
/>
<div className="flex items-center gap-2 justify-end">
<Label htmlFor="coinReward">
Cost
</Label>
</div>
<div className="col-span-3">
<div className="flex items-center gap-4">
<div className="flex items-center border rounded-lg overflow-hidden">
<button
type="button"
onClick={() => setCoinCost(prev => Math.max(0, prev - 1))}
className="px-3 py-2 bg-gray-100 hover:bg-gray-200 dark:bg-gray-800 dark:hover:bg-gray-700 transition-colors"
>
-
</button>
<Input
id="coinReward"
type="number"
value={coinCost}
onChange={(e) => setCoinCost(parseInt(e.target.value === "" ? "0" : e.target.value))}
min={0}
required
className="w-20 text-center border-0 [appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none"
/>
<button
type="button"
onClick={() => setCoinCost(prev => prev + 1)}
className="px-3 py-2 bg-gray-100 hover:bg-gray-200 dark:bg-gray-800 dark:hover:bg-gray-700 transition-colors"
>
+
</button>
</div>
<span className="text-sm text-muted-foreground">
coins
</span>
</div>
</div>
</div>
<div className="grid grid-cols-4 items-center gap-4">
<div className="flex items-center gap-2 justify-end">
<Label htmlFor="targetCompletions">
Redeemable
</Label>
</div>
<div className="col-span-3">
<div className="flex items-center gap-4">
<div className="flex items-center border rounded-lg overflow-hidden">
<button
type="button"
onClick={() => setTargetCompletions(prev => prev !== undefined && prev > 1 ? prev - 1 : undefined)}
className="px-3 py-2 bg-gray-100 hover:bg-gray-200 dark:bg-gray-800 dark:hover:bg-gray-700 transition-colors"
>
-
</button>
<Input
id="targetCompletions"
type="number"
value={targetCompletions || ''}
onChange={(e) => {
const value = e.target.value
setTargetCompletions(value && value !== "0" ? parseInt(value) : undefined)
}}
min={0}
placeholder="∞"
className="w-20 text-center border-0 [appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none"
/>
<button
type="button"
onClick={() => setTargetCompletions(prev => Math.min(10, (prev || 0) + 1))}
className="px-3 py-2 bg-gray-100 hover:bg-gray-200 dark:bg-gray-800 dark:hover:bg-gray-700 transition-colors"
>
+
</button>
</div>
<span className="text-sm text-muted-foreground">
times
</span>
</div>
{errors.targetCompletions && (
<div className="text-sm text-red-500">
{errors.targetCompletions}
</div>
)}
</div>
</div>
<div className="grid grid-cols-4 items-center gap-4">
<Label htmlFor="link" className="text-right">
Link
</Label>
<div className="col-span-3">
<Input
id="link"
type="url"
placeholder="https://..."
value={link}
onChange={(e) => setLink(e.target.value)}
className="col-span-3"
/>
{errors.link && (
<div className="text-sm text-red-500">
{errors.link}
</div>
)}
</div>
</div>
{usersData.users && usersData.users.length > 1 && (
<div className="grid grid-cols-4 items-center gap-4">
<div className="flex items-center justify-end gap-2">
<Label htmlFor="sharing-toggle">Share</Label>
</div>
<div className="col-span-3">
<div className="flex flex-wrap gap-2">
{usersData.users.filter((u) => u.id !== currentUser?.id).map(user => (
<Avatar
key={user.id}
className={`h-8 w-8 border-2 cursor-pointer
${selectedUserIds.includes(user.id)
? 'border-primary'
: 'border-muted'
}`}
title={user.username}
onClick={() => {
setSelectedUserIds(prev =>
prev.includes(user.id)
? prev.filter(id => id !== user.id)
: [...prev, user.id]
)
}}
>
<AvatarImage src={user?.avatarPath && `/api/avatars/${user.avatarPath.split('/').pop()}` || ""} />
<AvatarFallback>{user.username[0]}</AvatarFallback>
</Avatar>
))}
</div>
</div>
</div>
)}
</div>
<DialogFooter>
<Button type="submit">{item ? 'Save Changes' : 'Add Reward'}</Button>
<Button type="submit">{editingItem ? 'Save Changes' : 'Add Reward'}</Button>
</DialogFooter>
</form>
</DialogContent>

View File

@@ -1,12 +1,24 @@
'use client'
import { ReactNode } from 'react'
import { ReactNode, useEffect } from 'react'
import { useAtom } from 'jotai'
import { pomodoroAtom } from '@/lib/atoms'
import { pomodoroAtom, userSelectAtom } from '@/lib/atoms'
import PomodoroTimer from './PomodoroTimer'
import UserSelectModal from './UserSelectModal'
import { useSession } from 'next-auth/react'
export default function ClientWrapper({ children }: { children: ReactNode }) {
const [pomo] = useAtom(pomodoroAtom)
const [userSelect, setUserSelect] = useAtom(userSelectAtom)
const { data: session, status } = useSession()
const currentUserId = session?.user.id
useEffect(() => {
if (status === 'loading') return
if (!currentUserId && !userSelect) {
setUserSelect(true)
}
}, [currentUserId, status, userSelect])
return (
<>
@@ -14,6 +26,9 @@ export default function ClientWrapper({ children }: { children: ReactNode }) {
{pomo.show && (
<PomodoroTimer />
)}
{userSelect && (
<UserSelectModal onClose={() => setUserSelect(false)}/>
)}
</>
)
}

View File

@@ -5,16 +5,20 @@ import { t2d, d2s, getNow, isSameDate } from '@/lib/utils'
import { Button } from '@/components/ui/button'
import { FormattedNumber } from '@/components/FormattedNumber'
import { History, Pencil } from 'lucide-react'
import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar'
import EmptyState from './EmptyState'
import { Input } from '@/components/ui/input'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { settingsAtom } from '@/lib/atoms'
import { settingsAtom, usersAtom } from '@/lib/atoms'
import Link from 'next/link'
import { useAtom } from 'jotai'
import { useCoins } from '@/hooks/useCoins'
import { TransactionNoteEditor } from './TransactionNoteEditor'
import { useHelpers } from '@/lib/client-helpers'
export default function CoinsManager() {
const { currentUser } = useHelpers()
const [selectedUser, setSelectedUser] = useState<string>()
const {
add,
remove,
@@ -26,8 +30,9 @@ export default function CoinsManager() {
totalSpent,
coinsSpentToday,
transactionsToday
} = useCoins()
} = useCoins({selectedUser})
const [settings] = useAtom(settingsAtom)
const [usersData] = useAtom(usersAtom)
const DEFAULT_AMOUNT = '0'
const [amount, setAmount] = useState(DEFAULT_AMOUNT)
const [pageSize, setPageSize] = useState(50)
@@ -58,7 +63,22 @@ export default function CoinsManager() {
return (
<div className="container mx-auto px-4 py-8">
<h1 className="text-3xl font-bold mb-6">Coins Management</h1>
<div className="flex items-center justify-between mb-6">
<h1 className="text-3xl font-bold mr-6">Coins Management</h1>
{currentUser?.isAdmin && (
<select
className="border rounded p-2"
value={selectedUser}
onChange={(e) => setSelectedUser(e.target.value)}
>
{usersData.users.map(user => (
<option key={user.id} value={user.id}>
{user.username}
</option>
))}
</select>
)}
</div>
<div className="grid gap-6 md:grid-cols-2">
<Card>
@@ -252,6 +272,17 @@ export default function CoinsManager() {
>
{transaction.type.split('_').join(' ')}
</span>
{transaction.userId && currentUser?.isAdmin && (
<Avatar className="h-6 w-6">
<AvatarImage
src={usersData.users.find(u => u.id === transaction.userId)?.avatarPath &&
`/api/avatars/${usersData.users.find(u => u.id === transaction.userId)?.avatarPath?.split('/').pop()}` || ""}
/>
<AvatarFallback>
{usersData.users.find(u => u.id === transaction.userId)?.username[0]}
</AvatarFallback>
</Avatar>
)}
</div>
<p className="text-sm text-gray-500">
{d2s({ dateTime: t2d({ timestamp: transaction.timestamp, timezone: settings.system.timezone }), timezone: settings.system.timezone })}

View File

@@ -0,0 +1,35 @@
import { Badge } from "@/components/ui/badge"
import { useAtom } from 'jotai'
import { completedHabitsMapAtom, habitsAtom, habitsByDateFamily } from '@/lib/atoms'
import { getTodayInTimezone } from '@/lib/utils'
import { useHabits } from '@/hooks/useHabits'
import { settingsAtom } from '@/lib/atoms'
interface CompletionCountBadgeProps {
type: 'habits' | 'tasks'
date?: string
}
export default function CompletionCountBadge({
type,
date
}: CompletionCountBadgeProps) {
const [settings] = useAtom(settingsAtom)
const [completedHabitsMap] = useAtom(completedHabitsMapAtom)
const targetDate = date || getTodayInTimezone(settings.system.timezone)
const [dueHabits] = useAtom(habitsByDateFamily(targetDate))
const completedCount = completedHabitsMap.get(targetDate)?.filter(h =>
type === 'tasks' ? h.isTask : !h.isTask
).length || 0
const totalCount = dueHabits.filter(h =>
type === 'tasks' ? h.isTask : !h.isTask
).length
return (
<Badge variant="secondary">
{`${completedCount}/${totalCount} Completed`}
</Badge>
)
}

View File

@@ -1,4 +1,5 @@
import { Circle, Coins, ArrowRight, CircleCheck, ChevronDown, ChevronUp, Timer } from 'lucide-react'
import { Circle, Coins, ArrowRight, CircleCheck, ChevronDown, ChevronUp, Timer, Plus } from 'lucide-react'
import CompletionCountBadge from './CompletionCountBadge'
import {
ContextMenu,
ContextMenuContent,
@@ -9,7 +10,7 @@ import { cn, isHabitDueToday, getHabitFreq } from '@/lib/utils'
import Link from 'next/link'
import { useState, useEffect } from 'react'
import { useAtom } from 'jotai'
import { pomodoroAtom, settingsAtom, completedHabitsMapAtom, browserSettingsAtom } from '@/lib/atoms'
import { pomodoroAtom, settingsAtom, completedHabitsMapAtom, browserSettingsAtom, BrowserSettings, hasTasksAtom, dailyHabitsAtom } from '@/lib/atoms'
import { getTodayInTimezone, isSameDate, t2d, d2t, getNow } from '@/lib/utils'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
@@ -18,6 +19,8 @@ import { WishlistItemType } from '@/lib/types'
import { Habit } from '@/lib/types'
import Linkify from './linkify'
import { useHabits } from '@/hooks/useHabits'
import AddEditHabitModal from './AddEditHabitModal'
import { Button } from './ui/button'
interface UpcomingItemsProps {
habits: Habit[]
@@ -32,24 +35,19 @@ export default function DailyOverview({
}: UpcomingItemsProps) {
const { completeHabit, undoComplete } = useHabits()
const [settings] = useAtom(settingsAtom)
const [browserSettings] = useAtom(browserSettingsAtom)
const [dailyHabits, setDailyHabits] = useState<Habit[]>([])
const [completedHabitsMap] = useAtom(completedHabitsMapAtom)
const [dailyItems] = useAtom(dailyHabitsAtom)
const dailyTasks = dailyItems.filter(habit => habit.isTask)
const dailyHabits = dailyItems.filter(habit => !habit.isTask)
const today = getTodayInTimezone(settings.system.timezone)
const todayCompletions = completedHabitsMap.get(today) || []
const isTasksView = browserSettings.viewType === 'tasks'
useEffect(() => {
// Filter habits that are due today based on their recurrence rule
const filteredHabits = habits.filter(habit =>
(isTasksView ? habit.isTask : !habit.isTask) &&
isHabitDueToday({ habit, timezone: settings.system.timezone })
)
setDailyHabits(filteredHabits)
}, [habits, isTasksView])
const { saveHabit } = useHabits()
const [browserSettings, setBrowserSettings] = useAtom(browserSettingsAtom)
// Get all wishlist items sorted by redeemable status (non-redeemable first) then by coin cost
// Filter out archived wishlist items
const sortedWishlistItems = wishlistItems
.filter(item => !item.archived)
.sort((a, b) => {
const aRedeemable = a.coinCost <= coinBalance
const bRedeemable = b.coinCost <= coinBalance
@@ -63,9 +61,15 @@ export default function DailyOverview({
return a.coinCost - b.coinCost
})
const [expandedHabits, setExpandedHabits] = useState(false)
const [expandedWishlist, setExpandedWishlist] = useState(false)
const [hasTasks] = useAtom(hasTasksAtom)
const [_, setPomo] = useAtom(pomodoroAtom)
const [modalConfig, setModalConfig] = useState<{
isOpen: boolean,
isTask: boolean
}>({
isOpen: false,
isTask: false
});
return (
<>
@@ -74,20 +78,57 @@ export default function DailyOverview({
<CardTitle>Today's Overview</CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-4">
<div>
<div className="flex items-center justify-between mb-2">
<h3 className="font-semibold">{isTasksView ? 'Daily Tasks' : 'Daily Habits'}</h3>
<Badge variant="secondary">
{`${dailyHabits.filter(habit => {
const completions = (completedHabitsMap.get(today) || [])
.filter(h => h.id === habit.id).length;
return completions >= (habit.targetCompletions || 1);
}).length}/${dailyHabits.length} Completed`}
</Badge>
<div className="space-y-6">
{/* Tasks Section */}
{hasTasks && dailyTasks.length === 0 ? (
<div>
<div className="flex items-center justify-between mb-2">
<h3 className="font-semibold">Daily Tasks</h3>
<Button
variant="ghost"
size="sm"
className="h-7 w-7 rounded-full hover:bg-primary/10 hover:text-primary"
onClick={() => {
setModalConfig({
isOpen: true,
isTask: true
});
}}
>
<Plus className="h-4 w-4" />
<span className="sr-only">Add Task</span>
</Button>
</div>
<div className="text-center text-muted-foreground text-sm py-4">
No tasks due today. Add some tasks to get started!
</div>
</div>
<ul className={`grid gap-2 transition-all duration-300 ease-in-out ${expandedHabits ? 'max-h-none' : 'max-h-[200px]'} overflow-hidden`}>
{dailyHabits
) : hasTasks && (
<div>
<div className="flex items-center justify-between mb-2">
<div className="flex items-center gap-2">
<h3 className="font-semibold">Daily Tasks</h3>
</div>
<div className="flex items-center gap-2">
<CompletionCountBadge type="tasks" />
<Button
variant="ghost"
size="sm"
className="h-7 w-7 rounded-full hover:bg-primary/10 hover:text-primary"
onClick={() => {
setModalConfig({
isOpen: true,
isTask: true
});
}}
>
<Plus className="h-4 w-4" />
<span className="sr-only">Add Task</span>
</Button>
</div>
</div>
<ul className={`grid gap-2 transition-all duration-300 ease-in-out ${browserSettings.expandedTasks ? 'max-h-none' : 'max-h-[200px]'} overflow-hidden`}>
{dailyTasks
.sort((a, b) => {
// First by completion status
const aCompleted = todayCompletions.includes(a);
@@ -114,23 +155,23 @@ export default function DailyOverview({
const bTarget = b.targetCompletions || 1;
return bTarget - aTarget;
})
.slice(0, expandedHabits ? undefined : 5)
.slice(0, browserSettings.expandedTasks ? undefined : 5)
.map((habit) => {
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 }))
).length
const target = habit.targetCompletions || 1
const isCompleted = completionsToday >= target
const isCompleted = completionsToday >= target || (habit.isTask && habit.archived)
return (
<li
className={`flex items-center justify-between text-sm p-2 rounded-md
${isCompleted ? 'bg-secondary/50' : 'bg-secondary/20'}`}
key={habit.id}
>
<span className="flex items-center gap-2">
<span className="flex items-center gap-2 flex-1 min-w-0">
<ContextMenu>
<ContextMenuTrigger asChild>
<div className="flex-none">
<div className="flex-shrink-0">
<button
onClick={(e) => {
e.preventDefault();
@@ -163,7 +204,7 @@ export default function DailyOverview({
</button>
</div>
</ContextMenuTrigger>
<span className={isCompleted ? 'line-through' : ''}>
<span className={cn(isCompleted ? 'line-through' : '', 'break-all')}>
<Linkify>
{habit.name}
</Linkify>
@@ -182,7 +223,7 @@ export default function DailyOverview({
</ContextMenuContent>
</ContextMenu>
</span>
<span className="flex items-center gap-2 text-xs text-muted-foreground">
<span className="flex items-center gap-2 text-xs text-muted-foreground flex-shrink-0">
{habit.targetCompletions && (
<span className="bg-secondary px-1.5 py-0.5 rounded-full">
{completionsToday}/{target}
@@ -216,10 +257,215 @@ export default function DailyOverview({
</ul>
<div className="flex items-center justify-between">
<button
onClick={() => setExpandedHabits(!expandedHabits)}
onClick={() => setBrowserSettings(prev => ({ ...prev, expandedTasks: !prev.expandedTasks }))}
className="text-sm text-muted-foreground hover:text-primary flex items-center gap-1"
>
{expandedHabits ? (
{browserSettings.expandedTasks ? (
<>
Show less
<ChevronUp className="h-3 w-3" />
</>
) : (
<>
Show all
<ChevronDown className="h-3 w-3" />
</>
)}
</button>
<Link
href="/habits?view=tasks"
className="text-sm text-muted-foreground hover:text-primary flex items-center gap-1"
onClick={() => setBrowserSettings(prev => ({ ...prev, viewType: 'tasks' }))}
>
View
<ArrowRight className="h-3 w-3" />
</Link>
</div>
</div>
)}
{/* Habits Section */}
{dailyHabits.length === 0 ? (
<div>
<div className="flex items-center justify-between mb-2">
<h3 className="font-semibold">Daily Habits</h3>
<Button
variant="ghost"
size="sm"
className="h-7 w-7 rounded-full hover:bg-primary/10 hover:text-primary"
onClick={() => {
setModalConfig({
isOpen: true,
isTask: false
});
}}
>
<Plus className="h-4 w-4" />
<span className="sr-only">Add Habit</span>
</Button>
</div>
<div className="text-center text-muted-foreground text-sm py-4">
No habits due today. Add some habits to get started!
</div>
</div>
) : (
<div>
<div className="flex items-center justify-between mb-2">
<div className="flex items-center gap-2">
<h3 className="font-semibold">Daily Habits</h3>
</div>
<div className="flex items-center gap-2">
<CompletionCountBadge type="habits" />
<Button
variant="ghost"
size="sm"
className="h-7 w-7 rounded-full hover:bg-primary/10 hover:text-primary"
onClick={() => {
setModalConfig({
isOpen: true,
isTask: false
});
}}
>
<Plus className="h-4 w-4" />
<span className="sr-only">Add Habit</span>
</Button>
</div>
</div>
<ul className={`grid gap-2 transition-all duration-300 ease-in-out ${browserSettings.expandedHabits ? 'max-h-none' : 'max-h-[200px]'} overflow-hidden`}>
{dailyHabits
.sort((a, b) => {
// First by completion status
const aCompleted = todayCompletions.includes(a);
const bCompleted = todayCompletions.includes(b);
if (aCompleted !== bCompleted) {
return aCompleted ? 1 : -1;
}
// Then by frequency (daily first)
const aFreq = getHabitFreq(a);
const bFreq = getHabitFreq(b);
const freqOrder = ['daily', 'weekly', 'monthly', 'yearly'];
if (freqOrder.indexOf(aFreq) !== freqOrder.indexOf(bFreq)) {
return freqOrder.indexOf(aFreq) - freqOrder.indexOf(bFreq);
}
// Then by coin reward (higher first)
if (a.coinReward !== b.coinReward) {
return b.coinReward - a.coinReward;
}
// Finally by target completions (higher first)
const aTarget = a.targetCompletions || 1;
const bTarget = b.targetCompletions || 1;
return bTarget - aTarget;
})
.slice(0, browserSettings.expandedHabits ? undefined : 5)
.map((habit) => {
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 }))
).length
const target = habit.targetCompletions || 1
const isCompleted = completionsToday >= target
return (
<li
className={`flex items-center justify-between text-sm p-2 rounded-md
${isCompleted ? 'bg-secondary/50' : 'bg-secondary/20'}`}
key={habit.id}
>
<span className="flex items-center gap-2 flex-1 min-w-0">
<ContextMenu>
<ContextMenuTrigger asChild>
<div className="flex-shrink-0">
<button
onClick={(e) => {
e.preventDefault();
if (isCompleted) {
undoComplete(habit);
} else {
completeHabit(habit);
}
}}
className="relative hover:opacity-70 transition-opacity w-4 h-4"
>
{isCompleted ? (
<CircleCheck className="h-4 w-4 text-green-500" />
) : (
<div className="relative h-4 w-4">
<Circle className="absolute h-4 w-4 text-muted-foreground" />
<div
className="absolute h-4 w-4 rounded-full overflow-hidden"
style={{
background: `conic-gradient(
currentColor ${(completionsToday / target) * 360}deg,
transparent ${(completionsToday / target) * 360}deg 360deg
)`,
mask: 'radial-gradient(transparent 50%, black 51%)',
WebkitMask: 'radial-gradient(transparent 50%, black 51%)'
}}
/>
</div>
)}
</button>
</div>
</ContextMenuTrigger>
<span className={cn(isCompleted ? 'line-through' : '', 'break-all')}>
<Linkify>
{habit.name}
</Linkify>
</span>
<ContextMenuContent className="w-64">
<ContextMenuItem onClick={() => {
setPomo((prev) => ({
...prev,
show: true,
selectedHabitId: habit.id
}))
}}>
<Timer className="mr-2 h-4 w-4" />
<span>Start Pomodoro</span>
</ContextMenuItem>
</ContextMenuContent>
</ContextMenu>
</span>
<span className="flex items-center gap-2 text-xs text-muted-foreground flex-shrink-0">
{habit.targetCompletions && (
<span className="bg-secondary px-1.5 py-0.5 rounded-full">
{completionsToday}/{target}
</span>
)}
{getHabitFreq(habit) !== 'daily' && (
<Badge variant="outline" className="text-xs">
{getHabitFreq(habit)}
</Badge>
)}
<span className="flex items-center">
<Coins className={cn(
"h-3 w-3 mr-1 transition-all",
isCompleted
? "text-yellow-500 drop-shadow-[0_0_2px_rgba(234,179,8,0.3)]"
: "text-gray-400"
)} />
<span className={cn(
"transition-all",
isCompleted
? "text-yellow-500 font-medium"
: "text-gray-400"
)}>
{habit.coinReward}
</span>
</span>
</span>
</li>
)
})}
</ul>
<div className="flex items-center justify-between">
<button
onClick={() => setBrowserSettings(prev => ({ ...prev, expandedHabits: !prev.expandedHabits }))}
className="text-sm text-muted-foreground hover:text-primary flex items-center gap-1"
>
{browserSettings.expandedHabits ? (
<>
Show less
<ChevronUp className="h-3 w-3" />
@@ -234,12 +480,14 @@ export default function DailyOverview({
<Link
href="/habits"
className="text-sm text-muted-foreground hover:text-primary flex items-center gap-1"
onClick={() => setBrowserSettings(prev => ({ ...prev, viewType: 'habits' }))}
>
View
<ArrowRight className="h-3 w-3" />
</Link>
</div>
</div>
)}
<div className="space-y-2">
<div className="flex items-center justify-between mb-2">
@@ -249,7 +497,7 @@ export default function DailyOverview({
</Badge>
</div>
<div>
<div className={`space-y-3 transition-all duration-300 ease-in-out ${expandedWishlist ? 'max-h-none' : 'max-h-[200px]'} overflow-hidden`}>
<div className={`space-y-3 transition-all duration-300 ease-in-out ${browserSettings.expandedWishlist ? 'max-h-none' : 'max-h-[200px]'} overflow-hidden`}>
{sortedWishlistItems.length === 0 ? (
<div className="text-center text-muted-foreground text-sm py-4">
No wishlist items yet. Add some goals to work towards!
@@ -257,7 +505,7 @@ export default function DailyOverview({
) : (
<>
{sortedWishlistItems
.slice(0, expandedWishlist ? undefined : 5)
.slice(0, browserSettings.expandedWishlist ? undefined : 5)
.map((item) => {
const isRedeemable = item.coinCost <= coinBalance
return (
@@ -311,10 +559,10 @@ export default function DailyOverview({
</div>
<div className="flex items-center justify-between">
<button
onClick={() => setExpandedWishlist(!expandedWishlist)}
onClick={() => setBrowserSettings(prev => ({ ...prev, expandedWishlist: !prev.expandedWishlist }))}
className="text-sm text-muted-foreground hover:text-primary flex items-center gap-1"
>
{expandedWishlist ? (
{browserSettings.expandedWishlist ? (
<>
Show less
<ChevronUp className="h-3 w-3" />
@@ -339,6 +587,17 @@ export default function DailyOverview({
</div>
</CardContent>
</Card>
{modalConfig.isOpen && (
<AddEditHabitModal
onClose={() => setModalConfig({ isOpen: false, isTask: false })}
onSave={async (habit) => {
await saveHabit({ ...habit, isTask: modalConfig.isTask })
setModalConfig({ isOpen: false, isTask: false });
}}
habit={null}
isTask={modalConfig.isTask}
/>
)}
</>
)
}

View File

@@ -1,19 +1,18 @@
'use client'
import { useAtom } from 'jotai'
import { wishlistAtom, habitsAtom, settingsAtom, coinsAtom } from '@/lib/atoms'
import { wishlistAtom, habitsAtom, settingsAtom } from '@/lib/atoms'
import DailyOverview from './DailyOverview'
import HabitStreak from './HabitStreak'
import CoinBalance from './CoinBalance'
import { useHabits } from '@/hooks/useHabits'
import { ViewToggle } from './ViewToggle'
import { useCoins } from '@/hooks/useCoins'
export default function Dashboard() {
const [habitsData] = useAtom(habitsAtom)
const habits = habitsData.habits
const [settings] = useAtom(settingsAtom)
const [coins] = useAtom(coinsAtom)
const coinBalance = coins.balance
const { balance } = useCoins()
const [wishlist] = useAtom(wishlistAtom)
const wishlistItems = wishlist.items
@@ -21,15 +20,14 @@ export default function Dashboard() {
<div className="container mx-auto px-4 py-8">
<div className="flex justify-between items-center mb-6">
<h1 className="text-3xl font-bold">Dashboard</h1>
<ViewToggle />
</div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
<CoinBalance coinBalance={coinBalance} />
<CoinBalance coinBalance={balance} />
<HabitStreak habits={habits} />
<DailyOverview
wishlistItems={wishlistItems}
habits={habits}
coinBalance={coinBalance}
coinBalance={balance}
/>
{/* <HabitHeatmap habits={habits} /> */}

View File

@@ -3,13 +3,13 @@
import { useState, useMemo, useCallback } from 'react'
import { Calendar } from '@/components/ui/calendar'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
import CompletionCountBadge from '@/components/CompletionCountBadge'
import { Button } from '@/components/ui/button'
import { Check, Circle, CircleCheck } from 'lucide-react'
import { d2s, getNow, t2d, getCompletedHabitsForDate, isHabitDue, getISODate, getCompletionsForToday, getCompletionsForDate } from '@/lib/utils'
import { useAtom } from 'jotai'
import { useHabits } from '@/hooks/useHabits'
import { habitsAtom, settingsAtom, completedHabitsMapAtom } from '@/lib/atoms'
import { habitsAtom, settingsAtom, completedHabitsMapAtom, hasTasksAtom } from '@/lib/atoms'
import { DateTime } from 'luxon'
import Linkify from './linkify'
import { Habit } from '@/lib/types'
@@ -25,8 +25,10 @@ export default function HabitCalendar() {
}
}, [completePastHabit])
const [settings] = useAtom(settingsAtom)
const [selectedDate, setSelectedDate] = 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 [habitsData] = useAtom(habitsAtom)
const [hasTasks] = useAtom(hasTasksAtom)
const habits = habitsData.habits
const [completedHabitsMap] = useAtom(completedHabitsMapAtom)
@@ -39,9 +41,9 @@ export default function HabitCalendar() {
}, [completedHabitsMap, settings.system.timezone])
return (
<div className="container mx-auto px-4 py-8">
<h1 className="text-3xl font-bold mb-6">Habit Calendar</h1>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div className="container mx-auto px-4 py-6">
<h1 className="text-2xl font-semibold mb-6">Habit Calendar</h1>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
<Card>
<CardHeader>
<CardTitle>Calendar</CardTitle>
@@ -49,8 +51,8 @@ export default function HabitCalendar() {
<CardContent>
<Calendar
mode="single"
selected={selectedDate.toJSDate()}
onSelect={(e) => e && setSelectedDate(DateTime.fromJSDate(e))}
selected={selectedDateTime.toJSDate()}
onSelect={(e) => e && setSelectedDateTime(DateTime.fromJSDate(e))}
weekStartsOn={settings.system.weekStartDay}
className="rounded-md border"
modifiers={{
@@ -62,7 +64,7 @@ export default function HabitCalendar() {
)
}}
modifiersClassNames={{
completed: 'bg-green-100 text-green-800 font-bold',
completed: 'bg-green-100 dark:bg-green-900/30 text-green-800 dark:text-green-300 font-medium rounded-md',
}}
/>
</CardContent>
@@ -70,28 +72,94 @@ export default function HabitCalendar() {
<Card>
<CardHeader>
<CardTitle>
{selectedDate ? (
<>Habits for {d2s({ dateTime: selectedDate, timezone: settings.system.timezone, format: "yyyy-MM-dd" })}</>
{selectedDateTime ? (
<>{d2s({ dateTime: selectedDateTime, timezone: settings.system.timezone, format: DateTime.DATE_MED_WITH_WEEKDAY })}</>
) : (
'Select a date'
)}
</CardTitle>
</CardHeader>
<CardContent>
{selectedDate && (
<ul className="space-y-2">
{habits
.filter(habit => isHabitDue({
habit,
timezone: settings.system.timezone,
date: selectedDate
}))
.map((habit) => {
const completions = getCompletionsForDate({ habit, date: selectedDate, timezone: settings.system.timezone })
{selectedDateTime && (
<div className="space-y-8">
{hasTasks && (
<div className="pt-2 border-t">
<div className="flex items-center justify-between mb-3">
<h3 className="font-medium text-sm text-muted-foreground uppercase tracking-wide">Tasks</h3>
<CompletionCountBadge type="tasks" date={selectedDate.toString()} />
</div>
<ul className="space-y-3">
{habits
.filter(habit => habit.isTask && isHabitDue({
habit,
timezone: settings.system.timezone,
date: selectedDateTime
}))
.map((habit) => {
const completions = getCompletionsForDate({ habit, date: selectedDateTime, timezone: settings.system.timezone })
const isCompleted = completions >= (habit.targetCompletions || 1)
return (
<li key={habit.id} className="flex items-center justify-between gap-2 px-3 py-2 rounded-lg hover:bg-muted/50 transition-colors">
<span className="flex items-center gap-2">
<Linkify>{habit.name}</Linkify>
</span>
<div className="flex items-center gap-2">
<div className="flex items-center gap-2">
{habit.targetCompletions && (
<span className="text-sm text-muted-foreground">
{completions}/{habit.targetCompletions}
</span>
)}
<button
onClick={() => handleCompletePastHabit(habit, selectedDateTime)}
disabled={isCompleted}
className="relative h-4 w-4 hover:opacity-70 transition-opacity disabled:opacity-100"
>
{isCompleted ? (
<CircleCheck className="h-4 w-4 text-green-500" />
) : (
<div className="relative h-4 w-4">
<Circle className="absolute h-4 w-4 text-muted-foreground" />
<div
className="absolute h-4 w-4 rounded-full overflow-hidden"
style={{
background: `conic-gradient(
currentColor ${(completions / (habit.targetCompletions ?? 1)) * 360}deg,
transparent ${(completions / (habit.targetCompletions ?? 1)) * 360}deg 360deg
)`,
mask: 'radial-gradient(transparent 50%, black 51%)',
WebkitMask: 'radial-gradient(transparent 50%, black 51%)'
}}
/>
</div>
)}
</button>
</div>
</div>
</li>
)
})}
</ul>
</div>
)}
<div>
<div className="flex items-center justify-between mb-3">
<h3 className="font-medium text-sm text-muted-foreground uppercase tracking-wide">Habits</h3>
<CompletionCountBadge type="habits" date={selectedDate.toString()} />
</div>
<ul className="space-y-3">
{habits
.filter(habit => !habit.isTask && isHabitDue({
habit,
timezone: settings.system.timezone,
date: selectedDateTime
}))
.map((habit) => {
const completions = getCompletionsForDate({ habit, date: selectedDateTime, timezone: settings.system.timezone })
const isCompleted = completions >= (habit.targetCompletions || 1)
return (
<li key={habit.id} className="flex items-center justify-between gap-2">
<span>
<li key={habit.id} className="flex items-center justify-between gap-2 px-3 py-2 rounded-lg hover:bg-muted/50 transition-colors">
<span className="flex items-center gap-2">
<Linkify>{habit.name}</Linkify>
</span>
<div className="flex items-center gap-2">
@@ -102,7 +170,7 @@ export default function HabitCalendar() {
</span>
)}
<button
onClick={() => handleCompletePastHabit(habit, selectedDate)}
onClick={() => handleCompletePastHabit(habit, selectedDateTime)}
disabled={isCompleted}
className="relative h-4 w-4 hover:opacity-70 transition-opacity disabled:opacity-100"
>
@@ -129,8 +197,10 @@ export default function HabitCalendar() {
</div>
</li>
)
})}
</ul>
})}
</ul>
</div>
</div>
)}
</CardContent>
</Card>

View File

@@ -1,10 +1,10 @@
import { Habit } from '@/lib/types'
import { Habit, SafeUser, User, Permission } from '@/lib/types'
import { useAtom } from 'jotai'
import { settingsAtom, pomodoroAtom, browserSettingsAtom } from '@/lib/atoms'
import { getTodayInTimezone, isSameDate, t2d, d2t, getNow, parseNaturalLanguageRRule, parseRRule, d2s } from '@/lib/utils'
import { settingsAtom, pomodoroAtom, browserSettingsAtom, usersAtom } from '@/lib/atoms'
import { getTodayInTimezone, isSameDate, t2d, d2t, getNow, parseRRule, d2s, getCompletionsForToday, isTaskOverdue, getFrequencyDisplayText } from '@/lib/utils'
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { Coins, Edit, Trash2, Check, Undo2, MoreVertical, Timer } from 'lucide-react'
import { Coins, Edit, Trash2, Check, Undo2, MoreVertical, Timer, Archive, ArchiveRestore, Calendar } from 'lucide-react'
import {
DropdownMenu,
DropdownMenuContent,
@@ -14,8 +14,10 @@ import {
} from '@/components/ui/dropdown-menu'
import { useEffect, useState } from 'react'
import { useHabits } from '@/hooks/useHabits'
import { INITIAL_RECURRENCE_RULE } from '@/lib/constants'
import { INITIAL_RECURRENCE_RULE, RECURRENCE_RULE_MAP } from '@/lib/constants'
import { DateTime } from 'luxon'
import { Avatar, AvatarFallback, AvatarImage } from './ui/avatar'
import { useHelpers } from '@/lib/client-helpers'
interface HabitItemProps {
habit: Habit
@@ -23,16 +25,38 @@ interface HabitItemProps {
onDelete: () => void
}
const renderUserAvatars = (habit: Habit, currentUser: User | null, usersData: { users: User[] }) => {
if (!habit.userIds || habit.userIds.length <= 1) return null;
return (
<div className="flex -space-x-2 ml-2 flex-shrink-0">
{habit.userIds?.filter((u) => u !== currentUser?.id).map(userId => {
const user = usersData.users.find(u => u.id === userId)
if (!user) return null
return (
<Avatar key={user.id} className="h-6 w-6">
<AvatarImage src={user?.avatarPath && `/api/avatars/${user.avatarPath.split('/').pop()}` || ""} />
<AvatarFallback>{user.username[0]}</AvatarFallback>
</Avatar>
)
})}
</div>
);
};
export default function HabitItem({ habit, onEdit, onDelete }: HabitItemProps) {
const { completeHabit, undoComplete } = useHabits()
const { completeHabit, undoComplete, archiveHabit, unarchiveHabit, saveHabit } = useHabits()
const [settings] = useAtom(settingsAtom)
const [_, setPomo] = useAtom(pomodoroAtom)
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 }))
).length || 0
const completionsToday = getCompletionsForToday({ habit, timezone: settings.system.timezone })
const target = habit.targetCompletions || 1
const isCompletedToday = completionsToday >= target
const [isHighlighted, setIsHighlighted] = useState(false)
const [usersData] = useAtom(usersAtom)
const { currentUser, hasPermission } = useHelpers()
const canWrite = hasPermission('habit', 'write')
const canInteract = hasPermission('habit', 'interact')
const [browserSettings] = useAtom(browserSettingsAtom)
const isTasksView = browserSettings.viewType === 'tasks'
const isRecurRule = !isTasksView
@@ -59,21 +83,33 @@ export default function HabitItem({ habit, onEdit, onDelete }: HabitItemProps) {
return (
<Card
id={`habit-${habit.id}`}
className={`h-full flex flex-col transition-all duration-500 ${isHighlighted ? 'bg-yellow-100 dark:bg-yellow-900' : ''}`}
className={`h-full flex flex-col transition-all duration-500 ${isHighlighted ? 'bg-yellow-100 dark:bg-yellow-900' : ''} ${habit.archived ? 'opacity-75' : ''}`}
>
<CardHeader className="flex-none">
<CardTitle className="line-clamp-1">{habit.name}</CardTitle>
<div className="flex justify-between items-start">
<CardTitle className={`line-clamp-1 ${habit.archived ? 'text-gray-400 dark:text-gray-500' : ''} flex items-center ${isTasksView ? 'w-full' : ''} justify-between`}>
<span>{habit.name}</span>
{isTaskOverdue(habit, settings.system.timezone) && (
<span className="ml-2 inline-flex items-center rounded-md bg-red-50 dark:bg-red-900/30 px-2 py-1 text-xs font-medium text-red-700 dark:text-red-400 ring-1 ring-inset ring-red-600/10 dark:ring-red-500/20">
Overdue
</span>
)}
</CardTitle>
{renderUserAvatars(habit, currentUser as User, usersData)}
</div>
{habit.description && (
<CardDescription className="whitespace-pre-line">
<CardDescription className={`whitespace-pre-line mt-2 ${habit.archived ? 'text-gray-400 dark:text-gray-500' : ''}`}>
{habit.description}
</CardDescription>
)}
</CardHeader>
<CardContent className="flex-1">
<p className="text-sm text-gray-500">When: {isRecurRule ? parseRRule(habit.frequency || INITIAL_RECURRENCE_RULE).toText() : d2s({ dateTime: t2d({ timestamp: habit.frequency, timezone: settings.system.timezone }), timezone: settings.system.timezone, format: DateTime.DATE_MED_WITH_WEEKDAY })}</p>
<p className={`text-sm ${habit.archived ? 'text-gray-400 dark:text-gray-500' : 'text-gray-500'}`}>
When: {getFrequencyDisplayText(habit.frequency, isRecurRule, settings.system.timezone)}
</p>
<div className="flex items-center mt-2">
<Coins className="h-4 w-4 text-yellow-400 mr-1" />
<span className="text-sm font-medium">{habit.coinReward} coins per completion</span>
<Coins className={`h-4 w-4 mr-1 ${habit.archived ? 'text-gray-400 dark:text-gray-500' : 'text-yellow-400'}`} />
<span className={`text-sm font-medium ${habit.archived ? 'text-gray-400 dark:text-gray-500' : ''}`}>{habit.coinReward} coins per completion</span>
</div>
</CardContent>
<CardFooter className="flex justify-between gap-2">
@@ -83,8 +119,8 @@ export default function HabitItem({ habit, onEdit, onDelete }: HabitItemProps) {
variant={isCompletedToday ? "secondary" : "default"}
size="sm"
onClick={async () => await completeHabit(habit)}
disabled={isCompletedToday && completionsToday >= target}
className="overflow-hidden w-24 sm:w-auto"
disabled={!canInteract || habit.archived || (isCompletedToday && completionsToday >= target)}
className={`overflow-hidden w-24 sm:w-auto ${habit.archived ? 'cursor-not-allowed' : ''}`}
>
<Check className="h-4 w-4 sm:mr-2" />
<span>
@@ -116,11 +152,12 @@ export default function HabitItem({ habit, onEdit, onDelete }: HabitItemProps) {
)}
</Button>
</div>
{completionsToday > 0 && (
{completionsToday > 0 && !habit.archived && (
<Button
variant="outline"
size="sm"
onClick={async () => await undoComplete(habit)}
disabled={!canWrite}
className="w-10 sm:w-auto"
>
<Undo2 className="h-4 w-4" />
@@ -129,15 +166,18 @@ export default function HabitItem({ habit, onEdit, onDelete }: HabitItemProps) {
)}
</div>
<div className="flex gap-2">
<Button
variant="edit"
size="sm"
onClick={onEdit}
className="hidden sm:flex"
>
<Edit className="h-4 w-4" />
<span className="ml-2">Edit</span>
</Button>
{!habit.archived && (
<Button
variant="edit"
size="sm"
onClick={onEdit}
disabled={!canWrite}
className="hidden sm:flex"
>
<Edit className="h-4 w-4" />
<span className="ml-2">Edit</span>
</Button>
)}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="sm" className="h-8 w-8 p-0">
@@ -145,17 +185,46 @@ export default function HabitItem({ habit, onEdit, onDelete }: HabitItemProps) {
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => {
setPomo((prev) => ({
...prev,
show: true,
selectedHabitId: habit.id
}))
}}>
<Timer className="mr-2 h-4 w-4" />
<span>Start Pomodoro</span>
</DropdownMenuItem>
<DropdownMenuItem onClick={onEdit} className="sm:hidden">
{!habit.archived && (
<DropdownMenuItem onClick={() => {
if (!canInteract) return
setPomo((prev) => ({
...prev,
show: true,
selectedHabitId: habit.id
}))
}}>
<Timer className="mr-2 h-4 w-4" />
<span>Start Pomodoro</span>
</DropdownMenuItem>
)}
{!habit.archived && (
<>
{habit.isTask && (
<DropdownMenuItem disabled={!canWrite} onClick={() => {
saveHabit({...habit, frequency: d2t({ dateTime: getNow({ timezone: settings.system.timezone })})})
}}>
<Calendar className="mr-2 h-4 w-4" />
<span>Move to Today</span>
</DropdownMenuItem>
)}
<DropdownMenuItem disabled={!canWrite} onClick={() => archiveHabit(habit.id)}>
<Archive className="mr-2 h-4 w-4" />
<span>Archive</span>
</DropdownMenuItem>
</>
)}
{habit.archived && (
<DropdownMenuItem disabled={!canWrite} onClick={() => unarchiveHabit(habit.id)}>
<ArchiveRestore className="mr-2 h-4 w-4" />
<span>Unarchive</span>
</DropdownMenuItem>
)}
<DropdownMenuItem
onClick={onEdit}
className="sm:hidden"
disabled={habit.archived}
>
<Edit className="mr-2 h-4 w-4" />
Edit
</DropdownMenuItem>

View File

@@ -12,17 +12,26 @@ import ConfirmDialog from './ConfirmDialog'
import { Habit } from '@/lib/types'
import { useHabits } from '@/hooks/useHabits'
import { HabitIcon, TaskIcon } from '@/lib/constants'
import { ViewToggle } from './ViewToggle'
export default function HabitList() {
const { saveHabit, deleteHabit } = useHabits()
const [habitsData, setHabitsData] = useAtom(habitsAtom)
const [browserSettings] = useAtom(browserSettingsAtom)
const isTasksView = browserSettings.viewType === 'tasks'
const habits = habitsData.habits.filter(habit =>
const habits = habitsData.habits.filter(habit =>
isTasksView ? habit.isTask : !habit.isTask
)
const activeHabits = habits.filter(h => !h.archived)
const archivedHabits = habits.filter(h => h.archived)
const [settings] = useAtom(settingsAtom)
const [isModalOpen, setIsModalOpen] = useState(false)
const [modalConfig, setModalConfig] = useState<{
isOpen: boolean,
isTask: boolean
}>({
isOpen: false,
isTask: false
})
const [editingHabit, setEditingHabit] = useState<Habit | null>(null)
const [deleteConfirmation, setDeleteConfirmation] = useState<{ isOpen: boolean, habitId: string | null }>({
isOpen: false,
@@ -32,16 +41,19 @@ export default function HabitList() {
return (
<div className="container mx-auto px-4 py-8">
<div className="flex justify-between items-center mb-6">
<h1 className="text-3xl font-bold">
{isTasksView ? 'My Tasks' : 'My Habits'}
</h1>
<Button onClick={() => setIsModalOpen(true)}>
<Plus className="mr-2 h-4 w-4" /> {isTasksView ? 'Add Task' : 'Add Habit'}
</Button>
</div>
<div className="flex justify-between items-center mb-6">
<h1 className="text-3xl font-bold">
{isTasksView ? 'My Tasks' : 'My Habits'}
</h1>
<Button onClick={() => setModalConfig({ isOpen: true, isTask: isTasksView })}>
<Plus className="mr-2 h-4 w-4" /> {isTasksView ? 'Add Task' : 'Add Habit'}
</Button>
</div>
<div className='py-4'>
<ViewToggle />
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 items-stretch">
{habits.length === 0 ? (
{activeHabits.length === 0 ? (
<div className="col-span-2">
<EmptyState
icon={isTasksView ? TaskIcon : HabitIcon}
@@ -50,31 +62,53 @@ export default function HabitList() {
/>
</div>
) : (
habits.map((habit) => (
activeHabits.map((habit: Habit) => (
<HabitItem
key={habit.id}
habit={habit}
onEdit={() => {
setEditingHabit(habit)
setIsModalOpen(true)
setModalConfig({ isOpen: true, isTask: isTasksView })
}}
onDelete={() => setDeleteConfirmation({ isOpen: true, habitId: habit.id })}
/>
))
)}
{archivedHabits.length > 0 && (
<>
<div className="col-span-1 sm:col-span-2 relative flex items-center my-6">
<div className="flex-grow border-t border-gray-300 dark:border-gray-600" />
<span className="mx-4 text-sm text-gray-500 dark:text-gray-400">Archived</span>
<div className="flex-grow border-t border-gray-300 dark:border-gray-600" />
</div>
{archivedHabits.map((habit: Habit) => (
<HabitItem
key={habit.id}
habit={habit}
onEdit={() => {
setEditingHabit(habit)
setModalConfig({ isOpen: true, isTask: isTasksView })
}}
onDelete={() => setDeleteConfirmation({ isOpen: true, habitId: habit.id })}
/>
))}
</>
)}
</div>
{isModalOpen &&
{modalConfig.isOpen &&
<AddEditHabitModal
onClose={() => {
setIsModalOpen(false)
setModalConfig({ isOpen: false, isTask: false })
setEditingHabit(null)
}}
onSave={async (habit) => {
await saveHabit({ ...habit, id: editingHabit?.id })
setIsModalOpen(false)
await saveHabit({ ...habit, id: editingHabit?.id, isTask: modalConfig.isTask })
setModalConfig({ isOpen: false, isTask: false })
setEditingHabit(null)
}}
habit={editingHabit}
isTask={modalConfig.isTask}
/>
}
<ConfirmDialog

View File

@@ -5,7 +5,7 @@ import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
import { d2s, getNow, t2d, getCompletedHabitsForDate } from '@/lib/utils'
import { LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer } from 'recharts'
import { useAtom } from 'jotai'
import { settingsAtom } from '@/lib/atoms'
import { settingsAtom, hasTasksAtom } from '@/lib/atoms'
interface HabitStreakProps {
habits: Habit[]
@@ -13,6 +13,7 @@ interface HabitStreakProps {
export default function HabitStreak({ habits }: HabitStreakProps) {
const [settings] = useAtom(settingsAtom)
const [hasTasks] = useAtom(hasTasksAtom)
// Get the last 7 days of data
const dates = Array.from({ length: 7 }, (_, i) => {
const d = getNow({ timezone: settings.system.timezone });
@@ -20,21 +21,27 @@ export default function HabitStreak({ habits }: HabitStreakProps) {
}).reverse()
const completions = dates.map(date => {
const completedCount = getCompletedHabitsForDate({
habits,
const completedHabits = getCompletedHabitsForDate({
habits: habits.filter(h => !h.isTask),
date: t2d({ timestamp: date, timezone: settings.system.timezone }),
timezone: settings.system.timezone
}).length;
});
const completedTasks = getCompletedHabitsForDate({
habits: habits.filter(h => h.isTask),
date: t2d({ timestamp: date, timezone: settings.system.timezone }),
timezone: settings.system.timezone
});
return {
date,
completed: completedCount
habits: completedHabits.length,
tasks: completedTasks.length
};
});
return (
<Card>
<CardHeader>
<CardTitle>Daily Habit Completion Streak</CardTitle>
<CardTitle>Daily Completion Streak</CardTitle>
</CardHeader>
<CardContent>
<div className="w-full aspect-[2/1]">
@@ -51,14 +58,25 @@ export default function HabitStreak({ habits }: HabitStreakProps) {
<CartesianGrid strokeDasharray="3 3" />
<XAxis dataKey="date" />
<YAxis />
<Tooltip formatter={(value) => [`${value} habits`, 'Completed']} />
<Tooltip formatter={(value, name) => [`${value} ${name}`, 'Completed']} />
<Line
type="monotone"
dataKey="completed"
name="habits"
dataKey="habits"
stroke="#14b8a6"
strokeWidth={2}
dot={false}
/>
{hasTasks && (
<Line
type="monotone"
name="tasks"
dataKey="tasks"
stroke="#f59e0b"
strokeWidth={2}
dot={false}
/>
)}
</LineChart>
</ResponsiveContainer>
</div>

View File

@@ -18,6 +18,7 @@ import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'
import AboutModal from './AboutModal'
import Link from 'next/link'
import dynamic from 'next/dynamic'
import { Profile } from './Profile'
interface HeaderProps {
className?: string
@@ -26,11 +27,9 @@ interface HeaderProps {
const TodayEarnedCoins = dynamic(() => import('./TodayEarnedCoins'), { ssr: false })
export default function Header({ className }: HeaderProps) {
const [showAbout, setShowAbout] = useState(false)
const [settings] = useAtom(settingsAtom)
const [coins] = useAtom(coinsAtom)
const [browserSettings] = useAtom(browserSettingsAtom)
const isTasksView = browserSettings.viewType === 'tasks'
const { balance } = useCoins()
return (
<>
<header className={`border-b bg-white dark:bg-gray-800 shadow-sm ${className || ''}`}>
@@ -44,7 +43,7 @@ export default function Header({ className }: HeaderProps) {
<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={coins.balance}
amount={balance}
settings={settings}
className="text-gray-800 dark:text-gray-100 font-medium text-lg"
/>
@@ -56,45 +55,11 @@ export default function Header({ className }: HeaderProps) {
<Button variant="ghost" size="icon" aria-label="Notifications">
<Bell className="h-5 w-5" />
</Button>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" className="flex items-center gap-2">
{/* <Menu className="h-5 w-5" /> */}
<Avatar className="h-8 w-8">
<AvatarImage src={settings?.profile?.avatarPath ? `/api/avatars/${settings.profile.avatarPath.split('/').pop()}` : '/avatars/default.png'} />
<AvatarFallback>
<User className="h-4 w-4" />
</AvatarFallback>
</Avatar>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-56 p-2">
<DropdownMenuItem className="cursor-pointer px-3 py-2" asChild>
<Link
href="/settings"
aria-label='settings'
className="flex items-center w-full gap-2"
>
<Settings className="h-4 w-4" />
<span>Settings</span>
</Link>
</DropdownMenuItem>
<DropdownMenuItem className="cursor-pointer px-3 py-2" asChild>
<button
onClick={() => setShowAbout(true)}
className="flex items-center w-full gap-2"
>
<Info className="h-4 w-4" />
<span>About</span>
</button>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<Profile />
</div>
</div>
</div>
</header>
<AboutModal isOpen={showAbout} onClose={() => setShowAbout(false)} />
</>
)
}

View File

@@ -3,7 +3,7 @@ import { Sparkles } from "lucide-react"
export function Logo() {
return (
<div className="flex items-center gap-2">
<Sparkles className="h-6 w-6 text-primary" />
{/* <Sparkles className="h-6 w-6 text-primary" /> */}
<span className="font-bold text-xl">HabitTrove</span>
</div>
)

View File

@@ -7,6 +7,7 @@ import { browserSettingsAtom } from '@/lib/atoms'
import { useEffect, useState } from 'react'
import AboutModal from './AboutModal'
import { HabitIcon, TaskIcon } from '@/lib/constants'
import { useHelpers } from '@/lib/client-helpers'
type ViewPort = 'main' | 'mobile'
@@ -33,6 +34,7 @@ export default function Navigation({ className, viewPort }: NavigationProps) {
const [isMobileView, setIsMobileView] = useState(false)
const [browserSettings] = useAtom(browserSettingsAtom)
const isTasksView = browserSettings.viewType === 'tasks'
const { isIOS } = useHelpers()
useEffect(() => {
const handleResize = () => {
@@ -52,14 +54,14 @@ export default function Navigation({ className, viewPort }: NavigationProps) {
if (viewPort === 'mobile' && isMobileView) {
return (
<>
<div className="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">
<div className="flex justify-around">
<div className={isIOS ? "pb-20" : "pb-16"} /> {/* Add padding at the bottom to prevent content from being hidden */}
<nav className={`lg:hidden fixed bottom-0 left-0 right-0 bg-white dark:bg-gray-800 shadow-lg ${isIOS ? "pb-4" : ""}`}>
<div className="grid grid-cols-5 w-full">
{[...navItems(isTasksView).filter(item => item.position === 'main'), ...navItems(isTasksView).filter(item => item.position === 'bottom')].map((item) => (
<Link
key={item.label}
href={item.href}
className="flex flex-col items-center py-2 text-gray-600 dark:text-gray-300 hover:text-blue-500 dark:hover:text-blue-400"
className="flex flex-col items-center justify-center py-2 text-gray-600 dark:text-gray-300 hover:text-blue-500 dark:hover:text-blue-400"
>
<item.icon className="h-6 w-6" />
<span className="text-xs mt-1">{item.label}</span>

View File

@@ -0,0 +1,93 @@
'use client';
import { Avatar, AvatarFallback, AvatarImage } from './ui/avatar';
import { Input } from './ui/input';
import { Button } from './ui/button';
import { Label } from './ui/label';
import { User as UserIcon } from 'lucide-react';
import { Permission, User } from '@/lib/types';
import { toast } from '@/hooks/use-toast';
import { useState } from 'react';
interface PasswordEntryFormProps {
user: User;
onCancel: () => void;
onSubmit: (password: string) => Promise<void>;
error?: string;
}
export default function PasswordEntryForm({
user,
onCancel,
onSubmit,
error
}: PasswordEntryFormProps) {
const hasPassword = !!user.password;
const [password, setPassword] = useState('');
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
try {
await onSubmit(password);
} catch (err) {
toast({
title: "Error",
description: err instanceof Error ? err.message : 'Login failed',
variant: "destructive"
});
}
};
return (
<form onSubmit={handleSubmit} className="space-y-6">
<div className="flex flex-col items-center gap-4 p-4 bg-muted/50 rounded-lg">
<Avatar className="h-24 w-24">
<AvatarImage
src={user.avatarPath && `/api/avatars/${user.avatarPath.split('/').pop()}`}
/>
<AvatarFallback>
<UserIcon className="h-12 w-12" />
</AvatarFallback>
</Avatar>
<div className="text-center">
<div className="font-medium text-lg">
{user.username}
</div>
<button
type="button"
onClick={onCancel}
className="text-sm text-blue-500 hover:text-blue-600 mt-1"
>
Not you?
</button>
</div>
</div>
{hasPassword && <div className="space-y-4">
<div className="space-y-2">
<Label htmlFor="password">Password</Label>
<Input
id="password"
type="password"
placeholder="Enter password"
value={password}
onChange={(e) => setPassword(e.target.value)}
className={error ? 'border-red-500' : ''}
/>
{error && (
<p className="text-sm text-red-500 bg-red-50 dark:bg-red-950/50 p-2 rounded">{error}</p>
)}
</div>
</div>}
<div className="flex justify-end gap-2 pt-2">
<Button type="button" variant="outline" onClick={onCancel}>
Cancel
</Button>
<Button type="submit" disabled={hasPassword && !password}>
Login
</Button>
</div>
</form>
);
}

View File

@@ -0,0 +1,107 @@
'use client';
import { Switch } from './ui/switch';
import { Label } from './ui/label';
import { Permission } from '@/lib/types';
interface PermissionSelectorProps {
permissions: Permission[];
isAdmin: boolean;
onPermissionsChange: (permissions: Permission[]) => void;
onAdminChange: (isAdmin: boolean) => void;
}
const permissionLabels: { [key: string]: string } = {
habit: 'Habit / Task',
wishlist: 'Wishlist',
coins: 'Coins'
};
export function PermissionSelector({
permissions,
isAdmin,
onPermissionsChange,
onAdminChange,
}: PermissionSelectorProps) {
const currentPermissions = isAdmin ?
{
habit: { write: true, interact: true },
wishlist: { write: true, interact: true },
coins: { write: true, interact: true }
} :
permissions[0] || {
habit: { write: false, interact: true },
wishlist: { write: false, interact: true },
coins: { write: false, interact: true }
};
const handlePermissionChange = (resource: keyof Permission, type: 'write' | 'interact', checked: boolean) => {
const newPermissions = [{
...currentPermissions,
[resource]: {
...currentPermissions[resource],
[type]: checked
}
}];
onPermissionsChange(newPermissions);
};
return (
<div className="space-y-4">
<div className="space-y-2">
<Label>Permissions</Label>
<div className="grid grid-cols-1 gap-4">
<div className="flex items-center justify-between p-3 rounded-lg border bg-muted/50">
<div className="flex items-center gap-2">
<div className="font-medium text-sm">Admin Access</div>
</div>
<Switch
id="isAdmin"
className="h-4 w-7"
checked={isAdmin}
onCheckedChange={onAdminChange}
/>
</div>
{isAdmin ? (
<p className="text-xs text-muted-foreground px-3">
Admins have full permission to all data for all users
</p>
) : (
<div className="grid grid-cols-3 gap-4">
{['habit', 'wishlist', 'coins'].map((resource) => (
<div key={resource} className="p-3 space-y-3 rounded-lg border bg-muted/50">
<div className="font-medium capitalize text-sm border-b pb-2">{permissionLabels[resource]}</div>
<div className="flex flex-col gap-2.5">
<div className="flex flex-col sm:flex-row items-start sm:items-center justify-between gap-2">
<Label htmlFor={`${resource}-write`} className="text-xs text-muted-foreground break-words">Write</Label>
<Switch
id={`${resource}-write`}
className="h-4 w-7"
checked={currentPermissions[resource as keyof Permission].write}
onCheckedChange={(checked) =>
handlePermissionChange(resource as keyof Permission, 'write', checked)
}
/>
</div>
<div className="flex flex-col sm:flex-row items-start sm:items-center justify-between gap-2">
<Label htmlFor={`${resource}-interact`} className="text-xs text-muted-foreground break-words">Interact</Label>
<Switch
id={`${resource}-interact`}
className="h-4 w-7"
checked={currentPermissions[resource as keyof Permission].interact}
onCheckedChange={(checked) =>
handlePermissionChange(resource as keyof Permission, 'interact', checked)
}
/>
</div>
</div>
</div>
))}
</div>
)}
</div>
</div>
</div>
)
}

View File

@@ -148,14 +148,6 @@ export default function PomodoroTimer() {
}
}, [state])
const playSound = useCallback(() => {
const audio = new Audio('/sounds/timer-end.wav')
audio.play().catch(error => {
console.error('Error playing sound:', error)
})
}, [])
const handleTimerEnd = async () => {
setState("stopped")
const currentTimerType = currentTimer.current.type
@@ -165,9 +157,6 @@ export default function PomodoroTimer() {
currentTimer.current.labels[Math.floor(Math.random() * currentTimer.current.labels.length)]
)
// Play sound
playSound()
// update habits only after focus sessions
if (selectedHabit && currentTimerType === 'focus') {
await completeHabit(selectedHabit)
@@ -184,6 +173,16 @@ export default function PomodoroTimer() {
setTimeLeft(currentTimer.current.duration)
}
const skipTimer = () => {
currentTimer.current = currentTimer.current.type === 'focus'
? PomoConfigs.break
: PomoConfigs.focus
resetTimer()
setCurrentLabel(
currentTimer.current.labels[Math.floor(Math.random() * currentTimer.current.labels.length)]
)
}
const formatTime = (seconds: number) => {
const minutes = Math.floor(seconds / 60)
const secs = seconds % 60
@@ -314,12 +313,7 @@ export default function PomodoroTimer() {
</Button>
<Button
variant="outline"
onClick={() => {
currentTimer.current = currentTimer.current.type === 'focus'
? PomoConfigs.break
: PomoConfigs.focus
resetTimer()
}}
onClick={skipTimer}
disabled={state === "started"}
className="sm:px-4"
>

192
components/Profile.tsx Normal file
View File

@@ -0,0 +1,192 @@
'use client'
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"
import { Button } from "@/components/ui/button"
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui/dropdown-menu"
import { Settings, Info, User, Moon, Sun, Palette, ArrowRightLeft, LogOut, Crown } from "lucide-react"
import { Dialog, DialogContent, DialogHeader, DialogTitle } from './ui/dialog'
import UserForm from './UserForm'
import Link from "next/link"
import { useAtom } from "jotai"
import { settingsAtom, userSelectAtom } from "@/lib/atoms"
import AboutModal from "./AboutModal"
import { useEffect, useState } from "react"
import { useTheme } from "next-themes"
import { signOut } from "@/app/actions/user"
import { toast } from "@/hooks/use-toast"
import { useHelpers } from "@/lib/client-helpers"
export function Profile() {
const [settings] = useAtom(settingsAtom)
const [userSelect, setUserSelect] = useAtom(userSelectAtom)
const [isEditing, setIsEditing] = useState(false)
const [showAbout, setShowAbout] = useState(false)
const { theme, setTheme } = useTheme()
const { currentUser: user } = useHelpers()
const [open, setOpen] = useState(false)
const handleSignOut = async () => {
try {
await signOut()
toast({
title: "Signed out successfully",
description: "You have been logged out of your account",
})
setTimeout(() => window.location.reload(), 300);
} catch (error) {
toast({
title: "Error",
description: "Failed to sign out",
variant: "destructive",
})
}
}
return (
<>
<DropdownMenu open={open} onOpenChange={setOpen}>
<DropdownMenuTrigger asChild>
<Button variant="ghost" className="flex items-center gap-2">
<Avatar className="h-8 w-8">
<AvatarImage src={user?.avatarPath && `/api/avatars/${user.avatarPath.split('/').pop()}` || ""} />
<AvatarFallback>
<User className="h-4 w-4" />
</AvatarFallback>
</Avatar>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-[200px] p-2">
<div className="px-2 py-1.5 mb-2 border-b">
<div className="flex items-center gap-2">
<Avatar className="h-8 w-8">
<AvatarImage src={user?.avatarPath && `/api/avatars/${user.avatarPath.split('/').pop()}` || ""} />
<AvatarFallback>
<User className="h-4 w-4" />
</AvatarFallback>
</Avatar>
<div className="flex flex-col mr-4">
<span className="text-sm font-semibold flex items-center gap-1">
{user?.username || "Guest"}
{user?.isAdmin && <Crown className="h-3 w-3 text-yellow-500" />}
</span>
{user && (
<button
onClick={(e) => {
e.stopPropagation();
setOpen(false);
setIsEditing(true);
}}
className="text-xs text-muted-foreground hover:text-primary transition-colors text-left"
>
Edit profile
</button>
)}
</div>
{user && (
<button
onClick={(e) => {
e.stopPropagation();
setOpen(false);
handleSignOut();
}}
className="border border-primary/50 text-primary rounded-md p-1.5 transition-colors hover:bg-primary/10 hover:border-primary active:scale-95"
>
<LogOut className="h-4 w-4" />
</button>
)}
</div>
</div>
<DropdownMenuItem className="cursor-pointer px-2 py-1.5" onClick={() => {
setOpen(false); // Close the dropdown
setUserSelect(true); // Open the user select modal
}}>
<div className="flex items-center justify-between w-full">
<div className="flex items-center gap-2">
<ArrowRightLeft className="h-4 w-4" />
<span>Switch user</span>
</div>
</div>
</DropdownMenuItem>
<DropdownMenuItem className="cursor-pointer px-2 py-1.5" asChild>
<Link
href="/settings"
aria-label='settings'
className="flex items-center w-full gap-3"
>
<Settings className="h-4 w-4" />
<span>Settings</span>
</Link>
</DropdownMenuItem>
<DropdownMenuItem className="cursor-pointer px-2 py-1.5" asChild>
<button
onClick={() => setShowAbout(true)}
className="flex items-center w-full gap-3"
>
<Info className="h-4 w-4" />
<span>About</span>
</button>
</DropdownMenuItem>
<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 gap-3">
<Palette className="h-4 w-4" />
<span>Theme</span>
</div>
<button
onClick={(e) => {
e.stopPropagation();
setTheme(theme === 'dark' ? 'light' : 'dark');
}}
className={`
w-12 h-6 rounded-full relative transition-all duration-300 ease-in-out
hover:scale-105 shadow-inner
${theme === 'dark'
? 'bg-blue-600/90 hover:bg-blue-600'
: 'bg-gray-200 hover:bg-gray-300'
}
`}
>
<div className={`
w-5 h-5 rounded-full absolute top-0.5 left-0.5
transition-all duration-300 ease-in-out
shadow-md bg-white
${theme === 'dark' ? 'translate-x-6' : 'translate-x-0'}
`}>
<div className="absolute inset-0 flex items-center justify-center">
{theme === 'dark' ? (
<Moon className="h-3 w-3 text-gray-600" />
) : (
<Sun className="h-3 w-3 text-gray-600" />
)}
</div>
</div>
</button>
</div>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<AboutModal isOpen={showAbout} onClose={() => setShowAbout(false)} />
{/* Add the UserForm dialog */}
{isEditing && user && (
<Dialog open={isEditing} onOpenChange={() => setIsEditing(false)}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>Edit Profile</DialogTitle>
</DialogHeader>
<UserForm
userId={user.id}
onCancel={() => setIsEditing(false)}
onSuccess={() => {
setIsEditing(false);
window.location.reload();
}}
/>
</DialogContent>
</Dialog>
)}
</>
)
}

288
components/UserForm.tsx Normal file
View File

@@ -0,0 +1,288 @@
'use client';
import { useState } from 'react';
import { passwordSchema, usernameSchema } from '@/lib/zod';
import { Input } from './ui/input';
import { Button } from './ui/button';
import { Label } from './ui/label';
import { Switch } from './ui/switch';
import { Permission } from '@/lib/types';
import { toast } from '@/hooks/use-toast';
import { useAtom, useAtomValue } from 'jotai';
import { serverSettingsAtom, usersAtom } from '@/lib/atoms';
import { createUser, updateUser, updateUserPassword, uploadAvatar } from '@/app/actions/data';
import { SafeUser, User } from '@/lib/types';
import { Avatar, AvatarFallback, AvatarImage } from './ui/avatar';
import { User as UserIcon } from 'lucide-react';
import _ from 'lodash';
import { PermissionSelector } from './PermissionSelector';
import { useHelpers } from '@/lib/client-helpers';
interface UserFormProps {
userId?: string; // if provided, we're editing; if not, we're creating
onCancel: () => void;
onSuccess: () => void;
}
export default function UserForm({ userId, onCancel, onSuccess }: UserFormProps) {
const [users, setUsersData] = useAtom(usersAtom);
const serverSettings = useAtomValue(serverSettingsAtom)
const user = userId ? users.users.find(u => u.id === userId) : undefined;
const { currentUser } = useHelpers()
const getDefaultPermissions = (): Permission[] => [{
habit: {
write: true,
interact: true
},
wishlist: {
write: true,
interact: true
},
coins: {
write: true,
interact: true
}
}];
const [avatarPath, setAvatarPath] = useState(user?.avatarPath)
const [username, setUsername] = useState(user?.username || '');
const [password, setPassword] = useState<string | undefined>('');
const [disablePassword, setDisablePassword] = useState(user?.password === '' || serverSettings.isDemo);
const [error, setError] = useState('');
const [avatarFile, setAvatarFile] = useState<File | null>(null);
const [isAdmin, setIsAdmin] = useState(user?.isAdmin || false);
const [permissions, setPermissions] = useState<Permission[]>(
user?.permissions || getDefaultPermissions()
);
const isEditing = !!user;
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
try {
// Validate username
const usernameResult = usernameSchema.safeParse(username);
if (!usernameResult.success) {
setError(usernameResult.error.errors[0].message);
return;
}
// Validate password unless disabled
if (!disablePassword && password) {
const passwordResult = passwordSchema.safeParse(password);
if (!passwordResult.success) {
setError(passwordResult.error.errors[0].message);
return;
}
}
if (isEditing) {
// Update existing user
if (username !== user.username || avatarPath !== user.avatarPath || !_.isEqual(permissions, user.permissions) || isAdmin !== user.isAdmin) {
await updateUser(user.id, { username, avatarPath, permissions, isAdmin });
}
// Handle password update
if (disablePassword) {
await updateUserPassword(user.id, undefined);
} else if (password) {
await updateUserPassword(user.id, password);
}
setUsersData(prev => ({
...prev,
users: prev.users.map(u =>
u.id === user.id ? {
...u,
username,
avatarPath,
permissions,
isAdmin,
password: disablePassword ? '' : (password || u.password) // use the correct password to update atom
} : u
),
}));
toast({
title: "User updated",
description: `Successfully updated user ${username}`,
variant: 'default'
});
} else {
// Create new user
const formData = new FormData();
formData.append('username', username);
if (disablePassword) {
formData.append('password', '');
} else if (password) {
formData.append('password', password);
}
formData.append('permissions', JSON.stringify(isAdmin ? undefined : permissions));
formData.append('isAdmin', JSON.stringify(isAdmin));
formData.append('avatarPath', avatarPath || '');
const newUser = await createUser(formData);
setUsersData(prev => ({
...prev,
users: [...prev.users, newUser]
}));
toast({
title: "User created",
description: `Successfully created user ${username}`,
variant: 'default'
});
}
setPassword('');
setError('');
onSuccess();
} catch (err) {
setError(err instanceof Error ? err.message : `Failed to ${isEditing ? 'update' : 'create'} user`);
}
};
const handleAvatarChange = async (file: File) => {
if (file.size > 5 * 1024 * 1024) {
toast({
title: "Error",
description: "File size must be less than 5MB",
variant: 'destructive'
});
return;
}
const formData = new FormData();
formData.append('avatar', file);
try {
const path = await uploadAvatar(formData);
setAvatarPath(path);
setAvatarFile(null); // Clear the file since we've uploaded it
toast({
title: "Avatar uploaded",
description: "Successfully uploaded avatar",
variant: 'default'
});
} catch (err) {
toast({
title: "Error",
description: "Failed to upload avatar",
variant: 'destructive'
});
}
};
return (
<form onSubmit={handleSubmit} className="space-y-6 max-h-[80vh] overflow-y-auto p-4">
<div className="flex flex-col items-center gap-4 p-4 bg-muted/50 rounded-lg">
<Avatar className="h-24 w-24">
<AvatarImage
src={avatarPath && `/api/avatars/${avatarPath.split('/').pop()}`}
alt={username}
/>
<AvatarFallback>
<UserIcon className="h-12 w-12" />
</AvatarFallback>
</Avatar>
<div>
<input
type="file"
id="avatar"
name="avatar"
accept="image/png, image/jpeg"
className="hidden"
onChange={(e) => {
const file = e.target.files?.[0];
if (file) {
handleAvatarChange(file);
}
}}
/>
<Button
type="button"
variant="secondary"
onClick={() => {
const input = document.getElementById('avatar') as HTMLInputElement;
input.value = ''; // Reset input to allow selecting same file again
input.click();
}}
className="w-full"
>
{isEditing ? 'Change Avatar' : 'Upload Avatar'}
</Button>
</div>
</div>
<div className="space-y-4">
<div className="space-y-2">
<Label htmlFor="username">Username</Label>
<Input
id="username"
type="text"
placeholder="Username"
value={username}
onChange={(e) => setUsername(e.target.value)}
className={error ? 'border-red-500' : ''}
/>
</div>
<div className="space-y-4">
<div className="space-y-2">
<Label htmlFor="password">
{isEditing ? 'New Password' : 'Password'}
</Label>
<Input
id="password"
type="password"
placeholder={isEditing ? "Leave blank to keep current" : "Enter password"}
value={password || ''}
onChange={(e) => setPassword(e.target.value)}
className={error ? 'border-red-500' : ''}
disabled={disablePassword}
/>
{serverSettings.isDemo && (
<p className="text-sm text-red-500">Password is automatically disabled in demo instance</p>
)}
</div>
<div className="flex items-center space-x-2">
<Switch
id="disable-password"
checked={disablePassword}
onCheckedChange={setDisablePassword}
disabled={serverSettings.isDemo}
/>
<Label htmlFor="disable-password">Disable password</Label>
</div>
</div>
{error && (
<p className="text-sm text-red-500 bg-red-50 dark:bg-red-950/50 p-2 rounded">{error}</p>
)}
{currentUser && currentUser.isAdmin && <PermissionSelector
permissions={permissions}
isAdmin={isAdmin}
onPermissionsChange={setPermissions}
onAdminChange={setIsAdmin}
/>}
</div>
<div className="flex justify-end gap-2 pt-2">
<Button
type="button"
variant="outline"
onClick={onCancel}
>
Cancel
</Button>
<Button type="submit" disabled={!username}>
{isEditing ? 'Save Changes' : 'Create User'}
</Button>
</div>
</form>
);
}

View File

@@ -0,0 +1,213 @@
'use client';
import { useState } from 'react';
import PasswordEntryForm from './PasswordEntryForm';
import UserForm from './UserForm';
import { Dialog, DialogContent, DialogHeader, DialogTitle } from './ui/dialog';
import { Avatar, AvatarFallback, AvatarImage } from './ui/avatar';
import { Crown, Pencil, Plus, User as UserIcon, UserRoundPen } from 'lucide-react';
import { Input } from './ui/input';
import { Button } from './ui/button';
import { useAtom } from 'jotai';
import { usersAtom } from '@/lib/atoms';
import { signIn } from '@/app/actions/user';
import { createUser } from '@/app/actions/data';
import { toast } from '@/hooks/use-toast';
import { Description } from '@radix-ui/react-dialog';
import { SafeUser, User } from '@/lib/types';
import { cn } from '@/lib/utils';
import { useHelpers } from '@/lib/client-helpers';
function UserCard({
user,
onSelect,
onEdit,
showEdit,
isCurrentUser
}: {
user: User,
onSelect: () => void,
onEdit: () => void,
showEdit: boolean,
isCurrentUser: boolean
}) {
return (
<div key={user.id} className="relative group">
<button
onClick={onSelect}
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",
isCurrentUser && "ring-2 ring-primary"
)}
>
<Avatar className="h-16 w-16">
<AvatarImage
src={user.avatarPath && `/api/avatars/${user.avatarPath.split('/').pop()}`}
alt={user.username}
/>
<AvatarFallback>
<UserIcon className="h-8 w-8" />
</AvatarFallback>
</Avatar>
<span className="text-sm font-medium flex items-center gap-1">
{user.username}
{user.isAdmin && <Crown className="h-4 w-4 text-yellow-500" />}
</span>
</button>
{showEdit && (
<button
onClick={(e) => {
e.stopPropagation();
onEdit();
}}
className="absolute top-0 right-0 p-1 rounded-full bg-gray-200 hover:bg-gray-300 dark:bg-gray-700 dark:hover:bg-gray-600 transition-colors"
>
<UserRoundPen className="h-4 w-4" />
</button>
)}
</div>
);
}
function AddUserButton({ onClick }: { onClick: () => void }) {
return (
<button
onClick={onClick}
className="flex flex-col items-center gap-2 p-2 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors"
>
<Avatar className="h-16 w-16">
<AvatarFallback>
<Plus className="h-8 w-8" />
</AvatarFallback>
</Avatar>
<span className="text-sm font-medium">Add User</span>
</button>
);
}
function UserSelectionView({
users,
currentUser,
onUserSelect,
onEditUser,
onCreateUser
}: {
users: User[],
currentUser?: SafeUser,
onUserSelect: (userId: string) => void,
onEditUser: (userId: string) => void,
onCreateUser: () => void
}) {
return (
<div className="grid grid-cols-3 gap-4 p-2">
{users
.filter(user => user.id !== currentUser?.id)
.map((user) => (
<UserCard
key={user.id}
user={user}
onSelect={() => onUserSelect(user.id)}
onEdit={() => onEditUser(user.id)}
showEdit={!!currentUser?.isAdmin}
isCurrentUser={false}
/>
))}
{currentUser?.isAdmin && <AddUserButton onClick={onCreateUser} />}
</div>
);
}
export default function UserSelectModal({ onClose }: { onClose: () => void }) {
const [selectedUser, setSelectedUser] = useState<string>();
const [isCreating, setIsCreating] = useState(false);
const [isEditing, setIsEditing] = useState(false);
const [error, setError] = useState('');
const [usersData] = useAtom(usersAtom);
const users = usersData.users;
const {currentUser} = useHelpers();
const handleUserSelect = (userId: string) => {
setSelectedUser(userId);
setError('');
};
const handleEditUser = (userId: string) => {
setSelectedUser(userId);
setIsEditing(true);
};
const handleCreateUser = () => {
setIsCreating(true);
};
const handleFormSuccess = () => {
setSelectedUser(undefined);
setIsCreating(false);
setIsEditing(false);
onClose();
};
const handleFormCancel = () => {
setSelectedUser(undefined);
setIsCreating(false);
setIsEditing(false);
setError('');
};
return (
<Dialog open={true} onOpenChange={onClose}>
<DialogContent className="sm:max-w-md">
<Description></Description>
<DialogHeader>
<DialogTitle>{isCreating ? 'Create New User' : 'Select User'}</DialogTitle>
</DialogHeader>
<div className="flex flex-col gap-4">
{!selectedUser && !isCreating && !isEditing ? (
<UserSelectionView
users={users}
currentUser={currentUser}
onUserSelect={handleUserSelect}
onEditUser={handleEditUser}
onCreateUser={handleCreateUser}
/>
) : isCreating || isEditing ? (
<UserForm
userId={isEditing ? selectedUser : undefined}
onCancel={handleFormCancel}
onSuccess={handleFormSuccess}
/>
) : (
<PasswordEntryForm
user={users.find(u => u.id === selectedUser)!}
onCancel={() => setSelectedUser(undefined)}
onSubmit={async (password) => {
try {
setError('');
const user = users.find(u => u.id === selectedUser);
if (!user) throw new Error("User not found");
await signIn(user.username, password);
setError('');
onClose();
toast({
title: "Signed in successfully",
description: `Welcome back, ${user.username}!`,
variant: "default"
});
setTimeout(() => window.location.reload(), 300);
} catch (err) {
setError('invalid password');
throw err;
}
}}
error={error}
/>
)}
</div>
</DialogContent>
</Dialog>
);
}

View File

@@ -3,9 +3,11 @@
import { cn } from '@/lib/utils'
import { useAtom } from 'jotai'
import { CheckSquare, ListChecks } from 'lucide-react'
import { browserSettingsAtom } from '@/lib/atoms'
import { browserSettingsAtom, habitsAtom, settingsAtom } from '@/lib/atoms'
import type { ViewType } from '@/lib/types'
import { HabitIcon, TaskIcon } from '@/lib/constants'
import { isHabitDueToday } from '@/lib/utils'
import { NotificationBadge } from './ui/notification-badge'
interface ViewToggleProps {
defaultView?: ViewType
@@ -17,6 +19,8 @@ export function ViewToggle({
className
}: ViewToggleProps) {
const [browserSettings, setBrowserSettings] = useAtom(browserSettingsAtom)
const [habits] = useAtom(habitsAtom)
const [settings] = useAtom(settingsAtom)
const handleViewChange = (checked: boolean) => {
const newView = checked ? 'tasks' : 'habits'
@@ -26,6 +30,11 @@ export function ViewToggle({
})
}
// Calculate due tasks count
const dueTasksCount = habits.habits.filter(habit =>
habit.isTask && isHabitDueToday({ habit, timezone: settings.system.timezone })
).length
return (
<div className={cn('inline-flex rounded-full bg-muted/50 h-8', className)}>
<div className="relative flex gap-0.5 rounded-full bg-background p-0.5 h-full">
@@ -39,16 +48,23 @@ export function ViewToggle({
<HabitIcon className="h-4 w-4" />
<span className="hidden sm:inline">Habits</span>
</button>
<button
onClick={() => handleViewChange(true)}
className={cn(
'relative z-10 rounded-full px-4 py-2 text-sm font-medium transition-colors flex items-center gap-2',
browserSettings.viewType === 'tasks' ? 'text-primary-foreground' : 'text-muted-foreground hover:text-foreground'
)}
<NotificationBadge
label={dueTasksCount}
show={dueTasksCount > 0}
variant={browserSettings.viewType === 'tasks' ? 'secondary' : 'default'}
className="shadow-md"
>
<TaskIcon className="h-4 w-4" />
<span className="hidden sm:inline">Tasks</span>
</button>
<button
onClick={() => handleViewChange(true)}
className={cn(
'relative z-10 rounded-full px-4 py-2 text-sm font-medium transition-colors flex items-center gap-2',
browserSettings.viewType === 'tasks' ? 'text-primary-foreground' : 'text-muted-foreground hover:text-foreground'
)}
>
<TaskIcon className="h-4 w-4" />
<span className="hidden sm:inline">Tasks</span>
</button>
</NotificationBadge>
<div
className={cn(
'absolute left-0.5 top-0.5 h-[calc(100%-0.25rem)] rounded-full bg-primary transition-transform',

View File

@@ -1,8 +1,12 @@
import { WishlistItemType } from '@/lib/types'
import { WishlistItemType, User, Permission } from '@/lib/types'
import { useAtom } from 'jotai'
import { usersAtom } from '@/lib/atoms'
import { Avatar, AvatarFallback, AvatarImage } from './ui/avatar'
import { useHelpers } from '@/lib/client-helpers'
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from '@/components/ui/card'
import ReactMarkdown from 'react-markdown'
import { Button } from '@/components/ui/button'
import { Coins, Edit, Trash2, Gift, MoreVertical } from 'lucide-react'
import { Coins, Edit, Trash2, Gift, MoreVertical, Archive, ArchiveRestore } from 'lucide-react'
import {
DropdownMenu,
DropdownMenuContent,
@@ -16,39 +20,84 @@ interface WishlistItemProps {
onEdit: () => void
onDelete: () => void
onRedeem: () => void
onArchive: () => void
onUnarchive: () => void
canRedeem: boolean
isHighlighted?: boolean
isRecentlyRedeemed?: boolean
isArchived?: boolean
}
const renderUserAvatars = (item: WishlistItemType, currentUser: User | null, usersData: { users: User[] }) => {
if (!item.userIds || item.userIds.length <= 1) return null;
return (
<div className="flex -space-x-2 ml-2 flex-shrink-0">
{item.userIds?.filter((u) => u !== currentUser?.id).map(userId => {
const user = usersData.users.find(u => u.id === userId)
if (!user) return null
return (
<Avatar key={user.id} className="h-6 w-6">
<AvatarImage src={user?.avatarPath && `/api/avatars/${user.avatarPath.split('/').pop()}` || ""} />
<AvatarFallback>{user.username[0]}</AvatarFallback>
</Avatar>
)
})}
</div>
);
};
export default function WishlistItem({
item,
onEdit,
onDelete,
onRedeem,
onArchive,
onUnarchive,
canRedeem,
isHighlighted,
isRecentlyRedeemed
}: WishlistItemProps) {
const { currentUser, hasPermission } = useHelpers()
const canWrite = hasPermission('wishlist', 'write')
const canInteract = hasPermission('wishlist', 'interact')
const [usersData] = useAtom(usersAtom)
return (
<Card
id={`wishlist-${item.id}`}
className={`h-full flex flex-col transition-all duration-500 ${isHighlighted ? 'bg-yellow-100 dark:bg-yellow-900' : ''
} ${isRecentlyRedeemed ? 'animate-[celebrate_1s_ease-in-out] shadow-lg ring-2 ring-primary' : ''
}`}
} ${item.archived ? 'opacity-75' : ''}`}
>
<CardHeader className="flex-none">
<CardTitle className="line-clamp-1">{item.name}</CardTitle>
{item.description && (
<CardDescription className="whitespace-pre-line">
{item.description}
</CardDescription>
)}
<div className="flex items-center gap-2">
<CardTitle className={`line-clamp-1 ${item.archived ? 'text-gray-400 dark:text-gray-500' : ''}`}>
{item.name}
</CardTitle>
{item.targetCompletions && (
<span className="text-sm text-gray-500 dark:text-gray-400">
({item.targetCompletions} {item.targetCompletions === 1 ? 'use' : 'uses'} left)
</span>
)}
</div>
<div className="flex items-center justify-between">
<div className="flex-1">
{item.description && (
<CardDescription className={`whitespace-pre-line ${item.archived ? 'text-gray-400 dark:text-gray-500' : ''}`}>
{item.description}
</CardDescription>
)}
</div>
{renderUserAvatars(item, currentUser as User, usersData)}
</div>
</CardHeader>
<CardContent className="flex-1">
<div className="flex items-center">
<Coins className="h-4 w-4 text-yellow-400 mr-1" />
<span className="text-sm font-medium">{item.coinCost} coins</span>
<div className="flex items-center gap-2">
<Coins className={`h-4 w-4 ${item.archived ? 'text-gray-400 dark:text-gray-500' : 'text-yellow-400'}`} />
<span className={`text-sm font-medium ${item.archived ? 'text-gray-400 dark:text-gray-500' : ''}`}>
{item.coinCost} coins
</span>
</div>
</CardContent>
<CardFooter className="flex justify-between gap-2">
@@ -57,8 +106,8 @@ export default function WishlistItem({
variant={canRedeem ? "default" : "secondary"}
size="sm"
onClick={onRedeem}
disabled={!canRedeem}
className={`transition-all duration-300 w-24 sm:w-auto ${isRecentlyRedeemed ? 'bg-green-500 hover:bg-green-600' : ''}`}
disabled={!canRedeem || !canInteract || item.archived}
className={`transition-all duration-300 w-24 sm:w-auto ${isRecentlyRedeemed ? 'bg-green-500 hover:bg-green-600' : ''} ${item.archived ? 'cursor-not-allowed' : ''}`}
>
<Gift className={`h-4 w-4 sm:mr-2 ${isRecentlyRedeemed ? 'animate-spin' : ''}`} />
<span>
@@ -77,15 +126,18 @@ export default function WishlistItem({
</Button>
</div>
<div className="flex gap-2">
<Button
variant="edit"
size="sm"
onClick={onEdit}
className="hidden sm:flex"
>
<Edit className="h-4 w-4" />
<span className="ml-2">Edit</span>
</Button>
{!item.archived && (
<Button
variant="edit"
size="sm"
onClick={onEdit}
disabled={!canWrite}
className="hidden sm:flex"
>
<Edit className="h-4 w-4" />
<span className="ml-2">Edit</span>
</Button>
)}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="sm" className="h-8 w-8 p-0">
@@ -93,6 +145,18 @@ export default function WishlistItem({
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
{!item.archived && (
<DropdownMenuItem disabled={!canWrite} onClick={onArchive}>
<Archive className="mr-2 h-4 w-4" />
<span>Archive</span>
</DropdownMenuItem>
)}
{item.archived && (
<DropdownMenuItem disabled={!canWrite} onClick={onUnarchive}>
<ArchiveRestore className="mr-2 h-4 w-4" />
<span>Unarchive</span>
</DropdownMenuItem>
)}
<DropdownMenuItem onClick={onEdit} className="sm:hidden">
<Edit className="mr-2 h-4 w-4" />
Edit
@@ -101,6 +165,7 @@ export default function WishlistItem({
<DropdownMenuItem
className="text-red-600 focus:text-red-600 dark:text-red-400 dark:focus:text-red-400 cursor-pointer"
onClick={onDelete}
disabled={!canWrite}
>
<Trash2 className="mr-2 h-4 w-4" />
Delete

View File

@@ -9,6 +9,8 @@ import WishlistItem from './WishlistItem'
import AddEditWishlistItemModal from './AddEditWishlistItemModal'
import ConfirmDialog from './ConfirmDialog'
import { WishlistItemType } from '@/lib/types'
import { openWindow } from '@/lib/utils'
import { toast } from '@/hooks/use-toast'
export default function WishlistManager() {
const {
@@ -16,10 +18,15 @@ export default function WishlistManager() {
editWishlistItem,
deleteWishlistItem,
redeemWishlistItem,
archiveWishlistItem,
unarchiveWishlistItem,
canRedeem,
wishlistItems
} = useWishlist()
const activeItems = wishlistItems.filter(item => !item.archived)
const archivedItems = wishlistItems.filter(item => item.archived)
const [highlightedItemId, setHighlightedItemId] = useState<string | null>(null)
const [recentlyRedeemedId, setRecentlyRedeemedId] = useState<string | null>(null)
const [isModalOpen, setIsModalOpen] = useState(false)
@@ -57,6 +64,19 @@ export default function WishlistManager() {
setTimeout(() => {
setRecentlyRedeemedId(null)
}, 3000)
if (item.link) {
setTimeout(() => {
const opened = openWindow(item.link!)
if (!opened) {
toast({
title: "Popup Blocked",
description: "Please allow popups to open the link",
variant: "destructive"
})
}
}, 300)
}
}
}
@@ -69,7 +89,7 @@ export default function WishlistManager() {
</Button>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 items-stretch">
{wishlistItems.length === 0 ? (
{activeItems.length === 0 ? (
<div className="col-span-2">
<EmptyState
icon={Gift}
@@ -78,7 +98,7 @@ export default function WishlistManager() {
/>
</div>
) : (
wishlistItems.map((item) => (
activeItems.map((item) => (
<div
key={item.id}
ref={(el) => {
@@ -97,28 +117,46 @@ export default function WishlistManager() {
}}
onDelete={() => setDeleteConfirmation({ isOpen: true, itemId: item.id })}
onRedeem={() => handleRedeem(item)}
onArchive={() => archiveWishlistItem(item.id)}
onUnarchive={() => unarchiveWishlistItem(item.id)}
canRedeem={canRedeem(item.coinCost)}
/>
</div>
))
)}
{archivedItems.length > 0 && (
<>
<div className="col-span-2 relative flex items-center my-6">
<div className="flex-grow border-t border-gray-300 dark:border-gray-600" />
<span className="mx-4 text-sm text-gray-500 dark:text-gray-400">Archived</span>
<div className="flex-grow border-t border-gray-300 dark:border-gray-600" />
</div>
{archivedItems.map((item) => (
<WishlistItem
key={item.id}
item={item}
onEdit={() => {
setEditingItem(item)
setIsModalOpen(true)
}}
onDelete={() => setDeleteConfirmation({ isOpen: true, itemId: item.id })}
onRedeem={() => handleRedeem(item)}
onArchive={() => archiveWishlistItem(item.id)}
onUnarchive={() => unarchiveWishlistItem(item.id)}
canRedeem={canRedeem(item.coinCost)}
/>
))}
</>
)}
</div>
<AddEditWishlistItemModal
isOpen={isModalOpen}
onClose={() => {
setIsModalOpen(false)
setEditingItem(null)
}}
onSave={(item) => {
if (editingItem) {
editWishlistItem({ ...item, id: editingItem.id })
} else {
addWishlistItem(item)
}
setIsModalOpen(false)
setEditingItem(null)
}}
item={editingItem}
setIsOpen={setIsModalOpen}
editingItem={editingItem}
setEditingItem={setEditingItem}
addWishlistItem={addWishlistItem}
editWishlistItem={editWishlistItem}
/>
<ConfirmDialog
isOpen={deleteConfirmation.isOpen}

View File

@@ -1,6 +1,6 @@
'use client'
import { settingsAtom, habitsAtom, coinsAtom, wishlistAtom } from "@/lib/atoms"
import { settingsAtom, habitsAtom, coinsAtom, wishlistAtom, usersAtom, serverSettingsAtom } from "@/lib/atoms"
import { useHydrateAtoms } from "jotai/utils"
import { JotaiHydrateInitialValues } from "@/lib/types"
@@ -12,7 +12,9 @@ export function JotaiHydrate({
[settingsAtom, initialValues.settings],
[habitsAtom, initialValues.habits],
[coinsAtom, initialValues.coins],
[wishlistAtom, initialValues.wishlist]
[wishlistAtom, initialValues.wishlist],
[usersAtom, initialValues.users],
[serverSettingsAtom, initialValues.serverSettings]
])
return children
}

View File

@@ -0,0 +1,11 @@
"use client"
import * as React from "react"
import { ThemeProvider as NextThemesProvider } from "next-themes"
export function ThemeProvider({
children,
...props
}: React.ComponentProps<typeof NextThemesProvider>) {
return <NextThemesProvider {...props}>{children}</NextThemesProvider>
}

View File

@@ -0,0 +1,40 @@
"use client"
import * as React from "react"
import { Moon, MoonIcon, Sun } from "lucide-react"
import { useTheme } from "next-themes"
import { Button } from "@/components/ui/button"
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"
export function ThemeToggle() {
const { setTheme } = useTheme()
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" size="icon">
<Sun className="h-[1.2rem] w-[1.2rem] rotate-0 scale-100 transition-all dark:-rotate-90 dark:scale-0" />
<Moon className="absolute h-[1.2rem] w-[1.2rem] rotate-90 scale-0 transition-all dark:rotate-0 dark:scale-100" />
<span className="sr-only">Toggle theme</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => setTheme("light")}>
Light
</DropdownMenuItem>
<DropdownMenuItem onClick={() => setTheme("dark")}>
Dark
</DropdownMenuItem>
<DropdownMenuItem onClick={() => setTheme("system")}>
System
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)
}

View File

@@ -0,0 +1,41 @@
import React from 'react';
import {Badge, BadgeProps} from './badge';
import {cn} from '@/lib/utils';
export interface NotificationBadgeProps extends BadgeProps {
label?: string | number;
show?: boolean;
variant?: 'destructive' | 'default' | 'secondary';
}
export const NotificationBadge = ({
label,
className,
show,
variant = 'destructive',
children,
...props
}: NotificationBadgeProps) => {
const showBadge =
typeof label !== 'undefined' && (typeof show === 'undefined' || show);
return (
<div className='inline-flex relative'>
{children}
{showBadge && (
<Badge
variant={variant}
className={cn(
'absolute rounded-full -top-1.5 -right-1.5 z-20 border h-4 w-4 p-0 flex items-center justify-center text-xs',
typeof label !== 'undefined' && ('' + label).length === 0
? ''
: 'min-w-[1rem]',
className
)}
{...props}
>
{'' + label}
</Badge>
)}
</div>
);
};

View File

@@ -5,3 +5,5 @@ services:
volumes:
- "./data:/app/data" # Use a relative path instead of $(pwd)
image: dohsimpson/habittrove
environment:
- AUTH_SECRET=your-secret-key-here

View File

@@ -1,25 +1,71 @@
import { useAtom } from 'jotai'
import { calculateCoinsEarnedToday, calculateCoinsSpentToday, calculateTotalEarned, calculateTotalSpent, calculateTransactionsToday, checkPermission } from '@/lib/utils'
import {
coinsAtom,
coinsEarnedTodayAtom,
totalEarnedAtom,
totalSpentAtom,
coinsSpentTodayAtom,
transactionsTodayAtom
// coinsEarnedTodayAtom,
// totalEarnedAtom,
// totalSpentAtom,
// coinsSpentTodayAtom,
// transactionsTodayAtom,
// coinsBalanceAtom,
settingsAtom,
usersAtom
} from '@/lib/atoms'
import { addCoins, removeCoins, saveCoinsData } from '@/app/actions/data'
import { CoinsData } from '@/lib/types'
import { CoinsData, User } from '@/lib/types'
import { toast } from '@/hooks/use-toast'
import { useHelpers } from '@/lib/client-helpers'
export function useCoins() {
function handlePermissionCheck(
user: User | undefined,
resource: 'habit' | 'wishlist' | 'coins',
action: 'write' | 'interact'
): boolean {
if (!user) {
toast({
title: "Authentication Required",
description: "Please sign in to continue.",
variant: "destructive",
})
return false
}
if (!user.isAdmin && !checkPermission(user.permissions, resource, action)) {
toast({
title: "Permission Denied",
description: `You don't have ${action} permission for ${resource}s.`,
variant: "destructive",
})
return false
}
return true
}
export function useCoins(options?: { selectedUser?: string }) {
const [coins, setCoins] = useAtom(coinsAtom)
const [coinsEarnedToday] = useAtom(coinsEarnedTodayAtom)
const [totalEarned] = useAtom(totalEarnedAtom)
const [totalSpent] = useAtom(totalSpentAtom)
const [coinsSpentToday] = useAtom(coinsSpentTodayAtom)
const [transactionsToday] = useAtom(transactionsTodayAtom)
const [settings] = useAtom(settingsAtom)
const [users] = useAtom(usersAtom)
const { currentUser } = useHelpers()
let user: User | undefined;
if (!options?.selectedUser) {
user = currentUser;
} else {
user = users.users.find(u => u.id === options.selectedUser)
}
// Filter transactions for the selectd user
const transactions = coins.transactions.filter(t => t.userId === user?.id)
const balance = transactions.reduce((sum, t) => sum + t.amount, 0)
const coinsEarnedToday = calculateCoinsEarnedToday(transactions, settings.system.timezone)
const totalEarned = calculateTotalEarned(transactions)
const totalSpent = calculateTotalSpent(transactions)
const coinsSpentToday = calculateCoinsSpentToday(transactions, settings.system.timezone)
const transactionsToday = calculateTransactionsToday(transactions, settings.system.timezone)
const add = async (amount: number, description: string, note?: string) => {
if (!handlePermissionCheck(currentUser, 'coins', 'write')) return null
if (isNaN(amount) || amount <= 0) {
toast({
title: "Invalid amount",
@@ -32,7 +78,8 @@ export function useCoins() {
amount,
description,
type: 'MANUAL_ADJUSTMENT',
note
note,
userId: user?.id
})
setCoins(data)
toast({ title: "Success", description: `Added ${amount} coins` })
@@ -40,6 +87,7 @@ export function useCoins() {
}
const remove = async (amount: number, description: string, note?: string) => {
if (!handlePermissionCheck(currentUser, 'coins', 'write')) return null
const numAmount = Math.abs(amount)
if (isNaN(numAmount) || numAmount <= 0) {
toast({
@@ -53,7 +101,8 @@ export function useCoins() {
amount: numAmount,
description,
type: 'MANUAL_ADJUSTMENT',
note
note,
userId: user?.id
})
setCoins(data)
toast({ title: "Success", description: `Removed ${numAmount} coins` })
@@ -61,6 +110,7 @@ export function useCoins() {
}
const updateNote = async (transactionId: string, note: string) => {
if (!handlePermissionCheck(currentUser, 'coins', 'write')) return null
const transaction = coins.transactions.find(t => t.id === transactionId)
if (!transaction) {
toast({
@@ -93,8 +143,8 @@ export function useCoins() {
add,
remove,
updateNote,
balance: coins.balance,
transactions: coins.transactions,
balance,
transactions: transactions,
coinsEarnedToday,
totalEarned,
totalSpent,

View File

@@ -1,19 +1,62 @@
import { useAtom } from 'jotai'
import { habitsAtom, coinsAtom, settingsAtom } from '@/lib/atoms'
import { habitsAtom, coinsAtom, settingsAtom, usersAtom } from '@/lib/atoms'
import { addCoins, removeCoins, saveHabitsData } from '@/app/actions/data'
import { Habit } from '@/lib/types'
import { DateTime } from 'luxon'
import { getNowInMilliseconds, getTodayInTimezone, isSameDate, t2d, d2t, getNow, getCompletionsForDate, getISODate, d2s } from '@/lib/utils'
import { Habit, Permission, SafeUser, User } from '@/lib/types'
import { toast } from '@/hooks/use-toast'
import { DateTime } from 'luxon'
import {
getNowInMilliseconds,
getTodayInTimezone,
isSameDate,
t2d,
d2t,
getNow,
getCompletionsForDate,
getISODate,
d2s,
playSound,
checkPermission
} from '@/lib/utils'
import { ToastAction } from '@/components/ui/toast'
import { Undo2 } from 'lucide-react'
import { useHelpers } from '@/lib/client-helpers'
function handlePermissionCheck(
user: SafeUser | undefined,
resource: 'habit' | 'wishlist' | 'coins',
action: 'write' | 'interact'
): boolean {
if (!user) {
toast({
title: "Authentication Required",
description: "Please sign in to continue.",
variant: "destructive",
})
return false
}
if (!user.isAdmin && !checkPermission(user.permissions, resource, action)) {
toast({
title: "Permission Denied",
description: `You don't have ${action} permission for ${resource}s.`,
variant: "destructive",
})
return false
}
return true
}
export function useHabits() {
const [usersData] = useAtom(usersAtom)
const { currentUser } = useHelpers()
const [habitsData, setHabitsData] = useAtom(habitsAtom)
const [coins, setCoins] = useAtom(coinsAtom)
const [settings] = useAtom(settingsAtom)
const completeHabit = async (habit: Habit) => {
if (!handlePermissionCheck(currentUser, 'habit', 'interact')) return
const timezone = settings.system.timezone
const today = getTodayInTimezone(timezone)
@@ -32,13 +75,15 @@ export function useHabits() {
description: `You've already completed this habit today.`,
variant: "destructive",
})
return null
return
}
// Add new completion
const updatedHabit = {
...habit,
completions: [...habit.completions, d2t({ dateTime: getNow({ timezone }) })]
completions: [...habit.completions, d2t({ dateTime: getNow({ timezone }) })],
// Archive the habit if it's a task and we're about to reach the target
archived: habit.isTask && completionsToday + 1 === target ? true : habit.archived
}
const updatedHabits = habitsData.habits.map(h =>
@@ -46,29 +91,36 @@ export function useHabits() {
)
await saveHabitsData({ habits: updatedHabits })
setHabitsData({ habits: updatedHabits })
// Check if we've now reached the target
const isTargetReached = completionsToday + 1 === target
if (isTargetReached) {
const updatedCoins = await addCoins({
amount: habit.coinReward,
description: `Completed habit: ${habit.name}`,
description: `Completed: ${habit.name}`,
type: habit.isTask ? 'TASK_COMPLETION' : 'HABIT_COMPLETION',
relatedItemId: habit.id,
})
isTargetReached && playSound()
toast({
title: "Completed!",
description: `You earned ${habit.coinReward} coins.`,
action: <ToastAction altText="Undo" className="gap-2" onClick={() => undoComplete(updatedHabit)}>
<Undo2 className="h-4 w-4" />Undo
</ToastAction>
})
setCoins(updatedCoins)
} else {
toast({
title: "Progress!",
description: `You've completed ${completionsToday + 1}/${target} times today.`,
action: <ToastAction altText="Undo" className="gap-2" onClick={() => undoComplete(updatedHabit)}>
<Undo2 className="h-4 w-4" />Undo
</ToastAction>
})
}
toast({
title: isTargetReached ? "Habit completed!" : "Progress!",
description: isTargetReached
? `You earned ${habit.coinReward} coins.`
: `You've completed ${completionsToday + 1}/${target} times today.`,
action: <ToastAction altText="Undo" className="gap-2" onClick={() => undoComplete(updatedHabit)}>
<Undo2 className="h-4 w-4" />Undo
</ToastAction>
})
// move atom update at the end of function to improve UI responsiveness
setHabitsData({ habits: updatedHabits })
return {
updatedHabits,
@@ -78,6 +130,7 @@ export function useHabits() {
}
const undoComplete = async (habit: Habit) => {
if (!handlePermissionCheck(currentUser, 'habit', 'interact')) return
const timezone = settings.system.timezone
const today = t2d({ timestamp: getTodayInTimezone(timezone), timezone })
@@ -87,12 +140,13 @@ export function useHabits() {
)
if (todayCompletions.length > 0) {
// Remove the most recent completion
// Remove the most recent completion and unarchive if needed
const updatedHabit = {
...habit,
completions: habit.completions.filter(
(_, index) => index !== habit.completions.length - 1
)
),
archived: habit.isTask ? false : habit.archived // Unarchive if it's a task
}
const updatedHabits = habitsData.habits.map(h =>
@@ -107,7 +161,7 @@ export function useHabits() {
if (todayCompletions.length === target) {
const updatedCoins = await removeCoins({
amount: habit.coinReward,
description: `Undid habit completion: ${habit.name}`,
description: `Undid completion: ${habit.name}`,
type: habit.isTask ? 'TASK_UNDO' : 'HABIT_UNDO',
relatedItemId: habit.id,
})
@@ -137,11 +191,12 @@ export function useHabits() {
description: "This habit hasn't been completed today.",
variant: "destructive",
})
return null
return
}
}
const saveHabit = async (habit: Omit<Habit, 'id'> & { id?: string }) => {
if (!handlePermissionCheck(currentUser, 'habit', 'write')) return
const newHabit = {
...habit,
id: habit.id || getNowInMilliseconds().toString()
@@ -156,6 +211,7 @@ export function useHabits() {
}
const deleteHabit = async (id: string) => {
if (!handlePermissionCheck(currentUser, 'habit', 'write')) return
const updatedHabits = habitsData.habits.filter(h => h.id !== id)
await saveHabitsData({ habits: updatedHabits })
setHabitsData({ habits: updatedHabits })
@@ -163,6 +219,7 @@ export function useHabits() {
}
const completePastHabit = async (habit: Habit, date: DateTime) => {
if (!handlePermissionCheck(currentUser, 'habit', 'interact')) return
const timezone = settings.system.timezone
const dateKey = getISODate({ dateTime: date, timezone })
@@ -178,7 +235,7 @@ export function useHabits() {
description: `This habit was already completed on ${d2s({ dateTime: date, timezone, format: 'yyyy-MM-dd' })}.`,
variant: "destructive",
})
return null
return
}
// Use current time but with the past date
@@ -207,7 +264,7 @@ export function useHabits() {
if (isTargetReached) {
const updatedCoins = await addCoins({
amount: habit.coinReward,
description: `Completed habit: ${habit.name} on ${d2s({ dateTime: date, timezone, format: 'yyyy-MM-dd' })}`,
description: `Completed: ${habit.name} on ${d2s({ dateTime: date, timezone, format: 'yyyy-MM-dd' })}`,
type: habit.isTask ? 'TASK_COMPLETION' : 'HABIT_COMPLETION',
relatedItemId: habit.id,
})
@@ -215,7 +272,7 @@ export function useHabits() {
}
toast({
title: isTargetReached ? "Habit completed!" : "Progress!",
title: isTargetReached ? "Completed!" : "Progress!",
description: isTargetReached
? `You earned ${habit.coinReward} coins for ${dateKey}.`
: `You've completed ${completionsOnDate + 1}/${target} times on ${dateKey}.`,
@@ -231,11 +288,31 @@ export function useHabits() {
}
}
const archiveHabit = async (id: string) => {
if (!handlePermissionCheck(currentUser, 'habit', 'write')) return
const updatedHabits = habitsData.habits.map(h =>
h.id === id ? { ...h, archived: true } : h
)
await saveHabitsData({ habits: updatedHabits })
setHabitsData({ habits: updatedHabits })
}
const unarchiveHabit = async (id: string) => {
if (!handlePermissionCheck(currentUser, 'habit', 'write')) return
const updatedHabits = habitsData.habits.map(h =>
h.id === id ? { ...h, archived: false } : h
)
await saveHabitsData({ habits: updatedHabits })
setHabitsData({ habits: updatedHabits })
}
return {
completeHabit,
undoComplete,
saveHabit,
deleteHabit,
completePastHabit
completePastHabit,
archiveHabit,
unarchiveHabit
}
}

View File

View File

@@ -4,35 +4,82 @@ 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'
function handlePermissionCheck(
user: any,
resource: 'habit' | 'wishlist' | 'coins',
action: 'write' | 'interact'
): boolean {
if (!user) {
toast({
title: "Authentication Required",
description: "Please sign in to continue.",
variant: "destructive",
})
return false
}
if (!user.isAdmin && !checkPermission(user.permissions, resource, action)) {
toast({
title: "Permission Denied",
description: `You don't have ${action} permission for ${resource}s.`,
variant: "destructive",
})
return false
}
return true
}
export function useWishlist() {
const { currentUser: user } = useHelpers()
const [wishlist, setWishlist] = useAtom(wishlistAtom)
const [coins, setCoins] = useAtom(coinsAtom)
const balance = coins.balance
const { balance } = useCoins()
const addWishlistItem = async (item: Omit<WishlistItemType, 'id'>) => {
if (!handlePermissionCheck(user, 'wishlist', 'write')) return
const newItem = { ...item, id: Date.now().toString() }
const newItems = [...wishlist.items, newItem]
setWishlist({ items: newItems })
await saveWishlistItems(newItems)
const newWishListData = { items: newItems }
setWishlist(newWishListData)
await saveWishlistItems(newWishListData)
}
const editWishlistItem = async (updatedItem: WishlistItemType) => {
if (!handlePermissionCheck(user, 'wishlist', 'write')) return
const newItems = wishlist.items.map(item =>
item.id === updatedItem.id ? updatedItem : item
)
setWishlist({ items: newItems })
await saveWishlistItems(newItems)
const newWishListData = { items: newItems }
setWishlist(newWishListData)
await saveWishlistItems(newWishListData)
}
const deleteWishlistItem = async (id: string) => {
if (!handlePermissionCheck(user, 'wishlist', 'write')) return
const newItems = wishlist.items.filter(item => item.id !== id)
setWishlist({ items: newItems })
await saveWishlistItems(newItems)
const newWishListData = { items: newItems }
setWishlist(newWishListData)
await saveWishlistItems(newWishListData)
}
const redeemWishlistItem = async (item: WishlistItemType) => {
if (!handlePermissionCheck(user, 'wishlist', 'interact')) return false
if (balance >= item.coinCost) {
// Check if item has target completions and if we've reached the limit
if (item.targetCompletions && item.targetCompletions <= 0) {
toast({
title: "Redemption limit reached",
description: `You've reached the maximum redemptions for "${item.name}".`,
variant: "destructive",
})
return false
}
const data = await removeCoins({
amount: item.coinCost,
description: `Redeemed reward: ${item.name}`,
@@ -41,6 +88,31 @@ export function useWishlist() {
})
setCoins(data)
// Update target completions if set
if (item.targetCompletions !== undefined) {
const newItems = wishlist.items.map(wishlistItem => {
if (wishlistItem.id === item.id) {
const newTarget = wishlistItem.targetCompletions! - 1
// If target reaches 0, archive the item
if (newTarget <= 0) {
return {
...wishlistItem,
targetCompletions: undefined,
archived: true
}
}
return {
...wishlistItem,
targetCompletions: newTarget
}
}
return wishlistItem
})
const newWishListData = { items: newItems }
setWishlist(newWishListData)
await saveWishlistItems(newWishListData)
}
// Randomly choose a celebration effect
const celebrationEffects = [
celebrations.emojiParty
@@ -66,11 +138,33 @@ export function useWishlist() {
const canRedeem = (cost: number) => balance >= cost
const archiveWishlistItem = async (id: string) => {
if (!handlePermissionCheck(user, 'wishlist', 'write')) return
const newItems = wishlist.items.map(item =>
item.id === id ? { ...item, archived: true } : item
)
const newWishListData = { items: newItems }
setWishlist(newWishListData)
await saveWishlistItems(newWishListData)
}
const unarchiveWishlistItem = async (id: string) => {
if (!handlePermissionCheck(user, 'wishlist', 'write')) return
const newItems = wishlist.items.map(item =>
item.id === id ? { ...item, archived: false } : item
)
const newWishListData = { items: newItems }
setWishlist(newWishListData)
await saveWishlistItems(newWishListData)
}
return {
addWishlistItem,
editWishlistItem,
deleteWishlistItem,
redeemWishlistItem,
archiveWishlistItem,
unarchiveWishlistItem,
canRedeem,
wishlistItems: wishlist.items
}

7
instrumentation.ts Normal file
View File

@@ -0,0 +1,7 @@
import { init } from '@/lib/env.server' // startup env var check
export function register() {
if (typeof window === "undefined") {
init()
}
}

View File

@@ -6,6 +6,9 @@ import {
getDefaultWishlistData,
Habit,
ViewType,
getDefaultUsersData,
CompletionCache,
getDefaultServerSettings,
} from "./types";
import {
getTodayInTimezone,
@@ -17,55 +20,73 @@ import {
calculateCoinsSpentToday,
calculateTransactionsToday,
getCompletionsForToday,
getISODate
getISODate,
isHabitDueToday,
getNow,
isHabitDue
} from "@/lib/utils";
import { atomWithStorage } from "jotai/utils";
import { atomFamily, atomWithStorage } from "jotai/utils";
import { DateTime } from "luxon";
export interface BrowserSettings {
viewType: ViewType
expandedHabits: boolean
expandedTasks: boolean
expandedWishlist: boolean
}
export const browserSettingsAtom = atomWithStorage('browserSettings', {
viewType: 'habits'
viewType: 'habits',
expandedHabits: false,
expandedTasks: false,
expandedWishlist: false
} as BrowserSettings)
export const usersAtom = atom(getDefaultUsersData())
export const settingsAtom = atom(getDefaultSettings());
export const habitsAtom = atom(getDefaultHabitsData());
export const coinsAtom = atom(getDefaultCoinsData());
export const wishlistAtom = atom(getDefaultWishlistData());
export const serverSettingsAtom = atom(getDefaultServerSettings());
// Derived atom for coins earned today
export const coinsEarnedTodayAtom = atom((get) => {
const coins = get(coinsAtom);
const settings = get(settingsAtom);
return calculateCoinsEarnedToday(coins.transactions, settings.system.timezone);
});
// // Derived atom for coins earned today
// export const coinsEarnedTodayAtom = atom((get) => {
// const coins = get(coinsAtom);
// const settings = get(settingsAtom);
// return calculateCoinsEarnedToday(coins.transactions, settings.system.timezone);
// });
// Derived atom for total earned
export const totalEarnedAtom = atom((get) => {
const coins = get(coinsAtom);
return calculateTotalEarned(coins.transactions);
});
// // Derived atom for total earned
// export const totalEarnedAtom = atom((get) => {
// const coins = get(coinsAtom);
// return calculateTotalEarned(coins.transactions);
// });
// Derived atom for total spent
export const totalSpentAtom = atom((get) => {
const coins = get(coinsAtom);
return calculateTotalSpent(coins.transactions);
});
// // Derived atom for total spent
// export const totalSpentAtom = atom((get) => {
// const coins = get(coinsAtom);
// return calculateTotalSpent(coins.transactions);
// });
// Derived atom for coins spent today
export const coinsSpentTodayAtom = atom((get) => {
const coins = get(coinsAtom);
const settings = get(settingsAtom);
return calculateCoinsSpentToday(coins.transactions, settings.system.timezone);
});
// // Derived atom for coins spent today
// export const coinsSpentTodayAtom = atom((get) => {
// const coins = get(coinsAtom);
// const settings = get(settingsAtom);
// return calculateCoinsSpentToday(coins.transactions, settings.system.timezone);
// });
// 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);
});
// // 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);
// });
// // Derived atom for current balance from all transactions
// export const coinsBalanceAtom = atom((get) => {
// const coins = get(coinsAtom);
// return coins.transactions.reduce((sum, transaction) => sum + transaction.amount, 0);
// });
/* transient atoms */
interface PomodoroAtom {
@@ -82,36 +103,51 @@ export const pomodoroAtom = atom<PomodoroAtom>({
minimized: false,
})
// Derived atom for *fully* completed habits by date, respecting target completions
export const completedHabitsMapAtom = atom((get) => {
const habits = get(habitsAtom).habits
const timezone = get(settingsAtom).system.timezone
const map = new Map<string, Habit[]>()
export const userSelectAtom = atom<boolean>(false)
// Derived atom for completion cache
export const completionCacheAtom = atom((get) => {
const habits = get(habitsAtom).habits;
const timezone = get(settingsAtom).system.timezone;
const cache: CompletionCache = {};
habits.forEach(habit => {
// Group completions by date
const completionsByDate = new Map<string, number>()
habit.completions.forEach(completion => {
const dateKey = getISODate({ dateTime: t2d({ timestamp: completion, timezone }), timezone })
completionsByDate.set(dateKey, (completionsByDate.get(dateKey) || 0) + 1)
})
// Check if habit meets target completions for each date
completionsByDate.forEach((count, dateKey) => {
const target = habit.targetCompletions || 1
if (count >= target) {
if (!map.has(dateKey)) {
map.set(dateKey, [])
}
map.get(dateKey)!.push(habit)
habit.completions.forEach(utcTimestamp => {
const localDate = t2d({ timestamp: utcTimestamp, timezone })
.toFormat('yyyy-MM-dd');
if (!cache[localDate]) {
cache[localDate] = {};
}
})
})
cache[localDate][habit.id] = (cache[localDate][habit.id] || 0) + 1;
});
});
return map
})
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
Object.entries(completionCache).forEach(([dateKey, habitCompletions]) => {
const completedHabits = habits.filter(habit => {
const completionsNeeded = habit.targetCompletions || 1;
const completionsAchieved = habitCompletions[habit.id] || 0;
return completionsAchieved >= completionsNeeded;
});
if (completedHabits.length > 0) {
map.set(dateKey, completedHabits);
}
});
return map;
});
export const pomodoroTodayCompletionsAtom = atom((get) => {
@@ -129,3 +165,28 @@ export const pomodoroTodayCompletionsAtom = atom((get) => {
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
export const habitsByDateFamily = atomFamily((dateString: string) =>
atom((get) => {
const habits = get(habitsAtom).habits;
const settings = get(settingsAtom);
const timezone = settings.system.timezone;
const date = DateTime.fromISO(dateString).setZone(timezone);
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));
});

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

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

View File

@@ -19,3 +19,14 @@ export const DUE_MAP: { [key: string]: string } = {
export const HabitIcon = Target
export const TaskIcon = CheckSquare;
export const QUICK_DATES = [
{ label: 'Today', value: 'today' },
{ label: 'Tomorrow', value: 'tomorrow' },
{ label: 'Monday', value: 'this monday' },
{ label: 'Tuesday', value: 'this tuesday' },
{ label: 'Wednesday', value: 'this wednesday' },
{ label: 'Thursday', value: 'this thursday' },
{ label: 'Friday', value: 'this friday' },
{ label: 'Saturday', value: 'this saturday' },
{ label: 'Sunday', value: 'this sunday' },
] as const

32
lib/env.server.ts Normal file
View File

@@ -0,0 +1,32 @@
import { z } from "zod"
const zodEnv = z.object({
AUTH_SECRET: z.string(),
DEMO: z.string().optional(),
})
declare global {
interface ProcessEnv extends z.TypeOf<typeof zodEnv> {
AUTH_SECRET: string;
DEMO?: string;
}
}
export function init() {
try {
zodEnv.parse(process.env)
} catch (err) {
if (err instanceof z.ZodError) {
const { fieldErrors } = err.flatten()
const errorMessage = Object.entries(fieldErrors)
.map(([field, errors]) =>
errors ? `${field}: ${errors.join(", ")}` : field,
)
.join("\n ")
throw new Error(
`Missing environment variables:\n ${errorMessage}`,
)
}
}
}

6
lib/exceptions.ts Normal file
View File

@@ -0,0 +1,6 @@
export class PermissionError extends Error {
constructor(message: string) {
super(message)
this.name = 'PermissionError'
}
}

40
lib/server-helpers.ts Normal file
View File

@@ -0,0 +1,40 @@
import { auth } from '@/auth'
import 'server-only'
import { User, UserId } from './types'
import { loadUsersData } from '@/app/actions/data'
import { randomBytes, scryptSync } from 'crypto'
export async function getCurrentUserId(): Promise<UserId | undefined> {
const session = await auth()
const user = session?.user
return user?.id
}
export async function getCurrentUser(): Promise<User | undefined> {
const currentUserId = await getCurrentUserId()
if (!currentUserId) {
return undefined
}
const usersData = await loadUsersData()
return usersData.users.find((u) => u.id === currentUserId)
}
export function saltAndHashPassword(password: string, salt?: string): string {
if (password.length === 0) throw new Error('Password must not be empty')
salt = salt || randomBytes(16).toString('hex')
const hash = scryptSync(password, salt, 64).toString('hex')
return `${salt}:${hash}`
}
export function verifyPassword(password?: string, storedHash?: string): boolean {
// if both password and storedHash is undefined, return true
if (!password && !storedHash) return true
// else if either password or storedHash is undefined, return false
if (!password || !storedHash) return false
// Split the stored hash into its salt and hash components
const [salt, hash] = storedHash.split(':')
// Hash the input password with the same salt
const newHash = saltAndHashPassword(password, salt).split(':')[1]
// Compare the new hash with the stored hash
return newHash === hash
}

View File

@@ -1,3 +1,37 @@
import { uuid } from "./utils"
export type UserId = string
export type Permission = {
habit: {
write: boolean
interact: boolean
}
wishlist: {
write: boolean
interact: boolean
}
coins: {
write: boolean
interact: boolean
}
}
export type SessionUser = {
id: UserId
}
export type SafeUser = SessionUser & {
username: string
avatarPath?: string
permissions?: Permission[]
isAdmin?: boolean
}
export type User = SafeUser & {
password: string
}
export type Habit = {
id: string
name: string
@@ -7,6 +41,8 @@ export type Habit = {
targetCompletions?: number // Optional field, default to 1
completions: string[] // Array of UTC ISO date strings
isTask?: boolean // mark the habit as a task
archived?: boolean // mark the habit as archived
userIds?: UserId[]
}
@@ -17,6 +53,10 @@ export type WishlistItemType = {
name: string
description: string
coinCost: number
archived?: boolean // mark the wishlist item as archived
targetCompletions?: number // Optional field, infinity when unset
link?: string // Optional URL to external resource
userIds?: UserId[]
}
export type TransactionType = 'HABIT_COMPLETION' | 'HABIT_UNDO' | 'WISH_REDEMPTION' | 'MANUAL_ADJUSTMENT' | 'TASK_COMPLETION' | 'TASK_UNDO';
@@ -29,6 +69,11 @@ export interface CoinTransaction {
timestamp: string;
relatedItemId?: string;
note?: string;
userId?: UserId;
}
export interface UserData {
users: User[]
}
export interface HabitsData {
@@ -48,6 +93,17 @@ export interface WishlistData {
}
// Default value functions
export const getDefaultUsersData = (): UserData => ({
users: [
{
id: uuid(),
username: 'admin',
password: '',
isAdmin: true,
}
]
});
export const getDefaultHabitsData = (): HabitsData => ({
habits: []
});
@@ -74,12 +130,17 @@ export const getDefaultSettings = (): Settings => ({
profile: {}
});
export const getDefaultServerSettings = (): ServerSettings => ({
isDemo: false
})
// Map of data types to their default values
export const DATA_DEFAULTS = {
wishlist: getDefaultWishlistData,
habits: getDefaultHabitsData,
coins: getDefaultCoinsData,
settings: getDefaultSettings,
auth: getDefaultUsersData,
} as const;
// Type for all possible data types
@@ -98,7 +159,7 @@ export interface SystemSettings {
}
export interface ProfileSettings {
avatarPath?: string;
avatarPath?: string; // deprecated
}
export interface Settings {
@@ -107,6 +168,12 @@ export interface Settings {
profile: ProfileSettings;
}
export type CompletionCache = {
[dateKey: string]: { // dateKey format: "YYYY-MM-DD"
[habitId: string]: number // number of completions on that date
}
}
export type ViewType = 'habits' | 'tasks'
export interface JotaiHydrateInitialValues {
@@ -114,4 +181,10 @@ export interface JotaiHydrateInitialValues {
coins: CoinsData;
habits: HabitsData;
wishlist: WishlistData;
users: UserData;
serverSettings: ServerSettings;
}
export interface ServerSettings {
isDemo: boolean
}

View File

@@ -15,7 +15,9 @@ import {
calculateTotalSpent,
calculateCoinsSpentToday,
isHabitDueToday,
isHabitDue
isHabitDue,
uuid,
isTaskOverdue
} from './utils'
import { CoinTransaction } from './types'
import { DateTime } from "luxon";
@@ -31,6 +33,87 @@ describe('cn utility', () => {
})
})
describe('isTaskOverdue', () => {
const createTestHabit = (frequency: string, isTask = true, archived = false): Habit => ({
id: 'test-habit',
name: 'Test Habit',
description: '',
frequency,
coinReward: 10,
completions: [],
isTask,
archived
})
test('should return false for non-tasks', () => {
const habit = createTestHabit('FREQ=DAILY', false)
expect(isTaskOverdue(habit, 'UTC')).toBe(false)
})
test('should return false for archived tasks', () => {
const habit = createTestHabit('2024-01-01T00:00:00Z', true, true)
expect(isTaskOverdue(habit, 'UTC')).toBe(false)
})
test('should return false for future tasks', () => {
const tomorrow = DateTime.now().plus({ days: 1 }).toUTC().toISO()
const habit = createTestHabit(tomorrow)
expect(isTaskOverdue(habit, 'UTC')).toBe(false)
})
test('should return false for completed past tasks', () => {
const yesterday = DateTime.now().minus({ days: 1 }).toUTC().toISO()
const habit = {
...createTestHabit(yesterday),
completions: [DateTime.now().toUTC().toISO()]
}
expect(isTaskOverdue(habit, 'UTC')).toBe(false)
})
test('should return true for incomplete past tasks', () => {
const yesterday = DateTime.now().minus({ days: 1 }).toUTC().toISO()
const habit = createTestHabit(yesterday)
expect(isTaskOverdue(habit, 'UTC')).toBe(true)
})
test('should handle timezone differences correctly', () => {
// Create a task due "tomorrow" in UTC
const tomorrow = DateTime.now().plus({ days: 1 }).toUTC().toISO()
const habit = createTestHabit(tomorrow)
// Test in various timezones
expect(isTaskOverdue(habit, 'UTC')).toBe(false)
expect(isTaskOverdue(habit, 'America/New_York')).toBe(false)
expect(isTaskOverdue(habit, 'Asia/Tokyo')).toBe(false)
})
})
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', () => {
let fixedNow: DateTime;
let currentDateIndex = 0;
@@ -452,13 +535,8 @@ describe('isHabitDueToday', () => {
test('should return false for invalid recurrence rule', () => {
const habit = testHabit('INVALID_RRULE')
// Mock console.error to prevent test output pollution
const consoleSpy = spyOn(console, 'error').mockImplementation(() => { })
// Expect the function to throw an error
expect(() => isHabitDueToday({ habit, timezone: 'UTC' })).toThrow()
consoleSpy.mockRestore()
const consoleSpy = spyOn(console, 'error').mockImplementation(() => {})
expect(isHabitDueToday({ habit, timezone: 'UTC' })).toBe(false)
})
})
@@ -570,8 +648,7 @@ describe('isHabitDue', () => {
test('should return false for invalid recurrence rule', () => {
const habit = testHabit('INVALID_RRULE')
const date = DateTime.fromISO('2024-01-01T00:00:00Z')
const consoleSpy = spyOn(console, 'error').mockImplementation(() => { })
expect(() => isHabitDue({ habit, timezone: 'UTC', date })).toThrow()
consoleSpy.mockRestore()
const consoleSpy = spyOn(console, 'error').mockImplementation(() => {})
expect(isHabitDue({ habit, timezone: 'UTC', date })).toBe(false)
})
})

View File

@@ -2,9 +2,11 @@ import { clsx, type ClassValue } from "clsx"
import { twMerge } from "tailwind-merge"
import { DateTime, DateTimeFormatOptions } from "luxon"
import { datetime, RRule } from 'rrule'
import { Freq, Habit, CoinTransaction } from '@/lib/types'
import { DUE_MAP, INITIAL_RECURRENCE_RULE, RECURRENCE_RULE_MAP } from "./constants"
import * as chrono from 'chrono-node';
import { Freq, Habit, CoinTransaction, Permission } from '@/lib/types'
import { DUE_MAP, INITIAL_DUE, INITIAL_RECURRENCE_RULE, RECURRENCE_RULE_MAP } from "./constants"
import * as chrono from 'chrono-node'
import _ from "lodash"
import { v4 as uuidv4 } from 'uuid'
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
@@ -36,7 +38,7 @@ export function t2d({ timestamp, timezone }: { timestamp: string; timezone: stri
return DateTime.fromISO(timestamp).setZone(timezone);
}
// convert datetime object to iso timestamp, mostly for storage write
// convert datetime object to iso timestamp, mostly for storage write (be sure to use default utc timezone when writing)
export function d2t({ dateTime, timezone = 'utc' }: { dateTime: DateTime, timezone?: string }) {
return dateTime.setZone(timezone).toISO()!;
}
@@ -189,20 +191,28 @@ export function getRRuleUTC(recurrenceRule: string) {
export function parseNaturalLanguageRRule(ruleText: string) {
ruleText = ruleText.trim()
let rrule: RRule
if (RECURRENCE_RULE_MAP[ruleText]) {
return RRule.fromString(RECURRENCE_RULE_MAP[ruleText])
rrule = RRule.fromString(RECURRENCE_RULE_MAP[ruleText])
} else {
rrule = RRule.fromText(ruleText)
}
return RRule.fromText(ruleText)
if (isUnsupportedRRule(rrule)) return RRule.fromString('invalid') // return invalid if unsupported
return rrule
}
export function parseRRule(ruleText: string) {
ruleText = ruleText.trim()
let rrule: RRule
if (RECURRENCE_RULE_MAP[ruleText]) {
return RRule.fromString(RECURRENCE_RULE_MAP[ruleText])
rrule = RRule.fromString(RECURRENCE_RULE_MAP[ruleText])
} else {
rrule = RRule.fromString(ruleText)
}
return RRule.fromString(ruleText)
if (isUnsupportedRRule(rrule)) return RRule.fromString('invalid') // return invalid if unsupported
return rrule
}
export function serializeRRule(rrule: RRule) {
@@ -220,6 +230,25 @@ export function parseNaturalLanguageDate({ text, timezone }: { text: string, tim
return DateTime.fromJSDate(due).setZone(timezone)
}
export function getFrequencyDisplayText(frequency: string | undefined, isRecurRule: boolean, timezone: string) {
if (isRecurRule) {
try {
return parseRRule((frequency) || INITIAL_RECURRENCE_RULE).toText();
} catch {
return 'invalid'
}
} else {
if (!frequency) {
return INITIAL_DUE
}
return d2s({
dateTime: t2d({ timestamp: frequency, timezone: timezone }),
timezone: timezone,
format: DateTime.DATE_MED_WITH_WEEKDAY
});
}
}
export function isHabitDue({
habit,
timezone,
@@ -229,17 +258,29 @@ export function isHabitDue({
timezone: string
date: DateTime
}): boolean {
// handle task
if (habit.isTask) {
// For tasks, frequency is stored as a UTC ISO timestamp
const taskDueDate = t2d({ timestamp: habit.frequency, timezone })
return isSameDate(taskDueDate, date);
}
// handle habit
if (habit.archived) {
return false
}
const startOfDay = date.setZone(timezone).startOf('day')
const endOfDay = date.setZone(timezone).endOf('day')
const ruleText = habit.frequency
const rrule = parseRRule(ruleText)
let rrule
try {
rrule = parseRRule(ruleText)
} catch (error) {
console.error(`Failed to parse rrule for habit: ${habit.id} ${habit.name}`)
return false
}
rrule.origOptions.tzid = timezone
rrule.options.tzid = rrule.origOptions.tzid
rrule.origOptions.dtstart = datetime(startOfDay.year, startOfDay.month, startOfDay.day, startOfDay.hour, startOfDay.minute, startOfDay.second)
@@ -253,6 +294,17 @@ export function isHabitDue({
return startOfDay <= t && t <= endOfDay
}
export function isHabitCompleted(habit: Habit, timezone: string): boolean {
return getCompletionsForToday({ habit, timezone: timezone }) >= (habit.targetCompletions || 1)
}
export function isTaskOverdue(habit: Habit, timezone: string): boolean {
if (!habit.isTask || habit.archived) return false
const dueDate = t2d({ timestamp: habit.frequency, timezone }).startOf('day')
const now = getNow({ timezone }).startOf('day')
return dueDate < now && !isHabitCompleted(habit, timezone)
}
export function isHabitDueToday({
habit,
timezone
@@ -276,6 +328,65 @@ export function getHabitFreq(habit: Habit): Freq {
case RRule.WEEKLY: return 'weekly'
case RRule.MONTHLY: return 'monthly'
case RRule.YEARLY: return 'yearly'
default: throw new Error(`Invalid frequency: ${freq}`)
default:
console.error(`Invalid frequency: ${freq} (habit: ${habit.id} ${habit.name}) (rrule: ${rrule.toString()}). Defaulting to daily`)
return 'daily'
}
}
export function isUnsupportedRRule(rrule: RRule): boolean {
const freq = rrule.origOptions.freq
return freq === RRule.HOURLY || freq === RRule.MINUTELY || freq === RRule.SECONDLY
}
// play sound (client side only, must be run in browser)
export const playSound = (soundPath: string = '/sounds/timer-end.wav') => {
const audio = new Audio(soundPath)
audio.play().catch(error => {
console.error('Error playing sound:', error)
})
}
// open a new window (client side only, must be run in browser)
export const openWindow = (url: string): boolean => {
const newWindow = window.open(url, '_blank')
if (newWindow === null) {
// Popup was blocked
return false
}
return true
}
export function deepMerge<T>(a: T, b: T) {
return _.merge(a, b, (x: unknown, y: unknown) => {
if (_.isArray(a)) {
return a.concat(b)
}
})
}
export function checkPermission(
permissions: Permission[] | undefined,
resource: 'habit' | 'wishlist' | 'coins',
action: 'write' | 'interact'
): boolean {
if (!permissions) return false
return permissions.some(permission => {
switch (resource) {
case 'habit':
return permission.habit[action]
case 'wishlist':
return permission.wishlist[action]
case 'coins':
return permission.coins[action]
default:
return false
}
})
}
export function uuid() {
return uuidv4()
}

17
lib/zod.ts Normal file
View File

@@ -0,0 +1,17 @@
import { literal, object, string } from "zod"
export const usernameSchema = string()
.min(3, "Username must be at least 3 characters")
.max(20, "Username must be less than 20 characters")
.regex(/^[a-zA-Z0-9]+$/, "Username must be alphanumeric")
export const passwordSchema = string()
.min(4, "Password must be more than 4 characters")
.max(32, "Password must be less than 32 characters")
.optional()
.or(literal(''))
export const signInSchema = object({
username: usernameSchema,
password: passwordSchema,
})

268
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{
"name": "habittrove",
"version": "0.1.24",
"version": "0.2.4",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "habittrove",
"version": "0.1.24",
"version": "0.2.4",
"dependencies": {
"@emoji-mart/data": "^1.2.1",
"@emoji-mart/react": "^1.1.1",
@@ -36,7 +36,9 @@
"linkify-react": "^4.2.0",
"lucide-react": "^0.469.0",
"luxon": "^3.5.0",
"next": "15.1.3",
"next": "15.2.3",
"next-auth": "^5.0.0-beta.25",
"next-themes": "^0.4.4",
"react": "^19.0.0",
"react-confetti": "^6.2.2",
"react-day-picker": "^8.10.1",
@@ -46,12 +48,15 @@
"rrule": "^2.8.1",
"tailwind-merge": "^2.6.0",
"tailwindcss-animate": "^1.0.7",
"web-push": "^3.6.7"
"uuid": "^11.0.5",
"web-push": "^3.6.7",
"zod": "^3.24.1"
},
"devDependencies": {
"@eslint/eslintrc": "^3",
"@tailwindcss/typography": "^0.5.15",
"@types/bun": "^1.1.14",
"@types/lodash": "^4.17.15",
"@types/luxon": "^3.4.2",
"@types/node": "^20.17.10",
"@types/react": "^19",
@@ -78,6 +83,37 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/@auth/core": {
"version": "0.37.2",
"resolved": "https://registry.npmjs.org/@auth/core/-/core-0.37.2.tgz",
"integrity": "sha512-kUvzyvkcd6h1vpeMAojK2y7+PAV5H+0Cc9+ZlKYDFhDY31AlvsB+GW5vNO4qE3Y07KeQgvNO9U0QUx/fN62kBw==",
"license": "ISC",
"dependencies": {
"@panva/hkdf": "^1.2.1",
"@types/cookie": "0.6.0",
"cookie": "0.7.1",
"jose": "^5.9.3",
"oauth4webapi": "^3.0.0",
"preact": "10.11.3",
"preact-render-to-string": "5.2.3"
},
"peerDependencies": {
"@simplewebauthn/browser": "^9.0.1",
"@simplewebauthn/server": "^9.0.2",
"nodemailer": "^6.8.0"
},
"peerDependenciesMeta": {
"@simplewebauthn/browser": {
"optional": true
},
"@simplewebauthn/server": {
"optional": true
},
"nodemailer": {
"optional": true
}
}
},
"node_modules/@babel/runtime": {
"version": "7.26.0",
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.26.0.tgz",
@@ -738,9 +774,10 @@
}
},
"node_modules/@next/env": {
"version": "15.1.3",
"resolved": "https://registry.npmjs.org/@next/env/-/env-15.1.3.tgz",
"integrity": "sha512-Q1tXwQCGWyA3ehMph3VO+E6xFPHDKdHFYosadt0F78EObYxPio0S09H9UGYznDe6Wc8eLKLG89GqcFJJDiK5xw=="
"version": "15.2.3",
"resolved": "https://registry.npmjs.org/@next/env/-/env-15.2.3.tgz",
"integrity": "sha512-a26KnbW9DFEUsSxAxKBORR/uD9THoYoKbkpFywMN/AFvboTt94b8+g/07T8J6ACsdLag8/PDU60ov4rPxRAixw==",
"license": "MIT"
},
"node_modules/@next/eslint-plugin-next": {
"version": "15.1.3",
@@ -760,12 +797,13 @@
}
},
"node_modules/@next/swc-darwin-arm64": {
"version": "15.1.3",
"resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-15.1.3.tgz",
"integrity": "sha512-aZtmIh8jU89DZahXQt1La0f2EMPt/i7W+rG1sLtYJERsP7GRnNFghsciFpQcKHcGh4dUiyTB5C1X3Dde/Gw8gg==",
"version": "15.2.3",
"resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-15.2.3.tgz",
"integrity": "sha512-uaBhA8aLbXLqwjnsHSkxs353WrRgQgiFjduDpc7YXEU0B54IKx3vU+cxQlYwPCyC8uYEEX7THhtQQsfHnvv8dw==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"darwin"
@@ -775,12 +813,13 @@
}
},
"node_modules/@next/swc-darwin-x64": {
"version": "15.1.3",
"resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-15.1.3.tgz",
"integrity": "sha512-aw8901rjkVBK5mbq5oV32IqkJg+CQa6aULNlN8zyCWSsePzEG3kpDkAFkkTOh3eJ0p95KbkLyWBzslQKamXsLA==",
"version": "15.2.3",
"resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-15.2.3.tgz",
"integrity": "sha512-pVwKvJ4Zk7h+4hwhqOUuMx7Ib02u3gDX3HXPKIShBi9JlYllI0nU6TWLbPT94dt7FSi6mSBhfc2JrHViwqbOdw==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"darwin"
@@ -790,12 +829,13 @@
}
},
"node_modules/@next/swc-linux-arm64-gnu": {
"version": "15.1.3",
"resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-15.1.3.tgz",
"integrity": "sha512-YbdaYjyHa4fPK4GR4k2XgXV0p8vbU1SZh7vv6El4bl9N+ZSiMfbmqCuCuNU1Z4ebJMumafaz6UCC2zaJCsdzjw==",
"version": "15.2.3",
"resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-15.2.3.tgz",
"integrity": "sha512-50ibWdn2RuFFkOEUmo9NCcQbbV9ViQOrUfG48zHBCONciHjaUKtHcYFiCwBVuzD08fzvzkWuuZkd4AqbvKO7UQ==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
@@ -805,12 +845,13 @@
}
},
"node_modules/@next/swc-linux-arm64-musl": {
"version": "15.1.3",
"resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-15.1.3.tgz",
"integrity": "sha512-qgH/aRj2xcr4BouwKG3XdqNu33SDadqbkqB6KaZZkozar857upxKakbRllpqZgWl/NDeSCBYPmUAZPBHZpbA0w==",
"version": "15.2.3",
"resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-15.2.3.tgz",
"integrity": "sha512-2gAPA7P652D3HzR4cLyAuVYwYqjG0mt/3pHSWTCyKZq/N/dJcUAEoNQMyUmwTZWCJRKofB+JPuDVP2aD8w2J6Q==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
@@ -820,12 +861,13 @@
}
},
"node_modules/@next/swc-linux-x64-gnu": {
"version": "15.1.3",
"resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-15.1.3.tgz",
"integrity": "sha512-uzafnTFwZCPN499fNVnS2xFME8WLC9y7PLRs/yqz5lz1X/ySoxfaK2Hbz74zYUdEg+iDZPd8KlsWaw9HKkLEVw==",
"version": "15.2.3",
"resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-15.2.3.tgz",
"integrity": "sha512-ODSKvrdMgAJOVU4qElflYy1KSZRM3M45JVbeZu42TINCMG3anp7YCBn80RkISV6bhzKwcUqLBAmOiWkaGtBA9w==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
@@ -835,12 +877,13 @@
}
},
"node_modules/@next/swc-linux-x64-musl": {
"version": "15.1.3",
"resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-15.1.3.tgz",
"integrity": "sha512-el6GUFi4SiDYnMTTlJJFMU+GHvw0UIFnffP1qhurrN1qJV3BqaSRUjkDUgVV44T6zpw1Lc6u+yn0puDKHs+Sbw==",
"version": "15.2.3",
"resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-15.2.3.tgz",
"integrity": "sha512-ZR9kLwCWrlYxwEoytqPi1jhPd1TlsSJWAc+H/CJHmHkf2nD92MQpSRIURR1iNgA/kuFSdxB8xIPt4p/T78kwsg==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
@@ -850,12 +893,13 @@
}
},
"node_modules/@next/swc-win32-arm64-msvc": {
"version": "15.1.3",
"resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-15.1.3.tgz",
"integrity": "sha512-6RxKjvnvVMM89giYGI1qye9ODsBQpHSHVo8vqA8xGhmRPZHDQUE4jcDbhBwK0GnFMqBnu+XMg3nYukNkmLOLWw==",
"version": "15.2.3",
"resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-15.2.3.tgz",
"integrity": "sha512-+G2FrDcfm2YDbhDiObDU/qPriWeiz/9cRR0yMWJeTLGGX6/x8oryO3tt7HhodA1vZ8r2ddJPCjtLcpaVl7TE2Q==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"win32"
@@ -865,12 +909,13 @@
}
},
"node_modules/@next/swc-win32-x64-msvc": {
"version": "15.1.3",
"resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-15.1.3.tgz",
"integrity": "sha512-VId/f5blObG7IodwC5Grf+aYP0O8Saz1/aeU3YcWqNdIUAmFQY3VEPKPaIzfv32F/clvanOb2K2BR5DtDs6XyQ==",
"version": "15.2.3",
"resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-15.2.3.tgz",
"integrity": "sha512-gHYS9tc+G2W0ZC8rBL+H6RdtXIyk40uLiaos0yj5US85FNhbFEndMA2nW3z47nzOWiSvXTZ5kBClc3rD0zJg0w==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"win32"
@@ -920,6 +965,15 @@
"node": ">=12.4.0"
}
},
"node_modules/@panva/hkdf": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/@panva/hkdf/-/hkdf-1.2.1.tgz",
"integrity": "sha512-6oclG6Y3PiDFcoyk8srjLfVKyMfVCKJ27JwNPViuXziFpmdz+MZnZN/aKY0JGXgYuO/VghU0jcOAZgWXZ1Dmrw==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/panva"
}
},
"node_modules/@pkgjs/parseargs": {
"version": "0.11.0",
"resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz",
@@ -1830,6 +1884,12 @@
"resolved": "https://registry.npmjs.org/@types/canvas-confetti/-/canvas-confetti-1.9.0.tgz",
"integrity": "sha512-aBGj/dULrimR1XDZLtG9JwxX1b4HPRF6CX9Yfwh3NvstZEm1ZL7RBnel4keCPSqs1ANRu1u2Aoz9R+VmtjYuTg=="
},
"node_modules/@types/cookie": {
"version": "0.6.0",
"resolved": "https://registry.npmjs.org/@types/cookie/-/cookie-0.6.0.tgz",
"integrity": "sha512-4Kh9a6B2bQciAhf7FSuMRRkUWecJgJu9nPnx3yzpsfXX/c50REIqpHY4C82bXP90qrLtXtkDxTZosYO3UpOwlA==",
"license": "MIT"
},
"node_modules/@types/d3-array": {
"version": "3.2.1",
"resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.1.tgz",
@@ -1947,6 +2007,13 @@
"integrity": "sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ==",
"dev": true
},
"node_modules/@types/lodash": {
"version": "4.17.15",
"resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.17.15.tgz",
"integrity": "sha512-w/P33JFeySuhN6JLkysYUK2gEmy9kHHFN7E8ro0tkfmlDOgxBDzWEZ/J8cWA+fHqFevpswDTFZnDx+R9lbL6xw==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/luxon": {
"version": "3.4.2",
"resolved": "https://registry.npmjs.org/@types/luxon/-/luxon-3.4.2.tgz",
@@ -3235,6 +3302,15 @@
"integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==",
"dev": true
},
"node_modules/cookie": {
"version": "0.7.1",
"resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.1.tgz",
"integrity": "sha512-6DnInpx7SJ2AK3+CTUE/ZM0vWTUboZCegxhC2xiIydHR9jNuTAASBrfEpHhiGOZw/nX51bHt6YQl8jsGo4y/0w==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/cross-spawn": {
"version": "7.0.6",
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
@@ -5429,6 +5505,15 @@
"jiti": "bin/jiti.js"
}
},
"node_modules/jose": {
"version": "5.9.6",
"resolved": "https://registry.npmjs.org/jose/-/jose-5.9.6.tgz",
"integrity": "sha512-AMlnetc9+CV9asI19zHmrgS/WYsWUwCn2R7RzlbJWD7F9eWYUTGyBmU9o6PxngtLGOiDGPRu+Uc4fhKzbpteZQ==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/panva"
}
},
"node_modules/jotai": {
"version": "2.8.0",
"resolved": "https://registry.npmjs.org/jotai/-/jotai-2.8.0.tgz",
@@ -6437,11 +6522,12 @@
"peer": true
},
"node_modules/next": {
"version": "15.1.3",
"resolved": "https://registry.npmjs.org/next/-/next-15.1.3.tgz",
"integrity": "sha512-5igmb8N8AEhWDYzogcJvtcRDU6n4cMGtBklxKD4biYv4LXN8+awc/bbQ2IM2NQHdVPgJ6XumYXfo3hBtErg1DA==",
"version": "15.2.3",
"resolved": "https://registry.npmjs.org/next/-/next-15.2.3.tgz",
"integrity": "sha512-x6eDkZxk2rPpu46E1ZVUWIBhYCLszmUY6fvHBFcbzJ9dD+qRX6vcHusaqqDlnY+VngKzKbAiG2iRCkPbmi8f7w==",
"license": "MIT",
"dependencies": {
"@next/env": "15.1.3",
"@next/env": "15.2.3",
"@swc/counter": "0.1.3",
"@swc/helpers": "0.5.15",
"busboy": "1.6.0",
@@ -6456,14 +6542,14 @@
"node": "^18.18.0 || ^19.8.0 || >= 20.0.0"
},
"optionalDependencies": {
"@next/swc-darwin-arm64": "15.1.3",
"@next/swc-darwin-x64": "15.1.3",
"@next/swc-linux-arm64-gnu": "15.1.3",
"@next/swc-linux-arm64-musl": "15.1.3",
"@next/swc-linux-x64-gnu": "15.1.3",
"@next/swc-linux-x64-musl": "15.1.3",
"@next/swc-win32-arm64-msvc": "15.1.3",
"@next/swc-win32-x64-msvc": "15.1.3",
"@next/swc-darwin-arm64": "15.2.3",
"@next/swc-darwin-x64": "15.2.3",
"@next/swc-linux-arm64-gnu": "15.2.3",
"@next/swc-linux-arm64-musl": "15.2.3",
"@next/swc-linux-x64-gnu": "15.2.3",
"@next/swc-linux-x64-musl": "15.2.3",
"@next/swc-win32-arm64-msvc": "15.2.3",
"@next/swc-win32-x64-msvc": "15.2.3",
"sharp": "^0.33.5"
},
"peerDependencies": {
@@ -6489,6 +6575,43 @@
}
}
},
"node_modules/next-auth": {
"version": "5.0.0-beta.25",
"resolved": "https://registry.npmjs.org/next-auth/-/next-auth-5.0.0-beta.25.tgz",
"integrity": "sha512-2dJJw1sHQl2qxCrRk+KTQbeH+izFbGFPuJj5eGgBZFYyiYYtvlrBeUw1E/OJJxTRjuxbSYGnCTkUIRsIIW0bog==",
"license": "ISC",
"dependencies": {
"@auth/core": "0.37.2"
},
"peerDependencies": {
"@simplewebauthn/browser": "^9.0.1",
"@simplewebauthn/server": "^9.0.2",
"next": "^14.0.0-0 || ^15.0.0-0",
"nodemailer": "^6.6.5",
"react": "^18.2.0 || ^19.0.0-0"
},
"peerDependenciesMeta": {
"@simplewebauthn/browser": {
"optional": true
},
"@simplewebauthn/server": {
"optional": true
},
"nodemailer": {
"optional": true
}
}
},
"node_modules/next-themes": {
"version": "0.4.4",
"resolved": "https://registry.npmjs.org/next-themes/-/next-themes-0.4.4.tgz",
"integrity": "sha512-LDQ2qIOJF0VnuVrrMSMLrWGjRMkq+0mpgl6e0juCLqdJ+oo8Q84JRWT6Wh11VDQKkMMe+dVzDKLWs5n87T+PkQ==",
"license": "MIT",
"peerDependencies": {
"react": "^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc"
}
},
"node_modules/next/node_modules/postcss": {
"version": "8.4.31",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.31.tgz",
@@ -6531,6 +6654,15 @@
"node": ">=0.10.0"
}
},
"node_modules/oauth4webapi": {
"version": "3.1.4",
"resolved": "https://registry.npmjs.org/oauth4webapi/-/oauth4webapi-3.1.4.tgz",
"integrity": "sha512-eVfN3nZNbok2s/ROifO0UAc5G8nRoLSbrcKJ09OqmucgnhXEfdIQOR4gq1eJH1rN3gV7rNw62bDEgftsgFtBEg==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/panva"
}
},
"node_modules/object-assign": {
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
@@ -6953,6 +7085,28 @@
"resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz",
"integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ=="
},
"node_modules/preact": {
"version": "10.11.3",
"resolved": "https://registry.npmjs.org/preact/-/preact-10.11.3.tgz",
"integrity": "sha512-eY93IVpod/zG3uMF22Unl8h9KkrcKIRs2EGar8hwLZZDU1lkjph303V9HZBwufh2s736U6VXuhD109LYqPoffg==",
"license": "MIT",
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/preact"
}
},
"node_modules/preact-render-to-string": {
"version": "5.2.3",
"resolved": "https://registry.npmjs.org/preact-render-to-string/-/preact-render-to-string-5.2.3.tgz",
"integrity": "sha512-aPDxUn5o3GhWdtJtW0svRC2SS/l8D9MAgo2+AWml+BhDImb27ALf04Q2d+AHqUUOc6RdSXFIBVa2gxzgMKgtZA==",
"license": "MIT",
"dependencies": {
"pretty-format": "^3.8.0"
},
"peerDependencies": {
"preact": ">=10"
}
},
"node_modules/prelude-ls": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz",
@@ -6962,6 +7116,12 @@
"node": ">= 0.8.0"
}
},
"node_modules/pretty-format": {
"version": "3.8.0",
"resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-3.8.0.tgz",
"integrity": "sha512-WuxUnVtlWL1OfZFQFuqvnvs6MiAGk9UNsBostyBOB0Is9wb5uRESevA6rnl/rkksXaGX3GzZhPup5d6Vp1nFew==",
"license": "MIT"
},
"node_modules/prop-types": {
"version": "15.8.1",
"resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz",
@@ -8656,6 +8816,19 @@
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw=="
},
"node_modules/uuid": {
"version": "11.0.5",
"resolved": "https://registry.npmjs.org/uuid/-/uuid-11.0.5.tgz",
"integrity": "sha512-508e6IcKLrhxKdBbcA2b4KQZlLVp2+J5UwQ6F7Drckkc5N9ZJwFa4TgWtsww9UG8fGHbm6gbV19TdM5pQ4GaIA==",
"funding": [
"https://github.com/sponsors/broofa",
"https://github.com/sponsors/ctavan"
],
"license": "MIT",
"bin": {
"uuid": "dist/esm/bin/uuid"
}
},
"node_modules/vfile": {
"version": "6.0.3",
"resolved": "https://registry.npmjs.org/vfile/-/vfile-6.0.3.tgz",
@@ -9027,6 +9200,15 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/zod": {
"version": "3.24.1",
"resolved": "https://registry.npmjs.org/zod/-/zod-3.24.1.tgz",
"integrity": "sha512-muH7gBL9sI1nciMZV67X5fTKKBLtwpZ5VBp1vsOQzj1MhrBZ4wlVCm3gedKZWLp0Oyel8sIGfeiz54Su+OVT+A==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/colinhacks"
}
},
"node_modules/zwitch": {
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/zwitch/-/zwitch-2.0.4.tgz",

View File

@@ -1,6 +1,6 @@
{
"name": "habittrove",
"version": "0.1.25",
"version": "0.2.5",
"private": true,
"scripts": {
"dev": "next dev --turbopack",
@@ -43,7 +43,9 @@
"linkify-react": "^4.2.0",
"lucide-react": "^0.469.0",
"luxon": "^3.5.0",
"next": "15.1.3",
"next": "15.2.3",
"next-auth": "^5.0.0-beta.25",
"next-themes": "^0.4.4",
"react": "^19.0.0",
"react-confetti": "^6.2.2",
"react-day-picker": "^8.10.1",
@@ -53,12 +55,15 @@
"rrule": "^2.8.1",
"tailwind-merge": "^2.6.0",
"tailwindcss-animate": "^1.0.7",
"web-push": "^3.6.7"
"uuid": "^11.0.5",
"web-push": "^3.6.7",
"zod": "^3.24.1"
},
"devDependencies": {
"@eslint/eslintrc": "^3",
"@tailwindcss/typography": "^0.5.15",
"@types/bun": "^1.1.14",
"@types/lodash": "^4.17.15",
"@types/luxon": "^3.4.2",
"@types/node": "^20.17.10",
"@types/react": "^19",

8
types/next-auth.d.ts vendored Normal file
View File

@@ -0,0 +1,8 @@
import 'next-auth'
import { SafeUser } from '@/lib/types'
declare module 'next-auth' {
interface Session {
user: SafeUser
}
}