diff --git a/.gitignore b/.gitignore index 91afeb3..ec60210 100644 --- a/.gitignore +++ b/.gitignore @@ -46,3 +46,5 @@ next-env.d.ts Budfile certificates /backups/* + +CHANGELOG.md.tmp diff --git a/CHANGELOG.md b/CHANGELOG.md index ce38742..4ca152f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,17 @@ # Changelog +## Version 0.2.23 + +### Fixed + +* floating number coin balance (#155) +* disable freshness check if browser does not support web crypto (#161) + +### Improved + +* use transparent background PWA icon with correct text (#103) +* display icon in logo + ## Version 0.2.22 ### Added diff --git a/app/actions/data.ts b/app/actions/data.ts index ca39529..cd438f1 100644 --- a/app/actions/data.ts +++ b/app/actions/data.ts @@ -130,7 +130,7 @@ async function saveData(type: DataType, data: T): Promise { * Calculates the server's global freshness token based on all core data files. * This is an expensive operation as it reads all data files. */ -async function calculateServerFreshnessToken(): Promise { +async function calculateServerFreshnessToken(): Promise { try { const settings = await loadSettings(); const habits = await loadHabitsData(); diff --git a/components/Logo.tsx b/components/Logo.tsx index 0beafe4..87464d1 100644 --- a/components/Logo.tsx +++ b/components/Logo.tsx @@ -1,9 +1,9 @@ -import { Sparkles } from "lucide-react" +import Image from "next/image" export function Logo() { return (
- {/* */} + HabitTrove Logo HabitTrove
) diff --git a/hooks/useCoins.tsx b/hooks/useCoins.tsx index bbd507e..168e753 100644 --- a/hooks/useCoins.tsx +++ b/hooks/useCoins.tsx @@ -1,7 +1,7 @@ import { useAtom } from 'jotai'; import { useState, useEffect, useMemo } from 'react'; import { useTranslations } from 'next-intl'; -import { calculateCoinsEarnedToday, calculateCoinsSpentToday, calculateTotalEarned, calculateTotalSpent, calculateTransactionsToday, checkPermission } from '@/lib/utils' +import { calculateCoinsEarnedToday, calculateCoinsSpentToday, calculateTotalEarned, calculateTotalSpent, calculateTransactionsToday, checkPermission, roundToInteger } from '@/lib/utils' import { coinsAtom, coinsEarnedTodayAtom, @@ -86,12 +86,22 @@ export function useCoins(options?: { selectedUser?: string }) { setBalance(loggedInUserBalance); } else if (targetUser?.id) { // If an admin is viewing another user, calculate their metrics manually - setCoinsEarnedToday(calculateCoinsEarnedToday(transactions, timezone)); - setTotalEarned(calculateTotalEarned(transactions)); - setTotalSpent(calculateTotalSpent(transactions)); - setCoinsSpentToday(calculateCoinsSpentToday(transactions, timezone)); - setTransactionsToday(calculateTransactionsToday(transactions, timezone)); - setBalance(transactions.reduce((acc, t) => acc + t.amount, 0)); + const earnedToday = calculateCoinsEarnedToday(transactions, timezone); + setCoinsEarnedToday(roundToInteger(earnedToday)); + + const totalEarnedVal = calculateTotalEarned(transactions); + setTotalEarned(roundToInteger(totalEarnedVal)); + + const totalSpentVal = calculateTotalSpent(transactions); + setTotalSpent(roundToInteger(totalSpentVal)); + + const spentToday = calculateCoinsSpentToday(transactions, timezone); + setCoinsSpentToday(roundToInteger(spentToday)); + + setTransactionsToday(calculateTransactionsToday(transactions, timezone)); // This is a count + + const calculatedBalance = transactions.reduce((acc, t) => acc + t.amount, 0); + setBalance(roundToInteger(calculatedBalance)); } }, [ targetUser?.id, diff --git a/lib/atoms.ts b/lib/atoms.ts index e47f7df..fa54111 100644 --- a/lib/atoms.ts +++ b/lib/atoms.ts @@ -26,7 +26,8 @@ import { isHabitDueToday, getNow, isHabitDue, - getHabitFreq + getHabitFreq, + roundToInteger } from "@/lib/utils"; import { atomFamily, atomWithStorage } from "jotai/utils"; import { DateTime } from "luxon"; @@ -57,26 +58,30 @@ export const serverSettingsAtom = atom(getDefaultServerSettings()); export const coinsEarnedTodayAtom = atom((get) => { const coins = get(coinsAtom); const settings = get(settingsAtom); - return calculateCoinsEarnedToday(coins.transactions, settings.system.timezone); + const value = calculateCoinsEarnedToday(coins.transactions, settings.system.timezone); + return roundToInteger(value); }); // Derived atom for total earned export const totalEarnedAtom = atom((get) => { const coins = get(coinsAtom); - return calculateTotalEarned(coins.transactions); + const value = calculateTotalEarned(coins.transactions); + return roundToInteger(value); }); // Derived atom for total spent export const totalSpentAtom = atom((get) => { const coins = get(coinsAtom); - return calculateTotalSpent(coins.transactions); + const value = calculateTotalSpent(coins.transactions); + return roundToInteger(value); }); // 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); + const value = calculateCoinsSpentToday(coins.transactions, settings.system.timezone); + return roundToInteger(value); }); // Derived atom for transactions today @@ -103,9 +108,10 @@ export const coinsBalanceAtom = atom((get) => { return 0; // No user logged in or ID not set, so balance is 0 } const coins = get(coinsAtom); - return coins.transactions + const balance = coins.transactions .filter(transaction => transaction.userId === loggedInUserId) .reduce((sum, transaction) => sum + transaction.amount, 0); + return roundToInteger(balance); }); /* transient atoms */ diff --git a/lib/utils.test.ts b/lib/utils.test.ts index 0eb8426..3e68d8a 100644 --- a/lib/utils.test.ts +++ b/lib/utils.test.ts @@ -22,9 +22,10 @@ import { serializeRRule, convertHumanReadableFrequencyToMachineReadable, convertMachineReadableFrequencyToHumanReadable, - getUnsupportedRRuleReason, prepareDataForHashing, - generateCryptoHash + generateCryptoHash, + getUnsupportedRRuleReason, + roundToInteger } from './utils' import { CoinTransaction, ParsedResultType, Settings, HabitsData, CoinsData, WishlistData, UserData } from './types' import { DateTime } from "luxon"; @@ -42,6 +43,33 @@ describe('cn utility', () => { }) }) +describe('roundToInteger', () => { + test('should round positive numbers correctly', () => { + expect(roundToInteger(10.123)).toBe(10); + expect(roundToInteger(10.5)).toBe(11); + expect(roundToInteger(10.75)).toBe(11); + expect(roundToInteger(10.49)).toBe(10); + }); + + test('should round negative numbers correctly', () => { + expect(roundToInteger(-10.123)).toBe(-10); + expect(roundToInteger(-10.5)).toBe(-10); // Math.round rounds -x.5 to -(x-1) e.g. -10.5 to -10 + expect(roundToInteger(-10.75)).toBe(-11); + expect(roundToInteger(-10.49)).toBe(-10); + }); + + test('should handle zero correctly', () => { + expect(roundToInteger(0)).toBe(0); + expect(roundToInteger(0.0)).toBe(0); + expect(roundToInteger(-0.0)).toBe(-0); + }); + + test('should handle integers correctly', () => { + expect(roundToInteger(15)).toBe(15); + expect(roundToInteger(-15)).toBe(-15); + }); +}); + describe('getUnsupportedRRuleReason', () => { test('should return message for HOURLY frequency', () => { const rrule = new RRule({ freq: RRule.HOURLY }); @@ -142,7 +170,7 @@ describe('isTaskOverdue', () => { // 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) @@ -597,7 +625,7 @@ describe('isHabitDueToday', () => { test('should return false for invalid recurrence rule', () => { const habit = testHabit('INVALID_RRULE') - const consoleSpy = spyOn(console, 'error').mockImplementation(() => {}) + const consoleSpy = spyOn(console, 'error').mockImplementation(() => { }) expect(isHabitDueToday({ habit, timezone: 'UTC' })).toBe(false) }) }) @@ -710,7 +738,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(() => {}) + const consoleSpy = spyOn(console, 'error').mockImplementation(() => { }) expect(isHabitDue({ habit, timezone: 'UTC', date })).toBe(false) }) }) diff --git a/lib/utils.ts b/lib/utils.ts index 7b3f327..d9aede3 100644 --- a/lib/utils.ts +++ b/lib/utils.ts @@ -19,6 +19,11 @@ export function getTodayInTimezone(timezone: string): string { return getISODate({ dateTime: now, timezone }); } +// round a number to the nearest integer +export function roundToInteger(value: number): number { + return Math.round(value); +} + export function getISODate({ dateTime, timezone }: { dateTime: DateTime, timezone: string }): string { return dateTime.setZone(timezone).toISODate()!; } @@ -518,14 +523,19 @@ export function prepareDataForHashing( * @param dataString The string to hash. * @returns A promise that resolves to the hex string of the hash. */ -export async function generateCryptoHash(dataString: string): Promise { - const encoder = new TextEncoder(); - const data = encoder.encode(dataString); - // globalThis.crypto should be available in modern browsers and Node.js (v19+) - // For Node.js v15-v18, you might need: const { subtle } = require('node:crypto').webcrypto; - const hashBuffer = await globalThis.crypto.subtle.digest('SHA-256', data); - const hashArray = Array.from(new Uint8Array(hashBuffer)); - // Convert buffer to hex string - const hashHex = hashArray.map(b => b.toString(16).padStart(2, '0')).join(''); - return hashHex; +export async function generateCryptoHash(dataString: string): Promise { + try { + const encoder = new TextEncoder(); + const data = encoder.encode(dataString); + // globalThis.crypto should be available in modern browsers and Node.js (v19+) + // For Node.js v15-v18, you might need: const { subtle } = require('node:crypto').webcrypto; + const hashBuffer = await globalThis.crypto.subtle.digest('SHA-256', data); + const hashArray = Array.from(new Uint8Array(hashBuffer)); + // Convert buffer to hex string + const hashHex = hashArray.map(b => b.toString(16).padStart(2, '0')).join(''); + return hashHex; + } catch (error) { + console.error(`Failed to generate hash: ${error}`); + return null; + } } diff --git a/package.json b/package.json index 3a46669..276df73 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "habittrove", - "version": "0.2.22", + "version": "0.2.23", "private": true, "scripts": { "dev": "next dev --turbopack", diff --git a/public/icons/icon.png b/public/icons/icon.png index 0a09cf0..e5e9dd2 100644 Binary files a/public/icons/icon.png and b/public/icons/icon.png differ diff --git a/public/icons/web-app-manifest-192x192.png b/public/icons/web-app-manifest-192x192.png index 3c2e67c..d5a6a2c 100644 Binary files a/public/icons/web-app-manifest-192x192.png and b/public/icons/web-app-manifest-192x192.png differ diff --git a/public/icons/web-app-manifest-512x512.png b/public/icons/web-app-manifest-512x512.png index 0c519f5..d295a17 100644 Binary files a/public/icons/web-app-manifest-512x512.png and b/public/icons/web-app-manifest-512x512.png differ