mirror of
https://github.com/ManInDark/HabitTrove.git
synced 2026-01-21 06:34:30 +01:00
Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9052c9f37a | ||
|
|
a615a45c39 |
3
.gitignore
vendored
3
.gitignore
vendored
@@ -41,6 +41,7 @@ yarn-error.log*
|
||||
next-env.d.ts
|
||||
|
||||
# customize
|
||||
data/*
|
||||
/data/*
|
||||
/data.*/*
|
||||
Budfile
|
||||
certificates
|
||||
|
||||
18
CHANGELOG.md
18
CHANGELOG.md
@@ -1,5 +1,23 @@
|
||||
# Changelog
|
||||
|
||||
## 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
|
||||
|
||||
@@ -19,7 +19,8 @@ import {
|
||||
getDefaultWishlistData,
|
||||
getDefaultHabitsData,
|
||||
getDefaultCoinsData,
|
||||
Permission
|
||||
Permission,
|
||||
ServerSettings
|
||||
} from '@/lib/types'
|
||||
import { d2t, deepMerge, getNow, checkPermission, uuid } from '@/lib/utils';
|
||||
import { verifyPassword } from "@/lib/server-helpers";
|
||||
@@ -184,7 +185,7 @@ export async function loadCoinsData(): Promise<CoinsData> {
|
||||
const data = await loadData<CoinsData>('coins')
|
||||
return {
|
||||
...data,
|
||||
transactions: data.transactions.filter(x => x.userId === user.id)
|
||||
transactions: user.isAdmin ? data.transactions : data.transactions.filter(x => x.userId === user.id)
|
||||
}
|
||||
} catch {
|
||||
return getDefaultCoinsData()
|
||||
@@ -218,12 +219,14 @@ 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()
|
||||
@@ -234,7 +237,8 @@ export async function addCoins({
|
||||
description,
|
||||
timestamp: d2t({ dateTime: getNow({}) }),
|
||||
...(relatedItemId && { relatedItemId }),
|
||||
...(note && note.trim() !== '' && { note })
|
||||
...(note && note.trim() !== '' && { note }),
|
||||
userId: userId || await getCurrentUserId()
|
||||
}
|
||||
|
||||
const newData: CoinsData = {
|
||||
@@ -269,12 +273,14 @@ 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()
|
||||
@@ -285,7 +291,8 @@ export async function removeCoins({
|
||||
description,
|
||||
timestamp: d2t({ dateTime: getNow({}) }),
|
||||
...(relatedItemId && { relatedItemId }),
|
||||
...(note && note.trim() !== '' && { note })
|
||||
...(note && note.trim() !== '' && { note }),
|
||||
userId: userId || await getCurrentUserId()
|
||||
}
|
||||
|
||||
const newData: CoinsData = {
|
||||
@@ -474,3 +481,9 @@ export async function deleteUser(userId: string): Promise<void> {
|
||||
|
||||
await saveUsersData(newData)
|
||||
}
|
||||
|
||||
export async function loadServerSettings(): Promise<ServerSettings> {
|
||||
return {
|
||||
isDemo: !!process.env.DEMO,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@ 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, loadUsersData } 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"
|
||||
@@ -37,12 +37,13 @@ export default async function RootLayout({
|
||||
}: {
|
||||
children: React.ReactNode
|
||||
}) {
|
||||
const [initialSettings, initialHabits, initialCoins, initialWishlist, initialUsers] = await Promise.all([
|
||||
const [initialSettings, initialHabits, initialCoins, initialWishlist, initialUsers, initialServerSettings] = await Promise.all([
|
||||
loadSettings(),
|
||||
loadHabitsData(),
|
||||
loadCoinsData(),
|
||||
loadWishlistData(),
|
||||
loadUsersData(),
|
||||
loadServerSettings(),
|
||||
])
|
||||
|
||||
return (
|
||||
@@ -74,7 +75,8 @@ export default async function RootLayout({
|
||||
habits: initialHabits,
|
||||
coins: initialCoins,
|
||||
wishlist: initialWishlist,
|
||||
users: initialUsers
|
||||
users: initialUsers,
|
||||
serverSettings: initialServerSettings,
|
||||
}}
|
||||
>
|
||||
<ThemeProvider
|
||||
|
||||
@@ -16,8 +16,8 @@ import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover
|
||||
import data from '@emoji-mart/data'
|
||||
import Picker from '@emoji-mart/react'
|
||||
import { Habit, SafeUser } from '@/lib/types'
|
||||
import { d2s, d2t, getISODate, getNow, parseNaturalLanguageDate, parseNaturalLanguageRRule, parseRRule, serializeRRule } from '@/lib/utils'
|
||||
import { INITIAL_DUE, INITIAL_RECURRENCE_RULE, QUICK_DATES } from '@/lib/constants'
|
||||
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 {
|
||||
@@ -43,15 +43,33 @@ export default function AddEditHabitModal({ onClose, onSave, habit, isTask }: Ad
|
||||
const [coinReward, setCoinReward] = useState(habit?.coinReward || 1)
|
||||
const [targetCompletions, setTargetCompletions] = useState(habit?.targetCompletions || 1)
|
||||
const isRecurRule = !isTask
|
||||
const origRuleText = isRecurRule ? parseRRule(habit?.frequency || INITIAL_RECURRENCE_RULE).toText() : habit?.frequency || INITIAL_DUE
|
||||
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()
|
||||
await onSave({
|
||||
@@ -60,8 +78,7 @@ export default function AddEditHabitModal({ onClose, onSave, habit, isTask }: Ad
|
||||
coinReward,
|
||||
targetCompletions: targetCompletions > 1 ? targetCompletions : undefined,
|
||||
completions: habit?.completions || [],
|
||||
frequency: isRecurRule ? serializeRRule(parseNaturalLanguageRRule(ruleText)) : d2t({ dateTime: parseNaturalLanguageDate({ text: ruleText, timezone: settings.system.timezone }) }),
|
||||
isTask: isTask || undefined,
|
||||
frequency: getFrequencyUpdate(),
|
||||
userIds: selectedUserIds.length > 0 ? selectedUserIds.concat(currentUser?.id || []) : (currentUser && [currentUser.id])
|
||||
})
|
||||
}
|
||||
|
||||
@@ -17,6 +17,8 @@ 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,
|
||||
@@ -28,14 +30,13 @@ 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)
|
||||
const [currentPage, setCurrentPage] = useState(1)
|
||||
const { currentUser } = useHelpers()
|
||||
|
||||
const [note, setNote] = useState('')
|
||||
|
||||
@@ -62,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>
|
||||
|
||||
@@ -168,10 +168,10 @@ export default function DailyOverview({
|
||||
${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();
|
||||
@@ -204,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>
|
||||
@@ -223,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}
|
||||
@@ -373,10 +373,10 @@ export default function DailyOverview({
|
||||
${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();
|
||||
@@ -409,7 +409,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>
|
||||
@@ -428,7 +428,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}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Habit, SafeUser, User, Permission } from '@/lib/types'
|
||||
import { useAtom } from 'jotai'
|
||||
import { settingsAtom, pomodoroAtom, browserSettingsAtom, usersAtom } from '@/lib/atoms'
|
||||
import { getTodayInTimezone, isSameDate, t2d, d2t, getNow, parseNaturalLanguageRRule, parseRRule, d2s, getCompletionsForToday, isTaskOverdue } from '@/lib/utils'
|
||||
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, Archive, ArchiveRestore, Calendar } from 'lucide-react'
|
||||
@@ -14,7 +14,7 @@ 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'
|
||||
@@ -104,7 +104,9 @@ export default function HabitItem({ habit, onEdit, onDelete }: HabitItemProps) {
|
||||
)}
|
||||
</CardHeader>
|
||||
<CardContent className="flex-1">
|
||||
<p className={`text-sm ${habit.archived ? 'text-gray-400 dark:text-gray-500' : '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 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>
|
||||
|
||||
@@ -8,8 +8,8 @@ import { Label } from './ui/label';
|
||||
import { Switch } from './ui/switch';
|
||||
import { Permission } from '@/lib/types';
|
||||
import { toast } from '@/hooks/use-toast';
|
||||
import { useAtom } from 'jotai';
|
||||
import { usersAtom } from '@/lib/atoms';
|
||||
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';
|
||||
@@ -26,6 +26,7 @@ interface UserFormProps {
|
||||
|
||||
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[] => [{
|
||||
@@ -46,7 +47,7 @@ export default function UserForm({ userId, onCancel, onSuccess }: UserFormProps)
|
||||
const [avatarPath, setAvatarPath] = useState(user?.avatarPath)
|
||||
const [username, setUsername] = useState(user?.username || '');
|
||||
const [password, setPassword] = useState<string | undefined>('');
|
||||
const [disablePassword, setDisablePassword] = useState(user?.password === '' || process.env.NEXT_PUBLIC_DEMO === 'true');
|
||||
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);
|
||||
@@ -240,7 +241,7 @@ export default function UserForm({ userId, onCancel, onSuccess }: UserFormProps)
|
||||
className={error ? 'border-red-500' : ''}
|
||||
disabled={disablePassword}
|
||||
/>
|
||||
{process.env.NEXT_PUBLIC_DEMO === 'true' && (
|
||||
{serverSettings.isDemo && (
|
||||
<p className="text-sm text-red-500">Password is automatically disabled in demo instance</p>
|
||||
)}
|
||||
</div>
|
||||
@@ -250,6 +251,7 @@ export default function UserForm({ userId, onCancel, onSuccess }: UserFormProps)
|
||||
id="disable-password"
|
||||
checked={disablePassword}
|
||||
onCheckedChange={setDisablePassword}
|
||||
disabled={serverSettings.isDemo}
|
||||
/>
|
||||
<Label htmlFor="disable-password">Disable password</Label>
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
'use client'
|
||||
|
||||
import { settingsAtom, habitsAtom, coinsAtom, wishlistAtom, usersAtom } from "@/lib/atoms"
|
||||
import { settingsAtom, habitsAtom, coinsAtom, wishlistAtom, usersAtom, serverSettingsAtom } from "@/lib/atoms"
|
||||
import { useHydrateAtoms } from "jotai/utils"
|
||||
import { JotaiHydrateInitialValues } from "@/lib/types"
|
||||
|
||||
@@ -13,7 +13,8 @@ export function JotaiHydrate({
|
||||
[habitsAtom, initialValues.habits],
|
||||
[coinsAtom, initialValues.coins],
|
||||
[wishlistAtom, initialValues.wishlist],
|
||||
[usersAtom, initialValues.users]
|
||||
[usersAtom, initialValues.users],
|
||||
[serverSettingsAtom, initialValues.serverSettings]
|
||||
])
|
||||
return children
|
||||
}
|
||||
|
||||
@@ -1,21 +1,23 @@
|
||||
import { useAtom } from 'jotai'
|
||||
import { checkPermission } from '@/lib/utils'
|
||||
import { calculateCoinsEarnedToday, calculateCoinsSpentToday, calculateTotalEarned, calculateTotalSpent, calculateTransactionsToday, checkPermission } from '@/lib/utils'
|
||||
import {
|
||||
coinsAtom,
|
||||
coinsEarnedTodayAtom,
|
||||
totalEarnedAtom,
|
||||
totalSpentAtom,
|
||||
coinsSpentTodayAtom,
|
||||
transactionsTodayAtom,
|
||||
coinsBalanceAtom
|
||||
// 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'
|
||||
|
||||
function handlePermissionCheck(
|
||||
user: any,
|
||||
user: User | undefined,
|
||||
resource: 'habit' | 'wishlist' | 'coins',
|
||||
action: 'write' | 'interact'
|
||||
): boolean {
|
||||
@@ -40,18 +42,30 @@ function handlePermissionCheck(
|
||||
return true
|
||||
}
|
||||
|
||||
export function useCoins() {
|
||||
const { currentUser: user } = useHelpers()
|
||||
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 [balance] = useAtom(coinsBalanceAtom)
|
||||
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(user, 'coins', 'write')) return null
|
||||
if (!handlePermissionCheck(currentUser, 'coins', 'write')) return null
|
||||
if (isNaN(amount) || amount <= 0) {
|
||||
toast({
|
||||
title: "Invalid amount",
|
||||
@@ -64,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` })
|
||||
@@ -72,7 +87,7 @@ export function useCoins() {
|
||||
}
|
||||
|
||||
const remove = async (amount: number, description: string, note?: string) => {
|
||||
if (!handlePermissionCheck(user, 'coins', 'write')) return null
|
||||
if (!handlePermissionCheck(currentUser, 'coins', 'write')) return null
|
||||
const numAmount = Math.abs(amount)
|
||||
if (isNaN(numAmount) || numAmount <= 0) {
|
||||
toast({
|
||||
@@ -86,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` })
|
||||
@@ -94,7 +110,7 @@ export function useCoins() {
|
||||
}
|
||||
|
||||
const updateNote = async (transactionId: string, note: string) => {
|
||||
if (!handlePermissionCheck(user, 'coins', 'write')) return null
|
||||
if (!handlePermissionCheck(currentUser, 'coins', 'write')) return null
|
||||
const transaction = coins.transactions.find(t => t.id === transactionId)
|
||||
if (!transaction) {
|
||||
toast({
|
||||
@@ -128,7 +144,7 @@ export function useCoins() {
|
||||
remove,
|
||||
updateNote,
|
||||
balance,
|
||||
transactions: coins.transactions,
|
||||
transactions: transactions,
|
||||
coinsEarnedToday,
|
||||
totalEarned,
|
||||
totalSpent,
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import { useAtom } from 'jotai'
|
||||
import { wishlistAtom, coinsAtom, coinsBalanceAtom } from '@/lib/atoms'
|
||||
import { wishlistAtom, coinsAtom } from '@/lib/atoms'
|
||||
import { saveWishlistItems, removeCoins } from '@/app/actions/data'
|
||||
import { toast } from '@/hooks/use-toast'
|
||||
import { WishlistItemType } from '@/lib/types'
|
||||
import { celebrations } from '@/utils/celebrations'
|
||||
import { checkPermission } from '@/lib/utils'
|
||||
import { useHelpers } from '@/lib/client-helpers'
|
||||
import { useCoins } from './useCoins'
|
||||
|
||||
function handlePermissionCheck(
|
||||
user: any,
|
||||
@@ -37,7 +38,7 @@ export function useWishlist() {
|
||||
const { currentUser: user } = useHelpers()
|
||||
const [wishlist, setWishlist] = useAtom(wishlistAtom)
|
||||
const [coins, setCoins] = useAtom(coinsAtom)
|
||||
const [balance] = useAtom(coinsBalanceAtom)
|
||||
const { balance } = useCoins()
|
||||
|
||||
const addWishlistItem = async (item: Omit<WishlistItemType, 'id'>) => {
|
||||
if (!handlePermissionCheck(user, 'wishlist', 'write')) return
|
||||
|
||||
68
lib/atoms.ts
68
lib/atoms.ts
@@ -8,6 +8,7 @@ import {
|
||||
ViewType,
|
||||
getDefaultUsersData,
|
||||
CompletionCache,
|
||||
getDefaultServerSettings,
|
||||
} from "./types";
|
||||
import {
|
||||
getTodayInTimezone,
|
||||
@@ -46,45 +47,46 @@ 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);
|
||||
});
|
||||
// // 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 {
|
||||
|
||||
@@ -2,13 +2,13 @@ import { z } from "zod"
|
||||
|
||||
const zodEnv = z.object({
|
||||
AUTH_SECRET: z.string(),
|
||||
NEXT_PUBLIC_DEMO: z.string().optional(),
|
||||
DEMO: z.string().optional(),
|
||||
})
|
||||
|
||||
declare global {
|
||||
interface ProcessEnv extends z.TypeOf<typeof zodEnv> {
|
||||
AUTH_SECRET: string;
|
||||
NEXT_PUBLIC_DEMO?: string;
|
||||
DEMO?: string;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -24,10 +24,9 @@ export function init() {
|
||||
)
|
||||
.join("\n ")
|
||||
|
||||
console.error(
|
||||
throw new Error(
|
||||
`Missing environment variables:\n ${errorMessage}`,
|
||||
)
|
||||
process.exit(1)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -130,6 +130,10 @@ 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,
|
||||
@@ -178,4 +182,9 @@ export interface JotaiHydrateInitialValues {
|
||||
habits: HabitsData;
|
||||
wishlist: WishlistData;
|
||||
users: UserData;
|
||||
serverSettings: ServerSettings;
|
||||
}
|
||||
|
||||
export interface ServerSettings {
|
||||
isDemo: boolean
|
||||
}
|
||||
@@ -535,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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -653,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)
|
||||
})
|
||||
})
|
||||
|
||||
56
lib/utils.ts
56
lib/utils.ts
@@ -3,7 +3,7 @@ import { twMerge } from "tailwind-merge"
|
||||
import { DateTime, DateTimeFormatOptions } from "luxon"
|
||||
import { datetime, RRule } from 'rrule'
|
||||
import { Freq, Habit, CoinTransaction, Permission } from '@/lib/types'
|
||||
import { DUE_MAP, RECURRENCE_RULE_MAP } from "./constants"
|
||||
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'
|
||||
@@ -191,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) {
|
||||
@@ -222,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,
|
||||
@@ -247,8 +274,13 @@ export function isHabitDue({
|
||||
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)
|
||||
@@ -296,10 +328,18 @@ 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)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "habittrove",
|
||||
"version": "0.2.2",
|
||||
"version": "0.2.4",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev --turbopack",
|
||||
|
||||
Reference in New Issue
Block a user