diff --git a/.gitignore b/.gitignore index 014ab87..91afeb3 100644 --- a/.gitignore +++ b/.gitignore @@ -45,3 +45,4 @@ next-env.d.ts /data.*/* Budfile certificates +/backups/* diff --git a/CHANGELOG.md b/CHANGELOG.md index 1a2e7f9..8a301bc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,13 @@ # Changelog +## Version 0.2.9 + +### Added + +* Auto backup feature: Automatically backs up data +* Backup rotation: Keeps the last 7 daily backups +* Setting to enable/disable auto backup. + ## Version 0.2.8 ### Added diff --git a/Dockerfile b/Dockerfile index afe8e5e..3d94229 100644 --- a/Dockerfile +++ b/Dockerfile @@ -43,8 +43,9 @@ ENV NEXT_TELEMETRY_DISABLED=1 RUN addgroup --system --gid 1001 nodejs RUN adduser --system --uid 1001 nextjs -# Create data directory and set permissions -RUN mkdir -p /app/data && chown nextjs:nodejs /app/data +# Create data and backups directories and set permissions +RUN mkdir -p /app/data /app/backups \ + && chown nextjs:nodejs /app/data /app/backups COPY --from=builder /app/public ./public COPY --from=builder /app/CHANGELOG.md ./ @@ -61,6 +62,6 @@ EXPOSE 3000 ENV PORT=3000 ENV HOSTNAME="0.0.0.0" -VOLUME ["/app/data"] +VOLUME ["/app/data", "/app/backups"] CMD ["node", "server.js"] diff --git a/README.md b/README.md index 4a513dc..4d5929f 100644 --- a/README.md +++ b/README.md @@ -17,6 +17,7 @@ Want to try HabitTrove before installing? Visit the public [demo instance](https - 📅 Calendar heatmap to visualize your progress (WIP) - 🌙 Dark mode support - 📲 Progressive Web App (PWA) support +- 💾 Automatic daily backups with rotation ## Usage @@ -39,8 +40,8 @@ The easiest way to run HabitTrove is using our pre-built Docker images from Dock 1. First, prepare the data directory with correct permissions: ```bash -mkdir -p data -chown -R 1001:1001 data # Required for the nextjs user in container +mkdir -p data backups +chown -R 1001:1001 data backups # Required for the nextjs user in container ``` 2. Then run using either method: @@ -51,15 +52,16 @@ export AUTH_SECRET=$(openssl rand -base64 32) echo $AUTH_SECRET # Using docker-compose (recommended) -## update the AUTH_SECRET environment variable in docker-compose file +## Update the AUTH_SECRET environment variable in docker-compose.yaml nano docker-compose.yaml -## start the container +## 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 \ # Add this line to map the backups directory -e AUTH_SECRET=$AUTH_SECRET \ dohsimpson/habittrove ``` @@ -73,9 +75,11 @@ Available image tags: Choose your tag based on needs: - Use `latest` for general production use -- Use version tags (e.g., `v0.1.4`) for reproducible deployments +- Use version tags (e.g., `v0.2.9`) for reproducible deployments - Use `dev` for testing new features +**Note on Volumes:** The application stores user data in `/app/data` and backups in `/app/backups` inside the container. The examples above map `./data` and `./backups` from your host machine to these container directories. Ensure these host directories exist and have the correct permissions (`chown -R 1001:1001 data backups`). + ### Building Locally If you want to build the image locally (useful for development): diff --git a/app/actions/data.ts b/app/actions/data.ts index b33ee96..22614df 100644 --- a/app/actions/data.ts +++ b/app/actions/data.ts @@ -64,6 +64,27 @@ async function ensureDataDir() { } } +// --- Backup Debug Action --- +export async function triggerManualBackup(): Promise<{ success: boolean; message: string }> { + // Optional: Add extra permission check if needed for debug actions + // const user = await getCurrentUser(); + // if (!user?.isAdmin) { + // return { success: false, message: "Permission denied." }; + // } + + console.log("Manual backup trigger requested..."); + try { + // Import runBackup locally to avoid potential circular dependencies if moved + const { runBackup } = await import('@/lib/backup'); + await runBackup(); + console.log("Manual backup trigger completed successfully."); + return { success: true, message: "Backup process completed successfully." }; + } catch (error) { + console.error("Manual backup trigger failed:", error); + return { success: false, message: `Backup failed: ${error instanceof Error ? error.message : 'Unknown error'}` }; + } +} + async function loadData(type: DataType): Promise { try { await ensureDataDir() diff --git a/app/debug/backup/page.tsx b/app/debug/backup/page.tsx new file mode 100644 index 0000000..c022d67 --- /dev/null +++ b/app/debug/backup/page.tsx @@ -0,0 +1,60 @@ +'use client' + +import { useState } from 'react'; +import { Button } from '@/components/ui/button'; +import { triggerManualBackup } from '@/app/actions/data'; // Import the server action +import { Loader2 } from 'lucide-react'; // For loading indicator + +export default function DebugBackupPage() { + const [isLoading, setIsLoading] = useState(false); + const [statusMessage, setStatusMessage] = useState(''); + const [isError, setIsError] = useState(false); + + const handleBackupClick = async () => { + setIsLoading(true); + setStatusMessage('Starting backup...'); + setIsError(false); + + try { + const result = await triggerManualBackup(); + setStatusMessage(result.message); + setIsError(!result.success); + } catch (error) { + console.error("Error calling triggerManualBackup action:", error); + setStatusMessage(`Client-side error: ${error instanceof Error ? error.message : 'Unknown error'}`); + setIsError(true); + } finally { + setIsLoading(false); + } + }; + + return ( +
+

Debug Backup

+
+

+ Click the button below to manually trigger the data backup process. + Check the server console logs for detailed output. Backups are stored in the `/backups` directory. +

+ + {statusMessage && ( +
+ {statusMessage} +
+ )} +
+
+ ); +} diff --git a/app/settings/page.tsx b/app/settings/page.tsx index 73fff78..a4c84d1 100644 --- a/app/settings/page.tsx +++ b/app/settings/page.tsx @@ -1,19 +1,25 @@ 'use client' import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' -import { Switch } from '@/components/ui/switch' -import { Label } from '@/components/ui/label' -import { DynamicTimeNoSSR } from '@/components/DynamicTimeNoSSR' -import { useAtom } from 'jotai' -import { settingsAtom } from '@/lib/atoms' +import { Switch } from '@/components/ui/switch'; +import { Label } from '@/components/ui/label'; +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from "@/components/ui/tooltip"; +import { DynamicTimeNoSSR } from '@/components/DynamicTimeNoSSR'; +import { useAtom } from 'jotai'; +import { settingsAtom } from '@/lib/atoms'; import { Settings, WeekDay } from '@/lib/types' import { saveSettings, uploadAvatar } from '../actions/data' import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar' -import { Button } from '@/components/ui/button' -import { User } from 'lucide-react' +import { Button } from '@/components/ui/button'; +import { User, Info } from 'lucide-react'; // Import Info icon export default function SettingsPage() { - const [settings, setSettings] = useAtom(settingsAtom) + const [settings, setSettings] = useAtom(settingsAtom); const updateSettings = async (newSettings: Settings) => { await saveSettings(newSettings) @@ -140,6 +146,46 @@ export default function SettingsPage() { + + {/* Add this section for Auto Backup */} +
+
+
+ + + + + + + +

+ When enabled, the application data (habits, coins, settings, etc.) + will be automatically backed up daily around 2 AM server time. + Backups are stored as ZIP files in the `backups/` directory + at the project root. Only the last 7 backups are kept; older + ones are automatically deleted. +

+
+
+
+
+
+ Automatically back up data daily +
+
+ + updateSettings({ + ...settings, + system: { ...settings.system, autoBackupEnabled: checked } + }) + } + /> +
+ {/* End of Auto Backup section */} + diff --git a/docker-compose.yaml b/docker-compose.yaml index 690354a..bd7502f 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -3,7 +3,8 @@ services: ports: - "3000:3000" volumes: - - "./data:/app/data" # Use a relative path instead of $(pwd) + - "./data:/app/data" + - "./backups:/app/backups" image: dohsimpson/habittrove environment: - - AUTH_SECRET=your-secret-key-here + - AUTH_SECRET=your-secret-key-here # Replace with your actual secret diff --git a/instrumentation.ts b/instrumentation.ts index 5eb114d..930a17a 100644 --- a/instrumentation.ts +++ b/instrumentation.ts @@ -1,7 +1,28 @@ -import { init } from '@/lib/env.server' // startup env var check +import { init } from '@/lib/env.server'; // startup env var check -export function register() { - if (typeof window === "undefined") { - init() - } -} \ No newline at end of file +// Ensure this function is exported +export async function register() { + // We only want to run this code on the server side + if (process.env.NEXT_RUNTIME === 'nodejs') { + console.log('Node.js runtime detected, running server-side instrumentation...'); + // Initialize environment variables first + console.log('Initializing environment variables...'); + init(); + console.log('Environment variables initialized.'); + + // Dynamically import the scheduler initializer + // Use await import() for ESM compatibility + try { + console.log('Attempting to import scheduler...'); + // Ensure the path is correct relative to the project root + const { initializeScheduler } = await import('./lib/scheduler'); + console.log('Scheduler imported successfully. Initializing...'); + initializeScheduler(); + console.log('Scheduler initialization called.'); + } catch (error) { + console.error('Failed to import or initialize scheduler:', error); + } + } else { + console.log(`Instrumentation hook running in environment: ${process.env.NEXT_RUNTIME}. Skipping server-side initialization.`); + } +} diff --git a/lib/backup.ts b/lib/backup.ts new file mode 100644 index 0000000..73b797c --- /dev/null +++ b/lib/backup.ts @@ -0,0 +1,143 @@ +import fs from 'fs/promises'; +import { createWriteStream } from 'fs'; // Use specific import for createWriteStream +import path from 'path'; +import archiver from 'archiver'; +import { loadSettings } from '@/app/actions/data'; // Adjust path if needed +import { DateTime } from 'luxon'; + +const BACKUP_DIR = path.join(process.cwd(), 'backups'); +const DATA_DIR = path.join(process.cwd(), 'data'); +const MAX_BACKUPS = 7; // Number of backups to keep + +async function ensureBackupDir() { + try { + await fs.access(BACKUP_DIR); + } catch { + await fs.mkdir(BACKUP_DIR, { recursive: true }); + console.log('Created backup directory:', BACKUP_DIR); + } +} + +async function rotateBackups() { + try { + await ensureBackupDir(); + const files = await fs.readdir(BACKUP_DIR); + const backupFiles = files + .filter(file => file.startsWith('backup-') && file.endsWith('.zip')) + .map(file => ({ + name: file, + path: path.join(BACKUP_DIR, file), + })); + + if (backupFiles.length <= MAX_BACKUPS) { + console.log(`Rotation check: ${backupFiles.length} backups found, less than or equal to max ${MAX_BACKUPS}. No rotation needed.`); + return; // No rotation needed + } + + console.log(`Rotation check: ${backupFiles.length} backups found, exceeding max ${MAX_BACKUPS}. Starting rotation.`); + + // Get stats to sort by creation time (mtime as proxy) + const fileStats = await Promise.all( + backupFiles.map(async (file) => ({ + ...file, + stat: await fs.stat(file.path), + })) + ); + + // Sort oldest first + fileStats.sort((a, b) => a.stat.mtime.getTime() - b.stat.mtime.getTime()); + + const filesToDelete = fileStats.slice(0, fileStats.length - MAX_BACKUPS); + console.log(`Identified ${filesToDelete.length} backups to delete.`); + + for (const file of filesToDelete) { + try { + await fs.unlink(file.path); + console.log(`Rotated (deleted) old backup: ${file.name}`); + } catch (err) { + console.error(`Error deleting old backup ${file.name}:`, err); + } + } + } catch (error) { + console.error('Error during backup rotation:', error); + } +} + +export async function runBackup() { + try { + const settings = await loadSettings(); + if (!settings.system.autoBackupEnabled) { + console.log('Auto backup is disabled in settings. Skipping backup.'); + return; + } + + console.log('Starting daily backup...'); + await ensureBackupDir(); + + const timestamp = DateTime.now().toFormat('yyyy-MM-dd_HH-mm-ss'); + const backupFileName = `backup-${timestamp}.zip`; + const backupFilePath = path.join(BACKUP_DIR, backupFileName); + + // Use createWriteStream from fs directly + const output = createWriteStream(backupFilePath); + const archive = archiver('zip', { + zlib: { level: 9 } // Sets the compression level. + }); + + return new Promise((resolve, reject) => { + output.on('close', async () => { + console.log(`Backup created successfully: ${backupFileName} (${archive.pointer()} total bytes)`); + try { + await rotateBackups(); // Rotate after successful backup + resolve(); + } catch (rotationError) { + console.error("Error during post-backup rotation:", rotationError); + // Decide if backup failure should depend on rotation failure + // For now, resolve even if rotation fails, as backup itself succeeded. + resolve(); + } + }); + + // Handle stream finish event for better completion tracking + output.on('finish', () => { + console.log('Backup file stream finished writing.'); + }); + + archive.on('warning', (err) => { + if (err.code === 'ENOENT') { + // Log specific warnings but don't necessarily reject + console.warn('Archiver warning (ENOENT):', err); + } else { + // Treat other warnings as potential issues, but maybe not fatal + console.warn('Archiver warning:', err); + } + }); + + archive.on('error', (err) => { + console.error('Archiver error:', err); + reject(err); // Reject the promise on critical archiver errors + }); + + // Pipe archive data to the file + archive.pipe(output); + + // Append the entire data directory to the archive + // The second argument specifies the path prefix inside the zip file (false means root) + console.log(`Archiving directory: ${DATA_DIR}`); + archive.directory(DATA_DIR, false); + + // Finalize the archive (writes the central directory) + console.log('Finalizing archive...'); + archive.finalize().catch(err => { + // Catch potential errors during finalization + console.error('Error during archive finalization:', err); + reject(err); + }); + }); + + } catch (error) { + console.error('Failed to run backup:', error); + // Rethrow or handle as appropriate for the scheduler + throw error; + } +} diff --git a/lib/scheduler.ts b/lib/scheduler.ts new file mode 100644 index 0000000..048f0bd --- /dev/null +++ b/lib/scheduler.ts @@ -0,0 +1,54 @@ +import cron from 'node-cron'; +import { runBackup } from './backup'; + +let isSchedulerInitialized = false; + +export function initializeScheduler() { + if (isSchedulerInitialized) { + console.log('Scheduler already initialized.'); + return; + } + + console.log('Initializing scheduler...'); + + // Schedule backup to run daily at 2:00 AM server time + // Format: second minute hour day-of-month month day-of-week + // '0 2 * * *' means at minute 0 of hour 2 (2:00 AM) every day + const backupJob = cron.schedule('0 2 * * *', async () => { + console.log(`[${new Date().toISOString()}] Running scheduled daily backup task...`); + try { + await runBackup(); + console.log(`[${new Date().toISOString()}] Scheduled backup task completed successfully.`); + } catch (err) { + console.error(`[${new Date().toISOString()}] Scheduled backup task failed:`, err); + } + }, { + scheduled: true, + // Consider adding timezone support later if needed, based on user settings + // timezone: "Your/Timezone" + }); + + console.log('Scheduler initialized. Daily backup scheduled for 2:00 AM server time.'); + isSchedulerInitialized = true; + + // Graceful shutdown handling (optional but recommended) + process.on('SIGTERM', () => { + console.log('SIGTERM signal received. Stopping scheduler...'); + backupJob.stop(); + // Add cleanup for other jobs if needed + process.exit(0); + }); + + process.on('SIGINT', () => { + console.log('SIGINT signal received. Stopping scheduler...'); + backupJob.stop(); + // Add cleanup for other jobs if needed + process.exit(0); + }); + + // --- Add other scheduled tasks here in the future --- + // Example: + // cron.schedule('* * * * *', () => { + // console.log('Running every minute'); + // }); +} diff --git a/lib/types.ts b/lib/types.ts index 0f92656..a0c264c 100644 --- a/lib/types.ts +++ b/lib/types.ts @@ -130,7 +130,8 @@ export const getDefaultSettings = (): Settings => ({ }, system: { timezone: Intl.DateTimeFormat().resolvedOptions().timeZone, - weekStartDay: 1 // Monday + weekStartDay: 1, // Monday + autoBackupEnabled: true, // Add this line (default to true) }, profile: {} }); @@ -161,6 +162,7 @@ export type WeekDay = 0 | 1 | 2 | 3 | 4 | 5 | 6; // 0 = Sunday, 6 = Saturday export interface SystemSettings { timezone: string; weekStartDay: WeekDay; + autoBackupEnabled: boolean; // Add this line } export interface ProfileSettings { diff --git a/package-lock.json b/package-lock.json index 870e06a..b5ff0d6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "habittrove", - "version": "0.2.7", + "version": "0.2.8", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "habittrove", - "version": "0.2.7", + "version": "0.2.8", "dependencies": { "@emoji-mart/data": "^1.2.1", "@emoji-mart/react": "^1.1.1", @@ -27,6 +27,7 @@ "@radix-ui/react-tooltip": "^1.1.6", "@types/canvas-confetti": "^1.9.0", "@uiw/react-heat-map": "^2.3.2", + "archiver": "^7.0.1", "canvas-confetti": "^1.9.3", "chrono-node": "^2.7.7", "class-variance-authority": "^0.7.1", @@ -41,6 +42,7 @@ "next": "15.2.3", "next-auth": "^5.0.0-beta.25", "next-themes": "^0.4.4", + "node-cron": "^3.0.3", "react": "^19.0.0", "react-confetti": "^6.2.2", "react-day-picker": "^8.10.1", @@ -57,10 +59,12 @@ "devDependencies": { "@eslint/eslintrc": "^3", "@tailwindcss/typography": "^0.5.15", + "@types/archiver": "^6.0.3", "@types/bun": "^1.1.14", "@types/lodash": "^4.17.15", "@types/luxon": "^3.4.2", "@types/node": "^20.17.10", + "@types/node-cron": "^3.0.11", "@types/react": "^19", "@types/react-dom": "^19", "@types/web-push": "^3.6.4", @@ -2134,6 +2138,16 @@ "node": ">=4" } }, + "node_modules/@types/archiver": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/@types/archiver/-/archiver-6.0.3.tgz", + "integrity": "sha512-a6wUll6k3zX6qs5KlxIggs1P1JcYJaTCx2gnlr+f0S1yd2DoaEwoIK10HmBaLnZwWneBz+JBm0dwcZu0zECBcQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/readdir-glob": "*" + } + }, "node_modules/@types/bun": { "version": "1.1.14", "resolved": "https://registry.npmjs.org/@types/bun/-/bun-1.1.14.tgz", @@ -2306,6 +2320,13 @@ "undici-types": "~6.19.2" } }, + "node_modules/@types/node-cron": { + "version": "3.0.11", + "resolved": "https://registry.npmjs.org/@types/node-cron/-/node-cron-3.0.11.tgz", + "integrity": "sha512-0ikrnug3/IyneSHqCBeslAhlK2aBfYek1fGo4bP4QnZPmiqSGRK+Oy7ZMisLWkesffJvQ1cqAcBnJC+8+nxIAg==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/react": { "version": "19.0.2", "resolved": "https://registry.npmjs.org/@types/react/-/react-19.0.2.tgz", @@ -2323,6 +2344,16 @@ "@types/react": "^19.0.0" } }, + "node_modules/@types/readdir-glob": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@types/readdir-glob/-/readdir-glob-1.1.5.tgz", + "integrity": "sha512-raiuEPUYqXu+nvtY2Pe8s8FEmZ3x5yAH4VkLdihcPdalvsHltomrRC9BzuStrJ9yk06470hS0Crw0f1pXqD+Hg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/unist": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/@types/unist/-/unist-3.0.3.tgz", @@ -2763,6 +2794,18 @@ "dev": true, "peer": true }, + "node_modules/abort-controller": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/abort-controller/-/abort-controller-3.0.0.tgz", + "integrity": "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==", + "license": "MIT", + "dependencies": { + "event-target-shim": "^5.0.0" + }, + "engines": { + "node": ">=6.5" + } + }, "node_modules/acorn": { "version": "8.14.0", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.14.0.tgz", @@ -2901,6 +2944,42 @@ "node": ">= 8" } }, + "node_modules/archiver": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/archiver/-/archiver-7.0.1.tgz", + "integrity": "sha512-ZcbTaIqJOfCc03QwD468Unz/5Ir8ATtvAHsK+FdXbDIbGfihqh9mrvdcYunQzqn4HrvWWaFyaxJhGZagaJJpPQ==", + "license": "MIT", + "dependencies": { + "archiver-utils": "^5.0.2", + "async": "^3.2.4", + "buffer-crc32": "^1.0.0", + "readable-stream": "^4.0.0", + "readdir-glob": "^1.1.2", + "tar-stream": "^3.0.0", + "zip-stream": "^6.0.1" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/archiver-utils": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/archiver-utils/-/archiver-utils-5.0.2.tgz", + "integrity": "sha512-wuLJMmIBQYCsGZgYLTy5FIB2pF6Lfb6cXMSF8Qywwk3t20zWnAi7zLcQFdKQmIB8wyZpY5ER38x08GbwtR2cLA==", + "license": "MIT", + "dependencies": { + "glob": "^10.0.0", + "graceful-fs": "^4.2.0", + "is-stream": "^2.0.1", + "lazystream": "^1.0.0", + "lodash": "^4.17.15", + "normalize-path": "^3.0.0", + "readable-stream": "^4.0.0" + }, + "engines": { + "node": ">= 14" + } + }, "node_modules/arg": { "version": "5.0.2", "resolved": "https://registry.npmjs.org/arg/-/arg-5.0.2.tgz", @@ -3098,6 +3177,12 @@ "integrity": "sha512-OH/2E5Fg20h2aPrbe+QL8JZQFko0YZaF+j4mnQ7BGhfavO7OpSLa8a0y9sBwomHdSbkhTS8TQNayBfnW5DwbvQ==", "dev": true }, + "node_modules/async": { + "version": "3.2.6", + "resolved": "https://registry.npmjs.org/async/-/async-3.2.6.tgz", + "integrity": "sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==", + "license": "MIT" + }, "node_modules/available-typed-arrays": { "version": "1.0.7", "resolved": "https://registry.npmjs.org/available-typed-arrays/-/available-typed-arrays-1.0.7.tgz", @@ -3131,6 +3216,12 @@ "node": ">= 0.4" } }, + "node_modules/b4a": { + "version": "1.6.7", + "resolved": "https://registry.npmjs.org/b4a/-/b4a-1.6.7.tgz", + "integrity": "sha512-OnAYlL5b7LEkALw87fUVafQw5rVR9RjwGd4KUwNQ6DrrNmaVaUCgLipfVlzrPQ4tWOR9P0IXGNOx50jYCCdSJg==", + "license": "Apache-2.0" + }, "node_modules/bail": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/bail/-/bail-2.0.2.tgz", @@ -3145,6 +3236,33 @@ "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==" }, + "node_modules/bare-events": { + "version": "2.5.4", + "resolved": "https://registry.npmjs.org/bare-events/-/bare-events-2.5.4.tgz", + "integrity": "sha512-+gFfDkR8pj4/TrWCGUGWmJIkBwuxPS5F+a5yWjOHQt2hHvNZd5YLzadjmDUtFmMM4y429bnKLa8bYBMHcYdnQA==", + "license": "Apache-2.0", + "optional": true + }, + "node_modules/base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, "node_modules/big.js": { "version": "5.2.2", "resolved": "https://registry.npmjs.org/big.js/-/big.js-5.2.2.tgz", @@ -3224,6 +3342,39 @@ "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" } }, + "node_modules/buffer": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz", + "integrity": "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.2.1" + } + }, + "node_modules/buffer-crc32": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/buffer-crc32/-/buffer-crc32-1.0.0.tgz", + "integrity": "sha512-Db1SbgBS/fg/392AblrMJk97KggmvYhr4pB5ZIMTWtaivCPMWLkmb7m21cJvpvgK+J3nsU2CmmixNBZx4vFj/w==", + "license": "MIT", + "engines": { + "node": ">=8.0.0" + } + }, "node_modules/buffer-equal-constant-time": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz", @@ -3560,6 +3711,22 @@ "node": ">= 6" } }, + "node_modules/compress-commons": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/compress-commons/-/compress-commons-6.0.2.tgz", + "integrity": "sha512-6FqVXeETqWPoGcfzrXb37E50NP0LXT8kAMu5ooZayhWWdgEY4lBEEcbQNXtkuKQsGduxiIcI4gOTsxTmuq/bSg==", + "license": "MIT", + "dependencies": { + "crc-32": "^1.2.0", + "crc32-stream": "^6.0.0", + "is-stream": "^2.0.1", + "normalize-path": "^3.0.0", + "readable-stream": "^4.0.0" + }, + "engines": { + "node": ">= 14" + } + }, "node_modules/concat-map": { "version": "0.0.1", "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", @@ -3575,6 +3742,37 @@ "node": ">= 0.6" } }, + "node_modules/core-util-is": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz", + "integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==", + "license": "MIT" + }, + "node_modules/crc-32": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/crc-32/-/crc-32-1.2.2.tgz", + "integrity": "sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ==", + "license": "Apache-2.0", + "bin": { + "crc32": "bin/crc32.njs" + }, + "engines": { + "node": ">=0.8" + } + }, + "node_modules/crc32-stream": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/crc32-stream/-/crc32-stream-6.0.0.tgz", + "integrity": "sha512-piICUB6ei4IlTv1+653yq5+KoqfBYmj9bw6LqXoOneTMDXk5nM1qt12mFW1caG3LlJXEKW1Bp0WggEmIfQB34g==", + "license": "MIT", + "dependencies": { + "crc-32": "^1.2.0", + "readable-stream": "^4.0.0" + }, + "engines": { + "node": ">= 14" + } + }, "node_modules/cross-spawn": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", @@ -4633,6 +4831,15 @@ "node": ">=0.10.0" } }, + "node_modules/event-target-shim": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/event-target-shim/-/event-target-shim-5.0.1.tgz", + "integrity": "sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/eventemitter3": { "version": "4.0.7", "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-4.0.7.tgz", @@ -4642,8 +4849,6 @@ "version": "3.3.0", "resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz", "integrity": "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==", - "dev": true, - "peer": true, "engines": { "node": ">=0.8.x" } @@ -4667,6 +4872,12 @@ "node": ">=6.0.0" } }, + "node_modules/fast-fifo": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/fast-fifo/-/fast-fifo-1.3.2.tgz", + "integrity": "sha512-/d9sfos4yxzpwkDkuN7k2SqFKtYNmCTzgfEpz82x34IM9/zc8KGxQoXg1liNC/izpRM/MBdt44Nmx41ZWqk+FQ==", + "license": "MIT" + }, "node_modules/fast-glob": { "version": "3.3.1", "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.1.tgz", @@ -5017,8 +5228,7 @@ "node_modules/graceful-fs": { "version": "4.2.11", "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", - "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", - "dev": true + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==" }, "node_modules/graphemer": { "version": "1.4.0", @@ -5194,6 +5404,26 @@ "url": "https://github.com/sponsors/typicode" } }, + "node_modules/ieee754": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", + "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "BSD-3-Clause" + }, "node_modules/ignore": { "version": "5.3.2", "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", @@ -5597,6 +5827,18 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/is-stream": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz", + "integrity": "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==", + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/is-string": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/is-string/-/is-string-1.1.1.tgz", @@ -5918,6 +6160,54 @@ "node": ">=0.10" } }, + "node_modules/lazystream": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/lazystream/-/lazystream-1.0.1.tgz", + "integrity": "sha512-b94GiNHQNy6JNTrt5w6zNyffMrNkXZb3KTkCZJb2V1xaEGCk093vkZ2jk3tpaeP33/OiXC+WvK9AxUebnf5nbw==", + "license": "MIT", + "dependencies": { + "readable-stream": "^2.0.5" + }, + "engines": { + "node": ">= 0.6.3" + } + }, + "node_modules/lazystream/node_modules/isarray": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", + "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==", + "license": "MIT" + }, + "node_modules/lazystream/node_modules/readable-stream": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", + "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", + "license": "MIT", + "dependencies": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" + } + }, + "node_modules/lazystream/node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "license": "MIT" + }, + "node_modules/lazystream/node_modules/string_decoder": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", + "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.1.0" + } + }, "node_modules/levn": { "version": "0.4.1", "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", @@ -6903,6 +7193,27 @@ "node": "^10 || ^12 || >=14" } }, + "node_modules/node-cron": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/node-cron/-/node-cron-3.0.3.tgz", + "integrity": "sha512-dOal67//nohNgYWb+nWmg5dkFdIwDm8EpeGYMekPMrngV3637lqnX0lbUcCtgibHTz6SEz7DAIjKvKDFYCnO1A==", + "license": "ISC", + "dependencies": { + "uuid": "8.3.2" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/node-cron/node_modules/uuid": { + "version": "8.3.2", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz", + "integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==", + "license": "MIT", + "bin": { + "uuid": "dist/bin/uuid" + } + }, "node_modules/node-releases": { "version": "2.0.19", "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.19.tgz", @@ -7386,6 +7697,21 @@ "integrity": "sha512-WuxUnVtlWL1OfZFQFuqvnvs6MiAGk9UNsBostyBOB0Is9wb5uRESevA6rnl/rkksXaGX3GzZhPup5d6Vp1nFew==", "license": "MIT" }, + "node_modules/process": { + "version": "0.11.10", + "resolved": "https://registry.npmjs.org/process/-/process-0.11.10.tgz", + "integrity": "sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A==", + "license": "MIT", + "engines": { + "node": ">= 0.6.0" + } + }, + "node_modules/process-nextick-args": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz", + "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==", + "license": "MIT" + }, "node_modules/prop-types": { "version": "15.8.1", "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", @@ -7642,6 +7968,52 @@ "pify": "^2.3.0" } }, + "node_modules/readable-stream": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-4.7.0.tgz", + "integrity": "sha512-oIGGmcpTLwPga8Bn6/Z75SVaH1z5dUut2ibSyAMVhmUggWpmDn2dapB0n7f8nwaSiRtepAsfJyfXIO5DCVAODg==", + "license": "MIT", + "dependencies": { + "abort-controller": "^3.0.0", + "buffer": "^6.0.3", + "events": "^3.3.0", + "process": "^0.11.10", + "string_decoder": "^1.3.0" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + } + }, + "node_modules/readdir-glob": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/readdir-glob/-/readdir-glob-1.1.3.tgz", + "integrity": "sha512-v05I2k7xN8zXvPD9N+z/uhXPaj0sUFCe2rcWZIpBsqxfP7xXFQ0tipAd/wjj1YxWyWtUS5IDJpOG82JKt2EAVA==", + "license": "Apache-2.0", + "dependencies": { + "minimatch": "^5.1.0" + } + }, + "node_modules/readdir-glob/node_modules/brace-expansion": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz", + "integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==", + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/readdir-glob/node_modules/minimatch": { + "version": "5.1.6", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.6.tgz", + "integrity": "sha512-lKwV/1brpG6mBUFHtb7NUmtABCb2WZZmm2wNiOA5hAb8VdCS4B3dtMWyvcoViccwAW/COERjXLt0zP1zXUN26g==", + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/readdirp": { "version": "3.6.0", "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", @@ -8189,6 +8561,28 @@ "node": ">=10.0.0" } }, + "node_modules/streamx": { + "version": "2.22.0", + "resolved": "https://registry.npmjs.org/streamx/-/streamx-2.22.0.tgz", + "integrity": "sha512-sLh1evHOzBy/iWRiR6d1zRcLao4gGZr3C1kzNz4fopCOKJb6xD9ub8Mpi9Mr1R6id5o43S+d93fI48UC5uM9aw==", + "license": "MIT", + "dependencies": { + "fast-fifo": "^1.3.2", + "text-decoder": "^1.1.0" + }, + "optionalDependencies": { + "bare-events": "^2.2.0" + } + }, + "node_modules/string_decoder": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", + "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.2.0" + } + }, "node_modules/string-width": { "version": "5.1.2", "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz", @@ -8580,6 +8974,17 @@ "node": ">=6" } }, + "node_modules/tar-stream": { + "version": "3.1.7", + "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-3.1.7.tgz", + "integrity": "sha512-qJj60CXt7IU1Ffyc3NJMjh6EkuCFej46zUqJ4J7pqYlThyd9bO0XBTmcOIhSzZJVWfsLks0+nle/j538YAW9RQ==", + "license": "MIT", + "dependencies": { + "b4a": "^1.6.4", + "fast-fifo": "^1.2.0", + "streamx": "^2.15.0" + } + }, "node_modules/terser": { "version": "5.37.0", "resolved": "https://registry.npmjs.org/terser/-/terser-5.37.0.tgz", @@ -8698,6 +9103,15 @@ "dev": true, "peer": true }, + "node_modules/text-decoder": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/text-decoder/-/text-decoder-1.2.3.tgz", + "integrity": "sha512-3/o9z3X0X0fTupwsYvR03pJ/DjWuqqrfwBgTQzdWDiQSm9KitAyz/9WqsT2JQW7KV2m+bC2ol/zqpW37NHxLaA==", + "license": "Apache-2.0", + "dependencies": { + "b4a": "^1.6.4" + } + }, "node_modules/thenify": { "version": "3.3.1", "resolved": "https://registry.npmjs.org/thenify/-/thenify-3.3.1.tgz", @@ -9464,6 +9878,20 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/zip-stream": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/zip-stream/-/zip-stream-6.0.1.tgz", + "integrity": "sha512-zK7YHHz4ZXpW89AHXUPbQVGKI7uvkd3hzusTdotCg1UxyaVtg0zFJSTfW/Dq5f7OBBVnq6cZIaC8Ti4hb6dtCA==", + "license": "MIT", + "dependencies": { + "archiver-utils": "^5.0.0", + "compress-commons": "^6.0.2", + "readable-stream": "^4.0.0" + }, + "engines": { + "node": ">= 14" + } + }, "node_modules/zod": { "version": "3.24.1", "resolved": "https://registry.npmjs.org/zod/-/zod-3.24.1.tgz", diff --git a/package.json b/package.json index 5398b05..1e1a3b4 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "habittrove", - "version": "0.2.8", + "version": "0.2.9", "private": true, "scripts": { "dev": "next dev --turbopack", @@ -34,6 +34,7 @@ "@radix-ui/react-tooltip": "^1.1.6", "@types/canvas-confetti": "^1.9.0", "@uiw/react-heat-map": "^2.3.2", + "archiver": "^7.0.1", "canvas-confetti": "^1.9.3", "chrono-node": "^2.7.7", "class-variance-authority": "^0.7.1", @@ -48,6 +49,7 @@ "next": "15.2.3", "next-auth": "^5.0.0-beta.25", "next-themes": "^0.4.4", + "node-cron": "^3.0.3", "react": "^19.0.0", "react-confetti": "^6.2.2", "react-day-picker": "^8.10.1", @@ -64,10 +66,12 @@ "devDependencies": { "@eslint/eslintrc": "^3", "@tailwindcss/typography": "^0.5.15", + "@types/archiver": "^6.0.3", "@types/bun": "^1.1.14", "@types/lodash": "^4.17.15", "@types/luxon": "^3.4.2", "@types/node": "^20.17.10", + "@types/node-cron": "^3.0.11", "@types/react": "^19", "@types/react-dom": "^19", "@types/web-push": "^3.6.4",