From 276e8a8a7be575420e3b0225f88a475d7f9b8eea Mon Sep 17 00:00:00 2001 From: Doh Date: Fri, 30 May 2025 18:04:03 -0400 Subject: [PATCH] refresh stale data (#156) --- CHANGELOG.md | 7 +++ app/actions/data.ts | 51 ++++++++++++++++ components/ClientWrapper.tsx | 87 ++++++++++++++++++++------- components/Profile.tsx | 2 +- components/RefreshBanner.tsx | 27 +++++++++ components/jotai-hydrate.tsx | 2 +- lib/atoms.ts | 18 ++++++ lib/server-helpers.ts | 2 +- lib/utils.test.ts | 100 ++++++++++++++++++++++++++++++- lib/utils.ts | 50 +++++++++++++++- package-lock.json | 112 +++++++++++++++++++++++------------ package.json | 4 +- 12 files changed, 396 insertions(+), 66 deletions(-) create mode 100644 components/RefreshBanner.tsx diff --git a/CHANGELOG.md b/CHANGELOG.md index 79318ab..ce38742 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,12 @@ # Changelog +## Version 0.2.22 + +### Added + +* auto check data freshness on interval (#138) +* warn about out-of-sync data + ## Version 0.2.21 ### Fixed diff --git a/app/actions/data.ts b/app/actions/data.ts index f4f160e..ca39529 100644 --- a/app/actions/data.ts +++ b/app/actions/data.ts @@ -29,6 +29,9 @@ import { signInSchema } from '@/lib/zod'; import { auth } from '@/auth'; import _ from 'lodash'; import { getCurrentUser, getCurrentUserId } from '@/lib/server-helpers' +import stableStringify from 'json-stable-stringify'; +import { prepareDataForHashing, generateCryptoHash } from '@/lib/utils'; + import { PermissionError } from '@/lib/exceptions' @@ -123,6 +126,33 @@ 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 { + try { + const settings = await loadSettings(); + const habits = await loadHabitsData(); + const coins = await loadCoinsData(); + const wishlist = await loadWishlistData(); + const users = await loadUsersData(); + + const dataString = prepareDataForHashing( + settings, + habits, + coins, + wishlist, + users + ); + const serverToken = await generateCryptoHash(dataString); + return serverToken; + } catch (error) { + console.error("Error calculating server freshness token:", error); + throw error; + } +} + // Wishlist specific functions export async function loadWishlistData(): Promise { const user = await getCurrentUser() @@ -595,3 +625,24 @@ export async function loadServerSettings(): Promise { isDemo: !!process.env.DEMO, } } + +/** + * Checks if the client's data is fresh by comparing its token with the server's token. + * @param clientToken The freshness token calculated by the client. + * @returns A promise that resolves to an object { isFresh: boolean }. + */ +export async function checkDataFreshness(clientToken: string): Promise<{ isFresh: boolean }> { + try { + const serverToken = await calculateServerFreshnessToken(); + const isFresh = clientToken === serverToken; + if (!isFresh) { + console.log(`Data freshness check: Stale. Client token: ${clientToken}, Server token: ${serverToken}`); + } + return { isFresh }; + } catch (error) { + console.error("Error in checkDataFreshness:", error); + // If server fails to determine its token, assume client might be stale to be safe, + // or handle error reporting differently. + return { isFresh: false }; + } +} diff --git a/components/ClientWrapper.tsx b/components/ClientWrapper.tsx index db1d1ca..ec79c9b 100644 --- a/components/ClientWrapper.tsx +++ b/components/ClientWrapper.tsx @@ -1,27 +1,29 @@ 'use client' -import { ReactNode, Suspense, useEffect, useState } from 'react' -import { useAtom, useSetAtom } from 'jotai' // Import useSetAtom -import { aboutOpenAtom, pomodoroAtom, userSelectAtom, currentUserIdAtom } from '@/lib/atoms' // Import currentUserIdAtom +import { ReactNode, useEffect, useCallback, useState, Suspense } from 'react' +import { useAtom, useSetAtom, useAtomValue } from 'jotai' +import { aboutOpenAtom, pomodoroAtom, userSelectAtom, currentUserIdAtom, clientFreshnessTokenAtom } from '@/lib/atoms' import PomodoroTimer from './PomodoroTimer' import UserSelectModal from './UserSelectModal' import { useSession } from 'next-auth/react' import AboutModal from './AboutModal' import LoadingSpinner from './LoadingSpinner' +import { checkDataFreshness as checkServerDataFreshness } from '@/app/actions/data' +import RefreshBanner from './RefreshBanner' -export default function ClientWrapper({ children }: { children: ReactNode }) { +function ClientWrapperContent({ children }: { children: ReactNode }) { const [pomo] = useAtom(pomodoroAtom) const [userSelect, setUserSelect] = useAtom(userSelectAtom) const [aboutOpen, setAboutOpen] = useAtom(aboutOpenAtom) const setCurrentUserIdAtom = useSetAtom(currentUserIdAtom) const { data: session, status } = useSession() const currentUserId = session?.user.id - const [isMounted, setIsMounted] = useState(false); + const [showRefreshBanner, setShowRefreshBanner] = useState(false); + + // clientFreshnessTokenAtom is async, useAtomValue will suspend until it's resolved. + // Suspense boundary is in app/layout.tsx or could be added here if needed more locally. + const clientToken = useAtomValue(clientFreshnessTokenAtom); - // block client-side hydration until mounted (this is crucial to wait for all jotai atoms to load), to prevent SSR hydration errors in the children components - useEffect(() => { - setIsMounted(true); - }, []); useEffect(() => { if (status === 'loading') return @@ -34,21 +36,62 @@ export default function ClientWrapper({ children }: { children: ReactNode }) { setCurrentUserIdAtom(currentUserId) }, [currentUserId, setCurrentUserIdAtom]) - if (!isMounted) { - return - } + const performFreshnessCheck = useCallback(async () => { + if (!clientToken || status !== 'authenticated') return; + + try { + const result = await checkServerDataFreshness(clientToken); + if (!result.isFresh) { + setShowRefreshBanner(true); + } + } catch (error) { + console.error("Failed to check data freshness with server:", error); + } + }, [clientToken, status]); + + useEffect(() => { + // Interval for polling data freshness + if (clientToken && !showRefreshBanner && status === 'authenticated') { + const intervalId = setInterval(() => { + performFreshnessCheck(); + }, 30000); // Check every 30 seconds + + return () => clearInterval(intervalId); + } + }, [clientToken, performFreshnessCheck, showRefreshBanner, status]); + + const handleRefresh = () => { + setShowRefreshBanner(false); + window.location.reload(); + }; + return ( <> {children} - {pomo.show && ( - - )} - {userSelect && ( - setUserSelect(false)} /> - )} - {aboutOpen && ( - setAboutOpen(false)} /> - )} + {pomo.show && } + {userSelect && setUserSelect(false)} />} + {aboutOpen && setAboutOpen(false)} />} + {showRefreshBanner && } - ) + ); +} + +export default function ClientWrapper({ children }: { children: ReactNode }) { + const [isMounted, setIsMounted] = useState(false); + + // block client-side hydration until mounted (this is crucial to wait for all jotai atoms to load), + // to prevent SSR hydration errors in the children components + useEffect(() => { + setIsMounted(true); + }, []); + + if (!isMounted) { + return ; + } + + return ( + }> + {children} + + ); } diff --git a/components/Profile.tsx b/components/Profile.tsx index 05c9ba4..3a86129 100644 --- a/components/Profile.tsx +++ b/components/Profile.tsx @@ -65,7 +65,7 @@ export function Profile() {
- + {user?.username || t('guestUsername')} {user?.isAdmin && } diff --git a/components/RefreshBanner.tsx b/components/RefreshBanner.tsx new file mode 100644 index 0000000..d683a38 --- /dev/null +++ b/components/RefreshBanner.tsx @@ -0,0 +1,27 @@ +'use client' + +import { Button } from "@/components/ui/button" +import { AlertTriangle } from "lucide-react" + +interface RefreshBannerProps { + onRefresh: () => void; +} + +export default function RefreshBanner({ onRefresh }: RefreshBannerProps) { + return ( +
+ +
+

Data out of sync

+

New data is available. Please refresh to see the latest updates.

+
+ +
+ ) +} diff --git a/components/jotai-hydrate.tsx b/components/jotai-hydrate.tsx index 64f8fe5..36445be 100644 --- a/components/jotai-hydrate.tsx +++ b/components/jotai-hydrate.tsx @@ -14,7 +14,7 @@ export function JotaiHydrate({ [coinsAtom, initialValues.coins], [wishlistAtom, initialValues.wishlist], [usersAtom, initialValues.users], - [serverSettingsAtom, initialValues.serverSettings] + [serverSettingsAtom, initialValues.serverSettings], ]) return children } diff --git a/lib/atoms.ts b/lib/atoms.ts index ff5f48b..e47f7df 100644 --- a/lib/atoms.ts +++ b/lib/atoms.ts @@ -123,9 +123,27 @@ export const pomodoroAtom = atom({ minimized: false, }) +import { prepareDataForHashing, generateCryptoHash } from '@/lib/utils'; + export const userSelectAtom = atom(false) export const aboutOpenAtom = atom(false) +/** + * Asynchronous atom that calculates a freshness token (hash) based on the current client-side data. + * This token can be compared with a server-generated token to detect data discrepancies. + */ +export const clientFreshnessTokenAtom = atom(async (get) => { + const settings = get(settingsAtom); + const habits = get(habitsAtom); + const coins = get(coinsAtom); + const wishlist = get(wishlistAtom); + const users = get(usersAtom); + + const dataString = prepareDataForHashing(settings, habits, coins, wishlist, users); + const hash = await generateCryptoHash(dataString); + return hash; +}); + // Derived atom for completion cache export const completionCacheAtom = atom((get) => { const habits = get(habitsAtom).habits; diff --git a/lib/server-helpers.ts b/lib/server-helpers.ts index 98aaad2..6fb0bb9 100644 --- a/lib/server-helpers.ts +++ b/lib/server-helpers.ts @@ -37,4 +37,4 @@ export function verifyPassword(password?: string, storedHash?: string): boolean const newHash = saltAndHashPassword(password, salt).split(':')[1] // Compare the new hash with the stored hash return newHash === hash -} \ No newline at end of file +} diff --git a/lib/utils.test.ts b/lib/utils.test.ts index 3077e3c..0eb8426 100644 --- a/lib/utils.test.ts +++ b/lib/utils.test.ts @@ -22,10 +22,13 @@ import { serializeRRule, convertHumanReadableFrequencyToMachineReadable, convertMachineReadableFrequencyToHumanReadable, - getUnsupportedRRuleReason + getUnsupportedRRuleReason, + prepareDataForHashing, + generateCryptoHash } from './utils' -import { CoinTransaction, ParsedResultType } from './types' +import { CoinTransaction, ParsedResultType, Settings, HabitsData, CoinsData, WishlistData, UserData } from './types' import { DateTime } from "luxon"; +import { getDefaultSettings, getDefaultHabitsData, getDefaultCoinsData, getDefaultWishlistData, getDefaultUsersData } from './types'; import { RRule, Weekday } from 'rrule'; import { Habit } from '@/lib/types'; import { INITIAL_DUE } from './constants'; @@ -956,3 +959,96 @@ describe('convertMachineReadableFrequencyToHumanReadable', () => { expect(humanReadable).toBe('invalid') }) }) + +describe('freshness utilities', () => { + const mockSettings: Settings = getDefaultSettings(); + const mockHabits: HabitsData = getDefaultHabitsData(); + const mockCoins: CoinsData = getDefaultCoinsData(); + const mockWishlist: WishlistData = getDefaultWishlistData(); + const mockUsers: UserData = getDefaultUsersData(); + + // Add a user to mockUsers for more realistic testing + mockUsers.users.push({ + id: 'user-123', + username: 'testuser', + isAdmin: false, + }); + mockHabits.habits.push({ + id: 'habit-123', + name: 'Test Habit', + description: 'A habit for testing', + frequency: 'FREQ=DAILY', + coinReward: 10, + completions: [], + userIds: ['user-123'] + }); + + + describe('prepareDataForHashing', () => { + test('should produce a consistent string for the same data', () => { + const data1 = { settings: mockSettings, habits: mockHabits, coins: mockCoins, wishlist: mockWishlist, users: mockUsers }; + const data2 = { settings: mockSettings, habits: mockHabits, coins: mockCoins, wishlist: mockWishlist, users: mockUsers }; // Identical data + + const string1 = prepareDataForHashing(data1.settings, data1.habits, data1.coins, data1.wishlist, data1.users); + const string2 = prepareDataForHashing(data2.settings, data2.habits, data2.coins, data2.wishlist, data2.users); + + expect(string1).toBe(string2); + }); + + test('should produce a different string if settings data changes', () => { + const string1 = prepareDataForHashing(mockSettings, mockHabits, mockCoins, mockWishlist, mockUsers); + const modifiedSettings = { ...mockSettings, system: { ...mockSettings.system, timezone: 'America/Chicago' } }; + const string2 = prepareDataForHashing(modifiedSettings, mockHabits, mockCoins, mockWishlist, mockUsers); + expect(string1).not.toBe(string2); + }); + + test('should produce a different string if habits data changes', () => { + const string1 = prepareDataForHashing(mockSettings, mockHabits, mockCoins, mockWishlist, mockUsers); + const modifiedHabits = { ...mockHabits, habits: [...mockHabits.habits, { id: 'new-habit', name: 'New', description: '', frequency: 'FREQ=DAILY', coinReward: 5, completions: [] }] }; + const string2 = prepareDataForHashing(mockSettings, modifiedHabits, mockCoins, mockWishlist, mockUsers); + expect(string1).not.toBe(string2); + }); + + test('should handle empty data consistently', () => { + const emptySettings = getDefaultSettings(); + const emptyHabits = getDefaultHabitsData(); + const emptyCoins = getDefaultCoinsData(); + const emptyWishlist = getDefaultWishlistData(); + const emptyUsers = getDefaultUsersData(); + + const string1 = prepareDataForHashing(emptySettings, emptyHabits, emptyCoins, emptyWishlist, emptyUsers); + const string2 = prepareDataForHashing(emptySettings, emptyHabits, emptyCoins, emptyWishlist, emptyUsers); + expect(string1).toBe(string2); + expect(string1).toBeDefined(); + }); + }); + + describe('generateCryptoHash', () => { + test('should generate a SHA-256 hex string', async () => { + const dataString = 'test string'; + const hash = await generateCryptoHash(dataString); + expect(hash).toMatch(/^[a-f0-9]{64}$/); // SHA-256 hex is 64 chars + }); + + test('should generate different hashes for different strings', async () => { + const hash1 = await generateCryptoHash('test string 1'); + const hash2 = await generateCryptoHash('test string 2'); + expect(hash1).not.toBe(hash2); + }); + + test('should generate the same hash for the same string', async () => { + const hash1 = await generateCryptoHash('consistent string'); + const hash2 = await generateCryptoHash('consistent string'); + expect(hash1).toBe(hash2); + }); + + // Test with a known SHA-256 value if possible, or ensure crypto.subtle.digest is available + // For "hello world", SHA-256 is "b94d27b9934d3e08a52e52d7da7dabfac484efe37a5380ee9088f7ace2efcde9" + test('should generate correct hash for a known string', async () => { + const knownString = "hello world"; + const expectedHash = "b94d27b9934d3e08a52e52d7da7dabfac484efe37a5380ee9088f7ace2efcde9"; + const actualHash = await generateCryptoHash(knownString); + expect(actualHash).toBe(expectedHash); + }); + }); +}) diff --git a/lib/utils.ts b/lib/utils.ts index 9f04688..7b3f327 100644 --- a/lib/utils.ts +++ b/lib/utils.ts @@ -2,11 +2,12 @@ import { clsx, type ClassValue } from "clsx" import { twMerge } from "tailwind-merge" import { DateTime, DateTimeFormatOptions } from "luxon" import { datetime, RRule } from 'rrule' -import { Freq, Habit, CoinTransaction, Permission, ParsedFrequencyResult, ParsedResultType, User } from '@/lib/types' +import { Freq, Habit, CoinTransaction, Permission, ParsedFrequencyResult, ParsedResultType, User, Settings, HabitsData, CoinsData, WishlistData, UserData } from '@/lib/types' import { DUE_MAP, INITIAL_DUE, RECURRENCE_RULE_MAP } from "./constants" import * as chrono from 'chrono-node' import _ from "lodash" import { v4 as uuidv4 } from 'uuid' +import stableStringify from 'json-stable-stringify'; export function cn(...inputs: ClassValue[]) { return twMerge(clsx(inputs)) @@ -481,3 +482,50 @@ export function hasPermission( // Otherwise, check specific permissions. return checkPermission(currentUser.permissions, resource, action); } + +/** + * Prepares a consistent string representation of the data for hashing. + * It combines all relevant data pieces into a single object and then stringifies it stably. + */ +export function prepareDataForHashing( + settings: Settings, + habits: HabitsData, + coins: CoinsData, + wishlist: WishlistData, + users: UserData +): string { + // Combine all data into a single object. + // The order of keys in this object itself doesn't matter due to stableStringify, + // but being explicit helps in understanding what's being hashed. + const combinedData = { + settings, + habits, + coins, + wishlist, + users, + }; + const stringifiedData = stableStringify(combinedData); + // Handle cases where stringify might return undefined. + if (stringifiedData === undefined) { + throw new Error("Failed to stringify data for hashing. stableStringify returned undefined."); + } + return stringifiedData; +} + +/** + * Generates a SHA-256 hash for a given string using the Web Crypto API. + * This function is suitable for both client-side and server-side (Node.js 19+) environments. + * @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; +} diff --git a/package-lock.json b/package-lock.json index aa6c2fe..6428907 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "habittrove", - "version": "0.2.13", + "version": "0.2.20", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "habittrove", - "version": "0.2.13", + "version": "0.2.20", "dependencies": { "@emoji-mart/data": "^1.2.1", "@emoji-mart/react": "^1.1.1", @@ -36,6 +36,7 @@ "date-fns": "^3.6.0", "jotai": "^2.8.0", "js-confetti": "^0.12.0", + "json-stable-stringify": "^1.3.0", "linkify": "^0.2.1", "linkify-react": "^4.2.0", "lucide-react": "^0.469.0", @@ -63,6 +64,7 @@ "@tailwindcss/typography": "^0.5.15", "@types/archiver": "^6.0.3", "@types/bun": "^1.1.14", + "@types/json-stable-stringify": "^1.1.0", "@types/lodash": "^4.17.15", "@types/luxon": "^3.4.2", "@types/node": "^20.17.10", @@ -2851,6 +2853,13 @@ "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", "dev": true }, + "node_modules/@types/json-stable-stringify": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@types/json-stable-stringify/-/json-stable-stringify-1.1.0.tgz", + "integrity": "sha512-ESTsHWB72QQq+pjUFIbEz9uSCZppD31YrVkbt2rnUciTYEvcwN6uZIhX5JZeBHqRlFJ41x/7MewCs7E2Qux6Cg==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/json5": { "version": "0.0.29", "resolved": "https://registry.npmjs.org/@types/json5/-/json5-0.0.29.tgz", @@ -3999,7 +4008,6 @@ "version": "1.0.8", "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.8.tgz", "integrity": "sha512-oKlSFMcMwpUg2ednkhQ454wfWiU/ul3CkJe/PEHcTKuiX6RpbehUiFMXu13HalGZxfUwCQzZG747YXBn1im9ww==", - "dev": true, "dependencies": { "call-bind-apply-helpers": "^1.0.0", "es-define-property": "^1.0.0", @@ -4014,10 +4022,10 @@ } }, "node_modules/call-bind-apply-helpers": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.1.tgz", - "integrity": "sha512-BhYE+WDaywFg2TBWYNXAE+8B1ATnThNBqXHP5nQu0jWJdVvY2hvkpyB3qOmtmDePiS5/BDQ8wASEWGMWRG148g==", - "dev": true, + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", "dependencies": { "es-errors": "^1.3.0", "function-bind": "^1.1.2" @@ -4027,13 +4035,13 @@ } }, "node_modules/call-bound": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.3.tgz", - "integrity": "sha512-YTd+6wGlNlPxSuri7Y6X8tY2dmm12UMH66RpKMhiX6rsk5wXXnYgbUcOt8kiS31/AjfoTOvCsE+w8nZQLQnzHA==", - "dev": true, + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "license": "MIT", "dependencies": { - "call-bind-apply-helpers": "^1.0.1", - "get-intrinsic": "^1.2.6" + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" }, "engines": { "node": ">= 0.4" @@ -4604,7 +4612,6 @@ "version": "1.1.4", "resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz", "integrity": "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==", - "dev": true, "dependencies": { "es-define-property": "^1.0.0", "es-errors": "^1.3.0", @@ -4703,7 +4710,6 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", - "dev": true, "dependencies": { "call-bind-apply-helpers": "^1.0.1", "es-errors": "^1.3.0", @@ -4831,7 +4837,6 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", - "dev": true, "engines": { "node": ">= 0.4" } @@ -4840,7 +4845,6 @@ "version": "1.3.0", "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", - "dev": true, "engines": { "node": ">= 0.4" } @@ -4880,10 +4884,10 @@ "peer": true }, "node_modules/es-object-atoms": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.0.0.tgz", - "integrity": "sha512-MZ4iQ6JwHOBQjahnjwaC1ZtIBH+2ohjamzAO3oaHcXYup7qxjF2fixyH+Q71voWHeOkI2q/TnJao/KfXYIZWbw==", - "dev": true, + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "license": "MIT", "dependencies": { "es-errors": "^1.3.0" }, @@ -5644,21 +5648,21 @@ } }, "node_modules/get-intrinsic": { - "version": "1.2.6", - "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.2.6.tgz", - "integrity": "sha512-qxsEs+9A+u85HhllWJJFicJfPDhRmjzoYdl64aMWW9yRIJmSyxdn8IEkuIM530/7T+lv0TIHd8L6Q/ra0tEoeA==", - "dev": true, + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", "dependencies": { - "call-bind-apply-helpers": "^1.0.1", - "dunder-proto": "^1.0.0", + "call-bind-apply-helpers": "^1.0.2", "es-define-property": "^1.0.1", "es-errors": "^1.3.0", - "es-object-atoms": "^1.0.0", + "es-object-atoms": "^1.1.1", "function-bind": "^1.1.2", + "get-proto": "^1.0.1", "gopd": "^1.2.0", "has-symbols": "^1.1.0", "hasown": "^2.0.2", - "math-intrinsics": "^1.0.0" + "math-intrinsics": "^1.1.0" }, "engines": { "node": ">= 0.4" @@ -5675,6 +5679,19 @@ "node": ">=6" } }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/get-symbol-description": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/get-symbol-description/-/get-symbol-description-1.1.0.tgz", @@ -5795,7 +5812,6 @@ "version": "1.2.0", "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", - "dev": true, "engines": { "node": ">= 0.4" }, @@ -5839,7 +5855,6 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz", "integrity": "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==", - "dev": true, "dependencies": { "es-define-property": "^1.0.0" }, @@ -5866,7 +5881,6 @@ "version": "1.1.0", "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", - "dev": true, "engines": { "node": ">= 0.4" }, @@ -6523,8 +6537,7 @@ "node_modules/isarray": { "version": "2.0.5", "resolved": "https://registry.npmjs.org/isarray/-/isarray-2.0.5.tgz", - "integrity": "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==", - "dev": true + "integrity": "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==" }, "node_modules/isexe": { "version": "2.0.0", @@ -6671,6 +6684,25 @@ "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", "dev": true }, + "node_modules/json-stable-stringify": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/json-stable-stringify/-/json-stable-stringify-1.3.0.tgz", + "integrity": "sha512-qtYiSSFlwot9XHtF9bD9c7rwKjr+RecWT//ZnPvSmEjpV5mmPOCN4j8UjY5hbjNkOwZ/jQv3J6R1/pL7RwgMsg==", + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.4", + "isarray": "^2.0.5", + "jsonify": "^0.0.1", + "object-keys": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/json-stable-stringify-without-jsonify": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", @@ -6689,6 +6721,15 @@ "json5": "lib/cli.js" } }, + "node_modules/jsonify": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/jsonify/-/jsonify-0.0.1.tgz", + "integrity": "sha512-2/Ki0GcmuqSrgFyelQq9M05y7PS0mEwuIzrf3f1fPqkVDVRvZrPZtVSMHxdgo8Aq0sxAOb/cr2aqqA3LeWHVPg==", + "license": "Public Domain", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/jsx-ast-utils": { "version": "3.3.5", "resolved": "https://registry.npmjs.org/jsx-ast-utils/-/jsx-ast-utils-3.3.5.tgz", @@ -6966,7 +7007,6 @@ "version": "1.1.0", "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", - "dev": true, "engines": { "node": ">= 0.4" } @@ -7896,7 +7936,6 @@ "version": "1.1.1", "resolved": "https://registry.npmjs.org/object-keys/-/object-keys-1.1.1.tgz", "integrity": "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==", - "dev": true, "engines": { "node": ">= 0.4" } @@ -8958,7 +8997,6 @@ "version": "1.2.2", "resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz", "integrity": "sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==", - "dev": true, "dependencies": { "define-data-property": "^1.1.4", "es-errors": "^1.3.0", diff --git a/package.json b/package.json index 8984fe7..3a46669 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "habittrove", - "version": "0.2.21", + "version": "0.2.22", "private": true, "scripts": { "dev": "next dev --turbopack", @@ -43,6 +43,7 @@ "date-fns": "^3.6.0", "jotai": "^2.8.0", "js-confetti": "^0.12.0", + "json-stable-stringify": "^1.3.0", "linkify": "^0.2.1", "linkify-react": "^4.2.0", "lucide-react": "^0.469.0", @@ -70,6 +71,7 @@ "@tailwindcss/typography": "^0.5.15", "@types/archiver": "^6.0.3", "@types/bun": "^1.1.14", + "@types/json-stable-stringify": "^1.1.0", "@types/lodash": "^4.17.15", "@types/luxon": "^3.4.2", "@types/node": "^20.17.10",