update PWA icon, fix floating number balance (#159)

This commit is contained in:
Doh
2025-06-04 18:40:48 -04:00
committed by GitHub
parent 98b5d5eebb
commit 8d2bfaf62c
12 changed files with 100 additions and 32 deletions

2
.gitignore vendored
View File

@@ -46,3 +46,5 @@ next-env.d.ts
Budfile Budfile
certificates certificates
/backups/* /backups/*
CHANGELOG.md.tmp

View File

@@ -1,5 +1,17 @@
# Changelog # 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 ## Version 0.2.22
### Added ### Added

View File

@@ -130,7 +130,7 @@ async function saveData<T>(type: DataType, data: T): Promise<void> {
* Calculates the server's global freshness token based on all core data files. * Calculates the server's global freshness token based on all core data files.
* This is an expensive operation as it reads all data files. * This is an expensive operation as it reads all data files.
*/ */
async function calculateServerFreshnessToken(): Promise<string> { async function calculateServerFreshnessToken(): Promise<string | null> {
try { try {
const settings = await loadSettings(); const settings = await loadSettings();
const habits = await loadHabitsData(); const habits = await loadHabitsData();

View File

@@ -1,9 +1,9 @@
import { Sparkles } from "lucide-react" import Image from "next/image"
export function Logo() { export function Logo() {
return ( return (
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
{/* <Sparkles className="h-6 w-6 text-primary" /> */} <Image src="/icons/icon.png" alt="HabitTrove Logo" width={96} height={96} className="h-12 w-12 hidden xs:inline" />
<span className="font-bold text-xl">HabitTrove</span> <span className="font-bold text-xl">HabitTrove</span>
</div> </div>
) )

View File

@@ -1,7 +1,7 @@
import { useAtom } from 'jotai'; import { useAtom } from 'jotai';
import { useState, useEffect, useMemo } from 'react'; import { useState, useEffect, useMemo } from 'react';
import { useTranslations } from 'next-intl'; 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 { import {
coinsAtom, coinsAtom,
coinsEarnedTodayAtom, coinsEarnedTodayAtom,
@@ -86,12 +86,22 @@ export function useCoins(options?: { selectedUser?: string }) {
setBalance(loggedInUserBalance); setBalance(loggedInUserBalance);
} else if (targetUser?.id) { } else if (targetUser?.id) {
// If an admin is viewing another user, calculate their metrics manually // If an admin is viewing another user, calculate their metrics manually
setCoinsEarnedToday(calculateCoinsEarnedToday(transactions, timezone)); const earnedToday = calculateCoinsEarnedToday(transactions, timezone);
setTotalEarned(calculateTotalEarned(transactions)); setCoinsEarnedToday(roundToInteger(earnedToday));
setTotalSpent(calculateTotalSpent(transactions));
setCoinsSpentToday(calculateCoinsSpentToday(transactions, timezone)); const totalEarnedVal = calculateTotalEarned(transactions);
setTransactionsToday(calculateTransactionsToday(transactions, timezone)); setTotalEarned(roundToInteger(totalEarnedVal));
setBalance(transactions.reduce((acc, t) => acc + t.amount, 0));
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, targetUser?.id,

View File

@@ -26,7 +26,8 @@ import {
isHabitDueToday, isHabitDueToday,
getNow, getNow,
isHabitDue, isHabitDue,
getHabitFreq getHabitFreq,
roundToInteger
} from "@/lib/utils"; } from "@/lib/utils";
import { atomFamily, atomWithStorage } from "jotai/utils"; import { atomFamily, atomWithStorage } from "jotai/utils";
import { DateTime } from "luxon"; import { DateTime } from "luxon";
@@ -57,26 +58,30 @@ export const serverSettingsAtom = atom(getDefaultServerSettings());
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);
return calculateCoinsEarnedToday(coins.transactions, settings.system.timezone); const value = calculateCoinsEarnedToday(coins.transactions, settings.system.timezone);
return roundToInteger(value);
}); });
// 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 calculateTotalEarned(coins.transactions); const value = calculateTotalEarned(coins.transactions);
return roundToInteger(value);
}); });
// 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 calculateTotalSpent(coins.transactions); const value = calculateTotalSpent(coins.transactions);
return roundToInteger(value);
}); });
// 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);
return calculateCoinsSpentToday(coins.transactions, settings.system.timezone); const value = calculateCoinsSpentToday(coins.transactions, settings.system.timezone);
return roundToInteger(value);
}); });
// Derived atom for transactions today // 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 return 0; // No user logged in or ID not set, so balance is 0
} }
const coins = get(coinsAtom); const coins = get(coinsAtom);
return coins.transactions const balance = coins.transactions
.filter(transaction => transaction.userId === loggedInUserId) .filter(transaction => transaction.userId === loggedInUserId)
.reduce((sum, transaction) => sum + transaction.amount, 0); .reduce((sum, transaction) => sum + transaction.amount, 0);
return roundToInteger(balance);
}); });
/* transient atoms */ /* transient atoms */

View File

@@ -22,9 +22,10 @@ import {
serializeRRule, serializeRRule,
convertHumanReadableFrequencyToMachineReadable, convertHumanReadableFrequencyToMachineReadable,
convertMachineReadableFrequencyToHumanReadable, convertMachineReadableFrequencyToHumanReadable,
getUnsupportedRRuleReason,
prepareDataForHashing, prepareDataForHashing,
generateCryptoHash generateCryptoHash,
getUnsupportedRRuleReason,
roundToInteger
} from './utils' } from './utils'
import { CoinTransaction, ParsedResultType, Settings, HabitsData, CoinsData, WishlistData, UserData } from './types' import { CoinTransaction, ParsedResultType, Settings, HabitsData, CoinsData, WishlistData, UserData } from './types'
import { DateTime } from "luxon"; 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', () => { describe('getUnsupportedRRuleReason', () => {
test('should return message for HOURLY frequency', () => { test('should return message for HOURLY frequency', () => {
const rrule = new RRule({ freq: RRule.HOURLY }); const rrule = new RRule({ freq: RRule.HOURLY });
@@ -597,7 +625,7 @@ describe('isHabitDueToday', () => {
test('should return false for invalid recurrence rule', () => { test('should return false for invalid recurrence rule', () => {
const habit = testHabit('INVALID_RRULE') const habit = testHabit('INVALID_RRULE')
const consoleSpy = spyOn(console, 'error').mockImplementation(() => {}) const consoleSpy = spyOn(console, 'error').mockImplementation(() => { })
expect(isHabitDueToday({ habit, timezone: 'UTC' })).toBe(false) expect(isHabitDueToday({ habit, timezone: 'UTC' })).toBe(false)
}) })
}) })
@@ -710,7 +738,7 @@ describe('isHabitDue', () => {
test('should return false for invalid recurrence rule', () => { test('should return false for invalid recurrence rule', () => {
const habit = testHabit('INVALID_RRULE') const habit = testHabit('INVALID_RRULE')
const date = DateTime.fromISO('2024-01-01T00:00:00Z') 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) expect(isHabitDue({ habit, timezone: 'UTC', date })).toBe(false)
}) })
}) })

View File

@@ -19,6 +19,11 @@ export function getTodayInTimezone(timezone: string): string {
return getISODate({ dateTime: now, timezone }); 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 { export function getISODate({ dateTime, timezone }: { dateTime: DateTime, timezone: string }): string {
return dateTime.setZone(timezone).toISODate()!; return dateTime.setZone(timezone).toISODate()!;
} }
@@ -518,14 +523,19 @@ export function prepareDataForHashing(
* @param dataString The string to hash. * @param dataString The string to hash.
* @returns A promise that resolves to the hex string of the hash. * @returns A promise that resolves to the hex string of the hash.
*/ */
export async function generateCryptoHash(dataString: string): Promise<string> { export async function generateCryptoHash(dataString: string): Promise<string | null> {
const encoder = new TextEncoder(); try {
const data = encoder.encode(dataString); const encoder = new TextEncoder();
// globalThis.crypto should be available in modern browsers and Node.js (v19+) const data = encoder.encode(dataString);
// For Node.js v15-v18, you might need: const { subtle } = require('node:crypto').webcrypto; // globalThis.crypto should be available in modern browsers and Node.js (v19+)
const hashBuffer = await globalThis.crypto.subtle.digest('SHA-256', data); // For Node.js v15-v18, you might need: const { subtle } = require('node:crypto').webcrypto;
const hashArray = Array.from(new Uint8Array(hashBuffer)); const hashBuffer = await globalThis.crypto.subtle.digest('SHA-256', data);
// Convert buffer to hex string const hashArray = Array.from(new Uint8Array(hashBuffer));
const hashHex = hashArray.map(b => b.toString(16).padStart(2, '0')).join(''); // Convert buffer to hex string
return hashHex; 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;
}
} }

View File

@@ -1,6 +1,6 @@
{ {
"name": "habittrove", "name": "habittrove",
"version": "0.2.22", "version": "0.2.23",
"private": true, "private": true,
"scripts": { "scripts": {
"dev": "next dev --turbopack", "dev": "next dev --turbopack",

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.4 KiB

After

Width:  |  Height:  |  Size: 8.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 28 KiB

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 130 KiB

After

Width:  |  Height:  |  Size: 101 KiB