mirror of
https://github.com/ManInDark/HabitTrove.git
synced 2026-01-21 06:34:30 +01:00
Merge Tag v0.2.22
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -21,13 +21,14 @@ import {
|
||||
WishlistData,
|
||||
WishlistItemType
|
||||
} from '@/lib/types';
|
||||
import { d2t, getNow, uuid } from '@/lib/utils';
|
||||
import { d2t, generateCryptoHash, getNow, prepareDataForHashing, uuid } from '@/lib/utils';
|
||||
import { signInSchema } from '@/lib/zod';
|
||||
import fs from 'fs/promises';
|
||||
import _ from 'lodash';
|
||||
import path from 'path';
|
||||
|
||||
|
||||
|
||||
type ResourceType = 'habit' | 'wishlist' | 'coins'
|
||||
type ActionType = 'write' | 'interact'
|
||||
|
||||
@@ -119,6 +120,33 @@ async function saveData<T>(type: DataType, data: T): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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<string> {
|
||||
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<WishlistData> {
|
||||
const user = await getCurrentUser()
|
||||
@@ -591,3 +619,24 @@ export async function loadServerSettings(): Promise<ServerSettings> {
|
||||
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 };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,27 +1,29 @@
|
||||
'use client'
|
||||
|
||||
import { aboutOpenAtom, currentUserIdAtom, pomodoroAtom, userSelectAtom } from '@/lib/atoms';
|
||||
import { useAtom, useSetAtom } from 'jotai';
|
||||
import { useSession } from 'next-auth/react'
|
||||
import { ReactNode, useEffect, useState } from 'react'
|
||||
import AboutModal from './AboutModal'
|
||||
import LoadingSpinner from './LoadingSpinner'
|
||||
import PomodoroTimer from './PomodoroTimer'
|
||||
import UserSelectModal from './UserSelectModal'
|
||||
import { checkDataFreshness as checkServerDataFreshness } from '@/app/actions/data';
|
||||
import { aboutOpenAtom, clientFreshnessTokenAtom, currentUserIdAtom, pomodoroAtom, userSelectAtom } from '@/lib/atoms';
|
||||
import { useAtom, useAtomValue, useSetAtom } from 'jotai';
|
||||
import { useSession } from 'next-auth/react';
|
||||
import { ReactNode, Suspense, useCallback, useEffect, useState } from 'react';
|
||||
import AboutModal from './AboutModal';
|
||||
import LoadingSpinner from './LoadingSpinner';
|
||||
import PomodoroTimer from './PomodoroTimer';
|
||||
import RefreshBanner from './RefreshBanner';
|
||||
import UserSelectModal from './UserSelectModal';
|
||||
|
||||
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 <LoadingSpinner />
|
||||
}
|
||||
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 && (
|
||||
<PomodoroTimer />
|
||||
)}
|
||||
{userSelect && (
|
||||
<UserSelectModal onClose={() => setUserSelect(false)} />
|
||||
)}
|
||||
{aboutOpen && (
|
||||
<AboutModal onClose={() => setAboutOpen(false)} />
|
||||
)}
|
||||
{pomo.show && <PomodoroTimer />}
|
||||
{userSelect && <UserSelectModal onClose={() => setUserSelect(false)} />}
|
||||
{aboutOpen && <AboutModal onClose={() => setAboutOpen(false)} />}
|
||||
{showRefreshBanner && <RefreshBanner onRefresh={handleRefresh} />}
|
||||
</>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
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 <LoadingSpinner />;
|
||||
}
|
||||
|
||||
return (
|
||||
<Suspense fallback={<LoadingSpinner />}>
|
||||
<ClientWrapperContent>{children}</ClientWrapperContent>
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -65,7 +65,7 @@ export function Profile() {
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<div className="flex flex-col mr-4">
|
||||
<span className="text-sm font-semibold flex items-center gap-1">
|
||||
<span className="text-sm font-semibold flex items-center gap-1 break-all">
|
||||
{user?.username || t('guestUsername')}
|
||||
{user?.isAdmin && <Crown className="h-3 w-3 text-yellow-500" />}
|
||||
</span>
|
||||
|
||||
27
components/RefreshBanner.tsx
Normal file
27
components/RefreshBanner.tsx
Normal file
@@ -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 (
|
||||
<div className="fixed bottom-4 right-4 z-[100] bg-yellow-400 dark:bg-yellow-500 text-black dark:text-gray-900 p-4 rounded-lg shadow-lg flex items-center gap-3">
|
||||
<AlertTriangle className="h-6 w-6 text-yellow-800 dark:text-yellow-900" />
|
||||
<div>
|
||||
<p className="font-semibold">Data out of sync</p>
|
||||
<p className="text-sm">New data is available. Please refresh to see the latest updates.</p>
|
||||
</div>
|
||||
<Button
|
||||
onClick={onRefresh}
|
||||
variant="outline"
|
||||
className="ml-auto bg-yellow-500 hover:bg-yellow-600 dark:bg-yellow-600 dark:hover:bg-yellow-700 border-yellow-600 dark:border-yellow-700 text-white dark:text-gray-900"
|
||||
>
|
||||
Refresh
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -14,7 +14,7 @@ export function JotaiHydrate({
|
||||
[coinsAtom, initialValues.coins],
|
||||
[wishlistAtom, initialValues.wishlist],
|
||||
[usersAtom, initialValues.users],
|
||||
[serverSettingsAtom, initialValues.serverSettings]
|
||||
[serverSettingsAtom, initialValues.serverSettings],
|
||||
])
|
||||
return children
|
||||
}
|
||||
|
||||
18
lib/atoms.ts
18
lib/atoms.ts
@@ -115,9 +115,27 @@ export const pomodoroAtom = atom<PomodoroAtom>({
|
||||
minimized: false,
|
||||
})
|
||||
|
||||
import { prepareDataForHashing, generateCryptoHash } from '@/lib/utils';
|
||||
|
||||
export const userSelectAtom = atom<boolean>(false)
|
||||
export const aboutOpenAtom = atom<boolean>(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;
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
})
|
||||
|
||||
50
lib/utils.ts
50
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<string> {
|
||||
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;
|
||||
}
|
||||
|
||||
112
package-lock.json
generated
112
package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
"lodash": "^4.17.21",
|
||||
@@ -71,6 +72,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",
|
||||
|
||||
Reference in New Issue
Block a user