mirror of
https://github.com/ManInDark/HabitTrove.git
synced 2026-03-09 12:09:48 +01:00
fix coin stats and add transaction note (#31)
This commit is contained in:
10
CHANGELOG.md
10
CHANGELOG.md
@@ -1,5 +1,15 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## Version 0.1.17
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- transactions note
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- coin statistics
|
||||||
|
|
||||||
## Version 0.1.16
|
## Version 0.1.16
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|||||||
@@ -104,7 +104,8 @@ export async function addCoins(
|
|||||||
amount: number,
|
amount: number,
|
||||||
description: string,
|
description: string,
|
||||||
type: TransactionType = 'MANUAL_ADJUSTMENT',
|
type: TransactionType = 'MANUAL_ADJUSTMENT',
|
||||||
relatedItemId?: string
|
relatedItemId?: string,
|
||||||
|
note?: string
|
||||||
): Promise<CoinsData> {
|
): Promise<CoinsData> {
|
||||||
const data = await loadCoinsData()
|
const data = await loadCoinsData()
|
||||||
const newTransaction: CoinTransaction = {
|
const newTransaction: CoinTransaction = {
|
||||||
@@ -113,7 +114,8 @@ export async function addCoins(
|
|||||||
type,
|
type,
|
||||||
description,
|
description,
|
||||||
timestamp: d2t({ dateTime: getNow({}) }),
|
timestamp: d2t({ dateTime: getNow({}) }),
|
||||||
...(relatedItemId && { relatedItemId })
|
...(relatedItemId && { relatedItemId }),
|
||||||
|
...(note && note.trim() !== '' && { note })
|
||||||
}
|
}
|
||||||
|
|
||||||
const newData: CoinsData = {
|
const newData: CoinsData = {
|
||||||
@@ -144,7 +146,8 @@ export async function removeCoins(
|
|||||||
amount: number,
|
amount: number,
|
||||||
description: string,
|
description: string,
|
||||||
type: TransactionType = 'MANUAL_ADJUSTMENT',
|
type: TransactionType = 'MANUAL_ADJUSTMENT',
|
||||||
relatedItemId?: string
|
relatedItemId?: string,
|
||||||
|
note?: string
|
||||||
): Promise<CoinsData> {
|
): Promise<CoinsData> {
|
||||||
const data = await loadCoinsData()
|
const data = await loadCoinsData()
|
||||||
const newTransaction: CoinTransaction = {
|
const newTransaction: CoinTransaction = {
|
||||||
@@ -153,7 +156,8 @@ export async function removeCoins(
|
|||||||
type,
|
type,
|
||||||
description,
|
description,
|
||||||
timestamp: d2t({ dateTime: getNow({}) }),
|
timestamp: d2t({ dateTime: getNow({}) }),
|
||||||
...(relatedItemId && { relatedItemId })
|
...(relatedItemId && { relatedItemId }),
|
||||||
|
...(note && note.trim() !== '' && { note })
|
||||||
}
|
}
|
||||||
|
|
||||||
const newData: CoinsData = {
|
const newData: CoinsData = {
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { useState } from 'react'
|
|||||||
import { t2d, d2s, getNow, isSameDate } from '@/lib/utils'
|
import { t2d, d2s, getNow, isSameDate } from '@/lib/utils'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import { FormattedNumber } from '@/components/FormattedNumber'
|
import { FormattedNumber } from '@/components/FormattedNumber'
|
||||||
import { History } from 'lucide-react'
|
import { History, Pencil } from 'lucide-react'
|
||||||
import EmptyState from './EmptyState'
|
import EmptyState from './EmptyState'
|
||||||
import { Input } from '@/components/ui/input'
|
import { Input } from '@/components/ui/input'
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||||
@@ -12,11 +12,13 @@ import { settingsAtom } from '@/lib/atoms'
|
|||||||
import Link from 'next/link'
|
import Link from 'next/link'
|
||||||
import { useAtom } from 'jotai'
|
import { useAtom } from 'jotai'
|
||||||
import { useCoins } from '@/hooks/useCoins'
|
import { useCoins } from '@/hooks/useCoins'
|
||||||
|
import { TransactionNoteEditor } from './TransactionNoteEditor'
|
||||||
|
|
||||||
export default function CoinsManager() {
|
export default function CoinsManager() {
|
||||||
const {
|
const {
|
||||||
add,
|
add,
|
||||||
remove,
|
remove,
|
||||||
|
updateNote,
|
||||||
balance,
|
balance,
|
||||||
transactions,
|
transactions,
|
||||||
coinsEarnedToday,
|
coinsEarnedToday,
|
||||||
@@ -31,14 +33,26 @@ export default function CoinsManager() {
|
|||||||
const [pageSize, setPageSize] = useState(50)
|
const [pageSize, setPageSize] = useState(50)
|
||||||
const [currentPage, setCurrentPage] = useState(1)
|
const [currentPage, setCurrentPage] = useState(1)
|
||||||
|
|
||||||
|
const [note, setNote] = useState('')
|
||||||
|
|
||||||
|
const handleSaveNote = async (transactionId: string, note: string) => {
|
||||||
|
await updateNote(transactionId, note)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDeleteNote = async (transactionId: string) => {
|
||||||
|
await updateNote(transactionId, '')
|
||||||
|
}
|
||||||
|
|
||||||
const handleAddRemoveCoins = async () => {
|
const handleAddRemoveCoins = async () => {
|
||||||
const numAmount = Number(amount)
|
const numAmount = Number(amount)
|
||||||
if (numAmount > 0) {
|
if (numAmount > 0) {
|
||||||
await add(numAmount, "Manual addition")
|
await add(numAmount, "Manual addition", note)
|
||||||
setAmount(DEFAULT_AMOUNT)
|
setAmount(DEFAULT_AMOUNT)
|
||||||
|
setNote('')
|
||||||
} else if (numAmount < 0) {
|
} else if (numAmount < 0) {
|
||||||
await remove(Math.abs(numAmount), "Manual removal")
|
await remove(Math.abs(numAmount), "Manual removal", note)
|
||||||
setAmount(DEFAULT_AMOUNT)
|
setAmount(DEFAULT_AMOUNT)
|
||||||
|
setNote('')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -59,45 +73,51 @@ export default function CoinsManager() {
|
|||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<div className="flex items-center justify-center gap-4">
|
<div className="flex flex-col gap-4">
|
||||||
<Button
|
<div className="flex items-center justify-center gap-4">
|
||||||
variant="outline"
|
<Button
|
||||||
size="icon"
|
variant="outline"
|
||||||
className="h-10 w-10 text-lg"
|
size="icon"
|
||||||
onClick={() => setAmount(prev => (Number(prev) - 1).toString())}
|
className="h-10 w-10 text-lg"
|
||||||
>
|
onClick={() => setAmount(prev => (Number(prev) - 1).toString())}
|
||||||
-
|
>
|
||||||
</Button>
|
-
|
||||||
<div className="relative w-32">
|
</Button>
|
||||||
<Input
|
<div className="relative w-32">
|
||||||
type="number"
|
<Input
|
||||||
value={amount}
|
type="number"
|
||||||
onChange={(e) => setAmount(e.target.value)}
|
value={amount}
|
||||||
className="text-center text-xl font-medium h-12"
|
onChange={(e) => setAmount(e.target.value)}
|
||||||
/>
|
className="text-center text-xl font-medium h-12"
|
||||||
<div className="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground">
|
/>
|
||||||
🪙
|
<div className="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground">
|
||||||
|
🪙
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="icon"
|
||||||
|
className="h-10 w-10 text-lg"
|
||||||
|
onClick={() => setAmount(prev => (Number(prev) + 1).toString())}
|
||||||
|
>
|
||||||
|
+
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="w-full space-y-2">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Button
|
||||||
|
onClick={handleAddRemoveCoins}
|
||||||
|
className="flex-1 h-14 transition-colors flex items-center justify-center font-medium"
|
||||||
|
variant="default"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{Number(amount) >= 0 ? 'Add Coins' : 'Remove Coins'}
|
||||||
|
</div>
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="icon"
|
|
||||||
className="h-10 w-10 text-lg"
|
|
||||||
onClick={() => setAmount(prev => (Number(prev) + 1).toString())}
|
|
||||||
>
|
|
||||||
+
|
|
||||||
</Button>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Button
|
|
||||||
onClick={handleAddRemoveCoins}
|
|
||||||
className="w-full h-14 transition-colors flex items-center justify-center font-medium"
|
|
||||||
variant="default"
|
|
||||||
>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
{Number(amount) >= 0 ? 'Add Coins' : 'Remove Coins'}
|
|
||||||
</div>
|
|
||||||
</Button>
|
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
@@ -236,6 +256,12 @@ export default function CoinsManager() {
|
|||||||
<p className="text-sm text-gray-500">
|
<p className="text-sm text-gray-500">
|
||||||
{d2s({ dateTime: t2d({ timestamp: transaction.timestamp, timezone: settings.system.timezone }), timezone: settings.system.timezone })}
|
{d2s({ dateTime: t2d({ timestamp: transaction.timestamp, timezone: settings.system.timezone }), timezone: settings.system.timezone })}
|
||||||
</p>
|
</p>
|
||||||
|
<TransactionNoteEditor
|
||||||
|
transactionId={transaction.id}
|
||||||
|
initialNote={transaction.note}
|
||||||
|
onSave={handleSaveNote}
|
||||||
|
onDelete={handleDeleteNote}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<span
|
<span
|
||||||
className={`font-mono ${transaction.amount >= 0
|
className={`font-mono ${transaction.amount >= 0
|
||||||
|
|||||||
138
components/TransactionNoteEditor.tsx
Normal file
138
components/TransactionNoteEditor.tsx
Normal file
@@ -0,0 +1,138 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useState } from 'react'
|
||||||
|
import { Input } from '@/components/ui/input'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { Check, Loader2, Pencil, Trash2, X } from 'lucide-react'
|
||||||
|
import { toast } from '@/hooks/use-toast'
|
||||||
|
|
||||||
|
interface TransactionNoteEditorProps {
|
||||||
|
transactionId: string
|
||||||
|
initialNote?: string
|
||||||
|
onSave: (id: string, note: string) => Promise<void>
|
||||||
|
onDelete: (id: string) => Promise<void>
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TransactionNoteEditor({
|
||||||
|
transactionId,
|
||||||
|
initialNote = '',
|
||||||
|
onSave,
|
||||||
|
onDelete
|
||||||
|
}: TransactionNoteEditorProps) {
|
||||||
|
const [isEditing, setIsEditing] = useState(false)
|
||||||
|
const [noteText, setNoteText] = useState(initialNote)
|
||||||
|
const [isSaving, setIsSaving] = useState(false)
|
||||||
|
|
||||||
|
const handleSave = async () => {
|
||||||
|
const trimmedNote = noteText.trim()
|
||||||
|
if (trimmedNote.length > 200) {
|
||||||
|
toast({
|
||||||
|
title: 'Note too long',
|
||||||
|
description: 'Notes must be less than 200 characters',
|
||||||
|
variant: 'destructive'
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsSaving(true)
|
||||||
|
try {
|
||||||
|
await onSave(transactionId, trimmedNote)
|
||||||
|
setIsEditing(false)
|
||||||
|
} catch (error) {
|
||||||
|
toast({
|
||||||
|
title: 'Error saving note',
|
||||||
|
description: 'Please try again',
|
||||||
|
variant: 'destructive'
|
||||||
|
})
|
||||||
|
// Revert to initial value on error
|
||||||
|
setNoteText(initialNote)
|
||||||
|
} finally {
|
||||||
|
setIsSaving(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDelete = async () => {
|
||||||
|
setIsSaving(true)
|
||||||
|
try {
|
||||||
|
await onDelete(transactionId)
|
||||||
|
setNoteText(initialNote)
|
||||||
|
setIsEditing(false)
|
||||||
|
} catch (error) {
|
||||||
|
toast({
|
||||||
|
title: 'Error deleting note',
|
||||||
|
description: 'Please try again',
|
||||||
|
variant: 'destructive'
|
||||||
|
})
|
||||||
|
} finally {
|
||||||
|
setIsSaving(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isEditing) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-2 mt-1">
|
||||||
|
<Input
|
||||||
|
value={noteText}
|
||||||
|
onChange={(e) => setNoteText(e.target.value)}
|
||||||
|
placeholder="Add a note..."
|
||||||
|
className="w-64"
|
||||||
|
maxLength={200}
|
||||||
|
/>
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={handleSave}
|
||||||
|
disabled={isSaving}
|
||||||
|
className="text-green-600 dark:text-green-500 hover:text-green-700 dark:hover:text-green-400 transition-colors"
|
||||||
|
title="Save note"
|
||||||
|
>
|
||||||
|
{isSaving ? <Loader2 className="h-4 w-4 animate-spin" /> : <Check className="h-4 w-4" />}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => {
|
||||||
|
setNoteText(initialNote)
|
||||||
|
setIsEditing(false)
|
||||||
|
}}
|
||||||
|
disabled={isSaving}
|
||||||
|
className="text-red-600 dark:text-red-500 hover:text-red-700 dark:hover:text-red-400 transition-colors"
|
||||||
|
title="Cancel"
|
||||||
|
>
|
||||||
|
<X className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
{initialNote && (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={handleDelete}
|
||||||
|
disabled={isSaving}
|
||||||
|
className="text-gray-600 dark:text-gray-500 hover:text-gray-700 dark:hover:text-gray-400 transition-colors"
|
||||||
|
title="Delete note"
|
||||||
|
>
|
||||||
|
{isSaving ? <Loader2 className="h-4 w-4 animate-spin" /> : <Trash2 className="h-4 w-4" />}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="group flex items-center gap-2 mt-1">
|
||||||
|
{noteText && (
|
||||||
|
<span className="text-sm text-gray-500 dark:text-gray-400">
|
||||||
|
{noteText}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
onClick={() => setIsEditing(true)}
|
||||||
|
className="text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200"
|
||||||
|
aria-label="Edit note"
|
||||||
|
>
|
||||||
|
<Pencil className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,26 +1,25 @@
|
|||||||
import { useAtom } from 'jotai'
|
import { useAtom } from 'jotai'
|
||||||
import {
|
import {
|
||||||
coinsAtom,
|
coinsAtom,
|
||||||
settingsAtom,
|
|
||||||
coinsEarnedTodayAtom,
|
coinsEarnedTodayAtom,
|
||||||
totalEarnedAtom,
|
totalEarnedAtom,
|
||||||
totalSpentAtom,
|
totalSpentAtom,
|
||||||
coinsSpentTodayAtom,
|
coinsSpentTodayAtom,
|
||||||
transactionsTodayAtom
|
transactionsTodayAtom
|
||||||
} from '@/lib/atoms'
|
} from '@/lib/atoms'
|
||||||
import { addCoins, removeCoins } from '@/app/actions/data'
|
import { addCoins, removeCoins, saveCoinsData } from '@/app/actions/data'
|
||||||
|
import { CoinsData } from '@/lib/types'
|
||||||
import { toast } from '@/hooks/use-toast'
|
import { toast } from '@/hooks/use-toast'
|
||||||
|
|
||||||
export function useCoins() {
|
export function useCoins() {
|
||||||
const [coins, setCoins] = useAtom(coinsAtom)
|
const [coins, setCoins] = useAtom(coinsAtom)
|
||||||
const [settings] = useAtom(settingsAtom)
|
|
||||||
const [coinsEarnedToday] = useAtom(coinsEarnedTodayAtom)
|
const [coinsEarnedToday] = useAtom(coinsEarnedTodayAtom)
|
||||||
const [totalEarned] = useAtom(totalEarnedAtom)
|
const [totalEarned] = useAtom(totalEarnedAtom)
|
||||||
const [totalSpent] = useAtom(totalSpentAtom)
|
const [totalSpent] = useAtom(totalSpentAtom)
|
||||||
const [coinsSpentToday] = useAtom(coinsSpentTodayAtom)
|
const [coinsSpentToday] = useAtom(coinsSpentTodayAtom)
|
||||||
const [transactionsToday] = useAtom(transactionsTodayAtom)
|
const [transactionsToday] = useAtom(transactionsTodayAtom)
|
||||||
|
|
||||||
const add = async (amount: number, description: string) => {
|
const add = async (amount: number, description: string, note?: string) => {
|
||||||
if (isNaN(amount) || amount <= 0) {
|
if (isNaN(amount) || amount <= 0) {
|
||||||
toast({
|
toast({
|
||||||
title: "Invalid amount",
|
title: "Invalid amount",
|
||||||
@@ -29,13 +28,13 @@ export function useCoins() {
|
|||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
const data = await addCoins(amount, description)
|
const data = await addCoins(amount, description, 'MANUAL_ADJUSTMENT', undefined, note)
|
||||||
setCoins(data)
|
setCoins(data)
|
||||||
toast({ title: "Success", description: `Added ${amount} coins` })
|
toast({ title: "Success", description: `Added ${amount} coins` })
|
||||||
return data
|
return data
|
||||||
}
|
}
|
||||||
|
|
||||||
const remove = async (amount: number, description: string) => {
|
const remove = async (amount: number, description: string, note?: string) => {
|
||||||
const numAmount = Math.abs(amount)
|
const numAmount = Math.abs(amount)
|
||||||
if (isNaN(numAmount) || numAmount <= 0) {
|
if (isNaN(numAmount) || numAmount <= 0) {
|
||||||
toast({
|
toast({
|
||||||
@@ -45,15 +44,45 @@ export function useCoins() {
|
|||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
const data = await removeCoins(numAmount, description)
|
const data = await removeCoins(numAmount, description, 'MANUAL_ADJUSTMENT', undefined, note)
|
||||||
setCoins(data)
|
setCoins(data)
|
||||||
toast({ title: "Success", description: `Removed ${numAmount} coins` })
|
toast({ title: "Success", description: `Removed ${numAmount} coins` })
|
||||||
return data
|
return data
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const updateNote = async (transactionId: string, note: string) => {
|
||||||
|
const transaction = coins.transactions.find(t => t.id === transactionId)
|
||||||
|
if (!transaction) {
|
||||||
|
toast({
|
||||||
|
title: "Error",
|
||||||
|
description: "Transaction not found"
|
||||||
|
})
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const updatedTransaction = {
|
||||||
|
...transaction,
|
||||||
|
note: note.trim() || undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
const updatedTransactions = coins.transactions.map(t =>
|
||||||
|
t.id === transactionId ? updatedTransaction : t
|
||||||
|
)
|
||||||
|
|
||||||
|
const newData: CoinsData = {
|
||||||
|
...coins,
|
||||||
|
transactions: updatedTransactions
|
||||||
|
}
|
||||||
|
|
||||||
|
await saveCoinsData(newData)
|
||||||
|
setCoins(newData)
|
||||||
|
return newData
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
add,
|
add,
|
||||||
remove,
|
remove,
|
||||||
|
updateNote,
|
||||||
balance: coins.balance,
|
balance: coins.balance,
|
||||||
transactions: coins.transactions,
|
transactions: coins.transactions,
|
||||||
coinsEarnedToday,
|
coinsEarnedToday,
|
||||||
|
|||||||
62
lib/atoms.ts
62
lib/atoms.ts
@@ -5,7 +5,16 @@ import {
|
|||||||
getDefaultCoinsData,
|
getDefaultCoinsData,
|
||||||
getDefaultWishlistData
|
getDefaultWishlistData
|
||||||
} from "./types";
|
} from "./types";
|
||||||
import { getTodayInTimezone, isSameDate, t2d } from "@/lib/utils";
|
import {
|
||||||
|
getTodayInTimezone,
|
||||||
|
isSameDate,
|
||||||
|
t2d,
|
||||||
|
calculateCoinsEarnedToday,
|
||||||
|
calculateTotalEarned,
|
||||||
|
calculateTotalSpent,
|
||||||
|
calculateCoinsSpentToday,
|
||||||
|
calculateTransactionsToday
|
||||||
|
} from "@/lib/utils";
|
||||||
|
|
||||||
export const settingsAtom = atom(getDefaultSettings());
|
export const settingsAtom = atom(getDefaultSettings());
|
||||||
export const habitsAtom = atom(getDefaultHabitsData());
|
export const habitsAtom = atom(getDefaultHabitsData());
|
||||||
@@ -16,72 +25,31 @@ export const wishlistAtom = atom(getDefaultWishlistData());
|
|||||||
export const coinsEarnedTodayAtom = atom((get) => {
|
export const coinsEarnedTodayAtom = atom((get) => {
|
||||||
const coins = get(coinsAtom);
|
const coins = get(coinsAtom);
|
||||||
const settings = get(settingsAtom);
|
const settings = get(settingsAtom);
|
||||||
const today = getTodayInTimezone(settings.system.timezone);
|
return calculateCoinsEarnedToday(coins.transactions, settings.system.timezone);
|
||||||
return coins.transactions
|
|
||||||
.filter(transaction =>
|
|
||||||
isSameDate(t2d({ timestamp: transaction.timestamp, timezone: settings.system.timezone }),
|
|
||||||
t2d({ timestamp: today, timezone: settings.system.timezone }))
|
|
||||||
)
|
|
||||||
.reduce((sum, transaction) => {
|
|
||||||
if (transaction.type !== 'HABIT_UNDO' && transaction.amount > 0) {
|
|
||||||
return sum + transaction.amount;
|
|
||||||
}
|
|
||||||
if (transaction.type === 'HABIT_UNDO') {
|
|
||||||
return sum - Math.abs(transaction.amount);
|
|
||||||
}
|
|
||||||
return sum;
|
|
||||||
}, 0);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Derived atom for total earned
|
// Derived atom for total earned
|
||||||
export const totalEarnedAtom = atom((get) => {
|
export const totalEarnedAtom = atom((get) => {
|
||||||
const coins = get(coinsAtom);
|
const coins = get(coinsAtom);
|
||||||
return coins.transactions
|
return calculateTotalEarned(coins.transactions);
|
||||||
.filter(t => {
|
|
||||||
if (t.type === 'HABIT_COMPLETION' && t.relatedItemId) {
|
|
||||||
return !coins.transactions.some(undoT =>
|
|
||||||
undoT.type === 'HABIT_UNDO' &&
|
|
||||||
undoT.relatedItemId === t.relatedItemId
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return t.amount > 0 && t.type !== 'HABIT_UNDO';
|
|
||||||
})
|
|
||||||
.reduce((sum, t) => sum + t.amount, 0);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Derived atom for total spent
|
// Derived atom for total spent
|
||||||
export const totalSpentAtom = atom((get) => {
|
export const totalSpentAtom = atom((get) => {
|
||||||
const coins = get(coinsAtom);
|
const coins = get(coinsAtom);
|
||||||
return Math.abs(
|
return calculateTotalSpent(coins.transactions);
|
||||||
coins.transactions
|
|
||||||
.filter(t => t.type === 'WISH_REDEMPTION' || t.type === 'MANUAL_ADJUSTMENT')
|
|
||||||
.reduce((sum, t) => sum + (t.amount < 0 ? t.amount : 0), 0)
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Derived atom for coins spent today
|
// Derived atom for coins spent today
|
||||||
export const coinsSpentTodayAtom = atom((get) => {
|
export const coinsSpentTodayAtom = atom((get) => {
|
||||||
const coins = get(coinsAtom);
|
const coins = get(coinsAtom);
|
||||||
const settings = get(settingsAtom);
|
const settings = get(settingsAtom);
|
||||||
const today = getTodayInTimezone(settings.system.timezone);
|
return calculateCoinsSpentToday(coins.transactions, settings.system.timezone);
|
||||||
return Math.abs(
|
|
||||||
coins.transactions
|
|
||||||
.filter(t =>
|
|
||||||
isSameDate(t2d({ timestamp: t.timestamp, timezone: settings.system.timezone }),
|
|
||||||
t2d({ timestamp: today, timezone: settings.system.timezone })) &&
|
|
||||||
t.amount < 0
|
|
||||||
)
|
|
||||||
.reduce((sum, t) => sum + t.amount, 0)
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Derived atom for transactions today
|
// Derived atom for transactions today
|
||||||
export const transactionsTodayAtom = atom((get) => {
|
export const transactionsTodayAtom = atom((get) => {
|
||||||
const coins = get(coinsAtom);
|
const coins = get(coinsAtom);
|
||||||
const settings = get(settingsAtom);
|
const settings = get(settingsAtom);
|
||||||
const today = getTodayInTimezone(settings.system.timezone);
|
return calculateTransactionsToday(coins.transactions, settings.system.timezone);
|
||||||
return coins.transactions.filter(t =>
|
|
||||||
isSameDate(t2d({ timestamp: t.timestamp, timezone: settings.system.timezone }),
|
|
||||||
t2d({ timestamp: today, timezone: settings.system.timezone }))
|
|
||||||
).length;
|
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ export interface CoinTransaction {
|
|||||||
description: string;
|
description: string;
|
||||||
timestamp: string;
|
timestamp: string;
|
||||||
relatedItemId?: string;
|
relatedItemId?: string;
|
||||||
|
note?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface HabitsData {
|
export interface HabitsData {
|
||||||
|
|||||||
@@ -1,5 +1,21 @@
|
|||||||
import { expect, test, describe, beforeAll, afterAll } from "bun:test";
|
import { expect, test, describe, beforeAll, afterAll } from "bun:test";
|
||||||
import { cn, getTodayInTimezone, getNow, getNowInMilliseconds, t2d, d2t, d2s, d2sDate, d2n, isSameDate } from './utils'
|
import {
|
||||||
|
cn,
|
||||||
|
getTodayInTimezone,
|
||||||
|
getNow,
|
||||||
|
getNowInMilliseconds,
|
||||||
|
t2d,
|
||||||
|
d2t,
|
||||||
|
d2s,
|
||||||
|
d2sDate,
|
||||||
|
d2n,
|
||||||
|
isSameDate,
|
||||||
|
calculateCoinsEarnedToday,
|
||||||
|
calculateTotalEarned,
|
||||||
|
calculateTotalSpent,
|
||||||
|
calculateCoinsSpentToday,
|
||||||
|
} from './utils'
|
||||||
|
import { CoinTransaction } from './types'
|
||||||
import { DateTime } from "luxon";
|
import { DateTime } from "luxon";
|
||||||
|
|
||||||
describe('cn utility', () => {
|
describe('cn utility', () => {
|
||||||
@@ -93,4 +109,66 @@ describe('datetime utilities', () => {
|
|||||||
expect(isSameDate(date1, date3)).toBe(false)
|
expect(isSameDate(date1, date3)).toBe(false)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
describe('transaction calculations', () => {
|
||||||
|
const testTransactions: CoinTransaction[] = [
|
||||||
|
{
|
||||||
|
id: '1',
|
||||||
|
amount: 10,
|
||||||
|
type: 'HABIT_COMPLETION',
|
||||||
|
description: 'Test habit',
|
||||||
|
timestamp: '2024-01-01T12:00:00Z'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '2',
|
||||||
|
amount: -5,
|
||||||
|
type: 'HABIT_UNDO',
|
||||||
|
description: 'Undo test habit',
|
||||||
|
timestamp: '2024-01-01T13:00:00Z',
|
||||||
|
relatedItemId: '1'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '3',
|
||||||
|
amount: 20,
|
||||||
|
type: 'HABIT_COMPLETION',
|
||||||
|
description: 'Another habit',
|
||||||
|
timestamp: '2024-01-01T14:00:00Z'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '4',
|
||||||
|
amount: -15,
|
||||||
|
type: 'WISH_REDEMPTION',
|
||||||
|
description: 'Redeemed wish',
|
||||||
|
timestamp: '2024-01-01T15:00:00Z'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '5',
|
||||||
|
amount: 5,
|
||||||
|
type: 'HABIT_COMPLETION',
|
||||||
|
description: 'Yesterday habit',
|
||||||
|
timestamp: '2023-12-31T23:00:00Z'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
test('calculateCoinsEarnedToday should calculate today\'s earnings including undos', () => {
|
||||||
|
const result = calculateCoinsEarnedToday(testTransactions, 'UTC')
|
||||||
|
expect(result).toBe(25) // 10 + 20 - 5 (including the -5 undo)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('calculateTotalEarned should calculate lifetime earnings including undos', () => {
|
||||||
|
const result = calculateTotalEarned(testTransactions)
|
||||||
|
expect(result).toBe(30) // 10 + 20 + 5 - 5 (including the -5 undo)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('calculateTotalSpent should calculate total spent excluding undos', () => {
|
||||||
|
const result = calculateTotalSpent(testTransactions)
|
||||||
|
expect(result).toBe(15) // Only the 15 wish redemption (excluding the 5 undo)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('calculateCoinsSpentToday should calculate today\'s spending excluding undos', () => {
|
||||||
|
const result = calculateCoinsSpentToday(testTransactions, 'UTC')
|
||||||
|
expect(result).toBe(15) // Only the 15 wish redemption (excluding the 5 undo)
|
||||||
|
})
|
||||||
|
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
54
lib/utils.ts
54
lib/utils.ts
@@ -1,7 +1,7 @@
|
|||||||
import { clsx, type ClassValue } from "clsx"
|
import { clsx, type ClassValue } from "clsx"
|
||||||
import { twMerge } from "tailwind-merge"
|
import { twMerge } from "tailwind-merge"
|
||||||
import { DateTime } from "luxon"
|
import { DateTime } from "luxon"
|
||||||
import { Habit } from '@/lib/types'
|
import { Habit, CoinTransaction } from '@/lib/types'
|
||||||
|
|
||||||
export function cn(...inputs: ClassValue[]) {
|
export function cn(...inputs: ClassValue[]) {
|
||||||
return twMerge(clsx(inputs))
|
return twMerge(clsx(inputs))
|
||||||
@@ -121,3 +121,55 @@ export function getHabitProgress({
|
|||||||
const target = habit.targetCompletions || 1
|
const target = habit.targetCompletions || 1
|
||||||
return Math.min(100, (completionsToday / target) * 100)
|
return Math.min(100, (completionsToday / target) * 100)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function calculateCoinsEarnedToday(transactions: CoinTransaction[], timezone: string): number {
|
||||||
|
const today = getTodayInTimezone(timezone);
|
||||||
|
return transactions
|
||||||
|
.filter(transaction =>
|
||||||
|
isSameDate(t2d({ timestamp: transaction.timestamp, timezone }),
|
||||||
|
t2d({ timestamp: today, timezone })) &&
|
||||||
|
(transaction.amount > 0 || transaction.type === 'HABIT_UNDO')
|
||||||
|
)
|
||||||
|
.reduce((sum, transaction) => sum + transaction.amount, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function calculateTotalEarned(transactions: CoinTransaction[]): number {
|
||||||
|
return transactions
|
||||||
|
.filter(transaction =>
|
||||||
|
transaction.amount > 0 || transaction.type === 'HABIT_UNDO'
|
||||||
|
)
|
||||||
|
.reduce((sum, transaction) => sum + transaction.amount, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function calculateTotalSpent(transactions: CoinTransaction[]): number {
|
||||||
|
return Math.abs(
|
||||||
|
transactions
|
||||||
|
.filter(transaction =>
|
||||||
|
transaction.amount < 0 &&
|
||||||
|
transaction.type !== 'HABIT_UNDO'
|
||||||
|
)
|
||||||
|
.reduce((sum, transaction) => sum + transaction.amount, 0)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function calculateCoinsSpentToday(transactions: CoinTransaction[], timezone: string): number {
|
||||||
|
const today = getTodayInTimezone(timezone);
|
||||||
|
return Math.abs(
|
||||||
|
transactions
|
||||||
|
.filter(transaction =>
|
||||||
|
isSameDate(t2d({ timestamp: transaction.timestamp, timezone }),
|
||||||
|
t2d({ timestamp: today, timezone })) &&
|
||||||
|
transaction.amount < 0 &&
|
||||||
|
transaction.type !== 'HABIT_UNDO'
|
||||||
|
)
|
||||||
|
.reduce((sum, transaction) => sum + transaction.amount, 0)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function calculateTransactionsToday(transactions: CoinTransaction[], timezone: string): number {
|
||||||
|
const today = getTodayInTimezone(timezone);
|
||||||
|
return transactions.filter(t =>
|
||||||
|
isSameDate(t2d({ timestamp: t.timestamp, timezone }),
|
||||||
|
t2d({ timestamp: today, timezone }))
|
||||||
|
).length;
|
||||||
|
}
|
||||||
|
|||||||
4
package-lock.json
generated
4
package-lock.json
generated
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "habittrove",
|
"name": "habittrove",
|
||||||
"version": "0.1.12",
|
"version": "0.1.16",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "habittrove",
|
"name": "habittrove",
|
||||||
"version": "0.1.12",
|
"version": "0.1.16",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@emoji-mart/data": "^1.2.1",
|
"@emoji-mart/data": "^1.2.1",
|
||||||
"@emoji-mart/react": "^1.1.1",
|
"@emoji-mart/react": "^1.1.1",
|
||||||
|
|||||||
Reference in New Issue
Block a user