mirror of
https://github.com/ManInDark/HabitTrove.git
synced 2026-01-21 06:34:30 +01:00
Merge Tag v0.2.23
This commit is contained in:
2
.gitignore
vendored
2
.gitignore
vendored
@@ -46,3 +46,5 @@ next-env.d.ts
|
|||||||
Budfile
|
Budfile
|
||||||
certificates
|
certificates
|
||||||
/backups/*
|
/backups/*
|
||||||
|
|
||||||
|
CHANGELOG.md.tmp
|
||||||
|
|||||||
12
CHANGELOG.md
12
CHANGELOG.md
@@ -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
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
# HabitTrove
|
# <img align="left" width="50" height="50" src="https://github.com/user-attachments/assets/99dcf223-3680-4b3a-8050-d9788f051682" /> HabitTrove
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
|
|||||||
@@ -124,7 +124,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();
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
|
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>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
18
lib/atoms.ts
18
lib/atoms.ts
@@ -8,6 +8,7 @@ import {
|
|||||||
getHabitFreq,
|
getHabitFreq,
|
||||||
getTodayInTimezone,
|
getTodayInTimezone,
|
||||||
isHabitDue,
|
isHabitDue,
|
||||||
|
roundToInteger,
|
||||||
t2d
|
t2d
|
||||||
} from "@/lib/utils";
|
} from "@/lib/utils";
|
||||||
import { atom } from "jotai";
|
import { atom } from "jotai";
|
||||||
@@ -49,26 +50,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
|
||||||
@@ -95,9 +100,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 */
|
||||||
@@ -115,7 +121,7 @@ export const pomodoroAtom = atom<PomodoroAtom>({
|
|||||||
minimized: false,
|
minimized: false,
|
||||||
})
|
})
|
||||||
|
|
||||||
import { prepareDataForHashing, generateCryptoHash } from '@/lib/utils';
|
import { generateCryptoHash, prepareDataForHashing } from '@/lib/utils';
|
||||||
|
|
||||||
export const userSelectAtom = atom<boolean>(false)
|
export const userSelectAtom = atom<boolean>(false)
|
||||||
export const aboutOpenAtom = atom<boolean>(false)
|
export const aboutOpenAtom = atom<boolean>(false)
|
||||||
|
|||||||
@@ -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)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
12
lib/utils.ts
12
lib/utils.ts
@@ -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,7 +523,8 @@ 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> {
|
||||||
|
try {
|
||||||
const encoder = new TextEncoder();
|
const encoder = new TextEncoder();
|
||||||
const data = encoder.encode(dataString);
|
const data = encoder.encode(dataString);
|
||||||
// globalThis.crypto should be available in modern browsers and Node.js (v19+)
|
// globalThis.crypto should be available in modern browsers and Node.js (v19+)
|
||||||
@@ -528,4 +534,8 @@ export async function generateCryptoHash(dataString: string): Promise<string> {
|
|||||||
// Convert buffer to hex string
|
// Convert buffer to hex string
|
||||||
const hashHex = hashArray.map(b => b.toString(16).padStart(2, '0')).join('');
|
const hashHex = hashArray.map(b => b.toString(16).padStart(2, '0')).join('');
|
||||||
return hashHex;
|
return hashHex;
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Failed to generate hash: ${error}`);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 |
Reference in New Issue
Block a user