Compare commits

...

2 Commits

Author SHA1 Message Date
1b17d6b50a fix: add TS types 2025-08-17 19:49:11 +02:00
8269f3adad fix: refactored code & removed unused parts 2025-08-09 18:57:04 +02:00
12 changed files with 177 additions and 367 deletions

View File

@@ -21,7 +21,7 @@ import {
WishlistData, WishlistData,
WishlistItemType WishlistItemType
} from '@/lib/types'; } from '@/lib/types';
import { d2t, generateCryptoHash, getNow, prepareDataForHashing, uuid } from '@/lib/utils'; import { d2t, generateCryptoHash, getNow, prepareDataForHashing } from '@/lib/utils';
import { signInSchema } from '@/lib/zod'; import { signInSchema } from '@/lib/zod';
import fs from 'fs/promises'; import fs from 'fs/promises';
import _ from 'lodash'; import _ from 'lodash';
@@ -33,21 +33,6 @@ type ResourceType = 'habit' | 'wishlist' | 'coins'
type ActionType = 'write' | 'interact' type ActionType = 'write' | 'interact'
async function verifyPermission(
resource: ResourceType,
action: ActionType
): Promise<void> {
// const user = await getCurrentUser()
// if (!user) throw new PermissionError('User not authenticated')
// if (user.isAdmin) return // Admins bypass permission checks
// if (!checkPermission(user.permissions, resource, action)) {
// throw new PermissionError(`User does not have ${action} permission for ${resource}`)
// }
return
}
function getDefaultData<T>(type: DataType): T { function getDefaultData<T>(type: DataType): T {
return DATA_DEFAULTS[type]() as T; return DATA_DEFAULTS[type]() as T;
} }
@@ -91,7 +76,7 @@ async function loadData<T>(type: DataType): Promise<T> {
await fs.access(filePath) await fs.access(filePath)
} catch { } catch {
// File doesn't exist, create it with default data // File doesn't exist, create it with default data
const initialData = getDefaultData(type) const initialData = getDefaultData<T>(type)
await fs.writeFile(filePath, JSON.stringify(initialData, null, 2)) await fs.writeFile(filePath, JSON.stringify(initialData, null, 2))
return initialData as T return initialData as T
} }
@@ -126,11 +111,13 @@ async function saveData<T>(type: DataType, data: T): Promise<void> {
*/ */
async function calculateServerFreshnessToken(): Promise<string | null> { async function calculateServerFreshnessToken(): Promise<string | null> {
try { try {
const settings = await loadSettings(); const [settings, habits, coins, wishlist, users] = await Promise.all([
const habits = await loadHabitsData(); loadSettings(),
const coins = await loadCoinsData(); loadHabitsData(),
const wishlist = await loadWishlistData(); loadCoinsData(),
const users = await loadUsersData(); loadWishlistData(),
loadUsersData()
]);
const dataString = prepareDataForHashing( const dataString = prepareDataForHashing(
settings, settings,
@@ -139,8 +126,7 @@ async function calculateServerFreshnessToken(): Promise<string | null> {
wishlist, wishlist,
users users
); );
const serverToken = await generateCryptoHash(dataString); return generateCryptoHash(dataString);
return serverToken;
} catch (error) { } catch (error) {
console.error("Error calculating server freshness token:", error); console.error("Error calculating server freshness token:", error);
throw error; throw error;
@@ -150,7 +136,7 @@ async function calculateServerFreshnessToken(): Promise<string | null> {
// Wishlist specific functions // Wishlist specific functions
export async function loadWishlistData(): Promise<WishlistData> { export async function loadWishlistData(): Promise<WishlistData> {
const user = await getCurrentUser() const user = await getCurrentUser()
if (!user) return getDefaultWishlistData() if (!user) return getDefaultWishlistData<WishlistData>()
const data = await loadData<WishlistData>('wishlist') const data = await loadData<WishlistData>('wishlist')
return { return {
@@ -165,7 +151,6 @@ export async function loadWishlistItems(): Promise<WishlistItemType[]> {
} }
export async function saveWishlistItems(data: WishlistData): Promise<void> { export async function saveWishlistItems(data: WishlistData): Promise<void> {
await verifyPermission('wishlist', 'write')
const user = await getCurrentUser() const user = await getCurrentUser()
data.items = data.items.map(wishlist => ({ data.items = data.items.map(wishlist => ({
@@ -188,17 +173,14 @@ export async function saveWishlistItems(data: WishlistData): Promise<void> {
// Habits specific functions // Habits specific functions
export async function loadHabitsData(): Promise<HabitsData> { export async function loadHabitsData(): Promise<HabitsData> {
const user = await getCurrentUser() const user = await getCurrentUser()
if (!user) return getDefaultHabitsData() if (!user) return getDefaultHabitsData<HabitsData>()
const data = await loadData<HabitsData>('habits') const data = await loadData<HabitsData>('habits')
return { return {
...data,
habits: data.habits.filter(x => user.isAdmin || x.userIds?.includes(user.id)) habits: data.habits.filter(x => user.isAdmin || x.userIds?.includes(user.id))
} }
} }
export async function saveHabitsData(data: HabitsData): Promise<void> { export async function saveHabitsData(data: HabitsData): Promise<void> {
await verifyPermission('habit', 'write')
const user = await getCurrentUser() const user = await getCurrentUser()
// Create clone of input data // Create clone of input data
const newData = _.cloneDeep(data) const newData = _.cloneDeep(data)
@@ -210,7 +192,7 @@ export async function saveHabitsData(data: HabitsData): Promise<void> {
})) }))
if (!user?.isAdmin) { if (!user?.isAdmin) {
const existingData = await loadData<HabitsData>('habits') const existingData = await loadHabitsData();
const existingHabits = existingData.habits.filter(x => user?.id && !x.userIds?.includes(user?.id)) const existingHabits = existingData.habits.filter(x => user?.id && !x.userIds?.includes(user?.id))
newData.habits = [ newData.habits = [
...existingHabits, ...existingHabits,
@@ -226,14 +208,14 @@ export async function saveHabitsData(data: HabitsData): Promise<void> {
export async function loadCoinsData(): Promise<CoinsData> { export async function loadCoinsData(): Promise<CoinsData> {
try { try {
const user = await getCurrentUser() const user = await getCurrentUser()
if (!user) return getDefaultCoinsData() if (!user) return getDefaultCoinsData<CoinsData>()
const data = await loadData<CoinsData>('coins') const data = await loadData<CoinsData>('coins')
return { return {
...data, ...data,
transactions: user.isAdmin ? data.transactions : data.transactions.filter(x => x.userId === user.id) transactions: user.isAdmin ? data.transactions : data.transactions.filter(x => x.userId === user.id)
} }
} catch { } catch {
return getDefaultCoinsData() return getDefaultCoinsData<CoinsData>()
} }
} }
@@ -273,11 +255,10 @@ export async function addCoins({
note?: string note?: string
userId?: string userId?: string
}): Promise<CoinsData> { }): Promise<CoinsData> {
await verifyPermission('coins', type === 'MANUAL_ADJUSTMENT' ? 'write' : 'interact')
const currentUser = await getCurrentUser() const currentUser = await getCurrentUser()
const data = await loadCoinsData() const data = await loadCoinsData()
const newTransaction: CoinTransaction = { const newTransaction: CoinTransaction = {
id: uuid(), id: crypto.randomUUID(),
amount, amount,
type, type,
description, description,
@@ -297,7 +278,7 @@ export async function addCoins({
} }
export async function loadSettings(): Promise<Settings> { export async function loadSettings(): Promise<Settings> {
const defaultSettings = getDefaultSettings() const defaultSettings = getDefaultSettings<Settings>()
try { try {
const user = await getCurrentUser() const user = await getCurrentUser()
@@ -328,11 +309,10 @@ export async function removeCoins({
note?: string note?: string
userId?: string userId?: string
}): Promise<CoinsData> { }): Promise<CoinsData> {
await verifyPermission('coins', type === 'MANUAL_ADJUSTMENT' ? 'write' : 'interact')
const currentUser = await getCurrentUser() const currentUser = await getCurrentUser()
const data = await loadCoinsData() const data = await loadCoinsData()
const newTransaction: CoinTransaction = { const newTransaction: CoinTransaction = {
id: uuid(), id: crypto.randomUUID(),
amount: -amount, amount: -amount,
type, type,
description, description,
@@ -390,7 +370,7 @@ export async function loadUsersData(): Promise<UserData> {
try { try {
return await loadData<UserData>('auth') return await loadData<UserData>('auth')
} catch { } catch {
return getDefaultUsersData() return getDefaultUsersData<UserData>()
} }
} }
@@ -434,7 +414,7 @@ export async function createUser(formData: FormData): Promise<User> {
const newUser: User = { const newUser: User = {
id: uuid(), id: crypto.randomUUID(),
username, username,
password: hashedPassword, password: hashedPassword,
permissions, permissions,

View File

@@ -12,20 +12,12 @@ import { Suspense } from 'react'
import { loadCoinsData, loadHabitsData, loadServerSettings, loadSettings, loadUsersData, loadWishlistData } from './actions/data' import { loadCoinsData, loadHabitsData, loadServerSettings, loadSettings, loadUsersData, loadWishlistData } from './actions/data'
import './globals.css' import './globals.css'
// Inter (clean, modern, excellent readability)
// const inter = Inter({
// subsets: ['latin'],
// weight: ['400', '500', '600', '700']
// })
// Clean and contemporary // Clean and contemporary
const dmSans = DM_Sans({ const activeFont = DM_Sans({
subsets: ['latin'], subsets: ['latin'],
weight: ['400', '500', '600', '700'] weight: ['400', '500', '600', '700']
}) })
const activeFont = dmSans
export const metadata = { export const metadata = {
title: 'HabitTrove', title: 'HabitTrove',
description: 'Track your habits and get rewarded', description: 'Track your habits and get rewarded',

View File

@@ -123,7 +123,7 @@ export default function AddEditHabitModal({ onClose, onSave, habit, isTask }: Ad
}} }}
/> />
</div> </div>
</div>ohsimpson </div>
<div className="grid grid-cols-4 items-center gap-4"> <div className="grid grid-cols-4 items-center gap-4">
<Label htmlFor="description" className="text-right"> <Label htmlFor="description" className="text-right">
{t('descriptionLabel')} {t('descriptionLabel')}

View File

@@ -1,50 +1,23 @@
import { useAtom } from 'jotai'; import { addCoins, removeCoins, saveCoinsData } from '@/app/actions/data';
import { useState, useEffect, useMemo } from 'react'; import { toast } from '@/hooks/use-toast';
import { useTranslations } from 'next-intl';
import { calculateCoinsEarnedToday, calculateCoinsSpentToday, calculateTotalEarned, calculateTotalSpent, calculateTransactionsToday, checkPermission, roundToInteger } from '@/lib/utils'
import { import {
coinsAtom, coinsAtom,
coinsBalanceAtom,
coinsEarnedTodayAtom, coinsEarnedTodayAtom,
coinsSpentTodayAtom,
currentUserAtom,
settingsAtom,
totalEarnedAtom, totalEarnedAtom,
totalSpentAtom, totalSpentAtom,
coinsSpentTodayAtom,
transactionsTodayAtom, transactionsTodayAtom,
coinsBalanceAtom,
settingsAtom,
usersAtom, usersAtom,
currentUserAtom, } from '@/lib/atoms';
} from '@/lib/atoms' import { MAX_COIN_LIMIT } from '@/lib/constants';
import { addCoins, removeCoins, saveCoinsData } from '@/app/actions/data' import { CoinsData } from '@/lib/types';
import { CoinsData, User } from '@/lib/types' import { calculateCoinsEarnedToday, calculateCoinsSpentToday, calculateTotalEarned, calculateTotalSpent, calculateTransactionsToday, handlePermissionCheck, roundToInteger } from '@/lib/utils';
import { toast } from '@/hooks/use-toast' import { useAtom } from 'jotai';
import { MAX_COIN_LIMIT } from '@/lib/constants' import { useTranslations } from 'next-intl';
import { useEffect, useMemo, useState } from 'react';
function handlePermissionCheck(
user: User | undefined,
resource: 'habit' | 'wishlist' | 'coins',
action: 'write' | 'interact',
tCommon: (key: string, values?: Record<string, any>) => string
): boolean {
if (!user) {
toast({
title: tCommon("authenticationRequiredTitle"),
description: tCommon("authenticationRequiredDescription"),
variant: "destructive",
})
return false
}
if (!user.isAdmin && !checkPermission(user.permissions, resource, action)) {
toast({
title: tCommon("permissionDeniedTitle"),
description: tCommon("permissionDeniedDescription", { action, resource }),
variant: "destructive",
})
return false
}
return true
}
export function useCoins(options?: { selectedUser?: string }) { export function useCoins(options?: { selectedUser?: string }) {
const t = useTranslations('useCoins'); const t = useTranslations('useCoins');

View File

@@ -1,54 +1,24 @@
import { useAtom, atom } from 'jotai'
import { useTranslations } from 'next-intl'
import { habitsAtom, coinsAtom, settingsAtom, usersAtom, habitFreqMapAtom, currentUserAtom } from '@/lib/atoms'
import { addCoins, removeCoins, saveHabitsData } from '@/app/actions/data' import { addCoins, removeCoins, saveHabitsData } from '@/app/actions/data'
import { Habit, Permission, SafeUser, User } from '@/lib/types' import { ToastAction } from '@/components/ui/toast'
import { toast } from '@/hooks/use-toast' import { toast } from '@/hooks/use-toast'
import { DateTime } from 'luxon' import { coinsAtom, currentUserAtom, habitFreqMapAtom, habitsAtom, settingsAtom, usersAtom } from '@/lib/atoms'
import { Habit } from '@/lib/types'
import { import {
getNowInMilliseconds, d2s,
getTodayInTimezone,
isSameDate,
t2d,
d2t, d2t,
getNow,
getCompletionsForDate, getCompletionsForDate,
getISODate, getISODate,
d2s, getNow,
getTodayInTimezone,
handlePermissionCheck,
isSameDate,
playSound, playSound,
checkPermission t2d
} from '@/lib/utils' } from '@/lib/utils'
import { ToastAction } from '@/components/ui/toast' import { useAtom } from 'jotai'
import { Undo2 } from 'lucide-react' import { Undo2 } from 'lucide-react'
import { DateTime } from 'luxon'
import { useTranslations } from 'next-intl'
function handlePermissionCheck(
user: SafeUser | User | undefined,
resource: 'habit' | 'wishlist' | 'coins',
action: 'write' | 'interact',
tCommon: (key: string, values?: Record<string, any>) => string
): boolean {
if (!user) {
toast({
title: tCommon("authenticationRequiredTitle"),
description: tCommon("authenticationRequiredDescription"),
variant: "destructive",
})
return false
}
if (!user.isAdmin && !checkPermission(user.permissions, resource, action)) {
toast({
title: tCommon("permissionDeniedTitle"),
description: tCommon("permissionDeniedDescription", { action, resource }),
variant: "destructive",
})
return false
}
return true
}
export function useHabits() { export function useHabits() {
const t = useTranslations('useHabits'); const t = useTranslations('useHabits');
@@ -106,7 +76,7 @@ export function useHabits() {
type: habit.isTask ? 'TASK_COMPLETION' : 'HABIT_COMPLETION', type: habit.isTask ? 'TASK_COMPLETION' : 'HABIT_COMPLETION',
relatedItemId: habit.id, relatedItemId: habit.id,
}) })
isTargetReached && playSound() playSound()
toast({ toast({
title: t("completedTitle"), title: t("completedTitle"),
description: t("earnedCoinsDescription", { coinReward: habit.coinReward }), description: t("earnedCoinsDescription", { coinReward: habit.coinReward }),
@@ -207,7 +177,7 @@ export function useHabits() {
if (!handlePermissionCheck(currentUser, 'habit', 'write', tCommon)) return if (!handlePermissionCheck(currentUser, 'habit', 'write', tCommon)) return
const newHabit = { const newHabit = {
...habit, ...habit,
id: habit.id || getNowInMilliseconds().toString() id: habit.id || crypto.randomUUID()
} }
const updatedHabits = habit.id const updatedHabits = habit.id
? habitsData.habits.map(h => h.id === habit.id ? newHabit : h) ? habitsData.habits.map(h => h.id === habit.id ? newHabit : h)

View File

@@ -1,40 +1,13 @@
import { removeCoins, saveWishlistItems } from '@/app/actions/data'
import { toast } from '@/hooks/use-toast'
import { coinsAtom, currentUserAtom, wishlistAtom } from '@/lib/atoms'
import { WishlistItemType } from '@/lib/types'
import { handlePermissionCheck } from '@/lib/utils'
import { celebrations } from '@/utils/celebrations'
import { useAtom } from 'jotai' import { useAtom } from 'jotai'
import { useTranslations } from 'next-intl' import { useTranslations } from 'next-intl'
import { wishlistAtom, coinsAtom, currentUserAtom } from '@/lib/atoms'
import { saveWishlistItems, removeCoins } from '@/app/actions/data'
import { toast } from '@/hooks/use-toast'
import { WishlistItemType, User, SafeUser } from '@/lib/types'
import { celebrations } from '@/utils/celebrations'
import { checkPermission } from '@/lib/utils'
import { useCoins } from './useCoins' import { useCoins } from './useCoins'
function handlePermissionCheck(
user: User | SafeUser | undefined,
resource: 'habit' | 'wishlist' | 'coins',
action: 'write' | 'interact',
tCommon: (key: string, values?: Record<string, any>) => string
): boolean {
if (!user) {
toast({
title: tCommon("authenticationRequiredTitle"),
description: tCommon("authenticationRequiredDescription"),
variant: "destructive",
})
return false
}
if (!user.isAdmin && !checkPermission(user.permissions, resource, action)) {
toast({
title: tCommon("permissionDeniedTitle"),
description: tCommon("permissionDeniedDescription", { action, resource }),
variant: "destructive",
})
return false
}
return true
}
export function useWishlist() { export function useWishlist() {
const t = useTranslations('useWishlist'); const t = useTranslations('useWishlist');
const tCommon = useTranslations('Common'); const tCommon = useTranslations('Common');

View File

@@ -4,10 +4,11 @@ import {
calculateTotalEarned, calculateTotalEarned,
calculateTotalSpent, calculateTotalSpent,
calculateTransactionsToday, calculateTransactionsToday,
generateCryptoHash,
getCompletionsForToday, getCompletionsForToday,
getHabitFreq, getHabitFreq,
getTodayInTimezone,
isHabitDue, isHabitDue,
prepareDataForHashing,
roundToInteger, roundToInteger,
t2d t2d
} from "@/lib/utils"; } from "@/lib/utils";
@@ -15,6 +16,7 @@ import { atom } from "jotai";
import { atomFamily, atomWithStorage } from "jotai/utils"; import { atomFamily, atomWithStorage } from "jotai/utils";
import { DateTime } from "luxon"; import { DateTime } from "luxon";
import { import {
CoinsData,
CompletionCache, CompletionCache,
Freq, Freq,
getDefaultCoinsData, getDefaultCoinsData,
@@ -24,7 +26,12 @@ import {
getDefaultUsersData, getDefaultUsersData,
getDefaultWishlistData, getDefaultWishlistData,
Habit, Habit,
UserId HabitsData,
ServerSettings,
Settings,
UserData,
UserId,
WishlistData
} from "./types"; } from "./types";
export interface BrowserSettings { export interface BrowserSettings {
@@ -39,12 +46,12 @@ export const browserSettingsAtom = atomWithStorage('browserSettings', {
expandedWishlist: false expandedWishlist: false
} as BrowserSettings) } as BrowserSettings)
export const usersAtom = atom(getDefaultUsersData()) export const usersAtom = atom(getDefaultUsersData<UserData>())
export const settingsAtom = atom(getDefaultSettings()); export const settingsAtom = atom(getDefaultSettings<Settings>());
export const habitsAtom = atom(getDefaultHabitsData()); export const habitsAtom = atom(getDefaultHabitsData<HabitsData>());
export const coinsAtom = atom(getDefaultCoinsData()); export const coinsAtom = atom(getDefaultCoinsData<CoinsData>());
export const wishlistAtom = atom(getDefaultWishlistData()); export const wishlistAtom = atom(getDefaultWishlistData<WishlistData>());
export const serverSettingsAtom = atom(getDefaultServerSettings()); export const serverSettingsAtom = atom(getDefaultServerSettings<ServerSettings>());
// Derived atom for coins earned today // Derived atom for coins earned today
export const coinsEarnedTodayAtom = atom((get) => { export const coinsEarnedTodayAtom = atom((get) => {
@@ -121,8 +128,6 @@ export const pomodoroAtom = atom<PomodoroAtom>({
minimized: false, minimized: false,
}) })
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)
@@ -229,10 +234,3 @@ export const habitsByDateFamily = atomFamily((dateString: string) =>
return habits.filter(habit => isHabitDue({ habit, timezone, date })); return habits.filter(habit => isHabitDue({ habit, timezone, date }));
}) })
); );
// Derived atom for daily habits
export const dailyHabitsAtom = atom((get) => {
const settings = get(settingsAtom);
const today = getTodayInTimezone(settings.system.timezone);
return get(habitsByDateFamily(today));
});

View File

@@ -4,7 +4,7 @@
import { useAtom } from 'jotai' import { useAtom } from 'jotai'
import { useSession } from "next-auth/react" import { useSession } from "next-auth/react"
import { usersAtom } from './atoms' import { usersAtom } from './atoms'
import { checkPermission } from './utils' import { hasPermission } from './utils'
export function useHelpers() { export function useHelpers() {
const { data: session, status } = useSession() const { data: session, status } = useSession()
@@ -30,8 +30,7 @@ export function useHelpers() {
currentUser, currentUser,
usersData, usersData,
status, status,
hasPermission: (resource: 'habit' | 'wishlist' | 'coins', action: 'write' | 'interact') => currentUser?.isAdmin || hasPermission,
checkPermission(currentUser?.permissions, resource, action),
isIOS: iOS(), isIOS: iOS(),
} }
} }

View File

@@ -1,5 +1,4 @@
import { RRule } from "rrule" import { RRule } from "rrule"
import { uuid } from "./utils"
import { DateTime } from "luxon" import { DateTime } from "luxon"
export type UserId = string export type UserId = string
@@ -97,33 +96,38 @@ export interface WishlistData {
} }
// Default value functions // Default value functions
export const getDefaultUsersData = (): UserData => ({ export function getDefaultUsersData<UserData>(): UserData {
return {
users: [ users: [
{ {
id: uuid(), id: crypto.randomUUID(),
username: 'admin', username: 'admin',
// password: '', // No default password for admin initially? Or set a secure default? // password: '', // No default password for admin initially? Or set a secure default?
isAdmin: true, isAdmin: true,
lastNotificationReadTimestamp: undefined, // Initialize as undefined lastNotificationReadTimestamp: undefined, // Initialize as undefined
} }
] ]
}); } as UserData;
};
export const getDefaultHabitsData = (): HabitsData => ({ export function getDefaultHabitsData<HabitsData>(): HabitsData {
habits: [] return { habits: [] } as HabitsData;
}); }
export function getDefaultTasksData<TasksData>(): TasksData {
return { tasks: [] } as TasksData;
};
export const getDefaultCoinsData = (): CoinsData => ({ export function getDefaultCoinsData<CoinsData>(): CoinsData {
balance: 0, return { balance: 0, transactions: [] } as CoinsData;
transactions: [] };
});
export const getDefaultWishlistData = (): WishlistData => ({ export function getDefaultWishlistData<WishlistData>(): WishlistData {
items: [] return { items: [] } as WishlistData;
}); }
export const getDefaultSettings = (): Settings => ({ export function getDefaultSettings<Settings>(): Settings {
return {
ui: { ui: {
useNumberFormatting: true, useNumberFormatting: true,
useGrouping: true, useGrouping: true,
@@ -135,14 +139,15 @@ export const getDefaultSettings = (): Settings => ({
language: 'en', // Default language language: 'en', // Default language
}, },
profile: {} profile: {}
}); } as Settings;
};
export const getDefaultServerSettings = (): ServerSettings => ({ export function getDefaultServerSettings<ServerSettings>(): ServerSettings {
isDemo: false return { isDemo: false } as ServerSettings;
}) }
// Map of data types to their default values // Map of data types to their default values
export const DATA_DEFAULTS = { export const DATA_DEFAULTS: { [key: string]: <T>() => T } = {
wishlist: getDefaultWishlistData, wishlist: getDefaultWishlistData,
habits: getDefaultHabitsData, habits: getDefaultHabitsData,
coins: getDefaultCoinsData, coins: getDefaultCoinsData,

View File

@@ -3,12 +3,9 @@ import {
cn, cn,
getTodayInTimezone, getTodayInTimezone,
getNow, getNow,
getNowInMilliseconds,
t2d, t2d,
d2t, d2t,
d2s, d2s,
d2sDate,
d2n,
isSameDate, isSameDate,
calculateCoinsEarnedToday, calculateCoinsEarnedToday,
calculateTotalEarned, calculateTotalEarned,
@@ -16,16 +13,15 @@ import {
calculateCoinsSpentToday, calculateCoinsSpentToday,
isHabitDueToday, isHabitDueToday,
isHabitDue, isHabitDue,
uuid,
isTaskOverdue, isTaskOverdue,
deserializeRRule, deserializeRRule,
serializeRRule, serializeRRule,
convertHumanReadableFrequencyToMachineReadable, convertHumanReadableFrequencyToMachineReadable,
convertMachineReadableFrequencyToHumanReadable, convertMachineReadableFrequencyToHumanReadable,
prepareDataForHashing, prepareDataForHashing,
generateCryptoHash,
getUnsupportedRRuleReason, getUnsupportedRRuleReason,
roundToInteger roundToInteger,
generateCryptoHash
} 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";
@@ -178,32 +174,6 @@ describe('isTaskOverdue', () => {
}) })
}) })
describe('uuid', () => {
test('should generate valid UUIDs', () => {
const id = uuid()
// UUID v4 format: 8-4-4-4-12 hex digits
expect(id).toMatch(/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/)
})
test('should generate unique UUIDs', () => {
const ids = new Set()
for (let i = 0; i < 1000; i++) {
ids.add(uuid())
}
// All 1000 UUIDs should be unique
expect(ids.size).toBe(1000)
})
test('should generate v4 UUIDs', () => {
const id = uuid()
// Version 4 UUID has specific bits set:
// - 13th character is '4'
// - 17th character is '8', '9', 'a', or 'b'
expect(id.charAt(14)).toBe('4')
expect('89ab').toContain(id.charAt(19))
})
})
describe('datetime utilities', () => { describe('datetime utilities', () => {
let fixedNow: DateTime; let fixedNow: DateTime;
let currentDateIndex = 0; let currentDateIndex = 0;
@@ -321,13 +291,6 @@ describe('getNow', () => {
}) })
}) })
describe('getNowInMilliseconds', () => {
test('should return current time in milliseconds', () => {
const now = DateTime.now().setZone('UTC')
expect(getNowInMilliseconds()).toBe(now.toMillis().toString())
})
})
describe('timestamp conversion utilities', () => { describe('timestamp conversion utilities', () => {
const testTimestamp = '2024-01-01T00:00:00.000Z'; const testTimestamp = '2024-01-01T00:00:00.000Z';
const testDateTime = DateTime.fromISO(testTimestamp); const testDateTime = DateTime.fromISO(testTimestamp);
@@ -351,16 +314,6 @@ describe('timestamp conversion utilities', () => {
const customFormat = d2s({ dateTime: testDateTime, format: 'yyyy-MM-dd', timezone: 'utc' }); const customFormat = d2s({ dateTime: testDateTime, format: 'yyyy-MM-dd', timezone: 'utc' });
expect(customFormat).toBe('2024-01-01') expect(customFormat).toBe('2024-01-01')
}) })
test('d2sDate should format DateTime as date string', () => {
const result = d2sDate({ dateTime: testDateTime });
expect(result).toBeString()
})
test('d2n should convert DateTime to milliseconds string', () => {
const result = d2n({ dateTime: testDateTime });
expect(result).toBe('1704067200000')
})
}) })
describe('isSameDate', () => { describe('isSameDate', () => {
@@ -989,11 +942,11 @@ describe('convertMachineReadableFrequencyToHumanReadable', () => {
}) })
describe('freshness utilities', () => { describe('freshness utilities', () => {
const mockSettings: Settings = getDefaultSettings(); const mockSettings: Settings = getDefaultSettings<Settings>();
const mockHabits: HabitsData = getDefaultHabitsData(); const mockHabits: HabitsData = getDefaultHabitsData<HabitsData>();
const mockCoins: CoinsData = getDefaultCoinsData(); const mockCoins: CoinsData = getDefaultCoinsData<CoinsData>();
const mockWishlist: WishlistData = getDefaultWishlistData(); const mockWishlist: WishlistData = getDefaultWishlistData<WishlistData>();
const mockUsers: UserData = getDefaultUsersData(); const mockUsers: UserData = getDefaultUsersData<UserData>();
// Add a user to mockUsers for more realistic testing // Add a user to mockUsers for more realistic testing
mockUsers.users.push({ mockUsers.users.push({
@@ -1038,11 +991,11 @@ describe('freshness utilities', () => {
}); });
test('should handle empty data consistently', () => { test('should handle empty data consistently', () => {
const emptySettings = getDefaultSettings(); const emptySettings = getDefaultSettings<Settings>();
const emptyHabits = getDefaultHabitsData(); const emptyHabits = getDefaultHabitsData<HabitsData>();
const emptyCoins = getDefaultCoinsData(); const emptyCoins = getDefaultCoinsData<CoinsData>();
const emptyWishlist = getDefaultWishlistData(); const emptyWishlist = getDefaultWishlistData<WishlistData>();
const emptyUsers = getDefaultUsersData(); const emptyUsers = getDefaultUsersData<UserData>();
const string1 = prepareDataForHashing(emptySettings, emptyHabits, emptyCoins, emptyWishlist, emptyUsers); const string1 = prepareDataForHashing(emptySettings, emptyHabits, emptyCoins, emptyWishlist, emptyUsers);
const string2 = prepareDataForHashing(emptySettings, emptyHabits, emptyCoins, emptyWishlist, emptyUsers); const string2 = prepareDataForHashing(emptySettings, emptyHabits, emptyCoins, emptyWishlist, emptyUsers);

View File

@@ -1,13 +1,12 @@
import { clsx, type ClassValue } from "clsx" import { toast } from "@/hooks/use-toast"
import { twMerge } from "tailwind-merge" import { CoinsData, CoinTransaction, Freq, Habit, HabitsData, ParsedFrequencyResult, ParsedResultType, SafeUser, Settings, User, UserData, WishlistData } from '@/lib/types'
import { DateTime, DateTimeFormatOptions } from "luxon"
import { datetime, RRule } from 'rrule'
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 * as chrono from 'chrono-node'
import _ from "lodash" import { clsx, type ClassValue } from "clsx"
import { v4 as uuidv4 } from 'uuid' import { DateTime, DateTimeFormatOptions } from "luxon"
import stableStringify from 'json-stable-stringify'; import { Formats } from "next-intl"
import { datetime, RRule } from 'rrule'
import { twMerge } from "tailwind-merge"
import { DUE_MAP, INITIAL_DUE, RECURRENCE_RULE_MAP } from "./constants"
export function cn(...inputs: ClassValue[]) { export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs)) return twMerge(clsx(inputs))
@@ -33,12 +32,6 @@ export function getNow({ timezone = 'utc', keepLocalTime }: { timezone?: string,
return DateTime.now().setZone(timezone, { keepLocalTime }); return DateTime.now().setZone(timezone, { keepLocalTime });
} }
// get current time in epoch milliseconds
export function getNowInMilliseconds() {
const now = getNow({});
return d2n({ dateTime: now });
}
// iso timestamp to datetime object, most for storage read // iso timestamp to datetime object, most for storage read
export function t2d({ timestamp, timezone }: { timestamp: string; timezone: string }) { export function t2d({ timestamp, timezone }: { timestamp: string; timezone: string }) {
return DateTime.fromISO(timestamp).setZone(timezone); return DateTime.fromISO(timestamp).setZone(timezone);
@@ -61,30 +54,11 @@ export function d2s({ dateTime, format, timezone }: { dateTime: DateTime, format
return dateTime.setZone(timezone).toLocaleString(DateTime.DATETIME_MED); return dateTime.setZone(timezone).toLocaleString(DateTime.DATETIME_MED);
} }
// convert datetime object to date string, mostly for display
export function d2sDate({ dateTime }: { dateTime: DateTime }) {
return dateTime.toLocaleString(DateTime.DATE_MED);
}
// convert datetime object to epoch milliseconds string, mostly for storage write
export function d2n({ dateTime }: { dateTime: DateTime }) {
return dateTime.toMillis().toString();
}
// compare the date portion of two datetime objects (i.e. same year, month, day) // compare the date portion of two datetime objects (i.e. same year, month, day)
export function isSameDate(a: DateTime, b: DateTime) { export function isSameDate(a: DateTime, b: DateTime) {
return a.hasSame(b, 'day'); return a.hasSame(b, 'day');
} }
export function normalizeCompletionDate(date: string, timezone: string): string {
// If already in ISO format, return as is
if (date.includes('T')) {
return date;
}
// Convert from yyyy-MM-dd to ISO format
return DateTime.fromFormat(date, 'yyyy-MM-dd', { zone: timezone }).toUTC().toISO()!;
}
export function getCompletionsForDate({ export function getCompletionsForDate({
habit, habit,
date, date,
@@ -438,22 +412,20 @@ export const openWindow = (url: string): boolean => {
return true return true
} }
export function deepMerge<T>(a: T, b: T) { export function hasPermission(
return _.merge(a, b, (x: unknown, y: unknown) => { user: User | undefined,
if (_.isArray(a)) {
return a.concat(b)
}
})
}
export function checkPermission(
permissions: Permission[] | undefined,
resource: 'habit' | 'wishlist' | 'coins', resource: 'habit' | 'wishlist' | 'coins',
action: 'write' | 'interact' action: 'write' | 'interact'
): boolean { ): boolean {
if (!permissions) return false if (!user || !user.permissions) {
return false;
return permissions.some(permission => { }
// If user is admin, they have all permissions.
if (user.isAdmin) {
return true;
}
// Otherwise, check specific permissions.
return user.permissions.some(permission => {
switch (resource) { switch (resource) {
case 'habit': case 'habit':
return permission.habit[action] return permission.habit[action]
@@ -467,27 +439,6 @@ export function checkPermission(
}) })
} }
export function uuid() {
return uuidv4()
}
export function hasPermission(
currentUser: User | undefined,
resource: 'habit' | 'wishlist' | 'coins',
action: 'write' | 'interact'
): boolean {
// If no current user, no permissions.
if (!currentUser) {
return false;
}
// If user is admin, they have all permissions.
if (currentUser.isAdmin) {
return true;
}
// Otherwise, check specific permissions.
return checkPermission(currentUser.permissions, resource, action);
}
/** /**
* Prepares a consistent string representation of the data for hashing. * 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. * It combines all relevant data pieces into a single object and then stringifies it stably.
@@ -499,22 +450,13 @@ export function prepareDataForHashing(
wishlist: WishlistData, wishlist: WishlistData,
users: UserData users: UserData
): string { ): string {
// Combine all data into a single object. return JSON.stringify({
// 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, settings,
habits, habits,
coins, coins,
wishlist, wishlist,
users, 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;
} }
/** /**
@@ -539,3 +481,31 @@ export async function generateCryptoHash(dataString: string): Promise<string | n
return null; return null;
} }
} }
export function handlePermissionCheck(
user: User | SafeUser | undefined,
resource: 'habit' | 'wishlist' | 'coins',
action: 'write' | 'interact',
tCommon: (key: string, values?: Record<string, string | number | Date> | undefined, formats?: Formats | undefined) => string
): boolean {
if (!user) {
toast({
title: tCommon("authenticationRequiredTitle"),
description: tCommon("authenticationRequiredDescription"),
variant: "destructive",
})
return false
}
if (!hasPermission(user, resource, action)) {
toast({
title: tCommon("permissionDeniedTitle"),
description: tCommon("permissionDeniedDescription", { action, resource }),
variant: "destructive",
})
return false
}
return true
}

View File

@@ -43,7 +43,6 @@
"date-fns": "^3.6.0", "date-fns": "^3.6.0",
"jotai": "^2.8.0", "jotai": "^2.8.0",
"js-confetti": "^0.12.0", "js-confetti": "^0.12.0",
"json-stable-stringify": "^1.3.0",
"linkify": "^0.2.1", "linkify": "^0.2.1",
"linkify-react": "^4.2.0", "linkify-react": "^4.2.0",
"lodash": "^4.17.21", "lodash": "^4.17.21",
@@ -63,7 +62,6 @@
"rrule": "^2.8.1", "rrule": "^2.8.1",
"tailwind-merge": "^2.6.0", "tailwind-merge": "^2.6.0",
"tailwindcss-animate": "^1.0.7", "tailwindcss-animate": "^1.0.7",
"uuid": "^11.0.5",
"web-push": "^3.6.7", "web-push": "^3.6.7",
"zod": "^3.24.1" "zod": "^3.24.1"
}, },
@@ -72,7 +70,6 @@
"@tailwindcss/typography": "^0.5.15", "@tailwindcss/typography": "^0.5.15",
"@types/archiver": "^6.0.3", "@types/archiver": "^6.0.3",
"@types/bun": "^1.1.14", "@types/bun": "^1.1.14",
"@types/json-stable-stringify": "^1.1.0",
"@types/lodash": "^4.17.15", "@types/lodash": "^4.17.15",
"@types/luxon": "^3.4.2", "@types/luxon": "^3.4.2",
"@types/node": "^20.17.10", "@types/node": "^20.17.10",