mirror of
https://github.com/ManInDark/HabitTrove.git
synced 2026-01-21 06:34:30 +01:00
fix coin stats and add transaction note (#31)
This commit is contained in:
62
lib/atoms.ts
62
lib/atoms.ts
@@ -5,7 +5,16 @@ import {
|
||||
getDefaultCoinsData,
|
||||
getDefaultWishlistData
|
||||
} 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 habitsAtom = atom(getDefaultHabitsData());
|
||||
@@ -16,72 +25,31 @@ export const wishlistAtom = atom(getDefaultWishlistData());
|
||||
export const coinsEarnedTodayAtom = atom((get) => {
|
||||
const coins = get(coinsAtom);
|
||||
const settings = get(settingsAtom);
|
||||
const today = getTodayInTimezone(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);
|
||||
return calculateCoinsEarnedToday(coins.transactions, settings.system.timezone);
|
||||
});
|
||||
|
||||
// Derived atom for total earned
|
||||
export const totalEarnedAtom = atom((get) => {
|
||||
const coins = get(coinsAtom);
|
||||
return 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);
|
||||
return calculateTotalEarned(coins.transactions);
|
||||
});
|
||||
|
||||
// Derived atom for total spent
|
||||
export const totalSpentAtom = atom((get) => {
|
||||
const coins = get(coinsAtom);
|
||||
return Math.abs(
|
||||
coins.transactions
|
||||
.filter(t => t.type === 'WISH_REDEMPTION' || t.type === 'MANUAL_ADJUSTMENT')
|
||||
.reduce((sum, t) => sum + (t.amount < 0 ? t.amount : 0), 0)
|
||||
);
|
||||
return calculateTotalSpent(coins.transactions);
|
||||
});
|
||||
|
||||
// Derived atom for coins spent today
|
||||
export const coinsSpentTodayAtom = atom((get) => {
|
||||
const coins = get(coinsAtom);
|
||||
const settings = get(settingsAtom);
|
||||
const today = getTodayInTimezone(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)
|
||||
);
|
||||
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);
|
||||
const today = getTodayInTimezone(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;
|
||||
return calculateTransactionsToday(coins.transactions, settings.system.timezone);
|
||||
});
|
||||
|
||||
@@ -24,6 +24,7 @@ export interface CoinTransaction {
|
||||
description: string;
|
||||
timestamp: string;
|
||||
relatedItemId?: string;
|
||||
note?: string;
|
||||
}
|
||||
|
||||
export interface HabitsData {
|
||||
|
||||
@@ -1,5 +1,21 @@
|
||||
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";
|
||||
|
||||
describe('cn utility', () => {
|
||||
@@ -93,4 +109,66 @@ describe('datetime utilities', () => {
|
||||
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)
|
||||
})
|
||||
|
||||
})
|
||||
})
|
||||
|
||||
96
lib/utils.ts
96
lib/utils.ts
@@ -1,7 +1,7 @@
|
||||
import { clsx, type ClassValue } from "clsx"
|
||||
import { twMerge } from "tailwind-merge"
|
||||
import { DateTime } from "luxon"
|
||||
import { Habit } from '@/lib/types'
|
||||
import { Habit, CoinTransaction } from '@/lib/types'
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs))
|
||||
@@ -66,17 +66,17 @@ export function normalizeCompletionDate(date: string, timezone: string): string
|
||||
return DateTime.fromFormat(date, 'yyyy-MM-dd', { zone: timezone }).toUTC().toISO()!;
|
||||
}
|
||||
|
||||
export function getCompletionsForDate({
|
||||
habit,
|
||||
date,
|
||||
timezone
|
||||
}: {
|
||||
habit: Habit,
|
||||
date: DateTime | string,
|
||||
timezone: string
|
||||
export function getCompletionsForDate({
|
||||
habit,
|
||||
date,
|
||||
timezone
|
||||
}: {
|
||||
habit: Habit,
|
||||
date: DateTime | string,
|
||||
timezone: string
|
||||
}): number {
|
||||
const dateObj = typeof date === 'string' ? DateTime.fromISO(date) : date
|
||||
return habit.completions.filter((completion: string) =>
|
||||
return habit.completions.filter((completion: string) =>
|
||||
isSameDate(t2d({ timestamp: completion, timezone }), dateObj)
|
||||
).length
|
||||
}
|
||||
@@ -97,27 +97,79 @@ export function getCompletedHabitsForDate({
|
||||
})
|
||||
}
|
||||
|
||||
export function isHabitCompletedToday({
|
||||
habit,
|
||||
timezone
|
||||
}: {
|
||||
habit: Habit,
|
||||
timezone: string
|
||||
export function isHabitCompletedToday({
|
||||
habit,
|
||||
timezone
|
||||
}: {
|
||||
habit: Habit,
|
||||
timezone: string
|
||||
}): boolean {
|
||||
const today = getTodayInTimezone(timezone)
|
||||
const completionsToday = getCompletionsForDate({ habit, date: today, timezone })
|
||||
return completionsToday >= (habit.targetCompletions || 1)
|
||||
}
|
||||
|
||||
export function getHabitProgress({
|
||||
habit,
|
||||
timezone
|
||||
}: {
|
||||
habit: Habit,
|
||||
timezone: string
|
||||
export function getHabitProgress({
|
||||
habit,
|
||||
timezone
|
||||
}: {
|
||||
habit: Habit,
|
||||
timezone: string
|
||||
}): number {
|
||||
const today = getTodayInTimezone(timezone)
|
||||
const completionsToday = getCompletionsForDate({ habit, date: today, timezone })
|
||||
const target = habit.targetCompletions || 1
|
||||
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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user