### Initialize Development Environment Source: https://github.com/dohsimpson/habittrove/blob/main/README.md Commands to clone the repository and install dependencies. ```bash git clone https://github.com/dohsimpson/habittrove.git cd habittrove ``` ```bash npm install --force ``` -------------------------------- ### Run Development Server Source: https://github.com/dohsimpson/habittrove/blob/main/README.md Commands to configure the development environment and start the local server. ```bash npm run setup:dev ``` ```bash npm run dev ``` -------------------------------- ### Example: Adding Korean Translation to HabitTrove Source: https://github.com/dohsimpson/habittrove/blob/main/docs/translation-guide.md A step-by-step example demonstrating the process of adding Korean translations, including file copying, UI updates, README modification, changelog entry, and version bumping. ```bash # 1. Copy translation file cp /path/to/ko.json messages/ko.json # 2. Add to settings page # Add: # 3. Update README.md # Change: 简体中文, 日본語 # To: 简体中文, 한국어, 日본語 # 4. Add changelog entry # Create new version section with language addition # 5. Bump package version # Update version in package.json ``` -------------------------------- ### Deploy HabitTrove with Docker Source: https://github.com/dohsimpson/habittrove/blob/main/README.md Commands to generate an authentication secret and start the application using Docker Compose or direct docker run. ```bash # Generate a secure authentication secret export AUTH_SECRET=$(openssl rand -base64 32) echo $AUTH_SECRET # Using docker-compose (recommended) ## Update the AUTH_SECRET environment variable in docker-compose.yaml nano docker-compose.yaml ## Start the container docker compose up -d # Or using docker run directly docker run -d \ -p 3000:3000 \ -v ./data:/app/data \ -v ./backups:/app/backups \ -e AUTH_SECRET=$AUTH_SECRET \ dohsimpson/habittrove ``` -------------------------------- ### Validate JSON Structure with jq Source: https://github.com/dohsimpson/habittrove/blob/main/docs/translation-guide.md Ensures the translation JSON file is valid and compares its key structure against the English template. Requires `jq` to be installed. ```bash jq empty messages/{language-code}.json ``` ```bash diff <(jq -S . messages/en.json | jq -r 'keys | sort | .[]') <(jq -S . messages/{language-code}.json | jq -r 'keys | sort | .[]') ``` -------------------------------- ### Prepare Docker Data Directories Source: https://github.com/dohsimpson/habittrove/blob/main/README.md Create the necessary directories and set ownership for container persistence. ```bash mkdir -p data backups chown -R 1001:1001 data backups # Required for the nextjs user in container ``` -------------------------------- ### Run Tests and Build for Production Source: https://github.com/dohsimpson/habittrove/blob/main/README.md Commands to execute the test suite and generate a production build. ```bash npm test ``` ```bash npm run build ``` -------------------------------- ### Build and Run Docker Locally Source: https://github.com/dohsimpson/habittrove/blob/main/README.md Commands to build the Docker image from source and run it locally. ```bash # Build the Docker image npm run docker-build # Run the container npm run docker-run ``` -------------------------------- ### Create Data Directories Source: https://context7.com/dohsimpson/habittrove/llms.txt Creates necessary data and backup directories and sets their ownership to user ID 1001. This ensures persistent storage for the application. ```bash # Create data directories with correct permissions mkdir -p data backups chown -R 1001:1001 data backups ``` -------------------------------- ### Deploy HabitTrove with Docker Run Source: https://context7.com/dohsimpson/habittrove/llms.txt Deploys HabitTrove using a single `docker run` command. This method requires manual configuration of ports, volumes, and environment variables. ```bash # Or using docker run docker run -d \ -p 3000:3000 \ -v ./data:/app/data \ -v ./backups:/app/backups \ -e AUTH_SECRET=$AUTH_SECRET \ dohsimpson/habittrove ``` -------------------------------- ### Deploy HabitTrove with Docker Compose Source: https://context7.com/dohsimpson/habittrove/llms.txt Deploys HabitTrove using Docker Compose. This is the recommended method for setting up the application with persistent volumes and necessary environment variables. ```bash # Using docker-compose (recommended) docker compose up -d ``` -------------------------------- ### Run Automatic Daily Backup Source: https://context7.com/dohsimpson/habittrove/llms.txt Initiates a daily backup of the application's data directory. It checks if backups are enabled, creates a timestamped zip archive, and then rotates old backups to maintain a limited number of recent backups. ```typescript // lib/backup.ts import fs from 'fs/promises' import { createWriteStream } from 'fs' import path from 'path' import archiver from 'archiver' import { loadSettings } from '@/app/actions/data' import { DateTime } from 'luxon' const BACKUP_DIR = path.join(process.cwd(), 'backups') const DATA_DIR = path.join(process.cwd(), 'data') const MAX_BACKUPS = 7 export async function runBackup() { const settings = await loadSettings() if (!settings.system.autoBackupEnabled) { console.log('Auto backup is disabled. Skipping.') return } console.log('Starting daily backup...') // Ensure backup directory exists await fs.mkdir(BACKUP_DIR, { recursive: true }) const timestamp = DateTime.now().toFormat('yyyy-MM-dd_HH-mm-ss') const backupFileName = `backup-${timestamp}.zip` const backupFilePath = path.join(BACKUP_DIR, backupFileName) const output = createWriteStream(backupFilePath) const archive = archiver('zip', { zlib: { level: 9 } }) return new Promise((resolve, reject) => { output.on('close', async () => { console.log(`Backup created: ${backupFileName} (${archive.pointer()} bytes)`) await rotateBackups() resolve() }) archive.on('error', reject) archive.pipe(output) archive.directory(DATA_DIR, false) archive.finalize() }) } async function rotateBackups() { const files = await fs.readdir(BACKUP_DIR) const backupFiles = files .filter(f => f.startsWith('backup-') && f.endsWith('.zip')) .map(f => ({ name: f, path: path.join(BACKUP_DIR, f) })) if (backupFiles.length <= MAX_BACKUPS) return // Get file stats and sort by modification time const fileStats = await Promise.all( backupFiles.map(async (file) => ({ ...file, stat: await fs.stat(file.path), })) ) fileStats.sort((a, b) => a.stat.mtime.getTime() - b.stat.mtime.getTime()) // Delete oldest backups const filesToDelete = fileStats.slice(0, fileStats.length - MAX_BACKUPS) for (const file of filesToDelete) { await fs.unlink(file.path) console.log(`Rotated old backup: ${file.name}`) } } ``` -------------------------------- ### Settings Interface Source: https://context7.com/dohsimpson/habittrove/llms.txt Defines user settings for UI preferences, system configurations like timezone and language, and profile information. ```typescript // User settings interface Settings { ui: { useNumberFormatting: boolean useGrouping: boolean } system: { timezone: string weekStartDay: 0 | 1 | 2 | 3 | 4 | 5 | 6 // 0=Sunday, 1=Monday, etc. autoBackupEnabled: boolean language: string // 'en', 'es', 'de', 'fr', 'ru', 'zh', 'ja', 'ko', 'ca' } profile: { avatarPath?: string } } ``` -------------------------------- ### Create User Action Source: https://context7.com/dohsimpson/habittrove/llms.txt Handles the creation of a new user, including password hashing and validation against a schema. Ensures username uniqueness before saving. ```typescript // app/actions/data.ts - User management functions export async function createUser(formData: FormData): Promise { const username = formData.get('username') as string let password = formData.get('password') as string | undefined const avatarPath = formData.get('avatarPath') as string const permissions = formData.get('permissions') ? JSON.parse(formData.get('permissions') as string) : undefined // Validate against schema await signInSchema.parseAsync({ username, password }) const data = await loadUsersData() if (data.users.some(user => user.username === username)) { throw new Error('Username already exists') } const hashedPassword = password ? saltAndHashPassword(password) : undefined const newUser: User = { id: uuid(), username, password: hashedPassword, permissions, isAdmin: false, lastNotificationReadTimestamp: undefined, ...(avatarPath && { avatarPath }) } await saveUsersData({ users: [...data.users, newUser] }) return sanitizeUserData({ users: [newUser] }).users[0] } ``` -------------------------------- ### Implement useWishlist Hook Source: https://context7.com/dohsimpson/habittrove/llms.txt This hook manages wishlist state using Jotai atoms and handles server-side persistence for wishlist items and coin transactions. ```typescript // hooks/useWishlist.tsx import { useAtom } from 'jotai' import { wishlistAtom, coinsAtom, currentUserAtom } from '@/lib/atoms' import { saveWishlistItems, removeCoins } from '@/app/actions/data' import { WishlistItemType } from '@/lib/types' import { celebrations } from '@/utils/celebrations' export function useWishlist() { const [wishlist, setWishlist] = useAtom(wishlistAtom) const [coins, setCoins] = useAtom(coinsAtom) const { balance } = useCoins() // Add a new wishlist item const addWishlistItem = async (item: Omit) => { const newItem = { ...item, id: Date.now().toString() } const newItems = [...wishlist.items, newItem] setWishlist({ items: newItems }) await saveWishlistItems({ items: newItems }) } // Edit an existing item const editWishlistItem = async (updatedItem: WishlistItemType) => { const newItems = wishlist.items.map(item => item.id === updatedItem.id ? updatedItem : item ) setWishlist({ items: newItems }) await saveWishlistItems({ items: newItems }) } // Delete a wishlist item const deleteWishlistItem = async (id: string) => { const newItems = wishlist.items.filter(item => item.id !== id) setWishlist({ items: newItems }) await saveWishlistItems({ items: newItems }) } // Redeem a reward - deduct coins and trigger celebration const redeemWishlistItem = async (item: WishlistItemType) => { if (balance < item.coinCost) { toast({ title: "Not enough coins", description: `You need ${item.coinCost - balance} more coins`, variant: "destructive", }) return false } // Check redemption limits if (item.targetCompletions !== undefined && item.targetCompletions <= 0) { toast({ title: "Redemption limit reached", variant: "destructive" }) return false } // Deduct coins const data = await removeCoins({ amount: item.coinCost, description: `Redeemed reward: ${item.name}`, type: 'WISH_REDEMPTION', relatedItemId: item.id }) setCoins(data) // Update target completions if set if (item.targetCompletions !== undefined) { const newItems = wishlist.items.map(wishlistItem => { if (wishlistItem.id === item.id) { const newTarget = wishlistItem.targetCompletions! - 1 return newTarget <= 0 ? { ...wishlistItem, targetCompletions: undefined, archived: true } : { ...wishlistItem, targetCompletions: newTarget } } return wishlistItem }) setWishlist({ items: newItems }) await saveWishlistItems({ items: newItems }) } // Trigger celebration effect celebrations.emojiParty() // Open linked URL if present if (item.link) { window.open(item.link, '_blank') } return true } const canRedeem = (cost: number) => balance >= cost return { addWishlistItem, editWishlistItem, deleteWishlistItem, redeemWishlistItem, archiveWishlistItem, unarchiveWishlistItem, canRedeem, wishlistItems: wishlist.items } } ``` -------------------------------- ### Manage coin balances with useCoins hook Source: https://context7.com/dohsimpson/habittrove/llms.txt Provides methods to add, remove, and update coin transactions. Requires Jotai atoms for state management and server actions for data persistence. ```typescript // hooks/useCoins.tsx import { useAtom } from 'jotai' import { coinsAtom, settingsAtom, usersAtom, currentUserAtom, coinsBalanceAtom } from '@/lib/atoms' import { addCoins, removeCoins, saveCoinsData } from '@/app/actions/data' export function useCoins(options?: { selectedUser?: string }) { const [coins, setCoins] = useAtom(coinsAtom) const [settings] = useAtom(settingsAtom) const [users] = useAtom(usersAtom) const [currentUser] = useAtom(currentUserAtom) const [loggedInUserBalance] = useAtom(coinsBalanceAtom) const targetUser = options?.selectedUser ? users.users.find(u => u.id === options.selectedUser) : currentUser // Filter transactions for target user const transactions = useMemo(() => { return coins.transactions.filter(t => t.userId === targetUser?.id) }, [coins, targetUser?.id]) // Calculate balance for target user const balance = useMemo(() => { if (targetUser?.id === currentUser?.id) return loggedInUserBalance return transactions.reduce((acc, t) => acc + t.amount, 0) }, [transactions, targetUser?.id, currentUser?.id, loggedInUserBalance]) // Manually add coins const add = async (amount: number, description: string, note?: string) => { if (amount <= 0 || amount > 9999) { toast({ title: "Invalid amount", variant: "destructive" }) return null } const data = await addCoins({ amount, description, type: 'MANUAL_ADJUSTMENT', note, userId: targetUser?.id }) setCoins(data) return data } // Manually remove coins const remove = async (amount: number, description: string, note?: string) => { const data = await removeCoins({ amount: Math.abs(amount), description, type: 'MANUAL_ADJUSTMENT', note, userId: targetUser?.id }) setCoins(data) return data } // Update transaction note const updateNote = async (transactionId: string, note: string) => { const updatedTransactions = coins.transactions.map(t => t.id === transactionId ? { ...t, note: note.trim() || undefined } : t ) const newData = { ...coins, transactions: updatedTransactions } await saveCoinsData(newData) setCoins(newData) } return { add, remove, updateNote, balance, transactions, coinsEarnedToday: calculateCoinsEarnedToday(transactions, settings.system.timezone), totalEarned: calculateTotalEarned(transactions), totalSpent: calculateTotalSpent(transactions), } } ``` -------------------------------- ### Load Settings Source: https://context7.com/dohsimpson/habittrove/llms.txt Loads application settings, providing default settings if the user is not found or settings are not yet persisted. Requires `getCurrentUser` and `loadData` helpers. ```typescript // Load and save settings export async function loadSettings(): Promise { const user = await getCurrentUser() if (!user) return getDefaultSettings() const data = await loadData('settings') return { ...getDefaultSettings(), ...data } } ``` -------------------------------- ### NextAuth.js Credentials Provider Configuration Source: https://context7.com/dohsimpson/habittrove/llms.txt Configures NextAuth.js with a credentials provider for username and password authentication. Includes callbacks for JWT and session management to include user ID. ```typescript import NextAuth from "next-auth" import Credentials from "next-auth/providers/credentials" import { getUser } from "./app/actions/data" import { signInSchema } from "./lib/zod" import { SessionUser } from "./lib/types" export const { handlers, signIn, signOut, auth } = NextAuth({ trustHost: true, providers: [ Credentials({ credentials: { username: {}, password: {}, }, authorize: async (credentials) => { const { username, password } = await signInSchema.parseAsync(credentials) // Verify credentials against stored user const user = await getUser(username, password) if (!user) { throw new Error("Invalid credentials.") } const safeUser: SessionUser = { id: user.id } return safeUser }, }), ], callbacks: { jwt: async ({ token, user }) => { if (user) { token.id = (user as SessionUser).id } return token }, session: async ({ session, token }) => { if (session?.user) { session.user.id = token.id as string } return session } } }) ``` -------------------------------- ### Load Habits with User Filtering Source: https://context7.com/dohsimpson/habittrove/llms.txt Loads habit data, filtering it based on the current user's admin status or inclusion in the habit's user IDs. Requires `getCurrentUser` and `loadData` helpers. ```typescript // app/actions/data.ts 'use server' import fs from 'fs/promises' import path from 'path' import { HabitsData, CoinsData, WishlistData, CoinTransaction, TransactionType } from '@/lib/types' import { getCurrentUser } from '@/lib/server-helpers' // Load habits with user-based filtering export async function loadHabitsData(): Promise { const user = await getCurrentUser() if (!user) return { habits: [] } const data = await loadData('habits') return { ...data, habits: data.habits.filter(x => user.isAdmin || x.userIds?.includes(user.id)) } } ``` -------------------------------- ### Generate Authentication Secret Source: https://context7.com/dohsimpson/habittrove/llms.txt Generates a secure, base64 encoded authentication secret using openssl. This secret is required for running the application. ```bash # Generate authentication secret export AUTH_SECRET=$(openssl rand -base64 32) ``` -------------------------------- ### POST /app/actions/data/addCoins Source: https://context7.com/dohsimpson/habittrove/llms.txt Adds a specified amount of coins to a user's balance via a manual adjustment. ```APIDOC ## POST /app/actions/data/addCoins ### Description Adds a specified amount of coins to a user's balance. Validates that the amount is between 1 and 9999. ### Method POST ### Parameters #### Request Body - **amount** (number) - Required - The amount of coins to add. - **description** (string) - Required - A description of the transaction. - **type** (string) - Required - Set to 'MANUAL_ADJUSTMENT'. - **note** (string) - Optional - Additional notes for the transaction. - **userId** (string) - Optional - The ID of the user receiving the coins. ### Response #### Success Response (200) - **coins** (object) - The updated coin data object. ``` -------------------------------- ### Standard Component Pattern Source: https://github.com/dohsimpson/habittrove/blob/main/CLAUDE.md Use this structure for standard React components in the project, utilizing Jotai atoms and custom hooks. ```typescript // Standard component pattern: export default function ComponentName() { const [data, setData] = useAtom(dataAtom) const { businessLogicFunction } = useCustomHook() // Component logic } ``` -------------------------------- ### Define Base and Derived Atoms with Jotai Source: https://context7.com/dohsimpson/habittrove/llms.txt Central state atoms for HabitTrove, including base atoms for core data and derived atoms for computed values. Use these for reactive state management. ```typescript import { atom } from 'jotai' import { atomWithStorage } from 'jotai/utils' // Base atoms - source of truth export const habitsAtom = atom(getDefaultHabitsData()) export const coinsAtom = atom(getDefaultCoinsData()) export const wishlistAtom = atom(getDefaultWishlistData()) export const settingsAtom = atom(getDefaultSettings()) export const usersAtom = atom(getDefaultPublicUsersData()) export const currentUserIdAtom = atom(undefined) // Browser-persisted settings export const browserSettingsAtom = atomWithStorage('browserSettings', { viewType: 'habits' as 'habits' | 'tasks', expandedHabits: false, expandedTasks: false, expandedWishlist: false }) // Derived atoms - computed values export const currentUserAtom = atom((get) => { const currentUserId = get(currentUserIdAtom) const users = get(usersAtom) return users.users.find(user => user.id === currentUserId) }) export const coinsBalanceAtom = atom((get) => { const loggedInUserId = get(currentUserIdAtom) if (!loggedInUserId) return 0 const coins = get(coinsAtom) return coins.transactions .filter(t => t.userId === loggedInUserId) .reduce((sum, t) => sum + t.amount, 0) }) export const coinsEarnedTodayAtom = atom((get) => { const coins = get(coinsAtom) const settings = get(settingsAtom) return calculateCoinsEarnedToday(coins.transactions, settings.system.timezone) }) // Completion cache for performance optimization export const completionCacheAtom = atom((get) => { const habits = get(habitsAtom).habits const timezone = get(settingsAtom).system.timezone const cache: { [dateKey: string]: { [habitId: string]: number } } = {} habits.forEach(habit => { habit.completions.forEach(utcTimestamp => { const localDate = t2d({ timestamp: utcTimestamp, timezone }).toFormat('yyyy-MM-dd') if (!cache[localDate]) cache[localDate] = {} cache[localDate][habit.id] = (cache[localDate][habit.id] || 0) + 1 }) }) return cache }) // Daily habits filtered by recurrence rules export const dailyHabitsAtom = atom((get) => { const settings = get(settingsAtom) const today = getTodayInTimezone(settings.system.timezone) return get(habitsByDateFamily(today)) }) ``` -------------------------------- ### Date and Time Utility Functions Source: https://context7.com/dohsimpson/habittrove/llms.txt Provides functions for handling dates and times across different timezones, converting between ISO timestamps and DateTime objects, and checking for same-day occurrences. ```typescript // lib/utils.ts import { DateTime } from 'luxon' import { RRule } from 'rrule' import { Habit, CoinTransaction, Permission } from '@/lib/types' // Get today's date string for a timezone export function getTodayInTimezone(timezone: string): string { return DateTime.now().setZone(timezone).toISODate()! } // Convert ISO timestamp to DateTime in timezone export function t2d({ timestamp, timezone }: { timestamp: string; timezone: string }) { return DateTime.fromISO(timestamp).setZone(timezone) } // Convert DateTime to ISO timestamp (UTC by default for storage) export function d2t({ dateTime, timezone = 'utc' }: { dateTime: DateTime, timezone?: string }) { return dateTime.setZone(timezone).toISO()! } // Check if two DateTimes are the same calendar day export function isSameDate(a: DateTime, b: DateTime) { return a.hasSame(b, 'day') } // Get completions count for a specific date 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 => isSameDate(t2d({ timestamp: completion, timezone }), dateObj) ).length } // Check if a habit is due on a given date export function isHabitDue({ habit, timezone, date }: { habit: Habit, timezone: string, date: DateTime }): boolean { // Tasks have a specific due date if (habit.isTask) { const taskDueDate = t2d({ timestamp: habit.frequency, timezone }) return isSameDate(taskDueDate, date) } if (habit.archived) return false // Parse recurrence rule and check if date matches const rrule = RRule.fromString(habit.frequency) if (!rrule) return false rrule.options.dtstart = date.startOf('day').toJSDate() rrule.options.count = 1 const matches = rrule.all() return matches.length > 0 } // Calculate coins earned today export function calculateCoinsEarnedToday( transactions: CoinTransaction[], timezone: string ): number { const today = getTodayInTimezone(timezone) return transactions .filter(t => isSameDate(t2d({ timestamp: t.timestamp, timezone }), t2d({ timestamp: today, timezone })) && (t.amount > 0 || t.type === 'HABIT_UNDO') ) .reduce((sum, t) => sum + t.amount, 0) } // Check user permission for a resource export function checkPermission( permissions: Permission[] | undefined, resource: 'habit' | 'wishlist' | 'coins', action: 'write' | 'interact' ): boolean { if (!permissions) return false return permissions.some(permission => permission[resource][action]) } ``` -------------------------------- ### Save Settings Source: https://context7.com/dohsimpson/habittrove/llms.txt Saves the provided settings object to persistence. Requires `saveData` helper. ```typescript export async function saveSettings(settings: Settings): Promise { await saveData('settings', settings) } ``` -------------------------------- ### POST /app/actions/data/saveCoinsData Source: https://context7.com/dohsimpson/habittrove/llms.txt Persists the entire coin data state, including updated transaction notes. ```APIDOC ## POST /app/actions/data/saveCoinsData ### Description Saves the full coin data structure, typically used for updating transaction notes. ### Method POST ### Parameters #### Request Body - **newData** (object) - Required - The full coins state object including transactions. ### Response #### Success Response (200) - **status** (string) - Confirmation of save operation. ``` -------------------------------- ### Implement useHabits Hook for Habit Management Source: https://context7.com/dohsimpson/habittrove/llms.txt This hook manages habit operations including completion, undoing completions, saving, deleting, and archiving. It uses Jotai for state management and optimistic UI updates. ```typescript // hooks/useHabits.tsx import { useAtom } from 'jotai' import { habitsAtom, coinsAtom, settingsAtom, currentUserAtom } from '@/lib/atoms' import { addCoins, removeCoins, saveHabitsData } from '@/app/actions/data' import { Habit } from '@/lib/types' export function useHabits() { const [habitsData, setHabitsData] = useAtom(habitsAtom) const [coins, setCoins] = useAtom(coinsAtom) const [settings] = useAtom(settingsAtom) const [currentUser] = useAtom(currentUserAtom) // Complete a habit for today const completeHabit = async (habit: Habit) => { const timezone = settings.system.timezone const completionsToday = getCompletionsForDate({ habit, date: getTodayInTimezone(timezone), timezone }) const target = habit.targetCompletions || 1 if (completionsToday >= target) { toast({ title: "Already completed", variant: "destructive" }) return } // Add completion timestamp const updatedHabit = { ...habit, completions: [...habit.completions, d2t({ dateTime: getNow({ timezone }) })], archived: habit.isTask && completionsToday + 1 === target ? true : habit.archived } const updatedHabits = habitsData.habits.map(h => h.id === habit.id ? updatedHabit : h) await saveHabitsData({ habits: updatedHabits }) // Award coins when target is reached if (completionsToday + 1 === target) { const updatedCoins = await addCoins({ amount: habit.coinReward, description: `Completed: ${habit.name}`, type: habit.isTask ? 'TASK_COMPLETION' : 'HABIT_COMPLETION', relatedItemId: habit.id, }) playSound() setCoins(updatedCoins) } setHabitsData({ habits: updatedHabits }) } // Undo a completion from today const undoComplete = async (habit: Habit) => { const timezone = settings.system.timezone const today = t2d({ timestamp: getTodayInTimezone(timezone), timezone }) const todayCompletions = habit.completions.filter(completion => isSameDate(t2d({ timestamp: completion, timezone }), today) ) if (todayCompletions.length > 0) { const updatedHabit = { ...habit, completions: habit.completions.slice(0, -1), archived: habit.isTask ? false : habit.archived } const updatedHabits = habitsData.habits.map(h => h.id === habit.id ? updatedHabit : h) await saveHabitsData({ habits: updatedHabits }) setHabitsData({ habits: updatedHabits }) // Remove coins if we were at target const target = habit.targetCompletions || 1 if (todayCompletions.length === target) { const updatedCoins = await removeCoins({ amount: habit.coinReward, description: `Undid completion: ${habit.name}`, type: habit.isTask ? 'TASK_UNDO' : 'HABIT_UNDO', relatedItemId: habit.id, }) setCoins(updatedCoins) } } } // Save a new or existing habit const saveHabit = async (habit: Omit & { id?: string }) => { const newHabit = { ...habit, id: habit.id || Date.now().toString() } const updatedHabits = habit.id ? habitsData.habits.map(h => h.id === habit.id ? newHabit : h) : [...habitsData.habits, newHabit] await saveHabitsData({ habits: updatedHabits }) setHabitsData({ habits: updatedHabits }) } // Delete a habit const deleteHabit = async (id: string) => { const updatedHabits = habitsData.habits.filter(h => h.id !== id) await saveHabitsData({ habits: updatedHabits }) setHabitsData({ habits: updatedHabits }) } // Archive/unarchive habits const archiveHabit = async (id: string) => { const updatedHabits = habitsData.habits.map(h => h.id === id ? { ...h, archived: true } : h ) await saveHabitsData({ habits: updatedHabits }) setHabitsData({ habits: updatedHabits }) } return { completeHabit, undoComplete, saveHabit, deleteHabit, archiveHabit, unarchiveHabit } } ``` -------------------------------- ### Add Coins with Transaction Source: https://context7.com/dohsimpson/habittrove/llms.txt Adds coins to the balance by creating a new transaction record. Supports manual adjustments and other transaction types. Requires `getCurrentUser`, `loadCoinsData`, `saveCoinsData`, and `uuid`. ```typescript // Add coins with transaction creation export async function addCoins({ amount, description, type = 'MANUAL_ADJUSTMENT', relatedItemId, note, userId, }: { amount: number description: string type?: TransactionType relatedItemId?: string note?: string userId?: string }): Promise { const currentUser = await getCurrentUser() const data = await loadCoinsData() const newTransaction: CoinTransaction = { id: uuid(), amount, type, description, timestamp: new Date().toISOString(), ...(relatedItemId && { relatedItemId }), ...(note && { note }), userId: userId || currentUser?.id } const newData: CoinsData = { balance: data.balance + amount, transactions: [newTransaction, ...data.transactions] } await saveCoinsData(newData) return newData } ``` -------------------------------- ### WishlistItemType Definition Source: https://context7.com/dohsimpson/habittrove/llms.txt Defines the structure for wishlist items that users can redeem with coins. Includes cost, redemption limits, and optional links. ```typescript // Wishlist item for rewards users can redeem with earned coins type WishlistItemType = { id: string name: string description: string coinCost: number archived?: boolean targetCompletions?: number // Limits how many times item can be redeemed link?: string // Optional URL to open on redemption userIds?: string[] // Shared ownership drawing?: string } ``` -------------------------------- ### Validate Translation Structure with Node.js and grep Source: https://github.com/dohsimpson/habittrove/blob/main/docs/translation-guide.md Performs validation on translation files by checking JSON validity, comparing key structures using a Node.js script, and verifying placeholder consistency with `grep` and `diff`. ```bash # Check JSON validity jq empty messages/{lang}.json ``` ```javascript # Compare key structure node -e " const en = require('./messages/en.json'); const target = require('./messages/{lang}.json'); // ... deep key comparison script " ``` ```bash # Verify placeholder consistency grep -o '{[^}]*}' messages/en.json | sort | uniq > en_vars.txt grep -o '{[^}]*}' messages/{lang}.json | sort | uniq > {lang}_vars.txt diff en_vars.txt {lang}_vars.txt ``` -------------------------------- ### Update User Action Source: https://context7.com/dohsimpson/habittrove/llms.txt Updates an existing user's information, checking for username conflicts. Does not modify the user's password. ```typescript export async function updateUser( userId: string, updates: Partial> ): Promise { const data = await loadUsersData() const userIndex = data.users.findIndex(user => user.id === userId) if (userIndex === -1) throw new Error('User not found') // Check for duplicate username if (updates.username) { const isDuplicate = data.users.some( user => user.username === updates.username && user.id !== userId ) if (isDuplicate) throw new Error('Username already exists') } const updatedUser = { ...data.users[userIndex], ...updates } data.users[userIndex] = updatedUser await saveUsersData(data) return sanitizeUserData({ users: [updatedUser] }).users[0] } ``` -------------------------------- ### Habit Type Definition Source: https://context7.com/dohsimpson/habittrove/llms.txt Defines the structure for tracking habits and tasks, including frequency, rewards, and completion history. Supports shared ownership and optional drawings. ```typescript // lib/types.ts - Core type definitions // Habit type for tracking daily/weekly/monthly habits and one-time tasks type Habit = { id: string name: string description: string frequency: string // RRule string for habits, ISO timestamp for tasks coinReward: number targetCompletions?: number // Default: 1, for habits like "drink 7 glasses of water" completions: string[] // Array of UTC ISO date strings isTask?: boolean // One-time task vs recurring habit archived?: boolean pinned?: boolean userIds?: string[] // Shared ownership support drawing?: string // Optional freehand drawing data } ``` -------------------------------- ### CoinTransaction Interface Source: https://context7.com/dohsimpson/habittrove/llms.txt Defines the structure for tracking coin transactions, including earned and spent amounts, transaction types, and timestamps. Links to related items. ```typescript // Coin transaction for tracking all balance changes interface CoinTransaction { id: string amount: number // Positive for earned, negative for spent type: 'HABIT_COMPLETION' | 'HABIT_UNDO' | 'WISH_REDEMPTION' | 'MANUAL_ADJUSTMENT' | 'TASK_COMPLETION' | 'TASK_UNDO' description: string timestamp: string // UTC ISO string relatedItemId?: string // Links to habit or wishlist item note?: string userId?: string } ``` -------------------------------- ### Save Habits with User ID Assignment Source: https://context7.com/dohsimpson/habittrove/llms.txt Saves habit data, assigning the current user's ID if not already present. Non-admin users can only modify their own habits, with existing habits of other users preserved. Requires `getCurrentUser` and `loadData` helpers. ```typescript // Save habits with automatic user ID assignment export async function saveHabitsData(data: HabitsData): Promise { const user = await getCurrentUser() const newData = { ...data, habits: data.habits.map(habit => ({ ...habit, userIds: habit.userIds || (user ? [user.id] : undefined) })) } // Non-admin users can only modify their own habits if (!user?.isAdmin) { const existingData = await loadData('habits') const otherUsersHabits = existingData.habits.filter(x => user?.id && !x.userIds?.includes(user.id) ) newData.habits = [...otherUsersHabits, ...newData.habits] } await saveData('habits', newData) } ``` -------------------------------- ### Shared Ownership Filtering Pattern Source: https://github.com/dohsimpson/habittrove/blob/main/CLAUDE.md Use this pattern to filter items based on shared ownership where multiple user IDs are associated with a single record. ```typescript // Filtering for shared ownership: const userItems = allItems.filter(item => item.userIds && item.userIds.includes(targetUserId) ) ``` -------------------------------- ### POST /app/actions/data/removeCoins Source: https://context7.com/dohsimpson/habittrove/llms.txt Removes a specified amount of coins from a user's balance via a manual adjustment. ```APIDOC ## POST /app/actions/data/removeCoins ### Description Removes a specified amount of coins from a user's balance. ### Method POST ### Parameters #### Request Body - **amount** (number) - Required - The amount of coins to remove. - **description** (string) - Required - A description of the transaction. - **type** (string) - Required - Set to 'MANUAL_ADJUSTMENT'. - **note** (string) - Optional - Additional notes for the transaction. - **userId** (string) - Optional - The ID of the user from whom coins are removed. ### Response #### Success Response (200) - **coins** (object) - The updated coin data object. ``` -------------------------------- ### Remove Coins with Transaction Source: https://context7.com/dohsimpson/habittrove/llms.txt Removes coins by creating a negative transaction, ensuring the balance does not go below zero. Supports manual adjustments and other transaction types. Requires `getCurrentUser`, `loadCoinsData`, `saveCoinsData`, and `uuid`. ```typescript // Remove coins (negative transaction) export async function removeCoins({ amount, description, type = 'MANUAL_ADJUSTMENT', relatedItemId, note, userId, }: { amount: number description: string type?: TransactionType relatedItemId?: string note?: string userId?: string }): Promise { const currentUser = await getCurrentUser() const data = await loadCoinsData() const newTransaction: CoinTransaction = { id: uuid(), amount: -amount, type, description, timestamp: new Date().toISOString(), ...(relatedItemId && { relatedItemId }), ...(note && { note }), userId: userId || currentUser?.id } const newData: CoinsData = { balance: Math.max(0, data.balance - amount), transactions: [newTransaction, ...data.transactions] } await saveCoinsData(newData) return newData } ``` -------------------------------- ### Delete User Action Source: https://context7.com/dohsimpson/habittrove/llms.txt Removes a user and cleans up their associated data from wishlists, habits, and transactions. Ensures that shared items are not entirely removed if multiple users are associated. ```typescript export async function deleteUser(userId: string): Promise { // Remove user from all shared habits and wishlist items const wishlistData = await loadData('wishlist') wishlistData.items = wishlistData.items.reduce((acc, item) => { if (item.userIds?.includes(userId)) { if (item.userIds.length === 1) return acc // Remove if sole owner acc.push({ ...item, userIds: item.userIds.filter(id => id !== userId) }) } else { acc.push(item) } return acc }, [] as WishlistItemType[]) await saveData('wishlist', wishlistData) // Same for habits const habitsData = await loadData('habits') habitsData.habits = habitsData.habits.reduce((acc, habit) => { if (habit.userIds?.includes(userId)) { if (habit.userIds.length === 1) return acc acc.push({ ...habit, userIds: habit.userIds.filter(id => id !== userId) }) } else { acc.push(habit) } return acc }, [] as Habit[]) await saveData('habits', habitsData) // Remove user's transactions const coinsData = await loadData('coins') coinsData.transactions = coinsData.transactions.filter(t => t.userId !== userId) coinsData.balance = coinsData.transactions.reduce((sum, t) => sum + t.amount, 0) await saveData('coins', coinsData) // Finally remove the user const authData = await loadUsersData() authData.users = authData.users.filter(user => user.id !== userId) await saveUsersData(authData) } ``` === COMPLETE CONTENT === This response contains all available snippets from this library. No additional content exists. Do not make further requests.