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, 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)) } // get today's date string for timezone export function getTodayInTimezone(timezone: string): string { const now = getNow({ timezone }); return getISODate({ dateTime: now, timezone }); } // round a number to the nearest integer export function roundToInteger(value: number): number { return Math.round(value); } export function getISODate({ dateTime, timezone }: { dateTime: DateTime, timezone: string }): string { return dateTime.setZone(timezone).toISODate()!; } // get datetime object of now export function getNow({ timezone = 'utc', keepLocalTime }: { timezone?: string, keepLocalTime?: boolean }) { 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 export function t2d({ timestamp, timezone }: { timestamp: string; timezone: string }) { return DateTime.fromISO(timestamp).setZone(timezone); } // convert datetime object to iso timestamp, mostly for storage write (be sure to use default utc timezone when writing) export function d2t({ dateTime, timezone = 'utc' }: { dateTime: DateTime, timezone?: string }) { return dateTime.setZone(timezone).toISO()!; } // convert datetime object to string, mostly for display export function d2s({ dateTime, format, timezone }: { dateTime: DateTime, format?: string | DateTimeFormatOptions, timezone: string }) { if (format) { if (typeof format === 'string') { return dateTime.setZone(timezone).toFormat(format); } else { return dateTime.setZone(timezone).toLocaleString(format); } } 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) export function isSameDate(a: DateTime, b: DateTime) { 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({ habit, date, timezone }: { habit: Habit, date: DateTime | string, timezone: string }): number { const dateObj = typeof date === 'string' ? DateTime.fromISO(date) : date return habit.completions.filter((completion: string) => isSameDate(t2d({ timestamp: completion, timezone }), dateObj) ).length } export function getCompletionsForToday({ habit, timezone }: { habit: Habit, timezone: string }): number { return getCompletionsForDate({ habit, date: getTodayInTimezone(timezone), timezone }) } export function getCompletedHabitsForDate({ habits, date, timezone }: { habits: Habit[], date: DateTime | string, timezone: string }): Habit[] { return habits.filter(habit => { const completionsToday = getCompletionsForDate({ habit, date, timezone }) const target = habit.targetCompletions || 1 return completionsToday >= target }) } export function getHabitProgress({ habit, timezone }: { habit: Habit, timezone: string }): number { const today = getTodayInTimezone(timezone) const completionsToday = getCompletionsForDate({ habit, date: today, timezone }) const target = habit.targetCompletions || 1 return Math.min(100, (completionsToday / target) * 100) } export function calculateCoinsEarnedToday(transactions: CoinTransaction[], timezone: string): number { const today = getTodayInTimezone(timezone); return transactions .filter(transaction => isSameDate(t2d({ timestamp: transaction.timestamp, timezone }), t2d({ timestamp: today, timezone })) && (transaction.amount > 0 || transaction.type === 'HABIT_UNDO') ) .reduce((sum, transaction) => sum + transaction.amount, 0); } export function calculateTotalEarned(transactions: CoinTransaction[]): number { return transactions .filter(transaction => transaction.amount > 0 || transaction.type === 'HABIT_UNDO' ) .reduce((sum, transaction) => sum + transaction.amount, 0); } export function calculateTotalSpent(transactions: CoinTransaction[]): number { return Math.abs( transactions .filter(transaction => transaction.amount < 0 && transaction.type !== 'HABIT_UNDO' ) .reduce((sum, transaction) => sum + transaction.amount, 0) ); } export function calculateCoinsSpentToday(transactions: CoinTransaction[], timezone: string): number { const today = getTodayInTimezone(timezone); return Math.abs( transactions .filter(transaction => isSameDate(t2d({ timestamp: transaction.timestamp, timezone }), t2d({ timestamp: today, timezone })) && transaction.amount < 0 && transaction.type !== 'HABIT_UNDO' ) .reduce((sum, transaction) => sum + transaction.amount, 0) ); } export function calculateTransactionsToday(transactions: CoinTransaction[], timezone: string): number { const today = getTodayInTimezone(timezone); return transactions.filter(t => isSameDate(t2d({ timestamp: t.timestamp, timezone }), t2d({ timestamp: today, timezone })) ).length; } // Enhanced validation for weekly/monthly rules function validateRecurrenceRule(rrule: RRule | null): ParsedFrequencyResult { if (!rrule) { return { result: null, message: 'Invalid recurrence rule.' }; } const unsupportedReason = getUnsupportedRRuleReason(rrule); if (unsupportedReason) { return { result: rrule, message: unsupportedReason }; } const options = rrule.origOptions; if (options.freq === RRule.WEEKLY && (!options.byweekday || !Array.isArray(options.byweekday) || options.byweekday.length === 0)) { return { result: null, message: 'Please specify day(s) of the week (e.g., "every week on Mon, Wed").' }; } if (options.freq === RRule.MONTHLY && (!options.bymonthday || !Array.isArray(options.bymonthday) || options.bymonthday.length === 0) && (!options.bysetpos || !Array.isArray(options.bysetpos) || options.bysetpos.length === 0) && // Need to check bysetpos for rules like "last Friday" (!options.byweekday || !Array.isArray(options.byweekday) || options.byweekday.length === 0)) { // Need byweekday with bysetpos return { result: null, message: 'Please specify day of the month (e.g., "every month on the 15th") or position (e.g., "every month on the last Friday").' }; } return { result: rrule, message: null }; } // Convert a human-readable frequency (recurring or non-recurring) into a machine-readable one export function convertHumanReadableFrequencyToMachineReadable({ text, timezone, isRecurring = false }: { text: string, timezone: string, isRecurring?: boolean }): ParsedFrequencyResult { text = text.trim() if (!isRecurring) { if (DUE_MAP[text]) { text = DUE_MAP[text] } const now = getNow({ timezone }) const due = chrono.parseDate(text, { instant: now.toJSDate(), timezone }) if (!due) return { result: null, message: 'Invalid due date.' } const result = due ? DateTime.fromJSDate(due).setZone(timezone) : null return { message: null, result: result ? (result.isValid ? result : null) : null } } let rrule: RRule | null if (RECURRENCE_RULE_MAP[text]) { rrule = deserializeRRule(RECURRENCE_RULE_MAP[text]) } else if (text.toLowerCase() === 'weekdays') { // Handle 'weekdays' specifically if not in the map rrule = new RRule({ freq: RRule.WEEKLY, byweekday: [RRule.MO, RRule.TU, RRule.WE, RRule.TH, RRule.FR] }); } else { try { rrule = RRule.fromText(text) } catch (error) { rrule = null } } return validateRecurrenceRule(rrule); } // convert a machine-readable rrule **string** to an rrule object export function deserializeRRule(rruleStr: string): RRule | null { try { return RRule.fromString(rruleStr); } catch (error) { return null; } } // convert a machine-readable rrule **object** to an rrule string export function serializeRRule(rrule: RRule | null): string { if (!rrule) return 'invalid'; // Handle null case explicitly return rrule.toString() } // Convert a machine-readable frequency (recurring or non-recurring) into a human-readable one export function convertMachineReadableFrequencyToHumanReadable({ frequency, isRecurRule, timezone }: { frequency: ParsedResultType, isRecurRule: boolean, timezone: string }): string { if (isRecurRule) { if (!frequency) { return 'invalid'; // Handle null/undefined for recurring rules } if (frequency instanceof RRule) { return frequency.toText(); } else if (typeof frequency === "string") { const parsedResult = deserializeRRule(frequency); return parsedResult?.toText() || 'invalid'; } else { return 'invalid'; } } else { // Handle non-recurring frequency if (!frequency) { // Use the imported constant for initial due date text return INITIAL_DUE; } if (typeof frequency === 'string') { return d2s({ dateTime: t2d({ timestamp: frequency, timezone: timezone }), timezone: timezone, format: DateTime.DATE_MED_WITH_WEEKDAY }); } else if (frequency instanceof DateTime) { return d2s({ dateTime: frequency, timezone: timezone, format: DateTime.DATE_MED_WITH_WEEKDAY }); } else { return 'invalid'; } } } export function isHabitDue({ habit, timezone, date }: { habit: Habit timezone: string date: DateTime }): boolean { // handle task if (habit.isTask) { // For tasks, frequency is stored as a UTC ISO timestamp const taskDueDate = t2d({ timestamp: habit.frequency, timezone }) return isSameDate(taskDueDate, date); } // handle habit if (habit.archived) { return false } const startOfDay = date.setZone(timezone).startOf('day') const endOfDay = date.setZone(timezone).endOf('day') const ruleText = habit.frequency const rrule = deserializeRRule(ruleText) if (!rrule) return false rrule.origOptions.tzid = timezone rrule.options.tzid = rrule.origOptions.tzid rrule.origOptions.dtstart = datetime(startOfDay.year, startOfDay.month, startOfDay.day, startOfDay.hour, startOfDay.minute, startOfDay.second) rrule.options.dtstart = rrule.origOptions.dtstart rrule.origOptions.count = 1 rrule.options.count = rrule.origOptions.count const matches = rrule.all() if (!matches.length) return false const t = DateTime.fromJSDate(matches[0]).toUTC().setZone('local', { keepLocalTime: true }).setZone(timezone) return startOfDay <= t && t <= endOfDay } export function isHabitCompleted(habit: Habit, timezone: string): boolean { return getCompletionsForToday({ habit, timezone: timezone }) >= (habit.targetCompletions || 1) } export function isTaskOverdue(habit: Habit, timezone: string): boolean { if (!habit.isTask || habit.archived) return false const dueDate = t2d({ timestamp: habit.frequency, timezone }).startOf('day') const now = getNow({ timezone }).startOf('day') return dueDate < now && !isHabitCompleted(habit, timezone) } export function isHabitDueToday({ habit, timezone }: { habit: Habit timezone: string }): boolean { const today = getNow({ timezone }) return isHabitDue({ habit, timezone, date: today }) } export function getHabitFreq(habit: Habit): Freq { if (habit.isTask) { // don't support recurring task yet return 'daily' } const rrule = RRule.fromString(habit.frequency) const freq = rrule.origOptions.freq switch (freq) { case RRule.DAILY: return 'daily' case RRule.WEEKLY: return 'weekly' case RRule.MONTHLY: return 'monthly' case RRule.YEARLY: return 'yearly' default: console.error(`Invalid frequency: ${freq} (habit: ${habit.id} ${habit.name}) (rrule: ${rrule.toString()}). Defaulting to daily`) return 'daily' } } /** * Checks if an RRule is unsupported and returns the reason. * @param rrule The RRule object to check. * @returns A string message explaining why the rule is unsupported, or null if it's supported. */ export function getUnsupportedRRuleReason(rrule: RRule): string | null { const freq = rrule.origOptions.freq; const interval = rrule.origOptions.interval || 1; // RRule defaults interval to 1 if (freq === RRule.HOURLY) { return 'Hourly frequency is not supported.'; } if (freq === RRule.MINUTELY) { return 'Minutely frequency is not supported.'; } if (freq === RRule.SECONDLY) { return 'Secondly frequency is not supported.'; } if (freq === RRule.DAILY && interval > 1) { return 'Daily frequency with intervals greater than 1 is not supported.'; } return null; // Rule is supported } // play sound (client side only, must be run in browser) export const playSound = (soundPath: string = '/sounds/timer-end.wav') => { const audio = new Audio(soundPath) audio.play().catch(error => { console.error('Error playing sound:', error) }) } // open a new window (client side only, must be run in browser) export const openWindow = (url: string): boolean => { const newWindow = window.open(url, '_blank') if (newWindow === null) { // Popup was blocked return false } return true } export function deepMerge(a: T, b: T) { return _.merge(a, b, (x: unknown, y: unknown) => { if (_.isArray(a)) { return a.concat(b) } }) } export function checkPermission( permissions: Permission[] | undefined, resource: 'habit' | 'wishlist' | 'coins', action: 'write' | 'interact' ): boolean { if (!permissions) return false return permissions.some(permission => { switch (resource) { case 'habit': return permission.habit[action] case 'wishlist': return permission.wishlist[action] case 'coins': return permission.coins[action] default: return false } }) } 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. * 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 { try { 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; } catch (error) { console.error(`Failed to generate hash: ${error}`); return null; } }