Skip to content

Commit 276e8a8

Browse files
authored
refresh stale data (#156)
1 parent 1967d15 commit 276e8a8

File tree

12 files changed

+396
-66
lines changed

12 files changed

+396
-66
lines changed

CHANGELOG.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,12 @@
11
# Changelog
22

3+
## Version 0.2.22
4+
5+
### Added
6+
7+
* auto check data freshness on interval (#138)
8+
* warn about out-of-sync data
9+
310
## Version 0.2.21
411

512
### Fixed

app/actions/data.ts

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,9 @@ import { signInSchema } from '@/lib/zod';
2929
import { auth } from '@/auth';
3030
import _ from 'lodash';
3131
import { getCurrentUser, getCurrentUserId } from '@/lib/server-helpers'
32+
import stableStringify from 'json-stable-stringify';
33+
import { prepareDataForHashing, generateCryptoHash } from '@/lib/utils';
34+
3235

3336
import { PermissionError } from '@/lib/exceptions'
3437

@@ -123,6 +126,33 @@ async function saveData<T>(type: DataType, data: T): Promise<void> {
123126
}
124127
}
125128

129+
/**
130+
* Calculates the server's global freshness token based on all core data files.
131+
* This is an expensive operation as it reads all data files.
132+
*/
133+
async function calculateServerFreshnessToken(): Promise<string> {
134+
try {
135+
const settings = await loadSettings();
136+
const habits = await loadHabitsData();
137+
const coins = await loadCoinsData();
138+
const wishlist = await loadWishlistData();
139+
const users = await loadUsersData();
140+
141+
const dataString = prepareDataForHashing(
142+
settings,
143+
habits,
144+
coins,
145+
wishlist,
146+
users
147+
);
148+
const serverToken = await generateCryptoHash(dataString);
149+
return serverToken;
150+
} catch (error) {
151+
console.error("Error calculating server freshness token:", error);
152+
throw error;
153+
}
154+
}
155+
126156
// Wishlist specific functions
127157
export async function loadWishlistData(): Promise<WishlistData> {
128158
const user = await getCurrentUser()
@@ -595,3 +625,24 @@ export async function loadServerSettings(): Promise<ServerSettings> {
595625
isDemo: !!process.env.DEMO,
596626
}
597627
}
628+
629+
/**
630+
* Checks if the client's data is fresh by comparing its token with the server's token.
631+
* @param clientToken The freshness token calculated by the client.
632+
* @returns A promise that resolves to an object { isFresh: boolean }.
633+
*/
634+
export async function checkDataFreshness(clientToken: string): Promise<{ isFresh: boolean }> {
635+
try {
636+
const serverToken = await calculateServerFreshnessToken();
637+
const isFresh = clientToken === serverToken;
638+
if (!isFresh) {
639+
console.log(`Data freshness check: Stale. Client token: ${clientToken}, Server token: ${serverToken}`);
640+
}
641+
return { isFresh };
642+
} catch (error) {
643+
console.error("Error in checkDataFreshness:", error);
644+
// If server fails to determine its token, assume client might be stale to be safe,
645+
// or handle error reporting differently.
646+
return { isFresh: false };
647+
}
648+
}

components/ClientWrapper.tsx

Lines changed: 65 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -1,27 +1,29 @@
11
'use client'
22

3-
import { ReactNode, Suspense, useEffect, useState } from 'react'
4-
import { useAtom, useSetAtom } from 'jotai' // Import useSetAtom
5-
import { aboutOpenAtom, pomodoroAtom, userSelectAtom, currentUserIdAtom } from '@/lib/atoms' // Import currentUserIdAtom
3+
import { ReactNode, useEffect, useCallback, useState, Suspense } from 'react'
4+
import { useAtom, useSetAtom, useAtomValue } from 'jotai'
5+
import { aboutOpenAtom, pomodoroAtom, userSelectAtom, currentUserIdAtom, clientFreshnessTokenAtom } from '@/lib/atoms'
66
import PomodoroTimer from './PomodoroTimer'
77
import UserSelectModal from './UserSelectModal'
88
import { useSession } from 'next-auth/react'
99
import AboutModal from './AboutModal'
1010
import LoadingSpinner from './LoadingSpinner'
11+
import { checkDataFreshness as checkServerDataFreshness } from '@/app/actions/data'
12+
import RefreshBanner from './RefreshBanner'
1113

12-
export default function ClientWrapper({ children }: { children: ReactNode }) {
14+
function ClientWrapperContent({ children }: { children: ReactNode }) {
1315
const [pomo] = useAtom(pomodoroAtom)
1416
const [userSelect, setUserSelect] = useAtom(userSelectAtom)
1517
const [aboutOpen, setAboutOpen] = useAtom(aboutOpenAtom)
1618
const setCurrentUserIdAtom = useSetAtom(currentUserIdAtom)
1719
const { data: session, status } = useSession()
1820
const currentUserId = session?.user.id
19-
const [isMounted, setIsMounted] = useState(false);
21+
const [showRefreshBanner, setShowRefreshBanner] = useState(false);
22+
23+
// clientFreshnessTokenAtom is async, useAtomValue will suspend until it's resolved.
24+
// Suspense boundary is in app/layout.tsx or could be added here if needed more locally.
25+
const clientToken = useAtomValue(clientFreshnessTokenAtom);
2026

21-
// 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
22-
useEffect(() => {
23-
setIsMounted(true);
24-
}, []);
2527

2628
useEffect(() => {
2729
if (status === 'loading') return
@@ -34,21 +36,62 @@ export default function ClientWrapper({ children }: { children: ReactNode }) {
3436
setCurrentUserIdAtom(currentUserId)
3537
}, [currentUserId, setCurrentUserIdAtom])
3638

37-
if (!isMounted) {
38-
return <LoadingSpinner />
39-
}
39+
const performFreshnessCheck = useCallback(async () => {
40+
if (!clientToken || status !== 'authenticated') return;
41+
42+
try {
43+
const result = await checkServerDataFreshness(clientToken);
44+
if (!result.isFresh) {
45+
setShowRefreshBanner(true);
46+
}
47+
} catch (error) {
48+
console.error("Failed to check data freshness with server:", error);
49+
}
50+
}, [clientToken, status]);
51+
52+
useEffect(() => {
53+
// Interval for polling data freshness
54+
if (clientToken && !showRefreshBanner && status === 'authenticated') {
55+
const intervalId = setInterval(() => {
56+
performFreshnessCheck();
57+
}, 30000); // Check every 30 seconds
58+
59+
return () => clearInterval(intervalId);
60+
}
61+
}, [clientToken, performFreshnessCheck, showRefreshBanner, status]);
62+
63+
const handleRefresh = () => {
64+
setShowRefreshBanner(false);
65+
window.location.reload();
66+
};
67+
4068
return (
4169
<>
4270
{children}
43-
{pomo.show && (
44-
<PomodoroTimer />
45-
)}
46-
{userSelect && (
47-
<UserSelectModal onClose={() => setUserSelect(false)} />
48-
)}
49-
{aboutOpen && (
50-
<AboutModal onClose={() => setAboutOpen(false)} />
51-
)}
71+
{pomo.show && <PomodoroTimer />}
72+
{userSelect && <UserSelectModal onClose={() => setUserSelect(false)} />}
73+
{aboutOpen && <AboutModal onClose={() => setAboutOpen(false)} />}
74+
{showRefreshBanner && <RefreshBanner onRefresh={handleRefresh} />}
5275
</>
53-
)
76+
);
77+
}
78+
79+
export default function ClientWrapper({ children }: { children: ReactNode }) {
80+
const [isMounted, setIsMounted] = useState(false);
81+
82+
// block client-side hydration until mounted (this is crucial to wait for all jotai atoms to load),
83+
// to prevent SSR hydration errors in the children components
84+
useEffect(() => {
85+
setIsMounted(true);
86+
}, []);
87+
88+
if (!isMounted) {
89+
return <LoadingSpinner />;
90+
}
91+
92+
return (
93+
<Suspense fallback={<LoadingSpinner />}>
94+
<ClientWrapperContent>{children}</ClientWrapperContent>
95+
</Suspense>
96+
);
5497
}

components/Profile.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -65,7 +65,7 @@ export function Profile() {
6565
</AvatarFallback>
6666
</Avatar>
6767
<div className="flex flex-col mr-4">
68-
<span className="text-sm font-semibold flex items-center gap-1">
68+
<span className="text-sm font-semibold flex items-center gap-1 break-all">
6969
{user?.username || t('guestUsername')}
7070
{user?.isAdmin && <Crown className="h-3 w-3 text-yellow-500" />}
7171
</span>

components/RefreshBanner.tsx

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
'use client'
2+
3+
import { Button } from "@/components/ui/button"
4+
import { AlertTriangle } from "lucide-react"
5+
6+
interface RefreshBannerProps {
7+
onRefresh: () => void;
8+
}
9+
10+
export default function RefreshBanner({ onRefresh }: RefreshBannerProps) {
11+
return (
12+
<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">
13+
<AlertTriangle className="h-6 w-6 text-yellow-800 dark:text-yellow-900" />
14+
<div>
15+
<p className="font-semibold">Data out of sync</p>
16+
<p className="text-sm">New data is available. Please refresh to see the latest updates.</p>
17+
</div>
18+
<Button
19+
onClick={onRefresh}
20+
variant="outline"
21+
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"
22+
>
23+
Refresh
24+
</Button>
25+
</div>
26+
)
27+
}

components/jotai-hydrate.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ export function JotaiHydrate({
1414
[coinsAtom, initialValues.coins],
1515
[wishlistAtom, initialValues.wishlist],
1616
[usersAtom, initialValues.users],
17-
[serverSettingsAtom, initialValues.serverSettings]
17+
[serverSettingsAtom, initialValues.serverSettings],
1818
])
1919
return children
2020
}

lib/atoms.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -123,9 +123,27 @@ export const pomodoroAtom = atom<PomodoroAtom>({
123123
minimized: false,
124124
})
125125

126+
import { prepareDataForHashing, generateCryptoHash } from '@/lib/utils';
127+
126128
export const userSelectAtom = atom<boolean>(false)
127129
export const aboutOpenAtom = atom<boolean>(false)
128130

131+
/**
132+
* Asynchronous atom that calculates a freshness token (hash) based on the current client-side data.
133+
* This token can be compared with a server-generated token to detect data discrepancies.
134+
*/
135+
export const clientFreshnessTokenAtom = atom(async (get) => {
136+
const settings = get(settingsAtom);
137+
const habits = get(habitsAtom);
138+
const coins = get(coinsAtom);
139+
const wishlist = get(wishlistAtom);
140+
const users = get(usersAtom);
141+
142+
const dataString = prepareDataForHashing(settings, habits, coins, wishlist, users);
143+
const hash = await generateCryptoHash(dataString);
144+
return hash;
145+
});
146+
129147
// Derived atom for completion cache
130148
export const completionCacheAtom = atom((get) => {
131149
const habits = get(habitsAtom).habits;

lib/server-helpers.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,4 +37,4 @@ export function verifyPassword(password?: string, storedHash?: string): boolean
3737
const newHash = saltAndHashPassword(password, salt).split(':')[1]
3838
// Compare the new hash with the stored hash
3939
return newHash === hash
40-
}
40+
}

lib/utils.test.ts

Lines changed: 98 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -22,10 +22,13 @@ import {
2222
serializeRRule,
2323
convertHumanReadableFrequencyToMachineReadable,
2424
convertMachineReadableFrequencyToHumanReadable,
25-
getUnsupportedRRuleReason
25+
getUnsupportedRRuleReason,
26+
prepareDataForHashing,
27+
generateCryptoHash
2628
} from './utils'
27-
import { CoinTransaction, ParsedResultType } from './types'
29+
import { CoinTransaction, ParsedResultType, Settings, HabitsData, CoinsData, WishlistData, UserData } from './types'
2830
import { DateTime } from "luxon";
31+
import { getDefaultSettings, getDefaultHabitsData, getDefaultCoinsData, getDefaultWishlistData, getDefaultUsersData } from './types';
2932
import { RRule, Weekday } from 'rrule';
3033
import { Habit } from '@/lib/types';
3134
import { INITIAL_DUE } from './constants';
@@ -956,3 +959,96 @@ describe('convertMachineReadableFrequencyToHumanReadable', () => {
956959
expect(humanReadable).toBe('invalid')
957960
})
958961
})
962+
963+
describe('freshness utilities', () => {
964+
const mockSettings: Settings = getDefaultSettings();
965+
const mockHabits: HabitsData = getDefaultHabitsData();
966+
const mockCoins: CoinsData = getDefaultCoinsData();
967+
const mockWishlist: WishlistData = getDefaultWishlistData();
968+
const mockUsers: UserData = getDefaultUsersData();
969+
970+
// Add a user to mockUsers for more realistic testing
971+
mockUsers.users.push({
972+
id: 'user-123',
973+
username: 'testuser',
974+
isAdmin: false,
975+
});
976+
mockHabits.habits.push({
977+
id: 'habit-123',
978+
name: 'Test Habit',
979+
description: 'A habit for testing',
980+
frequency: 'FREQ=DAILY',
981+
coinReward: 10,
982+
completions: [],
983+
userIds: ['user-123']
984+
});
985+
986+
987+
describe('prepareDataForHashing', () => {
988+
test('should produce a consistent string for the same data', () => {
989+
const data1 = { settings: mockSettings, habits: mockHabits, coins: mockCoins, wishlist: mockWishlist, users: mockUsers };
990+
const data2 = { settings: mockSettings, habits: mockHabits, coins: mockCoins, wishlist: mockWishlist, users: mockUsers }; // Identical data
991+
992+
const string1 = prepareDataForHashing(data1.settings, data1.habits, data1.coins, data1.wishlist, data1.users);
993+
const string2 = prepareDataForHashing(data2.settings, data2.habits, data2.coins, data2.wishlist, data2.users);
994+
995+
expect(string1).toBe(string2);
996+
});
997+
998+
test('should produce a different string if settings data changes', () => {
999+
const string1 = prepareDataForHashing(mockSettings, mockHabits, mockCoins, mockWishlist, mockUsers);
1000+
const modifiedSettings = { ...mockSettings, system: { ...mockSettings.system, timezone: 'America/Chicago' } };
1001+
const string2 = prepareDataForHashing(modifiedSettings, mockHabits, mockCoins, mockWishlist, mockUsers);
1002+
expect(string1).not.toBe(string2);
1003+
});
1004+
1005+
test('should produce a different string if habits data changes', () => {
1006+
const string1 = prepareDataForHashing(mockSettings, mockHabits, mockCoins, mockWishlist, mockUsers);
1007+
const modifiedHabits = { ...mockHabits, habits: [...mockHabits.habits, { id: 'new-habit', name: 'New', description: '', frequency: 'FREQ=DAILY', coinReward: 5, completions: [] }] };
1008+
const string2 = prepareDataForHashing(mockSettings, modifiedHabits, mockCoins, mockWishlist, mockUsers);
1009+
expect(string1).not.toBe(string2);
1010+
});
1011+
1012+
test('should handle empty data consistently', () => {
1013+
const emptySettings = getDefaultSettings();
1014+
const emptyHabits = getDefaultHabitsData();
1015+
const emptyCoins = getDefaultCoinsData();
1016+
const emptyWishlist = getDefaultWishlistData();
1017+
const emptyUsers = getDefaultUsersData();
1018+
1019+
const string1 = prepareDataForHashing(emptySettings, emptyHabits, emptyCoins, emptyWishlist, emptyUsers);
1020+
const string2 = prepareDataForHashing(emptySettings, emptyHabits, emptyCoins, emptyWishlist, emptyUsers);
1021+
expect(string1).toBe(string2);
1022+
expect(string1).toBeDefined();
1023+
});
1024+
});
1025+
1026+
describe('generateCryptoHash', () => {
1027+
test('should generate a SHA-256 hex string', async () => {
1028+
const dataString = 'test string';
1029+
const hash = await generateCryptoHash(dataString);
1030+
expect(hash).toMatch(/^[a-f0-9]{64}$/); // SHA-256 hex is 64 chars
1031+
});
1032+
1033+
test('should generate different hashes for different strings', async () => {
1034+
const hash1 = await generateCryptoHash('test string 1');
1035+
const hash2 = await generateCryptoHash('test string 2');
1036+
expect(hash1).not.toBe(hash2);
1037+
});
1038+
1039+
test('should generate the same hash for the same string', async () => {
1040+
const hash1 = await generateCryptoHash('consistent string');
1041+
const hash2 = await generateCryptoHash('consistent string');
1042+
expect(hash1).toBe(hash2);
1043+
});
1044+
1045+
// Test with a known SHA-256 value if possible, or ensure crypto.subtle.digest is available
1046+
// For "hello world", SHA-256 is "b94d27b9934d3e08a52e52d7da7dabfac484efe37a5380ee9088f7ace2efcde9"
1047+
test('should generate correct hash for a known string', async () => {
1048+
const knownString = "hello world";
1049+
const expectedHash = "b94d27b9934d3e08a52e52d7da7dabfac484efe37a5380ee9088f7ace2efcde9";
1050+
const actualHash = await generateCryptoHash(knownString);
1051+
expect(actualHash).toBe(expectedHash);
1052+
});
1053+
});
1054+
})

0 commit comments

Comments
 (0)