### 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.